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:
@@ -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,
|
||||
|
||||
@@ -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})` : ""}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user