From 19c710fe9369027ba22c10f27673c8a30bae308b Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 4 Nov 2026 21:58:51 -0600 Subject: [PATCH] fix(issues): record an interrupt comment as the user's Stop (#19149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - A board user can interrupt a running agent with a comment (`interrupt: true`) to redirect the work > - An interrupt is a Stop plus a message, but it did not record the user's stop identity the way the Stop control does > - The release path therefore treated an interrupted native run as a failed run: it blocked the task and took the finalization lease from the runner that was still settling > - Workspace preservation then stopped the sandbox under that live runner, so the runner reconnected for about 6 minutes and the stop could not be verified afterward > - The redirect message stayed saved, but it waited on workspace repair that could not finish > - This pull request records the board user's stop identity on interrupts, the same as the Stop control and terminalization stops > - The benefit is that an interrupt now settles like a Stop and the redirect can continue ## Linked Issues or Issue Description Refs #19122 (the Stop-then-redirect fix for the dispatch gate; this PR covers the interrupt-comment path). **What happened?** A board user posted a comment with `interrupt: true` on a task with a running native run (Daytona sandbox). The run was cancelled with `operator_interrupted` and the native cancel was acknowledged. Within a second, the task went to `blocked` and a `native_continuation_requires_reconciliation` hold was created. The runner transport then logged `state=reconnecting` for about 6 minutes, and the run ended with `runner_process_termination_unconfirmed: ... Daytona sandbox lease ... is no longer active`. Workspace recovery started about 10–17 minutes later and failed with `agent_stop_unconfirmed` / `provider_unavailable`. The redirect comment stayed saved as a deferred wake waiting on `workspace_repair_required`. No follow-up run started. **Expected behavior** An interrupt behaves like Stop followed by a message. The stopped runner settles normally. The task is not blocked as a failure. The saved message continues once the previous execution has stopped. **Steps to reproduce** 1. Start a native run on a remote sandbox. 2. Post a board comment with `interrupt: true`. 3. Observe the task become `blocked` with a `native-continuation:` hold, and the run's finalization row rewritten to `terminal_failure` with no lease owner while the executor is still settling. 4. Observe workspace preservation stop the sandbox, the runner transport reconnect until its grace ends, and recovery fail to confirm the stop. **Root cause** `recordNativeTerminalRecoveryIfNeeded` (`server/src/modules/wake-queue/adapters/postgres.ts`) skips intentional stops through `isAcknowledgedNativeStop`. That check requires `resultJson.cancelledByActorType === "user"` and `cancelledByUserId`. The Stop control and terminalization stops record these fields. `operatorInterruptCancelOptions` (`server/src/routes/issues.ts`) recorded only `interruptedByActorType` / `interruptedByActorId`. So an acknowledged interrupt took the failure branch. That branch blocks the task and sets `nativeRunFinalizations` to `terminal_failure` with `leaseOwner: null`. Cancelled-workspace preservation then saw a terminal row with no owner and stopped the sandbox while the runner was still settling. **Paperclip version or commit** Seen on staging at 69dea878c0b920b335594debb0286443ba9f6c35. The touched code is unchanged on current `master`. **Agent adapter(s) involved** Not adapter-specific (core bug). Seen with the Paperclip runner adapter on a Daytona sandbox. ## What Changed - `operatorInterruptCancelOptions` now records `cancelledByActorType: "user"` and `cancelledByUserId` for a board user, in addition to the existing interrupt fields. All three interrupt call sites (comment POST, issue PATCH, queued-comment interrupt) use it. - Route regression in `issue-update-comment-wakeup-routes.test.ts`: an `interrupt: true` comment on a running native run sends these fields, and the route's actual fields satisfy `isAcknowledgedNativeStop` with an acknowledged native stop receipt. - Release-path regression in `wake-queue/adapters/postgres.test.ts`: an acknowledged interrupt Stop, while the executor still owns finalization, releases the task without blocking it, creates no recovery hold, keeps the finalization lease owner, and keeps the saved redirect deferred. ## Verification - Red before the fix: - The route test failed because `cancelRun` received no `cancelledByActorType` / `cancelledByUserId`. - The release-path test failed with the old interrupt fields: the task was blocked, a hold was created, and the finalization owner was cleared. - Green after the fix: - `cd server && npx vitest run src/__tests__/issue-update-comment-wakeup-routes.test.ts src/modules/wake-queue` - Related suites: `acknowledged-native-stop`, `issue-queued-comments-routes`, `issue-comment-reopen-routes`, `issue-execution-policy-routes`, `explicit-native-continuation`, `heartbeat-process-recovery`. - The staging incident was read through the read-only run, event, log, recovery-action and wake diagnostics APIs. - Not done: a live staging reproduction at the fixed revision. Deployment is out of scope. ## Risks - An interrupt now counts as the user's Stop everywhere the Stop control does. This includes case-handoff supersession in `cancelRun`: an interrupt supersedes a pending case handoff, like Stop. - Only board users get the stop identity. Agent actors cannot use `interrupt: true` (the route returns 403). - Runs interrupted before this change keep their old fields. They are not migrated. - Not changed here, and suitable for follow-ups: - `recordNativeTerminalRecoveryIfNeeded` can still clear a live finalization `leaseOwner` for other failed native runs. - The runner PRP ingress reconnect loop (`server/src/realtime/runner-prp-outbound.ts`) does not stop early when the sandbox lease is closed. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in Claude Code with tool use (shell, file edits, subagent code tracing, browser for read-only staging diagnostics). ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Paperclip Co-authored-by: Claude Opus 5.5 --- ...issue-update-comment-wakeup-routes.test.ts | 17 ++++++++++ .../wake-queue/adapters/postgres.test.ts | 31 +++++++++++++++++++ server/src/routes/issues.ts | 4 +++ 3 files changed, 52 insertions(+) diff --git a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts index 114f05b3d6e..2477493f8ba 100644 --- a/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts +++ b/server/src/__tests__/issue-update-comment-wakeup-routes.test.ts @@ -621,6 +621,23 @@ describe("issue update comment wakeups", () => { ); }); + it("records an interrupt comment as the board user's acknowledged native Stop", async () => { + const existing = makeIssue({ assigneeAgentId: ASSIGNEE_AGENT_ID, assigneeUserId: null, executionRunId: SOURCE_RUN_ID, status: "in_progress" }); + const run = { id: SOURCE_RUN_ID, companyId: existing.companyId, agentId: ASSIGNEE_AGENT_ID, nativeIssueId: existing.id }; + mockIssueService.getById.mockResolvedValue(existing); + mockIssueService.addComment.mockResolvedValue({ id: "comment-interrupt-redirect", issueId: existing.id, companyId: existing.companyId, body: "Redirect" }); + mockHeartbeatService.getRun.mockResolvedValue({ ...run, runtimeMode: "native", status: "running", contextSnapshot: { issueId: existing.id } }); + mockHeartbeatService.cancelRun.mockResolvedValue({ ...run, status: "cancelled" }); + const res = await request(await createApp()).post(`/api/issues/${existing.id}/comments`).send({ body: "Redirect", interrupt: true }); + expect(res.status).toBe(201); + const stopMetadata = mockHeartbeatService.cancelRun.mock.calls[0]![2].resultJson; + expect(stopMetadata).toMatchObject({ operatorInterrupted: true, cancelledByActorType: "user", cancelledByUserId: "local-board" }); + expect(isAcknowledgedNativeStop({ ...run, status: "cancelled", resultJson: { ...stopMetadata, + nativeCancellation: { schema: "paperclip.native-cancellation.v1", runId: SOURCE_RUN_ID, companyId: existing.companyId, + issueId: existing.id, scope: "run", reasonCode: "cancellation_run_only", dispatchState: "acknowledged", dispatched: true, + intentAuditId: "stop-intent", acknowledgementAuditId: "stop-acknowledgement" } } })).toBe(true); + }); + it("interrupts the active run without waking an agent when the handoff assigns a user", async () => { const existing = makeIssue({ assigneeAgentId: PREVIOUS_AGENT_ID, diff --git a/server/src/modules/wake-queue/adapters/postgres.test.ts b/server/src/modules/wake-queue/adapters/postgres.test.ts index 3a8eae708c0..73fb36fc2a0 100644 --- a/server/src/modules/wake-queue/adapters/postgres.test.ts +++ b/server/src/modules/wake-queue/adapters/postgres.test.ts @@ -691,6 +691,37 @@ describeEmbeddedPostgres("wake-queue postgres adapter", () => { expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, issueId))).toHaveLength(0); }); + it("releases an acknowledged interrupt Stop without blocking or retiring the settling executor", async () => { + const companyId = await seedCompany(); + const agentId = await seedAgent({ companyId }); + const issueId = await seedIssue({ companyId, assigneeAgentId: agentId, status: "in_progress" }); + const runId = await seedRun({ companyId, agentId, status: "cancelled", contextSnapshot: { issueId } }); + await db.update(heartbeatRuns).set({ runtimeMode: "native", nativeIssueId: issueId, errorCode: "operator_interrupted", resultJson: { + operatorInterrupted: true, interruptionSource: "issue_comment_interrupt", interruptedIssueId: issueId, + interruptedByActorType: "user", interruptedByActorId: "board-user", + cancelledByActorType: "user", cancelledByUserId: "board-user", + nativeCancellation: { schema: "paperclip.native-cancellation.v1", runId, companyId, issueId, + scope: "run", reasonCode: "cancellation_run_only", dispatchState: "acknowledged", dispatched: true, + intentAuditId: randomUUID(), acknowledgementAuditId: randomUUID() }, + } }).where(eq(heartbeatRuns.id, runId)); + const leaseExpiresAt = new Date(Date.now() + 60_000); + await db.insert(nativeRunFinalizations).values({ companyId, issueId, runId, phase: "observed", + leaseOwner: "settling-executor", leaseExpiresAt }); + await db.update(issues).set({ executionRunId: runId, checkoutRunId: runId }).where(eq(issues.id, issueId)); + const before = (await db.select().from(issues).where(eq(issues.id, issueId)))[0]; + const wakeId = await seedDeferredWake({ companyId, agentId, issueId }); + const adapter = createPostgresWakeQueueAdapter(db, stubDeps); + await adapter.withIssueExecutionLock({ companyId, runId, now: new Date() }, async () => { throw new Error("must not restart the stopped run"); }); + expect((await db.select().from(issues).where(eq(issues.id, issueId)))[0]).toMatchObject({ + status: "in_progress", statusVersion: before.statusVersion, executionRunId: null, checkoutRunId: null, + }); + expect(await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.sourceIssueId, issueId))).toHaveLength(0); + expect((await db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, runId)))[0]).toMatchObject({ + phase: "observed", leaseOwner: "settling-executor", leaseExpiresAt, recoveryState: null, + }); + expect((await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, wakeId)))[0].status).toBe("deferred_issue_execution"); + }); + it.each(["in_progress", "blocked"])("preserves recovery ownership and queued messages when a native task fails from %s", async (status) => { const companyId = await seedCompany(); const agentId = await seedAgent({ companyId }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 4a967876ed8..9b9138819c2 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -7720,6 +7720,10 @@ export function issueRoutes( interruptedIssueId: input.issueId, interruptedByActorType: input.actor.actorType, interruptedByActorId: input.actor.actorId, + ...(input.actor.actorType === "user" ? { + cancelledByActorType: "user", + cancelledByUserId: input.actor.actorId, + } : {}), }, eventMessage: "run interrupted by board comment", eventPayload: {