Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .claude/skills/disk-reduction/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,10 @@ python3 .claude/skills/disk-reduction/scripts/clean-shared-buckets.py
python3 .claude/skills/disk-reduction/scripts/clean-shared-buckets.py --apply
```

**Why buckets orphan so fast.** A bucket is `~/.cache/nub/shared-target-<key>`, where `<key>` hashes the content of the depended-on crates (`vendor/aube` and `crates`, excluding the leaves `nub-cli`/`nub-native`/`nub-phantom`). Two facts compound:
**Why buckets orphan so fast.** A bucket is `~/.cache/nub/shared-target-<key>`, where `<key>` hashes the content of the depended-on crates plus `runtime/` (`vendor/aube`, `crates` excluding the leaves `nub-cli`/`nub-native`/`nub-phantom`, and `runtime`). Two facts compound:

- **Only a NON-diverged worktree resolves to a bucket at all.** The moment a branch touches a depended-on crate, `rust-build.sh` sends it to a private `$root/target` instead — so most feature branches reference no bucket, and a bucket's referrers are only ever the worktrees sitting at some exact content state.
- **`main` advancing moves the key.** Every merge that touches `vendor/aube` or a non-leaf crate strands the previous bucket.
- **`main` advancing moves the key.** Every merge that touches `vendor/aube`, a non-leaf crate, or `runtime/` strands the previous bucket.

`rust-build.sh` has its own GC, but it only retires a bucket after **14 days** of untouched mtime, so a fortnight of churn accumulates first. On 2026-08-14, 13 of 15 buckets proved orphaned — 12 in the first pass, holding ~135 G by `du` and returning 73 GiB of `df`, plus a 15 G bucket that orphaned the moment its last worktree was removed. Re-run the audit after removing any worktree.

Expand Down Expand Up @@ -77,7 +77,7 @@ Removing a clean worktree is lossless — the branch and its commits stay in the
- **Expect a CONCURRENT sweeper, and never read the audit as a stable plan.** Dozens of agent sessions share this host, so another one is often mid-sweep: measured 2026-08-17, free space climbed 14 → 87 GiB during two read-only audits, five targets listed as eligible were already gone by the apply pass, and one worktree flipped dirty→clean between the two runs. Consequences: re-audit rather than trusting a list you printed minutes ago, and treat a vanished path as success. `clean-worktree-targets.py` used a bare `shutil.rmtree`, which raises `FileNotFoundError` on exactly that race and aborted the whole run with ~70 GiB of candidates still pending; it now deletes via `rm -rf` and reports a `failed=` count so a real permission error is still visible.
- **`du -sh -d1` silently prints nothing.** BSD `du` rejects `-s` with `-d` and writes the usage error to stderr, so a `2>/dev/null` scan for big directories returns an empty list that reads as "nothing here". Use `du -h -d1`.
- **This shell is zsh; `rust-build.sh` is `#!/bin/sh`.** zsh does not word-split an unquoted `$var`, so lifting the `$leaves` pathspec idiom into an interactive shell collapses three `:(exclude)` pathspecs into one bogus one and yields a **different hash**. Every bucket then looks orphaned, including the live one. Write the pathspecs as separate literal arguments, or drive git from Python where argv is explicit.
- **Never map buckets by running `--print-target` in every worktree.** It is not a read: it `mkdir`s and `touch`es the target (the liveness signal), CoW-seeds a private target for an isolated worktree — recreating the multi-GB dirs you just deleted — and fires the 14-day GC. Use it once, in the main tree, as the control.
- **`--print-target` is now a pure read on both branches** — it exits before the seeding, the `mkdir`/`touch` liveness signal, and the 14-day GC, so mapping buckets by running it per worktree no longer recreates the dirs you just deleted. (It was a write until 2026-08-27; on an older checkout of `rust-build.sh`, use it once, in the main tree, as the control.)
- **You cannot fake a `cargo` process by copying a system binary.** `cp /bin/sleep /tmp/cargo` breaks the code signature, and arm64 macOS kills the copy on exec — so a "is a build running?" negative control silently tests nothing and reports the guard as broken. Test the parser against synthetic `ps` output instead.
- **`grep -E 'lld'` matches `installd`.** macOS runs `installd`, `system_installd` and `appinstalld` continuously, so a loose linker grep reports builds on a completely idle machine. Match the executable basename exactly.
- **Read `ps -Ao comm=`, not `command=`, when hunting build processes.** An argv listing includes your own command line, so any check that mentions `cargo` matches itself and blocks forever.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@
":(exclude)crates/nub-native",
":(exclude)crates/nub-phantom",
]
CRATE_PATHS = ["vendor/aube", "crates"]
# `runtime` rides along for the same reason it joined rust-build.sh: a dev
# binary resolves runtime/*.cjs from the tree that compiled nub-core, so the
# hash covers it and a runtime-divergent worktree isolates.
CRATE_PATHS = ["vendor/aube", "crates", "runtime"]
BUCKET_RE = re.compile(r"^shared-target-([0-9a-f]+)$")


Expand Down
8 changes: 4 additions & 4 deletions .claude/skills/rust-build/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ description: >-
incremental builds, the cross-worktree artifact-contamination hazard that
sharing creates (the phantom `E0063: missing field` on correct source), and the
wrapper (`scripts/rust-build.sh`) that shares by default and auto-isolates the
moment a worktree diverges a depended-on crate. Auto-triggers on a spurious
moment a worktree diverges a depended-on crate or `runtime/`. Auto-triggers on a spurious
cargo compile error that names a field/symbol absent from your checkout, on
"worktree build contamination", and on setting CARGO_TARGET_DIR for a worktree.
metadata:
Expand Down Expand Up @@ -57,12 +57,12 @@ error[E0063]: missing field `lockfile_legacy_basenames` in initializer of `aube_

Sharers are grouped by the **content** of their depended-on crates, hashed into the bucket name (`shared-target-<hash>`):

- **Depended-on crate sources unmodified → share** that content's bucket. Everyone in it agrees by construction. The common case: feature work in `nub-cli`, integration tests, docs, non-Rust files.
- **Diverged a depended-on crate → isolate** to a private per-worktree `target/` (removed with the worktree), CoW-seeded from the matching bucket so you rebuild only what differs.
- **Depended-on crate sources and `runtime/` unmodified → share** that content's bucket. Everyone in it agrees by construction. The common case: feature work in `nub-cli`, integration tests, docs.
- **Diverged a depended-on crate or `runtime/` → isolate** to a private per-worktree `target/` (removed with the worktree), CoW-seeded from the matching bucket so you rebuild only what differs. `runtime/` is hashed for a different reason than the crates: a dev binary resolves `runtime/*.cjs` from the tree that compiled `nub-core` (baked `CARGO_MANIFEST_DIR`), so a shared-bucket binary loads whichever sharer compiled last — a worktree with edited runtime files would silently test a sibling's copy.

**Content, not merge-base:** a merge-base proves only that *this* worktree made no local changes against *its own* base, so two worktrees whose bases straddle a `nub-core`/`aube` commit both pass while disagreeing on content. The key hashes the git **index**, which only matters on the shared branch (no local changes by definition), so it moves on rebase, never mid-edit.

Depended-on crates = every workspace/vendored crate **except leaf artifacts nothing links**: `crates/nub-cli` (bin), `crates/nub-native` (cdylib, own workspace), `crates/nub-phantom` (bin, own workspace). So: `crates/nub-core`, `crates/nub-cache-key`, `crates/nub-phantom-core`, `crates/nub-phantom-scan`, and all of `vendor/aube`. `nub-phantom-core`/`nub-phantom-scan` are **not** leaves — `nub-cli` depends on both — and the pathspec `:(exclude)crates/nub-phantom` matches only that directory, not those siblings.
The hashed set = every workspace/vendored crate **except leaf artifacts nothing links** — `crates/nub-cli` (bin), `crates/nub-native` (cdylib, own workspace), `crates/nub-phantom` (bin, own workspace) — **plus `runtime/`**. So: `crates/nub-core`, `crates/nub-cache-key`, `crates/nub-phantom-core`, `crates/nub-phantom-scan`, all of `vendor/aube`, and `runtime/`. `nub-phantom-core`/`nub-phantom-scan` are **not** leaves — `nub-cli` depends on both — and the pathspec `:(exclude)crates/nub-phantom` matches only that directory, not those siblings.

## Letting the wrapper choose IS the caching strategy

Expand Down
88 changes: 58 additions & 30 deletions scripts/rust-build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@ leaves=":(exclude)crates/nub-cli :(exclude)crates/nub-native :(exclude)crates/nu
# Both checks are deliberately broad (any path under a depended-on crate, not just
# *.rs): over-isolating on an irrelevant file costs one cold build; under-isolating
# risks the clobber. Depended-on = every workspace/vendored crate except nub-cli.
# `runtime/` is in the set for a different reason than the crates: the binary
# resolves `runtime/*.cjs` at run time from the tree that compiled nub-core (its
# baked CARGO_MANIFEST_DIR), so a shared-bucket binary loads whichever SHARER
# compiled last — a worktree with edited runtime files would test a sibling's
# copy, silently. Isolating on runtime divergence keeps every shared-bucket
# binary pointed at base-identical runtime content. (Observed: three worktrees'
# red/green verdicts flipped as siblings rebuilt the common bucket.)
# `-C "$root"` on the git queries so the pathspecs resolve from the repo root
# regardless of the CWD the wrapper was invoked from (a subdir would otherwise
# misread them). The final `exec cargo` still runs in the original CWD.
Expand All @@ -105,17 +112,17 @@ diverged=""
if [ -n "$base" ]; then
# shellcheck disable=SC2086 # $leaves must word-split into separate pathspecs
diverged=$(git -C "$root" diff --name-only "$base" -- \
vendor/aube crates $leaves 2>/dev/null || true)
vendor/aube crates runtime $leaves 2>/dev/null || true)
fi
# shellcheck disable=SC2086
untracked=$(git -C "$root" ls-files --others --exclude-standard -- \
vendor/aube crates $leaves 2>/dev/null || true)
vendor/aube crates runtime $leaves 2>/dev/null || true)

# The content key names the bucket AND, when isolating, names the seed to clone
# from — so it is computed unconditionally. `ls-files -s` emits the staged blob
# OIDs, so this is a pure content hash of the depended-on crates. ~0.2s.
# shellcheck disable=SC2086
key=$(git -C "$root" ls-files -s -- vendor/aube crates $leaves 2>/dev/null \
key=$(git -C "$root" ls-files -s -- vendor/aube crates runtime $leaves 2>/dev/null \
Comment thread
pullfrog[bot] marked this conversation as resolved.
Resolved
| shasum 2>/dev/null | cut -c1-12 || true)
if [ "$keyed" = 1 ]; then
bucket="$shared${key:+-$key}"
Expand Down Expand Up @@ -159,19 +166,60 @@ seed_from() {
}

# The newest COMPLETE bucket on disk, or nothing. Buckets are only ever created
# by non-diverged worktrees, so every bucket name is a base-content key. The
# `.seeding` filter is load-bearing: a claim dir shares the `$shared-` prefix and
# `-t` sorts newest-first, so an actively-filling claim would be the MOST likely
# pick — seeding from a half-copied tree pairs a fresh-looking fingerprint with a
# truncated rlib, the exact failure this design exists to prevent.
# by non-diverged worktrees, so every bucket name is a base-content key. Two
# load-bearing filters: the `.seeding` skip (a claim dir shares the `$shared-`
# prefix and `-t` sorts newest-first, so an actively-filling claim would be the
# MOST likely pick — seeding from a half-copied tree pairs a fresh-looking
# fingerprint with a truncated rlib), and the emptiness skip (a just-created
# bucket is published by mkdir+touch below BEFORE cargo writes anything, so
# during its first cold build it is the newest by mtime while holding nothing
# worth cloning).
newest_bucket() {
# shellcheck disable=SC2012 # names are ours and contain no newlines
ls -dt "$shared"-* 2>/dev/null | grep -v '\.seeding$' | head -1
for _b in $(ls -dt "$shared"-* 2>/dev/null | grep -v '\.seeding$'); do
if [ -n "$(ls -A "$_b" 2>/dev/null)" ]; then

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ls -A tests for any entry, and cargo makes a pre-created target dir non-empty almost immediately: measured on a clean dir here, .rustc_info.json lands 57 ms after cargo build starts. So this skip closes a ~50 ms window, not "during its first cold build" as L173-176 and L203-205 both claim — for the remaining minutes of that cold build the near-empty bucket is still newest-by-mtime, still non-empty, and still the top pick.

Technical details
# The emptiness filter is not a warmth check

## Affected sites
- `scripts/rust-build.sh:180` — `[ -n "$(ls -A "$_b" 2>/dev/null)" ]` is satisfied by `.rustc_info.json` alone.
- `scripts/rust-build.sh:173-176` — "during its first cold build it is the newest by mtime while holding nothing worth cloning" describes a window that ends ~50 ms in.
- `scripts/rust-build.sh:203-205` — "an isolated worktree seeding during that window falls back to the newest non-empty bucket" states the cost accounting is now covered; it is not.
- `scripts/rust-build.sh:231-235` — an isolated worktree therefore still CoW-clones an artifact-less bucket and prints `(seeded from shared-target-...)` while paying a full cold build.

## Measurement
```sh
cargo new --lib /tmp/cp2 && cd /tmp/cp2 && rm -rf target && mkdir target
(cargo build --offline &) ; poll ls -A target every 50ms
# -> non-empty after 57ms, first entry: .rustc_info.json
```

## Required outcome
- Either the check distinguishes a bucket with compiled artifacts from one cargo has merely touched, or the two comments stop claiming it covers the cold build.

## Suggested approach (optional)
A warmth test wants the artifact directories rather than the root — e.g. requiring a non-empty `"$_b"/*/deps` or `"$_b"/*/.fingerprint`. If you would rather not add that, narrowing both comments to what `ls -A` actually buys is a legitimate resolution; the cost is a cold build, not a wrong build.

## Open questions for the human
- The prior round asked whether bounding this is worth a warmth check at all. If the answer is still "the window is short enough to accept", then the `ls -A` condition is not earning its complexity and the comments are the only thing that needs to change.

printf '%s\n' "$_b"
return 0
fi
done
return 1
}

if [ -n "$diverged" ] || [ -n "$untracked" ]; then
target="$root/target" # private, worktree-local; removed with the worktree
why="isolated — this worktree diverges a depended-on crate from origin/main"
isolated=1
else
target="$bucket" # shared fast path, content-keyed
why="shared bucket ${key:-none} — depended-on crate content"
isolated=""
# A SHARED bucket is NEVER seeded from another bucket. Any other bucket's
# hashed content differs by construction (a matching key would BE this bucket),
# CoW clones preserve mtimes, and cargo's path-dep freshness check
# (LocalFingerprint::CheckDepInfo) compares mtimes rather than content — so a
# sharer whose checkout is older than the seeded dep-info would accept a
# foreign rlib as fresh: the phantom E0063 this keying exists to prevent,
# persisting inside a bucket other worktrees join. A key move therefore costs
# the first SHARER one cold build, later sharers join warm, and an isolated
# worktree seeding during that window falls back to the newest non-empty
# bucket. (The isolated branch may seed foreign content because its target is
# PRIVATE and its just-edited divergent files carry fresh mtimes.)
fi

# `--print-target` resolves the target dir the SAME way a real build would and
# prints it, so callers that must locate the artifact afterwards (make
# install-dev symlinking nub-dev) follow the shared/isolated decision instead of
# hardcoding a path that is only right in one of the two cases. It exits HERE,
# before the seeding and the mkdir/GC below, so it is a pure read on BOTH
# branches — tooling that compares against it (the disk-reduction bucket sweep's
# positive control) must not materialize a bucket, a private target, or a GC
# pass as a side effect of asking.
if [ "${1:-}" = "--print-target" ]; then
printf '%s\n' "$target"
exit 0
fi

if [ -n "$isolated" ]; then
# ANY bucket will do, and the fallback is what makes seeding fire at all for
# the ordinary case: $bucket is keyed by this worktree's INDEX, but buckets are
# only created by non-diverged worktrees, so a committed or staged divergence
Expand All @@ -180,22 +228,11 @@ if [ -n "$diverged" ] || [ -n "$untracked" ]; then
# clobber a sibling, cargo just rebuilds it, and the crates.io rlibs we're
# after are identical across buckets.
_seed="$bucket"
[ -d "$_seed" ] || _seed=$(newest_bucket)
[ -d "$_seed" ] && [ -n "$(ls -A "$_seed" 2>/dev/null)" ] || _seed=$(newest_bucket)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

newest_bucket now return 1s, and this assignment is the last command of the A && B || C list — so set -e (L65) is not suppressed and the script exits 1 here, before mkdir, before the rust-build: banner, and before exec cargo. Nothing is printed. Baseline 672a6c1 could not hit this: newest_bucket ended in head, which always exits 0.

Under make verify this is not an edge case. NUB_SHARED_TARGET="$(CURDIR)/target" makes newest_bucket glob $(CURDIR)/target-*, a name that never exists, so it always returns 1 — any diverged worktree whose target/ is absent or empty fails the pre-push gate with an empty log.

Technical details
# `newest_bucket`'s failure exit must not propagate through `set -e`

## Affected sites
- `scripts/rust-build.sh:231` — `_seed=$(newest_bucket)` terminates the `&& ... ||` list, so its status is the list's status and `set -e` fires.
- `scripts/rust-build.sh:177-186` — `newest_bucket()` gained `return 1`; the pre-`fa2d907` pipeline form could not fail.
- `scripts/rust-build.sh:232` — `[ -n "$_seed" ] && [ -d "$_seed" ] || _seed="$shared"` already normalizes an empty `$_seed`, so the callers downstream need no change.
- `Makefile:117-136` (`make verify`), `Makefile:68` (`make install-dev`) — the two gates that go down with it.

## Reproduction (run in this repo, both shapes confirmed on sh/dash/bash)
```sh
touch runtime/__probe.txt                 # force divergence
rm -rf /tmp/nb-a
NUB_SHARED_TARGET=/tmp/nb-a/target sh scripts/rust-build.sh --version   # rc=1, no output
mkdir -p /tmp/nb-c/target                 # second shape: present but EMPTY
NUB_SHARED_TARGET=/tmp/nb-c/target sh scripts/rust-build.sh --version   # rc=1, no output
```
Restoring only the pre-`fa2d907` `newest_bucket` body and the old `_seed` line into a copy of the script makes both cases print the banner and run cargo (rc=0), which isolates the cause to this change rather than to the surrounding restructure.

## Required outcome
- A diverged worktree with no usable seed source falls through to `$shared` and builds cold, instead of aborting.
- The `present-but-empty` seed dir behaves the same way.
Suggested change
[ -d "$_seed" ] && [ -n "$(ls -A "$_seed" 2>/dev/null)" ] || _seed=$(newest_bucket)
[ -d "$_seed" ] && [ -n "$(ls -A "$_seed" 2>/dev/null)" ] || _seed=$(newest_bucket || true)

[ -n "$_seed" ] && [ -d "$_seed" ] || _seed="$shared"
if seed_from "$_seed" "$target"; then
why="$why (seeded from $(basename "$_seed"))"
fi
else
target="$bucket" # shared fast path, content-keyed
why="shared bucket ${key:-none} — depended-on crate content"
# MIGRATION: content-keying renames the bucket, so on the first run after this
# change lands the warm legacy dir would be orphaned and every worktree would
# eat one cold build. Seed the new bucket from it (same CoW clone, ~0 cost).
# Self-retiring: the legacy dir is covered by the GC below, so once every live
# worktree has moved to a keyed bucket it ages out on its own.
if [ "$target" != "$shared" ] && seed_from "$shared" "$target"; then
why="$why (migrated from the legacy shared dir)"
fi
fi

mkdir -p "$target"
Expand Down Expand Up @@ -243,15 +280,6 @@ if [ "${NUB_BUILD_FG:-}" != "1" ] && [ "$(uname)" = "Darwin" ] \
qos="taskpolicy -c utility"
fi

# `--print-target` resolves the target dir the SAME way a real build would and
# prints it, so callers that must locate the artifact afterwards (make
# install-dev symlinking nub-dev) follow the shared/isolated decision instead of
# hardcoding a path that is only right in one of the two cases.
if [ "${1:-}" = "--print-target" ]; then
printf '%s\n' "$target"
exit 0
fi

# The machine-global rustc wrapper (make qos-global) now carries the GLOBAL
# CONCURRENCY SEMAPHORE, not just a QoS clamp, so this invocation must let it
# run. It previously blanked RUSTC_WRAPPER — sound when the wrapper only
Expand Down
Loading