diff --git a/.changeset/fn-verify-worktree-reaping.md b/.changeset/fn-verify-worktree-reaping.md new file mode 100644 index 0000000000..157dfd42be --- /dev/null +++ b/.changeset/fn-verify-worktree-reaping.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Leaked verification worktrees are now reaped, so planning no longer queues behind exhausted slots. +category: fix +dev: mission-verification's fn-verify-* checkouts leaked on process death (dispose is in-process best-effort); the self-healing temp-dir sweep now includes the fn-verify- prefix under tmpdir() with the same age gates and active-session refusal. 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 8e59d5f30c..465036e5cc 100644 --- a/packages/engine/src/__tests__/self-healing-tempdir-sweep.test.ts +++ b/packages/engine/src/__tests__/self-healing-tempdir-sweep.test.ts @@ -193,6 +193,28 @@ describe("SelfHealingManager worktrees-dir sweeps", () => { }); describe("SelfHealingManager temp-dir AI merge worktree sweep", () => { + it("removes stale fn-verify verification checkouts from tmpdir (leak found on the live board)", async () => { + /* + FNXC:TempWorktreeSweep 2026-07-31-22:55: + GitCheckoutMaterializer's dispose() is in-process best-effort; a killed process leaks the + fn-verify-* checkout AND its git worktree registration forever, because no sweep knew the + prefix. Reverting the sweep-prefix change makes this test fail (the dir survives). + */ + const stale = tempMergeDir(`fn-verify-${Math.random().toString(36).slice(2)}`); + makeStale(stale); + const staleApp = tempMergeDir(`fn-verify-app-${Math.random().toString(36).slice(2)}`); + makeStale(staleApp); + const fresh = tempMergeDir(`fn-verify-fresh-${Math.random().toString(36).slice(2)}`); + const { manager } = makeManager(); + + await expect(sweep(manager)).resolves.toBe(2); + + expect(existsSync(stale)).toBe(false); + expect(existsSync(staleApp)).toBe(false); + // Young checkouts may belong to a live verification run — the age gate must hold. + expect(existsSync(fresh)).toBe(true); + }); + it("removes stale fusion-ai-merge directories and emits success audits", async () => { const stale = tempMergeDir(); makeStale(stale); diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index 82b9447cc9..c3733f5d79 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -14905,7 +14905,19 @@ const movedTask = await this.store.moveTask(task.id, completeLane); for (const tempRoot of roots) { let entries: string[]; try { - entries = readdirSync(tempRoot).filter((entry) => entry.startsWith("fusion-ai-merge-")); + /* + FNXC:TempWorktreeSweep 2026-07-31-22:55: + `fn-verify-` (and `fn-verify-app-`) checkouts from mission-verification's + GitCheckoutMaterializer leak when the process dies between materialize and dispose — seven + registered worktrees aged Jul 24-27 were found holding registrations, and on the live board + the visible symptom was planning admission queueing behind exhausted worktree slots + ("queued to plan" for ~6 minutes). Their dispose() is best-effort in-process only; this + sweep is the only out-of-process reaper, so it must know the prefix. Verification checkouts + are detached throwaways that only ever live under tmpdir(), so the extra prefixes apply to + that root alone; the same age gates, active-session refusal, and audit rows govern them. + */ + const sweepPrefixes = tempRoot === tmpdir() ? ["fusion-ai-merge-", "fn-verify-"] : ["fusion-ai-merge-"]; + entries = readdirSync(tempRoot).filter((entry) => sweepPrefixes.some((prefix) => entry.startsWith(prefix))); } catch (err: unknown) { if (!existsSync(tempRoot)) continue; const errorMessage = err instanceof Error ? err.message : String(err);