Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 106 additions & 11 deletions tools/bg/standing-by-detector.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
});
125 changes: 90 additions & 35 deletions tools/bg/standing-by-detector.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<PollResult[]> {
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<never> {
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) {
Expand All @@ -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(", ")}`);
}
}

Expand All @@ -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);
}
}
Loading