diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 5d907470fa4..0bd61ba0422 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -651,6 +651,39 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(comments[0]?.body).toContain("Latest retry failure: `process_lost` - run failed before issue advanced."); }); + it("re-enqueues continuation when the latest automatic continuation succeeded without closing the issue", async () => { + const { agentId, issueId, runId } = await seedStrandedIssueFixture({ + status: "in_progress", + runStatus: "succeeded", + retryReason: "issue_continuation_needed", + }); + const heartbeat = heartbeatService(db); + + const result = await heartbeat.reconcileStrandedAssignedIssues(); + expect(result.continuationRequeued).toBe(1); + expect(result.escalated).toBe(0); + expect(result.issueIds).toEqual([issueId]); + + const issue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null); + expect(issue?.status).toBe("in_progress"); + + const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); + expect(comments).toHaveLength(0); + + const runs = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.agentId, agentId)); + expect(runs).toHaveLength(2); + + const retryRun = runs.find((row) => row.id !== runId); + expect(retryRun?.id).toBeTruthy(); + expect((retryRun?.contextSnapshot as Record)?.retryReason).toBe("issue_continuation_needed"); + if (retryRun) { + await waitForRunToSettle(heartbeat, retryRun.id); + } + }); + it("does not reconcile user-assigned work through the agent stranded-work recovery path", async () => { const { issueId, runId } = await seedStrandedIssueFixture({ status: "todo", diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 02d108bb658..af9c568697d 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -94,6 +94,7 @@ const MAX_INLINE_WAKE_COMMENT_BODY_CHARS = 4_000; const MAX_INLINE_WAKE_COMMENT_BODY_TOTAL_CHARS = 12_000; const execFile = promisify(execFileCallback); const ACTIVE_HEARTBEAT_RUN_STATUSES = ["queued", "running"] as const; +const UNSUCCESSFUL_HEARTBEAT_RUN_TERMINAL_STATUSES = ["failed", "cancelled", "timed_out"] as const; const SESSIONED_LOCAL_ADAPTERS = new Set([ "claude_local", "codex_local", @@ -681,6 +682,22 @@ function summarizeRunFailureForIssueComment( return null; } +function didAutomaticRecoveryFail( + latestRun: Pick | null, + expectedRetryReason: "assignment_recovery" | "issue_continuation_needed", +) { + if (!latestRun) return false; + + const latestContext = parseObject(latestRun.contextSnapshot); + const latestRetryReason = readNonEmptyString(latestContext.retryReason); + return ( + latestRetryReason === expectedRetryReason && + UNSUCCESSFUL_HEARTBEAT_RUN_TERMINAL_STATUSES.includes( + latestRun.status as (typeof UNSUCCESSFUL_HEARTBEAT_RUN_TERMINAL_STATUSES)[number], + ) + ); +} + function normalizeLedgerBillingType(value: unknown): BillingType { const raw = readNonEmptyString(value); switch (raw) { @@ -3037,16 +3054,13 @@ export function heartbeatService(db: Db) { } const latestRun = await getLatestIssueRun(issue.companyId, issue.id); - const latestContext = parseObject(latestRun?.contextSnapshot); - const latestRetryReason = readNonEmptyString(latestContext.retryReason); - if (issue.status === "todo") { if (!latestRun || latestRun.status === "succeeded") { result.skipped += 1; continue; } - if (latestRetryReason === "assignment_recovery") { + if (didAutomaticRecoveryFail(latestRun, "assignment_recovery")) { const failureSummary = summarizeRunFailureForIssueComment(latestRun); const updated = await escalateStrandedAssignedIssue({ issue, @@ -3083,7 +3097,7 @@ export function heartbeatService(db: Db) { continue; } - if (latestRetryReason === "issue_continuation_needed") { + if (didAutomaticRecoveryFail(latestRun, "issue_continuation_needed")) { const failureSummary = summarizeRunFailureForIssueComment(latestRun); const updated = await escalateStrandedAssignedIssue({ issue,