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" }); }