fix(engine): stranded-completed promoters withhold tasks whose last execution ended in a failure park (#2257)

## What & why

FN-8141 laundered a failed task into `done`. The executor correctly
parked the task `failed` ("task parked failed during no-fn_task_done
retry" / "fn_task_done refusal retry budget exhausted"), the pause-abort
machinery bounced it to `todo`, and ~12 minutes later
`recoverStrandedCompletedTodoTasks` promoted it to `in-review` because
every step was done/skipped — overriding the honest failure park. From
there the AI merger found an empty diff and finalized it as a no-op
`done`, with no reviewer ever seeing it.

Existing exclusions (`task.error`,
`evaluateNoCommitsNoOpFinalize().blocked`, active statuses, refreshing
review state) all missed it because the failure provenance lived **only
in the durable task log** by the time the promoter ran — status/error
had been cleared by the pause-abort bounce.

This PR restores the invariant: **a stranded-completed promoter must not
promote a task whose most recent execution lifecycle ended in a
failure/refusal park.**

## Change

- New pure, unit-testable evaluator
`evaluateCompletedPromotionFailureProvenance(task)` in `@fusion/core`
(next to `no-commits-finalize-guard.ts`). It scans the task-log **tail**
(bounded to 250 entries) and lets the **most-recent execution-outcome
marker** decide: a failure/refusal park → `{ blocked: true, reason:
"failure-provenance" }`; a fresh clean completion (`Task marked done by
agent` / `All steps complete — implicit fn_task_done`) that appears more
recently supersedes an earlier park; zero failure markers → not blocked.
Recency is by construction, so a failure that predates a newer clean
execution is never reached.
- Both self-healing sweeps (`recoverCompletedTasks` stuck-in-progress
**and** `recoverStrandedCompletedTodoTasks` stranded-todo) fetch the
full task for candidates that already cleared the cheap slim filters
(slim listings strip `log`) and skip when blocked, emitting a
**deduped** `task:reconcile-stranded-completed-no-action` run-audit
event (ids/outcomes-only: `taskId`, `reason`, `sweep`, `marker?`).
- Defense-in-depth: the shared executor `recoverCompletedTask`
chokepoint — which the sweeps AND the executor's own
unpause/`resumeOrphaned` fast-paths all funnel through — also refuses a
provenance-blocked promotion, so no route can launder a failed park.

**Escape hatch (documented in FNXC comments):** an operator
retrying/moving the task starts a fresh execution whose clean-completion
marker supersedes the failure park, clearing the block with no code
change.

## Surface enumeration

- `recoverCompletedTasks` (stuck-in-progress sweep, self-healing.ts) —
guarded + audited.
- `recoverStrandedCompletedTodoTasks` (stranded-todo sweep,
self-healing.ts) — guarded + audited. FN-8141 shows both columns can
launder.
- `recoverCompletedTask` executor callback (the route both sweeps +
unpause + `resumeOrphaned` share) — verified it did **not** check
log-based provenance; added the guard there as the final chokepoint.

## Test evidence

Pure-evaluator unit tests (`@fusion/core`) — marker detection,
most-recent-outcome recency, supersede-by-clean-completion,
empty/missing log, tail-scan bound:
```
pnpm --filter @fusion/core exec vitest run src/__tests__/completed-promotion-failure-provenance.test.ts
  Test Files  1 passed (1)   Tests  9 passed (9)
```

Self-healing integration tests (`@fusion/engine`) — FN-8141-shaped todo
(3 done + 2 skipped + refusal-exhaust/park marker) is NOT promoted and
emits the no-action event exactly once (deduped across a second cycle);
same task after a fresh clean execution IS promoted; stuck-in-progress
variant covered:
```
pnpm --filter @fusion/engine exec vitest run src/__tests__/self-healing.test.ts -t "recoverCompletedTasks|recoverStrandedCompletedTodoTasks|FN-8141"
  Test Files  1 passed (1)   Tests  14 passed | 382 skipped (396)
```

`@fusion/core` builds clean. My engine changes add **zero** new type
errors (verified: all 13 engine build errors are the pre-existing pi-SDK
cluster in `auth-storage.ts`/`pi.ts`/`provider-registration.ts`, none in
`self-healing.ts`/`run-audit.ts`/`executor.ts`/the new file).

## Known environmental blocker

`pnpm verify:fast` cannot go green on this branch: the `@fusion/engine`
build is **already broken at baseline** (confirmed by stashing all my
changes) by the pi 0.80.x SDK migration errors
(`ModelRegistry`/`AuthStorage`/`ModelRuntime`) — the exact FN-8145
upstream breakage described in the FN-8141 incident. That is out of
scope for this task and independent of this diff. Likewise, the 22
pre-existing
`restart.integration.test.ts`/`executor-fast-mode-workflows.test.ts`
failures are identical with and without my changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-16 19:36:57 -07:00
committed by GitHub
parent 1c4fdb7b59
commit 136958fc60
8 changed files with 477 additions and 3 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Self-healing no longer promotes a failed/refused task into review after its work was reverted.
category: fix
dev: FN-8141 — new pure evaluator `evaluateCompletedPromotionFailureProvenance` (@fusion/core) reads the durable task log tail; both stranded-completed promoters (`recoverCompletedTasks` stuck-in-progress and `recoverStrandedCompletedTodoTasks` stranded-todo) and the shared `recoverCompletedTask` chokepoint now withhold promotion when the most recent execution-outcome was a failure/refusal park, emitting a deduped `task:reconcile-stranded-completed-no-action` run-audit event (reason `failure-provenance`). A fresh clean execution (operator retry) supersedes the park.

View File

@@ -0,0 +1,121 @@
import { describe, expect, it } from "vitest";
import { evaluateCompletedPromotionFailureProvenance, type TaskLogEntry } from "../index.js";
/**
* FNXC:Lifecycle 2026-07-16-10:30:
* FN-8141 invariant: a stranded-completed promotion candidate whose MOST RECENT execution-outcome
* in the durable task log was a failure/refusal park must be blocked; a failure superseded by a
* newer clean completion, or a task with zero failure markers, must not be blocked.
*/
let seq = 0;
function entry(action: string): TaskLogEntry {
// Monotonic timestamps keep log order deterministic without relying on Date.now().
seq += 1;
return { timestamp: `2026-07-16T10:00:${String(seq).padStart(2, "0")}.000Z`, action };
}
function log(actions: string[]): TaskLogEntry[] {
return actions.map(entry);
}
const FAILURE_PARK = "FN-8141: task parked failed during no-fn_task_done retry — honoring park, not retrying";
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)";
describe("evaluateCompletedPromotionFailureProvenance", () => {
it("blocks when the tail failure marker is the FN-8141 park", () => {
const result = evaluateCompletedPromotionFailureProvenance({
log: log([
"Starting execution",
IMPLICIT_DONE,
REFUSAL_EXHAUST,
FAILURE_PARK,
// pause-abort bounce to todo (not an execution-outcome marker)
"Execution paused — session preserved for resume, moved to todo",
]),
});
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("blocks on the refusal-budget-exhaust marker even without the terminal park line", () => {
const result = evaluateCompletedPromotionFailureProvenance({
log: log([IMPLICIT_DONE, REFUSAL_EXHAUST]),
});
expect(result).toMatchObject({ blocked: true, reason: "failure-provenance" });
});
it("blocks on the retry-budget and implicit-refusal failure variants", () => {
expect(
evaluateCompletedPromotionFailureProvenance({
log: log(["boom — execution failed after task-done retry budget was exhausted"]),
}).blocked,
).toBe(true);
expect(
evaluateCompletedPromotionFailureProvenance({
log: log(["nope — execution failed because implicit fn_task_done was refused"]),
}).blocked,
).toBe(true);
});
it("does NOT block when a fresh clean completion supersedes an earlier failure park", () => {
const result = evaluateCompletedPromotionFailureProvenance({
log: log([
REFUSAL_EXHAUST,
FAILURE_PARK,
"Execution paused — session preserved for resume, moved to todo",
// operator retried → fresh execution completed all steps cleanly
"Resuming execution after unpause",
CLEAN_DONE,
]),
});
expect(result).toEqual({ blocked: false });
});
it("does NOT block when the clean completion is the implicit-done variant", () => {
const result = evaluateCompletedPromotionFailureProvenance({
log: log([FAILURE_PARK, IMPLICIT_DONE]),
});
expect(result).toEqual({ blocked: false });
});
it("does NOT block a task with zero failure markers", () => {
expect(
evaluateCompletedPromotionFailureProvenance({
log: log(["Starting execution", CLEAN_DONE]),
}),
).toEqual({ blocked: false });
});
it("does NOT block an empty or missing log", () => {
expect(evaluateCompletedPromotionFailureProvenance({ log: [] })).toEqual({ blocked: false });
expect(
evaluateCompletedPromotionFailureProvenance({ log: undefined as unknown as TaskLogEntry[] }),
).toEqual({ blocked: false });
});
it("treats the MOST RECENT outcome as authoritative regardless of earlier markers", () => {
// failure → clean → failure again: the tail failure wins.
expect(
evaluateCompletedPromotionFailureProvenance({
log: log([FAILURE_PARK, CLEAN_DONE, REFUSAL_EXHAUST]),
}).blocked,
).toBe(true);
// clean → failure → clean: the tail clean wins.
expect(
evaluateCompletedPromotionFailureProvenance({
log: log([CLEAN_DONE, REFUSAL_EXHAUST, CLEAN_DONE]),
}).blocked,
).toBe(false);
});
it("bounds the scan to the tail: an ancient failure beyond the window is not reached", () => {
// One failure marker followed by >250 benign, non-outcome entries: the scan window never
// reaches the failure, so no failure provenance is detected (not blocked).
const ancientFailure = [FAILURE_PARK, ...Array.from({ length: 300 }, (_v, i) => `heartbeat ${i}`)];
expect(evaluateCompletedPromotionFailureProvenance({ log: log(ancientFailure) }).blocked).toBe(false);
});
});

View File

@@ -0,0 +1,90 @@
import type { Task } from "./types.js";
export interface CompletedPromotionFailureProvenanceEvaluation {
/** True when the task's current execution lifecycle ended in a failure/refusal park. */
blocked: boolean;
/** Stable reason code for the no-action run-audit event. */
reason?: "failure-provenance";
/** The blocking failure-marker log action text (diagnostics only). */
markerAction?: string;
}
/**
* FNXC:Lifecycle 2026-07-16-10:30:
* FN-8141 laundered a failed task into `done`: the executor parked the task `failed`
* ("task parked failed during no-fn_task_done retry" / "fn_task_done refusal retry budget exhausted"),
* the pause-abort machinery bounced it to `todo`, and 12 minutes later the stranded-completed
* promoters (`recoverStrandedCompletedTodoTasks` / `recoverCompletedTasks` in self-healing.ts)
* moved it to `in-review` because every step was done/skipped — overriding the honest failure park.
*
* Invariant restored: a stranded-completed promoter must NOT promote a task whose MOST RECENT
* execution-outcome in the durable task log was a failure/refusal park. The escape hatch stays
* intact because an operator retrying/moving the task produces a fresh execution that logs a clean
* completion marker ("Task marked done by agent" / "All steps complete — implicit fn_task_done"),
* which is more recent than the failure marker and therefore supersedes it.
*
* Recency by construction: we scan the log tail and stop at the FIRST (most recent) entry that 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
* blocked. The scan is bounded to the tail so these per-housekeeping-cycle sweeps stay cheap.
*/
/** Bound the tail scan; sweeps run every housekeeping cycle over many tasks. */
const MAX_LOG_SCAN = 250;
/**
* Log-action substrings that mark the current execution lifecycle ending in a failure/refusal park.
* Sources (packages/engine/src/executor.ts):
* - "task parked failed during no-fn_task_done retry" (FN-7965 terminal park honoring)
* - "fn_task_done refusal retry budget exhausted" (explicit fn_task_done refusal exhaustion)
* - "execution failed after task-done retry budget was exhausted" (retry-budget failure)
* - "execution failed because implicit fn_task_done was refused" (implicit-completion refusal)
*/
const FAILURE_PARK_MARKERS = [
"task parked failed during no-fn_task_done retry",
"fn_task_done refusal retry budget exhausted",
"execution failed after task-done retry budget was exhausted",
"execution failed because implicit fn_task_done was refused",
];
/**
* 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.
* 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)
*/
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 {
return markers.some((marker) => text.includes(marker));
}
/**
* Evaluate whether a stranded-completed promotion candidate should be withheld because its current
* execution lifecycle ended in a failure/refusal park. Pure and unit-testable; both self-healing
* sweeps share it. Requires the full task `log` (slim listings strip it, so promoters must fetch the
* full task for candidates before calling this).
*/
export function evaluateCompletedPromotionFailureProvenance(
task: Pick<Task, "log">,
): CompletedPromotionFailureProvenanceEvaluation {
const log = task.log ?? [];
// Walk from the tail so the most recent execution-outcome marker decides. Cap the scan window.
const scanFloor = Math.max(0, log.length - MAX_LOG_SCAN);
for (let i = log.length - 1; i >= scanFloor; i--) {
const action = log[i]?.action ?? "";
if (matchesAny(action, FAILURE_PARK_MARKERS)) {
return { blocked: true, reason: "failure-provenance", markerAction: action };
}
if (matchesAny(action, CLEAN_COMPLETION_MARKERS)) {
return { blocked: false };
}
}
return { blocked: false };
}

View File

@@ -723,6 +723,8 @@ export {
} from "./no-op-completion-marker.js";
export { evaluateNoCommitsNoOpFinalize } from "./no-commits-finalize-guard.js";
export type { NoCommitsNoOpFinalizeEvaluation } from "./no-commits-finalize-guard.js";
export { evaluateCompletedPromotionFailureProvenance } from "./completed-promotion-failure-provenance.js";
export type { CompletedPromotionFailureProvenanceEvaluation } from "./completed-promotion-failure-provenance.js";
export {
__getDeterministicGuardMutexSize,
deterministicGuardLocks,

View File

@@ -2408,6 +2408,51 @@ describe("SelfHealingManager", () => {
managerWithRecovery.stop();
});
/*
FNXC:Lifecycle 2026-07-16-10:30:
FN-8141 — the same laundering can start from the in-progress column. The stuck-in-progress
promoter must also withhold an all-steps-done/skipped task whose most recent execution ended in a
failure/refusal park, and emit `task:reconcile-stranded-completed-no-action` (sweep stuck-in-progress).
*/
it("FN-8141: does NOT promote a stuck-in-progress task whose last execution ended in a failure park", 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-IP",
column: "in-progress",
paused: false,
steps: [{ status: "done" }, { status: "done" }, { status: "skipped" }],
},
]);
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "FN-8141-IP",
lineageId: "lin-8141-ip",
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-IP: task parked failed during no-fn_task_done retry — honoring park, not retrying" },
],
});
const result = await managerWithRecovery.recoverCompletedTasks();
expect(result).toBe(0);
expect(recoverFn).not.toHaveBeenCalled();
const emitted = (store.recordRunAuditEvent as ReturnType<typeof vi.fn>).mock.calls.some(
([ev]) =>
(ev as { mutationType?: string }).mutationType === "task:reconcile-stranded-completed-no-action" &&
(ev as { metadata?: { sweep?: string } }).metadata?.sweep === "stuck-in-progress",
);
expect(emitted).toBe(true);
managerWithRecovery.stop();
});
it("skips tasks that are actively executing", async () => {
const recoverFn = vi.fn().mockResolvedValue(true);
const getExecuting = vi.fn().mockReturnValue(new Set(["FN-001"]));
@@ -3017,6 +3062,107 @@ describe("SelfHealingManager", () => {
managerWithRecovery.stop();
});
/*
FNXC:Lifecycle 2026-07-16-10:30:
FN-8141 — the stranded-todo promoter must not launder a failed task into in-review. A candidate
with all steps done/skipped whose MOST RECENT durable-log execution-outcome is a failure/refusal
park is withheld and emits `task:reconcile-stranded-completed-no-action` (reason failure-provenance)
once; the same task after a fresh clean execution IS promoted; the escape hatch is operator retry.
*/
it("FN-8141: does NOT promote a stranded-todo task whose last execution ended in a failure park, and emits the no-action event once", async () => {
const recoverFn = vi.fn().mockResolvedValue(true);
const managerWithRecovery = new SelfHealingManager(store, {
rootDir: "/tmp/test-project",
recoverCompletedTask: recoverFn,
getExecutingTaskIds: () => new Set<string>(),
});
// Slim board row: 3 done + 2 skipped, no error/active status (exactly the FN-8141 shape that
// passed all existing exclusions).
(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" }, { status: "skipped" }, { status: "skipped" }],
},
]);
// Full task carries the durable failure-park provenance the slim row cannot.
(store.getTask as ReturnType<typeof vi.fn>).mockResolvedValue({
id: "FN-8141",
lineageId: "lin-8141",
log: [
{ timestamp: "2026-07-16T10:00:01.000Z", action: "All steps complete — implicit fn_task_done (agent did not call tool explicitly)" },
{ 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: "Execution paused — session preserved for resume, moved to todo" },
],
});
const result = await managerWithRecovery.recoverStrandedCompletedTodoTasks();
expect(result).toBe(0);
expect(recoverFn).not.toHaveBeenCalled();
const auditCalls = (store.recordRunAuditEvent as ReturnType<typeof vi.fn>).mock.calls;
const noActionEvents = auditCalls.filter(
([ev]) =>
(ev as { mutationType?: string }).mutationType === "task:reconcile-stranded-completed-no-action" &&
(ev as { metadata?: { reason?: string; sweep?: string } }).metadata?.reason === "failure-provenance" &&
(ev as { metadata?: { sweep?: string } }).metadata?.sweep === "stranded-todo",
);
expect(noActionEvents).toHaveLength(1);
// Deduped: a second sweep with the same unchanged provenance does not re-emit.
await managerWithRecovery.recoverStrandedCompletedTodoTasks();
const noActionEventsAfter = (store.recordRunAuditEvent as ReturnType<typeof vi.fn>).mock.calls.filter(
([ev]) => (ev as { mutationType?: string }).mutationType === "task:reconcile-stranded-completed-no-action",
);
expect(noActionEventsAfter).toHaveLength(1);
expect(recoverFn).not.toHaveBeenCalled();
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, {
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: the old failure park is followed by a fresh clean completion (operator retry escape hatch).
(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: "Resuming execution after unpause" },
{ timestamp: "2026-07-16T10:00:05.000Z", action: "Task marked done by agent" },
],
});
const result = await managerWithRecovery.recoverStrandedCompletedTodoTasks();
expect(result).toBe(1);
expect(recoverFn).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-8141" }));
managerWithRecovery.stop();
});
});
describe("recoverMissingWorktreeReviewFailures", () => {

View File

@@ -14,7 +14,7 @@ import { existsSync, lstatSync, realpathSync } from "node:fs";
import { readFile, rm, writeFile } from "node:fs/promises";
import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, AsyncMissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent, AgentMemoryInclusionMode, ProjectSettings, MergeResult, WorkflowIrNode, WorkflowIrNodeKind, WorkflowStepResult as CoreWorkflowStepResult, ThinkingLevel } from "@fusion/core";
import { getUnmetSchedulingDependencies } from "./scheduler.js";
import { RetryStormError, serializeRetryStormError, isExperimentalFeatureEnabled, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveMaxConsecutiveToolFailureRetries, resolveConsecutiveToolFailureRetryBackoffMs, resolveConsecutiveToolFailureThreshold, resolveExecutorEscalationTarget, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, COMPLETION_SUMMARY_NODE_ID, upsertWorkflowStepResult, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS, AgentStore, resolveExecutorFallbackModel } from "@fusion/core";
import { RetryStormError, serializeRetryStormError, isExperimentalFeatureEnabled, evaluateCompletedPromotionFailureProvenance, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveMaxConsecutiveToolFailureRetries, resolveConsecutiveToolFailureRetryBackoffMs, resolveConsecutiveToolFailureThreshold, resolveExecutorEscalationTarget, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, COMPLETION_SUMMARY_NODE_ID, upsertWorkflowStepResult, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS, AgentStore, resolveExecutorFallbackModel } from "@fusion/core";
import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js";
import { mergeEffectiveSettings } from "./effective-settings.js";
import { moveTaskToReplanColumn, resolveReplanTargetColumn } from "./replan-target.js";
@@ -4422,6 +4422,23 @@ export class TaskExecutor {
return false;
}
/*
FNXC:Lifecycle 2026-07-16-10:30:
FN-8141 defense-in-depth: recoverCompletedTask is the shared promotion chokepoint for BOTH
self-healing sweeps AND the executor's own unpause / resumeOrphaned fast-paths. A task whose
most recent execution-outcome in the durable log was a failure/refusal park must not be
promoted to in-review by ANY route, even one that re-derived completion from all-steps-done/
skipped (skipped counts as complete, which is exactly how FN-8141 laundered a failed task).
The self-healing sweeps additionally emit the deduped no-action audit event; here we simply
refuse. Escape hatch: an operator retrying the task starts a fresh execution whose clean
completion marker supersedes the failure park, clearing this block with no code change.
*/
const failureProvenance = evaluateCompletedPromotionFailureProvenance(liveForCompletenessCheck ?? task);
if (failureProvenance.blocked) {
executorLog.log(`${task.id}: skipping recoverCompletedTask — most recent execution ended in a failure/refusal park (operator-decides)`);
return false;
}
const settings = await this.store.getSettings();
if (settings.globalPause || settings.enginePaused) {
executorLog.log(

View File

@@ -807,7 +807,17 @@ export type DatabaseMutationType =
* withheld state persists unchanged.
* Metadata: { taskId: string; reason: "user-paused" | "auto-merge-off-human-review"; stage?: string; oversightLevel?: string }
*/
| "overseer:oversight-withheld-human-control";
| "overseer:oversight-withheld-human-control"
/**
* FNXC:Lifecycle 2026-07-16-10:30:
* FN-8141 no-action event: a stranded-completed promoter (`recoverCompletedTasks` stuck-in-progress
* sweep OR `recoverStrandedCompletedTodoTasks` stranded-todo sweep in self-healing.ts) withheld
* promotion of an all-steps-done/skipped task because its most recent execution-outcome in the
* durable task log was a failure/refusal park (`evaluateCompletedPromotionFailureProvenance`).
* Emitted at most once per taskId while the blocking provenance persists (deduped in-memory).
* Metadata: { taskId, reason: "failure-provenance", sweep: "stuck-in-progress" | "stranded-todo", marker?: string }
*/
| "task:reconcile-stranded-completed-no-action";
// ── Filesystem mutation types ─────────────────────────────────────────────────

View File

@@ -30,7 +30,7 @@ import { setImmediate as setImmediateCb } from "node:timers";
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { isAbsolute, join, relative, resolve } from "node:path";
import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkflowColumnsEnabled, isWorkspaceTask, isSharedBranchGroupMemberIntegration, parseExplicitDuplicateMarker, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, resolveWorkflowIrForTask, AWAITING_APPROVAL_PAUSE_REASON, type Agent, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult, type WorkflowStepResult } from "@fusion/core";
import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, evaluateCompletedPromotionFailureProvenance, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkflowColumnsEnabled, isWorkspaceTask, isSharedBranchGroupMemberIntegration, parseExplicitDuplicateMarker, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, resolveWorkflowIrForTask, AWAITING_APPROVAL_PAUSE_REASON, type Agent, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult, type WorkflowStepResult } from "@fusion/core";
import type { MeshLeaseManager } from "./mesh-lease-manager.js";
import { createLogger, schedulerLog } from "./logger.js";
import { mergeEffectiveSettings } from "./effective-settings.js";
@@ -743,6 +743,14 @@ export class SelfHealingManager {
private workspacePartialLandDrops: Map<string, number> = new Map();
private orphanWorktreeRemovalFailures: Map<string, number> = new Map();
private finalizeUnprovenWarned = new Set<string>();
/*
* FNXC:Lifecycle 2026-07-16-10:30:
* FN-8141 dedup: `task:reconcile-stranded-completed-no-action` is emitted at most once per taskId
* while a failure-provenance block persists, so the sweeps don't spam run-audit every housekeeping
* cycle. Cleared on stop(); a fresh clean execution that clears the block re-arms emission naturally
* because the task leaves the promotable state before returning to it.
*/
private strandedCompletedFailureProvenanceWarned = new Set<string>();
private metaResolvedSkipAuditMemo = new Map<string, string>();
private metaStalledSkipAuditMemo = new Map<string, string>();
private preservedQueuedOverlapLogged = new Map<string, string>();
@@ -1045,6 +1053,68 @@ export class SelfHealingManager {
log.log(`[${stage}] ${task.id}: triple-proof not satisfied — no action (operator-decides)`);
}
/*
* FNXC:Lifecycle 2026-07-16-10:30:
* FN-8141 — a stranded-completed promoter must respect failure provenance. Before promoting an
* all-steps-done/skipped candidate to in-review, verify its MOST RECENT execution-outcome in the
* durable task log was not a failure/refusal park; a park bounced to `todo`/`in-progress` by the
* pause-abort machinery must not be laundered into `in-review`. Slim listings strip `log`, so the
* full task is fetched only for candidates that already cleared the cheap step/status filters.
* On block, emits `task:reconcile-stranded-completed-no-action` once per taskId (deduped) and
* withholds promotion. Escape hatch: an operator retrying/moving the task starts a fresh execution
* whose clean completion marker supersedes the failure park, clearing the block with no code change.
* Fail-closed: if the full task cannot be read this cycle, withhold promotion rather than risk
* laundering a failed park.
*/
private async isStrandedCompletedPromotionBlockedByFailureProvenance(
taskId: string,
sweep: "stuck-in-progress" | "stranded-todo",
): Promise<boolean> {
let fullTask: Task;
try {
fullTask = await this.store.getTask(taskId);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.warn(`[stranded-completed-provenance] ${taskId}: full-task fetch failed (${message}) — withholding ${sweep} promotion this cycle`);
return true;
}
const evaluation = evaluateCompletedPromotionFailureProvenance(fullTask);
if (!evaluation.blocked) {
// Provenance cleared (fresh clean execution or never-failed): re-arm the dedup so a later
// re-failure is reported again.
this.strandedCompletedFailureProvenanceWarned.delete(taskId);
return false;
}
if (!this.strandedCompletedFailureProvenanceWarned.has(taskId)) {
this.strandedCompletedFailureProvenanceWarned.add(taskId);
try {
await createRunAuditor(this.store, {
runId: generateSyntheticRunId("self-healing-stranded-completed-provenance", taskId),
agentId: "self-healing",
taskId,
taskLineageId: fullTask.lineageId,
phase: "stranded-completed-provenance",
}).database({
type: "task:reconcile-stranded-completed-no-action" as DatabaseMutationType,
target: taskId,
metadata: {
taskId,
reason: evaluation.reason ?? "failure-provenance",
sweep,
...(evaluation.markerAction ? { marker: evaluation.markerAction } : {}),
},
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log.warn(`[stranded-completed-provenance] ${taskId}: no-action audit emission failed: ${message}`);
}
}
log.log(`[stranded-completed-provenance] ${taskId}: withholding ${sweep} promotion — most recent execution ended in a failure/refusal park (operator-decides)`);
return true;
}
private async listActiveHeartbeatTaskIds(): Promise<Set<string>> {
const activeTaskIds = new Set<string>();
if (!this.options.agentStore) {
@@ -1409,6 +1479,7 @@ export class SelfHealingManager {
}
this.finalizeUnprovenWarned.clear();
this.strandedCompletedFailureProvenanceWarned.clear();
this.metaResolvedSkipAuditMemo.clear();
this.metaStalledSkipAuditMemo.clear();
this.preservedQueuedOverlapLogged.clear();
@@ -2810,6 +2881,11 @@ export class SelfHealingManager {
log.log(`${task.id} started executing concurrently — skipping recovery this cycle`);
continue;
}
// FN-8141: never promote a stuck-in-progress task whose most recent execution ended in a
// failure/refusal park — honor the honest failure instead of laundering it into in-review.
if (await this.isStrandedCompletedPromotionBlockedByFailureProvenance(task.id, "stuck-in-progress")) {
continue;
}
log.log(`Recovering completed task ${task.id}: ${task.title || task.description?.slice(0, 60) || "(untitled)"}`);
const success = await recoverFn(task);
if (success) recovered++;
@@ -2866,6 +2942,11 @@ export class SelfHealingManager {
log.log(`${task.id} started executing concurrently — skipping stranded todo recovery this cycle`);
continue;
}
// FN-8141: never promote a stranded-todo task whose most recent execution ended in a
// failure/refusal park — the pause-abort bounce to todo must not launder the failure.
if (await this.isStrandedCompletedPromotionBlockedByFailureProvenance(task.id, "stranded-todo")) {
continue;
}
const success = await recoverFn(task);
if (success) recovered++;