From 31e218bdb259d730e7a673f7d3d682675b16bcf6 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 1 May 2026 10:33:51 -0500 Subject: [PATCH] Stop repeated productive continuation recovery Co-Authored-By: Paperclip --- .../heartbeat-process-recovery.test.ts | 37 +++++++++++++++++-- server/src/services/recovery/service.ts | 24 ++++++++++++ 2 files changed, 58 insertions(+), 3 deletions(-) diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 0d07e4a38b2..891706357f6 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -2180,11 +2180,10 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(wakeups).toHaveLength(1); }); - it("re-enqueues recovery when the latest automatic continuation made progress but left no live path", async () => { + it("re-enqueues recovery when the latest in-progress continuation made progress but left no live path", async () => { const { agentId, issueId, runId } = await seedStrandedIssueFixture({ status: "in_progress", runStatus: "succeeded", - retryReason: "issue_continuation_needed", livenessState: "advanced", }); const heartbeat = heartbeatService(db); @@ -2220,7 +2219,7 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(wakeups).toHaveLength(2); }); - it("does not treat a productive terminal run as healthy when in-progress work has no live path", async () => { + it("blocks stranded in-progress work after a productive continuation retry was already used", async () => { const { companyId, agentId, issueId, runId } = await seedStrandedIssueFixture({ status: "in_progress", runStatus: "succeeded", @@ -2229,6 +2228,38 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { }); const heartbeat = heartbeatService(db); + const result = await heartbeat.reconcileStrandedAssignedIssues(); + expect(result.continuationRequeued).toBe(0); + expect(result.escalated).toBe(1); + 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("blocked"); + + const recovery = await expectStrandedRecoveryArtifacts({ + companyId, + agentId, + issueId, + runId, + previousStatus: "in_progress", + retryReason: "issue_continuation_needed", + }); + + const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, issueId)); + expect(comments).toHaveLength(1); + expect(comments[0]?.body).toContain("automatically retried continuation"); + expect(comments[0]?.body).toContain("still has no live execution path"); + expect(comments[0]?.body).toContain(`Recovery issue: [${recovery.identifier}]`); + }); + + 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", + runStatus: "succeeded", + livenessState: "advanced", + }); + const heartbeat = heartbeatService(db); + const sourceIssue = await db.select().from(issues).where(eq(issues.id, issueId)).then((rows) => rows[0] ?? null); expect(sourceIssue).toMatchObject({ status: "in_progress", diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 8473a9b3d9c..8cf1c430441 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -201,6 +201,12 @@ function isProductiveContinuationRun(latestRun: LatestIssueRun) { latestRun.livenessState === "needs_followup"); } +function isRepeatedProductiveContinuationRecovery(latestRun: SuccessfulLatestIssueRun) { + const latestContext = parseObject(latestRun.contextSnapshot); + return readNonEmptyString(latestContext.retryReason) === "issue_continuation_needed" && + isProductiveContinuationRun(latestRun); +} + function parseLivenessIncidentKey(incidentKey: string | null | undefined) { if (!incidentKey) return null; return parseIssueGraphLivenessIncidentKey(incidentKey); @@ -1715,6 +1721,24 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) continue; } + if (isRepeatedProductiveContinuationRecovery(successfulRun)) { + const updated = await escalateStrandedAssignedIssue({ + issue, + previousStatus: "in_progress", + latestRun: successfulRun, + comment: + "Paperclip automatically retried continuation for this assigned `in_progress` issue and the retry " + + "made progress, but it still has no live execution path. Moving it to `blocked` so it is visible for intervention.", + }); + if (updated) { + result.escalated += 1; + result.issueIds.push(issue.id); + } else { + result.skipped += 1; + } + continue; + } + if (await isInvocationBudgetBlocked(issue, agentId)) { result.skipped += 1; continue;