From c1bb9385195a0cb57d18cbbde44daa31b1149688 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 11 Apr 2026 10:53:28 -0500 Subject: [PATCH 1/5] 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( From 2172476e84234e8a4772d3416b0d19b89c96d513 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 12 Apr 2026 20:34:06 -0500 Subject: [PATCH 2/5] Fix linked worktree reuse for execution workspaces --- .../src/__tests__/workspace-runtime.test.ts | 93 +++++++++ server/src/services/workspace-runtime.ts | 197 ++++++++++++------ 2 files changed, 232 insertions(+), 58 deletions(-) diff --git a/server/src/__tests__/workspace-runtime.test.ts b/server/src/__tests__/workspace-runtime.test.ts index ff492ad2..e1908c5f 100644 --- a/server/src/__tests__/workspace-runtime.test.ts +++ b/server/src/__tests__/workspace-runtime.test.ts @@ -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(); diff --git a/server/src/services/workspace-runtime.ts b/server/src/services/workspace-runtime.ts index c0447bcc..846af64f 100644 --- a/server/src/services/workspace-runtime.ts +++ b/server/src/services/workspace-runtime.ts @@ -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 = {}; + + 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 { + 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 { + 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 { + return Boolean(await runGit(["rev-parse", "--git-dir"], cwd).catch(() => null)); +} + async function detectDefaultBranch(repoRoot: string): Promise { // 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, From ab5eeca94e6ae1be98cc67b08a2784e9be0640c2 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 12 Apr 2026 20:41:31 -0500 Subject: [PATCH 3/5] Fix stale issue live-run state --- ui/src/lib/issueActiveRun.test.ts | 45 +++++++++++++++++++++++++++++++ ui/src/lib/issueActiveRun.ts | 15 +++++++++++ ui/src/pages/IssueDetail.tsx | 7 +++-- 3 files changed, 65 insertions(+), 2 deletions(-) create mode 100644 ui/src/lib/issueActiveRun.test.ts create mode 100644 ui/src/lib/issueActiveRun.ts diff --git a/ui/src/lib/issueActiveRun.test.ts b/ui/src/lib/issueActiveRun.test.ts new file mode 100644 index 00000000..78b96118 --- /dev/null +++ b/ui/src/lib/issueActiveRun.test.ts @@ -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 => ({ + 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(); + }); +}); diff --git a/ui/src/lib/issueActiveRun.ts b/ui/src/lib/issueActiveRun.ts new file mode 100644 index 00000000..48b8d5c3 --- /dev/null +++ b/ui/src/lib/issueActiveRun.ts @@ -0,0 +1,15 @@ +import type { Issue } from "@paperclipai/shared"; +import type { ActiveRunForIssue } from "../api/heartbeats"; + +export function shouldTrackIssueActiveRun( + issue: Pick | null | undefined, +): boolean { + return Boolean(issue && (issue.status === "in_progress" || issue.executionRunId)); +} + +export function resolveIssueActiveRun( + issue: Pick | null | undefined, + activeRun: ActiveRunForIssue | null | undefined, +): ActiveRunForIssue | null { + return shouldTrackIssueActiveRun(issue) ? (activeRun ?? null) : null; +} diff --git a/ui/src/pages/IssueDetail.tsx b/ui/src/pages/IssueDetail.tsx index fb315979..3fbc72f9 100644 --- a/ui/src/pages/IssueDetail.tsx +++ b/ui/src/pages/IssueDetail.tsx @@ -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( From be82a912b2f82af270c5c0a499edb1025dada953 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 12 Apr 2026 20:43:50 -0500 Subject: [PATCH 4/5] Fix signoff e2e for auto-checked out issues --- tests/e2e/signoff-policy.spec.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/e2e/signoff-policy.spec.ts b/tests/e2e/signoff-policy.spec.ts index 934eeaf9..a2cde2c3 100644 --- a/tests/e2e/signoff-policy.spec.ts +++ b/tests/e2e/signoff-policy.spec.ts @@ -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 { 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 { + 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`, { From 8e82ac7e38483e93b633458521fdf1ce425ac69a Mon Sep 17 00:00:00 2001 From: Dotta Date: Sun, 12 Apr 2026 20:57:31 -0500 Subject: [PATCH 5/5] Handle harness checkout conflicts gracefully --- .../heartbeat-comment-wake-batching.test.ts | 1 + server/src/services/heartbeat.ts | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts index 2ed58443..3626c2f5 100644 --- a/server/src/__tests__/heartbeat-comment-wake-batching.test.ts +++ b/server/src/__tests__/heartbeat-comment-wake-batching.test.ts @@ -574,4 +574,5 @@ describe("heartbeat comment wake batching", () => { await gateway.close(); } }, 20_000); + }); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 6fbcfe10..e0e0a317 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -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"; @@ -787,6 +787,10 @@ function shouldAutoCheckoutIssueForWake(input: { return true; } +function isCheckoutConflictError(error: unknown): boolean { + return error instanceof HttpError && error.status === 409 && error.message === "Issue checkout conflict"; +} + function deriveCommentId( contextSnapshot: Record | null | undefined, payload: Record | null | undefined, @@ -2704,8 +2708,13 @@ export function heartbeatService(db: Db) { agentId: agent.id, }) ) { - await issuesSvc.checkout(issueId, agent.id, ["todo", "backlog", "blocked"], run.id); - context[PAPERCLIP_HARNESS_CHECKOUT_KEY] = true; + 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 =