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
93 lines
3.4 KiB
Rust
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());
|
|
}
|
|
}
|