diff --git a/.changeset/fix-active-session-worktree-sweep.md b/.changeset/fix-active-session-worktree-sweep.md new file mode 100644 index 0000000000..c562e63eb9 --- /dev/null +++ b/.changeset/fix-active-session-worktree-sweep.md @@ -0,0 +1,5 @@ +--- +"@runfusion/fusion": patch +--- + +Stop self-healing from removing worktrees that are still in use. The idle-worktree and cap-enforcement sweeps now skip any worktree bound to a live executor/merger/step/workflow session, so a checkout is no longer reaped while its task transiently sits in `done` or loses its worktree linkage mid-run. diff --git a/packages/engine/src/__tests__/self-healing-cli-sessions.test.ts b/packages/engine/src/__tests__/self-healing-cli-sessions.test.ts index 2dfe8144b3..4e7ae3e79d 100644 --- a/packages/engine/src/__tests__/self-healing-cli-sessions.test.ts +++ b/packages/engine/src/__tests__/self-healing-cli-sessions.test.ts @@ -20,6 +20,7 @@ import type { TaskStore } from "@fusion/core"; import { SelfHealingManager } from "../self-healing.js"; import * as worktreePool from "../worktree-pool.js"; import { StuckTaskDetector, type DisposableSession } from "../stuck-task-detector.js"; +import { activeSessionRegistry } from "../active-session-registry.js"; function createStore(settings: Record): TaskStore & EventEmitter { const emitter = new EventEmitter() as TaskStore & EventEmitter; @@ -46,6 +47,7 @@ describe("self-healing idle-worktree sweeps skip resume-eligible CLI session wor afterEach(() => { rmSync(rootDir, { recursive: true, force: true }); + activeSessionRegistry.clear(); vi.restoreAllMocks(); }); @@ -85,6 +87,42 @@ describe("self-healing idle-worktree sweeps skip resume-eligible CLI session wor expect(cleaned).toBe(1); }); + it("cleanupOrphans skips a worktree backing a live (active-session) executor session", async () => { + // FN-4811/FN-5065 regression: a registered idle worktree whose task transiently + // sits in "done" (so scanIdleWorktrees lists it) must NOT be reaped while a live + // executor/merger/step session is still bound to it — that yanks the checkout out + // from under in-flight work ("removed before the work is done"). + const store = createStore({ recycleWorktrees: false }); + vi.spyOn(worktreePool, "scanIdleWorktrees").mockResolvedValue([reservedPath, freePath]); + const removeSpy = vi.spyOn(worktreePool, "removeWorktree").mockResolvedValue(undefined as never); + + activeSessionRegistry.registerPath(reservedPath, { taskId: "FN-1", kind: "executor", ownerKey: "owner-1" }); + + // No isWorktreeResumeReserved seam — protection comes solely from the active session. + const manager = new SelfHealingManager(store, { rootDir }); + const cleaned = await (manager as any).cleanupOrphans(); + + const removed = removeSpy.mock.calls.map((c) => (c[0] as { worktreePath: string }).worktreePath); + expect(removed).toEqual([freePath]); + expect(cleaned).toBe(1); + }); + + it("enforceWorktreeCap skips a worktree backing a live (active-session) executor session", async () => { + mkdirSync(join(worktreesDir, "wt-extra")); + const store = createStore({ maxWorktrees: 1, recycleWorktrees: false }); + vi.spyOn(worktreePool, "scanIdleWorktrees").mockResolvedValue([reservedPath, freePath, join(worktreesDir, "wt-extra")]); + const removeSpy = vi.spyOn(worktreePool, "removeWorktree").mockResolvedValue(undefined as never); + + activeSessionRegistry.registerPath(reservedPath, { taskId: "FN-1", kind: "executor", ownerKey: "owner-1" }); + + const manager = new SelfHealingManager(store, { rootDir }); + await (manager as any).enforceWorktreeCap(); + + const removed = removeSpy.mock.calls.map((c) => (c[0] as { worktreePath: string }).worktreePath); + expect(removed).not.toContain(reservedPath); + expect(removed).toContain(freePath); + }); + it("without the seam predicate, both worktrees are reaped (no behavior change)", async () => { const store = createStore({ recycleWorktrees: false }); vi.spyOn(worktreePool, "scanIdleWorktrees").mockResolvedValue([reservedPath, freePath]); diff --git a/packages/engine/src/self-healing.ts b/packages/engine/src/self-healing.ts index ec64edb904..dcbd736c99 100644 --- a/packages/engine/src/self-healing.ts +++ b/packages/engine/src/self-healing.ts @@ -8760,6 +8760,14 @@ export class SelfHealingManager { let cleaned = 0; for (const worktreePath of orphaned) { + // FN-4811/FN-5065: never reap a worktree bound to a live executor/merger/ + // step/workflow session. Such a task can sit transiently in "done" (or have + // null worktree metadata mid-transition) while the owning process is still + // working in the checkout — scanIdleWorktrees would otherwise flag it idle. + if (activeSessionRegistry.isPathActive(worktreePath) || activeSessionRegistry.isPathActive(resolve(worktreePath))) { + log.log(`[self-healing] deferring idle-sweep for ${worktreePath}: active session present`); + continue; + } // U8: never reclaim a worktree backing a resume-eligible CLI session. if (this.isWorktreeResumeReserved(worktreePath)) { log.log(`[self-healing] deferring idle-sweep for ${worktreePath}: resume-eligible CLI session present`); @@ -9218,6 +9226,13 @@ export class SelfHealingManager { for (const { path: worktreePath } of withMtime) { if (removed >= excess) break; + // FN-4811/FN-5065: never reap a worktree bound to a live executor/merger/ + // step/workflow session — cap pressure must not yank a checkout out from + // under a process that is still working in it. + if (activeSessionRegistry.isPathActive(worktreePath) || activeSessionRegistry.isPathActive(resolve(worktreePath))) { + log.log(`[self-healing] cap-enforcement skipping ${worktreePath}: active session present`); + continue; + } // U8: never reclaim a worktree backing a resume-eligible CLI session. if (this.isWorktreeResumeReserved(worktreePath)) { log.log(`[self-healing] cap-enforcement skipping ${worktreePath}: resume-eligible CLI session present`);