import { mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { execSync } from "node:child_process"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { EventEmitter } from "node:events"; const { logger } = vi.hoisted(() => ({ logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn() }, })); vi.mock("../logger.js", () => ({ createLogger: vi.fn(() => logger), })); import type { Task } from "@fusion/core"; import { TaskStore } from "@fusion/core"; import { SelfHealingManager } from "../self-healing.js"; function git(cwd: string, command: string): string { return execSync(`git ${command}`, { cwd, encoding: "utf8" }).trim(); } function makeSlimTask(id: string, overrides: Partial = {}): Task { return { id, title: id, description: id, column: "todo", dependencies: [], steps: [], currentStep: 0, log: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), ...overrides, } as Task; } describe("self-healing worktree metadata reconcile", () => { let rootDir = ""; let store: TaskStore; beforeEach(async () => { rootDir = mkdtempSync(join(tmpdir(), "fn-4962-")); git(rootDir, "init -b main"); git(rootDir, "config user.name 'Fusion'"); git(rootDir, "config user.email 'hi@runfusion.ai'"); writeFileSync(join(rootDir, "README.md"), "root\n"); git(rootDir, "add README.md"); git(rootDir, "commit -m 'init'"); store = new TaskStore(rootDir, undefined, { inMemoryDb: false }); await store.createTask({ title: "FN-4913 reproduction", description: "repro", }); }); afterEach(() => { try { store?.close(); } catch { // noop } if (rootDir) rmSync(rootDir, { recursive: true, force: true }); vi.clearAllMocks(); }); it("keeps reconcile-task-worktree-metadata ordered before reclaim-stale-active-branches in startup and maintenance", () => { const selfHealingPath = fileURLToPath(new URL("../self-healing.ts", import.meta.url)); const source = readFileSync(selfHealingPath, "utf8"); const startupSlice = source.slice( source.indexOf("const steps:"), source.indexOf("for (const step of steps)"), ); expect(startupSlice.indexOf('"reconcile-task-worktree-metadata"')).toBeGreaterThan(-1); expect(startupSlice.indexOf('"reclaim-stale-active-branches"')).toBeGreaterThan(-1); expect(startupSlice.indexOf('"reconcile-task-worktree-metadata"')).toBeLessThan( startupSlice.indexOf('"reclaim-stale-active-branches"'), ); const maintenanceSlice = source.slice( source.indexOf("const batch2Fns:"), source.indexOf("for (const fn of batch2Fns)"), ); expect(maintenanceSlice.indexOf('"reconcile-task-worktree-metadata"')).toBeGreaterThan(-1); expect(maintenanceSlice.indexOf('"reclaim-stale-active-branches"')).toBeGreaterThan(-1); expect(maintenanceSlice.indexOf('"reconcile-task-worktree-metadata"')).toBeLessThan( maintenanceSlice.indexOf('"reclaim-stale-active-branches"'), ); }); it("rebinds stale task.worktree + null branch to live fusion/ worktree", async () => { const [task] = await store.listTasks(); expect(task).toBeTruthy(); const stalePath = join(rootDir, ".worktrees", "misty-grove"); const livePath = join(rootDir, ".worktrees", "sleek-stone"); mkdirSync(join(rootDir, ".worktrees"), { recursive: true }); const branch = `fusion/${task.id.toLowerCase()}`; git(rootDir, `branch ${branch}`); git(rootDir, `worktree add ${livePath} ${branch}`); writeFileSync(join(livePath, "feature.txt"), "changed\n"); git(livePath, "add feature.txt"); git(livePath, "commit -m 'feature commit'"); await store.moveTask(task.id, "todo"); await store.moveTask(task.id, "in-progress"); await store.moveTask(task.id, "in-review"); await store.updateTask(task.id, { worktree: stalePath, branch: null, }); const auditSpy = vi.spyOn(store, "recordRunAuditEvent"); const manager = new SelfHealingManager(store, { rootDir, getExecutingTaskIds: () => new Set(), }); await (manager as any).reconcileTaskWorktreeMetadata(); const canonicalLivePath = realpathSync(livePath); const updated = await store.getTask(task.id); expect(updated?.worktree).toBe(canonicalLivePath); expect(updated?.branch).toBe(branch); const taskJson = JSON.parse( readFileSync(join(rootDir, ".fusion", "tasks", task.id, "task.json"), "utf8"), ) as { worktree?: string | null; branch?: string | null }; expect(taskJson.worktree).toBe(canonicalLivePath); expect(taskJson.branch).toBe(branch); expect(logger.log).toHaveBeenCalled(); expect(auditSpy).toHaveBeenCalledWith( expect.objectContaining({ mutationType: "task:auto-recover-worktree-metadata-rebound" }), ); }); }); describe("reconcileTaskWorktreeMetadata matrix", () => { it("skips done/archived and executing tasks", async () => { const tasks = [ makeSlimTask("FN-100", { column: "done", worktree: "/missing", branch: undefined }), makeSlimTask("FN-101", { column: "archived", worktree: "/missing", branch: undefined }), makeSlimTask("FN-102", { column: "todo", worktree: "/missing", branch: undefined }), ]; const store = Object.assign(new EventEmitter(), { getSettings: vi.fn(async () => ({ globalPause: false, enginePaused: false })), listTasks: vi.fn(async () => tasks), updateTask: vi.fn(async () => undefined), recordRunAuditEvent: vi.fn(async () => undefined), }) as unknown as TaskStore; const manager = new SelfHealingManager(store, { rootDir: process.cwd(), getExecutingTaskIds: () => new Set(["FN-102"]), }); const repaired = await manager.reconcileTaskWorktreeMetadata(); expect(repaired).toBe(0); expect((store as any).updateTask).not.toHaveBeenCalled(); }); });