diff --git a/hive-agent/src/disk_watch.rs b/hive-agent/src/disk_watch.rs index 2a8f6d1f..a3cc2f20 100644 --- a/hive-agent/src/disk_watch.rs +++ b/hive-agent/src/disk_watch.rs @@ -57,6 +57,13 @@ const TODO_KEY: &str = "usage"; /// Only directories at least this large are worth naming. Coarse on /// purpose: the listed set shouldn't churn while a build runs. const BIG_DIR_BYTES: u64 = 5 << 30; +/// Floor on the agent's total footprint (sum across every `roots()` +/// tree) below which the todo never fires, regardless of any single +/// subdir crossing `BIG_DIR_BYTES` on its own — an agent whose whole +/// tree is this small cannot meaningfully move a large filesystem's +/// usage even by deleting everything it owns, so naming one oversized +/// subdir inside a small total isn't worth a turn to act on. +const MIN_AGENT_FOOTPRINT_BYTES: u64 = 3 * BIG_DIR_BYTES; /// At most this many directories are named in the todo. const MAX_LISTED: usize = 5; /// Directories deeper than this (relative to a scan root) are summed into @@ -119,21 +126,23 @@ fn probe() -> Option { if pct < WARN_PCT { return None; } - summary_for(pct, &big_dirs()) + summary_for(pct, agent_footprint_bytes(), &big_dirs()) } /// Pure rendering half of [`probe`]: the todo text for a given usage -/// percentage + set of oversized directories. Separated so the threshold -/// and — more importantly — the *stability* of the summary are unit-tested -/// without a real filesystem. +/// percentage, total agent footprint, and set of oversized directories. +/// Separated so the thresholds 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 || dirs.is_empty() { +/// agent's. Same reasoning extends to [`MIN_AGENT_FOOTPRINT_BYTES`]: a +/// small total footprint means there is nothing here worth the bother +/// even when one subdir happens to cross [`BIG_DIR_BYTES`] on its own. +fn summary_for(pct: u64, footprint: u64, dirs: &[PathBuf]) -> Option { + if pct < WARN_PCT || dirs.is_empty() || footprint < MIN_AGENT_FOOTPRINT_BYTES { return None; } let bucket = pct / BUCKET_PCT * BUCKET_PCT; @@ -201,6 +210,26 @@ fn roots() -> Vec { kept } +/// Sum of bytes under every `roots()` tree combined — the agent's total +/// footprint on the filesystem [`probe`] measured. A second full walk +/// alongside [`big_dirs`]'s own: both are already gated behind +/// `pct >= WARN_PCT`, so neither runs on a healthy disk, and the extra +/// walk trades a little redundant `read_dir` work for keeping this and +/// [`big_dirs`] as two small, independently obvious functions instead of +/// one that returns a tuple nobody asked for. +fn agent_footprint_bytes() -> u64 { + let mut budget = SCAN_BUDGET; + let mut discard = Vec::new(); + let mut total: u64 = 0; + for root in roots() { + let Ok(md) = std::fs::metadata(&root) else { + continue; + }; + total = total.saturating_add(dir_bytes(&root, md.dev(), 0, &mut budget, &mut discard)); + } + total +} + /// Walk the agent's own tree and return the directories worth naming: /// at least [`BIG_DIR_BYTES`], deepest-first (a `target/` dir, not the /// `/agents/