disk_watch: stay silent when the agent owns nothing worth deleting

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
This commit is contained in:
atlas 2026-07-27 12:10:27 +02:00
commit c3e6e23d22

View file

@ -115,8 +115,14 @@ fn probe() -> Option<String> {
/// percentage + set of oversized directories. Separated so the threshold /// percentage + set of oversized directories. Separated so the threshold
/// and — more importantly — the *stability* of the summary are unit-tested /// and — more importantly — the *stability* of the summary are unit-tested
/// without a real filesystem. /// 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<String> { fn summary_for(pct: u64, dirs: &[PathBuf]) -> Option<String> {
if pct < WARN_PCT { if pct < WARN_PCT || dirs.is_empty() {
return None; return None;
} }
let bucket = pct / BUCKET_PCT * BUCKET_PCT; let bucket = pct / BUCKET_PCT * BUCKET_PCT;
@ -125,16 +131,12 @@ fn summary_for(pct: u64, dirs: &[PathBuf]) -> Option<String> {
Only delete things that are actually big (tens of GiB); a few MB of notes won't move the needle.\n\ 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\ 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 \ 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() { for dir in dirs {
out.push_str( // Infallible: writing into a String.
"\nBiggest directories under your own tree (`du -sh` them before removing anything):", let _ = write!(out, "\n - {}", dir.display());
);
for dir in dirs {
// Infallible: writing into a String.
let _ = write!(out, "\n - {}", dir.display());
}
} }
Some(out) Some(out)
} }
@ -273,15 +275,23 @@ mod tests {
#[test] #[test]
fn under_threshold_is_silent() { 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] #[test]
fn at_threshold_speaks_up() { 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("over 80% full"));
assert!(s.contains("tens of GiB")); assert!(s.contains("tens of GiB"));
assert!(s.contains("tell the operator")); 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 /// The anti-nag property: usage drifting inside one bucket must render
@ -289,15 +299,17 @@ mod tests {
/// nothing wakes the agent. /// nothing wakes the agent.
#[test] #[test]
fn drift_inside_a_bucket_is_identical() { fn drift_inside_a_bucket_is_identical() {
let a = summary_for(85, &[]).expect("todo"); let dirs = [p("/agents/a/state/hh/target")];
let b = summary_for(89, &[]).expect("todo"); let a = summary_for(85, &dirs).expect("todo");
let b = summary_for(89, &dirs).expect("todo");
assert_eq!(a, b); assert_eq!(a, b);
} }
#[test] #[test]
fn crossing_a_bucket_changes_the_summary() { fn crossing_a_bucket_changes_the_summary() {
let a = summary_for(89, &[]).expect("todo"); let dirs = [p("/agents/a/state/hh/target")];
let b = summary_for(90, &[]).expect("todo"); let a = summary_for(89, &dirs).expect("todo");
let b = summary_for(90, &dirs).expect("todo");
assert_ne!(a, b); assert_ne!(a, b);
assert!(b.contains("over 90% full")); assert!(b.contains("over 90% full"));
} }
@ -353,7 +365,7 @@ mod tests {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
let todos = Todos::open(&dir.path().join("state.sqlite")).expect("open"); let todos = Todos::open(&dir.path().join("state.sqlite")).expect("open");
let wake = Notify::new(); 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)); reconcile(&todos, &wake, Some(&summary));
assert_eq!(todos.list(Some(SUBSYSTEM)).expect("list").len(), 1); assert_eq!(todos.list(Some(SUBSYSTEM)).expect("list").len(), 1);