FN-6199: harden AI merge worktree cleanup
Guarantee AI merge clean-room worktrees are pruned on merge completion and swept by task state. - prune pre-existing same-task AI merge temp worktrees before starting a new merge and audit cleanup outcomes - add task-aware tempdir sweep rules with a short grace period for done or archived tasks and immediate cleanup for deleted tasks - extend merger and self-healing coverage and document the updated stale worktree policy Files changed: .changeset/ai-merge-cleanup-sweep.md | 5 + docs/architecture.md | 2 +- packages/engine/src/__tests__/merger-ai-cleanup.test.ts | 109 ++++++++++++++++++-- packages/engine/src/__tests__/self-healing-tempdir-sweep.test.ts | 112 ++++++++++++++++++++- packages/engine/src/merger-ai.ts | 63 ++++++++++++ packages/engine/src/self-healing.ts | 26 ++++- 6 files changed, 300 insertions(+), 17 deletions(-) Fusion-Task-Id: FN-6199 Fusion-Task-Lineage: 659cc52f-0994-4315-a187-6c865bbe3983
This commit is contained in:
5
.changeset/ai-merge-cleanup-sweep.md
Normal file
5
.changeset/ai-merge-cleanup-sweep.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Harden AI merge temporary worktree cleanup with same-task pre-merge pruning and task-aware stale tempdir sweeping for completed or deleted tasks.
|
||||||
@@ -670,7 +670,7 @@ Runtime action-gate flow (v1):
|
|||||||
- `TransientErrorDetector` (`transient-error-detector.ts`) — retriable error classification
|
- `TransientErrorDetector` (`transient-error-detector.ts`) — retriable error classification
|
||||||
- `SelfHealingManager` (`self-healing.ts`) — auto-unpause/maintenance recovery actions
|
- `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`).
|
- 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`).
|
||||||
- Batch 1 also sweeps stale AI merge clean-room worktrees under `tmpdir()` whose names start with `fusion-ai-merge-`. The sweep only considers directories older than 2 hours, canonicalizes paths before checking `activeSessionRegistry`, attempts `git worktree remove --force <path>` before filesystem removal, and emits `worktree:tempdir-sweep` run-audit telemetry for removal attempts and failures. It is intentionally native even when `worktrunk.enabled` because these temp-dir 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.
|
- Batch 1 also sweeps stale AI merge clean-room worktrees under `tmpdir()` whose names start with `fusion-ai-merge-`. The default age gate is 2 hours, but task-aware cleanup uses a 10-minute grace period for `done`/`archived` tasks and treats missing/deleted task rows as immediately stale. The sweep canonicalizes paths before checking `activeSessionRegistry`, attempts `git worktree remove --force <path>` before filesystem removal, and emits `worktree:tempdir-sweep` run-audit telemetry for removal attempts and failures. It is intentionally native even when `worktrunk.enabled` because these temp-dir 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.
|
||||||
- `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`.
|
- `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`.
|
- 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`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,33 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { rm } from "node:fs/promises";
|
import { rm } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { cleanupAiMergeWorktree, runAiMerge } from "../merger-ai.js";
|
import { cleanupAiMergeWorktree, pruneExistingAiMergeWorktrees, runAiMerge } from "../merger-ai.js";
|
||||||
|
import { activeSessionRegistry } from "../active-session-registry.js";
|
||||||
import type { RunAuditor } from "../run-audit.js";
|
import type { RunAuditor } from "../run-audit.js";
|
||||||
|
|
||||||
|
const fsState = vi.hoisted(() => ({ failReaddirPath: "" }));
|
||||||
|
|
||||||
|
vi.mock("node:fs", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("node:fs")>("node:fs");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
readdirSync: vi.fn((path: Parameters<typeof actual.readdirSync>[0], options?: Parameters<typeof actual.readdirSync>[1]) => {
|
||||||
|
if (String(path) === fsState.failReaddirPath) throw new Error("simulated readdir failure");
|
||||||
|
return actual.readdirSync(path, options as never);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const tracked = new Set<string>();
|
const tracked = new Set<string>();
|
||||||
const RM = { recursive: true, force: true, maxRetries: 5, retryDelay: 50 } as const;
|
const RM = { recursive: true, force: true, maxRetries: 5, retryDelay: 50 } as const;
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
fsState.failReaddirPath = "";
|
||||||
|
activeSessionRegistry.clear();
|
||||||
for (const dir of tracked) {
|
for (const dir of tracked) {
|
||||||
try { rmSync(dir, RM); } catch { /* best effort */ }
|
try { rmSync(dir, RM); } catch { /* best effort */ }
|
||||||
}
|
}
|
||||||
@@ -51,7 +67,8 @@ async function cleanup(input: Partial<Parameters<typeof cleanupAiMergeWorktree>[
|
|||||||
return { mergeRoot, events, logs };
|
return { mergeRoot, events, logs };
|
||||||
}
|
}
|
||||||
|
|
||||||
function initRepoWithBranch(): { dir: string } {
|
function initRepoWithBranch(taskId = "FN-1"): { dir: string } {
|
||||||
|
const branch = `fusion/${taskId.toLowerCase()}`;
|
||||||
const dir = mkdtempSync(join(tmpdir(), "fusion-ai-merge-cleanup-test-"));
|
const dir = mkdtempSync(join(tmpdir(), "fusion-ai-merge-cleanup-test-"));
|
||||||
tracked.add(dir);
|
tracked.add(dir);
|
||||||
git(dir, "init -q -b main");
|
git(dir, "init -q -b main");
|
||||||
@@ -60,7 +77,7 @@ function initRepoWithBranch(): { dir: string } {
|
|||||||
writeFileSync(join(dir, "base.txt"), "base\n");
|
writeFileSync(join(dir, "base.txt"), "base\n");
|
||||||
git(dir, "add -A");
|
git(dir, "add -A");
|
||||||
git(dir, "commit -q -m base");
|
git(dir, "commit -q -m base");
|
||||||
git(dir, "checkout -q -b fusion/fn-1");
|
git(dir, `checkout -q -b ${branch}`);
|
||||||
writeFileSync(join(dir, "feature.txt"), "feature work\n");
|
writeFileSync(join(dir, "feature.txt"), "feature work\n");
|
||||||
git(dir, "add -A");
|
git(dir, "add -A");
|
||||||
git(dir, "commit -q -m 'feat: work'");
|
git(dir, "commit -q -m 'feat: work'");
|
||||||
@@ -68,12 +85,12 @@ function initRepoWithBranch(): { dir: string } {
|
|||||||
return { dir };
|
return { dir };
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeStore() {
|
function makeStore(taskId = "FN-1") {
|
||||||
const task: any = {
|
const task: any = {
|
||||||
id: "FN-1",
|
id: taskId,
|
||||||
column: "in-review",
|
column: "in-review",
|
||||||
status: null,
|
status: null,
|
||||||
branch: "fusion/fn-1",
|
branch: `fusion/${taskId.toLowerCase()}`,
|
||||||
worktree: null,
|
worktree: null,
|
||||||
title: "do the thing",
|
title: "do the thing",
|
||||||
steps: [],
|
steps: [],
|
||||||
@@ -93,9 +110,16 @@ function makeStore() {
|
|||||||
return { store, audits, logs };
|
return { store, audits, logs };
|
||||||
}
|
}
|
||||||
|
|
||||||
function realMergeAgent() {
|
function tempAiMergeDir(name: string): string {
|
||||||
|
const dir = join(tmpdir(), name);
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
tracked.add(dir);
|
||||||
|
return dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
function realMergeAgent(taskId = "FN-1") {
|
||||||
return vi.fn(async (cwd: string) => {
|
return vi.fn(async (cwd: string) => {
|
||||||
execSync("git merge --squash fusion/fn-1", { cwd, stdio: "pipe" });
|
execSync(`git merge --squash fusion/${taskId.toLowerCase()}`, { cwd, stdio: "pipe" });
|
||||||
execSync("git add -A", { cwd, stdio: "pipe" });
|
execSync("git add -A", { cwd, stdio: "pipe" });
|
||||||
execSync('git commit -q -m "squash: feature"', { cwd, stdio: "pipe" });
|
execSync('git commit -q -m "squash: feature"', { cwd, stdio: "pipe" });
|
||||||
});
|
});
|
||||||
@@ -151,6 +175,44 @@ describe("AI merge temp worktree cleanup", () => {
|
|||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("pruneExistingAiMergeWorktrees removes stale same-task directories", async () => {
|
||||||
|
const stale = tempAiMergeDir("fusion-ai-merge-fn-777-stale");
|
||||||
|
const { audit, events } = makeAudit();
|
||||||
|
const logs: string[] = [];
|
||||||
|
|
||||||
|
await expect(pruneExistingAiMergeWorktrees("FN-777", process.cwd(), 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: realpathSync(tmpdir()) + "/fusion-ai-merge-fn-777-stale", phase: "pre-merge-prune", success: true }) }),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pruneExistingAiMergeWorktrees skips directories for other tasks", async () => {
|
||||||
|
const other = tempAiMergeDir("fusion-ai-merge-fn-778-stale");
|
||||||
|
const { audit, events } = makeAudit();
|
||||||
|
|
||||||
|
await expect(pruneExistingAiMergeWorktrees("FN-777", process.cwd(), audit, vi.fn(async () => undefined))).resolves.toBe(0);
|
||||||
|
|
||||||
|
expect(existsSync(other)).toBe(true);
|
||||||
|
expect(events).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pruneExistingAiMergeWorktrees skips active-session paths", async () => {
|
||||||
|
const stale = tempAiMergeDir("fusion-ai-merge-fn-777-active");
|
||||||
|
const canonical = realpathSync(stale);
|
||||||
|
activeSessionRegistry.registerPath(canonical, { taskId: "FN-777", kind: "executor", ownerKey: "FN-777" });
|
||||||
|
const { audit, events } = makeAudit();
|
||||||
|
|
||||||
|
await expect(pruneExistingAiMergeWorktrees("FN-777", process.cwd(), audit, vi.fn(async () => undefined))).resolves.toBe(0);
|
||||||
|
expect(existsSync(stale)).toBe(true);
|
||||||
|
expect(events).toEqual([]);
|
||||||
|
|
||||||
|
activeSessionRegistry.unregisterPath(canonical);
|
||||||
|
await expect(pruneExistingAiMergeWorktrees("FN-777", process.cwd(), audit, vi.fn(async () => undefined))).resolves.toBe(1);
|
||||||
|
expect(existsSync(stale)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("runAiMerge emits success cleanup audit events", async () => {
|
it("runAiMerge emits success cleanup audit events", async () => {
|
||||||
const { dir } = initRepoWithBranch();
|
const { dir } = initRepoWithBranch();
|
||||||
const { store, audits } = makeStore();
|
const { store, audits } = makeStore();
|
||||||
@@ -166,4 +228,33 @@ describe("AI merge temp worktree cleanup", () => {
|
|||||||
expect.objectContaining({ metadata: expect.objectContaining({ phase: "fs-rm", success: true }) }),
|
expect.objectContaining({ metadata: expect.objectContaining({ phase: "fs-rm", success: true }) }),
|
||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("runAiMerge calls pre-merge prune before creating worktree", async () => {
|
||||||
|
const taskId = "FN-777";
|
||||||
|
const { dir } = initRepoWithBranch(taskId);
|
||||||
|
const orphan = tempAiMergeDir("fusion-ai-merge-fn-777-orphan");
|
||||||
|
const { store, audits } = makeStore(taskId);
|
||||||
|
|
||||||
|
await runAiMerge(store, dir, taskId, { manual: true }, {
|
||||||
|
mergeAgent: realMergeAgent(taskId),
|
||||||
|
reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(existsSync(orphan)).toBe(false);
|
||||||
|
expect(audits.filter((event) => event.mutationType === "merge:ai-worktree-cleanup")).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ metadata: expect.objectContaining({ phase: "pre-merge-prune", success: true }) }),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pre-merge prune failure does not abort merge", async () => {
|
||||||
|
const { dir } = initRepoWithBranch();
|
||||||
|
const { store, logs } = makeStore();
|
||||||
|
fsState.failReaddirPath = tmpdir();
|
||||||
|
|
||||||
|
await expect(runAiMerge(store, dir, "FN-1", { manual: true }, {
|
||||||
|
mergeAgent: realMergeAgent(),
|
||||||
|
reviewAgent: vi.fn(async () => "REVIEW_VERDICT: approve"),
|
||||||
|
})).resolves.toMatchObject({ ok: true, merged: true });
|
||||||
|
expect(logs.join("\n")).toContain("pre-merge prune failed");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -72,17 +72,18 @@ afterEach(() => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function makeStore(settings: Record<string, unknown> = {}) {
|
function makeStore(settings: Record<string, unknown> = {}, getTask: () => Promise<any> = async () => ({ id: "FN-1", column: "in-progress" })) {
|
||||||
const audits: any[] = [];
|
const audits: any[] = [];
|
||||||
const store: any = {
|
const store: any = {
|
||||||
getSettings: vi.fn(async () => ({ ...settings })),
|
getSettings: vi.fn(async () => ({ ...settings })),
|
||||||
|
getTask: vi.fn(getTask),
|
||||||
recordRunAuditEvent: vi.fn(async (event: any) => { audits.push(event); }),
|
recordRunAuditEvent: vi.fn(async (event: any) => { audits.push(event); }),
|
||||||
};
|
};
|
||||||
return { store, audits };
|
return { store, audits };
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeManager(settings: Record<string, unknown> = {}) {
|
function makeManager(settings: Record<string, unknown> = {}, getTask?: () => Promise<any>) {
|
||||||
const { store, audits } = makeStore(settings);
|
const { store, audits } = makeStore(settings, getTask);
|
||||||
const manager = new SelfHealingManager(store, { rootDir: projectRoot });
|
const manager = new SelfHealingManager(store, { rootDir: projectRoot });
|
||||||
return { manager, audits };
|
return { manager, audits };
|
||||||
}
|
}
|
||||||
@@ -93,11 +94,27 @@ function tempMergeDir(name = `fusion-ai-merge-fn-1-${Math.random().toString(36).
|
|||||||
return dir;
|
return dir;
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeStale(path: string): void {
|
function makeAge(path: string, ageMs: number): void {
|
||||||
const old = new Date(Date.now() - 3 * 60 * 60 * 1000);
|
const old = new Date(Date.now() - ageMs);
|
||||||
utimesSync(path, old, old);
|
utimesSync(path, old, old);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeStale(path: string): void {
|
||||||
|
makeAge(path, 3 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDoneTaskStale(path: string): void {
|
||||||
|
makeAge(path, 11 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskWithColumn(column: string): () => Promise<any> {
|
||||||
|
return async () => ({ id: "FN-999", column });
|
||||||
|
}
|
||||||
|
|
||||||
|
function missingTask(): () => Promise<any> {
|
||||||
|
return async () => { throw new Error("Task FN-999 not found"); };
|
||||||
|
}
|
||||||
|
|
||||||
async function sweep(manager: SelfHealingManager): Promise<number> {
|
async function sweep(manager: SelfHealingManager): Promise<number> {
|
||||||
return await (manager as any).cleanupStaleTempMergeWorktrees();
|
return await (manager as any).cleanupStaleTempMergeWorktrees();
|
||||||
}
|
}
|
||||||
@@ -188,6 +205,91 @@ describe("SelfHealingManager temp-dir AI merge worktree sweep", () => {
|
|||||||
]));
|
]));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("removes worktree for done task after grace period", async () => {
|
||||||
|
const stale = tempMergeDir("fusion-ai-merge-fn-999-donetask");
|
||||||
|
makeDoneTaskStale(stale);
|
||||||
|
const { manager, audits } = makeManager({}, taskWithColumn("done"));
|
||||||
|
|
||||||
|
await expect(sweep(manager)).resolves.toBe(1);
|
||||||
|
|
||||||
|
expect(existsSync(stale)).toBe(false);
|
||||||
|
expect(sweepAudits(audits)).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ metadata: expect.objectContaining({ path: realpathSync(sandboxRoot) + "/fusion-ai-merge-fn-999-donetask", success: true, reason: "done-task-stale" }) }),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes worktree for archived task after grace period", async () => {
|
||||||
|
const stale = tempMergeDir("fusion-ai-merge-fn-999-archivedtask");
|
||||||
|
makeDoneTaskStale(stale);
|
||||||
|
const { manager, audits } = makeManager({}, taskWithColumn("archived"));
|
||||||
|
|
||||||
|
await expect(sweep(manager)).resolves.toBe(1);
|
||||||
|
|
||||||
|
expect(existsSync(stale)).toBe(false);
|
||||||
|
expect(sweepAudits(audits)).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ metadata: expect.objectContaining({ success: true, reason: "done-task-stale" }) }),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes worktree for deleted task immediately", async () => {
|
||||||
|
const fresh = tempMergeDir("fusion-ai-merge-fn-999-deletedtask");
|
||||||
|
const { manager, audits } = makeManager({}, missingTask());
|
||||||
|
|
||||||
|
await expect(sweep(manager)).resolves.toBe(1);
|
||||||
|
|
||||||
|
expect(existsSync(fresh)).toBe(false);
|
||||||
|
expect(sweepAudits(audits)).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ metadata: expect.objectContaining({ success: true, reason: "deleted-task" }) }),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps worktree for in-progress task within 2h gate", async () => {
|
||||||
|
const fresh = tempMergeDir("fusion-ai-merge-fn-999-inprogressfresh");
|
||||||
|
const { manager } = makeManager({}, taskWithColumn("in-progress"));
|
||||||
|
|
||||||
|
await expect(sweep(manager)).resolves.toBe(0);
|
||||||
|
|
||||||
|
expect(existsSync(fresh)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes worktree for in-progress task after 2h gate", async () => {
|
||||||
|
const stale = tempMergeDir("fusion-ai-merge-fn-999-inprogressstale");
|
||||||
|
makeStale(stale);
|
||||||
|
const { manager, audits } = makeManager({}, taskWithColumn("in-progress"));
|
||||||
|
|
||||||
|
await expect(sweep(manager)).resolves.toBe(1);
|
||||||
|
|
||||||
|
expect(existsSync(stale)).toBe(false);
|
||||||
|
expect(sweepAudits(audits)).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ metadata: expect.objectContaining({ success: true, reason: "stale" }) }),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps fresh worktree for done task within grace period", async () => {
|
||||||
|
const fresh = tempMergeDir("fusion-ai-merge-fn-999-donefresh");
|
||||||
|
makeAge(fresh, 5 * 60 * 1000);
|
||||||
|
const { manager } = makeManager({}, taskWithColumn("done"));
|
||||||
|
|
||||||
|
await expect(sweep(manager)).resolves.toBe(0);
|
||||||
|
|
||||||
|
expect(existsSync(fresh)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles non-parseable directory names with age-only fallback", async () => {
|
||||||
|
const fresh = tempMergeDir("fusion-ai-merge-unknown-fresh");
|
||||||
|
const stale = tempMergeDir("fusion-ai-merge-unknown-stale");
|
||||||
|
makeStale(stale);
|
||||||
|
const { manager, audits } = makeManager({}, missingTask());
|
||||||
|
|
||||||
|
await expect(sweep(manager)).resolves.toBe(1);
|
||||||
|
|
||||||
|
expect(existsSync(fresh)).toBe(true);
|
||||||
|
expect(existsSync(stale)).toBe(false);
|
||||||
|
expect(sweepAudits(audits)).toEqual(expect.arrayContaining([
|
||||||
|
expect.objectContaining({ metadata: expect.objectContaining({ path: expect.stringContaining("unknown-stale"), success: true, reason: "stale" }) }),
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
|
||||||
it("proceeds when worktrunk is enabled", async () => {
|
it("proceeds when worktrunk is enabled", async () => {
|
||||||
const stale = tempMergeDir();
|
const stale = tempMergeDir();
|
||||||
makeStale(stale);
|
makeStale(stale);
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
*/
|
*/
|
||||||
import { execFile } from "node:child_process";
|
import { execFile } from "node:child_process";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
import { readdirSync, realpathSync, rmSync } from "node:fs";
|
||||||
import { mkdtemp, rm } from "node:fs/promises";
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
@@ -62,6 +63,7 @@ import { accumulateSessionTokenUsage } from "./session-token-usage.js";
|
|||||||
import { createRunAuditor, generateSyntheticRunId, type RunAuditor } from "./run-audit.js";
|
import { createRunAuditor, generateSyntheticRunId, type RunAuditor } from "./run-audit.js";
|
||||||
import { createLogger } from "./logger.js";
|
import { createLogger } from "./logger.js";
|
||||||
import { captureSingleCommitLandedMetadata, type MergerOptions } from "./merger.js";
|
import { captureSingleCommitLandedMetadata, type MergerOptions } from "./merger.js";
|
||||||
|
import { activeSessionRegistry } from "./active-session-registry.js";
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
const aiMergeLog = createLogger("merger-ai");
|
const aiMergeLog = createLogger("merger-ai");
|
||||||
@@ -103,6 +105,61 @@ function describeCleanupError(err: unknown): string {
|
|||||||
return stderr ? `${message}: ${stderr.trim()}` : message;
|
return stderr ? `${message}: ${stderr.trim()}` : message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function pruneExistingAiMergeWorktrees(
|
||||||
|
taskId: string,
|
||||||
|
projectRootDir: string,
|
||||||
|
audit: RunAuditor,
|
||||||
|
log: (message: string) => Promise<void>,
|
||||||
|
): Promise<number> {
|
||||||
|
const prefix = `fusion-ai-merge-${taskId.toLowerCase()}-`;
|
||||||
|
const tempRoot = tmpdir();
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = readdirSync(tempRoot).filter((entry) => entry.startsWith(prefix));
|
||||||
|
} catch (err: unknown) {
|
||||||
|
await log(`AI merge pre-merge prune: failed to read ${tempRoot}: ${getErrorMessage(err)}`);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pruned = 0;
|
||||||
|
for (const entry of entries) {
|
||||||
|
const candidatePath = join(tempRoot, entry);
|
||||||
|
let canonicalPath = candidatePath;
|
||||||
|
try {
|
||||||
|
canonicalPath = realpathSync(candidatePath);
|
||||||
|
} catch {
|
||||||
|
canonicalPath = candidatePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSessionRegistry.isPathActive(canonicalPath) || activeSessionRegistry.isPathActive(candidatePath)) {
|
||||||
|
await log(`AI merge pre-merge prune: skipping active worktree ${canonicalPath}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await execFileAsync("git", ["worktree", "remove", "--force", canonicalPath], {
|
||||||
|
cwd: projectRootDir,
|
||||||
|
timeout: 30_000,
|
||||||
|
});
|
||||||
|
} catch (err: unknown) {
|
||||||
|
await log(`AI merge pre-merge prune: git worktree remove failed for ${canonicalPath}: ${describeCleanupError(err)} — falling back to filesystem removal`);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
rmSync(canonicalPath, { recursive: true, force: true });
|
||||||
|
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalPath, metadata: { taskId, mergeRoot: canonicalPath, phase: "pre-merge-prune", success: true } });
|
||||||
|
pruned++;
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const error = getErrorMessage(err);
|
||||||
|
const code = getErrorStringProperty(err, "code");
|
||||||
|
await log(`AI merge pre-merge prune: filesystem rm failed for ${canonicalPath}${code ? ` (${code})` : ""}: ${error}`);
|
||||||
|
await audit.git({ type: "merge:ai-worktree-cleanup", target: canonicalPath, metadata: { taskId, mergeRoot: canonicalPath, phase: "pre-merge-prune", success: false, error, ...(code ? { code } : {}) } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pruned;
|
||||||
|
}
|
||||||
|
|
||||||
export async function cleanupAiMergeWorktree(input: {
|
export async function cleanupAiMergeWorktree(input: {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
mergeRoot: string;
|
mergeRoot: string;
|
||||||
@@ -839,6 +896,12 @@ export async function runAiMerge(
|
|||||||
const taskTitle = task.title?.trim() ? task.title.split("\n")[0] : undefined;
|
const taskTitle = task.title?.trim() ? task.title.split("\n")[0] : undefined;
|
||||||
|
|
||||||
await setStatus("merging");
|
await setStatus("merging");
|
||||||
|
try {
|
||||||
|
const pruned = await pruneExistingAiMergeWorktrees(taskId, projectRootDir, audit, log);
|
||||||
|
if (pruned > 0) await log(`AI merge: pruned ${pruned} pre-existing worktree(s) for ${taskId}`);
|
||||||
|
} catch (err: unknown) {
|
||||||
|
await log(`AI merge: pre-merge prune failed: ${getErrorMessage(err)}`);
|
||||||
|
}
|
||||||
let advanceRetries = 0;
|
let advanceRetries = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
throwIfAborted(options.signal, taskId);
|
throwIfAborted(options.signal, taskId);
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ const DB_CORRUPTION_NOTIFICATION_COOLDOWN_MS = 60 * 60 * 1000;
|
|||||||
const FTS_MAINTENANCE_MERGE_CADENCE_TICKS = 1;
|
const FTS_MAINTENANCE_MERGE_CADENCE_TICKS = 1;
|
||||||
const FTS_MAINTENANCE_OPTIMIZE_CADENCE_TICKS = 4;
|
const FTS_MAINTENANCE_OPTIMIZE_CADENCE_TICKS = 4;
|
||||||
const STALE_TEMP_MERGE_WORKTREE_MS = 2 * 60 * 60 * 1000;
|
const STALE_TEMP_MERGE_WORKTREE_MS = 2 * 60 * 60 * 1000;
|
||||||
|
const DONE_TASK_TEMP_WORKTREE_GRACE_MS = 10 * 60 * 1000;
|
||||||
// Live pathology peaked around 775 KB/task (~96 MB for ~120 tasks), while a
|
// 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
|
// 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.
|
// bounded so sustained text churn heals before segment growth becomes material.
|
||||||
@@ -94,6 +95,11 @@ export const MAX_COMPLETION_HANDOFF_LIMBO_RECOVERIES = 3;
|
|||||||
export const MAX_POST_DONE_NONCONTINUABLE_WEDGE_RECOVERIES = 3;
|
export const MAX_POST_DONE_NONCONTINUABLE_WEDGE_RECOVERIES = 3;
|
||||||
const MAX_NO_PROGRESS_RESUME_ATTEMPTS = 2;
|
const MAX_NO_PROGRESS_RESUME_ATTEMPTS = 2;
|
||||||
|
|
||||||
|
function extractTaskIdFromTempMergeDir(dirname: string): string | null {
|
||||||
|
const match = /^fusion-ai-merge-(fn-\d+)-[a-z0-9]+$/i.exec(dirname);
|
||||||
|
return match?.[1]?.toUpperCase() ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
type BranchGroupLandingRecorder = {
|
type BranchGroupLandingRecorder = {
|
||||||
recordBranchGroupMemberLanded?: (groupId: string, payload: {
|
recordBranchGroupMemberLanded?: (groupId: string, payload: {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
@@ -8887,6 +8893,7 @@ export class SelfHealingManager {
|
|||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const path = join(tempRoot, entry);
|
const path = join(tempRoot, entry);
|
||||||
let canonicalPath = path;
|
let canonicalPath = path;
|
||||||
|
let cleanupReason = "stale";
|
||||||
try {
|
try {
|
||||||
const stat = statSync(path);
|
const stat = statSync(path);
|
||||||
if (!stat.isDirectory()) {
|
if (!stat.isDirectory()) {
|
||||||
@@ -8894,7 +8901,22 @@ export class SelfHealingManager {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const ageMs = now - stat.mtimeMs;
|
const ageMs = now - stat.mtimeMs;
|
||||||
if (ageMs < STALE_TEMP_MERGE_WORKTREE_MS) continue;
|
let ageGateMs = STALE_TEMP_MERGE_WORKTREE_MS;
|
||||||
|
cleanupReason = "stale";
|
||||||
|
const taskId = extractTaskIdFromTempMergeDir(entry);
|
||||||
|
if (taskId) {
|
||||||
|
try {
|
||||||
|
const task = await this.store.getTask(taskId);
|
||||||
|
if (task.column === "done" || task.column === "archived") {
|
||||||
|
ageGateMs = DONE_TASK_TEMP_WORKTREE_GRACE_MS;
|
||||||
|
cleanupReason = "done-task-stale";
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
ageGateMs = 0;
|
||||||
|
cleanupReason = "deleted-task";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ageGateMs > 0 && ageMs < ageGateMs) continue;
|
||||||
try {
|
try {
|
||||||
canonicalPath = realpathSync(path);
|
canonicalPath = realpathSync(path);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -8927,7 +8949,7 @@ export class SelfHealingManager {
|
|||||||
try {
|
try {
|
||||||
rmSync(canonicalPath, { recursive: true, force: true });
|
rmSync(canonicalPath, { recursive: true, force: true });
|
||||||
log.log(`[self-healing] temp-dir sweep: cleaned stale AI merge worktree ${canonicalPath}`);
|
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" } });
|
await auditor.git({ type: "worktree:tempdir-sweep", target: canonicalPath, metadata: { path: canonicalPath, success: true, reason: cleanupReason } });
|
||||||
cleaned++;
|
cleaned++;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||||
|
|||||||
Reference in New Issue
Block a user