From 0c904e2fd575e66b1d442a5c262b2382d47fe546 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Fri, 6 Nov 2026 17:18:04 -0600 Subject: [PATCH] fix(tasks): treat a queued-message Interrupt as a hand-off, not a failed attempt (#19633) 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 user can send a message while an agent works. The message waits in a queue strip above the composer. The strip has an **Interrupt** button that stops the current turn so the message starts now. > - In a browser walk of onboarding, Interrupt showed a red "Couldn't finish this attempt. Your message is saved." banner with Retry. The thread also showed two rows for the same stopped run ("Stopped for 22s" and "Stopped after 21s"). After about 20 s it recovered by itself. > - A stop that the user asked for is not a failure. The red banner and Retry make a normal action look broken. > - #19369 stopped resource waits from showing as failed attempts. This is the same banner, reached through the Interrupt path. > - This pull request shows the Interrupt hand-off as working, and renders the stopped turn once. > - The benefit is that steering a running agent looks calm and correct: one stopped row, no error, and the queued message starts next. ## Linked Issues or Issue Description Refs #19369 (same banner class, resource-wait path) **What happened?** While an agent ran, the user queued a message and clicked Interrupt on the queued strip. A red banner "Couldn't finish this attempt. Your message is saved." with Retry appeared at the top of the task. Two rows showed the same stopped run: "Stopped for 22s" and "Stopped after 21s". About 20 s later the banner went away and the message was delivered. **Expected behavior** A user Interrupt is not a failure. No red banner or Retry. One "Stopped" row, and the queued message starts next. **Steps to reproduce** 1. Start a task with a Paperclip Runner agent. 2. While it works, send a message so it waits in the queue strip. 3. Wait more than 10 s, then click Interrupt on the queued strip. 4. See the red banner, Retry, and two "Stopped" rows. **Paperclip version or commit** master e482f0f632 **Agent adapter(s) involved** Not adapter-specific (core bug); seen with Claude API through Paperclip Runner. ## What Changed Cause 1 — banner (`server/src/services/task-interruption-presentation.ts`): - The Interrupt route cancels the run with `queuedCommentInterruptQueueId` set to the queued wake, and stamps `queuedCommentInterrupt.requestedAt` on that wake. The projection skips its "intentional Stop" rule whenever a pending wake exists, and treats a pending wake as "working" only for 10 s after the message was *queued*. A message queued earlier than 10 s before Interrupt, or a cleanup that takes longer than 10 s, fell through to `state: "failed", canRetry: true`. - The projection now reads `queuedCommentInterruptQueueId` from the run and `queuedCommentInterrupt.requestedAt` from the wake (both bounded strings in the existing batched queries). When the cancelled run was stopped for exactly the pending wake, it returns `working` for up to 5 min after the click. Pause, approval, reconnect and other user actions stay first. A pending Retry preparation and exhausted system recovery keep their own presentation. After 5 min, a hand-off that never started shows one Retry, as before. Cause 2 — two rows (`ui/src/components/TaskChatThread.tsx`): - When a native runner turn ends, the live tail stays until the settled turn *and* its reply comment render, or a 15 s backstop expires. A stopped turn posts no reply, so the tail stayed for 15 s next to the settled turn. That gave "Stopped after 21s" (live tail) and "Stopped for 22s" (settled turn). - A cancelled settling run now hands off as soon as its settled turn renders. Tests: - `server/src/__tests__/task-interruption-query.test.ts`: new embedded-Postgres test drives the real batched query with the exact `resultJson` the Interrupt route writes. It checks `working`/no Retry/no Sentry failure capture, a run stopped for a different queue still fails, and the 5 min bound. - `ui/src/components/TaskChatThread.test.tsx`: new test renders a live native runner turn, then the cancelled run with no reply. It checks there is no live tail and exactly one "Stopped" row. ## Verification Red on master (tests added before the fix): ``` $ npx vitest run src/__tests__/task-interruption-query.test.ts -t "queued-message Interrupt" FAIL task interruption latest run query > shows a queued-message Interrupt as handing off, not as a failed attempt AssertionError: expected { …(8) } to match object { state: 'working', …(2) } - "canRetry": false, + "canRetry": true, - "state": "working", + "state": "failed", Tests 1 failed | 18 skipped (19) ``` ``` $ npx vitest run src/components/TaskChatThread.test.tsx -t "interrupted native turn" ❯ src/components/TaskChatThread.test.tsx:5528:82 expect(container.querySelector('[data-testid="task-chat-live-transcript"]')).toBeNull(); Tests 1 failed | 368 skipped (369) ``` Green after the fix: ``` $ npx vitest run src/__tests__/task-interruption-query.test.ts src/services/task-interruption-presentation.test.ts Test Files 2 passed (2) Tests 60 passed (60) $ npx vitest run src/components/TaskChatThread.test.tsx -t "interrupted native turn" Tests 1 passed | 368 skipped (369) ``` Local `pnpm verify` (affected Vitest selection) result is added in a follow-up comment; required CI supplies typecheck, build and the full suite. ## Risks - Low risk. Both changes are presentation only. Admission, cleanup and Stop gates do not change. - A hand-off that is blocked for a long time now shows no banner for up to 5 min. The queued strip still shows its own wait text ("Waiting for the previous run to finish recovery…") during that time, so the wait is visible. After 5 min, Retry returns. - The thread change applies only to a settling run with status `cancelled`. Failed and succeeded runs keep the existing reply-comment hand-off. - Not in scope: the task status icon changed to `todo` during the hand-off. That is the issue status, not this projection. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with tool use (shell, file edit, test execution). ## 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 - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../__tests__/task-interruption-query.test.ts | 47 +++++++++++++++++++ .../task-interruption-presentation.ts | 39 ++++++++++++++- ui/src/components/TaskChatThread.test.tsx | 24 ++++++++++ ui/src/components/TaskChatThread.tsx | 8 +++- 4 files changed, 115 insertions(+), 3 deletions(-) diff --git a/server/src/__tests__/task-interruption-query.test.ts b/server/src/__tests__/task-interruption-query.test.ts index f0352841235..02a1c12ba35 100644 --- a/server/src/__tests__/task-interruption-query.test.ts +++ b/server/src/__tests__/task-interruption-query.test.ts @@ -90,6 +90,53 @@ describe.skipIf(!support.supported)("task interruption latest run query", () => .toMatchObject({ state: "failed", canRetry: true }); }); + it("shows a queued-message Interrupt as handing off, not as a failed attempt", async () => { + const companyId = randomUUID(), agentId = randomUUID(), runId = randomUUID(), userId = "board-user"; + await db.insert(companies).values({ id: companyId, name: "Interrupted task", issuePrefix: `Q${companyId.slice(0, 7)}` }); + await db.insert(agents).values({ id: agentId, companyId, name: "Researcher", adapterType: "paperclip_runner" }); + const [issue] = await db.insert(issues).values({ companyId, title: "Creative brief", + status: "todo", assigneeAgentId: agentId }).returning(); + // The message was queued well before the user clicked Interrupt. + const [wake] = await db.insert(agentWakeupRequests).values({ + companyId, agentId, source: "on_demand", status: "deferred_issue_execution", requestedByActorType: "user", + requestedAt: new Date(Date.now() - 60_000), payload: { issueId: issue.id }, + }).returning(); + const interrupt = async (requestedAt: Date) => { + await db.update(agentWakeupRequests).set({ payload: { issueId: issue.id, + queuedCommentInterrupt: { actorId: userId, requestedAt: requestedAt.toISOString() } } }) + .where(eq(agentWakeupRequests.id, wake.id)); + }; + await interrupt(new Date(Date.now() - 20_000)); + // Same resultJson the queued-comment Interrupt route passes to cancelRun. + await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, nativeIssueId: issue.id, scopeKind: "issue", + status: "cancelled", runtimeMode: "native", errorCode: "operator_interrupted", + startedAt: new Date(Date.now() - 42_000), finishedAt: new Date(Date.now() - 20_000), + resultJson: { operatorInterrupted: true, interruptionSource: "issue_comment_interrupt", interruptedIssueId: issue.id, + interruptedByActorType: "user", interruptedByActorId: userId, cancelledByActorType: "user", cancelledByUserId: userId, + queuedCommentInterruptQueueId: wake.id } }); + captureTaskInterruptionFailure.mockClear(); + for (const presentation of [(await taskInterruptionPresentations(db, companyId, [issue])).get(issue.id), + await taskInterruptionPresentation(db, issue)]) { + expect(presentation).toMatchObject({ state: "working", canRetry: false, intentId: wake.id }); + } + expect(captureTaskInterruptionFailure).not.toHaveBeenCalled(); + // A newer queued message does not hide the wake the run was stopped for. + await db.insert(agentWakeupRequests).values({ + companyId, agentId, source: "on_demand", status: "deferred_issue_execution", requestedByActorType: "user", + requestedAt: new Date(Date.now() - 30_000), payload: { issueId: issue.id }, + }); + expect(await taskInterruptionPresentation(db, issue)).toMatchObject({ state: "working", canRetry: false, intentId: wake.id }); + // A run stopped for a different queue is not this hand-off. + await db.update(heartbeatRuns).set({ resultJson: { operatorInterrupted: true, cancelledByActorType: "user", + cancelledByUserId: userId, queuedCommentInterruptQueueId: randomUUID() } }).where(eq(heartbeatRuns.id, runId)); + expect(await taskInterruptionPresentation(db, issue)).toMatchObject({ state: "failed", canRetry: true }); + await db.update(heartbeatRuns).set({ resultJson: { operatorInterrupted: true, cancelledByActorType: "user", + cancelledByUserId: userId, queuedCommentInterruptQueueId: wake.id } }).where(eq(heartbeatRuns.id, runId)); + // A hand-off that never starts eventually offers one Retry. + await interrupt(new Date(Date.now() - 10 * 60_000)); + expect(await taskInterruptionPresentation(db, issue)).toMatchObject({ state: "failed", canRetry: true }); + }); + it("decides several pending native resumes in one list read", async () => { const companyId = randomUUID(), agentId = randomUUID(), otherAgentId = randomUUID(); await db.insert(companies).values({ id: companyId, name: "Resuming list", issuePrefix: `L${companyId.slice(0, 7)}` }); diff --git a/server/src/services/task-interruption-presentation.ts b/server/src/services/task-interruption-presentation.ts index 73c7682f9be..d4f2e1f07c9 100644 --- a/server/src/services/task-interruption-presentation.ts +++ b/server/src/services/task-interruption-presentation.ts @@ -28,6 +28,9 @@ const resourceWaitRetryReasons = new Set([WORKSPACE_BUSY_RETRY_REASON, A // A due same-run resume dispatches within seconds; past this, offer Retry. const NATIVE_RESUME_DISPATCH_GRACE_MS = 2 * 60_000; +// A queued-message Interrupt waits for the stopped run's cleanup before its +// successor starts. Past this, a hand-off that never started offers Retry. +const QUEUED_INTERRUPT_HANDOFF_GRACE_MS = 5 * 60_000; type ProjectionIssue = Pick; type ProjectionRun = Pick; projectAccessAllowed?: boolean; projectAccessProjectId?: string | null; @@ -121,6 +128,16 @@ export function projectTaskInterruption(issue: ProjectionIssue, input: Projectio // physical containment and pending cleanup keep their execution-side gates. if (!wait && run.runtimeMode === "native" && run.companyId === issue.companyId && run.nativeIssueId === issue.id && isAcknowledgedNativeStop({ ...run, companyId: run.companyId, nativeIssueId: run.nativeIssueId })) return base; + // A queued-message Interrupt stops this run to deliver that exact message. + // The stop is the user's intent and the message starts next, so show the + // hand-off as working, not as a failed attempt. + // Newer queued input can follow it, so match the recorded queue, not the latest wait. + const interruptWait = input.interruptWait ?? wait; + const interruptAt = Date.parse(interruptWait?.queuedInterruptRequestedAt ?? ""); + if (interruptWait && run.status === "cancelled" && run.resultJson?.queuedCommentInterruptQueueId === interruptWait.id + && Number.isFinite(interruptAt) && now - interruptAt < QUEUED_INTERRUPT_HANDOFF_GRACE_MS + && !(retryWait?.sourceRunId === run.id && retryWait.preparationStatus) && system?.state !== "exhausted") + return { ...base, intentId: interruptWait.id, state: "working" }; if (["running", "queued"].includes(run.status)) return { ...base, state: "working" }; const deniedProject = deniedProjectId(issue, run); let projectAccess: TaskInterruptionPresentation["projectAccess"]; @@ -237,6 +254,7 @@ export async function taskInterruptionPresentations(db: Db, companyId: string, i 'projectAccessDenied', ${heartbeatRuns.resultJson}->'projectAccessDenied', 'cancelledByActorType', ${boundedJsonString(sql`${heartbeatRuns.resultJson}->'cancelledByActorType'`, 32)}, 'cancelledByUserId', ${boundedJsonString(sql`${heartbeatRuns.resultJson}->'cancelledByUserId'`, 160)}, + 'queuedCommentInterruptQueueId', ${boundedJsonString(sql`${heartbeatRuns.resultJson}->'queuedCommentInterruptQueueId'`, 160)}, 'nativeCancellation', jsonb_build_object( 'schema', ${boundedJsonString(sql`${heartbeatRuns.resultJson}->'nativeCancellation'->'schema'`, 64)}, 'runId', ${boundedJsonString(sql`${heartbeatRuns.resultJson}->'nativeCancellation'->'runId'`, 160)}, @@ -316,6 +334,19 @@ export async function taskInterruptionPresentations(db: Db, companyId: string, i })); } } + // A queued-message Interrupt records its queue on the stopped run. Newer + // input can hide that wake from the latest-wait read, so load it by id. + const interruptQueueId = (run: typeof runs[number]) => { + const id = run.status === "cancelled" ? run.resultJson?.queuedCommentInterruptQueueId : null; + return typeof id === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id) ? id : null; + }; + const interruptQueueIds = runs.flatMap(run => interruptQueueId(run) ?? []); + const interruptWaits = interruptQueueIds.length ? await db.select({ issueId: wakeIssue, id: agentWakeupRequests.id, + requestedAt: agentWakeupRequests.requestedAt, + queuedInterruptRequestedAt: boundedJsonString(sql`${agentWakeupRequests.payload}#>'{queuedCommentInterrupt,requestedAt}'`, 40) }) + .from(agentWakeupRequests).where(and(eq(agentWakeupRequests.companyId, companyId), inArray(agentWakeupRequests.id, interruptQueueIds), + eq(agentWakeupRequests.status, "deferred_issue_execution"), eq(agentWakeupRequests.requestedByActorType, "user"))) : []; + const interruptWaitById = new Map(interruptWaits.map(wait => [wait.id, wait])); const runIds = runs.map(run => run.id); const actions = runIds.length ? await db.selectDistinctOn([issueRecoveryActions.sourceIssueId]).from(issueRecoveryActions).where(and( eq(issueRecoveryActions.companyId, companyId), inArray(issueRecoveryActions.sourceIssueId, ids), @@ -354,6 +385,11 @@ export async function taskInterruptionPresentations(db: Db, companyId: string, i projectIds.forEach((projectId, index) => currentAccess.set(`${agentId}:${projectId}`, decisions[index]?.allowed === true)); } const actionMap = new Map(actions.map(action => [action.sourceIssueId, action])); + const interruptWaitFor = (issueId: string) => { + const run = runMap.get(issueId); + const wait = run ? interruptWaitById.get(interruptQueueId(run) ?? "") : undefined; + return wait?.issueId === issueId ? wait : undefined; + }; const agentMap = new Map(agentRows.map(agent => [agent.id, agent])); const projectMap = new Map(projectRows.map(project => [project.id, project])); const pauses = new Set(pauseRows.map(row => row.issueId)), approvalsSet = new Set(approvalRows.map(row => row.issueId)); @@ -362,6 +398,7 @@ export async function taskInterruptionPresentations(db: Db, companyId: string, i const denied = required?.projectIds.filter(projectId => currentAccess.get(`${issue.assigneeAgentId}:${projectId}`) !== true); const presentation = projectTaskInterruption(issue, { run: runMap.get(issue.id), action: actionMap.get(issue.id), wait: waitMap.get(issue.id), retryWait: retryWaitMap.get(issue.id), + interruptWait: interruptWaitFor(issue.id), projectAccessAllowed: denied?.length === 0, projectAccessProjectId: denied?.includes(issue.projectId ?? "") ? issue.projectId : null, workspaceSelectionAvailable: required?.available ?? false, diff --git a/ui/src/components/TaskChatThread.test.tsx b/ui/src/components/TaskChatThread.test.tsx index bdaa13c562e..fd18a489f84 100644 --- a/ui/src/components/TaskChatThread.test.tsx +++ b/ui/src/components/TaskChatThread.test.tsx @@ -5556,6 +5556,30 @@ describe("TaskChatThread live transcript", () => { }, ); + it("renders one Stopped row when an interrupted native turn settles without a reply", () => { + const runId = "native-interrupted"; + nativeTranscriptState.transcriptByRun.set(runId, [ + assistantEntry("2026-11-06T15:14:05Z", "INTERRUPTED-PROGRESS-LINE", "progress"), + { kind: "run_terminal", ts: "2026-11-06T15:14:26Z", turnState: "cancelled", runState: "cancelled", disposition: "blocked" }, + ]); + const activeRun = { + id: runId, runtimeMode: "native" as const, status: "running", invocationSource: "issue" as const, + triggerDetail: null, startedAt: "2026-11-06T15:14:05Z", createdAt: "2026-11-06T15:14:05Z", finishedAt: null, + agentId: "agent-1", agentName: "Researcher", adapterType: "paperclip_runner", + }; + renderThread({ comments: [], onAdd: async () => {}, issueStatus: "in_progress", activeRun }); + // Interrupt cancels the turn. A stopped turn posts no reply comment, so the + // settled turn alone owns it; the live tail must not render a second row. + renderThread({ + comments: [], onAdd: async () => {}, issueStatus: "in_progress", + linkedRuns: [{ ...activeRun, runId, status: "cancelled", finishedAt: "2026-11-06T15:14:26Z", + errorCode: "operator_interrupted", resultJson: { operatorInterrupted: true, cancelledByActorType: "user" } }], + }); + expect(container.querySelector('[data-testid="task-chat-live-transcript"]')).toBeNull(); + expect(container.textContent?.split("INTERRUPTED-PROGRESS-LINE")).toHaveLength(2); + expect(container.textContent?.match(/Stopped (for|after) /g)).toHaveLength(1); + }); + it("keeps the transcript mounted through run settle until the settled turn renders (PAP-462 B4)", () => { transcriptState.transcriptByRun.set("run-1", [ { diff --git a/ui/src/components/TaskChatThread.tsx b/ui/src/components/TaskChatThread.tsx index d4420da495f..bd605379bd9 100644 --- a/ui/src/components/TaskChatThread.tsx +++ b/ui/src/components/TaskChatThread.tsx @@ -2405,9 +2405,12 @@ export function TaskChatThread(props: TaskChatThreadProps) { // Hand off once the settled turn or its reply comment is in the thread; a // stopped run that yields neither is released by the backstop timeout so the // tail never lingers indefinitely. + const settlingRunSource = + settlingRun != null ? runs.find((run) => run.id === settlingRun.id) : undefined; const settlingIsPaperclipRunner = - settlingRun != null && - isNativePaperclipRunnerRun(runs.find((run) => run.id === settlingRun.id)); + settlingRun != null && isNativePaperclipRunnerRun(settlingRunSource); + // A stopped turn posts no reply, so its settled turn is already complete. + const settlingRunStopped = settlingRunSource?.status === "cancelled"; const settlingHasComment = settlingRun != null && comments.some((comment) => @@ -2434,6 +2437,7 @@ export function TaskChatThread(props: TaskChatThreadProps) { ? settledRunIds.has(settlingRun.id) && (settlingHasComment || settlingHasDurableInteraction || + settlingRunStopped || // A projected terminal answer can already own the response while // the canonical comment request is still in flight. Do not show a // second activity tail just because that fetch is slower.