fix(recovery): block routine disposition continuation

This commit is contained in:
nicls 2026-09-12 09:52:15 +02:00
parent eb9f954bae
commit c1eafb8d9b
2 changed files with 158 additions and 1 deletions

View File

@ -5775,6 +5775,112 @@ describeEmbeddedPostgres("heartbeat orphaned process recovery", () => {
});
});
it("blocks routine execution after successful missing-disposition recovery instead of starting productive continuation", async () => {
const { companyId, agentId, runId, issueId } =
await seedStrandedIssueFixture({
status: "in_progress",
runStatus: "succeeded",
livenessState: "advanced",
});
const sourceRunId = randomUUID();
await db
.update(issues)
.set({
originKind: "routine_execution",
originId: randomUUID(),
})
.where(eq(issues.id, issueId));
await db
.update(heartbeatRuns)
.set({
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: "source_scoped_recovery_action",
recoveryActionId: randomUUID(),
recoveryCause: SUCCESSFUL_RUN_MISSING_STATE_REASON,
sourceRunId,
recoveryIntent: "status_only",
allowDeliverableWork: false,
allowDocumentUpdates: false,
resumeRequiresNormalModel: true,
},
})
.where(eq(heartbeatRuns.id, runId));
const result =
await heartbeatService(db).reconcileStrandedAssignedIssues();
expect(result.continuationRequeued).toBe(0);
expect(result.successfulRunHandoffEscalated).toBe(1);
expect(result.issueIds).toEqual([issueId]);
expect(
await db
.select()
.from(issues)
.where(eq(issues.id, issueId))
.then((rows) => rows[0]?.status),
).toBe("blocked");
await expectSourceScopedStrandedRecoveryAction({
companyId,
agentId,
issueId,
runId,
previousStatus: "in_progress",
retryReason: null,
cause: SUCCESSFUL_RUN_MISSING_STATE_REASON,
kind: "missing_disposition",
});
const runs = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId));
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",
});
await db
.update(issues)
.set({
originKind: "routine_execution",
originId: randomUUID(),
})
.where(eq(issues.id, issueId));
await db
.update(heartbeatRuns)
.set({
contextSnapshot: {
issueId,
taskId: issueId,
wakeReason: "issue_commented",
source: "issue.comment",
},
})
.where(eq(heartbeatRuns.id, runId));
const result =
await heartbeatService(db).reconcileStrandedAssignedIssues();
expect(result.continuationRequeued).toBe(1);
expect(result.successfulRunHandoffEscalated).toBe(0);
const runs = await db
.select()
.from(heartbeatRuns)
.where(eq(heartbeatRuns.agentId, agentId));
expect(runs).toHaveLength(2);
expect(
runs.find((run) => run.id !== runId)?.contextSnapshot,
).toMatchObject({
issueId,
source: "issue.productive_terminal_continuation_recovery",
});
});
it("converts a continuation parked for review into a dependency wait on its open sub-tasks", async () => {
const { companyId, agentId, issueId } = await seedStrandedIssueFixture({
status: "in_progress",

View File

@ -752,6 +752,55 @@ function isExhaustedSuccessfulRunHandoff(latestRun: LatestIssueRun) {
return { ...evidence, exhausted: true };
}
function routineMissingDispositionRecoveryEvidence(
issue: Pick<typeof issues.$inferSelect, "originKind">,
latestRun: LatestIssueRun,
) {
// A status-only recovery may succeed without resolving the routine item.
// Treat that lineage as exhausted so it cannot become productive work.
if (
issue.originKind !== "routine_execution" ||
latestRun?.status !== "succeeded"
)
return null;
const context = parseObject(latestRun.contextSnapshot);
const paperclipWake = parseObject(context.paperclipWake);
const recovery = parseObject(paperclipWake.recovery);
const wakeReason =
readNonEmptyString(context.wakeReason) ??
readNonEmptyString(paperclipWake.reason);
const recoveryCause =
readNonEmptyString(context.recoveryCause) ??
readNonEmptyString(recovery.cause);
const isRecoveryActionRun =
wakeReason === "source_scoped_recovery_action" ||
readNonEmptyString(context.recoveryActionId) !== null;
const isMissingDispositionRecovery =
recoveryCause === SUCCESSFUL_RUN_MISSING_STATE_REASON ||
recoveryCause === "successful_run_missing_issue_disposition";
if (!isRecoveryActionRun || !isMissingDispositionRecovery) return null;
return {
sourceRunId:
readNonEmptyString(context.sourceRunId) ??
readNonEmptyString(context.resumeFromRunId) ??
readNonEmptyString(context.retryOfRunId),
correctiveRunId: latestRun.id,
missingDisposition:
readNonEmptyString(context.missingDisposition) ?? "clear_next_step",
handoffAttempt: Math.max(1, asNumber(context.handoffAttempt, 1)),
maxHandoffAttempts: Math.max(
1,
asNumber(
context.maxHandoffAttempts,
DEFAULT_MAX_SUCCESSFUL_RUN_HANDOFF_ATTEMPTS,
),
),
exhausted: true,
};
}
function issueIdFromRunContext(contextSnapshot: unknown) {
const context = parseObject(contextSnapshot);
return (
@ -4938,7 +4987,9 @@ export function recoveryService(
}
continue;
}
const handoffEvidence = isExhaustedSuccessfulRunHandoff(latestRun);
const handoffEvidence =
isExhaustedSuccessfulRunHandoff(latestRun) ??
routineMissingDispositionRecoveryEvidence(issue, latestRun);
if (handoffEvidence) {
if (isPluginManagedIssueLifecycle(issue)) {
result.skipped += 1;