From c1bb9385195a0cb57d18cbbde44daa31b1149688 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 11 Apr 2026 10:53:28 -0500 Subject: [PATCH] Auto-checkout scoped issue wakes in the harness --- packages/adapter-utils/src/server-utils.ts | 14 ++++ .../src/__tests__/codex-local-execute.test.ts | 10 ++- .../heartbeat-comment-wake-batching.test.ts | 21 ++++- server/src/onboarding-assets/ceo/HEARTBEAT.md | 3 +- server/src/services/heartbeat.ts | 84 ++++++++++++++----- 5 files changed, 107 insertions(+), 25 deletions(-) diff --git a/packages/adapter-utils/src/server-utils.ts b/packages/adapter-utils/src/server-utils.ts index 3c0e0db7..ba44249f 100644 --- a/packages/adapter-utils/src/server-utils.ts +++ b/packages/adapter-utils/src/server-utils.ts @@ -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:"); } diff --git a/server/src/__tests__/codex-local-execute.test.ts b/server/src/__tests__/codex-local-execute.test.ts index 9514a977..a4e77f9b 100644 --- a/server/src/__tests__/codex-local-execute.test.ts +++ b/server/src/__tests__/codex-local-execute.test.ts @@ -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; diff --git a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts index 1a4d2f96..2ed58443 100644 --- a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts +++ b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts @@ -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 diff --git a/server/src/onboarding-assets/ceo/HEARTBEAT.md b/server/src/onboarding-assets/ceo/HEARTBEAT.md index 8773823d..7d297594 100644 --- a/server/src/onboarding-assets/ceo/HEARTBEAT.md +++ b/server/src/onboarding-assets/ceo/HEARTBEAT.md @@ -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. diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 20cca465..6fbcfe10 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -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>(); const REPO_ONLY_CWD_SENTINEL = "/__paperclip_repo_only__"; @@ -760,6 +761,32 @@ function describeSessionResetReason( return null; } +function shouldAutoCheckoutIssueForWake(input: { + contextSnapshot: Record | 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 deriveCommentId( contextSnapshot: Record | null | undefined, payload: Record | null | undefined, @@ -1005,6 +1032,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 +1244,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 +2693,21 @@ 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, + }) + ) { + await issuesSvc.checkout(issueId, agent.id, ["todo", "backlog", "blocked"], run.id); + context[PAPERCLIP_HARNESS_CHECKOUT_KEY] = true; + issueContext = await getIssueExecutionContext(agent.companyId, issueId); + } const issueAssigneeOverrides = issueContext && issueContext.assigneeAgentId === agent.id ? parseIssueAssigneeAdapterOverrides(