mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-09 16:02:36 +01:00
fix(issues): accept queued Send while the previous run has not started (#19370)
## 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
33269e1fec
commit
7440cfc9b1
2 files changed
+50
-6
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user