diff --git a/AGENTS.md b/AGENTS.md index 83754bcf22..1ad9ecc886 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `/.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 diff --git a/docs/architecture.md b/docs/architecture.md index 85efeae024..80158be4fd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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--` 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 ` 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, `/.ai-merge/`, as `fusion-ai-merge-fn--` 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 `` (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 `/.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 ` 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`. diff --git a/packages/engine/src/__tests__/merger-ai-cleanup.test.ts b/packages/engine/src/__tests__/merger-ai-cleanup.test.ts index 0a7cbfd47c..ab72637436 100644 --- a/packages/engine/src/__tests__/merger-ai-cleanup.test.ts +++ b/packages/engine/src/__tests__/merger-ai-cleanup.test.ts @@ -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"); }); diff --git a/packages/engine/src/__tests__/self-healing-tempdir-sweep.test.ts b/packages/engine/src/__tests__/self-healing-tempdir-sweep.test.ts index aef562d042..b705784d53 100644 --- a/packages/engine/src/__tests__/self-healing-tempdir-sweep.test.ts +++ b/packages/engine/src/__tests__/self-healing-tempdir-sweep.test.ts @@ -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("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; }), @@ -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 = {}, 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 { 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 { 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); diff --git a/packages/engine/src/__tests__/worktree-paths.test.ts b/packages/engine/src/__tests__/worktree-paths.test.ts index 369cef64a8..d927e3d324 100644 --- a/packages/engine/src/__tests__/worktree-paths.test.ts +++ b/packages/engine/src/__tests__/worktree-paths.test.ts @@ -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); diff --git a/packages/engine/src/__tests__/worktree-pool.test.ts b/packages/engine/src/__tests__/worktree-pool.test.ts index 72c0dfad32..aae3bc59ba 100644 --- a/packages/engine/src/__tests__/worktree-pool.test.ts +++ b/packages/engine/src/__tests__/worktree-pool.test.ts @@ -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()); + }); +}); + diff --git a/packages/engine/src/merger-ai.ts b/packages/engine/src/merger-ai.ts index f4485ef3a8..caf959f102 100644 --- a/packages/engine/src/merger-ai.ts +++ b/packages/engine/src/merger-ai.ts @@ -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 { diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index f524796810..23bb92ff5a 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -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): 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 { 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; diff --git a/packages/engine/src/worktree-paths.ts b/packages/engine/src/worktree-paths.ts index 18e7c7debe..b399444ed1 100644 --- a/packages/engine/src/worktree-paths.ts +++ b/packages/engine/src/worktree-paths.ts @@ -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 | 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 | undefined, diff --git a/packages/engine/src/worktree-pool.ts b/packages/engine/src/worktree-pool.ts index 939b5f77d7..58ed42fb9f 100644 --- a/packages/engine/src/worktree-pool.ts +++ b/packages/engine/src/worktree-pool.ts @@ -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 {