feat(FN-5009): merge fusion/fn-5009

This commit is contained in:
gsxdsm
2026-05-18 12:31:21 -07:00
parent 9aa7518bad
commit a8a413cc91
10 changed files with 513 additions and 0 deletions

View File

@@ -0,0 +1,206 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { Task, TaskStore } from "@fusion/core";
import { BacklogPressureReporter } from "../backlog-pressure-reporter.js";
function createTask(overrides: Partial<Task> = {}): Task {
return {
id: "FN-1",
description: "test",
title: "Test task",
column: "todo",
priority: "normal",
dependencies: [],
steps: [],
currentStep: 0,
paused: false,
status: undefined,
blockedBy: "",
overlapBlockedBy: "",
log: [],
createdAt: "2026-05-18T00:00:00.000Z",
updatedAt: "2026-05-18T00:00:00.000Z",
...overrides,
} as Task;
}
function createStore(params: {
settings?: Record<string, unknown>;
todoSlim?: Task[];
inProgressSlim?: Task[];
todoFull?: Task[];
allTasks?: Task[];
insightStore?: { upsertInsight: ReturnType<typeof vi.fn>; listInsights: ReturnType<typeof vi.fn> };
throwInsightStore?: boolean;
}): TaskStore {
const listTasks = vi.fn().mockImplementation(async (options?: { column?: string; slim?: boolean }) => {
if (options?.column === "todo" && options?.slim) return params.todoSlim ?? [];
if (options?.column === "in-progress" && options?.slim) return params.inProgressSlim ?? [];
if (options?.column === "todo" && !options?.slim) return params.todoFull ?? [];
if (!options?.column && options?.slim) return params.allTasks ?? [];
return [];
});
return {
getSettings: vi.fn().mockResolvedValue(params.settings ?? {}),
listTasks,
getInsightStore: vi.fn().mockImplementation(() => {
if (params.throwInsightStore) throw new Error("missing insight store");
return params.insightStore;
}),
logEntry: vi.fn().mockResolvedValue(undefined),
} as unknown as TaskStore;
}
describe("BacklogPressureReporter", () => {
const logger = { warn: vi.fn(), error: vi.fn() };
beforeEach(() => {
vi.clearAllMocks();
});
it("no-ops when disabled", async () => {
const store = createStore({ settings: { backlogPressureAlertEnabled: false } });
const reporter = new BacklogPressureReporter({ store, projectId: "/tmp/project", logger });
await expect(reporter.report()).resolves.toEqual({ alerted: false, reason: "disabled" });
});
it.each([
{ todo: 25, inProgress: 5, expected: "under-threshold" },
{ todo: 4, inProgress: 0, expected: "under-threshold", settings: { backlogPressureMinTodoCount: 5 } },
])("no-ops for threshold matrix %#", async ({ todo, inProgress, expected, settings }) => {
const todoSlim = Array.from({ length: todo }, (_, i) => createTask({ id: `FN-T${i}`, title: `Todo ${i}` }));
const inProgressSlim = Array.from({ length: inProgress }, (_, i) => createTask({ id: `FN-P${i}`, column: "in-progress" }));
const store = createStore({ settings, todoSlim, inProgressSlim });
const reporter = new BacklogPressureReporter({ store, projectId: "/tmp/project", logger });
await expect(reporter.report()).resolves.toEqual({ alerted: false, reason: expected });
});
it("no-ops when fewer than 3 runnable candidates exist", async () => {
const todoSlim = Array.from({ length: 20 }, (_, i) => createTask({ id: `FN-T${i}` }));
const inProgressSlim = [createTask({ id: "FN-P1", column: "in-progress" })];
const todoFull = [
createTask({ id: "FN-1", blockedBy: "FN-0" }),
createTask({ id: "FN-2", paused: true }),
createTask({ id: "FN-3", status: "queued" as Task["status"] }),
createTask({ id: "FN-4" }),
createTask({ id: "FN-5" }),
];
const allTasks = [...todoFull, createTask({ id: "FN-0", column: "todo" })];
const store = createStore({ todoSlim, inProgressSlim, todoFull, allTasks, insightStore: { upsertInsight: vi.fn(), listInsights: vi.fn().mockReturnValue([]) } });
const reporter = new BacklogPressureReporter({ store, projectId: "/tmp/project", logger });
await expect(reporter.report()).resolves.toEqual({ alerted: false, reason: "insufficient-candidates" });
});
it("excludes dependency-blocked candidates but keeps missing-dependency refs", async () => {
const todoSlim = Array.from({ length: 44 }, (_, i) => createTask({ id: `FN-T${i}` }));
const inProgressSlim = [createTask({ id: "FN-P1", column: "in-progress" }), createTask({ id: "FN-P2", column: "in-progress" }), createTask({ id: "FN-P3", column: "in-progress" })];
const todoFull = [
createTask({ id: "FN-A", priority: "urgent", blockedBy: "FN-X" }),
createTask({ id: "FN-B", priority: "high", overlapBlockedBy: "FN-Y" }),
createTask({ id: "FN-C", priority: "high", status: "queued" as Task["status"] }),
createTask({ id: "FN-D", priority: "high", paused: true }),
createTask({ id: "FN-E", priority: "urgent", dependencies: ["FN-DEP-TODO"] }),
createTask({ id: "FN-F", priority: "urgent", dependencies: ["FN-DEP-MISSING"] }),
createTask({ id: "FN-G", priority: "high" }),
createTask({ id: "FN-H", priority: "normal" }),
];
const allTasks = [
...todoFull,
createTask({ id: "FN-DEP-TODO", column: "todo" }),
createTask({ id: "FN-DEP-DONE", column: "done" }),
];
const insightStore = { upsertInsight: vi.fn(), listInsights: vi.fn().mockReturnValue([]) };
const reporter = new BacklogPressureReporter({
store: createStore({ todoSlim, inProgressSlim, todoFull, allTasks, insightStore }),
projectId: "/tmp/project",
logger,
now: () => Date.parse("2026-05-18T12:00:00.000Z"),
});
const result = await reporter.report();
expect(result.alerted).toBe(true);
const content = JSON.parse(insightStore.upsertInsight.mock.calls[0][1].content);
const ids = content.candidates.map((candidate: { id: string }) => candidate.id);
expect(ids).toContain("FN-F");
expect(ids).toContain("FN-G");
expect(ids).toContain("FN-H");
expect(ids).not.toContain("FN-A");
expect(ids).not.toContain("FN-B");
expect(ids).not.toContain("FN-C");
expect(ids).not.toContain("FN-D");
expect(ids).not.toContain("FN-E");
});
it("emits payload with counts, ratio, detectedAt, and candidates", async () => {
const now = Date.parse("2026-05-18T12:00:00.000Z");
const todoSlim = Array.from({ length: 44 }, (_, i) => createTask({ id: `FN-T${i}` }));
const inProgressSlim = [createTask({ id: "FN-P1", column: "in-progress" }), createTask({ id: "FN-P2", column: "in-progress" }), createTask({ id: "FN-P3", column: "in-progress" })];
const todoFull = Array.from({ length: 8 }, (_, i) => createTask({ id: `FN-C${i}`, title: `Candidate ${i}`, priority: i === 0 ? "urgent" : "normal" }));
const insightStore = { upsertInsight: vi.fn(), listInsights: vi.fn().mockReturnValue([]) };
const store = createStore({ todoSlim, inProgressSlim, todoFull, allTasks: todoFull, insightStore });
const reporter = new BacklogPressureReporter({ store, projectId: "/tmp/project", logger, now: () => now });
const result = await reporter.report();
expect(result).toEqual({ alerted: true });
expect(insightStore.upsertInsight).toHaveBeenCalledTimes(1);
const input = insightStore.upsertInsight.mock.calls[0][1];
const content = JSON.parse(input.content);
expect(content.todoCount).toBe(44);
expect(content.inProgressCount).toBe(3);
expect(content.ratio).toBe(14.67);
expect(content.detectedAt).toBe("2026-05-18T12:00:00.000Z");
expect(content.candidates.length).toBeGreaterThanOrEqual(3);
});
it("respects cooldown window", async () => {
vi.useFakeTimers();
try {
const baseNow = Date.parse("2026-05-18T12:00:00.000Z");
vi.setSystemTime(baseNow);
const todoSlim = Array.from({ length: 44 }, (_, i) => createTask({ id: `FN-T${i}` }));
const inProgressSlim = Array.from({ length: 3 }, (_, i) => createTask({ id: `FN-P${i}`, column: "in-progress" }));
const todoFull = Array.from({ length: 5 }, (_, i) => createTask({ id: `FN-C${i}` }));
const insightStore = {
upsertInsight: vi.fn(),
listInsights: vi.fn().mockReturnValue([]),
};
const store = createStore({ todoSlim, inProgressSlim, todoFull, allTasks: todoFull, insightStore, settings: { backlogPressureAlertCooldownMs: 60_000 } });
const reporter = new BacklogPressureReporter({ store, projectId: "/tmp/project", logger, now: () => Date.now() });
await reporter.report();
expect(insightStore.upsertInsight).toHaveBeenCalledTimes(1);
insightStore.listInsights.mockReturnValue([
{ title: "Backlog pressure detected 2026-05-18", updatedAt: new Date(Date.now()).toISOString() },
]);
await expect(reporter.report()).resolves.toEqual({ alerted: false, reason: "under-threshold" });
expect(insightStore.upsertInsight).toHaveBeenCalledTimes(1);
vi.advanceTimersByTime(61_000);
insightStore.listInsights.mockReturnValue([
{ title: "Backlog pressure detected 2026-05-18", updatedAt: new Date(baseNow).toISOString() },
]);
await reporter.report();
expect(insightStore.upsertInsight).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
});
it("falls back to task log entry when insight store is unavailable", async () => {
const todoSlim = Array.from({ length: 44 }, (_, i) => createTask({ id: `FN-T${i}` }));
const inProgressSlim = Array.from({ length: 3 }, (_, i) => createTask({ id: `FN-P${i}`, column: "in-progress" }));
const todoFull = [
createTask({ id: "FN-1", priority: "urgent" }),
createTask({ id: "FN-2", priority: "high" }),
createTask({ id: "FN-3", priority: "normal" }),
];
const store = createStore({ todoSlim, inProgressSlim, todoFull, allTasks: todoFull, throwInsightStore: true });
const reporter = new BacklogPressureReporter({ store, projectId: "/tmp/project", logger });
const result = await reporter.report();
expect(result).toEqual({ alerted: true });
expect(store.logEntry).toHaveBeenCalledTimes(1);
expect(store.logEntry).toHaveBeenCalledWith("FN-1", expect.stringContaining("[backlog-pressure]"));
});
});

View File

@@ -89,6 +89,7 @@ describe("reliability interactions: lease recovery central claim", () => {
parseFileScopeFromPrompt: vi.fn().mockResolvedValue([]),
on: vi.fn(),
off: vi.fn(),
getRootDir: vi.fn().mockReturnValue("/test/project"),
} as unknown as TaskStore;
const reconcileLeaseRow = vi.fn().mockResolvedValue(true);

View File

@@ -13,6 +13,7 @@ function createStore() {
const store = {
on,
off: vi.fn(),
getRootDir: vi.fn().mockReturnValue("/test/project"),
} as unknown as TaskStore;
const emit = (event: string, payload: unknown) => {

View File

@@ -8,6 +8,7 @@ import { readFile } from "node:fs/promises";
import { schedulerLog } from "../logger.js";
const staleReporterReportMock = vi.fn();
const backlogPressureReporterReportMock = vi.fn();
// Mock fs modules
vi.mock("node:fs", async (importOriginal) => {
@@ -45,6 +46,12 @@ vi.mock("../stale-task-reporter.js", () => ({
})),
}));
vi.mock("../backlog-pressure-reporter.js", () => ({
BacklogPressureReporter: vi.fn().mockImplementation(() => ({
report: backlogPressureReporterReportMock,
})),
}));
// Helper to create mock tasks
function createMockTask(overrides: Partial<Task> = {}): Task {
return {
@@ -162,6 +169,7 @@ describe("filterPathsByIgnoreList", () => {
describe("Scheduler", () => {
beforeEach(() => {
staleReporterReportMock.mockReset().mockResolvedValue({ surfaced: 0 });
backlogPressureReporterReportMock.mockReset().mockResolvedValue({ alerted: false });
});
// Helper to create mock MissionStore (shared across mission-related test suites)
function createMockMissionStore(overrides = {}) {
@@ -251,6 +259,57 @@ describe("Scheduler", () => {
});
});
describe("backlog pressure reporter integration", () => {
it("invokes reporter from schedule when enabled", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-05-18T12:00:00.000Z"));
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFile).mockResolvedValue("# Task\nDo something");
const store = createMockStore({
listTasks: vi.fn().mockResolvedValue([createMockTask({ id: "FN-BACKLOG", column: "todo", dependencies: [] })]),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
staleInProgressWarningMs: 0,
staleInReviewWarningMs: 0,
}),
});
const scheduler = new Scheduler(store);
(scheduler as unknown as { running: boolean }).running = true;
await scheduler.schedule();
expect(backlogPressureReporterReportMock).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
}
});
it("does not invoke reporter when disabled", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-05-18T12:00:00.000Z"));
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(readFile).mockResolvedValue("# Task\nDo something");
const store = createMockStore({
listTasks: vi.fn().mockResolvedValue([createMockTask({ id: "FN-BACKLOG", column: "todo", dependencies: [] })]),
getSettings: vi.fn().mockResolvedValue({
maxConcurrent: 2,
maxWorktrees: 4,
backlogPressureAlertEnabled: false,
staleInProgressWarningMs: 0,
staleInReviewWarningMs: 0,
}),
});
const scheduler = new Scheduler(store);
(scheduler as unknown as { running: boolean }).running = true;
await scheduler.schedule();
expect(backlogPressureReporterReportMock).not.toHaveBeenCalled();
} finally {
vi.useRealTimers();
}
});
});
describe("constructor", () => {
it("initializes with default options", () => {
const store = createMockStore();

View File

@@ -0,0 +1,180 @@
import { computeInsightFingerprint, type Task, type TaskPriority, type TaskStore } from "@fusion/core";
import { createLogger } from "./logger.js";
const reporterLog = createLogger("backlog-pressure");
const TOP_CANDIDATES = 5;
const TITLE_PREFIX = "Backlog pressure detected";
const PRIORITY_WEIGHT: Record<TaskPriority, number> = {
urgent: 0,
high: 1,
normal: 2,
low: 3,
};
type BacklogPressureLogger = {
warn: (message: string, ...args: unknown[]) => void;
error?: (message: string, ...args: unknown[]) => void;
};
interface BacklogPressureReporterOptions {
store: TaskStore;
projectId: string;
logger?: BacklogPressureLogger;
now?: () => number;
}
/**
* Detects sustained backlog pressure (high todo inventory vs low in-progress throughput)
* and surfaces a durable workflow insight. If InsightStore access is unavailable, it
* falls back to writing the same payload to the top candidate task log entry.
*/
export class BacklogPressureReporter {
private readonly store: TaskStore;
private readonly projectId: string;
private readonly logger: BacklogPressureLogger;
private readonly now: () => number;
constructor(options: BacklogPressureReporterOptions) {
this.store = options.store;
this.projectId = options.projectId;
this.logger = options.logger ?? reporterLog;
this.now = options.now ?? (() => Date.now());
}
async report(): Promise<{ alerted: boolean; reason?: string }> {
try {
const settings = await this.store.getSettings();
if (settings.backlogPressureAlertEnabled === false) {
return { alerted: false, reason: "disabled" };
}
const ratioThreshold = settings.backlogPressureRatioThreshold ?? 10;
const minTodoCount = settings.backlogPressureMinTodoCount ?? 5;
if (!Number.isFinite(ratioThreshold) || ratioThreshold <= 0 || !Number.isFinite(minTodoCount) || minTodoCount <= 0) {
this.logger.warn("[backlog-pressure] invalid config: thresholds must be positive finite numbers");
return { alerted: false, reason: "invalid-config" };
}
const [todoSlim, inProgressSlim] = await Promise.all([
this.store.listTasks({ column: "todo", slim: true }),
this.store.listTasks({ column: "in-progress", slim: true }),
]);
const todoCount = todoSlim.length;
const inProgressCount = inProgressSlim.length;
const ratio = todoCount / Math.max(inProgressCount, 1);
if (todoCount < minTodoCount || ratio <= ratioThreshold) {
return { alerted: false, reason: "under-threshold" };
}
const [todoFull, allTasks] = await Promise.all([
this.store.listTasks({ column: "todo" }),
this.store.listTasks({ slim: true, includeArchived: true }),
]);
const byId = new Map(allTasks.map((task) => [task.id, task]));
const candidates = todoFull
.filter((task) => this.isRunnableCandidate(task, byId))
.sort((a, b) => {
const pa = PRIORITY_WEIGHT[a.priority ?? "normal"];
const pb = PRIORITY_WEIGHT[b.priority ?? "normal"];
if (pa !== pb) return pa - pb;
return Date.parse(a.createdAt) - Date.parse(b.createdAt);
})
.slice(0, TOP_CANDIDATES);
if (candidates.length < 3) {
return { alerted: false, reason: "insufficient-candidates" };
}
const nowMs = this.now();
const cooldownMs = settings.backlogPressureAlertCooldownMs ?? 24 * 60 * 60_000;
const detectedAtIso = new Date(nowMs).toISOString();
const dayBucket = detectedAtIso.slice(0, 10);
const title = `${TITLE_PREFIX} ${dayBucket}`;
const contentPayload = {
todoCount,
inProgressCount,
ratio: Number(ratio.toFixed(2)),
detectedAt: detectedAtIso,
candidates: candidates.map((candidate) => ({
id: candidate.id,
title: candidate.title,
priority: candidate.priority,
})),
};
const content = JSON.stringify(contentPayload);
const candidateIds = candidates.map((c) => c.id);
let insightStore;
try {
if (!this.projectId) {
throw new Error("empty projectId");
}
insightStore = this.store.getInsightStore();
} catch (error) {
await this.store.logEntry(candidates[0].id, `[backlog-pressure] ${content}`);
this.logger.warn("[backlog-pressure] insight store unavailable; logged fallback payload", error);
this.logger.warn(`[backlog-pressure] alert: todo=${todoCount} inProgress=${inProgressCount} ratio=${ratio.toFixed(2)} candidates=${candidateIds.join(",")}`);
return { alerted: true };
}
if (cooldownMs > 0 && Number.isFinite(cooldownMs)) {
const insights = insightStore.listInsights({
projectId: this.projectId,
category: "workflow",
status: "generated",
limit: 5,
});
const latest = [...insights]
.filter((insight) => insight.title.startsWith(TITLE_PREFIX))
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))[0];
if (latest) {
const updatedAt = Date.parse(latest.updatedAt);
if (Number.isFinite(updatedAt) && nowMs - updatedAt < cooldownMs) {
return { alerted: false, reason: "under-threshold" };
}
}
}
const fingerprint = computeInsightFingerprint(title, "workflow");
insightStore.upsertInsight(this.projectId, {
title,
content,
category: "workflow",
fingerprint,
provenance: {
trigger: "schedule",
description: "Todo:InProgress imbalance heuristic (generated by backlog-pressure-reporter)",
relatedEntityIds: candidateIds,
metadata: { generator: "backlog-pressure-reporter" },
},
});
this.logger.warn(`[backlog-pressure] alert: todo=${todoCount} inProgress=${inProgressCount} ratio=${ratio.toFixed(2)} candidates=${candidateIds.join(",")}`);
return { alerted: true };
} catch (error) {
this.logger.error?.("[backlog-pressure] reporter failed", error);
return { alerted: false, reason: "error" };
}
}
private isRunnableCandidate(task: Task, byId: Map<string, Task>): boolean {
if (task.paused) return false;
if ((task.blockedBy ?? "").trim().length > 0) return false;
if ((task.overlapBlockedBy ?? "").trim().length > 0) return false;
if (task.status === "queued") return false;
for (const depId of task.dependencies ?? []) {
const dependency = byId.get(depId);
if (!dependency) continue;
if (dependency.column !== "done") {
return false;
}
}
return true;
}
}
export { TOP_CANDIDATES };

View File

@@ -29,6 +29,7 @@ import type { MeshLeaseManager } from "./mesh-lease-manager.js";
import { selectPermanentAgentForTask } from "./agent-assignment.js";
import type { AutoClaimSnapshotManager } from "./auto-claim-snapshot.js";
import { StaleTaskReporter } from "./stale-task-reporter.js";
import { BacklogPressureReporter } from "./backlog-pressure-reporter.js";
import { createRunAuditor, generateSyntheticRunId } from "./run-audit.js";
/**
@@ -281,7 +282,9 @@ export class Scheduler {
/** Tracks dispatch-queued reason signatures to avoid per-tick log spam. */
private wasDispatchQueuedReasonLogged = new Set<string>();
private readonly staleTaskReporter: StaleTaskReporter;
private readonly backlogPressureReporter: BacklogPressureReporter;
private lastStaleTaskReportAt = 0;
private lastBacklogPressureReportAt = 0;
private readonly lastHighOverlapFanoutWarningKey = new Map<string, string>();
/**
@@ -296,6 +299,11 @@ export class Scheduler {
private options: SchedulerOptions = {},
) {
this.staleTaskReporter = new StaleTaskReporter({ store: this.store });
this.backlogPressureReporter = new BacklogPressureReporter({
store: this.store,
projectId: this.store.getRootDir(),
logger: schedulerLog,
});
/**
* Event-driven scheduling: when a task is created, trigger a scheduling
* pass immediately instead of waiting for the next poll interval.
@@ -1399,6 +1407,16 @@ export class Scheduler {
schedulerLog.warn("Stale task reporter failed", error);
}
}
if (settings.backlogPressureAlertEnabled !== false && Date.now() - this.lastBacklogPressureReportAt >= 60_000) {
try {
await this.backlogPressureReporter.report();
} catch (error) {
schedulerLog.warn("Backlog pressure reporter failed", error);
} finally {
this.lastBacklogPressureReportAt = Date.now();
}
}
} catch (err) {
schedulerLog.error("Scheduling error:", err);
} finally {