From 46866a5c5a3eb6b1c020cb3ed552f6bab97f813d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 16 Jul 2026 21:39:49 -0700 Subject: [PATCH] fix(core): promoter recovery output no longer counts as clean-completion evidence in the failure-provenance guard (#2262) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What FN-8141 follow-up 2. Removes `"Auto-recovered: task work was complete but stranded"` from `CLEAN_COMPLETION_MARKERS` in `packages/core/src/completed-promotion-failure-provenance.ts`. Clean-completion evidence is now **execution outcomes only**: `"Task marked done by agent"` (accepted explicit fn_task_done, also covers the PREMISE STALE skip-then-done flow) and `"All steps complete — implicit fn_task_done"` (implicit-completion success). ## Why That string is the PROMOTER'S OWN OUTPUT — self-healing's `recoverCompletedTasks` (executor.ts:4594) narrating "I promoted this task" — not evidence of a clean execution outcome. Any task whose durable log contains a promotion written by the pre-#2257 buggy sweep (the real FN-8141 row, or any pre-guard history) carried a permanent "clean" marker: the tail scan hit the promotion line before the older failure park and returned not-blocked, re-enabling the exact laundering the guard exists to stop. Audit confirmed no other genuine execution-outcome success markers are missing — the PREMISE STALE accepted `fn_task_done` writes the already-listed `"Task marked done by agent"` line (executor.ts:14939), and the honest-blocked exit (`BLOCKED: ...`) is correctly NOT counted. `grep` confirmed the removed string has only one other consumer: its writer at executor.ts:4594. A task already promoted to in-review/done is out of the promoters' todo/in-progress scan scope, so legitimately-recovered old tasks are not wedged (verified by test rather than assumed). ## Test evidence - Core `completed-promotion-failure-provenance.test.ts`: **11 passed** — added pre-fix-history shape (failure park → promoter recovery line → blocked), promoter-line-alone → blocked, and positive coverage of each remaining marker. - Engine `self-healing.test.ts`: **405 passed** — added promoter withholds on the pre-fix-history shape and emits the existing `task:reconcile-stranded-completed-no-action` (reason `failure-provenance`) event. - `pnpm --filter @fusion/engine exec tsc --noEmit`: clean. - `pnpm verify:fast`: PASS (3 steps green, no tests run). 🤖 Generated with [Claude Code](https://claude.com/claude-code) ## Summary by CodeRabbit - **Bug Fixes** - Prevented failed tasks with prior failure history from being automatically promoted. - Ensured recovery messages cannot override authoritative failure records or be mistaken for successful completion. - Preserved the existing no-action behavior and audit event when promotion is blocked by failure provenance. - **Tests** - Added regression coverage for failed-task promotion and stranded-task recovery scenarios. Co-authored-by: Claude Opus --- .../failure-provenance-promoter-marker.md | 7 +++ ...leted-promotion-failure-provenance.test.ts | 31 +++++++++++ .../completed-promotion-failure-provenance.ts | 25 +++++++-- .../engine/src/__tests__/self-healing.test.ts | 53 +++++++++++++++++++ 4 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 .changeset/failure-provenance-promoter-marker.md diff --git a/.changeset/failure-provenance-promoter-marker.md b/.changeset/failure-provenance-promoter-marker.md new file mode 100644 index 0000000000..199587bc71 --- /dev/null +++ b/.changeset/failure-provenance-promoter-marker.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Failed tasks with pre-fix promotion history can no longer auto-promote past the failure-provenance guard. +category: fix +dev: Removed the promoter's own recovery line ("Auto-recovered: task work was complete but stranded") from CLEAN_COMPLETION_MARKERS in completed-promotion-failure-provenance.ts; clean-completion evidence is now execution outcomes only (accepted/implicit fn_task_done). FN-8141 follow-up 2. diff --git a/packages/core/src/__tests__/completed-promotion-failure-provenance.test.ts b/packages/core/src/__tests__/completed-promotion-failure-provenance.test.ts index 8e12c593ea..f3fe7ea5ce 100644 --- a/packages/core/src/__tests__/completed-promotion-failure-provenance.test.ts +++ b/packages/core/src/__tests__/completed-promotion-failure-provenance.test.ts @@ -23,6 +23,8 @@ const FAILURE_PARK = "FN-8141: task parked failed during no-fn_task_done retry const REFUSAL_EXHAUST = "bulk-step-completion-without-review — fn_task_done refusal retry budget exhausted"; const CLEAN_DONE = "Task marked done by agent"; const IMPLICIT_DONE = "All steps complete — implicit fn_task_done (agent did not call tool explicitly)"; +// Promoter's own recovery output (executor.ts recoverCompletedTasks) — NOT an execution outcome. +const PROMOTER_RECOVERY = "Auto-recovered: task work was complete but stranded in todo — moved to in-review"; describe("evaluateCompletedPromotionFailureProvenance", () => { it("blocks when the tail failure marker is the FN-8141 park", () => { @@ -82,6 +84,35 @@ describe("evaluateCompletedPromotionFailureProvenance", () => { expect(result).toEqual({ blocked: false }); }); + /** + * FNXC:Lifecycle 2026-07-16-14:05 (Follow-up 2): the promoter's own recovery line must NOT count + * as clean-completion evidence. A pre-#2257 buggy sweep wrote "Auto-recovered: ... stranded" AFTER + * the honest failure park; the tail scan used to hit that line first and return not-blocked, + * permanently unblocking the guard on any task with pre-fix history and re-enabling FN-8141 + * laundering. The recovery line is now inert, so the older failure park is the authoritative tail. + */ + it("blocks on the pre-fix-history shape: failure park followed by a promoter recovery line", () => { + const result = evaluateCompletedPromotionFailureProvenance({ + log: log([ + REFUSAL_EXHAUST, + FAILURE_PARK, + "Execution paused — session preserved for resume, moved to todo", + // pre-#2257 buggy sweep promoted the stranded row — its own output, not an execution outcome + PROMOTER_RECOVERY, + ]), + }); + expect(result.blocked).toBe(true); + expect(result.reason).toBe("failure-provenance"); + expect(result.markerAction).toContain("task parked failed during no-fn_task_done retry"); + }); + + it("does NOT treat the promoter recovery line as a clean marker on its own", () => { + // A promotion line with no execution-outcome marker leaves the failure park authoritative. + expect( + evaluateCompletedPromotionFailureProvenance({ log: log([FAILURE_PARK, PROMOTER_RECOVERY]) }).blocked, + ).toBe(true); + }); + it("does NOT block a task with zero failure markers", () => { expect( evaluateCompletedPromotionFailureProvenance({ diff --git a/packages/core/src/completed-promotion-failure-provenance.ts b/packages/core/src/completed-promotion-failure-provenance.ts index fd78c33587..e79de5c1c5 100644 --- a/packages/core/src/completed-promotion-failure-provenance.ts +++ b/packages/core/src/completed-promotion-failure-provenance.ts @@ -27,6 +27,18 @@ export interface CompletedPromotionFailureProvenanceEvaluation { * either a failure park or a clean completion. A failure that predates a newer clean execution is * never reached — the completion marker decides first. A task with zero failure markers is never * blocked. The scan is bounded to the tail so these per-housekeeping-cycle sweeps stay cheap. + * + * FNXC:Lifecycle 2026-07-16-14:05: + * CLEAN_COMPLETION_MARKERS must be EXECUTION outcomes only — a log line the agent's execution + * lifecycle wrote when it genuinely completed the work (an accepted fn_task_done, or the implicit + * all-steps-done completion). The PROMOTER'S OWN OUTPUT is never evidence: the stranded-completed + * recovery line "Auto-recovered: task work was complete but stranded ..." (executor.ts + * recoverCompletedTasks) is self-healing narrating "I promoted this", not proof the execution ended + * cleanly. Counting it as a clean marker let any task carrying a promotion written by the pre-#2257 + * buggy sweep (e.g. the real FN-8141 row, or any pre-guard history) permanently unblock the guard — + * the tail scan hit the promotion line before the older failure park and returned not-blocked, + * re-enabling the exact laundering this guard exists to stop. Removed for that reason. Likewise the + * honest-blocked exit ("BLOCKED: ...") is NOT a clean completion and must never be a marker. */ /** Bound the tail scan; sweeps run every housekeeping cycle over many tasks. */ @@ -48,17 +60,20 @@ const FAILURE_PARK_MARKERS = [ ]; /** - * Log-action substrings that mark a fresh clean execution outcome. A clean completion appearing + * Log-action substrings that mark a fresh clean EXECUTION outcome. A clean completion appearing * MORE RECENTLY than a failure park proves the failing lifecycle was superseded by a good one. + * These are execution-lifecycle outcomes ONLY — never promoter/recovery output (see the header + * FNXC note; "Auto-recovered: task work was complete but stranded" was removed because it is the + * promoter narrating its own move, not proof the execution ended cleanly). * Sources (packages/engine/src/executor.ts): - * - "Task marked done by agent" (explicit fn_task_done success) - * - "All steps complete — implicit fn_task_done" (implicit-completion success) - * - "Auto-recovered: task work was complete but stranded" (stranded-completion recovery) + * - "Task marked done by agent" (executor.ts ~14939 — accepted explicit fn_task_done; also covers + * the PREMISE STALE skip-then-done flow, which reaches the same accepted-completion write) + * - "All steps complete — implicit fn_task_done" (executor.ts ~12095/~12402 — implicit-completion + * success when all steps are done without an explicit tool call) */ const CLEAN_COMPLETION_MARKERS = [ "Task marked done by agent", "All steps complete — implicit fn_task_done", - "Auto-recovered: task work was complete but stranded", ]; function matchesAny(text: string, markers: string[]): boolean { diff --git a/packages/engine/src/__tests__/self-healing.test.ts b/packages/engine/src/__tests__/self-healing.test.ts index 9130955b93..e36e66913a 100644 --- a/packages/engine/src/__tests__/self-healing.test.ts +++ b/packages/engine/src/__tests__/self-healing.test.ts @@ -3158,6 +3158,59 @@ describe("SelfHealingManager", () => { managerWithRecovery.stop(); }); + /* + FNXC:Lifecycle 2026-07-16-14:05 (Follow-up 2): + FN-8141 pre-fix history — a task whose durable log carries a promoter-written recovery line + ("Auto-recovered: task work was complete but stranded ...") AFTER the honest failure park (as the + pre-#2257 buggy sweep produced on the real FN-8141 row) must STILL be withheld. That line is the + promoter narrating its own move, not an execution outcome, so it is no longer a clean-completion + marker; the older failure park stays authoritative and the promoter withholds + emits the + existing no-action event. Without this the tail scan hit the recovery line first, returned + not-blocked, and re-enabled the exact laundering the guard exists to stop. + */ + it("FN-8141: withholds a stranded-todo task whose only post-failure log entry is a promoter recovery line (pre-fix history)", async () => { + const recoverFn = vi.fn().mockResolvedValue(true); + const managerWithRecovery = new SelfHealingManager(store, { + rootDir: "/tmp/test-project", + recoverCompletedTask: recoverFn, + getExecutingTaskIds: () => new Set(), + }); + + (store.listTasks as ReturnType).mockResolvedValue([ + { + id: "FN-8141", + column: "todo", + paused: false, + error: null, + reviewLevel: 2, + steps: [{ status: "done" }, { status: "done" }, { status: "done" }], + }, + ]); + // Log tail: failure park followed by the promoter's OWN recovery narration (pre-#2257 sweep). + (store.getTask as ReturnType).mockResolvedValue({ + id: "FN-8141", + lineageId: "lin-8141", + log: [ + { timestamp: "2026-07-16T10:00:02.000Z", action: "bulk-step-completion-without-review — fn_task_done refusal retry budget exhausted" }, + { timestamp: "2026-07-16T10:00:03.000Z", action: "FN-8141: task parked failed during no-fn_task_done retry — honoring park, not retrying" }, + { timestamp: "2026-07-16T10:00:04.000Z", action: "Auto-recovered: task work was complete but stranded in todo — moved to in-review" }, + ], + }); + + const result = await managerWithRecovery.recoverStrandedCompletedTodoTasks(); + + expect(result).toBe(0); + expect(recoverFn).not.toHaveBeenCalled(); + const noActionEvents = (store.recordRunAuditEvent as ReturnType).mock.calls.filter( + ([ev]) => + (ev as { mutationType?: string }).mutationType === "task:reconcile-stranded-completed-no-action" && + (ev as { metadata?: { reason?: string } }).metadata?.reason === "failure-provenance", + ); + expect(noActionEvents).toHaveLength(1); + + managerWithRecovery.stop(); + }); + it("FN-8141: DOES promote the same task once a fresh clean execution completes all steps after the failure park", async () => { const recoverFn = vi.fn().mockResolvedValue(true); const managerWithRecovery = new SelfHealingManager(store, {