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>
132 lines
4.9 KiB
TypeScript
132 lines
4.9 KiB
TypeScript
/*
|
|
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();
|
|
});
|
|
});
|