FN-6295: move AI merge clean rooms into worktrees

Move AI-merge clean-room worktrees into the configured worktrees directory while preserving legacy cleanup.

- Resolve AI-merge roots through the configured worktrees directory using a hidden .ai-merge container.
- Keep local git excludes and stale cleanup compatible with legacy .fusion/ai-merge and tmpdir locations.
- Exclude the .ai-merge container from worktree pool and self-healing one-level sweeps.
- Add regression coverage for path resolution, cleanup, orphan reap, and cap enforcement behavior.
- Update architecture and agent guidance for the relocated clean-room root.

Files changed:
 AGENTS.md                                          |  2 +-
 docs/architecture.md                               |  4 +-
 .../engine/src/__tests__/merger-ai-cleanup.test.ts | 50 +++++++++----
 .../__tests__/self-healing-tempdir-sweep.test.ts   | 87 +++++++++++++++++++---
 .../engine/src/__tests__/worktree-paths.test.ts    | 24 ++++++
 .../engine/src/__tests__/worktree-pool.test.ts     | 55 ++++++++++++++
 packages/engine/src/merger-ai.ts                   | 27 +++++--
 packages/engine/src/self-healing.ts                | 25 +++----
 packages/engine/src/worktree-paths.ts              | 17 +++++
 packages/engine/src/worktree-pool.ts               | 10 +--
 10 files changed, 247 insertions(+), 54 deletions(-)

Fusion-Task-Id: FN-6295
Fusion-Task-Lineage: d95d32c2-3253-4964-b68a-252b4cb65109
This commit is contained in:
gsxdsm
2026-06-12 12:49:34 -07:00
parent 4f3a70aebf
commit 283adce4d0
10 changed files with 247 additions and 54 deletions

View File

@@ -116,7 +116,7 @@ Never kill processes on port 4040 and never start test servers on 4040. Use `--p
Do not issue a recursive `find` (or any unbounded recursive directory walk) rooted at the OS temp directory — `$TMPDIR`, `/tmp`, or macOS `/var/folders/...` (canonical `/private/var/...`). The temp root can hold an enormous number of entries on CI and long-lived dev hosts, so a broad scan can hang for minutes and pin I/O.
When you need a Fusion temp artifact, target the known prefix directly and list a single level with a prefix filter — never walk the whole temp tree. The canonical bounded pattern is the engine's own sweep: non-recursive `readdirSync(...)` passes over the repo-local `.fusion/ai-merge/` root plus legacy `tmpdir()` leftovers, filtered by a known prefix such as `fusion-ai-merge-` (`SelfHealingManager.cleanupStaleTempMergeWorktrees()` in `packages/engine/src/self-healing.ts`). Scoped `find` calls under a project worktree or `.fusion/` are fine; only the broad temp-root scan is forbidden.
When you need a Fusion temp artifact, target the known prefix directly and list a single level with a prefix filter — never walk the whole temp tree. The canonical bounded pattern is the engine's own sweep: non-recursive `readdirSync(...)` passes over the configured `<worktreesDir>/.ai-merge/` root plus legacy `.fusion/ai-merge/` and `tmpdir()` leftovers, filtered by a known prefix such as `fusion-ai-merge-` (`SelfHealingManager.cleanupStaleTempMergeWorktrees()` in `packages/engine/src/self-healing.ts`). Scoped `find` calls under a project worktree or `.fusion/` are fine; only the broad temp-root scan is forbidden.
### Engine Process Rules

View File

@@ -672,8 +672,8 @@ Runtime action-gate flow (v1):
- `TransientErrorDetector` (`transient-error-detector.ts`) — retriable error classification
- `SelfHealingManager` (`self-healing.ts`) — auto-unpause/maintenance recovery actions
- Batch 1 maintenance now includes one `fts-maintenance` step for both search indexes. The live `tasks_fts` branch still runs `merge` every tick, `optimize` every 4th tick, and `rebuild` above `32 MiB` or `1 MiB × live task count`. The archive `archived_tasks_fts` branch is lighter because archive writes are mostly append-only: `merge` every 8th tick, `optimize` every 24th tick, and `rebuild` above `64 MiB` or `512 KiB × archived row count`. Each branch is independently guarded by `fts5Available` and emits `task:fts-maintenance` run-audit telemetry with distinct `target` values (`tasks_fts` vs `archived_tasks_fts`).
- AI merge clean-room worktrees are created under the repo-local cleanup-exempt root `.fusion/ai-merge/` as `fusion-ai-merge-fn-<id>-<random>` detached worktrees, with `.fusion/ai-merge/` added to the repo's local git exclude when possible so an in-flight clean room does not dirty the integration checkout. Inline cleanup runs from `runAiMerge`'s clean-room `finally` for successful lands, empty/no-op finalization, concurrent-advance retries, and thrown/aborted merges. Cleanup canonicalizes the path, attempts `git worktree remove --force`, always falls back to filesystem removal, then runs `git worktree prune` so stale or partial registrations (including `git worktree add` failures) do not dangle. Cleanup emits `merge:ai-worktree-cleanup` audit events for git-remove, fs-rm, and prune phases; benign already-absent/de-registered paths are treated as idempotent success, while genuine filesystem-removal failures are logged/audited with `success: false` rather than silently swallowed.
- Batch 1 sweeps stale AI merge clean-room worktrees both under the repo-local `.fusion/ai-merge/` root and the legacy `tmpdir()` location for pre-relocation leftovers; candidates are bounded to names starting with `fusion-ai-merge-`. `runAiMerge` registers each live clean-room worktree in `activeSessionRegistry` with kind `ai-merge` as soon as the directory exists and keeps both raw and canonical paths registered for the duration of the merge, so both the periodic sweep and pre-merge prune defer when either path is active (including concurrent same-task merge attempts). The default age gate is 2 hours; task-aware cleanup uses a 10-minute grace period for `done`/`archived` tasks and for genuinely missing/deleted task rows, and every removal path is clamped by the same 10-minute minimum-age floor so a freshly created worktree is never reaped. Transient `getTask` lookup failures (for example SQLite busy/parse errors) are not treated as deletion evidence; they log a warning, emit `lookup-error` only if eventually removed, and retain the conservative 2-hour gate. The sweep canonicalizes paths before checking `activeSessionRegistry`, attempts `git worktree remove --force <path>` before filesystem removal, runs `git worktree prune` after cleanup attempts, and emits `worktree:tempdir-sweep` run-audit telemetry for removal attempts and failures. It is intentionally native even when `worktrunk.enabled` because these clean-room worktrees are outside the worktrunk-managed project layout. Fresh directories, active-session paths, and individual removal failures are skipped/logged without aborting the maintenance cycle.
- AI merge clean-room worktrees are created under the configured worktrees directory's hidden container, `<worktreesDir>/.ai-merge/`, as `fusion-ai-merge-fn-<id>-<random>` detached worktrees. When that container is repo-local, its relative path is added to the repo's local git exclude when possible (alongside the legacy `.fusion/ai-merge/` entry) so an in-flight clean room does not dirty the integration checkout. Inline cleanup runs from `runAiMerge`'s clean-room `finally` for successful lands, empty/no-op finalization, concurrent-advance retries, and thrown/aborted merges. Cleanup canonicalizes the path, attempts `git worktree remove --force`, always falls back to filesystem removal, then runs `git worktree prune` so stale or partial registrations (including `git worktree add` failures) do not dangle. Cleanup emits `merge:ai-worktree-cleanup` audit events for git-remove, fs-rm, and prune phases; benign already-absent/de-registered paths are treated as idempotent success, while genuine filesystem-removal failures are logged/audited with `success: false` rather than silently swallowed.
- Worktrees-dir sweeps that list direct children of `<worktreesDir>` (pool idle scan, orphan cleanup/reap, self-healing unregistered-orphan reap, and cap enforcement) must exclude the `.ai-merge` container by name; those one-level sweeps never inspect or recycle clean rooms beneath it. Batch 1 sweeps stale AI merge clean-room worktrees under the new `<worktreesDir>/.ai-merge/` root and still scans legacy `.fusion/ai-merge/` plus legacy `tmpdir()` locations for pre-relocation leftovers; candidates are bounded to names starting with `fusion-ai-merge-`. `runAiMerge` registers each live clean-room worktree in `activeSessionRegistry` with kind `ai-merge` as soon as the directory exists and keeps both raw and canonical paths registered for the duration of the merge, so the dedicated periodic sweep and pre-merge prune defer when either path is active (including concurrent same-task merge attempts). The default age gate is 2 hours; task-aware cleanup uses a 10-minute grace period for `done`/`archived` tasks and for genuinely missing/deleted task rows, and every removal path is clamped by the same 10-minute minimum-age floor so a freshly created worktree is never reaped. Transient `getTask` lookup failures (for example SQLite busy/parse errors) are not treated as deletion evidence; they log a warning, emit `lookup-error` only if eventually removed, and retain the conservative 2-hour gate. The sweep canonicalizes paths before checking `activeSessionRegistry`, attempts `git worktree remove --force <path>` before filesystem removal, runs `git worktree prune` after cleanup attempts, and emits `worktree:tempdir-sweep` run-audit telemetry for removal attempts and failures. Fresh directories, active-session paths, and individual removal failures are skipped/logged without aborting the maintenance cycle.
- `recoverGhostReviewTasks()` is a fallback only for idle, non-terminal `in-review` states. Terminal/actionable states (notably `status: "failed"`) are preserved and **not** auto-kicked back to `todo`.
- Mission validation has a dedicated stale-run reaper: startup recovery and Batch 2 maintenance call `reapStaleMissionValidatorRuns()` when wired by the runtime, using `VALIDATOR_RUN_STALE_MAX_AGE_MS` (currently 6 hours). The sweep terminates ownerless `mission_validator_runs.status='running'` rows as `error`, writes the reap reason into `summary`, leaves `lastValidatorRunId` pointing at the now-terminal run, and emits run-audit telemetry with `mutationType: "mission:validator-run-reaped"` plus `runId`/`featureId`/`missionId`/`triggerType`/`elapsedMs` metadata. Active mission features move to `loopState="needs_fix"` + `lastValidatorStatus="error"` unless their parent mission is already `complete`/`archived`.

View File

@@ -8,6 +8,7 @@ import { cleanupAiMergeWorktree, pruneExistingAiMergeWorktrees, resolveAiMergeRo
import { activeSessionRegistry } from "../active-session-registry.js";
import { MIN_TEMP_WORKTREE_REAP_AGE_MS } from "../self-healing.js";
import { classifyTransientMergeError } from "../transient-merge-error-classifier.js";
import { resolveAiMergeRootPath, resolveLegacyAiMergeRootPath, resolveWorktreesDir } from "../worktree-paths.js";
import type { RunAuditor } from "../run-audit.js";
const fsState = vi.hoisted(() => ({ failReaddirPath: "" }));
@@ -118,6 +119,18 @@ function tempAiMergeDir(name: string): string {
return dir;
}
function localAiMergeDir(projectRoot: string, name: string): string {
const dir = join(resolveAiMergeRoot(projectRoot), name);
mkdirSync(dir, { recursive: true });
return dir;
}
function legacyRepoAiMergeDir(projectRoot: string, name: string): string {
const dir = join(resolveLegacyAiMergeRootPath(projectRoot), name);
mkdirSync(dir, { recursive: true });
return dir;
}
function tempProjectRoot(): string {
const dir = mkdtempSync(join(tmpdir(), "fusion-ai-merge-project-"));
tracked.add(dir);
@@ -257,21 +270,28 @@ describe("AI merge temp worktree cleanup", () => {
]));
});
it("pruneExistingAiMergeWorktrees removes stale same-task directories", async () => {
const stale = tempAiMergeDir("fusion-ai-merge-fn-777-stale");
makeAge(stale, MIN_TEMP_WORKTREE_REAP_AGE_MS + 1_000);
const canonicalStale = realpathSync(stale);
it("pruneExistingAiMergeWorktrees removes stale same-task directories from new and legacy roots", async () => {
const projectRoot = tempProjectRoot();
const staleNew = localAiMergeDir(projectRoot, "fusion-ai-merge-fn-777-stale-new");
const staleLegacyRepo = legacyRepoAiMergeDir(projectRoot, "fusion-ai-merge-fn-777-stale-legacy-repo");
const staleLegacyTmp = tempAiMergeDir("fusion-ai-merge-fn-777-stale-tmp");
for (const stale of [staleNew, staleLegacyRepo, staleLegacyTmp]) {
makeAge(stale, MIN_TEMP_WORKTREE_REAP_AGE_MS + 1_000);
}
const canonicalStale = [staleNew, staleLegacyRepo, staleLegacyTmp].map((path) => realpathSync(path));
const { audit, events } = makeAudit();
const logs: string[] = [];
const projectRoot = tempProjectRoot();
await expect(pruneExistingAiMergeWorktrees("FN-777", projectRoot, audit, vi.fn(async (message: string) => { logs.push(message); }))).resolves.toBe(3);
await expect(pruneExistingAiMergeWorktrees("FN-777", projectRoot, audit, vi.fn(async (message: string) => { logs.push(message); }))).resolves.toBe(1);
expect(existsSync(stale)).toBe(false);
expect(events).toEqual(expect.arrayContaining([
expect.objectContaining({ type: "merge:ai-worktree-cleanup", metadata: expect.objectContaining({ taskId: "FN-777", mergeRoot: canonicalStale, phase: "pre-merge-prune", success: true }) }),
]));
expect(existsSync(staleNew)).toBe(false);
expect(existsSync(staleLegacyRepo)).toBe(false);
expect(existsSync(staleLegacyTmp)).toBe(false);
for (const mergeRoot of canonicalStale) {
expect(events).toEqual(expect.arrayContaining([
expect.objectContaining({ type: "merge:ai-worktree-cleanup", metadata: expect.objectContaining({ taskId: "FN-777", mergeRoot, phase: "pre-merge-prune", success: true }) }),
]));
}
});
it("pruneExistingAiMergeWorktrees skips too-new same-task directories", async () => {
@@ -316,9 +336,12 @@ describe("AI merge temp worktree cleanup", () => {
reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"),
});
const expectedRoot = join(resolveWorktreesDir(dir, undefined), ".ai-merge");
expect(observedMergeRoot).toContain("fusion-ai-merge-fn-1-");
expect(observedMergeRoot).toContain(join(dir, ".fusion", "ai-merge"));
expect(observedMergeRoot.startsWith(expectedRoot)).toBe(true);
expect(observedMergeRoot.startsWith(resolveAiMergeRootPath(dir, undefined))).toBe(true);
expect(observedMergeRoot.startsWith(join(tmpdir(), "fusion-ai-merge-fn-1-"))).toBe(false);
expect(observedMergeRoot.startsWith(resolveLegacyAiMergeRootPath(dir))).toBe(false);
expect(observedMergeRoot.startsWith(resolveAiMergeRoot(dir))).toBe(true);
expect(activeSessionRegistry.pathsForTask("FN-1")).toEqual([]);
const cleanupEvents = audits.filter((event) => event.mutationType === "merge:ai-worktree-cleanup");
@@ -365,7 +388,8 @@ describe("AI merge temp worktree cleanup", () => {
thrown = err;
}
expect(observedMergeRoot).toContain(join(dir, ".fusion", "ai-merge"));
expect(observedMergeRoot.startsWith(resolveAiMergeRootPath(dir, undefined))).toBe(true);
expect(observedMergeRoot.startsWith(resolveLegacyAiMergeRootPath(dir))).toBe(false);
expect(String(thrown)).toMatch(/ENOENT|ENOTDIR|not a working tree/i);
expect(classifyTransientMergeError(String(thrown))).toBe("process-spawn-failure");
});

View File

@@ -5,7 +5,7 @@ import { join } from "node:path";
const osState = vi.hoisted(() => ({ tempRoot: "" }));
const fsState = vi.hoisted(() => ({ failRmPath: "", rmCalls: [] as string[] }));
const childState = vi.hoisted(() => ({ execCalls: [] as string[] }));
const childState = vi.hoisted(() => ({ execCalls: [] as string[], execStdout: "" }));
vi.mock("node:os", async () => {
const actual = await vi.importActual<typeof import("node:os")>("node:os");
@@ -37,7 +37,7 @@ vi.mock("node:child_process", async () => {
childState.execCalls.push(command);
const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : maybeCallback;
queueMicrotask(() => {
if (typeof callback === "function") callback(null, "", "");
if (typeof callback === "function") callback(null, childState.execStdout, "");
});
return {} as ReturnType<typeof actual.exec>;
}),
@@ -46,18 +46,20 @@ vi.mock("node:child_process", async () => {
import { activeSessionRegistry } from "../active-session-registry.js";
import { DONE_TASK_TEMP_WORKTREE_GRACE_MS, MIN_TEMP_WORKTREE_REAP_AGE_MS, SelfHealingManager, STALE_TEMP_MERGE_WORKTREE_MS } from "../self-healing.js";
import { resolveAiMergeRootPath, resolveLegacyAiMergeRootPath } from "../worktree-paths.js";
const RM = { recursive: true, force: true, maxRetries: 5, retryDelay: 50 } as const;
let sandboxRoot = "";
let projectRoot = "";
beforeEach(() => {
sandboxRoot = mkdtempSync(join(tmpdir(), "fusion-tempdir-sweep-sandbox-"));
projectRoot = mkdtempSync(join(tmpdir(), "fusion-tempdir-sweep-project-"));
sandboxRoot = realpathSync(mkdtempSync(join(tmpdir(), "fusion-tempdir-sweep-sandbox-")));
projectRoot = realpathSync(mkdtempSync(join(tmpdir(), "fusion-tempdir-sweep-project-")));
osState.tempRoot = sandboxRoot;
fsState.failRmPath = "";
fsState.rmCalls = [];
childState.execCalls = [];
childState.execStdout = "";
activeSessionRegistry.clear();
});
@@ -67,6 +69,7 @@ afterEach(() => {
fsState.failRmPath = "";
fsState.rmCalls = [];
childState.execCalls = [];
childState.execStdout = "";
for (const dir of [sandboxRoot, projectRoot]) {
try { rmSync(dir, RM); } catch { /* best effort */ }
}
@@ -77,6 +80,7 @@ function makeStore(settings: Record<string, unknown> = {}, getTask: () => Promis
const store: any = {
getSettings: vi.fn(async () => ({ ...settings })),
getTask: vi.fn(getTask),
listTasks: vi.fn(async () => []),
recordRunAuditEvent: vi.fn(async (event: any) => { audits.push(event); }),
};
return { store, audits };
@@ -95,7 +99,13 @@ function tempMergeDir(name = `fusion-ai-merge-fn-1-${Math.random().toString(36).
}
function localMergeDir(name = `fusion-ai-merge-fn-1-${Math.random().toString(36).slice(2)}`): string {
const dir = join(projectRoot, ".fusion", "ai-merge", name);
const dir = join(resolveAiMergeRootPath(projectRoot, undefined), name);
mkdirSync(dir, { recursive: true });
return dir;
}
function legacyRepoMergeDir(name = `fusion-ai-merge-fn-1-${Math.random().toString(36).slice(2)}`): string {
const dir = join(resolveLegacyAiMergeRootPath(projectRoot), name);
mkdirSync(dir, { recursive: true });
return dir;
}
@@ -125,6 +135,21 @@ function transientErrorTask(): () => Promise<any> {
return async () => { throw new Error("SQLITE_BUSY: database is locked"); };
}
function gitWorktreeList(names: string[]): string {
return [
`worktree ${projectRoot}`,
"HEAD abc123",
"branch refs/heads/main",
"",
...names.flatMap((name) => [
`worktree ${join(projectRoot, ".worktrees", name)}`,
"HEAD def456",
`branch refs/heads/fusion/${name}`,
"",
]),
].join("\n");
}
async function sweep(manager: SelfHealingManager): Promise<number> {
return await (manager as any).cleanupStaleTempMergeWorktrees();
}
@@ -133,6 +158,40 @@ function sweepAudits(audits: any[]) {
return audits.filter((event) => event.mutationType === "worktree:tempdir-sweep");
}
describe("SelfHealingManager worktrees-dir sweeps", () => {
it("excludes the .ai-merge container from unregistered-orphan reap while removing genuine orphans", async () => {
const worktreesDir = join(projectRoot, ".worktrees");
const aiMergeContainer = join(worktreesDir, ".ai-merge");
const orphan = join(worktreesDir, "half-built");
mkdirSync(aiMergeContainer, { recursive: true });
mkdirSync(orphan, { recursive: true });
const { manager } = makeManager({ recycleWorktrees: true });
await expect((manager as any).reapUnregisteredOrphans()).resolves.toBe(1);
expect(existsSync(aiMergeContainer)).toBe(true);
expect(existsSync(orphan)).toBe(false);
expect(fsState.rmCalls).toContain(orphan);
expect(fsState.rmCalls).not.toContain(aiMergeContainer);
});
it("excludes the .ai-merge container from cap enforcement while removing genuine idle worktrees", async () => {
const worktreesDir = join(projectRoot, ".worktrees");
const aiMergeContainer = join(worktreesDir, ".ai-merge");
const idle = join(worktreesDir, "idle-wt");
mkdirSync(aiMergeContainer, { recursive: true });
mkdirSync(idle, { recursive: true });
childState.execStdout = gitWorktreeList(["idle-wt"]);
const { manager } = makeManager({ maxWorktrees: 0 });
await expect((manager as any).enforceWorktreeCap()).resolves.toBeUndefined();
expect(existsSync(aiMergeContainer)).toBe(true);
expect(childState.execCalls.some((command) => command.includes(".ai-merge"))).toBe(false);
expect(childState.execCalls.some((command) => command.includes("idle-wt"))).toBe(true);
});
});
describe("SelfHealingManager temp-dir AI merge worktree sweep", () => {
it("removes stale fusion-ai-merge directories and emits success audits", async () => {
const stale = tempMergeDir();
@@ -147,20 +206,24 @@ describe("SelfHealingManager temp-dir AI merge worktree sweep", () => {
]));
});
it("removes stale repo-local AI merge directories", async () => {
const stale = localMergeDir("fusion-ai-merge-fn-1-localstale");
makeStale(stale);
it("removes stale AI merge directories from new and legacy repo-local roots", async () => {
const staleNew = localMergeDir("fusion-ai-merge-fn-1-localstale");
const staleLegacy = legacyRepoMergeDir("fusion-ai-merge-fn-1-legacystale");
makeStale(staleNew);
makeStale(staleLegacy);
const { manager, audits } = makeManager();
await expect(sweep(manager)).resolves.toBe(1);
await expect(sweep(manager)).resolves.toBe(2);
expect(existsSync(stale)).toBe(false);
expect(existsSync(staleNew)).toBe(false);
expect(existsSync(staleLegacy)).toBe(false);
expect(sweepAudits(audits)).toEqual(expect.arrayContaining([
expect.objectContaining({ metadata: expect.objectContaining({ path: realpathSync(join(projectRoot, ".fusion", "ai-merge")) + "/fusion-ai-merge-fn-1-localstale", success: true, reason: "stale" }) }),
expect.objectContaining({ metadata: expect.objectContaining({ path: realpathSync(resolveAiMergeRootPath(projectRoot, undefined)) + "/fusion-ai-merge-fn-1-localstale", success: true, reason: "stale" }) }),
expect.objectContaining({ metadata: expect.objectContaining({ path: realpathSync(resolveLegacyAiMergeRootPath(projectRoot)) + "/fusion-ai-merge-fn-1-legacystale", success: true, reason: "stale" }) }),
]));
});
it("defers active repo-local AI merge directories", async () => {
it("defers active worktrees-dir AI merge directories", async () => {
const stale = localMergeDir("fusion-ai-merge-fn-1-localactive");
makeStale(stale);
const canonical = realpathSync(stale);

View File

@@ -2,7 +2,10 @@ import { describe, expect, it } from "vitest";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import {
AI_MERGE_DIRNAME,
isAiMergeContainerDir,
isInsideConfiguredWorktreesDir,
resolveAiMergeRootPath,
resolveTaskWorktreePath,
resolveTaskWorktreePathForBackend,
resolveWorktreesDir,
@@ -41,6 +44,27 @@ describe("worktree-paths", () => {
);
});
it("builds the AI-merge root under the default worktrees dir", () => {
expect(resolveAiMergeRootPath(rootDir, undefined)).toBe(join(rootDir, ".worktrees", AI_MERGE_DIRNAME));
});
it("builds the AI-merge root under an absolute custom worktrees dir", () => {
expect(resolveAiMergeRootPath(rootDir, { worktreesDir: "/tmp/ext-worktrees" } as any)).toBe(join("/tmp/ext-worktrees", AI_MERGE_DIRNAME));
});
it("builds the AI-merge root under expanded {repo} and ~ worktrees dirs", () => {
expect(resolveAiMergeRootPath(rootDir, { worktreesDir: "../{repo}.worktrees" } as any)).toBe(
resolve(rootDir, "../repo-name.worktrees", AI_MERGE_DIRNAME),
);
expect(resolveAiMergeRootPath(rootDir, { worktreesDir: "~/.fn/{repo}/trees" } as any)).toBe(join(homedir(), ".fn/repo-name/trees", AI_MERGE_DIRNAME));
});
it("identifies only the dedicated AI-merge container name", () => {
expect(isAiMergeContainerDir(AI_MERGE_DIRNAME)).toBe(true);
expect(isAiMergeContainerDir("fusion-ai-merge-fn-1-abc")).toBe(false);
expect(isAiMergeContainerDir(".ai-merge-child")).toBe(false);
});
it("detects paths inside and outside configured dir", () => {
const dir = resolveWorktreesDir(rootDir, { worktreesDir: "../{repo}.worktrees" } as any);
expect(isInsideConfiguredWorktreesDir(rootDir, { worktreesDir: "../{repo}.worktrees" } as any, join(dir, "fn-1"))).toBe(true);

View File

@@ -875,6 +875,20 @@ describe("scanIdleWorktrees", () => {
);
});
it("excludes the .ai-merge container even when git lists clean-room children", async () => {
mockedReaddirSync.mockReturnValue([
makeDirEntry(".ai-merge"),
makeDirEntry("registered-wt"),
] as any);
mockRegisteredWorktrees("/root", [".ai-merge/fusion-ai-merge-fn-1-active", "registered-wt"]);
const store = createMockStore([]);
const idle = await scanIdleWorktrees("/root", store);
expect(idle).toEqual(["/root/.worktrees/registered-wt"]);
expect(idle).not.toContain("/root/.worktrees/.ai-merge");
});
it("does not return unregistered directories for pool rehydration", async () => {
mockedReaddirSync.mockReturnValue([
makeDirEntry("registered-wt"),
@@ -1033,6 +1047,25 @@ describe("cleanupOrphanedWorktrees", () => {
expect(removeCalls).toHaveLength(0);
});
it("excludes the .ai-merge container while still removing genuine unregistered orphans", async () => {
mockedReaddirSync.mockReturnValue([
makeDirEntry(".ai-merge"),
makeDirEntry("broken-wt"),
] as any);
mockRegisteredWorktrees("/root", []);
const store = createMockStore([]);
const cleaned = await cleanupOrphanedWorktrees("/root", store);
expect(cleaned).toBe(1);
expect(mockedRmSync).toHaveBeenCalledWith("/root/.worktrees/broken-wt", {
recursive: true,
force: true,
});
expect(mockedRmSync).not.toHaveBeenCalledWith("/root/.worktrees/.ai-merge", expect.anything());
});
it("removes unregistered directories even when stale active task metadata references them", async () => {
mockedReaddirSync.mockReturnValue([
makeDirEntry("broken-wt"),
@@ -1056,3 +1089,25 @@ describe("cleanupOrphanedWorktrees", () => {
});
});
describe("reapOrphanWorktrees", () => {
beforeEach(() => {
vi.clearAllMocks();
mockRegisteredWorktrees("/root", []);
mockedExistsSync.mockImplementation((path) => String(path) === "/root/.worktrees");
mockedLstatSync.mockReturnValue({ isDirectory: () => true, isSymbolicLink: () => false } as any);
});
it("excludes the .ai-merge container while removing half-initialized task worktrees", async () => {
mockedReaddirSync.mockReturnValue([
makeDirEntry(".ai-merge"),
makeDirEntry("half-built"),
] as any);
const removed = await reapOrphanWorktrees("/root");
expect(removed).toBe(1);
expect(mockedRmSync).toHaveBeenCalledWith("/root/.worktrees/half-built", { recursive: true, force: true });
expect(mockedRmSync).not.toHaveBeenCalledWith("/root/.worktrees/.ai-merge", expect.anything());
});
});

View File

@@ -35,7 +35,7 @@ import { promisify } from "node:util";
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { isAbsolute, join, relative } from "node:path";
import {
buildTaskLineageTrailer,
getPrimaryPrInfo,
@@ -65,6 +65,7 @@ import { createLogger } from "./logger.js";
import { captureSingleCommitLandedMetadata, type MergerOptions } from "./merger.js";
import { activeSessionRegistry } from "./active-session-registry.js";
import { MIN_TEMP_WORKTREE_REAP_AGE_MS } from "./self-healing.js";
import { resolveAiMergeRootPath, resolveLegacyAiMergeRootPath } from "./worktree-paths.js";
const execFileAsync = promisify(execFile);
const aiMergeLog = createLogger("merger-ai");
@@ -113,13 +114,23 @@ export function isBenignAbsentWorktreeError(err: unknown): boolean {
return /is not a working tree|No such file or directory|spawn\s+.*\bENOENT\b/i.test(description);
}
function ensureAiMergeRootIgnored(projectRootDir: string): void {
function ensureAiMergeRootIgnored(projectRootDir: string, settings?: Settings): void {
const excludePath = join(projectRootDir, ".git", "info", "exclude");
if (!existsSync(excludePath)) return;
try {
const current = readFileSync(excludePath, "utf-8");
if (!/(?:^|\n)\.fusion\/ai-merge\/(?:\n|$)/.test(current)) {
appendFileSync(excludePath, `${current.endsWith("\n") ? "" : "\n"}.fusion/ai-merge/\n`);
const legacyAiMergeRoot = resolveLegacyAiMergeRootPath(projectRootDir);
const legacyRelativeAiMergeRoot = relative(projectRootDir, legacyAiMergeRoot);
const entries = [`${legacyRelativeAiMergeRoot.replaceAll("\\", "/")}/`];
const aiMergeRoot = resolveAiMergeRootPath(projectRootDir, settings);
const relativeAiMergeRoot = relative(projectRootDir, aiMergeRoot);
if (relativeAiMergeRoot && !relativeAiMergeRoot.startsWith("..") && !isAbsolute(relativeAiMergeRoot)) {
entries.push(`${relativeAiMergeRoot.replaceAll("\\", "/")}/`);
}
const missing = entries.filter((entry) => !current.split(/\r?\n/).includes(entry));
if (missing.length > 0) {
appendFileSync(excludePath, `${current.endsWith("\n") ? "" : "\n"}${missing.join("\n")}\n`);
}
} catch {
// Best effort only: cleanup still removes the root contents, and existing
@@ -127,15 +138,15 @@ function ensureAiMergeRootIgnored(projectRootDir: string): void {
}
}
export function resolveAiMergeRoot(projectRootDir: string, _settings?: Settings): string {
const root = resolve(projectRootDir, ".fusion", "ai-merge");
export function resolveAiMergeRoot(projectRootDir: string, settings?: Settings): string {
const root = resolveAiMergeRootPath(projectRootDir, settings);
mkdirSync(root, { recursive: true });
ensureAiMergeRootIgnored(projectRootDir);
ensureAiMergeRootIgnored(projectRootDir, settings);
return root;
}
function getAiMergeTempSearchRoots(projectRootDir: string, settings?: Settings): string[] {
const roots = [resolveAiMergeRoot(projectRootDir, settings), tmpdir()];
const roots = [resolveAiMergeRoot(projectRootDir, settings), resolveLegacyAiMergeRootPath(projectRootDir), tmpdir()];
const testWorkerRoot = process.env.FUSION_TEST_WORKER_ROOT;
if (testWorkerRoot) {
try {

View File

@@ -18,7 +18,7 @@
* - `pruneWorktrees`: defer to backend prune
* - `cleanupOrphans`: defer to backend prune/remove semantics
* - `reapUnregisteredOrphans`: defer to backend prune/remove semantics
* - `cleanupStaleTempMergeWorktrees`: remains native (repo-local AI-merge root + legacy temp-dir scope, outside worktrunk layout)
* - `cleanupStaleTempMergeWorktrees`: remains native (dedicated AI-merge root + legacy roots)
* - `enforceWorktreeCap`: defer to backend prune/remove semantics
* - `reclaimSelfOwnedBranchConflicts`: remains native (branch-level)
* - `reclaimStaleActiveBranches`: remains native (branch-level)
@@ -48,7 +48,7 @@ import { createRunAuditor, generateSyntheticRunId, type DatabaseMutationType, ty
import { AutoRecoveryDispatcher } from "./auto-recovery.js";
import { activeSessionRegistry, executingTaskLock } from "./active-session-registry.js";
import { findAlreadyMergedTaskCommit } from "./already-merged-detector.js";
import { resolveWorktreesDir } from "./worktree-paths.js";
import { isAiMergeContainerDir, resolveAiMergeRootPath, resolveLegacyAiMergeRootPath, resolveWorktreesDir } from "./worktree-paths.js";
import { canonicalFusionBranchName, resolveTaskWorkingBranch } from "./worktree-names.js";
import { resolveIntegrationBranch } from "./integration-branch.js";
import { resolveBranchGroupMergeRouting } from "./group-merge-coordinator.js";
@@ -102,8 +102,8 @@ function extractTaskIdFromTempMergeDir(dirname: string): string | null {
return match?.[1]?.toUpperCase() ?? null;
}
function resolveRepoLocalAiMergeRoot(rootDir: string): string {
return resolve(rootDir, ".fusion", "ai-merge");
function resolveRepoLocalAiMergeRoot(rootDir: string, settings?: Pick<Settings, "worktreesDir">): string {
return resolveAiMergeRootPath(rootDir, settings);
}
function getErrorMessage(err: unknown): string {
@@ -9012,7 +9012,7 @@ export class SelfHealingManager {
let dirs: string[];
try {
dirs = readdirSync(worktreesDir, { withFileTypes: true })
.filter((e) => e.isDirectory())
.filter((e) => e.isDirectory() && !isAiMergeContainerDir(e.name))
.map((e) => join(worktreesDir, e.name));
} catch (err: unknown) {
log.warn(`Failed to read .worktrees/ for unregistered orphan reap: ${err instanceof Error ? err.message : String(err)}`);
@@ -9058,21 +9058,20 @@ export class SelfHealingManager {
}
/**
* Sweep stale AI merge clean-room worktrees from the repo-local clean-room
* root plus the legacy `tmpdir()` location used by older engine versions.
* Sweep stale AI merge clean-room worktrees from the configured worktrees-dir
* clean-room root plus legacy `.fusion/ai-merge/` and `tmpdir()` locations
* used by older engine versions.
*
* These worktrees are intentionally outside the project/worktrunk-managed
* `.worktrees/` layout, so this native sweep proceeds even when worktrunk is
* enabled. Safety is bounded by age gates plus active-session checks.
* Safety is bounded by age gates plus active-session checks.
*/
private async cleanupStaleTempMergeWorktrees(): Promise<number> {
try {
const settings = await this.store.getSettings();
if (settings.worktrunk?.enabled === true) {
log.log("[self-healing] temp-dir sweep: worktrunk enabled — AI merge clean-room worktrees are outside worktrunk's managed layout, proceeding with native sweep");
log.log("[self-healing] temp-dir sweep: worktrunk enabled — AI merge clean-room worktrees use Fusion's dedicated clean-room root, proceeding with native sweep");
}
const roots = Array.from(new Set([resolveRepoLocalAiMergeRoot(this.options.rootDir), tmpdir()]));
const roots = Array.from(new Set([resolveRepoLocalAiMergeRoot(this.options.rootDir, settings), resolveLegacyAiMergeRootPath(this.options.rootDir), tmpdir()]));
const auditor = createRunAuditor(this.store, {
runId: generateSyntheticRunId("self-heal", "tempdir-sweep"),
agentId: "self-healing",
@@ -9418,7 +9417,7 @@ export class SelfHealingManager {
const cap = (settings.maxWorktrees ?? 4) * 2;
const entries = readdirSync(worktreesDir, { withFileTypes: true });
const dirs = entries.filter((e) => e.isDirectory());
const dirs = entries.filter((e) => e.isDirectory() && !isAiMergeContainerDir(e.name));
if (dirs.length <= cap) return;

View File

@@ -4,6 +4,23 @@ import type { Settings } from "@fusion/core";
import type { WorktreeBackendKind } from "./worktree-backend.js";
import { canonicalizePath } from "./worktree-pool.js";
export const AI_MERGE_DIRNAME = ".ai-merge";
export function isAiMergeContainerDir(name: string): boolean {
return name === AI_MERGE_DIRNAME;
}
export function resolveAiMergeRootPath(
rootDir: string,
settings: Pick<Settings, "worktreesDir"> | undefined,
): string {
return join(resolveWorktreesDir(rootDir, settings), AI_MERGE_DIRNAME);
}
export function resolveLegacyAiMergeRootPath(rootDir: string): string {
return join(rootDir, ".fusion", "ai-merge");
}
export function resolveWorktreesDir(
rootDir: string,
settings: Pick<Settings, "worktreesDir"> | undefined,

View File

@@ -5,7 +5,7 @@ import { basename, join, relative, resolve, isAbsolute } from "node:path";
import type { ColumnId, SecretsStore, Settings, TaskStore, WorktrunkSettings } from "@fusion/core";
import { assertCleanBranchAtBase, inspectBranchConflict } from "./branch-conflicts.js";
import { worktreePoolLog } from "./logger.js";
import { isInsideConfiguredWorktreesDir, resolveWorktreesDir } from "./worktree-paths.js";
import { isAiMergeContainerDir, isInsideConfiguredWorktreesDir, resolveWorktreesDir } from "./worktree-paths.js";
import { canonicalFusionBranchName } from "./worktree-names.js";
import {
resolveWorktrunkBinary,
@@ -704,7 +704,7 @@ export async function scanIdleWorktrees(
try {
const entries = readdirSync(worktreesDir, { withFileTypes: true });
dirs = entries
.filter((e) => e.isDirectory())
.filter((e) => e.isDirectory() && !isAiMergeContainerDir(e.name))
.map((e) => join(worktreesDir, e.name));
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
@@ -766,7 +766,7 @@ export async function cleanupOrphanedWorktrees(
if (existsSync(worktreesDir)) {
try {
dirs = readdirSync(worktreesDir, { withFileTypes: true })
.filter((e) => e.isDirectory())
.filter((e) => e.isDirectory() && !isAiMergeContainerDir(e.name))
.map((e) => join(worktreesDir, e.name));
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
@@ -863,8 +863,8 @@ export async function reapOrphanWorktrees(
try {
entries = readdirSync(worktreesDir, { withFileTypes: true })
.filter((e) => {
// Only real directories — never symlinks
if (!e.isDirectory()) return false;
// Only real directories — never symlinks; never the dedicated AI-merge container.
if (!e.isDirectory() || isAiMergeContainerDir(e.name)) return false;
try {
return lstatSync(join(worktreesDir, e.name)).isDirectory() && !lstatSync(join(worktreesDir, e.name)).isSymbolicLink();
} catch {