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

@@ -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) {