From a53330786ec4d045cedd480c4416fb387905d080 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 9 Jun 2026 08:02:56 -0700 Subject: [PATCH] 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 --- .changeset/workflow-overlap-hold-release.md | 5 ++ .../engine/src/__tests__/scheduler.test.ts | 72 ++++++++++++++++ packages/engine/src/hold-release.ts | 4 +- packages/engine/src/scheduler.ts | 85 ++++++++++++++++++- 4 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 .changeset/workflow-overlap-hold-release.md diff --git a/.changeset/workflow-overlap-hold-release.md b/.changeset/workflow-overlap-hold-release.md new file mode 100644 index 0000000000..63faa58794 --- /dev/null +++ b/.changeset/workflow-overlap-hold-release.md @@ -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. diff --git a/packages/engine/src/__tests__/scheduler.test.ts b/packages/engine/src/__tests__/scheduler.test.ts index d39885ccaa..b670577900 100644 --- a/packages/engine/src/__tests__/scheduler.test.ts +++ b/packages/engine/src/__tests__/scheduler.test.ts @@ -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([ + ["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([ + ["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) => { + 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(); diff --git a/packages/engine/src/hold-release.ts b/packages/engine/src/hold-release.ts index a265cb923f..66fd86660b 100644 --- a/packages/engine/src/hold-release.ts +++ b/packages/engine/src/hold-release.ts @@ -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; /** Allocate a worktree path for a release into a processing column (passed * through to `moveTask`'s `allocateWorktree`). */ allocateWorktree?: (task: Task, reservedNames: Set) => 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}`); diff --git a/packages/engine/src/scheduler.ts b/packages/engine/src/scheduler.ts index fc31981913..4782b194e5 100644 --- a/packages/engine/src/scheduler.ts +++ b/packages/engine/src/scheduler.ts @@ -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(); + const activeScopeColumns = new Map(); + const overlapIgnorePaths = settings.overlapIgnorePaths ?? []; + const filteredScopeByTaskId = new Map(); + const getFilteredFileScope = async (taskId: string): Promise => { + 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 => { + 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(); },