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:
5
.changeset/workflow-overlap-hold-release.md
Normal file
5
.changeset/workflow-overlap-hold-release.md
Normal 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.
|
||||
@@ -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();
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user