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:
Dustin Byrne
2026-03-25 23:08:04 -04:00
parent bf37701775
commit 74379bdbce
7 changed files with 572 additions and 280 deletions

View File

@@ -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 {

View File

@@ -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();
}); });
}); });

View File

@@ -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);

View File

@@ -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";

View File

@@ -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;

View 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');
});
});
});

View 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",
});
}
}