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:
7
.changeset/skip-bypass-taint-guard.md
Normal file
7
.changeset/skip-bypass-taint-guard.md
Normal 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).
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
76
packages/core/src/__tests__/skip-bypass-taint-guard.test.ts
Normal file
76
packages/core/src/__tests__/skip-bypass-taint-guard.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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),
|
||||
|
||||
58
packages/core/src/skip-bypass-taint-guard.ts
Normal file
58
packages/core/src/skip-bypass-taint-guard.ts
Normal 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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user