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:
@@ -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).
|
||||||
66
packages/engine/src/__tests__/_workspace-fixture.ts
Normal file
66
packages/engine/src/__tests__/_workspace-fixture.ts
Normal 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 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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
|
// executor must retry the agent session in place rather than bouncing the
|
||||||
// task through todo (and must not fire a failure notification).
|
// task through todo (and must not fire a failure notification).
|
||||||
const { store, task, executor } = makeHarness({ column: "todo" });
|
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
|
const executeSpy = vi
|
||||||
.spyOn(executor as any, "execute")
|
.spyOn(executor as any, "execute")
|
||||||
.mockResolvedValue(undefined);
|
.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
|
// and a retry scheduled); the task then changes state before the timer
|
||||||
// fires, and the fire-time re-fetch must abort the dispatch.
|
// fires, and the fire-time re-fetch must abort the dispatch.
|
||||||
const { store, task, executor } = makeHarness({ column: "todo" });
|
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);
|
const executeSpy = vi.spyOn(executor as any, "execute").mockResolvedValue(undefined);
|
||||||
|
|
||||||
await invokeGraphFailure(executor, task);
|
await invokeGraphFailure(executor, task);
|
||||||
@@ -164,7 +164,7 @@ describe("pause-abort benign requeue-to-todo (FN-6782)", () => {
|
|||||||
status: "failed",
|
status: "failed",
|
||||||
error: "Workflow graph failure surfaced after paused engine abort during pause/resume",
|
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);
|
const executeSpy = vi.spyOn(executor as any, "execute").mockResolvedValue(undefined);
|
||||||
|
|
||||||
await invokeGraphFailure(executor, task);
|
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
|
// pause that ended up in todo must stay parked-benign and wait for
|
||||||
// explicit resume — auto-resuming it would override the operator's intent.
|
// explicit resume — auto-resuming it would override the operator's intent.
|
||||||
const { store, task, executor } = makeHarness(overrides, provenance);
|
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);
|
const executeSpy = vi.spyOn(executor as any, "execute").mockResolvedValue(undefined);
|
||||||
|
|
||||||
await invokeGraphFailure(executor, task);
|
await invokeGraphFailure(executor, task);
|
||||||
@@ -217,7 +217,7 @@ describe("pause-abort benign requeue-to-todo (FN-6782)", () => {
|
|||||||
column: "todo",
|
column: "todo",
|
||||||
graphResumeRetryCount: 2,
|
graphResumeRetryCount: 2,
|
||||||
});
|
});
|
||||||
(executor as any).activeWorktrees.set(task.id, task.worktree);
|
(executor as any).addActiveWorktree(task.id, task.worktree);
|
||||||
const executeSpy = vi
|
const executeSpy = vi
|
||||||
.spyOn(executor as any, "execute")
|
.spyOn(executor as any, "execute")
|
||||||
.mockResolvedValue(undefined);
|
.mockResolvedValue(undefined);
|
||||||
@@ -246,7 +246,7 @@ describe("pause-abort benign requeue-to-todo (FN-6782)", () => {
|
|||||||
status: "failed",
|
status: "failed",
|
||||||
error: "Workflow graph failure surfaced after paused engine abort during pause/resume",
|
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);
|
await invokeGraphFailure(executor, task);
|
||||||
|
|
||||||
|
|||||||
@@ -605,7 +605,7 @@ describe("TaskExecutor bounded recovery retries", () => {
|
|||||||
|
|
||||||
(executor as any).executing.add(taskId);
|
(executor as any).executing.add(taskId);
|
||||||
executingTaskLock.tryClaim(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).activeSessions.set(taskId, { session });
|
||||||
(executor as any).activeStepExecutors.set(taskId, stepExecutor);
|
(executor as any).activeStepExecutors.set(taskId, stepExecutor);
|
||||||
(executor as any).activeWorkflowStepSessions.set(taskId, workflowSession);
|
(executor as any).activeWorkflowStepSessions.set(taskId, workflowSession);
|
||||||
@@ -686,7 +686,7 @@ describe("TaskExecutor bounded recovery retries", () => {
|
|||||||
});
|
});
|
||||||
(executor as any).executing.add("FN-001");
|
(executor as any).executing.add("FN-001");
|
||||||
executingTaskLock.tryClaim("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 as any).activeSessions.set("FN-001", { session });
|
||||||
|
|
||||||
executor.markStuckAborted("FN-001", true);
|
executor.markStuckAborted("FN-001", true);
|
||||||
@@ -750,7 +750,7 @@ describe("TaskExecutor bounded recovery retries", () => {
|
|||||||
vi.mocked(removeWorktree).mockRejectedValue(new Error("worktree busy"));
|
vi.mocked(removeWorktree).mockRejectedValue(new Error("worktree busy"));
|
||||||
(executor as any).executing.add("FN-001");
|
(executor as any).executing.add("FN-001");
|
||||||
executingTaskLock.tryClaim("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 as any).activeSessions.set("FN-001", { session });
|
||||||
|
|
||||||
executor.markStuckAborted("FN-001", true);
|
executor.markStuckAborted("FN-001", true);
|
||||||
@@ -797,7 +797,7 @@ describe("TaskExecutor bounded recovery retries", () => {
|
|||||||
vi.mocked(removeWorktree).mockResolvedValue(undefined as any);
|
vi.mocked(removeWorktree).mockResolvedValue(undefined as any);
|
||||||
(executor as any).executing.add("FN-001");
|
(executor as any).executing.add("FN-001");
|
||||||
executingTaskLock.tryClaim("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 as any).activeSessions.set("FN-001", { session });
|
||||||
|
|
||||||
executor.markStuckAborted("FN-001", true);
|
executor.markStuckAborted("FN-001", true);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,89 +1,220 @@
|
|||||||
// @ts-nocheck
|
/*
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
FNXC:Workspace 2026-06-21-12:00:
|
||||||
import { loadWorkspaceConfig } from "@fusion/core";
|
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.
|
||||||
import { acquireWorkspaceRepoWorktree } from "../worktree-acquisition.js";
|
|
||||||
|
|
||||||
vi.mock("@fusion/core", async (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.
|
||||||
const actual = await importOriginal<typeof import("@fusion/core")>();
|
*/
|
||||||
|
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 {
|
return {
|
||||||
...actual,
|
id,
|
||||||
loadWorkspaceConfig: vi.fn(),
|
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 repoAPath = (fx: WorkspaceFixture) => `${fx.repoPath("repo-a")}/.worktrees/fn-ws-1`;
|
||||||
const actual = await importOriginal<typeof import("../worktree-acquisition.js")>();
|
const repoBPath = (fx: WorkspaceFixture) => `${fx.repoPath("repo-b")}/.worktrees/fn-ws-1`;
|
||||||
return {
|
|
||||||
...actual,
|
|
||||||
acquireWorkspaceRepoWorktree: vi.fn(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockedLoadWorkspaceConfig = vi.mocked(loadWorkspaceConfig);
|
describeIfGit("workspace fixture", () => {
|
||||||
const mockedAcquireWorkspaceRepoWorktree = vi.mocked(acquireWorkspaceRepoWorktree);
|
let fx: WorkspaceFixture;
|
||||||
|
afterEach(() => fx?.cleanup());
|
||||||
|
|
||||||
const MOCK_WORKSPACE_CONFIG = {
|
it("builds a non-git root with two real git sub-repos and a resolvable workspace config", async () => {
|
||||||
repos: ["wolf-server", "wolf-community-frontend-1"],
|
fx = await createWorkspaceFixture();
|
||||||
};
|
// Root is NOT a git repo.
|
||||||
|
expect(() => fx.git("..", "git rev-parse --git-dir")).toThrow();
|
||||||
describe("acquireWorkspaceRepoWorktree", () => {
|
// Each sub-repo is a real git repo with a commit on main.
|
||||||
beforeEach(() => {
|
expect(fx.git("repo-a", "git rev-parse --abbrev-ref HEAD")).toBe("main");
|
||||||
vi.clearAllMocks();
|
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);
|
||||||
it("returns alreadyAcquired=false for a fresh repo", async () => {
|
expect(config?.repos).toEqual(["repo-a", "repo-b"]);
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("workspace config", () => {
|
describeIfGit("U1 KTD2 — activeWorktrees Set + every enumerated consumer", () => {
|
||||||
it("loadWorkspaceConfig returns null for non-workspace", async () => {
|
let fx: WorkspaceFixture;
|
||||||
mockedLoadWorkspaceConfig.mockResolvedValueOnce(null);
|
afterEach(() => fx?.cleanup());
|
||||||
const config = await loadWorkspaceConfig("/some/single-repo");
|
|
||||||
expect(config).toBeNull();
|
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 () => {
|
it("listWorktreeHolders flat-maps the Set into N holder rows for one task", async () => {
|
||||||
mockedLoadWorkspaceConfig.mockResolvedValueOnce(MOCK_WORKSPACE_CONFIG);
|
fx = await createWorkspaceFixture();
|
||||||
const config = await loadWorkspaceConfig("/some/workspace");
|
const executor = workspaceExecutor();
|
||||||
expect(config?.repos).toEqual(["wolf-server", "wolf-community-frontend-1"]);
|
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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ describe("FN-4973: executor worktree conflict cleanup", () => {
|
|||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const executor = new TaskExecutor(store, "/tmp/test");
|
const executor = new TaskExecutor(store, "/tmp/test");
|
||||||
store.listTasks.mockResolvedValue([]);
|
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" });
|
activeSessionRegistry.registerPath(CONFLICT_PATH, { taskId: "FN-4973", kind: "executor", ownerKey: "FN-4973" });
|
||||||
|
|
||||||
vi.spyOn(worktreePoolModule, "removeWorktree").mockRejectedValue(
|
vi.spyOn(worktreePoolModule, "removeWorktree").mockRejectedValue(
|
||||||
|
|||||||
@@ -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 () => {
|
it("returns the owner taskId when activeWorktrees has another task using the path", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const executor = new TaskExecutor(store, "/tmp/test");
|
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");
|
const owner = await (executor as any).findActiveWorktreeOwner(ACTIVE_PATH, "FN-4811");
|
||||||
expect(owner).toBe("FN-OTHER");
|
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 () => {
|
it("returns null when activeWorktrees only has the requesting task at the path", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const executor = new TaskExecutor(store, "/tmp/test");
|
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([]);
|
store.listTasks.mockResolvedValue([]);
|
||||||
|
|
||||||
const owner = await (executor as any).findActiveWorktreeOwner(ACTIVE_PATH, "FN-4811");
|
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 () => {
|
it("refuses removal when worktree is in activeWorktrees for another task", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const executor = new TaskExecutor(store, "/tmp/test");
|
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([]);
|
store.listTasks.mockResolvedValue([]);
|
||||||
|
|
||||||
const result = await (executor as any).cleanupConflictingWorktree(
|
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 () => {
|
it("returns 'sticky' without invoking inspection when conflict path is actively owned", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const executor = new TaskExecutor(store, "/tmp/test");
|
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([]);
|
store.listTasks.mockResolvedValue([]);
|
||||||
|
|
||||||
const inspectSpy = vi.spyOn(branchConflictModule, "inspectBranchConflict");
|
const inspectSpy = vi.spyOn(branchConflictModule, "inspectBranchConflict");
|
||||||
|
|||||||
@@ -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 () => {
|
it("reconciles stale same-task registry entry during cleanup()", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const executor = new TaskExecutor(store, ROOT);
|
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.registerPath(PATH, { taskId: TASK_ID, kind: "executor", ownerKey: TASK_ID });
|
||||||
(activeSessionRegistry.lookupByPath(PATH) as any).registeredAt = 0;
|
(activeSessionRegistry.lookupByPath(PATH) as any).registeredAt = 0;
|
||||||
const removeSpy = vi.spyOn(worktreePoolModule, "removeWorktree").mockResolvedValue(undefined);
|
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 () => {
|
it("preserves refusal for truly-live same-task bindings", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const executor = new TaskExecutor(store, ROOT);
|
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.registerPath(PATH, { taskId: TASK_ID, kind: "executor", ownerKey: TASK_ID });
|
||||||
vi.spyOn(worktreePoolModule, "removeWorktree").mockRejectedValue(
|
vi.spyOn(worktreePoolModule, "removeWorktree").mockRejectedValue(
|
||||||
new ActiveSessionWorktreeRemovalError({
|
new ActiveSessionWorktreeRemovalError({
|
||||||
@@ -82,7 +82,7 @@ describe("FN-5346 reliability interactions: post-completion stale self-owned bin
|
|||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
store.listTasks.mockResolvedValue([]);
|
store.listTasks.mockResolvedValue([]);
|
||||||
const executor = new TaskExecutor(store, ROOT);
|
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" });
|
activeSessionRegistry.registerPath(PATH, { taskId: "FN-FOREIGN", kind: "executor", ownerKey: "FN-FOREIGN" });
|
||||||
const removeSpy = vi.spyOn(worktreePoolModule, "removeWorktree").mockResolvedValue(undefined);
|
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 () => {
|
it("is idempotent across repeated cleanup sweeps", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const executor = new TaskExecutor(store, ROOT);
|
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.registerPath(PATH, { taskId: TASK_ID, kind: "executor", ownerKey: TASK_ID });
|
||||||
(activeSessionRegistry.lookupByPath(PATH) as any).registeredAt = 0;
|
(activeSessionRegistry.lookupByPath(PATH) as any).registeredAt = 0;
|
||||||
const removeSpy = vi.spyOn(worktreePoolModule, "removeWorktree").mockResolvedValue(undefined);
|
const removeSpy = vi.spyOn(worktreePoolModule, "removeWorktree").mockResolvedValue(undefined);
|
||||||
|
|
||||||
await executor.cleanup(TASK_ID);
|
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);
|
await executor.cleanup(TASK_ID);
|
||||||
|
|
||||||
const clearedCalls = (store.logEntry as any).mock.calls.filter(
|
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 store = createMockStore();
|
||||||
const executor = new TaskExecutor(store, ROOT);
|
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.registerPath(PATH, { taskId: TASK_ID, kind: "executor", ownerKey: TASK_ID });
|
||||||
vi.spyOn(worktreePoolModule, "removeWorktree").mockResolvedValue(undefined);
|
vi.spyOn(worktreePoolModule, "removeWorktree").mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
|||||||
@@ -118,7 +118,7 @@ describe("FN-4973 reliability interactions: stale self-owned active-session reco
|
|||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
store.listTasks.mockResolvedValue([]);
|
store.listTasks.mockResolvedValue([]);
|
||||||
const executor = new TaskExecutor(store, "/tmp/test");
|
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 });
|
activeSessionRegistry.registerPath(CONFLICT_PATH, { taskId: TASK_ID, kind: "executor", ownerKey: TASK_ID });
|
||||||
|
|
||||||
vi.spyOn(worktreePoolModule, "removeWorktree").mockRejectedValue(
|
vi.spyOn(worktreePoolModule, "removeWorktree").mockRejectedValue(
|
||||||
|
|||||||
@@ -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 () => {
|
it("FN-4976 does not clear foreign-owned activeSessionRegistry entry and FN-4811 refusal still fires", async () => {
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const executor = new TaskExecutor(store, ROOT);
|
const executor = new TaskExecutor(store, ROOT);
|
||||||
(executor as any).activeWorktrees.set("FN-OTHER", PATH);
|
(executor as any).addActiveWorktree("FN-OTHER", PATH);
|
||||||
store.listTasks.mockResolvedValue([]);
|
store.listTasks.mockResolvedValue([]);
|
||||||
activeSessionRegistry.registerPath(PATH, { taskId: "FN-OTHER", kind: "executor", ownerKey: "FN-OTHER" });
|
activeSessionRegistry.registerPath(PATH, { taskId: "FN-OTHER", kind: "executor", ownerKey: "FN-OTHER" });
|
||||||
|
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ import { isContextLimitError } from "./context-limit-detector.js";
|
|||||||
import { StepSessionExecutor } from "./step-session-executor.js";
|
import { StepSessionExecutor } from "./step-session-executor.js";
|
||||||
import { makeAncestryBlastRadiusGuard, resetStepToBaseline, runTaskStep } from "./step-runner.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.
|
// 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 { resolveCapturedBaseCommitSha } from "./base-commit-capture.js";
|
||||||
import { installTaskWorktreeIdentityGuard } from "./worktree-hooks.js";
|
import { installTaskWorktreeIdentityGuard } from "./worktree-hooks.js";
|
||||||
import {
|
import {
|
||||||
@@ -1465,7 +1465,28 @@ interface ActiveExecutorSessionState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class TaskExecutor {
|
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>();
|
private executing = new Set<string>();
|
||||||
/** Tasks currently being prepared for unpause resume, before execute() has registered them. */
|
/** Tasks currently being prepared for unpause resume, before execute() has registered them. */
|
||||||
private resumingUnpaused = new Set<string>();
|
private resumingUnpaused = new Set<string>();
|
||||||
@@ -1583,9 +1604,10 @@ export class TaskExecutor {
|
|||||||
this.activeSessions.delete(taskId);
|
this.activeSessions.delete(taskId);
|
||||||
// U5: drop the effective column-agent principal for this task's session.
|
// U5: drop the effective column-agent principal for this task's session.
|
||||||
this.effectiveColumnAgentByTask.delete(taskId);
|
this.effectiveColumnAgentByTask.delete(taskId);
|
||||||
const resolvedWorktreePath = worktreePath ?? this.activeWorktrees.get(taskId);
|
// 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.
|
||||||
if (resolvedWorktreePath) {
|
const resolvedWorktreePaths = worktreePath ? [worktreePath] : this.getActiveWorktreePaths(taskId);
|
||||||
activeSessionRegistry.unregisterPath(resolvedWorktreePath);
|
for (const path of resolvedWorktreePaths) {
|
||||||
|
activeSessionRegistry.unregisterPath(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1600,9 +1622,10 @@ export class TaskExecutor {
|
|||||||
this.activeStepExecutorSeenSteeringIds.delete(taskId);
|
this.activeStepExecutorSeenSteeringIds.delete(taskId);
|
||||||
// U5: drop the effective column-agent principal for this task's step session.
|
// U5: drop the effective column-agent principal for this task's step session.
|
||||||
this.effectiveColumnAgentByTask.delete(taskId);
|
this.effectiveColumnAgentByTask.delete(taskId);
|
||||||
const resolvedWorktreePath = worktreePath ?? this.activeWorktrees.get(taskId);
|
// FNXC:Workspace 2026-06-21-12:00: KTD2 — unregister every held worktree path (Set), not one.
|
||||||
if (resolvedWorktreePath) {
|
const resolvedWorktreePaths = worktreePath ? [worktreePath] : this.getActiveWorktreePaths(taskId);
|
||||||
activeSessionRegistry.unregisterPath(resolvedWorktreePath);
|
for (const path of resolvedWorktreePaths) {
|
||||||
|
activeSessionRegistry.unregisterPath(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1615,9 +1638,10 @@ export class TaskExecutor {
|
|||||||
private deleteActiveWorkflowStepSession(taskId: string, worktreePath?: string): void {
|
private deleteActiveWorkflowStepSession(taskId: string, worktreePath?: string): void {
|
||||||
this.activeWorkflowStepSessions.delete(taskId);
|
this.activeWorkflowStepSessions.delete(taskId);
|
||||||
this.activeWorkflowStepSessionSeenSteeringIds.delete(taskId);
|
this.activeWorkflowStepSessionSeenSteeringIds.delete(taskId);
|
||||||
const resolvedWorktreePath = worktreePath ?? this.activeWorktrees.get(taskId);
|
// FNXC:Workspace 2026-06-21-12:00: KTD2 — unregister every held worktree path (Set), not one.
|
||||||
if (resolvedWorktreePath) {
|
const resolvedWorktreePaths = worktreePath ? [worktreePath] : this.getActiveWorktreePaths(taskId);
|
||||||
activeSessionRegistry.unregisterPath(resolvedWorktreePath);
|
for (const path of resolvedWorktreePaths) {
|
||||||
|
activeSessionRegistry.unregisterPath(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2053,7 +2077,8 @@ export class TaskExecutor {
|
|||||||
return false;
|
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.activeWorktrees.delete(taskId);
|
||||||
this.executing.delete(taskId);
|
this.executing.delete(taskId);
|
||||||
this.recoveringCompleted.delete(taskId);
|
this.recoveringCompleted.delete(taskId);
|
||||||
@@ -2063,8 +2088,8 @@ export class TaskExecutor {
|
|||||||
this.effectiveColumnAgentByTask.delete(taskId);
|
this.effectiveColumnAgentByTask.delete(taskId);
|
||||||
|
|
||||||
const registeredPaths = new Set(activeSessionRegistry.pathsForTask(taskId));
|
const registeredPaths = new Set(activeSessionRegistry.pathsForTask(taskId));
|
||||||
if (worktreePath) {
|
for (const path of heldWorktreePaths) {
|
||||||
registeredPaths.add(worktreePath);
|
registeredPaths.add(path);
|
||||||
}
|
}
|
||||||
for (const path of registeredPaths) {
|
for (const path of registeredPaths) {
|
||||||
activeSessionRegistry.unregisterPath(path);
|
activeSessionRegistry.unregisterPath(path);
|
||||||
@@ -7430,7 +7455,19 @@ export class TaskExecutor {
|
|||||||
const hadAssignedWorktree = Boolean(task.worktree);
|
const hadAssignedWorktree = Boolean(task.worktree);
|
||||||
const taskCommandAbortController = new AbortController();
|
const taskCommandAbortController = new AbortController();
|
||||||
this.registerConfiguredCommandController(task.id, taskCommandAbortController);
|
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 {
|
try {
|
||||||
return await acquireTaskWorktree({
|
return await acquireTaskWorktree({
|
||||||
task,
|
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
|
// Capture the base commit SHA for diff computation whenever a task
|
||||||
// starts with a newly assigned worktree.
|
// starts with a newly assigned worktree.
|
||||||
if (!acquisition.isResume) {
|
if (!acquisition.isResume) {
|
||||||
@@ -7664,8 +7706,10 @@ export class TaskExecutor {
|
|||||||
this.options.onError?.(task, new Error(failureMessage));
|
this.options.onError?.(task, new Error(failureMessage));
|
||||||
return;
|
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}`);
|
executorLog.log(`${task.id}: worktree ready at ${worktreePath}`);
|
||||||
|
|
||||||
const injected = await this.buildInjectedRuntimeEnv(task.id, worktreePath, acquisition.branch ?? undefined);
|
const injected = await this.buildInjectedRuntimeEnv(task.id, worktreePath, acquisition.branch ?? undefined);
|
||||||
@@ -10457,8 +10501,13 @@ export class TaskExecutor {
|
|||||||
options?: { noOpCompletion?: boolean; noOpCompletionReason?: string },
|
options?: { noOpCompletion?: boolean; noOpCompletionReason?: string },
|
||||||
): Promise<{ ok: true } | { ok: false; reason: "wrong_toplevel" | "wrong_branch" | "no_commits"; observed: string; expected: string }> {
|
): Promise<{ ok: true } | { ok: false; reason: "wrong_toplevel" | "wrong_branch" | "no_commits"; observed: string; expected: string }> {
|
||||||
const settings = await this.store.getSettings();
|
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 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) {
|
if (!worktreePath) {
|
||||||
return {
|
return {
|
||||||
@@ -14440,9 +14489,9 @@ You have access to the file system to review changes.${verdictBlock}`;
|
|||||||
conflictPath: string,
|
conflictPath: string,
|
||||||
currentTaskId: string,
|
currentTaskId: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
// Check if conflicting worktree is in our active set
|
// 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, worktreePath] of this.activeWorktrees) {
|
for (const [taskId, worktreePaths] of this.activeWorktrees) {
|
||||||
if (taskId !== currentTaskId && worktreePath === conflictPath) {
|
if (taskId !== currentTaskId && worktreePaths.has(conflictPath)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14479,8 +14528,11 @@ You have access to the file system to review changes.${verdictBlock}`;
|
|||||||
*/
|
*/
|
||||||
listWorktreeHolders(): Array<{ taskId: string; worktreePath: string }> {
|
listWorktreeHolders(): Array<{ taskId: string; worktreePath: string }> {
|
||||||
const holders: Array<{ taskId: string; worktreePath: string }> = [];
|
const holders: Array<{ taskId: string; worktreePath: string }> = [];
|
||||||
for (const [taskId, worktreePath] of this.activeWorktrees) {
|
// 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.
|
||||||
holders.push({ taskId, worktreePath });
|
for (const [taskId, worktreePaths] of this.activeWorktrees) {
|
||||||
|
for (const worktreePath of worktreePaths) {
|
||||||
|
holders.push({ taskId, worktreePath });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return holders;
|
return holders;
|
||||||
}
|
}
|
||||||
@@ -14489,8 +14541,9 @@ You have access to the file system to review changes.${verdictBlock}`;
|
|||||||
worktreePath: string,
|
worktreePath: string,
|
||||||
requestingTaskId: string,
|
requestingTaskId: string,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
for (const [taskId, path] of this.activeWorktrees) {
|
// FNXC:Workspace 2026-06-21-12:00: KTD2 — membership across the task's worktree set (a workspace task holds N).
|
||||||
if (taskId !== requestingTaskId && path === worktreePath) {
|
for (const [taskId, paths] of this.activeWorktrees) {
|
||||||
|
if (taskId !== requestingTaskId && paths.has(worktreePath)) {
|
||||||
return taskId;
|
return taskId;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -14516,12 +14569,9 @@ You have access to the file system to review changes.${verdictBlock}`;
|
|||||||
* Returns true if cleanup succeeded.
|
* Returns true if cleanup succeeded.
|
||||||
*/
|
*/
|
||||||
private hasActiveWorktreeBinding(taskId: string, worktreePath: string): boolean {
|
private hasActiveWorktreeBinding(taskId: string, worktreePath: string): boolean {
|
||||||
for (const [activeTaskId, activePath] of this.activeWorktrees) {
|
// FNXC:Workspace 2026-06-21-12:00: KTD2 — membership across the task's worktree set.
|
||||||
if (activeTaskId === taskId && activePath === worktreePath) {
|
const paths = this.activeWorktrees.get(taskId);
|
||||||
return true;
|
return paths ? paths.has(worktreePath) : false;
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async reconcileSelfOwnedBeforeRemove(worktreePath: string, taskId: string): Promise<void> {
|
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.
|
* always cleaned up by the merger on a per-task basis.
|
||||||
*/
|
*/
|
||||||
async cleanup(taskId: string): Promise<void> {
|
async cleanup(taskId: string): Promise<void> {
|
||||||
const worktreePath = this.activeWorktrees.get(taskId);
|
const worktreePaths = this.getActiveWorktreePaths(taskId);
|
||||||
if (!worktreePath) return;
|
if (worktreePaths.length === 0) return;
|
||||||
|
|
||||||
this.activeWorktrees.delete(taskId);
|
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
|
// Check if another task still needs this worktree
|
||||||
const otherUser = await findWorktreeUser(this.store, worktreePath, taskId);
|
const otherUser = await findWorktreeUser(this.store, worktreePath, taskId);
|
||||||
if (otherUser) {
|
if (otherUser) {
|
||||||
@@ -15420,8 +15477,14 @@ You have access to the file system to review changes.${verdictBlock}`;
|
|||||||
return true;
|
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 {
|
getWorktreePath(taskId: string): string | undefined {
|
||||||
return this.activeWorktrees.get(taskId);
|
if (this.workspaceConfig) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return this.getActiveWorktreePaths(taskId)[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Agent Spawning ─────────────────────────────────────────────────────
|
// ── 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).
|
// 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,
|
// 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.
|
// 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)) {
|
if (!rootDir || !worktreePath || rootDir === worktreePath || !prompt.includes(rootDir)) {
|
||||||
return prompt;
|
return prompt;
|
||||||
}
|
}
|
||||||
@@ -15755,7 +15822,7 @@ export function buildExecutionPrompt(
|
|||||||
customFieldDefs?: WorkflowFieldDefinition[],
|
customFieldDefs?: WorkflowFieldDefinition[],
|
||||||
workspaceConfig?: WorkspaceConfig | null,
|
workspaceConfig?: WorkspaceConfig | null,
|
||||||
): string {
|
): string {
|
||||||
const prompt = scopePromptToWorktree(task.prompt, rootDir, worktreePath);
|
const prompt = scopePromptToWorktree(task.prompt, rootDir, worktreePath, workspaceConfig);
|
||||||
const reviewLevel = parseReviewLevelFromPrompt(prompt);
|
const reviewLevel = parseReviewLevelFromPrompt(prompt);
|
||||||
|
|
||||||
// Build co-author trailer arg for git commits based on settings. The user's
|
// Build co-author trailer arg for git commits based on settings. The user's
|
||||||
|
|||||||
Reference in New Issue
Block a user