feat(FN-5009): merge fusion/fn-5009
This commit is contained in:
206
packages/engine/src/__tests__/backlog-pressure-reporter.test.ts
Normal file
206
packages/engine/src/__tests__/backlog-pressure-reporter.test.ts
Normal 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]"));
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
180
packages/engine/src/backlog-pressure-reporter.ts
Normal file
180
packages/engine/src/backlog-pressure-reporter.ts
Normal 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 };
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user