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