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:
7
.changeset/failure-provenance-promoter-marker.md
Normal file
7
.changeset/failure-provenance-promoter-marker.md
Normal 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.
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<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 () => {
|
||||
const recoverFn = vi.fn().mockResolvedValue(true);
|
||||
const managerWithRecovery = new SelfHealingManager(store, {
|
||||
|
||||
Reference in New Issue
Block a user