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 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({
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
Reference in New Issue
Block a user