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 sets cache: true, with
outputs.files and the task’s inputs.files. Omit cache and
the task always runs; no read, no write. inputs stands alone: a task
is keyed on it whether it caches or not, and its dependents fold that
key.
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”Core keys no package file: no package.json, lockfile or install
setting is a part of its own. A package.json keys a task as an input
it lists (step 11), a lockfile or install setting as a plugin’s key
material (step 9); @vzn/vx-lockfile’s bun(), pnpm(), npm() and
yarn() add both. Without a lockfile plugin an install moves no key, so
a JS workspace declares one (vx init and @vzn/vx-migrate write it).
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-v56', insrc/cache/key-fold.ts). Bumped when the key derivation or the artifact container changes, or stored bytes are wrong under an unchanged key. 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. -
Task config hash —
xxh3(JSON.stringify(hashableConfig(node.config)))of the evaluated task config. The projection is one field wide:exec.remoteis dropped, because it says WHERE a task runs and not what it does (§ What’s NOT in the key). Its keys are sorted all the way down, so the order a config or a plugin spells them in moves no key;vx lockwrites them sorted, so a run only checks. Captures:execblock (command, env declarations, timeout, persistent).dependsOnandinputs.tasksdeclarations.outputs.files,inputs.files,inputs.env,inputs.runtime/workspaceRuntimedeclarations (the strings themselves; their resolved file content / env values / command output contribute separately).outputs.cleanas the task runs it: a task without its own takes the workspace’s (after theconfigstage), and a workspacefalsefolds as the task’s ownfalsewould;trueor unset folds nothing, so those keys never moved. Unfolded, the entry an unclean run saved, leftovers included, was hit after the clean was back on.inputs.stability.strategyas the task runs it:VX_INPUTS_STABILITY_STRATEGY, else the task’s own, else the workspace’s, folded as the task’s own would be when it differs from what the task declares ('immutable'when it declares none). Unset or'immutable'folds nothing. It decides which saves are kept: unfolded, an entry'unchecked'kept was hit after the check was back on, and a formatter it covered never ran. The severity is not folded: it fails the task, it keeps no other save.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) — and to a command: a requested defaultbuildruns none and folds none (X-119). -
inputs.envresolved values —[name, value]pairs read from hostprocess.envat hash time (delimitedname\0valueso boundaries are unambiguous). Listed names get their current values; an unset name folds its bare name, with no\0, so unset and set-to-empty are two keys (the child tells them apart:passThroughleaves an unset name out). A name holding a NUL or another control character is refused at load, and a value holding U+FFFD at hash time (a value that is not UTF-8 reads as it, so two such values would key alike). -
inputs.runtimeresolved output —[command, output]pairs, whereoutputis the trimmed stdout of each command run viash -cin the project dir at hash time; a command with stderr (or a NUL in stdout) folds\0<stdout length>\0<stdout><stderr>, so bytes moved between the streams move the key. The runtime-output analog of step 5: the command strings are in the resolved config (step 3), their output is resolved live every run. Folded with the command count + eachcommand\0outputpair. -
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
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. The set isdependsOn’s, never the run’s selection: a dependency--exclude-dependencieskeeps from running is still folded, with the key a full run would derive for it (nx#35234), so the flag moves no key. Its outputs are whatever is on disk, which nothing proves current, so a task whose key folds one, and everything built on it, may hit but does not save (orchestrator/excluded-keys.ts); a task withinputs.tasks: []folds none and saves as usual. -
Plugin key material — the
{ name: value }pairs a plugin’skey(task, ctx)stage returned for this task, stored on the node as sortedplugin/namepairs and folded after the upstream keys and BEFORE the input files, ONLY when non-empty — so a workspace with nokeyplugin derives byte-identical keys to one before the stage existed (noCACHE_VERSIONbump when it shipped).vx whynames a changed pair asplugin <plugin>/<name>. Two plugins of one package (a plugin’s name is its package’s) that return one name are told apart as<name>,<name>#2, … in value order, so declaration order still keys nothing (item 1028).A plugin that reads a root file claims it (
VxPlugin.fingerprint).@vzn/vx-lockfile’spnpm()folds the project’s own resolved dependency closure frompnpm-lock.yamland the root package’s, sopnpm update foore-keys only the projects that depend onfoo— every project when the root does, since the root’s tools run from the rootnode_modules/.binon every task’s PATH;bun(),npm()andyarn()are the same for their lockfiles (npm()readsnpm-shrinkwrap.jsonoverpackage-lock.json, as npm does;bun()refuses abun.lockbwith nobun.lock, which it cannot read), and this repo declaresbun(). Each also claims its manager’s install settings (pnpm():pnpm-workspace.yaml,.npmrc, the rootpackage.json’spnpmfield;bun():bunfig.toml,.npmrc;npm():.npmrc;yarn():.yarnrc.yml,.yarnrc,.npmrc, the rootpackage.json’sworkspaces.nohoist,installConfig,resolutionsand dependency ranges) and folds each whole into every task. The key folds the lockfile, nevernode_modules, so a run after agit pullthat brought a lockfile change, with no install, would save a build made against the old packages under the new lockfile’s key; each plugin refuses such a run, naming the package and the install that fixes it (bun install), by the record the manager leaves of its install (@vzn/vx-lockfile’s README, “The installed tree”).The run reads each claimed root file once, before any task. A task in the root project may rewrite one —
pnpm installwithout--frozen-lockfileupdates the lockfile and the installed tree — and every key taken before it names the old one. So a task that may (unsandboxed in the root project, or granted a write over one) tells the run when its command ran, the run re-checks the claimed files then (onestateach, following a symlink as both reads do — anlstatmissed a lockfile rewritten through a link, item 760 — the bytes compared for any written since the read), and once one moved nothing keyed on the old bytes is probed or saved for the rest of the run, with one status line naming the file. Every reader after such a task is keyed late, not up front. Without it the run after a lockfile rewrite restored a build made against the old install, and a save filed a build against the new install under the old lockfile’s key, replayed whenever the tree went back (item 750,modules/fingerprint-watch.md). With nothing claimed, no root file is read or watched. -
Task platform — the platform core placed a task naming
exec.platformson (platform:linux-x64), folded ONLY for such a task, so each platform it runs on keeps its own entry and a task naming none keys as before.vx whynames it asplatform. -
Input files’ content hashes —
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 run’s up-front enumeration is three concurrent spawns,git ls-files -s -v -z(every tracked path, its OID and its cache-state flag),git status --porcelain -z -uall --ignored=matching --no-renames(dirty tracked paths, the untracked files and the ignored ones) andgit var -l(the clean-filter gate’s config) — so deriving these hashes costs zero file reads and zero per-file stats. Each trusted OID’s blob must be the size the index recorded for the file (A-60):git addunder a clean filter (core.autocrlf=true, atextrule) stores the LF blob of a CRLF file, and once the filter is gone git holds that stat-clean entry clean without re-reading it, so status and the filter gate (today’s config) both let the LF blob key the CRLF bytes. Which paths an index distrusts is a function of its entries, so the verdict is kept inblob_verdictsby a hash of the index file and the pathspecs: a warm run reads the file and one row (the--debuglisting and a lookup per entry cost 550 ms at 100,000 files). The key stands only for an index written before the run began and still in place after its listing, so agit addbetween the two cannot pair one index’s verdict with the other’s entries; any other run checks every blob. That check’s own listing reads the index again: one written since the enumeration’s stamp (an add rewrote a resized entry to one whose size fits) trusts no OID, as the enumeration does. A changed index spawnsgit ls-files -s -v -z --debugfor the recorded sizes, takes each still-trusted blob’s size fromblob_sizes(fixed for its OID), and asksgit cat-file --batch-checkfor the ones not yet known, one per core (256 OIDs or more each), beside the listing rather than after it (one took 30 ms over 2,505 loose objects, four 14). A whole-tree run starts the check as its cache opens, beside the config load and graph build. The sizes it learns are stored while the tasks run, not before the first one (40,030 cost 217 ms there at 9,603 tasks), in OID order by a native string sort (a JS comparator was 32 ms before the first task, 2026-11-07), and a cold cache skips the lookup. The check is by size, so a filter that keeps the size (afilterdriver such astr a-z A-Z), removed after an add, still leaves a blob that stands for other bytes: probed, the run hit the build of the filtered bytes. Only a read of every trusted file would catch it, the cost the index OIDs exist to avoid. After changing a filter,git add --renormalize .makes the index describe the worktree again. A re-listing mid-run, or a nested repository’s project, spawnsgit ls-files -s --others --exclude-standard -z .in the project dir instead, and its OIDs are not trusted: those files hash by content.--no-renames, because status pairs a deleted file with a similar unmerged path (a conflict mid-resolve) as its rename source and prints onlyUU <path>: the deletion went unsaid, the file kept its index OID, and the run hit the output built with it (A-59).Ref storage is not a key input: every ref vx reads comes from a git command, so a repository in reftable storage (git 2.45) keys every task as its files-backend twin. A sha256 repository keys from its 64-hex index OIDs, and a dirty file is hashed in-process as sha256, so Git 3’s defaults (reftable and sha256 together) run, hit and select
--affectedas a sha1 repository does (tests/git-reftable.unsafe.test.ts).A submodule or an embedded repository is enumerated by its own git: the workspace repository lists the nested one as a single entry (a gitlink, or
dir/when untracked) and none of its files, so vx replaces that entry with the filesgit ls-fileslists inside it (one spawn per nested repository per run, nested ones recursively), and those files hash by content rather than by index OID. That holds for a nested repository inside a project (vendor/libunder a**glob — until 2026-09-27 its files never reached the key, and an edit there was a hit on the old output), for a project inside one, and for aworkspaceFilesglob. A gitlink whose directory has no.git(a submodule never initialised, or one whose.gitwas removed to vendor its files, the gitlink left in the index) has no repository to ask andgit statussays nothing of it: its files are listed by a walk and hash by content (A-61), so an empty one folds nothing.--affectedfollows the same shape — git reports the nested repository as one changed path, and every project under it is selected.A task with no
cache: truederives a key too, from itsinputs— its dependents fold it (step 8), as Turbo and Nx hash every task. That key is for its dependents only: the task always runs, and no cache, local or remote, is asked about or handed anything under it; its log goes to the run’s own store (run/<id>/<task>/log). A lookup followscache, never a key (tests/uncached-no-lookup.test.ts). One that also declares noinputsis keyless: no key can stand for what it read, so a key that folds it is this run’s alone, and every task behind it misses and saves nothing. A task reading**with nocachefolds its own outputs too, since it declared none: its key moves whenever it writes a file git does not ignore, so a cached dependent misses once more after the upstream’s first run. Ignore the outputs in git, narrow itsinputs, or declare the block (tests/uncached-upstream-key.test.ts).Declaring no outputs, such a task may also have written anywhere in its project, so what the run learned about that project at its start — the git listing and the index OIDs — is dropped once its command exits, pass or fail, and a later key re-lists and hashes by content. For the same reason a task after it in the same project (
dependsOn, even withtasks: []) is never keyed up front by the local short-circuit or the remote prefetch. Without both, an uncachedgenthat copies a seed into abuild’s declaredconfig.jsonreplayed seed B’s build under seed A (turborepo#13788, item 743). A sandbox narrows the reach to its write grants: none reaches nothing, and a grant elsewhere in the workspace reaches every project. An unsandboxed write into another project crosses a project boundary and is not tracked, as for a cached task’s undeclared write (tests/undeclared-writes.test.ts).A task with
cache: truemay still rewrite its own inputs in place — a formatter declaringoutputs: []— and a same-project reader after it whose key does not fold its key (tasks: [], or a filter that leaves it out, on every path) is not keyed up front either: its key would be taken over the bytes before the rewrite, and seeds A,B,B,A replayed B on the fourth run (item 750). A reader that folds the rewriter’s key keeps its up-front probe — that key names the rewriter’s inputs, which are what it may rewrite. A cached task writing a file that is neither a declared output nor its own input is out of contract: a hit restores only what it declared.A file whose name is not valid UTF-8 (Linux allows any byte but
/and NUL) cannot be opened from a string, so it cannot be hashed: a task whose globs select one is refused by name, and so is a task whose declared outputs hold one, rather than the file dropping out of the key or the artifact without a word (turborepo#9345); one found by the clean before a run or a restore is refused as one vx cannot clear. The task readsfailed (cache save failed)(its command exited 0, with its real time) orfailed (output clean failed)(its command did not run), never an exit the command did not return. Rename it, or exclude it with a negated glob.Your globs are a filter over the set git reports, so a broad glob never reaches a gitignored file. An entry that names one outright does: a literal (
.env.local,gen), or a glob whose fixed prefix git ignores (dist/**, or agenwhose every file it ignores, where it lists nothing), walked on disk under that prefix only (2026-10-16). Such a file is hashed from disk with the blob formula an untracked file gets, and is never stored or restored.--affectedreflects only what git sees, so an edit to one never selects a task on its own (as in Nx);vx watchre-runs it on an edit there. A glob whose last segment matches an ignored file directly (.env*over an ignored.env.local,config/*.json) still leaves it out, butvx lockand the miss name it:inputs leave out git-ignored .env.local (matched by .env*), with the literal that folds it. A**glob is not asked, so an ignorednode_modulessays nothing. A glob matching nothing stays silent while another glob of the task matches a file, and so does a literal naming a path that does not exist, until it does. When no glob matches (a literal does not count) or the inputs resolve to nothing, the miss warnsinputs matched no files (nope/**)(see below). An ignored directory named outright folds every file under it; an empty one folds nothing.A task glob is a standard glob:
[ab]is a character class, as inBun.Glob, Turbo and Nx, and a route directory is escaped:app/\[id\]/**foldsapp/[id]. Bare,app/[id]/**is the class and foldsapp/i/…andapp/d/…, never the route;vx lockrefuses it whenapp/[id]exists. When an input or output glob matches no files and, read as a literal path, names one that exists, the “matched no files” warning says so and gives the escaped spelling.An index OID is only trusted where git stores the worktree bytes verbatim, so the enumeration prunes it three ways:
git status --porcelaindrops paths whose working tree diverges; the-vflag on the samels-filesspawn dropsskip-worktree/assume-unchangedentries, whose OID says nothing about what is (or isn’t) on disk; and, once those spawns return, a clean-filter gate (agit check-attrspawn only when the gate needs one) drops paths wheretext/eol/ident, afilterdriver,working-tree-encodingorcore.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. And when the repository’s config weakens the statgit statusjudges by —core.trustctime=false, orcore.checkStat=minimal(whole-second mtime and size) — no index OID is trusted at all: a same-size rewrite that keeps its mtime (cp -p,tar -x) reads clean to git, and until 2026-09-27 (A-6) kept the old bytes’ key. Every file is then hashed through the memo below, which keys on ctime and inode.core.ignorecase=trueon a disk that does not fold case (a.gitcopied from macOS to Linux) madegit statustake a newa.txtfor the trackedA.txt, so it entered no key; under that setting vx looks upHEADasheadonce, and on a disk that tells them apart asks git again with-c core.ignorecase=false, as every later listing under the workspace does (--affected, watch’s inventory,vx lock’s discovery and ignore checks). vx trusts whatgit statuscalls clean, so acore.fsmonitorthat misses an event (a dead daemon, a network share) keeps an edited file’s old key: a stale hit.Even with ctime trusted, git reads it to the second, and its racy rule looks at mtime alone. So a same-size rewrite that keeps an older mtime, made in the second the index recorded the file, also reads clean. So an entry whose recorded ctime, not its mtime, reaches the second its index was written is hashed instead (2026-11-04). The stamps come from git’s own
ls-files --debug, read with the blob-size check below and kept in its verdict, so a warm run reads one row and does no extra work.Every pruned path, plus untracked files, 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). A file changed withinFILE_HASH_RACY_MS(50 ms) of its stat is hashed but not memoised; a ctime with no sub-second part, as a file system that keeps whole seconds writes it (ext3, HFS+, FAT/exFAT, some NFS), widens that window by a second, and an even second by two, since FAT32 keeps even seconds; a rewrite later in the same stamp keeps it (racyWindowMs; until 2026-09-27, A-2 and A-38, such a rewrite of the same size was a hit on the first bytes’ output). Every such window compares a file’s stamp, on its file system’s clock, with a moment vx read from its own, so a run first measures how far the file system runs behind (one file written and removed in the cache dir) and widens every window by a lag past the kernel’s tick (20 ms): a WSL2 VM drifted from the Windows host that stamps/mnt/c, a Docker Desktop bind mount or an NFS server dated an edit made while a command ran before the command, and the entry filed the edit’s output under the old bytes’ key (until 2026-10-16). 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, identity)pairs, sorted for stability across OSes and walk orders. The identity is the OID prefixed by the file’s git mode unless that mode is a plain file’s:100755:<oid>for an executable,120000:<oid>for a symlink, the bare OID for 100644. The index gives the mode for a clean file, and the stat gives it for any other file (owner execute bit). A blob OID holds no mode, so until item 887 achmod +x, or a symlink swapped for a file holding its target string, kept the key thatgit statusand--affectedsaw move. Undercore.fileMode=false(WSL’s DrvFs) git reports no chmod, so there a clean file’s mode comes from an lstat too, and its OID from the index (item 1076), and--affecteddiffs withcore.fileMode=true(likewisecore.trustctime=trueandcore.checkStat=defaultunder a weakened stat), so it selects what the key sees. Each of these settings is read as git reads a boolean:false,no,off, the empty value or a zero integer (0,00) is false, and a bare key is true.A symlink folds as git folds it, the blob of its target string (its mode-120000 index OID), and then the content behind it, as Nx reads a linked file where it points: a file’s identity, or for a directory every file beneath it at any depth, tracked or not (
.git,node_modules,.vx/cacheand.yarn/cacheaside, as Nx’s walker skips them), a link among them followed in turn. The target may be anywhere: in the project, elsewhere in the workspace, outside it. Retargeting a link changes the key, to equal bytes too; an edit behind it changes the key. A dangling link, or a loop of links, keys its target string alone.--affectedselects a link whose target changed (a file the diff names, or one under a linked directory) as a changed path of its project, and a target git cannot diff (outside the workspace, git-ignored) on every run;vx watcharms each link’s target and names the link when it changes (linked-inputs.ts). Nx does not enter a linked directory; vx does, since the task reads it. A glob whose static directory passes through a link (lib/**overlib -> ../shared) takes the link as an input: git lists a linked directory as the link alone, so the glob matched nothing and an edit behind it was a hit (2026-10-25); each brace alternative counts alone ({lib,src}/**). A link to a directory below a glob’s fixed prefix (src/**/*.tsoversrc/v -> ../shared,**/*.tsoverlib -> ../shared) is an input the same way whenever the glob could match a path under it: its name alone matched no pattern, and an edit behind it was a hit (2026-10-28). So is one in a gitignored directory an entry names (gen/**/*.tsovergen/v -> ../shared), which the walk on disk meets the same way. A!entry applies to the paths beneath such a link, as beneath a real directory, never to its name:!src/**/*.skipkeepssrc/l.skip -> ../top, as it keeps every file of a realsrc/d.skip/, and only a negation that takes back the whole tree (!src/l.skip,!src/l.skip/**) leaves the link out. Asked of the name, the negation dropped every file behind it, and an edit there was a hit (2026-11-10).
The composition is seed-chained (xxh3(part, prevDigest)) with a
label prefix per field, so two different field layouts can’t collide.
Bun’s xxHash3 reads only the low 32 bits of a seed, so xxh3 feeds the
seed forward (xxHash3(part, seed) ^ seed) and the chain carries all 64
bits of state from step to step (item 682; tests/hash-chain.test.ts).
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 against
output_files, a millisecond-mtime check against this machine’s stamp), extraction is skipped entirely — the outcome reports up-to-date (restored: false). Mode and millisecond mtime ride the artifact’s own.vx-meta.jsonsidecar — recorded from a stat while packing — so the index rows carry the identical values whether the entry was saved locally or ingested from a remote. The check also requires the file’s inode, ctime and mtime to equal the ones this machine recorded after the save or restore that last wrote it (recordOutputStamps,output_stamps): a restore dates the files it writes now (step 3), so the stamp, not the row, holds their mtime. Size, mode and mtime alone cannot tell two entries apart when their outputs carry one fixed mtime (tar -x,cp -p,SOURCE_DATE_EPOCH) and one size: a v1 → v2 → v1 round trip reported up-to-date with v2’s bytes on disk (item 886). No task can set a ctime, and a forged mtime (touch -r) moves the ctime too. The inode proves less than it seems: a restore unlinks the file and renames a new one in, and ext4 hands the new one the freed inode (measured, item 941), so ctime is the guard. A row with no stamp (an ingest from a remote, or a file that had changed when the stamp was taken) is never current: the hit restores and stamps. A file whose ctime has no sub-second part (a file system that keeps whole seconds: ext3, HFS+, FAT, some NFS) is not stamped while that second, widened as the racy windows are, is current: a same-size hand edit later in the second of vx’s own write kept every field and read up-to-date over the wrong bytes on every later run. The next hit finds the bytes equal and stamps then. The residual: ctime ticks on the kernel’s coarse clock (4 ms at HZ=250), so a same-size rewrite inside the tick of vx’s own write, in place or as a new file that took the same inode, still matches. - Otherwise the
outputs/<rel>(+workspace-outputs/<rel>) entries are extracted from the artifact (its bytes inline in the index, read in one snapshot with the entry’s rows, or<cacheDir>/<hash>.tar.zst) in place: every entry is staged in the workspace’s.vx/tmp/(never an output folder), and only once the whole archive has staged is each renamed over the file it replaces. A folder missing on the way (a cleaneddist/) is built whole there and renamed in once, so a reader sees it complete or not at all; one rename per file, each across directories out of one staging directory, doubled a 5,000-artifact restore’s kernel time. A file that already holds the entry’s bytes (compared byte for byte, up to 4 MiB) is left in place, only its mode set. Every file the restore writes is dated now, as the task’s own write would be, never with its saved mtime, and no file’s mtime moves back: a saved mtime predates outputs written since, so a make-style dependant that keeps its outputs (clean: false, a[ out -nt in ]check) took a restored, changed upstream as older than its own output, skipped the rebuild and saved the other upstream’s bytes under the new key (until 2026-11-08,tests/restore-mtime.test.ts). A target on another filesystem is first copied to a temp beside it, every such copy before any rename, so a copy that fails (a full disk there) changes nothing; a file takes the group its directory gives new files (setgid on Linux, any directory on macOS), ACLs are not copied. The next run removes the staging a killed run left. A process reading the output tree during the restore never finds a declared output missing or half-written (the tree form’s copy into a folder that stands excepted, see § Storage layout), and a restore that fails first (a full disk, a damaged entry) leaves the outputs as they were. Then the declared outputs the entry does not hold are removed — see § Strict output ownership. Only a found output that blocks a rename (a file where the entry needs a directory, or the reverse) goes before. Turbo also restores in place; Nx deletes and copies each file, and a reader meets a missing or short file. - The captured output is replayed to the live terminal via the logger — stdout and stderr in the order the task wrote them, both in the frame on vx’s stdout (cli.md § Output format) — so the framed block looks like a fresh run.
- 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, or, when nothing any key folds has moved since a run of the same selection, reading that run’s keys back whole:orchestrator/run-keys.ts) 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 (compared by
literal prefix, less what its own ! inputs take back whole; one with
undeclared writes reaches every input), whose
own outputs.files meet another same-project task’s (it would restore
into that tree beside the producer’s restore), or whose
inputs.workspaceFiles could reach any upstream’s outputs, has a
preliminary key and stays exec-tier / dep-gated. Any
outputs.workspaceFiles producer upstream also makes a dependent’s key
preliminary, whatever that dependent reads: a root-anchored output is
boundary-ignoring by design, so it can land inside the dependent’s own
project dir, where an ordinary project-relative glob reads it. Such a
dependent is therefore in NEITHER tier — it is excluded from probe
reuse as well as from the restore tier, because execute-task reuses a
preProbed hash verbatim, so probe reuse is itself a stale-hit path
when the key is preliminary. On top of that, an outputs.workspaceFiles
declaration keeps every task whose project directory its static prefix
can reach out of the restore tier — edge or no edge, because a
root-anchored output can land in any project’s directory — and every
transitive dependant of one with it (their up-front keys fold a
preliminary key); a glob with no literal prefix reaches every project.
Before item 584 one such declaration emptied the tier graph-wide.
vx lock refuses a task whose input globs can match another task’s
declared outputs (X-54, no opt-out), unless git ignores those outputs
whole (and the entry does not start inside them) or the producer is a
dependency its key does not fold, whose bytes it then hashes after the
producer ran, so the clauses on declared outputs fire only for such a
dependency read or a pair the check cannot decide. An output git ignores whole is no reach either:
a key reads only what git lists, so a **/* reader beside a gitignored
**/dist/** producer keys up front, the same key before and after the
build. The same holds for a workspaceFiles reader over another
project’s ignored output. The run asks git once, beside its walk and only when a reader
reaches an output: one git check-ignore over each such
output’s directory (dist/, and a fresh __vx_probe__/dist/ for a
glob that starts with **), and the walk’s listing must hold no file
the glob matches (a tracked output, a directory a ! rule takes back);
anything git cannot answer keeps the key late. A root glob with no
literal directory whose one wildcard part is a name of * and ?
(*.tgz, **/*.tsbuildinfo) is asked as a fresh name it matches
(__vx_probe__.tgz, and __vx_probe__/__vx_probe__.tgz under **), and
counts as ignored whole only when the rule git names for each is that
name pattern itself (*.tgz, **/*.tgz, or /*.tgz for a root-only
glob): pkg-*.tgz or ?.tgz ignore some names, not every one. Any other
glob with no literal directory (*.{js,ts}) stays undecided, an overlap. Undeclared writes, an
outputs.workspaceFiles producer upstream, a rewriter the key does not
fold, and runtime probes after a writer still leave a key preliminary.
With a
remote declared the short-circuit probes the local store alone, so a
local hit still restores ahead of its dependencies and no remote GET
sits on the critical path (remote prefetch asks the remote for the
rest). It never fires with local reads off or on a local cache
that holds no artifact (Cache.holdsNothing: every probe would miss,
and a cold 1,090-package run started its first task ~90 ms sooner
without it), 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 (
cache.concurrency.download). The prefetch ingests a hit into the local cache; misses/errors degrade tofalse. - 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.
A current tree: when a warm hit restores nothing
Section titled “A current tree: when a warm hit restores nothing”A hit whose outputs are already on disk should cost a few stats, not an
extraction. Two checks decide, both against rows the cache recorded when
the tree last matched the entry (output_files, and since 2026-09-03
output_dirs):
- The set. The files under the declared output globs must be exactly
the entry’s files — no strays, nothing missing — because a restore
wipes and rewrites the declared outputs, and a stray left in place
would be a stale file the hit silently kept. Until Wave 6 this was a
glob walk on every hit (0.36 ms each; 365 ms of CPU on a warm
1000-project run). Now every glob names the directory its matches
live under (
outputDirPrefixes):<dir>/**and a bare literal<dir>are that tree (a literal that names a file is recorded as a file,mtime_ms-2, and holds while a regular file stands there, its bytes being the per-file check’s), any other glob the tree under its static prefix (dist/*.jsisdist), and a root-anchored one (Turbo’s**/dist/**) the whole project. The cache records every directory under each with its mtime after each save and restore; on the next hit, unchanged mtimes on all of them prove the set unchanged, since a file added or removed anywhere the glob could see bumps its parent directory, and a new directory bumps the recorded directory that contains it. The rows are taken per task and written together — one transaction at the first read, at prune, at stats or at close, since item 622; a thousand commits at run end were the whole snapshot stage before — and a reader in the same process flushes them first. A save’s or restore’s snapshot is taken at run end, once the directories are past the racy window, and by then a later task or another process may have written into them: the walk collects the files it passes and records nothing unless the files the globs take there are the entry’s rows (item 1087), so a source file beside the outputs is no stray. A glob that leaves the project, a missing row set (a remote ingest records none), a moved directory, or more than 8,192 directories (OUTPUT_DIRS_CAP, recorded as one never-current row,mtime_ms-3, so the hit does not snapshot it again) keeps the walk — and a walk that proves the tree current records the directories so the following hit can skip it. - The files. Every recorded file’s
(size, mode)and the stamped(mtime-ms, inode, ctime)must match. This never left: the directory rule only replaces the enumeration, not the fingerprint.
The trade is the one every mtime-based skip accepts, already documented
for files: a deliberately forged directory mtime (touch -r) hides a
stray. Both directions are pinned in tests/output-dirs.test.ts.
Hard invariants of the remote prefetch:
- Remote-only. This entire path is gated on a
LayeredCachebeing configured. A local-only run never prefetches; its up-front keys and local probes are the short-circuit’s (§ Local restore tier). The prefetch never adds an upfront localget/isOutputsCurrent/ stat pass. - Stable keys only. A task with a preliminary key (§ Local
restore tier’s stability gate: undeclared writes, an
outputs.workspaceFilesproducer upstream, or input globs that match the outputs of a dependency the key does not fold) is not prefetched: its key would name the wrong artifact, and it resolves via the lazy read-through inexecute-task. Instability propagates: a task that folds an unstable upstream is itself unstable. When in doubt, skip. - A lookup waits on no probe. A task that asks for its key while
the batch probe is out GETs it at once: waiting made a remote hit two
round-trips (
layered-cache.test.ts› “a get() while the batch probe is out GETs at once…”). - The first pool of keys, pulled at once. The first
cache.concurrency.downloadstable keys in graph order are pulled as the batch probe is sent, not after it answers: on CI the probe took ~490 ms and a GET ~250 ms, so a hit’s dependant waited on a GET of its own. A local run against a fake remote at those delays went from ~810 ms to ~330 ms. At most that many GETs can 404 (remote-prefetch.test.ts› “pulls the first pool of keys…”). - Kept hits, trusted at once. What a
hasManysays a remote holds is kept in the index’sremote_hitstable (shared by every worktree), so a later process trusts it:vx run --dryfollowed byvx runprobes once in each, and the run GETs the kept hits before its own probe answers. Only hits are kept. A plain run waits on the remote for nothing; an executor that plans its machines (demand, vx-dte) plans after the batch probe answers, since a key with no row may still be held (2026-10-23: “We wait for remote before we plan. We need data to plan. We don’t wait for remote without dte”;remote-prefetch.test.ts› “a kept hit is pulled before the probe answers…”). When a remote’s batch gives no answer (Vercel’s on main f71b377: every task then counted as left to run, and 11 of 15 workers asked were cancelled), such a run asks that remotehasfor each key instead (remoteHasEach,concurrencyat a time); a failed one leaves the run with no answer, as before. Each run still askshasManyfor every key the local store lacks, in the background, and its whole answer rewrites the rows it asked: new hits are added, gone ones dropped. A GET every remote answers with nothing drops the row too, and the task runs (orchestrator-remote.test.ts› “plan then run…”, “a kept hit the server dropped…”). - 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-policy=remote:) fires no prefetch. With remote writes on (--force), the same up-front keys go to onehasManyper remote the run writes but does not read and that can batch, in the background: no task waits for it, only each upload does. An upload of a key that remote holds is skipped (an entry is immutable) and is counted nowhere. A key the probe did not cover, or a remote with nohasMany, askshasfor that key inside its upload job; a probe that fails uploads, as before. - No
hasbefore aput. An entry is immutable: a remote’sputof a key it holds keeps the stored bytes, and a wire that would overwrite checks inside (turboCache()asks HEAD unless a 404 or a batch answer this run said the key is absent;reapi()looks the action up beside its upload and keeps a stored result; Nx servers answer 409). Aputtoldreplace(the stored bytes did not read back) writes over them, or, on a wire that cannot, returnsfalse(vxCache()sends nothing, as the server never replaces an artifact it holds, and says so once;nxCache()meets the 409).ctx.cache.putand a run’s own entries send theputalone. --drybatches too. The plan derives every key first, then asks each remote withhasManyonce, and keeps the hits for the run that follows; a remote without it is askedhasper key.- 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. A key whose remote bytes do not read back (a
CorruptArtifactError, core’s or a layer’s own digest or signature check) is uploaded again after the task re-runs, held or not, withreplace, so the remote heals. A remote that cannot replace it (vxCache(),nxCache()) keeps missing the key until it expires; the upload is not counted as landed, so no DTE worker is told it did. - Said once, and said whole. The warning names the operation
(
probe,download,upload), the artifact and the layer’sendpoint, with a URL’s credentials, query and fragment dropped:upload <hash> to <endpoint> failed: HTTP 413. One failure class (the error’scodewhen it has one, else its message with the hash taken out) is said once per run, and the repeats are counted at close:2 more requests failed the same way: <cause>, the cause’s hash written<hash>.
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 are warned once per failure class and are otherwise ignored (the task
already succeeded; the only loss is the remote entry).
An upload is the outputs as the task wrote them; secret masking does not reach file contents, so a task whose outputs embed a secret is one to leave uncached (security).
Planning probes (--dry)
Section titled “Planning probes (--dry)”The planning path (vx run --dry) predicts 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. Locally the probe reads whether the entry’s
row is there and stats the artifact, never the row itself: the whole
row, its stored stdout included, made a 200-task plan over 1 MB outputs
230 ms against 28 (min of 11, 2026-10-02).
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 its task artifact
get/save and the file-hash memo’s writes (never recordRun / stats / prune / ingest
/ key derivation); the LayeredCache additionally gates its own remote
read-through (remoteRead), upload (remoteWrite), and prefetch
(remoteRead). The orchestrator derives two booleans per task:
willRead = task is cached AND it is not remote-only AND it folds no keyless task AND (localRead || remoteRead)willWrite = task is cached AND it is not remote-only AND (localWrite || remoteWrite)
A task reads the cache only when willRead, saves only when
willWrite, it folds no keyless task and no task it depends on is a
remote-only no-op (an exec.remote: 'only' task no remote executor took:
it ran nothing under the key a real run has), and cleans its declared outputs
before exec only when willWrite. A keyless task runs a command and
declares no inputs: its key is unknown, so a task that folds it
(through dependsOn, directly or through a group) always misses, and
a cached one warns that it is not saved, naming the task with no
inputs its key folds. Remote-only is an exec.remote: 'only' task placed on a
remote executor: it never touches this machine’s disk (no probe, no
restore, no output clean, no local save), and its result lives in the
remote executor’s own store.
The CLI maps three flags to a policy (precedence: start all-on → apply
--cache-policy → --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, outputs cleaned;
a key already stored, locally or in a remote, is kept as it is); --cache-policy=<spec> = explicit per-layer control.
Off CI, unless a spec names the remote, remoteWrite starts off: a key
holds only declared inputs, so a laptop’s undeclared tool or file could
replay on CI. See docs/cli.md § Cache control.
The setup we recommend for a shared remote: the write token only in the
default branch’s CI (a protected environment), a read-only token on
laptops and in pull request jobs, and the branch’s cache scope
(github() names it, a laptop derives it) so a PR’s own writes, when its
token allows them, land apart. The token
is the boundary; the policy and the scope are the client’s half
(docs/security.md § Cache poisoning).
One subtlety: when localWrite is off but remoteWrite is on
(--cache-policy=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 cache: true AND at least one write axis
is on):
outputs.files(andoutputs.workspaceFiles) are resolved against the project dir / workspace root.- The per-component input fingerprint is the one captured before the
command ran: on a miss
describeTaskInputsfillscaptureIntoonce (the same call yields the key re-checked below), so no secondcomputeTaskHashruns. - The artifact — one
stdoutentry (bounded: the first and last 8 Mi characters of the task’s output, the dropped middle named where it was), theoutputs/<rel>(+workspace-outputs/<rel>) entries, the.vx-meta.jsonsidecar and the.vx-sumchecksum — is packed in-process (no staging dir, no subprocess) into a single<hash>.tar.zstand validated. One of at most 32 KiB compressed (INLINE_MAX, almost every artifact: a one-filedist/is a few hundred bytes) stays in memory; a larger one is written to a temp name. - One
BEGIN IMMEDIATESQLite transaction upserts a small artifact’s bytes intoartifacts(v33), or renames a large one into place and deletes the key’s inline bytes, and upserts theentriesrow (taskId, command, exit code, duration, size, stdout, timestamps), theoutput_filesfingerprint rows, and the entry’s oneentry_inputsrow (INSERT OR IGNORE). Saves that reach this step in one event-loop turn share the transaction, each in a savepoint of its own: a refused write undoes only its own rows and artifact. A BEGIN and COMMIT per save were half the index time on a cold run’s main thread (500 saves, 81 of 164 ms, 2026-11-03). Concurrent readers see either no entry or a complete entry — never a partial one — and bytes and rows go live together: the rename waits for the write lock, so no other writer’s rows land beside it, and a commit that fails takes the artifact back out (the key then misses). Until 2026-09-27 (A-3) the rename came first, and a commit refused past the busy timeout left the new bytes beside the old rows: every later hit on the key failed the task as a corrupt artifact. A--forcesave of a key the index already holds is skipped, so--forcerewrites nothing. A re-save (a key dropped as corrupt, or two writers that both found it absent) first moves the previous artifact aside under a temp name and unlinks it after the commit: ext4 flushes the incoming file when a rename replaces one, 0.55 ms a save on the main thread against 0.04 (a forced 1,000-task run 4.01 s → 3.46 s, 2026-10-02). A reader probing between the two renames misses. An inline save moves aside whatever file its key has, the same way (a rename that finds nothing on a first save, in place of a statement asking the index). As of every commit a key’s bytes are in one place, and a reader takes the inline bytes first, so a file an older vx wrote beside them is never read and the sweep takes it. An inline save writes no file at all: no temp, no rename, and a crash leaves nothing behind (the bytes and the rows are one transaction in one database file).
The key is re-checked before the save (item 743). It was taken
before the command ran — at the task’s start, or up front by the local
short-circuit — and the save files the outputs under it, so the inputs
must still be what it describes. Two checks, in order. The key
re-derived just before the command (describeTaskInputs, which the
executor seam needs anyway) must equal it. Then each input file is
lstated once: a file whose ctime falls
after, or within FILE_HASH_RACY_MS before, the moment its digest was
learned — the enumeration’s start for an index OID, the describe for a
hashed file — is hashed again and compared, and a file that is gone has
moved. A file written after the describe (just before the command)
has moved whatever it holds (a whole-second stamp counts as any moment
of its second, an even one of two, in both checks): an input changed and changed BACK while the
command ran matched its digest again, and its output, built from the
edit, was filed under the key and restored over the original (item
1015). That moment is read off the file system’s clock (a one-byte write
to a file the run holds open, unlinked, in the cache dir, fsClockNow), not Date.now(): Linux
before 6.13 stamps from its coarse clock, a tick behind, which dated an
edit in the command’s first tick before it. A coarse clock gives a write
in that moment’s own tick the moment itself, whether an upstream’s just
before or the command’s just after: it is the upstream’s when the
describe’s stat of the file saw that ctime already, else the command’s
(a tracked clean file, keyed by its index OID, is not stat’ed there).
The residual: an upstream’s write, the describe and an edit by the
command all inside one tick, which a kernel since 6.13 stamps apart. A symlinked input is also followed again: the key folds the
content behind it, which an edit changes without touching the link, and
a different digest there has moved. When one moved, the task’s result stands but no entry is saved,
one status line names the file ([vx] app#format: `packages/app/a.ts` changed after its key was taken — …), and the run forgets what it
knew about the project, as after an uncached task (§ Cache key
derivation, step 11). This covers a formatter rewriting its own input
(turborepo#10111) and a user’s edit mid-run (turborepo#1146). A file
whose ctime alone moved during the command, its bytes and mtime as the
key found them (a hard link such as cp -al src dist, a chmod, a chown),
is withheld too, since an edit undone with its mtime put back looks the
same; its line says so (… `app/src/a.txt`'s ctime moved while the task ran, but not its bytes or mtime …) and names the ways out: copy instead
of linking, or inputs.stability: { strategy: 'mutable' }. A task
left remote (--cache-download=none) is held to the same checks: a moved
key still lets a local consumer fetch its outputs, but the fetch saves
no entry; until X-123 it saved them under the old key. A task
that rewrites its own input to the SAME bytes (sed -i always writes)
is not saved either: its write cannot be told from an edit reverted
mid-run, so it pays a re-run each time rather than risk a stale entry.
The status line names the ways out: a file the task generates
(TanStack Router’s committed routeTree.gen.ts, rewritten by every
build) is declared as an output; for a file the key read, also
inputs.stability: { strategy: 'mutable' } below, the one a task that
rewrites its sources in place takes (a formatter cannot declare them as
outputs: vx deletes outputs before a run, and vx lock refuses an
output entry that takes back an input entry whole). That holds on CI too: a
task of the same project running alongside can change an input and put
it back. A task that rewrites its own input to identical bytes (tsdown’s
package.json) sets inputs.stability: { strategy: 'mutable' }, and a write
during its command is then judged by its bytes, as an earlier write is;
different bytes still withhold the entry, and so does a write made while
another command that may write the file ran (command-windows.ts: one
in the same project, or one with a write grant elsewhere in the
workspace), named on the line as while <task> ran. VX_INPUTS_STABILITY_STRATEGY=mutable
makes that the default (2026-10-12); 'unchecked' skips the
check, and severity: 'error' fails the task instead of warning
(docs/schema.md). A file ADDED under an
input glob since the listing the key filtered (addedInput) withholds
the save the same way, named on the same line: the listing’s
directories a glob reaches are lstated, one whose ctime moved since
the listing is read, and a new name the declaration matches (or an
unlisted directory) is asked of git, which alone knows what is ignored;
a literal input absent then and present now is asked too, and so is
a new link to a directory a glob may match below. Not seen: a
file added inside a directory that held no listed file before (only
ignored ones, or none) and was not itself created mid-run. Cost: one
lstat per input and per reached directory on a miss that saves, a
readdir per directory that changed, and a git ls-files only when a
candidate turned up; a hit runs no command and checks nothing.
The line for a file that appeared (added, or a literal input absent
before) does not say to declare it an output: vx deletes an uncommitted
output before each run, so a snapshot vitest writes on its first run
(src/__snapshots__/*.snap) was written anew every run and a changed
value passed. It says to commit a file the task checks against, or to
exclude a generated one from the inputs ('!src/gen.ts') and then
declare it in outputs. A committed test snapshot the task rewrote
(jest -u; a path under __snapshots__/ or ending .snap) gets the
same word, commit it, never outputs or 'mutable'.
A sandboxed task none of whose write grants reach the file (a glob by
its whole directory) could not have written it, so its line gives no
outputs or 'mutable' advice: it says another process changed the file
during the run (an editor, a checkout) and that the next run runs on it.
A file listed but gone before the key hashes it (an upstream that
deletes a file its dependant’s globs match) is keyed as absent, not
read: the dependant failed as internal error … ENOENT on every run
until A-55. Absent stays unmoved while it stays gone. So is a
declared path under a parent that is now a file (conf/b.txt with
conf a file, a tracked directory replaced by a file): its lstat
answers ENOTDIR, which failed the key as an internal error until
2026-11-10.
Its dependants whose keys fold its key save nothing either,
transitively: that key does not name the bytes they built from ([vx] app#use: ran over app#gen's outputs, which its key no longer describes — …). Until
2026-09-27 (A-12) they saved, and once the input was put back they hit
the edit’s output.
A claimed root file a task rewrote since the run read it (§ Cache key
derivation, step 9) withholds the save the same way, and no key
taken before it is probed or restored: a hit the up-front probe found
goes back to the scheduler and runs once its deps are done. Until X-124
that hit restored the old install’s bytes.
A miss that ran here and saves nothing — it failed, the cache
policy writes nothing (--cache-policy=local:r,remote:r), an upstream failed
under --continue — runs the same input re-check and, when an input
moved, forgets the project as above; otherwise its declared outputs are
resolved and marked in the git snapshot exactly as a save marks them.
Before item 750 only a save marked them, so a same-run reader keyed
from the snapshot’s index OID for an output (or for an input the task
rewrote) restored the bytes from before the command.
A declared set that resolves to nothing is said on the run’s
status line. inputs matched no files (lib/**) — the key would
not change when the source does — is said on every miss of a cacheable
task, right after describeTaskInputs and before the command, whether
or not it saves. It is said too when every wildcard glob matched nothing
and only literal files did (scr/** beside the package.json a
lockfile plugin appends), naming the wildcard globs. outputs matched no files (build/**) — an empty
artifact was saved and a later hit restores nothing — is said on the
save path, and when a deferred fetch (--cache-download=none) lands
nothing. It is said too when every wildcard output glob matched nothing
and only literal files did (dist/** beside the tsconfig.tsbuildinfo
turbo()/nx() add to an incremental tsc task): outputs matched no files (dist/**) — a later hit restores only the literal files (tsconfig.tsbuildinfo). Both are almost always a glob
against the wrong directory; the output line names one other cause when
it applies: a sandboxed task with no exec.sandbox.allow.write, whose
writes never reached disk, or a workspaceFiles directory that is a
symlink out of the workspace, whose files vx drops as outside (a project
output directory linked out of the project refuses the task instead, X-88). outputs.files: [] is a deliberate cached
no-op and says nothing; a task with no cache: true is never checked.
Undeclared state is not saved (orchestrator/undeclared-state.ts).
A git-ignored file in the task’s project that is neither an input nor
an output survives the clean before a miss, and a hit never restores
it. An incremental tool keeps its state there: tsc -b writes
tsconfig.tsbuildinfo beside the tsconfig, so after vx emptied dist/
it emitted only the changed file, and the partial dist/ was saved. A
miss stamps those files, as git status listed them at run start,
before its command; if one changed (and a sandbox let the task write
it), or the outputs matched no files while one is there, the result
stands but is not saved, and the line names the file and the fix:
declare it in outputs.files, so a miss starts without it and a hit
restores it. Of a directory git ignores whole (.cache/,
node_modules/), only the .tsbuildinfo files under it count, found
once per run, a node_modules searched only in its own dot directories
(.tmp): a bundler’s cache there (.next/cache) is rewritten on every
build without cutting its outputs short. A miss costs a stat per such
file; a hit, nothing. A file
the command creates is not listed, so a first build is still saved
(tests/undeclared-state.test.ts). The rule guards a partial output, so
a task that declares no outputs is exempt (eslint --cache, tsc --noEmit --incremental), and a file another task of the project declares
as its output is that task’s. An undeclared file a task of the same
project running alongside may have written still withholds the save (an
unsandboxed task’s write cannot be told from it); the line names that
task, and declaring the file in its outputs ends it.
The outputs are what exists when the task’s command exits. The run
waits for the command’s own process, not for everything it started — a
backgrounded grandchild that still holds the output pipe does not hold
the run (tests/runner.test.ts) — and the save resolves the output
globs at once. A descendant the command detached (setsid … &, a
daemon) that writes after that is not in the artifact: the entry saves
what was there, often nothing (with the outputs matched no files line), and the next hit’s restore removes what the
descendant wrote and restores the saved entry in its place (upstream
survey, turborepo#12786). vx does not wait for it and cannot see it: a
process that left the task’s session is outside the process group vx
tracks and signals (kill-tree). So a command
finishes writing its outputs before it exits — wait for what it
backgrounds, or do not detach it.
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 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. - After restore on a cache hit. The post-restore tree is the
cached snapshot byte-for-byte: the restore renames the entry’s files
over the ones it finds, then removes what the entry does not hold.
Each output directory with a recorded mode (
dirModes) gets it whether the restore made it or found it standing with another, and a mode that differs makes the tree stale (2026-11-07: a branch switch A → B → A left B’s 0755 on A’schmod 700 dist/keys). One with none was left at least 0755, so a standing one narrower than that is given 0755 minus the umask, and is stale too (2026-11-10: B’schmod 555 dist/sstayed after a hit of A, which left it 0755). Hand-edits to output files don’t persist through a cache replay. When the hit’s own check walked the output globs to find the tree stale, the removal uses that walk’s list instead of walking again (X-161).
Both use the same helpers (cleanOutputs, cleanOutputPaths in
src/cache/inputs.ts) with the same boundary rules, and vx’s own paths stay off the wipe
whatever the glob: the project root’s .git, .vx and vx.lock.json, and
for outputs.workspaceFiles every project’s vx.lock.json (OUTPUT_NEVER;
a build’s own dist/.git or dist/sub/vx.lock.json is an output like any other;
.vx holds the workspace config and the lock, which a clean deleted and a
hit restored stale while .vx/cache alone was listed). node_modules is
off it too unless a glob names it (node_modules/**, an install task’s
legitimate output) or sits under the glob’s own literal directory: a
build’s own, Nitro’s .output/server/node_modules/** under .output/**
or Next standalone’s under .next/standalone/**, is saved, restored and
cleaned like the rest (until 2026-11-12 the save dropped it, and a hit
restored a server missing its imports); a project’s installed one, reached
only by a wildcard (**/*.js) or by any workspaceFiles glob, never is.
Until 2026-09-27 (A-13) **/*.js cleaned every
installed .js, and a workspaceFiles output glob reached even .git.
No clean deletes a file git tracks (2026-10-14). Under an output
glob it is still an output: the task may rewrite it (TanStack Router’s
committed routeTree.gen.ts), the artifact saves the bytes the run left,
and a restore writes them. It is also in the task’s key, even under its
own output glob and when no inputs.files glob reaches it (dist/LICENSE
under dist/** with inputs src/**; until 2026-10-28 that one was left
out, and a hit wrote the old bytes over a committed edit, as
turborepo#14831 fixed in Turbo) or a ! entry takes it back (**,
!dist/**; until 2026-11-10 the negation dropped it too), so an edit
moves the key and the saved bytes follow from it; the task’s rewrite is no input that moved mid-run.
A run that removes one (rm dist/tracked.txt) saves nothing: no hit
could replay the removal, since its restore leaves the file too, so the
tree a hit left (a fresh CI checkout’s) held a file the run took away,
and every warm run restored again (until 2026-11-11). With the file gone
the key moves, and the next run saves as usual. The clean before the run
lists the committed files it leaves; after a passing run, one lstat each
says whether one is gone (none, and no stat, for outputs git tracks
nothing under). Under outputs.clean: false the first attempt cleans
nothing, so no list is taken: that opt-out keeps this gap.
A committed file under outputs.workspaceFiles is keyed only where
inputs.workspaceFiles reaches it. Until then the
clean deleted a committed file under an output, and a reader with no edge
to its producer failed naming only the file (A-48, vueuse’s
metadata/index.json). Untracked files under the glob are cleaned as
before. Paths compare as
git compares them: case-folded under core.ignorecase where the disk
folds case too (macOS), and a
file inside a submodule counts as committed. A file past a committed
symbolic link (dist -> real) does not: git tracks none through one, so
it is asked by its target’s name (real/x).
Workspace outputs take the same rules, and a path an output ! entry
takes back (A-44) is off the wipe, the artifact and the restore alike.
Skipped when:
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-policy=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). outputs.cleanisfalse(on the task, else the workspace’soutputs.clean): the command sees last run’s outputs (tsc’s.tsbuildinfo). You opt out of the clean-tree guarantee: the save stores every file the outputs match after the run, so a leftover from an older build is cached and restored everywhere. A retry cleans as usual; a hit’s restore still removes what its entry does not hold.
A directory of this user’s that withholds its own write (a task’s
chmod 555 dist/ro, which a hit restores too) gets owner write for the
clean’s remove, as a restore’s write into it does; the subdirectories the
clean empties in it go too. Before a restore it gets its mode back after.
Before the command runs it stays writable: a glob’s root stays (dist
for dist/**), and given back a task’s chmod 555 dist it refused the
command’s own write, so the task never missed again. One the task left
unlistable (chmod 000 dist/s) is listed the same way for a clean or a
restore; a save cannot store what it cannot read, so it fails the task
with cannot list declared output directory dist/s: EACCES — …, and the
next run’s clean still clears it.
A declared output the process cannot remove even so (a dist/ another
user wrote, a read-only checkout) fails the task with cannot remove declared output <path>: EACCES — …, and a restore that cannot write
into such a directory with restore of <hash> into <dir> could not write its outputs (EACCES: …): the environment’s failure, reported plainly, never as an
internal error or a corrupt artifact. A restore stages in the
workspace’s .vx/tmp/, so a read-only checkout with writable output
volumes needs .vx/tmp/ writable too (a mount or volume of its own);
where it is not, the restore fails with could not stage its outputs: restores stage in <root>/.vx/tmp, which is not writable (EROFS: …),
naming that path, not the outputs.
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.
Additive outputs: two tasks, one tree, an edge between them
Section titled “Additive outputs: two tasks, one tree, an edge between them”Two cached tasks whose declared outputs overlap are refused at graph
build: any path both could write counts, so build/{a,b}.js meets
build/*.js, and out/** meets a glob for each .d.ts one level down
at out/types/x.d.ts. By default that holds even when one depends on the other: the
workspace rule run.rules.exclusiveOutputs (on unless set to false in
.vx/workspace.ts, X-53) keeps one path to one task, and its refusal says
how to turn it off. The shape below is correct, only slower: a stamp
before every run, a diff after, and a clean by rows. Give each task its
own output path where you can (dist-individual beside dist).
With the rule off, an edge fixes the order, and the dependant is
additive (item 588): twenty’s
build fills dist and build:individual depends on it and writes
dist/individual; strapi’s build:types runs tsc into the same
dist as build. For the dependant:
- its own output set is what its run added or changed under its
declared outputs — the outputs are stamped (size, mtime, inode, ctime) before the
run and diffed after, the same proof a hit’s “already current” check
trusts — and only that set is saved;
outputs.workspaceFilesare stamped the same way (until A-43 its miss cleaned them by glob, deleting a same-tree upstream’s root-anchored files before it read them); - a file it found and left alone is its own too when a save or
restore of this task left it exactly so (a row of one of its entries,
current by the same proof): an incremental emitter (
tsc,cmp || cp) rewrites only what changed, and its entry held only those, so a hit over a wiped tree restored without the rest. A stale file of its own that its command leaves is saved too, as under Turbo; - a run that removes a file it found (a bundler deleting the upstream’s intermediate) saves nothing: an artifact holds what a run wrote, never what it took away, and the upstream’s restore puts the file back, so the dependant runs again on every warm run (X-32). Any task that removes a committed file under its outputs saves nothing the same way (Strict output ownership);
- before a retry, what the failed attempt added is removed, so its partial outputs are neither left nor saved; a failed attempt that changed or removed a file it found saves nothing on the retry’s pass, since no retry puts that file back;
- it cleans by recorded rows, never by glob, before a run (nothing: stale files of its own are its command’s to clean, as under Turbo) and before a restore (its rows only, pruning emptied directories only inside its declared trees, so a sibling’s fresh directory stays);
- its “already current” check requires its rows present and current and ignores everything else under the glob;
- it is never restore-tier: it restores or runs after its upstream, by the edge.
The upstream keeps strict ownership of its glob — a miss or a restore
wipes the whole tree, the dependant’s additions included, and the
dependant restores or runs after — and its “already current” check
ignores strays a dependant’s glob could have added, so a warm run stays
a no-op for both — a run of the upstream alone too: every task in the
workspace outside the run whose outputs overlap the upstream’s is a
dependant (an upstream would be in the run, and vx lock refuses an
unordered pair), so the run loads every project’s config to find them. A file the dependant rewrites in place (refine’s
types regenerating build’s .d.ts) counts as the dependant’s own
(its ctime moved, even where a tool stamps the size and mtime back; X-33),
which is correct and costs the upstream a restore on
the next warm run; the design note keeps that shape out of scope.
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 9 — the key material of the plugin that claims it (@vzn/vx-lockfile: only the projects whose dependency closure moved); NOT a trigger with no such plugin, since core keys no package file |
Edit an install setting (.npmrc, bunfig.toml, pnpm-workspace.yaml, .yarnrc.yml, .yarnrc) |
step 9 — the claiming lockfile plugin folds it whole into every task; NOT a trigger without one |
Edit package.json’s workspaces field |
membership: a project that joins or leaves changes which projects exist and which nested dirs its parent’s globs exclude (step 11) |
Edit the project’s package.json (dep / version / scripts change) |
step 11 — an input file of every cached task when @vzn/vx-lockfile is declared (it adds package.json to inputs.files), or when a task lists it; NOT a trigger otherwise |
Edit the task’s vx.config.ts |
step 3 (task config hash) |
| Edit a config file that the task config imports | step 3 (configHash sees the resolved object after Bun evaluates imports) |
Change CLI forwardArgs (after --) |
step 4 |
Change a inputs.env host value |
step 5 |
Change the combined stdout+stderr of a inputs.runtime command (resolved at hash time) |
step 6 |
Change the combined stdout+stderr of a inputs.workspaceRuntime command (resolved at hash time) |
step 7 |
| Upstream task’s cache key changes (because its inputs changed) | step 8 |
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/projects.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 8 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 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. A
workspace package a discover plugin lists (a lockfile plugin such as
bun()) counts whether or not it has a vx config: it is the
project --affected gives its files to, and its default build keys
them (X-57). The same set fences the output clean, so a root
outputs.files: ['**/*.js'] never removes a member’s source. Core reads
no package.json, so with no such plugin a directory with no vx config is
no project: it is the root’s, its files are the root’s inputs, and a root
output glob (**/*.tsbuildinfo) cleans its untracked matches. Every
glob pass drops a path under one of them (an ancestor lookup,
A-11). A nested workspace (a directory holding its own
.vx/workspace.*, which vx lock records in the lock) joins the set for
outputs only: the clean before a run deleted its untracked files. Inputs
still fold its files, as a task may read them (a git worktree checked out
under src/ holds one): an extra miss, never a stale hit. vx lock
refuses an output glob rooted in one, and vx lock --check fails a lock
that does not hold one. The only way
for project A to depend on project B’s state via project-relative
globs is dependsOn + upstream-hash propagation (step 8).
For a task with exec.sandbox and cache, the sandbox enforces that
“only”. A workspace package reached through a node_modules link is
granted only when the task’s key folds a task of that package, walking
the same selection inputs.tasks applies at hash time
(orchestrator/keyed-projects.ts, over graph/folds.ts’s
selectFoldedDeps); every other link target inside the root is
withheld, so an import of a sibling the key never sees fails and is
reported instead of caching a result an edit there would not re-run.
A node_modules that is itself a link (ln -s ../b node_modules) is
judged by its target the same way, and one holding the project or .git
is never granted. A granted package’s nested projects are walled unless
the key folds them too: its own key stops at them.
Any exec task counts, cached or not: its key includes its project’s files
through its inputs, and one with no inputs is keyless, so a key that
folds it is never stored or read. A persistent task counts the same way,
so a cached e2e behind a dev server keyed on its sources re-runs when
they change.
The coverage is per package, not per file: an edge to ui#build
(inputs src/**) also admits a read of ui/README.md, whose edit moves
no key — the same limit a grant wider than a task’s own inputs already
has. A task with no cache keeps every link, having no key to be stale.
Exception: inputs.workspaceFiles /
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, an ignored one included, whichgit status --ignored=matchingnames from the walk it already does, no$GIT_DIR/info/attributes, and none of the files git reads outside the tree: the global one, which iscore.attributesFileor by default$XDG_CONFIG_HOME/git/attributesor~/.config/git/attributes, and the system one), no rule can name a filter and vx does no extra work. This is the defaultgit initrepo. The gate readsgit var -l, one spawn, which from git 2.42 names the global and system files itself (GIT_ATTR_GLOBAL,GIT_ATTR_SYSTEM); for an older git the global lookup is mirrored and the system file is looked for at/etc/gitattributesand<prefix>/etc/gitattributesbeside the binary, where a standard build puts it. Before 2026-09-24 the gate missed the default global file, so* textthere left a CRLF file keyed on its LF blob: a CRLF→LF edit was a stale hit. - Otherwise
git check-attr --allresolves every attribute a rule sets from the index — without reading worktree content — and only the paths carryingtext,eol,ident,filter,working-tree-encodingor the legacycrlflose their OID. Asked by name, git printed all five for every path,unspecifiedincluded: vueuse’s* text=auto eol=lfwas 473 KB to decode and split each run (~5 ms of the main thread), 160 KB under--all. Afilterdriver was missing from the list until item 978: a clean driver that drops comment lines (sed '/^#/d', nbstripout’s shape) stores one blob for two worktree files, so an edit git calls clean replayed the old output. Git LFS files are afiltertoo, and are hashed from disk.
-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.
Nor can the index’s stat stand in for the file’s own lstat there. When
a file’s stat moved, git compares content after the filter, calls a
match clean and keeps the old stat: a\r\nb\n rewritten as a\nb\r\n
(same size, same blob) is clean, and the index still describes the
first file. So each such file is stat’ed once per run.
--affected asks the same question (core.autocrlf, then check-attr)
of a file whose stat moved: such a file is a change, though git, which
compares after the filter, calls it clean against the base.
Runtime inputs and the lock (the env parallel)
Section titled “Runtime inputs and the lock (the env parallel)”inputs.runtime / inputs.workspaceRuntime are modeled
exactly on inputs.env, and they share its asymmetry with the
lockfile:
- The command strings live in the resolved config, so
vx lockfreezes them into.vx/projects.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.
A runtime command executes once per run, per project (memoized by
projectDir + command), at key-derivation time — which, with the
up-front classify/prefetch pass, is before any task runs. It is a
run-level reading of the ENVIRONMENT (toolchain versions, resolved
config), not a per-task probe: it is not asked again before a save, as
input files are, because that would be one spawn per command per miss
where a file costs one lstat. A command that reads another task’s
OUTPUT reads whatever is on disk when it is asked. When the reader’s key
folds that task’s key and that task is cached, the upstream key covers
it: an answer taken before the upstream ran costs a spurious miss on the
run after the output changes, never a stale hit, and the answer stays
shared. When it does not (tasks: [], a filter that leaves it out, an
uncached writer, whose writes its key does not follow, transitively), nothing
covered it until X-34: the save filed bytes built from the new state
under the old answer, and a later run that started from the old state
hit them. Now such a task (probesAfterWrites, stable-keys.ts) takes no
key up front and is never restore-tier, and its probes run for it alone,
after its upstream finished — one spawn per such task per run, not one
per project (tests/in-run-writes.test.ts). Declaring the producing
task’s output as an input (dependsOn + files) is still the cheaper
spelling: files are re-checked, answers are not.
A probe runs in its own process group. One still running when vx exits
— a Ctrl-C, or a refusal, while a hung git or node -e … answers — is
SIGKILLed with its whole tree; until 2026-09-27 (A-9) it ran on under
init. A kill -9 of vx runs no exit hook, and leaves it.
So a run loads the frozen command strings from the lock 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, 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.
Concurrent runs
Section titled “Concurrent runs”Two vx processes on one workspace (a vx watch beside a vx run, two
CI jobs on one checkout) take turns: a run takes the workspace’s run
lock before it opens the cache and releases it with its cache handle — before
a persistent task’s wait, so a dev server never holds it — and the
second run waits, saying after a second whom it waits for:
[vx] waiting for another vx run (pid 4821) on this workspace to finish…The cache itself was always safe (SQLite waits on its lock, artifacts
land by rename; a transaction that reads before it writes takes the
lock at BEGIN, since SQLite answers its later write database is locked
at once: a run’s history was lost so in 13 of 48 runs on one shared
cache dir, X-105); a task’s OUTPUT TREE was not — both runs cleaned and
restored the same dist/, and a clean landing while the other run’s
restore was staging took its files out from under it. The lock is an
atomic directory under the temp directory, keyed by the workspace root
(--cache-dir does not make two runs strangers) and holding the
holder’s pid, so a lock a killed run left behind is reclaimed once its
pid is gone. The temp directory is the one each process sees, TMPDIR
included: two shells that set different ones (a nix develop shell
sets its own, as can direnv or sudo) hold two locks and do not take
turns (item 970). Run them under one TMPDIR. A lock under the
workspace would survive a reboot that /tmp does not, and where a
holder’s start time is not readable (off Linux) a pid reused since
would read as a live holder forever. Where the directory cannot be made at all (another user’s
lock, a temp directory this user cannot write) the run says so once and
proceeds unlocked — a courtesy between cooperating runs, never a
refusal — and a restore that then loses its staged files to the other
run’s clean fails with restore of <hash> into <dir> was interrupted: a file it had just written vanished (ENOENT: …). Another vx run is using this workspace — re-run once it is done.; the artifact is intact
either way (a restore renames into place only once the whole archive
has staged). Machines sharing a workspace over a network file system
do not share a temp directory, so they do not share the lock.
A vx cache clean in any checkout of the repository waits for every
vx process holding the shared store open (a run holds it from its lock
to its end), so it never deletes the store under a run in a sibling checkout,
whose own lock it never took. It holds the store alone while it moves it
aside (cache.removing beside it) and deletes it after, so a run
arriving meanwhile waits ([vx] waiting for a vx cache clean of <dir> to finish…) and then makes a new store; one arriving after the move
makes it at once. A run that opened the store as the clean let go
checks that the file it holds is still the one at its name (one stat)
and makes a new store if not: it used to die after 5 s on database is locked, or save into the deleted store and lose that entry. A clean of a cache directory with
no store (--cache-dir, cache.dir, VX_CACHE_DIR) waits too, as another
workspace’s runs may share it: each run holds it shared (cache.lock in
it) from before its cache opens to its end, the clean holds it alone,
and a run arriving meanwhile waits for the clean. A run waiting for the lock holds no cache
open, so a clean holding the lock never waits on it. An eviction can still land between a
hit’s probe and its restore where vx holds no lock: a clean on another
machine sharing the directory over a network file system, or a file
removed by hand. The restore then finds no
artifact, and that entry is a miss: the run says [vx] <id>: its cache artifact <hash> vanished before the restore … — running it,
runs the task, and saves it again. It failed every such task as an
internal error until 2026-09-24 (upstream survey: nx#36688, nx#34032).
A hit the local short-circuit was restoring ahead of its dependencies
goes back to the schedule and runs once they are done, never in the
restore’s slot: built before them, its bytes would be saved under the
healthy key (tests/vanished-artifact.test.ts).
A local artifact whose bytes are wrong (a failed checksum, a torn
write, one past the artifact ceiling, an entry missing a recorded
output, a tree entry missing one of its files, a name that now reads
unsafe, X-115) is a miss the same way: [vx] <id>: cache: corrupt artifact for <hash>: …; dropped it — running it. The entry is dropped, so the
task’s save stores the key again; it failed the task as an internal
error on every run until --force before A-52. A cache the run may
only read keeps the entry (…; left it (this cache is read-only)).
A failure that is another process re-saving the key since the probe
(its rows changed) restores the new entry instead, or is a plain miss
that keeps it: vx drops only the entry that failed.
Storage layout
Section titled “Storage layout”A directory this user cannot write into (another user’s .vx/cache,
a cache baked read-only into a CI image) serves its hits: the run says
[vx] cache directory <path> is not writable (EACCES: …) — serving hits from it; this run saves nothing and records no history once, before
any task, then restores what it holds and runs the rest without saving
it. An empty one has nothing to serve and fails the run before any task:
cache directory <path> is not writable (EACCES: …), with
--cache-dir <path> as the way out. vx cache clean is refused the
same way (its --dry only reads, and reads). The readers (vx show, why,
last, info) open such a directory read-only and go on.
Where the directory cannot be created at all (a read-only checkout with
no cache yet) a run says cannot create cache directory <path> (EACCES: …) (VX_E_FS), naming the workspace cache.dir field and
--cache-dir; a file where the directory or a parent of it goes is
named as the one to move away. The readers and vx cache clean find no
cache and go on.
A full disk is the same kind of failure and is reported the same way,
never as a corrupt artifact and never as a stack: a save that runs out
of room is [vx] cache save failed: <task>: ENOSPC … (the task’s work ran, the
footer counts it not saved rather than ↑, and the next run misses; the
saves that fail after it are one line once the graph ends,
[vx] cache save failed for N more tasks: …), a restore that does is the task’s failure line
could not write its outputs (ENOSPC: …). Free space on that disk and re-run., and a run record that does is [vx] run history not recorded: … — the verdict above stands (the run exits by its tasks; vx last
will not know that one). A full index (SQLITE_FULL) gives way where the
write is only a memo: the file-hash memo, the output stamps and the
access times are skipped and the run goes on; a prune unlinks the
artifacts first, so the rows it then deletes have room to go, and a
save’s temp file is removed when its write fails.
A process out of file descriptors (EMFILE, ENFILE) is named the
same way: a save is [vx] cache save failed: <task>: save of <hash> could not open a file (EMFILE: …) — … raise the limit (ulimit -n 4096) and re-run, and a restore fails the task with that hint, never as a
corrupt artifact (A-39). Opening the index is named the same way: SQLite reports a
descriptor it could not get as unable to open database file, and vx
says the cache index <path> could not be opened (…) — …raise the limit
(A-56). A path SQLite will not open, past 504 bytes (its 512 less the
-journal it may add), says its length and to point the cache at a
shorter directory (--cache-dir, VX_CACHE_DIR).
A restore that cannot READ its artifact (a cache directory another
user owns) names the cache, not the outputs: restore of <hash> could not read its artifact (EACCES: …). Make the cache directory readable by this user, or point cacheDir / --cache-dir at one that is. (A-40).
An input the key must hash that this user cannot read fails its task
naming the read: <path> is not readable by this user (EACCES), and vx reads it to derive a cache key. Make it readable, or, for a task input, take it out of inputs.files. (A-50). An input directory this user may
enter but not list (chmod 111) refuses the run by name: git cannot
list it, so the files a task reads there by name were in no key, and an
edit to one was a stale hit. One it may not enter is left out, as no task
of its reads under it.
By default the entries and their artifacts live in a shared store
in ~/.vx/<id>/cache, and each workspace keeps only its own index in
its .vx/cache: every checkout of the repository hits what another
saved (a second clone, a worktree), and none reads another’s history.
Task timings are shared too: task_durations, what a duration forecast
reads, so a new worktree has one on its first run. So are plugins’ own
entries (plugin_blobs, a plugin’s ctx.cache).
A hit in one checkout replays the bytes another saved, so an output must
be relocatable: one that bakes in its checkout’s absolute path (Next’s
required-server-files.json holds the build’s directory) points the
other checkout at the first. Give such a workspace its own store
(cache.dir in .vx/workspace.ts), or key the task per checkout
(import.meta.dir in its command, schema).
The id is Nx 23’s: 16 hex of a sha256 of the remote (origin, then
upstream, base, the first; host/owner/repo in lower case, so ssh
and https agree) and the workspace’s path in the repository; with no
remote, the first commit. A repository with neither (no commit yet, a
shallow clone with no remote) shares nothing: its workspace holds its
entries, and once it has an identity (its first commit) the next run
moves them into the store, so they still replay. Rewriting the first
commit with no remote (commit --amend, rebase --root) is a new id and
an empty store; the old one stays in ~/.vx until removed by hand.
Each level of ~/.vx is
owner-only; vx closes one of yours that others can read (chmod 700), and one others can write, or owned by another user, is not used. Design: design/shared-store-2026-10.md.
~/.vx/<id>/cache/ the shared store├── store.db entries, entry_stdout, output_files,│ entry_inputs, task_durations,│ plugin_blobs, remote_hits, store_meta; artifacts│ up to `cache.store.embeddings` (v33)└── <hash>.tree/ the larger artifacts, as plain files (below)
<workspaceRoot>/.vx/cache/ this workspace's own index└── cache.db run history, memos, output stamps; attaches store.db as `store`The store’s directory carries no version: every key is seeded with
CACHE_VERSION, which moves when hashing or the artifact layout does, so
two vx versions never read each other’s artifacts. store.db is the
artifacts’ inventory and records its schema (store_meta.schema): a vx
of another SCHEMA_VERSION drops its tables, prints nothing (the
cache is vx’s to keep), and keeps every artifact, each indexed again
when its task next hits. The check, drop, re-create and stamp are one
write transaction, so another version’s open waits rather than landing
between them. A home this user cannot write keeps the store
in <workspaceRoot>/.vx/cache/ instead, silently, and so does a store
another user owns (one sudo vx run). vx info and vx cache clean
decide it the same way, the reading ones without making or changing
anything, so they read the entries runs use. Name a
cache directory (--cache-dir, relative to the working directory; else
VX_CACHE_DIR, else cache.dir in .vx/workspace.ts, each relative to the
workspace root) and it holds everything, with no repository store
beside it. Two clones naming one directory share its entries; each keeps its own
output stamps and directory snapshots (keyed by its root), so switching
between them stays up to date instead of restoring every output.
One under a selected task’s declared outputs is refused before any task,
naming the task: the task’s output clean would delete it mid-run. Its layout:
<cacheDir>/ (default .vx/cache when one is named)├── .gitignore `*` — written when the dir is created, or into an│ existing dir that lacks one, so the cache is│ never committed and never enumerated as an│ input (a user's own file is left alone)├── cache.db SQLite metadata + run history (its steps,│ warnings and failed output too), the│ lockfile memos, and the artifacts of at│ most 32 KiB compressed, the same bytes as│ a file below (v33)├── cache.db-wal write-ahead log├── cache.db-shm shared memory└── <hash>.tree/ one artifact per cache entry, the larger ones (v47): ├── <rel> declared output files, project-relative (when any), │ each with its saved mtime; an output symlink │ as the link itself └── workspace-outputs/<rel> declared outputs.workspaceFiles, WORKSPACE-ROOT-relative (when any)The stdout of a tree entry is its entry_stdout row, and each file’s
mode and mtime, the empty and moded output directories, the key and the
miss’s CPU and RSS are its output_files and entries rows: a tree
keeps each output at its row’s path and nothing else (v53). A
.vx-meta.json and an outputs/ level once took a 4 KiB block each,
~8 of the ~16 KiB a small entry spent on the file system beyond its
files (217 against 225 KiB an entry on the 500-task bench; a tree save
16 to 26 % faster, a restore unchanged). An upload rebuilds the archive’s sidecar from the rows,
each link held to its row’s length and CRC-32. With
cache.store.decompress: false an artifact is instead one <hash>.tar.zst
holding stdout, outputs/, workspace-outputs/, .vx-meta.json and
.vx-sum: the bytes a remote stores. The tree form is that archive
unpacked once at save (or at ingest of a remote hit, decoded once straight
into the tree), so a restore copies
each file out (copy_file_range, a clone on a filesystem that has one;
never a hard link: a later write to a restored output must not reach the
store). A missing folder (a cleaned dist/, a fresh checkout) is built
whole in the workspace’s .vx/tmp/ and renamed in once, as the
extractor stages, so a reader meets it complete or not at all (copied
straight to its place, its files were there empty, then short). A
missing file in a folder that stands is copied straight to its place: a
reader can meet it short while it copies, as the price of a restore
that renaming each such file in made a fifth to a third slower (3,000
files, 2026-11-06). A symlink is laid as written, after the extractor’s
own checks of where it points; one it would refuse, a restore into outputs
already on disk, a workspace output, or a folder on another filesystem than
.vx/tmp/ takes the archive extractor, fed the tree packed back into its
tar. An upload packs the tree into the tar.zst in the upload queue, off
the save. A hit trusts the tree and checks none of its files’ bytes: the store
is vx’s own, and a check could not stop a write to it. Each file’s size
is held to its row’s, which costs no read: a file cut short at rest (a
crash after its rename, before its data reached the disk) is a corrupt
entry: a restore drops it and runs the task, and an upload refuses it. A tree no row names
is an orphan the sweep reaps; only the archive form is adopted back
after a SCHEMA_VERSION reset, as a tree’s stdout lives in its row.
If a record can be in cache.db, it is (v50). Only the artifacts above
and the files below stay files: the config bundle in
.vx/cache/compiled/ (Bun imports the bytecode from disk, and it is read
before the workspace config names the cache dir), the no-remote workspace
id .vx/cache/workspace-id (outlives a cleared or reset index), restore
staging in .vx/tmp/ (output bytes), and the probes a run writes and
removes at once (.vx-clock-<pid>, the watch stamps). A hit needs
.vx/tmp/ writable even where --cache-dir points elsewhere: a
read-only checkout mounts one over it. A failed run’s
task output is failures rows (one per task: output plain text, ANSI
stripped, secrets masked, the first 8 KiB and last 56 KiB; locations
and sandbox_fix JSON), written at run end only when a task failed with
output or a moved input; the newest 50 failed runs are kept. A refused
write (a full disk) costs only that output, never the run’s verdict.
<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 container is written and read by vx’s own streaming tar code
(src/cache/tar-stream.ts): ustar with the name/prefix split and a pax
path record past it, header checksums, and nothing else — there is no
tar subprocess, no libarchive, and no staging copy. Save streams
it: each output is stat’d once, then read from disk as it is written
and compressed straight into the temp file (measured 2026-09-03 on a
150 MiB output: peak +705 MiB → +241–269 MiB; the streamed compressor
is ~2.4× the one-call cost per byte, so a small artifact is packed in
memory and compressed in one call — tiny saves unchanged within
noise). Ingest lists entries through the reader without materialising
a byte, and restore streams it: the zstd
frame is decoded and the tar read as it arrives — ustar name/prefix
(the prefix under POSIX ustar\0 magic only: old GNU’s ustar keeps
atime there, A-29),
pax path/size, GNU long names, header checksums, truncation; an
extended header (read whole) past 1 MiB or a pax size that is not a
whole number is refused as corrupt, since a 1 GiB pax header costs 2 GiB
of memory from a ~32 KB artifact — and
every regular entry is written to the workspace’s .vx/tmp/<pid>-0/
(vx’s own, ignored by git, never an output folder), inside a copy of
the topmost missing folder on its way when one is missing, and renamed
over its target (that copy renamed in whole) only after the whole archive has ended cleanly and
the index’s recorded outputs are all present. vx itself holds one
chunk of the tar at a time (measured 2026-09-03, incompressible
artifacts, fresh process: 150 MiB peaked at +644 MiB through
Bun.Archive and +49 MiB streamed at the same wall time; a single
400 MiB entry restores at +30 MiB, the same as a 150 MiB one), while
the per-entry buffers of many mid-sized entries are garbage the
collector reclaims at its own pace (200 × 2 MiB measured +90–180 MiB,
never the artifact’s size). An artifact up to 4 MiB compressed is decoded in one call
first — the stream setup costs ~35 µs each, 4% of the headline
restore row when every artifact is a one-file dist/ — and then fed
to the same reader and extractor, so there is one extraction path.
The reader reads a header in place when it lies within one chunk and
its numeric fields off the bytes when they are plain octal (anything
else takes the full parse): a 4-entry artifact’s read 50–57 µs → 22–23
(min of 15), and a 300-artifact, 20-file restore run’s reader 224 → 92
ms of main thread (2026-10-02).
The 2 GiB decompression ceiling applies to both: declared size and
output length for the one-call decode, a running count for the stream.
An ingest bounds the compressed body first: a remote body past the
ceiling’s zstd bound (a length header, a Blob’s size, or a running count
on a chunked stream) is refused before it can fill the disk.
A frame that declares no content size (what a streaming compressor
writes — vx’s own saves above 4 MiB) is always decoded as a stream, so
a sizeless bomb has nowhere to expand and vx’s artifacts ingest
anywhere. So is a body that is not exactly one frame: the declaration is
the first frame’s alone, while the one-call decoder decodes every frame,
so until 2026-09-27 (A-5) a 100-byte frame with a large one appended
expanded whole in memory (2 GiB from a 32 KB body) before its length was
checked. The frame’s blocks are walked by their own sizes to tell. A save meets the same ceiling first: the pack plans the tar
from one stat per output, and a tar past 2 GiB is refused before a byte
is compressed — the run stays green, one status line names the task
([vx] cache save failed: <task> is not cached: its outputs pack to …, past the 2.0 GB artifact ceiling a restore enforces), and nothing is
stored for a later run to hit and fail to restore. Left to the scan, a
2.2 GB output paid the whole compress and a decode (6 to 14 s) to learn
the same thing and called the task’s outputs a corrupt artifact.
The last entry, .vx-sum, is a CRC-32 over every entry before it (each
its name, a NUL, then its body) as 8 hex digits. Tar sums its headers
only and neither zstd writer asks for a frame checksum, so a byte flipped
in a raw zstd block (incompressible output: an image, a wasm, a tarball)
decoded clean and a hit replayed the wrong bytes: 1,141 of 1,141 flips in
a random 8 KB body went unseen (L-19). Scan and restore hash every entry
as they read it and refuse an artifact whose sum is absent, wrong, or
followed by another entry, before anything it holds lands; the refusal
is a corrupt artifact, so a restore degrades to a miss and an ingest
stores nothing. It catches damage in transit or at rest, not a store that
forges artifacts: nothing a key carries can tell those from honest ones.
Its cost is the CRC’s (~10 GB/s): a 32 MB restore 11.3 → 14.4 ms and
1,000 small files 16.2 → 18.9 ms on tmpfs, min of 15.
Tar headers carry mode and second mtimes. vx needs both permission bits (a lost executable bit builds cold
and breaks warm) and millisecond mtimes (the skip-restore probe compares
them) exactly, so the pack stats each output once and writes
.vx-meta.json — { version, key, files: { <entry>: [mode, mtimeMs] }, dirs?, links?, dirModes?, exec? } —
into the archive. A restore applies the mode, minus the process
umask as tar gives a non-root user (under umask 077 a 0644 output comes
back 0600; until 2026-10-16 it came back 0644, readable by every user,
tests/restore-umask.test.ts), and a tree a restore left that way still
reads as current. The mtime is the index rows’ and the cache’s own
tree form’s; a restore dates a file it writes now (§ Cache lookup → restore).
Both hold at their edges too: a mode of
000, an mtime of 0 (SOURCE_DATE_EPOCH=0), one before 1970, whose
tar header carries 0 since ustar’s field holds no sign, and one past
March 2242, whose header carries the field’s 11-digit maximum (X-114:
the save failed value … does not fit a 12-byte field). Until 2026-09-27
(A-4) the first two were skipped, so the file came back 0644 and
stamped now and every later hit restored it again, and the third wrote
a header the reader refused, so its task never saved. exec ({ cpuMs?, peakRssBytes? },
2026-09-12) is what the PRODUCING execution used: it rides the artifact
so a machine that never ran the task — a fresh runner on a remote hit —
still learns what the task needs (@vzn/vx-history packs on
it), every wire ships the bytes verbatim, and an artifact without it
reads as before. The ingest side takes the numbers only as plain
non-negative numbers; anything else in a foreign sidecar is dropped.
dirs (v44) names the empty directories the output globs matched at
save (outputs/dist/empty, workspace-outputs/gen/empty under
outputs.workspaceFiles), and the empty root of a <dir>/** glob,
which the glob itself does not match (public/uploads a task made empty
under public/uploads/**): a tar of regular files cannot hold them, and
a hit makes each one, so the tree a hit leaves is the one the miss left.
A miss’s clean removes the empty directories it finds under the outputs
too, so one an older build left is not saved as this run’s, and a hit
removes one the entry does not hold, as it removes a stray file.
Each name is held to a file entry’s rules and to the declared outputs,
and is reached only through directories that stay in the project. Its
index row is the path with a trailing slash (dist/empty/); the
skip-restore check asks only that a directory stands there, and a stale
file found in it goes while the directory stays. links (v45) maps each
symlink under the outputs to its target as written (outputs/dist/latest
→ v2.txt). A hit makes the link apart from its place and renames it
over the target like a file, and only if the target is relative and
stays inside the workspace at every step, read segment by segment from
the link’s own directory, and through no other link it restores except
as its last hop (a soname chain, at most 8 links, no loop); any other
fails the whole restore. A target
that climbs above the root and comes back (../../../ws/app/x) is out:
judged by where it ends, it resolved inside only on a checkout whose root
is named ws, saved there and was refused as corrupt everywhere else
(until 2026-11-07). Its index
row carries the link type in its mode and the target’s length and CRC-32
where a file has its size and mtime, so the skip-restore check compares
the target and never follows the link. A FIFO, socket or device under
the outputs holds no bytes to store and stays out of the entry; the
save names each one in a warning, since a hit restores the tree without
it (until 2026-11-06 it was left out without a word). The clean before
a miss and a restore removes one, as it removes a file: a FIFO left at a
declared output path hung the task that wrote there, and a stray one
survived a restore (until 2026-11-07). A hit whose tree is otherwise
current still leaves one standing: listing them reads every directory
again, ~17% of the walk that proves the tree. Tar entries that are not regular
files (symlinks, hardlinks, devices) are never materialised — the
reader reports them only to be skipped — so a
poisoned artifact cannot smuggle one onto disk. Nor can it name a file
the task did not declare: a remote artifact is ingested only when every
file it carries matches the task’s outputs (the lookup passes
them, CacheGetContext.outputs), and never when it holds one path as a
file and a directory at once. Either used to reach the tree, an input
overwritten or a .git/hooks file planted under a green
cache-hit-remote, or a restore that failed every later run from the
local copy (item 942); now the read is a miss and the task runs. Every
restore asks the same of the local copy, whatever route it reached the
store by: an entry outside the declared outputs, one a ! entry takes
back, one under the project root’s .git or .vx or its vx.lock.json, or one under a
node_modules no output glob names fails the whole restore before
anything is written; the entry is dropped and the task runs. Remote
execution (@vzn/vx-reapi) writes a remote action’s outputs through the
same gate (restorableOutput). On the save side a
symlinked output is stored as the link, as Turbo and Nx keep it: a
link to a file, to a directory or to nothing comes back as that link,
and no byte of its target is read, so a link a sandboxed task planted to
a file it could not read packs nothing of it (L-23). An absolute link, or
one leading out of the workspace or into .git (as text, and on disk: a
link already there before a .. is followed, so with a committed
up -> .. the target ../up/../x is out), fails the save by name
(the restore’s own check, so no entry is stored that every restore refuses), as the config
spells the path (workspaceFiles output gen/latest). The clean before exec and restore removes every
file, symlink, FIFO, socket and device the output globs cover (a link is unlinked, never
followed). An output directory that is a symlink (dist -> real-out) is
followed by the clean as by the save and restore, named by a glob
(dist/**) or a literal (dist) alike, so its target is the
output and an entry holds only what its run wrote (X-88); one that
resolves outside the project refuses the task, naming the link, and
nothing is deleted through it (X-5); so does one, in either scope, that
resolves into a directory an entry of the task’s own inputs.files or
inputs.workspaceFiles can match below (dist -> src under src/** or
**/*.ts: the clean deleted every untracked source, X-55; dist -> build-out under src/** stays an output), and so does an
outputs.workspaceFiles directory that resolves outside the workspace
(it saved an empty entry under a warning, M-65). So does one, in either scope, that
resolves into the root’s own .git (any case) or its .vx: for the root
project and outputs.workspaceFiles those are inside, and dist -> .git
under dist/** had the clean delete the repository. A recorded row
reached through such a link is never removed either. The clean prunes the directories it emptied (before a miss it keeps
the directory a wildcard glob is rooted at, dist for dist/**, as the
task writes there), so a task whose
output changed shape — dist/out a directory one run and a file the
next — restores either entry over the other’s tree; a stray the globs
do not cover, or an empty directory where the entry holds a file, that
stands in an entry’s way fails the restore naming it, not as a corrupt
artifact (item 1094). A directory on the way that is a
symbolic link OUT of the project (a dist made a link after the entry
was saved) is never written through and never replaced — the link is
the user’s, and replacing it is the bug nx#37061 reports — so the
restore refuses, naming it: <dir>/dist is a symbolic link to <target>, outside <dir> — a cache restore never writes through a link that leaves its directory. Remove the link and re-run (the restore puts a real directory there), or stop declaring outputs under it.
The check runs before each rename and again once all have landed: a
directory swapped for such a link while the renames ran (another
process, a concurrent restore) fails the restore the same way, and the
entries that landed through it are removed from where they went. A
link that stays inside the project is written through, and so is one
whose target is gone: the restore creates the directory it names —
following a chain of links to its end — and writes through it, the
link kept. Whether a link stays inside is decided on where it resolves,
so a dangling link to ../elsewhere or to an absolute path out of the
project is the same refusal, nothing created outside, and a cycle of
links is refused by name. Nor is a link written through into Git’s
metadata: a path that resolves into the project’s own .git (or the
workspace’s, for outputs.workspaceFiles), in any case, is refused naming the link (dist/g -> ../.git carried
dist/g/hooks/pre-commit into .git/hooks), and an artifact’s own link
into .git fails its restore (vercel/turborepo#14808). (A link the output globs themselves cover,
dist/sub under dist/**, is an output: the clean unlinks it.) Entry NAMES are
validated by vx before anything decides where to write, and a bad
entry anywhere, even the last, rejects the WHOLE archive: the temps
are unlinked and the empty directories the extraction created are
pruned (tests/archive-security.test.ts). A backslash in a name is a
name character (vx runs on Linux and macOS; Windows through WSL), so an
output like dist/back\slash caches and restores as written.
Key properties: one entry is one artifact — a row of artifacts
when small, else one file, so eviction is one delete in the rows’
transaction or 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, wherever the local copy lives.
The artifact is the record and the index its inventory
(2026-10-06): a lookup reads the row first, and a key with no row whose
<hash>.tar.zst is on disk (a SCHEMA_VERSION drop, a deleted
cache.db or store.db) has the artifact indexed again from its own
bytes, checked as a remote’s are (its recorded key, its names against
the task’s declared outputs), and hits; one that fails the check is a
miss, and the save that follows replaces it. An inline artifact with no
row is indexed the same way, from its bytes, in one transaction; a reset
keeps the artifacts table, but deleting store.db or cache.db by
hand takes the inline artifacts with it. A .tmp-* a crashed save
left is never a hit. vx cache clean sweeps row-less files. A
row-less artifact may be in use: two vx versions share one store, and
each open drops the other’s rows. So the policy judges it as it judges a
row, its file time standing for accessed_at: past olderThan it goes,
and under maxSize it counts, oldest use first with the rows. A hit
renews a file time over an hour old, so the last use is read as the file
time plus an hour. An inline artifact with no row is judged the same
way, its at standing for the file time, and vx info counts it with
the files. A temp a crashed save left goes once it is an hour
old, and so does a file an inline artifact of its key shadows; nothing
younger than an hour is taken. Re-indexing touches the
artifact’s mtime before it links its temp, so the sweep sees that one
fresh too.
A row’s accessed_at is renewed the same way: a hit renews it only once
it is over an hour old (2026-10-08). Renewing every hit rewrote
2,180 rows at a warm 1,090-package run’s close, ~30 ms. So the policy
reads a row’s last use as accessed_at plus an hour too: an entry used
within olderThan is never pruned, and one may stay up to an hour past
it (tests/cache.test.ts › “an entry used within the age limit is never
pruned”).
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”schema_meta.version is the gate: an index written by any other
SCHEMA_VERSION, earlier or newer, is reset by the first run that opens
it (pre-alpha: no migrations; the index is an inventory,
2026-10-06): every table but schema_meta is dropped and recreated,
so each comes back in its current shape (A-54: output_dirs kept an
earlier vx’s columns). The check, drop, re-create
and stamp are one write transaction, so another version’s open waits
rather than landing between them; an index already current is opened
without the lock.
A reading verb (vx why, vx last, vx info) leaves it untouched and
says why (item 896; vx cache clean --dry previews the reset
instead, item 1083).
That open prints nothing (2026-10-06: the cache is vx’s to
keep). The artifacts stay: each is indexed again from its own bytes
when its task next asks for its key (below).
-- src/cache/schema.ts (SCHEMA_VERSION = 'v56', in cache.ts)-- With a shared store, entries, entry_stdout, output_files, entry_inputs,-- store_meta, task_durations, task_outcomes, blob_sizes, plugin_blobs and remote_hits live in its store.db, attached as `store`; the rest is-- the workspace's cache.db. A named cache dir holds all of them.
CREATE TABLE schema_meta ( key TEXT PRIMARY KEY, -- 'version', 'cache_version', 'store_dir' 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 created_at INTEGER NOT NULL, -- ms-epoch; when the entry as it stands was written accessed_at INTEGER NOT NULL, -- ms-epoch; a hit renews it once over an hour old (LRU) cpu_ms INTEGER, -- v26: the producing execution's usage, from peak_rss_bytes INTEGER, -- the artifact's sidecar (save + ingest) run_id TEXT -- v54: the run that wrote it, so `vx last --log` shows a run's own output);
-- v29: stdout apart from its entry. An UPDATE rewrites a whole record, so-- the accessed_at bump rewrote each hit's stdout (up to 16 MB): 200 hits-- of 1 MB cost the run's close 125-150 ms.CREATE TABLE entry_stdout ( hash TEXT PRIMARY KEY, -- FK entries(hash) ON DELETE CASCADE stdout BLOB NOT NULL -- v52: zstd frame of the captured stdout's UTF-8; no row = '');
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, -- salted xxh3 of the JSON-encoded `--` args; null when none started_at INTEGER NOT NULL, -- ms-epoch ended_at INTEGER NOT NULL, run INTEGER, -- v53: the run's run_ids row (the view below shows run_id) 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 attempts INTEGER, -- v23: attempts a retried task took (>1) cached INTEGER, -- v25: 1 = declared `cache: true`; 0 = runs every time -- v27: why the task failed or was skipped, as the run's footer said it -- (items 267–270); NULL where the reason does not apply blocked_by TEXT, -- a skip's root blocker, or what a task ran behind (a task id) timed_out INTEGER, -- 1 when vx's own timeout killed it sandbox_violations INTEGER, -- the sandbox's violation count not_ready TEXT, -- 'timeout' | 'exited' | 'spawn' (persistent task) restored INTEGER, -- v31, on a hit: 1 = outputs restored, 0 = up to date remote_error TEXT, -- v40: each remote read or write of the key that failed peak_cores REAL, -- v43: most cores one 1 s sampling interval burned avg_rss_bytes INTEGER, -- v43: mean resident memory over the samples placement TEXT, -- v44: where an executor ran it; NULL = here placement_cores INTEGER, -- v44: that machine's cores placement_memory INTEGER, -- v44: that machine's memory, bytes keyless_by TEXT, -- v46: the task with no inputs its key folded def TEXT, -- v48: the task's definition (command, env.define) cpu_share REAL, -- v49: the share of a core its threads got there; NULL = 1 queued_ms INTEGER, -- v51: waited ready for a slot; NULL = started at once output_bytes INTEGER, -- v51: its output files' bytes (hit: restored, miss: saved) blocks INTEGER, -- v51: tasks downstream of it in the run flaky_passes INTEGER, -- v51: the flaky record its row quoted; NULL = not flaky flaky_failures INTEGER -- v51);
-- Two indexes, both append-only under a run's inserts: every row of a run-- carries the same run and a started_at newer than everything before it.-- There is deliberately NO (project, task) index — it scattered every run's-- rows over one B-tree leaf per pair (a 1,000-hit warm run's record stage-- went 57–79 ms → 14–19 ms without it) while its readers gained nothing;-- the history reader bounds its scan by rowid instead (see history.md).CREATE INDEX runs_started_at ON runs(started_at);CREATE INDEX runs_run ON runs(run);
-- v53: each run's id once. As text in every row, the UUIDv7 and its index-- were a third of what an all-hit run added (500 tasks: 115 KB, now 76 KB).-- A prune drops ids below the oldest kept row's.CREATE TABLE run_ids ( id INTEGER PRIMARY KEY, run_id TEXT NOT NULL UNIQUE -- UUIDv7 shared across all tasks in one invocation);
-- What readers and `vx last --sql` mean by `runs`: the rows above with-- run_id in run's place, every column as before. TEMP, made on each open:-- an older vx resetting this index runs `DROP TABLE runs`, which a view-- in main would fail. It shadows main.runs; writers name main.runs, and-- so does a reader with no use for run_id (SQLite keeps the join in an-- aggregate).CREATE TEMP VIEW runs AS SELECT r.id, r.hash, r.project, r.task, r.status, r.exit_code, r.duration_ms, r.forward_args, r.started_at, r.ended_at, k.run_id, r.cpu_ms, /* … every column */ FROM main.runs r LEFT JOIN main.run_ids k ON k.id = r.run;
-- 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 why`'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.
-- The file-hash memo: a content hash per input file that git could not-- answer (untracked or dirty), keyed by the stat identity that proves-- the bytes unchanged. Machine-local; § Cache key derivation step 11.-- A prune (`vx cache clean` with a bound) drops rows unwritten for-- 30 days (a memo miss is the whole cost of a dropped row; item 1082).CREATE TABLE file_hashes ( path TEXT PRIMARY KEY, mtime_ms INTEGER NOT NULL, size_bytes INTEGER NOT NULL, ctime_ms INTEGER NOT NULL, ino INTEGER NOT NULL, content_hash TEXT NOT NULL, seen_at INTEGER NOT NULL);
-- v42: each task's last key by a probe of the fold up to its files and-- its files' git records, so a warm task's key is read back, not folded-- again (src/cache/fold-memo.ts). A pure memo: rows unwritten for 30 days-- go with a prune.CREATE TABLE fold_memo ( task_id TEXT PRIMARY KEY, probe TEXT NOT NULL, key TEXT NOT NULL, seen_at INTEGER NOT NULL);
-- The size of each index blob the enumeration checked against the-- worktree size git recorded (§ Cache key derivation, A-60): fixed for-- its OID, so a warm run asks git for none. Swept with file_hashes by-- seen_at, the time the row was written. v38: in the store, so a worktree-- reads what another learned.CREATE TABLE blob_sizes ( oid TEXT PRIMARY KEY, size INTEGER NOT NULL, seen_at INTEGER NOT NULL);
-- v30: the paths an index distrusts, by a hash of the index file and the-- pathspecs (A-60): a warm run reads one row, not one per blob. Swept-- with file_hashes.CREATE TABLE blob_verdicts ( digest TEXT PRIMARY KEY, paths TEXT NOT NULL, seen_at INTEGER NOT NULL);
-- v16: per-output-file fingerprints, scoped by the entry that produced-- them — what a hit stats to skip the restore when the tree is already-- current (§ A current tree). ON DELETE CASCADE follows a prune.CREATE TABLE output_files ( entry_hash TEXT NOT NULL, path TEXT NOT NULL, size_bytes INTEGER NOT NULL, mode INTEGER NOT NULL, mtime_ms INTEGER NOT NULL, PRIMARY KEY (entry_hash, path), FOREIGN KEY (entry_hash) REFERENCES entries(hash) ON DELETE CASCADE);
-- v28, its own table since v32: inode + ctime THIS workspace saw after-- the save/restore that last wrote the file (item 886); no row is never-- current. Apart from the shared entry: two worktrees overwrote each-- other's stamps and every switch restored. No foreign key (the entry-- may be the store's); prune and re-save delete what they orphan.-- v55: and its mtime, since a restore dates a file it writes now. v56:-- `root`, a hash of the workspace root's real path in a named cache dir-- (0 in a workspace's own index): clones naming one dir share this-- index, and overwrote each other's stamps the same way.CREATE TABLE output_stamps ( entry_hash TEXT NOT NULL, root INTEGER NOT NULL, path TEXT NOT NULL, ino INTEGER NOT NULL, ctime_ms INTEGER NOT NULL, mtime_ms INTEGER NOT NULL, PRIMARY KEY (entry_hash, root, path));
-- Every directory under a whole-subtree output glob, with its mtime as-- of the last save or restore on THIS machine: unchanged mtimes prove-- the output SET unchanged without a glob walk. Machine-local — a remote-- ingest writes none, and the first hit after it walks and records.CREATE TABLE output_dirs ( entry_hash TEXT NOT NULL, root INTEGER NOT NULL, -- per workspace root, as output_stamps path TEXT NOT NULL, mtime_ms INTEGER NOT NULL, PRIMARY KEY (entry_hash, root, path) -- no foreign key since v32, as output_stamps);
-- 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, and run-level counts. Recorded-- alongside `runs` via recordRunBundle (one transaction); a big run's-- hits land before it, in batches (HistoryStream).CREATE TABLE invocations ( run_id TEXT PRIMARY KEY, -- UUIDv7, == 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, -- the --cache-policy spec, e.g. "local:rw,remote:r" 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 up_to_date_count INTEGER NOT NULL DEFAULT 0, -- v31: hits that restored nothing restored_local_count INTEGER NOT NULL DEFAULT 0, -- v31: hits that restored, local layer restored_remote_count INTEGER NOT NULL DEFAULT 0, -- v31: hits that restored, remote layer 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);CREATE INDEX invocations_started ON invocations(started_at);CREATE INDEX invocations_branch ON invocations(branch);CREATE INDEX invocations_ci ON invocations(ci);
-- v50: what a run said that its runs rows do not hold, for `vx last`-- (cache/run-notes.ts); `vx last --sql` reads them as it reads runs. Rows-- of the newest 50 runs that had any, per table (run ids are UUIDv7, so-- they sort by age); a run with none writes none.CREATE TABLE steps ( -- vx's steps the run did not wait on (vx:prefetch) run_id TEXT NOT NULL, seq INTEGER NOT NULL, -- the order they ended step TEXT NOT NULL, -- 'vx:prefetch' duration_ms INTEGER NOT NULL, ok INTEGER NOT NULL, PRIMARY KEY (run_id, seq));CREATE TABLE warnings ( -- what the run warned of, colour stripped run_id TEXT NOT NULL, seq INTEGER NOT NULL, text TEXT NOT NULL, PRIMARY KEY (run_id, seq));CREATE TABLE failures ( -- a failed task's output (runFailures, vx last --log) run_id TEXT NOT NULL, task_id TEXT NOT NULL, -- project#task input_moved TEXT, -- the input that changed while it ran output TEXT NOT NULL, -- plain, masked, first 8 KiB + last 56 KiB locations TEXT NOT NULL, -- JSON [{ file, line?, col? }] sandbox_fix TEXT, -- JSON: the allow entries its denials needed PRIMARY KEY (run_id, task_id));
-- v50: a lockfile claimant's parse (orchestrator/lockfile-claim.ts), one-- row per lockfile and claimant (an xxh3 of its parsers' source, scope and-- part), read while version and lock match and replaced when they do not.-- Read and written on a connection of its own, as its readers (a discover-- hook, --affected's selection) run before a run opens the index; only an-- index of this schema that exists is read or written.CREATE TABLE lockfile_memos ( file TEXT NOT NULL, -- 'pnpm-lock.yaml' claimant TEXT NOT NULL, version INTEGER NOT NULL, -- the claimant's digest version lock TEXT NOT NULL, -- xxh3 of the lockfile's bytes extras TEXT NOT NULL, -- JSON { file: content hash } of the files it names importers TEXT NOT NULL, -- JSON { importer: digest }, or null: a run's read digests nothing edges TEXT NOT NULL, -- JSON { importer: { deps, peers } } recorded TEXT, -- JSON { importer: [dependency name] } PRIMARY KEY (file, claimant));
-- 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 joins two entries' components-- over (kind, name), no recompute. v34: one row per entry, the-- components one JSON array (a row per component cost a b-tree insert-- each: ~0.5 ms a save at 50 input files). ON DELETE CASCADE sweeps the-- row when a prune drops the entry.CREATE TABLE entry_inputs ( entry_hash TEXT PRIMARY KEY, -- == entries.hash / runs.hash components TEXT NOT NULL, -- JSON [[kind, name, hash], …] in (kind, name) code-unit order FOREIGN KEY (entry_hash) REFERENCES entries(hash) ON DELETE CASCADE);-- kind: file|env|runtime|ws-runtime|upstream|package|config|forward|workspace|plugin|format-- name: file: workspace-rel path; env: var name; upstream: task id; …-- hash: env|runtime|ws-runtime|forward|plugin: xxh3hex(salt + value); an unset env var: 'unset'
-- v32: what belongs to the entries, not to one workspace: 'value_salt',-- the salt entry_inputs digests are taken under, so they compare with-- another workspace's run.CREATE TABLE store_meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL);
-- v36: each executed success's duration, appended in the run's history-- transaction, in the store so every worktree forecasts from it.-- source: 'branch:<name>'; the forecast reads the newest five per task.-- v37: 'remote' rows are a default-branch commit's timings a plugin-- imported, one per (sha, task). A run on a crowded worker (runs.cpu_share-- < 1) is kept as the time it would take alone: its CPU's wait for a core,-- cpu/share - cpu, spread over the cores it kept busy (at least one), off-- its wall time.CREATE TABLE task_durations ( id INTEGER PRIMARY KEY AUTOINCREMENT, project TEXT NOT NULL, task TEXT NOT NULL, source TEXT NOT NULL, sha TEXT, duration_ms INTEGER NOT NULL, recorded_at INTEGER NOT NULL, -- v45: a 'remote' row's measured need; NULL on the rest. cpu_parallelism REAL, peak_rss_bytes INTEGER, saturated_cores INTEGER, saturated_memory_bytes INTEGER, -- v48: the task's definition the row is of (`taskDefinition`) def TEXT);CREATE INDEX task_durations_task ON task_durations(project, task, source, id);CREATE UNIQUE INDEX task_durations_remote ON task_durations(sha, project, task) WHERE source = 'remote';
-- v38: the flaky record (failure-mode.ts), in the store so a key that-- passed in one worktree and failed in another is flaky in both. Every-- executed success or failure that names its key (keyed, cached, not run-- behind a failure), and a key's first hit: a later hit replays a pass on-- record, so a warm run on passed keys writes no row (one index probe,-- ~1 ms per 1,000 hits). Pruned by age with the run history.CREATE TABLE task_outcomes ( id INTEGER PRIMARY KEY AUTOINCREMENT, project TEXT NOT NULL, task TEXT NOT NULL, hash TEXT NOT NULL, status TEXT NOT NULL, attempts INTEGER, recorded_at INTEGER NOT NULL);CREATE INDEX task_outcomes_hash ON task_outcomes(hash, status);
-- v37: a plugin's own entries (`ctx.cache`), by core's key for its-- name; written once, pruned by age with the run history.CREATE TABLE plugin_blobs ( key TEXT PRIMARY KEY, bytes BLOB NOT NULL, at INTEGER NOT NULL);
-- v47: what a remote's hasMany said it holds, hits only: a key with no-- row is a miss. A whole hasMany answer rewrites the rows it asked, a GET-- every remote answers with nothing drops one; pruned by seen_at with the-- run history.CREATE TABLE remote_hits ( hash TEXT PRIMARY KEY, size INTEGER, duration_ms INTEGER, seen_at INTEGER NOT NULL);
-- v33: an artifact of at most 32 KiB compressed, its exact <hash>.tar.zst-- bytes, written in the transaction that writes its entry rows. In the-- store (or a named cache dir's cache.db), and outside every drop a-- SCHEMA_VERSION reset makes: the artifact is the record.CREATE TABLE artifacts ( hash TEXT PRIMARY KEY, at INTEGER NOT NULL, -- last use (ms): a row-less sweep's file time bytes BLOB NOT NULL); -- rowid: a hash probe never reads a blob
-- 'layout' = 'a2': the artifacts table's own version; another value-- drops the table, silently. Nothing else drops it. Read only where-- the schema was not this vx's, so a layout change bumps the schema.CREATE TABLE artifacts_meta ( key TEXT PRIMARY KEY, value TEXT NOT NULL);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_inputsstores a digest of each value-bearing component —env,runtime,ws-runtime,forwardandpluginrows holdxxh3hex(salt + value), an unset env var the literal'unset'— never the value, so a secret read as a cache input does not land incache.dbas plaintext. The salt is 128 random bits the store draws once (store_metakeyvalue_salt):vx whyprints these digests, and an unkeyed xxh3 in a public CI log let anyone confirm or brute-force a short secret. The “why did this re-run?” diff only needs to know a component changed, which the digest tells it.cache.dbstill records commands and captured stdout; it is a local, gitignored, single-user file.
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. An older schema drops
and recreates every table but
schema_meta(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 three indexed
SELECTs (theentriesrow, which joins whether the artifact is inline, itsoutput_filesand itsoutput_dirs), plus astatof the artifact when it is a file. Restore is a tar.zst extract, skipped entirely when the on-disk tree already matches.accessed_atbumps (only rows over an hour old) are batched into one UPDATE at flush time. - Cache write is one in-process tar.zst pack + one SQLite transaction, which holds a small artifact’s bytes itself; a large one adds a temp write and an atomic rename. Hashing dominates the run; storage itself is cheap. The remote upload (if any) is backgrounded.
- Claimed root files (a lockfile plugin’s lockfile and settings)
are read once per
vx runinvocation; after a task that may rewrite one ran, onestatper file re-checks it (item 750). With no plugin claiming one, nothing is read.
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 3) 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 viainputs.workspaceFiles(root-anchored globs). -
Gitignored files no entry names. Vite and Next load
.env,.env.local,.env.[mode]and.env.[mode].localon their own (a production build reads.env.production.local), and their default.gitignoreignores*.local. A broad glob skips an ignored file,.env*included, so editing one replays the old build under a hit. Name each local file outright and let.env*take the tracked ones:files: ['src/**', '.env*', '.env.local', '.env.production.local', '.env.development.local'].vx lockand a miss name an ignored file a glob skips. Its bytes are hashed, never stored, and a missing file is simply not an input. Machines with different files compute different keys, so they miss each other’s shared entries.--affectedignores it (only what git sees selects a task), andvx watchre-runs on its edit. A probe (runtime: ['cat .env.local 2>/dev/null | cksum']) still works where a command, not a path, decides what to read. -
Node / Bun / OS / build-tool versions — unless you declare them. The canonical mechanism is
inputs.runtime/workspaceRuntime(e.g.workspaceRuntime: ['node -v']): the command output is resolved live at hash time, so it stays correct under the lock. Avoid baking versions viadefine: { X: execSync(...) }— that value freezes into the config object at lock time and goes stale. -
Another language’s manifests and lockfiles. A lockfile plugin keys JS dependencies only; name the rest in
inputs.files: Go’sgo.modandgo.sum, Rust’sCargo.tomlandCargo.lock, Python’spyproject.toml,uv.lockandrequirements*.txt. A sibling areplace … => ../golibor a path dependency reads isinputs.workspaceFiles: ['golib/**']beside its read grant.go buildstamps the commit (vcs.revision) into the binary, which no input holds, so a hit replays the old one: build with-buildvcs=false. -
.vx/projects.lock.json— globally excluded (v24); also filtered out of--affectedchange sets. So are the filesvx lockandvx initwrite beside it: each project’svx.lock.jsonreview copy,.vx/.gitattributes,.vx/.gitignoreand.vx/dependencies.lock.json. -
exec.remote. Pure PLACEMENT: it decides where a task runs, never what it produces, so pinning a task to this machine does not bust its cache. It must not — the whole contract of a remote executor is that the same command over the same inputs yields the same outputs, so a key that moved with placement would split your laptop from the worker pool over nothing.exec.timeoutandexec.retriesare the deliberate exception — they stay in the key. That asymmetry is easy to misread as an oversight, so: a timeout or a retry budget can change whether the task completes at all, which is a different question from where it ran. They were folded in before the placement fields existed, and stripping them now would be aCACHE_VERSIONbump for no correctness gain. -
run.timeout/run.retries(flag, variable or workspace). Run options, as the flags are: a passing attempt stores the same outputs under any limit or budget, and a retry starts clean. -
Every other setting (
run.*,cache.*,lock.*,watch.*,baseBranch,inputs.stability.severity). Each decides how a run runs or where an entry lives, never which outputs a save keeps. The two that do,outputs.cleanandinputs.stability.strategy, fold (step 3).
Bumping CACHE_VERSION
Section titled “Bumping CACHE_VERSION”The cache records the version it was written under
(schema_meta.cache_version). After an upgrade every cached task
misses once and re-saves, with nothing printed (2026-10-06: the
cache is vx’s to keep). The index survives, so the old entries stay
until they age out under vx cache clean --older-than; no key derives to them again.
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.
- A key part leaves the chain: every key moves, so no unchanged key
holds wrong bytes (
v43stayed when core stopped folding theworkspace:andpkg:parts, 2026-10-14).
tests/contract-stored-format.test.ts holds the layout rows: it
records the index’s DDL beside SCHEMA_VERSION and a fixture
artifact’s entries, sidecar and digest, and the tree a save keeps of it,
beside CACHE_VERSION
(tests/contract/stored-format.json), and fails when either layout
moves under its recorded version. Every bump of either version
regenerates the record
(VX_UPDATE_CONTRACT=1 bun test tests/contract-stored-format.test.ts),
which refuses a layout that moved without one.
The bump procedure has a dedicated skill at
.claude/skills/bump-cache-version/ (used as /bump-cache-version).
Files touched, in the skill’s order: src/cache/key-fold.ts (the constant),
this doc (history), docs/modules/cache.md (the quoted version, and the
key/entry shape if it changed), CLAUDE.md § Live invariants (the quoted
version — the decision log it once named was retired 2026-09-02),
docs/STATUS.md (the entry that says why the bump was needed, or why it
was not), the cache tests, tests/contract/stored-format.json
(regenerated, above), and
private/vx-docs/src/content/docs/guides/upgrading.md (the bump’s
breaking footer).
History
Section titled “History”-
v55 → v56: stored bytes wrong under a key the change does not move (2026-11-12). A task’s output bytes that are not UTF-8 are kept as written, in the log and the artifact; a v55 entry holds U+FFFD for them, so it is never read.
SCHEMA_VERSIONmoved to v56 with it:output_stampsandoutput_dirsgainedrootin their primary key, and a v55 index, which has no such column, failed every run until reset. -
v54 → v55: stored bytes wrong under a key the change does not move (2026-11-11). No clean deletes a committed file, so a hit cannot replay a run that removed one under its outputs: the restore left it, and the tree differed from the run’s on every hit. Such a run now saves nothing (X-32); a v54 entry may be one, so it is never read.
-
v53 → v54: stored bytes wrong under a key the change does not move (2026-11-11). An additive task (item 588) saved only what its run added or changed, so an incremental emitter’s entry lacked the files it found from its own last run and left alone, and a hit over a wiped tree restored without them; a v53 entry may lack them, so it is never read.
-
v52 → v53: the tree form’s layout (2026-11-10). A tree keeps each output at its row’s path, with no
.vx-meta.jsonand nooutputs/level; a v52 tree read by row path would find a project output namedoutputs/…at another output’s place, so it is never read. -
v51 → v52: stored bytes wrong under a key the change does not move (2026-11-07). A sandboxed task could read through a
node_modulesthat is itself a link (to the workspace root or a sibling project), or into a keyed linked package’s nested project, and its key folded neither; a v51 entry may hold those bytes, so it is never read. -
v50 → v51: stored bytes wrong under a key the change does not move (2026-10-29). An output ending
/.stored nothing, and a stored stream could keep a secret split across stdout and stderr writes unmasked; a v50 entry for either holds those bytes, so it is never read. -
v49 → v50: stored bytes wrong under a key the change does not move (2026-10-29). Two branches each took v49 for one of the two changes below: a store written by one lacks the other’s (brace alternatives read alone, or
dirModesin the sidecar), and a v50 read would trust it. A v49 entry is never read. -
v48 → v49: stored bytes wrong under a key the change does not move (2026-10-28). Each brace alternative is read alone:
{dist,lib}is the two trees, and{./dist,lib}/**drops the./. An output glob folds as its text, so a v48 entry foroutputs: ['{dist,lib}']holds an empty artifact under the unchanged key and would restore nothing. A hit made each missing output directory with mkdir’s default, sochmod 700 dist/keyscame back 0755 afterrm -rf distor on a fresh checkout, and every user could read the files it kept to its owner. The sidecar now keepsdirModes, the mode of each output directory that withholds a permission 0755 grants, and a hit gives each directory it makes that mode, minus the umask as a file’s (tests/restore-dir-mode.test.ts); a directory that stands is left as it is. A rename, or a tree entry’s copy-out, into one of this user’s that withholds its own write (chmod 555) gives it owner write for the commit, then its mode back. A v48 artifact keeps none. -
v47 → v48: stored bytes wrong under a key the change does not move (2026-10-18). A task glob is a standard glob again:
[...]is a character class and\[id\]names a route directory, which reverts v28. An output glob folds as its text, sooutputs: ['app/[id]/page.js']keeps its key while what it selects changes; a v47 entry under it holds the literal route and would restore it. -
v46 → v47: the container changes. A file-backed entry is a
<hash>.tree/of plain files (cache.store); the tar.zst is packed only to upload. A v46 store holds archives a v47 read would not look for. -
v43 kept (2026-10-14): core stopped folding the project’s
package.jsonand the root fingerprint files; every key moved, so nothing replays wrong bytes and no bump was needed. -
v44 → v45: the container changes. The sidecar lists the symlinks under the outputs (
links) and a hit makes them; a v44 artifact holds a link to a file as a copy of its target, and a link to a directory never cached (keep links, as Turbo and Nx do). -
v43 → v44: the container changes. The sidecar lists the empty directories under the outputs (
dirs) and a hit makes them; a v43 artifact lists none, and its hit leftdist/empty/out where the miss had made it (Growth’s parity run: Turbo and Nx restore it). -
v42 → v43: stored bytes wrong under an unchanged key (Security F4, 2026-10-14). A scoped run stored its local entries under the default key, so a later default run on the same disk replayed a PR’s bytes. A scoped run now stores under the scope’s key locally as it does remotely; the fix cannot reach an entry already saved that way.
-
v41 → v42: the stored output became both streams in the order the run printed them (
orchestrator/output-log.ts), so a hit replays stderr too. A v41 entry holds stdout alone. -
v40 → v41: stored bytes wrong under an unchanged key (X-88). An output directory linked inside its project was never cleaned, so an entry could hold files a run of another key left there. The fix cannot reach an entry already saved that way.
-
v39 → v40: stored bytes wrong under an unchanged key (X-32, X-33, X-34). An additive task’s entry a hit replayed over a file the task had removed, one that missed a same-size rewrite, and a runtime probe answered before its upstream wrote.
-
v38 → v39: stored bytes wrong under an unchanged key (A-61). A gitlink whose directory had lost its
.gitbut held files listed none of them, so an entry built from them sits under the key the same directory empty derives. -
v37 → v38: stored bytes wrong under an unchanged key (A-60). A file added under a clean filter that was later removed kept its LF index blob, git held it clean by its stat, and the key folded that blob while the task read the CRLF bytes. The blob-size check cannot reach an entry already saved that way.
-
v36 → v37: stored bytes wrong under an unchanged key (A-59).
git statuspaired a deleted file with a similar unmerged path as its rename source and printed onlyUU <path>, so the file kept its index OID and the key folded it while the task ran without it. The fix (--no-renames) cannot reach an entry already saved that way. -
v35 → v36: the container changes (L-19). Every artifact ends in a
.vx-sumentry, a CRC-32 over the entries before it, and scan and restore refuse one whose sum is absent or wrong: a byte flipped in a raw zstd block restored as a hit, undetected. A v35 artifact carries no sum, so the bump retires them rather than refusing each on read. -
v34 → v35: the container changes (item 943). The sidecar records the cache key the artifact was packed under, and ingest refuses bytes whose key is not the one it asked for, or that record none. Nothing tied an artifact to its key before: a remote layer that answered one key with another’s bytes (a truncated or colliding key mapping, two namespaces mixed) replayed the other task’s outputs under a green
cache-hit-remote, probed with projectbrestoring projecta’sout.txt. An artifact from v34 records no key, so the bump retires them rather than refusing each one on read. -
v33 → v34: stored bytes wrong under a key a CORRECT derivation now produces (item 750). A root task that rewrote the lockfile mid-run (
pnpm installwithout--frozen-lockfile) let a reader after it save a build made against the new install under the key of the lockfile the run started with, and that key is exactly what the fixed code derives when the tree is back on that lockfile. The fix stops new entries, not old ones. Probed, not argued: seeds A then B through the previous commit, then A twice through the fixed code under v33 — the last run started on lockfile A, installed A, and restored the build made against B; under v34 it built A. The item’s other two edges poisoned nothing: their stale restores happened in runs that saved nothing (a read-only policy, a tainted upstream) or saved under a key the 743 re-check already guarded. -
v32 → v33: stored bytes wrong under a key a CORRECT derivation now produces (item 743). The three stale-hit fixes of that item stop new poisoned entries, but not the ones already saved: a formatter’s entry sits under the key of its unformatted input, an uncached
gen’s consumer’s under the key of the seed beforegenran, an edit-mid-run’s under the key of the file before the edit — and each of those keys is exactly what the fixed code derives when the tree is back in that state. Not self-healing, unlike a fix whose old key no correct run derives. Probed, not argued: an entry saved by the previous commit’s formatter, the input checked out unformatted, and the fixed code under v32 reportedup-to-dateand left it unformatted; under v33 it runs. -
v31 → v32: stored bytes wrong under a key the fix does not change (item 739). A declared output whose name is not UTF-8 (
x\xffy) came back fromBun.Globdecoded lossily asx�y, which names no file, so the save packed the tree without it and every hit restored a tree missing that file under an unchanged key. vx now refuses such a name by name; an artifact saved before the fix is the wrong bytes, so v31 entries are not trusted. -
v30 → v31: stored bytes wrong under a key the fix does not change (item 726), v30’s shape for a sibling. A sandboxed task that declared
cachewas granted every workspace package linked in itsnode_modules, so it could import a sibling its key never folded and save what it built. The fix withholds that link unless the key answers for the package (§ Cross-project boundaries), so the next miss fails on the read; but an entry saved before it still hits when only the sibling changes. Probed, not argued: an entry saved by the previous commit,@x/uiedited, then this commit’s run under v30 reportedup-to-dateand left the olduiindist/; under v31 the same run misses and fails on the read, with the hint. -
v29 → v30: stored bytes wrong under a key the fix does not change (item 720). npm and Yarn link every workspace package at the root, the task’s own included, and the sandbox granted every link target whole, so a sandboxed task was handed its own project back whatever its
allow.readsaid. An undeclared read of its own file ran unreported and the result was saved under a key that never saw that file. The fix withholds the self-link, so the next miss fails on the read; but an entry saved before it hits for as long as only that file changes. -
v28 → v29: every key moves, and the bump is for the notice, not for wrong bytes (item 682). The key is a seed-chained xxHash3 fold, one step per field and per input file, and Bun’s xxHash3 reads only the low 32 bits of its seed: a bare chain carried 32 bits of state, so two input sets whose running digests shared their low halves merged at the next step, a stale hit at about 2^-32 per step rather than 2^-64. A birthday search over 2^17 values of one env variable found two that gave one
Cache.keyin under a second.xxh3now feeds the seed forward (xxHash3(part, seed) ^ seed): states that share their low half keep their high-half difference through every later step. The old keys were wrong but their stored bytes are not, so the fix alone is self-healing; without the bump every entry would miss silently. -
v27 → v28: stored bytes wrong under a key the fix does not change — the v25/v26 shape (item 667). A bracket in a task glob became a literal character; before,
Bun.Globread it as a character class. An INPUT glob over a route directory folded the wrong files, and that fix is self-healing: the file set, and so the key, moves. An OUTPUT glob is not:outputs: ['app/[id]/page.js']folds as its text, which the fix leaves unchanged, and the entries under it hold nothing (or the class’s namesakeapp/i/page.js). Proven through a real run: an entry written by v27 code, read by the fixed code, was acache-hitthat cleanedapp/[id]/page.jsand restored nothing; under v28 it is a miss, and the next hit restores the route. -
v26 → v27: the artifact CONTAINER changed, so the stored bytes under an unchanged key are no longer readable the same way — the layout case, not the wrong-bytes case. Packing moved from a
tarsubprocess (with a staged copy of every output and a per-host--format=gnu/--format=gnutarprobe) toBun.Archive, and the per-entry mode + millisecond mtime that tar headers carried natively now ride a.vx-meta.jsonsidecar. Measured on the realCache.savepath (300 outputs / 12 MB, min-of-5, interleaved arms against agit worktreeof the previous commit): 158 ms → 11 ms to pack, the artifact grows ~7 compressed bytes per output for the sidecar. Restore is indistinguishable up to ~12 MB, but on a 150 MB incompressible artifact it costs +28% time and +19% peak RSS (74 → 95 ms, 575 → 683 MB peak, fresh process per arm):files()returns entries that OWN their bytes, so they coexist with the decompressed tar where the old reader returned views into it. Peak is ~4.5× artifact size, up from ~3.8×. The bump is mandatory rather than self-healing: a v26 artifact has no sidecar, so a v27 reader would restore its outputs mode-0644 and mtime-now — silently wrong on disk rather than a miss. Since 2026-09-03 the same layout is written and read by vx’s own streaming tar code (no bump: the bytes are readable either way), and the peak above is history — save, ingest and restore hold one chunk now; see § Storage layout. -
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 clean); 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, 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/projects.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 whoseinputs.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
inputs.runtime/workspaceRuntimecommand output into the key (two namespaced sections after env-values). The command strings stay in the config hash (step 3); 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 info. Removes the per-entry manifest. -
v10 → v11 (PR #19): analytics columns added to the
runstable:run_id(UUIDv7),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 wheninputs.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 8) 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 never get one; symlinks did not either until 2026-09-09, when the fallback started hashing a link the way the index does). 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).