forked from farhoodlabs/paperclip
Merge pull request #3538 from paperclipai/PAP-1355-right-now-when-agents-boot-they-re-instructed-to-call-the-api-to-checkout-the-issue-so-that-they-have-exclusive
Improve scoped wake checkout and linked worktree reuse
This commit is contained in:
@@ -253,6 +253,7 @@ type PaperclipWakeComment = {
|
||||
type PaperclipWakePayload = {
|
||||
reason: string | null;
|
||||
issue: PaperclipWakeIssue | null;
|
||||
checkedOutByHarness: boolean;
|
||||
executionStage: PaperclipWakeExecutionStage | null;
|
||||
commentIds: string[];
|
||||
latestCommentId: string | null;
|
||||
@@ -363,6 +364,7 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl
|
||||
return {
|
||||
reason: asString(payload.reason, "").trim() || null,
|
||||
issue: normalizePaperclipWakeIssue(payload.issue),
|
||||
checkedOutByHarness: asBoolean(payload.checkedOutByHarness, false),
|
||||
executionStage,
|
||||
commentIds,
|
||||
latestCommentId: asString(payload.latestCommentId, "").trim() || null,
|
||||
@@ -432,6 +434,9 @@ export function renderPaperclipWakePrompt(
|
||||
if (normalized.issue?.priority) {
|
||||
lines.push(`- issue priority: ${normalized.issue.priority}`);
|
||||
}
|
||||
if (normalized.checkedOutByHarness) {
|
||||
lines.push("- checkout: already claimed by the harness for this run");
|
||||
}
|
||||
if (normalized.missingCount > 0) {
|
||||
lines.push(`- omitted comments: ${normalized.missingCount}`);
|
||||
}
|
||||
@@ -465,6 +470,15 @@ export function renderPaperclipWakePrompt(
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.checkedOutByHarness) {
|
||||
lines.push(
|
||||
"",
|
||||
"The harness already checked out this issue for the current run.",
|
||||
"Do not call `/api/issues/{id}/checkout` again unless you intentionally switch to a different task.",
|
||||
"",
|
||||
);
|
||||
}
|
||||
|
||||
if (normalized.comments.length > 0) {
|
||||
lines.push("New comments in order:");
|
||||
}
|
||||
|
||||
@@ -568,9 +568,10 @@ describe("codex execute", () => {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1201",
|
||||
title: "Fix gallery opening for inline images",
|
||||
status: "todo",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
},
|
||||
checkedOutByHarness: true,
|
||||
commentIds: [],
|
||||
latestCommentId: null,
|
||||
comments: [],
|
||||
@@ -598,16 +599,19 @@ describe("codex execute", () => {
|
||||
issue: {
|
||||
identifier: "PAP-1201",
|
||||
title: "Fix gallery opening for inline images",
|
||||
status: "todo",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
},
|
||||
checkedOutByHarness: true,
|
||||
commentIds: [],
|
||||
});
|
||||
expect(capture.prompt).toContain("## Paperclip Wake Payload");
|
||||
expect(capture.prompt).toContain("Do not switch to another issue until you have handled this wake.");
|
||||
expect(capture.prompt).toContain("- issue: PAP-1201 Fix gallery opening for inline images");
|
||||
expect(capture.prompt).toContain("- pending comments: 0/0");
|
||||
expect(capture.prompt).toContain("- issue status: todo");
|
||||
expect(capture.prompt).toContain("- issue status: in_progress");
|
||||
expect(capture.prompt).toContain("- checkout: already claimed by the harness for this run");
|
||||
expect(capture.prompt).toContain("The harness already checked out this issue for the current run.");
|
||||
} finally {
|
||||
if (previousHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = previousHome;
|
||||
|
||||
@@ -496,15 +496,34 @@ describe("heartbeat comment wake batching", () => {
|
||||
id: issueId,
|
||||
identifier: `${issuePrefix}-1`,
|
||||
title: "Require a comment",
|
||||
status: "todo",
|
||||
status: "in_progress",
|
||||
priority: "medium",
|
||||
},
|
||||
checkedOutByHarness: true,
|
||||
commentIds: [],
|
||||
},
|
||||
});
|
||||
expect(String(firstPayload.message ?? "")).toContain("## Paperclip Wake Payload");
|
||||
expect(String(firstPayload.message ?? "")).toContain("Do not switch to another issue until you have handled this wake.");
|
||||
expect(String(firstPayload.message ?? "")).toContain("- checkout: already claimed by the harness for this run");
|
||||
expect(String(firstPayload.message ?? "")).toContain(
|
||||
"The harness already checked out this issue for the current run.",
|
||||
);
|
||||
expect(String(firstPayload.message ?? "")).toContain(`${issuePrefix}-1 Require a comment`);
|
||||
const checkedOutIssue = await db
|
||||
.select({
|
||||
status: issues.status,
|
||||
checkoutRunId: issues.checkoutRunId,
|
||||
executionRunId: issues.executionRunId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(eq(issues.id, issueId))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(checkedOutIssue).toMatchObject({
|
||||
status: "in_progress",
|
||||
checkoutRunId: firstRun?.id,
|
||||
executionRunId: firstRun?.id,
|
||||
});
|
||||
gateway.releaseFirstWait();
|
||||
await waitFor(async () => {
|
||||
const runs = await db
|
||||
@@ -555,4 +574,5 @@ describe("heartbeat comment wake batching", () => {
|
||||
await gateway.close();
|
||||
}
|
||||
}, 20_000);
|
||||
|
||||
});
|
||||
|
||||
@@ -367,6 +367,99 @@ describe("realizeExecutionWorkspace", () => {
|
||||
expect(second.branchName).toBe(first.branchName);
|
||||
});
|
||||
|
||||
it("reuses the current linked worktree instead of nesting another worktree inside it", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
const branchName = "PAP-1355-worktree-reuse";
|
||||
const currentWorktree = path.join(repoRoot, ".paperclip", "worktrees", branchName);
|
||||
|
||||
await fs.mkdir(path.dirname(currentWorktree), { recursive: true });
|
||||
await execFileAsync("git", ["worktree", "add", "-b", branchName, currentWorktree, "HEAD"], { cwd: repoRoot });
|
||||
|
||||
const realized = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: currentWorktree,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1355",
|
||||
title: "worktree reuse",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
});
|
||||
|
||||
const expectedWorktreePath = await fs.realpath(currentWorktree);
|
||||
expect(realized.created).toBe(false);
|
||||
await expect(fs.realpath(realized.cwd)).resolves.toBe(expectedWorktreePath);
|
||||
await expect(fs.realpath(realized.worktreePath ?? "")).resolves.toBe(expectedWorktreePath);
|
||||
});
|
||||
|
||||
it("reuses an already checked out branch from git worktree metadata even when the target path differs", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
const branchName = "PAP-1355-worktree-reuse";
|
||||
const existingWorktree = path.join(repoRoot, ".paperclip", "worktrees", branchName);
|
||||
const { recorder, operations } = createWorkspaceOperationRecorderDouble();
|
||||
|
||||
await fs.mkdir(path.dirname(existingWorktree), { recursive: true });
|
||||
await execFileAsync("git", ["worktree", "add", "-b", branchName, existingWorktree, "HEAD"], { cwd: repoRoot });
|
||||
|
||||
const realized = await realizeExecutionWorkspace({
|
||||
base: {
|
||||
baseCwd: existingWorktree,
|
||||
source: "project_primary",
|
||||
projectId: "project-1",
|
||||
workspaceId: "workspace-1",
|
||||
repoUrl: null,
|
||||
repoRef: "HEAD",
|
||||
},
|
||||
config: {
|
||||
workspaceStrategy: {
|
||||
type: "git_worktree",
|
||||
branchTemplate: "{{issue.identifier}}-{{slug}}",
|
||||
worktreeParentDir: ".paperclip/other-worktrees",
|
||||
},
|
||||
},
|
||||
issue: {
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1355",
|
||||
title: "worktree reuse",
|
||||
},
|
||||
agent: {
|
||||
id: "agent-1",
|
||||
name: "Codex Coder",
|
||||
companyId: "company-1",
|
||||
},
|
||||
recorder,
|
||||
});
|
||||
|
||||
const expectedWorktreePath = await fs.realpath(existingWorktree);
|
||||
expect(realized.created).toBe(false);
|
||||
await expect(fs.realpath(realized.cwd)).resolves.toBe(expectedWorktreePath);
|
||||
expect(operations).toHaveLength(1);
|
||||
expect(operations[0]?.phase).toBe("worktree_prepare");
|
||||
expect(operations[0]?.command).toBeNull();
|
||||
expect(operations[0]?.metadata).toMatchObject({
|
||||
branchName,
|
||||
created: false,
|
||||
reused: true,
|
||||
worktreePath: expectedWorktreePath,
|
||||
});
|
||||
});
|
||||
|
||||
it("slugifies unsafe issue titles for branch names and worktree folders", async () => {
|
||||
const repoRoot = await createTempRepo();
|
||||
|
||||
|
||||
@@ -31,7 +31,8 @@ If `PAPERCLIP_APPROVAL_ID` is set:
|
||||
|
||||
## 5. Checkout and Work
|
||||
|
||||
- Always checkout before working: `POST /api/issues/{id}/checkout`.
|
||||
- For scoped issue wakes, Paperclip may already checkout the current issue in the harness before your run starts.
|
||||
- Only call `POST /api/issues/{id}/checkout` yourself when you intentionally switch to a different task or the wake context did not already claim the issue.
|
||||
- Never retry a 409 -- that task belongs to someone else.
|
||||
- Do the work. Update status and comment when done.
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
projects,
|
||||
projectWorkspaces,
|
||||
} from "@paperclipai/db";
|
||||
import { conflict, notFound } from "../errors.js";
|
||||
import { conflict, HttpError, notFound } from "../errors.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { publishLiveEvent } from "./live-events.js";
|
||||
import { getRunLogStore, type RunLogHandle } from "./run-log-store.js";
|
||||
@@ -74,6 +74,7 @@ const HEARTBEAT_MAX_CONCURRENT_RUNS_MAX = 10;
|
||||
const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
|
||||
const WAKE_COMMENT_IDS_KEY = "wakeCommentIds";
|
||||
const PAPERCLIP_WAKE_PAYLOAD_KEY = "paperclipWake";
|
||||
const PAPERCLIP_HARNESS_CHECKOUT_KEY = "paperclipHarnessCheckedOut";
|
||||
const DETACHED_PROCESS_ERROR_CODE = "process_detached";
|
||||
const startLocksByAgent = new Map<string, Promise<void>>();
|
||||
const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__";
|
||||
@@ -760,6 +761,36 @@ function describeSessionResetReason(
|
||||
return null;
|
||||
}
|
||||
|
||||
function shouldAutoCheckoutIssueForWake(input: {
|
||||
contextSnapshot: Record<string, unknown> | null | undefined;
|
||||
issueStatus: string | null;
|
||||
issueAssigneeAgentId: string | null;
|
||||
agentId: string;
|
||||
}) {
|
||||
if (input.issueAssigneeAgentId !== input.agentId) return false;
|
||||
|
||||
const issueStatus = readNonEmptyString(input.issueStatus);
|
||||
if (
|
||||
issueStatus !== "todo" &&
|
||||
issueStatus !== "backlog" &&
|
||||
issueStatus !== "blocked" &&
|
||||
issueStatus !== "in_progress"
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const wakeReason = readNonEmptyString(input.contextSnapshot?.wakeReason);
|
||||
if (!wakeReason) return false;
|
||||
if (wakeReason === "issue_comment_mentioned") return false;
|
||||
if (wakeReason.startsWith("execution_")) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function isCheckoutConflictError(error: unknown): boolean {
|
||||
return error instanceof HttpError && error.status === 409 && error.message === "Issue checkout conflict";
|
||||
}
|
||||
|
||||
function deriveCommentId(
|
||||
contextSnapshot: Record<string, unknown> | null | undefined,
|
||||
payload: Record<string, unknown> | null | undefined,
|
||||
@@ -1005,6 +1036,7 @@ async function buildPaperclipWakePayload(input: {
|
||||
priority: issueSummary.priority,
|
||||
}
|
||||
: null,
|
||||
checkedOutByHarness: input.contextSnapshot[PAPERCLIP_HARNESS_CHECKOUT_KEY] === true,
|
||||
executionStage: Object.keys(executionStage).length > 0 ? executionStage : null,
|
||||
commentIds,
|
||||
latestCommentId: commentIds[commentIds.length - 1] ?? null,
|
||||
@@ -1216,6 +1248,27 @@ export function heartbeatService(db: Db) {
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function getIssueExecutionContext(companyId: string, issueId: string) {
|
||||
return db
|
||||
.select({
|
||||
id: issues.id,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
priority: issues.priority,
|
||||
projectId: issues.projectId,
|
||||
projectWorkspaceId: issues.projectWorkspaceId,
|
||||
executionWorkspaceId: issues.executionWorkspaceId,
|
||||
executionWorkspacePreference: issues.executionWorkspacePreference,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeAdapterOverrides: issues.assigneeAdapterOverrides,
|
||||
executionWorkspaceSettings: issues.executionWorkspaceSettings,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.id, issueId), eq(issues.companyId, companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
async function getRuntimeState(agentId: string) {
|
||||
return db
|
||||
.select()
|
||||
@@ -2644,26 +2697,26 @@ export function heartbeatService(db: Db) {
|
||||
const taskKey = deriveTaskKeyWithHeartbeatFallback(context, null);
|
||||
const sessionCodec = getAdapterSessionCodec(agent.adapterType);
|
||||
const issueId = readNonEmptyString(context.issueId);
|
||||
const issueContext = issueId
|
||||
? await db
|
||||
.select({
|
||||
id: issues.id,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
priority: issues.priority,
|
||||
projectId: issues.projectId,
|
||||
projectWorkspaceId: issues.projectWorkspaceId,
|
||||
executionWorkspaceId: issues.executionWorkspaceId,
|
||||
executionWorkspacePreference: issues.executionWorkspacePreference,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeAdapterOverrides: issues.assigneeAdapterOverrides,
|
||||
executionWorkspaceSettings: issues.executionWorkspaceSettings,
|
||||
})
|
||||
.from(issues)
|
||||
.where(and(eq(issues.id, issueId), eq(issues.companyId, agent.companyId)))
|
||||
.then((rows) => rows[0] ?? null)
|
||||
: null;
|
||||
let issueContext = issueId ? await getIssueExecutionContext(agent.companyId, issueId) : null;
|
||||
if (
|
||||
issueId &&
|
||||
issueContext &&
|
||||
shouldAutoCheckoutIssueForWake({
|
||||
contextSnapshot: context,
|
||||
issueStatus: issueContext.status,
|
||||
issueAssigneeAgentId: issueContext.assigneeAgentId,
|
||||
agentId: agent.id,
|
||||
})
|
||||
) {
|
||||
try {
|
||||
await issuesSvc.checkout(issueId, agent.id, ["todo", "backlog", "blocked"], run.id);
|
||||
context[PAPERCLIP_HARNESS_CHECKOUT_KEY] = true;
|
||||
} catch (error) {
|
||||
if (!isCheckoutConflictError(error)) throw error;
|
||||
context[PAPERCLIP_HARNESS_CHECKOUT_KEY] = false;
|
||||
}
|
||||
issueContext = await getIssueExecutionContext(agent.companyId, issueId);
|
||||
}
|
||||
const issueAssigneeOverrides =
|
||||
issueContext && issueContext.assigneeAgentId === agent.id
|
||||
? parseIssueAssigneeAdapterOverrides(
|
||||
|
||||
@@ -513,6 +513,67 @@ function gitErrorIncludes(error: unknown, needle: string) {
|
||||
return message.toLowerCase().includes(needle.toLowerCase());
|
||||
}
|
||||
|
||||
type GitWorktreeListEntry = {
|
||||
worktree: string;
|
||||
branch: string | null;
|
||||
};
|
||||
|
||||
function parseGitWorktreeListPorcelain(raw: string): GitWorktreeListEntry[] {
|
||||
const entries: GitWorktreeListEntry[] = [];
|
||||
let current: Partial<GitWorktreeListEntry> = {};
|
||||
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
if (line.startsWith("worktree ")) {
|
||||
current = { worktree: line.slice("worktree ".length) };
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("branch ")) {
|
||||
current.branch = line.slice("branch ".length);
|
||||
continue;
|
||||
}
|
||||
if (line === "" && current.worktree) {
|
||||
entries.push({
|
||||
worktree: current.worktree,
|
||||
branch: current.branch ?? null,
|
||||
});
|
||||
current = {};
|
||||
}
|
||||
}
|
||||
|
||||
if (current.worktree) {
|
||||
entries.push({
|
||||
worktree: current.worktree,
|
||||
branch: current.branch ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function resolveGitOwnerRepoRoot(cwd: string): Promise<string> {
|
||||
const checkoutRoot = path.resolve(await runGit(["rev-parse", "--show-toplevel"], cwd));
|
||||
const commonDir = await runGit(["rev-parse", "--git-common-dir"], checkoutRoot).catch(() => null);
|
||||
if (!commonDir) return checkoutRoot;
|
||||
return path.dirname(path.resolve(checkoutRoot, commonDir));
|
||||
}
|
||||
|
||||
async function findRegisteredGitWorktreeByBranch(repoRoot: string, branchName: string): Promise<string | null> {
|
||||
const raw = await runGit(["worktree", "list", "--porcelain"], repoRoot).catch(() => null);
|
||||
if (!raw) return null;
|
||||
|
||||
const expectedBranchRef = `refs/heads/${branchName}`;
|
||||
for (const entry of parseGitWorktreeListPorcelain(raw)) {
|
||||
if (entry.branch !== expectedBranchRef) continue;
|
||||
return path.resolve(entry.worktree);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function isGitCheckout(cwd: string): Promise<boolean> {
|
||||
return Boolean(await runGit(["rev-parse", "--git-dir"], cwd).catch(() => null));
|
||||
}
|
||||
|
||||
async function detectDefaultBranch(repoRoot: string): Promise<string | null> {
|
||||
// Try the explicit remote HEAD first (set by git clone or git remote set-head)
|
||||
try {
|
||||
@@ -878,7 +939,7 @@ export async function realizeExecutionWorkspace(input: {
|
||||
};
|
||||
}
|
||||
|
||||
const repoRoot = await runGit(["rev-parse", "--show-toplevel"], input.base.baseCwd);
|
||||
const repoRoot = await resolveGitOwnerRepoRoot(input.base.baseCwd);
|
||||
const branchTemplate = asString(rawStrategy.branchTemplate, "{{issue.identifier}}-{{slug}}");
|
||||
const renderedBranch = renderWorkspaceTemplate(branchTemplate, {
|
||||
issue: input.issue,
|
||||
@@ -901,50 +962,59 @@ export async function realizeExecutionWorkspace(input: {
|
||||
|
||||
await fs.mkdir(worktreeParentDir, { recursive: true });
|
||||
|
||||
const existingWorktree = await directoryExists(worktreePath);
|
||||
if (existingWorktree) {
|
||||
const existingGitDir = await runGit(["rev-parse", "--git-dir"], worktreePath).catch(() => null);
|
||||
if (existingGitDir) {
|
||||
if (input.recorder) {
|
||||
await input.recorder.recordOperation({
|
||||
phase: "worktree_prepare",
|
||||
cwd: repoRoot,
|
||||
metadata: {
|
||||
repoRoot,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseRef,
|
||||
created: false,
|
||||
reused: true,
|
||||
},
|
||||
run: async () => ({
|
||||
status: "succeeded",
|
||||
exitCode: 0,
|
||||
system: `Reused existing git worktree at ${worktreePath}\n`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
await provisionExecutionWorktree({
|
||||
strategy: rawStrategy,
|
||||
base: input.base,
|
||||
repoRoot,
|
||||
worktreePath,
|
||||
branchName,
|
||||
issue: input.issue,
|
||||
agent: input.agent,
|
||||
created: false,
|
||||
recorder: input.recorder ?? null,
|
||||
async function reuseExistingWorktree(reusablePath: string) {
|
||||
if (input.recorder) {
|
||||
await input.recorder.recordOperation({
|
||||
phase: "worktree_prepare",
|
||||
cwd: repoRoot,
|
||||
metadata: {
|
||||
repoRoot,
|
||||
worktreePath: reusablePath,
|
||||
branchName,
|
||||
baseRef,
|
||||
created: false,
|
||||
reused: true,
|
||||
},
|
||||
run: async () => ({
|
||||
status: "succeeded",
|
||||
exitCode: 0,
|
||||
system: `Reused existing git worktree at ${reusablePath}\n`,
|
||||
}),
|
||||
});
|
||||
return {
|
||||
...input.base,
|
||||
strategy: "git_worktree",
|
||||
cwd: worktreePath,
|
||||
branchName,
|
||||
worktreePath,
|
||||
warnings: [],
|
||||
created: false,
|
||||
};
|
||||
}
|
||||
await provisionExecutionWorktree({
|
||||
strategy: rawStrategy,
|
||||
base: input.base,
|
||||
repoRoot,
|
||||
worktreePath: reusablePath,
|
||||
branchName,
|
||||
issue: input.issue,
|
||||
agent: input.agent,
|
||||
created: false,
|
||||
recorder: input.recorder ?? null,
|
||||
});
|
||||
return {
|
||||
...input.base,
|
||||
strategy: "git_worktree" as const,
|
||||
cwd: reusablePath,
|
||||
branchName,
|
||||
worktreePath: reusablePath,
|
||||
warnings: [],
|
||||
created: false,
|
||||
};
|
||||
}
|
||||
|
||||
const existingWorktree = await directoryExists(worktreePath);
|
||||
if (existingWorktree && await isGitCheckout(worktreePath)) {
|
||||
return await reuseExistingWorktree(worktreePath);
|
||||
}
|
||||
|
||||
const registeredBranchWorktree = await findRegisteredGitWorktreeByBranch(repoRoot, branchName);
|
||||
if (registeredBranchWorktree && await isGitCheckout(registeredBranchWorktree)) {
|
||||
return await reuseExistingWorktree(registeredBranchWorktree);
|
||||
}
|
||||
|
||||
if (existingWorktree) {
|
||||
throw new Error(`Configured worktree path "${worktreePath}" already exists and is not a git worktree.`);
|
||||
}
|
||||
|
||||
@@ -967,21 +1037,32 @@ export async function realizeExecutionWorkspace(input: {
|
||||
if (!gitErrorIncludes(error, "already exists")) {
|
||||
throw error;
|
||||
}
|
||||
await recordGitOperation(input.recorder, {
|
||||
phase: "worktree_prepare",
|
||||
args: ["worktree", "add", worktreePath, branchName],
|
||||
cwd: repoRoot,
|
||||
metadata: {
|
||||
repoRoot,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseRef,
|
||||
created: false,
|
||||
reusedExistingBranch: true,
|
||||
},
|
||||
successMessage: `Attached existing branch ${branchName} at ${worktreePath}\n`,
|
||||
failureLabel: `git worktree add ${worktreePath}`,
|
||||
});
|
||||
try {
|
||||
await recordGitOperation(input.recorder, {
|
||||
phase: "worktree_prepare",
|
||||
args: ["worktree", "add", worktreePath, branchName],
|
||||
cwd: repoRoot,
|
||||
metadata: {
|
||||
repoRoot,
|
||||
worktreePath,
|
||||
branchName,
|
||||
baseRef,
|
||||
created: false,
|
||||
reusedExistingBranch: true,
|
||||
},
|
||||
successMessage: `Attached existing branch ${branchName} at ${worktreePath}\n`,
|
||||
failureLabel: `git worktree add ${worktreePath}`,
|
||||
});
|
||||
} catch (attachError) {
|
||||
if (!gitErrorIncludes(attachError, "already checked out")) {
|
||||
throw attachError;
|
||||
}
|
||||
const reusablePath = await findRegisteredGitWorktreeByBranch(repoRoot, branchName);
|
||||
if (!reusablePath || !await isGitCheckout(reusablePath)) {
|
||||
throw attachError;
|
||||
}
|
||||
return await reuseExistingWorktree(reusablePath);
|
||||
}
|
||||
}
|
||||
await provisionExecutionWorktree({
|
||||
strategy: rawStrategy,
|
||||
|
||||
@@ -42,6 +42,12 @@ interface TestContext {
|
||||
issueIds: string[];
|
||||
}
|
||||
|
||||
interface IssueRunLockState {
|
||||
assigneeAgentId: string | null;
|
||||
checkoutRunId: string | null;
|
||||
executionRunId: string | null;
|
||||
}
|
||||
|
||||
/** Create an authenticated APIRequestContext for an agent (token set, no run ID yet). */
|
||||
async function createAgentRequest(token: string): Promise<APIRequestContext> {
|
||||
return pwRequest.newContext({
|
||||
@@ -58,6 +64,17 @@ async function invokeHeartbeat(board: APIRequestContext, agentId: string): Promi
|
||||
return run.id;
|
||||
}
|
||||
|
||||
async function getIssueRunLockState(board: APIRequestContext, issueId: string): Promise<IssueRunLockState> {
|
||||
const res = await board.get(`${BASE_URL}/api/issues/${issueId}`);
|
||||
expect(res.ok()).toBe(true);
|
||||
const issue = await res.json();
|
||||
return {
|
||||
assigneeAgentId: issue.assigneeAgentId ?? null,
|
||||
checkoutRunId: issue.checkoutRunId ?? null,
|
||||
executionRunId: issue.executionRunId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** PATCH an issue as an agent with a fresh heartbeat run ID. */
|
||||
async function agentPatch(
|
||||
board: APIRequestContext,
|
||||
@@ -88,6 +105,17 @@ async function agentCheckoutAndPatch(
|
||||
data: { agentId: agent.agentId, expectedStatuses },
|
||||
});
|
||||
if (!checkoutRes.ok()) {
|
||||
if (checkoutRes.status() === 409) {
|
||||
const issueRunLock = await getIssueRunLockState(board, issueId);
|
||||
const lockedRunId = issueRunLock.checkoutRunId ?? issueRunLock.executionRunId;
|
||||
const res = await agent.request.patch(`${BASE_URL}/api/issues/${issueId}`, {
|
||||
headers: { "X-Paperclip-Run-Id": lockedRunId ?? runId },
|
||||
data: patchData,
|
||||
});
|
||||
if (res.ok() && issueRunLock.assigneeAgentId === agent.agentId) {
|
||||
return res;
|
||||
}
|
||||
}
|
||||
// If agent checkout fails (e.g. run expired), fall back to board checkout
|
||||
// then PATCH with the agent's identity
|
||||
const boardCheckout = await board.post(`${BASE_URL}/api/issues/${issueId}/checkout`, {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import type { ActiveRunForIssue } from "../api/heartbeats";
|
||||
import { resolveIssueActiveRun, shouldTrackIssueActiveRun } from "./issueActiveRun";
|
||||
|
||||
describe("issueActiveRun", () => {
|
||||
const makeIssue = (
|
||||
overrides: Partial<Pick<Issue, "status" | "executionRunId">>,
|
||||
): Pick<Issue, "status" | "executionRunId"> => ({
|
||||
status: "todo",
|
||||
executionRunId: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it("tracks active runs while an issue is still in progress", () => {
|
||||
expect(shouldTrackIssueActiveRun(makeIssue({ status: "in_progress" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("tracks active runs while an execution run id is still attached", () => {
|
||||
expect(shouldTrackIssueActiveRun(makeIssue({ status: "done", executionRunId: "run-123" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("drops stale cached active runs once the issue is closed and unlocked", () => {
|
||||
const staleActiveRun: ActiveRunForIssue = {
|
||||
id: "run-123",
|
||||
status: "running",
|
||||
invocationSource: "assignment",
|
||||
triggerDetail: "system",
|
||||
startedAt: "2026-04-13T01:29:00.000Z",
|
||||
finishedAt: null,
|
||||
createdAt: "2026-04-13T01:29:00.000Z",
|
||||
agentId: "agent-1",
|
||||
agentName: "Builder",
|
||||
adapterType: "codex_local",
|
||||
issueId: "issue-1",
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveIssueActiveRun(
|
||||
makeIssue({ status: "done" }),
|
||||
staleActiveRun,
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Issue } from "@paperclipai/shared";
|
||||
import type { ActiveRunForIssue } from "../api/heartbeats";
|
||||
|
||||
export function shouldTrackIssueActiveRun(
|
||||
issue: Pick<Issue, "status" | "executionRunId"> | null | undefined,
|
||||
): boolean {
|
||||
return Boolean(issue && (issue.status === "in_progress" || issue.executionRunId));
|
||||
}
|
||||
|
||||
export function resolveIssueActiveRun(
|
||||
issue: Pick<Issue, "status" | "executionRunId"> | null | undefined,
|
||||
activeRun: ActiveRunForIssue | null | undefined,
|
||||
): ActiveRunForIssue | null {
|
||||
return shouldTrackIssueActiveRun(issue) ? (activeRun ?? null) : null;
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
readIssueDetailHeaderSeed,
|
||||
rememberIssueDetailLocationState,
|
||||
} from "../lib/issueDetailBreadcrumb";
|
||||
import { resolveIssueActiveRun, shouldTrackIssueActiveRun } from "../lib/issueActiveRun";
|
||||
import {
|
||||
hasBlockingShortcutDialog,
|
||||
resolveIssueDetailGoKeyAction,
|
||||
@@ -471,13 +472,15 @@ export function IssueDetail() {
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
|
||||
const { data: activeRun, isLoading: activeRunLoading } = useQuery({
|
||||
const shouldPollActiveRun = shouldTrackIssueActiveRun(issue);
|
||||
const { data: rawActiveRun, isLoading: activeRunLoading } = useQuery({
|
||||
queryKey: queryKeys.issues.activeRun(issueId!),
|
||||
queryFn: () => heartbeatsApi.activeRunForIssue(issueId!),
|
||||
enabled: !!issueId && (!!issue?.executionRunId || issue?.status === "in_progress"),
|
||||
enabled: !!issueId && shouldPollActiveRun,
|
||||
refetchInterval: (liveRuns?.length ?? 0) > 0 ? false : 3000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const activeRun = resolveIssueActiveRun(issue, rawActiveRun);
|
||||
|
||||
const hasLiveRuns = (liveRuns ?? []).length > 0 || !!activeRun;
|
||||
const runningIssueRun = useMemo(
|
||||
|
||||
Reference in New Issue
Block a user