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}