From 28be799e5e66d99ef0b3e3e21d9426ce4b63031d Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 1 May 2026 10:40:04 -0500 Subject: [PATCH] Disambiguate productive recovery source Co-Authored-By: Paperclip --- .../heartbeat-process-recovery.test.ts | 32 +++++++++++++++++++ server/src/services/recovery/service.ts | 1 + 2 files changed, 33 insertions(+) diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 891706357f6..e572d0627f5 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -516,6 +516,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { status: "todo" | "in_progress"; runStatus: "failed" | "timed_out" | "cancelled" | "succeeded"; retryReason?: "assignment_recovery" | "issue_continuation_needed" | null; + runSource?: string | null; assignToUser?: boolean; activePauseHold?: boolean; livenessState?: "completed" | "advanced" | "plan_only" | "empty_response" | "blocked" | "failed" | "needs_followup" | null; @@ -582,6 +583,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { ? "issue_assignment_recovery" : input.retryReason ?? "issue_assigned", ...(input.retryReason ? { retryReason: input.retryReason } : {}), + ...(input.runSource ? { source: input.runSource } : {}), }, startedAt: now, finishedAt: new Date("2026-03-19T00:05:00.000Z"), @@ -2224,6 +2226,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { status: "in_progress", runStatus: "succeeded", retryReason: "issue_continuation_needed", + runSource: "issue.productive_terminal_continuation_recovery", livenessState: "advanced", }); const heartbeat = heartbeatService(db); @@ -2252,6 +2255,35 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(comments[0]?.body).toContain(`Recovery issue: [${recovery.identifier}]`); }); + it("allows one productive-terminal recovery after regular continuation recovery made progress", async () => { + const { agentId, issueId, runId } = await seedStrandedIssueFixture({ + status: "in_progress", + runStatus: "succeeded", + retryReason: "issue_continuation_needed", + runSource: "issue.continuation_recovery", + livenessState: "advanced", + }); + 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 runs = await db + .select() + .from(heartbeatRuns) + .where(eq(heartbeatRuns.agentId, agentId)); + const retryRun = runs.find((row) => row.id !== runId); + expect(retryRun?.contextSnapshot as Record | undefined).toMatchObject({ + issueId, + taskId: issueId, + retryReason: "issue_continuation_needed", + retryOfRunId: runId, + source: "issue.productive_terminal_continuation_recovery", + }); + }); + it("does not treat a productive terminal run as healthy when in-progress work has no live path", async () => { const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({ status: "in_progress", diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 8cf1c430441..87192d7ad06 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -204,6 +204,7 @@ function isProductiveContinuationRun(latestRun: LatestIssueRun) { function isRepeatedProductiveContinuationRecovery(latestRun: SuccessfulLatestIssueRun) { const latestContext = parseObject(latestRun.contextSnapshot); return readNonEmptyString(latestContext.retryReason) === "issue_continuation_needed" && + readNonEmptyString(latestContext.source) === "issue.productive_terminal_continuation_recovery" && isProductiveContinuationRun(latestRun); }