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
/// 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<String> {
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<String> {
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);