fix(engine): run every lane in the task worktree; contention is a wait, not a failure

Contention prevention (why tasks shared a path at all):
- Planning ran `tools: "coding"` at the repo root, so every planner had write
  tools in the operator's checkout and all planners shared one path. Planning
  now acquires the task's own worktree (TriageProcessor.acquirePlanningWorktree
  -> TaskExecutor.ensureTaskWorktreeForPlanning).
- Graph nodes with no worktree acquired one instead of falling back to rootDir,
  so Plan Review / Code Review / custom gates all run isolated. Plan Review
  re-acquires when its recorded worktree is gone, replacing FN-7996's
  run-from-the-repo-root degrade. Workspace projects are unchanged.
- Registration goes through acquireActiveSessionPath, which reclaims a leaked
  entry whose holder is provably dead and aged past the FN-5256 floor. A live
  holder still contends — real serialization is never clobbered.

Classification (the reported symptom):
- A lease held by another task is no longer a provider failure. It carries
  SESSION_CONTENTION_HOLD_VALUE, classifies transient, is excluded from
  isNonPlanDefectPlanReviewFailure, and stops burning the node's fast retries.
- The executor waits it out on a 10-attempt 5s->60s ladder and then leaves the
  task cleanly queued. There is no terminal branch: contention always ends, so
  parking would only ask a human to press Retry on a condition that fixed
  itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-25 14:59:53 -07:00
parent e6b2da6cae
commit 168819b35d
11 changed files with 699 additions and 41 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Planning and every review step now run in the task's own worktree, never the shared checkout.
category: fix
dev: Planning acquires the task worktree (TriageProcessor `acquirePlanningWorktree` → `TaskExecutor.ensureTaskWorktreeForPlanning`); graph nodes with no worktree acquire one instead of falling back to `rootDir`, and Plan Review re-acquires when its recorded worktree is gone (replacing the FN-7996 repo-root degrade). Registration goes through `acquireActiveSessionPath`, which reclaims a leaked entry whose holder is provably dead and aged past the FN-5256 floor. Remaining contention gets `SESSION_CONTENTION_HOLD_VALUE`: `isSessionContentionError` classifies it transient, `isNonPlanDefectPlanReviewFailure` explicitly excludes it, and the executor waits on a 10-attempt 5s→60s ladder that ends in a benign requeue, never a park.

View File

@@ -0,0 +1,131 @@
/*
FNXC:NodeWorktreeIsolation 2026-07-25-22:10 (no lane runs in the shared checkout — regression):
Operator requirement: Plan Review, Code Review, and every other node run in the TASK-SPECIFIC worktree;
the shared main checkout is for merge only. Before this, read-only graph gates fell back to
`this.rootDir` because a pre-execution task has no worktree yet. That is what let two tasks share one
path (the reported FN-1398/FN-1403 Plan Review session collision) and what let reviewers read a checkout
that other tasks and the operator mutate underneath them.
Invariant under test across the node surfaces that previously degraded to the root:
- Plan Review (no worktree yet) acquires and runs in a task worktree;
- a custom read-only gate (no worktree yet) does the same — this is not Plan-Review-special;
- an existing usable worktree is REUSED, not re-acquired;
- the acquisition is skipped for workspace projects, whose sessions are browse-root-rooted by design.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { TaskDetail } from "@fusion/core";
import "./executor-test-helpers.js";
import { TaskExecutor } from "../executor.js";
import {
createMockStore,
mockedExecSync,
mockedExistsSync,
resetExecutorMocks,
} from "./executor-test-helpers.js";
const ROOT = "/tmp/test";
function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
const now = new Date().toISOString();
return {
id: "FN-1403",
title: "Isolation",
description: "Desc",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
worktree: undefined,
branch: undefined,
status: null,
error: null,
paused: false,
userPaused: false,
createdAt: now,
updatedAt: now,
...overrides,
} as TaskDetail;
}
const PLAN_REVIEW_NODE = {
id: "plan-review-step",
kind: "prompt",
config: { name: "Plan Review", prompt: "Review the plan.", toolMode: "readonly" },
};
const CUSTOM_READONLY_GATE = {
id: "custom-gate",
kind: "prompt",
config: { name: "Custom Gate", prompt: "Check something.", toolMode: "readonly" },
};
describe("every workflow node runs in the task worktree, never the shared checkout", () => {
beforeEach(() => {
resetExecutorMocks();
mockedExecSync.mockReturnValue("" as any);
});
it.each([
["Plan Review", PLAN_REVIEW_NODE],
["a custom read-only gate", CUSTOM_READONLY_GATE],
])("acquires a task worktree for %s when the task has none", async (_label, node) => {
const store = createMockStore();
const executor = new TaskExecutor(store, ROOT);
mockedExistsSync.mockReturnValue(true);
const captured: { worktreePath?: string } = {};
vi.spyOn(executor as any, "executeWorkflowStep").mockImplementation(async (...args: any[]) => {
captured.worktreePath = args[2];
return { success: true, output: "APPROVE" };
});
const live = makeTask();
store.getTask.mockResolvedValue(live as any);
await (executor as any).runGraphCustomNode(node, live, { reviewerInlineFixes: false }, undefined);
expect(captured.worktreePath).not.toBe(ROOT);
expect(captured.worktreePath).toContain(`${ROOT}/.worktrees/`);
});
it("reuses an existing usable worktree instead of acquiring another", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, ROOT);
const existing = `${ROOT}/.worktrees/existing`;
mockedExistsSync.mockReturnValue(true);
const acquireSpy = vi.spyOn(executor as any, "ensureGraphCustomNodeWorktree");
const captured: { worktreePath?: string } = {};
vi.spyOn(executor as any, "executeWorkflowStep").mockImplementation(async (...args: any[]) => {
captured.worktreePath = args[2];
return { success: true, output: "APPROVE" };
});
const live = makeTask({ worktree: existing, branch: "fusion/fn-1403" });
store.getTask.mockResolvedValue(live as any);
await (executor as any).runGraphCustomNode(PLAN_REVIEW_NODE, live, {}, undefined);
expect(captured.worktreePath).toBe(existing);
expect(acquireSpy).not.toHaveBeenCalled();
});
it("leaves workspace projects on the shared browse-root (per-repo isolation is the sub-repo lease)", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, ROOT);
(executor as any).workspaceConfig = { repos: ["apps/web"] };
mockedExistsSync.mockReturnValue(true);
const acquireSpy = vi.spyOn(executor as any, "ensureGraphCustomNodeWorktree");
const captured: { worktreePath?: string } = {};
vi.spyOn(executor as any, "executeWorkflowStep").mockImplementation(async (...args: any[]) => {
captured.worktreePath = args[2];
return { success: true, output: "APPROVE" };
});
const live = makeTask();
store.getTask.mockResolvedValue(live as any);
await (executor as any).runGraphCustomNode(PLAN_REVIEW_NODE, live, {}, undefined);
expect(captured.worktreePath).toBe(ROOT);
expect(acquireSpy).not.toHaveBeenCalled();
});
});

View File

@@ -296,7 +296,14 @@ describe("Plan Review missing-worktree repo-root fallback (FN-7996)", () => {
mockedExecSync.mockReturnValue("" as any);
});
it("runs the Plan Review reviewer from the repo root when the recorded worktree is gone", async () => {
/*
FNXC:NodeWorktreeIsolation 2026-07-25-22:10:
FN-7996's invariant is unchanged — a missing recorded worktree must never terminal-park Plan Review —
but the remedy is no longer "run in the shared repo root". Every lane now runs in the TASK's own
worktree, so the reviewer RE-ACQUIRES one. The assertion below is the same symptom (stale path gone,
review still runs) with the shared checkout removed as an outcome.
*/
it("re-acquires a task worktree for Plan Review when the recorded worktree is gone (never the repo root)", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
mockedExistsSync.mockImplementation((path: unknown) => path !== "/tmp/stale-wt");
@@ -317,10 +324,13 @@ describe("Plan Review missing-worktree repo-root fallback (FN-7996)", () => {
const result = await (executor as any).runGraphCustomNode(node, live, {}, undefined);
expect(result.outcome).toBe("success");
expect(captured.worktreePath).toBe("/tmp/test");
// Not the stale path, and — the point of the change — not the shared repo root either.
expect(captured.worktreePath).not.toBe("/tmp/stale-wt");
expect(captured.worktreePath).not.toBe("/tmp/test");
expect(captured.worktreePath).toContain("/tmp/test/.worktrees/");
expect(store.logEntry).toHaveBeenCalledWith(
live.id,
expect.stringContaining("running the reviewer from the repo root"),
expect.stringContaining("re-acquiring a task worktree instead of running in the shared checkout"),
undefined,
undefined,
);

View File

@@ -0,0 +1,137 @@
/*
FNXC:SessionContention 2026-07-25-21:30 (contention is never a provider failure — regression):
Reported symptom: FN-1403's Plan Review hit "active-session path /home/ubuntu/dev/freemap-svelte is held
by task FN-1398; task FN-1403 may not overwrite it". The engine narrated that as "Plan Review provider
failure — retrying in place (2/2)", spent the provider budget on two immediate retries that could not
possibly clear another task's hold, then left the task parked.
Invariant under test, across every surface a contention error can reach:
1. the pure classifier recognizes all THREE contention shapes (active-session registry, workspace
sub-repo acquire lease, workspace sub-repo land lease) and treats them as transient;
2. the classifier does NOT swallow unrelated failures (negative control);
3. a contention error is NOT classified as a non-plan-defect PROVIDER failure by the Plan Review
classifier — that is the exact misrouting that burned the budget;
4. registration PREVENTS contention where it is preventable: a leaked entry whose holder is dead is
reclaimed, while a LIVE holder still contends (real serialization must not be clobbered), and a
too-fresh entry is never reclaimed even if the probe says dead (FN-5256 warming floor).
*/
import { describe, expect, it } from "vitest";
import {
isSessionContentionError,
isTransientError,
} from "../transient-error-patterns.js";
import { isNonPlanDefectPlanReviewFailure } from "../transient-error-detector.js";
import {
ActiveSessionRegistry,
acquireActiveSessionPath,
} from "../active-session-registry.js";
const REPORTED_MESSAGE =
"active-session path /home/ubuntu/dev/freemap-svelte is held by task FN-1398; task FN-1403 may not overwrite it";
const ACQUIRE_BUSY_MESSAGE = "workspace sub-repo apps/web acquisition is in progress for task FN-1398";
const LAND_BUSY_MESSAGE = "workspace sub-repo apps/web land is in progress for task FN-1398";
describe("session contention classification", () => {
const contentionShapes = [
["active-session registry (the reported failure)", REPORTED_MESSAGE],
["workspace sub-repo acquire lease", ACQUIRE_BUSY_MESSAGE],
["workspace sub-repo land lease", LAND_BUSY_MESSAGE],
] as const;
for (const [label, message] of contentionShapes) {
it(`classifies ${label} as contention and as transient`, () => {
expect(isSessionContentionError(message)).toBe(true);
// Transient is what makes every generic retry path wait instead of parking the task.
expect(isTransientError(message)).toBe(true);
});
it(`does not route ${label} into the Plan Review PROVIDER-failure hold`, () => {
/*
The provider hold gives two immediate retries and then parks. Contention must be recognized
before that classifier ever sees it, so the graph publishes SESSION_CONTENTION_HOLD_VALUE and the
executor waits on a backoff ladder instead.
*/
expect(
isNonPlanDefectPlanReviewFailure({ failureValue: "session-contention-hold", errorMessage: message }),
).toBe(false);
});
}
it("does not classify unrelated failures as contention", () => {
for (const message of [
"Plan Review returned malformed JSON",
"ENOENT: no such file or directory, open '/repo/PROMPT.md'",
"task FN-1398 is soft-deleted (deletedAt=2026-07-25) and cannot be read or mutated",
]) {
expect(isSessionContentionError(message)).toBe(false);
}
});
});
describe("contention prevention at the registration seam", () => {
const OTHER = { taskId: "FN-1403", kind: "workflow-step" as const, ownerKey: "FN-1403#workflow-step" };
const PATH = "/repo/.worktrees/shared";
function registryHeldBy(taskId: string, registeredAtOffsetMs: number, now: number): ActiveSessionRegistry {
const registry = new ActiveSessionRegistry();
registry.registerPath(PATH, { taskId, kind: "workflow-step", ownerKey: `${taskId}#workflow-step` });
// Rewind registeredAt so age gating can be exercised without real time.
(registry.lookupByPath(PATH) as { registeredAt: number }).registeredAt = now - registeredAtOffsetMs;
return registry;
}
it("reclaims a leaked entry whose holder is provably dead", () => {
const now = 1_000_000;
const registry = registryHeldBy("FN-1398", 60_000, now);
const outcome = acquireActiveSessionPath(registry, PATH, OTHER, {
holderLiveProbe: () => false,
now: () => now,
});
expect(outcome.action).toBe("reclaimed-stale-foreign");
expect(registry.lookupByPath(PATH)?.taskId).toBe("FN-1403");
});
it("contends (never clobbers) when the holder is still live", () => {
const now = 1_000_000;
const registry = registryHeldBy("FN-1398", 60_000, now);
const outcome = acquireActiveSessionPath(registry, PATH, OTHER, {
holderLiveProbe: () => true,
now: () => now,
});
expect(outcome).toMatchObject({ action: "contended", holderTaskId: "FN-1398" });
expect(registry.lookupByPath(PATH)?.taskId).toBe("FN-1398");
});
it("refuses to reclaim a freshly-registered entry even when the probe reports dead (warming floor)", () => {
const now = 1_000_000;
const registry = registryHeldBy("FN-1398", 10, now);
const outcome = acquireActiveSessionPath(registry, PATH, OTHER, {
holderLiveProbe: () => false,
now: () => now,
});
expect(outcome.action).toBe("contended");
expect(registry.lookupByPath(PATH)?.taskId).toBe("FN-1398");
});
it("treats an absent probe as LIVE so ambiguity never clobbers a holder", () => {
const now = 1_000_000;
const registry = registryHeldBy("FN-1398", 60_000, now);
const outcome = acquireActiveSessionPath(registry, PATH, OTHER, { now: () => now });
expect(outcome.action).toBe("contended");
});
it("registers normally on a free path and stays idempotent for the same task", () => {
const registry = new ActiveSessionRegistry();
expect(acquireActiveSessionPath(registry, PATH, OTHER).action).toBe("registered");
expect(acquireActiveSessionPath(registry, PATH, OTHER).action).toBe("registered");
expect(registry.pathsForTask("FN-1403")).toEqual([PATH]);
});
});

View File

@@ -181,6 +181,63 @@ export interface SelfOwnedReconcileOptions {
now?: () => number;
}
/*
FNXC:SessionContention 2026-07-25-21:30 (contention prevention — foreign-stale reclaim):
Registration contention has exactly three shapes, and only one of them is legitimate:
1. Two tasks on the SHARED repo root. Not real contention — read-only root-rooted sessions need no
path exclusivity. Eliminated by construction: `sessionRegistryPath` task-scopes the root key.
2. A LEAKED entry whose owning task is dead (crashed run, torn-down executor, engine restart that
lost the session but not the map). Waiting for that holder is waiting forever — the holder will
never release. Reclaim it, which is what this seam does.
3. A LIVE holder on the same path. This is genuine serialization (the workspace sub-repo leases are
built on it) and the caller must wait, never overwrite.
So: probe the holder for liveness, reclaim when it is provably dead AND the entry has aged past the
FN-5256 staleness floor (a just-registered entry belongs to a warming session whose maps are not
populated yet — treating it as dead would yank a live shell), and surface a typed contention error only
for case 3. `holderLiveProbe` returning true is always respected; an unknown/throwing probe must be
reported as LIVE by its caller so ambiguity refuses the reclaim.
*/
export type ForeignHolderLiveProbe = (holderTaskId: string, path: string) => boolean;
export type AcquireActiveSessionPathOutcome =
| { action: "registered" }
| { action: "reclaimed-stale-foreign"; holderTaskId: string; ageMs: number }
| { action: "contended"; holderTaskId: string; holderKind: ActiveSessionKind; ageMs: number };
export interface AcquireActiveSessionPathOptions {
/** Returns true when the foreign holder still has a live session/execution surface. */
holderLiveProbe?: ForeignHolderLiveProbe;
/** Minimum entry age before a foreign entry may be reclaimed. Defaults to `DEFAULT_SELF_OWNED_MIN_IDLE_MS`. */
minIdleMs?: number;
/** Test seam — defaults to `Date.now()`. */
now?: () => number;
}
export function acquireActiveSessionPath(
registry: ActiveSessionRegistry,
path: string,
registration: ActiveSessionRegistration,
options: AcquireActiveSessionPathOptions = {},
): AcquireActiveSessionPathOutcome {
const existing = registry.lookupByPath(path);
if (!existing || existing.taskId === registration.taskId) {
registry.registerPath(path, registration);
return { action: "registered" };
}
const now = options.now?.() ?? Date.now();
const ageMs = now - existing.registeredAt;
const minIdleMs = options.minIdleMs ?? DEFAULT_SELF_OWNED_MIN_IDLE_MS;
const holderIsLive = options.holderLiveProbe?.(existing.taskId, path) ?? true;
if (holderIsLive || ageMs < minIdleMs) {
return { action: "contended", holderTaskId: existing.taskId, holderKind: existing.kind, ageMs };
}
registry.unregisterPath(path);
registry.registerPath(path, registration);
return { action: "reclaimed-stale-foreign", holderTaskId: existing.taskId, ageMs };
}
export function reconcileSelfOwnedActiveSessionForRemoval(
registry: ActiveSessionRegistry,
worktreePath: string,

View File

@@ -44,6 +44,7 @@ import {
import {
MERGE_REGION_KINDS,
PLAN_REVIEW_PROVIDER_FAILURE_HOLD_VALUE,
SESSION_CONTENTION_HOLD_VALUE,
WORKFLOW_DRIFT_PARK_CONTEXT_KEY,
WORKFLOW_NODE_ENGINE_PAUSE_ABORT_KIND,
WORKFLOW_OPTIONAL_GROUP_CONTEXT_KEY,
@@ -133,9 +134,12 @@ import { attemptBranchAutocorrect } from "./branch-autocorrect.js";
import { ActiveSessionWorktreeRemovalError } from "./worktree-backend.js";
import {canonicalizeWorktreePath, registerArchiveWorkspaceWorktreeDisposer, registerArchiveWorktreeDisposer, registerTaskMoveDisposer} from "@fusion/core";
import {
ActiveSessionPathHeldByForeignTaskError,
acquireActiveSessionPath,
activeSessionRegistry,
executingTaskLock,
reconcileSelfOwnedActiveSessionForRemoval,
type ActiveSessionKind,
} from "./active-session-registry.js";
// CLI Agent Executor (U7): task ↔ CLI session orchestration seam.
import {
@@ -181,7 +185,7 @@ import { AgentLogger } from "./agent-logger.js";
import { createLogger, executorLog, reviewerLog, formatError } from "./logger.js";
import { TokenCapDetector } from "./token-cap-detector.js";
import { isUsageLimitError, checkSessionError, type UsageLimitPauser } from "./usage-limit-detector.js";
import { isNonContinuableSessionError, isNonPlanDefectPlanReviewFailure, isTransientError, isSilentTransientError } from "./transient-error-detector.js";
import { isNonContinuableSessionError, isNonPlanDefectPlanReviewFailure, isSessionContentionError, isTransientError, isSilentTransientError } from "./transient-error-detector.js";
import { withRateLimitRetry } from "./rate-limit-retry.js";
import {
detectExternalIntegrationEvidenceGaps,
@@ -514,6 +518,16 @@ import {
const MAX_TRANSIENT_GRAPH_RESUME_RETRIES = 2;
const TRANSIENT_GRAPH_RESUME_RETRY_BACKOFF_MS = process.env.VITEST || process.env.NODE_ENV === "test" ? 0 : 1_000;
/*
FNXC:SessionContention 2026-07-25-21:30:
The contention ladder is deliberately long and slow compared with the provider-failure budget (2 fast
retries): a lease is held for as long as the holder's own work takes — minutes, not milliseconds. Ten
attempts backing off 5s→60s covers ~8 minutes of waiting, after which the task is left queued for
ordinary re-dispatch rather than parked.
*/
const MAX_SESSION_CONTENTION_HOLD_RETRIES = 10;
const SESSION_CONTENTION_HOLD_BACKOFF_MS = process.env.VITEST || process.env.NODE_ENV === "test" ? 0 : 5_000;
const SESSION_CONTENTION_HOLD_MAX_BACKOFF_MS = 60_000;
/** How long to wait before recovering a completed task still stuck in in-progress. */
const COMPLETED_TASK_WATCHDOG_MS = 60_000;
/** How long to wait before retrying a workflow rerun handoff that never reached in-progress. */
@@ -1995,9 +2009,43 @@ export class TaskExecutor {
return worktreePath;
}
/*
FNXC:SessionContention 2026-07-25-21:30 (contention prevention at the registration seam):
Every executor session registration goes through `acquireActiveSessionPath` instead of the raw
`registerPath`, so a LEAKED entry owned by a task with no live session surface in this process is
RECLAIMED rather than throwing at the newcomer. That closes the second contention class (a dead
holder can never release, so waiting on it is waiting forever). A genuinely live holder still throws
the typed error — that case is real serialization, and callers classify it as a retryable contention
hold (SESSION_CONTENTION_HOLD_VALUE), never as a provider/model failure.
The probe reports LIVE on any uncertainty: an unknown holder with a fresh entry is treated as live by
the staleness floor, so the reclaim only ever fires on proven-dead, aged entries.
*/
private acquireSessionRegistryPath(taskId: string, registryPath: string, kind: ActiveSessionKind, ownerKey: string): void {
const outcome = acquireActiveSessionPath(activeSessionRegistry, registryPath, { taskId, kind, ownerKey }, {
holderLiveProbe: (holderTaskId) => this.hasLiveTaskSessionSurface(holderTaskId) || executingTaskLock.has(holderTaskId),
});
if (outcome.action === "contended") {
throw new ActiveSessionPathHeldByForeignTaskError(registryPath, outcome.holderTaskId, taskId);
}
if (outcome.action === "reclaimed-stale-foreign") {
executorLog.warn(
`${taskId}: reclaimed a stale active-session entry on ${registryPath} from dead task ${outcome.holderTaskId} (idle ${outcome.ageMs}ms)`,
);
void this.store.recordRunAuditEvent?.({
taskId,
agentId: "executor",
runId: generateSyntheticRunId("session-path-reclaim", taskId),
domain: "database",
mutationType: "session:reclaim-stale-foreign-path",
target: taskId,
metadata: { taskId, holderTaskId: outcome.holderTaskId, kind, ageMs: outcome.ageMs },
})?.catch?.(() => undefined);
}
}
private setActiveSession(taskId: string, sessionState: ActiveExecutorSessionState, worktreePath: string): void {
this.activeSessions.set(taskId, sessionState);
activeSessionRegistry.registerPath(this.sessionRegistryPath(taskId, worktreePath), { taskId, kind: "executor", ownerKey: taskId });
this.acquireSessionRegistryPath(taskId, this.sessionRegistryPath(taskId, worktreePath), "executor", taskId);
}
private markGraphExecuteSelfRequeued(taskId: string): void {
@@ -2023,7 +2071,7 @@ export class TaskExecutor {
private setActiveStepExecutor(taskId: string, stepExecutor: StepSessionExecutor, worktreePath: string, seenSteeringIds = new Set<string>()): void {
this.activeStepExecutors.set(taskId, stepExecutor);
this.activeStepExecutorSeenSteeringIds.set(taskId, seenSteeringIds);
activeSessionRegistry.registerPath(this.sessionRegistryPath(taskId, worktreePath), { taskId, kind: "step-session", ownerKey: `${taskId}#step-session` });
this.acquireSessionRegistryPath(taskId, this.sessionRegistryPath(taskId, worktreePath), "step-session", `${taskId}#step-session`);
}
private deleteActiveStepExecutor(taskId: string, worktreePath?: string): void {
@@ -2044,7 +2092,7 @@ export class TaskExecutor {
private setActiveWorkflowStepSession(taskId: string, session: AgentSession, worktreePath: string, seenSteeringIds = new Set<string>()): void {
this.activeWorkflowStepSessions.set(taskId, session);
this.activeWorkflowStepSessionSeenSteeringIds.set(taskId, seenSteeringIds);
activeSessionRegistry.registerPath(this.sessionRegistryPath(taskId, worktreePath), { taskId, kind: "workflow-step", ownerKey: `${taskId}#workflow-step` });
this.acquireSessionRegistryPath(taskId, this.sessionRegistryPath(taskId, worktreePath), "workflow-step", `${taskId}#workflow-step`);
}
private deleteActiveWorkflowStepSession(taskId: string, worktreePath?: string): void {
@@ -8390,6 +8438,37 @@ export class TaskExecutor {
}
}
/*
FNXC:NodeWorktreeIsolation 2026-07-25-22:10 (planning acquires the task worktree):
Public seam for the planning/triage lane. Specification runs a CODING-tool session; pointing it at
the shared main checkout meant every planning agent had write tools in the operator's tree and every
concurrent planner shared one path. Acquire the task's own worktree up front and let the whole
lifecycle — planning, Plan Review, implementation, code review — reuse that single worktree.
Returns null (caller falls back to the root, unchanged behavior) when the project is a workspace, or
when acquisition fails: planning must never be blocked by a worktree problem.
*/
public async ensureTaskWorktreeForPlanning(taskId: string): Promise<string | null> {
try {
if (this.workspaceConfig === undefined) {
this.workspaceConfig = await loadWorkspaceConfig(this.rootDir);
}
if (this.workspaceConfig && (this.workspaceConfig.repos.length ?? 0) > 0) return null;
const live = await this.store.getTask(taskId);
if (live.worktree && existsSync(live.worktree)) return live.worktree;
const settings = await this.store.getSettings();
const acquisitionTask = live.worktree
? ({ ...live, worktree: undefined, sessionFile: undefined } as TaskDetail)
: live;
const acquired = await this.ensureGraphCustomNodeWorktree(acquisitionTask, settings, "planning");
return acquired.worktree || null;
} catch (error) {
executorLog.warn(`${taskId}: could not acquire a planning worktree — planning falls back to the repo root: ${formatError(error)}`);
return null;
}
}
private async prepareGraphNodeExecution(
node: WorkflowIrNode,
nodeTask: TaskDetail,
@@ -8605,39 +8684,59 @@ export class TaskExecutor {
optionalGroupId,
reviewerInlineFixes: (settings as Settings & { reviewerInlineFixes?: boolean }).reviewerInlineFixes,
});
const executionTarget = writeCapable ? await this.store.getTask(live.id) : live;
let executionTarget = writeCapable ? await this.store.getTask(live.id) : live;
/*
FNXC:NodeWorktreeIsolation 2026-07-25-22:10 (EVERY node runs in the task's own worktree):
Operator requirement: Plan Review, Code Review — everything except merge — executes in the
task-specific worktree, never in the shared main checkout. Read-only gates used to fall back to
`this.rootDir` because a pre-execution task has no worktree yet, which is what made two tasks
share a path in the first place (the reported FN-1398/FN-1403 Plan Review collision) and what let
a reviewer read a main checkout that other tasks and the operator mutate underneath it.
ACQUIRE the worktree at planning time instead: `ensureGraphCustomNodeWorktree` is the same
acquisition the write-capable nodes already use, so the worktree/branch/baseCommitSha the
implementation session later resumes into is created once, here, and reused.
A recorded-but-missing worktree is RE-ACQUIRED (strip the stale metadata first, mirroring
prepareGraphNodeExecution) rather than degraded to the root — this replaces FN-7996's
run-Plan-Review-from-the-repo-root fallback, which is exactly the shared-path behavior being
removed. Workspace projects are unchanged: `ensureGraphCustomNodeWorktree` returns the task
untouched there, because workspace sessions are rooted at the browse-root by design and per-repo
isolation comes from the sub-repo acquire lease.
*/
const nodeDisplayName = typeof cfg.name === "string" && cfg.name.trim() ? cfg.name.trim() : node.id;
const isPlanReviewNode = node.id === "plan-review-step" || nodeDisplayName === "Plan Review" || optionalGroupId === "plan-review";
if (!this.workspaceConfig) {
const recordedWorktreeMissing = Boolean(executionTarget.worktree) && !existsSync(executionTarget.worktree!);
/*
A node with NO recorded worktree is pre-execution (planning / Plan Review): acquire one.
A node whose RECORDED worktree vanished is a different situation — for gates that review
implementation output, the work is gone with it, and handing them a fresh empty worktree would
let them review the wrong tree and pass. Those keep failing fast into the unusable-worktree
recovery (FN-7996). Plan Review is the exception: it reviews the store-injected PROMPT.md, so it
re-acquires rather than parking — this replaces its old "run from the repo root" degrade.
*/
const shouldAcquire = !executionTarget.worktree || (recordedWorktreeMissing && isPlanReviewNode);
if (shouldAcquire) {
if (recordedWorktreeMissing) {
await this.store.logEntry(
live.id,
`Plan Review worktree ${executionTarget.worktree} is missing on disk — re-acquiring a task worktree instead of running in the shared checkout`,
undefined,
this.getRunContextFor(live.id),
);
}
const acquisitionTask = recordedWorktreeMissing
? ({ ...executionTarget, worktree: undefined, sessionFile: undefined } as TaskDetail)
: executionTarget;
executionTarget = await this.ensureGraphCustomNodeWorktree(acquisitionTask, settings, node.id);
}
}
if (writeCapable && !executionTarget.worktree && !this.workspaceConfig) {
return { outcome: "failure", value: "no-worktree-for-write-node" };
}
/*
FNXC:PlanReviewWorktree 2026-07-16-18:30:
FN-7996: Plan Review runs pre-execution and reviews the store-injected PROMPT.md (see
FNXC:PlanReviewSpecInjection) — it does not need worktree contents at all. But it inherited
whatever stale task.worktree metadata survived earlier park/requeue cycles, and a recycled or
pruned path made session start refuse ("Refusing to start coding agent in missing worktree"),
terminal-parking the task. When the recorded worktree is absent on disk, run Plan Review from
the repo root instead. Scoped strictly to Plan Review: other read-only gates review
implementation diffs, so silently retargeting them to the root would review the wrong tree —
they keep failing fast and route through the unusable-worktree graph-failure recovery.
*/
const nodeDisplayName = typeof cfg.name === "string" && cfg.name.trim() ? cfg.name.trim() : node.id;
const isPlanReviewNode = node.id === "plan-review-step" || nodeDisplayName === "Plan Review" || optionalGroupId === "plan-review";
let worktreePath = executionTarget.worktree || this.rootDir;
if (
isPlanReviewNode
&& !writeCapable
&& executionTarget.worktree
&& !existsSync(executionTarget.worktree)
) {
await this.store.logEntry(
live.id,
`Plan Review worktree ${executionTarget.worktree} is missing on disk — running the reviewer from the repo root (spec is store-injected)`,
undefined,
this.getRunContextFor(live.id),
);
worktreePath = this.rootDir;
}
const worktreePath = executionTarget.worktree || this.rootDir;
let prompt = typeof cfg.prompt === "string" ? cfg.prompt : "";
let modelProvider = typeof cfg.modelProvider === "string" && cfg.modelProvider.trim() ? cfg.modelProvider : undefined;
let modelId = typeof cfg.modelId === "string" && cfg.modelId.trim() ? cfg.modelId : undefined;
@@ -9089,6 +9188,93 @@ export class TaskExecutor {
|| latestAction === "Resuming execution after unpause";
}
/*
FNXC:SessionContention 2026-07-25-21:30:
Two ways in, because contention must never slip through to a park:
- the typed failure value the graph now publishes (SESSION_CONTENTION_HOLD_VALUE), and
- a message-shape fallback over the run's `:error` context patches, so contention arriving from a
path that has not been taught the typed value is still recognized.
*/
private graphFailureErrorTexts(result: WorkflowGraphTaskRunResult): string[] {
if (!result.context) return [];
const texts: string[] = [];
for (const [key, value] of Object.entries(result.context)) {
if (key.endsWith(":error") && typeof value === "string" && value.trim()) texts.push(value);
}
return texts;
}
private isSessionContentionGraphFailure(result: WorkflowGraphTaskRunResult): boolean {
if (this.graphFailureValue(result) === SESSION_CONTENTION_HOLD_VALUE) return true;
return this.graphFailureErrorTexts(result).some((text) => isSessionContentionError(text));
}
/*
FNXC:SessionContention 2026-07-25-21:30 (self-recovering wait — the task is never parked):
Retry the graph in place on an exponential backoff while the holder finishes. The counter is
IN-MEMORY on purpose: it needs no schema change, and an engine restart resetting it is the desired
behavior (a restart also drops the in-process registry, so the contention is gone anyway).
When the ladder is exhausted the task is left cleanly dispatchable — status/error cleared, progress
untouched — so ordinary scheduling picks it up later with a fresh budget. There is no terminal branch
here by design: lease contention always ends (the holder finishes, or self-healing sweeps it), so
parking the task would only require a human to press Retry on a condition that fixed itself.
*/
private sessionContentionHoldAttempts = new Map<string, number>();
private clearSessionContentionHold(taskId: string): void {
this.sessionContentionHoldAttempts.delete(taskId);
}
private async holdForSessionContention(
task: Task,
live: TaskDetail,
result: WorkflowGraphTaskRunResult,
): Promise<void> {
const detail = this.graphFailureErrorTexts(result).find((text) => isSessionContentionError(text));
const priorAttempts = this.sessionContentionHoldAttempts.get(task.id) ?? 0;
const attempt = priorAttempts + 1;
if (attempt > MAX_SESSION_CONTENTION_HOLD_RETRIES) {
this.clearSessionContentionHold(task.id);
const message = `Still waiting on another task to release a shared session path after ${MAX_SESSION_CONTENTION_HOLD_RETRIES} attempts — leaving the task queued for normal re-dispatch (not a failure)${detail ? `: ${detail}` : ""}`;
executorLog.warn(`${task.id}: ${message}`);
await this.store.logEntry(task.id, message, undefined, this.getRunContextFor(task.id));
if (live.status != null || live.error != null) {
await this.store.updateTask(task.id, { status: null, error: null }, this.getRunContextFor(task.id));
}
return;
}
this.sessionContentionHoldAttempts.set(task.id, attempt);
const message = `Waiting on another task to release a shared session path — retrying in place (${attempt}/${MAX_SESSION_CONTENTION_HOLD_RETRIES})${detail ? `: ${detail}` : ""}`;
executorLog.warn(`${task.id}: ${message}`);
await this.store.logEntry(task.id, message, undefined, this.getRunContextFor(task.id));
// A contention hold is not a failure state: clear any stale park so the row never shows as failed
// while it is simply waiting its turn.
if (live.status != null || live.error != null) {
await this.store.updateTask(task.id, { status: null, error: null }, this.getRunContextFor(task.id));
}
const delayMs = SESSION_CONTENTION_HOLD_BACKOFF_MS === 0
? 0
: Math.min(SESSION_CONTENTION_HOLD_MAX_BACKOFF_MS, SESSION_CONTENTION_HOLD_BACKOFF_MS * 2 ** (attempt - 1));
const scheduleRetry = () => {
void (async () => {
try {
const resume = await this.store.getTask(task.id);
if (!resume || resume.deletedAt || resume.paused || resume.userPaused) {
this.clearSessionContentionHold(task.id);
return;
}
await this.execute(resume);
} catch (err) {
executorLog.error(`Failed session-contention retry for ${task.id}:`, err);
}
})();
};
setTimeout(scheduleRetry, delayMs).unref?.();
}
private graphFailureValue(result: WorkflowGraphTaskRunResult): string | undefined {
const failedNode = result.visitedNodeIds[result.visitedNodeIds.length - 1];
if (!failedNode || !result.context) return undefined;
@@ -9943,6 +10129,18 @@ export class TaskExecutor {
return;
}
/*
FNXC:SessionContention 2026-07-25-21:30:
Classified BEFORE every other graph-failure router. A node that could not start because another
task holds its session path or sub-repo lease is not a provider outage, not a plan defect, and not
a terminal failure — it is a wait. Route it to the self-recovering backoff hold, which never parks
the task and never consumes the provider/artifact retry budgets.
*/
if (this.isSessionContentionGraphFailure(result)) {
await this.holdForSessionContention(task, live, result);
await this.persistTokenUsage(task.id);
return;
}
/*
FNXC:MissingWorktreeRecovery 2026-07-16-18:25:
An unusable-worktree session-start refusal inside a graph node must route to the bounded
worktree-session recovery BEFORE any other classifier: FN-7977's provider-failure hold

View File

@@ -1092,6 +1092,9 @@ export class InProcessRuntime
usageLimitPauser: this.usageLimitPauser,
agentStore: this.agentStore,
pluginRunner: this.pluginRunner,
// FNXC:NodeWorktreeIsolation 2026-07-25-22:10: planning acquires (or reuses) the task's own
// worktree through the executor's acquisition path, so no lane runs in the shared checkout.
acquirePlanningWorktree: (taskId) => this.executor.ensureTaskWorktreeForPlanning(taskId),
onSpecifyStart: (t) => {
this.recordActivity();
runtimeLog.log(`Specifying ${t.id}...`);

View File

@@ -24,8 +24,8 @@ now live in the import-free leaf `transient-error-patterns.ts` so the merge clas
one definition of "transient" without inheriting this module's logger chain (FN-8004).
Re-exported here so every existing importer of this module keeps working unchanged.
*/
import { isTransientAuthCredentialError, isTransientError } from "./transient-error-patterns.js";
export { TRANSIENT_ERROR_PATTERNS, isTransientAuthCredentialError, isTransientError } from "./transient-error-patterns.js";
import { isSessionContentionError, isTransientAuthCredentialError, isTransientError } from "./transient-error-patterns.js";
export { TRANSIENT_ERROR_PATTERNS, SESSION_CONTENTION_PATTERNS, isSessionContentionError, isTransientAuthCredentialError, isTransientError } from "./transient-error-patterns.js";
/*
@@ -49,6 +49,20 @@ export function isNonPlanDefectPlanReviewFailure(input: {
}): boolean {
if (input.verdict === "REVISE") return false;
/*
FNXC:SessionContention 2026-07-25-21:30:
Contention is NOT a provider failure and must never take the provider hold, whose budget is two
immediate retries followed by a park — retrying instantly cannot clear a lease another task holds.
Excluded at the source so a caller that forgets the dedicated contention check still cannot misroute
it; contention has its own backoff hold (SESSION_CONTENTION_HOLD_VALUE).
*/
if (
input.failureValue?.trim().toLowerCase() === "session-contention-hold"
|| (input.errorMessage ? isSessionContentionError(input.errorMessage) : false)
) {
return false;
}
const failureValue = input.failureValue?.trim().toLowerCase();
if (failureValue === "exception" || failureValue === "aborted") return true;

View File

@@ -94,6 +94,40 @@ export const TRANSIENT_ERROR_PATTERNS: RegExp[] = [
/\bACP session has no live connection\b/i,
];
/*
FNXC:Reliability-ErrorClassification 2026-07-25-21:10 (session/lease contention is NOT a provider failure):
Fusion serializes work on a shared path with in-process leases: the activeSessionRegistry foreign-task
guard, the workspace sub-repo acquire lease, and the workspace sub-repo land lease. When a second task
contends, the holder throws one of these three messages. They are pure CONTENTION — another task is
mid-flight on the same path — so the only correct response is "wait and try again", never "this model /
provider / plan is broken".
Reported failure: a Plan Review collision was classified as a provider failure ("Plan Review provider
failure — retrying in place (2/2)"), retried twice with no delay against a hold retrying could not clear,
then parked the task with its budget spent. Matching these shapes as transient makes every generic
retry/requeue path (executor main session, merge classifier, durable-agent heartbeat recovery) wait it
out instead. Graph nodes get an explicit backoff hold on top — see SESSION_CONTENTION_HOLD_VALUE.
*/
export const SESSION_CONTENTION_PATTERNS: RegExp[] = [
// ActiveSessionPathHeldByForeignTaskError (active-session-registry.ts)
/active-session path\s+\S+\s+is held by task\s+\S+/i,
// WorkspaceRepoAcquireBusyError (worktree-acquisition.ts)
/workspace sub-repo\s+\S+\s+acquisition is in progress for task\s+\S+/i,
// WorkspaceRepoLandBusyError (merger-ai.ts)
/workspace sub-repo\s+\S+\s+land is in progress for task\s+\S+/i,
];
/**
* Detect a path/lease contention failure: another task holds the session path,
* sub-repo acquire lease, or sub-repo land lease this task wants. Always
* temporary — the holder releases when its own work finishes.
*/
export function isSessionContentionError(errorMessage: string): boolean {
if (!errorMessage || typeof errorMessage !== "string") {
return false;
}
return SESSION_CONTENTION_PATTERNS.some((pattern) => pattern.test(errorMessage));
}
/**
* Check if an error message indicates a transient network/infrastructure error.
*
@@ -120,6 +154,11 @@ export function isTransientError(errorMessage: string): boolean {
if (isTransientAuthCredentialError(errorMessage)) {
return true;
}
// FNXC:Reliability-ErrorClassification 2026-07-25-21:10: lease/session contention is transient by
// construction, so every generic retry path waits for the holder instead of parking the task.
if (isSessionContentionError(errorMessage)) {
return true;
}
return TRANSIENT_ERROR_PATTERNS.some((pattern) => pattern.test(errorMessage));
}

View File

@@ -209,6 +209,15 @@ export interface TriageProcessorOptions {
agentStore?: import("@fusion/core").AgentStore;
/** Plugin runner for runtime selection. When provided, enables plugin runtime lookup. */
pluginRunner?: import("./plugin-runner.js").PluginRunner;
/*
FNXC:NodeWorktreeIsolation 2026-07-25-22:10:
Acquires (or reuses) the task-specific worktree so the planning session runs there instead of in the
shared main checkout. Planning uses the CODING tool surface, so running it at the repo root gave every
planner write tools in the operator's tree and made concurrent planners share one path. Optional: when
unwired (older callers, tests) or when it resolves null (workspace projects, acquisition failure),
planning falls back to the repo root exactly as before.
*/
acquirePlanningWorktree?: (taskId: string) => Promise<string | null>;
}
/**
@@ -1756,11 +1765,23 @@ export class TriageProcessor {
advertising that writer in the prompt while running readonly stranded triage
on the original PROMPT.md stub and sent the stub into Plan Review.
*/
/*
FNXC:NodeWorktreeIsolation 2026-07-25-22:10:
Planning runs in the TASK's own worktree, not the shared main checkout. This session carries the
coding tool surface (see FNXC:TriagePromptPersistence above), so rooting it at `this.rootDir`
put write tools in the operator's tree and made every concurrent planner share one path — the
same shared-path shape behind the reported Plan Review session collision. The worktree acquired
here is the one Plan Review and the implementation session then reuse.
*/
const planningCwd = (await this.options.acquirePlanningWorktree?.(task.id).catch(() => null)) || this.rootDir;
if (planningCwd !== this.rootDir) {
await this.store.logEntry(task.id, `Planning session running in task worktree ${planningCwd}`).catch(() => undefined);
}
const { session } = await createResolvedAgentSession({
sessionPurpose: "triage",
runtimeHint: triageRuntimeHint,
pluginRunner: this.options.pluginRunner,
cwd: this.rootDir,
cwd: planningCwd,
systemPrompt: triageSystemPromptFinal,
systemPromptLayers: triageLayers,
tools: "coding",

View File

@@ -11,6 +11,7 @@ import type {
} from "@fusion/core";
import { BUILTIN_CODING_WORKFLOW_IR, PLAN_REVIEW_GROUP_ID, WorkflowIrError, getWorkflowExtensionRegistry, resolveMaxReworkCycles, isExperimentalFeatureEnabled, GRAPH_NATIVE_POST_MERGE_FLAG, isCompletionSummaryNode, classifyReviewLease, isWorkflowOptionalGroupEnabled } from "@fusion/core";
import { isNonPlanDefectPlanReviewFailure } from "./transient-error-detector.js";
import { isSessionContentionError } from "./transient-error-patterns.js";
import { isRequiredArtifactReadFailedValue, parseRequiredArtifactMissingValue } from "./required-workflow-artifacts.js";
import {
@@ -66,6 +67,16 @@ interleaving impossible by construction (exactly one reviewer per gate per attem
*/
export const PLAN_REVIEW_LEASE_HELD_VALUE = "plan-review-lease-held";
/*
FNXC:SessionContention 2026-07-25-21:30:
A node that failed because another task holds the session path / sub-repo lease it needs is CONTENDED,
not broken. It gets its own failure value so the executor can route it to a backoff hold that waits for
the holder, instead of falling into the generic `exception` bucket — where Plan Review's classifier read
it as a PROVIDER failure, retried twice with no delay, and parked the task once that budget was spent
(the reported FN-1398/FN-1403 collision). Applies to EVERY node, not just Plan Review.
*/
export const SESSION_CONTENTION_HOLD_VALUE = "session-contention-hold";
export type WorkflowNodeAbortKind = "engine-pause";
export const WORKFLOW_INTERRUPTED_NODE_ID_CONTEXT_KEY = "workflow:interruptedNodeId";
@@ -914,6 +925,27 @@ export class WorkflowGraphExecutor {
* operator-actionable errors must remain visible in place so completed
* execution work cannot bounce back to the planner column.
*/
/*
* FNXC:SessionContention 2026-07-25-21:30:
* Classified FIRST, and for EVERY optional group — not just Plan Review. A gate that could
* not start because another task holds its session path must neither traverse the failure
* edge to plan-replan (nothing about the plan is wrong) nor be labeled a provider failure
* (nothing about the provider is wrong). It becomes a contention hold the executor waits out.
*/
const sessionContentionFailure =
stepStatus === "failed"
&& (
verdictRaw === SESSION_CONTENTION_HOLD_VALUE
|| isSessionContentionError(stepOutput ?? stepNotes ?? "")
);
if (sessionContentionFailure) {
this.deps.logTaskEntry?.(
`${logPrefix} ${groupName} is waiting on another task to release its session path — holding in place`,
);
context[`node:${node.id}:outcome`] = "failure";
context[`node:${node.id}:value`] = SESSION_CONTENTION_HOLD_VALUE;
return { outcome: "failure", value: SESSION_CONTENTION_HOLD_VALUE };
}
const nonPlanDefectPlanReviewFailure =
node.id === PLAN_REVIEW_GROUP_ID
&& stepStatus === "failed"
@@ -1482,6 +1514,13 @@ export class WorkflowGraphExecutor {
} catch (error) {
if (signal?.aborted) return this.withEnginePauseAbortContext(node, { outcome: "failure", value: "aborted" });
lastError = error;
/*
FNXC:SessionContention 2026-07-25-21:30:
Immediate in-loop retries cannot clear a lease another task holds — they just burn the node's
attempt budget in milliseconds and erase the diagnostic behind a generic `exception`. Stop
retrying here and hand the executor a typed contention failure it can back off on.
*/
if (isSessionContentionError(error instanceof Error ? error.message : String(error))) break;
}
}
@@ -1513,11 +1552,13 @@ export class WorkflowGraphExecutor {
}
return degraded;
}
const lastErrorText = lastError instanceof Error ? lastError.message : String(lastError);
const failureResult: WorkflowNodeResult = {
outcome: "failure",
value: "exception",
// FNXC:SessionContention 2026-07-25-21:30: contention is a retryable hold, not an exception.
value: isSessionContentionError(lastErrorText) ? SESSION_CONTENTION_HOLD_VALUE : "exception",
contextPatch: {
[`node:${node.id}:error`]: lastError instanceof Error ? lastError.message : String(lastError),
[`node:${node.id}:error`]: lastErrorText,
},
};
if (recordProgress && this.shouldRecordNodeProgress(node)) {