fix(engine): taint steps skipped after a bulk-completion refusal so they cannot auto-promote (#2260)

## What & why

**FN-8141 laundered a failed task into `done` with zero net changes and
no sign-off.** After the executor's
`bulk-step-completion-without-review` refusal fired (steps had no
APPROVE verdicts), the agent used the sanctioned skip affordance
(`fn_task_update status="skipped"`) on the remaining unreviewed steps.
Because every completion check counts `skipped` as complete, the task
then satisfied the exact condition the refusal was protecting, and
downstream **automatic** promotion (implicit `fn_task_done`,
self-healing `recoverStrandedCompletedTodoTasks`) moved it to in-review
— where the AI merger found an empty diff and finalized it as a no-op
`done`.

This PR restores the invariant: **steps skipped while a
bulk-step-completion refusal marker is active on the task are "tainted"
and cannot carry the task to review through any automatic path.** The
taint clears on an honest exit — an accepted `fn_task_done` (explicit or
non-tainted implicit) or an operator manual retry — so the legitimate
`PREMISE STALE` skip-then-done flow is unaffected.

## Design

- **Persisted marker**: new nullable `Task.bulkCompletionRefusalAt` (ISO
timestamp), stamped when the `bulk-step-completion-without-review`
refusal fires (explicit `fn_task_done` handler + implicit
`handleImplicitTaskDoneRefusal`). Survives requeue so a refusal on
attempt N taints attempt N+1's promotion. Full store plumbing (types,
descriptors, serialization, SQLite/PG schema + health self-heal).
- **Pure evaluator** `evaluateSkipBypassTaint(task)` in `@fusion/core`
(next to `evaluateNoCommitsNoOpFinalize`): `blocked` iff the marker is
set AND ≥1 step is `skipped`. Single rule every AUTO-promotion check
calls.
- **Clearing**: accepted explicit `fn_task_done`, accepted
implicit/retry completion (the success-reset `updateTask`s), and
`buildManualRetryResetPatch` (operator retry). A fresh lifecycle that
genuinely re-does the work leaves zero skipped steps, so it is never
blocked even if a marker lingers.

## Surface enumeration (every consumer of "all steps done/skipped" that
gates AUTO-promotion)

- **executor.ts**: `getCompletedTaskFinalizationDecision` (gated on the
`isTaskWorkComplete` branch only, never on an accepted `taskDone`);
`recoverCompletedTask` (shared chokepoint for unpause resume,
completed-task watchdog, orphan resume);
`evaluateImplicitCompletionRefusal` (both implicit-completion loops);
`isTaskAlreadyCompleteForNonContinuableSession`; graph merge-boundary
`getWorkflowMergeImplementationProofFailure`.
- **self-healing.ts**: `recoverCompletedTasks` (stuck in-progress) and
`recoverStrandedCompletedTodoTasks` (the exact FN-8141 promoter).
- **Verified-safe, left as-is**: per-step graph node projections
(executor ~6274/6298) and progress-render checks — they don't gate
whole-task auto-promotion.

## Test evidence

Scoped runs (all green):

```
CORE:   pnpm --filter @fusion/core exec vitest run \
          src/__tests__/skip-bypass-taint-guard.test.ts \
          src/__tests__/skip-bypass-taint-persistence.test.ts \
          src/__tests__/manual-retry-reset.test.ts
        → 17 passed

ENGINE: pnpm --filter @fusion/engine exec vitest run \
          src/__tests__/executor-skip-bypass-taint.test.ts \
          src/__tests__/self-healing.test.ts
        → 401 passed
```

Coverage: pure-evaluator (skip-before-refusal counts, skip-after-refusal
doesn't, taint-clearing, empty-marker/empty-steps edges); store
round-trip of the marker (set→read→clear); executor white-box (implicit
completion refused when tainted, allowed when clean or fully re-done,
graph merge-boundary reports missing proof, and the **explicit
`fn_task_done` PREMISE-STALE honest exit stays accepted**); self-healing
(FN-8141 sequence does not promote from either recovery path; a clean
legitimately-skipped task still promotes); manual-retry clears the
marker.

## Note on `pnpm verify:fast`

`verify:fast` currently fails at the workspace-artifact bootstrap on
**pre-existing** pi-SDK type errors in
`packages/engine/src/{auth-storage,pi,provider-registration}.ts` — the
FN-8145 upstream migration breakage (pi 0.80.x removed
`AuthStorage`/`ModelRegistry.create`). **None of those files are in this
diff.** `@fusion/core` builds clean (`packages/core build: Done`), and
`@fusion/engine` `tsc` reports **no errors in the files this PR
touches** (`executor.ts`, `self-healing.ts`); the only engine build
errors are the FN-8145 files. This base failure is the same condition
FN-8141 describes and is out of scope for this task.

🤖 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 20:37:05 -07:00
committed by GitHub
parent 29543a0aac
commit a136535f15
22 changed files with 572 additions and 17 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Block tasks that skip unreviewed steps after a completion refusal from auto-promoting to review.
category: fix
dev: New persisted task field `bulkCompletionRefusalAt` is stamped when the executor's `bulk-step-completion-without-review` refusal fires; the pure `evaluateSkipBypassTaint` (in @fusion/core) makes skipped-after-refusal steps not count toward any AUTO-promotion path (executor implicit-completion/finalize, `recoverCompletedTask`, self-healing stuck-in-progress + stranded-todo recovery, graph merge-boundary proof). Cleared on an accepted fn_task_done or operator manual retry; the PREMISE STALE accepted-done flow is unaffected (FN-8141).

View File

@@ -81,4 +81,10 @@ describe("buildManualRetryResetPatch", () => {
it("clears nextRecoveryAt", () => {
expect(buildManualRetryResetPatch()).toMatchObject({ nextRecoveryAt: null });
});
// FNXC:Lifecycle 2026-07-16-21:40: FN-8141 — an operator manual retry is an honest exit
// that clears the skip-bypass taint marker so the retried task can promote on its skips.
it("clears the FN-8141 skip-bypass taint marker (bulkCompletionRefusalAt)", () => {
expect(buildManualRetryResetPatch()).toMatchObject({ bulkCompletionRefusalAt: null });
});
});

View File

@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { evaluateSkipBypassTaint, type TaskStep } from "../index.js";
/*
FNXC:Lifecycle 2026-07-16-21:40:
FN-8141 — the skip-bypass taint evaluator is the single rule every AUTO-promotion
check consults. These tests assert the general invariant across the enumerated
inputs (skip before vs after refusal, taint-clearing, PREMISE-STALE unaffected),
not just the exact FN-8141 shape.
*/
function steps(statuses: Array<TaskStep["status"]>): TaskStep[] {
return statuses.map((status, index) => ({ name: `Step ${index}`, status }));
}
describe("evaluateSkipBypassTaint", () => {
it("does NOT block skips when no refusal marker is set (skip-before-refusal counts)", () => {
const result = evaluateSkipBypassTaint({
steps: steps(["done", "done", "skipped", "skipped"]),
bulkCompletionRefusalAt: undefined,
});
expect(result).toEqual({ blocked: false, tainted: false, skippedStepCount: 2 });
});
it("blocks the FN-8141 shape: skips present AND refusal marker active (skip-after-refusal)", () => {
const result = evaluateSkipBypassTaint({
steps: steps(["done", "done", "done", "skipped", "skipped"]),
bulkCompletionRefusalAt: "2026-07-16T21:40:00.000Z",
});
expect(result.blocked).toBe(true);
expect(result.tainted).toBe(true);
expect(result.skippedStepCount).toBe(2);
expect(result.reason).toContain("skipped after a bulk-step-completion refusal");
});
it("does NOT block a tainted task once its skipped steps are genuinely re-done (no skipped left)", () => {
// A fresh lifecycle that legitimately completes the work leaves zero skipped
// steps, so even a lingering marker cannot block — real work is never laundering.
const result = evaluateSkipBypassTaint({
steps: steps(["done", "done", "done", "done", "done"]),
bulkCompletionRefusalAt: "2026-07-16T21:40:00.000Z",
});
expect(result).toEqual({ blocked: false, tainted: true, skippedStepCount: 0 });
});
it("clears (does not block) when the marker is cleared, even with skipped steps present", () => {
// Simulates an accepted fn_task_done / operator retry that set the marker to null.
const result = evaluateSkipBypassTaint({
steps: steps(["done", "skipped", "skipped"]),
bulkCompletionRefusalAt: undefined,
});
expect(result.blocked).toBe(false);
expect(result.tainted).toBe(false);
});
it("PREMISE STALE accepted-done shape is unaffected: an empty-marker skip-heavy task promotes", () => {
// PREMISE STALE skips remaining steps then calls fn_task_done which is ACCEPTED
// and clears the marker, so the guard never sees a tainted skip-heavy task.
const result = evaluateSkipBypassTaint({
steps: steps(["done", "skipped", "skipped", "skipped"]),
bulkCompletionRefusalAt: undefined,
});
expect(result.blocked).toBe(false);
});
it("handles empty / missing steps without blocking", () => {
expect(evaluateSkipBypassTaint({ steps: [], bulkCompletionRefusalAt: "2026-07-16T21:40:00.000Z" }))
.toEqual({ blocked: false, tainted: true, skippedStepCount: 0 });
expect(evaluateSkipBypassTaint({ steps: undefined as unknown as TaskStep[], bulkCompletionRefusalAt: undefined }))
.toEqual({ blocked: false, tainted: false, skippedStepCount: 0 });
});
it("treats an empty-string marker as no taint (null-equivalent)", () => {
expect(evaluateSkipBypassTaint({ steps: steps(["done", "skipped"]), bulkCompletionRefusalAt: "" }))
.toEqual({ blocked: false, tainted: false, skippedStepCount: 1 });
});
});

View File

@@ -0,0 +1,44 @@
import { afterEach, beforeAll, beforeEach, afterAll, expect, it } from "vitest";
import {
pgDescribe,
createSharedPgTaskStoreTestHarness,
type SharedPgTaskStoreHarness,
} from "../__test-utils__/pg-test-harness.js";
const pgTest = pgDescribe;
/*
FNXC:Lifecycle 2026-07-16-21:40:
FN-8141 — the skip-bypass taint marker `bulkCompletionRefusalAt` must survive a
requeue (it is set on attempt N's refusal and consulted on attempt N+1's promotion),
so it has to round-trip through the store. Assert set/read/clear on the real backend.
*/
pgTest("TaskStore bulkCompletionRefusalAt (skip-bypass taint) persistence", () => {
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ prefix: "fusion_skip_bypass_taint" });
beforeAll(h.beforeAll);
afterAll(h.afterAll);
beforeEach(async () => {
await h.beforeEach();
});
afterEach(async () => {
await h.afterEach();
});
it("round-trips the taint marker: unset → set → cleared", async () => {
const store = h.store();
const task = await store.createTask({ description: "Skip bypass taint round-trip" });
// Fresh tasks carry no taint.
expect((await store.getTask(task.id)).bulkCompletionRefusalAt).toBeUndefined();
const stamp = "2026-07-16T21:40:00.000Z";
await store.updateTask(task.id, { bulkCompletionRefusalAt: stamp });
expect((await store.getTask(task.id)).bulkCompletionRefusalAt).toBe(stamp);
// null clears the marker back to undefined (the honest-exit / operator-retry path).
await store.updateTask(task.id, { bulkCompletionRefusalAt: null });
expect((await store.getTask(task.id)).bulkCompletionRefusalAt).toBeUndefined();
});
});

View File

@@ -725,6 +725,8 @@ 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 { evaluateSkipBypassTaint } from "./skip-bypass-taint-guard.js";
export type { SkipBypassTaintEvaluation } from "./skip-bypass-taint-guard.js";
export {
__getDeterministicGuardMutexSize,
deterministicGuardLocks,

View File

@@ -46,6 +46,10 @@ export function buildManualRetryResetPatch(options?: { resetMergeRetries?: boole
executorEscalationAttempted: false,
toolFailureDetectorLogCursor: null,
toolFailureRetryExhaustedAuditEmitted: false,
// FNXC:Lifecycle 2026-07-16-21:40:
// FN-8141 — an operator manual retry/edit is an honest exit signal that clears the
// skip-bypass taint, so a legitimately retried task can promote on its skipped steps.
bulkCompletionRefusalAt: null as unknown as Task["bulkCompletionRefusalAt"],
};
for (const key of MANUAL_RETRY_RESET_COUNTER_KEYS) {

View File

@@ -78,6 +78,8 @@ CREATE TABLE IF NOT EXISTS project.tasks (
execute_requeue_loop_signature text,
recovery_retry_count integer,
task_done_retry_count integer DEFAULT 0,
-- FNXC:Lifecycle 2026-07-16-21:40: FN-8141 skip-bypass taint marker (nullable ISO timestamp).
bulk_completion_refusal_at text,
worktree_session_retry_count integer DEFAULT 0,
completion_handoff_limbo_recovery_count integer DEFAULT 0,
merge_conflict_bounce_count integer DEFAULT 0,

View File

@@ -171,6 +171,10 @@ export const EXPECTED_PROJECT_COLUMNS: ReadonlyArray<{ schema?: string; table: s
// Additive column not present in the baseline snapshot, so existing embedded-PG
// databases must self-heal it via ALTER TABLE ADD COLUMN IF NOT EXISTS on boot.
{ table: "tasks", column: "plan_review_replan_count", type: "integer" },
// FNXC:Lifecycle 2026-07-16-21:40: FN-8141 skip-bypass taint marker. Additive nullable
// timestamp column absent from older embedded-PG snapshots, so it must self-heal via
// ALTER TABLE ADD COLUMN IF NOT EXISTS on boot (CREATE TABLE IF NOT EXISTS never upgrades).
{ table: "tasks", column: "bulk_completion_refusal_at", type: "text" },
// distributed_task_id_state
{ table: "distributed_task_id_state", column: "prefix", type: "text" },
{ table: "distributed_task_id_state", column: "next_sequence", type: "integer" },

View File

@@ -111,6 +111,8 @@ export const tasks = projectSchema.table("tasks", {
executeRequeueLoopSignature: text("execute_requeue_loop_signature"),
recoveryRetryCount: integer("recovery_retry_count"),
taskDoneRetryCount: integer("task_done_retry_count").default(0),
// FNXC:Lifecycle 2026-07-16-21:40: FN-8141 skip-bypass taint marker (nullable ISO timestamp).
bulkCompletionRefusalAt: text("bulk_completion_refusal_at"),
worktreeSessionRetryCount: integer("worktree_session_retry_count").default(0),
completionHandoffLimboRecoveryCount: integer("completion_handoff_limbo_recovery_count").default(0),
mergeConflictBounceCount: integer("merge_conflict_bounce_count").default(0),

View File

@@ -0,0 +1,58 @@
import type { Task } from "./types.js";
export interface SkipBypassTaintEvaluation {
/**
* True when the task carries an active bulk-step-completion refusal marker AND
* still has skipped steps: those skips must NOT count toward any AUTOMATIC
* promotion path (executor completion-finalize, self-healing stuck-in-progress
* recovery, stranded-todo promoter, graph merge boundary).
*/
blocked: boolean;
reason?: string;
/** Whether a bulk-step-completion refusal marker is currently active. */
tainted: boolean;
/** Count of steps currently in `skipped` state. */
skippedStepCount: number;
}
/*
FNXC:Lifecycle 2026-07-16-21:40:
FN-8141 laundered a failed task into `done`: after the executor's
`bulk-step-completion-without-review` refusal fired repeatedly (steps had no
APPROVE verdicts), the agent used the sanctioned skip affordance
(`fn_task_update status="skipped"`) on the remaining unreviewed steps. Because
every completion check counts `skipped` as complete, the task then satisfied the
exact condition the refusal was protecting, and downstream AUTO-promotion
(implicit fn_task_done, self-healing stranded-todo/stuck-in-progress recovery)
moved it to in-review with zero net changes and no reviewer/operator sign-off.
Invariant restored: steps skipped while a bulk-step-completion refusal marker is
active on the task are "tainted" and cannot carry the task to `done`/`in-review`
through any automatic path. The taint clears the moment there is an honest exit
signal — an ACCEPTED fn_task_done (explicit or non-tainted implicit), an operator
manual retry/edit, or a fresh lifecycle that legitimately completes the work — so
the legitimate PREMISE STALE flow (skip remaining steps + accepted fn_task_done)
is unaffected. This pure evaluator is the single rule every AUTO-promotion check
consults, mirroring `evaluateNoCommitsNoOpFinalize`.
*/
export function evaluateSkipBypassTaint(
task: Pick<Task, "steps" | "bulkCompletionRefusalAt">,
): SkipBypassTaintEvaluation {
const steps = task.steps ?? [];
const skippedStepCount = steps.filter((step) => step.status === "skipped").length;
const tainted = Boolean(task.bulkCompletionRefusalAt);
if (tainted && skippedStepCount > 0) {
return {
blocked: true,
reason:
`task has ${skippedStepCount} step(s) skipped after a bulk-step-completion refusal ` +
`(bulkCompletionRefusalAt=${task.bulkCompletionRefusalAt}); skipped-step completion ` +
`cannot auto-promote without reviewer or operator sign-off`,
tainted,
skippedStepCount,
};
}
return { blocked: false, tainted, skippedStepCount };
}

View File

@@ -1178,7 +1178,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
async updateTask(
id: string,
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("./types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record<string, unknown>; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; consecutiveToolFailureRetryCount?: number | null; executorEscalationAttempted?: boolean | null; toolFailureDetectorLogCursor?: number | null; toolFailureRetryExhaustedAuditEmitted?: boolean | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; planReviewReplanCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record<string, unknown> | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("./types.js").WorkflowTransitionNotificationMarker | undefined; plannerOversightLevel?: string | null; sessionAdvisorEnabled?: boolean | null; approvedPlanFingerprint?: string | null }, runContext?: RunMutationContext,
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("./types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("./types.js").TaskStep[]; customFields?: Record<string, unknown>; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("./types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("./types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("./types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; consecutiveToolFailureRetryCount?: number | null; executorEscalationAttempted?: boolean | null; toolFailureDetectorLogCursor?: number | null; toolFailureRetryExhaustedAuditEmitted?: boolean | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; planReviewReplanCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; bulkCompletionRefusalAt?: string | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("./types.js").TaskReview | null; reviewState?: import("./types.js").TaskReviewState | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; sourceIssue?: import("./types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record<string, unknown> | null; githubTracking?: import("./types.js").TaskGithubTracking | null; tokenUsage?: import("./types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("./types.js").WorkflowTransitionNotificationMarker | undefined; plannerOversightLevel?: string | null; sessionAdvisorEnabled?: boolean | null; approvedPlanFingerprint?: string | null }, runContext?: RunMutationContext,
): Promise<Task> {
return updateTaskImpl(this, id, updates, runContext);
}

View File

@@ -59,6 +59,8 @@ export interface TaskRow {
planReviewReplanCount: number | null;
recoveryRetryCount: number | null;
taskDoneRetryCount: number | null;
// FNXC:Lifecycle 2026-07-16-21:40: FN-8141 skip-bypass taint marker (ISO timestamp / null).
bulkCompletionRefusalAt: string | null;
worktreeSessionRetryCount: number | null;
completionHandoffLimboRecoveryCount: number | null;
verificationFailureCount: number | null;
@@ -246,6 +248,8 @@ export const TASK_COLUMN_DESCRIPTORS: TaskColumnDescriptor[] = [
defineTaskColumn("planReviewReplanCount", (task) => task.planReviewReplanCount ?? 0),
defineTaskColumn("recoveryRetryCount", (task) => task.recoveryRetryCount ?? null),
defineTaskColumn("taskDoneRetryCount", (task) => task.taskDoneRetryCount ?? 0),
// FNXC:Lifecycle 2026-07-16-21:40: FN-8141 skip-bypass taint marker persisted as nullable ISO timestamp.
defineTaskColumn("bulkCompletionRefusalAt", (task) => task.bulkCompletionRefusalAt ?? null),
defineTaskColumn("worktreeSessionRetryCount", (task) => task.worktreeSessionRetryCount ?? 0),
defineTaskColumn("completionHandoffLimboRecoveryCount", (task) => task.completionHandoffLimboRecoveryCount ?? 0),
defineTaskColumn("verificationFailureCount", (task) => task.verificationFailureCount ?? 0),

View File

@@ -44,7 +44,7 @@ export function getTaskSelectClauseWithActivityLogLimitImpl(store: TaskStore, li
"modelPresetId", "modelProvider", "modelId",
"validatorModelProvider", "validatorModelId",
"planningModelProvider", "planningModelId",
"mergeRetries", "workflowStepRetries", "stuckKillCount", "resumeLimboCount", "executeRequeueLoopCount", "graphResumeRetryCount", "consecutiveToolFailureRetryCount", "executorEscalationAttempted", "toolFailureDetectorLogCursor", "toolFailureRetryExhaustedAuditEmitted", "resumeLimboTipSha", "resumeLimboStepSignature", "executeRequeueLoopSignature", "postReviewFixCount", "planReviewReplanCount", "recoveryRetryCount", "taskDoneRetryCount", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "mergeTransientRetryCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", "reviewerFallbackRetryCount", "nextRecoveryAt",
"mergeRetries", "workflowStepRetries", "stuckKillCount", "resumeLimboCount", "executeRequeueLoopCount", "graphResumeRetryCount", "consecutiveToolFailureRetryCount", "executorEscalationAttempted", "toolFailureDetectorLogCursor", "toolFailureRetryExhaustedAuditEmitted", "resumeLimboTipSha", "resumeLimboStepSignature", "executeRequeueLoopSignature", "postReviewFixCount", "planReviewReplanCount", "recoveryRetryCount", "taskDoneRetryCount", "bulkCompletionRefusalAt", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "mergeTransientRetryCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", "reviewerFallbackRetryCount", "nextRecoveryAt",
"error", "summary", "thinkingLevel", "validatorThinkingLevel", "planningThinkingLevel", "executionMode",
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenUsagePerModel", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride",
"createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt",

View File

@@ -731,7 +731,7 @@ export async function resetPromptCheckboxesImpl(store: TaskStore, dir: string):
export async function updateTaskImpl(store: TaskStore,
id: string,
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("../types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("../types.js").TaskStep[]; customFields?: Record<string, unknown>; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("../types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("../types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("../types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; consecutiveToolFailureRetryCount?: number | null; executorEscalationAttempted?: boolean | null; toolFailureDetectorLogCursor?: number | null; toolFailureRetryExhaustedAuditEmitted?: boolean | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; planReviewReplanCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("../types.js").TaskReview | null; reviewState?: import("../types.js").TaskReviewState | null; workflowStepResults?: import("../types.js").WorkflowStepResult[] | null; mergeDetails?: import("../types.js").MergeDetails | null; sourceIssue?: import("../types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record<string, unknown> | null; githubTracking?: import("../types.js").TaskGithubTracking | null; tokenUsage?: import("../types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("../types.js").WorkflowTransitionNotificationMarker | undefined; sessionAdvisorEnabled?: boolean | null }, runContext?: RunMutationContext,
updates: { title?: string; description?: string; priority?: TaskPriority | null; prompt?: string; worktree?: string | null; workspaceWorktrees?: import("../types.js").Task["workspaceWorktrees"]; status?: string | null; dependencies?: string[]; steps?: import("../types.js").TaskStep[]; customFields?: Record<string, unknown>; currentStep?: number; blockedBy?: string | null; overlapBlockedBy?: string | null; assignedAgentId?: string | null; pausedByAgentId?: string | null; pausedReason?: string | null; tokenBudgetSoftAlertedAt?: string | null; worktrunkFallbackAlertedAt?: string | null; worktrunkFailure?: import("../types.js").Task["worktrunkFailure"] | null; tokenBudgetHardAlertedAt?: string | null; tokenBudgetOverride?: import("../types.js").TaskTokenBudgetOverride | null; dispatchStormCount?: number | null; lastDispatchAt?: string | null; assigneeUserId?: string | null; scopeOverride?: boolean | null; scopeOverrideReason?: string | null; scopeAutoWiden?: string[] | null; nodeId?: string | null; effectiveNodeId?: string | null; effectiveNodeSource?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; checkoutNodeId?: string | null; checkoutRunId?: string | null; checkoutLeaseRenewedAt?: string | null; checkoutLeaseEpoch?: number | null; paused?: boolean; baseBranch?: string | null; autoMerge?: boolean | null; branch?: string | null; executionStartBranch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; executionMode?: import("../types.js").ExecutionMode | null; mergeRetries?: number; workflowStepRetries?: number; stuckKillCount?: number | null; resumeLimboCount?: number | null; executeRequeueLoopCount?: number | null; graphResumeRetryCount?: number | null; consecutiveToolFailureRetryCount?: number | null; executorEscalationAttempted?: boolean | null; toolFailureDetectorLogCursor?: number | null; toolFailureRetryExhaustedAuditEmitted?: boolean | null; resumeLimboTipSha?: string | null; resumeLimboStepSignature?: string | null; executeRequeueLoopSignature?: string | null; postReviewFixCount?: number | null; planReviewReplanCount?: number | null; recoveryRetryCount?: number | null; taskDoneRetryCount?: number | null; bulkCompletionRefusalAt?: string | null; worktreeSessionRetryCount?: number | null; completionHandoffLimboRecoveryCount?: number | null; verificationFailureCount?: number | null; mergeConflictBounceCount?: number | null; mergeAuditBounceCount?: number | null; mergeTransientRetryCount?: number | null; branchConflictRecoveryCount?: number | null; reviewerContextRetryCount?: number | null; reviewerFallbackRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; noCommitsExpected?: boolean | null; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; validatorThinkingLevel?: string | null; planningThinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; firstExecutionAt?: string | null; cumulativeActiveMs?: number | null; executionStartedAt?: string | null; executionCompletedAt?: string | null; review?: import("../types.js").TaskReview | null; reviewState?: import("../types.js").TaskReviewState | null; workflowStepResults?: import("../types.js").WorkflowStepResult[] | null; mergeDetails?: import("../types.js").MergeDetails | null; sourceIssue?: import("../types.js").TaskSourceIssue | null; sourceMetadataPatch?: Record<string, unknown> | null; githubTracking?: import("../types.js").TaskGithubTracking | null; tokenUsage?: import("../types.js").TaskTokenUsage | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null; workflowTransitionNotification?: import("../types.js").WorkflowTransitionNotificationMarker | undefined; sessionAdvisorEnabled?: boolean | null }, runContext?: RunMutationContext,
): Promise<Task> {
/*
FNXC:StateMachine 2026-07-07-12:00:

View File

@@ -113,6 +113,8 @@ export function rowToTask(row: TaskRow): Task {
planReviewReplanCount: row.planReviewReplanCount ?? undefined,
recoveryRetryCount: row.recoveryRetryCount ?? undefined,
taskDoneRetryCount: row.taskDoneRetryCount ?? undefined,
// FNXC:Lifecycle 2026-07-16-21:40: FN-8141 skip-bypass taint marker; empty/null → undefined (no taint).
bulkCompletionRefusalAt: row.bulkCompletionRefusalAt || undefined,
worktreeSessionRetryCount: row.worktreeSessionRetryCount ?? undefined,
completionHandoffLimboRecoveryCount: row.completionHandoffLimboRecoveryCount ?? undefined,
verificationFailureCount: row.verificationFailureCount ?? undefined,

View File

@@ -37,7 +37,7 @@ export function getTaskSelectClauseImpl2(store: TaskStore, slim: boolean, tableA
"modelPresetId", "modelProvider", "modelId",
"validatorModelProvider", "validatorModelId",
"planningModelProvider", "planningModelId",
"mergeRetries", "workflowStepRetries", "stuckKillCount", "resumeLimboCount", "executeRequeueLoopCount", "graphResumeRetryCount", "consecutiveToolFailureRetryCount", "executorEscalationAttempted", "toolFailureDetectorLogCursor", "toolFailureRetryExhaustedAuditEmitted", "resumeLimboTipSha", "resumeLimboStepSignature", "executeRequeueLoopSignature", "postReviewFixCount", "planReviewReplanCount", "recoveryRetryCount", "taskDoneRetryCount", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "mergeTransientRetryCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", "reviewerFallbackRetryCount", "nextRecoveryAt",
"mergeRetries", "workflowStepRetries", "stuckKillCount", "resumeLimboCount", "executeRequeueLoopCount", "graphResumeRetryCount", "consecutiveToolFailureRetryCount", "executorEscalationAttempted", "toolFailureDetectorLogCursor", "toolFailureRetryExhaustedAuditEmitted", "resumeLimboTipSha", "resumeLimboStepSignature", "executeRequeueLoopSignature", "postReviewFixCount", "planReviewReplanCount", "recoveryRetryCount", "taskDoneRetryCount", "bulkCompletionRefusalAt", "worktreeSessionRetryCount", "completionHandoffLimboRecoveryCount", "verificationFailureCount", "mergeConflictBounceCount", "mergeAuditBounceCount", "mergeTransientRetryCount", "branchConflictRecoveryCount", "reviewerContextRetryCount", "reviewerFallbackRetryCount", "nextRecoveryAt",
"error", "summary", "thinkingLevel", "validatorThinkingLevel", "planningThinkingLevel", "executionMode",
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageCacheWriteTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt", "tokenUsageModelProvider", "tokenUsageModelId", "tokenUsagePerModel", "tokenBudgetSoftAlertedAt", "tokenBudgetHardAlertedAt", "tokenBudgetOverride",
"createdAt", "updatedAt", "columnMovedAt", "firstExecutionAt", "cumulativeActiveMs", "executionStartedAt", "executionCompletedAt",

View File

@@ -413,6 +413,12 @@ export async function updateTaskUnlockedImpl(store: TaskStore, id: string, updat
} else if (updates.taskDoneRetryCount !== undefined) {
task.taskDoneRetryCount = updates.taskDoneRetryCount;
}
// FNXC:Lifecycle 2026-07-16-21:40: FN-8141 skip-bypass taint marker; null clears the taint.
if (updates.bulkCompletionRefusalAt === null) {
task.bulkCompletionRefusalAt = undefined;
} else if (updates.bulkCompletionRefusalAt !== undefined) {
task.bulkCompletionRefusalAt = updates.bulkCompletionRefusalAt;
}
if (updates.worktreeSessionRetryCount === null) {
task.worktreeSessionRetryCount = undefined;
} else if (updates.worktreeSessionRetryCount !== undefined) {

View File

@@ -1699,6 +1699,17 @@ export interface Task {
* failures. Capped by `MAX_TASK_DONE_RETRIES`; when exhausted the task stays
* in `in-review` for human inspection. Cleared on successful completion. */
taskDoneRetryCount?: number;
/**
* FNXC:Lifecycle 2026-07-16-21:40:
* ISO-8601 timestamp stamped when the executor's `bulk-step-completion-without-review`
* refusal fires for this task's current execution lifecycle (FN-8141). While set, any
* step in `skipped` state is "tainted": it must not count toward AUTOMATIC promotion
* (executor completion-finalize, self-healing stuck-in-progress / stranded-todo recovery,
* graph merge boundary) — see `evaluateSkipBypassTaint`. Cleared on an honest exit: an
* ACCEPTED fn_task_done (explicit or non-tainted implicit) or an operator manual retry.
* Null/undefined means no active taint.
*/
bulkCompletionRefusalAt?: string;
/** Number of times self-healing auto-requeued an `in-review` task that failed
* at session start with an unusable-worktree error. Bounded by
* `MAX_WORKTREE_SESSION_RETRIES`; when exhausted the task remains parked in

View File

@@ -0,0 +1,96 @@
import "./executor-test-helpers.js";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { TaskExecutor, evaluateTaskDoneRefusal } from "../executor.js";
import { resetExecutorMocks } from "./executor-test-helpers.js";
import { evaluateSkipBypassTaint } from "@fusion/core";
/*
FNXC:Lifecycle 2026-07-16-21:40:
FN-8141 laundered a failed task into `done`: a `bulk-step-completion-without-review`
refusal fired, then the agent marked the remaining unreviewed steps `skipped`, and the
implicit completion path treated the all-done/skipped task as done. These tests assert
the executor's AUTO-promotion glue (implicit completion, graph merge-boundary proof) is
skip-bypass-taint-aware, while the explicit fn_task_done honest exit (PREMISE STALE) is
unaffected.
*/
function createStore() {
return {
on: vi.fn(),
off: vi.fn(),
getSettings: vi.fn().mockResolvedValue({ globalPause: false, enginePaused: false }),
listTasks: vi.fn().mockResolvedValue([]),
logEntry: vi.fn().mockResolvedValue(undefined),
} as any;
}
function taskWith(
statuses: Array<"done" | "skipped" | "pending" | "in-progress">,
bulkCompletionRefusalAt?: string,
) {
return {
id: "FN-8141",
title: "Skip bypass",
description: "",
column: "in-progress",
dependencies: [],
steps: statuses.map((status, index) => ({ name: `Step ${index + 1}`, status })),
currentStep: 0,
bulkCompletionRefusalAt,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
} as any;
}
describe("TaskExecutor skip-bypass taint (FN-8141)", () => {
beforeEach(() => {
resetExecutorMocks();
vi.clearAllMocks();
});
it("implicit completion is REFUSED when steps were skipped after a bulk-step-completion refusal", () => {
const executor = new TaskExecutor(createStore(), "/tmp/test");
// The FN-8141 sequence: 3 done + 2 skipped, refusal marker active, no accepted done.
const task = taskWith(["done", "done", "done", "skipped", "skipped"], "2026-07-16T21:40:00.000Z");
const result = (executor as any).evaluateImplicitCompletionRefusal(task, new Map());
expect(result.ok).toBe(false);
expect(result.refusalClass).toBe("bulk-step-completion-without-review");
expect(result.reason).toContain("skipped after a bulk-step-completion refusal");
});
it("implicit completion is ALLOWED for a clean all-done/skipped task with no refusal marker", () => {
const executor = new TaskExecutor(createStore(), "/tmp/test");
const task = taskWith(["done", "skipped"], undefined);
const result = (executor as any).evaluateImplicitCompletionRefusal(task, new Map());
expect(result).toEqual({ ok: true });
});
it("implicit completion is ALLOWED for a tainted task once every step is genuinely done (no skips left)", () => {
const executor = new TaskExecutor(createStore(), "/tmp/test");
const task = taskWith(["done", "done", "done"], "2026-07-16T21:40:00.000Z");
const result = (executor as any).evaluateImplicitCompletionRefusal(task, new Map());
expect(result).toEqual({ ok: true });
});
it("the graph merge boundary reports missing implementation proof for a tainted task", async () => {
const executor = new TaskExecutor(createStore(), "/tmp/test");
const task = taskWith(["done", "skipped", "skipped"], "2026-07-16T21:40:00.000Z");
const failure = await (executor as any).getWorkflowMergeImplementationProofFailure(task);
expect(failure).toContain("steps were skipped after a bulk-step-completion refusal");
});
it("does NOT taint the EXPLICIT fn_task_done honest exit: PREMISE STALE skip-then-done still accepted", () => {
// The taint lives only in the AUTO-promotion glue; the exported refusal function
// (which backs the explicit fn_task_done tool) must still accept a skipped-only task
// so the accepted-done path can clear the marker. Same shape the taint blocks for
// AUTO promotion is accepted here for the explicit call.
const task = taskWith(["done", "skipped", "skipped", "skipped"], "2026-07-16T21:40:00.000Z");
const explicit = evaluateTaskDoneRefusal(
task,
{ summary: "PREMISE STALE: already implemented on HEAD, remaining steps skipped" },
new Map(),
);
expect(explicit).toEqual({ ok: true });
// But the AUTO-promotion evaluator blocks the same shape while the marker is active.
expect(evaluateSkipBypassTaint(task).blocked).toBe(true);
});
});

View File

@@ -2511,6 +2511,36 @@ describe("SelfHealingManager", () => {
managerWithRecovery.stop();
});
// FNXC:Lifecycle 2026-07-16-21:40: FN-8141 — the stuck-in-progress recovery path must
// not auto-promote a skip-bypass-tainted task (skips after a bulk-step-completion refusal).
it("skips a skip-bypass-tainted in-progress task", async () => {
const recoverFn = vi.fn().mockResolvedValue(true);
const getExecuting = vi.fn().mockReturnValue(new Set<string>());
const managerWithRecovery = new SelfHealingManager(store, {
rootDir: "/tmp/test-project",
recoverCompletedTask: recoverFn,
getExecutingTaskIds: getExecuting,
});
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "FN-8141b",
column: "in-progress",
paused: false,
bulkCompletionRefusalAt: "2026-07-16T21:40:00.000Z",
steps: [{ status: "done" }, { status: "skipped" }, { status: "skipped" }],
},
]);
const result = await managerWithRecovery.recoverCompletedTasks();
expect(result).toBe(0);
expect(recoverFn).not.toHaveBeenCalled();
managerWithRecovery.stop();
});
it("skips paused tasks", async () => {
const recoverFn = vi.fn().mockResolvedValue(true);
const getExecuting = vi.fn().mockReturnValue(new Set<string>());
@@ -3078,8 +3108,10 @@ describe("SelfHealingManager", () => {
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).
// Slim board row: all steps done (the skipped-step shape is now owned by the generalized
// FN-6461/FN-8141 no-commits guard `evaluateNoCommitsNoOpFinalize`, which filters skipped
// tasks earlier; failure-provenance's distinct role is withholding an otherwise-complete task
// whose MOST RECENT execution ended in a failure/refusal park). No error/active status.
(store.listTasks as ReturnType<typeof vi.fn>).mockResolvedValue([
{
id: "FN-8141",
@@ -3087,7 +3119,7 @@ describe("SelfHealingManager", () => {
paused: false,
error: null,
reviewLevel: 2,
steps: [{ status: "done" }, { status: "done" }, { status: "done" }, { status: "skipped" }, { status: "skipped" }],
steps: [{ status: "done" }, { status: "done" }, { status: "done" }],
},
]);
// Full task carries the durable failure-park provenance the slim row cannot.
@@ -3163,6 +3195,80 @@ describe("SelfHealingManager", () => {
managerWithRecovery.stop();
});
/*
FNXC:Lifecycle 2026-07-16-21:40:
FN-8141 — the stranded-todo promoter was the exact path that laundered FN-8141 into
in-review. The composed exclusions (no-commits step-evidence, failure-provenance, and the
skip-bypass taint) are independent — any one blocks — so the tainted FN-8141 shape must NOT
promote. (In this lane the skipped steps are also caught by evaluateNoCommitsNoOpFinalize;
the taint guard's independent load-bearing behavior is proven at the in-progress
recoverCompletedTasks surface, where the no-commits guard is not applied.)
*/
it("does NOT promote a skip-bypass-tainted todo task (FN-8141 sequence)", 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-TAINT",
column: "todo",
paused: false,
error: null,
reviewLevel: 2,
// 3 done + 2 skipped, and a bulk-step-completion refusal already fired.
bulkCompletionRefusalAt: "2026-07-16T21:40:00.000Z",
steps: [
{ status: "done" }, { status: "done" }, { status: "done" },
{ status: "skipped" }, { status: "skipped" },
],
},
]);
const result = await managerWithRecovery.recoverStrandedCompletedTodoTasks();
expect(result).toBe(0);
expect(recoverFn).not.toHaveBeenCalled();
managerWithRecovery.stop();
});
it("still promotes a clean all-done todo task with no refusal marker (taint guard does not over-block)", async () => {
// Confirms the added skip-bypass-taint filter does not regress normal promotion: an
// untainted, fully-complete task still promotes. (The skipped-step promotion case in the
// stranded-todo lane is now owned by evaluateNoCommitsNoOpFinalize; the taint guard's own
// skipped-step semantics are covered by the pure evaluateSkipBypassTaint unit tests and by
// the in-progress recoverCompletedTasks suite, where the no-commits guard is not applied.)
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-200",
column: "todo",
paused: false,
error: null,
reviewLevel: 2,
bulkCompletionRefusalAt: undefined,
steps: [{ status: "done" }, { status: "done" }],
},
]);
const result = await managerWithRecovery.recoverStrandedCompletedTodoTasks();
expect(result).toBe(1);
expect(recoverFn).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-200" }));
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, 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 { RetryStormError, serializeRetryStormError, isExperimentalFeatureEnabled, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, 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";
@@ -689,6 +689,27 @@ export function evaluateTaskDoneRefusal(
return { ok: true };
}
/*
FNXC:Lifecycle 2026-07-16-21:40:
FN-8141 — synthesize a refusal for an IMPLICIT (agent-exited, no explicit
fn_task_done) completion whose skipped steps are skip-bypass tainted. Only the
implicit/auto paths consult this; an explicit accepted fn_task_done stays the
honest exit that clears the taint. Reuses the bulk-step-completion class so the
existing refusal budget/park machinery applies unchanged.
*/
function buildSkipBypassTaintRefusal(
evaluation: ReturnType<typeof evaluateSkipBypassTaint>,
): Extract<TaskDoneRefusalResult, { ok: false }> {
const reason = evaluation.reason
?? "skipped steps after a bulk-step-completion refusal cannot auto-complete the task";
return {
ok: false,
refusalClass: "bulk-step-completion-without-review",
reason,
message: formatTaskDoneRefusal("bulk-step-completion-without-review", reason),
};
}
/**
* Determines the step index from which revision should restart given a set of
* completed steps and user feedback. Exported for unit tests; no longer called
@@ -4069,7 +4090,16 @@ export class TaskExecutor {
private async getCompletedTaskFinalizationDecision(taskId: string, taskDone: boolean): Promise<"finalize" | "blocked" | "incomplete"> {
const task = await this.store.getTask(taskId);
const completionBlocker = await this.getTaskCompletionBlocker(task);
const workComplete = taskDone || this.isTaskWorkComplete(task);
/*
FNXC:Lifecycle 2026-07-16-21:40:
FN-8141 — `taskDone` means an ACCEPTED fn_task_done (explicit or a non-tainted
implicit completion), which is the honest exit and always finalizes. Only the
step-status-derived `isTaskWorkComplete` path can be laundered by skip-bypass, so
the taint guard gates that path alone; a genuine no-op/PREMISE-STALE accepted done
is never blocked.
*/
const workComplete = taskDone
|| (this.isTaskWorkComplete(task) && !evaluateSkipBypassTaint(task).blocked);
if (completionBlocker) {
executorLog.log(`${taskId} completion blocked — ${completionBlocker}`);
if (workComplete && await this.parkCompletedBlockedTask(task, completionBlocker, "finalization", workComplete)) {
@@ -4086,7 +4116,12 @@ export class TaskExecutor {
}
private isTaskAlreadyCompleteForNonContinuableSession(task: Task, taskDone: boolean): boolean {
return taskDone || task.column === "in-review" || this.isTaskWorkComplete(task);
// FNXC:Lifecycle 2026-07-16-21:40: FN-8141 — the step-status "already complete" branch
// must not treat skip-bypass-tainted skips as completion; an accepted done / in-review
// column are honest completion signals and stay unaffected.
return taskDone
|| task.column === "in-review"
|| (this.isTaskWorkComplete(task) && !evaluateSkipBypassTaint(task).blocked);
}
private async handleNonContinuableSessionError(task: Task, taskDone: boolean, errorMessage: string): Promise<boolean> {
@@ -4421,6 +4456,26 @@ export class TaskExecutor {
executorLog.log(`${task.id}: skipping recoverCompletedTask — task has incomplete steps awaiting executor remediation`);
return false;
}
/*
FNXC:Lifecycle 2026-07-16-21:40:
FN-8141 — recoverCompletedTask is the shared auto-promotion chokepoint for every
"work looks complete → in-review" path (unpause resume, completed-task watchdog,
orphan resume). Refuse to auto-promote a skip-bypass-tainted task: its steps were
skipped after a bulk-step-completion refusal with no accepted fn_task_done, so the
only honest exits are an accepted fn_task_done or operator intervention (both clear
the taint). Leaving it unpromoted lets the bounded requeue/park machinery converge
it to a human instead of laundering it to review.
*/
if (liveForCompletenessCheck && evaluateSkipBypassTaint(liveForCompletenessCheck).blocked) {
executorLog.warn(`${task.id}: skipping recoverCompletedTask — skip-bypass taint active (steps skipped after a bulk-step-completion refusal)`);
await this.store.logEntry(
task.id,
"Auto-promotion withheld: steps were skipped after a bulk-step-completion refusal with no accepted fn_task_done — requires reviewer or operator sign-off",
undefined,
this.getRunContextFor(task.id),
).catch(() => undefined);
return false;
}
/*
FNXC:Lifecycle 2026-07-16-10:30:
@@ -6830,6 +6885,18 @@ export class TaskExecutor {
}
private async getWorkflowMergeImplementationProofFailure(task: TaskDetail): Promise<string | undefined> {
/*
FNXC:Lifecycle 2026-07-16-21:40:
FN-8141 — the graph merge boundary is another AUTO-promotion path. If the task is
skip-bypass tainted (steps skipped after a bulk-step-completion refusal with no
accepted fn_task_done), treat it as missing implementation proof so the merge is
blocked with `implementation-incomplete` rather than laundered through a no-op merge.
Runs before the noCommitsExpected exemption so a tainted task cannot slip past it.
*/
const taint = evaluateSkipBypassTaint(task);
if (taint.blocked) {
return "implementation did not run: steps were skipped after a bulk-step-completion refusal without an accepted fn_task_done";
}
if (task.noCommitsExpected === true) return undefined;
let ir: WorkflowIr | undefined;
@@ -11206,7 +11273,7 @@ export class TaskExecutor {
}
// Reset retry counters on success
await this.store.updateTask(task.id, { workflowStepRetries: undefined, taskDoneRetryCount: null, executeRequeueLoopCount: null, executeRequeueLoopSignature: null });
await this.store.updateTask(task.id, { workflowStepRetries: undefined, taskDoneRetryCount: null, bulkCompletionRefusalAt: null, executeRequeueLoopCount: null, executeRequeueLoopSignature: null });
if (await this.shouldDeferCompletionForGlobalPause(task.id, "before in-review transition after step-session completion")) {
return;
}
@@ -12018,7 +12085,7 @@ export class TaskExecutor {
if (implicitCheck.steps.length > 0 &&
implicitCheck.steps.every((s) => s.status === "done" || s.status === "skipped")) {
// Implicit path has no summary; evaluateTaskDoneRefusal will skip summary-claims-incomplete and only enforce pending-code-review-revise / bulk-step-completion-without-review.
const refusal = evaluateTaskDoneRefusal(implicitCheck, {}, codeReviewVerdicts);
const refusal = this.evaluateImplicitCompletionRefusal(implicitCheck, codeReviewVerdicts);
if (!refusal.ok) {
await this.handleImplicitTaskDoneRefusal(implicitCheck, refusal);
return;
@@ -12068,7 +12135,7 @@ export class TaskExecutor {
}
// Reset retry counters on success
await this.store.updateTask(task.id, { workflowStepRetries: undefined, taskDoneRetryCount: null, executeRequeueLoopCount: null, executeRequeueLoopSignature: null });
await this.store.updateTask(task.id, { workflowStepRetries: undefined, taskDoneRetryCount: null, bulkCompletionRefusalAt: null, executeRequeueLoopCount: null, executeRequeueLoopSignature: null });
if (await this.shouldDeferCompletionForGlobalPause(task.id, "before in-review transition after task completion (post-reset)")) {
return;
}
@@ -12321,7 +12388,7 @@ export class TaskExecutor {
if (implicitCheck.steps.length > 0 &&
implicitCheck.steps.every((s) => s.status === "done" || s.status === "skipped")) {
// Implicit path has no summary; evaluateTaskDoneRefusal will skip summary-claims-incomplete and only enforce pending-code-review-revise / bulk-step-completion-without-review.
const refusal = evaluateTaskDoneRefusal(implicitCheck, {}, codeReviewVerdicts);
const refusal = this.evaluateImplicitCompletionRefusal(implicitCheck, codeReviewVerdicts);
if (!refusal.ok) {
await this.handleImplicitTaskDoneRefusal(implicitCheck, refusal);
retrySession?.dispose();
@@ -12371,7 +12438,7 @@ export class TaskExecutor {
await this.store.logEntry(task.id, "Fast mode — pre-merge workflow steps skipped", undefined, this.getRunContextFor(task.id));
}
await this.store.updateTask(task.id, { workflowStepRetries: undefined, taskDoneRetryCount: null, executeRequeueLoopCount: null, executeRequeueLoopSignature: null });
await this.store.updateTask(task.id, { workflowStepRetries: undefined, taskDoneRetryCount: null, bulkCompletionRefusalAt: null, executeRequeueLoopCount: null, executeRequeueLoopSignature: null });
if (await this.shouldDeferCompletionForGlobalPause(task.id, "before in-review transition after task completion retry")) {
return;
}
@@ -14429,6 +14496,38 @@ export class TaskExecutor {
return { blocked: false };
}
/*
FNXC:Lifecycle 2026-07-16-21:40:
FN-8141 — an IMPLICIT completion (agent exits with every step done/skipped and no
explicit fn_task_done) is an AUTO-promotion, so it must honor the skip-bypass taint.
A synthesized taint refusal here re-parks the run through the existing refusal budget
rather than laundering skipped-after-refusal steps into review. The explicit
fn_task_done tool path is NOT routed here — that call remains the honest exit.
*/
private evaluateImplicitCompletionRefusal(
task: Task,
codeReviewVerdicts: Map<number, ReviewVerdict>,
): ReturnType<typeof evaluateTaskDoneRefusal> {
const refusal = evaluateTaskDoneRefusal(task, {}, codeReviewVerdicts);
if (!refusal.ok) return refusal;
const taint = evaluateSkipBypassTaint(task);
if (taint.blocked) return buildSkipBypassTaintRefusal(taint);
return { ok: true };
}
/*
FNXC:Lifecycle 2026-07-16-21:40:
FN-8141 — a `bulk-step-completion-without-review` refusal stamps the durable taint
marker so that later skips (in this or a requeued lifecycle) cannot auto-promote. The
marker is cleared only on an honest exit (accepted fn_task_done / operator retry).
*/
private skipBypassTaintUpdateForRefusal(
refusal: Extract<ReturnType<typeof evaluateTaskDoneRefusal>, { ok: false }>,
): { bulkCompletionRefusalAt: string } | Record<string, never> {
if (refusal.refusalClass !== "bulk-step-completion-without-review") return {};
return { bulkCompletionRefusalAt: new Date().toISOString() };
}
private async handleImplicitTaskDoneRefusal(
task: Task,
refusal: Extract<ReturnType<typeof evaluateTaskDoneRefusal>, { ok: false }>,
@@ -14437,6 +14536,7 @@ export class TaskExecutor {
await this.store.logEntry(task.id, refusal.message, undefined, this.getRunContextFor(task.id));
executorLog.error(`${task.id}: fn_task_done refused (${refusal.refusalClass}) — ${refusal.reason} (implicit completion)`);
const taintUpdate = this.skipBypassTaintUpdateForRefusal(refusal);
const priorRequeues = task.taskDoneRetryCount ?? 0;
const nextRequeueCount = priorRequeues + 1;
if (priorRequeues < MAX_TASK_DONE_REQUEUE_RETRIES) {
@@ -14444,6 +14544,7 @@ export class TaskExecutor {
status: "queued",
error: null,
taskDoneRetryCount: nextRequeueCount,
...taintUpdate,
paused: false,
pausedByAgentId: null,
worktree: null,
@@ -14462,6 +14563,7 @@ export class TaskExecutor {
await this.store.updateTask(task.id, {
status: "failed",
error: refusal.message,
...taintUpdate,
paused: false,
pausedByAgentId: null,
worktree: null,
@@ -14680,6 +14782,9 @@ export class TaskExecutor {
await store.logEntry(taskId, refusalMessage, undefined, this.getRunContextFor(task.id));
executorLog.error(`${taskId}: fn_task_done refused (${taskDoneRefusal.refusalClass}) — ${taskDoneRefusal.reason}`);
// FNXC:Lifecycle 2026-07-16-21:40: FN-8141 — stamp the skip-bypass taint marker so a
// later skip-then-exit (in this or a requeued lifecycle) cannot auto-promote.
const taintUpdate = this.skipBypassTaintUpdateForRefusal(taskDoneRefusal);
const priorRequeues = task.taskDoneRetryCount ?? 0;
const nextRequeueCount = priorRequeues + 1;
if (priorRequeues < MAX_TASK_DONE_REQUEUE_RETRIES) {
@@ -14687,6 +14792,7 @@ export class TaskExecutor {
status: "queued",
error: null,
taskDoneRetryCount: nextRequeueCount,
...taintUpdate,
paused: false,
pausedByAgentId: null,
worktree: null,
@@ -14705,6 +14811,7 @@ export class TaskExecutor {
await store.updateTask(taskId, {
status: "failed",
error: refusalMessage,
...taintUpdate,
paused: false,
pausedByAgentId: null,
worktree: null,
@@ -14824,6 +14931,10 @@ export class TaskExecutor {
paused: false,
pausedByAgentId: null,
status: null,
// FNXC:Lifecycle 2026-07-16-21:40: FN-8141 — an ACCEPTED explicit fn_task_done is the
// honest completion signal (covers the PREMISE STALE skip-then-done flow); clear any
// skip-bypass taint so a subsequent auto-promotion path is not blocked.
bulkCompletionRefusalAt: null,
});
await store.logEntry(taskId, "Task marked done by agent", undefined, this.getRunContextFor(taskId));

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, 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 { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, evaluateNoCommitsNoOpFinalize, evaluateCompletedPromotionFailureProvenance, evaluateSkipBypassTaint, 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";
@@ -2864,7 +2864,13 @@ export class SelfHealingManager {
!t.paused &&
!executingIds.has(t.id) &&
t.steps.length > 0 &&
t.steps.every((s) => s.status === "done" || s.status === "skipped"),
t.steps.every((s) => s.status === "done" || s.status === "skipped") &&
// FNXC:Lifecycle 2026-07-16-21:40:
// FN-8141 — do not auto-recover a skip-bypass-tainted task: its steps were skipped
// after a bulk-step-completion refusal with no accepted fn_task_done, so promoting it
// to in-review would launder unreviewed work. It stays in-progress until an accepted
// fn_task_done or operator retry clears the taint.
!evaluateSkipBypassTaint(t).blocked,
);
if (stuckCompleted.length === 0) return 0;
@@ -2925,6 +2931,14 @@ export class SelfHealingManager {
* FN-6461 keeps skipped-to-completion no-commits tasks out of the stranded-todo promoter so a finalize guard demotion cannot loop back into in-review before an operator fixes the incomplete work.
*/
if (evaluateNoCommitsNoOpFinalize(task).blocked) return false;
/*
* FNXC:Lifecycle 2026-07-16-21:40:
* FN-8141 — the stranded-todo promoter was the exact path that laundered FN-8141 into
* in-review. A skip-bypass-tainted task (steps skipped after a bulk-step-completion
* refusal with no accepted fn_task_done) must not promote here; the bounded
* requeue/park machinery converges it to a human instead.
*/
if (evaluateSkipBypassTaint(task).blocked) return false;
if (task.error) return false;
if (task.status && STRANDED_COMPLETED_TODO_ACTIVE_STATUSES.has(task.status)) return false;
if (task.reviewState?.refreshStatus === "refreshing") return false;