Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
135 changes: 97 additions & 38 deletions tools/bg/standing-by-detector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});

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

Expand All @@ -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/);
});
});
Expand All @@ -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", () => {
Expand Down
93 changes: 74 additions & 19 deletions tools/bg/standing-by-detector.ts
Original file line number Diff line number Diff line change
@@ -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 type { AgentId, MessageEnvelope, SenderAgentId } from "../bus/types";

export type DetectorConfig = {
/** How often to poll, in minutes */
Expand All @@ -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",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid hard-coding the default sender to a single agent

Defaulting fromAgent to "otto" causes misattributed bus envelopes whenever this detector runs under any other agent identity without an explicit --agent override. That breaks provenance and any consumer logic keyed on sender identity, so the sender should be required or derived from runtime identity instead of silently impersonating one agent.

Useful? React with 👍 / 👎.

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 = {
Expand All @@ -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,
Expand All @@ -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",
};
}
Expand All @@ -81,13 +110,21 @@ export function pollOnce(
const idleMinutes = Math.max(0, idleMs / 60_000);
const idleDetected = idleMinutes >= config.idleThresholdMin;

let publishedEnvelopeId: 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.`;
Comment on lines +115 to +116

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Delay nudge publish until full idle heuristic is satisfied

This now emits infinite-backlog-nudge whenever commit age crosses the threshold, but pollOnce still does not include PR-activity checks (the Standing-by heuristic in B-0440 requires both commit and PR inactivity). In a common case where an agent is actively working in PR threads without new commits, this will publish false-positive nudges every poll interval and can trigger downstream automation on incorrect signals.

Useful? React with 👍 / 👎.

const envelope = adapters.publishNudge(config.fromAgent, config.toAgent, idleMinutes, rationale);
publishedEnvelopeId = envelope.id;
Comment thread
AceHack marked this conversation as resolved.
Outdated
}

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${publishedEnvelopeId ? ` (nudge published; envelope=${publishedEnvelopeId})` : config.noPublish ? " (publish skipped per --no-publish)" : ""}`
: `last commit ${idleMinutes.toFixed(1)}min ago; under threshold ${config.idleThresholdMin}min`,
};
}
Expand Down Expand Up @@ -119,30 +156,48 @@ export function parsePositiveMinutes(raw: string | undefined, name: string): num
return n;
}

const KNOWN_FLAGS = new Set(["--once", "--poll-min", "--idle-min"]);
const VALID_SENDER_IDS = ["otto", "alexa", "riven", "vera", "lior"] as const;
const VALID_AGENT_IDS = [...VALID_SENDER_IDS, "*"] as const;
Comment thread
AceHack marked this conversation as resolved.
Outdated
Comment thread
AceHack marked this conversation as resolved.
Outdated

function parseSenderId(raw: string | undefined): SenderAgentId {
if (raw === undefined) throw new Error("--agent requires a value");
if ((VALID_SENDER_IDS as readonly string[]).includes(raw)) return raw as SenderAgentId;
throw new Error(`--agent must be one of ${VALID_SENDER_IDS.join(", ")}; got "${raw}"`);
}

function parseAgentId(raw: string | undefined): AgentId {
if (raw === undefined) throw new Error("--to requires a value");
if ((VALID_AGENT_IDS as readonly string[]).includes(raw)) return raw as AgentId;
throw new Error(`--to must be one of ${VALID_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) {
Expand Down
Loading