diff --git a/.changeset/skip-bypass-taint-guard.md b/.changeset/skip-bypass-taint-guard.md new file mode 100644 index 0000000000..abc25e3625 --- /dev/null +++ b/.changeset/skip-bypass-taint-guard.md @@ -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). diff --git a/packages/core/src/__tests__/manual-retry-reset.test.ts b/packages/core/src/__tests__/manual-retry-reset.test.ts index 6bba74d4ee..e48be99ddd 100644 --- a/packages/core/src/__tests__/manual-retry-reset.test.ts +++ b/packages/core/src/__tests__/manual-retry-reset.test.ts @@ -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 }); + }); }); diff --git a/packages/core/src/__tests__/skip-bypass-taint-guard.test.ts b/packages/core/src/__tests__/skip-bypass-taint-guard.test.ts new file mode 100644 index 0000000000..a3e0498c8e --- /dev/null +++ b/packages/core/src/__tests__/skip-bypass-taint-guard.test.ts @@ -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[] { + 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 }); + }); +}); diff --git a/packages/core/src/__tests__/skip-bypass-taint-persistence.test.ts b/packages/core/src/__tests__/skip-bypass-taint-persistence.test.ts new file mode 100644 index 0000000000..bb956e62e0 --- /dev/null +++ b/packages/core/src/__tests__/skip-bypass-taint-persistence.test.ts @@ -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(); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e917dcdce8..d272a114c6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -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, diff --git a/packages/core/src/manual-retry-reset.ts b/packages/core/src/manual-retry-reset.ts index 470c6c6b69..3db5ef0ed2 100644 --- a/packages/core/src/manual-retry-reset.ts +++ b/packages/core/src/manual-retry-reset.ts @@ -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) { diff --git a/packages/core/src/postgres/migrations/0000_initial.sql b/packages/core/src/postgres/migrations/0000_initial.sql index 4ecf24c125..d7c681f49a 100644 --- a/packages/core/src/postgres/migrations/0000_initial.sql +++ b/packages/core/src/postgres/migrations/0000_initial.sql @@ -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, diff --git a/packages/core/src/postgres/postgres-health.ts b/packages/core/src/postgres/postgres-health.ts index ae6d4baa1c..a85b6e198b 100644 --- a/packages/core/src/postgres/postgres-health.ts +++ b/packages/core/src/postgres/postgres-health.ts @@ -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" }, diff --git a/packages/core/src/postgres/schema/project.ts b/packages/core/src/postgres/schema/project.ts index 16cb2b4c1a..d55b4e7f07 100644 --- a/packages/core/src/postgres/schema/project.ts +++ b/packages/core/src/postgres/schema/project.ts @@ -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), diff --git a/packages/core/src/skip-bypass-taint-guard.ts b/packages/core/src/skip-bypass-taint-guard.ts new file mode 100644 index 0000000000..ab4b1507b4 --- /dev/null +++ b/packages/core/src/skip-bypass-taint-guard.ts @@ -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, +): 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 }; +} diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 576ffb6399..0f18ddeda2 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -1178,7 +1178,7 @@ export class TaskStore extends EventEmitter { } 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; 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 | 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; 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 | 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 { return updateTaskImpl(this, id, updates, runContext); } diff --git a/packages/core/src/task-store/persistence.ts b/packages/core/src/task-store/persistence.ts index 3701b406d2..085fe30799 100644 --- a/packages/core/src/task-store/persistence.ts +++ b/packages/core/src/task-store/persistence.ts @@ -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), diff --git a/packages/core/src/task-store/remaining-ops-2.ts b/packages/core/src/task-store/remaining-ops-2.ts index dbd244e397..2cebda0a4f 100644 --- a/packages/core/src/task-store/remaining-ops-2.ts +++ b/packages/core/src/task-store/remaining-ops-2.ts @@ -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", diff --git a/packages/core/src/task-store/remaining-ops-6.ts b/packages/core/src/task-store/remaining-ops-6.ts index 943d5bbc43..eda525e711 100644 --- a/packages/core/src/task-store/remaining-ops-6.ts +++ b/packages/core/src/task-store/remaining-ops-6.ts @@ -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; 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 | 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; 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 | 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 { /* FNXC:StateMachine 2026-07-07-12:00: diff --git a/packages/core/src/task-store/serialization.ts b/packages/core/src/task-store/serialization.ts index 7d68c22e24..72dee267fb 100644 --- a/packages/core/src/task-store/serialization.ts +++ b/packages/core/src/task-store/serialization.ts @@ -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, diff --git a/packages/core/src/task-store/task-row-mappers.ts b/packages/core/src/task-store/task-row-mappers.ts index 2bc8e06c37..1b1e673c3e 100644 --- a/packages/core/src/task-store/task-row-mappers.ts +++ b/packages/core/src/task-store/task-row-mappers.ts @@ -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", diff --git a/packages/core/src/task-store/task-update.ts b/packages/core/src/task-store/task-update.ts index 2a2c8b9123..3e1406d5fa 100644 --- a/packages/core/src/task-store/task-update.ts +++ b/packages/core/src/task-store/task-update.ts @@ -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) { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 8380469f11..7636406ff7 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -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 diff --git a/packages/engine/src/__tests__/executor-skip-bypass-taint.test.ts b/packages/engine/src/__tests__/executor-skip-bypass-taint.test.ts new file mode 100644 index 0000000000..9ee4b07de5 --- /dev/null +++ b/packages/engine/src/__tests__/executor-skip-bypass-taint.test.ts @@ -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); + }); +}); diff --git a/packages/engine/src/__tests__/self-healing.test.ts b/packages/engine/src/__tests__/self-healing.test.ts index 6c446466ab..9130955b93 100644 --- a/packages/engine/src/__tests__/self-healing.test.ts +++ b/packages/engine/src/__tests__/self-healing.test.ts @@ -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()); + + const managerWithRecovery = new SelfHealingManager(store, { + rootDir: "/tmp/test-project", + recoverCompletedTask: recoverFn, + getExecutingTaskIds: getExecuting, + }); + + (store.listTasks as ReturnType).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()); @@ -3078,8 +3108,10 @@ describe("SelfHealingManager", () => { getExecutingTaskIds: () => new Set(), }); - // 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).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(), + }); + + (store.listTasks as ReturnType).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(), + }); + + (store.listTasks as ReturnType).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", () => { diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index a349d61c57..4df7018cc3 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -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, +): Extract { + 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 { @@ -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 { + /* + 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, + ): ReturnType { + 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, { ok: false }>, + ): { bulkCompletionRefusalAt: string } | Record { + if (refusal.refusalClass !== "bulk-step-completion-without-review") return {}; + return { bulkCompletionRefusalAt: new Date().toISOString() }; + } + private async handleImplicitTaskDoneRefusal( task: Task, refusal: Extract, { 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)); diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index e74f0a4d22..abc88914f0 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -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;