fix(core): promoter recovery output no longer counts as clean-completion evidence in the failure-provenance guard (#2262)

## 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)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## 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.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

Co-authored-by: Claude Opus <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-16 21:39:49 -07:00
committed by GitHub
parent f116d05c41
commit 46866a5c5a
4 changed files with 111 additions and 5 deletions

View File

@@ -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.

View File

@@ -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 REFUSAL_EXHAUST = "bulk-step-completion-without-review — fn_task_done refusal retry budget exhausted";
const CLEAN_DONE = "Task marked done by agent"; const CLEAN_DONE = "Task marked done by agent";
const IMPLICIT_DONE = "All steps complete — implicit fn_task_done (agent did not call tool explicitly)"; 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", () => { describe("evaluateCompletedPromotionFailureProvenance", () => {
it("blocks when the tail failure marker is the FN-8141 park", () => { it("blocks when the tail failure marker is the FN-8141 park", () => {
@@ -82,6 +84,35 @@ describe("evaluateCompletedPromotionFailureProvenance", () => {
expect(result).toEqual({ blocked: false }); 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", () => { it("does NOT block a task with zero failure markers", () => {
expect( expect(
evaluateCompletedPromotionFailureProvenance({ evaluateCompletedPromotionFailureProvenance({

View File

@@ -27,6 +27,18 @@ export interface CompletedPromotionFailureProvenanceEvaluation {
* either a failure park or a clean completion. A failure that predates a newer clean execution is * 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 * 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. * 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. */ /** 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. * 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): * Sources (packages/engine/src/executor.ts):
* - "Task marked done by agent" (explicit fn_task_done success) * - "Task marked done by agent" (executor.ts ~14939 — accepted explicit fn_task_done; also covers
* - "All steps complete — implicit fn_task_done" (implicit-completion success) * the PREMISE STALE skip-then-done flow, which reaches the same accepted-completion write)
* - "Auto-recovered: task work was complete but stranded" (stranded-completion recovery) * - "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 = [ const CLEAN_COMPLETION_MARKERS = [
"Task marked done by agent", "Task marked done by agent",
"All steps complete — implicit fn_task_done", "All steps complete — implicit fn_task_done",
"Auto-recovered: task work was complete but stranded",
]; ];
function matchesAny(text: string, markers: string[]): boolean { function matchesAny(text: string, markers: string[]): boolean {

View File

@@ -3158,6 +3158,59 @@ describe("SelfHealingManager", () => {
managerWithRecovery.stop(); 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<string>(),
});
(store.listTasks as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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 () => { 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 recoverFn = vi.fn().mockResolvedValue(true);
const managerWithRecovery = new SelfHealingManager(store, { const managerWithRecovery = new SelfHealingManager(store, {