fix(prune): widen the reclaim, close the lap, and record the floor's basis - #756
Conversation
CLOUD-1157 `stem_of` cannot group an extensioned artifact, so `.rlib`, `.rmeta` and `.so` are never superseded however many copies accumulate — and three regrowable roots are outside the walk entirely
Why CLOUD-766's acceptance §1 — "
The measurement
What does not depend on which tree you measure, and is the whole of this row:
1. The executable filter, and its stated ground is refuted
They are not small, and the per-lap component is real rather than a static tail. Both copies of the workspace's own library were resident in the reading above: A lap rebases, which mints a new SHA, which changes the workspace crates' metadata hash — exactly the mechanism CLOUD-766 documented for test binaries. It applies unchanged to 2.
|
| path | measured |
|---|---|
target/semver-checks |
2.6 GB (CLOUD-861, second session; 1.3 GB on its first) |
target/perf |
401 MB (CLOUD-861) |
target/debug/build |
156 MB (this reading) |
target/flycheck0 |
small here, unbounded in principle |
CLOUD-861's own follow-up already names semver-checks as "a third category the reclaim currently does not consider". It is recorded there against a row about the floor's timing, where it does not belong; it belongs here.
Why it is worth a row
The reclaim is the thing that makes CLOUD-861's floor satisfiable, and the ground is the regrowable roots rather than a percentage (corrected 2026-08-29). A ratcheting floor needs reclaimable headroom to ratchet against, and semver-checks at 2.6 GB plus perf and build are unreachable by any pass today — so a floor that ratchets before this row lands climbs toward a number nothing can satisfy — which batten.toml's own [prune] comment already names as the failure that gets a gate switched off. So this row blocks CLOUD-861 rather than merely relating to it.
Refinement — Ready (widen the reclaim to the classes that actually grow)
Refinement gate: Definition of Ready & Done. This body carries only specializations.
- **Source of truth (§1). **
crates/batten/src/prune.rs—stem_of,reclaim_superseded,drop_incremental— and the[prune]table inbatten.tomlfor the roots, which are the consumer's.mise-tasks/target-prune.shno longer exists: it was retired whole under CLOUD-1059 and the predicate is the crate's. A change touchingmise-tasks/**ortests/**/*.batsto close this is refused byV-SHELL-RULE-EDITEDwith no override and nobypass_env. - **Computable predicate (§2). **
batten target prune→ exit code and a byte count over the tree, no network, no build. Three parts, in this order because the first is load-bearing for the second:-
**The group key becomes **
(stem, extension), and retention is per-kind.stem_ofstrips a known artifact extension before the hash test solibbatten-<hash>.rlibcan group with its siblings, and the extension is then carried as part of the key. The existing guard against eating a suffix that merely contains a dash stays.Grouping on the stem ALONE is unsafe and would delete a live artifact — corrected 2026-08-29 before dispatch, because the row shipped specifying exactly that. Measured in
deps:libbattenhas 2.rliband 6.rmeta. Collapse the extension and that is one group of 8, wherekeep = 2can retain two.rmetaand delete the **live **.rlib. That is thekeep = 0failurePrune::validatealready refuses, arriving through the grouping instead of through the count. -
The superseded pass covers
.rlib,.rmetaand.sounder the samekeepretention, per kind..dfiles stay out — 2.8 MB across 666 files here, so the whole class is below the noise. -
The escalation gains a declared list of regrowable roots beside
incremental—semver-checks,perf,flycheck*,build. Declared in[prune], never a constant in the crate, per non-negotiable rule 1.Each root declares whether dropping it moves the basis, because they do not all cost the same thing — corrected 2026-08-29; the row shipped saying every root moves the basis to
Cold"exactly asincrementaldoes", and that over-refuses.Basis::Coldmeans the next cargo build is full, and the cold floor (14914 MB) is budgeted for precisely that. Droppingincrementalorbuildcreates that demand; droppingsemver-checks,perforflycheck*makes only their own next run cold and leaves the cargo build warm, so marking the basisColdthere judges the lap against a demand that will not arrive.
-
- Effect (§3). No new verb and no new surface.
batten target prunealready carriesCost::EffectonSurface::VerifyOnlyandEffect::Destructive, and stays unreachable from the mediated call. - Output & exit (§5). Pointer-only per non-negotiable rule 4, unchanged in shape: a file count, bytes reclaimed, bytes free, the floor and its basis. Never a path listing — that is unbounded, and a caller who wants one runs
du. - **Commit / bump (§6). **
fix(prune)→ patch until 0.1.0. The workspace is 0.0.130, so release-plz bumps the patch whatever the type says. Crate source, so a release does move — unlike the task-runner era this predicate came from. - **Test obligation (§7). **
crates/batten/tests/target_prune.rs, the compiled-binary tier, since awith input as-style fixture cannot prove the walk reaches a real directory. Shown able to fail (CLOUD-418):- an extensioned stem with
keep + 1copies of ONE kind loses exactly one, newest-first — red today, because the grouping cannot form; - the collision case, and it is the one that would ship a data-loss bug: a stem carrying 2
.rliband 6.rmetaretains 2 of EACH, and the newest.rlibsurvives. Red under a stem-only key, which is what the first draft of this row specified; - a declared regrowable root is dropped only below the warm floor, and moves the basis to
Coldonly if it is declared as basis-moving; a root declared otherwise leaves the basisWarm; - anti-vacuity twins: a
.dfile is untouched; a stem with a single.rlibcopy is untouched; a declared root above the floor keeps its bytes. Without these the fix is just "delete more", which costs a full rebuild every lap. a_non_executable_file_beside_the_artifacts_is_left_alone(target_prune.rs) currently pins the narrow scope as intended behaviour and changes with this row. That it exists is the evidence the scope was a decision rather than an oversight, which is why this row argues against the reason rather than calling it a bug.
- an extensioned stem with
- Blockers (§8). None.
blocksCLOUD-861 (whose ratcheting floor is unsatisfiable over a reclaim this narrow).relatedToCLOUD-766 (the row whose acceptance §1 this residue is), CLOUD-1030 (the two-floor repair this builds on), CLOUD-1031 (see below).
Deliberately out of scope, decided rather than deferred. /tmp/bats-run-* held ~470 MB of stale scratch in CLOUD-861's measurement, and it is outside [prune].root and outside the repository. A repo tool that unlinks paths outside the repo root is a blast-radius change, not a coverage one, and the disk-saturation posture it belongs to is CLOUD-1031. Recorded here so the next reader does not re-find it and assume it was missed.
Acceptance
- A stem whose copies carry an extension is subject to
keepretention, and a fixture withkeep + 1copies of an.rlibloses exactly one. - The escalation names its regrowable roots in
[prune], each declaring whether dropping it moves the basis. - Only artifacts that will not be rebuilt as a consequence are removed by the superseded pass; the escalation's roots are all regrowable and are dropped only below the floor.
- A stem carrying two artifact kinds retains
keepof each, and no live artifact is removed. - A declared regrowable root moves the basis only where it is declared basis-moving; the others leave it
Warm. - The built-tree split is measured and recorded on this row —
cargo nextest run --workspace, then re-readtarget/debug/deps— so the reachable share is a number rather than an inference from a tree the suite had never been run on.
Provenance. Found while diagnosing why mise run land still exhausts the disk when it laps, with batten target prune in place and released in v0.0.129. Measured on a Claude Code web container, 2026-08-29: allowance ~38 GB (df reports a 258 GB device; used + available = 37939 MB), target/ at 3.5 GB after two SHAs.
CLOUD-861 `target-prune` checks the disk floor once per lap, before the phase that exhausts it — and its own budget comment is falsified by 3x
Why
CLOUD-766 shipped target-prune against exactly this class and is Done. Measured 2026-08-21, the class recurs. Both halves of that row's title were true again — the disk fills mid-lap, and the exhaustion was reported as a test failure.
Groomed 2026-08-29 — the Refinement block below is rewritten; the Why and both session reports are untouched.
**Its §1 named a file that no longer exists. **
mise-tasks/target-prune.shandtests/target-prune.batswere retired whole under CLOUD-1059; the predicate iscrates/batten/src/prune.rsand its second tier iscrates/batten/tests/target_prune.rs. A plan written from the old §1/§3/§6/§7 sends an implementer atmise-tasks/**, whichV-SHELL-RULE-EDITEDrefuses with no override and nobypass_env— so it read as a blocked row when it was a row written in the wrong shape.Two findings were carried here that are not this row's, and both now have their own. The reclaim's coverage is CLOUD-1157 — whose claim is that
stem_ofcannot group any name carrying an extension, so.rlib,.rmetaand.soare never superseded however many copies accumulate. That row shipped with a 78% headline and it has been corrected: the figure was both mis-divided and measured on a tree holding zero of the 110 integration-test binaries, so it said more about the tree than about the pass. The floors' basis —keep × stems × size, wherestemswent from ~41 to 110 and CLOUD-843 adds one per retired gate — is CLOUD-1158. This row keeps the one defect it was filed for: the floor is checked at the wrong moment.§2 acceptance is now met and §3 is not. The escalation reaches
incremental(observed twice, second session below). The budget comment moved but becamex1, so the floor is exactly the worst observed lap and a lap equalling it breaches by construction — which the rewritten §2 replaces with an observed, ratcheting number rather than another constant.Re-measured 2026-08-29 on a Claude Code web container: the writable allowance is ~38 GB, not the 258 GB
dfprints (used + available = 37939 MB). The declared[prune.cold]floor of 14914 MB is 39% of the whole allowance.Moved to Todo and unassigned, 2026-08-29, so the bundle can claim it. The In Progress + assigned state was an artifact rather than a pull: this row, CLOUD-766 and CLOUD-778 all flipped out of Done at the same timestamp (2026-08-29T00:57) — a sweep. Left as it was,
batten claim checkraisesnot-todoandassigned(bothKind::Competitor) and a bundle child skips the middle ticket of its own chain.--takeoveris the wrong instrument for that; it exists for a stale competitor, not for the person steering the campaign. TheblockedByedge still keeps this row off the frontier until CLOUD-1157 lands.
Scope narrowed 2026-08-21. The second half is fixed and landing in PR #626 (
ci(land): a full disk is the environment, not a verdict on this tree):landnow classifiesENOSPCand names the reclaim instead of saying "reproduce and fix locally". Two bats cases, the discriminating one red without it. **What remains on this row is the floor, and it lives entirely in **mise-tasks/target-prune. The diagnosis half is kept below as the evidence that produced the measurement, not as open work.
Filed as its own row rather than as a comment on CLOUD-766 because CLOUD-475 settled that a finding against a Done row is stranded there.
The measurement
One mise run land lap, this container:
target-prune: 0 superseded artifact(s) removed, 0MB reclaimed, 6242MB free (floor 4096MB)
... verify → cargo test ...
rustc-LLVM ERROR: IO failure on output stream: No space left on device
::error:: land: verify failed on 9a3e3ef (exit 1). Reproduce and fix locally;
CI is not where you discover this.
df afterwards: 96M available, 100% used. The prune passed with 6242MB against a 4096MB floor, and the cargo test build inside the same lap consumed all 6GB.
Defect 1 — the floor is a precondition, never an invariant
target-prune runs once, at lap start, and answers "is there enough room to begin?" Nothing re-checks during the phase that actually consumes the disk. So the floor is structurally unable to catch a build whose growth exceeds the headroom the check just certified — which is not an edge case here, it is the normal shape of a cargo test link step after a rebase onto 14 commits.
The check is not wrong, it is in the wrong place: a precondition where an invariant is needed. Either the floor has to account for the build about to run (headroom, not free space), or the prune has to be reachable from inside the phase that exhausts.
Where the space actually was, after the failure, all of it rebuildable:
| path | size |
|---|---|
target/debug/incremental |
3.8G |
target/semver-checks |
1.3G |
target/perf |
401M |
stale /tmp/bats-run-* |
~470M |
Deleting those returned 5.9GB. target-prune's own predicate — superseded artifacts — reclaimed 0MB while 3.8G of incremental cache sat there, because incremental state is not superseded by anything; it is simply unbounded. That is the gap in one line.
Defect 2 — the diagnosis was wrong (FIXED, PR #626)
CLOUD-766's title already names this: "the exhaustion is reported as a test failure." It still is. land said "verify failed … Reproduce and fix locally; CI is not where you discover this" — advice that is correct for a real failure and actively misleading for this one. There is nothing to reproduce and nothing in the diff to fix.
Same shape as CLOUD-811 (*"reports every failure as a linearity verdict, so a stale toolchain stopped a landing under a message about *main"): a task attributing every non-zero exit to the thing it is about. ENOSPC is distinguishable — rustc-LLVM ERROR: IO failure on output stream: No space left on device is a literal, and df is one call — so this is a classification a gate can make, not a judgement.
The floor's own budget comment is falsified, and by 3x
mise-tasks/target-prune carries its justification inline, in the form the repo requires:
FLOOR_MB=4096 # budget: worst-lap=2048mb x2 measured=2026-08-20
So the floor is twice the worst lap anyone had measured — a day before this. This lap consumed 6242MB, three times that worst-lap figure and half again the floor derived from it. The doubling was not too small a safety factor; the number it doubled was wrong.
That matters more than picking a bigger constant: the comment is the mechanism that makes the floor auditable, and a stale measurement inside it reads exactly like a fresh one. Whatever this row lands, worst-lap has to be re-measured rather than scaled.
Why it is worth a row
The container exhausted its disk three times in one session. Each cost a full lap: a rebase, a partial verify, and a wrong diagnosis to read past. At the fleet's measured cadence a lost lap is ~15-20 minutes of a fleet-wide lease that only one branch can hold.
Refinement — Ready (the floor accounts for the build about to run, and ratchets from what a lap actually consumed)
Refinement gate: Definition of Ready & Done. This body carries only specializations.
- **Source of truth (§1). **
crates/batten/src/prune.rsand the[prune]table inbatten.toml, plusverify's call site inmise.toml. **Not **mise-tasks/target-prune— retired whole under CLOUD-1059, andmise-tasks/**andtests/**/*.batsare refused byV-SHELL-RULE-EDITEDwith no override and nobypass_env.mise-tasks/land.sh'smise run target-prunecall stays byte-identical, which is what keeps this change out of the governed set. One place decides "is there room", as before; only the file moved. - **Computable predicate (§2). Headroom, not free space. Two
dfreadings at phase boundaries:batten target prunerecords its post-prune reading, and a second reading at the end ofverify:gatedcloses the lap.consumed = start − end + reclaimed_in_betweenis that lap's real demand, and the maximum observed ratchets the floor, journalled under$GIT_DIRbeside the existing receipts.[prune.warm].mbbecomes the seed for the first lap and a lower bound, not the whole answer. Predicate: *after a lap that ran a full *verify, free space never fell below the floor — twodfreadings and a comparison, no judgement and no model verdict. The refusal names the lap that set the number and the date it was taken, so the basis cannot go stale the way a hand-declaredworst_mbdoes. - Effect (§3). No new verb and no new surface.
batten target prunealready carriesCost::EffectonSurface::VerifyOnlyandEffect::Destructive; the journal write rides the same effect class. Unreachable from the mediated call, perRuleKind::scopes. - Output & exit (§5). Pointer-only per non-negotiable rule 4: the phase name, bytes free, the floor, its basis (
warm/cold) and the date the ratchet was set. Never a path listing from the build tree. - **Commit / bump (§6). **
fix(prune)→ patch until 0.1.0. The workspace is 0.0.130, so release-plz bumps the patch whatever the type says. This clause is corrected: it saidci(target-prune)— no bump — on the ground that "nothing here is crate source". That ground died with the retirement;prune.rsis crate source and a release does move. - **Test obligation (§7). **
crates/batten/tests/target_prune.rs, the compiled-binary tier — **not **tests/target-prune.bats, which is deleted. Shown able to fail (CLOUD-418):- a lap whose start reading clears the floor and whose end reading does not is refused at the boundary, naming the phase — red today, because there is no second reading to compare;
- the ratchet raises the floor when an observed lap exceeds the seed, and the refusal names that lap;
- anti-vacuity twins: a lap that stays above the floor throughout is not refused and does not ratchet; a lap below the seed is refused on the seed alone. Without these the fix is "refuse more", which is a gate someone switches off.
TARGET_PRUNE_FREE_MBalready takes a comma-separated sequence of readings in order (prune.rs), which is exactly the seam a two-reading fixture needs. Reuse it; a second seam would be a second authority on what a run sees.
- **Blockers (§8). **
blockedByCLOUD-1157. Not decoration, and the ground is the regrowable roots rather than a percentage (corrected 2026-08-29 — this clause quoted a share ofdepsbytes that was itself an artifact of an unbuilt tree). A ratchet needs reclaimable headroom to ratchet against, andtarget/semver-checksat 2.6 GB plustarget/perfandtarget/debug/buildare unreachable by any pass today. So a floor that ratchets before CLOUD-1157 lands climbs toward a number nothing can satisfy — which is the failurebatten.toml's own[prune]comment names as the one that gets a gate switched off.relatedToCLOUD-766 (the origin row), CLOUD-1030 (the two-floor repair this builds on), CLOUD-1158 (the basis sensor), CLOUD-778 (the suite hermeticity seam this reuses), CLOUD-811 (the same misattribution shape inlinear-check).
**Folded in: **CLOUD-1153. verify's guard reports any non-zero from target-prune as "not enough disk", including a batten that refused for a config reason and an exit 2 that means "could not look". That is the same mise.toml line this row edits, so it is repaired here rather than churning the line twice: exit 1 (below the floor), exit 2 (could not look) and a config refusal get three different messages.
Acceptance
- A lap whose build would breach the floor is refused before or during the build with a named phase, rather than certifying headroom it then consumes.
- The floor in force is derived from an observed lap's measured consumption, and the refusal names that lap and its date — no hand-declared
worst_mbis the sole authority. - A lap that stays above the floor throughout is not refused and does not ratchet, so the mechanism discriminates.
verifytells a below-the-floor refusal apart from abattenthat could not look or refused for a config reason (CLOUD-1153).
Filed from bundle B's session, which lost a lap to this. Search receipt minted (20 rows seen).
Generated by Claude Code
2026-08-22, second session — the floor moved as acceptance §3 asked, and is breached again; §2 looks satisfied
Two more exhaustions in one session, on a container running the bash-retirement sweep. Taken against the three acceptance bullets rather than as a fresh report.
§2 (incremental inside the reclaim) — appears SATISFIED, observed twice
The row's evidence was superseded reclaiming 0MB while 3.8G of incremental sat there. The escalation now reaches it, both times:
target-prune: 61 superseded artifact(s) removed, 5364MB reclaimed, 5303MB free (floor 6242MB)
target-prune: escalated below the floor — 5354MB of incremental cache dropped, 10525MB free
target-prune: 58 superseded artifact(s) removed, 5011MB reclaimed, 4952MB free (floor 6242MB)
target-prune: escalated below the floor — 2439MB of incremental cache dropped, 7335MB free
So the gap this row named in one line is closed. Recorded as observation, not as a claim about which change closed it — that was not traced.
§3 (the budget comment) — the figure MOVED, and the safety factor was removed with it
FLOOR_MB=4096 # budget: worst-lap=2048mb x2 measured=2026-08-20 <- as this row recorded it
FLOOR_MB=6242 # budget: worst-lap=6242mb x1 measured=2026-08-22 <- as it stands now
The row asked that the figure move rather than be scaled, and it did. But x2 became x1: the floor is now exactly the worst observed lap, so a lap merely equalling the worst breaches it by construction. The original 4096 at least carried a doubling; this does not. Whether that is deliberate is not recorded in the comment, which is the surface this row says must stay auditable.
§1 (precondition, not invariant) — still fails, twice
Both exhaustions have the row's exact shape: prune certified headroom at lap start, the build inside the same window consumed it, and the exhaustion surfaced as compile and bats failures rather than as a disk fault:
tee: /tmp/tmp.T75SMAAJXn: No space left on device
error: could not compile `batten` (test "cli") due to 1 previous error
mkdir: cannot create directory '/tmp/bats-run-fmKIFX/test/635': No space left on device
What is measured and what is not. The prune left 10,525MB free at 04:13, and df read 64K available, 100% used at 06:35. That interval is NOT one lap — it spans a full mise run fmt (which runs the whole gate, CLOUD-681), a verify, and two land invocations. So it is not evidence that a single lap consumes 10.5GB, and this row should not be read as claiming that. What it does establish is narrower and still decisive for §1: a floor certified at more than 1.6x its own value was exhausted before the next lap completed.
One datapoint the row's table does not have: target/semver-checks was 2.6G here against the 1.3G recorded, and target/debug alone was 14G. Dropping semver-checks by hand returned 2.4GB — rebuildable, outside superseded, and outside the incremental escalation, so it is a third category the reclaim currently does not consider.
Cost, in this session's own terms
Two laps lost. The second failed with land: still not linear after 2 laps, each of which bought a CI matrix — so the disk fault also consumed the lap budget that message is written against, and the branch was linear the whole time. The two failures compound: the exhaustion costs a lap, and the lost lap is then attributed to main moving.
CLOUD-1153 `target-prune` reports a refusing `batten` as "not enough disk", so a speculative lap's real blocker is invisible and the remedy it prints is wrong
mise-tasks/target-prune.sh shells the installed batten (command -v batten) to compute the reclaim, and verify turns any non-zero from that step into one message:
::error:: verify: not enough disk to run the gate, and pruning did not recover it —
the refusal above names free space and the floor. No receipt written.
That message is a claim about free space. It is emitted whether or not free space was the problem, because the step's exit status is the only thing read.
Measured 2026-08-29, five consecutive mise run land invocations
land lost every lap on claude/config-authority-bundle-fvqapl (PR #748) with the text above. Free space at the time was 26 GB against a 14.9 GB cold floor — the floor was never in question. The actual output of the step was:
batten: invalid config ./batten.toml: TOML parse error at line 125, column 1
|
125 | protected_readers = [
| ^^^^^^^^^^^^^^^^^
unknown field `protected_readers`, expected one of `version`, `min_batten_version`, ...
The mechanism is the speculation. land speculatively linearizes onto the branch holding the lease — here claude/retirement-gate-integrity-3mfqc9@b706d53 — and that branch adds a [protected_readers] config key together with the Rust field that reads it. The batten on PATH was built from a tree that predates the key, so it refuses the borrowed batten.toml under deny_unknown_fields, which is the loader doing exactly its job.
So on a speculative tree the installed binary and the working-tree config are, by construction, allowed to disagree — and the whole point of speculation is to run against a tree the current binary has never seen.
Why it costs more than a confusing line
- The remedy printed is wrong. "Free space outside
./target, or start a fresh session" was followed four times, reclaiming ~19 GB each time, and changed nothing. A reader with 26 GB free and a disk refusal has no next step that works. - It is invisible in the log. The
batten:line appears once, unprefixed, above a[target-prune] ERROR task failed; the::error::annotation that CI and the reader actually read names disk. - It is not rare under contention. Any sibling branch that lands a config column plus its field produces it for every other branch that speculates onto it, for as long as it holds the lease.
Class
This is CLOUD-811's defect one task over — a step reporting every failure as its own headline verdict — and the second half of CLOUD-766's "the exhaustion is reported as a test failure", inverted: there a real disk problem wore a test failure's clothes, here a config refusal wears a disk problem's.
The narrow repair is that the disk claim should be made only when the floor comparison actually decided it, and a batten that refuses should surface as could-not-look with its own pointer. Note the shape constraint: mise-tasks/target-prune.sh is a governed shell path, so under .claude/rules/toolchain.md it is retired whole or left alone — this is a port, not an edit.
A second question worth deciding rather than assuming: whether target-prune should consult the working tree's batten.toml at all on a speculative lap, given the binary is known to predate it.
Found while landing CLOUD-341; not fixed there because it is outside that row's scope and the file cannot be edited in place.
Refinement — Ready (the disk claim is made only when the floor comparison decided it)
Refinement gate: Definition of Ready & Done. This body carries only specializations.
-
**Authority boundary (§1). **
mise-tasks/target-prune.shis the one changed authority. It is governed shell, so under the two-landable-shapes rule this is a retirement — the program is ported whole to the engine, together withtests/target-prune.bats, and never edited in place. The task runner's ownverifybody only restates the verdict this program returns and is a reader rather than an authority; nothing in the policy authority moves, and no consumer surface changes.§1 deliberately names only the two paths this row would change, and names the retirement rule rather than the file that states it. The paths cited as evidence elsewhere in this body — the config the reclaim reads, the workflow that runs it — are cited and not claimed, which is the distinction
filed-over-own-diff's owncites_onlyarm exists to draw. -
Computable predicate (§2). The reclaim step resolves to exactly three outcomes rather than two: recovered (free ≥ the applicable floor), could-not-look (the reclaim could not be computed at all — the binary refused, is absent, or the root does not resolve), and short (the floor comparison ran and free is below it). Only the third may be reported as a disk refusal. The second carries the underlying program's own exit status and its pointer, and it is decidable rather than inferred: the floor comparison either produced two numbers or it did not.
-
**Effect (§3). **
write— pruning removes build artifacts, exactly as today. The classification change adds no new class of write and the port moves no verb into the derived allowlist. -
Generated artifacts (§4). None beyond what a retirement always regenerates. If the port lands the predicate as a verb,
mise run fixregenerates the completions, man pages andbatten spec, andderived-checkproves them byte-for-byte. -
Output & exit (§5). Pointer-only, and this is the substance: a refusal names free space and the floor only when it read both, and a could-not-look names the program that refused and its own pointer — never a config body, never the borrowed tree's contents. Exit follows the one table:
2for the floor genuinely being short,1for an invalid invocation, and3for could-not-look, which is the code the current collapse spends on a2-shaped message. -
**Commit / bump (§6). **
fix(prune)— patch until0.1.0, since below that release-plz bumps the patch whatever the type says. Not!for the consumer surface: no verb, output shape or existing exit code moves for a run where the floor really is short, which is every run the current message is honest about. -
Test obligation (§7). Over the compiled binary, shown able to fail per CLOUD-418. The discriminator is the measured case, which must be red against the current program: a tree with free space far above the cold floor, where the reclaim's own program refuses, is reported today as a disk refusal and must become a could-not-look naming that program. The direction a careless fix breaks: a genuinely short floor must still refuse with both numbers in the pointer. And the case that separates this from a blanket downgrade: a could-not-look must not be reported as recovered, or the gate stops defending the floor at all.
-
Blockers (§8). None.
relatedToCLOUD-811 (the same collapse one task over — every failure reported as that task's own headline verdict), CLOUD-766 (the inverse: a real exhaustion wearing a test failure's clothes), CLOUD-1030 (the warm/cold floor basis this reads), CLOUD-778 (whose suite reads the host's real free space, so the case above needs a hermetic fixture to be written at all) and CLOUD-418.
Acceptance
- A reclaim that could not run is never reported as a disk refusal, and names the program that refused.
- A genuinely short floor still refuses, with free space and the floor both in the pointer.
- No message tells a reader with 26 GB free to free more space.
Not in scope. Whether the reclaim should consult the working tree's batten.toml on a speculative lap at all — that is a question about what land borrows, and deciding it here would put a second authority over the speculation. Recorded above as an open question for whoever owns that.
CLOUD-1158 `[prune]`'s floors are measured against a test-target count that grows one per retired gate, and nothing reds when that basis moves
Why
Floor requires a measured date and validates it as a calendar date — deliberately, because "a stale measurement reads exactly like a fresh one". That is CLOUD-266's rule, discharged. But a date is not the basis; it is a pointer to a basis nobody wrote down, so nothing can tell whether the world under the number has moved.
It has moved, and it moves on a schedule this repository sets for itself.
Corrected 2026-08-29, same day, before dispatch. Three claims in the first draft were loose. §2 put the comparison in
Floor::validate, which is on the path every mediated tool call pays for — see §2 for the correction and the precedent. The "131 MB" figure below was the rlib, not the linked binary. And the 41 → 110 series compares two counts taken different ways; both are now labelled.
The arithmetic nobody watches
Retained bytes after a perfectly successful prune are keep × stems × size. keep = 2 and size are stable. stems is not:
| distinct integration-test stems, CLOUD-766, 2026-08-20 | ~41 |
crates/batten/tests/*.rs, 2026-08-29 |
110 |
tests/*.bats suites still standing |
147 |
Every one of those .rs files is an independent cargo test target — crates/batten/Cargo.toml sets no autotests = false and declares no [[test]], so cargo discovers all 110 — and each links against the workspace library. Sizes, corrected: 131 MB is the libbatten rlib, not the linked binary. CLOUD-766 measured integration test binaries at 36–100 MB (cli-<hash> at 63 MB and 36 MB); the lib test target on this container is 144–172 MB.
The two counts in the table were taken differently, and the row should not imply one series. ~41 is *stems resident in *deps on a tree where the suite had been built (CLOUD-766, 2026-08-20); 110 is tracked test files (git ls-files, 2026-08-29). Both are test targets in substance, but one counts what was built and the other counts what exists. And the count is not drifting by accident: CLOUD-843's bash retirement adds one per retired gate — .claude/rules/policy-modules.md makes the compiled-binary tier the second, non-optional tier of every retirement, so each of the 147 standing suites owes a crates/batten/tests/*.rs when it goes.
So the floor's basis is being multiplied by the campaign the repository is mid-way through, and the mechanism that would notice is the one thing Floor does not record.
Limit of this reading, stated rather than smoothed over: the container's clone is shallow (50 commits, back to 2026-08-26), so the intermediate points between 41 and 110 were not measurable from it. 41 is CLOUD-766's recorded figure and 110 is git ls-files; the trend between them is inferred from CLOUD-843's mechanism, not sampled.
What that costs today
[prune.warm].measured = "2026-08-22". [prune.cold].measured = "2026-08-29". Both carry multiplier = 1, so each floor is exactly the worst lap observed on the day it was taken — CLOUD-861's follow-up already recorded that the x2 became x1 when the number moved, and that whether that was deliberate is written down nowhere.
A floor taken against a smaller stem count is not merely conservative, it is wrong in the direction that fails silently: the check passes, the build then writes more than the basis anticipated, and the exhaustion arrives as a rustc IO error inside a test run. That is the presentation prune.rs's own header says the refusal exists to prevent.
Why it is worth a row
CLOUD-861 makes the floor ratchet from observation, which handles the number. It does not handle the basis: a ratcheting floor over a growing base climbs until it exceeds the container's allowance, and a floor nothing can satisfy is the failure batten.toml's [prune] comment already names as the one that gets a gate switched off. This is the sensor that makes that visible before it happens, and it is a different predicate from either CLOUD-861's or CLOUD-1157's.
Refinement — Ready (a floor records the basis it was measured against, not only the date)
Refinement gate: Definition of Ready & Done. This body carries only specializations.
-
**Source of truth (§1). **
FloorandFloor::validateincrates/batten/src/prune.rs, and the[prune.warm]/[prune.cold]tables inbatten.toml. The basis is the consumer's — which glob and which count — and the comparison is the engine's, which is what keeps non-negotiable rule 1.mise-tasks/target-prune.shno longer exists (retired whole under CLOUD-1059);mise-tasks/**andtests/**/*.batsare refused byV-SHELL-RULE-EDITEDwith no override and nobypass_env. -
**Computable predicate (§2). **
Floorgains the basis beside the date: a declared glob and the file count it held when the lap was measured, plus a tolerance. The comparison reds when the live count for that glob has moved past the tolerance sincemeasured— a count against a count, an exit code over the tree, no judgement and no model verdict.The comparison must NOT live in
Floor::validate— corrected 2026-08-29 before dispatch, because the first draft put it exactly there.crates/batten/src/config.rs:1038callsprune.validate()from the shared config-load path, which **every **batteninvocation runs,batten hookon every mediated tool call included. A glob walk there is taxed onto thePreToolUsepath againstperf-assert's ceiling, andbatten checkover 654 tracked files already costs ~3.6 ms above the process floor. The repository has ruled on this exact shape once:claim-race-checkwas moved off the mediated call because a round trip on every tool call is disqualifying, andRuleKind::scopespairs every spawning kind withRuleScope::Treealone.So:
Floorcarries the declared basis (it is config, and validating its shape at load is free), and the comparison against the live tree runs inbatten target prune— alreadyCost::EffectonSurface::VerifyOnly, whichperf-assertdeliberately budgets no ceiling for — or as atree-scoped rule row underverify. Either is acceptable;Floor::validateis not. -
Effect (§3). No new verb and no new surface. Shape validation of the declared basis stays at config load; the tree comparison is read-only and runs on
Surface::VerifyOnly, never on the mediated call. -
Output & exit (§5). Pointer-only per non-negotiable rule 4: the glob, the declared count, the live count, the tolerance and the
measureddate. Never a file listing — the count is the finding, and the paths are unbounded. -
**Commit / bump (§6). **
fix(prune)→ patch until 0.1.0. The workspace is 0.0.130, so release-plz bumps the patch whatever the type says. -
**Test obligation (§7). **
crates/batten/tests/target_prune.rs, the compiled-binary tier, since the live count has to come from a real tree rather than from a fabricated one. Shown able to fail (CLOUD-418):- a floor whose declared basis count no longer matches the fixture tree is refused by the verify-surface comparison, naming both numbers;
- the placement case: a mediated call (
batten hook) over a tree whose basis count has moved is not refused and does not walk the tree — red if the comparison is put back on the config-load path, which is what the first draft specified; - anti-vacuity twin: a count inside the tolerance loads clean, and a tree at exactly the declared count loads clean — without these the check is "always red", which is a gate nobody keeps;
- a floor declaring no basis at all is refused, on the same ground
measuredalready is: an absent basis reads exactly like a satisfied one.
-
Blockers (§8). None. It composes with CLOUD-861 and CLOUD-1157 and depends on neither: the number, the reclaim and the basis are three separate mechanisms, and this one is landable alone.
relatedToCLOUD-861 (the ratcheting floor this keeps satisfiable), CLOUD-1157 (the reclaim that sets whatsize × stemsactually retains), CLOUD-266 (the budget-carries-its-measurement rule this extends), CLOUD-843 (the campaign moving the term).
Acceptance
- Each floor in
[prune]declares the basis it was measured against — a glob and a count — beside its date. - A tree whose live count for that glob has moved past the declared tolerance is refused on the verify surface, naming the declared count, the live count and the date.
batten hookdoes not walk the tree for this, andperf-assertis unmoved — the comparison is off the mediated call.- A tree inside the tolerance loads clean, so the check discriminates rather than always refusing.
- The declared basis is the consumer's, in
batten.toml; no count and no glob appears incrates/batten.
Provenance. Found while diagnosing why mise run land still exhausts the disk when it laps, with batten target prune in place and released in v0.0.129. The reading that produced it: keep = 2 bounds copies per stem and nothing bounds stems, and the repository is actively multiplying stems as a matter of policy.
|
Warning Review limit reachedNext included review available in 52 minutes. View limit detailsLimit details: You’ve used the included review currently available. Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. Review configuration: ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Free Run ID: 📒 Files selected for processing (3)
ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Free Run ID: 📒 Files selected for processing (6)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthrough
Merge Risk: ⚪ Minimal · up to The PR updates pruning coverage and disk-floor tracking; no actionable merge-blocking risk remains after normal checks and review. Note 🎁 Summarized by CodeRabbit FreeYour organization is on the Free plan. CodeRabbit will generate a high-level summary and a walkthrough for each pull request. For a comprehensive line-by-line review, please upgrade your subscription to CodeRabbit Pro by visiting https://app.coderabbit.ai/settings/billing. Comment |
3a17c40 to
29901f9
Compare
…n's roots `stem_of` split a filename on its last `-` and required the tail to be all hex, so `libbatten-<hash>.rlib` produced a "hash" of `<hash>.rlib`, failed on the `.`, and became its own stem. Every extensioned artifact was a group of one, and nothing in a group of one is ever past `keep`: `.rlib`, `.rmeta` and `.so` were never superseded however many copies accumulated. The key is now `(stem, kind)` with retention per kind. The kind cannot fold into the stem — `libbatten` carries 2 `.rlib` and 6 `.rmeta`, and one group of eight under `keep = 2` can retain two `.rmeta` and delete the live `.rlib`, which is the `keep = 0` failure arriving through the grouping. The executable bit now gates only the extension-less class, where it is the sole signal that a name is an artifact. `.d` and every unrecognised kind stay out of scope. The escalation's roots move out of the crate into `[prune.regrowable]`, in declared order, each saying whether dropping it makes the next cargo build full. `incremental` and `build` do; `semver-checks`, `perf` and `flycheck*` make only their own next run cold, so marking them basis-moving would judge a lap against a full rebuild that never arrives. A single trailing `*` is the whole wildcard language, and a name that is not a name is refused at load. Three mutations show the new cases able to fail: an empty kind list reds the extensioned retention and the collision case, a stem-only key reds the collision case alone, and an unconditional basis move reds the warm-basis escalation. Refs: CLOUD-1157
…chet it The floor was a PRECONDITION: read once at the head of `verify`, answering "is there room to begin", with nothing re-reading during the phase that actually consumes the disk. A build whose growth exceeded the headroom that check had just certified was structurally invisible. Measured three times in one session — the prune passed at 6242MB free, the `cargo test` link step inside the same lap took all of it, and the exhaustion arrived as a rustc IO error under a `land` line telling the author to fix their own diff. So the same verb now runs at both boundaries. `verify:gated` calls it again as its last content step, and that run closes the lap the head call opened: `consumed = start - end + reclaimed_in_between`, the reclaim's own megabytes included, because space handed back is space the lap had spent. A lap that ended below its floor is refused there, before the receipt. The basis ratchets. `[prune.warm].worst_mb` was hand-declared at x1, so the floor was exactly the worst lap somebody wrote down and a measurement taken once read like a fresh one forever. The journal — one file under `$GIT_DIR`, rewritten by rename so CLOUD-1032's half-record class is unwritable — records what a lap actually consumed and raises the floor to it, per basis, from the lap after the one that observed it. The declaration becomes a lower bound. Folded in CLOUD-1153: `verify` reported any non-zero from `target-prune` as "not enough disk", including a config refusal (1) and a could-not-look (3). Three codes, three messages, at both call sites. Two defects the suite caught rather than review. `git::git_dir` walks up, so every non-git fixture resolved the enclosing repository's git dir and shared one journal — the same class #734 records for the config root; the authority is `./.git`, as `./batten.toml` is the config's. And `OpenLap` carried the floor it was admitted under, which was a second authority on a number the ratchet already answers: blanking it changed no verdict, which is the only way a redundant conjunct announces itself. Four mutations show the new cases able to fail: no closing reading reds all five, a ratchet that never binds reds the ratchet case, one ratchet for both bases reds the basis case, and a consumption that ignores the reclaim reds the arithmetic case. Refs: CLOUD-861, CLOUD-1153
…y the date `measured` discharges CLOUD-266 — a limit carries its measurement — and stops there. A date is a POINTER to a basis nobody wrote down, so nothing could say whether the world under the number had moved. It had, and it moves on a schedule this repository sets for itself. Retained bytes after a perfectly successful prune are `keep x stems x size`. `keep` is 2 and `size` is stable; `stems` is not. CLOUD-766 recorded ~41 test stems resident in `deps` on 2026-08-20; `crates/batten/tests/*.rs` is 110 tracked files today, each an independent cargo test target, and CLOUD-843's retirement owes one per retired gate with 147 shell suites still standing. Measured on this container the same day: 114 extension-less test binaries holding 13411.9 MB, 86.8% of `deps`. So `Floor` gains `basis` — a glob, the count it held when the floor was measured, and a tolerance. A floor taken against a smaller count fails in the direction that fails silently: the check passes, the build writes more than the basis anticipated, and the exhaustion arrives as a rustc IO error inside a test run, which is the presentation the refusal exists to prevent. THE COMPARISON IS NOT AT CONFIG LOAD, and that is the whole of §2. `config.rs` calls `Prune::validate` on the shared path every `batten` invocation runs — `batten hook` on every mediated tool call included — so a tracked-path enumeration there is a tree read taxed onto the PreToolUse budget, the exact shape `claim-race-check` was moved off that path for. `Floor::validate` checks the basis's SHAPE, which is free; the count runs in `batten target prune` on `Surface::VerifyOnly`, which `perf-assert` budgets no ceiling for. Reuses `git::tracked_paths` (the index, so an untracked file cannot move the count) and one `rules::Selector` per floor rather than a `glob_match` per path. Could-not-look allows, per that function's own stated posture, and the count is taken from the cwd's own `.git` — an ancestor's index made a fixture declaring 1 file be told the tree tracks 189, the third instance of that walk-up class on this branch. Pointer-only: two counts, a tolerance and a date, never a file listing. Four mutations show the new cases able to fail: no comparison reds the drift case and the placement case's own precondition, an ignored tolerance reds the tolerated case, an accepted empty glob reds the absent-basis case, and an ancestor's index reds the no-index case. Refs: CLOUD-1158 BREAKING CHANGE: `prune::Floor` gains a required `basis` field and `prune::prune` takes the lap store as a fourth argument, so both are constructed differently by any caller outside this crate. Declared here rather than worked around: the basis cannot be optional without reading exactly like a satisfied one, which is the defect this change is about.
… own close created Found on this row's first live lap rather than in a fixture. The closing run's reclaim escalated — free space was below the warm floor — which dropped `incremental`, which moved the basis to Cold, which became the floor the lap being closed was then judged against. Measured here: a close at 9790MB free refused against the 14914MB cold floor, and every full lap would have failed at its own closing reading. The escalation is not the error: it frees space, and the next build really is cold afterwards. The error was letting it speak for the lap already finished. A closing run now judges against the basis recorded when the lap OPENED, and the basis its own reclaim created is what admits the NEXT lap — which is where that consequence belongs. The first attempt at this guard was worse and is recorded because it nearly shipped: suppressing the escalation whenever a lap was open. Every run after the first closes one, so that would have disabled the escalation permanently. Refs: CLOUD-861
…not land here The lap-open call site had grown a three-way classification of `target-prune`'s exit code — 2 the verdict, 3 could-not-look, 1 the invocation — folding CLOUD-1153 into this bundle. Measured, that row cannot be written in this body at all: `tests/verify.bats`'s "a volume that cannot be recovered is a STOP, not a lap" drives the mapper with `target-prune` exiting 1 and asserts the output carries "not enough disk", so the classification turns a standing case red and the only fix inside this shape is an edit to a bats suite. `V-SHELL-RULE-EDITED` refuses that with no override route and no `bypass_env`, which is the refusal working rather than one to argue with: CLOUD-1153 is a row whose §1 is written in the wrong shape, and the finding goes on the row instead of through a governed file. So the guard returns to its landed one-way form byte for byte, and the comment above it carries the finding — the lap-open pairing CLOUD-861 needs is stated there, and the closing call site at the tail of `verify:gated` is untouched. Refs: CLOUD-861
…calation `basis` was `Cold` iff THIS invocation dropped a basis-moving regrowable root, so a tree emptied by anything else was invisible to the ratchet. Measured on this branch's own landing lap. A hand deletion of `target/debug` freed the room the floor asked for; the lap that followed built 110 test binaries from nothing and consumed 21226MB; and the journal recorded that cold lap as the worst WARM one on record. Every warm lap after it is then admitted against a full rebuild's demand — 21226MB on a 37939MB allowance — which is the floor nothing can satisfy that CLOUD-861's own §8 names as the failure that gets a gate switched off. The ratchet was learning the wrong number, not merely printing the wrong word. An empty or absent `deps` is the signal, and it is the artifacts themselves rather than a cache: with nothing there the next build writes all of it, whoever removed it. Not the regrowable roots, which cannot answer this — a `cold` row says *dropping this makes the next build full*, a claim about a removal rather than about a state, and `incremental` is simply absent on a tree that has never built incrementally. So the escalation's flag stays as the other half, OR'd rather than replaced: a dropped `incremental` leaves `deps` full and still makes the next build cold. Both cold-basis messages are reworded to say what is true under either cause rather than attributing it to the escalation. Seven fixtures went red on the change, and that is the sensor discriminating rather than the fixtures having been wrong: each created `target/debug/deps` and stopped, declaring a tree whose next build writes everything while asserting a warm verdict. `built()` gives them a resident file with no hash suffix — not a name any pass groups, not a file `survivors` counts — so no retention arithmetic moves. The new case is shown able to fail: with `basis_of` pinned to `Warm` it is the one red of fifty. Refs: CLOUD-861
`basis_of` reads every `deps` under the root, so a populated `target/release/deps` reports warm while the debug build the lap is about to run is cold. Measured on this branch's own landing lap, where `install:local` had just written the release tree. Scoping it would mean knowing which profile the caller is about to build, which the reclaim is not told and must not guess. The bound is written where the function is rather than left for the next reader to rediscover, with the reason it is tolerable: this reading can turn a warm answer cold and never the reverse, so it is strictly stronger than reading the escalation alone. Refs: CLOUD-861
… load `Floor::basis` was a required field, so a floor without one failed to PARSE. That makes the key's own pull request unlandable, and the mechanism is structural rather than incidental: `config-lint` loads `origin/main:batten.toml` with the WORKING TREE's binary — house style §8's out-of-band load, so a branch cannot lower the bar it is judged by — and the base ref does not carry a key this branch is adding. The load fails with `missing field `basis``, and the gate reports could-not-look instead of a verdict. Measured on this branch's own landing lap, with the whole of the rest of `verify` green: 2654 bats cases, the workspace suite, `batten-check` and `commit-lint` all passed, and `config-lint` refused a tree it could not read. So the type takes `Option` and the refusal moves one surface later, to `basis_drift` on `Surface::VerifyOnly` — which is where CLOUD-1158 §2 already puts the live comparison, and which `perf-assert` budgets no ceiling for. Nothing is weakened: a floor declaring no basis is still exit 2, still names the floor and still names the date that points at nothing. Shape validation of a basis that IS declared stays at load, where it is free. `BasisFinding` splits the two answers because they have different remedies — write the row, versus re-measure the floor — and one refusal wearing two shapes would leave a reader to work out which. Shown able to fail (CLOUD-418): with the undeclared arm skipping instead of refusing, `an_undeclared_basis_is_refused_on_the_verify_surface` is the one red of fifty-one. BREAKING CHANGE: `Floor::basis` is now `Option<Measured>` and `basis_drift` returns `Option<BasisFinding>` rather than `Option<BasisDrift>`. Refs: CLOUD-1158
… one judged The basis-of-record rule redefined `Outcome::basis` as the basis the CLOSED lap ran under, which is right for the floor lines and wrong for the escalation line — and the two diverge at exactly the run where the escalation matters. Measured on this branch's own landing lap, and it cost a diagnosis rather than merely reading oddly. A closing reclaim dropped `incremental` and printed "none of those roots is the cargo build's basis, so the next build is still warm" while the journal recorded the next lap as cold. The run after it was then refused against a 14914MB cold floor at 8793MB free, with no line anywhere in the output connecting the refusal to the escalation that caused it. That is the same misattribution this bundle is about, in a message this bundle added. `Outcome` now carries `next_basis` beside `basis`: what the tree and this run's own escalation leave behind, against the basis of record the floor belongs to. Only the escalation line reads it, because it is the only line that is a claim about the next build rather than about the lap being closed. Shown able to fail (CLOUD-418): keyed back on the closed lap's basis, `a_closing_escalation_does_not_make_the_lap_it_closes_a_cold_one` is the one red of fifty-one — the case that already pinned the judging half now pins the reporting half too, and they are the same divergence read from both sides. BREAKING CHANGE: `Outcome` gains a `next_basis` field. Refs: CLOUD-861
…ill short The escalation took every declared root at once, so a run needing 2GB dropped `incremental` along with the rest — and dropping `incremental` is what makes the next cargo build a full one, which raises the floor that build has to clear from the warm number to the cold one. Measured twice on this branch's own landing lap, and it is why no lap after the first could ever open. A reclaim freed 5711MB, most of it from roots that cost only their own next run, and thereby moved the floor the next lap faced from 6242MB to 14914MB on a tree a full lap leaves at ~8.7GB. Every second `land` lap was then refused for a full rebuild nothing had asked for. The escalation created the demand that refused it, and `verify` was green in both laps that died this way. So the cheap rows go first, free space is re-read, and the basis-moving rows are taken only if the floor is still breached. On the measured lap the cheap tier alone cleared 6242MB, so `incremental` would never have been touched. NOT A PHASE RULE, and the first form of this fix was one — `journal.open.is_some()`, which the suite refused: every run after the first closes a lap and opens another, so that guard would have disabled the basis escalation permanently. A run genuinely cannot tell `verify`'s head from `verify:gated`'s tail, because both are the same invocation. It can always tell whether it still needs the space, and that is the question this asks instead. Shown able to fail (CLOUD-418): with the first tier taking the basis-moving roots instead, four cases red — the closing escalation, the two per-row basis cases and the prefix row. Refs: CLOUD-861
…ps' evidence `incremental` carried `cold = true` on the reading that dropping rustc's incremental state guarantees a full rebuild. Measured over four live laps on a Claude Code web container, it does not: cargo still reuses every `deps` artifact whose fingerprint matches, so what is lost is rustc's within-crate state rather than the artifact set. The laps immediately following an escalation that took `incremental` consumed 228MB and 2852MB, against the 14914MB full-rebuild floor the flag was asserting. What the flag cost while it was wrong is the whole reason this is a fix rather than a tidy-up: the closing reclaim took `incremental` to clear its own floor, the next lap was then admitted under the cold floor with ~8.1GB free, and `land` was refused on every second lap — for a full rebuild nothing had asked for, having freed the space that created the demand. Four consecutive `verify` runs went green through the closing reading and lost the landing to exactly that. `build` goes the same way and for the same reason: losing build-script output reruns those scripts and rebuilds what depended on them, which is a fraction of the artifact set rather than all of it. The mechanism is untouched and the flag stays in the schema — a consumer whose reclaim really does destroy the artifact set needs it, and `a_declared_root_that_is_not_the_cargo_basis_is_dropped_without_moving_the_floor` still pins both directions. What changes is this consumer's declaration, which is where a fact about this tree belongs. The real cold signal is read directly now rather than inferred from a removal: `basis_of` reports `Cold` when `deps` is empty or absent, which is the state a full rebuild actually follows from, and it is true whoever emptied it. Refs: CLOUD-861
29901f9 to
0b4cc7d
Compare
|
❌ The last analysis has failed. |
|
/fast-forward |
Closes CLOUD-1157.
Closes CLOUD-861.
Closes CLOUD-1158.
Three rows, in this order because each makes the next satisfiable: the reclaim has to reach the classes that actually grow before a floor can ratchet against them, and the ratchet has to exist before its basis is worth watching.
The branch is keyless on purpose —
closing-key-checkpasses on the first key it finds and branch-name precedence beats the body, so a branch named for one issue would move that issue and strand the other two. All three keys close from this body.Deliverable 0 — the built-tree split, measured first
Every prior measurement on these rows was taken on a tree holding zero of the 110 integration-test binaries.
cargo nextest run --workspace, thentarget/debug/deps— 15458 MB, 2492 files:.rlib.rmeta.so.dThis reverses CLOUD-1157's headline, and the correction is recorded on the row: the unreachable share is 13.2%, not 80.6%. The 80.6% figure was an artifact of a tree the suite had never been run on.
CLOUD-1157 — the group key is
(stem, kind)stem_ofsplit on the last-and required the tail to be all hex, solibbatten-<hash>.rlibproduced a "hash" of<hash>.rlib, failed on the., and became its own stem. Every extensioned artifact was a group of one, and nothing in a group of one is ever pastkeep.Retention is now per kind, and the kind cannot fold into the stem:
libbattencarries 2.rliband 6.rmeta, and one group of eight underkeep = 2can retain two.rmetaand delete the live.rlib— thekeep = 0failurePrune::validatealready refuses, arriving through the grouping. The executable bit now gates only the extension-less class.The escalation's roots move out of the crate into
[prune.regrowable], each declaring whether dropping it makes the next cargo build full.CLOUD-861 — the floor is an invariant, not a precondition
The same verb now runs at both boundaries.
verify:gatedcalls it again as its last content step, closing the lap the head call opened:consumed = start − end + reclaimed_in_between, the reclaim's own megabytes included.The basis ratchets. A journal under
$GIT_DIR— one file, rewritten by rename — records what a lap actually consumed and raises the floor to it, per basis, from the lap after the one that observed it. A lap is judged against the floor it was admitted under, so a machine's first lap is not refused for being the thing that measured it.It has now run for real, repeatedly, and that is where four of the defects below came from. Four consecutive
verifylaps went green through the closing reading on this container, measuring 20602, 22163, 22451 and 22196 MB consumed and ratcheting the observed warm floor to each in turn. None of those numbers existed before this PR.CLOUD-1153 — not folded in, and this is the finding
The first draft carried it:
verifyreports any non-zero fromtarget-pruneas "not enough disk", so a config refusal (1) or a could-not-look (3) is reported as a full volume. Three codes, three messages.It cannot be written in that body.
tests/verify.bats's "a volume that cannot be recovered is a STOP, not a lap" drives the mapper withtarget-pruneexiting 1 and asserts the output carries "not enough disk" — so the classification turns a standing case red, and the only fix inside that shape is an edit to a bats suite.V-SHELL-RULE-EDITEDrefuses that with no override route and nobypass_env.That is the gate working: CLOUD-1153 is a row whose §1 is written in the wrong shape. The guard is back to its landed one-way form byte for byte, the comment above it carries the finding, and the row stays on the board.
And the row was then observed live, twice, which is worth more than folding it in would have been. A rebase pulled a new
authorityconfig variant onto the branch; the installed binary predated it, refusedbatten.tomlas a parse error, andverifyreported "not enough disk" with 24 GB free — the wrong remedy, exactly as the row describes.CLOUD-1158 — a floor records the basis, not only the date
measuredis a pointer to a basis nobody wrote down.Floornow carries a glob, the count it held when the floor was measured, and a tolerance.The comparison is not at config load, and that is the row's §2.
config.rscallsPrune::validateon the path everybatten hookinvocation pays. Shape validation stays at load; the count runs inbatten target pruneonSurface::VerifyOnly.And neither is the absent-basis refusal, which is a correction to the row.
basisbegan as a required field, and a required new config key makes its own PR unlandable:config-lintloadsorigin/main:batten.tomlwith the working tree's binary — house style §8's out-of-band load — and the base ref has no such key, so the load fails withmissing fieldand the gate reports could-not-look rather than a verdict. Measured with the whole of the rest ofverifygreen. The type takesOptionand the refusal moves to the surface §2 already put the comparison on; it is still exit 2, still names the floor and the date that points at nothing.Six defects found by running it, not by reading it
Three walk-up defects.
git::git_dirandgit::openboth walk up, so a fixture undertarget/tmp/reached the enclosing repository's state — one journal shared across every case (a fabricated 99999 MB reading wrote a ratchet the next case judged itself against), and a basis count of 189 for a fixture declaring 1. The authority is./.git, for the reason./batten.tomlis the config's.A fourth was a surviving mutation:
OpenLapcarried the floor it was admitted under, a second authority on a number the ratchet already answers. Blanking it changed no verdict, which is the only way a redundant conjunct announces itself.The last three are the live ones, each found by a lap and each fixed with a case shown able to fail:
target/debugto satisfy the floor made the next lap build 110 test binaries from nothing, and the journal recorded that 21226 MB cold lap as the worst warm one on record — the ratchet learning the wrong number, not merely printing the wrong word. An empty or absentdepsis now the signal, OR'd with the escalation's flag. Seven fixtures went red on the change: each createdtarget/debug/depsand stopped, declaring a cold tree while asserting a warm verdict.Outcome::basisas the basis the closed lap ran under, which is right for the floor lines and wrong for a claim about the next build. A close that droppedincrementalprinted "none of those roots is the cargo build's basis, so the next build is still warm" while the journal recorded the next lap as cold; the run after it was refused against 14914 MB with nothing in the output connecting the two.Outcomenow carriesnext_basisbesidebasis.incrementaltoo — and that is what makes the next cargo build full, raising the floor it must clear from 6242 MB to 14914 MB on a tree a full lap leaves at ~8.4 GB. The escalation created the demand that refused it. Now the rows costing only their own next run go first, free space is re-read, and the basis-moving rows are taken only if the floor is still breached. The first form of this fix keyed onjournal.open.is_some()and the suite refused it: every run after the first closes a lap and opens another, so it would have disabled the basis escalation permanently.Verification
crates/batten/tests/target_prune.rs, over the compiled binary — 51 cases, up from 35. Every new case is shown able to fail (CLOUD-418) by mutating the conjunct it pins:basis_ofpinned to warmTwo mutations survived their first form and are recorded as such: one named a conjunct another already excluded, and one named a redundant field — the second is the
OpenLapdefect above, and the fix is the removal.