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