fix(FN-6082): restore workflow overlap blocking

Workflow-column hold releases now participate in active file-scope leases before moving tasks into in-progress, preserving overlapBlockedBy card badges and scheduler blocking.

Fusion-Task-Id: FN-6082
This commit is contained in:
gsxdsm
2026-06-09 08:02:56 -07:00
parent cd8126d6a4
commit a53330786e
4 changed files with 162 additions and 4 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Restore file-overlap blocking for workflow-column task releases so cards stay queued with overlap badges until active file-scope leases clear.

View File

@@ -679,6 +679,78 @@ describe("Scheduler", () => {
expect(schedulerLog.log).toHaveBeenCalledWith(expect.stringContaining("no reservable slot"));
});
it("holds workflow-column releases when file scopes overlap active work", async () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFile).mockResolvedValue("# Task\nDo something");
const tasks = new Map<string, Task>([
["FN-001", createMockTask({ id: "FN-001", column: "in-progress", dependencies: [] })],
["FN-002", createMockTask({ id: "FN-002", column: "todo", dependencies: [] })],
["FN-003", createMockTask({ id: "FN-003", column: "todo", dependencies: [] })],
]);
const scopes = new Map<string, string[]>([
["FN-001", ["packages/engine/src/scheduler.ts"]],
["FN-002", ["packages/engine/src/scheduler.ts"]],
["FN-003", ["packages/core/src/store.ts"]],
]);
const movedListeners = new Set<(data: { task: object; to: string }) => void>();
const moveTask = vi.fn(async (taskId: string, column: Task["column"]) => {
const current = tasks.get(taskId);
if (!current) throw new Error(`missing task ${taskId}`);
const updated = { ...current, column } as Task;
tasks.set(taskId, updated);
for (const listener of movedListeners) {
listener({ task: updated, to: column });
}
return updated;
});
const updateTask = vi.fn(async (taskId: string, updates: Partial<Task>) => {
const current = tasks.get(taskId);
if (!current) throw new Error(`missing task ${taskId}`);
const updated = { ...current, ...updates } as Task;
if (updates.blockedBy === null) updated.blockedBy = undefined;
if (updates.overlapBlockedBy === null) updated.overlapBlockedBy = undefined;
tasks.set(taskId, updated);
return updated;
});
const store = createMockStore({
listTasks: vi.fn(async () => [...tasks.values()]),
getTask: vi.fn(async (taskId: string) => tasks.get(taskId) ?? null),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 15,
maxWorktrees: 10,
groupOverlappingFiles: true,
experimentalFeatures: { workflowColumns: true },
}),
parseFileScopeFromPrompt: vi.fn(async (taskId: string) => scopes.get(taskId) ?? []),
updateTask,
moveTask,
on: vi.fn((event: string, listener: (data: { task: object; to: string }) => void) => {
if (event === "task:moved") movedListeners.add(listener);
}),
off: vi.fn((event: string, listener: (data: { task: object; to: string }) => void) => {
if (event === "task:moved") movedListeners.delete(listener);
}),
});
const scheduler = new Scheduler(store);
(scheduler as unknown as { running: boolean }).running = true;
await scheduler.schedule();
expect(tasks.get("FN-002")).toMatchObject({
column: "todo",
status: "queued",
blockedBy: undefined,
overlapBlockedBy: "FN-001",
});
expect(tasks.get("FN-003")?.column).toBe("in-progress");
expect(moveTask.mock.calls.filter((call) => call[1] === "in-progress").map((call) => call[0])).toEqual(["FN-003"]);
expect(store.logEntry).toHaveBeenCalledWith(
"FN-002",
expect.stringContaining("queued — blocked by active file-scope lease FN-001"),
);
});
it("flag-OFF: todo dispatch is tagged as scheduler-sourced for redispatch guards", async () => {
const off = setupTodoStore(false);
await off.scheduler.schedule();

View File

@@ -76,7 +76,7 @@ export interface HoldReleaseDeps {
* default-workflow legacy parity path where the scheduler dispatch loop owns
* worktree allocation via `allocateWorktree`.
*/
reserveSlot?: (task: Task, targetColumn: string) => SlotReservation | null;
reserveSlot?: (task: Task, targetColumn: string) => SlotReservation | null | Promise<SlotReservation | null>;
/** Allocate a worktree path for a release into a processing column (passed
* through to `moveTask`'s `allocateWorktree`). */
allocateWorktree?: (task: Task, reservedNames: Set<string>) => string | null;
@@ -411,7 +411,7 @@ async function issueRelease(
let reservation: SlotReservation | null = null;
if (targetIsProcessing && deps.reserveSlot) {
reservation = deps.reserveSlot(task, target);
reservation = await deps.reserveSlot(task, target);
if (!reservation) {
// Semaphore/worktree exhausted — reservation-first means no move at all.
schedulerLog.log(`Hold release for ${task.id} deferred — no reservable slot for ${target}`);

View File

@@ -2126,15 +2126,92 @@ export class Scheduler {
try {
const maxWorktrees = settings.maxWorktrees ?? this.options.maxWorktrees ?? 4;
let reservedWorktreeSlots = tasks.filter((task) => task.column === "in-progress").length;
const activeScopes = new Map<string, string[]>();
const activeScopeColumns = new Map<string, Task["column"]>();
const overlapIgnorePaths = settings.overlapIgnorePaths ?? [];
const filteredScopeByTaskId = new Map<string, string[]>();
const getFilteredFileScope = async (taskId: string): Promise<string[]> => {
const cached = filteredScopeByTaskId.get(taskId);
if (cached) return cached;
const scope = await this.store.parseFileScopeFromPrompt(taskId);
const filteredScope = filterPathsByIgnoreList(scope, overlapIgnorePaths);
filteredScopeByTaskId.set(taskId, filteredScope);
return filteredScope;
};
if (settings.groupOverlappingFiles) {
for (const task of tasks) {
if (task.column !== "in-progress") continue;
const filteredScope = await getFilteredFileScope(task.id);
if (isCoordinationOnlyTask(task, filteredScope)) continue;
if (filteredScope.length > 0) {
activeScopes.set(task.id, filteredScope);
activeScopeColumns.set(task.id, task.column);
}
}
const inReviewWithWorktree = tasks.filter(
(task) => task.column === "in-review" && Boolean(task.worktree) && !task.paused && task.status !== "failed",
);
for (const task of inReviewWithWorktree) {
const filteredScope = await getFilteredFileScope(task.id);
if (isCoordinationOnlyTask(task, filteredScope)) continue;
if (filteredScope.length > 0) {
activeScopes.set(task.id, filteredScope);
activeScopeColumns.set(task.id, task.column);
}
}
}
await runHoldReleaseSweep(this.store, {
now: () => Date.now(),
reserveSlot: (): SlotReservation | null => {
reserveSlot: async (task): Promise<SlotReservation | null> => {
let reservedScope = false;
if (settings.groupOverlappingFiles) {
const taskScope = await getFilteredFileScope(task.id);
if (taskScope.length > 0 && !isCoordinationOnlyTask(task, taskScope)) {
const overlappingTaskId = Array.from(activeScopes.entries())
.sort(([aId], [bId]) => aId.localeCompare(bId))
.find(([, activeScope]) => this.pathsOverlap(taskScope, activeScope))?.[0] ?? null;
if (overlappingTaskId) {
const activeLeaseColumn = activeScopeColumns.get(overlappingTaskId) ?? "in-progress";
await this.store.updateTask(task.id, {
status: "queued",
blockedBy: null,
overlapBlockedBy: overlappingTaskId,
});
await this.logDispatchQueuedReason(
task.id,
`queued — blocked by active file-scope lease ${overlappingTaskId} (column=${activeLeaseColumn})`,
);
return null;
}
activeScopes.set(task.id, taskScope);
activeScopeColumns.set(task.id, "in-progress");
reservedScope = true;
} else if (task.overlapBlockedBy) {
await this.store.updateTask(task.id, { overlapBlockedBy: null });
}
}
if (Number.isFinite(maxWorktrees) && reservedWorktreeSlots >= maxWorktrees) {
if (reservedScope) {
activeScopes.delete(task.id);
activeScopeColumns.delete(task.id);
}
return null;
}
const sem = this.options.semaphore;
if (sem && !sem.tryAcquire()) return null;
if (sem && !sem.tryAcquire()) {
if (reservedScope) {
activeScopes.delete(task.id);
activeScopeColumns.delete(task.id);
}
return null;
}
reservedWorktreeSlots += 1;
let released = false;
@@ -2142,6 +2219,10 @@ export class Scheduler {
release: () => {
if (released) return;
released = true;
if (reservedScope) {
activeScopes.delete(task.id);
activeScopeColumns.delete(task.id);
}
reservedWorktreeSlots = Math.max(0, reservedWorktreeSlots - 1);
sem?.release();
},