diff --git a/docs/observability.md b/docs/observability.md index c86a9013..35f22bf1 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -236,6 +236,7 @@ here; that's already covered by Claude's own export. | `hyperhive.agent.session.count` | — | counter | `model` (incremented once per fresh, non-`--continue`'d session) | | `hyperhive.agent.loose_ends.threads` | — | 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 | Resource attributes (`service.name`, `agent`, `hive`, `swarm`) come from the same container-wide `OTEL_RESOURCE_ATTRIBUTES` as everything else in this diff --git a/hive-agent/src/claude_md_watch.rs b/hive-agent/src/claude_md_watch.rs new file mode 100644 index 00000000..efd21e79 --- /dev/null +++ b/hive-agent/src/claude_md_watch.rs @@ -0,0 +1,191 @@ +//! `CLAUDE.md` size watch: raises a loose-ends-v2 todo when the agent's +//! own `CLAUDE.md` grows past 500 lines, and feeds the same line count +//! into an OTEL gauge every tick regardless of threshold. Per the +//! operator's own framing of the ask — "claude md is personality and +//! how you organize yourself, not a work log" — the prompt is asked to +//! suggest moving detail out (separate files, an archive) rather than +//! mandate deleting it, and not to forbid deletion either. +//! +//! Same shape as [`crate::disk_watch`] / [`crate::state_entry_watch`] +//! (periodic in-process probe, `Todos::upsert` with a *stable, bucketed* +//! summary) — a third instance of the same anti-nag pattern rather than +//! a new one. +//! +//! `CLAUDE.md` lives at the top of the agent's own state dir — the same +//! directory `claude --print` runs with as its cwd (`turn::session_cwd`), +//! which is how the CLI's own auto-load discovers it. +//! +//! Anti-nag: the todo is keyed (`claude_md`/[`TODO_KEY`]), and the +//! summary is bucketed by line count ([`BUCKET`]) so drifting inside one +//! bucket reports `changed == false` on `upsert` — 510 → 540 stays quiet, +//! crossing 550 speaks up again. Dropping back under [`WARN_LINES`] +//! (someone archived a chunk) clears the todo, same as the siblings. + +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +use tokio::sync::Notify; + +use crate::todos::Todos; + +/// Same cadence as `disk_watch` / `state_entry_watch` — no reason for +/// this to poll more often, and reading one small file is cheaper still. +const CHECK_INTERVAL: Duration = Duration::from_mins(15); +/// Line count past which the hint appears. Matches the issue's ask +/// exactly ("exceeds 500 lines"). +const WARN_LINES: u64 = 500; +/// Line counts are reported in buckets this wide — the anti-nag knob, +/// same role as `disk_watch::BUCKET_PCT` / `state_entry_watch::BUCKET`. +const BUCKET: u64 = 50; +/// Producing subsystem marker for the todo row. +const SUBSYSTEM: &str = "claude_md"; +/// Fixed dedup key — there is only ever one `CLAUDE.md`-size todo per +/// agent. +const TODO_KEY: &str = "size"; + +/// Background loop: periodically reconcile the `CLAUDE.md`-size todo and +/// feed the OTEL gauge. Detached task — runs for the harness's lifetime; +/// errors are logged, never fatal. +pub async fn run(todos: Arc, wake: Arc) { + loop { + tokio::time::sleep(CHECK_INTERVAL).await; + let lines = match tokio::task::spawn_blocking(|| line_count(&claude_md_path())).await { + Ok(l) => l, + Err(e) => { + tracing::warn!(error = %e, "claude.md watch probe panicked"); + continue; + } + }; + if let Some(lines) = lines { + crate::otel_turn_metrics::record_claude_md_lines(lines); + } + reconcile(&todos, &wake, lines.and_then(summary_for).as_deref()); + } +} + +/// Apply one probe result to the todo store: raise/refresh while over +/// threshold, clear once back under. Split from [`run`] so the store +/// interaction is testable without a timer. +fn reconcile(todos: &Todos, wake: &Notify, summary: Option<&str>) { + match summary { + Some(summary) => match todos.upsert(SUBSYSTEM, Some(TODO_KEY), summary, None, false) { + Ok((_, true)) => wake.notify_one(), + Ok((_, false)) => {} + Err(e) => tracing::warn!(error = ?e, "claude.md todo upsert failed"), + }, + None => { + if let Err(e) = todos.clear(SUBSYSTEM, Some(TODO_KEY)) { + tracing::warn!(error = ?e, "claude.md todo clear failed"); + } + } + } +} + +/// `CLAUDE.md`'s path: the top of the agent's own state dir, matching +/// where `claude --print` runs (`turn::session_cwd`) and therefore where +/// its own auto-load discovers the file. +fn claude_md_path() -> std::path::PathBuf { + crate::paths::state_dir().join("CLAUDE.md") +} + +/// Line count of the file at `path`. `None` when it can't be read — no +/// `CLAUDE.md` yet is not a warning, and a transient read failure isn't +/// either. +fn line_count(path: &Path) -> Option { + let contents = std::fs::read_to_string(path).ok()?; + // A trailing newline (the common case) must not count as one more + // blank line than an editor would show. + Some(contents.lines().count() as u64) +} + +/// Pure rendering half of [`run`]: the todo text for a given line count. +/// Separated so the threshold and the *stability* of the summary are +/// unit-tested without a real filesystem. +fn summary_for(lines: u64) -> Option { + if lines < WARN_LINES { + return None; + } + let bucket = lines / BUCKET * BUCKET; + Some(format!( + "your CLAUDE.md is {bucket}+ lines. CLAUDE.md is personality and how you organize \ + yourself, not a work log — if it's grown past that, moving detail into separate files \ + (linked from CLAUDE.md) or an archive is what actually shrinks this count. It \ + auto-loads into every turn, so a smaller file is a fixed cost saved on every turn. \ + Nothing here is a hard limit, and deleting stale content outright is fine too if \ + that's genuinely what it is — no obligation either way. No need to act on this now — \ + leave the todo open and come back to it whenever." + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn under_threshold_is_silent() { + assert!(summary_for(WARN_LINES - 1).is_none()); + } + + #[test] + fn at_threshold_speaks_up() { + let s = summary_for(WARN_LINES).expect("todo"); + assert!(s.contains("500+ lines")); + assert!(s.contains("not a work log")); + assert!(s.contains("no obligation")); + } + + /// The anti-nag property: a count drifting inside one bucket must + /// render byte-identically, so `Todos::upsert` reports + /// `changed == false` and nothing wakes the agent. + #[test] + fn drift_inside_a_bucket_is_identical() { + let a = summary_for(510).expect("todo"); + let b = summary_for(549).expect("todo"); + assert_eq!(a, b); + } + + #[test] + fn crossing_a_bucket_changes_the_summary() { + let a = summary_for(549).expect("todo"); + let b = summary_for(550).expect("todo"); + assert_ne!(a, b); + assert!(b.contains("550+ lines")); + } + + /// The store round-trip: first raise wakes, an identical re-probe does + /// not, and dropping back under threshold clears the row. + #[test] + fn reconcile_wakes_once_then_clears() { + let dir = tempfile::tempdir().expect("tempdir"); + let todos = Todos::open(&dir.path().join("state.sqlite")).expect("open"); + let wake = Notify::new(); + let summary = summary_for(WARN_LINES).expect("todo"); + + reconcile(&todos, &wake, Some(&summary)); + assert_eq!(todos.list(Some(SUBSYSTEM)).expect("list").len(), 1); + + reconcile(&todos, &wake, Some(&summary)); + assert_eq!(todos.list(Some(SUBSYSTEM)).expect("list").len(), 1); + + reconcile(&todos, &wake, None); + assert!(todos.list(Some(SUBSYSTEM)).expect("list").is_empty()); + } + + #[test] + fn line_count_matches_editor_expectations() { + let dir = tempfile::tempdir().expect("tempdir"); + let path = dir.path().join("CLAUDE.md"); + // Three lines, trailing newline — an editor shows 3 lines, not 4. + std::fs::write(&path, "one\ntwo\nthree\n").expect("write"); + assert_eq!(line_count(&path), Some(3)); + } + + #[test] + fn missing_file_is_silent_not_an_error() { + assert_eq!( + line_count(Path::new("/definitely/does/not/exist/CLAUDE.md")), + None + ); + } +} diff --git a/hive-agent/src/main.rs b/hive-agent/src/main.rs index edacee1a..d4e8aa94 100644 --- a/hive-agent/src/main.rs +++ b/hive-agent/src/main.rs @@ -9,6 +9,7 @@ //! Single bin crate: the module tree below (formerly this crate's `lib.rs`, //! before lib + bin were collapsed into one) plus the serve loop. +mod claude_md_watch; mod db_migrate; mod disk_watch; mod events; @@ -527,6 +528,9 @@ fn spawn_todo_socket( // Same shape, different signal: nudge on a crowded state-dir // top level instead of disk pressure. tokio::spawn(state_entry_watch::run(store.clone(), todo_wake.clone())); + // Same shape again: nudge (and feed an OTEL gauge) on a + // grown CLAUDE.md instead of a grown state dir. + tokio::spawn(claude_md_watch::run(store.clone(), todo_wake.clone())); Some(store) } Err(e) => { diff --git a/hive-agent/src/otel_turn_metrics.rs b/hive-agent/src/otel_turn_metrics.rs index be13a517..0f968f11 100644 --- a/hive-agent/src/otel_turn_metrics.rs +++ b/hive-agent/src/otel_turn_metrics.rs @@ -18,6 +18,11 @@ //! from `handle_turn`. The `PeriodicReader` still batches + exports on its //! own interval; only the recording is event-driven, not the export. //! +//! [`record_claude_md_lines`] is the one exception, sharing this module's +//! exporter setup rather than [`record`]'s per-turn call site — `CLAUDE.md` +//! size *is* continuously live (like hive-c0re's cgroup values), so +//! [`crate::claude_md_watch`] records it from its own periodic tick instead. +//! //! Resource attributes (`service.name`, `agent`, `hive`, `swarm`) are picked //! up automatically by the SDK from the container-wide `OTEL_RESOURCE_ATTRIBUTES` //! env var (same mechanism `hive-metric` relies on) — nothing to set here. @@ -46,6 +51,11 @@ struct Instruments { session_count: Counter, open_threads: Gauge, open_reminders: Gauge, + /// Recorded from [`crate::claude_md_watch`]'s own tick, not from + /// [`record`] — `CLAUDE.md` size is a continuously-live value (like + /// 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. + claude_md_lines: Gauge, } /// Lazily built on the first call to [`record`]. `None` when OTEL isn't @@ -87,6 +97,18 @@ pub fn record(row: &TurnStatRow, fresh_session: bool) { } } +/// Record the current `CLAUDE.md` line count. Called from +/// [`crate::claude_md_watch`]'s periodic tick (every ~15 minutes, not +/// per turn) — see the field doc on [`Instruments::claude_md_lines`] for +/// why this doesn't ride [`record`]. No-op when OTEL isn't configured, +/// same as `record`. +pub fn record_claude_md_lines(lines: u64) { + let Some(inst) = INSTRUMENTS.get_or_init(build).as_ref() else { + return; + }; + inst.claude_md_lines.record(lines, &[]); +} + fn build() -> Option { if !enabled() { tracing::debug!("otel turn-metrics: no endpoint configured, exporter disabled"); @@ -110,6 +132,7 @@ fn build() -> Option { open_reminders: meter .u64_gauge("hyperhive.agent.loose_ends.reminders") .build(), + claude_md_lines: meter.u64_gauge("hyperhive.agent.claude_md.lines").build(), _provider: provider, }) }