hive-agent: warn + emit an OTEL gauge on a grown CLAUDE.md
This commit is contained in:
parent
4d7af143e3
commit
2e6f38dc6c
4 changed files with 219 additions and 0 deletions
|
|
@ -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.session.count` | — | counter | `model` (incremented once per fresh, non-`--continue`'d session) |
|
||||||
| `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 |
|
||||||
|
|
||||||
Resource attributes (`service.name`, `agent`, `hive`, `swarm`) come from the
|
Resource attributes (`service.name`, `agent`, `hive`, `swarm`) come from the
|
||||||
same container-wide `OTEL_RESOURCE_ATTRIBUTES` as everything else in this
|
same container-wide `OTEL_RESOURCE_ATTRIBUTES` as everything else in this
|
||||||
|
|
|
||||||
191
hive-agent/src/claude_md_watch.rs
Normal file
191
hive-agent/src/claude_md_watch.rs
Normal file
|
|
@ -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<Todos>, wake: Arc<Notify>) {
|
||||||
|
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<u64> {
|
||||||
|
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<String> {
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
//! Single bin crate: the module tree below (formerly this crate's `lib.rs`,
|
//! Single bin crate: the module tree below (formerly this crate's `lib.rs`,
|
||||||
//! before lib + bin were collapsed into one) plus the serve loop.
|
//! before lib + bin were collapsed into one) plus the serve loop.
|
||||||
|
|
||||||
|
mod claude_md_watch;
|
||||||
mod db_migrate;
|
mod db_migrate;
|
||||||
mod disk_watch;
|
mod disk_watch;
|
||||||
mod events;
|
mod events;
|
||||||
|
|
@ -527,6 +528,9 @@ fn spawn_todo_socket(
|
||||||
// Same shape, different signal: nudge on a crowded state-dir
|
// Same shape, different signal: nudge on a crowded state-dir
|
||||||
// top level instead of disk pressure.
|
// top level instead of disk pressure.
|
||||||
tokio::spawn(state_entry_watch::run(store.clone(), todo_wake.clone()));
|
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)
|
Some(store)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,11 @@
|
||||||
//! 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
|
||||||
|
//! 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
|
//! 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`
|
||||||
//! env var (same mechanism `hive-metric` relies on) — nothing to set here.
|
//! env var (same mechanism `hive-metric` relies on) — nothing to set here.
|
||||||
|
|
@ -46,6 +51,11 @@ struct Instruments {
|
||||||
session_count: Counter<u64>,
|
session_count: Counter<u64>,
|
||||||
open_threads: Gauge<u64>,
|
open_threads: Gauge<u64>,
|
||||||
open_reminders: Gauge<u64>,
|
open_reminders: Gauge<u64>,
|
||||||
|
/// 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<u64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lazily built on the first call to [`record`]. `None` when OTEL isn't
|
/// 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<Instruments> {
|
fn build() -> Option<Instruments> {
|
||||||
if !enabled() {
|
if !enabled() {
|
||||||
tracing::debug!("otel turn-metrics: no endpoint configured, exporter disabled");
|
tracing::debug!("otel turn-metrics: no endpoint configured, exporter disabled");
|
||||||
|
|
@ -110,6 +132,7 @@ fn build() -> Option<Instruments> {
|
||||||
open_reminders: meter
|
open_reminders: meter
|
||||||
.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(),
|
||||||
_provider: provider,
|
_provider: provider,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue