From 646625790853090c247e6e81ed74e94070ea9e79 Mon Sep 17 00:00:00 2001 From: nicls Date: Sat, 12 Sep 2026 10:02:44 +0200 Subject: [PATCH] fix(recovery): preserve fresh routine owner direction --- .../heartbeat-process-recovery.test.ts | 32 ++++++++++++----- server/src/services/recovery/service.ts | 35 +++++++++++++++++-- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/server/src/__tests__/heartbeat-process-recovery.test.ts b/server/src/__tests__/heartbeat-process-recovery.test.ts index 2960c6db06..7fc0be4c81 100644 --- a/server/src/__tests__/heartbeat-process-recovery.test.ts +++ b/server/src/__tests__/heartbeat-process-recovery.test.ts @@ -5838,12 +5838,14 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { expect(runs).toHaveLength(1); }); - it("preserves productive continuation for a routine execution after fresh owner work", async () => { - const { agentId, runId, issueId } = await seedStrandedIssueFixture({ - status: "in_progress", - runStatus: "succeeded", - livenessState: "advanced", - }); + it("preserves productive continuation when fresh owner direction precedes its asynchronous wake", async () => { + const { companyId, agentId, runId, issueId } = + await seedStrandedIssueFixture({ + status: "in_progress", + runStatus: "succeeded", + livenessState: "advanced", + }); + const recoveryRunAt = new Date(Date.now() - 1_000); await db .update(issues) .set({ @@ -5857,11 +5859,25 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => { contextSnapshot: { issueId, taskId: issueId, - wakeReason: "issue_commented", - source: "issue.comment", + wakeReason: "source_scoped_recovery_action", + recoveryActionId: randomUUID(), + recoveryCause: SUCCESSFUL_RUN_MISSING_STATE_REASON, + recoveryIntent: "status_only", + allowDeliverableWork: false, + allowDocumentUpdates: false, + resumeRequiresNormalModel: true, }, + createdAt: recoveryRunAt, }) .where(eq(heartbeatRuns.id, runId)); + await db.insert(issueComments).values({ + companyId, + issueId, + authorType: "user", + authorUserId: "local-board", + body: "Continue with this new owner instruction.", + createdAt: new Date(recoveryRunAt.getTime() + 500), + }); const result = await heartbeatService(db).reconcileStrandedAssignedIssues(); diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 46070204f8..81278a2973 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -6,6 +6,7 @@ import { gt, gte, inArray, + isNotNull, isNull, not, notInArray, @@ -1022,6 +1023,28 @@ export function recoveryService( .then((rows) => rows[0] ?? null); } + async function hasFreshUserDirectionAfterRun( + issue: Pick, + latestRun: NonNullable, + ) { + return db + .select({ id: issueComments.id }) + .from(issueComments) + .where( + and( + eq(issueComments.companyId, issue.companyId), + eq(issueComments.issueId, issue.id), + isNotNull(issueComments.authorUserId), + isNull(issueComments.authorAgentId), + isNull(issueComments.createdByRunId), + isNull(issueComments.deletedAt), + gt(issueComments.createdAt, latestRun.createdAt), + ), + ) + .limit(1) + .then((rows) => Boolean(rows[0])); + } + async function summarizeRecentContinuationRetries( companyId: string, issueId: string, @@ -4987,9 +5010,17 @@ export function recoveryService( } continue; } - const handoffEvidence = - isExhaustedSuccessfulRunHandoff(latestRun) ?? + const exhaustedHandoffEvidence = + isExhaustedSuccessfulRunHandoff(latestRun); + const routineRecoveryEvidence = routineMissingDispositionRecoveryEvidence(issue, latestRun); + const hasFreshUserDirection = + routineRecoveryEvidence && latestRun + ? await hasFreshUserDirectionAfterRun(issue, latestRun) + : false; + const handoffEvidence = + exhaustedHandoffEvidence ?? + (hasFreshUserDirection ? null : routineRecoveryEvidence); if (handoffEvidence) { if (isPluginManagedIssueLifecycle(issue)) { result.skipped += 1;