feat(workspace): Phase A U1 — executor session scoping for workspace mode

In workspace mode (loadWorkspaceConfig present), the executor now skips the
root acquireTaskWorktree({rootDir}) and every intervening rootDir git preflight
(base-commit capture, contamination, identity-guard, verifyWorktreeInvariants),
runs the agent session rooted at the non-git workspace root (cwd=rootDir,
browse-only; task.worktree never set), and tracks activeWorktrees as a per-task
Set<path>. scopePromptToWorktree is a no-op in workspace mode. The non-workspace
path is unchanged (every change branches on this.workspaceConfig; a single-repo
task holds a one-element Set).

Converted every activeWorktrees consumer to membership semantics (feasibility-
verified list): findActiveWorktreeOwner, hasActiveWorktreeBinding, the FN-6736
phantom-binding reclaim, listWorktreeHolders (flat-maps a Set into N holder rows
— verified the FN-6782 reaper keys off taskId only, so slot accounting is
unaffected), the conflict-set iteration, the three deleteActive* unregister
resolvers (loop every path), cleanup, getWorktreePath (undefined for a
multi-worktree workspace task), and the verifyWorktreeInvariants singular
resolution (gated off in workspace mode — per-repo verify returns in Phase B).

Rewrote executor-workspace.test.ts from vi.mock-the-subject to a real two-repo
git fixture harness (_workspace-fixture.ts, shared with later units), 13 tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-21 21:07:20 -07:00
parent 316d2659b8
commit 09bd01baf0
12 changed files with 524 additions and 135 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
Workspace mode Phase A (U1): executor session scoping. In workspace mode the executor now skips the root worktree acquisition and every rootDir git preflight (base-commit capture, contamination, worktree-liveness), runs the agent session rooted at the browse-only workspace root, and tracks acquired sub-repo worktrees as a per-task set. Single-repo tasks are unchanged (one-element set, byte-for-byte preflight parity).

View File

@@ -0,0 +1,66 @@
/*
FNXC:Workspace 2026-06-21-12:00:
Shared REAL two-repo git fixture for workspace-mode engine tests (U1 + U2 + later phases). The foundation's executor-workspace test self-mocked the functions under test, which proves nothing; this harness instead builds genuine on-disk git repos under a NON-git workspace root so that any leaked rootDir git preflight actually fails. U2 and later units import `createWorkspaceFixture` directly — keep it dependency-light (only node:child_process + node:fs + saveWorkspaceConfig).
A workspace root is a plain directory (NOT a git repo) containing N sub-repos. Each sub-repo is a real git repo with an initial commit on a default branch. `<root>/.fusion/workspace.json` lists the sub-repo relative paths so `loadWorkspaceConfig(root)` returns a populated config — the exact signal `this.workspaceConfig` keys off in the executor.
*/
import { execSync, spawnSync } from "node:child_process";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { saveWorkspaceConfig } from "@fusion/core";
export const hasGit = spawnSync("git", ["--version"], { stdio: "pipe" }).status === 0;
function git(repo: string, command: string): string {
return execSync(command, { cwd: repo, encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
}
/** Initialize a real git repo at `repoDir` with one commit on `defaultBranch`. */
export function initRepoWithCommit(repoDir: string, defaultBranch = "main"): void {
mkdirSync(repoDir, { recursive: true });
git(repoDir, `git init -b ${defaultBranch}`);
git(repoDir, 'git config user.email "test@example.com"');
git(repoDir, 'git config user.name "Test"');
writeFileSync(path.join(repoDir, "README.md"), `# ${path.basename(repoDir)}\n`, "utf-8");
git(repoDir, "git add README.md");
git(repoDir, "git commit -m 'init'");
}
export interface WorkspaceFixture {
/** Absolute path to the non-git workspace root. */
rootDir: string;
/** Relative sub-repo paths (workspace.json `repos`). */
repos: string[];
/** Absolute path to a sub-repo by relative name. */
repoPath(rel: string): string;
/** Run a git command inside a sub-repo. */
git(rel: string, command: string): string;
/** Remove all on-disk fixture state. */
cleanup(): void;
}
/**
* Create a real two-repo (by default) workspace fixture on disk.
* - `rootDir` is a plain non-git directory.
* - Each `repos[i]` is a real git repo with an initial commit.
* - `<root>/.fusion/workspace.json` is written so loadWorkspaceConfig() resolves.
*/
export async function createWorkspaceFixture(
repos: string[] = ["repo-a", "repo-b"],
defaultBranch = "main",
): Promise<WorkspaceFixture> {
const rootDir = mkdtempSync(path.join(os.tmpdir(), "fusion-workspace-"));
for (const rel of repos) {
initRepoWithCommit(path.join(rootDir, rel), defaultBranch);
}
await saveWorkspaceConfig(rootDir, { repos });
return {
rootDir,
repos,
repoPath: (rel: string) => path.join(rootDir, rel),
git: (rel: string, command: string) => git(path.join(rootDir, rel), command),
cleanup: () => rmSync(rootDir, { recursive: true, force: true }),
};
}

View File

@@ -88,7 +88,7 @@ describe("pause-abort benign requeue-to-todo (FN-6782)", () => {
// executor must retry the agent session in place rather than bouncing the
// task through todo (and must not fire a failure notification).
const { store, task, executor } = makeHarness({ column: "todo" });
(executor as any).activeWorktrees.set(task.id, task.worktree);
(executor as any).addActiveWorktree(task.id, task.worktree);
const executeSpy = vi
.spyOn(executor as any, "execute")
.mockResolvedValue(undefined);
@@ -137,7 +137,7 @@ describe("pause-abort benign requeue-to-todo (FN-6782)", () => {
// and a retry scheduled); the task then changes state before the timer
// fires, and the fire-time re-fetch must abort the dispatch.
const { store, task, executor } = makeHarness({ column: "todo" });
(executor as any).activeWorktrees.set(task.id, task.worktree);
(executor as any).addActiveWorktree(task.id, task.worktree);
const executeSpy = vi.spyOn(executor as any, "execute").mockResolvedValue(undefined);
await invokeGraphFailure(executor, task);
@@ -164,7 +164,7 @@ describe("pause-abort benign requeue-to-todo (FN-6782)", () => {
status: "failed",
error: "Workflow graph failure surfaced after paused engine abort during pause/resume",
});
(executor as any).activeWorktrees.set(task.id, task.worktree);
(executor as any).addActiveWorktree(task.id, task.worktree);
const executeSpy = vi.spyOn(executor as any, "execute").mockResolvedValue(undefined);
await invokeGraphFailure(executor, task);
@@ -197,7 +197,7 @@ describe("pause-abort benign requeue-to-todo (FN-6782)", () => {
// pause that ended up in todo must stay parked-benign and wait for
// explicit resume — auto-resuming it would override the operator's intent.
const { store, task, executor } = makeHarness(overrides, provenance);
(executor as any).activeWorktrees.set(task.id, task.worktree);
(executor as any).addActiveWorktree(task.id, task.worktree);
const executeSpy = vi.spyOn(executor as any, "execute").mockResolvedValue(undefined);
await invokeGraphFailure(executor, task);
@@ -217,7 +217,7 @@ describe("pause-abort benign requeue-to-todo (FN-6782)", () => {
column: "todo",
graphResumeRetryCount: 2,
});
(executor as any).activeWorktrees.set(task.id, task.worktree);
(executor as any).addActiveWorktree(task.id, task.worktree);
const executeSpy = vi
.spyOn(executor as any, "execute")
.mockResolvedValue(undefined);
@@ -246,7 +246,7 @@ describe("pause-abort benign requeue-to-todo (FN-6782)", () => {
status: "failed",
error: "Workflow graph failure surfaced after paused engine abort during pause/resume",
});
(executor as any).activeWorktrees.set(task.id, task.worktree);
(executor as any).addActiveWorktree(task.id, task.worktree);
await invokeGraphFailure(executor, task);

View File

@@ -605,7 +605,7 @@ describe("TaskExecutor bounded recovery retries", () => {
(executor as any).executing.add(taskId);
executingTaskLock.tryClaim(taskId);
(executor as any).activeWorktrees.set(taskId, worktreePath);
(executor as any).addActiveWorktree(taskId, worktreePath);
(executor as any).activeSessions.set(taskId, { session });
(executor as any).activeStepExecutors.set(taskId, stepExecutor);
(executor as any).activeWorkflowStepSessions.set(taskId, workflowSession);
@@ -686,7 +686,7 @@ describe("TaskExecutor bounded recovery retries", () => {
});
(executor as any).executing.add("FN-001");
executingTaskLock.tryClaim("FN-001");
(executor as any).activeWorktrees.set("FN-001", "/tmp/test/.worktrees/FN-001");
(executor as any).addActiveWorktree("FN-001", "/tmp/test/.worktrees/FN-001");
(executor as any).activeSessions.set("FN-001", { session });
executor.markStuckAborted("FN-001", true);
@@ -750,7 +750,7 @@ describe("TaskExecutor bounded recovery retries", () => {
vi.mocked(removeWorktree).mockRejectedValue(new Error("worktree busy"));
(executor as any).executing.add("FN-001");
executingTaskLock.tryClaim("FN-001");
(executor as any).activeWorktrees.set("FN-001", "/tmp/test/.worktrees/FN-001");
(executor as any).addActiveWorktree("FN-001", "/tmp/test/.worktrees/FN-001");
(executor as any).activeSessions.set("FN-001", { session });
executor.markStuckAborted("FN-001", true);
@@ -797,7 +797,7 @@ describe("TaskExecutor bounded recovery retries", () => {
vi.mocked(removeWorktree).mockResolvedValue(undefined as any);
(executor as any).executing.add("FN-001");
executingTaskLock.tryClaim("FN-001");
(executor as any).activeWorktrees.set("FN-001", "/tmp/test/.worktrees/FN-001");
(executor as any).addActiveWorktree("FN-001", "/tmp/test/.worktrees/FN-001");
(executor as any).activeSessions.set("FN-001", { session });
executor.markStuckAborted("FN-001", true);

View File

@@ -0,0 +1,120 @@
/*
FNXC:Workspace 2026-06-21-12:00:
U1 session-cwd scenarios that require driving the real TaskExecutor.execute() to the agent-session boundary. Uses the shared executor-test-helpers harness — it mocks the AI/session/git/fs seams (NOT the workspace gating, NOT acquireTaskWorktree), so setting `(executor as any).workspaceConfig` exercises the genuine KTD1 gate: root acquisition is skipped, and every agent session (initial + retry) is created with `cwd === rootDir` (browse-only workspace root). The non-workspace path is the regression control (cwd === the acquired worktree path).
*/
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
import "./executor-test-helpers.js";
import { TaskExecutor } from "../executor.js";
import { acquireTaskWorktree } from "../worktree-acquisition.js";
import type { WorkspaceConfig } from "@fusion/core";
import {
createMockStore,
mockedCreateFnAgent,
mockedExecSync,
resetExecutorMocks,
} from "./executor-test-helpers.js";
vi.mock("../worktree-acquisition.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../worktree-acquisition.js")>();
return { ...actual, acquireTaskWorktree: vi.fn(actual.acquireTaskWorktree) };
});
const mockedAcquireTaskWorktree = vi.mocked(acquireTaskWorktree);
const ROOT = "/tmp/workspace-root";
function inProgressTask(overrides: Record<string, unknown> = {}) {
return {
id: "FN-001",
title: "Test",
description: "Test",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
} as any;
}
describe("U1 KTD1 — session cwd is the browse-only workspace root", () => {
beforeEach(() => {
resetExecutorMocks();
// Make any accidental git invocation observable: empty stdout keeps real-git
// helpers from throwing, but acquireTaskWorktree assertions catch a leak.
mockedExecSync.mockReturnValue("");
});
afterEach(() => vi.restoreAllMocks());
it("skips root acquireTaskWorktree and creates every session (initial + retry) with cwd === rootDir", async () => {
const store = createMockStore();
const mockPrompt = vi.fn().mockResolvedValue(undefined); // no fn_task_done → drives retries too
mockedCreateFnAgent.mockResolvedValue({
session: { prompt: mockPrompt, dispose: vi.fn() },
sessionFile: "/tmp/sessions/ws.jsonl",
} as any);
const executor = new TaskExecutor(store, ROOT);
// Drive the genuine workspace gate (loadWorkspaceConfig is covered elsewhere).
(executor as any).workspaceConfig = { repos: ["repo-a", "repo-b"] } as WorkspaceConfig;
await executor.execute(inProgressTask({ worktree: null }));
// KTD1: the non-git root is never acquired as a worktree.
expect(mockedAcquireTaskWorktree).not.toHaveBeenCalled();
// Every agent session (initial + the retries fired because fn_task_done was
// never called) is rooted at the workspace root.
expect(mockedCreateFnAgent.mock.calls.length).toBeGreaterThanOrEqual(2);
for (const call of mockedCreateFnAgent.mock.calls) {
expect((call[0] as any).cwd).toBe(ROOT);
}
// task.worktree is never set in workspace mode.
const worktreeWrites = (store.updateTask as any).mock.calls.filter(
(c: any[]) => c[1] && Object.prototype.hasOwnProperty.call(c[1], "worktree") && c[1].worktree,
);
expect(worktreeWrites).toHaveLength(0);
});
});
describe("U1 regression — non-workspace task acquires a worktree and roots the session there", () => {
beforeEach(() => {
resetExecutorMocks();
mockedExecSync.mockReturnValue("");
});
afterEach(() => vi.restoreAllMocks());
it("calls acquireTaskWorktree and creates the session with cwd === the acquired worktree path", async () => {
const store = createMockStore();
const ACQUIRED = "/tmp/test/.worktrees/swift-falcon";
mockedAcquireTaskWorktree.mockResolvedValue({
worktreePath: ACQUIRED,
branch: "fusion/fn-001",
source: "fresh",
hydrated: false,
isResume: false,
});
const mockPrompt = vi.fn().mockResolvedValue(undefined);
mockedCreateFnAgent.mockResolvedValue({
session: { prompt: mockPrompt, dispose: vi.fn() },
sessionFile: "/tmp/sessions/ns.jsonl",
} as any);
const executor = new TaskExecutor(store, "/tmp/test");
// No workspaceConfig → single-repo path. Pin the lazy-load guard so the real
// loader is never consulted (it would return null for /tmp/test anyway).
(executor as any).workspaceConfig = null;
await executor.execute(inProgressTask({ worktree: null }));
expect(mockedAcquireTaskWorktree).toHaveBeenCalledTimes(1);
expect(mockedCreateFnAgent.mock.calls.length).toBeGreaterThanOrEqual(1);
for (const call of mockedCreateFnAgent.mock.calls) {
expect((call[0] as any).cwd).toBe(ACQUIRED);
}
});
});

View File

@@ -1,89 +1,220 @@
// @ts-nocheck
import { describe, it, expect, vi, beforeEach } from "vitest";
import { loadWorkspaceConfig } from "@fusion/core";
import { acquireWorkspaceRepoWorktree } from "../worktree-acquisition.js";
/*
FNXC:Workspace 2026-06-21-12:00:
U1 executor session-scoping tests. REWRITTEN from the foundation's self-mocking version (which vi.mock'd the very functions under test and proved nothing). These tests use a REAL two-repo git fixture (`createWorkspaceFixture`) under a NON-git workspace root, so a leaked rootDir git preflight would actually fail. They drive the real TaskExecutor methods that U1 changed: the activeWorktrees Set conversion + every enumerated consumer (KTD2), the preflight gate + browse-only-root scoping (KTD1), and the synthetic-acquisition cwd.
vi.mock("@fusion/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@fusion/core")>();
Seam choice (FN-5048): `(executor as any).workspaceConfig` is set directly to drive the gating with real git — loadWorkspaceConfig is covered by its own unit and is not the subject here. No mock-the-world child_process/fs shell.
*/
import { afterEach, describe, expect, it, vi } from "vitest";
import { EventEmitter } from "node:events";
import { loadWorkspaceConfig, type Task, type TaskStore, type WorkspaceConfig } from "@fusion/core";
import { TaskExecutor, buildExecutionPrompt } from "../executor.js";
import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js";
const describeIfGit = hasGit ? describe : describe.skip;
function createStore(overrides: Partial<Record<string, unknown>> = {}): TaskStore & EventEmitter {
const emitter = new EventEmitter();
return Object.assign(emitter, {
updateTask: vi.fn().mockResolvedValue(undefined),
logEntry: vi.fn().mockResolvedValue(undefined),
getSettings: vi.fn().mockResolvedValue({ autoMerge: false }),
on: emitter.on.bind(emitter),
...overrides,
}) as unknown as TaskStore & EventEmitter;
}
function makeTask(id = "FN-WS-1", overrides: Partial<Task> = {}): Task {
return {
...actual,
loadWorkspaceConfig: vi.fn(),
};
});
id,
title: "Workspace task",
description: "",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
} as Task;
}
vi.mock("../worktree-acquisition.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../worktree-acquisition.js")>();
return {
...actual,
acquireWorkspaceRepoWorktree: vi.fn(),
};
});
const repoAPath = (fx: WorkspaceFixture) => `${fx.repoPath("repo-a")}/.worktrees/fn-ws-1`;
const repoBPath = (fx: WorkspaceFixture) => `${fx.repoPath("repo-b")}/.worktrees/fn-ws-1`;
const mockedLoadWorkspaceConfig = vi.mocked(loadWorkspaceConfig);
const mockedAcquireWorkspaceRepoWorktree = vi.mocked(acquireWorkspaceRepoWorktree);
describeIfGit("workspace fixture", () => {
let fx: WorkspaceFixture;
afterEach(() => fx?.cleanup());
const MOCK_WORKSPACE_CONFIG = {
repos: ["wolf-server", "wolf-community-frontend-1"],
};
describe("acquireWorkspaceRepoWorktree", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("returns alreadyAcquired=false for a fresh repo", async () => {
mockedAcquireWorkspaceRepoWorktree.mockResolvedValueOnce({
worktreePath: "/workspace/wolf-server/.worktrees/fn-001-abc",
branch: "fusion/fn-001",
alreadyAcquired: false,
});
const result = await acquireWorkspaceRepoWorktree({
repoRelPath: "wolf-server",
workspaceRootDir: "/workspace",
task: { id: "FN-001", workspaceWorktrees: undefined } as never,
store: { getTask: vi.fn(), updateTask: vi.fn(), logEntry: vi.fn() } as never,
settings: {},
});
expect(result.alreadyAcquired).toBe(false);
expect(result.worktreePath).toContain("wolf-server");
});
it("returns alreadyAcquired=true when worktree already acquired", async () => {
mockedAcquireWorkspaceRepoWorktree.mockResolvedValueOnce({
worktreePath: "/workspace/wolf-server/.worktrees/fn-001-abc",
branch: "fusion/fn-001",
alreadyAcquired: true,
});
const result = await acquireWorkspaceRepoWorktree({
repoRelPath: "wolf-server",
workspaceRootDir: "/workspace",
task: {
id: "FN-001",
workspaceWorktrees: {
"wolf-server": { worktreePath: "/workspace/wolf-server/.worktrees/fn-001-abc", branch: "fusion/fn-001" },
},
} as never,
store: { getTask: vi.fn(), updateTask: vi.fn(), logEntry: vi.fn() } as never,
settings: {},
});
expect(result.alreadyAcquired).toBe(true);
it("builds a non-git root with two real git sub-repos and a resolvable workspace config", async () => {
fx = await createWorkspaceFixture();
// Root is NOT a git repo.
expect(() => fx.git("..", "git rev-parse --git-dir")).toThrow();
// Each sub-repo is a real git repo with a commit on main.
expect(fx.git("repo-a", "git rev-parse --abbrev-ref HEAD")).toBe("main");
expect(fx.git("repo-b", "git rev-list --count HEAD")).toBe("1");
// loadWorkspaceConfig resolves the on-disk config the executor keys off.
const config = await loadWorkspaceConfig(fx.rootDir);
expect(config?.repos).toEqual(["repo-a", "repo-b"]);
});
});
describe("workspace config", () => {
it("loadWorkspaceConfig returns null for non-workspace", async () => {
mockedLoadWorkspaceConfig.mockResolvedValueOnce(null);
const config = await loadWorkspaceConfig("/some/single-repo");
expect(config).toBeNull();
describeIfGit("U1 KTD2 — activeWorktrees Set + every enumerated consumer", () => {
let fx: WorkspaceFixture;
afterEach(() => fx?.cleanup());
function workspaceExecutor() {
fx ??= undefined as never;
const store = createStore();
const executor = new TaskExecutor(store, fx.rootDir);
(executor as any).workspaceConfig = { repos: fx.repos } as WorkspaceConfig;
return executor;
}
it("a workspace task holding TWO sub-repo paths is found by membership, not equality", async () => {
fx = await createWorkspaceFixture();
const executor = workspaceExecutor();
const pA = repoAPath(fx);
const pB = repoBPath(fx);
(executor as any).addActiveWorktree("FN-WS-1", pA);
(executor as any).addActiveWorktree("FN-WS-1", pB);
// hasActiveWorktreeBinding: both held paths match; an unheld path does not.
expect((executor as any).hasActiveWorktreeBinding("FN-WS-1", pA)).toBe(true);
expect((executor as any).hasActiveWorktreeBinding("FN-WS-1", pB)).toBe(true);
expect((executor as any).hasActiveWorktreeBinding("FN-WS-1", "/nope")).toBe(false);
// findActiveWorktreeOwner: another task asking about either held path finds FN-WS-1.
await expect((executor as any).findActiveWorktreeOwner(pA, "FN-OTHER")).resolves.toBe("FN-WS-1");
await expect((executor as any).findActiveWorktreeOwner(pB, "FN-OTHER")).resolves.toBe("FN-WS-1");
// The owner itself is excluded.
await expect((executor as any).findActiveWorktreeOwner(pA, "FN-WS-1")).resolves.toBeNull();
});
it("loadWorkspaceConfig returns config for workspace", async () => {
mockedLoadWorkspaceConfig.mockResolvedValueOnce(MOCK_WORKSPACE_CONFIG);
const config = await loadWorkspaceConfig("/some/workspace");
expect(config?.repos).toEqual(["wolf-server", "wolf-community-frontend-1"]);
it("listWorktreeHolders flat-maps the Set into N holder rows for one task", async () => {
fx = await createWorkspaceFixture();
const executor = workspaceExecutor();
const pA = repoAPath(fx);
const pB = repoBPath(fx);
(executor as any).addActiveWorktree("FN-WS-1", pA);
(executor as any).addActiveWorktree("FN-WS-1", pB);
const holders = executor.listWorktreeHolders();
expect(holders).toHaveLength(2);
expect(holders).toContainEqual({ taskId: "FN-WS-1", worktreePath: pA });
expect(holders).toContainEqual({ taskId: "FN-WS-1", worktreePath: pB });
});
it("shouldGenerateNewWorktreeName iterates the Set (conflict membership)", async () => {
fx = await createWorkspaceFixture();
const store = createStore({ listTasks: vi.fn().mockResolvedValue([]) });
const executor = new TaskExecutor(store, fx.rootDir);
(executor as any).workspaceConfig = { repos: fx.repos } as WorkspaceConfig;
const pA = repoAPath(fx);
(executor as any).addActiveWorktree("FN-HOLDER", pA);
// A different task contending for FN-HOLDER's path must be told to generate a new name.
await expect((executor as any).shouldGenerateNewWorktreeName(pA, "FN-WS-1")).resolves.toBe(true);
// The holder asking about its own path is not a conflict (excluded), and the
// DB liveness fallback returns no other user.
await expect((executor as any).shouldGenerateNewWorktreeName(pA, "FN-HOLDER")).resolves.toBe(false);
});
it("getWorktreePath returns undefined for a multi-worktree workspace task (Set-collapse contract)", async () => {
fx = await createWorkspaceFixture();
const executor = workspaceExecutor();
(executor as any).addActiveWorktree("FN-WS-1", repoAPath(fx));
(executor as any).addActiveWorktree("FN-WS-1", repoBPath(fx));
expect(executor.getWorktreePath("FN-WS-1")).toBeUndefined();
});
it("cleanup drops in-memory tracking in workspace mode but never removes the root", async () => {
fx = await createWorkspaceFixture();
const removeSpy = vi.fn();
const executor = workspaceExecutor();
(executor as any).removeOwnWorktreeWithReconcile = removeSpy;
(executor as any).addActiveWorktree("FN-WS-1", repoAPath(fx));
(executor as any).addActiveWorktree("FN-WS-1", repoBPath(fx));
await executor.cleanup("FN-WS-1");
expect(executor.getWorktreePath("FN-WS-1")).toBeUndefined();
expect((executor as any).activeWorktrees.has("FN-WS-1")).toBe(false);
// The browse-only root must never be torn down as if it were a worktree.
expect(removeSpy).not.toHaveBeenCalled();
});
it("clearPhantomExecutorBinding (FN-6736) unregisters every held path, not one", async () => {
fx = await createWorkspaceFixture();
const executor = workspaceExecutor();
const pA = repoAPath(fx);
const pB = repoBPath(fx);
(executor as any).addActiveWorktree("FN-WS-1", pA);
(executor as any).addActiveWorktree("FN-WS-1", pB);
const ok = (executor as any).clearPhantomExecutorBinding("FN-WS-1");
expect(ok).toBe(true);
expect((executor as any).activeWorktrees.has("FN-WS-1")).toBe(false);
});
});
describeIfGit("U1 KTD2 — non-workspace task is a one-element Set (regression: unchanged)", () => {
let fx: WorkspaceFixture;
afterEach(() => fx?.cleanup());
it("getWorktreePath returns the sole path; listWorktreeHolders emits exactly one row", async () => {
fx = await createWorkspaceFixture();
const store = createStore();
const executor = new TaskExecutor(store, fx.repoPath("repo-a")); // single-repo root
// No workspaceConfig set → single-repo mode.
const wt = `${fx.repoPath("repo-a")}/.worktrees/fn-001`;
(executor as any).addActiveWorktree("FN-001", wt);
expect(executor.getWorktreePath("FN-001")).toBe(wt);
expect(executor.listWorktreeHolders()).toEqual([{ taskId: "FN-001", worktreePath: wt }]);
expect((executor as any).hasActiveWorktreeBinding("FN-001", wt)).toBe(true);
});
});
describeIfGit("U1 KTD1 — verifyWorktreeInvariants gated off in workspace mode", () => {
let fx: WorkspaceFixture;
afterEach(() => fx?.cleanup());
it("returns ok for a zero-acquire workspace task (no task.worktree) so fn_task_done does not requeue", async () => {
fx = await createWorkspaceFixture();
const store = createStore();
const executor = new TaskExecutor(store, fx.rootDir);
(executor as any).workspaceConfig = { repos: fx.repos } as WorkspaceConfig;
// A workspace task that acquired ZERO sub-repos has no task.worktree and no
// tracked paths. The singular invariant would otherwise refuse on
// "missing task.worktree"; in workspace mode it is gated OFF.
const result = await (executor as any).verifyWorktreeInvariants(makeTask("FN-WS-1", { worktree: undefined }));
expect(result).toEqual({ ok: true });
});
it("non-workspace task with no worktree still fails the invariant (regression: gate is workspace-only)", async () => {
fx = await createWorkspaceFixture();
const store = createStore();
const executor = new TaskExecutor(store, fx.repoPath("repo-a"));
// No workspaceConfig.
const result = await (executor as any).verifyWorktreeInvariants(makeTask("FN-001", { worktree: undefined }));
expect(result.ok).toBe(false);
});
});
describeIfGit("U1 KTD1 — scopePromptToWorktree / buildExecutionPrompt no-op in workspace mode", () => {
let fx: WorkspaceFixture;
afterEach(() => fx?.cleanup());
it("does not rewrite root-anchored paths when a workspace config is present", async () => {
fx = await createWorkspaceFixture();
const task = makeTask("FN-WS-1", { prompt: `Edit ${fx.rootDir}/repo-a/src/index.ts and commit.` });
const config: WorkspaceConfig = { repos: fx.repos };
// worktreePath === rootDir in workspace mode; the prompt must be returned verbatim.
const prompt = buildExecutionPrompt(task as any, fx.rootDir, { autoMerge: false } as any, fx.rootDir, undefined, undefined, config);
expect(prompt).toContain(`${fx.rootDir}/repo-a/src/index.ts`);
// The workspace repo list is appended (foundation behavior).
expect(prompt).toContain("repo-a");
});
});

View File

@@ -39,7 +39,7 @@ describe("FN-4973: executor worktree conflict cleanup", () => {
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
store.listTasks.mockResolvedValue([]);
(executor as any).activeWorktrees.set("FN-4973", CONFLICT_PATH);
(executor as any).addActiveWorktree("FN-4973", CONFLICT_PATH);
activeSessionRegistry.registerPath(CONFLICT_PATH, { taskId: "FN-4973", kind: "executor", ownerKey: "FN-4973" });
vi.spyOn(worktreePoolModule, "removeWorktree").mockRejectedValue(

View File

@@ -58,7 +58,7 @@ describe("FN-4811: active worktree removal liveness gate", () => {
it("returns the owner taskId when activeWorktrees has another task using the path", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
(executor as any).activeWorktrees.set("FN-OTHER", ACTIVE_PATH);
(executor as any).addActiveWorktree("FN-OTHER", ACTIVE_PATH);
const owner = await (executor as any).findActiveWorktreeOwner(ACTIVE_PATH, "FN-4811");
expect(owner).toBe("FN-OTHER");
@@ -67,7 +67,7 @@ describe("FN-4811: active worktree removal liveness gate", () => {
it("returns null when activeWorktrees only has the requesting task at the path", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
(executor as any).activeWorktrees.set("FN-4811", ACTIVE_PATH);
(executor as any).addActiveWorktree("FN-4811", ACTIVE_PATH);
store.listTasks.mockResolvedValue([]);
const owner = await (executor as any).findActiveWorktreeOwner(ACTIVE_PATH, "FN-4811");
@@ -125,7 +125,7 @@ describe("FN-4811: active worktree removal liveness gate", () => {
it("refuses removal when worktree is in activeWorktrees for another task", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
(executor as any).activeWorktrees.set("FN-OTHER", ACTIVE_PATH);
(executor as any).addActiveWorktree("FN-OTHER", ACTIVE_PATH);
store.listTasks.mockResolvedValue([]);
const result = await (executor as any).cleanupConflictingWorktree(
@@ -226,7 +226,7 @@ describe("FN-4811: active worktree removal liveness gate", () => {
it("returns 'sticky' without invoking inspection when conflict path is actively owned", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
(executor as any).activeWorktrees.set("FN-OWNER", ACTIVE_PATH);
(executor as any).addActiveWorktree("FN-OWNER", ACTIVE_PATH);
store.listTasks.mockResolvedValue([]);
const inspectSpy = vi.spyOn(branchConflictModule, "inspectBranchConflict");

View File

@@ -23,7 +23,7 @@ describe("FN-5346 reliability interactions: post-completion stale self-owned bin
it("reconciles stale same-task registry entry during cleanup()", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, ROOT);
(executor as any).activeWorktrees.set(TASK_ID, PATH);
(executor as any).addActiveWorktree(TASK_ID, PATH);
activeSessionRegistry.registerPath(PATH, { taskId: TASK_ID, kind: "executor", ownerKey: TASK_ID });
(activeSessionRegistry.lookupByPath(PATH) as any).registeredAt = 0;
const removeSpy = vi.spyOn(worktreePoolModule, "removeWorktree").mockResolvedValue(undefined);
@@ -56,7 +56,7 @@ describe("FN-5346 reliability interactions: post-completion stale self-owned bin
it("preserves refusal for truly-live same-task bindings", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, ROOT);
(executor as any).activeWorktrees.set(TASK_ID, PATH);
(executor as any).addActiveWorktree(TASK_ID, PATH);
activeSessionRegistry.registerPath(PATH, { taskId: TASK_ID, kind: "executor", ownerKey: TASK_ID });
vi.spyOn(worktreePoolModule, "removeWorktree").mockRejectedValue(
new ActiveSessionWorktreeRemovalError({
@@ -82,7 +82,7 @@ describe("FN-5346 reliability interactions: post-completion stale self-owned bin
const store = createMockStore();
store.listTasks.mockResolvedValue([]);
const executor = new TaskExecutor(store, ROOT);
(executor as any).activeWorktrees.set("FN-FOREIGN", PATH);
(executor as any).addActiveWorktree("FN-FOREIGN", PATH);
activeSessionRegistry.registerPath(PATH, { taskId: "FN-FOREIGN", kind: "executor", ownerKey: "FN-FOREIGN" });
const removeSpy = vi.spyOn(worktreePoolModule, "removeWorktree").mockResolvedValue(undefined);
@@ -96,13 +96,13 @@ describe("FN-5346 reliability interactions: post-completion stale self-owned bin
it("is idempotent across repeated cleanup sweeps", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, ROOT);
(executor as any).activeWorktrees.set(TASK_ID, PATH);
(executor as any).addActiveWorktree(TASK_ID, PATH);
activeSessionRegistry.registerPath(PATH, { taskId: TASK_ID, kind: "executor", ownerKey: TASK_ID });
(activeSessionRegistry.lookupByPath(PATH) as any).registeredAt = 0;
const removeSpy = vi.spyOn(worktreePoolModule, "removeWorktree").mockResolvedValue(undefined);
await executor.cleanup(TASK_ID);
(executor as any).activeWorktrees.set(TASK_ID, PATH);
(executor as any).addActiveWorktree(TASK_ID, PATH);
await executor.cleanup(TASK_ID);
const clearedCalls = (store.logEntry as any).mock.calls.filter(
@@ -120,7 +120,7 @@ describe("FN-5346 reliability interactions: post-completion stale self-owned bin
const store = createMockStore();
const executor = new TaskExecutor(store, ROOT);
(executor as any).activeWorktrees.set(TASK_ID, PATH);
(executor as any).addActiveWorktree(TASK_ID, PATH);
activeSessionRegistry.registerPath(PATH, { taskId: TASK_ID, kind: "executor", ownerKey: TASK_ID });
vi.spyOn(worktreePoolModule, "removeWorktree").mockResolvedValue(undefined);

View File

@@ -118,7 +118,7 @@ describe("FN-4973 reliability interactions: stale self-owned active-session reco
const store = createMockStore();
store.listTasks.mockResolvedValue([]);
const executor = new TaskExecutor(store, "/tmp/test");
(executor as any).activeWorktrees.set(TASK_ID, CONFLICT_PATH);
(executor as any).addActiveWorktree(TASK_ID, CONFLICT_PATH);
activeSessionRegistry.registerPath(CONFLICT_PATH, { taskId: TASK_ID, kind: "executor", ownerKey: TASK_ID });
vi.spyOn(worktreePoolModule, "removeWorktree").mockRejectedValue(

View File

@@ -45,7 +45,7 @@ describe("FN-4976: stale self-owned activeSessionRegistry deadlock backstop", ()
it("FN-4976 does not clear foreign-owned activeSessionRegistry entry and FN-4811 refusal still fires", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, ROOT);
(executor as any).activeWorktrees.set("FN-OTHER", PATH);
(executor as any).addActiveWorktree("FN-OTHER", PATH);
store.listTasks.mockResolvedValue([]);
activeSessionRegistry.registerPath(PATH, { taskId: "FN-OTHER", kind: "executor", ownerKey: "FN-OTHER" });

View File

@@ -143,7 +143,7 @@ import { isContextLimitError } from "./context-limit-detector.js";
import { StepSessionExecutor } from "./step-session-executor.js";
import { makeAncestryBlastRadiusGuard, resetStepToBaseline, runTaskStep } from "./step-runner.js";
// FNXC:MergerUnification 2026-06-21-19:05: the foundation branch imported `acquireWorkspaceRepoWorktree` here but never used it in executor.ts (the agent tool wraps it via agent-tools.ts), which fails lint on the inherited base. Removed until master-plan U1 re-adds it together with its per-repo acquisition usage.
import { acquireTaskWorktree } from "./worktree-acquisition.js";
import { acquireTaskWorktree, type AcquireTaskWorktreeResult } from "./worktree-acquisition.js";
import { resolveCapturedBaseCommitSha } from "./base-commit-capture.js";
import { installTaskWorktreeIdentityGuard } from "./worktree-hooks.js";
import {
@@ -1465,7 +1465,28 @@ interface ActiveExecutorSessionState {
}
export class TaskExecutor {
private activeWorktrees = new Map<string, string>();
/*
FNXC:Workspace 2026-06-21-12:00:
activeWorktrees tracks the worktree paths a task currently holds for liveness/owner checks. In workspace mode a single task acquires N sub-repo worktrees (foundation `task.workspaceWorktrees`), so the value is a SET of paths, not one path. A non-workspace (single-repo) task holds a one-element set — every consumer is converted to membership semantics so the single-repo path is byte-for-byte unchanged (KTD2). Helpers below add/remove/iterate the set.
*/
private activeWorktrees = new Map<string, Set<string>>();
/**
* FNXC:Workspace 2026-06-21-12:00: Register a worktree path under a task's active set, creating the set on first add (KTD2). Single-repo tasks call this once → one-element set.
*/
private addActiveWorktree(taskId: string, worktreePath: string): void {
const set = this.activeWorktrees.get(taskId) ?? new Set<string>();
set.add(worktreePath);
this.activeWorktrees.set(taskId, set);
}
/**
* FNXC:Workspace 2026-06-21-12:00: Read-only snapshot of every worktree path a task currently holds (KTD2). Empty when the task holds none.
*/
private getActiveWorktreePaths(taskId: string): string[] {
const set = this.activeWorktrees.get(taskId);
return set ? Array.from(set) : [];
}
private executing = new Set<string>();
/** Tasks currently being prepared for unpause resume, before execute() has registered them. */
private resumingUnpaused = new Set<string>();
@@ -1583,9 +1604,10 @@ export class TaskExecutor {
this.activeSessions.delete(taskId);
// U5: drop the effective column-agent principal for this task's session.
this.effectiveColumnAgentByTask.delete(taskId);
const resolvedWorktreePath = worktreePath ?? this.activeWorktrees.get(taskId);
if (resolvedWorktreePath) {
activeSessionRegistry.unregisterPath(resolvedWorktreePath);
// FNXC:Workspace 2026-06-21-12:00: KTD2 — when no explicit path is given, unregister EVERY worktree path the task holds (a workspace task holds N sub-repo paths); single-repo tasks resolve a one-element set.
const resolvedWorktreePaths = worktreePath ? [worktreePath] : this.getActiveWorktreePaths(taskId);
for (const path of resolvedWorktreePaths) {
activeSessionRegistry.unregisterPath(path);
}
}
@@ -1600,9 +1622,10 @@ export class TaskExecutor {
this.activeStepExecutorSeenSteeringIds.delete(taskId);
// U5: drop the effective column-agent principal for this task's step session.
this.effectiveColumnAgentByTask.delete(taskId);
const resolvedWorktreePath = worktreePath ?? this.activeWorktrees.get(taskId);
if (resolvedWorktreePath) {
activeSessionRegistry.unregisterPath(resolvedWorktreePath);
// FNXC:Workspace 2026-06-21-12:00: KTD2 — unregister every held worktree path (Set), not one.
const resolvedWorktreePaths = worktreePath ? [worktreePath] : this.getActiveWorktreePaths(taskId);
for (const path of resolvedWorktreePaths) {
activeSessionRegistry.unregisterPath(path);
}
}
@@ -1615,9 +1638,10 @@ export class TaskExecutor {
private deleteActiveWorkflowStepSession(taskId: string, worktreePath?: string): void {
this.activeWorkflowStepSessions.delete(taskId);
this.activeWorkflowStepSessionSeenSteeringIds.delete(taskId);
const resolvedWorktreePath = worktreePath ?? this.activeWorktrees.get(taskId);
if (resolvedWorktreePath) {
activeSessionRegistry.unregisterPath(resolvedWorktreePath);
// FNXC:Workspace 2026-06-21-12:00: KTD2 — unregister every held worktree path (Set), not one.
const resolvedWorktreePaths = worktreePath ? [worktreePath] : this.getActiveWorktreePaths(taskId);
for (const path of resolvedWorktreePaths) {
activeSessionRegistry.unregisterPath(path);
}
}
@@ -2053,7 +2077,8 @@ export class TaskExecutor {
return false;
}
const worktreePath = this.activeWorktrees.get(taskId);
// FNXC:Workspace 2026-06-21-12:00: KTD2 — collect every worktree path the task holds (a workspace task holds N) before clearing the binding, so the registry sweep below unregisters all of them, not just one.
const heldWorktreePaths = this.getActiveWorktreePaths(taskId);
this.activeWorktrees.delete(taskId);
this.executing.delete(taskId);
this.recoveringCompleted.delete(taskId);
@@ -2063,8 +2088,8 @@ export class TaskExecutor {
this.effectiveColumnAgentByTask.delete(taskId);
const registeredPaths = new Set(activeSessionRegistry.pathsForTask(taskId));
if (worktreePath) {
registeredPaths.add(worktreePath);
for (const path of heldWorktreePaths) {
registeredPaths.add(path);
}
for (const path of registeredPaths) {
activeSessionRegistry.unregisterPath(path);
@@ -7430,7 +7455,19 @@ export class TaskExecutor {
const hadAssignedWorktree = Boolean(task.worktree);
const taskCommandAbortController = new AbortController();
this.registerConfiguredCommandController(task.id, taskCommandAbortController);
const acquisition = await (async () => {
/*
FNXC:Workspace 2026-06-21-12:00:
KTD1 — in workspace mode `this.rootDir` is a NON-git parent. Acquiring a root worktree there fails. Skip root acquisition entirely and run the agent session rooted at the browse-only workspace root; the agent acquires per-sub-repo worktrees on demand via fn_acquire_repo_worktree. `task.worktree` stays unset. We synthesize a non-fresh, non-resume acquisition with an empty branch so the downstream env-injection/onStart bookkeeping runs unchanged while every rootDir git preflight (base capture, contamination, liveness) is gated off below. The non-workspace branch is byte-for-byte the original acquisition path.
*/
const acquisition: AcquireTaskWorktreeResult = this.workspaceConfig
? {
worktreePath: this.rootDir,
branch: "",
source: "existing",
hydrated: true,
isResume: Boolean(task.sessionFile),
}
: await (async () => {
try {
return await acquireTaskWorktree({
task,
@@ -7520,6 +7557,11 @@ export class TaskExecutor {
}
}
/*
FNXC:Workspace 2026-06-21-12:00:
KTD1 — every preflight below (base-commit capture, contamination check, worktree-liveness gate) runs git against `worktreePath`, which equals the non-git workspace root in workspace mode. They would all fail. Gate the whole block off in workspace mode; the per-repo equivalents return in Phase B (master U3) against each acquired sub-repo worktree. The non-workspace branch is unchanged.
*/
if (!this.workspaceConfig) {
// Capture the base commit SHA for diff computation whenever a task
// starts with a newly assigned worktree.
if (!acquisition.isResume) {
@@ -7664,8 +7706,10 @@ export class TaskExecutor {
this.options.onError?.(task, new Error(failureMessage));
return;
}
} // end !this.workspaceConfig preflight gate (FNXC:Workspace KTD1)
this.activeWorktrees.set(task.id, worktreePath);
// FNXC:Workspace 2026-06-21-12:00: KTD2 — register the worktree path under the task's Set. In workspace mode `worktreePath` is the browse-only root; per-repo paths are added as the agent acquires them. Non-workspace tasks add exactly one path → a one-element set (unchanged liveness/owner semantics).
this.addActiveWorktree(task.id, worktreePath);
executorLog.log(`${task.id}: worktree ready at ${worktreePath}`);
const injected = await this.buildInjectedRuntimeEnv(task.id, worktreePath, acquisition.branch ?? undefined);
@@ -10457,8 +10501,13 @@ export class TaskExecutor {
options?: { noOpCompletion?: boolean; noOpCompletionReason?: string },
): Promise<{ ok: true } | { ok: false; reason: "wrong_toplevel" | "wrong_branch" | "no_commits"; observed: string; expected: string }> {
const settings = await this.store.getSettings();
// FNXC:Workspace 2026-06-21-12:00: KTD1/KTD2 — workspace tasks have no root worktree and no single `task.worktree`; the singular per-task invariant is meaningless against the non-git root. Phase B (master U3) iterates this check per sub-repo worktree. Until then it is gated OFF in workspace mode so fn_task_done (its only caller path) does not requeue a zero-acquire workspace task for "missing task.worktree".
if (this.workspaceConfig) {
return { ok: true };
}
const branchName = resolveTaskWorkingBranch(task);
const worktreePath = worktreePathOverride ?? task.worktree ?? this.activeWorktrees.get(task.id) ?? null;
// Non-workspace tasks hold a one-element set; fall back to its sole member to preserve the original singular resolution.
const worktreePath = worktreePathOverride ?? task.worktree ?? this.getActiveWorktreePaths(task.id)[0] ?? null;
if (!worktreePath) {
return {
@@ -14440,9 +14489,9 @@ You have access to the file system to review changes.${verdictBlock}`;
conflictPath: string,
currentTaskId: string,
): Promise<boolean> {
// Check if conflicting worktree is in our active set
for (const [taskId, worktreePath] of this.activeWorktrees) {
if (taskId !== currentTaskId && worktreePath === conflictPath) {
// FNXC:Workspace 2026-06-21-12:00: KTD2 — a task may hold N worktree paths; the conflict check is membership across the set, not equality on a single path.
for (const [taskId, worktreePaths] of this.activeWorktrees) {
if (taskId !== currentTaskId && worktreePaths.has(conflictPath)) {
return true;
}
}
@@ -14479,8 +14528,11 @@ You have access to the file system to review changes.${verdictBlock}`;
*/
listWorktreeHolders(): Array<{ taskId: string; worktreePath: string }> {
const holders: Array<{ taskId: string; worktreePath: string }> = [];
for (const [taskId, worktreePath] of this.activeWorktrees) {
holders.push({ taskId, worktreePath });
// FNXC:Workspace 2026-06-21-12:00: KTD2 — flat-map each task's Set into one holder row per worktree path. A workspace task emits N rows; the FN-6782 reaper (self-healing.ts) and in-process-runtime adapter key purely off taskId (verified) and are idempotent across duplicate-task rows, so multi-row holders do not mis-count maxWorktrees slots.
for (const [taskId, worktreePaths] of this.activeWorktrees) {
for (const worktreePath of worktreePaths) {
holders.push({ taskId, worktreePath });
}
}
return holders;
}
@@ -14489,8 +14541,9 @@ You have access to the file system to review changes.${verdictBlock}`;
worktreePath: string,
requestingTaskId: string,
): Promise<string | null> {
for (const [taskId, path] of this.activeWorktrees) {
if (taskId !== requestingTaskId && path === worktreePath) {
// FNXC:Workspace 2026-06-21-12:00: KTD2 — membership across the task's worktree set (a workspace task holds N).
for (const [taskId, paths] of this.activeWorktrees) {
if (taskId !== requestingTaskId && paths.has(worktreePath)) {
return taskId;
}
}
@@ -14516,12 +14569,9 @@ You have access to the file system to review changes.${verdictBlock}`;
* Returns true if cleanup succeeded.
*/
private hasActiveWorktreeBinding(taskId: string, worktreePath: string): boolean {
for (const [activeTaskId, activePath] of this.activeWorktrees) {
if (activeTaskId === taskId && activePath === worktreePath) {
return true;
}
}
return false;
// FNXC:Workspace 2026-06-21-12:00: KTD2 — membership across the task's worktree set.
const paths = this.activeWorktrees.get(taskId);
return paths ? paths.has(worktreePath) : false;
}
private async reconcileSelfOwnedBeforeRemove(worktreePath: string, taskId: string): Promise<void> {
@@ -14919,11 +14969,18 @@ You have access to the file system to review changes.${verdictBlock}`;
* always cleaned up by the merger on a per-task basis.
*/
async cleanup(taskId: string): Promise<void> {
const worktreePath = this.activeWorktrees.get(taskId);
if (!worktreePath) return;
const worktreePaths = this.getActiveWorktreePaths(taskId);
if (worktreePaths.length === 0) return;
this.activeWorktrees.delete(taskId);
// FNXC:Workspace 2026-06-21-12:00: KTD1 — in workspace mode the tracked path is the non-git workspace root (browse-only), never a removable worktree. Drop the in-memory tracking above but never remove the root. Per-repo worktree teardown returns in Phase B.
if (this.workspaceConfig) {
return;
}
// Non-workspace tasks hold a one-element set — preserve the original single-path removal semantics.
const worktreePath = worktreePaths[0];
// Check if another task still needs this worktree
const otherUser = await findWorktreeUser(this.store, worktreePath, taskId);
if (otherUser) {
@@ -15420,8 +15477,14 @@ You have access to the file system to review changes.${verdictBlock}`;
return true;
}
/**
* FNXC:Workspace 2026-06-21-12:00: KTD2 single-path-getter contract. Returns the task's sole worktree path for single-repo tasks (one-element set). For a multi-worktree workspace task there is no single answer — callers must read the per-repo `task.workspaceWorktrees` entry instead — so this returns undefined. A workspace task tracked only at the browse-only root also returns undefined, matching the "no removable single worktree" semantics.
*/
getWorktreePath(taskId: string): string | undefined {
return this.activeWorktrees.get(taskId);
if (this.workspaceConfig) {
return undefined;
}
return this.getActiveWorktreePaths(taskId)[0];
}
// ── Agent Spawning ─────────────────────────────────────────────────────
@@ -15721,7 +15784,11 @@ function formatTimestamp(iso: string): string {
// Project commands are injected here (for reliability) and also in the PROMPT.md (by triage).
// This ensures the executor agent always sees the authoritative commands from settings,
// even if the PROMPT.md was written manually or before commands were configured.
function scopePromptToWorktree(prompt: string, rootDir?: string, worktreePath?: string): string {
function scopePromptToWorktree(prompt: string, rootDir?: string, worktreePath?: string, workspaceConfig?: WorkspaceConfig | null): string {
// FNXC:Workspace 2026-06-21-12:00: KTD1 — in workspace mode the session is rooted at the workspace root itself (worktreePath === rootDir) and path rewriting to a per-task root worktree is meaningless: edits happen in per-sub-repo worktrees the agent acquires, not at the root. No-op the rewrite. (The rootDir === worktreePath guard below already covers this, but gate explicitly so intent survives future refactors.)
if (workspaceConfig) {
return prompt;
}
if (!rootDir || !worktreePath || rootDir === worktreePath || !prompt.includes(rootDir)) {
return prompt;
}
@@ -15755,7 +15822,7 @@ export function buildExecutionPrompt(
customFieldDefs?: WorkflowFieldDefinition[],
workspaceConfig?: WorkspaceConfig | null,
): string {
const prompt = scopePromptToWorktree(task.prompt, rootDir, worktreePath);
const prompt = scopePromptToWorktree(task.prompt, rootDir, worktreePath, workspaceConfig);
const reviewLevel = parseReviewLevelFromPrompt(prompt);
// Build co-author trailer arg for git commits based on settings. The user's