Skip to content

feat(scripts): add disk reclaim sweep with build-artifact retention - #349

Merged
laurigates merged 1 commit into
mainfrom
feat/disk-reclaim-retention
Aug 4, 2026
Merged

feat(scripts): add disk reclaim sweep with build-artifact retention#349
laurigates merged 1 commit into
mainfrom
feat/disk-reclaim-retention

Conversation

@laurigates

Copy link
Copy Markdown
Owner

Context

The machine had 5.2 GB free of 926 GB (APFS container 99.4% used). Reclaiming was a manual hunt every time, and nothing aged out build artifacts — 103 GB sat in seven abandoned Rust target/ dirs alone, and 185 GB of ollama models had not been read in 47+ days.

Reclaimed this session: 5.2 GB → 325 GB free.

What this adds

scripts/reclaim.sh

Splits build artifacts by staleness, which is the part that needed thinking about:

Artifact state Action Why
dir mtime older than --days remove the whole target/ / node_modules/ / .venv/ not built in months; a partial artifact set buys nothing
within --days (Rust only) cargo sweep --time <days> inside it trims stale artifacts from a live target so recent builds stay warm

Plus regenerable package caches: uv, bun, go modcache, Homebrew downloads, pre-commit, docker build cache.

Dry-run by default; --apply deletes. Emits the structured KEY=VALUE rollup convention so a caller reads a verdict rather than re-deriving the computation.

scripts/ollama-idle.sh

Ranks models by blob atime, not ollama list's MODIFIED column — that reports pull time, so a model downloaded once and never run looks fresher than one used daily. Verified atime is meaningful here: neither / nor /System/Volumes/Data mounts noatime.

just -g recipes + weekly launchd agent

reclaim-dry, reclaim, ollama-idle, ollama-prune in maint.just. com.lgates.reclaim runs the sweep unattended Sundays 09:23, logging to ~/Library/Logs/reclaim.run.log.

Two deliberate choices

  • The rollup reports artifact and cache figures separately and does not sum them. Whole-dir removals are exact; uv cache prune and pre-commit gc evict only unreferenced entries, so cache size is an upper bound on what they free. An earlier draft summed them and claimed 67.5 GB reclaimable when most of that was live uv cache.
  • Ollama pruning is excluded from the scheduled job. Everything the weekly run deletes rebuilds locally in minutes; re-pulling a 25 GB model does not.

Verification

  • shellcheck clean on both scripts; plutil -lint OK on the plist
  • Dry runs exercised against the real disk; just -g --list renders all four recipes
  • Applied with path-scoped chezmoi apply, leaving three pre-existing drifted targets untouched (confirmed via chezmoi status)

Not addressed

~/.cache/uv (36 GB) could not be cleared — the lock is held by four live uv processes running pal-mcp-server and python-lsp-server interpreters out of ~/.cache/uv/archive-v0/. Forcing it would delete files under running processes. The weekly job uses uv cache prune and tolerates the failure.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FUeU4LVGqKNPF7wrRFRRpf

The machine hit 5.2 GB free of 926 GB. Reclaiming was a manual hunt each
time, and nothing aged out build artifacts, so dead target/ and .venv dirs
accumulated indefinitely — 103 GB sat in seven Rust target dirs alone.

Adds two deterministic scripts plus a weekly unattended run:

- reclaim.sh splits artifacts by staleness. Directories whose mtime is older
  than --days are removed whole; still-active Rust targets instead get
  `cargo sweep --time`, which trims stale artifacts from within a live
  target/ so recent builds stay warm. Package caches (uv, bun, go, brew,
  pre-commit, docker build) are pruned alongside.

- ollama-idle.sh ranks models by blob atime rather than `ollama list`'s
  MODIFIED column, which reports pull time and so cannot distinguish a model
  used daily from one downloaded once and never run. Twelve models totalling
  185 GB had not been read in 47+ days.

The rollup reports artifact and cache figures separately and does not sum
them: whole-dir removals are exact, while `uv cache prune` and
`pre-commit gc` evict only unreferenced entries, so cache size is an upper
bound on what they actually free.

Ollama pruning is deliberately excluded from the scheduled job — re-pulling
a 25 GB model is slow enough to stay a deliberate choice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FUeU4LVGqKNPF7wrRFRRpf
@github-actions github-actions Bot added the size/m label Aug 3, 2026
@github-actions

github-actions Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

🤖 Claude analyzed the CI failures but determined no code changes are needed.

Failed workflow: https://github.com/laurigates/dotfiles/actions/runs/30831652472

This may indicate:

  • Flaky tests
  • Infrastructure issues
  • Transient failures
  • Configuration problems outside the codebase

Please review the failure logs manually.

@laurigates
laurigates merged commit 352ce80 into main Aug 4, 2026
7 of 8 checks passed
@laurigates
laurigates deleted the feat/disk-reclaim-retention branch August 4, 2026 05:23
laurigates added a commit that referenced this pull request Aug 4, 2026
Follow-up to #349, which shipped a rollup that overstates what the sweep
frees.

## The bug

The rollup summed `du` output into one headline number. Measured on this
machine:

| | |
|---|---|
| What #349 would have printed | **68.7 GB** |
| What the sweep actually freed (`df` delta) | **17.7 GB** |

Off by roughly 4×.

## Why

`du` sums `st_blocks`, and APFS reports a copy-on-write clone at **full
size** even though its blocks are shared and cost nothing to hold.

- **uv** and **bun** materialise `.venv` / `node_modules` from a global
package cache via `clonefile`. Verified empirically: a second project
installing the same package cost **0 bytes** of real disk while `du`
billed it at full size. Removing such a tree frees real space only where
it holds the *last* reference to those blocks.
- **cargo** is the exception. Every project compiles its own copy of
each dependency — there's no shared compiled-artifact cache (that's what
`sccache` is for) — so `target/` dirs are genuinely distinct bytes and
`du` is accurate. This matches the original session: deleting 7 target
dirs moved `df` by 103.8 GB against a `du` estimate of 103 GB.

## The fix

Splits the estimate by error mode instead of summing figures that aren't
comparable:

```
ARTIFACT_EXACT_GB=12.6    (cargo target/ — du is accurate here)
ARTIFACT_BOUNDED_GB=17.3  (node_modules/.venv — upper bound, CoW clones)
CACHE_SIZE_GB=38.8        (upper bound; prune/gc evict only unused entries)
ACTUAL_FREED_GB=17.7      (df delta — ground truth)
```

`ACTUAL_FREED_GB` supersedes all three under `--apply`. Internal
consistency holds: actual (17.7) exceeds `ARTIFACT_EXACT` (12.6), as it
must if cargo targets are real bytes and the bounded figure is mostly
shared.

## This is not fixable by changing tools

Every tool that reads `st_blocks` double-counts clones identically —
verified against a 200 MB file plus one clone (ground truth 200 MB):

| tool | reports |
|---|---|
| BSD `du` | 400M |
| GNU `du` | 400M |
| `dust` 1.2.4 | 400M |
| `dust -s` | 600M |
| `df` delta | **0 MB for the clone** ✅ |

A `df` delta is the only clone-aware measurement available on macOS.
Reported upstream as
[bootandy/dust#590](bootandy/dust#590), with a
proposed fix via `fcntl(F_LOG2PHYS_EXT)` physical-extent dedup (clones
share device offsets while having distinct inodes).

## Verification

- `shellcheck` clean
- Dry run and a real `--apply` both exercised; the 17.7 GB figure above
is from that run
- Applied with path-scoped `chezmoi apply`, leaving three pre-existing
drifted targets untouched

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01FUeU4LVGqKNPF7wrRFRRpf

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant