fix(recovery): preserve fresh routine owner direction

This commit is contained in:
nicls 2026-09-12 10:02:44 +02:00
parent c1eafb8d9b
commit 6466257908
2 changed files with 57 additions and 10 deletions

View File

@ -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();

View File

@ -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<typeof issues.$inferSelect, "companyId" | "id">,
latestRun: NonNullable<LatestIssueRun>,
) {
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;