diff --git a/.changeset/fix-stale-overlap-display.md b/.changeset/fix-stale-overlap-display.md new file mode 100644 index 0000000000..da7d986127 --- /dev/null +++ b/.changeset/fix-stale-overlap-display.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Hide stale file-overlap blockers when a task is queued on an unfinished dependency. +category: fix +dev: Scheduler dependency transitions now retain only overlap blockers backed by an active overlapping lease. diff --git a/packages/engine/src/__tests__/scheduler-workflow-cutover.test.ts b/packages/engine/src/__tests__/scheduler-workflow-cutover.test.ts index d022fe65ef..275503cf80 100644 --- a/packages/engine/src/__tests__/scheduler-workflow-cutover.test.ts +++ b/packages/engine/src/__tests__/scheduler-workflow-cutover.test.ts @@ -415,6 +415,111 @@ describe("Scheduler workflow cutover", () => { expect(onBlocked).toHaveBeenCalledWith(expect.objectContaining({ id: "FN-002" }), ["FN-001"]); }); + it("clears a stale overlap blocker while preserving an unfinished dependency", async () => { + const blocker = task({ id: "FN-001", column: "in-progress", paused: true, userPaused: true }); + const dependent = task({ + id: "FN-002", + dependencies: ["FN-001"], + status: "queued", + blockedBy: "FN-001", + overlapBlockedBy: "FN-001", + }); + const store = storeWith([blocker, dependent], { groupOverlappingFiles: true }); + vi.mocked(store.parseFileScopeFromPrompt).mockResolvedValue(["packages/engine/src/scheduler.ts"]); + const scheduler = new Scheduler(store); + (scheduler as unknown as { running: boolean }).running = true; + + await scheduler.schedule(); + + expect(store.transitionQueuedEpisode).toHaveBeenCalledWith("FN-002", { + signature: "dependency:FN-001", + blockedBy: "FN-001", + overlapBlockedBy: null, + action: "queued — unmet dependencies: FN-001", + }); + }); + + it("derives an active overlapping lease while the dependency remains unfinished", async () => { + const blocker = task({ id: "FN-001", column: "in-progress" }); + const dependent = task({ + id: "FN-002", + dependencies: ["FN-001"], + status: "queued", + blockedBy: "FN-001", + }); + const store = storeWith([blocker, dependent], { groupOverlappingFiles: true }); + vi.mocked(store.parseFileScopeFromPrompt).mockImplementation(async (id) => ( + id === "FN-001" || id === "FN-002" ? ["packages/engine/src/scheduler.ts"] : [] + )); + const scheduler = new Scheduler(store); + (scheduler as unknown as { running: boolean }).running = true; + + await scheduler.schedule(); + + expect(store.transitionQueuedEpisode).toHaveBeenCalledWith("FN-002", { + signature: "dependency:FN-001", + blockedBy: "FN-001", + overlapBlockedBy: "FN-001", + action: "queued — unmet dependencies: FN-001", + }); + }); + + it("clears an active but non-overlapping lease while preserving an unfinished dependency", async () => { + const blocker = task({ id: "FN-001", column: "in-progress" }); + const dependent = task({ + id: "FN-002", + dependencies: ["FN-001"], + status: "queued", + blockedBy: "FN-001", + overlapBlockedBy: "FN-001", + }); + const store = storeWith([blocker, dependent], { groupOverlappingFiles: true }); + vi.mocked(store.parseFileScopeFromPrompt).mockImplementation(async (id) => ( + id === "FN-001" ? ["packages/core/src/store.ts"] : ["packages/engine/src/scheduler.ts"] + )); + const scheduler = new Scheduler(store); + (scheduler as unknown as { running: boolean }).running = true; + + await scheduler.schedule(); + + expect(store.transitionQueuedEpisode).toHaveBeenCalledWith("FN-002", { + signature: "dependency:FN-001", + blockedBy: "FN-001", + overlapBlockedBy: null, + action: "queued — unmet dependencies: FN-001", + }); + }); + + it("keeps scheduling after a dependency-blocked task file scope cannot be read", async () => { + const blocker = task({ id: "FN-001", column: "in-progress" }); + const dependent = task({ + id: "FN-002", + dependencies: ["FN-001"], + status: "queued", + blockedBy: "FN-001", + overlapBlockedBy: "FN-001", + priority: "urgent", + }); + const ready = task({ id: "FN-003", priority: "normal" }); + const store = storeWith([blocker, dependent, ready], { groupOverlappingFiles: true }); + vi.mocked(store.parseFileScopeFromPrompt).mockImplementation(async (id) => { + if (id === "FN-002") throw new Error("scope read failed"); + return id === "FN-001" ? ["packages/engine/src/scheduler.ts"] : ["packages/core/src/store.ts"]; + }); + const scheduler = new Scheduler(store); + (scheduler as unknown as { running: boolean }).running = true; + + await scheduler.schedule(); + + expect(store.transitionQueuedEpisode).toHaveBeenCalledWith("FN-002", { + signature: "dependency:FN-001", + blockedBy: "FN-001", + overlapBlockedBy: null, + action: "queued — unmet dependencies: FN-001", + }); + expect(store.moveTaskIf).toHaveBeenCalledWith("FN-003", "in-progress", expect.anything(), expect.anything()); + }); + it("does not clear status or release work when maxConcurrent is full", async () => { const active = task({ id: "FN-001", column: "in-progress" }); const ready = task({ id: "FN-002", status: "queued", worktree: "/tmp/project/.worktrees/fn-002" }); diff --git a/packages/engine/src/scheduler.ts b/packages/engine/src/scheduler.ts index 2fa47575c1..b8956f944f 100644 --- a/packages/engine/src/scheduler.ts +++ b/packages/engine/src/scheduler.ts @@ -2406,10 +2406,27 @@ export class Scheduler { const unmetDeps = getUnmetSchedulingDependencies(task, tasks, schedulingDependencyOptions); if (unmetDeps.length > 0) { const normalizedUnmetDeps = [...new Set(unmetDeps)].sort(); + /* + FNXC:WorkflowScheduling 2026-08-05-06:22: + Dependency blocking and file-scope blocking are independent display truths. Keep the unfinished dependency authoritative while re-deriving overlapBlockedBy from the same active-scope registry used for dispatch, so paused/re-scoped leases disappear and resumed or replacement leases reappear without waiting for the dependency to finish. + */ + let activeOverlapBlockedBy: string | null = null; + if (activeScopes.size > 0) { + try { + const taskScope = await getFilteredFileScope(task.id); + if (taskScope.length > 0 && !isCoordinationOnlyTask(task, taskScope)) { + activeOverlapBlockedBy = Array.from(activeScopes.entries()) + .sort(([aId], [bId]) => aId.localeCompare(bId)) + .find(([, activeScope]) => this.pathsOverlap(taskScope, activeScope))?.[0] ?? null; + } + } catch (error) { + schedulerLog.warn(`Failed to refresh file-scope overlap blocker for dependency-blocked task ${task.id}`, error); + } + } await this.transitionQueuedEpisode(task, { signature: `dependency:${normalizedUnmetDeps.join(",")}`, blockedBy: unmetDeps[0] ?? null, - overlapBlockedBy: task.overlapBlockedBy ?? null, + overlapBlockedBy: activeOverlapBlockedBy, action: `queued — unmet dependencies: ${unmetDeps.join(", ")}`, }); this.options.onBlocked?.(task, unmetDeps);