From eb9a838d1201827978691fd3419edbe40365f5a8 Mon Sep 17 00:00:00 2001 From: Fusion Date: Thu, 14 May 2026 15:36:41 -0700 Subject: [PATCH] =?UTF-8?q?feat(FN-3977):=20complete=20Step=202=20?= =?UTF-8?q?=E2=80=94=20scheduler=20overlap=20bottleneck=20warnings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fusion-Task-Id: FN-3977 Fusion-Task-Lineage: c84f2ce0-3726-49b2-ad84-6703edbfecf1 --- .../engine/src/__tests__/scheduler.test.ts | 85 +++++++++++++++++++ packages/engine/src/scheduler.ts | 32 +++++++ 2 files changed, 117 insertions(+) diff --git a/packages/engine/src/__tests__/scheduler.test.ts b/packages/engine/src/__tests__/scheduler.test.ts index f96b11056..1cd72fb7c 100644 --- a/packages/engine/src/__tests__/scheduler.test.ts +++ b/packages/engine/src/__tests__/scheduler.test.ts @@ -3018,6 +3018,91 @@ describe("Scheduler", () => { }); }); + describe("overlap bottleneck warnings", () => { + it("logs scheduler warning and blocker task log for high overlap fan-out", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); + + const tasks = [ + createMockTask({ id: "FN-B", column: "in-progress", blockedBy: undefined }), + createMockTask({ id: "FN-1", column: "todo", blockedBy: "FN-B", dependencies: [] }), + createMockTask({ id: "FN-2", column: "todo", blockedBy: "FN-B", dependencies: [] }), + createMockTask({ id: "FN-3", column: "todo", blockedBy: "FN-B", dependencies: [] }), + createMockTask({ id: "FN-4", column: "todo", blockedBy: "FN-B", dependencies: [] }), + createMockTask({ id: "FN-5", column: "todo", blockedBy: "FN-B", dependencies: [] }), + ]; + + const store = createMockStore({ + listTasks: vi.fn().mockResolvedValue(tasks), + getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 10, maxWorktrees: 10, groupOverlappingFiles: false }), + }); + + const scheduler = new Scheduler(store); + (scheduler as any).running = true; + await scheduler.schedule(); + + expect(schedulerLog.warn).toHaveBeenCalledWith(expect.stringContaining("Overlap bottleneck: FN-B is currently blocking 5 todo task(s) via blockedBy")); + expect(store.logEntry).toHaveBeenCalledWith( + "FN-B", + expect.stringContaining("Overlap bottleneck: FN-B is currently blocking 5 todo task(s) via blockedBy"), + ); + }); + + it("does not warn for small or dependency-only fan-out", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); + + const tasks = [ + createMockTask({ id: "FN-B", column: "in-progress" }), + createMockTask({ id: "FN-1", column: "todo", dependencies: ["FN-B"] }), + createMockTask({ id: "FN-2", column: "todo", dependencies: ["FN-B"] }), + createMockTask({ id: "FN-3", column: "todo", blockedBy: "FN-B" }), + ]; + + const store = createMockStore({ + listTasks: vi.fn().mockResolvedValue(tasks), + getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 10, maxWorktrees: 10, groupOverlappingFiles: false }), + }); + + const scheduler = new Scheduler(store); + (scheduler as any).running = true; + await scheduler.schedule(); + + expect((store.logEntry as ReturnType).mock.calls.some((call) => + call[0] === "FN-B" && String(call[1]).includes("Overlap bottleneck:"), + )).toBe(false); + }); + + it("dedupes unchanged overlap bottleneck warnings across passes", async () => { + vi.mocked(existsSync).mockReturnValue(true); + vi.mocked(readFile).mockResolvedValue("# Task\nDo something"); + + const tasks = [ + createMockTask({ id: "FN-B", column: "in-progress" }), + createMockTask({ id: "FN-1", column: "todo", blockedBy: "FN-B" }), + createMockTask({ id: "FN-2", column: "todo", blockedBy: "FN-B" }), + createMockTask({ id: "FN-3", column: "todo", blockedBy: "FN-B" }), + createMockTask({ id: "FN-4", column: "todo", blockedBy: "FN-B" }), + createMockTask({ id: "FN-5", column: "todo", blockedBy: "FN-B" }), + ]; + + const store = createMockStore({ + listTasks: vi.fn().mockResolvedValue(tasks), + getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 10, maxWorktrees: 10, groupOverlappingFiles: false }), + }); + + const scheduler = new Scheduler(store); + (scheduler as any).running = true; + await scheduler.schedule(); + await scheduler.schedule(); + + const overlapLogs = (store.logEntry as ReturnType).mock.calls.filter((call) => + call[0] === "FN-B" && String(call[1]).includes("Overlap bottleneck:"), + ); + expect(overlapLogs).toHaveLength(1); + }); + }); + describe("reconcileAllMissionFeatures", () => { it("returns early when missionStore is not provided", async () => { const store = createMockStore(); diff --git a/packages/engine/src/scheduler.ts b/packages/engine/src/scheduler.ts index a2b132a8e..7003071c1 100644 --- a/packages/engine/src/scheduler.ts +++ b/packages/engine/src/scheduler.ts @@ -2,6 +2,8 @@ import { getCurrentRepo, resolveDependencyOrder, sortTasksByPriorityThenAgeAndId, + computeBlockerFanoutMap, + HIGH_FANOUT_BLOCKER_TODO_THRESHOLD, type TaskStore, type Task, type MissionStore, @@ -190,6 +192,7 @@ export class Scheduler { private wasDispatchQueuedReasonLogged = new Set(); private readonly staleTaskReporter: StaleTaskReporter; private lastStaleTaskReportAt = 0; + private readonly lastHighOverlapFanoutWarningKey = new Map(); /** * Async listener guard convention: @@ -504,6 +507,33 @@ export class Scheduler { await this.store.logEntry(taskId, reason); } + private async emitHighOverlapFanoutWarnings(tasks: Task[]): Promise { + const fanoutMap = computeBlockerFanoutMap(tasks, 3); + const seenBlockers = new Set(); + + for (const [blockerId, fanout] of fanoutMap) { + if (fanout.overlapBlockedTodoCount < HIGH_FANOUT_BLOCKER_TODO_THRESHOLD) continue; + const state = fanout.escalation ? "long-lived" : "temporary"; + const dedupeKey = `${fanout.overlapBlockedTodoCount}:${state}`; + seenBlockers.add(blockerId); + + if (this.lastHighOverlapFanoutWarningKey.get(blockerId) === dedupeKey) { + continue; + } + + const message = `Overlap bottleneck: ${blockerId} is currently blocking ${fanout.overlapBlockedTodoCount} todo task(s) via blockedBy (${state}).`; + schedulerLog.warn(message); + await this.store.logEntry(blockerId, message); + this.lastHighOverlapFanoutWarningKey.set(blockerId, dedupeKey); + } + + for (const blockerId of this.lastHighOverlapFanoutWarningKey.keys()) { + if (!seenBlockers.has(blockerId)) { + this.lastHighOverlapFanoutWarningKey.delete(blockerId); + } + } + } + private async rollbackRunningAgentsForQueuedTodoTask(taskId: string): Promise { const agentStore = this.options.agentStore; if (!agentStore) return; @@ -1053,6 +1083,8 @@ export class Scheduler { } } + await this.emitHighOverlapFanoutWarnings(tasks); + const staleWarningWindows = [settings.staleInProgressWarningMs, settings.staleInReviewWarningMs] .filter((value): value is number => typeof value === "number" && Number.isFinite(value) && value > 0); const minWarningMs = staleWarningWindows.length > 0 ? Math.min(...staleWarningWindows) : 0;