feat(HAI-033): add dependency-chain worktree reuse and conditional cleanup

- Add executor logic to resolve and reuse dependency worktrees for warm build caches
- Add reuseWorktree method that creates a new branch in an existing worktree
- Add findWorktreeUser helper to check if a worktree is shared across tasks
- Update executor and merger cleanup to skip worktree removal when still in use
- Add comprehensive tests for worktree reuse and conditional cleanup paths
This commit is contained in:
Dustin Byrne
2026-03-25 22:51:43 -04:00
parent 2fc6cba0b5
commit b41e215246
4 changed files with 554 additions and 17 deletions

View File

@@ -8,6 +8,9 @@ vi.mock("./pi.js", () => ({
vi.mock("./reviewer.js", () => ({
reviewStep: vi.fn(),
}));
vi.mock("./merger.js", () => ({
findWorktreeUser: vi.fn().mockResolvedValue(null),
}));
// Mock node modules used by executor
vi.mock("node:child_process", () => ({
@@ -20,6 +23,8 @@ vi.mock("node:fs", () => ({
import { TaskExecutor } from "./executor.js";
import { createHaiAgent } from "./pi.js";
import { execSync } from "node:child_process";
import { findWorktreeUser } from "./merger.js";
import type { Column, Task } from "@hai/core";
const mockedCreateHaiAgent = vi.mocked(createHaiAgent);
@@ -310,3 +315,248 @@ describe("TaskExecutor worktreeInitCommand", () => {
expect(store.getSettings).not.toHaveBeenCalled();
});
});
const mockedFindWorktreeUser = vi.mocked(findWorktreeUser);
describe("TaskExecutor worktree reuse", () => {
const makeTask = (overrides: Partial<Task> = {}): Task => ({
id: "HAI-020",
title: "Dependent task",
description: "Test",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
});
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(false);
mockedFindWorktreeUser.mockResolvedValue(null);
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
});
it("reuses dependency worktree when dep has existing worktree on disk", async () => {
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({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: false,
autoMerge: false,
worktreeInitCommand: "pnpm install",
});
mockedExistsSync.mockImplementation((p: any) => p === depWorktreePath);
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute(makeTask({ id: "HAI-020", dependencies: ["HAI-019"] }));
// Init command should NOT have been run
const initCall = mockedExecSync.mock.calls.find(
(call) => call[0] === "pnpm install",
);
expect(initCall).toBeUndefined();
});
it("resolveDependencyWorktree picks first dependency with existing worktree", async () => {
const store = createMockStore();
const depAPath = "/tmp/test/.worktrees/HAI-018";
const depBPath = "/tmp/test/.worktrees/HAI-019";
// Two deps: A has no worktree on disk, B does
store.listTasks.mockResolvedValue([
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);
const executor = new TaskExecutor(store, "/tmp/test");
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(checkoutCall![1]).toMatchObject({ cwd: depBPath });
});
});
describe("TaskExecutor cleanup — chain-aware", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(false);
mockedFindWorktreeUser.mockResolvedValue(null);
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
});
it("does NOT remove worktree if another task still uses it", async () => {
const store = createMockStore();
mockedFindWorktreeUser.mockResolvedValue("HAI-021");
const executor = new TaskExecutor(store, "/tmp/test");
// Execute a task to register a worktree
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(),
});
// Now cleanup — should skip removal because findWorktreeUser returns "HAI-021"
await executor.cleanup("HAI-020");
const removeCall = mockedExecSync.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("git worktree remove"),
);
expect(removeCall).toBeUndefined();
});
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

@@ -2,6 +2,7 @@ import { execSync } from "node:child_process";
import { join } from "node:path";
import { existsSync } from "node:fs";
import type { TaskStore, Task, TaskDetail, StepStatus } from "@hai/core";
import { findWorktreeUser } from "./merger.js";
import { Type, type Static } from "@mariozechner/pi-ai";
import { createHaiAgent } from "./pi.js";
import { reviewStep } from "./reviewer.js";
@@ -165,6 +166,43 @@ export class TaskExecutor {
}
}
/**
* Find a dependency task that has an existing worktree directory on disk.
* Returns the worktree path if found, null otherwise.
* Prefers dependencies whose worktree is still on disk so build caches
* (node_modules, target/, dist/) can be reused by dependent tasks.
*/
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> {
if (this.executing.has(task.id)) return;
this.executing.add(task.id);
@@ -184,14 +222,34 @@ export class TaskExecutor {
return;
}
// Check if a dependency has a reusable worktree (warm cache)
const depWorktree = this.resolveDependencyWorktree(task, allTasks);
// Create or reuse worktree
const branchName = `hai/${task.id.toLowerCase()}`;
const worktreePath = task.worktree || join(this.rootDir, ".worktrees", task.id);
const isResume = existsSync(worktreePath);
this.createWorktree(branchName, worktreePath);
let worktreePath: string;
let isResume: boolean;
let isReuse: boolean;
if (depWorktree && !task.worktree) {
// Reuse dependency worktree for warm build cache
worktreePath = depWorktree;
isResume = false;
isReuse = true;
const depTask = allTasks.find((t) => t.worktree === depWorktree);
const depId = depTask?.id ?? "unknown";
console.log(`[executor] Reusing worktree from ${depId} at ${depWorktree} (warm cache)`);
this.reuseWorktree(branchName, worktreePath);
} else {
worktreePath = task.worktree || join(this.rootDir, ".worktrees", task.id);
isResume = existsSync(worktreePath);
isReuse = false;
this.createWorktree(branchName, worktreePath);
}
this.activeWorktrees.set(task.id, worktreePath);
if (!isResume) {
if (!isResume && !isReuse) {
await this.store.updateTask(task.id, { worktree: worktreePath });
await this.store.logEntry(task.id, `Worktree created at ${worktreePath}`);
@@ -210,6 +268,10 @@ export class TaskExecutor {
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);
@@ -457,12 +519,26 @@ export class TaskExecutor {
console.log(`[executor] Worktree created: ${path}`);
}
/**
* Remove a task's worktree, but only if no other in-progress or todo task
* shares the same worktree path (dependency-chain reuse). The branch is
* always cleaned up by the merger on a per-task basis.
*/
async cleanup(taskId: string): Promise<void> {
const worktreePath = this.activeWorktrees.get(taskId);
if (!worktreePath) return;
this.activeWorktrees.delete(taskId);
// Check if another task still needs this worktree
const otherUser = await findWorktreeUser(this.store, worktreePath, taskId);
if (otherUser) {
console.log(`[executor] Worktree retained for ${taskId} — still needed by ${otherUser}`);
return;
}
try {
execSync(`git worktree remove "${worktreePath}" --force`, { cwd: this.rootDir, stdio: "pipe" });
this.activeWorktrees.delete(taskId);
console.log(`[executor] Cleaned up worktree for ${taskId}`);
} catch (err: any) {
console.error(`[executor] Failed to clean up worktree for ${taskId}:`, err.message);

View File

@@ -0,0 +1,184 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// Mock external dependencies
vi.mock("./pi.js", () => ({
createHaiAgent: vi.fn(),
}));
vi.mock("node:child_process", () => ({
execSync: vi.fn(),
}));
vi.mock("node:fs", () => ({
existsSync: vi.fn().mockReturnValue(true),
}));
import { aiMergeTask, findWorktreeUser } from "./merger.js";
import { createHaiAgent } from "./pi.js";
import { execSync } from "node:child_process";
import type { TaskStore, Task, MergeResult } from "@hai/core";
const mockedCreateHaiAgent = vi.mocked(createHaiAgent);
const mockedExecSync = vi.mocked(execSync);
const { existsSync: mockedExistsSyncRaw } = await import("node:fs");
const mockedExistsSync = vi.mocked(mockedExistsSyncRaw);
function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = []) {
const baseTask: Task = {
id: "HAI-050",
title: "Test task",
description: "Test",
column: "in-review",
dependencies: [],
worktree: "/tmp/root/.worktrees/HAI-050",
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...taskOverrides,
};
return {
getTask: vi.fn().mockResolvedValue({ ...baseTask, prompt: "# test" }),
listTasks: vi.fn().mockResolvedValue(allTasks),
updateTask: vi.fn().mockResolvedValue(baseTask),
moveTask: vi.fn().mockResolvedValue(baseTask),
logEntry: vi.fn().mockResolvedValue(undefined),
emit: vi.fn(),
on: vi.fn(),
} as unknown as TaskStore;
}
/**
* Set up execSync to handle the standard merge flow:
* rev-parse, log, diff, merge --squash, diff --cached, branch -d
*/
function setupHappyPathExecSync() {
mockedExecSync.mockImplementation((cmd: any) => {
const cmdStr = String(cmd);
if (cmdStr.includes("rev-parse --verify")) return Buffer.from("abc123");
if (cmdStr.includes("git log")) return "- feat: something" as any;
if (cmdStr.includes("git diff") && cmdStr.includes("--stat")) return "1 file changed" as any;
if (cmdStr.includes("merge --squash")) return Buffer.from("");
if (cmdStr.includes("diff --cached")) return "0" as any;
if (cmdStr.includes("branch -d") || cmdStr.includes("branch -D")) return Buffer.from("");
if (cmdStr.includes("worktree remove")) return Buffer.from("");
return Buffer.from("");
});
}
describe("findWorktreeUser", () => {
it("returns null when no other task uses the worktree", async () => {
const store = createMockStore({}, [
{ id: "HAI-050", worktree: "/tmp/wt", column: "done" } as Task,
]);
const result = await findWorktreeUser(store, "/tmp/wt", "HAI-050");
expect(result).toBeNull();
});
it("returns task ID when another non-done task uses the worktree", async () => {
const store = createMockStore({}, [
{ id: "HAI-050", worktree: "/tmp/wt", column: "done" } as Task,
{ id: "HAI-051", worktree: "/tmp/wt", column: "in-progress" } as Task,
]);
const result = await findWorktreeUser(store, "/tmp/wt", "HAI-050");
expect(result).toBe("HAI-051");
});
it("ignores done tasks", async () => {
const store = createMockStore({}, [
{ id: "HAI-050", worktree: "/tmp/wt", column: "done" } as Task,
{ id: "HAI-051", worktree: "/tmp/wt", column: "done" } as Task,
]);
const result = await findWorktreeUser(store, "/tmp/wt", "HAI-050");
expect(result).toBeNull();
});
});
describe("aiMergeTask — conditional worktree cleanup", () => {
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(true);
setupHappyPathExecSync();
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
});
it("does NOT remove worktree when another task references the same path", async () => {
const worktreePath = "/tmp/root/.worktrees/HAI-050";
const store = createMockStore(
{ id: "HAI-050", worktree: worktreePath },
[
{ id: "HAI-050", worktree: worktreePath, column: "in-review" } as Task,
{ id: "HAI-051", worktree: worktreePath, column: "in-progress" } as Task,
],
);
const result = await aiMergeTask(store, "/tmp/root", "HAI-050");
// Worktree should NOT be removed
const removeCall = mockedExecSync.mock.calls.find(
(call) => String(call[0]).includes("worktree remove"),
);
expect(removeCall).toBeUndefined();
expect(result.worktreeRemoved).toBe(false);
});
it("removes worktree when no other task references it", async () => {
const worktreePath = "/tmp/root/.worktrees/HAI-050";
const store = createMockStore(
{ id: "HAI-050", worktree: worktreePath },
[
{ id: "HAI-050", worktree: worktreePath, column: "in-review" } as Task,
],
);
const result = await aiMergeTask(store, "/tmp/root", "HAI-050");
const removeCall = mockedExecSync.mock.calls.find(
(call) => String(call[0]).includes("worktree remove"),
);
expect(removeCall).toBeDefined();
expect(result.worktreeRemoved).toBe(true);
});
it("always deletes the branch regardless of worktree sharing", async () => {
const worktreePath = "/tmp/root/.worktrees/HAI-050";
const store = createMockStore(
{ id: "HAI-050", worktree: worktreePath },
[
{ id: "HAI-050", worktree: worktreePath, column: "in-review" } as Task,
{ id: "HAI-051", worktree: worktreePath, column: "in-progress" } as Task,
],
);
const result = await aiMergeTask(store, "/tmp/root", "HAI-050");
// Branch should be deleted even though worktree is shared
const branchDeleteCall = mockedExecSync.mock.calls.find(
(call) => String(call[0]).includes("branch -d") || String(call[0]).includes("branch -D"),
);
expect(branchDeleteCall).toBeDefined();
expect(result.branchDeleted).toBe(true);
});
it("result.worktreeRemoved is false when worktree is retained", async () => {
const worktreePath = "/tmp/root/.worktrees/HAI-050";
const store = createMockStore(
{ id: "HAI-050", worktree: worktreePath },
[
{ id: "HAI-050", worktree: worktreePath, column: "in-review" } as Task,
{ id: "HAI-051", worktree: worktreePath, column: "todo" } as Task,
],
);
const result = await aiMergeTask(store, "/tmp/root", "HAI-050");
expect(result.worktreeRemoved).toBe(false);
expect(result.merged).toBe(true);
});
});

View File

@@ -44,6 +44,27 @@ git commit -m "feat(HAI-003): add user profile page" -m "- Add /profile route wi
Do NOT use generic messages like "merge branch" or "resolve conflicts".
Base the message on the ACTUAL work done in the branch commits.`;
/**
* Check if any non-done task (other than `excludeTaskId`) references the given
* worktree path. Returns the first matching task ID, or null if the worktree
* is safe to remove. Used by both the merger and executor cleanup to avoid
* deleting worktrees that are shared across dependent tasks.
*/
export async function findWorktreeUser(
store: TaskStore,
worktreePath: string,
excludeTaskId: string,
): Promise<string | null> {
const tasks = await store.listTasks();
for (const t of tasks) {
if (t.id === excludeTaskId) continue;
if (t.worktree === worktreePath && t.column !== "done") {
return t.id;
}
}
return null;
}
export interface MergerOptions {
/** Called with agent text output */
onAgentText?: (delta: string) => void;
@@ -190,18 +211,7 @@ export async function aiMergeTask(
session.dispose();
}
// 7. Clean up worktree
if (existsSync(worktreePath)) {
try {
execSync(`git worktree remove "${worktreePath}" --force`, {
cwd: rootDir,
stdio: "pipe",
});
result.worktreeRemoved = true;
} catch { /* non-fatal */ }
}
// 8. Delete branch
// 7. Delete branch (always per-task, regardless of worktree sharing)
try {
execSync(`git branch -d "${branch}"`, { cwd: rootDir, stdio: "pipe" });
result.branchDeleted = true;
@@ -212,6 +222,23 @@ export async function aiMergeTask(
} catch { /* non-fatal */ }
}
// 8. Clean up worktree — only if no other non-done task still references it
if (existsSync(worktreePath)) {
const otherUser = await findWorktreeUser(store, worktreePath, taskId);
if (otherUser) {
console.log(`[merger] Worktree retained — still needed by ${otherUser}`);
result.worktreeRemoved = false;
} else {
try {
execSync(`git worktree remove "${worktreePath}" --force`, {
cwd: rootDir,
stdio: "pipe",
});
result.worktreeRemoved = true;
} catch { /* non-fatal */ }
}
}
// 9. Move task to done
await completeTask(store, taskId, result);
return result;