Deliberate, not accidental. Both are cross-reference accuracy in overlapping
files — #7 adds contract commentary to cache-lib.sh, prune-cache.sh and publish-snapshot.sh, and #6 corrects a stale pointer sitting in publish-snapshot.sh's header, a few lines from where #7's .publish-* note
lands. Split across two tracks they would have been two agents editing the same
comment blocks and resolving each other's conflicts for no benefit.
Summary
#7 — the producer half of the leftover naming contract. daniel/gitdan's host-level arbiter (scripts/ci-cache-reclaim.sh) reclaims
the dot-prefixed trees this repo's scripts strand inside the cache volumes it
scans, and reads this repo's .reading-* markers to decide whether one of
those trees is still live. That arrangement was written down only on the
consuming side. Nothing here said a name had an external consumer, so a
contributor could add or rename a dot-prefixed shape with no local signal — and
since the arbiter enumerates by explicit prefix rather than dotglob
(deliberately: a dotglob pulls reader markers into the candidate stream
alongside the trees they protect), an unannounced shape is not handled
conservatively, it is invisible to it.
#6 — the stale cross-reference.publish-snapshot.sh's header attributed
the silent-truncation failure mode to "this script's own selftest (scenario
8)". Wrong twice over: the scenario that reproduces a truncated clone is in seed-target-dir-selftest.sh, and since #4 split the old scenario 8 into 8a
and 8b it is 8b. publish-snapshot-selftest.sh does have a scenario 8 — the
abandoned-marker case — so the pointer landed on a real scenario with a
plausible number that tests something else.
What the producer half now says, and where each piece sits
daniel/gitdan's LEFTOVER NAMING CONTRACT block stays canonical; this side
carries a pointer plus the local obligations, so the description lives in one
place rather than two that can drift.
scripts/cache-lib.sh — a header block (CROSS-REPO CONTRACT) naming
every shape this repo creates directly under a cache root, its producing
function, and how each one strands. Sited here because cache-lib.sh is
sourced by every script that writes one.
Site notes at the producing lines: .stage-<tag> in hardlink_clone_into(), .reading-<source>-<tag> at reader_marker_path(), .evicting-<name>-<pid> at prune-cache.sh's evict_dir().
README.md — a short section, cross-linked from the cache-layout block in How it works, that a newcomer meets before adding a scratch directory under
a cache root.
.reading-* is documented as protective, not garbage at every one of those
sites: it is the live-reader signal both this repo's prune pass and the arbiter
read, the arbiter never deletes one, and deleting a live marker is what clears
the way for a tree to be unlinked underneath the walk reading it.
The rule is stated as adding a shape counts exactly as much as renaming one —
no new dot-prefixed entry under a cache root without a matching prefix in ci-cache-reclaim.sh.
The direction rule
CACHE_READ_STALE_SECONDS (cache-lib.sh) and STALE_LOCK_SECONDS
(prune-cache.sh) are mirrored by the arbiter, and the asymmetry is now
written at both constants:
Raising one here without raising its mirror first makes the arbiter read a
marker whose owner still considers it live as stale, and delete a tree under
an in-flight clone. Its minimum-age guard does not back-stop that — a clone
holding a three-hour-old marker has a roughly three-hour-old staging tree, so
both of its guards pass.
Lowering one here needs no coordination: the arbiter then defers a
reclamation this side would already have permitted. The residual is disk, not
correctness.
The finding this turned up — daniel/gitdan#30
Writing the inventory turned up two dot-prefixed shapes the arbiter's
enumeration did not cover: .publish-old-<key>-<tag> and .publish-new-<tag>,
both from publish-snapshot.sh. .publish-old- is mild — the sweep at publish-snapshot.sh:85 reclaims it on the next publish of the same key. .publish-new- is not: it is tagged per job per run exactly as .stage-
is, so a publisher killed between staging its snapshot and the swap strands a
full hardlink clone that no later run of this script will ever match and that
the arbiter cannot see. That is gitdan#28's failure mode, reached by the route
this contract exists to close — the rule catching an uncovered shape on its
first application.
It is now filed as daniel/gitdan#30
and being fixed in that repo this round. This PR's comments reference the
ticket rather than the arbiter's momentary contents, so nothing here dates
itself against that merge; what they keep is the mechanism that made the shape
worth catching. The rule statement itself is unchanged.
Also added, since the inventory here is the first complete list of these shapes
in either repo: once #30 lands, both lists are five entries long, and a
length mismatch is the cheapest signal that one side gained a shape without
telling the other.
Cross-references corrected
git grep -n 'scenario [0-9]' -- scripts/ returns nine other hits. All are
in-file (prune-cache-selftest.sh × 3, restore-mtimes-selftest.sh × 5, seed-target-dir-selftest.sh × 3) or already name their suite
(seed-target-dir-selftest.sh:245, publish-snapshot-selftest.sh:44, restore-mtimes.sh:124). publish-snapshot.sh:32 was the only wrong one, and
its replacement now names the suite, the number 8b, and what the local
scenario 8 actually is, so the collision cannot re-form silently.
Files affected
scripts/cache-lib.sh, scripts/prune-cache.sh, scripts/publish-snapshot.sh
— comments only — and README.md.
No logic change. Every added line under scripts/ is a comment; the only
deletion is the two comment lines #6 replaces. Verifiable with git diff origin/main -- scripts/ | grep -E '^\+' | grep -vE '^\+\s*#', which
returns nothing but the +++ header.
Test plan
bash scripts/selftest.sh (full, compiler-backed) — all 5 suites pass, run
from committed state. Same as the branch point, as a comments-only change
should be.
shellcheck scripts/cache-lib.sh scripts/prune-cache.sh scripts/publish-snapshot.sh
— findings identical to origin/main (one pre-existing SC2016 at cache-lib.sh's _unshare_files xargs line). No new findings.
Nothing was run against the live CI cache volume on gitdan-ci.
Nothing to smoke visually; this repo has no CI and the diff is prose.
Closes #7
Closes #6
## Why the two tickets are bundled
Deliberate, not accidental. Both are cross-reference accuracy in overlapping
files — #7 adds contract commentary to `cache-lib.sh`, `prune-cache.sh` and
`publish-snapshot.sh`, and #6 corrects a stale pointer sitting in
`publish-snapshot.sh`'s header, a few lines from where #7's `.publish-*` note
lands. Split across two tracks they would have been two agents editing the same
comment blocks and resolving each other's conflicts for no benefit.
## Summary
**#7 — the producer half of the leftover naming contract.**
`daniel/gitdan`'s host-level arbiter (`scripts/ci-cache-reclaim.sh`) reclaims
the dot-prefixed trees *this* repo's scripts strand inside the cache volumes it
scans, and reads this repo's `.reading-*` markers to decide whether one of
those trees is still live. That arrangement was written down only on the
consuming side. Nothing here said a name had an external consumer, so a
contributor could add or rename a dot-prefixed shape with no local signal — and
since the arbiter enumerates by explicit prefix rather than dotglob
(deliberately: a dotglob pulls reader markers into the candidate stream
alongside the trees they protect), an unannounced shape is not handled
conservatively, it is invisible to it.
**#6 — the stale cross-reference.** `publish-snapshot.sh`'s header attributed
the silent-truncation failure mode to "this script's own selftest (scenario
8)". Wrong twice over: the scenario that reproduces a truncated clone is in
`seed-target-dir-selftest.sh`, and since #4 split the old scenario 8 into 8a
and 8b it is **8b**. `publish-snapshot-selftest.sh` does have a scenario 8 — the
abandoned-marker case — so the pointer landed on a real scenario with a
plausible number that tests something else.
## What the producer half now says, and where each piece sits
`daniel/gitdan`'s `LEFTOVER NAMING CONTRACT` block stays canonical; this side
carries a pointer plus the local obligations, so the description lives in one
place rather than two that can drift.
- **`scripts/cache-lib.sh`** — a header block (`CROSS-REPO CONTRACT`) naming
every shape this repo creates directly under a cache root, its producing
function, and how each one strands. Sited here because `cache-lib.sh` is
sourced by every script that writes one.
- **Site notes at the producing lines**: `.stage-<tag>` in
`hardlink_clone_into()`, `.reading-<source>-<tag>` at `reader_marker_path()`,
`.evicting-<name>-<pid>` at `prune-cache.sh`'s `evict_dir()`.
- **`README.md`** — a short section, cross-linked from the cache-layout block in
*How it works*, that a newcomer meets before adding a scratch directory under
a cache root.
`.reading-*` is documented as **protective, not garbage** at every one of those
sites: it is the live-reader signal both this repo's prune pass and the arbiter
read, the arbiter never deletes one, and deleting a live marker is what clears
the way for a tree to be unlinked underneath the walk reading it.
The rule is stated as **adding a shape counts exactly as much as renaming one** —
no new dot-prefixed entry under a cache root without a matching prefix in
`ci-cache-reclaim.sh`.
## The direction rule
`CACHE_READ_STALE_SECONDS` (`cache-lib.sh`) and `STALE_LOCK_SECONDS`
(`prune-cache.sh`) are mirrored by the arbiter, and the asymmetry is now
written at both constants:
- **Raising one here without raising its mirror first** makes the arbiter read a
marker whose owner still considers it live as stale, and delete a tree under
an in-flight clone. Its minimum-age guard does **not** back-stop that — a clone
holding a three-hour-old marker has a roughly three-hour-old staging tree, so
both of its guards pass.
- **Lowering one here** needs no coordination: the arbiter then defers a
reclamation this side would already have permitted. The residual is disk, not
correctness.
## The finding this turned up — `daniel/gitdan#30`
Writing the inventory turned up two dot-prefixed shapes the arbiter's
enumeration did not cover: `.publish-old-<key>-<tag>` and `.publish-new-<tag>`,
both from `publish-snapshot.sh`. `.publish-old-` is mild — the sweep at
`publish-snapshot.sh:85` reclaims it on the next publish of the same key.
**`.publish-new-` is not**: it is tagged per job per run exactly as `.stage-`
is, so a publisher killed between staging its snapshot and the swap strands a
full hardlink clone that no later run of this script will ever match and that
the arbiter cannot see. That is gitdan#28's failure mode, reached by the route
this contract exists to close — the rule catching an uncovered shape on its
first application.
It is now filed as [`daniel/gitdan#30`](https://gitdan.com/daniel/gitdan/issues/30)
and being fixed in that repo this round. **This PR's comments reference the
ticket rather than the arbiter's momentary contents**, so nothing here dates
itself against that merge; what they keep is the mechanism that made the shape
worth catching. The rule statement itself is unchanged.
Also added, since the inventory here is the first complete list of these shapes
in either repo: once #30 lands, both lists are five entries long, and a
length mismatch is the cheapest signal that one side gained a shape without
telling the other.
## Cross-references corrected
`git grep -n 'scenario [0-9]' -- scripts/` returns nine other hits. All are
in-file (`prune-cache-selftest.sh` × 3, `restore-mtimes-selftest.sh` × 5,
`seed-target-dir-selftest.sh` × 3) or already name their suite
(`seed-target-dir-selftest.sh:245`, `publish-snapshot-selftest.sh:44`,
`restore-mtimes.sh:124`). `publish-snapshot.sh:32` was the only wrong one, and
its replacement now names the suite, the number **8b**, and what the local
scenario 8 actually is, so the collision cannot re-form silently.
## Files affected
`scripts/cache-lib.sh`, `scripts/prune-cache.sh`, `scripts/publish-snapshot.sh`
— **comments only** — and `README.md`.
**No logic change.** Every added line under `scripts/` is a comment; the only
deletion is the two comment lines #6 replaces. Verifiable with
`git diff origin/main -- scripts/ | grep -E '^\+' | grep -vE '^\+\s*#'`, which
returns nothing but the `+++` header.
## Test plan
- `bash scripts/selftest.sh` (full, compiler-backed) — all 5 suites pass, run
from committed state. Same as the branch point, as a comments-only change
should be.
- `shellcheck scripts/cache-lib.sh scripts/prune-cache.sh scripts/publish-snapshot.sh`
— findings identical to `origin/main` (one pre-existing SC2016 at
`cache-lib.sh`'s `_unshare_files` xargs line). No new findings.
- Nothing was run against the live CI cache volume on `gitdan-ci`.
- Nothing to smoke visually; this repo has no CI and the diff is prose.
publish-snapshot.sh's header described the silent-truncation failure mode as
the one "this script's own selftest (scenario 8)" reproduces. Wrong twice:
the scenario that reproduces a truncated clone is in
seed-target-dir-selftest.sh, and since #4 split the old scenario 8 into 8a and
8b it is 8b. publish-snapshot-selftest.sh does have a scenario 8 — the
abandoned-marker case — so the pointer landed on a real scenario with a
plausible number that tests something else.
Names the suite as well as the number, and says what the local scenario 8
actually is so the collision cannot re-form.
Closes#6
daniel/gitdan's host-level arbiter (scripts/ci-cache-reclaim.sh) reclaims the
dot-prefixed trees this repo's scripts strand inside the cache volumes it
scans, and reads this repo's reader markers to decide whether one is still
live. That arrangement was documented only on the consuming side: a
contributor here could add or rename a dot-prefixed shape with no local signal
that anything outside the repo depended on the spelling, and the arbiter
enumerates by explicit prefix — deliberately, so it never sees a .reading-*
marker as a candidate — which makes an unannounced shape invisible to it
rather than conservatively handled.
Adds the producing side's half at the sites someone changing a name will
actually be looking at, pointing at gitdan's LEFTOVER NAMING CONTRACT block as
canonical rather than restating it:
- cache-lib.sh gains a header block naming every shape this repo creates under
a cache root, its producing function, and how each strands; plus the rule
that adding a shape obliges the same matching prefix over there as renaming
one does.
- Site notes at .stage-'s and .reading-'s producing lines, and at
.evicting-'s in prune-cache.sh.
- publish-snapshot.sh's .publish-old- / .publish-new- pair is documented as
the shapes that are NOT in the arbiter's list today, with .publish-new-
called out as the one that strands exactly as .stage- does and that neither
side reclaims.
- The staleness direction: CACHE_READ_STALE_SECONDS and STALE_LOCK_SECONDS are
mirrored there and the mirrors must be >= ours, because raising ours alone
makes the arbiter delete a tree under an in-flight clone (its minimum-age
guard does not back-stop that case). Lowering ours is safe in any order.
- README gains a short section a newcomer meets before adding a scratch
directory under a cache root, cross-linked from the cache-layout block.
Comments and docs only; no behaviour change.
Closes#7
The contract block asserted that neither side reclaims .publish-new- 'today'.
True when written and about to stop being true: gitdan#30 tracks adding both
.publish-* prefixes to the arbiter's enumeration, and a sibling track is
landing it this round. A comment that dates itself against a merge in flight
is worse than no comment.
Rewords all four sites (cache-lib.sh, publish-snapshot.sh, and README's table
row and prose) to reference gitdan#30 and keep the mechanism that made the
shape worth catching — .publish-new- is tagged per job per run exactly as
.stage- is — rather than the arbiter's momentary contents. The rule itself is
unchanged; it is the durable part, and it is what found this.
Adds the counting check while there: the five names here and the prefixes
ci-cache-reclaim.sh enumerates are meant to be the same length, so a mismatch
is the cheapest signal that one side gained a shape without telling the other.
claude-reviewer
approved these changes 2026-08-24 18:04:21 +00:00
A docs PR whose entire value is accuracy, so every claim was checked against code rather than read for plausibility. All the load-bearing ones hold, including the safety-critical one. Four nits below, none blocking.
Wide pass — is the documentation correct, and is it sited where it will be seen?
The five-shape table is accurate, name by name, site by site:
documented
verified at
.stage-<tag> / hardlink_clone_into()
cache-lib.sh:474
.publish-new-<tag> / publish-snapshot.sh
publish-snapshot.sh:100
.publish-old-<key>-<tag> / publish-snapshot.sh
publish-snapshot.sh:65
.evicting-<name>-<pid> / evict_dir()
prune-cache.sh:190
.reading-<source>-<tag> / reader_lock_acquire()
cache-lib.sh:333
No sixth shape exists — grep for dot-prefixed literals across scripts/ outside the selftests returns exactly these five.
Stranding conditions check out.TAG is ${{ github.job }}-${{ github.run_id }}-$$ (cargo-cache-publish/action.yml:135, cargo-cache/action.yml:159), so "tagged per job per run" is literal and the self-scoped rm -rf at publish-snapshot.sh:76 / :101 genuinely cannot reach another run's strand. .publish-old-'s sweep at :86 globs .publish-old-${OWN_KEY}-* — key-scoped as documented, and additionally gated on live_reader_count == 0 at :85, which cache-lib.sh's longer entry ("kept while a reader still holds it") captures and the README's one-line cell elides. Fine at the resolution each is written to.
Siting achieves the premise for four of the five: each note sits on the line that creates the name (cache-lib.sh:470, :329, prune-cache.sh:182, publish-snapshot.sh:55). See nit 1 for the fifth.
Narrow pass
1. The .reading-* claim is true of shipped code, not just intended. Against origin/dev:scripts/ci-cache-reclaim.sh: leftover_kind() matches only .stage- and .evicting- and returns non-zero for every other dotted name; collect_entries() globs only those two prefixes; marker_is_live() stats markers and never unlinks (its own comment: "Unlike live_reader_count this never sweeps the stale ones"). There is no unlink path that can reach a .reading-* file. The "one shape it must never delete" wording matches the arbiter's own "never deletes … a .reading-* reader marker itself, ever".
2. The staleness direction holds and the constants are what the PR says. Producer: CACHE_READ_STALE_SECONDS=7200 (cache-lib.sh:326), STALE_LOCK_SECONDS=7200 (prune-cache.sh:89). Arbiter: CI_CACHE_READER_STALE_SECONDS=7200, CI_CACHE_LEFTOVER_MIN_AGE_SECONDS=7200. The asymmetry argument — including "the minimum-age guard does not back-stop it, because a clone holding a 3h-old marker has a ~3h-old staging tree" — is a faithful restatement of the arbiter's own DIRECTION MATTERS block, not a reinvention. The PR correctly names both mirrors as bound by >=, which the arbiter also asserts for its minimum age.
3. The daniel/gitdan#30 rewording is complete; there is no fourth site. Every reference to the .publish-* gap is past tense plus a ticket pointer (README.md:266-269, cache-lib.sh:57-63, publish-snapshot.sh:58-64). prune-cache.sh:184 is the only other place that asserts current arbiter behaviour, and its claim is about .evicting-, which is covered today and stays covered after #30. Nothing in the diff goes stale when #30 lands.
4. The length check is meaningful, and it currently fires — correctly. Five shapes here; ci-cache-reclaim.sh:382-384 declares three prefix constants. #30 adds .publish-new- and .publish-old-, taking it to five. So the invariant is true after #30 and off by exactly the two shapes the adjacent sentence attributes to #30. See nit 3 on making it unambiguously countable.
5. The #6 sweep is complete.publish-snapshot.sh:32 now points at seed-target-dir-selftest.sh scenario 8b, and 8b is the right target: seed-target-dir-selftest.sh:41-45 describes "a subtree unlinked out of the parent's listing before cp -al reads it is never visited, the copy exits 0" — the exact mode the header paragraph describes. Every other scenario <n> in the repo either names its suite (README.md:436, 441, 449, 461; seed-target-dir-selftest.sh:245; publish-snapshot-selftest.sh:44) or is in-file (prune-cache-selftest.sh:96, 239, 253; restore-mtimes-selftest.sh throughout; seed-target-dir-selftest.sh:32, 107, 236; publish-snapshot-selftest.sh:23, 36). README.md:467's "scenario 9 of the same suite" is anaphoric to "the seed suite" one sentence earlier — unambiguous. No further edits were needed because #4 had already fixed the sibling site.
No behaviour change — confirmed independently
Stripping comments and blank lines, the three touched scripts hash identically across ec3ab6c and the branch head (cache-lib.she1d838cf… on both; prune-cache.sh and publish-snapshot.sh are byte-identical even before stripping blanks). The filtered diff of scripts/ contains zero non-comment lines. bash scripts/selftest.sh: all 5 suites pass. shellcheck -f gcc scripts/*.sh against base and head, position-normalised and sorted: 11 findings, diff clean — identical to main.
Scope
Clean. Four files, all owned by these two tickets. No selftest touched at all — the sibling track's scripts/*selftest*.sh and the README suite-table row for seed-target-dir-selftest.sh are untouched. cargo-cache/ and cargo-cache-publish/ untouched, so no v2 is forced under the README's tag policy. The bundling was right: #6's fix (:32) and #7's .publish-* note (:55) are twenty lines apart in one file.
Nits (non-blocking)
.publish-new-'s note is 45 lines from the line that creates it.TMP_DST="${ROOT}/.publish-new-${TAG}" is at publish-snapshot.sh:100, under its own comment block at :94-99 that says nothing about the contract. The other four shapes got a note on their producing line; this one is documented at the OLD= assignment. Someone renaming TMP_DST sees :94-99, not :55-64. A one-line pointer at :99 would close it.
".publish-old- is milder" understates the case #30 itself calls out. Both the README cell and publish-snapshot.sh:62 say it is swept by the next publish of that key, which is true — but daniel/gitdan#30, cited two lines later, notes that a key never published again (branch merged, deleted, renamed) leaves its rotated generation behind permanently. "Milder" is fair; "bounded" it is not. Four words ("if there ever is one") would keep the doc from being the thing a future reader has to correct.
"the prefixes ci-cache-reclaim.sh enumerates" is ambiguous for a drift detector whose whole value is countability. That script enumerates two prefixes as leftover candidates (collect_entries() globs .stage- and .evicting- only); it declares three (.reading- is read, never enumerated). Only the declared-constants reading makes 5-vs-5 work after #30. "the prefix constants it declares" removes the coin-flip.
Stale names in publish-snapshot.sh's own header, ten lines above the fix.:15 says step 1 stages at .stage-<tag> (the staged snapshot is .publish-new-<tag>; .stage- is the inner path inside hardlink_clone_into) and :17 says .publish-old-<tag> without the key. Pre-existing and outside both ACs — but this is a PR about the exact names in that exact file, and #6's thesis is that a pointer landing on a real thing with a plausible name is the worst kind of stale one. Worth a follow-up if not folded in here.
AC verification
#7 — (1) each of the three ACd shapes documented at its producing site naming the contract: cache-lib.sh:470, cache-lib.sh:329, prune-cache.sh:182 — pass, and two bonus shapes documented beyond the AC. (2) add-a-shape rule stated, not just rename: cache-lib.sh:49-51, README.md:260-261 — pass. (3) staleness direction with asymmetry: cache-lib.sh:315-325, prune-cache.sh:88-91, README.md:276-286 — pass. (4) README section a newcomer meets first, cross-linked from the cache-layout block with a working anchor: README.md:118-120 → :235 — pass. (5) no behaviour change — pass, hash-verified.
#6 — (1) correct suite and scenario — pass. (2) every remaining reference names its suite or is in-file — pass, swept exhaustively.
VERDICT: APPROVE-WITH-NITS
A docs PR whose entire value is accuracy, so every claim was checked against code rather than read for plausibility. All the load-bearing ones hold, including the safety-critical one. Four nits below, none blocking.
## Wide pass — is the documentation correct, and is it sited where it will be seen?
**The five-shape table is accurate**, name by name, site by site:
| documented | verified at |
|---|---|
| `.stage-<tag>` / `hardlink_clone_into()` | `cache-lib.sh:474` |
| `.publish-new-<tag>` / `publish-snapshot.sh` | `publish-snapshot.sh:100` |
| `.publish-old-<key>-<tag>` / `publish-snapshot.sh` | `publish-snapshot.sh:65` |
| `.evicting-<name>-<pid>` / `evict_dir()` | `prune-cache.sh:190` |
| `.reading-<source>-<tag>` / `reader_lock_acquire()` | `cache-lib.sh:333` |
No sixth shape exists — `grep` for dot-prefixed literals across `scripts/` outside the selftests returns exactly these five.
**Stranding conditions check out.** `TAG` is `${{ github.job }}-${{ github.run_id }}-$$` (`cargo-cache-publish/action.yml:135`, `cargo-cache/action.yml:159`), so "tagged per job per run" is literal and the self-scoped `rm -rf` at `publish-snapshot.sh:76` / `:101` genuinely cannot reach another run's strand. `.publish-old-`'s sweep at `:86` globs `.publish-old-${OWN_KEY}-*` — key-scoped as documented, and additionally gated on `live_reader_count == 0` at `:85`, which `cache-lib.sh`'s longer entry ("kept while a reader still holds it") captures and the README's one-line cell elides. Fine at the resolution each is written to.
**Siting achieves the premise** for four of the five: each note sits on the line that creates the name (`cache-lib.sh:470`, `:329`, `prune-cache.sh:182`, `publish-snapshot.sh:55`). See nit 1 for the fifth.
## Narrow pass
**1. The `.reading-*` claim is true of shipped code, not just intended.** Against `origin/dev:scripts/ci-cache-reclaim.sh`: `leftover_kind()` matches only `.stage-` and `.evicting-` and returns non-zero for every other dotted name; `collect_entries()` globs only those two prefixes; `marker_is_live()` stats markers and never unlinks (its own comment: "Unlike `live_reader_count` this never sweeps the stale ones"). There is no unlink path that can reach a `.reading-*` file. The "one shape it must never delete" wording matches the arbiter's own "never deletes … a `.reading-*` reader marker itself, ever".
**2. The staleness direction holds and the constants are what the PR says.** Producer: `CACHE_READ_STALE_SECONDS=7200` (`cache-lib.sh:326`), `STALE_LOCK_SECONDS=7200` (`prune-cache.sh:89`). Arbiter: `CI_CACHE_READER_STALE_SECONDS=7200`, `CI_CACHE_LEFTOVER_MIN_AGE_SECONDS=7200`. The asymmetry argument — including "the minimum-age guard does not back-stop it, because a clone holding a 3h-old marker has a ~3h-old staging tree" — is a faithful restatement of the arbiter's own DIRECTION MATTERS block, not a reinvention. The PR correctly names *both* mirrors as bound by `>=`, which the arbiter also asserts for its minimum age.
**3. The `daniel/gitdan#30` rewording is complete; there is no fourth site.** Every reference to the `.publish-*` gap is past tense plus a ticket pointer (`README.md:266-269`, `cache-lib.sh:57-63`, `publish-snapshot.sh:58-64`). `prune-cache.sh:184` is the only other place that asserts current arbiter behaviour, and its claim is about `.evicting-`, which is covered today and stays covered after #30. Nothing in the diff goes stale when #30 lands.
**4. The length check is meaningful, and it currently fires — correctly.** Five shapes here; `ci-cache-reclaim.sh:382-384` declares three prefix constants. #30 adds `.publish-new-` and `.publish-old-`, taking it to five. So the invariant is true after #30 and off by exactly the two shapes the adjacent sentence attributes to #30. See nit 3 on making it unambiguously countable.
**5. The #6 sweep is complete.** `publish-snapshot.sh:32` now points at `seed-target-dir-selftest.sh` scenario 8b, and 8b is the right target: `seed-target-dir-selftest.sh:41-45` describes "a subtree unlinked out of the parent's listing before `cp -al` reads it is never visited, the copy exits 0" — the exact mode the header paragraph describes. Every other `scenario <n>` in the repo either names its suite (`README.md:436, 441, 449, 461`; `seed-target-dir-selftest.sh:245`; `publish-snapshot-selftest.sh:44`) or is in-file (`prune-cache-selftest.sh:96, 239, 253`; `restore-mtimes-selftest.sh` throughout; `seed-target-dir-selftest.sh:32, 107, 236`; `publish-snapshot-selftest.sh:23, 36`). `README.md:467`'s "scenario 9 of the same suite" is anaphoric to "the seed suite" one sentence earlier — unambiguous. No further edits were needed because #4 had already fixed the sibling site.
## No behaviour change — confirmed independently
Stripping comments *and* blank lines, the three touched scripts hash identically across `ec3ab6c` and the branch head (`cache-lib.sh` `e1d838cf…` on both; `prune-cache.sh` and `publish-snapshot.sh` are byte-identical even before stripping blanks). The filtered diff of `scripts/` contains zero non-comment lines. `bash scripts/selftest.sh`: **all 5 suites pass**. `shellcheck -f gcc scripts/*.sh` against base and head, position-normalised and sorted: **11 findings, `diff` clean** — identical to `main`.
## Scope
Clean. Four files, all owned by these two tickets. No selftest touched at all — the sibling track's `scripts/*selftest*.sh` and the README suite-table row for `seed-target-dir-selftest.sh` are untouched. `cargo-cache/` and `cargo-cache-publish/` untouched, so no `v2` is forced under the README's tag policy. The bundling was right: #6's fix (`:32`) and #7's `.publish-*` note (`:55`) are twenty lines apart in one file.
## Nits (non-blocking)
1. **`.publish-new-`'s note is 45 lines from the line that creates it.** `TMP_DST="${ROOT}/.publish-new-${TAG}"` is at `publish-snapshot.sh:100`, under its own comment block at `:94-99` that says nothing about the contract. The other four shapes got a note on their producing line; this one is documented at the `OLD=` assignment. Someone renaming `TMP_DST` sees `:94-99`, not `:55-64`. A one-line pointer at `:99` would close it.
2. **"`.publish-old-` is milder" understates the case `#30` itself calls out.** Both the README cell and `publish-snapshot.sh:62` say it is swept by the next publish of that key, which is true — but `daniel/gitdan#30`, cited two lines later, notes that a key never published again (branch merged, deleted, renamed) leaves its rotated generation behind permanently. "Milder" is fair; "bounded" it is not. Four words ("if there ever is one") would keep the doc from being the thing a future reader has to correct.
3. **"the prefixes `ci-cache-reclaim.sh` enumerates" is ambiguous for a drift detector whose whole value is countability.** That script *enumerates* two prefixes as leftover candidates (`collect_entries()` globs `.stage-` and `.evicting-` only); it *declares* three (`.reading-` is read, never enumerated). Only the declared-constants reading makes 5-vs-5 work after #30. "the prefix constants it declares" removes the coin-flip.
4. **Stale names in `publish-snapshot.sh`'s own header, ten lines above the fix.** `:15` says step 1 stages at `.stage-<tag>` (the staged snapshot is `.publish-new-<tag>`; `.stage-` is the inner path inside `hardlink_clone_into`) and `:17` says `.publish-old-<tag>` without the key. Pre-existing and outside both ACs — but this is a PR about the exact names in that exact file, and #6's thesis is that a pointer landing on a real thing with a plausible name is the worst kind of stale one. Worth a follow-up if not folded in here.
## AC verification
**#7** — (1) each of the three ACd shapes documented at its producing site naming the contract: `cache-lib.sh:470`, `cache-lib.sh:329`, `prune-cache.sh:182` — pass, and two bonus shapes documented beyond the AC. (2) add-a-shape rule stated, not just rename: `cache-lib.sh:49-51`, `README.md:260-261` — pass. (3) staleness direction with asymmetry: `cache-lib.sh:315-325`, `prune-cache.sh:88-91`, `README.md:276-286` — pass. (4) README section a newcomer meets first, cross-linked from the cache-layout block with a working anchor: `README.md:118-120` → `:235` — pass. (5) no behaviour change — pass, hash-verified.
**#6** — (1) correct suite and scenario — pass. (2) every remaining reference names its suite or is in-file — pass, swept exhaustively.
Four corrections from the review of #8, all in the files this PR already
touches:
- A local signal on the line that creates .publish-new-. The other four shapes
each got a note at their producing line, which is the whole premise of #7 —
someone renaming TMP_DST reads its own comment block and would never see the
contract note 45 lines up at OLD.
- '.publish-old- is milder' understated it. Milder is true; bounded is not. A
key whose branch is merged, deleted or renamed is never published again, so
its rotated generation stays until something outside this repo takes it —
which is the case gitdan#30 itself makes, two lines away.
- The drift check now says the prefix constants ci-cache-reclaim.sh DECLARES,
not the ones it enumerates. Those are different numbers: collect_entries()
globs .stage- and .evicting- only, because .reading- is read and never
swept. Only the declared reading makes the five-against-five count work, and
a countability check that needs a coin flip to count is not one.
- publish-snapshot.sh's own header had two stale names ten lines above the
stale pointer this PR fixes: step 1 staged at .stage-<tag> (that is
hardlink_clone_into's inner path; the staged snapshot is .publish-new-<tag>)
and step 2 named .publish-old-<tag> without the key. Pre-existing and
outside both ACs, but #6's thesis is that a plausible-looking wrong name is
the worst kind, and these are in the file the PR is about.
Comments and docs only. With comments and blank lines stripped, all three
scripts hash identically to origin/main.
claude
changed title from WIP: docs: producer half of the leftover naming contract, and the stale scenario pointer to docs(cache): write down the producer half of the leftover naming contract2026-08-24 18:08:11 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #7
Closes #6
Why the two tickets are bundled
Deliberate, not accidental. Both are cross-reference accuracy in overlapping
files — #7 adds contract commentary to
cache-lib.sh,prune-cache.shandpublish-snapshot.sh, and #6 corrects a stale pointer sitting inpublish-snapshot.sh's header, a few lines from where #7's.publish-*notelands. Split across two tracks they would have been two agents editing the same
comment blocks and resolving each other's conflicts for no benefit.
Summary
#7 — the producer half of the leftover naming contract.
daniel/gitdan's host-level arbiter (scripts/ci-cache-reclaim.sh) reclaimsthe dot-prefixed trees this repo's scripts strand inside the cache volumes it
scans, and reads this repo's
.reading-*markers to decide whether one ofthose trees is still live. That arrangement was written down only on the
consuming side. Nothing here said a name had an external consumer, so a
contributor could add or rename a dot-prefixed shape with no local signal — and
since the arbiter enumerates by explicit prefix rather than dotglob
(deliberately: a dotglob pulls reader markers into the candidate stream
alongside the trees they protect), an unannounced shape is not handled
conservatively, it is invisible to it.
#6 — the stale cross-reference.
publish-snapshot.sh's header attributedthe silent-truncation failure mode to "this script's own selftest (scenario
8)". Wrong twice over: the scenario that reproduces a truncated clone is in
seed-target-dir-selftest.sh, and since #4 split the old scenario 8 into 8aand 8b it is 8b.
publish-snapshot-selftest.shdoes have a scenario 8 — theabandoned-marker case — so the pointer landed on a real scenario with a
plausible number that tests something else.
What the producer half now says, and where each piece sits
daniel/gitdan'sLEFTOVER NAMING CONTRACTblock stays canonical; this sidecarries a pointer plus the local obligations, so the description lives in one
place rather than two that can drift.
scripts/cache-lib.sh— a header block (CROSS-REPO CONTRACT) namingevery shape this repo creates directly under a cache root, its producing
function, and how each one strands. Sited here because
cache-lib.shissourced by every script that writes one.
.stage-<tag>inhardlink_clone_into(),.reading-<source>-<tag>atreader_marker_path(),.evicting-<name>-<pid>atprune-cache.sh'sevict_dir().README.md— a short section, cross-linked from the cache-layout block inHow it works, that a newcomer meets before adding a scratch directory under
a cache root.
.reading-*is documented as protective, not garbage at every one of thosesites: it is the live-reader signal both this repo's prune pass and the arbiter
read, the arbiter never deletes one, and deleting a live marker is what clears
the way for a tree to be unlinked underneath the walk reading it.
The rule is stated as adding a shape counts exactly as much as renaming one —
no new dot-prefixed entry under a cache root without a matching prefix in
ci-cache-reclaim.sh.The direction rule
CACHE_READ_STALE_SECONDS(cache-lib.sh) andSTALE_LOCK_SECONDS(
prune-cache.sh) are mirrored by the arbiter, and the asymmetry is nowwritten at both constants:
marker whose owner still considers it live as stale, and delete a tree under
an in-flight clone. Its minimum-age guard does not back-stop that — a clone
holding a three-hour-old marker has a roughly three-hour-old staging tree, so
both of its guards pass.
reclamation this side would already have permitted. The residual is disk, not
correctness.
The finding this turned up —
daniel/gitdan#30Writing the inventory turned up two dot-prefixed shapes the arbiter's
enumeration did not cover:
.publish-old-<key>-<tag>and.publish-new-<tag>,both from
publish-snapshot.sh..publish-old-is mild — the sweep atpublish-snapshot.sh:85reclaims it on the next publish of the same key..publish-new-is not: it is tagged per job per run exactly as.stage-is, so a publisher killed between staging its snapshot and the swap strands a
full hardlink clone that no later run of this script will ever match and that
the arbiter cannot see. That is gitdan#28's failure mode, reached by the route
this contract exists to close — the rule catching an uncovered shape on its
first application.
It is now filed as
daniel/gitdan#30and being fixed in that repo this round. This PR's comments reference the
ticket rather than the arbiter's momentary contents, so nothing here dates
itself against that merge; what they keep is the mechanism that made the shape
worth catching. The rule statement itself is unchanged.
Also added, since the inventory here is the first complete list of these shapes
in either repo: once #30 lands, both lists are five entries long, and a
length mismatch is the cheapest signal that one side gained a shape without
telling the other.
Cross-references corrected
git grep -n 'scenario [0-9]' -- scripts/returns nine other hits. All arein-file (
prune-cache-selftest.sh× 3,restore-mtimes-selftest.sh× 5,seed-target-dir-selftest.sh× 3) or already name their suite(
seed-target-dir-selftest.sh:245,publish-snapshot-selftest.sh:44,restore-mtimes.sh:124).publish-snapshot.sh:32was the only wrong one, andits replacement now names the suite, the number 8b, and what the local
scenario 8 actually is, so the collision cannot re-form silently.
Files affected
scripts/cache-lib.sh,scripts/prune-cache.sh,scripts/publish-snapshot.sh— comments only — and
README.md.No logic change. Every added line under
scripts/is a comment; the onlydeletion is the two comment lines #6 replaces. Verifiable with
git diff origin/main -- scripts/ | grep -E '^\+' | grep -vE '^\+\s*#', whichreturns nothing but the
+++header.Test plan
bash scripts/selftest.sh(full, compiler-backed) — all 5 suites pass, runfrom committed state. Same as the branch point, as a comments-only change
should be.
shellcheck scripts/cache-lib.sh scripts/prune-cache.sh scripts/publish-snapshot.sh— findings identical to
origin/main(one pre-existing SC2016 atcache-lib.sh's_unshare_filesxargs line). No new findings.gitdan-ci.VERDICT: APPROVE-WITH-NITS
A docs PR whose entire value is accuracy, so every claim was checked against code rather than read for plausibility. All the load-bearing ones hold, including the safety-critical one. Four nits below, none blocking.
Wide pass — is the documentation correct, and is it sited where it will be seen?
The five-shape table is accurate, name by name, site by site:
.stage-<tag>/hardlink_clone_into()cache-lib.sh:474.publish-new-<tag>/publish-snapshot.shpublish-snapshot.sh:100.publish-old-<key>-<tag>/publish-snapshot.shpublish-snapshot.sh:65.evicting-<name>-<pid>/evict_dir()prune-cache.sh:190.reading-<source>-<tag>/reader_lock_acquire()cache-lib.sh:333No sixth shape exists —
grepfor dot-prefixed literals acrossscripts/outside the selftests returns exactly these five.Stranding conditions check out.
TAGis${{ github.job }}-${{ github.run_id }}-$$(cargo-cache-publish/action.yml:135,cargo-cache/action.yml:159), so "tagged per job per run" is literal and the self-scopedrm -rfatpublish-snapshot.sh:76/:101genuinely cannot reach another run's strand..publish-old-'s sweep at:86globs.publish-old-${OWN_KEY}-*— key-scoped as documented, and additionally gated onlive_reader_count == 0at:85, whichcache-lib.sh's longer entry ("kept while a reader still holds it") captures and the README's one-line cell elides. Fine at the resolution each is written to.Siting achieves the premise for four of the five: each note sits on the line that creates the name (
cache-lib.sh:470,:329,prune-cache.sh:182,publish-snapshot.sh:55). See nit 1 for the fifth.Narrow pass
1. The
.reading-*claim is true of shipped code, not just intended. Againstorigin/dev:scripts/ci-cache-reclaim.sh:leftover_kind()matches only.stage-and.evicting-and returns non-zero for every other dotted name;collect_entries()globs only those two prefixes;marker_is_live()stats markers and never unlinks (its own comment: "Unlikelive_reader_countthis never sweeps the stale ones"). There is no unlink path that can reach a.reading-*file. The "one shape it must never delete" wording matches the arbiter's own "never deletes … a.reading-*reader marker itself, ever".2. The staleness direction holds and the constants are what the PR says. Producer:
CACHE_READ_STALE_SECONDS=7200(cache-lib.sh:326),STALE_LOCK_SECONDS=7200(prune-cache.sh:89). Arbiter:CI_CACHE_READER_STALE_SECONDS=7200,CI_CACHE_LEFTOVER_MIN_AGE_SECONDS=7200. The asymmetry argument — including "the minimum-age guard does not back-stop it, because a clone holding a 3h-old marker has a ~3h-old staging tree" — is a faithful restatement of the arbiter's own DIRECTION MATTERS block, not a reinvention. The PR correctly names both mirrors as bound by>=, which the arbiter also asserts for its minimum age.3. The
daniel/gitdan#30rewording is complete; there is no fourth site. Every reference to the.publish-*gap is past tense plus a ticket pointer (README.md:266-269,cache-lib.sh:57-63,publish-snapshot.sh:58-64).prune-cache.sh:184is the only other place that asserts current arbiter behaviour, and its claim is about.evicting-, which is covered today and stays covered after #30. Nothing in the diff goes stale when #30 lands.4. The length check is meaningful, and it currently fires — correctly. Five shapes here;
ci-cache-reclaim.sh:382-384declares three prefix constants. #30 adds.publish-new-and.publish-old-, taking it to five. So the invariant is true after #30 and off by exactly the two shapes the adjacent sentence attributes to #30. See nit 3 on making it unambiguously countable.5. The #6 sweep is complete.
publish-snapshot.sh:32now points atseed-target-dir-selftest.shscenario 8b, and 8b is the right target:seed-target-dir-selftest.sh:41-45describes "a subtree unlinked out of the parent's listing beforecp -alreads it is never visited, the copy exits 0" — the exact mode the header paragraph describes. Every otherscenario <n>in the repo either names its suite (README.md:436, 441, 449, 461;seed-target-dir-selftest.sh:245;publish-snapshot-selftest.sh:44) or is in-file (prune-cache-selftest.sh:96, 239, 253;restore-mtimes-selftest.shthroughout;seed-target-dir-selftest.sh:32, 107, 236;publish-snapshot-selftest.sh:23, 36).README.md:467's "scenario 9 of the same suite" is anaphoric to "the seed suite" one sentence earlier — unambiguous. No further edits were needed because #4 had already fixed the sibling site.No behaviour change — confirmed independently
Stripping comments and blank lines, the three touched scripts hash identically across
ec3ab6cand the branch head (cache-lib.she1d838cf…on both;prune-cache.shandpublish-snapshot.share byte-identical even before stripping blanks). The filtered diff ofscripts/contains zero non-comment lines.bash scripts/selftest.sh: all 5 suites pass.shellcheck -f gcc scripts/*.shagainst base and head, position-normalised and sorted: 11 findings,diffclean — identical tomain.Scope
Clean. Four files, all owned by these two tickets. No selftest touched at all — the sibling track's
scripts/*selftest*.shand the README suite-table row forseed-target-dir-selftest.share untouched.cargo-cache/andcargo-cache-publish/untouched, so nov2is forced under the README's tag policy. The bundling was right: #6's fix (:32) and #7's.publish-*note (:55) are twenty lines apart in one file.Nits (non-blocking)
.publish-new-'s note is 45 lines from the line that creates it.TMP_DST="${ROOT}/.publish-new-${TAG}"is atpublish-snapshot.sh:100, under its own comment block at:94-99that says nothing about the contract. The other four shapes got a note on their producing line; this one is documented at theOLD=assignment. Someone renamingTMP_DSTsees:94-99, not:55-64. A one-line pointer at:99would close it."
.publish-old-is milder" understates the case#30itself calls out. Both the README cell andpublish-snapshot.sh:62say it is swept by the next publish of that key, which is true — butdaniel/gitdan#30, cited two lines later, notes that a key never published again (branch merged, deleted, renamed) leaves its rotated generation behind permanently. "Milder" is fair; "bounded" it is not. Four words ("if there ever is one") would keep the doc from being the thing a future reader has to correct."the prefixes
ci-cache-reclaim.shenumerates" is ambiguous for a drift detector whose whole value is countability. That script enumerates two prefixes as leftover candidates (collect_entries()globs.stage-and.evicting-only); it declares three (.reading-is read, never enumerated). Only the declared-constants reading makes 5-vs-5 work after #30. "the prefix constants it declares" removes the coin-flip.Stale names in
publish-snapshot.sh's own header, ten lines above the fix.:15says step 1 stages at.stage-<tag>(the staged snapshot is.publish-new-<tag>;.stage-is the inner path insidehardlink_clone_into) and:17says.publish-old-<tag>without the key. Pre-existing and outside both ACs — but this is a PR about the exact names in that exact file, and #6's thesis is that a pointer landing on a real thing with a plausible name is the worst kind of stale one. Worth a follow-up if not folded in here.AC verification
#7 — (1) each of the three ACd shapes documented at its producing site naming the contract:
cache-lib.sh:470,cache-lib.sh:329,prune-cache.sh:182— pass, and two bonus shapes documented beyond the AC. (2) add-a-shape rule stated, not just rename:cache-lib.sh:49-51,README.md:260-261— pass. (3) staleness direction with asymmetry:cache-lib.sh:315-325,prune-cache.sh:88-91,README.md:276-286— pass. (4) README section a newcomer meets first, cross-linked from the cache-layout block with a working anchor:README.md:118-120→:235— pass. (5) no behaviour change — pass, hash-verified.#6 — (1) correct suite and scenario — pass. (2) every remaining reference names its suite or is in-file — pass, swept exhaustively.
WIP: docs: producer half of the leftover naming contract, and the stale scenario pointerto docs(cache): write down the producer half of the leftover naming contract