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:
@@ -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,
|
||||
|
||||
@@ -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" });
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
70
packages/engine/src/worktree-names.test.ts
Normal file
70
packages/engine/src/worktree-names.test.ts
Normal 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]+$/);
|
||||
});
|
||||
});
|
||||
76
packages/engine/src/worktree-names.ts
Normal file
76
packages/engine/src/worktree-names.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user