diff --git a/tools/bg/standing-by-detector.test.ts b/tools/bg/standing-by-detector.test.ts index cc9e7b3c5..1c8a2e8d7 100644 --- a/tools/bg/standing-by-detector.test.ts +++ b/tools/bg/standing-by-detector.test.ts @@ -1,24 +1,119 @@ import { describe, expect, test } from "bun:test"; -import { DEFAULT_CONFIG, pollOnce, runDetector, type DetectorConfig } from "./standing-by-detector"; +import { + DEFAULT_CONFIG, + parseArgs, + parsePositiveMinutes, + pollOnce, + type Adapters, +} from "./standing-by-detector"; -describe("standing-by-detector slice 1", () => { +function fakeAdapters(nowIso: string, lastCommitIso: string | null): Adapters { + return { + now: () => new Date(nowIso), + lastCommitIso: () => lastCommitIso, + }; +} + +describe("standing-by-detector slice 2", () => { test("default config has sensible thresholds", () => { expect(DEFAULT_CONFIG.pollIntervalMin).toBe(5); expect(DEFAULT_CONFIG.idleThresholdMin).toBe(15); expect(DEFAULT_CONFIG.once).toBe(false); }); - test("pollOnce returns a result with no-op detection", () => { - const result = pollOnce(DEFAULT_CONFIG); - expect(result.idleDetected).toBe(false); - expect(result.note).toContain("slice-1 skeleton"); + 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", () => { + test("flags idle when last commit is older than threshold", () => { + const result = pollOnce( + { ...DEFAULT_CONFIG, idleThresholdMin: 15 }, + 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"); + }); + + test("does NOT flag idle when last commit is recent", () => { + const result = pollOnce( + { ...DEFAULT_CONFIG, idleThresholdMin: 15 }, + fakeAdapters("2026-05-13T18:00:00Z", "2026-05-13T17:55:00Z"), + ); + expect(result.idleDetected).toBe(false); + expect(result.idleMinutes).toBe(5); + expect(result.note).toContain("under threshold"); + }); + + test("flags idle at exactly the threshold (inclusive)", () => { + const result = pollOnce( + { ...DEFAULT_CONFIG, idleThresholdMin: 15 }, + fakeAdapters("2026-05-13T18:00:00Z", "2026-05-13T17:45:00Z"), + ); + expect(result.idleDetected).toBe(true); + expect(result.idleMinutes).toBe(15); + }); + + test("handles null lastCommit (fresh repo / git unavailable)", () => { + const result = pollOnce( + DEFAULT_CONFIG, + fakeAdapters("2026-05-13T18:00:00Z", null), + ); + expect(result.idleDetected).toBe(false); + expect(result.lastCommitAt).toBeNull(); + expect(result.idleMinutes).toBeNull(); + expect(result.note).toContain("no commit found"); + }); + + test("clamps negative idleMinutes to zero (clock-skew safety)", () => { + const result = pollOnce( + DEFAULT_CONFIG, + fakeAdapters("2026-05-13T17:00:00Z", "2026-05-13T18:00:00Z"), + ); + expect(result.idleMinutes).toBe(0); + expect(result.idleDetected).toBe(false); + }); }); - test("runDetector with once: true exits after one iteration", async () => { - const config: DetectorConfig = { ...DEFAULT_CONFIG, once: true }; - const results = await runDetector(config); - expect(results).toHaveLength(1); - expect(results[0]!.idleDetected).toBe(false); + describe("parsePositiveMinutes", () => { + test("accepts positive finite numbers", () => { + expect(parsePositiveMinutes("5", "--poll-min")).toBe(5); + }); + + test("rejects undefined / non-numeric / zero / negative / Infinity", () => { + 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/); + }); + }); + + describe("parseArgs", () => { + test("default config when no args", () => { + expect(parseArgs([])).toEqual(DEFAULT_CONFIG); + }); + + test("--once flag", () => { + 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("rejects unknown flags fail-fast", () => { + expect(() => parseArgs(["--unknown"])).toThrow(/unknown flag/); + }); }); }); diff --git a/tools/bg/standing-by-detector.ts b/tools/bg/standing-by-detector.ts index c0eb315c3..31d4f19c2 100644 --- a/tools/bg/standing-by-detector.ts +++ b/tools/bg/standing-by-detector.ts @@ -1,20 +1,21 @@ -// standing-by-detector.ts — B-0440 slice 1: skeleton + no-op poll loop +// standing-by-detector.ts — B-0440 slice 2: commit-history poll via `git log` // -// Background service that detects when an agent has been "Standing by" for N -// consecutive autonomous-loop ticks without producing substrate. Future slices -// add commit-history polling, PR-activity polling, and bus integration to -// publish nudges via B-0400 protocol. +// 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. // -// This slice ships ONLY the skeleton: poll-loop scaffolding + configurable -// thresholds + log output. No real detection yet. +// PR-activity polling and bus-publish are still TBD (slices 3 + 4). // // Run: bun tools/bg/standing-by-detector.ts [--once] [--poll-min N] [--idle-min N] -// Compose with: B-0440 (this row) + B-0400 (bus) + B-0441 (proactive notifier). +// Compose with: B-0440 + B-0400 (bus) + B-0441 (proactive notifier). + +import { spawnSync } from "node:child_process"; export type DetectorConfig = { /** How often to poll, in minutes */ pollIntervalMin: number; - /** Idle threshold — if no activity in this many minutes, flag Standing-by */ + /** Idle threshold — if no commit in this many minutes, flag Standing-by */ idleThresholdMin: number; /** When true, run a single poll and exit (for testing / cron-driven mode) */ once: boolean; @@ -29,44 +30,87 @@ export const DEFAULT_CONFIG: DetectorConfig = { export type PollResult = { pollAt: string; // ISO-8601 idleDetected: boolean; + lastCommitAt: string | null; // ISO-8601 of the most recent commit on HEAD, or null + idleMinutes: number | null; note: string; }; +/** Adapter abstraction so tests can inject a deterministic clock + git-log result. */ +export type Adapters = { + now: () => Date; + lastCommitIso: () => string | null; +}; + +const REAL_ADAPTERS: Adapters = { + now: () => new Date(), + lastCommitIso: () => { + // eslint-disable-next-line sonarjs/no-os-command-from-path -- git invoked as explicit args array; no shell, no injection risk. + const result = spawnSync("git", ["log", "-1", "--format=%cI", "HEAD"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + if (result.status !== 0 || !result.stdout) return null; + const trimmed = result.stdout.trim(); + return trimmed.length > 0 ? trimmed : null; + }, +}; + /** - * Single poll iteration. Slice 1 returns a no-op result with a placeholder - * note. Future slices will implement actual commit-history + PR-activity - * checks. + * Single poll iteration. Reads the most recent commit on HEAD and compares + * its timestamp against the configured idle threshold. */ -export function pollOnce(_config: DetectorConfig): PollResult { +export function pollOnce( + config: DetectorConfig, + adapters: Adapters = REAL_ADAPTERS, +): PollResult { + const pollAt = adapters.now(); + const lastCommitIso = adapters.lastCommitIso(); + + if (lastCommitIso === null) { + return { + pollAt: pollAt.toISOString(), + idleDetected: false, + lastCommitAt: null, + idleMinutes: null, + note: "no commit found on HEAD (fresh repo or git unavailable); cannot evaluate idle threshold", + }; + } + + const lastCommit = new Date(lastCommitIso); + const idleMs = pollAt.getTime() - lastCommit.getTime(); + const idleMinutes = Math.max(0, idleMs / 60_000); + const idleDetected = idleMinutes >= config.idleThresholdMin; + return { - pollAt: new Date().toISOString(), - idleDetected: false, - note: "slice-1 skeleton — no detection yet; future slices add commit-history + PR-activity polls", + pollAt: pollAt.toISOString(), + idleDetected, + lastCommitAt: lastCommit.toISOString(), + idleMinutes, + note: idleDetected + ? `idle ${idleMinutes.toFixed(1)}min >= threshold ${config.idleThresholdMin}min — Standing-by candidate (future slice: publish bus nudge)` + : `last commit ${idleMinutes.toFixed(1)}min ago; under threshold ${config.idleThresholdMin}min`, }; } +/** Run a single poll iteration and return its result. */ +export function runOnce(config: DetectorConfig = DEFAULT_CONFIG): PollResult { + const result = pollOnce(config); + console.log(JSON.stringify(result)); + return result; +} + /** - * Run the poll 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 (the daemon - * cannot leak memory by retaining unbounded history). + * Run the detector as a daemon. Sleeps for pollIntervalMin between + * iterations and never returns; results are NOT accumulated. */ -export async function runDetector(config: DetectorConfig = DEFAULT_CONFIG): Promise { - if (config.once) { - const result = pollOnce(config); - console.log(JSON.stringify(result)); - return [result]; - } - - // Daemon mode: emit each result but discard after logging — never accumulate. +export async function runDaemon(config: DetectorConfig = DEFAULT_CONFIG): Promise { while (true) { - const result = pollOnce(config); - console.log(JSON.stringify(result)); + runOnce(config); await new Promise(resolve => setTimeout(resolve, config.pollIntervalMin * 60 * 1000)); } } -function parsePositiveMinutes(raw: string | undefined, name: string): number { +export 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) { @@ -75,16 +119,23 @@ function parsePositiveMinutes(raw: string | undefined, name: string): number { return n; } -function parseArgs(argv: string[]): DetectorConfig { +const KNOWN_FLAGS = new Set(["--once", "--poll-min", "--idle-min"]); + +export function parseArgs(argv: string[]): DetectorConfig { const config: DetectorConfig = { ...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") { + if (arg === "--once") { + config.once = 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 { + throw new Error(`unknown flag: ${arg}; known flags: ${[...KNOWN_FLAGS].join(", ")}`); } } @@ -94,5 +145,9 @@ function parseArgs(argv: string[]): DetectorConfig { // CLI entry — only fires when invoked directly, not when imported by tests. if (import.meta.main) { const config = parseArgs(process.argv.slice(2)); - await runDetector(config); + if (config.once) { + runOnce(config); + } else { + await runDaemon(config); + } }