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"));
|
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 () => {
|
it("flag-OFF: todo dispatch is tagged as scheduler-sourced for redispatch guards", async () => {
|
||||||
const off = setupTodoStore(false);
|
const off = setupTodoStore(false);
|
||||||
await off.scheduler.schedule();
|
await off.scheduler.schedule();
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ export interface HoldReleaseDeps {
|
|||||||
* default-workflow legacy parity path where the scheduler dispatch loop owns
|
* default-workflow legacy parity path where the scheduler dispatch loop owns
|
||||||
* worktree allocation via `allocateWorktree`.
|
* 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
|
/** Allocate a worktree path for a release into a processing column (passed
|
||||||
* through to `moveTask`'s `allocateWorktree`). */
|
* through to `moveTask`'s `allocateWorktree`). */
|
||||||
allocateWorktree?: (task: Task, reservedNames: Set<string>) => string | null;
|
allocateWorktree?: (task: Task, reservedNames: Set<string>) => string | null;
|
||||||
@@ -411,7 +411,7 @@ async function issueRelease(
|
|||||||
|
|
||||||
let reservation: SlotReservation | null = null;
|
let reservation: SlotReservation | null = null;
|
||||||
if (targetIsProcessing && deps.reserveSlot) {
|
if (targetIsProcessing && deps.reserveSlot) {
|
||||||
reservation = deps.reserveSlot(task, target);
|
reservation = await deps.reserveSlot(task, target);
|
||||||
if (!reservation) {
|
if (!reservation) {
|
||||||
// Semaphore/worktree exhausted — reservation-first means no move at all.
|
// Semaphore/worktree exhausted — reservation-first means no move at all.
|
||||||
schedulerLog.log(`Hold release for ${task.id} deferred — no reservable slot for ${target}`);
|
schedulerLog.log(`Hold release for ${task.id} deferred — no reservable slot for ${target}`);
|
||||||
|
|||||||
@@ -2126,15 +2126,92 @@ export class Scheduler {
|
|||||||
try {
|
try {
|
||||||
const maxWorktrees = settings.maxWorktrees ?? this.options.maxWorktrees ?? 4;
|
const maxWorktrees = settings.maxWorktrees ?? this.options.maxWorktrees ?? 4;
|
||||||
let reservedWorktreeSlots = tasks.filter((task) => task.column === "in-progress").length;
|
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, {
|
await runHoldReleaseSweep(this.store, {
|
||||||
now: () => Date.now(),
|
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 (Number.isFinite(maxWorktrees) && reservedWorktreeSlots >= maxWorktrees) {
|
||||||
|
if (reservedScope) {
|
||||||
|
activeScopes.delete(task.id);
|
||||||
|
activeScopeColumns.delete(task.id);
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sem = this.options.semaphore;
|
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;
|
reservedWorktreeSlots += 1;
|
||||||
let released = false;
|
let released = false;
|
||||||
@@ -2142,6 +2219,10 @@ export class Scheduler {
|
|||||||
release: () => {
|
release: () => {
|
||||||
if (released) return;
|
if (released) return;
|
||||||
released = true;
|
released = true;
|
||||||
|
if (reservedScope) {
|
||||||
|
activeScopes.delete(task.id);
|
||||||
|
activeScopeColumns.delete(task.id);
|
||||||
|
}
|
||||||
reservedWorktreeSlots = Math.max(0, reservedWorktreeSlots - 1);
|
reservedWorktreeSlots = Math.max(0, reservedWorktreeSlots - 1);
|
||||||
sem?.release();
|
sem?.release();
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user