feat(FN-3977): complete Step 2 — scheduler overlap bottleneck warnings
Fusion-Task-Id: FN-3977 Fusion-Task-Lineage: c84f2ce0-3726-49b2-ad84-6703edbfecf1
This commit is contained in:
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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();
|
||||
|
||||
@@ -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<string>();
|
||||
private readonly staleTaskReporter: StaleTaskReporter;
|
||||
private lastStaleTaskReportAt = 0;
|
||||
private readonly lastHighOverlapFanoutWarningKey = new Map<string, string>();
|
||||
|
||||
/**
|
||||
* Async listener guard convention:
|
||||
@@ -504,6 +507,33 @@ export class Scheduler {
|
||||
await this.store.logEntry(taskId, reason);
|
||||
}
|
||||
|
||||
private async emitHighOverlapFanoutWarnings(tasks: Task[]): Promise<void> {
|
||||
const fanoutMap = computeBlockerFanoutMap(tasks, 3);
|
||||
const seenBlockers = new Set<string>();
|
||||
|
||||
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<void> {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user