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