Caching
@vzn/vx’s cache is content-addressed, opt-in per task, and shaped to
cascade through the dependency graph the same way Turborepo’s does.
This page explains what’s in the cache key, what triggers
invalidation, what’s actually stored, and why.
Why caching is opt-in
Section titled “Why caching is opt-in”A task is cached iff its TaskConfig provides a cache block, with
both inputs.files and outputs.files. Omit cache and the
task always runs; no read, no write.
The reasoning:
- Defaulting caching ON with implicit globs leads to silently stale builds. The first time a user forgets to revisit their config to add an input, the cache returns a hit for an out-of-date snapshot. Stale hits are the worst failure mode of a task runner — they erode trust in the cache itself.
- Forcing declaration makes “what does this task read?” and “what does it produce?” conscious choices. The user pays a one-time cost (write the globs) for a permanent gain (the cache key actually reflects reality).
- The cost of a forgotten cache miss is small. A task re-runs. The cost of a stale cache hit is large. Asymmetric risk justifies asymmetric defaults.
Turbo defaults caching ON for outputs: [] tasks; Nx requires you to
opt out via cache: false. We chose the strictest of the three.
Cache key derivation
Section titled “Cache key derivation”The cache key for one task is a 16-hex xxHash3 digest, seed-chained over (in order):
-
CACHE_VERSION— the key-derivation sentinel (currently'vx-cache-v26', insrc/cache/cache.ts). Bumped only when the key derivation format changes. See § Bumping CACHE_VERSION. -
taskId—${projectName}#${taskName}. Two tasks with identical everything else still produce distinct keys — protects against e.g.pkg-a#buildaccidentally cache-hitting on apkg-b#buildentry. -
Workspace fingerprint — xxh3 of every supported workspace marker found at the root (see
modules/fingerprint.md):pnpm-lock.yaml,package-lock.json,npm-shrinkwrap.json,yarn.lock,bun.lock,bun.lockb,pnpm-workspace.yaml. Any install-resolved change (abun installthat bumpsbun.lock) or any workspace-shape change invalidates every cache entry. This is the single global “the world changed” lever. -
Project
package.jsonhash — xxh3 of the project’spackage.jsonbytes. Folded in implicitly (Turbo / Nx parity). Covers the case wherecache.inputs.files: ['src/**']is narrow and apackage.jsondep change would otherwise leak undetected. (Added at v12; rationale in § History.) -
Task config hash —
xxh3(JSON.stringify(node.config))of the evaluated task config. Captures:execblock (command, env declarations, timeout, persistent).dependsOnandcache.inputs.tasksdeclarations.cache.outputs.files,cache.inputs.files,cache.inputs.env,cache.inputs.runtime/workspaceRuntimedeclarations (the strings themselves; their resolved file content / env values / command output contribute separately).description(because it’s part of the resolved object — even though it has no behavioural effect; a description change isn’t a correctness change but the cost of a re-run is low).- Imported / computed values — anything a preset or
process.env-read at config-load time injected. Bun’s nativeawait import()evaluates the module and bakes those values into the object before we serialize.
-
forwardArgs— CLI args passed after--. Folded into the key sovx run test -- --watchdoesn’t cache-hit a previousvx run test. Scoped to the user-requested tasks only — dependsOn- pulled deps don’t see them (their cache identity stays clean). -
cache.inputs.envresolved values —[name, value]pairs read from hostprocess.envat hash time (delimitedname\0valueso boundaries are unambiguous). Listed names get their current values; unset names contribute the empty string. -
cache.inputs.runtimeresolved output —[command, output]pairs, whereoutputis the combined, trimmed stdout + stderr of each command run viash -cin the project dir at hash time. The runtime-output analog of step 7: the command strings are in the resolved config (step 5), their output is resolved live every run. Folded with the command count + eachcommand\0outputpair. -
cache.inputs.workspaceRuntimeresolved output — same as step 8 but commands run at the workspace root, and the pairs fold into a distinct namespace (ws-runtime-values:) so an identical(command, output)never aliases the project-cwdruntimevalues. -
Filtered upstream task cache hashes — every upstream task’s own cache key, filtered by
cache.inputs.tasks(default: all of them). Sorted by hash before folding so the ordering ofdependsOndoesn’t change the key. This is the cascade mechanism: if anything beneath you changes, your hash changes too. -
Input files’ content hashes —
cache.inputs.filesresolved to a concrete list of project-relative paths (gitignore-aware, declared-outputs-excluded, nested-projects-excluded), each file contributing its git blob OID (v20). On a clean tree the OID comes straight from the index — the same bulkgit ls-files -s --others --exclude-standardspawn that enumerates files also yields every tracked file’s OID — so deriving these hashes costs zero file reads, zero per-file stats, zero SQLite lookups.Your globs are a filter over the set git reports, so a filter can only ever remove — a gitignored file can never be filtered back in, however explicitly you name it. Naming one by hand is therefore a hard error rather than a silent nothing: it would leave the task ignoring a file its own config claims as an input, reporting
up-to-datewhile that file changed. Turbo lets an explicit entry override gitignore; vx cannot, because the key would then depend on a changegit diffcannot see and--affectedwould stop selecting the task. If the file is generated, depend on the task that produces it viacache.inputs.tasks. A glob matching nothing stays silent — that is legitimate — and so does a literal naming a file that does not exist.An index OID is only trusted where git stores the worktree bytes verbatim, so three concurrent probes prune it:
git status --porcelaindrops paths whose working tree diverges;git ls-files -vdropsskip-worktree/assume-unchangedentries, whose OID says nothing about what is (or isn’t) on disk; and a clean-filter gate drops paths wheretext/eol/identorcore.autocrlfcan rewrite bytes between index and worktree (the blob would be the LF-normalized form while the task reads the CRLF file). The gate costs nothing in a repo with no attributes and nocore.autocrlf— see “Clean filters” below.Every pruned path, plus untracked files and symlinks, falls back to an in-process
HASH("blob " + len + "\0" + content)over the worktree bytes (sha1, or sha256 in--object-format=sha256repos), memoized infile_hasheson(mtime, size, ctime, ino). That is the same value the index holds whenever no filter applies, so a file’s contribution doesn’t flip across dirty↔clean transitions. Folded as(relPath, oid)pairs, sorted for stability across OSes and walk orders.
The composition is seed-chained (xxh3(part, prevDigest)) with a
label prefix per field, so two different field layouts can’t collide.
xxHash3 is non-cryptographic by design — cache keys need uniqueness
across honest inputs, not adversarial collision resistance.
Cache lookup → restore
Section titled “Cache lookup → restore”On a hit:
cache.get(hash)is pure SQL: theentriesrow carries the metadata and the captured stdout; theoutput_filesrows carry the output fingerprints. The tar artifact is not touched by the probe (its existence is verified with one stat), so hit cost doesn’t scale with artifact size.- If the on-disk output tree already matches the cached snapshot
(size + mode + millisecond-mtime check against
output_files), extraction is skipped entirely — the outcome reports up-to-date (restored: false). Save-path rows record stat-ms mtimes (tar headers only carry seconds) andrestoreOutputsre-syncs restored files’ mtimes to the rows, so the comparison is exact in steady state. Residual blind spot, accepted like every mtime-based check: a same-size edit landing in the same millisecond as the recorded write, or a deliberately forged mtime (touch -r). - Otherwise the task’s declared outputs are wiped from the project
dir (
cleanOutputs) — see § Strict output ownership — and theoutputs/<rel>(+workspace-outputs/<rel>) entries are extracted from<cacheDir>/<hash>.tar.zstinto place. - Captured stdout is replayed to the live terminal via the logger — the framed block looks like a fresh run. (stderr is not cached — only successful runs are cached and their stderr is near-always empty; live runs still stream stderr normally.)
- The task is marked
cache-hit(orcache-hit-remotewhen theLayeredCachehydrated from the remote layer this run);durationMsis the wallclock for the restore op, not the original exec time.
The cached exitCode is preserved. A cached non-zero exit is
impossible by construction — see § Cache write — and
“by construction” is literal: neither save nor ingest accepts an
exitCode at all, so the stored value is pinned to 0 rather than
supplied. The restore path still checks it, because the column outlives
this process: a row from a hand-edited or foreign cache.db with a
non-zero exit classifies the hit failed instead of restoring a broken
build’s outputs under a green run.
Local restore tier (two-tier scheduler)
Section titled “Local restore tier (two-tier scheduler)”dependsOn is an ordering gate, so a dependent normally can’t even
probe the cache until its upstream finishes running. But a
stable-key task’s key is provably independent of any upstream’s
outputs — its hit/miss status is knowable up front, and restoring
it needs none of its deps’ output. So on local-only runs, run()
performs an up-front CLASSIFY (orchestrator/local-shortcircuit.ts):
- Derive every stable, cacheable, local-read task’s key (reusing the
run’s
hashCachememo) and probe localcache.getonce, building apreProbedmap covering stable hits AND stable misses. - Confirmed hits become the restore tier: the scheduler makes them ready immediately (no dep gate, and no failed-dep→skip check — their key is dep-success-independent) but at LOW priority, so cache misses own the worker pool and restores only backfill idle capacity.
execute-taskconsumespreProbed, so the up-front probes ARE the probes it would have done, hoisted — no double work, and every task still flows throughexecute()so output is unchanged.
Stability gate (shared with remote prefetch via
stable-keys.ts:deriveStableKeys): a task whose input globs could
match a same-project upstream’s declared outputs.files (or whose
inputs.workspaceFiles overlap an upstream’s
outputs.workspaceFiles) has a preliminary key and stays exec-tier
/ dep-gated. A graph declaring any outputs.workspaceFiles disables
the restore tier graph-wide (probe reuse still applies). The
short-circuit never runs under a LayeredCache (remote prefetch owns
those runs — an up-front get there would put remote GETs on the
critical path), never fires with local reads off, and never throws —
any error degrades to the normal lazy schedule. Measured: −6.6% on a
mixed slow-upstream/warm-downstream workload; parity on all-hit warm
runs.
Remote prefetch (async, remote-only)
Section titled “Remote prefetch (async, remote-only)”When a run is backed by a remote cache — a plugin’s cache capability
or an injected RunOptions.remoteCache layer — the network latency of
every remote GET
would otherwise sit on the critical path of the task that needs it.
So before execution starts, run() kicks off background prefetches:
- Every cacheable task’s key is derived once, up front, in
topological order (reusing the run’s
hashCachememo, soexecute-task’s latercomputeTaskHashfor the same task hits the memo — no double hashing). This derivation touches no cache layer — keys only. - Each stable-key task’s remote GET is fired concurrently under a
bounded pool (the run’s concurrency). The prefetch ingests a hit
into the local cache; misses/errors degrade to
false. - Execution starts immediately — the prefetches race alongside it, so remote latency overlaps real work instead of blocking it.
- When
execute-tasklater callscache.get(hash), theLayeredCacheawaits the already-in-flight (resolved-or-pending) prefetch for that key rather than starting a fresh round-trip: at most ONE remote GET per key, whether it was served by the prefetch, the lazy read-through, or both.
Hard invariants:
- Remote-only. This entire path is gated on a
LayeredCachebeing configured. A local-only run never derives the upfront keys, never prefetches, and is byte-for-byte identical (behavior and perf) to a run without this feature. It never adds an upfront localget/isOutputsCurrent/ stat pass. - Stable keys only. A task whose
cache.inputs.filescould match an upstream’s declared output has a preliminary key until that upstream runs (e.g. a consumer that globs**/*over a sibling’sgenerated.txt). Prefetching it would target the wrong artifact, so it’s skipped — its key resolves correctly via the lazy read-through inexecute-task. Instability propagates: a task that folds an unstable upstream is itself unstable. When in doubt, skip. - At most once. The
LayeredCachekeeps an in-flight map keyed by hash;prefetchandgetshare it, and a settledfalse(remote miss) prevents a second lazy probe of the same dead key. - Provenance preserved. A hash pulled from remote — even when a
later
getfinds it as a now-local hit — still reportssource: 'remote', so the outcome iscache-hit-remote. - A remote-read-off policy (
--no-cache,--force, or--cache=remote:) fires no prefetch. - Never fail. Every remote path (get / put / ingest / prefetch) catches all errors and degrades to a cache miss. A remote 500, a network drop, a corrupt artifact, or a failed integrity check can never fail a run.
Remote uploads (background, drained at run end)
Section titled “Remote uploads (background, drained at run end)”Writes go to the local cache synchronously; the remote PUT is a
fire-and-forget background upload. The task’s outcome (and its
dependents) never wait on upload latency — the uploads race alongside
the rest of the run and are drained before cache.close(), so a
short run still ships every artifact before the process exits. Upload
failures log via onRemoteError and are otherwise ignored (the task
already succeeded; the only loss is the remote entry).
Planning probes (--dry / --graph)
Section titled “Planning probes (--dry / --graph)”The planning paths (vx run --dry, --graph) predict hits without
side effects: against a remote cache they use a lightweight
existence probe — no artifact download, no local ingest. A predicted
hit-remote means the artifact exists remotely; the bytes move only
when a real run needs them.
Cache policy (read/write axes)
Section titled “Cache policy (read/write axes)”Caching is controlled by a four-axis CachePolicy — localRead,
localWrite, remoteRead, remoteWrite — independent toggles,
each enforced inside the matching cache layer at construction time. The
local Cache gets a { read, write } slice gating only its task
artifact get/save (never recordRun / stats / prune / ingest /
hashing); the LayeredCache additionally gates its own remote
read-through (remoteRead), upload (remoteWrite), and prefetch
(remoteRead). The orchestrator derives two booleans per task:
willRead = task has a cache block AND (localRead || remoteRead)willWrite = task has a cache block AND (localWrite || remoteWrite)
A task reads the cache only when willRead, saves only when
willWrite, and cleans its declared outputs before exec only when
willWrite.
The CLI maps three flags to a policy (precedence: start all-on → apply
--cache → --no-cache forces all off → --force forces both reads
off): --no-cache = everything off (no read, no write, no output
clean); --force = reads off / writes on (re-execute and refresh the
cache, outputs cleaned); --cache=<spec> = explicit per-layer control.
See docs/cli.md § Cache control.
One subtlety: when localWrite is off but remoteWrite is on
(--cache=local:,remote:rw), there’s no on-disk artifact for the
LayeredCache to read before uploading — so it packs the tar.zst bytes
in memory (Cache.packArtifactBytes) and uploads those.
Cache write
Section titled “Cache write”A miss runs the task. If the final exit code is 0 and the task’s
willWrite is true (it has a cache block AND at least one write axis
is on):
cache.outputs.files(andoutputs.workspaceFiles) are resolved against the project dir / workspace root.- A second
computeTaskHashruns with thecaptureIntoside-channel to record the per-component input fingerprint (miss-only; the HashCache memos make it a re-fold, no extra I/O). - The artifact — one
stdoutentry plusoutputs/<rel>(+workspace-outputs/<rel>) entries — is packed in-process into a single<hash>.tar.zst, written to a temp name, validated, and atomically renamed into place. Concurrent readers see either no entry or a complete entry — never a partial one. - One SQLite transaction upserts the
entriesrow (taskId, command, exit code, duration, size, stdout, timestamps), theoutput_filesfingerprint rows, and theentry_inputscomponent rows (INSERT OR IGNORE).
If the task exits non-zero, nothing is cached. This is deliberate:
- Caching a failure prevents retry flows. The next run gets the same failure even after the user fixes the underlying cause (the inputs haven’t changed, so the cache key matches).
- Failures should be transient by default — flaky tests, network blips, transient resource exhaustion shouldn’t bake into the cache.
Failed-task stdout / stderr still reach the user via the live stream
and the framed failure block replayed at run end. The runs table
records the failure (status + exit code) for analytics.
Strict output ownership
Section titled “Strict output ownership”Declared cache.outputs.files are wiped in two distinct places:
- Before exec on a cache miss. A leftover
dist/old.jsfrom a prior build can’t survive into a fresh build that doesn’t rewrite it. - Before restore on a cache hit. The post-restore tree is the cached snapshot byte-for-byte. Hand-edits to output files don’t persist through a cache replay.
Both branches use the same cleanOutputs helper (src/cache/inputs.ts)
with the same boundary rules. Skipped when:
cache.outputs.filesis empty (nothing declared as output).- The task’s
willWriteis false — no write axis is enabled (e.g.--no-cache, or a read-only--cache=local:r). The user is debugging and managing the tree, so vx leaves it alone.--forcekeeps writes on, so it DOES clean (the saved snapshot must be clean).
Why so strict? Turbo and Nx restore additively — files from a prior state can survive. We’ve seen this cause:
- Wrong test runs (a deleted-but-resurrected snapshot file from a cache miss survives a hit and now your test passes against the wrong baseline).
- Wrong shipped artifacts (a deleted source-mapped file from a prior
build sits in
dist/alongside the new bundle).
The strict-ownership behavior makes the project dir post-run a pure function of the cache key.
Invalidation paths
Section titled “Invalidation paths”A task’s cache becomes invalid when any of these change:
| Trigger | Mechanism |
|---|---|
Edit a file in the task’s inputs.files set | step 11 of key derivation |
Edit a file in the task’s inputs.workspaceFiles set (root-anchored; may live in ANY project’s dir — the documented boundary exception) | step 11 — resolved workspace files join the same input-file list |
Any package manager updates a lockfile (pnpm, npm, yarn, bun) | step 3 (workspace fingerprint) |
Edit pnpm-workspace.yaml or package.json’s workspaces field | step 3 |
Edit the project’s package.json (dep / version / scripts change) | step 4 (project package.json hash) |
Edit the task’s vx.config.ts | step 5 (task config hash) |
| Edit a config file that the task config imports | step 5 (configHash sees the resolved object after Bun evaluates imports) |
Change CLI forwardArgs (after --) | step 6 |
Change a cache.inputs.env host value | step 7 |
Change the combined stdout+stderr of a cache.inputs.runtime command (resolved at hash time) | step 8 |
Change the combined stdout+stderr of a cache.inputs.workspaceRuntime command (resolved at hash time) | step 9 |
| Upstream task’s cache key changes (because its inputs changed) | step 10 |
Bump CACHE_VERSION | step 1 — orphans every entry |
Change exec.env.passThrough values alone | NOT a trigger by design — passThrough values are host-specific |
Change a file not in inputs.files / inputs.workspaceFiles | NOT a trigger by design — declare it explicitly |
Edit vx-lock.json | NOT a trigger — globally excluded from inputs (v24); it’s vx’s own metadata, never a task input |
| Change a file in a nested project’s dir | NOT a trigger for the parent’s files globs — project boundaries are hard (workspaceFiles is the explicit exception) |
The cascade in step 10 is what makes monorepo caching work: edit a
file in lib/, and every package that depends on lib’s build task
re-runs automatically.
Cross-project boundaries
Section titled “Cross-project boundaries”A project’s cache.inputs.files globs never reach into another
project’s directory, even if a **/* pattern would otherwise match.
workspace/nested-dirs.ts computes the set of nested project
directories (projects rooted inside this one) once per vx run, and
adds them to the ignore list passed to every glob pass. The only way
for project A to depend on project B’s state via project-relative
globs is dependsOn + upstream-hash propagation (step 10).
Exception: cache.inputs.workspaceFiles /
cache.outputs.workspaceFiles are workspace-root-anchored and apply
NO boundary rule — a deliberate escape hatch (owner call: “they don’t
care about boundaries; it is bad practice but is there”). Prefer
project-relative declarations; reach for workspaceFiles only for
genuinely root-anchored files.
Clean filters (text / eol / ident, core.autocrlf)
Section titled “Clean filters (text / eol / ident, core.autocrlf)”Git can store a file’s blob in a different form than the bytes in your
working tree. Under a text, eol or ident attribute — or with
core.autocrlf set to true/input — the index holds the
LF-normalized (or ident-collapsed) blob while your editor and your
build see the CRLF (or expanded) file.
That matters because git status compares after applying the
filter, so such a file reports clean: git considers it unmodified
even though the blob and the worktree file are different byte
sequences. Trusting the index OID as the file’s content hash would
then let two genuinely different worktree contents fold the same
cache key, and a real change would be invisible.
So vx does not trust an index OID where a filter can apply. The check is gated in three steps, and the common case pays nothing:
core.autocrlfistrue/input— conversion applies to every auto-detected text file with no attribute needed, so no OID is trusted.- Otherwise, if no attributes source exists anywhere (no in-tree
.gitattributes, no$GIT_DIR/info/attributes, nocore.attributesFile), no rule can name a filter and vx does no extra work. This is the defaultgit initrepo. - Otherwise
git check-attrresolves the three attributes from the index — without reading worktree content — and only the paths actually carrying one lose their OID.
-text (explicitly unset) and unspecified paths keep their OIDs:
both leave the blob byte-identical to the worktree file.
Losing an OID is not over-invalidation. It routes that path to the content hasher, which hashes the worktree bytes — the correct source either way. The only cost is reading the file, which is exactly what the gate exists to avoid paying needlessly.
Runtime inputs and the lock (the env parallel)
Section titled “Runtime inputs and the lock (the env parallel)”cache.inputs.runtime / cache.inputs.workspaceRuntime are modeled
exactly on cache.inputs.env, and they share its asymmetry with the
lockfile:
- The command strings live in the resolved config, so
vx lockfreezes them intovx-lock.json(just as it freezes the env names a task declares). - The command output is resolved live at hash time on every run
— inside
resolveInputs, the same place env values are read from the hostprocess.env. The lock never stores it.
So vx run --frozen loads the frozen command strings but still spawns
them and folds their current output into the key. A node -v that goes
from v20 to v22 after the lock was written busts the cache under
--frozen, exactly as a changed NODE_ENV value would — the TypeScript
escape hatch (define: { TSC_VERSION: execSync(...) }) goes stale here
because its value was baked into the config object at lock time, whereas
a runtime input re-resolves.
Consequently vx lock --check does not — and need not — flag
runtime-output drift. lock --check audits that the frozen config
object still matches a fresh evaluation; the command output was never
part of that object (only the strings are), so a changed probe output
is correct, expected, live behavior rather than lock drift. This is the
same reason lock --check ignores inputs.env value changes.
Storage layout (v17+)
Section titled “Storage layout (v17+)”<workspaceRoot>/.vx/cache/ (configurable via vx.workspace.ts cacheDir)├── cache.db SQLite metadata + run history├── cache.db-wal write-ahead log├── cache.db-shm shared memory└── <hash>.tar.zst one artifact per cache entry: ├── stdout captured stdout (always present, may be empty) ├── outputs/<rel> declared output files, project-relative (when any) └── workspace-outputs/<rel> declared outputs.workspaceFiles, WORKSPACE-ROOT-relative (when any)<hash> is the 16-hex xxh3 key. The workspace-outputs/ namespace is
additive: tasks that don’t declare outputs.workspaceFiles produce
byte-identical artifacts to the plain v17 format (which is why the
field needed no CACHE_VERSION bump). output_files rows mirror the
two namespaces — project rows store the bare rel, workspace rows store
the full workspace-outputs/<rel> name as the discriminator.
The tar is packed --format=gnu: like ustar it emits no PAX
extended-header records (BSD tar emits one per entry, which would leave
PaxHeaders/<name> junk in restored trees), but unlike ustar it can
express every name a build produces — ustar refuses a single path
component over 100 bytes outright, and splits anything over 100 bytes
into prefix + name (v25). Staged copies carry the source file’s
permission bits, so an executable output restores executable.
Key properties: one entry is one file — eviction is a single
unlink; no per-entry manifest, no separate logs/ tree; and local +
remote layers transport the exact same tar.zst bytes end-to-end.
Captured stdout is stored twice on purpose: in the artifact (so it
survives the remote round-trip) and in the entries row (so a local
hit replays it with pure SQL, never decompressing the artifact).
SQLite tables
Section titled “SQLite tables”-- src/cache/cache.ts schema (SCHEMA_VERSION = 'v22')
CREATE TABLE schema_meta ( key TEXT PRIMARY KEY, -- 'version' value TEXT NOT NULL);
CREATE TABLE entries ( hash TEXT PRIMARY KEY, -- the 16-hex xxh3 cache key project TEXT NOT NULL, task TEXT NOT NULL, command TEXT NOT NULL, exit_code INTEGER NOT NULL, duration_ms INTEGER NOT NULL, size_bytes INTEGER NOT NULL, -- artifact size stdout TEXT NOT NULL DEFAULT '', -- captured stdout (pure-SQL hit replay) created_at INTEGER NOT NULL, -- ms-epoch accessed_at INTEGER NOT NULL -- ms-epoch; bumps batch at flush (LRU));
CREATE TABLE runs ( id INTEGER PRIMARY KEY AUTOINCREMENT, hash TEXT NOT NULL, -- '' when the outcome derived no key (see below) project TEXT NOT NULL, task TEXT NOT NULL, status TEXT NOT NULL, -- success | failed | cache-hit | cache-hit-remote | skipped exit_code INTEGER NOT NULL, duration_ms INTEGER NOT NULL, forward_args TEXT, -- JSON-encoded; null when no `--` args started_at INTEGER NOT NULL, -- ms-epoch ended_at INTEGER NOT NULL, run_id TEXT, -- ULID shared across all tasks in one invocation cpu_ms INTEGER, peak_rss_bytes INTEGER, wallclock_start_ns INTEGER, -- bigint; serialized as SQLite INTEGER (signed 64-bit) wallclock_end_ns INTEGER, cache_hit INTEGER -- 0/1; convenience for flamegraph color);
CREATE INDEX runs_hash ON runs(hash);CREATE INDEX runs_started_at ON runs(started_at);CREATE INDEX runs_project ON runs(project, task);CREATE INDEX runs_run_id ON runs(run_id);
-- Every non-group, non-aborted outcome of a run gets a row, so-- `invocations.task_count` always equals `COUNT(*)` here for that run_id-- and the terminal summary's "N total". Two of those outcomes never derive-- a cache key — a `skipped` task (its upstream failed, so it never probed)-- and a `persistent` one (a dev server is not cacheable) — and they store-- `hash = ''`. `''` is impossible for a real key (16 hex chars), so it reads-- unambiguously as "no key recorded"; the key-diff surfaces (`vx mcp`'s-- whyDidThisRerun, the cache-key diff) guard it rather than reporting-- "inputs unchanged" from two rows that never had inputs to compare.---- A `skipped` row is a task of the run but NOT an execution, so the rate and-- average aggregates in metrics.ts exclude it: counting a zero-duration-- non-event would dilute success rate, hit rate and mean duration. The-- completeness reads (listRuns / getRun / the run-detail timeline) include it.
-- v22 (Tier 3): one header row per `vx run` invocation. The `runs`-- table is per-task; this is the per-invocation record carrying the-- command, git/CI/host context, tags, and run-level counts. Recorded-- atomically alongside `runs` via recordRunBundle (one transaction).CREATE TABLE invocations ( run_id TEXT PRIMARY KEY, -- ULID, == runs.run_id command TEXT NOT NULL, -- full argv, e.g. "vx run build --all" requested_tasks TEXT NOT NULL, -- JSON string[] of options.tasks cache_policy TEXT NOT NULL, -- compact flags, e.g. "lR,lW,rR,rW" concurrency INTEGER NOT NULL, flow TEXT, -- 'focused' | 'broad' | NULL (programmatic) started_at INTEGER NOT NULL, -- ms-epoch ended_at INTEGER NOT NULL, total_duration_ms INTEGER NOT NULL, -- wall clock of the whole run task_count INTEGER NOT NULL, -- non-group, non-aborted tasks recorded failed_count INTEGER NOT NULL, hit_count INTEGER NOT NULL, -- cache-hit + cache-hit-remote hit_local_count INTEGER NOT NULL, -- cache-hit hit_remote_count INTEGER NOT NULL, -- cache-hit-remote exit_ok INTEGER NOT NULL, -- 1 if the run's `ok` commit_sha TEXT, -- nullable: not a git repo / probe failed branch TEXT, dirty INTEGER, -- 1 if worktree had uncommitted changes ci INTEGER NOT NULL, -- 1 if a CI env was detected ci_provider TEXT, -- 'github' | 'gitlab' | 'buildkite' | 'circleci' | 'generic' host TEXT, -- os.hostname() os TEXT, -- process.platform arch TEXT, -- process.arch vx_version TEXT NOT NULL, tags TEXT NOT NULL DEFAULT '{}' -- JSON object {k:v} from --tag);CREATE INDEX invocations_started ON invocations(started_at);CREATE INDEX invocations_branch ON invocations(branch);CREATE INDEX invocations_ci ON invocations(ci);
-- v22 (Tier 3): the input-fingerprint moat. One row per cache-key-- component, keyed by the cache-ENTRY hash it belongs to (NOT a run-- id). Written INSIDE the entry-save transaction — only on a cache-- MISS, never on a hit — via INSERT OR IGNORE. A warm all-cache-hit-- run writes nothing here. The "why did this re-run?" diff resolves a-- run to its task hash (runs.hash), then anti-joins two entries'-- (kind,name,hash) rows in SQL, no app-side recompute. ON DELETE-- CASCADE sweeps the rows when a prune drops the entry.CREATE TABLE entry_inputs ( entry_hash TEXT NOT NULL, -- == entries.hash / runs.hash kind TEXT NOT NULL, -- file|env|runtime|ws-runtime|upstream|package|config|forward|workspace name TEXT NOT NULL, -- file: workspace-rel path; env: var name; upstream: task id; … hash TEXT NOT NULL, -- the component's contribution to the key PRIMARY KEY (entry_hash, kind, name), FOREIGN KEY (entry_hash) REFERENCES entries(hash) ON DELETE CASCADE);WAL mode is on; readers don’t block writers. PRAGMA busy_timeout = 5000 makes concurrent vx run invocations queue instead of failing
with SQLITE_BUSY.
Trust boundary (Tier 3):
entry_inputsstoresenv/runtimecomponent values verbatim, so a secret read as a cache input can land incache.db. This is consistent with the existing trust boundary —cache.dbis already a local, gitignored, single-user file that records commands and captured stdout. Redaction / an opt-out (cache.inputs.envsecret: true) is out of scope for Tier 3.
The Tier-3 tables persist components that were already fed to
Cache.key() — the cache key derivation is unchanged, so the
CACHE_VERSION is NOT bumped (only SCHEMA_VERSION rolls to v22).
Capture happens as a pure side-channel inside the existing key() fold
(CacheKeyInput.captureInto), only on a cache miss (the warm path
captures nothing), and the rows are persisted with the entry — so a
warm all-cache-hit run does no extra hashing, I/O, or DB writes for the
moat.
Why SQLite + a single artifact per entry
Section titled “Why SQLite + a single artifact per entry”- Index queries are fast. Stats (
SELECT COUNT(*) FROM entries), TTL pruning (WHERE accessed_at < ?), per-task lookup (WHERE hash = ?) all hit a B-tree. - A hit costs SQL, not decompression. Metadata + stdout live in the row; the artifact is only opened when outputs actually restore.
- One artifact = one wire payload. The same tar.zst bytes serve local storage and the remote round-trip — no repacking.
- One handle, one schema-meta sentinel. Schema mismatch wipes the tables (pre-alpha) — there’s no migration code to maintain.
Performance characteristics
Section titled “Performance characteristics”- Hashing cost on a clean tree is near-zero per file: git index
OIDs come from the bulk enumeration spawn, so key derivation does no
file reads. Dirty/untracked files — and any path whose OID is not
trustworthy (see “Clean filters”) — hash in-process (whole-file
read, behind a
(mtime, size, ctime, ino)memo). Narrowinputs.filesstill helps on heavily dirty trees. - Cache read is one indexed
SELECT(+ a stat of the artifact). Restore is a tar.zst extract, skipped entirely when the on-disk tree already matches.accessed_atbumps are batched into one UPDATE at flush time. - Cache write is one in-process tar.zst pack + atomic rename + one SQLite transaction. Hashing dominates the run; storage itself is cheap. The remote upload (if any) is backgrounded.
- Workspace fingerprint is computed once per
vx runinvocation and reused for every task in that run.
What’s NOT in the key (and why)
Section titled “What’s NOT in the key (and why)”exec.env.passThroughvalues. Would force cache misses across machines with different CI flags, regions, or shell prompts. The names are in the config hash (step 5) so adding/removing a passthrough still bumps the key for affected tasks.- Files outside the project directory that aren’t declared.
Workspace-root configs (
tsconfig.base.json, etc.) are not auto-included — declare them viacache.inputs.workspaceFiles(root-anchored globs). - Node / Bun / OS / build-tool versions — unless you declare them.
The canonical mechanism is
cache.inputs.runtime/workspaceRuntime(e.g.workspaceRuntime: ['node -v']): the command output is resolved live at hash time, so it stays correct under--frozen. Avoid baking versions viadefine: { X: execSync(...) }— that value freezes into the config object at lock time and goes stale. vx-lock.json— globally excluded (v24); also filtered out of--affectedchange sets.
Bumping CACHE_VERSION
Section titled “Bumping CACHE_VERSION”Required when:
- A new field is added to the cache key derivation (step list above).
- The order or framing of existing key fields changes.
- The on-disk layout changes (artifact format, entry naming).
- The
CacheEntryshape changes in a way that affects restore. - The SQLite schema changes in a way that affects existing rows
(
SCHEMA_VERSIONalso bumps in that case).
Not required when:
- Behavioural changes that adjust which values flow into existing key components — those naturally produce different keys for affected tasks.
- Changes to WHEN reads/writes fire (policy, prefetch, restore tier, background uploads) — key derivation and artifact bytes untouched.
- Doc-only updates.
- Refactors that don’t change the bytes fed into the hash.
The bump procedure has a dedicated skill at
.claude/skills/bump-cache-version/ (used as /bump-cache-version).
Files touched: src/cache/cache.ts (the constant), this doc (history),
docs/modules/cache.md (key/entry shape if it changed),
CLAUDE.md (decision log), and the cache test file.
History
Section titled “History”-
v25 → v26: the same shape as v25 — stored bytes that are wrong under a key nothing about the fix changes — reached by a different producer. A task whose child was killed by a shutdown signal reports
aborted, butaborteddid not propagate to dependents the wayfailedandskippeddo. So a dependent ran against the aborted task’s PARTIAL outputs, succeeded, and cached what it had built. Because a dependent’s key folds its upstream’s INPUT key — and a signal changes no input — that entry sits under exactly the key a healthy run derives, and the next run replays it as a green hit with exit 0. Reproduced end to end: run 1 killed mid-write leavesPARTIAL, run 2 is fully healthy and still servesPARTIALfrom cache.Making
abortedpropagate stops new poison but cannot reach entries already written, and aLayeredCacheuploads them — so the reach is a whole team’s shared cache, not one developer’s disk. That is what makes the trade worth it: one cold rebuild against a class of silently-wrong output. The interactive Ctrl-C path was never the vector (vx’s handler exits before a dependent can cache); the reachable ones are an externalkill, a supervisor,docker stop, a self-terminating script, and everyhandleSignals: falseembedder — which includesvx watchand the distributed agent loop. -
v24 → v25: the ARTIFACT BYTES in every existing entry are wrong while the key addressing them is unchanged — the one situation a version bump exists for, and the opposite of the recent self-healing no-bump cases. Two defects on the pack/restore path, both silent data loss on an ordinary cache hit with no attacker involved:
- The executable bit was stripped from every cached output.
packArtifactstaged each output withBun.write, which creates the destination under the process umask and does NOT carry the source’s mode, so the tar header recorded 0644 and the restore faithfully reproduced 0644. Any build emitting a CLI shim, a compiled binary or a generated script worked cold and broke warm — including this repo’s ownbuild.bun.*release binaries. Fixed by chmod-ing each staged copy to the source’s mode. - Outputs whose archive entry name exceeded 100 bytes were dropped
on every restore. POSIX ustar splits such a name into
prefix+name; the reader read onlyname, which no longer starts withoutputs/, so the file was neither restored nor indexed. Not self-healing: with nooutput_filesrow, the skip-restore check compared a truncated expectation against a truncated tree and agreed forever. Threshold is a project-relative output path of ~93 chars — ordinary for a modern bundler. Fixed by readingprefix(gated on the POSIX magic, since GNU headers reuse those bytes for atime) AND by packing--format=gnu, which carries long names in anLrecord.
The format switch also fixes a working build being reported as FAILED: ustar cannot split a single path COMPONENT over 100 bytes and exits non-zero (“file name is too long (cannot be split)”), which
packArtifactraised after the task had already succeeded. 120-char filenames are legal everywhere and routine in snapshot/fixture trees.Shipped with three defence-in-depth fixes that needed no bump of their own:
restoreOutputsnow throws instead of returning quietly when the artifact is gone or cannot produce an output the index recorded (the caller has already wiped the declared outputs by then, so a quiet return reported a green hit over an emptied tree — reachable via a concurrentvx cache prune); the tar reader rejects an entry whose declared size runs past the end of the archive (subarrayclamps, so it used to install short, NUL-padded content as a cache hit instead of degrading to a miss); and directory entries now get the same containment + realpath checks as file entries (mkdirfollows a pre-existing symlink, so directories could be created outside the destination). NoSCHEMA_VERSIONbump — no table changed. - The executable bit was stripped from every cached output.
-
SCHEMA v23 → v24 (no
CACHE_VERSIONbump):file_hashesgainsctime_ms+ino. The memo keyed on(mtime, size)alone, and its row persists across runs, so any producer that preserves mtime —tar -x,unzip,cp -p,rsync --times, aSOURCE_DATE_EPOCHgenerator — got the previous run’s digest for genuinely different bytes: a stale cache hit.utimescannot suppress ctime unprivileged and an atomic write-then-rename changes the inode, so the two together close it (git’s index keys on ctime+ino+dev for the same reason); both come free from the stat already taken. The key DERIVATION is unchanged — the memo simply stops answering wrongly — so an affected task’s key moves from a wrong value to the right one: it misses once, re-runs, re-caches. Self-healing, never a wrong hit. Landed alongside two other stale-hit fixes that needed no schema change: the cache-miss path now marks the outputscleanOutputswiped (it was the only one of four sibling call sites that dropped the return, so a deleted output kept a live index OID and stayed in a consumer’s input set), andskip-worktree/assume-unchangedentries no longer keep a trusted OID (they sit at stage 0 andgit statusreports nothing for them, so a sparse-checkout path that was absent from disk still counted as an input). The schema gate drops + recreates on the version mismatch (pre-alpha, no migration), so this costs one cold rebuild. -
SCHEMA v21 → v22 (no
CACHE_VERSIONbump): Tier-3 dashboard tables —invocations(one header row pervx runwith command, git/CI/host context, tags, and run-level counts, recorded withrunsviaCache.recordRunBundle) andentry_inputs(one row per cache-key component, keyed by the cache-ENTRY hash — the input-fingerprint moat).entry_inputsis written inside the entry-save transaction, only on a cache miss (INSERT OR IGNORE), so a warm all-cache-hit run writes nothing for the moat — Tier 3 has zero warm-run cost. The cache KEY derivation is unchanged: these tables persist components already fed toCache.key()(captured via a pure side-channel,CacheKeyInput.captureInto, at the same fold sites — only on a miss), so existing artifacts stay valid andCACHE_VERSIONstaysv24. The schema gate drops + recreates on the version mismatch (pre-alpha, no migration). -
v23 → v24: exclude
vx-lock.jsonfrom the input file set globally (ALWAYS_IGNOREincache/inputs.ts). The lockfile is committed, so git enumerates it, but it’s vx’s own frozen-config metadata — never a task input. Without this, avx lockre-write busts every key on a project that globs the root lockfile (a broad**/*on the root project). Tasks whosecache.inputs.filesnever matched it derive byte-identical keys. No SCHEMA bump — only the hashed file set changed, not the key layout or on-disk format. -
v22 → v23: fold
cache.inputs.runtime/workspaceRuntimecommand output into the key (two namespaced sections after env-values). The command strings stay in the config hash (step 5); their combined trimmed stdout+stderr is resolved live at hash time and folded asruntime-values:(project-cwd) andws-runtime-values:(root-cwd) sections, eachcommand\0output. No SCHEMA bump — onlyCache.keyderivation gained two sections; the on-disk format is unchanged. Tasks declaring neither field fold a:0count for both and derive byte-identical keys to before the bump. -
v21 → v22: pure-input transitive (+ SCHEMA v21): reverted the v21 output-fold. Downstream keys fold the upstream’s input key (its own task hash) — a pure function of the filesystem, like Turbo/Nx. No output content participates in any cache key. Early cutoff is gone: an upstream that re-executes (comment edit, env change) but reproduces byte-identical output now still re-runs its dependents. This was a deliberate simplification — cutoff is rare in practice and not worth the cascade complexity (it forced output content into keys, which blocks any upfront/batched probe). Multi-state is preserved: branch ping-pong A→B→A still re-hits, because the upstream’s input differs per state and folds transitively into every dependent key. SCHEMA v21 drops the now-unused
outputs_hashcolumn;CacheLayer.savereturnsvoid. -
v20 → v21: early cutoff (+ SCHEMA v19, reverted in v22): downstream keys folded the upstream’s output content identity (
outputsHash) instead of its task hash. Removed — see v22. -
v7 → v8 (PR #2): folded
forwardArgsinto the key for CLI argument-forwarding alignment. -
v8 → v9 (PR #3):
TaskConfigshape changed —execcollapsed from an array to a single command,tasksnested underrun. -
v9 → v10 (PR #7): on-disk layout switched from per-entry
meta.json+outputs/directory to a workspace-widecache.db(SQLite) plus output files directly at<hash>/and log files atlogs/<hash>.{stdout,stderr}. Adds run history forvx stats. Removes the per-entry manifest. -
v10 → v11 (PR #19): analytics columns added to the
runstable:run_id(ULID),cpu_ms,peak_rss_bytes,wallclock_start_ns/wallclock_end_ns,cache_hit. All nullable; directly queryable viasqlite3 cache.db. The on-disk<hash>/layout itself was unchanged. -
v11 → v12 (PR #42): project’s
package.jsonbytes folded into every task’s cache key implicitly. Matches Turbo / Nx “implicit dependencies” behavior — apackage.jsondep change invalidates the project’s tasks even whencache.inputs.filesis narrow and doesn’t cover the file. -
v12 → v13 (PR #65): per-entry on-disk layout unified. Outputs moved from
<hash>/<rel>(mixed with metadata) to<hash>/outputs/<rel>; stdout / stderr moved from the siblinglogs/<hash>.{stdout,stderr}into<hash>/stdoutand<hash>/stderr. Eviction collapses to a singlerm -rf <hash>/. Also dropped the runner’slogs/<run_id>/<project>__<task>.{stdout,stderr}dump — output is already streamed live, surfaced on the outcome object, and the cache entry covers successful runs; CI captures parent stdout natively. The duplicate sibling dump was pure redundancy. -
v13 → v14: file enumeration switched from a
Bun.Globwalker with our ownignore-library filter togit ls-files --cached --others --exclude-standard. Matches what Turborepo and Nx both do at the bottom of their hash pipelines. Side-effects user-visible: (a) nested.gitignorepatterns are anchored to the gitignore’s own directory, fixing the v13 footgun wherepkg/.gitignore: src/skip.tswas misinterpreted as<workspaceRoot>/src/skip.ts; (b).git/info/excludeand global excludes participate; (c) untracked-but-not-ignored files enter inputs immediately (nogit addrequired). (The non-git fallback walker was later removed entirely — vx hard-requires git; a non-repo workspace gets a cleanUserErrortelling the user togit init.) -
v14 → v15: cache-key hash swapped from SHA-256 (via
Bun.CryptoHasher) to xxHash3 (viaBun.hash.xxHash3). Key strings shrink from 64 hex chars to 16, matching Turbo’s xxh64 output width; derivation is ~5× faster, dominating the cache-warm path that hashes hundreds of input files. xxHash3 has no streaming Hasher API, soCache.key()chains via the seed parameter (eachxxh3(part, prevDigest)folds one field into the running digest) andhashFileFromDiskreads the whole file before hashing — fine for source files (typically < 1MB each); the throughput win outweighs the memory hit.SCHEMA_VERSIONbumps tov15at the same time (PR #86 already tookv14for the tar.zst artifact layout): thefile_hashes.sha256column is renamed tocontent_hash, and the schema-mismatch path nowDROPs the stale tables beforeCREATE TABLE IF NOT EXISTSruns so the rename actually takes effect on existing DBs. Non-cryptographic by design — cache keys never need collision resistance against an adversary, just uniqueness across honest inputs. -
v15 → v16 (PR #86 series): artifact storage moved to a single compressed
<hash>.tar.zstper entry; the manifest.json entry was dropped (file fingerprints live in theoutput_filestable). -
v16 → v17: artifact narrowed to exactly
stdout+outputs/<rel>— nometa.json, no stderr (only successful runs are cached and their stderr is near-always empty noise). Local and remote layers transport the same bytes end-to-end; entry metadata lives solely in SQLite. -
v17 → v18: env-value folding in
Cache.key()switched its name/value delimiter from=to\0.${n}=${v}was ambiguous —("A", "B=C")and("A=B", "C")folded identical bytes. Env names containing=are unreachable from a real POSIX environ, so this is contract hardening rather than a field bug, but the key derivation’s stated invariant is unambiguous part boundaries — now it holds everywhere (file inputs already used\0). -
v18 → v19:
'^task'dependsOn expansion switched from transitive-deps to nearest-holder frontier semantics (Turbo/Nx direct-deps parity, plus vx’s sparse bridging through deps that don’t declare the task). Task graphs lose the redundant deep edges, so the filtered-upstream-hash set (step 10) shrinks for any task whose deps chain'^task'themselves — same inputs now derive a different key. Reachability/ordering is unchanged whenever holders chain'^task'(the universal pattern); a holder that doesn’t is now the documented stopping point. No on-disk format change. -
v19 → v20: input-file content hashes switched from xxh3 to git blob OIDs (Turbo’s technique). The bulk enumeration spawn became
git ls-files -s --others --exclude-standard—-slines carry<mode> <oid> <stage>\t<path>for tracked files, so one spawn yields the file list AND the index OIDs; a secondgit status --porcelain -zspawn prunes OIDs for paths whose working tree diverges from the index (renames drop both sides; stage>0 conflict entries and symlinks never get one). A clean tree’s key derivation does zero reads / stats / SQLite per file. Everything else falls back toCache.hashFile, which now computes the identical blob OID in-process (object format auto-detected viagit rev-parse --show-object-format, sha1 default) behind the existing mtime+size memo.SCHEMA_VERSIONbumps tov18in the same change: pre-v20file_hashes.content_hashrows store 16-hex xxh3 digests that must not leak into the OID domain through the memo. File-set visibility semantics are unchanged (verified: the-s --otherspath set is identical to--cached --others, including staged-but-deleted files and per-stage conflict duplicates).