fix(FN-6084): honor worktree limit for hold releases

Workflow-column hold releases now reserve against the active worktree count before moving tasks into in-progress, preventing restart bursts from exceeding maxWorktrees.

Fusion-Task-Id: FN-6084
This commit is contained in:
gsxdsm
2026-06-09 07:55:45 -07:00
parent c285c30c19
commit cd8126d6a4
3 changed files with 76 additions and 15 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Honor the worktree execution limit when workflow-column hold releases dispatch tasks.

View File

@@ -630,6 +630,55 @@ describe("Scheduler", () => {
expect(vi.mocked(store.listTasks).mock.calls.some(([options]) => options?.startupMemo === false)).toBe(true);
});
it("holds workflow-column releases when maxWorktrees is exhausted", 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: "in-progress", dependencies: [] })],
["FN-003", createMockTask({ id: "FN-003", column: "todo", dependencies: [] })],
["FN-004", createMockTask({ id: "FN-004", column: "todo", dependencies: [] })],
["FN-005", createMockTask({ id: "FN-005", column: "todo", dependencies: [] })],
]);
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 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: 3,
experimentalFeatures: { workflowColumns: true },
}),
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();
const inProgress = [...tasks.values()].filter((task) => task.column === "in-progress");
expect(inProgress.map((task) => task.id)).toEqual(["FN-001", "FN-002", "FN-003"]);
expect(moveTask.mock.calls.filter((call) => call[1] === "in-progress").map((call) => call[0])).toEqual(["FN-003"]);
expect(schedulerLog.log).toHaveBeenCalledWith(expect.stringContaining("no reservable slot"));
});
it("flag-OFF: todo dispatch is tagged as scheduler-sourced for redispatch guards", async () => {
const off = setupTodoStore(false);
await off.scheduler.schedule();

View File

@@ -1273,7 +1273,7 @@ export class Scheduler {
// still drives todo→in-progress pickup (parity); the sweep adds custom-
// workflow hold handling and the generalized capacity-release path.
if (isWorkflowColumnsEnabled(settings)) {
await this.runHoldReleaseSweepPass();
await this.runHoldReleaseSweepPass(tasks, settings);
tasks = await this.store.listTasks({ slim: true, includeArchived: false, startupMemo: false });
settings = await this.store.getSettings();
}
@@ -2122,24 +2122,31 @@ export class Scheduler {
* worktree allocation into the reservation-first ordering (KTD-10). Failures
* are isolated so a sweep error never breaks the scheduling pass.
*/
private async runHoldReleaseSweepPass(): Promise<void> {
private async runHoldReleaseSweepPass(tasks: Task[], settings: Settings): Promise<void> {
try {
const maxWorktrees = settings.maxWorktrees ?? this.options.maxWorktrees ?? 4;
let reservedWorktreeSlots = tasks.filter((task) => task.column === "in-progress").length;
await runHoldReleaseSweep(this.store, {
now: () => Date.now(),
reserveSlot: this.options.semaphore
? (): SlotReservation | null => {
const sem = this.options.semaphore!;
if (!sem.tryAcquire()) return null;
let released = false;
return {
release: () => {
if (released) return;
released = true;
sem.release();
},
};
reserveSlot: (): SlotReservation | null => {
if (Number.isFinite(maxWorktrees) && reservedWorktreeSlots >= maxWorktrees) {
return null;
}
: undefined,
const sem = this.options.semaphore;
if (sem && !sem.tryAcquire()) return null;
reservedWorktreeSlots += 1;
let released = false;
return {
release: () => {
if (released) return;
released = true;
reservedWorktreeSlots = Math.max(0, reservedWorktreeSlots - 1);
sem?.release();
},
};
},
allocateWorktree: (task, reservedNames) =>
planTaskWorktreePath(task, this.store.getRootDir(), undefined, reservedNames, {}),
});