diff --git a/.changeset/workspace-phase-a-u1-executor-session-scoping.md b/.changeset/workspace-phase-a-u1-executor-session-scoping.md new file mode 100644 index 0000000000..6fc9911756 --- /dev/null +++ b/.changeset/workspace-phase-a-u1-executor-session-scoping.md @@ -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). diff --git a/packages/engine/src/__tests__/_workspace-fixture.ts b/packages/engine/src/__tests__/_workspace-fixture.ts new file mode 100644 index 0000000000..5e94b78982 --- /dev/null +++ b/packages/engine/src/__tests__/_workspace-fixture.ts @@ -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. `/.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. + * - `/.fusion/workspace.json` is written so loadWorkspaceConfig() resolves. + */ +export async function createWorkspaceFixture( + repos: string[] = ["repo-a", "repo-b"], + defaultBranch = "main", +): Promise { + 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 }), + }; +} diff --git a/packages/engine/src/__tests__/executor-paused-abort-todo-benign.test.ts b/packages/engine/src/__tests__/executor-paused-abort-todo-benign.test.ts index 351ee343bf..a8237d0755 100644 --- a/packages/engine/src/__tests__/executor-paused-abort-todo-benign.test.ts +++ b/packages/engine/src/__tests__/executor-paused-abort-todo-benign.test.ts @@ -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); diff --git a/packages/engine/src/__tests__/executor-recovery.test.ts b/packages/engine/src/__tests__/executor-recovery.test.ts index ae9e39343b..c6d0e8bf35 100644 --- a/packages/engine/src/__tests__/executor-recovery.test.ts +++ b/packages/engine/src/__tests__/executor-recovery.test.ts @@ -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); diff --git a/packages/engine/src/__tests__/executor-workspace-session-cwd.test.ts b/packages/engine/src/__tests__/executor-workspace-session-cwd.test.ts new file mode 100644 index 0000000000..19d101f8dc --- /dev/null +++ b/packages/engine/src/__tests__/executor-workspace-session-cwd.test.ts @@ -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(); + return { ...actual, acquireTaskWorktree: vi.fn(actual.acquireTaskWorktree) }; +}); + +const mockedAcquireTaskWorktree = vi.mocked(acquireTaskWorktree); + +const ROOT = "/tmp/workspace-root"; + +function inProgressTask(overrides: Record = {}) { + 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); + } + }); +}); diff --git a/packages/engine/src/__tests__/executor-workspace.test.ts b/packages/engine/src/__tests__/executor-workspace.test.ts index 1916b52367..330915e966 100644 --- a/packages/engine/src/__tests__/executor-workspace.test.ts +++ b/packages/engine/src/__tests__/executor-workspace.test.ts @@ -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(); +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> = {}): 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 { 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(); - 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"); }); }); diff --git a/packages/engine/src/__tests__/executor-worktree-conflict.test.ts b/packages/engine/src/__tests__/executor-worktree-conflict.test.ts index c2714fda0b..adbc691c86 100644 --- a/packages/engine/src/__tests__/executor-worktree-conflict.test.ts +++ b/packages/engine/src/__tests__/executor-worktree-conflict.test.ts @@ -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( diff --git a/packages/engine/src/__tests__/reliability-interactions/active-worktree-removal-liveness.test.ts b/packages/engine/src/__tests__/reliability-interactions/active-worktree-removal-liveness.test.ts index 4889abc25d..d35c55ff7f 100644 --- a/packages/engine/src/__tests__/reliability-interactions/active-worktree-removal-liveness.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/active-worktree-removal-liveness.test.ts @@ -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"); diff --git a/packages/engine/src/__tests__/reliability-interactions/post-completion-stale-self-owned-binding.test.ts b/packages/engine/src/__tests__/reliability-interactions/post-completion-stale-self-owned-binding.test.ts index abe29b5b53..3717a9e355 100644 --- a/packages/engine/src/__tests__/reliability-interactions/post-completion-stale-self-owned-binding.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/post-completion-stale-self-owned-binding.test.ts @@ -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); diff --git a/packages/engine/src/__tests__/reliability-interactions/stale-self-owned-active-session-recovery.test.ts b/packages/engine/src/__tests__/reliability-interactions/stale-self-owned-active-session-recovery.test.ts index f555c81d5f..9ee54c545f 100644 --- a/packages/engine/src/__tests__/reliability-interactions/stale-self-owned-active-session-recovery.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/stale-self-owned-active-session-recovery.test.ts @@ -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( diff --git a/packages/engine/src/__tests__/reliability-interactions/stale-self-owned-session-registry.test.ts b/packages/engine/src/__tests__/reliability-interactions/stale-self-owned-session-registry.test.ts index 91e77b0ac4..869d6303bf 100644 --- a/packages/engine/src/__tests__/reliability-interactions/stale-self-owned-session-registry.test.ts +++ b/packages/engine/src/__tests__/reliability-interactions/stale-self-owned-session-registry.test.ts @@ -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" }); diff --git a/packages/engine/src/executor.ts b/packages/engine/src/executor.ts index de96d138d0..1f7c91769b 100644 --- a/packages/engine/src/executor.ts +++ b/packages/engine/src/executor.ts @@ -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(); + /* + 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>(); + + /** + * 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(); + 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(); /** Tasks currently being prepared for unpause resume, before execute() has registered them. */ private resumingUnpaused = new Set(); @@ -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 { - // 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 { - 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 { @@ -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 { - 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