fix(prune): stop protecting publisher target dirs under disk pressure
CI / shellcheck + selftests (pull_request) Successful in 1m48s

A publisher branch's target-<ref> was protected identically to its
snapshot-<ref>, so it was never a pressure-pass candidate however old
and however tight the disk. With one branch's target dir permanently
resident alongside its snapshot, a second branch had no room to seed,
and every PR that night needed a hand eviction between runs
(daniel/gitdan-actions#24).

A publisher's target dir is a convenience cache its own next run
reseeds from the snapshot, so losing it under pressure is cheap;
nothing downstream depends on it surviving. Only the snapshot stays
protected in the pressure and self-clear passes.

Unprotecting the target dir outright surfaced a second bug the fix
would otherwise have shipped: a branch's tip is trivially an ancestor
of itself, so once a protected ref's target dir was no longer skipped
before reaching the merged-branch check, pass 1 read it as "merged
into itself" and deleted it unconditionally on every run, independent
of disk pressure. is_protected_from_liveness keeps a protected ref's
target dir out of pass 1 alone, so it stays an ordinary pressure-pass
candidate without ever reaching that check. Scenario 3b in the
selftest red-proves this against the unprotect-only version of the
fix.

Also corrects the header's inode-sharing claim, measured false on the
live volume by daniel/zemyna#1073: publish-snapshot.sh unshares every
executable after its cp -al, and executables are most of the tree by
bytes, so a snapshot eviction is a real, large disk cost rather than
the near-free one the old text described — the target-before-snapshot
ordering still holds, now for the warm-start reason alone plus that
cost.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UkSjXXtU6JYcN2vPntWhfb
This commit is contained in:
2026-09-22 10:42:08 -05:00
co-authored by Claude Sonnet 5
parent 0184df25a2
commit dc473f0d0c
4 changed files with 106 additions and 38 deletions
+18 -9
View File
@@ -247,8 +247,8 @@ not collapsed into one:
pass still deciding about one is never mistaken for a pass that died holding
it. That settle window is a bound rather than a construction, and it is the
only part of this that is. Until the rest of it was structural it was merely
policy — snapshots belong to protected refs, protected refs are never
eviction candidates — a property held by vigilance rather than by
policy — snapshots belong to protected refs, and a protected ref's snapshot
is never an eviction candidate — a property held by vigilance rather than by
construction.
- **Every other way the source can change mid-clone is detected, not
prevented.** A `seed-fallback-dir` pointing at a directory something else
@@ -271,12 +271,21 @@ not collapsed into one:
available to the clone that follows. Caches for branches that are DEAD are
removed unconditionally; then, only if free space is under the requirement,
live caches are evicted oldest-first; then, as a last resort, this run's own
cache. Protected refs, the source this run is about to clone, and any cache
held open by a running job are never candidates. Within the pressure pass,
`target-*` directories are evicted before `snapshot-*` ones — the reverse of
the obvious order, because a snapshot is hardlinked to everything cloned from
it, so removing one frees almost no real bytes while costing every future PR
its warm start.
cache. A protected ref's *snapshot*, the source this run is about to clone,
and any cache held open by a running job are never candidates in the pressure
or self-clear passes. A protected ref's own *target* dir is an ordinary
pressure-pass candidate, since it is a convenience cache the publisher's next
run reseeds from the snapshot — but it is excluded from the liveness pass
alone, because a branch's tip is trivially an ancestor of itself, and without
that exclusion the merged-branch signal would read a publisher's own target
dir as merged into itself and delete it every run, unconditionally. Within the
pressure pass, `target-*` directories are evicted before `snapshot-*` ones:
losing a target dir is cheap for exactly that reseeding reason, while
evicting a snapshot forces every subsequent PR to start cold and, measured on
the live volume (daniel/zemyna#1073), frees real disk rather than the
near-nothing a shared-inode hardlink clone would suggest — `publish-snapshot.sh`
unshares every executable after its `cp -al`, and executables are most of the
tree by bytes.
**A branch is dead in two ways, and neither signal makes the other
redundant.** The first is that the branch is gone from origin. The second is
@@ -474,7 +483,7 @@ directory; the line just doesn't say which.
|---|---|---|
| `cache-root` | `/cache` | mount point of the persistent volume inside the job container |
| `cache-lineage` | *(empty)* | one directory level under `cache-root`, for a second job building the same ref for a different target or profile — see [Multiple jobs in one workflow](#multiple-jobs-in-one-workflow) |
| `protected-branches` | `dev main` | refs that publish snapshots and are never evicted |
| `protected-branches` | `dev main` | refs that publish snapshots, whose snapshots are never evicted (their target dirs are ordinary pressure-pass candidates) |
| `min-free-percent` | `0` | an ADDITIONAL free-space floor, as a percentage of the volume. The gate is derived per run from what the seed is about to clone; this only ever raises it |
| `restore-mtimes` | `true` | restore tracked-file mtimes from git history |
| `prune` | `true` | run the eviction pass — before the seed, so what it frees is available to the clone |