Files
fusion/packages/engine/src/worktree-pool.test.ts
Dustin Byrne ab2b663385 feat(KB-154): persist worktree pool across engine restarts
- Add rehydrate() method and scanIdleWorktrees() to restore idle worktrees from disk on startup
- Add cleanupOrphanedWorktrees() to remove stale worktrees when recycling is disabled
- Wire startup rehydrate/cleanup into dashboard command based on recycleWorktrees setting
- Export new functions from @kb/engine and update dashboard imports
- Add unit tests for rehydrate/scan/cleanup and integration tests for restart resilience
2026-03-28 16:30:23 -04:00

381 lines
12 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("node:child_process", () => ({
execSync: vi.fn(),
}));
vi.mock("node:fs", () => ({
existsSync: vi.fn().mockReturnValue(true),
readdirSync: vi.fn().mockReturnValue([]),
}));
import { WorktreePool, scanIdleWorktrees, cleanupOrphanedWorktrees } from "./worktree-pool.js";
import { execSync } from "node:child_process";
import { existsSync, readdirSync } from "node:fs";
import type { Task, Column } from "@kb/core";
const mockedExecSync = vi.mocked(execSync);
const mockedExistsSync = vi.mocked(existsSync);
const mockedReaddirSync = vi.mocked(readdirSync);
describe("WorktreePool", () => {
let pool: WorktreePool;
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
pool = new WorktreePool();
});
describe("acquire", () => {
it("returns null when pool is empty", () => {
expect(pool.acquire()).toBeNull();
});
it("returns a released path on acquire", () => {
pool.release("/tmp/worktree-1");
const result = pool.acquire();
expect(result).toBe("/tmp/worktree-1");
});
it("prunes entries where directory no longer exists on disk", () => {
pool.release("/tmp/stale-worktree");
pool.release("/tmp/good-worktree");
// First path doesn't exist, second does
mockedExistsSync.mockImplementation((p) => p === "/tmp/good-worktree");
const result = pool.acquire();
expect(result).toBe("/tmp/good-worktree");
expect(pool.size).toBe(0);
});
it("returns null when all entries are stale", () => {
pool.release("/tmp/stale-1");
pool.release("/tmp/stale-2");
mockedExistsSync.mockReturnValue(false);
expect(pool.acquire()).toBeNull();
expect(pool.size).toBe(0);
});
});
describe("release", () => {
it("adds a path to the pool", () => {
pool.release("/tmp/wt-1");
expect(pool.size).toBe(1);
expect(pool.has("/tmp/wt-1")).toBe(true);
});
it("does not duplicate on double release", () => {
pool.release("/tmp/wt-1");
pool.release("/tmp/wt-1");
expect(pool.size).toBe(1);
});
});
describe("size", () => {
it("reflects correct count after operations", () => {
expect(pool.size).toBe(0);
pool.release("/tmp/a");
pool.release("/tmp/b");
expect(pool.size).toBe(2);
pool.acquire();
expect(pool.size).toBe(1);
pool.acquire();
expect(pool.size).toBe(0);
});
});
describe("has", () => {
it("returns false for unknown paths", () => {
expect(pool.has("/tmp/unknown")).toBe(false);
});
it("returns true for released paths", () => {
pool.release("/tmp/wt");
expect(pool.has("/tmp/wt")).toBe(true);
});
it("returns false after path is acquired", () => {
pool.release("/tmp/wt");
pool.acquire();
expect(pool.has("/tmp/wt")).toBe(false);
});
});
describe("drain", () => {
it("empties the pool and returns all paths", () => {
pool.release("/tmp/a");
pool.release("/tmp/b");
pool.release("/tmp/c");
const paths = pool.drain();
expect(paths).toHaveLength(3);
expect(paths).toContain("/tmp/a");
expect(paths).toContain("/tmp/b");
expect(paths).toContain("/tmp/c");
expect(pool.size).toBe(0);
});
it("returns empty array when pool is empty", () => {
expect(pool.drain()).toEqual([]);
});
});
describe("prepareForTask", () => {
it("cleans dirty working tree before checkout", () => {
pool.prepareForTask("/tmp/wt", "kb/kb-042");
const calls = mockedExecSync.mock.calls.map((c) => c[0]);
expect(calls).toContain("git checkout -- .");
expect(calls).toContain("git clean -fd");
});
it("creates branch from main with force-reset", () => {
pool.prepareForTask("/tmp/wt", "kb/kb-042");
const checkoutCall = mockedExecSync.mock.calls.find(
(c) => typeof c[0] === "string" && (c[0] as string).includes("checkout -B"),
);
expect(checkoutCall).toBeDefined();
expect(checkoutCall![0]).toBe('git checkout -B "kb/kb-042" main');
expect(checkoutCall![1]).toMatchObject({ cwd: "/tmp/wt" });
});
it("runs all commands in the correct worktree directory", () => {
pool.prepareForTask("/tmp/my-worktree", "kb/kb-099");
for (const call of mockedExecSync.mock.calls) {
expect(call[1]).toMatchObject({ cwd: "/tmp/my-worktree" });
}
});
it("tolerates git checkout -- . failure (already clean)", () => {
mockedExecSync.mockImplementation((cmd: any) => {
if (cmd === "git checkout -- .") throw new Error("nothing to checkout");
return Buffer.from("");
});
// Should not throw
expect(() => pool.prepareForTask("/tmp/wt", "kb/kb-001")).not.toThrow();
// Should still run clean and branch creation
const calls = mockedExecSync.mock.calls.map((c) => c[0]);
expect(calls).toContain("git clean -fd");
expect(calls).toContain('git checkout -B "kb/kb-001" main');
});
});
describe("rehydrate", () => {
it("loads paths into the idle set", () => {
mockedExistsSync.mockReturnValue(true);
pool.rehydrate(["/tmp/wt-1", "/tmp/wt-2", "/tmp/wt-3"]);
expect(pool.size).toBe(3);
expect(pool.has("/tmp/wt-1")).toBe(true);
expect(pool.has("/tmp/wt-2")).toBe(true);
expect(pool.has("/tmp/wt-3")).toBe(true);
});
it("skips paths that don't exist on disk", () => {
mockedExistsSync.mockImplementation((p) => p === "/tmp/good-wt");
pool.rehydrate(["/tmp/good-wt", "/tmp/gone-wt"]);
expect(pool.size).toBe(1);
expect(pool.has("/tmp/good-wt")).toBe(true);
expect(pool.has("/tmp/gone-wt")).toBe(false);
});
it("handles empty array", () => {
pool.rehydrate([]);
expect(pool.size).toBe(0);
});
it("does not duplicate entries already in the pool", () => {
mockedExistsSync.mockReturnValue(true);
pool.release("/tmp/existing");
pool.rehydrate(["/tmp/existing", "/tmp/new"]);
expect(pool.size).toBe(2);
});
});
});
// ── Helper for mock store ─────────────────────────────────────────────
function makeTask(id: string, column: Column, worktree?: string): Task {
return {
id,
title: `Task ${id}`,
description: `Description for ${id}`,
column,
dependencies: [],
worktree,
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
}
function createMockStore(tasks: Task[] = []) {
return {
listTasks: vi.fn().mockResolvedValue(tasks),
} as any;
}
function makeDirEntry(name: string) {
return { name, isDirectory: () => true } as any;
}
// ── scanIdleWorktrees tests ───────────────────────────────────────────
describe("scanIdleWorktrees", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
});
it("correctly identifies idle vs active worktrees", async () => {
mockedReaddirSync.mockReturnValue([
makeDirEntry("swift-falcon"),
makeDirEntry("calm-river"),
makeDirEntry("bold-eagle"),
] as any);
const store = createMockStore([
makeTask("KB-001", "in-progress", "/root/.worktrees/swift-falcon"),
makeTask("KB-002", "done", "/root/.worktrees/calm-river"),
]);
const idle = await scanIdleWorktrees("/root", store);
// swift-falcon is assigned to in-progress task → NOT idle
// calm-river is assigned to done task → idle (done tasks don't count)
// bold-eagle is not assigned at all → idle
expect(idle).toContain("/root/.worktrees/calm-river");
expect(idle).toContain("/root/.worktrees/bold-eagle");
expect(idle).not.toContain("/root/.worktrees/swift-falcon");
});
it("handles empty .worktrees/ directory", async () => {
mockedReaddirSync.mockReturnValue([] as any);
const store = createMockStore([]);
const idle = await scanIdleWorktrees("/root", store);
expect(idle).toEqual([]);
});
it("handles missing .worktrees/ directory", async () => {
mockedExistsSync.mockReturnValue(false);
const store = createMockStore([]);
const idle = await scanIdleWorktrees("/root", store);
expect(idle).toEqual([]);
});
it("treats in-review tasks as active (worktree preserved)", async () => {
mockedReaddirSync.mockReturnValue([
makeDirEntry("review-wt"),
] as any);
const store = createMockStore([
makeTask("KB-010", "in-review", "/root/.worktrees/review-wt"),
]);
const idle = await scanIdleWorktrees("/root", store);
expect(idle).not.toContain("/root/.worktrees/review-wt");
});
it("returns all worktrees when no tasks exist", async () => {
mockedReaddirSync.mockReturnValue([
makeDirEntry("wt-1"),
makeDirEntry("wt-2"),
] as any);
const store = createMockStore([]);
const idle = await scanIdleWorktrees("/root", store);
expect(idle).toHaveLength(2);
expect(idle).toContain("/root/.worktrees/wt-1");
expect(idle).toContain("/root/.worktrees/wt-2");
});
});
// ── cleanupOrphanedWorktrees tests ────────────────────────────────────
describe("cleanupOrphanedWorktrees", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
mockedExecSync.mockReturnValue(Buffer.from(""));
});
it("removes worktrees not assigned to any active task", async () => {
mockedReaddirSync.mockReturnValue([
makeDirEntry("orphan-1"),
makeDirEntry("orphan-2"),
] as any);
const store = createMockStore([]);
const cleaned = await cleanupOrphanedWorktrees("/root", store);
expect(cleaned).toBe(2);
const removeCalls = mockedExecSync.mock.calls.filter(
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree remove"),
);
expect(removeCalls).toHaveLength(2);
expect(removeCalls[0][0]).toContain("/root/.worktrees/orphan-1");
expect(removeCalls[1][0]).toContain("/root/.worktrees/orphan-2");
});
it("preserves worktrees assigned to in-progress/in-review tasks", async () => {
mockedReaddirSync.mockReturnValue([
makeDirEntry("active-wt"),
makeDirEntry("orphan-wt"),
] as any);
const store = createMockStore([
makeTask("KB-001", "in-progress", "/root/.worktrees/active-wt"),
]);
const cleaned = await cleanupOrphanedWorktrees("/root", store);
expect(cleaned).toBe(1);
const removeCalls = mockedExecSync.mock.calls.filter(
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree remove"),
);
expect(removeCalls).toHaveLength(1);
expect(removeCalls[0][0]).toContain("orphan-wt");
expect(removeCalls[0][0]).not.toContain("active-wt");
});
it("handles git worktree remove failures gracefully (non-fatal)", async () => {
mockedReaddirSync.mockReturnValue([
makeDirEntry("fail-wt"),
makeDirEntry("ok-wt"),
] as any);
mockedExecSync.mockImplementation((cmd: any) => {
if (typeof cmd === "string" && cmd.includes("fail-wt")) {
throw new Error("worktree locked");
}
return Buffer.from("");
});
const store = createMockStore([]);
const cleaned = await cleanupOrphanedWorktrees("/root", store);
// Only 1 cleaned (the other failed), but no throw
expect(cleaned).toBe(1);
});
it("no-ops when .worktrees/ doesn't exist", async () => {
mockedExistsSync.mockReturnValue(false);
const store = createMockStore([]);
const cleaned = await cleanupOrphanedWorktrees("/root", store);
expect(cleaned).toBe(0);
expect(mockedExecSync).not.toHaveBeenCalled();
});
});