hive-agent: export a per-file line count for subagent role files
A role file is read into every subagent dispatch that names it, and nothing measured its size. A new watch on the same 15-minute tick as claude_md_watch records `hyperhive.agent.subagent_role.lines`, one series per `<role>.md` under the state dir's `subagent_roles/`, labelled `role` with the file stem. Metric only: no todo, no threshold. Fixes #4558
This commit is contained in:
parent
74b63dd031
commit
f19a9f546e
4 changed files with 123 additions and 6 deletions
|
|
@ -377,12 +377,17 @@ of their own — see [ACP-reported cost](#acp-reported-cost).
|
||||||
| `hyperhive.agent.loose_ends.threads` | — | gauge | none |
|
| `hyperhive.agent.loose_ends.threads` | — | gauge | none |
|
||||||
| `hyperhive.agent.loose_ends.reminders` | — | gauge | none |
|
| `hyperhive.agent.loose_ends.reminders` | — | gauge | none |
|
||||||
| `hyperhive.agent.claude_md.lines` | — | gauge | none — recorded from the `CLAUDE.md`-size watch's own ~15-minute tick, **not** per turn like the rows above |
|
| `hyperhive.agent.claude_md.lines` | — | gauge | none — recorded from the `CLAUDE.md`-size watch's own ~15-minute tick, **not** per turn like the rows above |
|
||||||
|
| `hyperhive.agent.subagent_role.lines` | — | gauge | `role` (the file stem) — one series per role file in `subagent_roles/`, on the same ~15-minute tick |
|
||||||
| `hyperhive.agent.claude_usage.percent` | `%` | gauge | `window` (`five_hour`, `seven_day`, … as the usage endpoint names them) — polled every 5 minutes, **not** per turn |
|
| `hyperhive.agent.claude_usage.percent` | `%` | gauge | `window` (`five_hour`, `seven_day`, … as the usage endpoint names them) — polled every 5 minutes, **not** per turn |
|
||||||
| `hyperhive.agent.claude_usage.resets_at` | `s` | gauge | `window` — unix seconds at which that window resets; same 5-minute poll |
|
| `hyperhive.agent.claude_usage.resets_at` | `s` | gauge | `window` — unix seconds at which that window resets; same 5-minute poll |
|
||||||
| `hyperhive.agent.context.used` | `{token}` | gauge | none — tokens in the context window at turn end, the numerator of the web UI's ctx% |
|
| `hyperhive.agent.context.used` | `{token}` | gauge | none — tokens in the context window at turn end, the numerator of the web UI's ctx% |
|
||||||
| `hyperhive.agent.context.size` | `{token}` | gauge | none — the context window those tokens fill, ctx%'s denominator |
|
| `hyperhive.agent.context.size` | `{token}` | gauge | none — the context window those tokens fill, ctx%'s denominator |
|
||||||
| `hyperhive.agent.cost.usage` | — | counter | `model`, `currency` — ACP agents only, see below |
|
| `hyperhive.agent.cost.usage` | — | counter | `model`, `currency` — ACP agents only, see below |
|
||||||
|
|
||||||
|
An agent with no `subagent_roles/` directory exports no
|
||||||
|
`subagent_role.lines` series. A role file deleted while the harness runs
|
||||||
|
keeps its last value until the harness restarts.
|
||||||
|
|
||||||
For the two `claude_usage` gauges the harness polls the Claude subscription
|
For the two `claude_usage` gauges the harness polls the Claude subscription
|
||||||
usage endpoint (`GET /api/oauth/usage`, the one behind claude's own `/usage`)
|
usage endpoint (`GET /api/oauth/usage`, the one behind claude's own `/usage`)
|
||||||
with the OAuth access token from the agent's own
|
with the OAuth access token from the agent's own
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,7 @@ mod schedule_timer;
|
||||||
mod serve_common;
|
mod serve_common;
|
||||||
mod state_entry_watch;
|
mod state_entry_watch;
|
||||||
mod stats;
|
mod stats;
|
||||||
|
mod subagent_role_watch;
|
||||||
mod swarm_agent_icon;
|
mod swarm_agent_icon;
|
||||||
mod swarm_agent_state;
|
mod swarm_agent_state;
|
||||||
mod swarm_inference;
|
mod swarm_inference;
|
||||||
|
|
@ -519,6 +520,7 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
|
||||||
// hive-c0re, because the files are agent-owned — see `vacuum` module docs.
|
// hive-c0re, because the files are agent-owned — see `vacuum` module docs.
|
||||||
tokio::spawn(crate::vacuum::run());
|
tokio::spawn(crate::vacuum::run());
|
||||||
tokio::spawn(claude_usage_watch::run());
|
tokio::spawn(claude_usage_watch::run());
|
||||||
|
tokio::spawn(subagent_role_watch::run());
|
||||||
// Log web_ui::serve's error instead of dropping it. A bare
|
// Log web_ui::serve's error instead of dropping it. A bare
|
||||||
// `tokio::spawn(web_ui::serve(...))` discards the JoinHandle, so
|
// `tokio::spawn(web_ui::serve(...))` discards the JoinHandle, so
|
||||||
// any Err (e.g. EACCES from `bind_unix` when HIVE_WEB_SOCKET points
|
// any Err (e.g. EACCES from `bind_unix` when HIVE_WEB_SOCKET points
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,12 @@
|
||||||
//! from `handle_turn`. The `PeriodicReader` still batches + exports on its
|
//! from `handle_turn`. The `PeriodicReader` still batches + exports on its
|
||||||
//! own interval; only the recording is event-driven, not the export.
|
//! own interval; only the recording is event-driven, not the export.
|
||||||
//!
|
//!
|
||||||
//! [`record_claude_md_lines`] is the one exception, sharing this module's
|
//! Three exceptions share this module's exporter but not [`record`]'s
|
||||||
//! exporter setup rather than [`record`]'s per-turn call site — `CLAUDE.md`
|
//! per-turn call site: their values are continuously live (like hive-c0re's
|
||||||
//! size *is* continuously live (like hive-c0re's cgroup values), so
|
//! cgroup values), so each is recorded from its own watch's periodic tick —
|
||||||
//! [`crate::claude_md_watch`] records it from its own periodic tick instead.
|
//! [`record_claude_md_lines`] from [`crate::claude_md_watch`],
|
||||||
//! [`record_claude_usage`] is the same kind of exception, fed from
|
//! [`record_subagent_role_lines`] from [`crate::subagent_role_watch`], and
|
||||||
//! [`crate::claude_usage_watch`]'s poll of the subscription usage endpoint.
|
//! [`record_claude_usage`] from [`crate::claude_usage_watch`].
|
||||||
//!
|
//!
|
||||||
//! Resource attributes (`service.name`, `agent`, `hive`, `swarm`) are picked
|
//! Resource attributes (`service.name`, `agent`, `hive`, `swarm`) are picked
|
||||||
//! up automatically by the SDK from the container-wide `OTEL_RESOURCE_ATTRIBUTES`
|
//! up automatically by the SDK from the container-wide `OTEL_RESOURCE_ATTRIBUTES`
|
||||||
|
|
@ -58,6 +58,9 @@ struct Instruments {
|
||||||
/// hive-c0re's container gauges), not a once-per-turn one, so it has
|
/// hive-c0re's container gauges), not a once-per-turn one, so it has
|
||||||
/// its own entry point rather than riding `record`'s per-turn call.
|
/// its own entry point rather than riding `record`'s per-turn call.
|
||||||
claude_md_lines: Gauge<u64>,
|
claude_md_lines: Gauge<u64>,
|
||||||
|
/// Recorded from [`crate::subagent_role_watch`]'s own tick, keyed by
|
||||||
|
/// `role`.
|
||||||
|
subagent_role_lines: Gauge<u64>,
|
||||||
/// Recorded from [`crate::claude_usage_watch`]'s own tick, keyed by
|
/// Recorded from [`crate::claude_usage_watch`]'s own tick, keyed by
|
||||||
/// `window`.
|
/// `window`.
|
||||||
claude_usage_percent: Gauge<f64>,
|
claude_usage_percent: Gauge<f64>,
|
||||||
|
|
@ -120,6 +123,17 @@ pub fn record_claude_md_lines(lines: u64) {
|
||||||
inst.claude_md_lines.record(lines, &[]);
|
inst.claude_md_lines.record(lines, &[]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Record one subagent role file's line count, keyed by `role` (the file
|
||||||
|
/// stem). Called from [`crate::subagent_role_watch`]'s periodic tick.
|
||||||
|
/// No-op when OTEL isn't configured, same as [`record`].
|
||||||
|
pub fn record_subagent_role_lines(role: String, lines: u64) {
|
||||||
|
let Some(inst) = INSTRUMENTS.get_or_init(build).as_ref() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
inst.subagent_role_lines
|
||||||
|
.record(lines, &[KeyValue::new("role", role)]);
|
||||||
|
}
|
||||||
|
|
||||||
/// Record one subscription usage window (`five_hour`, `seven_day`, …):
|
/// Record one subscription usage window (`five_hour`, `seven_day`, …):
|
||||||
/// `percent` is 0-100, `resets_at` unix seconds. No-op when OTEL isn't
|
/// `percent` is 0-100, `resets_at` unix seconds. No-op when OTEL isn't
|
||||||
/// configured, same as [`record`].
|
/// configured, same as [`record`].
|
||||||
|
|
@ -182,6 +196,9 @@ fn build() -> Option<Instruments> {
|
||||||
.u64_gauge("hyperhive.agent.loose_ends.reminders")
|
.u64_gauge("hyperhive.agent.loose_ends.reminders")
|
||||||
.build(),
|
.build(),
|
||||||
claude_md_lines: meter.u64_gauge("hyperhive.agent.claude_md.lines").build(),
|
claude_md_lines: meter.u64_gauge("hyperhive.agent.claude_md.lines").build(),
|
||||||
|
subagent_role_lines: meter
|
||||||
|
.u64_gauge("hyperhive.agent.subagent_role.lines")
|
||||||
|
.build(),
|
||||||
claude_usage_percent: meter
|
claude_usage_percent: meter
|
||||||
.f64_gauge("hyperhive.agent.claude_usage.percent")
|
.f64_gauge("hyperhive.agent.claude_usage.percent")
|
||||||
.with_unit("%")
|
.with_unit("%")
|
||||||
|
|
|
||||||
93
hive-agent/src/subagent_role_watch.rs
Normal file
93
hive-agent/src/subagent_role_watch.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
//! Subagent role-file size watch: feeds each role file's line count into
|
||||||
|
//! an OTEL gauge, one series per role, on the same ~15-minute tick as
|
||||||
|
//! [`crate::claude_md_watch`]. Metric only — no todo, no threshold.
|
||||||
|
//!
|
||||||
|
//! A role is read into every subagent dispatch that names it, so one
|
||||||
|
//! role's growth is the signal; a per-file series shows which role grew,
|
||||||
|
//! where a total would not.
|
||||||
|
//!
|
||||||
|
//! Role files are `<role>.md` directly under `subagent_roles/` in the
|
||||||
|
//! agent's state dir — the directory `hive-subagent-mcp`'s
|
||||||
|
//! `paths::subagent_roles_dir` loads them from. An absent directory is
|
||||||
|
//! the ordinary state for an agent with no roles and exports nothing.
|
||||||
|
//!
|
||||||
|
//! A role file deleted while the harness runs keeps its last value until
|
||||||
|
//! the harness restarts: a synchronous gauge has no way to retract a
|
||||||
|
//! series.
|
||||||
|
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
/// Same cadence as `claude_md_watch`.
|
||||||
|
const CHECK_INTERVAL: Duration = Duration::from_mins(15);
|
||||||
|
/// Extension every role file carries; the role name is the file stem.
|
||||||
|
const ROLE_EXT: &str = "md";
|
||||||
|
|
||||||
|
/// Background loop: periodically record every role file's line count.
|
||||||
|
/// Detached task — runs for the harness's lifetime; errors are logged,
|
||||||
|
/// never fatal.
|
||||||
|
pub async fn run() {
|
||||||
|
loop {
|
||||||
|
tokio::time::sleep(CHECK_INTERVAL).await;
|
||||||
|
match tokio::task::spawn_blocking(|| role_line_counts(&roles_dir())).await {
|
||||||
|
Ok(counts) => {
|
||||||
|
for (role, lines) in counts {
|
||||||
|
crate::otel_turn_metrics::record_subagent_role_lines(role, lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => tracing::warn!(error = %e, "subagent role watch probe panicked"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The agent's role directory, resolved the same way as
|
||||||
|
/// `hive-subagent-mcp`'s `paths::subagent_roles_dir`.
|
||||||
|
fn roles_dir() -> PathBuf {
|
||||||
|
crate::paths::state_dir().join("subagent_roles")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `(role, line count)` for every readable `<role>.md` in `dir`. Empty when
|
||||||
|
/// `dir` is absent or unreadable — no roles yet is not a warning.
|
||||||
|
fn role_line_counts(dir: &Path) -> Vec<(String, u64)> {
|
||||||
|
std::fs::read_dir(dir)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|entry| {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension()?.to_str()? != ROLE_EXT {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let role = path.file_stem()?.to_str()?.to_owned();
|
||||||
|
let contents = std::fs::read_to_string(&path).ok()?;
|
||||||
|
Some((role, contents.lines().count() as u64))
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn one_series_per_role_file_labelled_by_stem() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
std::fs::write(dir.path().join("reviewer.md"), "one\ntwo\nthree\n").expect("write");
|
||||||
|
std::fs::write(dir.path().join("scout.md"), "a\nb\nc\nd\ne").expect("write");
|
||||||
|
// Not a role file: wrong extension.
|
||||||
|
std::fs::write(dir.path().join("notes.txt"), "x\n").expect("write");
|
||||||
|
|
||||||
|
let mut counts = role_line_counts(dir.path());
|
||||||
|
counts.sort();
|
||||||
|
assert_eq!(
|
||||||
|
counts,
|
||||||
|
vec![("reviewer".to_owned(), 3), ("scout".to_owned(), 5)]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absent_dir_gives_no_series() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
assert!(role_line_counts(&dir.path().join("subagent_roles")).is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue