fix(FN-8795): refresh queued overlap blockers

This commit is contained in:
gsxdsm
2026-08-04 23:42:06 -07:00
parent e75c788db2
commit 9c77788850
3 changed files with 130 additions and 1 deletions

View File

@@ -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.

View File

@@ -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" });

View File

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