diff --git a/hive-agent/src/main.rs b/hive-agent/src/main.rs index 9f659a65..95106ff4 100644 --- a/hive-agent/src/main.rs +++ b/hive-agent/src/main.rs @@ -24,7 +24,6 @@ mod questions; mod reminder_timer; mod reminders; mod serve_common; -mod state_entry_watch; mod stats; mod stream_enrich; mod todo_server; @@ -523,9 +522,6 @@ fn spawn_todo_socket( // shares this store + wake directly instead of dialling the // socket the out-of-process producers use. tokio::spawn(disk_watch::run(store.clone(), todo_wake.clone())); - // 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())); Some(store) } Err(e) => { diff --git a/hive-agent/src/state_entry_watch.rs b/hive-agent/src/state_entry_watch.rs deleted file mode 100644 index 33ffa2c5..00000000 --- a/hive-agent/src/state_entry_watch.rs +++ /dev/null @@ -1,189 +0,0 @@ -//! State-dir entry-count watch: raises a loose-ends-v2 todo when the -//! top level of this agent's own state directory accumulates 30+ entries, -//! as a low-pressure nudge to tidy up before it gets unwieldy — not a -//! hard limit, nothing here blocks or deletes anything. -//! -//! Same shape as [`crate::disk_watch`] (periodic in-process probe, -//! `Todos::upsert` with a *stable, bucketed* summary), reused rather than -//! reinvented: a producer whose only signal is "the count crossed a -//! threshold" wants the exact same anti-nag property disk pressure did. -//! -//! Anti-nag: the todo is keyed (`state`/[`TODO_KEY`]), and the summary is -//! bucketed by count ([`BUCKET`]) rather than carrying the raw number, so -//! `upsert` reports `changed == false` while the count drifts inside one -//! bucket (31 → 35 stays quiet; crossing 40 speaks up again). Dropping -//! back under [`WARN_COUNT`] clears the todo, same as `disk_watch`. -//! -//! Top-level only, deliberately: a single large subdirectory (a git -//! clone, a build output tree) counts as one entry here regardless of -//! what's inside it — that's a different problem `disk_watch` already -//! covers on its own axis (bytes, not directory-entry count). - -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` — no reason for this to poll more often -/// than disk pressure does, and the `read_dir` here is cheaper still. -const CHECK_INTERVAL: Duration = Duration::from_mins(15); -/// Entry count past which the hint appears. Matches the ask exactly -/// ("30+ entries"). -const WARN_COUNT: u64 = 30; -/// Counts are reported in buckets this wide — the anti-nag knob, same -/// role as `disk_watch::BUCKET_PCT`. -const BUCKET: u64 = 10; -/// Producing subsystem marker for the todo row. -const SUBSYSTEM: &str = "state"; -/// Fixed dedup key — there is only ever one state-entry-count todo per -/// agent. -const TODO_KEY: &str = "entries"; - -/// Background loop: periodically reconcile the state-entry-count todo. -/// 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 summary = match tokio::task::spawn_blocking(probe).await { - Ok(s) => s, - Err(e) => { - tracing::warn!(error = %e, "state entry watch probe panicked"); - continue; - } - }; - reconcile(&todos, &wake, summary.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, "state entry todo upsert failed"), - }, - None => { - if let Err(e) = todos.clear(SUBSYSTEM, Some(TODO_KEY)) { - tracing::warn!(error = ?e, "state entry todo clear failed"); - } - } - } -} - -/// One probe: count the top-level entries in the agent's state dir and, -/// when at/over threshold, render the hint. `None` means "nothing to -/// report" — under threshold, or the directory couldn't be read. -fn probe() -> Option { - let count = top_level_count(&crate::paths::state_dir())?; - summary_for(count) -} - -/// Pure rendering half of [`probe`]: the todo text for a given entry -/// count. Separated so the threshold and the *stability* of the summary -/// are unit-tested without a real filesystem. -fn summary_for(count: u64) -> Option { - if count < WARN_COUNT { - return None; - } - let bucket = count / BUCKET * BUCKET; - Some(format!( - "your state dir has {bucket}+ entries at the top level — worth a look if it's \ - gotten unwieldy, no pressure. Grouping related top-level entries into subdirectories \ - is what actually lowers this count; nothing here is a hard limit and nothing gets \ - deleted for you. No need to act on this now — leave the todo open and come back to \ - it whenever." - )) -} - -/// Count entries directly inside `path` — non-recursive, so a large -/// subdirectory (a git clone, a build tree) still counts as exactly one -/// entry. `None` if the directory can't be read (matches `disk_watch`'s -/// treatment of a failed syscall: silence, not an error the agent has to -/// act on). -fn top_level_count(path: &Path) -> Option { - let entries = std::fs::read_dir(path).ok()?; - Some(entries.flatten().count() as u64) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn under_threshold_is_silent() { - assert!(summary_for(WARN_COUNT - 1).is_none()); - } - - #[test] - fn at_threshold_speaks_up() { - let s = summary_for(WARN_COUNT).expect("todo"); - assert!(s.contains("30+ entries")); - assert!(s.contains("no pressure")); - } - - /// 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(31).expect("todo"); - let b = summary_for(39).expect("todo"); - assert_eq!(a, b); - } - - #[test] - fn crossing_a_bucket_changes_the_summary() { - let a = summary_for(39).expect("todo"); - let b = summary_for(40).expect("todo"); - assert_ne!(a, b); - assert!(b.contains("40+ entries")); - } - - /// 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_COUNT).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 top_level_count_is_not_recursive() { - let dir = tempfile::tempdir().expect("tempdir"); - std::fs::create_dir(dir.path().join("big_subdir")).expect("mkdir"); - for i in 0..50 { - std::fs::write(dir.path().join("big_subdir").join(format!("f{i}")), b"x") - .expect("write"); - } - std::fs::write(dir.path().join("top.txt"), b"x").expect("write"); - // One subdirectory (however many files it hides) + one file = 2, - // not 51 — the whole point of "top level only". - assert_eq!(top_level_count(dir.path()), Some(2)); - } - - #[test] - fn missing_dir_is_silent_not_an_error() { - assert_eq!( - top_level_count(Path::new("/definitely/does/not/exist")), - None - ); - } -}