Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-agent/src/subagent_role_watch.rs
atlas f19a9f546e 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
2026-10-14 13:53:20 +02:00

93 lines
3.4 KiB
Rust

//! 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());
}
}