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 b4dfca2774
commit ffc5e14876
7 changed files with 507 additions and 27 deletions

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();