From 7440cfc9b19c1ce0122d39b569e17ff16019f47b Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Thu, 5 Nov 2026 18:59:16 -0600 Subject: [PATCH] fix(issues): accept queued Send while the previous run has not started (#19370) 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 task chat queues a message while the agent is busy. The queued-message bar shows a Send button when no turn is running. > - Send calls `POST /issues/:id/queued-comments/interrupt` with no target run. > - The issue can hold an execution run that has not started yet (`queued`) or that waits for a retry (`scheduled_retry`). The GET response then shows a deferred queue with no target run, so the UI shows Send. > - The interrupt route refused that click with 409. The UI then showed "Couldn't send. Message is still queued." > - This pull request saves the click as interrupt intent in that state. The queue is delivered when the previous run stops. > - The benefit is that the button the UI offers now works, and the message goes next. ## Linked Issues or Issue Description **What happened?** Cloud onboarding test, Codex subscription path (N-06): the tester clicked Send in the onboarding task. The UI showed "Couldn't send. Message is still queued." **Expected behavior** Send is accepted. The message stays in the queue and is delivered next, when the previous run stops. **Steps to reproduce** 1. Have an issue whose execution run is `queued` (not started) or `scheduled_retry`. 2. Post a message. It is deferred behind that run. 3. Click Send on the queued message. 4. The route returns 409: "This queue does not support interruption" (paperclip_runner) or "The previous run has not stopped" (legacy adapters). **Paperclip version or commit** `master` at b68f69a801. Root cause: `GET /queued-comments` resolves the target run from running runs only, so the UI offers Send. `POST /queued-comments/interrupt` locks `issue.executionRunId` at any status. It accepted only a terminal run or no run. A queued or retry-waiting run fell between those two rules. Relation to other fixes: #19331 (declined connection receipt) changed which wake the queue projects. It does not touch this check, and current `master` still fails. The sibling N-07 problem (steering a queued message on Codex) uses the `/steer` route. This PR does not change that route. I found no shared cause. Evidence limits: the tester's notes are the only evidence. The staging company that I could read uses the Claude subscription path, so I could not see the Codex run's status at the time of the click. The reproduced state is the only server path that gives "Couldn't send" for a Send click with no running turn. A slow-starting or retrying Codex run gives that state. ## What Changed - `server/src/routes/issues.ts`: the interrupt route no longer refuses a Send when the issue's run is `queued` or `scheduled_retry`. For paperclip_runner queues, a run that is not running is handled like a stopped run. The legacy "previous run has not stopped" 409 is removed. The route saves `queuedCommentInterrupt` intent, does not cancel the not-yet-started run, and returns the queue in the `deferred` state. The UI already shows "Messages are still queued. They will send when the task is ready." for that response. - `server/src/__tests__/issue-queued-comments-routes.test.ts`: new route test for native and legacy queues, with `queued` and `scheduled_retry` runs. It checks that GET offers Send, that the POST is accepted without a run cancel, that the intent is saved, and that the queue is delivered to one successor run after the previous run stops. ## Verification Red, on `master` (new test, before the fix): ``` pnpm vitest run server/src/__tests__/issue-queued-comments-routes.test.ts -t "accepts Send while" × accepts Send while the previous native run is queued and delivers it once that run stops × accepts Send while the previous native run is scheduled_retry and delivers it once that run stops × accepts Send while the previous legacy run is queued and delivers it once that run stops × accepts Send while the previous legacy run is scheduled_retry and delivers it once that run stops AssertionError: {"error":"This queue does not support interruption"}: expected 409 to be 200 AssertionError: {"error":"The previous run has not stopped"}: expected 409 to be 200 Tests 4 failed | 90 skipped (94) ``` Green, after the fix (same command): ``` Test Files 1 passed (1) Tests 4 passed | 90 skipped (94) ``` Neighbouring suites: - `server/src/__tests__/issue-queued-comments-routes.test.ts` (complete file): 94/94 passed. - `server/src/__tests__/agent-conversations.test.ts`, `server/src/services/issue-queued-comment-queue.test.ts`, `ui/src/components/task-chat/TaskChatQueuedMessages.test.tsx`: passed. - When I ran the two embedded-Postgres route files in one Vitest process, every test passed. Then the queued-comment file's `afterAll` database cleanup timed out after 30 s, which is a local resource limit. The file passes when it runs alone. - `pnpm --filter @paperclipai/server typecheck`: passed. - I made no UI changes, so I did not run `pnpm check:token-gates`. ## Risks - Low risk. Send was refused only for `queued` and `scheduled_retry` runs. Now that click is saved and the run is not cancelled. A running turn is unchanged: paperclip_runner still steers it, and legacy still interrupts it. - When a run waits for a retry, a saved Send waits for that retry to finish. The route does not skip the retry. This matches how the queue already works for messages without a click. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, in Claude Code with tool use (shell, file edits, browser for read-only staging evidence). ## 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 - [x] All Paperclip CI gates are green - [x] 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 --- .../issue-queued-comments-routes.test.ts | 45 +++++++++++++++++++ server/src/routes/issues.ts | 11 +++-- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/server/src/__tests__/issue-queued-comments-routes.test.ts b/server/src/__tests__/issue-queued-comments-routes.test.ts index 4d045bb25cc..7e701455967 100644 --- a/server/src/__tests__/issue-queued-comments-routes.test.ts +++ b/server/src/__tests__/issue-queued-comments-routes.test.ts @@ -1950,4 +1950,49 @@ describeEmbeddedPostgres("issue queued-comment routes", () => { await new Promise(resolve => setTimeout(resolve, 250)); }, ); + + // N-06: the composer offers "Send" for a deferred queue with no running + // turn. The issue's own run can still be waiting to start (queued) or to + // retry (scheduled_retry); that Send must be accepted and delivered next. + it.each([ + { runtime: "native", status: "queued" }, { runtime: "native", status: "scheduled_retry" }, + { runtime: "legacy", status: "queued" }, { runtime: "legacy", status: "scheduled_retry" }, + ] as const)("accepts Send while the previous $runtime run is $status and delivers it once that run stops", async ({ runtime, status }) => { + const seeded = await seedQueue(); + await db.update(agents).set({ adapterType: runtime === "native" ? "paperclip_runner" : "codex_local", + runtimeConfig: { heartbeat: { maxConcurrentRuns: 1 } }, + }).where(eq(agents.id, seeded.agentId)); + await db.update(heartbeatRuns).set({ runtimeMode: runtime, status, startedAt: null }) + .where(eq(heartbeatRuns.id, seeded.runId)); + const client = app(seeded.companyId); + const queue = await request(client).get(`/api/issues/${seeded.issueId}/queued-comments`).expect(200); + // This is the state in which the UI renders the "Send" button. + expect(queue.body).toMatchObject({ state: "deferred", targetRunId: null, queueId: seeded.wakeId }); + const sent = await request(client).post(`/api/issues/${seeded.issueId}/queued-comments/interrupt`).send({ + queueId: seeded.wakeId, revision: queue.body.revision, targetRunId: null, + }); + expect(sent.status, JSON.stringify(sent.body)).toBe(200); + expect(sent.body).toMatchObject({ state: "deferred", queueId: seeded.wakeId }); + expect(sent.body.entries.map((entry: { comment: { id: string } }) => entry.comment.id)).toEqual(seeded.commentIds); + // The previous run is not cancelled: it has not started any work to stop. + expect((await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, seeded.runId)))[0]!.status).toBe(status); + const [saved] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, seeded.wakeId)); + expect(saved.status).toBe("deferred_issue_execution"); + expect(saved.payload?.queuedCommentInterrupt).toMatchObject({ actorId: "queue-owner" }); + + // The previous run then finishes. Occupy the agent on another task so the + // successor stays queued and the test never launches a provider. + await db.update(heartbeatRuns).set({ status: "succeeded", finishedAt: new Date() }) + .where(eq(heartbeatRuns.id, seeded.runId)); + await db.update(issues).set({ executionRunId: null }).where(eq(issues.id, seeded.issueId)); + await db.insert(heartbeatRuns).values({ companyId: seeded.companyId, agentId: seeded.agentId, + status: "running", contextSnapshot: { issueId: randomUUID() }, + }); + await heartbeatService(db).resumeQueuedCommentInterrupt(seeded.companyId, seeded.wakeId); + const [wake] = await db.select().from(agentWakeupRequests).where(eq(agentWakeupRequests.id, seeded.wakeId)); + expect(wake.status).toBe("coalesced"); + const [successor] = await db.select().from(heartbeatRuns).where(eq(heartbeatRuns.id, wake.runId!)); + expect(successor.status).toBe("queued"); + expect(successor.contextSnapshot?.wakeCommentIds).toEqual(seeded.commentIds); + }); }); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 2f1ebf6605b..e5b36b52793 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -16896,17 +16896,16 @@ export function issueRoutes( }); assertQueueMutationTarget({ queue: locked.queue, queueId: req.body.queueId, revision: req.body.revision }); const freshResponse = locked.queue.entries.some(entry => entry.source?.requiresFreshSession); - const stoppedNativeQueue = locked.queue.protocol === "paperclip_runner_v1" && - (!locked.activeRun || ["succeeded", "failed", "timed_out", "interrupted", "cancelled"].includes(locked.activeRun.status)); + // The UI offers Send whenever no turn is running. The issue's run may + // still be waiting to start or to retry: it has no turn to interrupt, + // so save the click and deliver the queue as soon as that run stops. + const previousRunNotRunning = !locked.activeRun || locked.activeRun.status !== "running"; + const stoppedNativeQueue = locked.queue.protocol === "paperclip_runner_v1" && previousRunNotRunning; if ((locked.queue.protocol !== "legacy" && !freshResponse && !stoppedNativeQueue) || locked.state !== "deferred" || !locked.queue.entries.length || (locked.activeRun && locked.activeRun.agentId !== locked.wake.agentId)) { throw conflict("This queue does not support interruption"); } - if (locked.activeRun && locked.activeRun.status !== "running" && - !["succeeded", "failed", "timed_out", "interrupted", "cancelled"].includes(locked.activeRun.status)) { - throw conflict("The previous run has not stopped"); - } if (locked.activeRun?.status === "running" && locked.activeRun.id !== req.body.targetRunId) { throw conflict("The queued message targets a stale run", { code: "queued_comment_stale_target" }); }