feat(FN-5319): tighten scheduler queued-concurrency memo key to reduce fals

Add regression tests for queued concurrency in the scheduler and tighten the memo key used to track queued tasks, improving correctness of scheduler dispatch decisions.

Fusion-Task-Id: FN-5319
This commit is contained in:
Fusion (runfusion.ai)
2026-05-20 06:51:35 -07:00
committed by gsxdsm
parent 9efcf9375a
commit cee2c1385a
2 changed files with 107 additions and 16 deletions

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import type { PrMonitor } from "../pr-monitor.js";
import { Scheduler, pathsOverlap, filterPathsByIgnoreList } from "../scheduler.js";
import { Scheduler, pathsOverlap, filterPathsByIgnoreList, formatConcurrencyLimitMemoKey } from "../scheduler.js";
import { AgentSemaphore } from "../concurrency.js";
import type { TaskStore, Task, TaskDetail } from "@fusion/core";
import { existsSync } from "node:fs";
@@ -1176,6 +1176,87 @@ describe("Scheduler", () => {
expect(auditCalls[0]?.[0]?.metadata?.bindingGates).toEqual(["maxWorktrees"]);
});
it("dedupes queued-concurrency logs across used/limit churn on the same binding gate", async () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFile).mockResolvedValue("# Task\nDo something");
let semaphoreLimit = 2;
const semaphore = new AgentSemaphore(() => semaphoreLimit);
const tasks = [
createMockTask({ id: "FN-A", column: "in-progress" }),
createMockTask({ id: "FN-B", column: "todo", dependencies: [] }),
createMockTask({ id: "FN-C", column: "todo", dependencies: [] }),
];
const store = createMockStore({
listTasks: vi.fn().mockResolvedValue(tasks),
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 10, maxWorktrees: 10 }),
});
const scheduler = new Scheduler(store, { semaphore });
(scheduler as any).running = true;
await semaphore.acquire();
await scheduler.schedule();
semaphoreLimit = 3;
await semaphore.acquire();
await scheduler.schedule();
const concurrencyReasonCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls.filter(
(call: unknown[]) => call[0] === "FN-C" && String(call[1]).includes("queued — concurrency limit reached"),
);
expect(concurrencyReasonCalls).toHaveLength(1);
expect(String(concurrencyReasonCalls[0]?.[1])).toContain("semaphore used=1/2");
const auditCalls = (store.recordRunAuditEvent as ReturnType<typeof vi.fn>).mock.calls.filter(
(call: unknown[]) => (call[0] as { mutationType?: string } | undefined)?.mutationType === "scheduler:dispatch-queued-concurrency",
);
expect(auditCalls).toHaveLength(1);
expect(auditCalls[0]?.[0]?.metadata?.bindingGates).toEqual(["semaphore"]);
expect(auditCalls[0]?.[0]?.metadata?.semaphore).toEqual({ used: 1, limit: 2, slack: 1 });
});
it("re-logs and re-audits when binding holder identity changes", async () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFile).mockResolvedValue("# Task\nDo something");
const firstPass = [
createMockTask({ id: "FN-A", column: "in-progress" }),
createMockTask({ id: "FN-C", column: "todo", dependencies: [] }),
createMockTask({ id: "FN-D", column: "todo", dependencies: [] }),
];
const secondPass = [
createMockTask({ id: "FN-B", column: "in-progress" }),
createMockTask({ id: "FN-C", column: "todo", dependencies: [] }),
createMockTask({ id: "FN-D", column: "todo", dependencies: [] }),
];
let phase = 1;
const store = createMockStore({
listTasks: vi.fn().mockImplementation(async () => (phase === 1 ? firstPass : secondPass)),
getSettings: vi.fn().mockResolvedValue({ maxConcurrent: 10, maxWorktrees: 2 }),
});
const scheduler = new Scheduler(store);
(scheduler as any).running = true;
await scheduler.schedule();
phase = 2;
await scheduler.schedule();
const concurrencyReasonCalls = (store.logEntry as ReturnType<typeof vi.fn>).mock.calls.filter(
(call: unknown[]) => call[0] === "FN-D" && String(call[1]).includes("queued — concurrency limit reached"),
);
expect(concurrencyReasonCalls).toHaveLength(2);
const auditCalls = (store.recordRunAuditEvent as ReturnType<typeof vi.fn>).mock.calls.filter(
(call: unknown[]) => (call[0] as { mutationType?: string } | undefined)?.mutationType === "scheduler:dispatch-queued-concurrency",
);
expect(auditCalls).toHaveLength(2);
expect(auditCalls[0]?.[0]?.metadata?.bindingGates).toEqual(["maxWorktrees"]);
expect(auditCalls[1]?.[0]?.metadata?.bindingGates).toEqual(["maxWorktrees"]);
expect(auditCalls[0]?.[0]?.metadata?.holders?.maxWorktrees).toEqual(["FN-A"]);
expect(auditCalls[1]?.[0]?.metadata?.holders?.maxWorktrees).toEqual(["FN-B"]);
});
it("re-logs and re-audits when binding gate changes", async () => {
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFile).mockResolvedValue("# Task\nDo something");
@@ -1220,6 +1301,25 @@ describe("Scheduler", () => {
expect(auditCalls[0]?.[0]?.metadata?.bindingGates).toEqual(["maxConcurrent"]);
expect(auditCalls[1]?.[0]?.metadata?.bindingGates).toEqual(["maxWorktrees"]);
});
it("formats queued-concurrency memo keys from binding gates and holder identity only", () => {
const key = formatConcurrencyLimitMemoKey({
available: 0,
bindingGates: ["maxConcurrent", "maxWorktrees"],
maxConcurrentGate: { used: 1, limit: 2, slack: 1 },
maxWorktreesGate: { used: 9, limit: 10, slack: 1 },
semaphoreGate: { used: 7, limit: 8, slack: 1 },
holders: {
maxConcurrent: ["FN-B", "FN-A"],
maxWorktrees: ["FN-A", "FN-B", "FN-A"],
semaphore: ["FN-Z"],
},
});
expect(key).toBe("queued-concurrency:maxConcurrent,maxWorktrees:holders=FN-A,FN-B");
expect(key).not.toMatch(/used=|limit=|available=|\d+\/\d+/);
expect(key).not.toContain("FN-Z");
});
});
describe("priority-aware todo dispatch", () => {

View File

@@ -189,22 +189,13 @@ function formatConcurrencyLimitReason(diagnostic: ConcurrencyGateDiagnostic): st
return `queued — concurrency limit reached: gate=${gateLabel}; ${details.join("; ")}`;
}
function formatConcurrencyLimitMemoKey(diagnostic: ConcurrencyGateDiagnostic): string {
export function formatConcurrencyLimitMemoKey(diagnostic: ConcurrencyGateDiagnostic): string {
const gates = diagnostic.bindingGates.join(",");
const gateDetails = diagnostic.bindingGates.map((gate) => {
const snapshot = gate === "maxConcurrent"
? diagnostic.maxConcurrentGate
: gate === "maxWorktrees"
? diagnostic.maxWorktreesGate
: diagnostic.semaphoreGate;
const holders = diagnostic.holders[gate];
const holderKey = holders && holders.length > 0 ? holders.join(",") : "none";
if (!snapshot) {
return `${gate}:missing:${holderKey}`;
}
return `${gate}:${snapshot.used}/${snapshot.limit}:${holderKey}`;
});
return `queued-concurrency:${gates}:${gateDetails.join("|")}`;
const bindingHolders = [...new Set(
diagnostic.bindingGates.flatMap((gate) => diagnostic.holders[gate] ?? []),
)].sort();
const holderKey = bindingHolders.length > 0 ? bindingHolders.join(",") : "none";
return `queued-concurrency:${gates}:holders=${holderKey}`;
}
export interface SchedulerOptions {