/** * #60 — S2 wake-coverage detector (ADR-0023). REPORT-FIRST: it makes a coverage * gap LOUD and lets a human decide (ADR-0007/ADR-0005). It never changes routing, * never wakes anyone, never mutates the DB — pure observation. * * THE OBSERVABLE IS THE WAKE'S EFFECT, NOT THE ATTEMPT (victra ruling). A wake is * "observed" only when the agent actually DRAINED its mailbox via MCP get_messages. * A running watcher, a live process, a self-reported status: none count. A wake that * fires and produces no drain SHOULD read as uncovered. That is read-the-state * applied to coverage. The DURABLE record of that drain is `agents.last_drain_at` * (written on every !peek drain), NOT the `inbox_events` message_read stream — see * the DURABLE EVIDENCE note below for why the distinction is load-bearing. * * THREE VERDICTS, derived from the SAME table the detector reads — no allowlist, * no exemption lever. For an agent with mail pending past the bound: * - COVERED — the agent drained SOMETHING since the stuck mail arrived * (a message_read at/after its created_at). It is awake; the * specific message still sitting is a routing question, not a * wake-coverage one. NOT reported. * - UNCOVERED — the agent HAS drained before (has message_read history) but * NOT since the stuck mail arrived. It was being woken and now * is not: the S2 regression. Reported as an alarm. * - UNOBSERVABLE — the agent's durable last-drain marker is unset: NO drain is * recorded for this identity. That has SEVERAL causes — an out-of-band * reader (direct DB/CLI / non-MCP), a re-created row (unregister/reap) * not drained since, or a pre-existing identity whose drains predate * the marker (the TRANSITIONAL LIMIT below) — so the report states * what is KNOWN and offers the causes NON-EXHAUSTIVELY rather than * asserting a behaviour or a closed dichotomy. Either way the detector * cannot judge its coverage AT ALL. Reported, * but NEVER as uncovered — * "I cannot observe this" is a different fact from "this is * broken," and collapsing them is the exact error the whole * ADR set exists to prevent (the exit-1 vs exit-2 distinction, * one layer out). victra measured this class has exactly one * live member — the orchestrator itself — which is why a * detector that rendered it as "uncovered" would alarm on its * own operator from day one and be muted by week three. * * FLAT BOUND + ANTI-FLAP MARGIN (victra ruling, Option A). The discriminator is a * flat fleet-wide age bound (`boundMs`, default 24h) — measured on 7 days of real * history, a 24h bound yields exactly one alarm (a genuinely 6.9-day-stuck message) * and zero false, so per-agent baselines were rejected on evidence (a subject-derived * threshold lets a permanently-broken agent redefine its own normal and never alarm). * * `antiFlapMarginMs` is NOT a second "persistence" check — naming it that would * assert a two-observation mechanism this code does not implement. It is simply * extra dwell added to the bound: the EFFECTIVE fire threshold is * `boundMs + antiFlapMarginMs` (default 24h + 24h = 48h). One longer threshold, * honestly named. It works as a SINGLE STATELESS age check because the coverage * condition — "no drain since the mail arrived" — is CUMULATIVE over the whole * span: one evaluation at age >= boundMs+margin already establishes that nothing * drained across that entire window, so a second observation would add state and a * missed-evaluation edge case while adding no information (victra). * * MEASURED RANGE — where the evidence stops: the 7-day sweep gave exactly one true * positive and zero false at BOTH a 24h and a 48h effective threshold, so any * effective threshold in [24h, 48h] — i.e. a margin in [0, 24h] — is evidence-backed. * A margin that pushes the effective threshold PAST 48h is outside what was measured * and needs a fresh sweep before it ships. The default is the 48h edge, deliberately: * concierge's slowest observed BENIGN gap was 23.4h, so a 24h effective threshold is * a coin-flip on its next slow week (0.6h headroom); 48h gives 24.6h of clearance. * * ⚠ KNOWN LIMIT — TIME-TO-NOTICE IS UP TO TWO DAYS, NOT MINUTES. ADR-0023 framed * this detector as converting hours-to-notice into minutes. What ships here does * NOT: at a 48h effective threshold, a broken wake path is reported within TWO DAYS, * not minutes. This is a deliberate, measured trade, stated as a limit, not an * apology. A single fleet-wide threshold must clear the SLOWEST LEGITIMATE agent * (concierge, ~1 drain/day, slowest benign gap 23.4h); the FAST, hook-driven agents * this detector was actually built for are clean at 30 minutes, and the two days is * the price of not false-alarming on the slow ones (the first false alarm is what * trains everyone to skim past this log line forever). Closing the gap to minutes * needs either per-agent cadence — REJECTED on evidence, because a threshold derived * from the subject's own history lets a permanently-broken agent redefine its normal * and never alarm — or a different signal entirely. Neither ships here. * * ⚠ ALARM-RATE ESTIMATE IS FROM A STALE REGIME. The ~0–1 alarms/week figure came * from a backtest over PRE-#198 history, when aged never-observed mail sat pending * forever. #198 now delivers exactly that mail on the first ordinary drain — the * very population this detector counts — so the true post-#198 rate is UNMEASURED. * It SHOULD be lower (undelivered mail no longer accumulates), which is why * default-on is safe; but "should" is a prediction. FOLLOW-UP (not a blocker): * re-run the backtest against post-#198 data after ~a week of real operation to * replace the estimate with a measurement. * * ⚠ SILENCE-SAFE EVIDENCE (the rule, WIDENED after a real miss). Both checks read the * DURABLE `agents.last_drain_at`, never the `inbox_events` message_read stream (7-day * purge) — but for TWO DIFFERENT reasons, and conflating them would misremember the * rule: * - UNOBSERVABLE is the alarm-SUPPRESSING verdict, and A VERDICT THAT PRODUCES * SILENCE MUST NOT DEPEND ON EVIDENCE THAT CAN BE ABSENT — from expiry, from a * crash window, or from a partial failure. Such evidence must be written * ATOMICALLY WITH THE EVENT IT RECORDS, AND outlive the condition it silences * (victra). The rule was WIDENED from "must not depend on data that EXPIRES" after * codex #200: the durability half was specified, the atomicity half was not, and a * marker written just AFTER the drain transaction had a crash window that would * leave a real drain looking "never drained" → UNOBSERVABLE → silenced. Earned, * not composed. The two halves as implemented: * · DURABILITY — last_drain_at lives on `agents`, past the 7-day event purge, so * a >7d-dark agent is not falsely read as never-drained (the longest outages * are exactly where the harm is greatest). * · ATOMICITY — last_drain_at is stamped INSIDE the same BEGIN IMMEDIATE drain * transaction as the read-mark + message_read insert (src/db.ts getMessages), * so a crash can never record a drain without its marker; they commit or roll * back together. * - The COVERED check (drainSinceArrival) reads the same marker, but NOT for the * suppression rule: if it read the expiring inbox_events, expiry would make it read * FALSE → the agent ALARMS (noise, the SAFE direction), never silence. Its real * hazard is a CROSS-KNOB INVARIANT: RELAY_OUTBOX_RETENTION_DAYS and the effective * threshold (boundMs+antiFlapMarginMs) are two INDEPENDENTLY configurable settings * whose RELATIVE values would silently decide correctness — set retention below the * threshold and the detector alarms on agents that drained normally, with nothing * connecting the two knobs. Reading last_drain_at REMOVES that latent invariant * entirely (make-impossible) instead of documenting a rule nobody would check. * * BOUNDARY of the signal: `last_drain_at >= arrival` establishes the agent DRAINED * AFTER this message arrived — AGENT-LEVEL (awake since), NOT proof THIS message was in * that drain; a still-sitting message is then a routing question, not a wake one. * (Equally true of the prior inbox_events form — not a regression, just re-anchored.) * * The marker is written on every !peek drain and lives on `agents` (not `mailbox`) so * it resets on unregister — durable past retention without outliving the identity. * TRANSITIONAL LIMIT (inherent, not a defect): an agent ALREADY dark longer than the * inbox_events retention at migration time has no reconstructable drain history * (backfill is best-effort from the retained events), so it reads UNOBSERVABLE until * it next drains — which a dark agent will not do. Expired history is unrecoverable; * the marker guarantees correctness GOING FORWARD, which is what the rule requires. */ import type { CompatDatabase } from "./sqlite-compat.js"; export type WakeVerdict = "covered" | "uncovered" | "unobservable"; export interface WakeCoverageFinding { readonly agent: string; readonly verdict: WakeVerdict; /** Count of the agent's pending-global messages that are stuck past the effective threshold (bound + anti-flap margin). */ readonly pendingCount: number; /** ISO created_at of the oldest such message. */ readonly oldestCreatedAt: string; /** Age of the oldest such message in ms, at evaluation time. */ readonly oldestAgeMs: number; /** * The EFFECTIVE fire threshold actually applied (`boundMs + antiFlapMarginMs`), in ms. * Carried so the operator-facing line renders the threshold that was USED, not the * name of one of its two inputs — "bound" (24h) alone is a lie when 48h fired * (the-fixer finding 2; same class as the grace-log report-what-was-used fix). */ readonly thresholdMs: number; } export interface WakeCoverageOptions { /** Evaluation "now" in epoch ms. Injected for testability. */ readonly nowMs: number; /** Age past which pending-global mail is "stuck." Default 24h. */ readonly boundMs: number; /** * Extra dwell ADDED to the bound before firing (anti-flap). NOT a second check — * effective fire threshold = boundMs + antiFlapMarginMs. Default 24h (→ 48h * effective, the far edge of the measured [24h,48h] range). */ readonly antiFlapMarginMs: number; } export declare const DEFAULT_BOUND_MS: number; export declare const DEFAULT_ANTI_FLAP_MARGIN_MS: number; /** * Pure classification. Reads (never writes) `messages` + `inbox_events`, returns a * finding per agent whose oldest pending-global message has been stuck past * the effective threshold. COVERED agents are omitted (nothing to report); UNCOVERED and * UNOBSERVABLE are returned. Deterministic given (db, nowMs, boundMs, antiFlapMarginMs). */ export declare function classifyWakeCoverage(db: CompatDatabase, opts: WakeCoverageOptions): WakeCoverageFinding[]; /** * REPORT-FIRST rendering. The three verdicts render DISTINCTLY — "uncovered" and * "unobservable" must never read the same, and the unobservable line names WHY * (no drain events ever) so a reader is never left guessing broken-vs-invisible. * One line per finding, prefixed `[wake-coverage]`. Returns the lines; the caller * decides the sink (the daemon logs them to stderr — a human decides). */ export declare function formatWakeCoverageFindings(findings: readonly WakeCoverageFinding[]): string[]; /** * Sweep runner: classify, then emit each finding to the stderr logger (REPORT-FIRST * — the daemon makes drift loud and a human decides; ADR-0007/ADR-0005). Returns the * findings for callers/tests. Never mutates the DB. The daemon calls this on a * periodic tick — the integration point (cadence, enable flag, where the timer * lives) is proposed to the architect before it is wired near the running daemon. */ export declare function runWakeCoverageSweep(db: CompatDatabase, opts: WakeCoverageOptions): WakeCoverageFinding[]; /** Injectable timer seam (matches src/dashboard-state-broadcaster.ts). */ export interface WakeSweepScheduler { setInterval(cb: () => void, ms: number): { stop: () => void; }; } export interface WakeSweepHandle { stop(): void; } /** Real scheduler — unref'd so the sweep never blocks process shutdown. */ export declare function realWakeScheduler(): WakeSweepScheduler; export interface WakeDetectorConfig { readonly enabled: boolean; readonly intervalMs: number; readonly boundMs: number; readonly antiFlapMarginMs: number; } /** Read the detector's config from env. Exported for tests. */ export declare function wakeConfigFromEnv(): WakeDetectorConfig; /** * Start the periodic wake-coverage sweep. HTTP DAEMON ONLY — a per-terminal stdio * server must NOT run this: it would multiply one fleet-wide report across every * terminal and tie a fleet observation to a single terminal's lifetime. Runs one * sweep immediately (a standing regression is reported at startup, not one interval * later), then on the interval. Returns a stop handle; the real timer is unref'd so * it never blocks shutdown. No-op (stop is a no-op) when disabled. */ export declare function startWakeCoverageSweep(db: CompatDatabase, deps?: { scheduler?: WakeSweepScheduler; now?: () => number; }): WakeSweepHandle; //# sourceMappingURL=wake-coverage-detector.d.ts.map