diff --git a/tools/bg/standing-by-detector.test.ts b/tools/bg/standing-by-detector.test.ts index 1c8a2e8d7e..c8a1ef6185 100644 --- a/tools/bg/standing-by-detector.test.ts +++ b/tools/bg/standing-by-detector.test.ts @@ -6,40 +6,56 @@ import { pollOnce, type Adapters, } from "./standing-by-detector"; +import type { AgentId, MessageEnvelope, SenderAgentId } from "../bus/types"; -function fakeAdapters(nowIso: string, lastCommitIso: string | null): Adapters { +type FakeNudgeCall = { + from: SenderAgentId; + to: AgentId; + idleMinutes: number; + rationale: string; +}; + +function fakeAdapters( + nowIso: string, + lastCommitIso: string | null, + capturedCalls: FakeNudgeCall[] = [], +): Adapters { return { now: () => new Date(nowIso), lastCommitIso: () => lastCommitIso, + publishNudge: (from, to, idleMinutes, rationale): MessageEnvelope => { + capturedCalls.push({ from, to, idleMinutes, rationale }); + return { + id: "test-envelope-id", + from, + to, + timestamp: nowIso, + expiresAt: nowIso, + topic: "infinite-backlog-nudge", + payload: { idleMinutes, rationale }, + }; + }, }; } -describe("standing-by-detector slice 2", () => { - test("default config has sensible thresholds", () => { +describe("standing-by-detector slice 4", () => { + test("default config has sensible thresholds + bus defaults", () => { expect(DEFAULT_CONFIG.pollIntervalMin).toBe(5); expect(DEFAULT_CONFIG.idleThresholdMin).toBe(15); expect(DEFAULT_CONFIG.once).toBe(false); + expect(DEFAULT_CONFIG.noPublish).toBe(false); + expect(DEFAULT_CONFIG.fromAgent).toBe("otto"); + expect(DEFAULT_CONFIG.toAgent).toBe("*"); }); - test("pollOnce with adapters returns expected result shape (no daemon mode)", () => { - const result = pollOnce( - DEFAULT_CONFIG, - fakeAdapters("2026-05-13T18:00:00Z", "2026-05-13T17:58:00Z"), - ); - expect(result.pollAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); - expect(typeof result.idleDetected).toBe("boolean"); - expect(result.idleMinutes).toBe(2); - }); - - describe("pollOnce with injected adapters", () => { + describe("pollOnce with injected adapters — detection", () => { test("flags idle when last commit is older than threshold", () => { const result = pollOnce( - { ...DEFAULT_CONFIG, idleThresholdMin: 15 }, + { ...DEFAULT_CONFIG, idleThresholdMin: 15, noPublish: true }, fakeAdapters("2026-05-13T18:00:00Z", "2026-05-13T17:40:00Z"), ); expect(result.idleDetected).toBe(true); expect(result.idleMinutes).toBe(20); - expect(result.lastCommitAt).toBe("2026-05-13T17:40:00.000Z"); expect(result.note).toContain("Standing-by candidate"); }); @@ -50,36 +66,68 @@ describe("standing-by-detector slice 2", () => { ); expect(result.idleDetected).toBe(false); expect(result.idleMinutes).toBe(5); - expect(result.note).toContain("under threshold"); + expect(result.publishedEnvelopeId).toBeNull(); }); - test("flags idle at exactly the threshold (inclusive)", () => { + test("handles null lastCommit gracefully (no publish)", () => { + const result = pollOnce( + DEFAULT_CONFIG, + fakeAdapters("2026-05-13T18:00:00Z", null), + ); + expect(result.idleDetected).toBe(false); + expect(result.publishedEnvelopeId).toBeNull(); + expect(result.note).toContain("no commit found"); + }); + }); + + describe("pollOnce with injected adapters — bus publish", () => { + test("publishes nudge envelope when idle detected", () => { + const captured: FakeNudgeCall[] = []; const result = pollOnce( { ...DEFAULT_CONFIG, idleThresholdMin: 15 }, - fakeAdapters("2026-05-13T18:00:00Z", "2026-05-13T17:45:00Z"), + fakeAdapters("2026-05-13T18:00:00Z", "2026-05-13T17:40:00Z", captured), + ); + expect(result.publishedEnvelopeId).toBe("test-envelope-id"); + expect(result.note).toContain("nudge published"); + expect(captured).toHaveLength(1); + expect(captured[0]!.from).toBe("otto"); + expect(captured[0]!.to).toBe("*"); + expect(captured[0]!.idleMinutes).toBe(20); + expect(captured[0]!.rationale).toContain("Standing-by detected"); + expect(captured[0]!.rationale).toContain("infinite-backlog metabolism"); + }); + + test("does NOT publish when noPublish is true", () => { + const captured: FakeNudgeCall[] = []; + const result = pollOnce( + { ...DEFAULT_CONFIG, idleThresholdMin: 15, noPublish: true }, + fakeAdapters("2026-05-13T18:00:00Z", "2026-05-13T17:40:00Z", captured), ); expect(result.idleDetected).toBe(true); - expect(result.idleMinutes).toBe(15); + expect(result.publishedEnvelopeId).toBeNull(); + expect(captured).toHaveLength(0); + expect(result.note).toContain("publish skipped"); }); - test("handles null lastCommit (fresh repo / git unavailable)", () => { + test("does NOT publish when not idle", () => { + const captured: FakeNudgeCall[] = []; const result = pollOnce( - DEFAULT_CONFIG, - fakeAdapters("2026-05-13T18:00:00Z", null), + { ...DEFAULT_CONFIG, idleThresholdMin: 15 }, + fakeAdapters("2026-05-13T18:00:00Z", "2026-05-13T17:55:00Z", captured), ); expect(result.idleDetected).toBe(false); - expect(result.lastCommitAt).toBeNull(); - expect(result.idleMinutes).toBeNull(); - expect(result.note).toContain("no commit found"); + expect(captured).toHaveLength(0); }); - test("clamps negative idleMinutes to zero (clock-skew safety)", () => { + test("respects --agent and --to flags for publish identity", () => { + const captured: FakeNudgeCall[] = []; const result = pollOnce( - DEFAULT_CONFIG, - fakeAdapters("2026-05-13T17:00:00Z", "2026-05-13T18:00:00Z"), + { ...DEFAULT_CONFIG, idleThresholdMin: 15, fromAgent: "vera", toAgent: "lior" }, + fakeAdapters("2026-05-13T18:00:00Z", "2026-05-13T17:40:00Z", captured), ); - expect(result.idleMinutes).toBe(0); - expect(result.idleDetected).toBe(false); + expect(result.idleDetected).toBe(true); + expect(captured[0]!.from).toBe("vera"); + expect(captured[0]!.to).toBe("lior"); }); }); @@ -88,11 +136,9 @@ describe("standing-by-detector slice 2", () => { expect(parsePositiveMinutes("5", "--poll-min")).toBe(5); }); - test("rejects undefined / non-numeric / zero / negative / Infinity", () => { + test("rejects invalid inputs", () => { expect(() => parsePositiveMinutes(undefined, "--poll-min")).toThrow(/requires a value/); - expect(() => parsePositiveMinutes("abc", "--poll-min")).toThrow(/positive finite/); expect(() => parsePositiveMinutes("0", "--poll-min")).toThrow(/positive finite/); - expect(() => parsePositiveMinutes("-3", "--poll-min")).toThrow(/positive finite/); expect(() => parsePositiveMinutes("Infinity", "--poll-min")).toThrow(/positive finite/); }); }); @@ -106,10 +152,23 @@ describe("standing-by-detector slice 2", () => { expect(parseArgs(["--once"]).once).toBe(true); }); - test("--poll-min + --idle-min set values", () => { - const config = parseArgs(["--poll-min", "10", "--idle-min", "30"]); - expect(config.pollIntervalMin).toBe(10); - expect(config.idleThresholdMin).toBe(30); + test("--no-publish flag", () => { + expect(parseArgs(["--no-publish"]).noPublish).toBe(true); + }); + + test("--agent + --to flags", () => { + const config = parseArgs(["--agent", "vera", "--to", "lior"]); + expect(config.fromAgent).toBe("vera"); + expect(config.toAgent).toBe("lior"); + }); + + test("rejects invalid --agent values", () => { + expect(() => parseArgs(["--agent", "invalid"])).toThrow(/must be one of/); + expect(() => parseArgs(["--agent", "*"])).toThrow(/must be one of/); + }); + + test("rejects invalid --to values", () => { + expect(() => parseArgs(["--to", "invalid"])).toThrow(/must be one of/); }); test("rejects unknown flags fail-fast", () => { diff --git a/tools/bg/standing-by-detector.ts b/tools/bg/standing-by-detector.ts index dd821d3181..3a5f2e8453 100644 --- a/tools/bg/standing-by-detector.ts +++ b/tools/bg/standing-by-detector.ts @@ -1,16 +1,21 @@ -// standing-by-detector.ts — B-0440 slice 2: commit-history poll via `git log` +// standing-by-detector.ts — B-0440 slice 4: bus publish on idle detection // -// Background service that detects when an agent has been Standing by (idle) -// by comparing the timestamp of the most recent commit on HEAD against a -// configurable idle threshold (`idleThresholdMin`). When the gap exceeds -// the threshold the detector flags the agent as a Standing-by candidate. +// Background service that detects Standing-by failure mode (idle agent +// while cron fires) by comparing the timestamp of the most recent commit +// on HEAD against a configurable idle threshold. Slice 4 adds bus publish: +// when idle is detected, the detector publishes an `infinite-backlog-nudge` +// envelope via the B-0400 protocol so any subscribing agent can react. // -// PR-activity polling and bus-publish are still TBD (slices 3 + 4). +// PR-activity polling is still TBD (slice 3). Slice 4 is wired ahead of +// slice 3 because the bus publish path is small and unblocks the +// full reactive loop (detect → nudge). // -// Run: bun tools/bg/standing-by-detector.ts [--once] [--poll-min N] [--idle-min N] -// Compose with: B-0440 + B-0400 (bus) + B-0441 (proactive notifier). +// Run: bun tools/bg/standing-by-detector.ts [--once] [--poll-min N] [--idle-min N] [--no-publish] [--agent NAME] +// Compose with: B-0440 + B-0400 (bus, PR #3016) + B-0441 (proactive notifier). import { spawnSync } from "node:child_process"; +import { publish } from "../bus/bus"; +import { AGENT_IDS, SENDER_IDS, type AgentId, type MessageEnvelope, type SenderAgentId } from "../bus/types"; export type DetectorConfig = { /** How often to poll, in minutes */ @@ -19,26 +24,43 @@ export type DetectorConfig = { idleThresholdMin: number; /** When true, run a single poll and exit (for testing / cron-driven mode) */ once: boolean; + /** When true, skip bus publish even on idle detection (dry-run mode). */ + noPublish: boolean; + /** Bus sender identity (the detector publishes as this agent). */ + fromAgent: SenderAgentId; + /** Bus recipient (default "*" = broadcast nudge to all agents). */ + toAgent: AgentId; }; export const DEFAULT_CONFIG: DetectorConfig = { pollIntervalMin: 5, idleThresholdMin: 15, once: false, + noPublish: false, + fromAgent: "otto", + toAgent: "*", }; export type PollResult = { pollAt: string; // ISO-8601 idleDetected: boolean; - lastCommitAt: string | null; // ISO-8601 of the most recent commit on HEAD, or null + lastCommitAt: string | null; idleMinutes: number | null; + /** Envelope ID if a nudge was published, null otherwise. */ + publishedEnvelopeId: string | null; note: string; }; -/** Adapter abstraction so tests can inject a deterministic clock + git-log result. */ +/** Adapter abstraction so tests can inject deterministic time + git + bus. */ export type Adapters = { now: () => Date; lastCommitIso: () => string | null; + publishNudge: ( + from: SenderAgentId, + to: AgentId, + idleMinutes: number, + rationale: string, + ) => MessageEnvelope; }; const REAL_ADAPTERS: Adapters = { @@ -53,11 +75,17 @@ const REAL_ADAPTERS: Adapters = { const trimmed = result.stdout.trim(); return trimmed.length > 0 ? trimmed : null; }, + publishNudge: (from, to, idleMinutes, rationale) => + publish(from, to, { + topic: "infinite-backlog-nudge", + payload: { idleMinutes, rationale }, + }), }; /** - * Single poll iteration. Reads the most recent commit on HEAD and compares - * its timestamp against the configured idle threshold. + * Single poll iteration. Reads the most recent commit on HEAD, compares + * against the idle threshold, and publishes a bus nudge when idle is + * detected (unless noPublish is set). */ export function pollOnce( config: DetectorConfig, @@ -72,6 +100,7 @@ export function pollOnce( idleDetected: false, lastCommitAt: null, idleMinutes: null, + publishedEnvelopeId: null, note: "no commit found on HEAD (fresh repo or git unavailable); cannot evaluate idle threshold", }; } @@ -81,13 +110,36 @@ export function pollOnce( const idleMinutes = Math.max(0, idleMs / 60_000); const idleDetected = idleMinutes >= config.idleThresholdMin; + let publishedEnvelopeId: string | null = null; + let publishError: string | null = null; + if (idleDetected && !config.noPublish) { + const rationale = `Standing-by detected: ${idleMinutes.toFixed(1)}min since last commit on HEAD (threshold ${config.idleThresholdMin}min). Pick decomposition work per infinite-backlog metabolism.`; + try { + const envelope = adapters.publishNudge(config.fromAgent, config.toAgent, idleMinutes, rationale); + publishedEnvelopeId = envelope.id; + } catch (e) { + // Bus publish failure must NOT kill the poll loop; the daemon needs to + // survive transient bus IO errors. Capture the reason in the result. + publishError = e instanceof Error ? e.message : String(e); + } + } + return { pollAt: pollAt.toISOString(), idleDetected, lastCommitAt: lastCommit.toISOString(), idleMinutes, + publishedEnvelopeId, note: idleDetected - ? `idle ${idleMinutes.toFixed(1)}min >= threshold ${config.idleThresholdMin}min — Standing-by candidate (future slice: publish bus nudge)` + ? `idle ${idleMinutes.toFixed(1)}min >= threshold ${config.idleThresholdMin}min — Standing-by candidate${ + publishError + ? ` (publish failed: ${publishError})` + : publishedEnvelopeId + ? ` (nudge published; envelope=${publishedEnvelopeId})` + : config.noPublish + ? " (publish skipped per --no-publish)" + : "" + }` : `last commit ${idleMinutes.toFixed(1)}min ago; under threshold ${config.idleThresholdMin}min`, }; } @@ -119,30 +171,45 @@ export function parsePositiveMinutes(raw: string | undefined, name: string): num return n; } -const KNOWN_FLAGS = new Set(["--once", "--poll-min", "--idle-min"]); +function parseSenderId(raw: string | undefined): SenderAgentId { + if (raw === undefined) throw new Error("--agent requires a value"); + if ((SENDER_IDS as readonly string[]).includes(raw)) return raw as SenderAgentId; + throw new Error(`--agent must be one of ${SENDER_IDS.join(", ")}; got "${raw}"`); +} + +function parseAgentId(raw: string | undefined): AgentId { + if (raw === undefined) throw new Error("--to requires a value"); + if ((AGENT_IDS as readonly string[]).includes(raw)) return raw as AgentId; + throw new Error(`--to must be one of ${AGENT_IDS.join(", ")}; got "${raw}"`); +} + +const KNOWN_FLAGS = ["--once", "--poll-min", "--idle-min", "--no-publish", "--agent", "--to"] as const; export function parseArgs(argv: string[]): DetectorConfig { const config: DetectorConfig = { ...DEFAULT_CONFIG }; for (let i = 0; i < argv.length; i++) { - const arg = argv[i]!; // i < argv.length guarantees defined; noUncheckedIndexedAccess needs explicit assertion + const arg = argv[i]!; if (arg === "--once") { config.once = true; + } else if (arg === "--no-publish") { + config.noPublish = true; } else if (arg === "--poll-min") { config.pollIntervalMin = parsePositiveMinutes(argv[++i], "--poll-min"); } else if (arg === "--idle-min") { config.idleThresholdMin = parsePositiveMinutes(argv[++i], "--idle-min"); - } else if (KNOWN_FLAGS.has(arg)) { - throw new Error(`internal: known flag ${arg} not handled`); + } else if (arg === "--agent") { + config.fromAgent = parseSenderId(argv[++i]); + } else if (arg === "--to") { + config.toAgent = parseAgentId(argv[++i]); } else { - throw new Error(`unknown flag: ${arg}; known flags: ${[...KNOWN_FLAGS].join(", ")}`); + throw new Error(`unknown flag: ${arg}; known flags: ${KNOWN_FLAGS.join(", ")}`); } } return config; } -// CLI entry — only fires when invoked directly, not when imported by tests. if (import.meta.main) { const config = parseArgs(process.argv.slice(2)); if (config.once) {