From c3e6e23d2272bbe65a8e103824d73c0fb0bdd382 Mon Sep 17 00:00:00 2001 From: atlas Date: Mon, 27 Jul 2026 12:10:27 +0200 Subject: [PATCH] disk_watch: stay silent when the agent owns nothing worth deleting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The todo fired on filesystem usage alone, so every agent on a busy host got woken by a disk it had no power over. Measured case: a 927G volume at 81%, 742G used, of which one agent's entire tree was 5.3G. The todo cost that agent a full turn to arrive at "not actionable". `summary_for` now also requires a non-empty `big_dirs()` result. An agent that owns no oversized directory cannot free meaningful space, so there is nothing to say to it; the shared store filling up is the host's signal. Agents that ARE sitting on a stale `target/` still get told, with the same bucketed anti-nag summary as before. Drops the conditional around the "Biggest directories" section, which is now unreachable when empty. Existing tests passed `&[]` as a don't-care — they now pass a directory, since that argument became load-bearing. Closes #2759 --- hive-agent/src/disk_watch.rs | 46 +++++++++++++++++++++++------------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/hive-agent/src/disk_watch.rs b/hive-agent/src/disk_watch.rs index 7866fcd0..bc991d2b 100644 --- a/hive-agent/src/disk_watch.rs +++ b/hive-agent/src/disk_watch.rs @@ -115,8 +115,14 @@ fn probe() -> Option { /// percentage + set of oversized directories. Separated so the threshold /// and — more importantly — the *stability* of the summary are unit-tested /// without a real filesystem. +/// +/// Silent unless the agent owns something worth deleting. A full disk the +/// agent did not fill is not its problem to solve: it cannot free host +/// bytes, so the todo would only ever cost a turn to conclude "not +/// actionable". The shared store filling up is the host's signal, not an +/// agent's. fn summary_for(pct: u64, dirs: &[PathBuf]) -> Option { - if pct < WARN_PCT { + if pct < WARN_PCT || dirs.is_empty() { return None; } let bucket = pct / BUCKET_PCT * BUCKET_PCT; @@ -125,16 +131,12 @@ fn summary_for(pct: u64, dirs: &[PathBuf]) -> Option { Only delete things that are actually big (tens of GiB); a few MB of notes won't move the needle.\n\ First candidates: regenerable build output in your workspace (`target/`, `node_modules/`, `dist/`).\n\ Never delete anything still needed. If nothing is safe to drop, tell the operator you need more space \ - rather than forcing it — be cautious with deletions in general." + rather than forcing it — be cautious with deletions in general.\n\ + Biggest directories under your own tree (`du -sh` them before removing anything):" ); - if !dirs.is_empty() { - out.push_str( - "\nBiggest directories under your own tree (`du -sh` them before removing anything):", - ); - for dir in dirs { - // Infallible: writing into a String. - let _ = write!(out, "\n - {}", dir.display()); - } + for dir in dirs { + // Infallible: writing into a String. + let _ = write!(out, "\n - {}", dir.display()); } Some(out) } @@ -273,15 +275,23 @@ mod tests { #[test] fn under_threshold_is_silent() { - assert!(summary_for(WARN_PCT - 1, &[]).is_none()); + assert!(summary_for(WARN_PCT - 1, &[p("/agents/a/state/hh/target")]).is_none()); } #[test] fn at_threshold_speaks_up() { - let s = summary_for(WARN_PCT, &[]).expect("todo"); + let s = summary_for(WARN_PCT, &[p("/agents/a/state/hh/target")]).expect("todo"); assert!(s.contains("over 80% full")); assert!(s.contains("tens of GiB")); assert!(s.contains("tell the operator")); + assert!(s.contains("/agents/a/state/hh/target")); + } + + /// A full disk the agent did not fill is not its problem: it cannot + /// free host bytes, so naming no directories means saying nothing. + #[test] + fn full_disk_with_nothing_big_of_ours_is_silent() { + assert!(summary_for(99, &[]).is_none()); } /// The anti-nag property: usage drifting inside one bucket must render @@ -289,15 +299,17 @@ mod tests { /// nothing wakes the agent. #[test] fn drift_inside_a_bucket_is_identical() { - let a = summary_for(85, &[]).expect("todo"); - let b = summary_for(89, &[]).expect("todo"); + let dirs = [p("/agents/a/state/hh/target")]; + let a = summary_for(85, &dirs).expect("todo"); + let b = summary_for(89, &dirs).expect("todo"); assert_eq!(a, b); } #[test] fn crossing_a_bucket_changes_the_summary() { - let a = summary_for(89, &[]).expect("todo"); - let b = summary_for(90, &[]).expect("todo"); + let dirs = [p("/agents/a/state/hh/target")]; + let a = summary_for(89, &dirs).expect("todo"); + let b = summary_for(90, &dirs).expect("todo"); assert_ne!(a, b); assert!(b.contains("over 90% full")); } @@ -353,7 +365,7 @@ mod tests { 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(90, &[]).expect("todo"); + let summary = summary_for(90, &[p("/agents/a/state/hh/target")]).expect("todo"); reconcile(&todos, &wake, Some(&summary)); assert_eq!(todos.list(Some(SUBSYSTEM)).expect("list").len(), 1);