Skip to main content

agentd/obs/
metrics.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Process-local counters → Prometheus text. RFC 0010 §metrics. [feature: metrics]
3//!
4//! Off the default path: the public `record_*` fns are **no-ops unless built
5//! with `--features metrics`**, so call sites stay clean and the default build
6//! pays nothing (metrics are otherwise derivable from the JSON-lines event
7//! stream — that is the default story). With the feature, a tiny dependency-free
8//! atomic registry backs an opt-in HTTP `/metrics` scrape surface (`obs::serve`).
9//!
10//! Counters are **per supervisor process**. The long-lived root daemon's surface
11//! reflects the runs it supervises — every one-shot, reaction, and scheduled fire
12//! flows through `supervise_once` — plus the tokens its *direct* children report
13//! up the control channel. Nested subagents keep their own (process-local)
14//! counters, still visible in their logs; cross-process metric rollup is a
15//! deliberate non-goal for v1 (same boundary as the tree token ceiling).
16//!
17//! ## The frozen `metrics_schema` contract (RFC 0016 §4)
18//!
19//! RFC 0016 §4 freezes the metric **names** and label **keys** below into a
20//! versioned public API ([`METRICS_SCHEMA`]) a control plane (agentctl) authors
21//! dashboards/alerts/scalers against. The exposition mechanism is unchanged from
22//! RFC 0010 §3.8 (hand-written Prometheus 0.0.4 text — no `prometheus`/`metrics`
23//! crate). The §4.3 enumerated set is *the contract*; it is additive within the
24//! major and a removal/rename bumps the major (§8.2).
25//!
26//! **Cardinality (RFC 0016 §4.2, binding):** `/metrics` is unauthenticated and
27//! may be bound on all interfaces. Labels carry **bounded** values only
28//! (`status`, `model`, `type`, `server`, `tool`, `reason`, `limit`, `signal`,
29//! `phase`, `ok`); **never** `run_id` / `agent_id` / `agent_path` / `call_id` /
30//! a resource URI — those are unbounded and live in logs/traces only. A control
31//! plane that needs per-run granularity reads the run report (§6) or the event
32//! stream (§7), never a metric. This module therefore stores label-bearing
33//! series as small **fixed-domain** atomic arrays (the closed label set is known
34//! at compile time), so the cardinality is structurally bounded.
35//!
36//! Telemetry never crashes the agent (RFC 0016 §8.4): every fn here is a plain
37//! atomic add/store that cannot fail; `render` only ever reads.
38
39/// Frozen metrics-schema version (RFC 0016 §4.1 / §8.1). Surfaced in the manifest
40/// at `surfaces.metrics_schema`; the integrator wires the surface — this const is
41/// the single source of truth this chunk owns. Additive series/label-values bump
42/// the minor; a removed/renamed metric or label key bumps the major (§8.2).
43///
44/// 1.1 (RFC 0025): additive — the `agent_budget_tokens_remaining` gauge and the
45/// `tokens_lifetime` value of the `agent_limit_exceeded_total{limit}` domain.
46pub const METRICS_SCHEMA: &str = "1.1";
47
48/// Terminal disposition of one supervised run.
49#[derive(Debug, Clone, Copy)]
50pub enum RunOutcome {
51    Completed,
52    Failed,
53    Killed,
54}
55
56/// A supervised run began (`supervise_once` entry).
57pub fn record_run_started() {
58    #[cfg(feature = "metrics")]
59    imp::REGISTRY.runs_started.fetch_add(1, Ordering::Relaxed);
60}
61
62/// A supervised run reached a terminal disposition.
63///
64/// Also increments the frozen `agent_runs_total{status}` (RFC 0016 §4.3) under a
65/// **coarse** status projection of the three `RunOutcome` variants this call site
66/// carries (`completed` / `crashed` / `cancelled`). The precise RFC 0007 §3.4
67/// terminal-status string is available at the loop boundary but not at this
68/// supervisor hook — see [`record_run_status`] and the integration caveat: an
69/// integrator with the `TerminalStatus` in hand should call that instead for the
70/// full closed-vocabulary label domain.
71pub fn record_run(outcome: RunOutcome) {
72    #[cfg(feature = "metrics")]
73    imp::REGISTRY.record_run(outcome);
74    #[cfg(not(feature = "metrics"))]
75    let _ = outcome;
76}
77
78/// A supervised run reached a terminal status — the **frozen** §4.3 form.
79///
80/// `status` MUST be an RFC 0007 §3.4 closed-vocabulary string
81/// ([`crate::agentloop::stop::TerminalStatus::as_str`]); an out-of-vocabulary
82/// value is bucketed under `other` so the label domain stays closed (§4.2). This
83/// is the precise driver for `agent_runs_total{status}`; it is *not* wired from a
84/// supervisor hook in this chunk (the supervisor only has the coarse `RunOutcome`)
85/// — the integrator/loop chunk calls it where the `TerminalStatus` is known.
86pub fn record_run_status(status: &str) {
87    #[cfg(feature = "metrics")]
88    imp::REGISTRY.record_run_status(status);
89    #[cfg(not(feature = "metrics"))]
90    let _ = status;
91}
92
93/// A reactive trigger fired (one reaction).
94pub fn record_reaction() {
95    #[cfg(feature = "metrics")]
96    imp::REGISTRY.reactions.fetch_add(1, Ordering::Relaxed);
97}
98
99/// Tokens reported up by a direct child (`AgentMsg::Usage`).
100///
101/// Feeds both the legacy bare `agent_tokens_{input,output}_total` and the frozen
102/// `agent_tokens_total{type}` (RFC 0016 §4.3). The §4.3 schema also carries a
103/// `model` label; the `AgentMsg::Usage` control-channel message this call site
104/// rides does not carry the model, so the `model` label is absent here — wiring
105/// the `model` label needs a new call site at the intelligence boundary (see the
106/// integration caveat).
107pub fn record_tokens(input: u64, output: u64) {
108    #[cfg(feature = "metrics")]
109    imp::REGISTRY.record_tokens(input, output);
110    #[cfg(not(feature = "metrics"))]
111    let _ = (input, output);
112}
113
114/// The restart governor's circuit breaker tripped.
115pub fn record_restart_tripped() {
116    #[cfg(feature = "metrics")]
117    imp::REGISTRY
118        .restarts_tripped
119        .fetch_add(1, Ordering::Relaxed);
120}
121
122/// One loop step executed (`loop.step`, RFC 0010 §3.3). Drives
123/// `agent_loop_steps_total` (RFC 0016 §4.3).
124///
125/// **Process-local / unwired:** `loop.step` is emitted inside the re-exec'd child
126/// agentic loop, a different process from the supervisor that `/metrics` scrapes,
127/// so calling this would only bump the child's own registry (cross-process rollup
128/// is a v1 non-goal — module header). It is therefore intentionally NOT called
129/// from the loop; the series renders the supervisor's own process only and
130/// agentctl derives step counts from `loop.step` log lines.
131pub fn record_loop_step() {
132    #[cfg(feature = "metrics")]
133    imp::REGISTRY.loop_steps.fetch_add(1, Ordering::Relaxed);
134}
135
136/// A refusal / guard trip by reason (RFC 0016 §4.3 `agent_refusals_total`).
137///
138/// `reason` is the §4.3 closed domain (`trifecta`/`rate`/`budget`/`depth`/`mcp`);
139/// an unknown value buckets under `other`.
140///
141/// **Process-local / unwired:** refusals trip inside the re-exec'd child loop
142/// (the orchestrator self-tool / scope checks), so a bump here would only reach
143/// the child's process-local registry, never the supervisor scrape (cross-process
144/// rollup is a v1 non-goal). Intentionally not called; the headline safety signal
145/// is the refusal / `scope.trifecta_refused` log line.
146pub fn record_refusal(reason: &str) {
147    #[cfg(feature = "metrics")]
148    imp::REGISTRY.record_refusal(reason);
149    #[cfg(not(feature = "metrics"))]
150    let _ = reason;
151}
152
153/// A hard bound trip (`limit.exceeded`, RFC 0010 §3.3 / RFC 0016 §4.3).
154///
155/// `limit` is the §4.3 closed domain (`steps`/`tokens`/`deadline`/`depth`/
156/// `tree_tokens`/`restart_storm`/`spawn_rate`); an unknown value buckets under
157/// `other`.
158///
159/// **Partially wired:** the `tree_tokens` leg is the supervisor's own tree-ceiling
160/// trip (`supervisor::reactor`, this process), so it is live and reaches the
161/// scrape. The `steps`/`tokens`/`deadline`/`depth` legs trip inside the re-exec'd
162/// child loop and are therefore process-local (not called from the child for the
163/// scrape — derive those from `limit.exceeded` log lines).
164pub fn record_limit_exceeded(limit: &str) {
165    #[cfg(feature = "metrics")]
166    imp::REGISTRY.record_limit_exceeded(limit);
167    #[cfg(not(feature = "metrics"))]
168    let _ = limit;
169}
170
171/// A subagent was spawned (`subagent.spawn`, RFC 0016 §4.3).
172pub fn record_subagent_spawned() {
173    #[cfg(feature = "metrics")]
174    imp::REGISTRY
175        .subagents_spawned
176        .fetch_add(1, Ordering::Relaxed);
177}
178
179/// A subagent exited with a terminal `status` (`subagent.exit`, RFC 0007 §3.4).
180/// Drives `agent_subagents_exited_total{status}` (RFC 0016 §4.3).
181pub fn record_subagent_exited(status: &str) {
182    #[cfg(feature = "metrics")]
183    imp::REGISTRY.record_subagent_exited(status);
184    #[cfg(not(feature = "metrics"))]
185    let _ = status;
186}
187
188/// A subagent was restarted by the governor (`subagent.restart`, RFC 0003 §3.7).
189/// Drives `agent_subagent_restarts_total{reason}` (RFC 0016 §4.3).
190pub fn record_subagent_restart(reason: &str) {
191    #[cfg(feature = "metrics")]
192    imp::REGISTRY.record_subagent_restart(reason);
193    #[cfg(not(feature = "metrics"))]
194    let _ = reason;
195}
196
197/// A wedged/stuck subagent was killed (`subagent.stuck`, RFC 0003 — the
198/// reliability headline). Drives `agent_subagent_stuck_kills_total{signal}`
199/// (RFC 0016 §4.3); `signal` ∈ `term`\|`kill` (an unknown value buckets `other`).
200pub fn record_subagent_stuck_kill(signal: &str) {
201    #[cfg(feature = "metrics")]
202    imp::REGISTRY.record_subagent_stuck_kill(signal);
203    #[cfg(not(feature = "metrics"))]
204    let _ = signal;
205}
206
207/// An intelligence call was made (`intel.call`, RFC 0016 §4.3
208/// `agent_intel_calls_total`).
209pub fn record_intel_call() {
210    #[cfg(feature = "metrics")]
211    imp::REGISTRY.intel_calls.fetch_add(1, Ordering::Relaxed);
212}
213
214/// An intelligence-endpoint error by reason (RFC 0016 §4.3
215/// `agent_intel_errors_total`). `reason` ∈ `unreachable`\|`auth`\|`timeout`\|
216/// `5xx` (an unknown value buckets `other`).
217pub fn record_intel_error(reason: &str) {
218    #[cfg(feature = "metrics")]
219    imp::REGISTRY.record_intel_error(reason);
220    #[cfg(not(feature = "metrics"))]
221    let _ = reason;
222}
223
224/// An MCP connect attempt failed for a declared `server` (`mcp.connect.fail`,
225/// RFC 0016 §4.3 `agent_mcp_connect_failures_total`). `server` is the declared
226/// server name (bounded — there is a fixed, small declared set, RFC 0004); an
227/// over-capacity name buckets under `other` so the series stays bounded.
228pub fn record_mcp_connect_failure(server: &str) {
229    #[cfg(feature = "metrics")]
230    imp::REGISTRY.record_mcp_connect_failure(server);
231    #[cfg(not(feature = "metrics"))]
232    let _ = server;
233}
234
235/// A drain phase transition (RFC 0011 §4 / RFC 0016 §4.3 `agent_drains_total`).
236/// `phase` ∈ `started`\|`completed`\|`forced` (an unknown value buckets `other`).
237pub fn record_drain(phase: &str) {
238    #[cfg(feature = "metrics")]
239    imp::REGISTRY.record_drain(phase);
240    #[cfg(not(feature = "metrics"))]
241    let _ = phase;
242}
243
244/// A supervisor process restart was observed (rebuild+reconcile, RFC 0003 §3.11).
245/// Drives `agent_restarts_total` (RFC 0016 §4.3) — distinct from the breaker-trip
246/// counter [`record_restart_tripped`].
247///
248/// **Reserved / unwired in metrics_schema 1.0:** this build has no in-process
249/// rebuild+reconcile restart path to call it from (a pod restart is a fresh
250/// process with a zeroed registry — an orchestrator counts those, not the
251/// binary). The series renders (always 0) so the frozen contract stays
252/// discoverable; this fn exists for the future reconcile path.
253pub fn record_supervisor_restart() {
254    #[cfg(feature = "metrics")]
255    imp::REGISTRY
256        .supervisor_restarts
257        .fetch_add(1, Ordering::Relaxed);
258}
259
260/// A wedged-reactor liveness trip (RFC 0003 / RFC 0016 §5 / §4.3
261/// `agent_reactor_stalls_total`).
262///
263/// **Reserved / unwired in metrics_schema 1.0:** a wedged reactor is surfaced as a
264/// `/healthz` 503 (a per-scrape read of the heartbeat age in `obs::serve`), not as
265/// a one-shot in-process event, so there is no clean site to bump this exactly
266/// once. The series renders (always 0) for discoverability; the live alerting
267/// signal is the 503 itself.
268pub fn record_reactor_stall() {
269    #[cfg(feature = "metrics")]
270    imp::REGISTRY.reactor_stalls.fetch_add(1, Ordering::Relaxed);
271}
272
273/// Point-in-time set of the intelligence-endpoint reachability gauge
274/// (`agent_intel_up`, RFC 0016 §4.3 — RFC 0006/0018).
275pub fn set_intel_up(up: bool) {
276    #[cfg(feature = "metrics")]
277    imp::REGISTRY
278        .intel_up
279        .store(u64::from(up), Ordering::Relaxed);
280    #[cfg(not(feature = "metrics"))]
281    let _ = up;
282}
283
284/// Point-in-time set of the tree-pause gauge (`agent_paused`, RFC 0015 §5.5 /
285/// RFC 0016 §4.3) — 1 while the `pause` operator tool has frozen the agentic
286/// loops, 0 after `resume`. No-op-safe / metrics-gated, mirroring `set_intel_up`.
287pub fn set_paused(on: bool) {
288    #[cfg(feature = "metrics")]
289    imp::REGISTRY.paused.store(u64::from(on), Ordering::Relaxed);
290    #[cfg(not(feature = "metrics"))]
291    let _ = on;
292}
293
294/// Point-in-time set of the intelligence all-endpoints-down gauge
295/// (`agent_intel_all_down`, RFC 0018 §6 / RFC 0016 §4.3) — 1 while every model
296/// endpoint is down (the latched, eventually-consistent last-child-experience
297/// truth a subagent reports up via `AgentMsg::IntelHealth`; the same flag flips
298/// `/readyz` NotReady). 0 once any endpoint is usable again. Distinct from
299/// `agent_intel_up` (the active endpoint's reachability): all-down is the
300/// fleet-routing signal (no endpoint usable at all). No-op-safe / metrics-gated.
301pub fn set_intel_all_down(on: bool) {
302    #[cfg(feature = "metrics")]
303    imp::REGISTRY
304        .intel_all_down
305        .store(u64::from(on), Ordering::Relaxed);
306    #[cfg(not(feature = "metrics"))]
307    let _ = on;
308}
309
310/// Point-in-time set of the subagent-tree shape gauges (RFC 0016 §4.3:
311/// `agent_active_subagents` / `agent_tree_depth` / `agent_tree_breadth`).
312pub fn set_tree_shape(active: u64, depth: u64, breadth: u64) {
313    #[cfg(feature = "metrics")]
314    imp::REGISTRY.set_tree_shape(active, depth, breadth);
315    #[cfg(not(feature = "metrics"))]
316    let _ = (active, depth, breadth);
317}
318
319/// Point-in-time set of the reactive backlog gauges — the RFC 0019 scaling signal
320/// set (RFC 0016 §4.3: `agent_pending_events` / `agent_inflight_reactions` /
321/// `agent_subscriptions_active` / `agent_reaction_lag_ms`).
322pub fn set_reactive_backlog(pending: u64, inflight: u64, subscriptions: u64, lag_ms: u64) {
323    #[cfg(feature = "metrics")]
324    imp::REGISTRY.set_reactive_backlog(pending, inflight, subscriptions, lag_ms);
325    #[cfg(not(feature = "metrics"))]
326    let _ = (pending, inflight, subscriptions, lag_ms);
327}
328
329/// A config hot reload reached a terminal disposition (RFC 0017 §5.6). Drives
330/// `agent_config_reload_total{result}` with the closed domain
331/// `applied`\|`rejected` (an unknown value buckets `other`). A `rejected` reload
332/// is a clean no-op (the running config is unchanged); `applied` bumps the
333/// generation gauge via [`set_config_generation`].
334pub fn record_config_reload(result: &str) {
335    #[cfg(feature = "metrics")]
336    imp::REGISTRY.record_config_reload(result);
337    #[cfg(not(feature = "metrics"))]
338    let _ = result;
339}
340
341/// A turn worker ran (`agent_turns_total{kind}`, agentd 2.0).
342pub fn record_turn(kind: &str) {
343    #[cfg(feature = "metrics")]
344    imp::REGISTRY.record_turn(kind);
345    #[cfg(not(feature = "metrics"))]
346    let _ = kind;
347}
348
349/// A workflow step reached a terminal status (`agent_steps_total{status}`).
350pub fn record_step(status: &str) {
351    #[cfg(feature = "metrics")]
352    imp::REGISTRY.record_step(status);
353    #[cfg(not(feature = "metrics"))]
354    let _ = status;
355}
356
357/// A remote-store op completed (`agent_store_ops_total{result}` + latency sum).
358pub fn record_store_op(result: &str, latency_ms: u64) {
359    #[cfg(feature = "metrics")]
360    imp::REGISTRY.record_store_op(result, latency_ms);
361    #[cfg(not(feature = "metrics"))]
362    let _ = (result, latency_ms);
363}
364
365/// Point-in-time set of the durable inbox backlog (`agent_inbox_pending`).
366pub fn set_inbox_pending(n: u64) {
367    #[cfg(feature = "metrics")]
368    imp::REGISTRY.set_inbox_pending(n);
369    #[cfg(not(feature = "metrics"))]
370    let _ = n;
371}
372
373/// Point-in-time set of the largest live context's token estimate
374/// (`agent_context_tokens`).
375pub fn set_context_tokens(n: u64) {
376    #[cfg(feature = "metrics")]
377    imp::REGISTRY.set_context_tokens(n);
378    #[cfg(not(feature = "metrics"))]
379    let _ = n;
380}
381
382/// Point-in-time set of the config-generation gauge (`agent_config_generation`,
383/// RFC 0017 §5.6): the count of successfully-applied reloads, so a scraper can
384/// detect "this instance has picked up generation N" against agentctl's desired
385/// generation. Monotonic in practice (the reload loop only ever increments it).
386pub fn set_config_generation(generation: u64) {
387    #[cfg(feature = "metrics")]
388    imp::REGISTRY
389        .config_generation
390        .store(generation, Ordering::Relaxed);
391    #[cfg(not(feature = "metrics"))]
392    let _ = generation;
393}
394
395/// Point-in-time set of the lifetime-budget balance gauge
396/// (`agent_budget_tokens_remaining`, RFC 0025 §3.4): tokens left before the
397/// per-instance cumulative cap is reached — the alerting/scaling hook for the
398/// threshold event. Only ever set when a budget is installed (absent = the
399/// gauge stays at its 0 default, which a scraper reads together with the fact
400/// that no budget metric transitions occurred; unbounded instances simply never
401/// call this).
402pub fn set_budget_tokens_remaining(remaining: u64) {
403    #[cfg(feature = "metrics")]
404    imp::REGISTRY
405        .budget_tokens_remaining
406        .store(remaining, Ordering::Relaxed);
407    #[cfg(not(feature = "metrics"))]
408    let _ = remaining;
409}
410
411/// Render the current counters (+ live cgroup memory gauges) as Prometheus text.
412#[cfg(feature = "metrics")]
413pub fn render_prometheus() -> String {
414    let mut s = imp::REGISTRY.render();
415    s.push_str(&imp::memory_gauges(crate::supervisor::cgroup::snapshot()));
416    s
417}
418
419#[cfg(feature = "metrics")]
420use std::sync::atomic::Ordering;
421
422#[cfg(feature = "metrics")]
423mod imp {
424    use super::RunOutcome;
425    use std::fmt::Write;
426    use std::sync::atomic::{AtomicU64, Ordering};
427
428    pub(super) static REGISTRY: Registry = Registry::new();
429
430    // --- closed label domains (RFC 0016 §4.2/§4.3) ---------------------------
431    // Each is a fixed array of `(label_value, AtomicU64)`; an out-of-vocabulary
432    // value lands in the trailing `other` slot so the series stays bounded. The
433    // arrays ARE the cardinality bound — there is no map, no allocation, no
434    // unbounded label key path.
435
436    /// `agent_runs_total{status}` / `agent_subagents_exited_total{status}`
437    /// label domain: the RFC 0007 §3.4 closed terminal-status vocabulary
438    /// (verbatim from `TerminalStatus::as_str`), plus `other`.
439    const STATUS_LABELS: &[&str] = &[
440        "completed",
441        "refused",
442        "exhausted_steps",
443        "exhausted_tokens",
444        "deadline",
445        "stalled",
446        "loop_detected",
447        "cancelled",
448        "crashed",
449        "other",
450    ];
451
452    /// `agent_refusals_total{reason}` label domain (RFC 0016 §4.3).
453    const REFUSAL_REASONS: &[&str] = &["trifecta", "rate", "budget", "depth", "mcp", "other"];
454
455    /// `agent_limit_exceeded_total{limit}` label domain (mirrors the
456    /// `limit.exceeded` event's `limit` field, RFC 0016 §4.3).
457    const LIMIT_LABELS: &[&str] = &[
458        "steps",
459        "tokens",
460        "deadline",
461        "depth",
462        "tree_tokens",
463        "tokens_lifetime",
464        "restart_storm",
465        "spawn_rate",
466        "other",
467    ];
468
469    /// `agent_subagent_restarts_total{reason}` label domain (RFC 0003 §3.7).
470    const RESTART_REASONS: &[&str] = &["crashed", "stuck", "rate", "other"];
471
472    /// `agent_subagent_stuck_kills_total{signal}` label domain (RFC 0016 §4.3).
473    const SIGNAL_LABELS: &[&str] = &["term", "kill", "other"];
474
475    /// `agent_intel_errors_total{reason}` label domain (RFC 0016 §4.3).
476    const INTEL_ERROR_REASONS: &[&str] = &["unreachable", "auth", "timeout", "5xx", "other"];
477
478    /// `agent_drains_total{phase}` label domain (RFC 0011 §4 / RFC 0016 §4.3).
479    const DRAIN_PHASES: &[&str] = &["started", "completed", "forced", "other"];
480
481    /// `agent_tokens_total{type}` direction label domain (RFC 0016 §4.3).
482    const TOKEN_TYPES: &[&str] = &["in", "out"];
483
484    /// `agent_config_reload_total{result}` label domain (RFC 0017 §5.6). A hot
485    /// reload either `applied` (the reloadable diff took effect) or was `rejected`
486    /// (invalid / restart-only / inconsistent → a clean no-op); `other` is the
487    /// catch-all that keeps the series bounded (RFC 0016 §4.2).
488    const RELOAD_RESULTS: &[&str] = &["applied", "rejected", "other"];
489
490    /// `agent_turns_total{kind}` label domain (agentd 2.0, plan §3.11): a turn
491    /// worker's context kind — a `root`/conversation turn, a `preflight` think, a
492    /// `compaction` think — plus `other`.
493    const TURN_KINDS: &[&str] = &["root", "preflight", "compaction", "knowledge", "other"];
494
495    /// `agent_steps_total{status}` label domain (agentd 2.0): a workflow step's
496    /// terminal status — `done` / `failed` / `skipped` — plus `other`.
497    const STEP_STATUS: &[&str] = &["done", "failed", "skipped", "other"];
498
499    /// `agent_store_ops_total{result}` label domain (agentd 2.0, RFC 0025 §3.4):
500    /// a remote-store op outcome — `ok` / `conflict` (a CAS mismatch) / `error` —
501    /// plus `other`.
502    const STORE_RESULTS: &[&str] = &["ok", "conflict", "error", "other"];
503
504    /// A fixed-domain labelled counter family: one atomic per known label value.
505    /// `N` matches the backing domain slice length; the trailing slot is the
506    /// `other` catch-all that keeps the cardinality bound (RFC 0016 §4.2).
507    struct LabelCounter<const N: usize> {
508        slots: [AtomicU64; N],
509    }
510
511    impl<const N: usize> LabelCounter<N> {
512        const fn new() -> Self {
513            LabelCounter {
514                slots: [const { AtomicU64::new(0) }; N],
515            }
516        }
517
518        /// Increment the slot for `value`; an unknown value lands in the last
519        /// (`other`) slot. `domain` MUST have length `N`.
520        fn inc(&self, domain: &[&str], value: &str) {
521            let idx = domain.iter().position(|&l| l == value).unwrap_or(N - 1);
522            self.slots[idx].fetch_add(1, Ordering::Relaxed);
523        }
524    }
525
526    pub(super) struct Registry {
527        // --- legacy bare series (RFC 0010 §3.8; kept — additive within major) -
528        pub(super) runs_started: AtomicU64,
529        runs_completed: AtomicU64,
530        runs_failed: AtomicU64,
531        runs_killed: AtomicU64,
532        pub(super) reactions: AtomicU64,
533        tokens_input: AtomicU64,
534        tokens_output: AtomicU64,
535        pub(super) restarts_tripped: AtomicU64,
536
537        // --- frozen §4.3: run lifecycle & terminal-status --------------------
538        runs_total: LabelCounter<{ STATUS_LABELS.len() }>,
539        pub(super) loop_steps: AtomicU64,
540
541        // --- frozen §4.3: refusal / bound counters ---------------------------
542        refusals: LabelCounter<{ REFUSAL_REASONS.len() }>,
543        limit_exceeded: LabelCounter<{ LIMIT_LABELS.len() }>,
544
545        // --- frozen §4.3: subagent-tree gauges + counters --------------------
546        active_subagents: AtomicU64,
547        tree_depth: AtomicU64,
548        tree_breadth: AtomicU64,
549        pub(super) subagents_spawned: AtomicU64,
550        subagents_exited: LabelCounter<{ STATUS_LABELS.len() }>,
551        subagent_restarts: LabelCounter<{ RESTART_REASONS.len() }>,
552        subagent_stuck_kills: LabelCounter<{ SIGNAL_LABELS.len() }>,
553
554        // --- frozen §4.3: intelligence health --------------------------------
555        pub(super) intel_calls: AtomicU64,
556        pub(super) intel_up: AtomicU64,
557        // RFC 0018 §6: 1 while ALL model endpoints are down (the latched,
558        // eventually-consistent last-child-experience truth that also flips
559        // `/readyz`). Distinct from `intel_up` (the active endpoint's reachability).
560        pub(super) intel_all_down: AtomicU64,
561        intel_errors: LabelCounter<{ INTEL_ERROR_REASONS.len() }>,
562
563        // --- RFC 0015 §5.5: tree-pause gauge (0/1) ---------------------------
564        // Set by the `pause`/`resume` operator tools; 1 while the tree is paused.
565        pub(super) paused: AtomicU64,
566
567        // --- frozen §4.3: MCP server health ----------------------------------
568        mcp_connect_failures: LabelCounter<{ MCP_SERVER_SLOTS }>,
569
570        // --- frozen §4.3: lifecycle events -----------------------------------
571        drains: LabelCounter<{ DRAIN_PHASES.len() }>,
572        pub(super) supervisor_restarts: AtomicU64,
573        pub(super) reactor_stalls: AtomicU64,
574
575        // --- frozen §4.3: token accounting (typed) ---------------------------
576        tokens_typed: LabelCounter<{ TOKEN_TYPES.len() }>,
577
578        // --- frozen §4.3: reactive backlog (the RFC 0019 scaling signal set) --
579        pending_events: AtomicU64,
580        inflight_reactions: AtomicU64,
581        subscriptions_active: AtomicU64,
582        reaction_lag_ms: AtomicU64,
583
584        // --- RFC 0017 §5.6: hot-reload outcome counter + generation gauge -----
585        config_reloads: LabelCounter<{ RELOAD_RESULTS.len() }>,
586        pub(super) config_generation: AtomicU64,
587
588        // --- RFC 0025 §3.4: per-instance lifetime-budget balance gauge --------
589        pub(super) budget_tokens_remaining: AtomicU64,
590
591        // --- agentd 2.0 (plan §3.11): the v2 runtime series ------------------
592        turns_total: LabelCounter<{ TURN_KINDS.len() }>,
593        steps_total: LabelCounter<{ STEP_STATUS.len() }>,
594        store_ops: LabelCounter<{ STORE_RESULTS.len() }>,
595        store_latency_ms_sum: AtomicU64,
596        pub(super) inbox_pending: AtomicU64,
597        pub(super) context_tokens: AtomicU64,
598    }
599
600    impl Registry {
601        const fn new() -> Registry {
602            Registry {
603                runs_started: AtomicU64::new(0),
604                runs_completed: AtomicU64::new(0),
605                runs_failed: AtomicU64::new(0),
606                runs_killed: AtomicU64::new(0),
607                reactions: AtomicU64::new(0),
608                tokens_input: AtomicU64::new(0),
609                tokens_output: AtomicU64::new(0),
610                restarts_tripped: AtomicU64::new(0),
611                runs_total: LabelCounter::new(),
612                loop_steps: AtomicU64::new(0),
613                refusals: LabelCounter::new(),
614                limit_exceeded: LabelCounter::new(),
615                active_subagents: AtomicU64::new(0),
616                tree_depth: AtomicU64::new(0),
617                tree_breadth: AtomicU64::new(0),
618                subagents_spawned: AtomicU64::new(0),
619                subagents_exited: LabelCounter::new(),
620                subagent_restarts: LabelCounter::new(),
621                subagent_stuck_kills: LabelCounter::new(),
622                intel_calls: AtomicU64::new(0),
623                intel_up: AtomicU64::new(0),
624                intel_all_down: AtomicU64::new(0),
625                intel_errors: LabelCounter::new(),
626                paused: AtomicU64::new(0),
627                mcp_connect_failures: LabelCounter::new(),
628                drains: LabelCounter::new(),
629                supervisor_restarts: AtomicU64::new(0),
630                reactor_stalls: AtomicU64::new(0),
631                tokens_typed: LabelCounter::new(),
632                pending_events: AtomicU64::new(0),
633                inflight_reactions: AtomicU64::new(0),
634                subscriptions_active: AtomicU64::new(0),
635                reaction_lag_ms: AtomicU64::new(0),
636                config_reloads: LabelCounter::new(),
637                config_generation: AtomicU64::new(0),
638                budget_tokens_remaining: AtomicU64::new(0),
639                turns_total: LabelCounter::new(),
640                steps_total: LabelCounter::new(),
641                store_ops: LabelCounter::new(),
642                store_latency_ms_sum: AtomicU64::new(0),
643                inbox_pending: AtomicU64::new(0),
644                context_tokens: AtomicU64::new(0),
645            }
646        }
647
648        pub(super) fn record_run(&self, outcome: RunOutcome) {
649            // Legacy bare counters.
650            let c = match outcome {
651                RunOutcome::Completed => &self.runs_completed,
652                RunOutcome::Failed => &self.runs_failed,
653                RunOutcome::Killed => &self.runs_killed,
654            };
655            c.fetch_add(1, Ordering::Relaxed);
656            // Frozen §4.3 `agent_runs_total{status}` — a COARSE projection of the
657            // three `RunOutcome` variants this hook carries onto the RFC 0007 §3.4
658            // domain (a precise status needs `record_run_status`, see the caveat).
659            let status = match outcome {
660                RunOutcome::Completed => "completed",
661                RunOutcome::Failed => "crashed",
662                RunOutcome::Killed => "cancelled",
663            };
664            self.runs_total.inc(STATUS_LABELS, status);
665        }
666
667        pub(super) fn record_run_status(&self, status: &str) {
668            self.runs_total.inc(STATUS_LABELS, status);
669        }
670
671        pub(super) fn record_tokens(&self, input: u64, output: u64) {
672            self.tokens_input.fetch_add(input, Ordering::Relaxed);
673            self.tokens_output.fetch_add(output, Ordering::Relaxed);
674            // Frozen §4.3 `agent_tokens_total{type}` (the `model` label is not
675            // available at the `AgentMsg::Usage` hook — see the caveat).
676            self.tokens_typed.slots[0].fetch_add(input, Ordering::Relaxed);
677            self.tokens_typed.slots[1].fetch_add(output, Ordering::Relaxed);
678        }
679
680        pub(super) fn record_refusal(&self, reason: &str) {
681            self.refusals.inc(REFUSAL_REASONS, reason);
682        }
683
684        pub(super) fn record_limit_exceeded(&self, limit: &str) {
685            self.limit_exceeded.inc(LIMIT_LABELS, limit);
686        }
687
688        pub(super) fn record_subagent_exited(&self, status: &str) {
689            self.subagents_exited.inc(STATUS_LABELS, status);
690        }
691
692        pub(super) fn record_subagent_restart(&self, reason: &str) {
693            self.subagent_restarts.inc(RESTART_REASONS, reason);
694        }
695
696        pub(super) fn record_subagent_stuck_kill(&self, signal: &str) {
697            self.subagent_stuck_kills.inc(SIGNAL_LABELS, signal);
698        }
699
700        pub(super) fn record_intel_error(&self, reason: &str) {
701            self.intel_errors.inc(INTEL_ERROR_REASONS, reason);
702        }
703
704        pub(super) fn record_mcp_connect_failure(&self, server: &str) {
705            mcp_servers().record_failure(&self.mcp_connect_failures, server);
706        }
707
708        pub(super) fn record_drain(&self, phase: &str) {
709            self.drains.inc(DRAIN_PHASES, phase);
710        }
711
712        pub(super) fn record_config_reload(&self, result: &str) {
713            self.config_reloads.inc(RELOAD_RESULTS, result);
714        }
715
716        pub(super) fn record_turn(&self, kind: &str) {
717            self.turns_total.inc(TURN_KINDS, kind);
718        }
719
720        pub(super) fn record_step(&self, status: &str) {
721            self.steps_total.inc(STEP_STATUS, status);
722        }
723
724        pub(super) fn record_store_op(&self, result: &str, latency_ms: u64) {
725            self.store_ops.inc(STORE_RESULTS, result);
726            self.store_latency_ms_sum
727                .fetch_add(latency_ms, Ordering::Relaxed);
728        }
729
730        pub(super) fn set_inbox_pending(&self, n: u64) {
731            self.inbox_pending.store(n, Ordering::Relaxed);
732        }
733
734        pub(super) fn set_context_tokens(&self, n: u64) {
735            self.context_tokens.store(n, Ordering::Relaxed);
736        }
737
738        pub(super) fn set_tree_shape(&self, active: u64, depth: u64, breadth: u64) {
739            self.active_subagents.store(active, Ordering::Relaxed);
740            self.tree_depth.store(depth, Ordering::Relaxed);
741            self.tree_breadth.store(breadth, Ordering::Relaxed);
742        }
743
744        pub(super) fn set_reactive_backlog(
745            &self,
746            pending: u64,
747            inflight: u64,
748            subscriptions: u64,
749            lag_ms: u64,
750        ) {
751            self.pending_events.store(pending, Ordering::Relaxed);
752            self.inflight_reactions.store(inflight, Ordering::Relaxed);
753            self.subscriptions_active
754                .store(subscriptions, Ordering::Relaxed);
755            self.reaction_lag_ms.store(lag_ms, Ordering::Relaxed);
756        }
757
758        pub(super) fn render(&self) -> String {
759            let mut s = String::new();
760            let g = |a: &AtomicU64| a.load(Ordering::Relaxed);
761
762            // --- liveness / readiness gauges (RFC 0016 §4.3) -----------------
763            // `agent_up` is always 1 while we can render. `agent_ready` is
764            // derived from the same process-wide drain/lame-duck state `/readyz`
765            // reports (RFC 0010 §3.7) — read-only, no extra call site.
766            gauge(&mut s, "agent_up", "1 while the process is alive", 1);
767            // `agent_ready` mirrors `/readyz` exactly (RFC 0010 §3.7 / RFC 0018 §6):
768            // NotReady when draining, lame-ducked, OR all intelligence endpoints are
769            // down — the same three conditions the readiness probe consults.
770            let ready = u64::from(
771                !crate::signals::draining()
772                    && !crate::signals::lame_duck()
773                    && !crate::signals::intel_all_down(),
774            );
775            gauge(
776                &mut s,
777                "agent_ready",
778                "1 when ready to accept work (not draining / lame-ducked / intel-all-down)",
779                ready,
780            );
781            // `agent_paused` (RFC 0015 §5.5): 1 while the tree is paused at turn
782            // boundaries. Pause is NOT readiness — a paused instance can still be
783            // ready (the `ready` gauge above ignores pause, only drain/lame-duck).
784            gauge(
785                &mut s,
786                "agent_paused",
787                "1 while the agentic tree is paused at turn boundaries (RFC 0015 §4.3)",
788                g(&self.paused),
789            );
790
791            // --- run lifecycle & terminal-status (RFC 0016 §4.3) -------------
792            labelled_counter(
793                &mut s,
794                "agent_runs_total",
795                "Runs by terminal status (RFC 0007 §3.4).",
796                "status",
797                STATUS_LABELS,
798                &self.runs_total,
799            );
800            // `agent_loop_steps_total` is driven by `loop.step`, which is emitted
801            // INSIDE the re-exec'd child agentic loop — a different process from the
802            // supervisor this scrape reflects. `record_loop_step` is intentionally
803            // left unwired here: bumping it would only touch the child's own
804            // process-local registry, never this supervisor's. The series is
805            // rendered (so the frozen contract stays discoverable) but reads the
806            // supervisor's own process only; cross-process rollup is a v1 non-goal
807            // (module header). agentctl derives per-run step counts from `loop.step`
808            // log lines (the default story), not from this counter.
809            counter(
810                &mut s,
811                "agent_loop_steps_total",
812                "Agentic loop steps (process-local; emitted in the child loop, so the supervisor scrape reflects its own process only — cross-process rollup is a v1 non-goal).",
813                g(&self.loop_steps),
814            );
815
816            // --- token / cost accounting (RFC 0016 §4.3) ---------------------
817            // `agent_tokens_total{type}`: the `model` label RFC 0016 §4.3 freezes
818            // is DEFERRED in metrics_schema 1.0 — the only call site
819            // (`record_tokens`, fed by `AgentMsg::Usage` up the control channel)
820            // does not carry the model identifier, and adding it needs a new emit
821            // site at the intelligence boundary. The label key is reserved; it is
822            // intentionally absent (never faked) until that site lands. agentctl
823            // gets per-model token splits from `intel.result.usage` log lines.
824            labelled_counter(
825                &mut s,
826                "agent_tokens_total",
827                "Model tokens by direction (the frozen `model` label is deferred in metrics_schema 1.0 — the AgentMsg::Usage hook carries no model id; never faked).",
828                "type",
829                TOKEN_TYPES,
830                &self.tokens_typed,
831            );
832            // `agent_intel_calls_total`: same `model`-label deferral as tokens
833            // (the `record_intel_call` site carries no model id). Additionally
834            // process-local — `IntelClient::complete` runs in the re-exec'd child
835            // (the supervisor makes no LLM calls), so this reflects only the
836            // scraped process. Derive per-model call counts from `intel.call` logs.
837            counter(
838                &mut s,
839                "agent_intel_calls_total",
840                "Intelligence calls made (process-local — the LLM client runs in the child; the frozen `model` label is deferred in metrics_schema 1.0, never faked).",
841                g(&self.intel_calls),
842            );
843
844            // --- refusal / bound counters (RFC 0016 §4.3) --------------------
845            // `agent_refusals_total` is driven by the model/loop refusing or a
846            // guard tripping — all INSIDE the re-exec'd child loop (orchestrator
847            // self-tool / scope checks), so `record_refusal` is left unwired: it
848            // would only bump the child's process-local registry. Rendered for
849            // contract discoverability but process-local (the supervisor scrape
850            // reflects its own process; cross-process rollup is a v1 non-goal).
851            // agentctl derives refusals from the refusal/`scope.trifecta_refused`
852            // log lines.
853            labelled_counter(
854                &mut s,
855                "agent_refusals_total",
856                "Refusals/guard trips by reason (process-local; tripped in the child loop, so the supervisor scrape reflects its own process only).",
857                "reason",
858                REFUSAL_REASONS,
859                &self.refusals,
860            );
861            // `agent_limit_exceeded_total{limit}` is PARTIALLY wired: the
862            // `tree_tokens` leg is the supervisor's own tree-ceiling trip
863            // (`supervisor::reactor`, this process → reaches the scrape), so it is
864            // live. The `steps`/`tokens`/`deadline`/`depth` legs trip inside the
865            // re-exec'd child loop and are therefore process-local (unwired here;
866            // derived from `limit.exceeded` log lines). Same cross-process boundary
867            // as the rest of this module.
868            labelled_counter(
869                &mut s,
870                "agent_limit_exceeded_total",
871                "Hard-bound trips by limit (the `tree_tokens` leg is supervisor-live; the steps/tokens/deadline/depth legs trip in the child loop and are process-local).",
872                "limit",
873                LIMIT_LABELS,
874                &self.limit_exceeded,
875            );
876
877            // --- subagent-tree gauges + counters (RFC 0016 §4.3) -------------
878            gauge(
879                &mut s,
880                "agent_active_subagents",
881                "Subagents currently alive in the tree.",
882                g(&self.active_subagents),
883            );
884            gauge(
885                &mut s,
886                "agent_tree_depth",
887                "Current max subagent-tree depth.",
888                g(&self.tree_depth),
889            );
890            gauge(
891                &mut s,
892                "agent_tree_breadth",
893                "Current max siblings at any tree node.",
894                g(&self.tree_breadth),
895            );
896            counter(
897                &mut s,
898                "agent_subagents_spawned_total",
899                "Subagents spawned.",
900                g(&self.subagents_spawned),
901            );
902            labelled_counter(
903                &mut s,
904                "agent_subagents_exited_total",
905                "Subagents exited by terminal status (RFC 0007 §3.4).",
906                "status",
907                STATUS_LABELS,
908                &self.subagents_exited,
909            );
910            labelled_counter(
911                &mut s,
912                "agent_subagent_restarts_total",
913                "Subagent restarts by reason (RFC 0003 §3.7).",
914                "reason",
915                RESTART_REASONS,
916                &self.subagent_restarts,
917            );
918            labelled_counter(
919                &mut s,
920                "agent_subagent_stuck_kills_total",
921                "Wedged-subagent kills by signal (RFC 0003).",
922                "signal",
923                SIGNAL_LABELS,
924                &self.subagent_stuck_kills,
925            );
926
927            // --- intelligence health (RFC 0016 §4.3) -------------------------
928            gauge(
929                &mut s,
930                "agent_intel_up",
931                "1 when the intelligence endpoint is reachable.",
932                g(&self.intel_up),
933            );
934            // `agent_intel_all_down` (RFC 0018 §6): 1 while EVERY model endpoint is
935            // down — the fleet-routing signal (the same latch that flips /readyz).
936            gauge(
937                &mut s,
938                "agent_intel_all_down",
939                "1 while all intelligence endpoints are down (RFC 0018 §6).",
940                g(&self.intel_all_down),
941            );
942            labelled_counter(
943                &mut s,
944                "agent_intel_errors_total",
945                "Intelligence-endpoint errors by reason.",
946                "reason",
947                INTEL_ERROR_REASONS,
948                &self.intel_errors,
949            );
950
951            // --- MCP server health (RFC 0016 §4.3) ---------------------------
952            // `agent_mcp_up{server}` is gauge-per-declared-server; this chunk
953            // has no declared-server registration hook, so it is RESERVED/not
954            // emitted in this build (the honest-absence precedent the rest of this
955            // module follows). The connect-failure counter below IS wired — the
956            // daemon's supervisor-process connect path (initial + hot-reload add,
957            // `triggers::mode`) calls `record_mcp_connect_failure(server)`, so a
958            // failing declared server shows up here labelled by `server`. (A
959            // child-side connect failure is process-local and does not reach this
960            // supervisor scrape — the cross-process boundary in the module header.)
961            mcp_servers().render_connect_failures(&mut s, &self.mcp_connect_failures);
962
963            // --- tool-call accounting (RFC 0016 §4.3) — RESERVED -------------
964            // `agent_tool_calls_total{server,tool,ok}` is keyed off `tool.result`,
965            // whose boundary (`McpClient::call_tool`) runs predominantly INSIDE the
966            // re-exec'd child loop (the subagent's tool use); the only supervisor-
967            // process call sites are the reactor's own management/lease calls
968            // (`cluster` claim gate), not the agent's tool use the dashboard wants.
969            // A scrape-side counter would therefore be process-local and misleading
970            // (it would NOT reflect the children's tool calls), so the series is
971            // RESERVED here — rendered as a HELP/TYPE marker, no fabricated 0 — and
972            // agentctl reads tool calls from `tool.result` log lines (the default
973            // story). This mirrors the `agent_mcp_up` honest-absence precedent.
974            reserved(
975                &mut s,
976                "agent_tool_calls_total",
977                "counter",
978                "Tool calls by server/tool/ok (RFC 0016 §4.3) — reserved in metrics_schema 1.0; the tool-call boundary runs in the child loop, so a supervisor scrape can't reflect it (derive from tool.result log lines).",
979            );
980            // `agent_tool_call_duration_ms` / `agent_intel_call_duration_ms` /
981            // `agent_run_duration_ms` are frozen HISTOGRAMS. This crate has no
982            // histogram exposition machinery (no bucket/sum/count emission, by
983            // design — RFC 0010 §3.8 keeps the surface a hand-written counter/gauge
984            // text), so they are RESERVED: rendered as HELP/TYPE markers only, no
985            // fabricated buckets. Implementing them is a real feature beyond this
986            // honesty pass; a half-built histogram would be worse than an honest
987            // marker. Latency lives in the `dur_ms` field of the matching log lines.
988            reserved(
989                &mut s,
990                "agent_tool_call_duration_ms",
991                "histogram",
992                "Tool-call latency (RFC 0016 §4.3) — reserved in metrics_schema 1.0; histogram exposition not implemented (use the tool.result dur_ms field).",
993            );
994            reserved(
995                &mut s,
996                "agent_intel_call_duration_ms",
997                "histogram",
998                "Intelligence-call latency (RFC 0016 §4.3) — reserved in metrics_schema 1.0; histogram exposition not implemented (use the intel.result dur_ms field).",
999            );
1000            reserved(
1001                &mut s,
1002                "agent_run_duration_ms",
1003                "histogram",
1004                "Run latency by terminal status (RFC 0016 §4.3) — reserved in metrics_schema 1.0; histogram exposition not implemented (derive from run start→terminal log lines).",
1005            );
1006
1007            // --- lifecycle events (RFC 0016 §4.3) ----------------------------
1008            // `agent_drains_total{phase}` is wired: the reactor's per-run teardown
1009            // (`supervisor::reactor`) and the daemon's graceful wind-down
1010            // (`triggers::mode`) both run in this (supervisor) process and bump
1011            // `started`/`completed`/`forced`.
1012            labelled_counter(
1013                &mut s,
1014                "agent_drains_total",
1015                "Drain phase transitions (RFC 0011 §4).",
1016                "phase",
1017                DRAIN_PHASES,
1018                &self.drains,
1019            );
1020            // `agent_restarts_total` is RESERVED in metrics_schema 1.0: it counts
1021            // a supervisor process *restart* (rebuild+reconcile, RFC 0003 §3.11),
1022            // and this build has no such in-process restart path to emit it from
1023            // (a pod restart is a fresh process with a zeroed registry — an
1024            // orchestrator counts those, not the binary). Rendered (always 0) so
1025            // the frozen series stays discoverable; `record_supervisor_restart`
1026            // exists for the future reconcile path but is intentionally unwired.
1027            counter(
1028                &mut s,
1029                "agent_restarts_total",
1030                "Supervisor process restarts observed (RFC 0003 §3.11) — reserved in metrics_schema 1.0; no in-process restart/reconcile emit site in this build.",
1031                g(&self.supervisor_restarts),
1032            );
1033            // `agent_reactor_stalls_total` is RESERVED in metrics_schema 1.0: a
1034            // wedged reactor is surfaced as a `/healthz` 503 (a derived read of the
1035            // heartbeat age in `obs::serve`, evaluated per scrape — RFC 0010 §3.7),
1036            // not as a one-shot in-process event, so there is no clean emit site to
1037            // bump a counter exactly once. Rendered (always 0) for discoverability;
1038            // `record_reactor_stall` is intentionally unwired pending a dedicated
1039            // stall-detection edge. The liveness signal an operator alerts on is the
1040            // 503 itself, not this counter.
1041            counter(
1042                &mut s,
1043                "agent_reactor_stalls_total",
1044                "Wedged-reactor liveness trips (RFC 0003) — reserved in metrics_schema 1.0; the live signal is the /healthz 503, no one-shot in-process emit site yet.",
1045                g(&self.reactor_stalls),
1046            );
1047
1048            // --- hot reload (RFC 0017 §5.6) ----------------------------------
1049            // `agent_config_reload_total{result}` over the closed applied/rejected
1050            // domain, plus `agent_config_generation` (applied-reload count) so a
1051            // scraper detects "generation N is effective" against the desired one.
1052            labelled_counter(
1053                &mut s,
1054                "agent_config_reload_total",
1055                "Hot reloads by result (RFC 0017 §5.6).",
1056                "result",
1057                RELOAD_RESULTS,
1058                &self.config_reloads,
1059            );
1060            gauge(
1061                &mut s,
1062                "agent_config_generation",
1063                "Successfully-applied config reloads (the live generation).",
1064                g(&self.config_generation),
1065            );
1066
1067            // --- lifetime budget balance (RFC 0025 §3.4) ---------------------
1068            // Tokens remaining before the per-instance cumulative cap; the
1069            // alerting/scaling hook. 0 both when unbounded (never set) and when
1070            // exhausted — a scraper distinguishes them via the budget event/limit
1071            // metric, so unbounded instances read as "no budget in play".
1072            gauge(
1073                &mut s,
1074                "agent_budget_tokens_remaining",
1075                "Tokens left before the per-instance lifetime budget (RFC 0025); 0 when unbounded or exhausted.",
1076                g(&self.budget_tokens_remaining),
1077            );
1078
1079            // --- reactive backlog — the RFC 0019 scaling signal set (§4.3) ---
1080            gauge(
1081                &mut s,
1082                "agent_pending_events",
1083                "Reactive events received but not yet routed.",
1084                g(&self.pending_events),
1085            );
1086            gauge(
1087                &mut s,
1088                "agent_inflight_reactions",
1089                "Reactions currently executing.",
1090                g(&self.inflight_reactions),
1091            );
1092            gauge(
1093                &mut s,
1094                "agent_subscriptions_active",
1095                "Reconciled declared subscriptions.",
1096                g(&self.subscriptions_active),
1097            );
1098            gauge(
1099                &mut s,
1100                "agent_reaction_lag_ms",
1101                "Age of the oldest un-routed pending event (ms).",
1102                g(&self.reaction_lag_ms),
1103            );
1104
1105            // --- legacy bare series (RFC 0010 §3.8; retained, additive) ------
1106            counter(
1107                &mut s,
1108                "agent_runs_started_total",
1109                "Supervised runs started",
1110                g(&self.runs_started),
1111            );
1112            counter(
1113                &mut s,
1114                "agent_runs_completed_total",
1115                "Supervised runs that completed",
1116                g(&self.runs_completed),
1117            );
1118            counter(
1119                &mut s,
1120                "agent_runs_failed_total",
1121                "Supervised runs that failed on infra",
1122                g(&self.runs_failed),
1123            );
1124            counter(
1125                &mut s,
1126                "agent_runs_killed_total",
1127                "Supervised runs torn down by the supervisor",
1128                g(&self.runs_killed),
1129            );
1130            counter(
1131                &mut s,
1132                "agent_reactions_total",
1133                "Reactive triggers fired",
1134                g(&self.reactions),
1135            );
1136            counter(
1137                &mut s,
1138                "agent_tokens_input_total",
1139                "Input tokens reported by direct children",
1140                g(&self.tokens_input),
1141            );
1142            counter(
1143                &mut s,
1144                "agent_tokens_output_total",
1145                "Output tokens reported by direct children",
1146                g(&self.tokens_output),
1147            );
1148            counter(
1149                &mut s,
1150                "agent_restarts_tripped_total",
1151                "Restart-governor breaker trips",
1152                g(&self.restarts_tripped),
1153            );
1154
1155            // --- agentd 2.0 runtime series (plan §3.11) ----------------------
1156            labelled_counter(
1157                &mut s,
1158                "agent_turns_total",
1159                "Turn-worker runs by context kind (agentd 2.0).",
1160                "kind",
1161                TURN_KINDS,
1162                &self.turns_total,
1163            );
1164            labelled_counter(
1165                &mut s,
1166                "agent_steps_total",
1167                "Workflow steps by terminal status (agentd 2.0, RFC 0027).",
1168                "status",
1169                STEP_STATUS,
1170                &self.steps_total,
1171            );
1172            labelled_counter(
1173                &mut s,
1174                "agent_store_ops_total",
1175                "Remote-store ops by result (agentd 2.0, RFC 0025).",
1176                "result",
1177                STORE_RESULTS,
1178                &self.store_ops,
1179            );
1180            counter(
1181                &mut s,
1182                "agent_store_latency_ms_sum",
1183                "Cumulative remote-store op latency (ms); divide by agent_store_ops_total for the mean.",
1184                g(&self.store_latency_ms_sum),
1185            );
1186            gauge(
1187                &mut s,
1188                "agent_inbox_pending",
1189                "Durable inbox events awaiting processing (agentd 2.0, RFC 0025).",
1190                g(&self.inbox_pending),
1191            );
1192            gauge(
1193                &mut s,
1194                "agent_context_tokens",
1195                "Estimated token size of the largest live conversation context (agentd 2.0).",
1196                g(&self.context_tokens),
1197            );
1198            s
1199        }
1200    }
1201
1202    // --- `agent_mcp_connect_failures_total{server}` -------------------------
1203    // The `server` label is bounded (RFC 0004: a small, fixed declared set) but
1204    // its *values* are config-time strings, not a compile-time enum. We bound it
1205    // structurally with a fixed slot table that interns server names on first use;
1206    // once full, further names fold into `other` so the series can never grow
1207    // unbounded (RFC 0016 §4.2). The table is a process-global behind a Mutex —
1208    // a slow path touched only on a connect failure, never on the render hot path
1209    // beyond a read snapshot.
1210
1211    /// Max distinct `server` label values held before folding into `other`.
1212    const MCP_SERVER_SLOTS: usize = 16;
1213
1214    struct McpServerTable {
1215        names: std::sync::Mutex<Vec<String>>,
1216    }
1217
1218    impl McpServerTable {
1219        const fn new() -> Self {
1220            McpServerTable {
1221                names: std::sync::Mutex::new(Vec::new()),
1222            }
1223        }
1224
1225        /// Index for `server`; interns on first use, or the `other` slot
1226        /// (`MCP_SERVER_SLOTS - 1`) once the table is full. Poisoning is ignored
1227        /// (telemetry never crashes the agent — RFC 0016 §8.4).
1228        fn index(&self, server: &str) -> usize {
1229            let mut names = match self.names.lock() {
1230                Ok(g) => g,
1231                Err(p) => p.into_inner(),
1232            };
1233            if let Some(i) = names.iter().position(|n| n == server) {
1234                return i;
1235            }
1236            if names.len() < MCP_SERVER_SLOTS - 1 {
1237                names.push(server.to_string());
1238                return names.len() - 1;
1239            }
1240            MCP_SERVER_SLOTS - 1
1241        }
1242
1243        fn record_failure(&self, ctr: &LabelCounter<MCP_SERVER_SLOTS>, server: &str) {
1244            let idx = self.index(server);
1245            ctr.slots[idx].fetch_add(1, Ordering::Relaxed);
1246        }
1247
1248        /// Emit one `agent_mcp_connect_failures_total{server="…"}` line per
1249        /// interned server with a non-zero count, plus the `other` overflow slot.
1250        fn render_connect_failures(&self, s: &mut String, ctr: &LabelCounter<MCP_SERVER_SLOTS>) {
1251            let names = match self.names.lock() {
1252                Ok(g) => g,
1253                Err(p) => p.into_inner(),
1254            };
1255            let name = "agent_mcp_connect_failures_total";
1256            let _ = writeln!(s, "# HELP {name} MCP connect failures by server.");
1257            let _ = writeln!(s, "# TYPE {name} counter");
1258            for (i, server) in names.iter().enumerate() {
1259                let v = ctr.slots[i].load(Ordering::Relaxed);
1260                let _ = writeln!(s, "{name}{{server={:?}}} {v}", server.as_str());
1261            }
1262            let other = ctr.slots[MCP_SERVER_SLOTS - 1].load(Ordering::Relaxed);
1263            if other != 0 {
1264                let _ = writeln!(s, "{name}{{server=\"other\"}} {other}");
1265            }
1266        }
1267    }
1268
1269    fn mcp_servers() -> &'static McpServerTable {
1270        static TABLE: McpServerTable = McpServerTable::new();
1271        &TABLE
1272    }
1273
1274    /// One counter family in Prometheus text exposition format.
1275    fn counter(s: &mut String, name: &str, help: &str, value: u64) {
1276        let _ = writeln!(s, "# HELP {name} {help}");
1277        let _ = writeln!(s, "# TYPE {name} counter");
1278        let _ = writeln!(s, "{name} {value}");
1279    }
1280
1281    /// A frozen `metrics_schema 1.0` series whose machinery is not implemented in
1282    /// this build: render the `# HELP`/`# TYPE` headers (so the contract stays
1283    /// discoverable from the scrape and a future silent-drop is catchable) WITHOUT
1284    /// a fabricated always-0 sample line. This is the same honest-absence shape as
1285    /// `agent_mcp_up` — a marker, not a value. `kind` is the Prometheus type the
1286    /// series will eventually carry (`counter`/`histogram`); `help` MUST say it is
1287    /// reserved and why (cross-process boundary / no histogram exposition yet).
1288    fn reserved(s: &mut String, name: &str, kind: &str, help: &str) {
1289        let _ = writeln!(s, "# HELP {name} {help}");
1290        let _ = writeln!(s, "# TYPE {name} {kind}");
1291    }
1292
1293    /// One gauge family (point-in-time value) in Prometheus text format.
1294    fn gauge(s: &mut String, name: &str, help: &str, value: u64) {
1295        let _ = writeln!(s, "# HELP {name} {help}");
1296        let _ = writeln!(s, "# TYPE {name} gauge");
1297        let _ = writeln!(s, "{name} {value}");
1298    }
1299
1300    /// One float-valued gauge family (e.g. a [0,1] ratio) in Prometheus text.
1301    /// One labelled counter family: a single HELP/TYPE header, then one series
1302    /// line per closed-domain label value (RFC 0016 §4.2 — the domain is the
1303    /// bound). `domain` and the `LabelCounter` slots are the same length.
1304    fn labelled_counter<const N: usize>(
1305        s: &mut String,
1306        name: &str,
1307        help: &str,
1308        label: &str,
1309        domain: &[&str],
1310        ctr: &LabelCounter<N>,
1311    ) {
1312        let _ = writeln!(s, "# HELP {name} {help}");
1313        let _ = writeln!(s, "# TYPE {name} counter");
1314        for (i, value) in domain.iter().enumerate() {
1315            let v = ctr.slots[i].load(Ordering::Relaxed);
1316            let _ = writeln!(s, "{name}{{{label}={value:?}}} {v}");
1317        }
1318    }
1319
1320    /// Live cgroup v2 memory gauges, emitted only for fields the kernel exposes
1321    /// (kept out of `Registry::render` so the counter set stays deterministic).
1322    pub(super) fn memory_gauges(mem: crate::supervisor::cgroup::MemorySnapshot) -> String {
1323        let mut s = String::new();
1324        if let Some(v) = mem.max {
1325            gauge(
1326                &mut s,
1327                "agent_memory_max_bytes",
1328                "cgroup v2 memory.max hard limit (bytes)",
1329                v,
1330            );
1331        }
1332        if let Some(v) = mem.current {
1333            gauge(
1334                &mut s,
1335                "agent_memory_current_bytes",
1336                "cgroup v2 memory.current usage (bytes)",
1337                v,
1338            );
1339        }
1340        s
1341    }
1342
1343    #[cfg(test)]
1344    mod tests {
1345        use super::*;
1346
1347        #[test]
1348        fn render_is_valid_prometheus_text() {
1349            let r = Registry::new();
1350            r.runs_started.fetch_add(3, Ordering::Relaxed);
1351            r.record_run(RunOutcome::Completed);
1352            r.record_run(RunOutcome::Failed);
1353            r.record_tokens(100, 50);
1354            let out = r.render();
1355            assert!(out.contains("# TYPE agent_runs_started_total counter"));
1356            assert!(out.contains("agent_runs_started_total 3"));
1357            assert!(out.contains("agent_runs_completed_total 1"));
1358            assert!(out.contains("agent_runs_failed_total 1"));
1359            assert!(out.contains("agent_tokens_input_total 100"));
1360            assert!(out.contains("agent_tokens_output_total 50"));
1361        }
1362
1363        #[test]
1364        fn frozen_schema_emits_up_and_ready_gauges() {
1365            let r = Registry::new();
1366            let out = r.render();
1367            // RFC 0016 §4.3 liveness/readiness gauges, label-free.
1368            assert!(out.contains("# TYPE agent_up gauge"));
1369            assert!(out.contains("agent_up 1"));
1370            assert!(out.contains("# TYPE agent_ready gauge"));
1371            // ready is 0/1; in a bare test process (no drain) it is 1.
1372            assert!(out.contains("agent_ready "));
1373        }
1374
1375        #[test]
1376        fn paused_gauge_renders_zero_then_one() {
1377            // RFC 0015 §5.5: `agent_paused` is a 0/1 gauge, default 0.
1378            let r = Registry::new();
1379            let out = r.render();
1380            assert!(out.contains("# TYPE agent_paused gauge"));
1381            assert!(out.contains("agent_paused 0"));
1382            // Set via the same atomic `set_paused` writes; renders 1.
1383            r.paused.store(1, Ordering::Relaxed);
1384            assert!(r.render().contains("agent_paused 1"));
1385        }
1386
1387        #[test]
1388        fn intel_all_down_gauge_renders_zero_then_one() {
1389            // RFC 0018 §6: `agent_intel_all_down` is a 0/1 gauge, default 0, set
1390            // from the latched all-down flag (the same one /readyz reads).
1391            let r = Registry::new();
1392            let out = r.render();
1393            assert!(out.contains("# TYPE agent_intel_all_down gauge"));
1394            assert!(out.contains("agent_intel_all_down 0"));
1395            // Set via the same atomic `set_intel_all_down` writes; renders 1.
1396            r.intel_all_down.store(1, Ordering::Relaxed);
1397            assert!(r.render().contains("agent_intel_all_down 1"));
1398        }
1399
1400        #[test]
1401        fn runs_total_uses_the_closed_status_domain() {
1402            let r = Registry::new();
1403            r.record_run_status("completed");
1404            r.record_run_status("refused");
1405            r.record_run_status("refused");
1406            // an out-of-vocabulary status buckets under `other`, never a new label
1407            r.record_run_status("totally_made_up");
1408            let out = r.render();
1409            assert!(out.contains("agent_runs_total{status=\"completed\"} 1"));
1410            assert!(out.contains("agent_runs_total{status=\"refused\"} 2"));
1411            assert!(out.contains("agent_runs_total{status=\"other\"} 1"));
1412            // every closed-domain value is present (zero-valued series included)
1413            assert!(out.contains("agent_runs_total{status=\"loop_detected\"} 0"));
1414            // exactly one HELP/TYPE header for the family
1415            assert_eq!(out.matches("# TYPE agent_runs_total counter").count(), 1);
1416        }
1417
1418        #[test]
1419        fn typed_tokens_track_direction() {
1420            let r = Registry::new();
1421            r.record_tokens(880, 40);
1422            r.record_tokens(120, 10);
1423            let out = r.render();
1424            assert!(out.contains("agent_tokens_total{type=\"in\"} 1000"));
1425            assert!(out.contains("agent_tokens_total{type=\"out\"} 50"));
1426        }
1427
1428        #[test]
1429        fn refusals_and_limits_use_closed_domains() {
1430            let r = Registry::new();
1431            r.record_refusal("trifecta");
1432            r.record_refusal("depth");
1433            r.record_refusal("depth");
1434            r.record_limit_exceeded("spawn_rate");
1435            let out = r.render();
1436            assert!(out.contains("agent_refusals_total{reason=\"trifecta\"} 1"));
1437            assert!(out.contains("agent_refusals_total{reason=\"depth\"} 2"));
1438            assert!(out.contains("agent_limit_exceeded_total{limit=\"spawn_rate\"} 1"));
1439            // closed domains: a stray reason never widens the label set
1440            r.record_refusal("nope");
1441            assert!(
1442                r.render()
1443                    .contains("agent_refusals_total{reason=\"other\"} 1")
1444            );
1445        }
1446
1447        #[test]
1448        fn tree_and_backlog_gauges_are_settable() {
1449            let r = Registry::new();
1450            r.set_tree_shape(4, 2, 3);
1451            r.set_reactive_backlog(7, 1, 9, 250);
1452            let out = r.render();
1453            assert!(out.contains("agent_active_subagents 4"));
1454            assert!(out.contains("agent_tree_depth 2"));
1455            assert!(out.contains("agent_tree_breadth 3"));
1456            assert!(out.contains("agent_pending_events 7"));
1457            assert!(out.contains("agent_inflight_reactions 1"));
1458            assert!(out.contains("agent_subscriptions_active 9"));
1459            assert!(out.contains("agent_reaction_lag_ms 250"));
1460        }
1461
1462        #[test]
1463        fn mcp_connect_failures_label_by_server_and_fold_overflow() {
1464            let r = Registry::new();
1465            r.record_mcp_connect_failure("github");
1466            r.record_mcp_connect_failure("github");
1467            r.record_mcp_connect_failure("filesystem");
1468            let out = r.render();
1469            assert!(out.contains("agent_mcp_connect_failures_total{server=\"github\"} 2"));
1470            assert!(out.contains("agent_mcp_connect_failures_total{server=\"filesystem\"} 1"));
1471        }
1472
1473        #[test]
1474        fn drains_phase_distinguishes_clean_from_forced() {
1475            let r = Registry::new();
1476            r.record_drain("started");
1477            r.record_drain("completed");
1478            r.record_drain("forced");
1479            let out = r.render();
1480            assert!(out.contains("agent_drains_total{phase=\"completed\"} 1"));
1481            assert!(out.contains("agent_drains_total{phase=\"forced\"} 1"));
1482        }
1483
1484        #[test]
1485        fn config_reload_total_renders_both_label_values_and_generation() {
1486            // RFC 0017 §5.6: the reload counter has the closed applied/rejected
1487            // domain (every value rendered, zero-valued included), and the
1488            // generation gauge tracks applied reloads.
1489            let r = Registry::new();
1490            let out = r.render();
1491            // Both closed-domain series are present even at zero.
1492            assert!(out.contains("# TYPE agent_config_reload_total counter"));
1493            assert!(out.contains("agent_config_reload_total{result=\"applied\"} 0"));
1494            assert!(out.contains("agent_config_reload_total{result=\"rejected\"} 0"));
1495            assert!(out.contains("# TYPE agent_config_generation gauge"));
1496            assert!(out.contains("agent_config_generation 0"));
1497            // They increment over the closed domain; an unknown buckets `other`.
1498            r.record_config_reload("applied");
1499            r.record_config_reload("rejected");
1500            r.record_config_reload("rejected");
1501            r.record_config_reload("totally_made_up");
1502            r.config_generation.store(1, Ordering::Relaxed);
1503            let out = r.render();
1504            assert!(out.contains("agent_config_reload_total{result=\"applied\"} 1"));
1505            assert!(out.contains("agent_config_reload_total{result=\"rejected\"} 2"));
1506            assert!(out.contains("agent_config_reload_total{result=\"other\"} 1"));
1507            assert!(out.contains("agent_config_generation 1"));
1508            // Exactly one HELP/TYPE header for the counter family.
1509            assert_eq!(
1510                out.matches("# TYPE agent_config_reload_total counter")
1511                    .count(),
1512                1
1513            );
1514        }
1515
1516        #[test]
1517        fn budget_gauge_and_lifetime_limit_render() {
1518            // RFC 0025: the balance gauge (present at 0 by default) + the additive
1519            // `tokens_lifetime` value of the closed `agent_limit_exceeded_total`
1520            // domain.
1521            let r = Registry::new();
1522            let out = r.render();
1523            assert!(out.contains("# TYPE agent_budget_tokens_remaining gauge"));
1524            assert!(out.contains("agent_budget_tokens_remaining 0"));
1525
1526            r.budget_tokens_remaining.store(1500, Ordering::Relaxed);
1527            r.record_limit_exceeded("tokens_lifetime");
1528            let out = r.render();
1529            assert!(out.contains("agent_budget_tokens_remaining 1500"));
1530            assert!(out.contains("agent_limit_exceeded_total{limit=\"tokens_lifetime\"} 1"));
1531        }
1532
1533        #[test]
1534        fn no_unbounded_identifier_labels_leak() {
1535            // §4.2 cardinality: render must never contain a run_id/agent_path-style
1536            // label key. We assert the only label keys present are the bounded set.
1537            let r = Registry::new();
1538            r.record_run_status("completed");
1539            r.record_tokens(1, 1);
1540            r.record_refusal("trifecta");
1541            r.record_mcp_connect_failure("github");
1542            let out = r.render();
1543            for forbidden in [
1544                "run_id=",
1545                "agent_id=",
1546                "agent_path=",
1547                "call_id=",
1548                "session_id=",
1549                "uri=",
1550            ] {
1551                assert!(
1552                    !out.contains(forbidden),
1553                    "leaked unbounded label: {forbidden}"
1554                );
1555            }
1556        }
1557
1558        #[test]
1559        fn memory_gauges_emit_only_present_fields() {
1560            use crate::supervisor::cgroup::MemorySnapshot;
1561            // a limited cgroup → two gauge families
1562            let g = memory_gauges(MemorySnapshot {
1563                max: Some(1024),
1564                current: Some(512),
1565                high: None,
1566            });
1567            assert!(g.contains("# TYPE agent_memory_max_bytes gauge"));
1568            assert!(g.contains("agent_memory_max_bytes 1024"));
1569            assert!(g.contains("agent_memory_current_bytes 512"));
1570            assert_eq!(g.matches(" gauge\n").count(), 2);
1571            // no cgroup → no gauge lines (keeps /metrics clean off-cgroup)
1572            assert!(memory_gauges(MemorySnapshot::default()).is_empty());
1573        }
1574
1575        #[test]
1576        fn frozen_schema_4_3_series_all_present_emitted_or_reserved() {
1577            // Honesty gate: every frozen RFC 0016 §4.3 series MUST be discoverable
1578            // from the render — either as a live counter/gauge or as a reserved
1579            // HELP/TYPE marker. This catches a future silent drop of a frozen
1580            // series (a major-bump-only change) at test time.
1581            let r = Registry::new();
1582            let out = r.render();
1583            // The full §4.3 metric-name set (the names are the frozen contract).
1584            for name in [
1585                // liveness/readiness gauges
1586                "agent_up",
1587                "agent_ready",
1588                // run lifecycle + tokens + intel
1589                "agent_runs_total",
1590                "agent_run_duration_ms", // reserved (histogram)
1591                "agent_loop_steps_total",
1592                "agent_tokens_total",
1593                "agent_intel_calls_total",
1594                "agent_intel_call_duration_ms", // reserved (histogram)
1595                // refusal / bound
1596                "agent_refusals_total",
1597                "agent_limit_exceeded_total",
1598                // subagent tree
1599                "agent_active_subagents",
1600                "agent_tree_depth",
1601                "agent_tree_breadth",
1602                "agent_subagents_spawned_total",
1603                "agent_subagents_exited_total",
1604                "agent_subagent_restarts_total",
1605                "agent_subagent_stuck_kills_total",
1606                // intelligence health
1607                "agent_intel_up",
1608                "agent_intel_errors_total",
1609                // MCP server health
1610                "agent_mcp_connect_failures_total",
1611                // tool-call accounting (reserved)
1612                "agent_tool_calls_total",
1613                "agent_tool_call_duration_ms", // reserved (histogram)
1614                // lifecycle events
1615                "agent_drains_total",
1616                "agent_restarts_total",       // reserved (no emit site)
1617                "agent_reactor_stalls_total", // reserved (no emit site)
1618                // reactive backlog
1619                "agent_pending_events",
1620                "agent_inflight_reactions",
1621                "agent_subscriptions_active",
1622                "agent_reaction_lag_ms",
1623            ] {
1624                assert!(
1625                    out.contains(&format!("# TYPE {name} ")),
1626                    "frozen §4.3 series missing from render: {name}"
1627                );
1628            }
1629            // The three histograms + the deferred tool-call counter are RESERVED:
1630            // a HELP/TYPE marker, NO fabricated sample line (the honest-absence
1631            // shape — no `name <value>` and no `name{...} <value>`).
1632            for reserved in [
1633                "agent_run_duration_ms",
1634                "agent_intel_call_duration_ms",
1635                "agent_tool_call_duration_ms",
1636                "agent_tool_calls_total",
1637            ] {
1638                assert!(
1639                    out.contains(&format!("# TYPE {reserved} ")),
1640                    "reserved series marker missing: {reserved}"
1641                );
1642                // No sample line for the reserved series (only the two `#` headers).
1643                for line in out.lines() {
1644                    if line.starts_with('#') {
1645                        continue;
1646                    }
1647                    assert!(
1648                        !line.starts_with(reserved),
1649                        "reserved series {reserved} must not emit a sample line: {line:?}"
1650                    );
1651                }
1652            }
1653            // The reserved markers say so (honest HELP text).
1654            assert!(out.contains("reserved in metrics_schema 1.0"));
1655        }
1656
1657        #[test]
1658        fn wired_supervisor_counters_increment() {
1659            // The supervisor-process counters wired in this pass increment via the
1660            // same registry methods the emit sites call. (The emit sites live in
1661            // `supervisor::reactor` / `triggers::mode`; here we exercise the
1662            // registry contract those call sites depend on.)
1663            let r = Registry::new();
1664            // subagent spawn/exit (reactor.rs).
1665            r.subagents_spawned.fetch_add(1, Ordering::Relaxed);
1666            r.record_subagent_exited("completed");
1667            r.record_subagent_exited("cancelled");
1668            // stuck-kill ladder (reactor.rs drive_drain Term/Kill).
1669            r.record_subagent_stuck_kill("term");
1670            r.record_subagent_stuck_kill("kill");
1671            // drain phases (reactor.rs begin_drain/Done/timeout + mode.rs daemon).
1672            r.record_drain("started");
1673            r.record_drain("completed");
1674            r.record_drain("forced");
1675            // restart governor respawn (mode.rs Backoff branch).
1676            r.record_subagent_restart("crashed");
1677            // mcp connect failure (mode.rs connect + hot-reload add).
1678            r.record_mcp_connect_failure("github");
1679            // tree-token bound trip (reactor.rs Usage handler).
1680            r.record_limit_exceeded("tree_tokens");
1681            let out = r.render();
1682            assert!(out.contains("agent_subagents_spawned_total 1"));
1683            assert!(out.contains("agent_subagents_exited_total{status=\"completed\"} 1"));
1684            assert!(out.contains("agent_subagents_exited_total{status=\"cancelled\"} 1"));
1685            assert!(out.contains("agent_subagent_stuck_kills_total{signal=\"term\"} 1"));
1686            assert!(out.contains("agent_subagent_stuck_kills_total{signal=\"kill\"} 1"));
1687            assert!(out.contains("agent_drains_total{phase=\"started\"} 1"));
1688            assert!(out.contains("agent_drains_total{phase=\"completed\"} 1"));
1689            assert!(out.contains("agent_drains_total{phase=\"forced\"} 1"));
1690            assert!(out.contains("agent_subagent_restarts_total{reason=\"crashed\"} 1"));
1691            assert!(out.contains("agent_mcp_connect_failures_total{server=\"github\"} 1"));
1692            assert!(out.contains("agent_limit_exceeded_total{limit=\"tree_tokens\"} 1"));
1693        }
1694
1695        #[test]
1696        fn reserved_no_emit_counters_render_zero() {
1697            // `agent_restarts_total` (supervisor restart) and
1698            // `agent_reactor_stalls_total` have no in-process emit site in this
1699            // build; they render reserved-but-present at 0 so the contract stays
1700            // discoverable without falsely claiming a non-zero value.
1701            let r = Registry::new();
1702            let out = r.render();
1703            assert!(out.contains("# TYPE agent_restarts_total counter"));
1704            assert!(out.contains("agent_restarts_total 0"));
1705            assert!(out.contains("# TYPE agent_reactor_stalls_total counter"));
1706            assert!(out.contains("agent_reactor_stalls_total 0"));
1707            // Their HELP marks them reserved (not silently permanent-0). Both
1708            // reserved-counter HELP lines carry the marker phrase.
1709            assert!(out.matches("reserved in metrics_schema 1.0").count() >= 2);
1710        }
1711    }
1712}