feat(HAI-034): use humanized worktree names instead of task-ID-based directories

- Add worktree-names module with adjective-animal name generator (generateWorktreeName)
- Update executor to assign humanized random worktree names and support dependency worktree reuse
- Remove task-ID fallback paths from merger and store; add shared worktree cleanup via findWorktreeUser
- Remove worktree pool module and related scheduler/integration tests
- Update dashboard worktree label tests and add JSDoc documentation for worktree naming
This commit is contained in:
Dustin Byrne
2026-03-25 23:09:25 -04:00
parent 74379bdbce
commit b9d7c89905
7 changed files with 233 additions and 36 deletions

View File

@@ -468,7 +468,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
const branch = `hai/${id.toLowerCase()}`;
const worktreePath = task.worktree || join(this.rootDir, ".worktrees", id);
const worktreePath = task.worktree;
const result: MergeResult = {
task,
branch,
@@ -519,7 +519,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
// 3. Remove worktree
if (existsSync(worktreePath)) {
if (worktreePath && existsSync(worktreePath)) {
try {
execSync(`git worktree remove "${worktreePath}" --force`, {
cwd: this.rootDir,

View File

@@ -21,24 +21,30 @@ describe("getWorktreeLabel", () => {
expect(getWorktreeLabel(".worktrees/HAI-001")).toBe("HAI-001");
expect(getWorktreeLabel("/path/to/hai/hai-001")).toBe("hai-001");
});
it("extracts humanized worktree names", () => {
expect(getWorktreeLabel(".worktrees/swirly-monkey")).toBe("swirly-monkey");
expect(getWorktreeLabel("/tmp/project/.worktrees/quiet-falcon")).toBe("quiet-falcon");
expect(getWorktreeLabel(".worktrees/bright-orchid-2")).toBe("bright-orchid-2");
});
});
describe("groupByWorktree", () => {
it("groups active in-progress tasks by worktree", () => {
const t1 = makeTask({ id: "HAI-001", worktree: ".worktrees/HAI-001" });
const t2 = makeTask({ id: "HAI-002", worktree: ".worktrees/HAI-002" });
const t1 = makeTask({ id: "HAI-001", worktree: ".worktrees/swift-falcon" });
const t2 = makeTask({ id: "HAI-002", worktree: ".worktrees/quiet-robin" });
const groups = groupByWorktree([t1, t2], [t1, t2], 2);
expect(groups).toHaveLength(2);
expect(groups[0].label).toBe("HAI-001");
expect(groups[0].label).toBe("swift-falcon");
expect(groups[0].activeTasks).toEqual([t1]);
expect(groups[1].label).toBe("HAI-002");
expect(groups[1].label).toBe("quiet-robin");
expect(groups[1].activeTasks).toEqual([t2]);
});
it("places queued tasks only in the Up Next group, never in worktree groups", () => {
const active = makeTask({ id: "HAI-001", worktree: ".worktrees/HAI-001" });
const active = makeTask({ id: "HAI-001", worktree: ".worktrees/swift-falcon" });
const queued = makeTask({
id: "HAI-002",
column: "todo",
@@ -48,7 +54,7 @@ describe("groupByWorktree", () => {
const groups = groupByWorktree([active], [active, queued], 2);
// Worktree group should have no queued tasks
const worktreeGroup = groups.find((g) => g.label === "HAI-001");
const worktreeGroup = groups.find((g) => g.label === "swift-falcon");
expect(worktreeGroup).toBeDefined();
expect(worktreeGroup!.queuedTasks).toEqual([]);
@@ -60,7 +66,7 @@ describe("groupByWorktree", () => {
});
it("does not create Up Next group when there are no eligible queued tasks", () => {
const active = makeTask({ id: "HAI-001", worktree: ".worktrees/HAI-001" });
const active = makeTask({ id: "HAI-001", worktree: ".worktrees/swift-falcon" });
const groups = groupByWorktree([active], [active], 2);
@@ -68,7 +74,7 @@ describe("groupByWorktree", () => {
});
it("does not create Up Next when queued tasks have unsatisfied dependencies", () => {
const active = makeTask({ id: "HAI-001", worktree: ".worktrees/HAI-001" });
const active = makeTask({ id: "HAI-001", worktree: ".worktrees/swift-falcon" });
const blocked = makeTask({
id: "HAI-002",
column: "todo",
@@ -81,7 +87,7 @@ describe("groupByWorktree", () => {
});
it("respects maxConcurrent limit on queued tasks shown", () => {
const active = makeTask({ id: "HAI-001", worktree: ".worktrees/HAI-001" });
const active = makeTask({ id: "HAI-001", worktree: ".worktrees/swift-falcon" });
const q1 = makeTask({ id: "HAI-010", column: "todo" });
const q2 = makeTask({ id: "HAI-011", column: "todo" });
const q3 = makeTask({ id: "HAI-012", column: "todo" });

View File

@@ -11,6 +11,9 @@ vi.mock("./reviewer.js", () => ({
vi.mock("./merger.js", () => ({
findWorktreeUser: vi.fn().mockResolvedValue(null),
}));
vi.mock("./worktree-names.js", () => ({
generateWorktreeName: vi.fn().mockReturnValue("swift-falcon"),
}));
// Mock node modules used by executor
vi.mock("node:child_process", () => ({
@@ -232,7 +235,7 @@ describe("TaskExecutor worktreeInitCommand", () => {
);
expect(initCall).toBeDefined();
expect(initCall![1]).toMatchObject({
cwd: expect.stringContaining("HAI-010"),
cwd: expect.stringContaining(".worktrees/"),
timeout: 120_000,
});

View File

@@ -3,6 +3,7 @@ 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 { generateWorktreeName } from "./worktree-names.js";
import { Type, type Static } from "@mariozechner/pi-ai";
import { createHaiAgent } from "./pi.js";
import { reviewStep } from "./reviewer.js";
@@ -180,6 +181,47 @@ export class TaskExecutor {
* 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}`);
}
/**
* Execute a task in an isolated git worktree.
*
* **Worktree assignment:** New worktrees get humanized random names
* (e.g., `.worktrees/swift-falcon/`) via `generateWorktreeName()` rather
* than being named after the task ID. This decouples directory names from
* tasks, enabling worktree reuse across dependency chains. When resuming
* a task that already has `task.worktree` set, the existing path is used
* as-is. Branches remain task-scoped (`hai/{task-id}`).
*/
async execute(task: Task): Promise<void> {
if (this.executing.has(task.id)) return;
this.executing.add(task.id);
@@ -243,7 +285,9 @@ export class TaskExecutor {
}
}
} else {
// Resume: worktree already exists, just ensure git worktree is registered
worktreePath = task.worktree || join(this.rootDir, ".worktrees", generateWorktreeName(this.rootDir));
isResume = existsSync(worktreePath);
isReuse = false;
this.createWorktree(branchName, worktreePath);
}

View File

@@ -1,5 +1,4 @@
import { execSync } from "node:child_process";
import { join } from "node:path";
import { existsSync } from "node:fs";
import type { TaskStore, Task, MergeResult } from "@hai/core";
import { createHaiAgent } from "./pi.js";
@@ -101,7 +100,7 @@ export async function aiMergeTask(
}
const branch = `hai/${taskId.toLowerCase()}`;
const worktreePath = task.worktree || join(rootDir, ".worktrees", taskId);
const worktreePath = task.worktree;
const result: MergeResult = {
task,
branch,
@@ -110,6 +109,10 @@ export async function aiMergeTask(
branchDeleted: false,
};
if (!worktreePath) {
console.warn(`[merger] ${taskId}: no worktree path set — skipping worktree cleanup`);
}
// 2. Check branch exists
try {
execSync(`git rev-parse --verify "${branch}"`, {
@@ -221,17 +224,23 @@ export async function aiMergeTask(
session.dispose();
}
// 7. Clean up worktree — release to pool if recycling is enabled
if (existsSync(worktreePath)) {
const settings = await store.getSettings();
if (options.pool && settings.recycleWorktrees) {
// 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);
// 7. Delete branch (always per-task, regardless of worktree sharing)
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 (worktreePath && existsSync(worktreePath)) {
const otherUser = await findWorktreeUser(store, worktreePath, taskId);
if (otherUser) {
console.log(`[merger] Worktree retained — still needed by ${otherUser}`);
result.worktreeRemoved = false;
console.log(`[merger] Worktree returned to pool: ${worktreePath}`);
} else {
try {
execSync(`git worktree remove "${worktreePath}" --force`, {
@@ -243,17 +252,6 @@ 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
await completeTask(store, taskId, result);
return result;

View File

@@ -0,0 +1,70 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdtempSync, mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { generateWorktreeName } from "./worktree-names.js";
describe("generateWorktreeName", () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), "hai-wt-test-"));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it("returns a name matching adjective-noun pattern", () => {
const name = generateWorktreeName(tempDir);
expect(name).toMatch(/^[a-z]+-[a-z]+$/);
});
it("returns different names on subsequent calls (not deterministic)", () => {
// Generate several names — at least some should differ
const names = new Set<string>();
for (let i = 0; i < 20; i++) {
names.add(generateWorktreeName(tempDir));
}
// With 2500 combinations and 20 draws, we'd expect multiple unique names
expect(names.size).toBeGreaterThan(1);
});
it("avoids collision with existing .worktrees/ directories", () => {
// Create .worktrees dir with a known name
const worktreesDir = join(tempDir, ".worktrees");
mkdirSync(worktreesDir, { recursive: true });
// We need to force a collision — mock Math.random to always pick the same words
const originalRandom = Math.random;
Math.random = () => 0; // Will always pick first adjective and first noun
try {
// First call: should get the base name (e.g., "amber-badger")
const firstName = generateWorktreeName(tempDir);
expect(firstName).toMatch(/^[a-z]+-[a-z]+$/);
expect(firstName).not.toMatch(/-\d+$/); // no suffix
// Create that directory to simulate collision
mkdirSync(join(worktreesDir, firstName));
// Second call: should get a suffixed name
const secondName = generateWorktreeName(tempDir);
expect(secondName).toBe(`${firstName}-2`);
// Create that too
mkdirSync(join(worktreesDir, secondName));
// Third call: should get -3
const thirdName = generateWorktreeName(tempDir);
expect(thirdName).toBe(`${firstName}-3`);
} finally {
Math.random = originalRandom;
}
});
it("works when .worktrees/ directory does not exist", () => {
// tempDir has no .worktrees/ subdirectory
const name = generateWorktreeName(tempDir);
expect(name).toMatch(/^[a-z]+-[a-z]+$/);
});
});

View File

@@ -0,0 +1,76 @@
import { readdirSync } from "node:fs";
import { join } from "node:path";
import { existsSync } from "node:fs";
const ADJECTIVES = [
"amber", "azure", "bold", "brave", "bright",
"calm", "clear", "cool", "coral", "crisp",
"deft", "dusky", "eager", "early", "faint",
"fast", "fleet", "fresh", "gentle", "gilt",
"glad", "grand", "green", "happy", "hazy",
"ivory", "jade", "keen", "lemon", "light",
"lunar", "maple", "merry", "misty", "noble",
"opal", "pale", "pearl", "plush", "proud",
"quiet", "rapid", "rosy", "rusty", "sandy",
"sharp", "sleek", "solar", "swift", "vivid",
];
const NOUNS = [
"badger", "breeze", "brook", "cedar", "cliff",
"crane", "daisy", "delta", "dune", "eagle",
"ember", "falcon", "fern", "finch", "flame",
"frost", "grove", "hawk", "heron", "iris",
"lark", "lotus", "maple", "marsh", "mesa",
"moss", "oak", "olive", "orbit", "otter",
"panda", "peach", "petal", "pine", "plume",
"quail", "raven", "reef", "ridge", "robin",
"sage", "shore", "spark", "stone", "swift",
"thorn", "tiger", "trail", "trout", "wren",
];
/**
* Generate a random, human-friendly worktree directory name.
*
* Names follow an `adjective-noun` pattern (e.g., `swirly-monkey`,
* `quiet-falcon`, `bright-orchid`) drawn from embedded word lists of
* ~50 adjectives × ~50 nouns, producing ~2,500 unique combinations.
*
* **Collision avoidance:** The function checks existing subdirectories
* under `<rootDir>/.worktrees/`. If the randomly chosen name already
* exists, a numeric suffix is appended (e.g., `swift-falcon-2`,
* `swift-falcon-3`) until a unique name is found.
*
* @param rootDir - The project root directory (parent of `.worktrees/`)
* @returns A unique worktree directory name (not a full path)
*/
export function generateWorktreeName(rootDir: string): string {
const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
const baseName = `${adjective}-${noun}`;
const worktreesDir = join(rootDir, ".worktrees");
const existing = getExistingWorktreeNames(worktreesDir);
if (!existing.has(baseName)) {
return baseName;
}
// Collision — append numeric suffix
let suffix = 2;
while (existing.has(`${baseName}-${suffix}`)) {
suffix++;
}
return `${baseName}-${suffix}`;
}
function getExistingWorktreeNames(worktreesDir: string): Set<string> {
if (!existsSync(worktreesDir)) {
return new Set();
}
try {
const entries = readdirSync(worktreesDir, { withFileTypes: true });
return new Set(entries.filter((e) => e.isDirectory()).map((e) => e.name));
} catch {
return new Set();
}
}