diff --git a/tools/bg/backlog-ready-notifier.test.ts b/tools/bg/backlog-ready-notifier.test.ts new file mode 100644 index 000000000..0573d1017 --- /dev/null +++ b/tools/bg/backlog-ready-notifier.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; +import { DEFAULT_CONFIG, pollOnce, runNotifier, type NotifierConfig } from "./backlog-ready-notifier"; + +describe("backlog-ready-notifier slice 1", () => { + test("default config has sensible poll interval", () => { + expect(DEFAULT_CONFIG.pollIntervalMin).toBe(10); + expect(DEFAULT_CONFIG.once).toBe(false); + }); + + test("pollOnce returns a result with no-op scan", () => { + const result = pollOnce(DEFAULT_CONFIG); + expect(result.readyRowsFound).toBe(0); + expect(result.assignmentsPublished).toBe(0); + expect(result.note).toContain("slice-1 skeleton"); + expect(result.pollAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + test("runNotifier with once: true exits after one iteration", async () => { + const config: NotifierConfig = { ...DEFAULT_CONFIG, once: true }; + const results = await runNotifier(config); + expect(results).toHaveLength(1); + expect(results[0].readyRowsFound).toBe(0); + }); +}); diff --git a/tools/bg/backlog-ready-notifier.ts b/tools/bg/backlog-ready-notifier.ts new file mode 100644 index 000000000..d7851e0c1 --- /dev/null +++ b/tools/bg/backlog-ready-notifier.ts @@ -0,0 +1,92 @@ +// backlog-ready-notifier.ts — B-0441 slice 1: skeleton + no-op poll loop +// +// Background service that proactively surfaces ready-to-grind backlog rows +// (open, dependencies satisfied) to agents whose queue is empty. Composes +// with B-0440 (Standing-by detector): B-0440 catches the failure mode AFTER +// it occurs (reactive); this service PREVENTS the failure mode by surfacing +// work BEFORE the agent goes idle (proactive). +// +// This slice ships ONLY the skeleton. Future slices add backlog parsing, +// queue-state detection, and bus integration. +// +// Run: bun tools/bg/backlog-ready-notifier.ts [--once] [--poll-min N] +// Compose with: B-0441 + B-0400 (bus) + B-0440 (reactive peer). + +export type NotifierConfig = { + /** How often to poll, in minutes */ + pollIntervalMin: number; + /** When true, run a single poll and exit */ + once: boolean; +}; + +export const DEFAULT_CONFIG: NotifierConfig = { + pollIntervalMin: 10, + once: false, +}; + +export type PollResult = { + pollAt: string; // ISO-8601 + readyRowsFound: number; + assignmentsPublished: number; + note: string; +}; + +/** + * Single poll iteration. Slice 1 returns a no-op result. Future slices + * implement backlog scan + queue-state detection + assignment publish. + */ +export function pollOnce(_config: NotifierConfig): PollResult { + return { + pollAt: new Date().toISOString(), + readyRowsFound: 0, + assignmentsPublished: 0, + note: "slice-1 skeleton — no scan yet; future slices add backlog-readiness + queue-state + bus publish", + }; +} + +/** + * Run the notifier loop. When `once: true`, runs exactly one iteration and + * returns its result. Otherwise sleeps for pollIntervalMin between + * iterations and runs forever; results are NOT accumulated. + */ +export async function runNotifier(config: NotifierConfig = DEFAULT_CONFIG): Promise { + if (config.once) { + const result = pollOnce(config); + console.log(JSON.stringify(result)); + return [result]; + } + + while (true) { + const result = pollOnce(config); + console.log(JSON.stringify(result)); + await new Promise(resolve => setTimeout(resolve, config.pollIntervalMin * 60 * 1000)); + } +} + +function parsePositiveMinutes(raw: string | undefined, name: string): number { + if (raw === undefined) throw new Error(`${name} requires a value`); + const n = Number(raw); + if (!Number.isFinite(n) || n <= 0) { + throw new Error(`${name} must be a positive finite number; got "${raw}"`); + } + return n; +} + +function parseArgs(argv: string[]): NotifierConfig { + const config: NotifierConfig = { ...DEFAULT_CONFIG }; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === "--once") config.once = true; + else if (arg === "--poll-min") { + config.pollIntervalMin = parsePositiveMinutes(argv[++i], "--poll-min"); + } + } + + return config; +} + +if (import.meta.main) { + const config = parseArgs(process.argv.slice(2)); + await runNotifier(config); +}