feat(KB-130): allow dependent tasks to start from in-review dep branches

- Relax scheduler dep gate to treat in-review tasks as satisfied
- Add resolveBaseBranch() to detect in-review deps with unmerged worktrees
- Executor creates worktrees from dep branch instead of HEAD when baseBranch is set
- Extend file-overlap tracking to include in-review tasks with worktrees
- Add baseBranch field to Task type and store update logic
- Add scheduler and executor tests for in-review dependency handling
This commit is contained in:
Dustin Byrne
2026-03-27 02:15:20 -04:00
parent dd746ba904
commit 69dca3e11f
7 changed files with 507 additions and 27 deletions

View File

@@ -428,6 +428,167 @@ describe("TaskExecutor worktree naming", () => {
});
});
describe("TaskExecutor dependency-based worktree creation", () => {
const makeTask = (overrides: Partial<Task> = {}) => ({
id: "KB-060",
title: "Test",
description: "Test",
column: "in-progress" as const,
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
...overrides,
});
beforeEach(() => {
vi.clearAllMocks();
mockedExistsSync.mockReturnValue(false);
mockedCreateHaiAgent.mockResolvedValue({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
},
} as any);
});
it("creates worktree from baseBranch when set on task", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute(makeTask({
id: "KB-060",
baseBranch: "kb/kb-059",
}));
// The git worktree add command should include the startPoint
const worktreeAddCalls = mockedExecSync.mock.calls.filter(
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree add"),
);
expect(worktreeAddCalls.length).toBeGreaterThan(0);
expect(worktreeAddCalls[0][0]).toContain("kb/kb-059");
});
it("creates worktree from HEAD when baseBranch is not set", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute(makeTask({
id: "KB-061",
// no baseBranch
}));
// The git worktree add command should NOT include a startPoint
const worktreeAddCalls = mockedExecSync.mock.calls.filter(
(c) => typeof c[0] === "string" && (c[0] as string).includes("worktree add -b"),
);
expect(worktreeAddCalls.length).toBeGreaterThan(0);
// Command format: git worktree add -b "branch" "path" (no extra ref after path)
const cmd = worktreeAddCalls[0][0] as string;
// Count quoted segments: branch + path = 2 quoted args
const quoted = cmd.match(/"[^"]+"/g) || [];
expect(quoted).toHaveLength(2);
});
it("logs base branch in worktree creation log entry", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute(makeTask({
id: "KB-062",
baseBranch: "kb/kb-061",
}));
expect(store.logEntry).toHaveBeenCalledWith(
"KB-062",
expect.stringContaining("based on kb/kb-061"),
);
});
it("does not mention base branch in log when baseBranch is not set", async () => {
const store = createMockStore();
const executor = new TaskExecutor(store, "/tmp/test");
await executor.execute(makeTask({
id: "KB-063",
}));
// Check that log entry does NOT mention "based on"
const logCalls = store.logEntry.mock.calls.filter(
(call: any[]) => typeof call[1] === "string" && call[1].includes("Worktree created"),
);
expect(logCalls.length).toBeGreaterThan(0);
expect(logCalls[0][1]).not.toContain("based on");
});
it("passes baseBranch to pool prepareForTask when using pooled worktree", async () => {
const pool = new WorktreePool();
pool.release("/tmp/test/.worktrees/idle-wt");
mockedExistsSync.mockImplementation(
(p) => p === "/tmp/test/.worktrees/idle-wt",
);
const prepareSpy = vi.spyOn(pool, "prepareForTask");
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({
id: "KB-064",
baseBranch: "kb/kb-063",
}));
expect(prepareSpy).toHaveBeenCalledWith(
"/tmp/test/.worktrees/idle-wt",
"kb/kb-064",
"kb/kb-063",
);
});
it("passes undefined to pool prepareForTask when no baseBranch", async () => {
const pool = new WorktreePool();
pool.release("/tmp/test/.worktrees/idle-wt");
mockedExistsSync.mockImplementation(
(p) => p === "/tmp/test/.worktrees/idle-wt",
);
const prepareSpy = vi.spyOn(pool, "prepareForTask");
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({
id: "KB-065",
}));
expect(prepareSpy).toHaveBeenCalledWith(
"/tmp/test/.worktrees/idle-wt",
"kb/kb-065",
undefined,
);
});
});
describe("TaskExecutor worktree pool integration", () => {
const makeTask = (id = "KB-020") => ({
id,

View File

@@ -274,13 +274,16 @@ export class TaskExecutor {
let acquiredFromPool = false;
const settings = await this.store.getSettings();
// Resolve the base branch — set by the scheduler when a dep is in-review
const baseBranch = task.baseBranch || null;
if (!isResume) {
// Try acquiring a warm worktree from the pool
if (this.options.pool && settings.recycleWorktrees) {
const pooled = this.options.pool.acquire();
if (pooled) {
this.options.pool.prepareForTask(pooled, branchName);
this.options.pool.prepareForTask(pooled, branchName, baseBranch ?? undefined);
worktreePath = pooled;
acquiredFromPool = true;
executorLog.log(`Acquired worktree from pool: ${pooled}`);
@@ -291,9 +294,14 @@ export class TaskExecutor {
// Fall through to fresh worktree creation if pool had nothing
if (!acquiredFromPool) {
this.createWorktree(branchName, worktreePath);
this.createWorktree(branchName, worktreePath, baseBranch ?? undefined);
await this.store.updateTask(task.id, { worktree: worktreePath });
await this.store.logEntry(task.id, `Worktree created at ${worktreePath}`);
if (baseBranch) {
await this.store.logEntry(task.id, `Worktree created at ${worktreePath} (based on ${baseBranch})`);
} else {
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) {
@@ -713,13 +721,24 @@ export class TaskExecutor {
// ── Worktree management ────────────────────────────────────────────
private createWorktree(branch: string, path: string): void {
/**
* Create a git worktree at `path` on a new branch.
*
* @param branch — Branch name (e.g., `kb/kb-042`)
* @param path — Absolute worktree directory path
* @param startPoint — Optional git ref to branch from (e.g., `kb/kb-041`).
* When provided, the worktree starts from that ref instead of HEAD.
*/
private createWorktree(branch: string, path: string, startPoint?: string): void {
if (existsSync(path)) {
executorLog.log(`Worktree already exists: ${path}`);
return;
}
try {
execSync(`git worktree add -b "${branch}" "${path}"`, { cwd: this.rootDir, stdio: "pipe" });
const cmd = startPoint
? `git worktree add -b "${branch}" "${path}" "${startPoint}"`
: `git worktree add -b "${branch}" "${path}"`;
execSync(cmd, { cwd: this.rootDir, stdio: "pipe" });
} catch {
try {
execSync(`git worktree add "${path}" "${branch}"`, { cwd: this.rootDir, stdio: "pipe" });
@@ -727,7 +746,7 @@ export class TaskExecutor {
throw new Error(`Failed to create worktree: ${e.message}`);
}
}
executorLog.log(`Worktree created: ${path}`);
executorLog.log(`Worktree created: ${path}${startPoint ? ` (from ${startPoint})` : ""}`);
}
/**

View File

@@ -383,6 +383,249 @@ describe("Scheduler file-scope overlap", () => {
});
});
describe("Scheduler explicit dep relaxation (in-review as met)", () => {
beforeEach(() => {
vi.clearAllMocks();
});
async function runSchedule(scheduler: Scheduler): Promise<void> {
(scheduler as any).running = true;
await scheduler.schedule();
}
it("allows task to start when explicit dep is in-review", async () => {
const tasks = [
makeTask({ id: "KB-001", column: "in-review", worktree: "/tmp/wt/kb-001" }),
makeTask({ id: "KB-002", column: "todo", dependencies: ["KB-001"] }),
];
const store = createMockStore(tasks);
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
await runSchedule(scheduler);
expect(store.moveTask).toHaveBeenCalledWith("KB-002", "in-progress");
});
it("blocks task when explicit dep is in-progress (not yet done or in-review)", async () => {
const tasks = [
makeTask({ id: "KB-001", column: "in-progress" }),
makeTask({ id: "KB-002", column: "todo", dependencies: ["KB-001"] }),
];
const store = createMockStore(tasks);
const scheduler = new Scheduler(store, { maxConcurrent: 3 });
await runSchedule(scheduler);
expect(store.moveTask).not.toHaveBeenCalledWith("KB-002", "in-progress");
expect(store.updateTask).toHaveBeenCalledWith("KB-002", { status: "queued" });
});
it("allows task to start when explicit dep is done", async () => {
const tasks = [
makeTask({ id: "KB-001", column: "done" }),
makeTask({ id: "KB-002", column: "todo", dependencies: ["KB-001"] }),
];
const store = createMockStore(tasks);
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
await runSchedule(scheduler);
expect(store.moveTask).toHaveBeenCalledWith("KB-002", "in-progress");
});
it("blocks task when explicit dep is in todo", async () => {
const tasks = [
makeTask({ id: "KB-001", column: "todo" }),
makeTask({ id: "KB-002", column: "todo", dependencies: ["KB-001"] }),
];
const store = createMockStore(tasks);
const scheduler = new Scheduler(store, { maxConcurrent: 3 });
await runSchedule(scheduler);
// KB-001 should be started (no deps), KB-002 blocked (dep in todo)
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "in-progress");
expect(store.moveTask).not.toHaveBeenCalledWith("KB-002", "in-progress");
});
});
describe("Scheduler baseBranch recording", () => {
beforeEach(() => {
vi.clearAllMocks();
});
async function runSchedule(scheduler: Scheduler): Promise<void> {
(scheduler as any).running = true;
await scheduler.schedule();
}
it("sets baseBranch when explicit dep is in-review with worktree", async () => {
const tasks = [
makeTask({ id: "KB-001", column: "in-review", worktree: "/tmp/wt/kb-001" }),
makeTask({ id: "KB-002", column: "todo", dependencies: ["KB-001"] }),
];
const store = createMockStore(tasks);
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
await runSchedule(scheduler);
expect(store.updateTask).toHaveBeenCalledWith("KB-002", {
status: null,
blockedBy: null,
baseBranch: "kb/kb-001",
});
});
it("does not set baseBranch when dep is done (already merged to main)", async () => {
const tasks = [
makeTask({ id: "KB-001", column: "done" }),
makeTask({ id: "KB-002", column: "todo", dependencies: ["KB-001"] }),
];
const store = createMockStore(tasks);
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
await runSchedule(scheduler);
expect(store.updateTask).toHaveBeenCalledWith("KB-002", {
status: null,
blockedBy: null,
baseBranch: undefined,
});
});
it("sets baseBranch from blockedBy when blocker is in-review with worktree", async () => {
const tasks = [
makeTask({ id: "KB-001", column: "in-review", worktree: "/tmp/wt/kb-001" }),
makeTask({ id: "KB-002", column: "todo", blockedBy: "KB-001" }),
];
const store = createMockStore(tasks);
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
await runSchedule(scheduler);
expect(store.updateTask).toHaveBeenCalledWith("KB-002", {
status: null,
blockedBy: null,
baseBranch: "kb/kb-001",
});
});
it("does not set baseBranch when dep is in-review without worktree", async () => {
const tasks = [
makeTask({ id: "KB-001", column: "in-review" }), // no worktree
makeTask({ id: "KB-002", column: "todo", dependencies: ["KB-001"] }),
];
const store = createMockStore(tasks);
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
await runSchedule(scheduler);
expect(store.updateTask).toHaveBeenCalledWith("KB-002", {
status: null,
blockedBy: null,
baseBranch: undefined,
});
});
it("prefers explicit dep over blockedBy for baseBranch", async () => {
const tasks = [
makeTask({ id: "KB-001", column: "in-review", worktree: "/tmp/wt/kb-001" }),
makeTask({ id: "KB-003", column: "in-review", worktree: "/tmp/wt/kb-003" }),
makeTask({ id: "KB-002", column: "todo", dependencies: ["KB-001"], blockedBy: "KB-003" }),
];
const store = createMockStore(tasks);
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
await runSchedule(scheduler);
// Should use explicit dep KB-001, not blockedBy KB-003
expect(store.updateTask).toHaveBeenCalledWith("KB-002", {
status: null,
blockedBy: null,
baseBranch: "kb/kb-001",
});
});
it("does not set baseBranch for tasks with no deps or blockedBy", async () => {
const tasks = [
makeTask({ id: "KB-001", column: "todo" }),
];
const store = createMockStore(tasks);
const scheduler = new Scheduler(store, { maxConcurrent: 2 });
await runSchedule(scheduler);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
status: null,
blockedBy: null,
baseBranch: undefined,
});
});
});
describe("Scheduler in-review file scope overlap", () => {
beforeEach(() => {
vi.clearAllMocks();
});
async function runSchedule(scheduler: Scheduler): Promise<void> {
(scheduler as any).running = true;
await scheduler.schedule();
}
it("blocks todo task when in-review task with worktree has overlapping file scope", async () => {
const tasks = [
makeTask({ id: "KB-001", column: "in-review", worktree: "/tmp/wt/kb-001" }),
makeTask({ id: "KB-002", column: "todo" }),
];
const store = createMockStore(tasks);
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: true,
autoMerge: false,
});
store.parseFileScopeFromPrompt.mockImplementation(async (id: string) => {
if (id === "KB-001") return ["packages/shared/utils.ts"];
if (id === "KB-002") return ["packages/shared/utils.ts"];
return [];
});
const scheduler = new Scheduler(store, { maxConcurrent: 3 });
await runSchedule(scheduler);
expect(store.moveTask).not.toHaveBeenCalled();
expect(store.updateTask).toHaveBeenCalledWith("KB-002", { status: "queued", blockedBy: "KB-001" });
});
it("does not block when in-review task has no worktree (already merged)", async () => {
const tasks = [
makeTask({ id: "KB-001", column: "in-review" }), // no worktree — merged
makeTask({ id: "KB-002", column: "todo" }),
];
const store = createMockStore(tasks);
store.getSettings.mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 15000,
groupOverlappingFiles: true,
autoMerge: false,
});
store.parseFileScopeFromPrompt.mockImplementation(async (id: string) => {
if (id === "KB-001") return ["packages/shared/utils.ts"];
if (id === "KB-002") return ["packages/shared/utils.ts"];
return [];
});
const scheduler = new Scheduler(store, { maxConcurrent: 3 });
await runSchedule(scheduler);
// KB-002 should be started (no overlap with merged in-review task)
expect(store.moveTask).toHaveBeenCalledWith("KB-002", "in-progress");
});
});
describe("Scheduler paused tasks", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -121,6 +121,35 @@ export class Scheduler {
schedulerLog.log(`Poll interval updated to ${newIntervalMs}ms`);
}
/**
* Resolve the base branch for a task being started.
*
* Checks explicit dependencies and implicit `blockedBy` for an in-review
* task with an unmerged branch. Returns the git branch name to start from,
* or `null` if the task should start from HEAD (default).
*
* Priority: explicit dep in-review (first with worktree) > blockedBy in-review.
*/
private resolveBaseBranch(task: Task, allTasks: Task[]): string | null {
// Check explicit dependencies for in-review tasks with worktrees
for (const depId of task.dependencies) {
const dep = allTasks.find((t) => t.id === depId);
if (dep && dep.column === "in-review" && dep.worktree) {
return `kb/${dep.id.toLowerCase()}`;
}
}
// Check implicit blockedBy for in-review task with worktree
if (task.blockedBy) {
const blocker = allTasks.find((t) => t.id === task.blockedBy);
if (blocker && blocker.column === "in-review" && blocker.worktree) {
return `kb/${blocker.id.toLowerCase()}`;
}
}
return null;
}
/**
* Delegates to the module-level {@link pathsOverlap} for testability.
*/
@@ -199,20 +228,35 @@ export class Scheduler {
if (todo.length === 0) return;
/**
* Pre-compute file scopes for **all** currently in-progress tasks so
* that todo tasks are never started when their files overlap with work
* already underway. The re-entrance guard on this method ensures that
* this snapshot stays consistent throughout the pass — without it, a
* concurrent pass could read stale state and start conflicting tasks.
* Pre-compute file scopes for all currently active tasks (in-progress
* AND in-review with unmerged worktrees) so that todo tasks are never
* started when their files overlap with work already underway or
* awaiting merge.
*
* Including in-review tasks prevents a blocked task from starting on
* main HEAD when the blocker's changes haven't been merged yet.
*
* The re-entrance guard on this method ensures that this snapshot
* stays consistent throughout the pass — without it, a concurrent
* pass could read stale state and start conflicting tasks.
*
* Newly started tasks are appended to this map further below so that
* subsequent todo tasks in the same pass also see them.
*/
const inProgressScopes = new Map<string, string[]>();
const activeScopes = new Map<string, string[]>();
if (settings.groupOverlappingFiles) {
// In-progress tasks
for (const t of inProgress) {
const scope = await this.store.parseFileScopeFromPrompt(t.id);
if (scope.length > 0) inProgressScopes.set(t.id, scope);
if (scope.length > 0) activeScopes.set(t.id, scope);
}
// In-review tasks with unmerged worktrees
const inReviewWithWorktree = tasks.filter(
(t) => t.column === "in-review" && t.worktree,
);
for (const t of inReviewWithWorktree) {
const scope = await this.store.parseFileScopeFromPrompt(t.id);
if (scope.length > 0) activeScopes.set(t.id, scope);
}
}
@@ -223,10 +267,10 @@ export class Scheduler {
for (const taskId of ordered) {
const task = tasks.find((t) => t.id === taskId)!;
// Check all deps are satisfied (must be done — merged to main)
// Check all deps are satisfied (done or in-review with branch ready)
const unmetDeps = task.dependencies.filter((depId) => {
const dep = tasks.find((t) => t.id === depId);
return dep && dep.column !== "done";
return dep && dep.column !== "done" && dep.column !== "in-review";
});
if (unmetDeps.length > 0) {
@@ -240,7 +284,7 @@ export class Scheduler {
const taskScope = await this.store.parseFileScopeFromPrompt(task.id);
if (taskScope.length > 0) {
let overlappingTaskId: string | null = null;
for (const [ipId, ipScope] of inProgressScopes) {
for (const [ipId, ipScope] of activeScopes) {
if (this.pathsOverlap(taskScope, ipScope)) {
overlappingTaskId = ipId;
break;
@@ -258,9 +302,12 @@ export class Scheduler {
continue;
}
// Dependencies met — clear status and move to in-progress
// Dependencies met — resolve base branch from in-review deps
const baseBranch = this.resolveBaseBranch(task, tasks);
// Clear status and move to in-progress
schedulerLog.log(`Starting ${task.id}: ${task.title || task.id} (deps satisfied)`);
await this.store.updateTask(task.id, { status: null, blockedBy: null });
await this.store.updateTask(task.id, { status: null, blockedBy: null, baseBranch: baseBranch ?? undefined });
await this.store.moveTask(task.id, "in-progress");
this.options.onSchedule?.(task);
started++;
@@ -268,7 +315,7 @@ export class Scheduler {
// Track newly started task's file scope for overlap with remaining todo tasks
if (settings.groupOverlappingFiles) {
const scope = await this.store.parseFileScopeFromPrompt(task.id);
if (scope.length > 0) inProgressScopes.set(task.id, scope);
if (scope.length > 0) activeScopes.set(task.id, scope);
}
}
} catch (err) {

View File

@@ -72,19 +72,21 @@ export class WorktreePool {
* 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/).
* the task's branch based on the given start point (or `main` by default).
* This ensures the new task starts from the correct base 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
* 3. `git checkout -B <branchName> <startPoint>` — create/reset branch from start point
*
* @param worktreePath — Absolute path to the recycled worktree
* @param branchName — Branch name for the new task (e.g., `kb/kb-042`)
* @param startPoint — Git ref to branch from (e.g., `kb/kb-041`). Defaults to `main`.
*/
prepareForTask(worktreePath: string, branchName: string): void {
prepareForTask(worktreePath: string, branchName: string, startPoint?: string): void {
// Clean tracked modifications
try {
execSync("git checkout -- .", { cwd: worktreePath, stdio: "pipe" });
@@ -95,8 +97,9 @@ export class WorktreePool {
// 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`, {
// Create or force-reset the branch from the start point (or main)
const base = startPoint || "main";
execSync(`git checkout -B "${branchName}" ${base}`, {
cwd: worktreePath,
stdio: "pipe",
});