fix(FN-8795): refresh queued overlap blockers
This commit is contained in:
7
.changeset/fix-stale-overlap-display.md
Normal file
7
.changeset/fix-stale-overlap-display.md
Normal 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.
|
||||
@@ -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" });
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user