feat(HAI-037): add WorktreePool for recycling idle worktrees
- Add recycleWorktrees setting to control worktree pooling behavior - Implement WorktreePool class with acquire/release/prepareForTask lifecycle - Integrate pool into executor: acquire warm worktrees, skip init command for pooled entries - Integrate pool into merger: release worktrees to pool instead of removing on task completion - Add comprehensive unit and integration tests for pool, executor, and merger interactions
This commit is contained in:
@@ -66,6 +66,10 @@ export interface Settings {
|
|||||||
testCommand?: string;
|
testCommand?: string;
|
||||||
/** Custom build command for the project (e.g. "pnpm build") */
|
/** Custom build command for the project (e.g. "pnpm build") */
|
||||||
buildCommand?: string;
|
buildCommand?: string;
|
||||||
|
/** When true, completed task worktrees are returned to an idle pool instead
|
||||||
|
* of being deleted. New tasks acquire a warm worktree from the pool,
|
||||||
|
* preserving build caches (node_modules, target/, dist/). Default: false. */
|
||||||
|
recycleWorktrees?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_SETTINGS: Settings = {
|
export const DEFAULT_SETTINGS: Settings = {
|
||||||
@@ -75,6 +79,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
groupOverlappingFiles: false,
|
groupOverlappingFiles: false,
|
||||||
autoMerge: false,
|
autoMerge: false,
|
||||||
worktreeInitCommand: undefined,
|
worktreeInitCommand: undefined,
|
||||||
|
recycleWorktrees: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface BoardConfig {
|
export interface BoardConfig {
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ vi.mock("node:fs", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { TaskExecutor } from "./executor.js";
|
import { TaskExecutor } from "./executor.js";
|
||||||
|
import { aiMergeTask } from "./merger.js";
|
||||||
|
import { WorktreePool } from "./worktree-pool.js";
|
||||||
import { createHaiAgent } from "./pi.js";
|
import { createHaiAgent } from "./pi.js";
|
||||||
import { execSync } from "node:child_process";
|
import { execSync } from "node:child_process";
|
||||||
import { findWorktreeUser } from "./merger.js";
|
import { findWorktreeUser } from "./merger.js";
|
||||||
@@ -316,27 +318,24 @@ describe("TaskExecutor worktreeInitCommand", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const mockedFindWorktreeUser = vi.mocked(findWorktreeUser);
|
describe("TaskExecutor worktree pool integration", () => {
|
||||||
|
const makeTask = (id = "HAI-020") => ({
|
||||||
describe("TaskExecutor worktree reuse", () => {
|
id,
|
||||||
const makeTask = (overrides: Partial<Task> = {}): Task => ({
|
title: "Test",
|
||||||
id: "HAI-020",
|
|
||||||
title: "Dependent task",
|
|
||||||
description: "Test",
|
description: "Test",
|
||||||
column: "in-progress",
|
column: "in-progress" as const,
|
||||||
dependencies: [],
|
dependencies: [],
|
||||||
steps: [],
|
steps: [],
|
||||||
currentStep: 0,
|
currentStep: 0,
|
||||||
log: [],
|
log: [],
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
...overrides,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
// Default: worktree does NOT exist (new worktree)
|
||||||
mockedExistsSync.mockReturnValue(false);
|
mockedExistsSync.mockReturnValue(false);
|
||||||
mockedFindWorktreeUser.mockResolvedValue(null);
|
|
||||||
mockedCreateHaiAgent.mockResolvedValue({
|
mockedCreateHaiAgent.mockResolvedValue({
|
||||||
session: {
|
session: {
|
||||||
prompt: vi.fn().mockResolvedValue(undefined),
|
prompt: vi.fn().mockResolvedValue(undefined),
|
||||||
@@ -345,218 +344,248 @@ describe("TaskExecutor worktree reuse", () => {
|
|||||||
} as any);
|
} as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reuses dependency worktree when dep has existing worktree on disk", async () => {
|
it("acquires from pool when recycleWorktrees is true and pool has idle worktrees", async () => {
|
||||||
|
const pool = new WorktreePool();
|
||||||
|
pool.release("/tmp/test/.worktrees/idle-wt");
|
||||||
|
// Pool path exists on disk, task worktree path does not (not a resume)
|
||||||
|
mockedExistsSync.mockImplementation(
|
||||||
|
(p) => p === "/tmp/test/.worktrees/idle-wt",
|
||||||
|
);
|
||||||
|
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const depWorktreePath = "/tmp/test/.worktrees/HAI-019";
|
|
||||||
|
|
||||||
// Dep task is in-review with an existing worktree
|
|
||||||
store.listTasks.mockResolvedValue([
|
|
||||||
makeTask({
|
|
||||||
id: "HAI-019",
|
|
||||||
column: "in-review",
|
|
||||||
worktree: depWorktreePath,
|
|
||||||
dependencies: [],
|
|
||||||
}),
|
|
||||||
makeTask({
|
|
||||||
id: "HAI-020",
|
|
||||||
column: "in-progress",
|
|
||||||
dependencies: ["HAI-019"],
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
// existsSync: dep worktree exists on disk
|
|
||||||
mockedExistsSync.mockImplementation((p: any) => {
|
|
||||||
return p === depWorktreePath;
|
|
||||||
});
|
|
||||||
|
|
||||||
const executor = new TaskExecutor(store, "/tmp/test");
|
|
||||||
await executor.execute(makeTask({ id: "HAI-020", dependencies: ["HAI-019"] }));
|
|
||||||
|
|
||||||
// Should call `git checkout -b` (reuse), NOT `git worktree add`
|
|
||||||
const checkoutCall = mockedExecSync.mock.calls.find(
|
|
||||||
(call) => typeof call[0] === "string" && call[0].includes("git checkout -b"),
|
|
||||||
);
|
|
||||||
expect(checkoutCall).toBeDefined();
|
|
||||||
expect(checkoutCall![0]).toContain("hai/hai-020");
|
|
||||||
|
|
||||||
const worktreeAddCall = mockedExecSync.mock.calls.find(
|
|
||||||
(call) => typeof call[0] === "string" && call[0].includes("git worktree add"),
|
|
||||||
);
|
|
||||||
expect(worktreeAddCall).toBeUndefined();
|
|
||||||
|
|
||||||
// Task's worktree should be set to the reused path
|
|
||||||
expect(store.updateTask).toHaveBeenCalledWith("HAI-020", { worktree: depWorktreePath });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates fresh worktree when dependency worktree does NOT exist on disk", async () => {
|
|
||||||
const store = createMockStore();
|
|
||||||
|
|
||||||
// Dep is done but worktree was removed (cleared on done)
|
|
||||||
store.listTasks.mockResolvedValue([
|
|
||||||
makeTask({ id: "HAI-019", column: "done", dependencies: [] }),
|
|
||||||
makeTask({ id: "HAI-020", column: "in-progress", dependencies: ["HAI-019"] }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
mockedExistsSync.mockReturnValue(false);
|
|
||||||
|
|
||||||
const executor = new TaskExecutor(store, "/tmp/test");
|
|
||||||
await executor.execute(makeTask({ id: "HAI-020", dependencies: ["HAI-019"] }));
|
|
||||||
|
|
||||||
// Should call `git worktree add`, NOT `git checkout -b`
|
|
||||||
const worktreeAddCall = mockedExecSync.mock.calls.find(
|
|
||||||
(call) => typeof call[0] === "string" && call[0].includes("git worktree add"),
|
|
||||||
);
|
|
||||||
expect(worktreeAddCall).toBeDefined();
|
|
||||||
|
|
||||||
const checkoutCall = mockedExecSync.mock.calls.find(
|
|
||||||
(call) => typeof call[0] === "string" && call[0].includes("git checkout -b"),
|
|
||||||
);
|
|
||||||
expect(checkoutCall).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates fresh worktree when task has NO dependencies", async () => {
|
|
||||||
const store = createMockStore();
|
|
||||||
store.listTasks.mockResolvedValue([]);
|
|
||||||
mockedExistsSync.mockReturnValue(false);
|
|
||||||
|
|
||||||
const executor = new TaskExecutor(store, "/tmp/test");
|
|
||||||
await executor.execute(makeTask({ id: "HAI-020", dependencies: [] }));
|
|
||||||
|
|
||||||
const worktreeAddCall = mockedExecSync.mock.calls.find(
|
|
||||||
(call) => typeof call[0] === "string" && call[0].includes("git worktree add"),
|
|
||||||
);
|
|
||||||
expect(worktreeAddCall).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does NOT run worktreeInitCommand when reusing a worktree", async () => {
|
|
||||||
const store = createMockStore();
|
|
||||||
const depWorktreePath = "/tmp/test/.worktrees/HAI-019";
|
|
||||||
|
|
||||||
store.listTasks.mockResolvedValue([
|
|
||||||
makeTask({
|
|
||||||
id: "HAI-019",
|
|
||||||
column: "in-review",
|
|
||||||
worktree: depWorktreePath,
|
|
||||||
dependencies: [],
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
store.getSettings.mockResolvedValue({
|
store.getSettings.mockResolvedValue({
|
||||||
maxConcurrent: 2,
|
maxConcurrent: 2,
|
||||||
maxWorktrees: 4,
|
maxWorktrees: 4,
|
||||||
pollIntervalMs: 15000,
|
pollIntervalMs: 15000,
|
||||||
groupOverlappingFiles: false,
|
groupOverlappingFiles: false,
|
||||||
autoMerge: false,
|
autoMerge: false,
|
||||||
|
recycleWorktrees: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const executor = new TaskExecutor(store, "/tmp/test", { pool });
|
||||||
|
await executor.execute(makeTask());
|
||||||
|
|
||||||
|
// Should NOT call git worktree add (no fresh worktree)
|
||||||
|
const worktreeAddCalls = mockedExecSync.mock.calls.filter(
|
||||||
|
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree add"),
|
||||||
|
);
|
||||||
|
expect(worktreeAddCalls).toHaveLength(0);
|
||||||
|
|
||||||
|
// Should log pool acquisition
|
||||||
|
expect(store.logEntry).toHaveBeenCalledWith(
|
||||||
|
"HAI-020",
|
||||||
|
expect.stringContaining("Acquired worktree from pool"),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Pool should be empty after acquire
|
||||||
|
expect(pool.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates fresh worktree when pool is empty", async () => {
|
||||||
|
const pool = new WorktreePool();
|
||||||
|
// Pool is empty
|
||||||
|
|
||||||
|
const store = createMockStore();
|
||||||
|
store.getSettings.mockResolvedValue({
|
||||||
|
maxConcurrent: 2,
|
||||||
|
maxWorktrees: 4,
|
||||||
|
pollIntervalMs: 15000,
|
||||||
|
groupOverlappingFiles: false,
|
||||||
|
autoMerge: false,
|
||||||
|
recycleWorktrees: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const executor = new TaskExecutor(store, "/tmp/test", { pool });
|
||||||
|
await executor.execute(makeTask());
|
||||||
|
|
||||||
|
// Should call git worktree add (fresh worktree)
|
||||||
|
const worktreeAddCalls = mockedExecSync.mock.calls.filter(
|
||||||
|
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree add"),
|
||||||
|
);
|
||||||
|
expect(worktreeAddCalls.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Should log worktree creation, NOT pool acquisition
|
||||||
|
expect(store.logEntry).toHaveBeenCalledWith(
|
||||||
|
"HAI-020",
|
||||||
|
expect.stringContaining("Worktree created at"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips worktree init command for pooled worktrees", async () => {
|
||||||
|
const pool = new WorktreePool();
|
||||||
|
pool.release("/tmp/test/.worktrees/warm-wt");
|
||||||
|
// Pool path exists on disk, task worktree path does not
|
||||||
|
mockedExistsSync.mockImplementation(
|
||||||
|
(p) => p === "/tmp/test/.worktrees/warm-wt",
|
||||||
|
);
|
||||||
|
|
||||||
|
const store = createMockStore();
|
||||||
|
store.getSettings.mockResolvedValue({
|
||||||
|
maxConcurrent: 2,
|
||||||
|
maxWorktrees: 4,
|
||||||
|
pollIntervalMs: 15000,
|
||||||
|
groupOverlappingFiles: false,
|
||||||
|
autoMerge: false,
|
||||||
|
recycleWorktrees: true,
|
||||||
worktreeInitCommand: "pnpm install",
|
worktreeInitCommand: "pnpm install",
|
||||||
});
|
});
|
||||||
|
|
||||||
mockedExistsSync.mockImplementation((p: any) => p === depWorktreePath);
|
const executor = new TaskExecutor(store, "/tmp/test", { pool });
|
||||||
|
await executor.execute(makeTask());
|
||||||
|
|
||||||
const executor = new TaskExecutor(store, "/tmp/test");
|
// "pnpm install" should NOT have been called (pooled worktree has warm cache)
|
||||||
await executor.execute(makeTask({ id: "HAI-020", dependencies: ["HAI-019"] }));
|
const initCalls = mockedExecSync.mock.calls.filter(
|
||||||
|
(c) => c[0] === "pnpm install",
|
||||||
// Init command should NOT have been run
|
|
||||||
const initCall = mockedExecSync.mock.calls.find(
|
|
||||||
(call) => call[0] === "pnpm install",
|
|
||||||
);
|
);
|
||||||
expect(initCall).toBeUndefined();
|
expect(initCalls).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolveDependencyWorktree picks first dependency with existing worktree", async () => {
|
it("does not use pool when recycleWorktrees is false", async () => {
|
||||||
|
const pool = new WorktreePool();
|
||||||
|
pool.release("/tmp/test/.worktrees/idle-wt");
|
||||||
|
|
||||||
const store = createMockStore();
|
const store = createMockStore();
|
||||||
const depAPath = "/tmp/test/.worktrees/HAI-018";
|
// recycleWorktrees defaults to false
|
||||||
const depBPath = "/tmp/test/.worktrees/HAI-019";
|
|
||||||
|
|
||||||
// Two deps: A has no worktree on disk, B does
|
const executor = new TaskExecutor(store, "/tmp/test", { pool });
|
||||||
store.listTasks.mockResolvedValue([
|
await executor.execute(makeTask());
|
||||||
makeTask({ id: "HAI-018", column: "in-review" as Column, worktree: depAPath, dependencies: [] }),
|
|
||||||
makeTask({ id: "HAI-019", column: "in-review" as Column, worktree: depBPath, dependencies: [] }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
mockedExistsSync.mockImplementation((p: any) => p === depBPath);
|
// Should create a fresh worktree, NOT acquire from pool
|
||||||
|
const worktreeAddCalls = mockedExecSync.mock.calls.filter(
|
||||||
const executor = new TaskExecutor(store, "/tmp/test");
|
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree add"),
|
||||||
await executor.execute(makeTask({ id: "HAI-020", dependencies: ["HAI-018", "HAI-019"] }));
|
|
||||||
|
|
||||||
// Should reuse HAI-019's worktree (the one that exists)
|
|
||||||
expect(store.updateTask).toHaveBeenCalledWith("HAI-020", { worktree: depBPath });
|
|
||||||
|
|
||||||
const checkoutCall = mockedExecSync.mock.calls.find(
|
|
||||||
(call) => typeof call[0] === "string" && call[0].includes("git checkout -b"),
|
|
||||||
);
|
);
|
||||||
expect(checkoutCall).toBeDefined();
|
expect(worktreeAddCalls.length).toBeGreaterThan(0);
|
||||||
expect(checkoutCall![1]).toMatchObject({ cwd: depBPath });
|
|
||||||
|
// Pool should still have the entry (not acquired)
|
||||||
|
expect(pool.size).toBe(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("TaskExecutor cleanup — chain-aware", () => {
|
describe("WorktreePool capacity", () => {
|
||||||
|
it("pool does not enforce maxWorktrees — scheduler is the capacity gatekeeper", () => {
|
||||||
|
const pool = new WorktreePool();
|
||||||
|
pool.release("/tmp/a");
|
||||||
|
pool.release("/tmp/b");
|
||||||
|
pool.release("/tmp/c");
|
||||||
|
pool.release("/tmp/d");
|
||||||
|
pool.release("/tmp/e");
|
||||||
|
expect(pool.size).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Merger worktree pool integration", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockedExistsSync.mockReturnValue(false);
|
});
|
||||||
mockedFindWorktreeUser.mockResolvedValue(null);
|
|
||||||
|
function createMergerMockStore(overrides: Record<string, any> = {}) {
|
||||||
|
const listeners = new Map<string, Function[]>();
|
||||||
|
return {
|
||||||
|
on: vi.fn((event: string, fn: Function) => {
|
||||||
|
const existing = listeners.get(event) || [];
|
||||||
|
existing.push(fn);
|
||||||
|
listeners.set(event, existing);
|
||||||
|
}),
|
||||||
|
emit: vi.fn(),
|
||||||
|
getTask: vi.fn().mockResolvedValue({
|
||||||
|
id: "HAI-050",
|
||||||
|
title: "Test merge",
|
||||||
|
description: "Test",
|
||||||
|
column: "in-review",
|
||||||
|
dependencies: [],
|
||||||
|
worktree: "/tmp/test/.worktrees/HAI-050",
|
||||||
|
steps: [],
|
||||||
|
currentStep: 0,
|
||||||
|
log: [],
|
||||||
|
prompt: "",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
updateTask: vi.fn().mockResolvedValue({}),
|
||||||
|
moveTask: vi.fn().mockResolvedValue({
|
||||||
|
id: "HAI-050",
|
||||||
|
column: "done",
|
||||||
|
dependencies: [],
|
||||||
|
steps: [],
|
||||||
|
log: [],
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}),
|
||||||
|
logEntry: vi.fn(),
|
||||||
|
getSettings: vi.fn().mockResolvedValue({
|
||||||
|
maxConcurrent: 2,
|
||||||
|
maxWorktrees: 4,
|
||||||
|
pollIntervalMs: 15000,
|
||||||
|
groupOverlappingFiles: false,
|
||||||
|
autoMerge: false,
|
||||||
|
recycleWorktrees: false,
|
||||||
|
...overrides,
|
||||||
|
}),
|
||||||
|
} as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockMergerExecSync(cmd: any, opts?: any): any {
|
||||||
|
const s = typeof cmd === "string" ? cmd : "";
|
||||||
|
const isString = opts?.encoding === "utf-8";
|
||||||
|
if (s.includes("rev-parse --verify")) return isString ? "abc123" : Buffer.from("abc123");
|
||||||
|
if (s.includes("git log")) return isString ? "- test commit" : Buffer.from("- test commit");
|
||||||
|
if (s.includes("git diff") && s.includes("--stat")) return isString ? "file.ts | 5 +++++" : Buffer.from("file.ts | 5 +++++");
|
||||||
|
if (s.includes("diff --cached --quiet")) return isString ? "0" : Buffer.from("0");
|
||||||
|
if (s.includes("diff --name-only --diff-filter=U")) return isString ? "" : Buffer.from("");
|
||||||
|
return isString ? "" : Buffer.from("");
|
||||||
|
}
|
||||||
|
|
||||||
|
it("releases worktree to pool instead of removing when recycleWorktrees is true", async () => {
|
||||||
|
const pool = new WorktreePool();
|
||||||
|
const store = createMergerMockStore({ recycleWorktrees: true });
|
||||||
|
mockedExistsSync.mockReturnValue(true);
|
||||||
|
|
||||||
|
mockedExecSync.mockImplementation(mockMergerExecSync);
|
||||||
|
|
||||||
mockedCreateHaiAgent.mockResolvedValue({
|
mockedCreateHaiAgent.mockResolvedValue({
|
||||||
session: {
|
session: {
|
||||||
prompt: vi.fn().mockResolvedValue(undefined),
|
prompt: vi.fn().mockResolvedValue(undefined),
|
||||||
dispose: vi.fn(),
|
dispose: vi.fn(),
|
||||||
},
|
},
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
|
const result = await aiMergeTask(store, "/tmp/test", "HAI-050", { pool });
|
||||||
|
|
||||||
|
// Worktree should be in the pool, NOT removed
|
||||||
|
expect(pool.has("/tmp/test/.worktrees/HAI-050")).toBe(true);
|
||||||
|
expect(result.worktreeRemoved).toBe(false);
|
||||||
|
|
||||||
|
// git worktree remove should NOT have been called
|
||||||
|
const removeCalls = mockedExecSync.mock.calls.filter(
|
||||||
|
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree remove"),
|
||||||
|
);
|
||||||
|
expect(removeCalls).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does NOT remove worktree if another task still uses it", async () => {
|
it("removes worktree normally when recycleWorktrees is false", async () => {
|
||||||
const store = createMockStore();
|
const pool = new WorktreePool();
|
||||||
mockedFindWorktreeUser.mockResolvedValue("HAI-021");
|
const store = createMergerMockStore({ recycleWorktrees: false });
|
||||||
|
mockedExistsSync.mockReturnValue(true);
|
||||||
|
|
||||||
const executor = new TaskExecutor(store, "/tmp/test");
|
mockedExecSync.mockImplementation(mockMergerExecSync);
|
||||||
|
|
||||||
// Execute a task to register a worktree
|
mockedCreateHaiAgent.mockResolvedValue({
|
||||||
mockedExistsSync.mockReturnValue(false);
|
session: {
|
||||||
await executor.execute({
|
prompt: vi.fn().mockResolvedValue(undefined),
|
||||||
id: "HAI-020",
|
dispose: vi.fn(),
|
||||||
title: "Test",
|
},
|
||||||
description: "Test",
|
} as any);
|
||||||
column: "in-progress" as const,
|
|
||||||
dependencies: [],
|
|
||||||
steps: [],
|
|
||||||
currentStep: 0,
|
|
||||||
log: [],
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Now cleanup — should skip removal because findWorktreeUser returns "HAI-021"
|
const result = await aiMergeTask(store, "/tmp/test", "HAI-050", { pool });
|
||||||
await executor.cleanup("HAI-020");
|
|
||||||
|
|
||||||
const removeCall = mockedExecSync.mock.calls.find(
|
// Worktree should NOT be in the pool
|
||||||
(call) => typeof call[0] === "string" && call[0].includes("git worktree remove"),
|
expect(pool.size).toBe(0);
|
||||||
|
expect(result.worktreeRemoved).toBe(true);
|
||||||
|
|
||||||
|
// git worktree remove should have been called
|
||||||
|
const removeCalls = mockedExecSync.mock.calls.filter(
|
||||||
|
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree remove"),
|
||||||
);
|
);
|
||||||
expect(removeCall).toBeUndefined();
|
expect(removeCalls.length).toBeGreaterThan(0);
|
||||||
});
|
|
||||||
|
|
||||||
it("removes worktree when no other task uses it", async () => {
|
|
||||||
const store = createMockStore();
|
|
||||||
mockedFindWorktreeUser.mockResolvedValue(null);
|
|
||||||
|
|
||||||
const executor = new TaskExecutor(store, "/tmp/test");
|
|
||||||
|
|
||||||
mockedExistsSync.mockReturnValue(false);
|
|
||||||
await executor.execute({
|
|
||||||
id: "HAI-020",
|
|
||||||
title: "Test",
|
|
||||||
description: "Test",
|
|
||||||
column: "in-progress" as const,
|
|
||||||
dependencies: [],
|
|
||||||
steps: [],
|
|
||||||
currentStep: 0,
|
|
||||||
log: [],
|
|
||||||
createdAt: new Date().toISOString(),
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await executor.cleanup("HAI-020");
|
|
||||||
|
|
||||||
const removeCall = mockedExecSync.mock.calls.find(
|
|
||||||
(call) => typeof call[0] === "string" && call[0].includes("git worktree remove"),
|
|
||||||
);
|
|
||||||
expect(removeCall).toBeDefined();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { createHaiAgent } from "./pi.js";
|
|||||||
import { reviewStep } from "./reviewer.js";
|
import { reviewStep } from "./reviewer.js";
|
||||||
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||||
import type { AgentSemaphore } from "./concurrency.js";
|
import type { AgentSemaphore } from "./concurrency.js";
|
||||||
|
import type { WorktreePool } from "./worktree-pool.js";
|
||||||
|
|
||||||
const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"];
|
const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"];
|
||||||
|
|
||||||
@@ -119,6 +120,8 @@ Call \`task_done()\` to signal completion.`;
|
|||||||
|
|
||||||
export interface TaskExecutorOptions {
|
export interface TaskExecutorOptions {
|
||||||
semaphore?: AgentSemaphore;
|
semaphore?: AgentSemaphore;
|
||||||
|
/** Worktree pool for recycling idle worktrees across tasks. */
|
||||||
|
pool?: WorktreePool;
|
||||||
onStart?: (task: Task, worktreePath: string) => void;
|
onStart?: (task: Task, worktreePath: string) => void;
|
||||||
onComplete?: (task: Task) => void;
|
onComplete?: (task: Task) => void;
|
||||||
onError?: (task: Task, error: Error) => void;
|
onError?: (task: Task, error: Error) => void;
|
||||||
@@ -167,42 +170,16 @@ export class TaskExecutor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find a dependency task that has an existing worktree directory on disk.
|
* Execute a task in an isolated git worktree.
|
||||||
* Returns the worktree path if found, null otherwise.
|
*
|
||||||
* Prefers dependencies whose worktree is still on disk so build caches
|
* Worktree acquisition flow:
|
||||||
* (node_modules, target/, dist/) can be reused by dependent tasks.
|
* 1. If the worktree already exists on disk (resume after crash), reuse it.
|
||||||
|
* 2. If a {@link WorktreePool} is provided and `recycleWorktrees` is enabled,
|
||||||
|
* attempt to acquire a warm worktree from the pool. Pooled worktrees skip
|
||||||
|
* the `worktreeInitCommand` since their build caches are already warm.
|
||||||
|
* 3. Otherwise, create a fresh worktree via `git worktree add` and run the
|
||||||
|
* `worktreeInitCommand` if configured.
|
||||||
*/
|
*/
|
||||||
private resolveDependencyWorktree(task: Task, allTasks: Task[]): string | null {
|
|
||||||
if (task.dependencies.length === 0) return null;
|
|
||||||
|
|
||||||
for (const depId of task.dependencies) {
|
|
||||||
const dep = allTasks.find((t) => t.id === depId);
|
|
||||||
if (
|
|
||||||
dep &&
|
|
||||||
dep.worktree &&
|
|
||||||
(dep.column === "done" || dep.column === "in-review") &&
|
|
||||||
existsSync(dep.worktree)
|
|
||||||
) {
|
|
||||||
return dep.worktree;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reuse an existing worktree directory from a dependency task.
|
|
||||||
* Instead of creating a new worktree with `git worktree add`, this creates
|
|
||||||
* a new branch in the existing worktree via `git checkout -b`. The worktree
|
|
||||||
* directory (and its build caches) are preserved.
|
|
||||||
*/
|
|
||||||
private reuseWorktree(branch: string, worktreePath: string): void {
|
|
||||||
execSync(`git checkout -b "${branch}"`, {
|
|
||||||
cwd: worktreePath,
|
|
||||||
stdio: "pipe",
|
|
||||||
});
|
|
||||||
console.log(`[executor] Reused worktree at ${worktreePath}, created branch ${branch}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async execute(task: Task): Promise<void> {
|
async execute(task: Task): Promise<void> {
|
||||||
if (this.executing.has(task.id)) return;
|
if (this.executing.has(task.id)) return;
|
||||||
this.executing.add(task.id);
|
this.executing.add(task.id);
|
||||||
@@ -222,58 +199,56 @@ export class TaskExecutor {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if a dependency has a reusable worktree (warm cache)
|
// Create or reuse worktree — try pool first when recycling is enabled
|
||||||
const depWorktree = this.resolveDependencyWorktree(task, allTasks);
|
|
||||||
|
|
||||||
// Create or reuse worktree
|
|
||||||
const branchName = `hai/${task.id.toLowerCase()}`;
|
const branchName = `hai/${task.id.toLowerCase()}`;
|
||||||
let worktreePath: string;
|
let worktreePath = task.worktree || join(this.rootDir, ".worktrees", task.id);
|
||||||
let isResume: boolean;
|
const isResume = existsSync(worktreePath);
|
||||||
let isReuse: boolean;
|
let acquiredFromPool = false;
|
||||||
|
|
||||||
if (depWorktree && !task.worktree) {
|
if (!isResume) {
|
||||||
// Reuse dependency worktree for warm build cache
|
const settings = await this.store.getSettings();
|
||||||
worktreePath = depWorktree;
|
|
||||||
isResume = false;
|
// Try acquiring a warm worktree from the pool
|
||||||
isReuse = true;
|
if (this.options.pool && settings.recycleWorktrees) {
|
||||||
const depTask = allTasks.find((t) => t.worktree === depWorktree);
|
const pooled = this.options.pool.acquire();
|
||||||
const depId = depTask?.id ?? "unknown";
|
if (pooled) {
|
||||||
console.log(`[executor] Reusing worktree from ${depId} at ${depWorktree} (warm cache)`);
|
this.options.pool.prepareForTask(pooled, branchName);
|
||||||
this.reuseWorktree(branchName, worktreePath);
|
worktreePath = pooled;
|
||||||
|
acquiredFromPool = true;
|
||||||
|
console.log(`[executor] Acquired worktree from pool: ${pooled}`);
|
||||||
|
await this.store.updateTask(task.id, { worktree: worktreePath });
|
||||||
|
await this.store.logEntry(task.id, `Acquired worktree from pool: ${worktreePath}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall through to fresh worktree creation if pool had nothing
|
||||||
|
if (!acquiredFromPool) {
|
||||||
|
this.createWorktree(branchName, worktreePath);
|
||||||
|
await this.store.updateTask(task.id, { worktree: worktreePath });
|
||||||
|
await this.store.logEntry(task.id, `Worktree created at ${worktreePath}`);
|
||||||
|
|
||||||
|
// Run worktree init command for fresh worktrees (skip for pooled — caches are warm)
|
||||||
|
if (settings.worktreeInitCommand) {
|
||||||
|
try {
|
||||||
|
execSync(settings.worktreeInitCommand, {
|
||||||
|
cwd: worktreePath,
|
||||||
|
stdio: "pipe",
|
||||||
|
timeout: 120_000,
|
||||||
|
});
|
||||||
|
await this.store.logEntry(task.id, "Worktree init command completed", settings.worktreeInitCommand);
|
||||||
|
} catch (err: any) {
|
||||||
|
const message = err.stderr?.toString() || err.message || "Unknown error";
|
||||||
|
await this.store.logEntry(task.id, `Worktree init command failed: ${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
worktreePath = task.worktree || join(this.rootDir, ".worktrees", task.id);
|
// Resume: worktree already exists, just ensure git worktree is registered
|
||||||
isResume = existsSync(worktreePath);
|
|
||||||
isReuse = false;
|
|
||||||
this.createWorktree(branchName, worktreePath);
|
this.createWorktree(branchName, worktreePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.activeWorktrees.set(task.id, worktreePath);
|
this.activeWorktrees.set(task.id, worktreePath);
|
||||||
|
|
||||||
if (!isResume && !isReuse) {
|
|
||||||
await this.store.updateTask(task.id, { worktree: worktreePath });
|
|
||||||
await this.store.logEntry(task.id, `Worktree created at ${worktreePath}`);
|
|
||||||
|
|
||||||
// Run worktree init command if configured
|
|
||||||
const settings = await this.store.getSettings();
|
|
||||||
if (settings.worktreeInitCommand) {
|
|
||||||
try {
|
|
||||||
execSync(settings.worktreeInitCommand, {
|
|
||||||
cwd: worktreePath,
|
|
||||||
stdio: "pipe",
|
|
||||||
timeout: 120_000,
|
|
||||||
});
|
|
||||||
await this.store.logEntry(task.id, "Worktree init command completed", settings.worktreeInitCommand);
|
|
||||||
} catch (err: any) {
|
|
||||||
const message = err.stderr?.toString() || err.message || "Unknown error";
|
|
||||||
await this.store.logEntry(task.id, `Worktree init command failed: ${message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (isReuse) {
|
|
||||||
// Update task's worktree field to point to the reused directory
|
|
||||||
await this.store.updateTask(task.id, { worktree: worktreePath });
|
|
||||||
await this.store.logEntry(task.id, `Reusing worktree at ${worktreePath} (warm cache)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.options.onStart?.(task, worktreePath);
|
this.options.onStart?.(task, worktreePath);
|
||||||
|
|
||||||
const detail = await this.store.getTask(task.id);
|
const detail = await this.store.getTask(task.id);
|
||||||
|
|||||||
@@ -5,3 +5,4 @@ export { Scheduler, type SchedulerOptions } from "./scheduler.js";
|
|||||||
export { aiMergeTask, type MergerOptions } from "./merger.js";
|
export { aiMergeTask, type MergerOptions } from "./merger.js";
|
||||||
export { reviewStep, type ReviewType, type ReviewVerdict, type ReviewResult, type ReviewOptions } from "./reviewer.js";
|
export { reviewStep, type ReviewType, type ReviewVerdict, type ReviewResult, type ReviewOptions } from "./reviewer.js";
|
||||||
export { createHaiAgent, type AgentOptions, type AgentResult } from "./pi.js";
|
export { createHaiAgent, type AgentOptions, type AgentResult } from "./pi.js";
|
||||||
|
export { WorktreePool } from "./worktree-pool.js";
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { join } from "node:path";
|
|||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import type { TaskStore, Task, MergeResult } from "@hai/core";
|
import type { TaskStore, Task, MergeResult } from "@hai/core";
|
||||||
import { createHaiAgent } from "./pi.js";
|
import { createHaiAgent } from "./pi.js";
|
||||||
|
import type { WorktreePool } from "./worktree-pool.js";
|
||||||
|
|
||||||
const MERGE_SYSTEM_PROMPT = `You are a merge agent for "hai", an AI-orchestrated task board.
|
const MERGE_SYSTEM_PROMPT = `You are a merge agent for "hai", an AI-orchestrated task board.
|
||||||
|
|
||||||
@@ -70,11 +71,20 @@ export interface MergerOptions {
|
|||||||
onAgentText?: (delta: string) => void;
|
onAgentText?: (delta: string) => void;
|
||||||
/** Called with agent tool usage */
|
/** Called with agent tool usage */
|
||||||
onAgentTool?: (toolName: string) => void;
|
onAgentTool?: (toolName: string) => void;
|
||||||
|
/** Worktree pool — when provided and `recycleWorktrees` is enabled,
|
||||||
|
* worktrees are released to the pool instead of being removed. */
|
||||||
|
pool?: WorktreePool;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI-powered merge: resolves conflicts with a pi agent and
|
* AI-powered merge: resolves conflicts with a pi agent and
|
||||||
* writes a commit message that summarizes the branch's work.
|
* writes a commit message that summarizes the branch's work.
|
||||||
|
*
|
||||||
|
* When `options.pool` is provided and `recycleWorktrees` is enabled in
|
||||||
|
* settings, the worktree is detached from its branch and released to the
|
||||||
|
* idle pool instead of being removed. The task's branch is always deleted
|
||||||
|
* regardless of pooling. On next task execution, the pooled worktree will
|
||||||
|
* be acquired and prepared with a fresh branch via {@link WorktreePool.prepareForTask}.
|
||||||
*/
|
*/
|
||||||
export async function aiMergeTask(
|
export async function aiMergeTask(
|
||||||
store: TaskStore,
|
store: TaskStore,
|
||||||
@@ -211,23 +221,17 @@ export async function aiMergeTask(
|
|||||||
session.dispose();
|
session.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Delete branch (always per-task, regardless of worktree sharing)
|
// 7. Clean up worktree — release to pool if recycling is enabled
|
||||||
try {
|
|
||||||
execSync(`git branch -d "${branch}"`, { cwd: rootDir, stdio: "pipe" });
|
|
||||||
result.branchDeleted = true;
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
execSync(`git branch -D "${branch}"`, { cwd: rootDir, stdio: "pipe" });
|
|
||||||
result.branchDeleted = true;
|
|
||||||
} catch { /* non-fatal */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8. Clean up worktree — only if no other non-done task still references it
|
|
||||||
if (existsSync(worktreePath)) {
|
if (existsSync(worktreePath)) {
|
||||||
const otherUser = await findWorktreeUser(store, worktreePath, taskId);
|
const settings = await store.getSettings();
|
||||||
if (otherUser) {
|
if (options.pool && settings.recycleWorktrees) {
|
||||||
console.log(`[merger] Worktree retained — still needed by ${otherUser}`);
|
// Detach HEAD so the task branch can be deleted in step 8
|
||||||
|
try {
|
||||||
|
execSync("git checkout --detach", { cwd: worktreePath, stdio: "pipe" });
|
||||||
|
} catch { /* non-fatal — prepareForTask will reset HEAD on next acquire */ }
|
||||||
|
options.pool.release(worktreePath);
|
||||||
result.worktreeRemoved = false;
|
result.worktreeRemoved = false;
|
||||||
|
console.log(`[merger] Worktree returned to pool: ${worktreePath}`);
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
execSync(`git worktree remove "${worktreePath}" --force`, {
|
execSync(`git worktree remove "${worktreePath}" --force`, {
|
||||||
@@ -239,6 +243,17 @@ export async function aiMergeTask(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 8. Delete branch
|
||||||
|
try {
|
||||||
|
execSync(`git branch -d "${branch}"`, { cwd: rootDir, stdio: "pipe" });
|
||||||
|
result.branchDeleted = true;
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
execSync(`git branch -D "${branch}"`, { cwd: rootDir, stdio: "pipe" });
|
||||||
|
result.branchDeleted = true;
|
||||||
|
} catch { /* non-fatal */ }
|
||||||
|
}
|
||||||
|
|
||||||
// 9. Move task to done
|
// 9. Move task to done
|
||||||
await completeTask(store, taskId, result);
|
await completeTask(store, taskId, result);
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
164
packages/engine/src/worktree-pool.test.ts
Normal file
164
packages/engine/src/worktree-pool.test.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
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),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { WorktreePool } from "./worktree-pool.js";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
|
||||||
|
const mockedExecSync = vi.mocked(execSync);
|
||||||
|
const mockedExistsSync = vi.mocked(existsSync);
|
||||||
|
|
||||||
|
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", "hai/hai-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", "hai/hai-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 "hai/hai-042" main');
|
||||||
|
expect(checkoutCall![1]).toMatchObject({ cwd: "/tmp/wt" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs all commands in the correct worktree directory", () => {
|
||||||
|
pool.prepareForTask("/tmp/my-worktree", "hai/hai-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", "hai/hai-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 "hai/hai-001" main');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
103
packages/engine/src/worktree-pool.ts
Normal file
103
packages/engine/src/worktree-pool.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pool of idle git worktrees that can be recycled across tasks.
|
||||||
|
*
|
||||||
|
* When `recycleWorktrees` is enabled, completed task worktrees are returned
|
||||||
|
* to this pool instead of being deleted. New tasks acquire a warm worktree
|
||||||
|
* from the pool, preserving build caches (node_modules, target/, dist/).
|
||||||
|
*
|
||||||
|
* The pool only tracks *idle* worktrees — those not currently assigned to
|
||||||
|
* any active task. The scheduler's `maxWorktrees` setting still governs
|
||||||
|
* the total number of worktrees (active + idle).
|
||||||
|
*/
|
||||||
|
export class WorktreePool {
|
||||||
|
private idle = new Set<string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acquire an idle worktree from the pool.
|
||||||
|
*
|
||||||
|
* Returns the absolute path of an idle worktree, or `null` if the pool
|
||||||
|
* is empty. Before returning, verifies the directory still exists on disk
|
||||||
|
* and prunes any stale entries.
|
||||||
|
*/
|
||||||
|
acquire(): string | null {
|
||||||
|
for (const path of this.idle) {
|
||||||
|
this.idle.delete(path);
|
||||||
|
if (existsSync(path)) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
console.log(`[worktree-pool] Pruned stale entry: ${path}`);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a worktree to the idle pool after a task completes.
|
||||||
|
*
|
||||||
|
* The worktree directory is retained on disk with its build caches intact.
|
||||||
|
* Call this instead of `git worktree remove` when recycling is enabled.
|
||||||
|
*
|
||||||
|
* @param worktreePath — Absolute path to the worktree directory
|
||||||
|
*/
|
||||||
|
release(worktreePath: string): void {
|
||||||
|
this.idle.add(worktreePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Number of idle worktrees currently in the pool. */
|
||||||
|
get size(): number {
|
||||||
|
return this.idle.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check whether a specific path is in the idle pool. */
|
||||||
|
has(path: string): boolean {
|
||||||
|
return this.idle.has(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove and return all idle worktree paths.
|
||||||
|
*
|
||||||
|
* Useful for shutdown/cleanup — the caller is responsible for
|
||||||
|
* running `git worktree remove` on each returned path.
|
||||||
|
*/
|
||||||
|
drain(): string[] {
|
||||||
|
const paths = Array.from(this.idle);
|
||||||
|
this.idle.clear();
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prepare a recycled worktree for a new task.
|
||||||
|
*
|
||||||
|
* Resets the working tree to a clean state, then creates (or force-resets)
|
||||||
|
* the task's branch based on `main`. This ensures the new task starts
|
||||||
|
* from the latest main with a clean working directory, while preserving
|
||||||
|
* untracked build caches (node_modules, target/, dist/).
|
||||||
|
*
|
||||||
|
* Steps performed:
|
||||||
|
* 1. `git checkout -- .` — discard tracked file modifications
|
||||||
|
* 2. `git clean -fd` — remove untracked files (but not .gitignore'd caches)
|
||||||
|
* 3. `git checkout -B <branchName> main` — create/reset branch from main
|
||||||
|
*
|
||||||
|
* @param worktreePath — Absolute path to the recycled worktree
|
||||||
|
* @param branchName — Branch name for the new task (e.g., `hai/hai-042`)
|
||||||
|
*/
|
||||||
|
prepareForTask(worktreePath: string, branchName: string): void {
|
||||||
|
// Clean tracked modifications
|
||||||
|
try {
|
||||||
|
execSync("git checkout -- .", { cwd: worktreePath, stdio: "pipe" });
|
||||||
|
} catch {
|
||||||
|
// May fail if worktree is already clean — that's fine
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove untracked files (but not .gitignore'd build caches)
|
||||||
|
execSync("git clean -fd", { cwd: worktreePath, stdio: "pipe" });
|
||||||
|
|
||||||
|
// Create or force-reset the branch from main
|
||||||
|
execSync(`git checkout -B "${branchName}" main`, {
|
||||||
|
cwd: worktreePath,
|
||||||
|
stdio: "pipe",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user