Skip to main content

agentd/obs/
metrics.rs

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