diff --git a/packages/engine/src/__tests__/scheduler.test.ts b/packages/engine/src/__tests__/scheduler.test.ts index 1cd72fb7c..5f81224e3 100644 --- a/packages/engine/src/__tests__/scheduler.test.ts +++ b/packages/engine/src/__tests__/scheduler.test.ts @@ -1029,7 +1029,7 @@ describe("Scheduler", () => { // Dependency-blocked urgent task should be queued, not started. expect(updateTask).toHaveBeenCalledWith("FN-100", { status: "queued", blockedBy: "FN-900" }); // Overlap-blocked urgent task should be queued with blocker id. - expect(updateTask).toHaveBeenCalledWith("FN-103", { status: "queued", blockedBy: "FN-001" }); + expect(updateTask).toHaveBeenCalledWith("FN-103", { status: "queued", blockedBy: "FN-001", overlapBlockedBy: "FN-001" }); // Paused and recovery-gated urgent tasks never enter scheduling. expect(moveTask).not.toHaveBeenCalledWith("FN-101", "in-progress"); expect(moveTask).not.toHaveBeenCalledWith("FN-102", "in-progress"); @@ -1262,7 +1262,7 @@ describe("Scheduler", () => { (scheduler as any).running = true; await scheduler.schedule(); - expect(updateTask).toHaveBeenCalledWith("FN-002", { status: "queued", blockedBy: "FN-001" }); + expect(updateTask).toHaveBeenCalledWith("FN-002", { status: "queued", blockedBy: "FN-001", overlapBlockedBy: "FN-001" }); expect(moveTask).not.toHaveBeenCalledWith("FN-002", "in-progress"); }); @@ -1300,7 +1300,7 @@ describe("Scheduler", () => { (scheduler as any).running = true; await scheduler.schedule(); - expect(updateTask).toHaveBeenCalledWith("FN-002", { status: "queued", blockedBy: "FN-001" }); + expect(updateTask).toHaveBeenCalledWith("FN-002", { status: "queued", blockedBy: "FN-001", overlapBlockedBy: "FN-001" }); expect(moveTask).not.toHaveBeenCalledWith("FN-002", "in-progress"); }); }); @@ -1379,7 +1379,7 @@ describe("Scheduler", () => { (scheduler as any).running = true; await scheduler.schedule(); - expect(updateTask).toHaveBeenCalledWith("FN-T", { status: "queued", blockedBy: "FN-B" }); + expect(updateTask).toHaveBeenCalledWith("FN-T", { status: "queued", blockedBy: "FN-B", overlapBlockedBy: "FN-B" }); }); it("does not stamp blockedBy for todos without overlap, including empty scopes", async () => { diff --git a/packages/engine/src/scheduler.ts b/packages/engine/src/scheduler.ts index 7003071c1..22023c967 100644 --- a/packages/engine/src/scheduler.ts +++ b/packages/engine/src/scheduler.ts @@ -894,9 +894,10 @@ export class Scheduler { ); if (taskScope.length > 0) { const activeScopeEntries = Array.from(activeScopes.entries()).sort(([aId], [bId]) => aId.localeCompare(bId)); - const currentBlockerScope = task.blockedBy ? activeScopes.get(task.blockedBy) : undefined; + const overlapBlockerId = task.overlapBlockedBy || task.blockedBy; + const currentBlockerScope = overlapBlockerId ? activeScopes.get(overlapBlockerId) : undefined; const hasValidCurrentBlocker = - Boolean(task.blockedBy) + Boolean(overlapBlockerId) && Boolean(currentBlockerScope) && this.pathsOverlap(taskScope, currentBlockerScope!); @@ -907,22 +908,36 @@ export class Scheduler { * - idempotent writes only: update DB only when blockedBy/status must change */ const overlappingTaskId = hasValidCurrentBlocker - ? task.blockedBy + ? overlapBlockerId : activeScopeEntries.find(([, ipScope]) => this.pathsOverlap(taskScope, ipScope))?.[0] ?? null; if (overlappingTaskId) { - // Keep blockedBy tied to explicit unresolved dependencies when a task has - // dependency edges; avoid repointing dependency-unblocked tasks to unrelated - // overlap ids (FN-3924). For dependency-free tasks, blockedBy may reference - // the active overlap blocker. - const targetBlockedBy = task.dependencies.length > 0 ? null : overlappingTaskId; - if (task.status !== "queued" || task.blockedBy !== targetBlockedBy) { - await this.store.updateTask(task.id, { status: "queued", blockedBy: targetBlockedBy }); + const unresolvedDeps = task.dependencies.filter((depId) => { + const dep = tasks.find((t) => t.id === depId); + return dep && dep.column !== "done" && dep.column !== "in-review" && dep.column !== "archived"; + }); + const targetBlockedBy = task.dependencies.length > 0 + ? (unresolvedDeps[0] ?? null) + : overlappingTaskId; + if ( + task.status !== "queued" + || task.blockedBy !== targetBlockedBy + || task.overlapBlockedBy !== overlappingTaskId + ) { + await this.store.updateTask(task.id, { + status: "queued", + blockedBy: targetBlockedBy, + overlapBlockedBy: overlappingTaskId, + }); } await this.rollbackRunningAgentsForQueuedTodoTask(task.id); await this.logDispatchQueuedReason(task.id, `queued — file scope overlap with ${overlappingTaskId}`); continue; } + + if (task.overlapBlockedBy) { + await this.store.updateTask(task.id, { overlapBlockedBy: null }); + } } }