fix: preserve workspace finalization during watchdog recovery (#16247)

Keep task completion separate from runtime and workspace settlement. Permit verified board file-only repair of successful legacy retained-source runs without authorizing replay.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-10-13 18:35:27 -07:00
1 parent 9cc979116e
commit 5ea3f905cf
13 files changed
+209 -9

No files matched your search

@@ -65,13 +65,15 @@ export async function validateExecutionReconciliation(input: {
review.currentParticipant.agentId === run?.agentId;
const retainedWorkspace = hasRequiredWorkspaceRecovery(run?.resultJson);
const repairOnly = input.workspaceRepairOnly === true && retainedWorkspace;
const terminalRepairSource = ["failed", "interrupted", "timed_out", "cancelled"].includes(run?.status ?? "") ||
(repairOnly && run?.runtimeMode === "legacy" && run.status === "succeeded");
if (
(input.workspaceRepairOnly && !retainedWorkspace) ||
!run ||
!task ||
(!repairOnly && (task.assigneeAgentId !== agentId || (run.agentId !== agentId && !isCurrentReviewer))) ||
(run.nativeIssueId ?? run.contextSnapshot?.issueId) !== issueId ||
!["failed", "interrupted", "timed_out", "cancelled"].includes(run.status)
!terminalRepairSource
) {
throw conflict(
"The recovery source or task owner changed. Inspect the current execution before continuing.",
@@ -80,6 +80,39 @@ const support = external ? { supported: true } : await getEmbeddedPostgresTestSu
await expect(validateExecutionReconciliation({ db, ...f })).resolves.toMatchObject({ id: f.sourceRunId });
});
it("repairs retained files from a succeeded run without reopening or replaying its completed work", async () => {
const f = await seed();
await db.update(heartbeatRuns).set({ status: "succeeded", errorCode: null }).where(eq(heartbeatRuns.id, f.sourceRunId));
await db.update(issues).set({ status: "todo" }).where(eq(issues.id, f.issueId));
const repaired = await validateExecutionReconciliation({ db, ...f, workspaceRepairOnly: true });
const [action] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, f.actionId));
await markExecutionReconciliation(db, action, f.decision, "board", undefined, { workspaceRepairOnly: true });
expect(await getExecutionBlocker(db, f.companyId, f.issueId)).toBeNull();
expect(repaired.status).toBe("succeeded");
const [recorded] = await db.select().from(issueRecoveryActions).where(eq(issueRecoveryActions.id, f.actionId));
expect(recorded.evidence.continuationDelivery).toBe("not_requested");
const [task] = await db.select().from(issues).where(eq(issues.id, f.issueId));
expect(task).toMatchObject({ status: "todo", assigneeAgentId: f.agentId, executionRunId: null });
});
it.each(["running", "queued", "succeeded"])("does not authorize a normal continuation of a %s retained run", async status => {
const f = await seed();
await db.update(heartbeatRuns).set({ status }).where(eq(heartbeatRuns.id, f.sourceRunId));
await expect(validateExecutionReconciliation({ db, ...f })).rejects.toThrow("source or task owner changed");
});
it.each(["running", "queued"])("does not accept file repair while the source run is %s", async status => {
const f = await seed();
await db.update(heartbeatRuns).set({ status }).where(eq(heartbeatRuns.id, f.sourceRunId));
await expect(validateExecutionReconciliation({ db, ...f, workspaceRepairOnly: true })).rejects.toThrow("source or task owner changed");
});
it("does not replace native finalization with legacy success repair", async () => {
const f = await seed();
await db.update(heartbeatRuns).set({ status: "succeeded", runtimeMode: "native" }).where(eq(heartbeatRuns.id, f.sourceRunId));
await expect(validateExecutionReconciliation({ db, ...f, workspaceRepairOnly: true })).rejects.toThrow("source or task owner changed");
});
it.each(["pending_stop", "wrong_receipt", "missing_source", "unrecorded_source", "empty_sources"])("rejects repair with %s even when releasedAt is populated", async scenario => {
const f = await seed();
if (scenario === "pending_stop") {