From fa901dffc11f3ed30a5afb7e6b6348a2d9d81bfc Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Wed, 13 May 2026 13:30:52 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(bg):=20B-0440.1=20=E2=80=94=20standing?= =?UTF-8?q?-by=20detector=20skeleton=20+=20no-op=20poll=20loop=20(3=20file?= =?UTF-8?q?s,=203=20tests=20pass)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First implementation slice of B-0440 (Standing-by failure-mode detector). Ships ONLY the skeleton; future slices add real detection. Files: - tools/bg/standing-by-detector.ts (76 lines): - DetectorConfig type + DEFAULT_CONFIG (5min poll / 15min idle threshold) - pollOnce() — no-op result with slice-1 placeholder note - runDetector() — loop scaffolding; --once for cron-driven mode - CLI entry with --poll-min / --idle-min / --once flags - tools/bg/standing-by-detector.test.ts (3 tests): - default config thresholds - pollOnce returns ISO-timestamped no-op result - runDetector with once:true exits after one iteration - tools/bg/README.md: - Directory purpose - Service inventory (B-0440 current; B-0441/B-0442 planned) - Run instructions (cron-driven --once vs standalone daemon) Per Rule 0: TypeScript only (no .sh files in tools/bg/). Future slices (per B-0440 decomposition section): - Slice 2: commit-history poll via git log - Slice 3: PR-activity poll via gh CLI - Slice 4: nudge payload computation + bus publish (requires B-0400 schema extension for infinite-backlog-nudge topic) - Slice 5: integration with agent subscribers - Slice 6: additional tests + cron registration Composes with: - B-0440 (the backlog row this implements; PR #3000 merged) - B-0400 (bus protocol — for future slice 4) - B-0441 / B-0442 (companion services) - PR #2998 (architectural challenge that produced these rows) - PR #2999 (substrate-honest discipline triad — decomposition discipline) Co-Authored-By: Claude --- tools/bg/README.md | 41 +++++++++++++ tools/bg/standing-by-detector.test.ts | 24 ++++++++ tools/bg/standing-by-detector.ts | 88 +++++++++++++++++++++++++++ 3 files changed, 153 insertions(+) create mode 100644 tools/bg/README.md create mode 100644 tools/bg/standing-by-detector.test.ts create mode 100644 tools/bg/standing-by-detector.ts diff --git a/tools/bg/README.md b/tools/bg/README.md new file mode 100644 index 0000000000..5d40451abc --- /dev/null +++ b/tools/bg/README.md @@ -0,0 +1,41 @@ +# tools/bg/ — Background services + +Background services that mechanize substrate-engineering +disciplines so the foreground loop becomes OPTIONAL per the +architectural challenge in PR #2998. + +## Current services + +| Service | Slice | Purpose | +|---------|-------|---------| +| `standing-by-detector.ts` | B-0440.1 (skeleton) | Detect idle-foreground + nudge via bus when full impl lands | + +## Planned services (per B-0440/0441/0442) + +- `standing-by-detector.ts` — catches the Standing-by failure mode (B-0440) +- `backlog-ready-notifier.ts` — proactively assigns ready-to-grind rows (B-0441) +- `missed-substrate-detector.ts` — catches branch-vs-merged-PR drift (B-0442) + +## Composition + +All services compose with: + +- **B-0400 bus protocol** (`tools/bus/`) — transport for nudges + assignments + cascade alerts +- **Existing background infrastructure** — `com.zeta.claude-loop` launchd + cron heartbeat + +## Running + +```bash +# One-shot mode (cron-driven; recommended) +bun tools/bg/standing-by-detector.ts --once + +# Loop mode (standalone daemon) +bun tools/bg/standing-by-detector.ts --poll-min 5 --idle-min 15 +``` + +## Slice cadence + +Each service decomposes into ~6 implementation slices per its backlog row's +"Decomposition into implementation slices" section. Slice 1 is the skeleton ++ no-op poll loop; later slices add real detection logic, bus integration, +and tests. diff --git a/tools/bg/standing-by-detector.test.ts b/tools/bg/standing-by-detector.test.ts new file mode 100644 index 0000000000..5b27388115 --- /dev/null +++ b/tools/bg/standing-by-detector.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; +import { DEFAULT_CONFIG, pollOnce, runDetector, type DetectorConfig } from "./standing-by-detector"; + +describe("standing-by-detector slice 1", () => { + 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"); + expect(result.pollAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + 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); + }); +}); diff --git a/tools/bg/standing-by-detector.ts b/tools/bg/standing-by-detector.ts new file mode 100644 index 0000000000..bbe836caf3 --- /dev/null +++ b/tools/bg/standing-by-detector.ts @@ -0,0 +1,88 @@ +// standing-by-detector.ts — B-0440 slice 1: skeleton + no-op poll loop +// +// 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. +// +// This slice ships ONLY the skeleton: poll-loop scaffolding + configurable +// thresholds + log output. No real detection yet. +// +// 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). + +export type DetectorConfig = { + /** How often to poll, in minutes */ + pollIntervalMin: number; + /** Idle threshold — if no activity in this many minutes, flag Standing-by */ + idleThresholdMin: number; + /** When true, run a single poll and exit (for testing / cron-driven mode) */ + once: boolean; +}; + +export const DEFAULT_CONFIG: DetectorConfig = { + pollIntervalMin: 5, + idleThresholdMin: 15, + once: false, +}; + +export type PollResult = { + pollAt: string; // ISO-8601 + idleDetected: boolean; + note: string; +}; + +/** + * Single poll iteration. Slice 1 returns a no-op result with a placeholder + * note. Future slices will implement actual commit-history + PR-activity + * checks. + */ +export function pollOnce(_config: DetectorConfig): PollResult { + return { + pollAt: new Date().toISOString(), + idleDetected: false, + note: "slice-1 skeleton — no detection yet; future slices add commit-history + PR-activity polls", + }; +} + +/** + * Run the poll loop. When `once: true`, runs exactly one iteration. + * Otherwise sleeps for pollIntervalMin between iterations. + */ +export async function runDetector(config: DetectorConfig = DEFAULT_CONFIG): Promise { + const results: PollResult[] = []; + + do { + const result = pollOnce(config); + results.push(result); + console.log(JSON.stringify(result)); + + if (config.once) break; + + await new Promise(resolve => setTimeout(resolve, config.pollIntervalMin * 60 * 1000)); + } while (!config.once); + + return results; +} + +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" && i + 1 < argv.length) { + config.pollIntervalMin = Number(argv[++i]); + } else if (arg === "--idle-min" && i + 1 < argv.length) { + config.idleThresholdMin = Number(argv[++i]); + } + } + + 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)); + await runDetector(config); +} From 05efd257851da5ac62ac32ef053b94fa3af4f879 Mon Sep 17 00:00:00 2001 From: Aaron Stainback Date: Wed, 13 May 2026 13:37:58 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix(bg):=20B-0440.1=20=E2=80=94=20close=202?= =?UTF-8?q?=20Copilot=20findings=20(P1=20unbounded=20results=20+=20P2=20ar?= =?UTF-8?q?g=20validation)=20+=20markdownlint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - P1: runDetector daemon mode no longer accumulates results forever (split into single-iter return-array path + infinite-loop discard path). Same fix should land in B-0441.1 (PR #3007) — will follow up. - P2: --poll-min and --idle-min args now validated via parsePositiveMinutes (rejects NaN, non-finite, non-positive). - markdownlint: replace "+ no-op poll loop" with "with a no-op poll loop" to avoid MD032 blanks-around-lists false positive on the continuation line. Tests still 3 pass / 0 fail. Co-Authored-By: Claude --- tools/bg/README.md | 4 ++-- tools/bg/standing-by-detector.ts | 38 ++++++++++++++++++++------------ 2 files changed, 26 insertions(+), 16 deletions(-) diff --git a/tools/bg/README.md b/tools/bg/README.md index 5d40451abc..dd45f6ee2a 100644 --- a/tools/bg/README.md +++ b/tools/bg/README.md @@ -37,5 +37,5 @@ bun tools/bg/standing-by-detector.ts --poll-min 5 --idle-min 15 Each service decomposes into ~6 implementation slices per its backlog row's "Decomposition into implementation slices" section. Slice 1 is the skeleton -+ no-op poll loop; later slices add real detection logic, bus integration, -and tests. +with a no-op poll loop; later slices add real detection logic, bus +integration, and tests. diff --git a/tools/bg/standing-by-detector.ts b/tools/bg/standing-by-detector.ts index bbe836caf3..c0eb315c30 100644 --- a/tools/bg/standing-by-detector.ts +++ b/tools/bg/standing-by-detector.ts @@ -46,23 +46,33 @@ export function pollOnce(_config: DetectorConfig): PollResult { } /** - * Run the poll loop. When `once: true`, runs exactly one iteration. - * Otherwise sleeps for pollIntervalMin between iterations. + * 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). */ export async function runDetector(config: DetectorConfig = DEFAULT_CONFIG): Promise { - const results: PollResult[] = []; - - do { + if (config.once) { const result = pollOnce(config); - results.push(result); console.log(JSON.stringify(result)); + return [result]; + } - if (config.once) break; - + // Daemon mode: emit each result but discard after logging — never accumulate. + while (true) { + const result = pollOnce(config); + console.log(JSON.stringify(result)); await new Promise(resolve => setTimeout(resolve, config.pollIntervalMin * 60 * 1000)); - } while (!config.once); + } +} - return results; +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[]): DetectorConfig { @@ -71,10 +81,10 @@ function parseArgs(argv: string[]): DetectorConfig { for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (arg === "--once") config.once = true; - else if (arg === "--poll-min" && i + 1 < argv.length) { - config.pollIntervalMin = Number(argv[++i]); - } else if (arg === "--idle-min" && i + 1 < argv.length) { - config.idleThresholdMin = Number(argv[++i]); + else if (arg === "--poll-min") { + config.pollIntervalMin = parsePositiveMinutes(argv[++i], "--poll-min"); + } else if (arg === "--idle-min") { + config.idleThresholdMin = parsePositiveMinutes(argv[++i], "--idle-min"); } }