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