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