Buck2 rebuilds everything from scratch in each buck-out, and OSS buck2 has no
persistent action cache across daemon restarts (noted in ci.yml). Every CI run
and every isolated worktree rebuilds unchanged crates. We use BuildBuddy (free
tier) for a shared remote action cache and opt-in remote execution.
Status (RUE-316/RUE-320). The remote platform,
$ORIGINtoolchain-tree fix, and execution-platform-scoped linker are in place. Native/default builds keepclang++; the BuildBuddy execution configuration selects the ubiquitousccdriver through an exec-dep while Rust's bundled lld performs the real Linux link via-fuse-ld=lld. Full remote execution is supported as an explicit--prefer-remotemode, not the default local-development policy.
- Remote action cache: the repository
./buck2wrapper supplies--prefer-local, so cache misses execute locally while hits are shared across machines + daemon restarts. The cache can only affect speed, never correctness. --prefer-remote: full remote execution — compiles + links on BuildBuddy's container (~80 free-tier cores). Required merge-group CI runs a cache-disabled canary so worker-toolchain regressions cannot hide as hits.
Tracking: RUE-316 (groundwork), RUE-320 (platform-scoped linker).
scripts/rue cache install # prompts without echo for the BuildBuddy key
scripts/rue cache apply --all # primary checkout + current Git/Codex worktreesinstall writes one user-owned configuration at
${XDG_CONFIG_HOME:-~/.config}/rue/buildbuddy.buckconfig, with mode 0600.
apply places only an ignored .buckconfig.local symlink in each checkout, so
the credential is neither copied between worktrees nor stored in Git. It refuses
to replace an existing local config and refuses a central config readable by
another account. Commands never print the key. A secure config installed before
the Rust upload gate existed is upgraded atomically in place, preserving its
credential; an explicit default_allow_cache_upload = false remains an error
instead of being silently overridden.
Once installed, direct ./buck2, scripts/rue ..., and test.sh runs
automatically link a new worktree on first use. If the central config is absent,
Rue simply uses its ordinary local Buck configuration. If it is malformed or
insecure, Rue warns and continues without provisioning it. Existing local paths,
including broken or unrelated symlinks, are left untouched. This keeps cache
setup opt-in and prevents a credential problem from making local builds unusable.
Every worktree has its own buck-out; a large primary checkout and many smaller
worktrees therefore share one host budget even though Buck daemons cannot share
their local materializer state. Rue configures Buck's deferred materializer to
persist that state, defer write actions, and continuously remove outputs that
have not been used for one week. Cleanup starts twelve hours after daemon startup
and repeats daily. Buck coordinates those deletions with active builds; Rue does
not infer that a worktree is disposable from its age or directory name.
The background cleaner also watches the host filesystem. At 20% free space or lower it adaptively promotes the oldest non-active outputs until Buck projects that 20% will be free again. Outputs accessed in the last twelve hours remain protected even under pressure. This threshold is host-wide—the worktrees have separate materializer databases, but they consume the same filesystem budget.
Use the host-wide storage command from any current Rue checkout:
scripts/rue storage status # sizes, source state, and cache state
scripts/rue storage plan [AGE] # Buck dry-run in every registered worktree
scripts/rue storage clean [AGE] # stale + adaptive cleanup; default 1w
scripts/rue storage guard # run the build preflight explicitly
scripts/rue storage reset /exact/root # full Buck reset of an explicit targetEvery ./buck2 build, test, run, or install invocation runs the same
portable free-space preflight. Above 10% free it is only a df read. At 10% or
lower it synchronously requests adaptive cleanup from every registered Rue
worktree before allowing more disk-heavy work. If inventory or cleanup fails,
the command stops with recovery guidance instead of proceeding toward ENOSPC.
If tracked cleanup cannot escape the emergency threshold, the guard may fully
reset the largest legacy buck-out trees whose checked-out revisions predate
deferred materializer state. It uses the coordinator checkout's pinned Buck and
refuses to reset a root with an active command. Dirty source state is unrelated
and remains untouched. Between 10% and 20%, Buck's ordinary background policy
restores headroom without putting every build behind a host-wide scan.
The inventory comes from git worktree list and fails closed: if the registered
set cannot be read, no cleanup runs. clean removes stale or untracked Buck
outputs and may promote older tracked, non-active outputs when the host is below
the 20% target. reset is the migration escape hatch for an older worktree whose
artifacts predate persisted materializer state; it validates every named path as
a registered Rue worktree before it resets any of them. Neither command removes
source files or worktrees. scripts/rue gc remains a compatibility alias for
the host-wide one-week stale cleanup, and the older scripts/worktree-gc entry
point now performs the same safe cleanup without deleting directories.
The default setup is for the shared action cache. Normal commands stay on
--prefer-local; add --prefer-remote explicitly when remote execution is the
intended experiment. The checked-in .buckconfig.local.example remains a
reference for the generated configuration, not the recommended per-worktree
setup.
scripts/rue test with no filter takes a user-scoped lock under /tmp, shared by
independent Rue project roots. Only one full suite runs on the host at once;
scripts/rue quick, filtered tests, and direct targeted Buck commands remain
available concurrently. A waiter reports the current holder once and then at
most once per minute. Owner PID metadata and atomic stale-lock recovery handle
normal exit, interruption, and a process that died while acquiring the lock.
Within the full suite, Buck first discovers and runs all non-heavy tests with
//.... The spec, UI, CLI, generated-oracle, and program-reproducibility targets
carry the rue_heavy_suite label. test.sh queries that label from Buck's live
target graph and runs every result one at a time, preserving independent cache
entries without a hand-maintained inventory that can omit a new suite.
Getting from "no cache" to "full RE" hit several gaps; all are now handled, but they're recorded here so a future config change doesn't silently regress:
- Connection knobs (
.buckconfig.local): the addresses need agrpc://scheme (buck2 rejectsgrpcs://;tls = trueupgrades the transport), and[buck2] digest_algorithms = SHA256(BuildBuddy's CAS digest). Without these the RE client silently never connects (remote: 0, no error). remote_enabled = Trueplus--prefer-local: OSS buck2 only opens the RE connection when remote is enabled — even for cache-only use. A pureremote_enabled = Falsecache config connects to nothing. Buck's limited hybrid mode otherwise prefers remote execution, so the repository./buck2wrapper adds--prefer-localto ordinary build/test/run/install commands. Remote cache lookup still happens before a local miss executes. An explicit execution-mode flag overrides the wrapper.- Two cache-upload gates: the execution platform's
allow_cache_uploads = Truepermits local-result uploads, but ordinary Rust actions also defer to the OSS-default-off[buck2] default_allow_cache_upload = truesetting. Buck 2026-07-01 still hard-disabled uploads for most Rust actions; Buck 2026-07-15 contains the upstream fix that makes them honor this setting. Both the pin and the config knob are required — without them the client connects and downloads CAS inputs, but locally compiled Rust results never populate the action cache. - rustc under RE (
toolchains/rust/defs.bzl): rustc findslibrustc_driver.sovia its native$ORIGIN/../libRPATH, which only resolves if the whole rustc component tree is materialized on the remote worker. Thecompiler/rustdocRunInfo carry that component plus the separate standard-library component as hidden inputs, so RE uploads the compiler co-located and preserves the merged sysroot's relative links. This is the relocatable, canonical fix — not an absolute-pathLD_LIBRARY_PATHhack. Clippy and rustfmt are separate official component archives; Rue does not materialize the monolithic distribution's unused Cargo, rust-analyzer, LLVM tools, or documentation payloads. On macOS ARM64 this reduces the unpacked host toolchain from about 1.67 GiB to 492 MiB (71 percent), and the archives are execution dependencies so debug and release target configurations share that payload. - Container (
platforms/remote_cache.bzl): pinned torbe-ubuntu22-04(Python 3.10 — the prelude's rustc wrapper needs ≥3.9; the default image ships 3.6), by immutable digest rather than by its moving tag. See "Updating the remote worker image" below. This number is the worker image's, for that wrapper, and is not the repository's Python floor — that is 3.11, in AGENTS.md under "Repository tooling baseline". The two are independent: the worker runs Buck actions, and theremote execution (linux-x64)job builds rather than tests, so repositoryscripts/*.pydo not execute there. The worker image is below the repository floor, so an explicit--prefer-remotetest run of the two targets that needtomllibwould meet their version guard on the worker. - Linker (RUE-320): the remote worker needs
ccinstead of the absentclang++. Aremote-executionconstraint is inserted only into the explicit full-remote execution configuration. The cache-only configuration omits it because its misses execute natively under--prefer-local. The C++ tools provider is an exec-dep, so its linker select sees that constraint and choosescc; native/default and cache-only execution configurations retain the prelude'sclang++. The pinned prelude adds-fuse-ld=lldfor Linux, keeping the actual linker hermetic. - Rust action memory (RUE-320): a cache-disabled cold graph exceeded BuildBuddy's default per-action memory estimate and the executor OOM-killed rustc. The remote platform requests 4 GB per action; this is an execution scheduling hint and does not affect native or cache-only builds.
Change 4 ($ORIGIN toolchain-tree) is global and local-safe. Changes 1, 2, 3,
5, 6, and 7 live in the opt-in .buckconfig.local / remote_cache execution
path.
CI reads the key from the BUILDBUDDY_API_KEY repo secret (never from a file).
The cache is provisioned (RUE-1006/RUE-1019) in the CI workflow's clippy,
release, ordinary platform-test, and macOS corpus jobs, and in the sanitizer
valgrind job, via scripts/provision-build-cache install && apply gated on
secret presence.
Availability rules, which the workflow steps must respect:
- Fork
pull_requestruns have no secret. GitHub withholds repository secrets from any workflow run whose head branch lives in a fork, regardless of the author's permissions — and the normal contribution flow here is fork-based. Those lanes build cold, exactly as before the cache existed. The provisioning step therefore treats an empty key as "skip", never "fail". merge_groupruns have the secret. The merge queue is the serial bottleneck, so this is where the warm cache pays off. These runs execute already-approved, queued code, which is also why letting them write to the shared cache (allow_cache_uploads) is acceptable.- The dedicated compiler-reproducibility job is intentionally cache-free.
It runs
scripts/check-reproducible-compiler.sh(RUE-617), which hard-errors on a.buckconfig.local: the reference and relocated candidate builds must be identically configured for the byte comparison to indict path/scheduling/ environment leaks rather than configuration drift. Keeping that proof in an independent job lets the ordinary linux-x64 build and tests use the shared cache without changing the reproducibility contract.
The cache-probe workflow (.github/workflows/cache-probe.yml) remains the
measurement tool: it writes a transient config from the secret, does a cold
release build of //crates/... then a clean-and-rebuild, and reports buck2's
Commands: (cached / remote / local) line. Each workflow attempt injects a
unique, otherwise-unused Rust cfg so prior runs cannot satisfy the nominal cold
phase. The probe fails unless that phase executes local actions and the warm
phase increases cache hits while reducing local actions. It runs weekly
(Mondays 05:00 UTC) and on demand with gh workflow run cache-probe.yml.
The schedule exists because cache degradation is silent by construction: the cache can only change speed, never correctness, so a dead or non-populating cache turns no lane red. Without a recurring genuinely-cold-then-warm probe, the regression surfaces only as CI gradually becoming expensive.
The same workflow carries rue-program-warm, ADR-0070's positive warm-cache
control (RUE-1405, extended by RUE-1406;
scripts/check-rue-program-warm-cache.sh). It cold-builds the two canary
rue_program targets and the nine staged CLI programs under the same nonce
mechanism, then from a relocated checkout root runs the canaries' four
consuming scenarios and rebuilds //:cli-staged-programs, failing unless every
scan/derive/compile action was a cache hit. Cross-root service is the property
the derive step's manifest re-anchoring exists to guarantee, and it is what
lets a pull_request run's compile be consumed by the merge_group run. Both
consumer shapes are covered deliberately: rue_program_test scenarios, and the
CLI corpus actions that declare the staged directory as an input.
Required CI also records per-step wall time and aggregate cached/remote/local
command counts in each job summary via scripts/ci-timed. The probe answers
whether unchanged actions are reusable; the job summaries answer the different
question of how expensive a real change's remaining local actions were. Both
signals matter: a central Rust or ThinLTO change can have a high numerical hit
rate while a few invalidated actions remain on the critical path.
Each summary separates the four costs that a single wall-time number conflates:
| Column | Question it answers |
|---|---|
| Cached | how many actions the remote cache served |
| Remote | how many executed on the BuildBuddy worker |
| Local | how many executed on the runner |
| Test time | how long the test processes themselves ran |
The first three are Buck's own accounting of how each action was obtained.
The fourth is not: Rue's spec, UI, and CLI corpora are entire harnesses behind a
single sh_test action, so their runtime is opaque to those counters. A corpus
lane can report a 95 percent hit rate and still spend nearly all its wall time
in one harness process — that is a corpus-sharding problem, not a cache problem,
and the two are only distinguishable when the summary reports both.
The separation is not a presentational choice: the first three counters cannot
see test execution. A buck2 test run is handed to the test executor rather than
evaluated as an action, so it never enters the Commands: accounting, and OSS
buck2 ships no test-result cache to serve it from. In one merge_group run,
//:cli-tests-caldera (which executed nothing) and //:cli-tests-shard-1 (which
ran for 11:18) both reported Commands: 465 (cached: 465/464). A corpus job can
therefore print a perfect hit rate while re-running an eleven-minute suite in
full, and that reads as "everything was cached" to anyone who stops at the
cache columns. Test time is the signal that separates those two runs. RUE-1118
has the measurements.
RUE-320 also adds a merge-group-only remote-execution canary. It disables action
cache reads, selects the no-fallback //platforms:remote_execution executor, and
requires Buck to report remotely executed actions. This is deliberately
separate from the cache probe: one proves worker execution, the other proves
unchanged-action reuse.
Because test executions cannot be cached, a plain sh_test corpus re-ran on
every merge even when the merge commit's tree was byte-identical to the tree the
PR run had just validated. Three CI runs of one such tree — two pull_request,
one merge_group — converged to 465/465 cached build actions while every corpus
re-executed in full, at 91–97% of the merge queue's critical path.
cached_corpus_suite (corpus.bzl) therefore splits each heavy corpus in two:
- a genrule that runs the harness through
scripts/corpus-actionand writes a stamp on success. This is an ordinary action, so the PR run uploads it and themerge_grouprun reads it back like any compile; - a thin
sh_testthat asserts the stamp. It keeps the suite's name, labels, andPass: root//:NAME (time)result line, soscripts/ci-heavy-suiteand test.sh's RUE-924 corpus-omission audit keep working unchanged.
Two consequences are worth knowing before changing a corpus target:
- The declared input contract is now load-bearing. Under a plain
sh_testan undeclared input was merely untracked — the suite re-ran regardless. Here an undeclared input is a false pass: change a file the action does not name and the corpus reports success against the previous tree's result. Every path a harness reads at runtime must reach it throughenv, and hence through$(location ...). - Paths must be absolute.
$(location ...)expands to an absolute path in ansh_test, which runs from the project root, but to a project-relative path in a build action. The harnesses spawn the compiler with each case's temp directory as cwd, andfind_dirfalls back to relative defaults, so a relative value can resolve to a real but wrong directory and quietly test nothing.corpus-actionresolves everything named in the suite'sabsolutizelist immediately before the harness runs. - The suite must be a rule that permits cache uploads.
cached_corpus_suitedefines its own rule rather than usinggenrulefor one reason: the prelude computescacheable = attrs.cacheable and (local_only or prefer_local)and passes that asallow_cache_upload, where those two flags come from a Meta-internal label allowlist (uses_sudo,qt_moc,yarn_install, ...). A plain genrule therefore never uploads here, and the first merge_group run of RUE-1118 re-executed every corpus on a tree byte-identical to the one the PR run had just built. The rule setsallow_cache_upload = Trueexplicitly. - A measurement the corpus produces must be a declared output, not a path
handed in. RUE-1158 rebalances the CLI shards from per-case timings, which
ci-heavy-suiteused to collect by passing--env RUE_CLI_CASE_TIMINGS=to the test executor. Neither half of that survives the conversion: the harness now runs inside the action, where an executor--envnever reaches it, and the path was a per-runmktemp/RUNNER_TEMPvalue, which would change the action's digest on every run and defeat the caching entirely. The timings are a second declared output (cached_corpus_suite(case_timings = True), exposed as:NAME-action[timings]), so they are stored with the stamp and materialize on a cache hit.ci-heavy-suitefetches that sub-target after the run and copies it to the pathci.ymluploads. The general rule: anything a corpus produces that outlives the run belongs in the action's outputs, or it silently stops existing the moment the suite starts being cache-served.
rue_program (rue_rules.bzl) is the second cacheable writer class after the
corpus actions: its scan (rue --emit deps), manifest derivation, and compile
all set allow_cache_upload, so a PR run's program compiles are served to the
merge-group run like any rustc action. Three properties matter for cache
reasoning:
- The scan's output is machine-unstable but its key is not. The dependency envelope embeds absolute paths and inode/mtime identity, and a cache-served envelope from another checkout is expected; the derivation step re-anchors through the envelope's recorded roots and emits manifest entries relative to the manifest's own directory, so the derived manifest is byte-identical across checkout roots. Consequently a scan/derive cache miss on one machine still converges to a compile cache HIT, because the compile is keyed on the manifest's content, not the envelope's.
- The declared boundary is enforced at derivation, not by the cache. An
accepted read outside
srcs ∪ stdfails the build in-band; seescripts/rue-program-derive-manifest.pyfor why that check must not be simplified away. scripts/check-rue-program-digests.shis the standing control (therue-program-digestsCI lane): declared mutations re-run the chain, undeclared neighbours do not, asserted as steady-state convergence because OSS buck2 has no persistent digest-keyed local action cache and "revert-is-a-cache-hit" is not a property it promises.
The thin sh_test reports Pass: root//:NAME (0.0s) whether the corpus ran for
eleven minutes or was served from cache — it only checks the stamp. Do not read
that line as evidence of anything. Two signals actually answer the question:
Commands: N (cached: C, remote: R, local: L)— the corpus is one action, so a cache-served suite adds tocachedand a re-executed one adds tolocal. The pre-RUE-1118 baseline for a CLI shard was 465 commands; it is 466 now. A shard'scli-tests: measured N casesline answers it too: the timings are an output of that same action, soNis the case count of whichever run produced the cache entry, not proof that this run executed anything.- the job's wall time, which is unambiguous.
This matters because the two failure modes look identical in the test output. A
merge_group run that reports Pass (0.0s) on all eighteen corpus jobs while each
one takes ten minutes is doing no caching at all.
//:reproducible-programs and //:oracle-diff-generated-smoke remain plain
sh_tests: their harnesses are shell scripts that read repository paths
directly rather than through declared env inputs, so establishing that contract
has to come first. Both are off the merge queue's critical path.
The merge-group remote-execution canary claims "the compiler builds on the
worker we reviewed". A moving tag would silently change what that proves, and
would convert an upstream image republish into a merge-queue failure with no
local reproduction. //:required-ci-container-pin-validation therefore rejects
a platforms/remote_cache.bzl image reference without an @sha256: digest, and
rejects a latest tag anywhere in required CI. BuildBuddy publishes no
versioned tag for this image, so unlike the actionlint pin the digest stands
alone rather than accompanying a reviewed release tag.
-
Resolve the moving stream to its current immutable OCI index digest:
docker buildx imagetools inspect gcr.io/flame-public/rbe-ubuntu22-04:latest
The index digest covers every architecture, so the executor still selects the linux/amd64 manifest from it.
-
Update
container-imageinplatforms/remote_cache.bzltodocker://gcr.io/flame-public/rbe-ubuntu22-04@sha256:<INDEX-DIGEST>and refresh the resolution date in the comment above it. -
Confirm the new image still satisfies the constraints recorded above — Python ≥3.9 for the prelude's rustc wrapper, and a
ccdriver for the RUE-320 linker select:docker run --rm gcr.io/flame-public/rbe-ubuntu22-04@sha256:<INDEX-DIGEST> \ bash -c 'python3 --version && cc --version'
-
Run the policy and its focused regression tests, then let the merge-group
remote execution (linux-x64)canary prove the worker end to end:./buck2 test //:required-ci-container-pin-validation \ //:required-ci-container-pin-tool-tests
linker = "cc" still depends on the container having a C driver. The fully
hermetic version — mirroring the rust toolchain — would ship clang/lld as its own
toolchain so RE has zero container dependency. Not needed for correctness (lld
already does the linking); a future hardening if we make RE the default.