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"]);
|
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 () => {
|
it("does not clear status or release work when maxConcurrent is full", async () => {
|
||||||
const active = task({ id: "FN-001", column: "in-progress" });
|
const active = task({ id: "FN-001", column: "in-progress" });
|
||||||
const ready = task({ id: "FN-002", status: "queued", worktree: "/tmp/project/.worktrees/fn-002" });
|
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);
|
const unmetDeps = getUnmetSchedulingDependencies(task, tasks, schedulingDependencyOptions);
|
||||||
if (unmetDeps.length > 0) {
|
if (unmetDeps.length > 0) {
|
||||||
const normalizedUnmetDeps = [...new Set(unmetDeps)].sort();
|
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, {
|
await this.transitionQueuedEpisode(task, {
|
||||||
signature: `dependency:${normalizedUnmetDeps.join(",")}`,
|
signature: `dependency:${normalizedUnmetDeps.join(",")}`,
|
||||||
blockedBy: unmetDeps[0] ?? null,
|
blockedBy: unmetDeps[0] ?? null,
|
||||||
overlapBlockedBy: task.overlapBlockedBy ?? null,
|
overlapBlockedBy: activeOverlapBlockedBy,
|
||||||
action: `queued — unmet dependencies: ${unmetDeps.join(", ")}`,
|
action: `queued — unmet dependencies: ${unmetDeps.join(", ")}`,
|
||||||
});
|
});
|
||||||
this.options.onBlocked?.(task, unmetDeps);
|
this.options.onBlocked?.(task, unmetDeps);
|
||||||
|
|||||||
Reference in New Issue
Block a user