FN-6188: harden AI merge temp worktree cleanup

Improve AI merge temp worktree cleanup and add recovery coverage.

- audit AI merge clean-room cleanup success and failure paths, including stderr/code details
- sweep stale fusion-ai-merge temp directories from tmpdir() during self-healing with active-session safeguards
- document the native temp-dir sweep behavior and add merge cleanup/self-healing regression tests

Files changed:
 docs/architecture.md                               |   1 +
 .../engine/src/__tests__/merger-ai-cleanup.test.ts | 169 +++++++++++++++++
 .../__tests__/self-healing-tempdir-sweep.test.ts   | 200 +++++++++++++++++++++
 packages/engine/src/merger-ai.ts                   |  54 +++++-
 packages/engine/src/run-audit.ts                   |  26 +++
 packages/engine/src/self-healing.ts                | 105 +++++++++++
 6 files changed, 551 insertions(+), 4 deletions(-)

Fusion-Task-Id: FN-6188

Fusion-Task-Lineage: eae9bcdc-9765-4104-85dc-81bb50445e99
This commit is contained in:
gsxdsm
2026-06-10 05:13:40 -07:00
parent af0be9dd32
commit 4032a35a2f
6 changed files with 551 additions and 4 deletions

View File

@@ -0,0 +1,169 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execSync } from "node:child_process";
import { cleanupAiMergeWorktree, runAiMerge } from "../merger-ai.js";
import type { RunAuditor } from "../run-audit.js";
const tracked = new Set<string>();
const RM = { recursive: true, force: true, maxRetries: 5, retryDelay: 50 } as const;
afterEach(() => {
vi.restoreAllMocks();
for (const dir of tracked) {
try { rmSync(dir, RM); } catch { /* best effort */ }
}
tracked.clear();
});
function git(cwd: string, args: string): string {
return execSync(`git ${args}`, { cwd, encoding: "utf-8" }).trim();
}
function makeAudit() {
const events: any[] = [];
const audit: RunAuditor = {
git: vi.fn(async (event: any) => { events.push(event); }),
database: vi.fn(async () => undefined),
filesystem: vi.fn(async () => undefined),
sandbox: vi.fn(async () => undefined),
};
return { audit, events };
}
async function cleanup(input: Partial<Parameters<typeof cleanupAiMergeWorktree>[0]> = {}) {
const mergeRoot = input.mergeRoot ?? mkdtempSync(join(tmpdir(), "fusion-ai-merge-fn-1-cleanup-test-"));
tracked.add(mergeRoot);
const { audit, events } = makeAudit();
const logs: string[] = [];
await cleanupAiMergeWorktree({
taskId: "FN-1",
mergeRoot,
projectRootDir: input.projectRootDir ?? process.cwd(),
worktreeAdded: input.worktreeAdded ?? true,
audit: input.audit ?? audit,
log: input.log ?? vi.fn(async (message: string) => { logs.push(message); }),
gitRunner: input.gitRunner ?? vi.fn(async () => ""),
rmRunner: input.rmRunner ?? rm,
});
return { mergeRoot, events, logs };
}
function initRepoWithBranch(): { dir: string } {
const dir = mkdtempSync(join(tmpdir(), "fusion-ai-merge-cleanup-test-"));
tracked.add(dir);
git(dir, "init -q -b main");
git(dir, "config user.email t@t.t");
git(dir, "config user.name t");
writeFileSync(join(dir, "base.txt"), "base\n");
git(dir, "add -A");
git(dir, "commit -q -m base");
git(dir, "checkout -q -b fusion/fn-1");
writeFileSync(join(dir, "feature.txt"), "feature work\n");
git(dir, "add -A");
git(dir, "commit -q -m 'feat: work'");
git(dir, "checkout -q main");
return { dir };
}
function makeStore() {
const task: any = {
id: "FN-1",
column: "in-review",
status: null,
branch: "fusion/fn-1",
worktree: null,
title: "do the thing",
steps: [],
};
const audits: any[] = [];
const logs: string[] = [];
const store: any = {
getTask: vi.fn(async () => task),
getSettings: vi.fn(async () => ({ merger: { mode: "ai", maxReviewPasses: 1 } })),
updateTask: vi.fn(async (_id: string, patch: Record<string, unknown>) => { Object.assign(task, patch); return task; }),
moveTask: vi.fn(async (_id: string, column: string) => { task.column = column; return task; }),
emit: vi.fn(),
logEntry: vi.fn(async (_id: string, message: string) => { logs.push(message); }),
appendAgentLog: vi.fn(async (_id: string, message: string) => { logs.push(message); }),
recordRunAuditEvent: vi.fn(async (event: any) => { audits.push(event); }),
};
return { store, audits, logs };
}
function realMergeAgent() {
return vi.fn(async (cwd: string) => {
execSync("git merge --squash fusion/fn-1", { cwd, stdio: "pipe" });
execSync("git add -A", { cwd, stdio: "pipe" });
execSync('git commit -q -m "squash: feature"', { cwd, stdio: "pipe" });
});
}
describe("AI merge temp worktree cleanup", () => {
it("emits audit event and logs stderr on git worktree removal failure", async () => {
const err = new Error("git remove failed") as Error & { stderr?: string; code?: string };
err.stderr = "fatal: simulated worktree remove failure";
err.code = "1";
const { mergeRoot, events, logs } = await cleanup({ gitRunner: vi.fn(async () => { throw err; }) });
expect(events).toEqual(expect.arrayContaining([
expect.objectContaining({ type: "merge:ai-worktree-cleanup", metadata: expect.objectContaining({ phase: "git-remove", success: false, error: expect.stringContaining("simulated worktree remove failure"), code: "1" }) }),
expect.objectContaining({ type: "merge:ai-worktree-cleanup", metadata: expect.objectContaining({ phase: "fs-rm", success: true }) }),
]));
expect(logs.join("\n")).toContain("simulated worktree remove failure");
expect(existsSync(mergeRoot)).toBe(false);
});
it("emits audit event and logs errno details on filesystem rm failure", async () => {
const err = new Error("simulated filesystem cleanup denial") as NodeJS.ErrnoException;
err.code = "EACCES";
const { events, logs } = await cleanup({ rmRunner: vi.fn(async () => { throw err; }) as typeof rm });
expect(events).toEqual(expect.arrayContaining([
expect.objectContaining({ type: "merge:ai-worktree-cleanup", metadata: expect.objectContaining({ phase: "git-remove", success: true }) }),
expect.objectContaining({ type: "merge:ai-worktree-cleanup", metadata: expect.objectContaining({ phase: "fs-rm", success: false, code: "EACCES", error: expect.stringContaining("simulated filesystem cleanup denial") }) }),
]));
expect(logs.join("\n")).toContain("EACCES");
});
it("emits success audit events on happy-path cleanup", async () => {
const { events } = await cleanup();
expect(events).toEqual(expect.arrayContaining([
expect.objectContaining({ type: "merge:ai-worktree-cleanup", metadata: expect.objectContaining({ phase: "git-remove", success: true }) }),
expect.objectContaining({ type: "merge:ai-worktree-cleanup", metadata: expect.objectContaining({ phase: "fs-rm", success: true }) }),
]));
});
it("skips git removal but still audits filesystem cleanup when worktree was not added", async () => {
const gitRunner = vi.fn(async () => "");
const { events } = await cleanup({ worktreeAdded: false, gitRunner });
expect(gitRunner).not.toHaveBeenCalled();
expect(events.some((event) => event.metadata.phase === "git-remove")).toBe(false);
expect(events).toEqual(expect.arrayContaining([
expect.objectContaining({ type: "merge:ai-worktree-cleanup", metadata: expect.objectContaining({ phase: "fs-rm", success: true }) }),
]));
});
it("runAiMerge emits success cleanup audit events", async () => {
const { dir } = initRepoWithBranch();
const { store, audits } = makeStore();
await runAiMerge(store, dir, "FN-1", { manual: true }, {
mergeAgent: realMergeAgent(),
reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"),
});
const cleanupEvents = audits.filter((event) => event.mutationType === "merge:ai-worktree-cleanup");
expect(cleanupEvents).toEqual(expect.arrayContaining([
expect.objectContaining({ metadata: expect.objectContaining({ phase: "git-remove", success: true }) }),
expect.objectContaining({ metadata: expect.objectContaining({ phase: "fs-rm", success: true }) }),
]));
});
});

View File

@@ -0,0 +1,200 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, utimesSync } from "node:fs";
import { tmpdir } from "node:os";
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[] }));
vi.mock("node:os", async () => {
const actual = await vi.importActual<typeof import("node:os")>("node:os");
return { ...actual, tmpdir: vi.fn(() => osState.tempRoot || actual.tmpdir()) };
});
vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
return {
...actual,
rmSync: vi.fn((path: Parameters<typeof actual.rmSync>[0], options?: Parameters<typeof actual.rmSync>[1]) => {
const pathString = String(path);
fsState.rmCalls.push(pathString);
if (fsState.failRmPath && pathString === fsState.failRmPath) {
const err = new Error("simulated tempdir rm failure") as NodeJS.ErrnoException;
err.code = "EACCES";
throw err;
}
return actual.rmSync(path, options);
}),
};
});
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
exec: vi.fn((command: string, optionsOrCallback: unknown, maybeCallback?: unknown) => {
childState.execCalls.push(command);
const callback = typeof optionsOrCallback === "function" ? optionsOrCallback : maybeCallback;
queueMicrotask(() => {
if (typeof callback === "function") callback(null, "", "");
});
return {} as ReturnType<typeof actual.exec>;
}),
};
});
import { activeSessionRegistry } from "../active-session-registry.js";
import { SelfHealingManager } from "../self-healing.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-"));
osState.tempRoot = sandboxRoot;
fsState.failRmPath = "";
fsState.rmCalls = [];
childState.execCalls = [];
activeSessionRegistry.clear();
});
afterEach(() => {
activeSessionRegistry.clear();
osState.tempRoot = "";
fsState.failRmPath = "";
fsState.rmCalls = [];
childState.execCalls = [];
for (const dir of [sandboxRoot, projectRoot]) {
try { rmSync(dir, RM); } catch { /* best effort */ }
}
});
function makeStore(settings: Record<string, unknown> = {}) {
const audits: any[] = [];
const store: any = {
getSettings: vi.fn(async () => ({ ...settings })),
recordRunAuditEvent: vi.fn(async (event: any) => { audits.push(event); }),
};
return { store, audits };
}
function makeManager(settings: Record<string, unknown> = {}) {
const { store, audits } = makeStore(settings);
const manager = new SelfHealingManager(store, { rootDir: projectRoot });
return { manager, audits };
}
function tempMergeDir(name = `fusion-ai-merge-fn-1-${Math.random().toString(36).slice(2)}`): string {
const dir = join(sandboxRoot, name);
mkdirSync(dir, { recursive: true });
return dir;
}
function makeStale(path: string): void {
const old = new Date(Date.now() - 3 * 60 * 60 * 1000);
utimesSync(path, old, old);
}
async function sweep(manager: SelfHealingManager): Promise<number> {
return await (manager as any).cleanupStaleTempMergeWorktrees();
}
function sweepAudits(audits: any[]) {
return audits.filter((event) => event.mutationType === "worktree:tempdir-sweep");
}
describe("SelfHealingManager temp-dir AI merge worktree sweep", () => {
it("removes stale fusion-ai-merge directories and emits success audits", async () => {
const stale = tempMergeDir();
makeStale(stale);
const { manager, audits } = makeManager();
await expect(sweep(manager)).resolves.toBe(1);
expect(existsSync(stale)).toBe(false);
expect(sweepAudits(audits)).toEqual(expect.arrayContaining([
expect.objectContaining({ mutationType: "worktree:tempdir-sweep", metadata: expect.objectContaining({ path: realpathSync(sandboxRoot) + "/" + stale.split("/").pop(), success: true, reason: "stale" }) }),
]));
});
it("skips directories younger than the staleness threshold", async () => {
const fresh = tempMergeDir();
const { manager } = makeManager();
await expect(sweep(manager)).resolves.toBe(0);
expect(existsSync(fresh)).toBe(true);
});
it("skips active session paths and removes them after unregister", async () => {
const stale = tempMergeDir();
makeStale(stale);
const canonical = realpathSync(stale);
activeSessionRegistry.registerPath(canonical, { taskId: "FN-1", kind: "executor", ownerKey: "FN-1" });
const { manager, audits } = makeManager();
await expect(sweep(manager)).resolves.toBe(0);
expect(existsSync(stale)).toBe(true);
expect(sweepAudits(audits)).toEqual(expect.arrayContaining([
expect.objectContaining({ metadata: expect.objectContaining({ path: canonical, success: false, reason: "active-session" }) }),
]));
activeSessionRegistry.unregisterPath(canonical);
await expect(sweep(manager)).resolves.toBe(1);
expect(existsSync(stale)).toBe(false);
});
it("leaves non-fusion-ai-merge directories untouched", async () => {
const other = join(sandboxRoot, "other-temp-dir");
mkdirSync(other, { recursive: true });
makeStale(other);
const { manager } = makeManager();
await expect(sweep(manager)).resolves.toBe(0);
expect(existsSync(other)).toBe(true);
});
it("attempts git worktree removal before filesystem removal", async () => {
const stale = tempMergeDir();
makeStale(stale);
const canonical = realpathSync(stale);
const { manager } = makeManager();
await expect(sweep(manager)).resolves.toBe(1);
expect(childState.execCalls[0]).toContain(`git worktree remove --force '${canonical.replace(/'/g, `'"'"'`)}'`);
expect(fsState.rmCalls[0]).toBe(canonical);
});
it("continues when one stale directory fails filesystem removal", async () => {
const failing = tempMergeDir("fusion-ai-merge-fn-1-failing");
const succeeding = tempMergeDir("fusion-ai-merge-fn-1-succeeding");
makeStale(failing);
makeStale(succeeding);
fsState.failRmPath = realpathSync(failing);
const { manager, audits } = makeManager();
await expect(sweep(manager)).resolves.toBe(1);
expect(existsSync(failing)).toBe(true);
expect(existsSync(succeeding)).toBe(false);
expect(sweepAudits(audits)).toEqual(expect.arrayContaining([
expect.objectContaining({ metadata: expect.objectContaining({ path: realpathSync(failing), success: false, reason: "fs-rm-failed", error: expect.stringContaining("simulated tempdir rm failure") }) }),
expect.objectContaining({ metadata: expect.objectContaining({ path: expect.stringContaining("succeeding"), success: true, reason: "stale" }) }),
]));
});
it("proceeds when worktrunk is enabled", async () => {
const stale = tempMergeDir();
makeStale(stale);
const { manager } = makeManager({ worktrunk: { enabled: true } });
await expect(sweep(manager)).resolves.toBe(1);
expect(existsSync(stale)).toBe(false);
});
});

View File

@@ -87,6 +87,55 @@ async function gitOk(args: string[], cwd: string): Promise<boolean> {
}
}
function getErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function getErrorStringProperty(err: unknown, key: "stderr" | "code"): string | undefined {
if (!err || typeof err !== "object" || !(key in err)) return undefined;
const value = (err as Record<string, unknown>)[key];
return typeof value === "string" && value.trim() ? value : undefined;
}
function describeCleanupError(err: unknown): string {
const stderr = getErrorStringProperty(err, "stderr");
const message = getErrorMessage(err);
return stderr ? `${message}: ${stderr.trim()}` : message;
}
export async function cleanupAiMergeWorktree(input: {
taskId: string;
mergeRoot: string;
projectRootDir: string;
worktreeAdded: boolean;
audit: RunAuditor;
log: (message: string) => Promise<void>;
gitRunner?: typeof git;
rmRunner?: typeof rm;
}): Promise<void> {
const { taskId, mergeRoot, projectRootDir, worktreeAdded, audit, log, gitRunner = git, rmRunner = rm } = input;
if (worktreeAdded) {
try {
await gitRunner(["worktree", "remove", "--force", mergeRoot], projectRootDir);
await audit.git({ type: "merge:ai-worktree-cleanup", target: mergeRoot, metadata: { taskId, mergeRoot, phase: "git-remove", success: true } });
} catch (err: unknown) {
const error = describeCleanupError(err);
const code = getErrorStringProperty(err, "code");
await log(`AI merge cleanup: git worktree remove failed for ${mergeRoot}${code ? ` (${code})` : ""}: ${error}`);
await audit.git({ type: "merge:ai-worktree-cleanup", target: mergeRoot, metadata: { taskId, mergeRoot, phase: "git-remove", success: false, error, ...(code ? { code } : {}) } });
}
}
try {
await rmRunner(mergeRoot, { recursive: true, force: true });
await audit.git({ type: "merge:ai-worktree-cleanup", target: mergeRoot, metadata: { taskId, mergeRoot, phase: "fs-rm", success: true } });
} catch (err: unknown) {
const error = getErrorMessage(err);
const code = getErrorStringProperty(err, "code");
await log(`AI merge cleanup: filesystem rm failed for ${mergeRoot}${code ? ` (${code})` : ""}: ${error}`);
await audit.git({ type: "merge:ai-worktree-cleanup", target: mergeRoot, metadata: { taskId, mergeRoot, phase: "fs-rm", success: false, error, ...(code ? { code } : {}) } });
}
}
const FUSION_TASK_ID_TRAILER_KEY = "Fusion-Task-Id";
/** Trailers that associate the squash commit with its board task: the
@@ -836,10 +885,7 @@ export async function runAiMerge(
await log(`AI merge: advanced ${integrationBranch} → ${short(squashSha)} (local checkout: ${landed.localSync})`);
return await finalizeMerged(store, projectRootDir, taskId, task, branch, integrationBranch, squashSha, audit, log, { empty: false });
} finally {
if (worktreeAdded) {
await gitOk(["worktree", "remove", "--force", mergeRoot], projectRootDir);
}
await rm(mergeRoot, { recursive: true, force: true }).catch(() => undefined);
await cleanupAiMergeWorktree({ taskId, mergeRoot, projectRootDir, worktreeAdded, audit, log });
}
}
}

View File

@@ -167,6 +167,32 @@ export type GitMutationType =
| "merge:ai-review-landed-with-concerns"
| "merge:ai-local-sync"
| "merge:ai-landed"
/**
* Metadata shape:
* ```ts
* {
* taskId: string;
* mergeRoot: string;
* phase: "git-remove" | "fs-rm";
* success: boolean;
* error?: string;
* code?: string;
* }
* ```
*/
| "merge:ai-worktree-cleanup"
/**
* Metadata shape:
* ```ts
* {
* path: string;
* success: boolean;
* reason?: "stale" | "active-session" | "git-remove-failed" | "fs-rm-failed" | "not-directory" | "stat-failed";
* error?: string;
* }
* ```
*/
| "worktree:tempdir-sweep"
| "merge:reuse-handoff-acquired"
| "merge:reuse-handoff-refused"
| "merge:reuse-handoff-released"

View File

@@ -18,6 +18,7 @@
* - `pruneWorktrees`: defer to backend prune
* - `cleanupOrphans`: defer to backend prune/remove semantics
* - `reapUnregisteredOrphans`: defer to backend prune/remove semantics
* - `cleanupStaleTempMergeWorktrees`: remains native (temp-dir scope, outside worktrunk layout)
* - `enforceWorktreeCap`: defer to backend prune/remove semantics
* - `reclaimSelfOwnedBranchConflicts`: remains native (branch-level)
* - `reclaimStaleActiveBranches`: remains native (branch-level)
@@ -27,6 +28,7 @@ import { exec, execSync } from "node:child_process";
import { promisify } from "node:util";
import { setImmediate as setImmediateCb } from "node:timers";
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { isAbsolute, join, relative, resolve } from "node:path";
import { IN_REVIEW_STALL_DEADLOCK_LOG_PREFIX, IN_REVIEW_STALL_LOG_PREFIX, IN_REVIEW_STALL_TERMINAL_LOG_PREFIX, allowsAutoMergeProcessing, countRecentIdenticalStallEntries, detectDependencyCycle, detectSelfDefeatingDependency, getInReviewStalledSignal, getInReviewStallReason, getPrimaryPrInfo, getStalePausedReviewSignal, getStalePausedTodoSignal, getTaskHardMergeBlocker, getTaskMergeBlocker, isEphemeralAgent, isMergeRequestContractShadowEnabled, isWorkflowColumnsEnabled, isSharedBranchGroupMemberIntegration, parseExplicitDuplicateMarker, type AgentStore, type ChatStore, type MessageStore, type TaskStore, type Settings, type Task, type MergeDetails, type TaskPriority, type MergeResult } from "@fusion/core";
import type { MeshLeaseManager } from "./mesh-lease-manager.js";
@@ -73,6 +75,7 @@ const BOARD_STALL_NOTIFICATION_COOLDOWN_MS = 60 * 60_000;
const DB_CORRUPTION_NOTIFICATION_COOLDOWN_MS = 60 * 60 * 1000;
const FTS_MAINTENANCE_MERGE_CADENCE_TICKS = 1;
const FTS_MAINTENANCE_OPTIMIZE_CADENCE_TICKS = 4;
const STALE_TEMP_MERGE_WORKTREE_MS = 2 * 60 * 60 * 1000;
// Live pathology peaked around 775 KB/task (~96 MB for ~120 tasks), while a
// rebuilt healthy index was ~0.1 MB. Keep the steady-state budget generous but
// bounded so sustained text churn heals before segment growth becomes material.
@@ -1808,6 +1811,16 @@ export class SelfHealingManager {
const batch1Fns: Array<{ name: string; fn: () => Promise<unknown> }> = [
{ name: "prune-worktrees", fn: () => this.pruneWorktrees() },
{ name: "cleanup-orphans", fn: () => this.cleanupOrphans() },
{
name: "cleanup-stale-temp-merge-worktrees",
fn: async () => {
const cleaned = await this.cleanupStaleTempMergeWorktrees();
if (cleaned > 0) {
log.log(`Cleaned ${cleaned} stale AI merge temp worktree(s)`);
}
return cleaned;
},
},
{ name: "cleanup-orphaned-branches", fn: () => this.cleanupOrphanedBranches() },
{
name: "cleanup-old-chats",
@@ -8838,6 +8851,98 @@ export class SelfHealingManager {
return cleaned;
}
/**
* Sweep stale AI merge clean-room worktrees from `tmpdir()`.
*
* 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 a two-hour age gate 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 temp worktrees are outside worktrunk's managed layout, proceeding with native sweep");
}
const tempRoot = tmpdir();
let entries: string[];
try {
entries = readdirSync(tempRoot).filter((entry) => entry.startsWith("fusion-ai-merge-"));
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
log.warn(`[self-healing] temp-dir sweep: failed to read ${tempRoot}: ${errorMessage}`);
return 0;
}
if (entries.length === 0) return 0;
const auditor = createRunAuditor(this.store, {
runId: generateSyntheticRunId("self-heal", "tempdir-sweep"),
agentId: "self-healing",
phase: "tempdir-sweep",
});
const now = Date.now();
let cleaned = 0;
for (const entry of entries) {
const path = join(tempRoot, entry);
let canonicalPath = path;
try {
const stat = statSync(path);
if (!stat.isDirectory()) {
await auditor.git({ type: "worktree:tempdir-sweep", target: path, metadata: { path, success: false, reason: "not-directory" } });
continue;
}
const ageMs = now - stat.mtimeMs;
if (ageMs < STALE_TEMP_MERGE_WORKTREE_MS) continue;
try {
canonicalPath = realpathSync(path);
} catch {
canonicalPath = path;
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
log.warn(`[self-healing] temp-dir sweep: failed to stat ${path}: ${errorMessage}`);
await auditor.git({ type: "worktree:tempdir-sweep", target: path, metadata: { path, success: false, reason: "stat-failed", error: errorMessage } });
continue;
}
if (activeSessionRegistry.isPathActive(canonicalPath) || activeSessionRegistry.isPathActive(path)) {
log.log(`[self-healing] temp-dir sweep: deferring ${canonicalPath}: active session present`);
await auditor.git({ type: "worktree:tempdir-sweep", target: canonicalPath, metadata: { path: canonicalPath, success: false, reason: "active-session" } });
continue;
}
try {
await execAsync(`git worktree remove --force ${shellQuote(canonicalPath)}`, {
cwd: this.options.rootDir,
timeout: 120_000,
});
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
log.warn(`[self-healing] temp-dir sweep: git worktree remove failed for ${canonicalPath}: ${errorMessage} — falling back to filesystem removal`);
await auditor.git({ type: "worktree:tempdir-sweep", target: canonicalPath, metadata: { path: canonicalPath, success: false, reason: "git-remove-failed", error: errorMessage } });
}
try {
rmSync(canonicalPath, { recursive: true, force: true });
log.log(`[self-healing] temp-dir sweep: cleaned stale AI merge worktree ${canonicalPath}`);
await auditor.git({ type: "worktree:tempdir-sweep", target: canonicalPath, metadata: { path: canonicalPath, success: true, reason: "stale" } });
cleaned++;
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
log.warn(`[self-healing] temp-dir sweep: failed to remove ${canonicalPath}: ${errorMessage}`);
await auditor.git({ type: "worktree:tempdir-sweep", target: canonicalPath, metadata: { path: canonicalPath, success: false, reason: "fs-rm-failed", error: errorMessage } });
}
}
return cleaned;
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
log.error(`[self-healing] temp-dir sweep failed: ${errorMessage}`);
return 0;
}
}
/**
* Resolve orphaned `fusion/*` branches.