diff --git a/server/src/__tests__/issue-recovery-actions.test.ts b/server/src/__tests__/issue-recovery-actions.test.ts index 302065e96e..911137dc79 100644 --- a/server/src/__tests__/issue-recovery-actions.test.ts +++ b/server/src/__tests__/issue-recovery-actions.test.ts @@ -380,6 +380,80 @@ describeEmbeddedPostgres("issue recovery actions", () => { }, ); + it("stands down while the latest run was cancelled by a board operator", async () => { + const { companyId, coderId, sourceIssueId } = await seedCompany(); + await db.insert(heartbeatRuns).values({ + id: randomUUID(), + companyId, + agentId: coderId, + invocationSource: "manual", + status: "cancelled", + error: "Cancelled by a board operator", + errorCode: "cancelled", + resultJson: { cancelledByActorType: "user", cancelledByUserId: "board-user" }, + startedAt: new Date("2026-07-15T20:00:00.000Z"), + finishedAt: new Date("2026-07-15T20:01:00.000Z"), + contextSnapshot: { issueId: sourceIssueId }, + }); + const enqueueWakeup = vi.fn(async () => null); + const recovery = recoveryService(db, { enqueueWakeup }); + + const result = await recovery.reconcileStrandedAssignedIssues(); + + expect(result.operatorCancelExempted).toBe(1); + expect(await db.select().from(issueRecoveryActions)).toHaveLength(0); + expect(enqueueWakeup).not.toHaveBeenCalled(); + }); + + it("stands down after an operator interrupt cancellation", async () => { + const { companyId, coderId, sourceIssueId } = await seedCompany(); + await db.insert(heartbeatRuns).values({ + id: randomUUID(), + companyId, + agentId: coderId, + invocationSource: "manual", + status: "cancelled", + error: "Interrupted by board comment", + errorCode: "operator_interrupted", + startedAt: new Date("2026-07-15T20:00:00.000Z"), + finishedAt: new Date("2026-07-15T20:01:00.000Z"), + contextSnapshot: { issueId: sourceIssueId }, + }); + const enqueueWakeup = vi.fn(async () => null); + const recovery = recoveryService(db, { enqueueWakeup }); + + const result = await recovery.reconcileStrandedAssignedIssues(); + + expect(result.operatorCancelExempted).toBe(1); + expect(enqueueWakeup).not.toHaveBeenCalled(); + }); + + it("still recovers system-cancelled runs with no operator attribution", async () => { + const { companyId, coderId, sourceIssueId } = await seedCompany(); + await db.insert(heartbeatRuns).values({ + id: randomUUID(), + companyId, + agentId: coderId, + invocationSource: "manual", + status: "cancelled", + error: "Cancelled because the workspace lease expired", + errorCode: "cancelled", + startedAt: new Date("2026-07-15T20:00:00.000Z"), + finishedAt: new Date("2026-07-15T20:01:00.000Z"), + contextSnapshot: { issueId: sourceIssueId }, + }); + const enqueueWakeup = vi.fn(async () => null); + const recovery = recoveryService(db, { enqueueWakeup }); + + const result = await recovery.reconcileStrandedAssignedIssues(); + + expect(result.operatorCancelExempted).toBe(0); + // The system-cancelled run still flows into the pre-existing recovery + // behavior (a continuation requeue or escalation — either produces a + // wake), proving the stand-down is scoped to operator attribution. + expect(enqueueWakeup).toHaveBeenCalled(); + }); + it("schedules a provider-quota monitor for the original assignee without creating recovery work", async () => { const { companyId, coderId, sourceIssueId } = await seedCompany(); const runId = randomUUID(); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index b154931e01..d3659591b2 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -3756,7 +3756,15 @@ export function agentRoutes( const runId = req.params.runId as string; const existing = await getAccessibleResource(req, res, heartbeat.getRun(runId), "Heartbeat run not found"); if (!existing) return; - const run = await heartbeat.cancelRun(runId); + // Stamp the cancellation as operator-initiated (this route is board-only). + // Recovery reads this to stand down instead of classifying the cancelled + // run as agent stranding and re-waking the agent the operator just stopped. + const run = await heartbeat.cancelRun(runId, "Cancelled by a board operator", { + resultJson: { + cancelledByActorType: "user", + cancelledByUserId: req.actor.userId ?? null, + }, + }); if (run) { await logActivity(db, { diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 0610362e3a..fbeb4aacb9 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -645,6 +645,21 @@ function isStrandedIssueRecoveryIssue(issue: Pick