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: {