FN-9162: Group workspace worktrees under configurable roots

Configure collision-safe workspace and repository subfolders beneath custom worktree roots.

- add shared workspace layout helpers and export them through runtime and gate barrels
- route acquisition, pinning, cleanup, archive, and extension discovery through grouped paths
- allow absolute worktree roots in settings and document the workspace layout
- protect shared-root containers from destructive sweeps and cover multi-repository behavior

Files changed:
 .changeset/fn-9162-workspace-worktree-root.md      |   7 ++
 docs/settings-reference.md                         |   2 +-
 docs/workspaces.md                                 |   6 ++
 .../extension-workspace-worktree-root.test.ts      |  81 +++++++++++++++++
 packages/cli/src/extension.ts                      |  53 ++++++++++-
 packages/core/src/__tests__/pi-extensions.test.ts  |  46 +++++++++-
 .../core/src/__tests__/worktree-layout.test.ts     |  49 ++++++++++
 packages/core/src/index.gate.ts                    |  13 +++
 packages/core/src/index.ts                         |   9 ++
 packages/core/src/plugins/pi-extensions.ts         |  12 ++-
 packages/core/src/task-store/archive-lifecycle.ts  |  11 +--
 packages/core/src/tasks/worktree-layout.ts         |  77 ++++++++++++++++
 .../dashboard/app/components/SettingsModal.tsx     |   4 +-
 .../settings/sections/WorktreesSection.tsx         |   2 +-
 .../worktree-acquisition-workspace.test.ts         |  55 ++++++++++-
 .../engine/src/__tests__/worktree-paths.test.ts    |  19 +++-
 .../engine/src/__tests__/worktree-pinning.test.ts  |  17 ++++
 .../engine/src/__tests__/worktree-pool.test.ts     |  19 +++-
 .../src/executor/workspace-main-checkout-guard.ts  |   2 +-
 packages/engine/src/self-healing.ts                |  20 +++-
 .../engine/src/worktree/worktree-acquisition.ts    | 101 +++++++++++++++------
 packages/engine/src/worktree/worktree-names.ts     |  14 +--
 packages/engine/src/worktree/worktree-paths.ts     |  75 ++++++++++++---
 packages/engine/src/worktree/worktree-pinning.ts   |  10 +-
 packages/engine/src/worktree/worktree-pool.ts      |  44 ++++++---
 25 files changed, 656 insertions(+), 92 deletions(-)

Fusion-Task-Id: FN-9162

Fusion-Task-Lineage: b5f5e247-577c-4357-9ffe-97e2447b4fd4

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-19 19:46:35 -07:00
parent 5ba0b0cffc
commit 3b0a6b795f
25 changed files with 656 additions and 92 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": minor
---
summary: Group workspace worktrees beneath configurable workspace roots.
category: feature
dev: Native workspace worktrees use deterministic workspace and repository path segments.

View File

@@ -613,7 +613,7 @@ When `pushAfterMerge` is enabled, Fusion first tries a working-tree-independent
| Setting | Type | Default | Description | | Setting | Type | Default | Description |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `worktreesDir` | `string` | `undefined` | Optional container directory for task worktrees. Supports absolute paths, project-relative paths, `~` expansion, and `{repo}` token substitution (project root basename). Defaults to `<projectRoot>/.worktrees` when unset and applies to newly-created worktrees/pool scans. When `worktrunk.enabled` is `true`, worktrunk-managed layout takes precedence and this directory is ignored until worktrunk is disabled. | | `worktreesDir` | `string` | `undefined` | Optional container directory for task worktrees. Supports absolute paths, project-relative paths, `~` expansion, and `{repo}` token substitution. For workspace projects, a configured root groups native member checkouts as `<root>/<workspace>/<repo>/<name>`; safe workspace names are preserved and unsafe/nested segments gain deterministic hash suffixes. The default unset workspace layout is unchanged. Equal workspace basenames sharing one external root fail closed rather than collide. When `worktrunk.enabled` is `true`, worktrunk-managed layout takes precedence. |
| `worktrunk.enabled` | `boolean` | `false` | Enables the worktrunk backend (`WorktreeBackend`) for worktree operations. When enabled, worktrunk layout supersedes Fusion’s `.worktrees/<task-id>` and `worktreesDir` behavior. This key exists in global and project settings; project values override global values for matching fields. Setting this to `true` is rejected by the settings API and CLI until the pinned `wt` binary resolves and probe-verifies. Install first via Settings → Worktrunk integration (or `GET /api/worktrunk/status` + `POST /api/worktrunk/install-request`). Auto-install remains fail-closed until the upstream manifest is human-verified, so the default placeholder manifest will not fabricate a binary. See [Architecture: WorktreeBackend abstraction](./architecture.md#worktreebackend-abstraction). | | `worktrunk.enabled` | `boolean` | `false` | Enables the worktrunk backend (`WorktreeBackend`) for worktree operations. When enabled, worktrunk layout supersedes Fusion’s `.worktrees/<task-id>` and `worktreesDir` behavior. This key exists in global and project settings; project values override global values for matching fields. Setting this to `true` is rejected by the settings API and CLI until the pinned `wt` binary resolves and probe-verifies. Install first via Settings → Worktrunk integration (or `GET /api/worktrunk/status` + `POST /api/worktrunk/install-request`). Auto-install remains fail-closed until the upstream manifest is human-verified, so the default placeholder manifest will not fabricate a binary. See [Architecture: WorktreeBackend abstraction](./architecture.md#worktreebackend-abstraction). |
| `worktrunk.binaryPath` | `string \| undefined` | `undefined` | Optional absolute override for the `wt` binary. When unset, Fusion probes `wt` on `$PATH`, then checks the cached install path, and only then considers the gated auto-install flow. Auto-install is currently fail-closed until the upstream manifest is human-verified, so operators who enable `worktrunk.enabled` should set `worktrunk.binaryPath` or install `wt` themselves. When enabling `worktrunk.enabled`, this resolved/overridden path is still probe-verified before the setting is accepted. | | `worktrunk.binaryPath` | `string \| undefined` | `undefined` | Optional absolute override for the `wt` binary. When unset, Fusion probes `wt` on `$PATH`, then checks the cached install path, and only then considers the gated auto-install flow. Auto-install is currently fail-closed until the upstream manifest is human-verified, so operators who enable `worktrunk.enabled` should set `worktrunk.binaryPath` or install `wt` themselves. When enabling `worktrunk.enabled`, this resolved/overridden path is still probe-verified before the setting is accepted. |
| `worktrunk.onFailure` | `"fail" \| "fallback-native"` | `"fail"` | Failure behavior for delegated worktrunk operations. `"fail"` (default) pauses the task with `pausedReason: "worktrunk_operation_failed"` and surfaces worktrunk stderr via `task.worktrunkFailure`. `"fallback-native"` switches to the native backend and emits a one-shot dashboard fallback alert per task (`task.worktrunkFallbackAlertedAt`). | | `worktrunk.onFailure` | `"fail" \| "fallback-native"` | `"fail"` | Failure behavior for delegated worktrunk operations. `"fail"` (default) pauses the task with `pausedReason: "worktrunk_operation_failed"` and surfaces worktrunk stderr via `task.worktrunkFailure`. `"fallback-native"` switches to the native backend and emits a one-shot dashboard fallback alert per task (`task.worktrunkFallbackAlertedAt`). |

View File

@@ -133,3 +133,9 @@ A branch-gone member without landing proof requires manual intervention. Inspect
### Workspace mode appears to re-enable after being toggled off ### Workspace mode appears to re-enable after being toggled off
Check `.fusion/config.json`: explicit `workspaceMode: false` is the guard that suppresses automatic detection. Also inspect `.fusion/workspace.json`; the setting and member configuration are separate artifacts. Re-register or update the configuration deliberately if the project was previously detected as a workspace. Check `.fusion/config.json`: explicit `workspaceMode: false` is the guard that suppresses automatic detection. Also inspect `.fusion/workspace.json`; the setting and member configuration are separate artifacts. Re-register or update the configuration deliberately if the project was previously detected as a workspace.
## Worktree layout
When `worktreesDir` is unset, workspace members keep the existing `<member>/.worktrees/<name>` layout. When it is configured, Fusion resolves the configured root once from the workspace root and creates each native member checkout at `<configured-root>/<workspace>/<repo>/<name>`. A safe workspace directory basename is preserved verbatim; unsafe names use a sanitized segment plus a deterministic eight-character hash. Nested or unsafe member paths use the same sanitized-and-hashed rule, preventing flattened-name collisions.
Fusion writes `.fusion-workspace-root` only while acquiring an external shared root. It rejects a second, different workspace root with the same safe basename rather than sharing the group; configure another root or rename one workspace. The marker never resolves paths and is only a deletion veto. Recorded worktree paths remain authoritative, so existing checkouts are not migrated. Grouped paths are forward-derived and never converted back to a project root by parent trimming. `.ai-merge` remains at the ungrouped configured root. Workspace directory sweeps do not reclaim by walking groups; archive and workspace recovery reclaim recorded member paths addressably.

View File

@@ -0,0 +1,81 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { execFileSync } from "node:child_process";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join, resolve } from "node:path";
import {
WORKSPACE_GROUP_MARKER_FILENAME,
workspaceRepoSegment,
workspaceWorktreeGroupSegment,
} from "@fusion/core";
import {
__resolveProjectRootForTesting,
clearHostTaskStores,
closeCachedStores,
setHostTaskStore,
} from "../extension.js";
function git(cwd: string, args: string[]): string {
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
}
afterEach(async () => {
await closeCachedStores();
clearHostTaskStores();
vi.resetModules();
});
describe("grouped workspace extension root resolution", () => {
/*
FNXC:WorkspaceWorktree 2026-08-20-02:23:
Workspace members are independent Git repositories, so their linked checkout
cannot discover the host through a local `.fusion` walk. The separately loaded
Pi module must consume the host registry's forward-derived candidate and return
the workspace root, never the member, grouped container, or checkout.
*/
it("resolves a member checkout through the host registry in a separately evaluated extension module", async () => {
const workspaceRoot = mkdtempSync(join(tmpdir(), "PRD-1234-my-slug-"));
const sharedRoot = join(dirname(workspaceRoot), "fn-9162-shared-worktrees");
const apiRoot = join(workspaceRoot, "api");
const worktreeDir = join(
sharedRoot,
workspaceWorktreeGroupSegment(workspaceRoot),
workspaceRepoSegment("api"),
"fn-9162",
);
try {
mkdirSync(join(workspaceRoot, ".fusion"), { recursive: true });
writeFileSync(join(workspaceRoot, ".fusion", "config.json"), JSON.stringify({
settings: { workspaceMode: true, worktreesDir: sharedRoot },
}));
writeFileSync(join(workspaceRoot, ".fusion", "workspace.json"), JSON.stringify({ repos: ["api"] }));
mkdirSync(apiRoot, { recursive: true });
git(apiRoot, ["init", "-q", "-b", "main"]);
git(apiRoot, ["config", "user.email", "test@example.com"]);
git(apiRoot, ["config", "user.name", "Test"]);
writeFileSync(join(apiRoot, "base.txt"), "base\n");
git(apiRoot, ["add", "base.txt"]);
git(apiRoot, ["commit", "-q", "-m", "base"]);
mkdirSync(dirname(worktreeDir), { recursive: true });
git(apiRoot, ["worktree", "add", "--detach", worktreeDir, "HEAD"]);
writeFileSync(join(sharedRoot, workspaceWorktreeGroupSegment(workspaceRoot), WORKSPACE_GROUP_MARKER_FILENAME), resolve(workspaceRoot));
setHostTaskStore(workspaceRoot, { id: "workspace-host" } as never);
vi.resetModules();
const isolated = await import("../extension.js");
expect(isolated.__resolveProjectRootForTesting(join(worktreeDir, "src"))).toBe(resolve(workspaceRoot));
expect(isolated.__resolveProjectRootForTesting(worktreeDir)).not.toBe(resolve(apiRoot));
expect(isolated.__resolveProjectRootForTesting(worktreeDir)).not.toBe(dirname(dirname(worktreeDir)));
} finally {
try {
git(apiRoot, ["worktree", "remove", "--force", worktreeDir]);
} catch {
// The fixture may fail before Git records its linked checkout.
}
rmSync(sharedRoot, { recursive: true, force: true });
rmSync(workspaceRoot, { recursive: true, force: true });
}
});
});

View File

@@ -34,6 +34,7 @@ import {
MAX_TASK_LIST_TEXT_CHARS, MAX_TASK_LIST_TEXT_CHARS,
resolveSecretAccessPolicy, resolveSecretAccessPolicy,
getProjectRootFromWorktree, getProjectRootFromWorktree,
resolveWorktreesDirLayout,
resolveTaskGithubTracking, resolveTaskGithubTracking,
formatCurrentTaskLine, formatCurrentTaskLine,
resolveFusionSessionPrincipal, resolveFusionSessionPrincipal,
@@ -92,7 +93,7 @@ import {
import * as dashboard from "@fusion/dashboard"; import * as dashboard from "@fusion/dashboard";
import { resolve, relative, isAbsolute, sep, basename, extname, join } from "node:path"; import { resolve, relative, isAbsolute, sep, basename, extname, join } from "node:path";
import { readFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { existsSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { spawn, type ChildProcess } from "node:child_process"; import { spawn, type ChildProcess } from "node:child_process";
import { AsyncLocalStorage } from "node:async_hooks"; import { AsyncLocalStorage } from "node:async_hooks";
@@ -209,9 +210,42 @@ const MIME_TYPES: Record<string, string> = {
let warnedMissingProjectRootResolver = false; let warnedMissingProjectRootResolver = false;
type WorkspaceRootConfig = { settings?: { worktreesDir?: string; workspaceMode?: boolean } };
type WorkspaceReposConfig = { repos?: unknown };
/*
FNXC:WorkspaceWorktree 2026-08-20-01:46:
Extension sessions launched from grouped member checkouts must use a project root
provided by a forward-derived layout candidate. The hash-bearing group segment is
one-way, so parent trimming can select a non-project directory and is forbidden.
*/
function getKnownWorktreeCandidates(): Array<{ dir: string; projectRoot: string }> {
const candidates: Array<{ dir: string; projectRoot: string }> = [];
for (const projectRoot of knownProjectRoots) {
try {
const config = JSON.parse(readFileSync(join(projectRoot, ".fusion", "config.json"), "utf8")) as WorkspaceRootConfig;
const settings = config.settings;
candidates.push({ dir: resolveWorktreesDirLayout(projectRoot, settings), projectRoot });
if (settings?.workspaceMode !== true) continue;
const workspace = JSON.parse(readFileSync(join(projectRoot, ".fusion", "workspace.json"), "utf8")) as WorkspaceReposConfig;
if (!Array.isArray(workspace.repos)) continue;
for (const repoRelPath of workspace.repos) {
if (typeof repoRelPath !== "string") continue;
candidates.push({
dir: resolveWorktreesDirLayout(projectRoot, settings, { workspaceRootDir: projectRoot, repoRelPath }),
projectRoot,
});
}
} catch {
// A missing or malformed local config cannot prove a workspace root.
}
}
return candidates;
}
function resolveProjectRoot(cwd: string): string { function resolveProjectRoot(cwd: string): string {
const worktreeProjectRoot = typeof getProjectRootFromWorktree === "function" const worktreeProjectRoot = typeof getProjectRootFromWorktree === "function"
? getProjectRootFromWorktree(cwd) ? getProjectRootFromWorktree(cwd, { worktreesDirCandidates: getKnownWorktreeCandidates() })
: null; : null;
if (typeof getProjectRootFromWorktree !== "function" && !warnedMissingProjectRootResolver) { if (typeof getProjectRootFromWorktree !== "function" && !warnedMissingProjectRootResolver) {
warnedMissingProjectRootResolver = true; warnedMissingProjectRootResolver = true;
@@ -235,6 +269,16 @@ function resolveProjectRoot(cwd: string): string {
} }
} }
/*
FNXC:WorkspaceWorktree 2026-08-20-02:23:
Keep the production root-resolution route directly testable so a separately
loaded Pi extension proves it reads the host's shared known-project registry
for grouped workspace member checkouts instead of stopping at the member repo.
*/
export function __resolveProjectRootForTesting(cwd: string): string {
return resolveProjectRoot(cwd);
}
/* /*
FNXC:PostgresCutover 2026-07-04-00:00: FNXC:PostgresCutover 2026-07-04-00:00:
The agent-tool store path must boot the PostgreSQL backend (embedded by default, or external via DATABASE_URL) instead of constructing a legacy SQLite TaskStore, whose runtime was removed under VAL-REMOVAL-005. Mirrors the fn serve boot path, which already routes through createTaskStoreForBackend. The boot result is cached per project root so the connection pool (and any embedded PostgreSQL process) is released deterministically in closeCachedStores. The agent-tool store path must boot the PostgreSQL backend (embedded by default, or external via DATABASE_URL) instead of constructing a legacy SQLite TaskStore, whose runtime was removed under VAL-REMOVAL-005. Mirrors the fn serve boot path, which already routes through createTaskStoreForBackend. The boot result is cached per project root so the connection pool (and any embedded PostgreSQL process) is released deterministically in closeCachedStores.
@@ -259,6 +303,7 @@ interface ExtensionStoreState {
readonly cache: Map<string, CachedStoreEntry>; readonly cache: Map<string, CachedStoreEntry>;
readonly bootInflight: Map<string, Promise<TaskStore>>; readonly bootInflight: Map<string, Promise<TaskStore>>;
readonly bootFailureCooldown: Map<string, { untilMs: number; error: string }>; readonly bootFailureCooldown: Map<string, { untilMs: number; error: string }>;
readonly knownProjectRoots: Set<string>;
} }
const extensionStoreStateKey = Symbol.for("@runfusion/fusion/extension-store-state"); const extensionStoreStateKey = Symbol.for("@runfusion/fusion/extension-store-state");
@@ -267,11 +312,14 @@ const extensionStoreState = extensionStoreGlobal[extensionStoreStateKey] ?? {
cache: new Map<string, CachedStoreEntry>(), cache: new Map<string, CachedStoreEntry>(),
bootInflight: new Map<string, Promise<TaskStore>>(), bootInflight: new Map<string, Promise<TaskStore>>(),
bootFailureCooldown: new Map<string, { untilMs: number; error: string }>(), bootFailureCooldown: new Map<string, { untilMs: number; error: string }>(),
knownProjectRoots: new Set<string>(),
}; };
extensionStoreGlobal[extensionStoreStateKey] = extensionStoreState; extensionStoreGlobal[extensionStoreStateKey] = extensionStoreState;
/** Cache stores per project root to avoid re-booting the backend on every tool call. */ /** Cache stores per project root to avoid re-booting the backend on every tool call. */
const storeCache = extensionStoreState.cache; const storeCache = extensionStoreState.cache;
/* FNXC:WorkspaceWorktree 2026-08-20-01:46: Keep grouped-layout candidates visible to Pi's separately evaluated extension module. */
const knownProjectRoots = extensionStoreState.knownProjectRoots;
/* /*
FNXC:MergeQueue 2026-07-15-11:08: FNXC:MergeQueue 2026-07-15-11:08:
Concurrent first-call fn_* tools must share one boot promise. Without this, two parallel cache misses each call createTaskStoreForBackend and contend on fusion:schema-applier advisory locks / pool setup — the pattern behind wedged fn_task_show during AI merge. Concurrent first-call fn_* tools must share one boot promise. Without this, two parallel cache misses each call createTaskStoreForBackend and contend on fusion:schema-applier advisory locks / pool setup — the pattern behind wedged fn_task_show during AI merge.
@@ -573,6 +621,7 @@ async function getStore(
* The entry is external: closeCachedStores / clearHostTaskStores will not shut it down — the host owns lifecycle. * The entry is external: closeCachedStores / clearHostTaskStores will not shut it down — the host owns lifecycle.
*/ */
export function setHostTaskStore(projectRoot: string, store: TaskStore): void { export function setHostTaskStore(projectRoot: string, store: TaskStore): void {
knownProjectRoots.add(resolve(projectRoot));
// FNXC:WorkflowLifecycle 2026-07-16-10:00: Install before caching an injected host store because getStore returns cached stores without a construction pass; this preserves executor-less archive cleanup during host startup. // FNXC:WorkflowLifecycle 2026-07-16-10:00: Install before caching an injected host store because getStore returns cached stores without a construction pass; this preserves executor-less archive cleanup during host startup.
installBaselineArchiveWorktreeDisposer(store, {rootDir: projectRoot, getSettings: () => store.getSettings()}); installBaselineArchiveWorktreeDisposer(store, {rootDir: projectRoot, getSettings: () => store.getSettings()});
const canonical = resolveProjectRoot(projectRoot); const canonical = resolveProjectRoot(projectRoot);

View File

@@ -1,8 +1,12 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { mkdtempSync, mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path"; import { dirname, join, resolve } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
import {
workspaceRepoSegment,
workspaceWorktreeGroupSegment,
} from "../tasks/worktree-layout.js";
import { getProjectRootFromWorktree, resolvePiExtensionProjectRoot } from "../plugins/pi-extensions.js"; import { getProjectRootFromWorktree, resolvePiExtensionProjectRoot } from "../plugins/pi-extensions.js";
function git(cwd: string, args: string): string { function git(cwd: string, args: string): string {
@@ -38,6 +42,46 @@ describe("getProjectRootFromWorktree", () => {
).toBe("/tmp"); ).toBe("/tmp");
}); });
/*
FNXC:WorkspaceWorktree 2026-08-20-02:04:
A grouped checkout has two one-way segments between its configured root and
worktree. Pi must receive the known workspace root as a forward-derived
candidate, never infer it by trimming those path components.
*/
it("resolves a real grouped workspace checkout to the supplied workspace root", () => {
const root = mkdtempSync(join(tmpdir(), "PRD-9162 unsafe root "));
const sharedRoot = join(dirname(root), "fn-9162-shared-worktrees");
const candidateDir = join(
sharedRoot,
workspaceWorktreeGroupSegment(root),
workspaceRepoSegment("group/api"),
);
const worktreeDir = join(candidateDir, "fn-9162");
try {
git(root, "init -q -b main");
git(root, "config user.email test@example.com");
git(root, "config user.name Test");
mkdirSync(join(root, ".fusion"), { recursive: true });
writeFileSync(join(root, "base.txt"), "base\n");
git(root, "add -A");
git(root, "commit -q -m base");
mkdirSync(candidateDir, { recursive: true });
git(root, `worktree add --detach ${JSON.stringify(worktreeDir)} HEAD`);
expect(getProjectRootFromWorktree(join(worktreeDir, "src"), {
worktreesDirCandidates: [{ dir: candidateDir, projectRoot: root }],
})).toBe(resolve(root));
} finally {
try {
git(root, `worktree remove --force ${JSON.stringify(worktreeDir)}`);
} catch {
// Best-effort cleanup after an incomplete real-git fixture.
}
rmSync(sharedRoot, { recursive: true, force: true });
rmSync(root, { recursive: true, force: true });
}
});
it("returns null without throwing when child_process partial mocks omit spawnSync", async () => { it("returns null without throwing when child_process partial mocks omit spawnSync", async () => {
vi.resetModules(); vi.resetModules();
vi.doMock("node:child_process", () => ({ vi.doMock("node:child_process", () => ({

View File

@@ -0,0 +1,49 @@
import { createHash } from "node:crypto";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { describe, expect, it } from "vitest";
import {
assertWorkspaceRepoRelPath,
resolveWorktreesDirLayout,
sanitizePathSegment,
workspaceRepoSegment,
workspaceWorktreeGroupSegment,
} from "../tasks/worktree-layout.js";
describe("workspace worktree layout", () => {
const workspace = "/tmp/PRD-1234-my-slug";
const context = { workspaceRootDir: workspace, repoRelPath: "api" };
it("keeps the unset layout byte-identical", () => {
expect(resolveWorktreesDirLayout("/tmp/repo", undefined)).toBe("/tmp/repo/.worktrees");
expect(resolveWorktreesDirLayout(join(workspace, "api"), undefined, context)).toBe(join(workspace, "api", ".worktrees"));
});
it("resolves configured roots once at the workspace and groups repositories", () => {
expect(resolveWorktreesDirLayout(join(workspace, "api"), { worktreesDir: "../trees/{repo}" } as any, context))
.toBe(resolve(workspace, "../trees/PRD-1234-my-slug/PRD-1234-my-slug/api"));
expect(resolveWorktreesDirLayout(join(workspace, "api"), { worktreesDir: "/var/tmp/trees" } as any, context))
.toBe("/var/tmp/trees/PRD-1234-my-slug/api");
expect(resolveWorktreesDirLayout(join(workspace, "api"), { worktreesDir: "~/.trees" } as any, context))
.toBe(join(homedir(), ".trees/PRD-1234-my-slug/api"));
});
it("preserves safe workspace names and hashes unsafe names deterministically", () => {
expect(workspaceWorktreeGroupSegment(workspace)).toBe("PRD-1234-my-slug");
const unsafeRoot = "/tmp/PRD-1234 My Slug";
expect(workspaceWorktreeGroupSegment(unsafeRoot)).toBe(`PRD-1234-My-Slug-${createHash("sha256").update(resolve(unsafeRoot)).digest("hex").slice(0, 8)}`);
expect(workspaceWorktreeGroupSegment("/tmp/🧪")).toMatch(/^workspace-[a-f0-9]{8}$/);
expect(workspaceWorktreeGroupSegment("/a/PRD-1234-my-slug")).toBe(workspaceWorktreeGroupSegment("/b/PRD-1234-my-slug"));
});
it("separates nested repository paths from lossy flattened names", () => {
expect(workspaceRepoSegment("group/api")).toMatch(/^group-api-[a-f0-9]{8}$/);
expect(workspaceRepoSegment("group/api")).not.toBe(workspaceRepoSegment("group-api"));
expect(workspaceRepoSegment("group\\api")).toBe(workspaceRepoSegment("group/api"));
});
it("sanitizes and rejects escaping paths", () => {
expect(sanitizePathSegment(".. A/ß ..")).toBe("A");
for (const path of ["../api", "/api", "..", ""]) expect(() => assertWorkspaceRepoRelPath(path)).toThrow();
});
});

View File

@@ -2327,6 +2327,19 @@ export {
} from "./secrets/secrets-sync-passphrase.js"; } from "./secrets/secrets-sync-passphrase.js";
export { suggestTaskPrefix } from "./tasks/task-prefix.js"; export { suggestTaskPrefix } from "./tasks/task-prefix.js";
/* /*
FNXC:WorkspaceWorktree 2026-08-20-02:45:
The engine-core gate barrel must expose the shared workspace worktree layout helpers because engine path resolution imports them at runtime.
*/
export {
WORKSPACE_GROUP_MARKER_FILENAME,
sanitizePathSegment,
assertWorkspaceRepoRelPath,
workspaceWorktreeGroupSegment,
workspaceRepoSegment,
resolveWorktreesDirLayout,
} from "./tasks/worktree-layout.js";
export type { WorkspaceWorktreeContext } from "./tasks/worktree-layout.js";
/*
FNXC:WorkflowStepResults 2026-07-19-01:00: FNXC:WorkflowStepResults 2026-07-19-01:00:
Keep this gate-safe barrel's workflow-step-results re-exports in SYNC with the main barrel (index.ts). The `engine-core` vitest project builds its @fusion/core from THIS file (scripts/build-engine-core-gate-bundle.mjs), so any lease/step-result export present in index.ts but missing here resolves to `undefined` ONLY under engine-core — which is exactly how U3's `classifyReviewLease` went missing and threw "classifyReviewLease is not a function" on every defaultOn Plan Review run in that project (caught by task-pipeline-smoke). When adding an export to the index.ts workflow-step-results block, add it here too. Keep this gate-safe barrel's workflow-step-results re-exports in SYNC with the main barrel (index.ts). The `engine-core` vitest project builds its @fusion/core from THIS file (scripts/build-engine-core-gate-bundle.mjs), so any lease/step-result export present in index.ts but missing here resolves to `undefined` ONLY under engine-core — which is exactly how U3's `classifyReviewLease` went missing and threw "classifyReviewLease is not a function" on every defaultOn Plan Review run in that project (caught by task-pipeline-smoke). When adding an export to the index.ts workflow-step-results block, add it here too.
*/ */

View File

@@ -2866,6 +2866,15 @@ export {
export { pruneTaskLifecycleEvents } from "./task-store/task-lifecycle-event-retention.js"; export { pruneTaskLifecycleEvents } from "./task-store/task-lifecycle-event-retention.js";
export { buildConsumerId } from "./task-store/task-lifecycle-consumer-identity.js"; export { buildConsumerId } from "./task-store/task-lifecycle-consumer-identity.js";
export {
WORKSPACE_GROUP_MARKER_FILENAME,
sanitizePathSegment,
assertWorkspaceRepoRelPath,
workspaceWorktreeGroupSegment,
workspaceRepoSegment,
resolveWorktreesDirLayout,
} from "./tasks/worktree-layout.js";
export type { WorkspaceWorktreeContext } from "./tasks/worktree-layout.js";
export type { AgentActivityEventType, AgentActivityAttribution, AgentActivityIdProvenance, AgentActivityIdCandidate, AgentActivityAttributionClaim, AgentActivityMetadataValueSpec, AgentActivityEvent, AgentActivityEventInput, AgentActivityQuery } from "./types/agents/agents.js"; export type { AgentActivityEventType, AgentActivityAttribution, AgentActivityIdProvenance, AgentActivityIdCandidate, AgentActivityAttributionClaim, AgentActivityMetadataValueSpec, AgentActivityEvent, AgentActivityEventInput, AgentActivityQuery } from "./types/agents/agents.js";
export { AGENT_ACTIVITY_EVENT_TYPES, AGENT_ACTIVITY_ATTRIBUTIONS, AGENT_ACTIVITY_LANE_SENTINELS, AGENT_ACTIVITY_GENERATED_ID_PATTERNS, AGENT_ACTIVITY_HANDOFF_REASONS, AGENT_ACTIVITY_TOOL_NAMES, AGENT_ACTIVITY_WORKFLOW_STEP_IDS, AGENT_ACTIVITY_METADATA_SCHEMA, AGENT_ACTIVITY_METADATA_KEYS, isAgentActivityEventType } from "./types/agents/agents.js"; export { AGENT_ACTIVITY_EVENT_TYPES, AGENT_ACTIVITY_ATTRIBUTIONS, AGENT_ACTIVITY_LANE_SENTINELS, AGENT_ACTIVITY_GENERATED_ID_PATTERNS, AGENT_ACTIVITY_HANDOFF_REASONS, AGENT_ACTIVITY_TOOL_NAMES, AGENT_ACTIVITY_WORKFLOW_STEP_IDS, AGENT_ACTIVITY_METADATA_SCHEMA, AGENT_ACTIVITY_METADATA_KEYS, isAgentActivityEventType } from "./types/agents/agents.js";
export { appendAgentActivityEvent, queryAgentActivityEvents, getMaxAgentActivitySeq, pruneAgentActivityEvents } from "./task-store/async/async-agent-activity.js"; export { appendAgentActivityEvent, queryAgentActivityEvents, getMaxAgentActivitySeq, pruneAgentActivityEvents } from "./task-store/async/async-agent-activity.js";

View File

@@ -50,7 +50,7 @@ export function getFusionAgentSettingsPath(home?: string): string {
export function getProjectRootFromWorktree( export function getProjectRootFromWorktree(
cwd: string, cwd: string,
opts?: { worktreesDirCandidates?: string[] }, opts?: { worktreesDirCandidates?: Array<string | { dir: string; projectRoot: string }> },
): string | null { ): string | null {
const knownWorktreePatterns = [ const knownWorktreePatterns = [
/^(.+?)[\\/]\.worktrees[\\/][^\\/]+(?:[\\/]|$)/, /^(.+?)[\\/]\.worktrees[\\/][^\\/]+(?:[\\/]|$)/,
@@ -64,16 +64,18 @@ export function getProjectRootFromWorktree(
} }
for (const candidate of opts?.worktreesDirCandidates ?? []) { for (const candidate of opts?.worktreesDirCandidates ?? []) {
const normalizedCandidate = resolve(candidate); const candidateDir = typeof candidate === "string" ? candidate : candidate.dir;
const objectCandidate = typeof candidate === "string" ? undefined : candidate;
const normalizedCandidate = resolve(candidateDir);
const normalizedCwd = resolve(cwd); const normalizedCwd = resolve(cwd);
const rel = relative(normalizedCandidate, normalizedCwd); const rel = relative(normalizedCandidate, normalizedCwd);
if (rel !== "" && !rel.startsWith("..") && !isAbsolute(rel)) { if (rel !== "" && !rel.startsWith("..") && !isAbsolute(rel)) {
const firstSegment = rel.split(/[\\/]/).filter(Boolean)[0]; const firstSegment = rel.split(/[\\/]/).filter(Boolean)[0];
if (firstSegment) { if (firstSegment) {
// Grouped workspace paths are one-way; callers that know the project root provide it explicitly.
if (objectCandidate) return objectCandidate.projectRoot;
const parent = normalizedCandidate.split(/[\\/]/).slice(0, -1).join("/"); const parent = normalizedCandidate.split(/[\\/]/).slice(0, -1).join("/");
if (parent) { if (parent && parent !== "." && parent !== normalizedCandidate) return parent;
return parent;
}
} }
} }
} }

View File

@@ -16,12 +16,11 @@ import {getErrorMessage} from "../process/error-message.js";
import {ArchiveWorkspaceDisposalError, ArchiveWorkspaceDisposalIncompleteError, ArchiveWorkspaceWorktreeDisposerMissingError, getArchiveWorkspaceWorktreeDisposer, getArchiveWorktreeDisposer, type ArchiveWorkspaceDisposalResult, type WorkspaceDisposalPlanEntry} from "../db/archive-worktree-disposer.js"; import {ArchiveWorkspaceDisposalError, ArchiveWorkspaceDisposalIncompleteError, ArchiveWorkspaceWorktreeDisposerMissingError, getArchiveWorkspaceWorktreeDisposer, getArchiveWorktreeDisposer, type ArchiveWorkspaceDisposalResult, type WorkspaceDisposalPlanEntry} from "../db/archive-worktree-disposer.js";
import {acquireWorktreePathReservation, canonicalizeWorktreePath} from "../tasks/worktree-path-reservation.js"; import {acquireWorktreePathReservation, canonicalizeWorktreePath} from "../tasks/worktree-path-reservation.js";
import {LiveTaskWorktreeRemovalRefusedError} from "../tasks/task-archive-liveness.js"; import {LiveTaskWorktreeRemovalRefusedError} from "../tasks/task-archive-liveness.js";
import {basename, join, resolve} from "node:path"; import {join} from "node:path";
import {homedir} from "node:os"; import {resolveWorktreesDirLayout, type WorkspaceWorktreeContext} from "../tasks/worktree-layout.js";
function resolveArchiveWorktreesDir(store: TaskStore, configured?: string): string { function resolveArchiveWorktreesDir(store: TaskStore, configured?: string, workspaceContext?: WorkspaceWorktreeContext): string {
const value = configured?.replace(/^~(?=$|[\\/])/, homedir()).replaceAll("{repo}", basename(store.rootDir)); return resolveWorktreesDirLayout(store.rootDir, {worktreesDir: configured}, workspaceContext);
return value ? resolve(store.rootDir, value) : join(store.rootDir, ".worktrees");
} }
export async function buildWorkspaceDisposalPlan(store: TaskStore, task: Task): Promise<{plan: WorkspaceDisposalPlanEntry[]; singularDeduplicated: boolean}> { export async function buildWorkspaceDisposalPlan(store: TaskStore, task: Task): Promise<{plan: WorkspaceDisposalPlanEntry[]; singularDeduplicated: boolean}> {
@@ -89,7 +88,7 @@ export async function prepareArchivedWorkspaceWorktrees(store: TaskStore, task:
reservations[entry.repoRel] = await acquireWorktreePathReservation({ reservations[entry.repoRel] = await acquireWorktreePathReservation({
canonicalPath: canonical, canonicalPath: canonical,
rootDir: entry.repoRootDir, rootDir: entry.repoRootDir,
worktreesDir: resolveArchiveWorktreesDir({rootDir: entry.repoRootDir} as TaskStore, settings.worktreesDir), worktreesDir: resolveArchiveWorktreesDir(store, settings.worktreesDir, {workspaceRootDir: store.rootDir, repoRelPath: entry.repoRel}),
}); });
} }
return {plan, reservations, singularDeduplicated}; return {plan, reservations, singularDeduplicated};

View File

@@ -0,0 +1,77 @@
import { createHash } from "node:crypto";
import { homedir } from "node:os";
import { basename, isAbsolute, join, normalize, resolve, sep } from "node:path";
import type { Settings } from "../types/settings/settings-scope.js";
export interface WorkspaceWorktreeContext {
workspaceRootDir: string;
repoRelPath: string;
}
export const WORKSPACE_GROUP_MARKER_FILENAME = ".fusion-workspace-root";
/**
* FNXC:WorkspaceWorktree 2026-08-20-01:20:
* Workspace checkout grouping uses a pure, single-valued segment so acquisition,
* containment, reservations, and cleanup derive the same directory. Candidate
* directories make ownership undecidable; marker files only reject conflicting
* acquisition and are never an input to path resolution.
*/
export function sanitizePathSegment(raw: string): string {
return raw.replace(/[^A-Za-z0-9._-]/g, "-").replace(/-+/g, "-").replace(/^[-.]+|[-.]+$/g, "");
}
function hash8(value: string): string {
return createHash("sha256").update(value).digest("hex").slice(0, 8);
}
export function workspaceWorktreeGroupSegment(workspaceRootDir: string): string {
const resolvedRoot = resolve(workspaceRootDir);
const base = basename(resolvedRoot);
if (/^[A-Za-z0-9._][A-Za-z0-9._-]*$/.test(base) && base !== "." && base !== "..") return base;
const hash = hash8(resolvedRoot);
return `${sanitizePathSegment(base) || "workspace"}-${hash}`;
}
/** Reject a user-controlled repo path before it can escape a workspace root. */
export function assertWorkspaceRepoRelPath(repoRelPath: string): void {
if (typeof repoRelPath !== "string" || repoRelPath.length === 0 || isAbsolute(repoRelPath)) {
throw new Error(`Invalid workspace repo path (must be relative and in-root): ${String(repoRelPath)}`);
}
const normalized = normalize(repoRelPath.replaceAll("\\", "/"));
if (normalized === ".." || normalized.startsWith(`..${sep}`) || normalized.startsWith("../")) {
throw new Error(`Invalid workspace repo path (escapes workspace root): ${repoRelPath}`);
}
}
export function workspaceRepoSegment(repoRelPath: string): string {
assertWorkspaceRepoRelPath(repoRelPath);
const normalized = normalize(repoRelPath.replaceAll("\\", "/")).replaceAll("\\", "/");
if (/^[A-Za-z0-9._][A-Za-z0-9._-]*$/.test(normalized) && !normalized.includes("/") && normalized !== "." && normalized !== "..") {
return normalized;
}
const flattened = sanitizePathSegment(normalized.split("/").join("-")) || "repo";
return `${flattened}-${hash8(normalized)}`;
}
/**
* FNXC:WorkspaceWorktree 2026-08-20-01:20:
* The unset setting intentionally retains the historic per-repository `.worktrees`
* layout. Grouping applies only to an explicitly configured root, while `.ai-merge`
* remains resolved separately from the ungrouped root.
*/
export function resolveWorktreesDirLayout(
rootDir: string,
settings: Pick<Settings, "worktreesDir"> | undefined,
workspaceContext?: WorkspaceWorktreeContext,
): string {
const configured = settings?.worktreesDir;
if (!configured) return join(rootDir, ".worktrees");
const resolutionRoot = workspaceContext?.workspaceRootDir ?? rootDir;
const expandedHome = configured.replace(/^~(?=$|[\\/])/, homedir());
const expandedRepo = expandedHome.replaceAll("{repo}", basename(resolutionRoot));
const configuredRoot = resolve(resolutionRoot, expandedRepo);
if (!workspaceContext) return configuredRoot;
return join(configuredRoot, workspaceWorktreeGroupSegment(workspaceContext.workspaceRootDir), workspaceRepoSegment(workspaceContext.repoRelPath));
}

View File

@@ -1259,7 +1259,7 @@ export function SettingsModal({
loading: worktreesDirPickerLoading, loading: worktreesDirPickerLoading,
error: worktreesDirPickerError, error: worktreesDirPickerError,
refresh: refreshWorktreesDirPicker, refresh: refreshWorktreesDirPicker,
} = useWorkspaceFileBrowser("project", worktreesDirPickerOpen, projectId, { allowAbsolutePaths: false }); } = useWorkspaceFileBrowser("project", worktreesDirPickerOpen, projectId, { allowAbsolutePaths: true });
const { const {
entries: worktreeCopyFilePickerEntries, entries: worktreeCopyFilePickerEntries,
@@ -3277,8 +3277,6 @@ export function SettingsModal({
}, [closeWorktreesDirPicker]); }, [closeWorktreesDirPicker]);
const selectCurrentWorktreesDir = useCallback(() => { const selectCurrentWorktreesDir = useCallback(() => {
if (isSlashPrefixedAbsolutePath(worktreesDirPickerCurrentPath)) return;
const normalizedPath = worktreesDirPickerCurrentPath === "." const normalizedPath = worktreesDirPickerCurrentPath === "."
? "./" ? "./"
: (worktreesDirPickerCurrentPath.endsWith("/") ? worktreesDirPickerCurrentPath : `${worktreesDirPickerCurrentPath}/`); : (worktreesDirPickerCurrentPath.endsWith("/") ? worktreesDirPickerCurrentPath : `${worktreesDirPickerCurrentPath}/`);

View File

@@ -219,7 +219,7 @@ export function WorktreesSection({ form, setForm, gitRemotes, worktrunkInstall,
<SettingsHelpTip settingKey="worktreesDir"> <SettingsHelpTip settingKey="worktreesDir">
{form.worktrunk?.enabled === true {form.worktrunk?.enabled === true
? "Disabled because Worktrunk integration is enabled — worktrunk manages the worktree directory layout. Disable worktrunk integration to use a custom directory." ? "Disabled because Worktrunk integration is enabled — worktrunk manages the worktree directory layout. Disable worktrunk integration to use a custom directory."
: <>{t("settings.worktrees.optionalSupports", " Optional. Supports ")}<code>~</code>{t("settings.worktrees.and", " and ")}<code>{"{repo}"}</code>{t("settings.worktrees.defaultsTo", ". Defaults to ")}<code>&lt;projectRoot&gt;/.worktrees</code>{t("settings.worktrees.whenUnsetOnlyAffectsNewlyCreatedWorktrees", " when unset. Only affects newly-created worktrees. ")}</>} : <>{t("settings.worktrees.optionalSupports", " Optional. Supports ")}<code>~</code>{t("settings.worktrees.and", " and ")}<code>{"{repo}"}</code>{t("settings.worktrees.defaultsTo", ". Absolute paths are allowed. Workspace projects group configured roots by workspace and repository. Defaults to ")}<code>&lt;projectRoot&gt;/.worktrees</code>{t("settings.worktrees.whenUnsetOnlyAffectsNewlyCreatedWorktrees", " when unset. Only affects newly-created worktrees. ")}</>}
</SettingsHelpTip> </SettingsHelpTip>
</div> </div>
<div className="settings-overlap-ignore-path-controls"> <div className="settings-overlap-ignore-path-controls">

View File

@@ -9,16 +9,24 @@ root. The TaskStore is an in-memory fake (no DB / no network) per FN-5048 — re
git only where the invariant needs it; everything else is a narrow seam. git only where the invariant needs it; everything else is a narrow seam.
*/ */
import { execSync, spawnSync } from "node:child_process"; import { execSync, spawnSync } from "node:child_process";
import { existsSync, writeFileSync } from "node:fs"; import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { dirname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import type { Settings, Task, TaskStore } from "@fusion/core"; import {
WORKSPACE_GROUP_MARKER_FILENAME,
workspaceRepoSegment,
workspaceWorktreeGroupSegment,
type Settings,
type Task,
type TaskStore,
} from "@fusion/core";
import { import {
acquireTaskWorktree, acquireTaskWorktree,
acquireWorkspaceRepoWorktree, acquireWorkspaceRepoWorktree,
WorkspaceRepoAcquireBusyError, WorkspaceRepoAcquireBusyError,
} from "../worktree/worktree-acquisition.js"; } from "../worktree/worktree-acquisition.js";
import { ActiveSessionRegistry } from "../agents/active-session-registry.js"; import { ActiveSessionRegistry } from "../agents/active-session-registry.js";
import { cleanupOrphanedWorktrees } from "../worktree/worktree-pool.js";
import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js"; import { createWorkspaceFixture, hasGit, type WorkspaceFixture } from "./_workspace-fixture.js";
const describeIfGit = hasGit ? describe : describe.skip; const describeIfGit = hasGit ? describe : describe.skip;
@@ -546,6 +554,47 @@ describeIfGit("acquireWorkspaceRepoWorktree (U2 per-repo hardening)", { timeout:
expect(current().workspaceWorktrees).toBeUndefined(); expect(current().workspaceWorktrees).toBeUndefined();
}); });
/*
FNXC:WorkspaceWorktree 2026-08-20-02:04:
Grouped workspace roots must be proven through real `git worktree add` calls.
A path-helper fixture cannot detect the task-id collision that occurs when two
member repositories share a configured root.
*/
it("groups real multi-repo acquisitions and protects their shared-root container from a foreign project sweep", async () => {
fixture = await createWorkspaceFixture(["api", "web", "foreign"]);
const sharedRoot = join(dirname(fixture.rootDir), "shared-worktrees");
const settings = { ...SETTINGS, worktreesDir: sharedRoot };
const { store, current } = makeFakeStore(makeTask("FN-9162"));
const registry = new ActiveSessionRegistry();
const api = await acquireWorkspaceRepoWorktree({
repoRelPath: "api", workspaceRootDir: fixture.rootDir, task: current(), store, settings, registry,
});
const web = await acquireWorkspaceRepoWorktree({
repoRelPath: "web", workspaceRootDir: fixture.rootDir, task: current(), store, settings, registry,
});
const group = workspaceWorktreeGroupSegment(fixture.rootDir);
expect(api.worktreePath).toBe(join(sharedRoot, group, workspaceRepoSegment("api"), "fn-9162"));
expect(web.worktreePath).toBe(join(sharedRoot, group, workspaceRepoSegment("web"), "fn-9162"));
expect(api.worktreePath).not.toBe(web.worktreePath);
expect(existsSync(join(api.worktreePath, ".git"))).toBe(true);
expect(existsSync(join(web.worktreePath, ".git"))).toBe(true);
expect(readFileSync(join(sharedRoot, group, WORKSPACE_GROUP_MARKER_FILENAME), "utf8").trim()).toBe(fixture.rootDir);
// A non-workspace project can share the configured root. Its real cleanup
// path must not interpret this workspace's group container as an orphan.
const cleaned = await cleanupOrphanedWorktrees(
fixture.repoPath("foreign"),
{ listTasks: async () => [] } as unknown as TaskStore,
{ worktreesDir: sharedRoot, workspaceMode: false },
);
expect(cleaned).toBe(0);
expect(existsSync(api.worktreePath)).toBe(true);
expect(existsSync(web.worktreePath)).toBe(true);
expect(existsSync(join(sharedRoot, group, WORKSPACE_GROUP_MARKER_FILENAME))).toBe(true);
});
it("keeps the single-repo acquisition persistence contract when suppression is absent", async () => { it("keeps the single-repo acquisition persistence contract when suppression is absent", async () => {
fixture = await createWorkspaceFixture(["repo-a"]); fixture = await createWorkspaceFixture(["repo-a"]);
const initial = makeTask("FN-10"); const initial = makeTask("FN-10");

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { homedir } from "node:os"; import { homedir, tmpdir } from "node:os";
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { join, resolve } from "node:path"; import { join, resolve } from "node:path";
import { import {
AI_MERGE_DIRNAME, AI_MERGE_DIRNAME,
@@ -7,6 +8,7 @@ import {
isAiMergeContainerDir, isAiMergeContainerDir,
isWorktreeContainerDir, isWorktreeContainerDir,
isInsideConfiguredWorktreesDir, isInsideConfiguredWorktreesDir,
isReclaimableWorktreeCandidate,
resolveAiMergeRootPath, resolveAiMergeRootPath,
resolveTaskWorktreePath, resolveTaskWorktreePath,
resolveTaskWorktreePathForBackend, resolveTaskWorktreePathForBackend,
@@ -74,6 +76,21 @@ describe("worktree-paths", () => {
expect(isWorktreeContainerDir(".fusion-recovery-child")).toBe(false); expect(isWorktreeContainerDir(".fusion-recovery-child")).toBe(false);
}); });
it("vetoes workspace containers and plain directories before a destructive sweep", async () => {
const root = await mkdtemp(join(tmpdir(), "fusion-worktree-paths-"));
try {
const plain = join(root, "plain");
const group = join(root, "workspace-group");
await mkdir(plain);
await mkdir(group);
await writeFile(join(group, ".fusion-workspace-root"), "/workspace");
expect(await isReclaimableWorktreeCandidate(plain, { rootDir: root })).toBe(false);
expect(await isReclaimableWorktreeCandidate(group, { rootDir: root })).toBe(false);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("detects paths inside and outside configured dir", () => { it("detects paths inside and outside configured dir", () => {
const dir = resolveWorktreesDir(rootDir, { worktreesDir: "../{repo}.worktrees" } as any); const dir = resolveWorktreesDir(rootDir, { worktreesDir: "../{repo}.worktrees" } as any);
expect(isInsideConfiguredWorktreesDir(rootDir, { worktreesDir: "../{repo}.worktrees" } as any, join(dir, "fn-1"))).toBe(true); expect(isInsideConfiguredWorktreesDir(rootDir, { worktreesDir: "../{repo}.worktrees" } as any, join(dir, "fn-1"))).toBe(true);

View File

@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { workspaceRepoSegment, workspaceWorktreeGroupSegment } from "@fusion/core";
import { import {
isTaskPinnedWorktreeNaming, isTaskPinnedWorktreeNaming,
pinnedWorktreeSlug, pinnedWorktreeSlug,
@@ -50,6 +51,22 @@ describe("worktree-pinning", () => {
const b = pinnedWorktreePathForTask("FN-9", {}, "/repo"); const b = pinnedWorktreePathForTask("FN-9", {}, "/repo");
expect(a).toBe(b); expect(a).toBe(b);
}); });
it("groups task-id paths by workspace and repository under a configured root", () => {
const workspaceRoot = "/projects/PRD-1234-my-slug";
const settings = { worktreesDir: "/shared/worktrees" };
const api = pinnedWorktreePathForTask("FN-9162", settings, join(workspaceRoot, "api"), {
workspaceRootDir: workspaceRoot,
repoRelPath: "api",
});
const web = pinnedWorktreePathForTask("FN-9162", settings, join(workspaceRoot, "web"), {
workspaceRootDir: workspaceRoot,
repoRelPath: "web",
});
expect(api).toBe(join("/shared/worktrees", workspaceWorktreeGroupSegment(workspaceRoot), workspaceRepoSegment("api"), "fn-9162"));
expect(web).toBe(join("/shared/worktrees", workspaceWorktreeGroupSegment(workspaceRoot), workspaceRepoSegment("web"), "fn-9162"));
expect(api).not.toBe(web);
});
}); });
describe("preservedWorktreeTargetPathForTask", () => { describe("preservedWorktreeTargetPathForTask", () => {

View File

@@ -48,12 +48,20 @@ vi.mock("../worktree/worktree-desktop-artifacts.js", () => ({
removeDesktopBuildArtifacts: vi.fn().mockResolvedValue({ removed: [], skipped: [], failures: [] }), removeDesktopBuildArtifacts: vi.fn().mockResolvedValue({ removed: [], skipped: [], failures: [] }),
})); }));
vi.mock("../worktree/worktree-paths.js", () => ({
isInsideConfiguredWorktreesDir: vi.fn(() => true),
isReclaimableWorktreeCandidate: vi.fn().mockResolvedValue(true),
isWorktreeContainerDir: vi.fn((name: string) => name === ".ai-merge" || name === ".fusion-recovery"),
resolveWorktreesDir: vi.fn((rootDir: string) => `${rootDir}/.worktrees`),
}));
vi.mock("node:fs", () => ({ vi.mock("node:fs", () => ({
existsSync: vi.fn().mockReturnValue(true), existsSync: vi.fn().mockReturnValue(true),
lstatSync: vi.fn().mockReturnValue({ isDirectory: () => true, isSymbolicLink: () => false }), lstatSync: vi.fn().mockReturnValue({ isDirectory: () => true, isSymbolicLink: () => false }),
readdirSync: vi.fn().mockReturnValue([]), readdirSync: vi.fn().mockReturnValue([]),
readFileSync: vi.fn().mockReturnValue(""), readFileSync: vi.fn().mockReturnValue(""),
rmSync: vi.fn(), rmSync: vi.fn(),
realpathSync: vi.fn((path: string) => path),
})); }));
vi.mock("../worktree/worktree-prune.js", () => ({ vi.mock("../worktree/worktree-prune.js", () => ({
@@ -120,6 +128,10 @@ describe("WorktreePool", () => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(desktopArtifacts.removeDesktopBuildArtifacts).mockResolvedValue({ removed: [], skipped: [], failures: [] }); vi.mocked(desktopArtifacts.removeDesktopBuildArtifacts).mockResolvedValue({ removed: [], skipped: [], failures: [] });
mockedExistsSync.mockReturnValue(true); mockedExistsSync.mockReturnValue(true);
// Shared-root reaping must prove both paths use this repository's common gitdir.
mockedExecSync.mockImplementation((command: unknown) =>
String(command).includes("rev-parse --git-common-dir") ? Buffer.from("/root/.git\n") : Buffer.from(""),
);
pool = new WorktreePool(); pool = new WorktreePool();
}); });
@@ -835,6 +847,7 @@ function makeDirEntry(name: string) {
function mockRegisteredWorktrees(rootDir: string, names: string[]) { function mockRegisteredWorktrees(rootDir: string, names: string[]) {
mockedExecSync.mockImplementation((cmd: any) => { mockedExecSync.mockImplementation((cmd: any) => {
if (String(cmd).includes("rev-parse --git-common-dir")) return Buffer.from(`${rootDir}/.git\n`);
if (String(cmd) === "git worktree list --porcelain") { if (String(cmd) === "git worktree list --porcelain") {
return [ return [
`worktree ${rootDir}`, `worktree ${rootDir}`,
@@ -1171,7 +1184,7 @@ describe("reapOrphanWorktrees", () => {
mockedLstatSync.mockReturnValue({ isDirectory: () => true, isSymbolicLink: () => false } as any); mockedLstatSync.mockReturnValue({ isDirectory: () => true, isSymbolicLink: () => false } as any);
}); });
it("excludes internal containers while removing half-initialized task worktrees", async () => { it("excludes containers and unproven half-initialized directories", async () => {
mockedReaddirSync.mockReturnValue([ mockedReaddirSync.mockReturnValue([
makeDirEntry(".ai-merge"), makeDirEntry(".ai-merge"),
makeDirEntry(".fusion-recovery"), makeDirEntry(".fusion-recovery"),
@@ -1180,8 +1193,8 @@ describe("reapOrphanWorktrees", () => {
const removed = await reapOrphanWorktrees("/root"); const removed = await reapOrphanWorktrees("/root");
expect(removed).toBe(1); expect(removed).toBe(0);
expect(mockedRmSync).toHaveBeenCalledWith("/root/.worktrees/half-built", { recursive: true, force: true }); expect(mockedRmSync).not.toHaveBeenCalledWith("/root/.worktrees/half-built", expect.anything());
expect(mockedRmSync).not.toHaveBeenCalledWith("/root/.worktrees/.ai-merge", expect.anything()); expect(mockedRmSync).not.toHaveBeenCalledWith("/root/.worktrees/.ai-merge", expect.anything());
expect(mockedRmSync).not.toHaveBeenCalledWith("/root/.worktrees/.fusion-recovery", expect.anything()); expect(mockedRmSync).not.toHaveBeenCalledWith("/root/.worktrees/.fusion-recovery", expect.anything());
}); });

View File

@@ -93,7 +93,7 @@ export async function detectWorkspaceMainCheckoutWork(
} }
} catch { skipped.push(repo); continue; } } catch { skipped.push(repo); continue; }
const repoScope = deriveRepoScopeSubset(declaredScope, repo); const repoScope = deriveRepoScopeSubset(declaredScope, repo);
const worktreesDir = path.resolve(resolveWorktreesDir(checkout, deps.settings)); const worktreesDir = path.resolve(resolveWorktreesDir(checkout, deps.settings, { workspaceRootDir: deps.rootDir, repoRelPath: repo }));
const excluded = (file: string) => { const excluded = (file: string) => {
const absolute = path.resolve(checkout, file); const absolute = path.resolve(checkout, file);
return file === ".fusion" || file.startsWith(".fusion/") || isWithin(absolute, worktreesDir) || recordedPaths.some((candidate) => isWithin(absolute, candidate)); return file === ".fusion" || file.startsWith(".fusion/") || isWithin(absolute, worktreesDir) || recordedPaths.some((candidate) => isWithin(absolute, candidate));

View File

@@ -103,7 +103,7 @@ import { getTaskCompletionBlockerForStore } from "./execution/task-completion.js
import { shouldReclaimWedgedMerge } from "./merge/merge-reclaim-policy.js"; import { shouldReclaimWedgedMerge } from "./merge/merge-reclaim-policy.js";
import { advanceIntegrationBranchRef } from "./merge/merger-ref-update-advance.js"; import { advanceIntegrationBranchRef } from "./merge/merger-ref-update-advance.js";
import { isWorktreeContainerDir, resolveAiMergeRootPath, resolveLegacyAiMergeRootPath, resolveWorktreesDir } from "./worktree/worktree-paths.js"; import { isReclaimableWorktreeCandidate, isWorktreeContainerDir, resolveAiMergeRootPath, resolveLegacyAiMergeRootPath, resolveWorktreesDir } from "./worktree/worktree-paths.js";
import { canonicalFusionBranchName, resolveTaskWorkingBranch } from "./worktree/worktree-names.js"; import { canonicalFusionBranchName, resolveTaskWorkingBranch } from "./worktree/worktree-names.js";
import { preservedWorktreeTargetPathForTask } from "./worktree/worktree-pinning.js"; import { preservedWorktreeTargetPathForTask } from "./worktree/worktree-pinning.js";
import { resolveIntegrationBranch } from "./merge/integration-branch.js"; import { resolveIntegrationBranch } from "./merge/integration-branch.js";
@@ -16193,6 +16193,10 @@ const movedTask = await this.store.moveTask(task.id, completeLane);
*/ */
private async reapUnregisteredOrphans(): Promise<number> { private async reapUnregisteredOrphans(): Promise<number> {
const settings = await this.store.getSettings(); const settings = await this.store.getSettings();
if (settings.workspaceMode === true) {
log.debug("[self-healing] skipped workspace unregistered-orphan reap — recorded member paths are reclaimed addressably");
return 0;
}
if (settings.worktrunk?.enabled === true) { if (settings.worktrunk?.enabled === true) {
log.debug("[self-healing] skipped native unregistered-orphan reap — worktrunk backend owns layout"); log.debug("[self-healing] skipped native unregistered-orphan reap — worktrunk backend owns layout");
const backend = resolveWorktreeBackend(settings, { logger: log }); const backend = resolveWorktreeBackend(settings, { logger: log });
@@ -16216,7 +16220,10 @@ const movedTask = await this.store.moveTask(task.id, completeLane);
if (dirs.length === 0) return 0; if (dirs.length === 0) return 0;
const registered = await getRegisteredWorktreePaths(this.options.rootDir); const registered = await getRegisteredWorktreePaths(this.options.rootDir);
const unregistered = dirs.filter((d) => !registered.has(resolve(d))); const ownedDirs = (await Promise.all(dirs.map(async (dir) =>
(await isReclaimableWorktreeCandidate(dir, { rootDir: this.options.rootDir })) ? dir : null,
))).filter((dir): dir is string => dir !== null);
const unregistered = ownedDirs.filter((dir) => !registered.has(resolve(dir)));
let cleaned = 0; let cleaned = 0;
for (const path of unregistered) { for (const path of unregistered) {
@@ -16513,6 +16520,10 @@ const movedTask = await this.store.moveTask(task.id, completeLane);
private async enforceWorktreeCap(): Promise<void> { private async enforceWorktreeCap(): Promise<void> {
try { try {
const settings = await this.store.getSettings(); const settings = await this.store.getSettings();
if (settings.workspaceMode === true) {
log.debug("[self-healing] skipped workspace worktree cap enforcement — recorded member paths are reclaimed addressably");
return;
}
if (settings.worktrunk?.enabled === true) { if (settings.worktrunk?.enabled === true) {
log.debug("[self-healing] skipped native worktree cap enforcement — worktrunk backend owns layout"); log.debug("[self-healing] skipped native worktree cap enforcement — worktrunk backend owns layout");
const backend = resolveWorktreeBackend(settings, { logger: log }); const backend = resolveWorktreeBackend(settings, { logger: log });
@@ -16526,7 +16537,10 @@ const movedTask = await this.store.moveTask(task.id, completeLane);
const cap = (settings.maxWorktrees ?? 4) * 2; const cap = (settings.maxWorktrees ?? 4) * 2;
const entries = readdirSync(worktreesDir, { withFileTypes: true }); const entries = readdirSync(worktreesDir, { withFileTypes: true });
const dirs = entries.filter((e) => e.isDirectory() && !isWorktreeContainerDir(e.name)); const dirs = (await Promise.all(entries
.filter((entry) => entry.isDirectory() && !isWorktreeContainerDir(entry.name))
.map(async (entry) => (await isReclaimableWorktreeCandidate(join(worktreesDir, entry.name), { rootDir: this.options.rootDir })) ? entry : null),
)).filter((entry): entry is typeof entries[number] => entry !== null);
if (dirs.length <= cap) return; if (dirs.length <= cap) return;

View File

@@ -2,9 +2,9 @@ import { existsSync } from "node:fs";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises"; import { lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile } from "node:fs/promises";
import { exec } from "node:child_process"; import { exec } from "node:child_process";
import { isAbsolute, join, relative, resolve } from "node:path"; import { dirname, isAbsolute, join, relative, resolve } from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { acquireWorktreePathReservation, canonicalizeWorktreePath, resolveEngineIncarnationId, resolveEngineNodeId, type RunMutationContext, type Settings, type Task, type TaskStore, type SecretsStore, type WorkspaceLeaseHandle } from "@fusion/core"; import { acquireWorktreePathReservation, assertWorkspaceRepoRelPath, canonicalizeWorktreePath, resolveEngineIncarnationId, resolveEngineNodeId, workspaceWorktreeGroupSegment, WORKSPACE_GROUP_MARKER_FILENAME, type RunMutationContext, type Settings, type Task, type TaskStore, type SecretsStore, type WorkspaceLeaseHandle, type WorkspaceWorktreeContext } from "@fusion/core";
import { generateWorktreeName, resolveTaskWorkingBranch, slugify } from "./worktree-names.js"; import { generateWorktreeName, resolveTaskWorkingBranch, slugify } from "./worktree-names.js";
import { resolveTaskWorktreePathForBackend, resolveWorktreesDir, WORKTREE_RECOVERY_DIRNAME } from "./worktree-paths.js"; import { resolveTaskWorktreePathForBackend, resolveWorktreesDir, WORKTREE_RECOVERY_DIRNAME } from "./worktree-paths.js";
import { hydrateWorktreeDb } from "./worktree-db-hydrate.js"; import { hydrateWorktreeDb } from "./worktree-db-hydrate.js";
@@ -125,6 +125,8 @@ export interface AcquireTaskWorktreeOptions {
*/ */
/** Suppress singular `worktree` and `branch` persistence for workspace sub-repo acquisition. */ /** Suppress singular `worktree` and `branch` persistence for workspace sub-repo acquisition. */
suppressSingularWorktreePersist?: boolean; suppressSingularWorktreePersist?: boolean;
/** Workspace-only layout context; native git operations still use rootDir (the sub-repository). */
workspaceContext?: WorkspaceWorktreeContext;
} }
export interface AcquireTaskWorktreeResult { export interface AcquireTaskWorktreeResult {
@@ -141,6 +143,17 @@ export interface AcquireTaskWorktreeResult {
} }
/** A typed refresh refusal: callers must park before creating a coding session. */ /** A typed refresh refusal: callers must park before creating a coding session. */
export class WorkspaceWorktreeGroupConflictError extends Error {
constructor(
public readonly workspaceRootDir: string,
public readonly existingWorkspaceRootDir: string,
public readonly groupDir: string,
) {
super(`Workspace worktree group conflict: ${workspaceRootDir} and ${existingWorkspaceRootDir} resolve to ${groupDir}. Configure a distinct worktreesDir for one of these projects, or rename one workspace directory.`);
this.name = "WorkspaceWorktreeGroupConflictError";
}
}
export class WorktreeBaseRefreshError extends Error { export class WorktreeBaseRefreshError extends Error {
constructor(public readonly refresh: WorktreeBaseRefreshResult) { constructor(public readonly refresh: WorktreeBaseRefreshResult) {
super(`Worktree base refresh blocked execution: ${refresh.kind}`); super(`Worktree base refresh blocked execution: ${refresh.kind}`);
@@ -326,8 +339,44 @@ async function pinnedWorktreeBranchMatches(rootDir: string, worktreePath: string
return match?.branch === expectedBranch; return match?.branch === expectedBranch;
} }
/*
FNXC:WorkspaceWorktree 2026-08-20-01:20:
A shared configured root needs an acquisition-time owner marker so equal workspace
basenames never share a derivable group. The marker rejects conflicts only; all path
resolution remains the pure core layout function and sweeps use marker presence only
as a deletion veto.
*/
async function ensureWorkspaceGroupOwnership(
workspaceContext: WorkspaceWorktreeContext | undefined,
settings: Partial<Settings>,
): Promise<void> {
if (!workspaceContext || !settings.worktreesDir || settings.worktrunk?.enabled) return;
const workspaceRootDir = resolve(workspaceContext.workspaceRootDir);
const configuredRoot = resolveWorktreesDir(workspaceRootDir, settings);
const rel = relative(workspaceRootDir, configuredRoot);
if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) return;
const groupDir = join(configuredRoot, workspaceWorktreeGroupSegment(workspaceRootDir));
await mkdir(groupDir, { recursive: true });
const marker = join(groupDir, WORKSPACE_GROUP_MARKER_FILENAME);
try {
await writeFile(marker, workspaceRootDir, { flag: "wx" });
} catch (error: unknown) {
const errorCode = error && typeof error === "object" && "code" in error ? error.code : undefined;
if (errorCode !== "EEXIST") throw error;
try {
const existingRoot = resolve((await readFile(marker, "utf8")).trim());
if (existingRoot !== workspaceRootDir) throw new WorkspaceWorktreeGroupConflictError(workspaceRootDir, existingRoot, groupDir);
} catch (readError: unknown) {
if (readError instanceof WorkspaceWorktreeGroupConflictError) throw readError;
const readErrorCode = readError && typeof readError === "object" && "code" in readError ? readError.code : undefined;
if (readErrorCode !== "ENOENT") throw readError;
await writeFile(marker, workspaceRootDir, { flag: "wx" });
}
}
}
export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Promise<AcquireTaskWorktreeResult> { export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Promise<AcquireTaskWorktreeResult> {
const { task, rootDir, store, settings, pool, logger, audit, runContext, createWorktree, runConfiguredCommand, runInitCommand, taskEnv, secretsStore } = opts; const { task, rootDir, store, settings, pool, logger, audit, runContext, createWorktree, runConfiguredCommand, runInitCommand, taskEnv, secretsStore, workspaceContext } = opts;
const persistWorktreeAssignment = async (patch: Parameters<TaskStore["updateTask"]>[1]): Promise<void> => { const persistWorktreeAssignment = async (patch: Parameters<TaskStore["updateTask"]>[1]): Promise<void> => {
if (!opts.suppressSingularWorktreePersist) { if (!opts.suppressSingularWorktreePersist) {
await store.updateTask(task.id, patch); await store.updateTask(task.id, patch);
@@ -434,6 +483,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
} }
throw error; throw error;
} }
await ensureWorkspaceGroupOwnership(workspaceContext, settings);
const branchName = resolveTaskWorkingBranch(task); const branchName = resolveTaskWorkingBranch(task);
const resolveExistingWorktreeBackendKind = async (path: string): Promise<WorktreeBackend["kind"]> => const resolveExistingWorktreeBackendKind = async (path: string): Promise<WorktreeBackend["kind"]> =>
(await readPersistedWorktreeBackendKind(path)) ?? opts.createWorktreeBackendKind ?? backend.kind; (await readPersistedWorktreeBackendKind(path)) ?? opts.createWorktreeBackendKind ?? backend.kind;
@@ -465,10 +515,12 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
? task.id.toLowerCase() ? task.id.toLowerCase()
: naming === "task-title" : naming === "task-title"
? slugify(task.title || task.description.slice(0, 60)) ? slugify(task.title || task.description.slice(0, 60))
: generateWorktreeName(rootDir, settings); : generateWorktreeName(rootDir, settings, workspaceContext);
worktreePath = await resolveTaskWorktreePathForBackend(rootDir, worktreeName, settings, backend, branchName); worktreePath = await resolveTaskWorktreePathForBackend(rootDir, worktreeName, settings, backend, branchName, workspaceContext);
} }
// Grouped workspace paths have two container levels; native git requires the immediate parent to exist.
if (workspaceContext && backend.kind !== "worktrunk") await mkdir(dirname(worktreePath), { recursive: true });
let isResume = Boolean(task.worktree && existsSync(worktreePath)); let isResume = Boolean(task.worktree && existsSync(worktreePath));
// FNXC:TaskPinnedWorktrees 2026-07-16-00:00: the non-pinned resume-classification self-heal is skipped in // FNXC:TaskPinnedWorktrees 2026-07-16-00:00: the non-pinned resume-classification self-heal is skipped in
// pinned mode; acquirePinnedWorktree runs its own derive→validate→reuse-or-recreate decision below. // pinned mode; acquirePinnedWorktree runs its own derive→validate→reuse-or-recreate decision below.
@@ -487,8 +539,8 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
logger?.log(`${task.id}: assigned worktree is not usable; creating a fresh worktree instead: ${worktreePath}`); logger?.log(`${task.id}: assigned worktree is not usable; creating a fresh worktree instead: ${worktreePath}`);
await store.logEntry(task.id, "Assigned worktree is not a registered, usable git worktree; creating a fresh worktree instead", worktreePath, runContext); await store.logEntry(task.id, "Assigned worktree is not a registered, usable git worktree; creating a fresh worktree instead", worktreePath, runContext);
await persistWorktreeAssignment({ worktree: null, branch: null, sessionFile: null }); await persistWorktreeAssignment({ worktree: null, branch: null, sessionFile: null });
const fallbackName = generateWorktreeName(rootDir, settings); const fallbackName = generateWorktreeName(rootDir, settings, workspaceContext);
worktreePath = await resolveTaskWorktreePathForBackend(rootDir, fallbackName, settings, backend, branchName); worktreePath = await resolveTaskWorktreePathForBackend(rootDir, fallbackName, settings, backend, branchName, workspaceContext);
isResume = false; isResume = false;
} }
} }
@@ -579,7 +631,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
if (reservationHeld) return createWorktreeWithoutReservation(createBranch, createPath, createTaskId, startPoint, allowRename); if (reservationHeld) return createWorktreeWithoutReservation(createBranch, createPath, createTaskId, startPoint, allowRename);
const reservation = await acquireWorktreePathReservation({ const reservation = await acquireWorktreePathReservation({
canonicalPath: await canonicalizeWorktreePath(createPath), canonicalPath: await canonicalizeWorktreePath(createPath),
worktreesDir: resolveWorktreesDir(rootDir, settings), worktreesDir: resolveWorktreesDir(rootDir, settings, workspaceContext),
rootDir, rootDir,
/* /*
FNXC:WorkflowLifecycle 2026-07-16-10:00: FNXC:WorkflowLifecycle 2026-07-16-10:00:
@@ -739,8 +791,8 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
logger?.warn(`${task.id}: acquisition ${source} returned repo root; clearing assignment and creating a fresh worktree`); logger?.warn(`${task.id}: acquisition ${source} returned repo root; clearing assignment and creating a fresh worktree`);
await store.logEntry(task.id, "Acquisition attempted to return the project root as a task worktree; creating a fresh worktree instead", guardedPath, runContext); await store.logEntry(task.id, "Acquisition attempted to return the project root as a task worktree; creating a fresh worktree instead", guardedPath, runContext);
await persistWorktreeAssignment({ worktree: null, branch: null, sessionFile: null }); await persistWorktreeAssignment({ worktree: null, branch: null, sessionFile: null });
const fallbackName = generateWorktreeName(rootDir, settings); const fallbackName = generateWorktreeName(rootDir, settings, workspaceContext);
const fallbackPath = await resolveTaskWorktreePathForBackend(rootDir, fallbackName, settings, backend, branchName); const fallbackPath = await resolveTaskWorktreePathForBackend(rootDir, fallbackName, settings, backend, branchName, workspaceContext);
const created = await createWorktreeImpl(branchName, fallbackPath, task.id, freshStartPoint, allowSiblingBranchRename); const created = await createWorktreeImpl(branchName, fallbackPath, task.id, freshStartPoint, allowSiblingBranchRename);
return finalizeCreatedWorktree(created, "fresh", "return-guard"); return finalizeCreatedWorktree(created, "fresh", "return-guard");
}; };
@@ -783,7 +835,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
* consume any worktree-session retry budget (acquisition returns a valid fresh worktree directly). * consume any worktree-session retry budget (acquisition returns a valid fresh worktree directly).
*/ */
const acquirePinnedWorktree = async (): Promise<AcquireTaskWorktreeResult> => { const acquirePinnedWorktree = async (): Promise<AcquireTaskWorktreeResult> => {
const pinnedPath = pinnedWorktreePathForTask(task.id, settings, rootDir); const pinnedPath = pinnedWorktreePathForTask(task.id, settings, rootDir, workspaceContext);
const resumedBranch = task.branch ?? branchName; const resumedBranch = task.branch ?? branchName;
if (task.worktree && canonicalizePath(task.worktree) !== canonicalizePath(pinnedPath)) { if (task.worktree && canonicalizePath(task.worktree) !== canonicalizePath(pinnedPath)) {
@@ -798,7 +850,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
const reservation = await acquireWorktreePathReservation({ const reservation = await acquireWorktreePathReservation({
canonicalPath: await canonicalizeWorktreePath(pinnedPath), canonicalPath: await canonicalizeWorktreePath(pinnedPath),
worktreesDir: resolveWorktreesDir(rootDir, settings), worktreesDir: resolveWorktreesDir(rootDir, settings, workspaceContext),
rootDir, rootDir,
isLiveWorktree: async () => { isLiveWorktree: async () => {
if (activeSessionRegistry.isPathActive(pinnedPath)) return true; if (activeSessionRegistry.isPathActive(pinnedPath)) return true;
@@ -810,7 +862,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
*/ */
reconcileQuarantined: async () => { reconcileQuarantined: async () => {
if (existsSync(pinnedPath)) return; if (existsSync(pinnedPath)) return;
if (!isInsideWorktreesDir(rootDir, pinnedPath, settings)) { if (!isInsideWorktreesDir(rootDir, pinnedPath, settings, workspaceContext)) {
throw new Error(`Refusing to reconcile quarantined task-pinned worktree outside configured worktrees directory: ${pinnedPath}`); throw new Error(`Refusing to reconcile quarantined task-pinned worktree outside configured worktrees directory: ${pinnedPath}`);
} }
if (activeSessionRegistry.isPathActive(pinnedPath)) { if (activeSessionRegistry.isPathActive(pinnedPath)) {
@@ -869,7 +921,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
undefined, undefined,
runContext, runContext,
); );
if (isInsideWorktreesDir(rootDir, pinnedPath, settings)) { if (isInsideWorktreesDir(rootDir, pinnedPath, settings, workspaceContext)) {
try { try {
const preserveAsOrphanDirectory = !classification.ok const preserveAsOrphanDirectory = !classification.ok
&& (classification.classification === "incomplete" || classification.classification === "unregistered") && (classification.classification === "incomplete" || classification.classification === "unregistered")
@@ -897,7 +949,7 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
* Configured worktrees may live on another filesystem. Preserve atomically beside the * Configured worktrees may live on another filesystem. Preserve atomically beside the
* configured worktree root instead of weakening recovery to recursive copy-and-delete. * configured worktree root instead of weakening recovery to recursive copy-and-delete.
*/ */
const canonicalWorktreesRoot = await realpath(resolveWorktreesDir(rootDir, settings)); const canonicalWorktreesRoot = await realpath(resolveWorktreesDir(rootDir, settings, workspaceContext));
const localRecoveryRoot = await ensureContainedDirectory(canonicalWorktreesRoot, WORKTREE_RECOVERY_DIRNAME); const localRecoveryRoot = await ensureContainedDirectory(canonicalWorktreesRoot, WORKTREE_RECOVERY_DIRNAME);
const localRecoveryWorktrees = await ensureContainedDirectory(localRecoveryRoot, "worktrees"); const localRecoveryWorktrees = await ensureContainedDirectory(localRecoveryRoot, "worktrees");
actualRecoveryRoot = localRecoveryWorktrees; actualRecoveryRoot = localRecoveryWorktrees;
@@ -1052,8 +1104,8 @@ export async function acquireTaskWorktree(opts: AcquireTaskWorktreeOptions): Pro
logger?.warn(`${task.id}: failed to remove unusable pooled worktree ${worktreePath}: ${formatError(removeErr)}`); logger?.warn(`${task.id}: failed to remove unusable pooled worktree ${worktreePath}: ${formatError(removeErr)}`);
} }
} }
const fallbackName = generateWorktreeName(rootDir, settings); const fallbackName = generateWorktreeName(rootDir, settings, workspaceContext);
worktreePath = await resolveTaskWorktreePathForBackend(rootDir, fallbackName, settings, backend, branchName); worktreePath = await resolveTaskWorktreePathForBackend(rootDir, fallbackName, settings, backend, branchName, workspaceContext);
branch = branchName; branch = branchName;
} else { } else {
/* /*
@@ -1264,14 +1316,8 @@ FNXC:WorkspaceWorktree 2026-06-22-00:00:
An absolute path or a `..` escape (`../outside`) would resolve a worktree outside the workspace An absolute path or a `..` escape (`../outside`) would resolve a worktree outside the workspace
root. Validate it is a normalized, relative, in-root path before resolving the absolute path. root. Validate it is a normalized, relative, in-root path before resolving the absolute path.
*/ */
function assertInRootRepoRelPath(repoRelPath: string, sep: string, isAbsolute: (p: string) => boolean, normalize: (p: string) => string): void { function assertInRootRepoRelPath(repoRelPath: string): void {
if (typeof repoRelPath !== "string" || repoRelPath.length === 0 || isAbsolute(repoRelPath)) { assertWorkspaceRepoRelPath(repoRelPath);
throw new Error(`Invalid workspace repo path (must be relative and in-root): ${String(repoRelPath)}`);
}
const normalized = normalize(repoRelPath);
if (normalized === ".." || normalized.startsWith(`..${sep}`) || normalized.startsWith("../")) {
throw new Error(`Invalid workspace repo path (escapes workspace root): ${repoRelPath}`);
}
} }
/* /*
@@ -1288,10 +1334,10 @@ export async function acquireWorkspaceRepoWorktree(
): Promise<{ worktreePath: string; branch: string; baseCommitSha?: string; alreadyAcquired: boolean }> { ): Promise<{ worktreePath: string; branch: string; baseCommitSha?: string; alreadyAcquired: boolean }> {
const { repoRelPath, workspaceRootDir, task, store, settings, logger, secretsStore, audit, runContext, runConfiguredCommand, taskEnv } = opts; const { repoRelPath, workspaceRootDir, task, store, settings, logger, secretsStore, audit, runContext, runConfiguredCommand, taskEnv } = opts;
const registry = opts.registry ?? activeSessionRegistry; const registry = opts.registry ?? activeSessionRegistry;
const { join, isAbsolute, normalize, sep } = await import("node:path"); const { join } = await import("node:path");
// FNXC:WorkspaceWorktree 2026-06-22-00:00: reject absolute / `..`-escaping repo paths before resolving. // FNXC:WorkspaceWorktree 2026-06-22-00:00: reject absolute / `..`-escaping repo paths before resolving.
assertInRootRepoRelPath(repoRelPath, sep, isAbsolute, normalize); assertInRootRepoRelPath(repoRelPath);
const repoAbsPath = join(workspaceRootDir, repoRelPath); const repoAbsPath = join(workspaceRootDir, repoRelPath);
let durableAcquireLease: WorkspaceLeaseHandle | undefined; let durableAcquireLease: WorkspaceLeaseHandle | undefined;
@@ -1459,6 +1505,7 @@ export async function acquireWorkspaceRepoWorktree(
const result = await acquireTaskWorktree({ const result = await acquireTaskWorktree({
task: { ...task, worktree: undefined, branch: undefined, executionStartBranch: baseResolution.branch }, task: { ...task, worktree: undefined, branch: undefined, executionStartBranch: baseResolution.branch },
suppressSingularWorktreePersist: true, suppressSingularWorktreePersist: true,
workspaceContext: { workspaceRootDir, repoRelPath },
rootDir: repoAbsPath, rootDir: repoAbsPath,
store, store,
// FNXC:Workspace 2026-07-07-08:40 (FN-7360 regression — strip shared branch overrides for per-repo start-point): // FNXC:Workspace 2026-07-07-08:40 (FN-7360 regression — strip shared branch overrides for per-repo start-point):

View File

@@ -1,6 +1,6 @@
import { readdirSync } from "node:fs"; import { readdirSync } from "node:fs";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import type { Settings, Task } from "@fusion/core"; import type { Settings, Task, WorkspaceWorktreeContext } from "@fusion/core";
import { resolveTaskWorktreePath, resolveWorktreesDir } from "./worktree-paths.js"; import { resolveTaskWorktreePath, resolveWorktreesDir } from "./worktree-paths.js";
export const ADJECTIVES = [ export const ADJECTIVES = [
@@ -83,8 +83,8 @@ export function slugify(str: string): string {
* @param rootDir - The project root directory (parent of `.worktrees/`) * @param rootDir - The project root directory (parent of `.worktrees/`)
* @returns A unique worktree directory name (not a full path) * @returns A unique worktree directory name (not a full path)
*/ */
export function generateWorktreeName(rootDir: string, settings?: Pick<Settings, "worktreesDir">): string { export function generateWorktreeName(rootDir: string, settings?: Pick<Settings, "worktreesDir">, workspaceContext?: WorkspaceWorktreeContext): string {
return generateReservedWorktreeName(rootDir, new Set(), settings); return generateReservedWorktreeName(rootDir, new Set(), settings, workspaceContext);
} }
/** /**
@@ -95,12 +95,13 @@ export function generateReservedWorktreeName(
rootDir: string, rootDir: string,
reservedNames: Set<string> = new Set(), reservedNames: Set<string> = new Set(),
settings?: Pick<Settings, "worktreesDir">, settings?: Pick<Settings, "worktreesDir">,
workspaceContext?: WorkspaceWorktreeContext,
): string { ): string {
const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]; const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]; const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
const baseName = `${adjective}-${noun}`; const baseName = `${adjective}-${noun}`;
const worktreesDir = resolveWorktreesDir(rootDir, settings); const worktreesDir = resolveWorktreesDir(rootDir, settings, workspaceContext);
const existing = getExistingWorktreeNames(worktreesDir); const existing = getExistingWorktreeNames(worktreesDir);
for (const reserved of reservedNames) { for (const reserved of reservedNames) {
existing.add(reserved); existing.add(reserved);
@@ -136,6 +137,7 @@ export function planTaskWorktreePath(
naming: string | undefined, naming: string | undefined,
reservedNames: Set<string>, reservedNames: Set<string>,
settings?: Pick<Settings, "worktreesDir">, settings?: Pick<Settings, "worktreesDir">,
workspaceContext?: WorkspaceWorktreeContext,
): string { ): string {
if (task.worktree) { if (task.worktree) {
const existingName = task.worktree.split("/").filter(Boolean).pop(); const existingName = task.worktree.split("/").filter(Boolean).pop();
@@ -153,12 +155,12 @@ export function planTaskWorktreePath(
break; break;
case "random": case "random":
default: default:
worktreeName = generateReservedWorktreeName(rootDir, reservedNames, settings); worktreeName = generateReservedWorktreeName(rootDir, reservedNames, settings, workspaceContext);
break; break;
} }
reservedNames.add(worktreeName); reservedNames.add(worktreeName);
return resolveTaskWorktreePath(rootDir, settings, worktreeName); return resolveTaskWorktreePath(rootDir, settings, worktreeName, workspaceContext);
} }
function getExistingWorktreeNames(worktreesDir: string): Set<string> { function getExistingWorktreeNames(worktreesDir: string): Set<string> {

View File

@@ -1,12 +1,16 @@
import { homedir } from "node:os"; import { execFile } from "node:child_process";
import { basename, isAbsolute, join, relative, resolve } from "node:path"; import { existsSync, readFileSync, realpathSync } from "node:fs";
import type { Settings } from "@fusion/core"; import { promisify } from "node:util";
import { isAbsolute, join, relative, resolve } from "node:path";
import { resolveWorktreesDirLayout, WORKSPACE_GROUP_MARKER_FILENAME, type Settings, type WorkspaceWorktreeContext } from "@fusion/core";
import type { WorktreeBackendKind } from "./worktree-backend.js"; import type { WorktreeBackendKind } from "./worktree-backend.js";
import { canonicalizePath } from "./worktree-pool.js"; import { canonicalizePath } from "./worktree-pool.js";
export const AI_MERGE_DIRNAME = ".ai-merge"; export const AI_MERGE_DIRNAME = ".ai-merge";
export const WORKTREE_RECOVERY_DIRNAME = ".fusion-recovery"; export const WORKTREE_RECOVERY_DIRNAME = ".fusion-recovery";
const execFileAsync = promisify(execFile);
export function isAiMergeContainerDir(name: string): boolean { export function isAiMergeContainerDir(name: string): boolean {
return name === AI_MERGE_DIRNAME; return name === AI_MERGE_DIRNAME;
} }
@@ -19,6 +23,52 @@ export function isWorktreeContainerDir(name: string): boolean {
return isAiMergeContainerDir(name) || name === WORKTREE_RECOVERY_DIRNAME; return isAiMergeContainerDir(name) || name === WORKTREE_RECOVERY_DIRNAME;
} }
/**
* FNXC:WorkspaceWorktree 2026-08-20-01:46:
* Shared configured roots may contain other projects' worktrees and workspace group
* containers. Reaping is permitted only after Git proves the candidate shares this
* project's common directory; a workspace marker is solely an additional delete veto.
*/
export async function isReclaimableWorktreeCandidate(
entryAbsPath: string,
options: { rootDir: string },
): Promise<boolean> {
if (isWorktreeContainerDir(entryAbsPath.split(/[\\/]/).pop() ?? "")) return false;
if (existsSync(join(entryAbsPath, WORKSPACE_GROUP_MARKER_FILENAME))) return false;
const dotGit = join(entryAbsPath, ".git");
if (!existsSync(dotGit)) return false;
// The normal linked-worktree form is a gitdir file below the main checkout's
// admin directory. Prove that relationship without trusting a directory name.
try {
const match = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotGit, "utf8"));
if (match) {
const gitdir = resolve(entryAbsPath, match[1]!.trim());
const rootGitDir = resolve(options.rootDir, ".git");
const rel = relative(rootGitDir, gitdir);
if (rel !== "" && !rel.startsWith("..") && !isAbsolute(rel)) return true;
// FNXC:WorkspaceWorktree 2026-08-20-01:46: A linked project root has a `.git` file, so Git must prove its external common directory.
}
} catch {
// Fall through to Git's common-dir probe for uncommon worktree layouts.
}
try {
const [candidate, root] = await Promise.all([
execFileAsync("git", ["-C", entryAbsPath, "rev-parse", "--git-common-dir"], { encoding: "utf8", timeout: 10_000 }),
execFileAsync("git", ["-C", options.rootDir, "rev-parse", "--git-common-dir"], { encoding: "utf8", timeout: 10_000 }),
]);
const canonical = (cwd: string, value: string) => {
const path = resolve(cwd, value.trim());
try { return realpathSync(path); } catch { return path; }
};
return canonical(entryAbsPath, candidate.stdout) === canonical(options.rootDir, root.stdout);
} catch {
// Destructive sweeps fail closed when Git metadata cannot prove ownership.
return false;
}
}
export function resolveAiMergeRootPath( export function resolveAiMergeRootPath(
rootDir: string, rootDir: string,
settings: Pick<Settings, "worktreesDir"> | undefined, settings: Pick<Settings, "worktreesDir"> | undefined,
@@ -33,23 +83,18 @@ export function resolveLegacyAiMergeRootPath(rootDir: string): string {
export function resolveWorktreesDir( export function resolveWorktreesDir(
rootDir: string, rootDir: string,
settings: Pick<Settings, "worktreesDir"> | undefined, settings: Pick<Settings, "worktreesDir"> | undefined,
workspaceContext?: WorkspaceWorktreeContext,
): string { ): string {
const configured = settings?.worktreesDir; return resolveWorktreesDirLayout(rootDir, settings, workspaceContext);
if (!configured) {
return join(rootDir, ".worktrees");
}
const expandedHome = configured.replace(/^~(?=$|[\\/])/, homedir());
const expandedRepo = expandedHome.replaceAll("{repo}", basename(rootDir));
return resolve(rootDir, expandedRepo);
} }
export function resolveTaskWorktreePath( export function resolveTaskWorktreePath(
rootDir: string, rootDir: string,
settings: Pick<Settings, "worktreesDir"> | undefined, settings: Pick<Settings, "worktreesDir"> | undefined,
worktreeName: string, worktreeName: string,
workspaceContext?: WorkspaceWorktreeContext,
): string { ): string {
return join(resolveWorktreesDir(rootDir, settings), worktreeName); return join(resolveWorktreesDir(rootDir, settings, workspaceContext), worktreeName);
} }
// Structural backend input avoids importing the full WorktreeBackend interface here. // Structural backend input avoids importing the full WorktreeBackend interface here.
@@ -62,19 +107,21 @@ export async function resolveTaskWorktreePathForBackend(
resolveWorktreePath?: (input: { rootDir: string; worktreeName: string; branch: string }) => Promise<string>; resolveWorktreePath?: (input: { rootDir: string; worktreeName: string; branch: string }) => Promise<string>;
}, },
branch: string, branch: string,
workspaceContext?: WorkspaceWorktreeContext,
): Promise<string> { ): Promise<string> {
if (backend.kind === "worktrunk" && backend.resolveWorktreePath) { if (backend.kind === "worktrunk" && backend.resolveWorktreePath) {
return backend.resolveWorktreePath({ rootDir, worktreeName, branch }); return backend.resolveWorktreePath({ rootDir, worktreeName, branch });
} }
return resolveTaskWorktreePath(rootDir, settings, worktreeName); return resolveTaskWorktreePath(rootDir, settings, worktreeName, workspaceContext);
} }
export function isInsideConfiguredWorktreesDir( export function isInsideConfiguredWorktreesDir(
rootDir: string, rootDir: string,
settings: Pick<Settings, "worktreesDir"> | undefined, settings: Pick<Settings, "worktreesDir"> | undefined,
candidate: string, candidate: string,
workspaceContext?: WorkspaceWorktreeContext,
): boolean { ): boolean {
const worktreesDir = canonicalizePath(resolveWorktreesDir(rootDir, settings)); const worktreesDir = canonicalizePath(resolveWorktreesDir(rootDir, settings, workspaceContext));
const target = canonicalizePath(candidate); const target = canonicalizePath(candidate);
const rel = relative(worktreesDir, target); const rel = relative(worktreesDir, target);
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel); return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);

View File

@@ -1,4 +1,4 @@
import type { Settings } from "@fusion/core"; import type { Settings, WorkspaceWorktreeContext } from "@fusion/core";
import { basename } from "node:path"; import { basename } from "node:path";
import { resolveTaskWorktreePath } from "./worktree-paths.js"; import { resolveTaskWorktreePath } from "./worktree-paths.js";
@@ -35,8 +35,9 @@ export function pinnedWorktreePathForTask(
taskId: string, taskId: string,
settings: Pick<Settings, "worktreesDir"> | undefined, settings: Pick<Settings, "worktreesDir"> | undefined,
rootDir: string, rootDir: string,
workspaceContext?: WorkspaceWorktreeContext,
): string { ): string {
return resolveTaskWorktreePath(rootDir, settings, pinnedWorktreeSlug(taskId)); return resolveTaskWorktreePath(rootDir, settings, pinnedWorktreeSlug(taskId), workspaceContext);
} }
/** Preserve the task-pinned naming invariant while normalizing legacy paths. */ /** Preserve the task-pinned naming invariant while normalizing legacy paths. */
@@ -45,8 +46,9 @@ export function preservedWorktreeTargetPathForTask(
sourcePath: string, sourcePath: string,
settings: Pick<Settings, "worktreeNaming" | "worktreesDir"> | undefined, settings: Pick<Settings, "worktreeNaming" | "worktreesDir"> | undefined,
rootDir: string, rootDir: string,
workspaceContext?: WorkspaceWorktreeContext,
): string { ): string {
return isTaskPinnedWorktreeNaming(settings) return isTaskPinnedWorktreeNaming(settings)
? pinnedWorktreePathForTask(taskId, settings, rootDir) ? pinnedWorktreePathForTask(taskId, settings, rootDir, workspaceContext)
: resolveTaskWorktreePath(rootDir, settings, basename(sourcePath)); : resolveTaskWorktreePath(rootDir, settings, basename(sourcePath), workspaceContext);
} }

View File

@@ -3,12 +3,12 @@ import { promisify } from "node:util";
import { existsSync, lstatSync, readdirSync, readFileSync, rmSync, realpathSync } from "node:fs"; import { existsSync, lstatSync, readdirSync, readFileSync, rmSync, realpathSync } from "node:fs";
import { mkdir } from "node:fs/promises"; import { mkdir } from "node:fs/promises";
import { basename, dirname, join, relative, resolve, isAbsolute } from "node:path"; import { basename, dirname, join, relative, resolve, isAbsolute } from "node:path";
import type { SecretsStore, Settings, TaskStore, WorktrunkSettings } from "@fusion/core"; import type { SecretsStore, Settings, TaskStore, WorktrunkSettings, WorkspaceWorktreeContext } from "@fusion/core";
import { assertCleanBranchAtBase, inspectBranchConflict } from "../execution/branch-conflicts.js"; import { assertCleanBranchAtBase, inspectBranchConflict } from "../execution/branch-conflicts.js";
import { worktreePoolLog } from "../logger.js"; import { worktreePoolLog } from "../logger.js";
/* /*
*/ */
import { isInsideConfiguredWorktreesDir, isWorktreeContainerDir, resolveWorktreesDir } from "./worktree-paths.js"; import { isInsideConfiguredWorktreesDir, isReclaimableWorktreeCandidate, isWorktreeContainerDir, resolveWorktreesDir } from "./worktree-paths.js";
import { canonicalFusionBranchName } from "./worktree-names.js"; import { canonicalFusionBranchName } from "./worktree-names.js";
import { import {
resolveWorktrunkBinary, resolveWorktrunkBinary,
@@ -418,8 +418,9 @@ export function isInsideWorktreesDir(
rootDir: string, rootDir: string,
worktreePath: string, worktreePath: string,
settings?: Pick<Settings, "worktreesDir">, settings?: Pick<Settings, "worktreesDir">,
workspaceContext?: WorkspaceWorktreeContext,
): boolean { ): boolean {
return isInsideConfiguredWorktreesDir(rootDir, settings, worktreePath); return isInsideConfiguredWorktreesDir(rootDir, settings, worktreePath, workspaceContext);
} }
export type ReclaimableWorktreePlacement = export type ReclaimableWorktreePlacement =
@@ -880,8 +881,13 @@ export class WorktreePool {
export async function scanIdleWorktrees( export async function scanIdleWorktrees(
rootDir: string, rootDir: string,
store: TaskStore, store: TaskStore,
settings?: Pick<Settings, "worktreesDir">, settings?: Pick<Settings, "worktreesDir" | "workspaceMode">,
): Promise<string[]> { ): Promise<string[]> {
/* FNXC:WorkspaceWorktree 2026-08-20-01:20: Group containers are not task worktrees; workspace cleanup uses recorded member paths rather than directory walking. */
if (settings?.workspaceMode) {
worktreePoolLog.debug?.("Skipping directory walk for workspace worktrees; recorded paths are reclaimed addressably.");
return [];
}
const worktreesDir = resolveWorktreesDir(rootDir, settings); const worktreesDir = resolveWorktreesDir(rootDir, settings);
if (!existsSync(worktreesDir)) { if (!existsSync(worktreesDir)) {
@@ -901,9 +907,10 @@ export async function scanIdleWorktrees(
return []; return [];
} }
if (dirs.length === 0) { dirs = (await Promise.all(dirs.map(async (dir) =>
return []; (await isReclaimableWorktreeCandidate(dir, { rootDir })) ? dir : null,
} ))).filter((dir): dir is string => dir !== null);
if (dirs.length === 0) return [];
const registeredWorktrees = await getRegisteredWorktreePaths(rootDir); const registeredWorktrees = await getRegisteredWorktreePaths(rootDir);
const registeredDirs = dirs.filter((dir) => registeredWorktrees.has(resolve(dir))); const registeredDirs = dirs.filter((dir) => registeredWorktrees.has(resolve(dir)));
@@ -967,8 +974,12 @@ export async function scanIdleWorktrees(
export async function cleanupOrphanedWorktrees( export async function cleanupOrphanedWorktrees(
rootDir: string, rootDir: string,
store: TaskStore, store: TaskStore,
settings?: Pick<Settings, "worktreesDir">, settings?: Pick<Settings, "worktreesDir" | "workspaceMode">,
): Promise<number> { ): Promise<number> {
if (settings?.workspaceMode) {
worktreePoolLog.debug?.("Skipping workspace orphan sweep; recorded paths are reclaimed addressably.");
return 0;
}
const worktreesDir = resolveWorktreesDir(rootDir, settings); const worktreesDir = resolveWorktreesDir(rootDir, settings);
if (!existsSync(worktreesDir)) { if (!existsSync(worktreesDir)) {
return 0; return 0;
@@ -990,7 +1001,10 @@ export async function cleanupOrphanedWorktrees(
} }
} }
const unregistered = dirs.filter((dir) => !registeredWorktrees.has(resolve(dir))); const ownedDirs = (await Promise.all(dirs.map(async (dir) =>
(await isReclaimableWorktreeCandidate(dir, { rootDir })) ? dir : null,
))).filter((dir): dir is string => dir !== null);
const unregistered = ownedDirs.filter((dir) => !registeredWorktrees.has(resolve(dir)));
const candidates = [...orphaned, ...unregistered]; const candidates = [...orphaned, ...unregistered];
let cleaned = 0; let cleaned = 0;
@@ -1093,8 +1107,12 @@ function dotGitPointerIsDangling(dotGitPath: string): boolean {
export async function reapOrphanWorktrees( export async function reapOrphanWorktrees(
projectRoot: string, projectRoot: string,
settings?: Pick<Settings, "worktreesDir">, settings?: Pick<Settings, "worktreesDir" | "workspaceMode">,
): Promise<number> { ): Promise<number> {
if (settings?.workspaceMode) {
worktreePoolLog.debug?.("Skipping workspace orphan reaping; recorded paths are reclaimed addressably.");
return 0;
}
const worktreesDir = resolveWorktreesDir(projectRoot, settings); const worktreesDir = resolveWorktreesDir(projectRoot, settings);
if (!existsSync(worktreesDir)) { if (!existsSync(worktreesDir)) {
@@ -1107,7 +1125,7 @@ export async function reapOrphanWorktrees(
entries = readdirSync(worktreesDir, { withFileTypes: true }) entries = readdirSync(worktreesDir, { withFileTypes: true })
.filter((e) => { .filter((e) => {
// Only real directories — never symlinks or internal worktree containers. // Only real directories — never symlinks or internal worktree containers.
if (!e.isDirectory() || isWorktreeContainerDir(e.name)) return false; if (!e.isDirectory() || isWorktreeContainerDir(e.name) || !existsSync(join(worktreesDir, e.name, ".git"))) return false;
try { try {
return lstatSync(join(worktreesDir, e.name)).isDirectory() && !lstatSync(join(worktreesDir, e.name)).isSymbolicLink(); return lstatSync(join(worktreesDir, e.name)).isDirectory() && !lstatSync(join(worktreesDir, e.name)).isSymbolicLink();
} catch { } catch {
@@ -1121,6 +1139,10 @@ export async function reapOrphanWorktrees(
return 0; return 0;
} }
if (entries.length === 0) return 0;
entries = (await Promise.all(entries.map(async (entry) =>
(await isReclaimableWorktreeCandidate(entry.fullPath, { rootDir: projectRoot })) ? entry : null,
))).filter((entry): entry is { name: string; fullPath: string } => entry !== null);
if (entries.length === 0) return 0; if (entries.length === 0) return 0;
// Get the set of paths registered with git // Get the set of paths registered with git