@vzn/vx 0.0.642 → 0.0.644

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/package.json +5 -5
  2. package/schemas/info.json +0 -11
  3. package/skills/vx/SKILL.md +1 -1
  4. package/src/cache/archive.ts +153 -2
  5. package/src/cache/cache.ts +417 -183
  6. package/src/cache/file-hashes.ts +2 -14
  7. package/src/cache/fold-memo.ts +7 -3
  8. package/src/cache/index.ts +10 -2
  9. package/src/cache/key-fold.ts +4 -1
  10. package/src/cache/layer.ts +11 -4
  11. package/src/cache/layered-cache.ts +82 -8
  12. package/src/cache/plugin-cache.ts +51 -43
  13. package/src/cache/remote-lane.ts +18 -13
  14. package/src/cache/run-store.ts +4 -1
  15. package/src/cache/scoped-cache.ts +74 -239
  16. package/src/cache/tar-stream.ts +12 -7
  17. package/src/cli/format.ts +1 -2
  18. package/src/cli/help.ts +8 -4
  19. package/src/cli/info.ts +2 -19
  20. package/src/cli/plugin-commands.ts +11 -6
  21. package/src/cli/run.ts +7 -12
  22. package/src/cli/select.ts +7 -9
  23. package/src/cli/upgrade.ts +38 -8
  24. package/src/cli/workspace-config.ts +8 -2
  25. package/src/config.ts +22 -26
  26. package/src/exec/sandbox-paths.ts +2 -2
  27. package/src/graph/scheduler.ts +3 -1
  28. package/src/graph/task-graph.ts +15 -11
  29. package/src/index.ts +5 -1
  30. package/src/orchestrator/affected-tasks.ts +23 -2
  31. package/src/orchestrator/doctor.ts +0 -17
  32. package/src/orchestrator/execute-task.ts +1 -1
  33. package/src/orchestrator/index.ts +4 -0
  34. package/src/orchestrator/options.ts +15 -1
  35. package/src/orchestrator/plugin-host.ts +11 -4
  36. package/src/orchestrator/plugin.ts +23 -2
  37. package/src/orchestrator/prepare.ts +81 -18
  38. package/src/orchestrator/projects.ts +26 -5
  39. package/src/orchestrator/remote-prefetch.ts +11 -4
  40. package/src/orchestrator/run-context.ts +85 -0
  41. package/src/orchestrator/run-settings.ts +3 -0
  42. package/src/orchestrator/run.ts +9 -26
  43. package/src/orchestrator/task-hash.ts +6 -0
  44. package/src/report/logger.ts +0 -4
  45. package/src/report/summary.ts +3 -11
  46. package/src/settings.ts +54 -25
  47. package/src/util/index.ts +1 -0
  48. package/src/util/paths.ts +42 -33
  49. package/src/util/size.ts +4 -5
  50. package/src/workspace/affected.ts +120 -63
  51. package/src/workspace/config-schema.ts +44 -52
  52. package/src/workspace/index.ts +3 -0
@@ -49,7 +49,7 @@ import {
49
49
  utimesSync,
50
50
  writeFileSync,
51
51
  } from 'node:fs'
52
- import { readdir, rm, stat, unlink, writeFile } from 'node:fs/promises'
52
+ import { chmod, lstat, mkdir, readdir, rm, stat, unlink, utimes, writeFile } from 'node:fs/promises'
53
53
  import path from 'node:path'
54
54
  import { createArtifactTables, createTables, inHashes, lazyStatement } from './schema.js'
55
55
  import {
@@ -76,7 +76,12 @@ import {
76
76
  packArtifactBytes,
77
77
  packArtifactStream,
78
78
  planArtifact,
79
+ planOfTree,
80
+ sealTree,
81
+ treeIntact,
82
+ writeTree,
79
83
  } from './archive.js'
84
+ import { readUmask, restoredMode } from './umask.js'
80
85
  import {
81
86
  ArtifactVanishedError,
82
87
  type CacheEntry,
@@ -119,7 +124,7 @@ import { behindLink } from './linked-inputs.js'
119
124
  * An artifact or temp file without an `entries` row is never reaped
120
125
  * younger than this: a crashed save's temp is stale long before it. A
121
126
  * row-less artifact may still be in use (another vx version's open dropped
122
- * its row), so past this it is judged by the retention policy, its file
127
+ * its row), so past this it is judged by the prune policy, its file
123
128
  * time standing for `accessed_at`. A hit renews a file time older than
124
129
  * this, so the file time trails the last use by at most this much, and
125
130
  * the policy reads the file time plus this as the last use.
@@ -137,7 +142,7 @@ const ADOPT_STATS = 64
137
142
  * A hit renews an entry's `accessed_at` only once it is older than this:
138
143
  * renewing every hit rewrote 2,180 rows at a warm 1,090-package run's
139
144
  * close, ~30 ms of it. So `accessed_at` trails the last use by at most
140
- * this much, and the retention policy reads it plus this as the last use:
145
+ * this much, and the prune policy reads it plus this as the last use:
141
146
  * an entry used within the limit is never pruned, and one may stay up to
142
147
  * this long past it (owner, 2026-10-08).
143
148
  */
@@ -150,7 +155,7 @@ const ACCESS_REFRESH_MS = 60 * 60 * 1000
150
155
  * directory), and the sweep once took any `*.tar.zst` an hour old there,
151
156
  * the user's own release tarball included (item 968).
152
157
  */
153
- const VX_ARTIFACT_NAME = /^([0-9a-f]{16})\.tar\.zst(\.tmp-\d+-\d+-[0-9a-z]*)?$/
158
+ const VX_ARTIFACT_NAME = /^([0-9a-f]{16})\.(?:tar\.zst|tree)(\.tmp-\d+-\d+-[0-9a-z]*)?$/
154
159
 
155
160
  /** What vx writes in a cache directory besides its artifacts. */
156
161
  const VX_CACHE_NAMES = new Set([
@@ -186,8 +191,8 @@ export function vxCacheEntries(cacheDir: string): string[] {
186
191
  })
187
192
  }
188
193
 
189
- /** The `schema_meta` key holding when the orphan sweep last ran (ms epoch). */
190
- const SWEPT_AT = 'orphans_swept_at'
194
+ /** What `vx cache clean` keeps of run history and the hash memos. */
195
+ const HISTORY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000
191
196
 
192
197
  export interface SchemaReset {
193
198
  from: string
@@ -295,7 +300,7 @@ function outOfFdsAtOpen(dbFile: string, err: unknown): UserError | undefined {
295
300
  // output_files' ino + ctime_ms moved to the workspace's own
296
301
  // output_stamps, output_dirs lost its foreign key, and the value
297
302
  // salt moved to store_meta. The cache KEY is unchanged.
298
- // v33: an artifact of at most INLINE_MAX compressed bytes lives in the
303
+ // v33: an artifact of at most 32 KiB compressed lives in the
299
304
  // store's `artifacts` table, written in its rows' transaction, and
300
305
  // every entry read joins it. The artifact bytes and the cache KEY are
301
306
  // unchanged (CACHE_VERSION not bumped).
@@ -370,13 +375,6 @@ const ARTIFACT_TABLES = ['artifacts', 'artifacts_meta']
370
375
  */
371
376
  const ARTIFACT_LAYOUT = 'a2'
372
377
 
373
- /**
374
- * The largest compressed artifact kept in the index rather than in a file
375
- * of its own: it is written in its rows' transaction, with no temp and no
376
- * rename (docs/design/cache-save-cpu-2026-10.md).
377
- */
378
- const INLINE_MAX = 32 * 1024
379
-
380
378
  /**
381
379
  * An index that records a schema other than this vx's: the one an opener
382
380
  * that writes resets. Reads the recorded version alone and touches
@@ -509,8 +507,9 @@ async function scanOrphanFiles(
509
507
  const file = path.join(cacheDir, name)
510
508
  try {
511
509
  const st = await stat(file)
512
- if (st.isFile() && st.mtimeMs <= cutoff) {
513
- found.push({ file, size: st.size, mtimeMs: st.mtimeMs, temp })
510
+ if ((name.includes('.tree') ? st.isDirectory() : st.isFile()) && st.mtimeMs <= cutoff) {
511
+ const size = st.isDirectory() ? await treeBytes(file) : st.size
512
+ found.push({ file, size, mtimeMs: st.mtimeMs, temp })
514
513
  }
515
514
  } catch {
516
515
  // Gone between readdir and stat: not an orphan any more.
@@ -520,6 +519,16 @@ async function scanOrphanFiles(
520
519
  return found
521
520
  }
522
521
 
522
+ /** The bytes a tree-form artifact's files hold. */
523
+ async function treeBytes(dir: string): Promise<number> {
524
+ let n = 0
525
+ for (const e of await readdir(dir, { recursive: true, withFileTypes: true }).catch(() => [])) {
526
+ if (e.isFile())
527
+ n += (await stat(path.join(e.parentPath, e.name)).catch(() => ({ size: 0 }))).size
528
+ }
529
+ return n
530
+ }
531
+
523
532
  /**
524
533
  * SQL predicate selecting `runs` rows that record an EXECUTION.
525
534
  *
@@ -613,7 +622,7 @@ function usageOfEntry(entry: { cpuMs?: number; peakRssBytes?: number }): ExecUsa
613
622
  * it there", and the ignore file is created exclusively instead of probed
614
623
  * first. An existing cache costs three calls; it cost six.
615
624
  */
616
- function openCacheDir(cacheDir: string, workspaceRoot?: string): string | null {
625
+ function openCacheDir(cacheDir: string, workspaceRoot?: string, index = 'cache.db'): string | null {
617
626
  const [ignore, ignoreText] = ignoreFileFor(cacheDir)
618
627
  try {
619
628
  accessSync(cacheDir, constants.W_OK)
@@ -624,7 +633,7 @@ function openCacheDir(cacheDir: string, workspaceRoot?: string): string | null {
624
633
  return null
625
634
  }
626
635
  try {
627
- accessSync(path.join(cacheDir, 'cache.db'), constants.W_OK)
636
+ accessSync(path.join(cacheDir, index), constants.W_OK)
628
637
  } catch (err) {
629
638
  const code = (err as NodeJS.ErrnoException).code
630
639
  // A FILE at `cacheDir` passed the `access` above; mkdir names it.
@@ -649,7 +658,7 @@ function storeBlocked(storeDir: string, storeRoot: string | null): string | null
649
658
  if (refused !== null) return refused
650
659
  }
651
660
  }
652
- return openCacheDir(storeDir)
661
+ return openCacheDir(storeDir, undefined, 'store.db')
653
662
  } catch (err) {
654
663
  return errorText(err)
655
664
  }
@@ -788,6 +797,33 @@ function errorText(err: unknown): string {
788
797
  return err instanceof Error ? err.message : String(err)
789
798
  }
790
799
 
800
+ /** A tree the save wrote from its own outputs, with the scan its tar would give. */
801
+ interface SavedTree {
802
+ tree: string
803
+ scan: Awaited<ReturnType<typeof scanArtifact>>
804
+ bytes: number
805
+ }
806
+
807
+ /** How a save keeps an entry locally (`cache.store`). */
808
+ export interface StoreOptions {
809
+ /**
810
+ * The largest entry kept in the index, as one zstd tar (fewer files to
811
+ * make): written in its rows' transaction, with no temp and no rename
812
+ * (docs/design/cache-save-cpu-2026-10.md). 0 keeps every entry as files.
813
+ */
814
+ embeddings: number
815
+ /** A file-backed entry is a `<hash>.tree` of plain files; off, one `<hash>.tar.zst`. */
816
+ decompress: boolean
817
+ /** What a tree restore checks first: each file's size and mtime, every byte, or nothing. */
818
+ verify: 'stat' | 'hash' | 'none'
819
+ }
820
+
821
+ export const DEFAULT_STORE: StoreOptions = {
822
+ embeddings: 32 * 1024,
823
+ decompress: true,
824
+ verify: 'stat',
825
+ }
826
+
791
827
  /** What a run's background uploads moved: totals over the ones that landed. */
792
828
  export interface UploadTally {
793
829
  count: number
@@ -812,12 +848,6 @@ export interface RemoteTraffic {
812
848
  /** Uploads queued or running across every remote. */
813
849
  inFlight: number
814
850
  readonly keys: Map<string, RemoteKeyTraffic>
815
- /**
816
- * A key a remote is asked under that is not the task's own (a scope's)
817
- * → the task key it answers for: its traffic counts under the task key,
818
- * which the footer and the run's rows look up.
819
- */
820
- readonly aliases: Map<string, string>
821
851
  }
822
852
 
823
853
  export class Cache implements CacheLayer {
@@ -830,7 +860,7 @@ export class Cache implements CacheLayer {
830
860
  */
831
861
  readonly uploads: UploadTally = { count: 0, bytes: 0, ms: 0, failed: 0 }
832
862
  /** The same remotes' traffic per key, filled by the same `LayeredCache`. */
833
- readonly traffic: RemoteTraffic = { inFlight: 0, keys: new Map(), aliases: new Map() }
863
+ readonly traffic: RemoteTraffic = { inFlight: 0, keys: new Map() }
834
864
 
835
865
  private readonly db: Database
836
866
  private readonly insertEntry: ReturnType<Database['prepare']>
@@ -945,6 +975,7 @@ export class Cache implements CacheLayer {
945
975
  * records none (deleted, or never written beside a run's store).
946
976
  */
947
977
  storeRoot?: string | null,
978
+ private readonly store: StoreOptions = DEFAULT_STORE,
948
979
  ) {
949
980
  this.inspecting = mode !== 'open'
950
981
  this.read = localPolicy.read
@@ -1683,7 +1714,7 @@ export class Cache implements CacheLayer {
1683
1714
  } else {
1684
1715
  // Verify the artifact file actually exists. The DB and the
1685
1716
  // filesystem can drift if someone manually deletes the cache dir.
1686
- const st = statSync(this.tarPath(hash), { throwIfNoEntry: false })
1717
+ const st = this.artifactStat(hash)
1687
1718
  if (st === undefined) return null
1688
1719
  if (st.mtimeMs < Date.now() - ORPHAN_GRACE_MS) this.staleTimes.add(hash)
1689
1720
  }
@@ -1723,7 +1754,7 @@ export class Cache implements CacheLayer {
1723
1754
  names = []
1724
1755
  }
1725
1756
  this.artifactNames ??= new Set(names)
1726
- return !names.some((n) => n.endsWith('.tar.zst'))
1757
+ return !names.some((n) => n.endsWith('.tar.zst') || n.endsWith('.tree'))
1727
1758
  }
1728
1759
 
1729
1760
  /**
@@ -1772,7 +1803,7 @@ export class Cache implements CacheLayer {
1772
1803
  if (r.inline_at < graceStart) this.staleInline.add(r.hash)
1773
1804
  return true
1774
1805
  }
1775
- const st = statSync(this.tarPath(r.hash), { throwIfNoEntry: false })
1806
+ const st = this.artifactStat(r.hash)
1776
1807
  if (st !== undefined && st.mtimeMs < graceStart) this.staleTimes.add(r.hash)
1777
1808
  return st !== undefined
1778
1809
  })
@@ -1817,7 +1848,7 @@ export class Cache implements CacheLayer {
1817
1848
  private storedHere(hash: string): boolean {
1818
1849
  const row = this.entryExists.get(hash) as { inline_at: number | null } | null
1819
1850
  if (row === null) return false
1820
- return row.inline_at !== null || existsSync(this.tarPath(hash))
1851
+ return row.inline_at !== null || this.artifactStat(hash) !== undefined
1821
1852
  }
1822
1853
 
1823
1854
  // Local cache has no slower layer to warm from — prefetch is a no-op.
@@ -1892,9 +1923,120 @@ export class Cache implements CacheLayer {
1892
1923
  }
1893
1924
  }
1894
1925
 
1926
+ /**
1927
+ * Restore a tree-form entry: its files checked as `cache.store.verify`
1928
+ * says, then copied (or cloned) out one by one where the outputs are
1929
+ * absent, as on every restore after a clean. Anything else (a tree to
1930
+ * restore into, a workspace output, a link) goes through the extractor
1931
+ * the tar form uses, fed the tree packed back into its tar.
1932
+ */
1933
+ private async restoreTree(
1934
+ hash: string,
1935
+ tree: string,
1936
+ rows: readonly OutputFileRow[],
1937
+ projectDir: string,
1938
+ workspaceRoot: string | undefined,
1939
+ verify: (provided: ReadonlySet<string>) => void,
1940
+ accept: ((name: string) => boolean) | undefined,
1941
+ ): Promise<void> {
1942
+ const files = rows.filter((r) => !r.path.endsWith('/') && r.mode !== LINK_MODE)
1943
+ let bytes = 0
1944
+ for (const r of files) bytes += r.size
1945
+ if (bytes > this.artifactCeiling) {
1946
+ throw new CorruptArtifactError(
1947
+ hash,
1948
+ `its outputs hold ${formatBytes(bytes)}, past the ${formatBytes(this.artifactCeiling)} artifact ceiling`,
1949
+ )
1950
+ }
1951
+ if (this.store.verify === 'stat') {
1952
+ const changed = await Promise.all(
1953
+ files.map((r) =>
1954
+ stat(path.join(tree, treeName(r.path))).then(
1955
+ (st) => st.size !== r.size || Math.floor(st.mtimeMs) !== r.mtimeMs,
1956
+ () => true,
1957
+ ),
1958
+ ),
1959
+ )
1960
+ if (changed.includes(true)) {
1961
+ throw new CorruptArtifactError(hash, 'a stored file changed since it was saved')
1962
+ }
1963
+ } else if (this.store.verify === 'hash' && !(await treeIntact(tree, this.stdoutText(hash)))) {
1964
+ throw new CorruptArtifactError(hash, 'the stored files no longer sum to what was saved')
1965
+ }
1966
+ if (await this.copyTree(tree, rows, files, projectDir, accept)) return
1967
+ await extractArtifactStream(
1968
+ packArtifactStream(planOfTree(tree, this.stdoutText(hash))),
1969
+ projectDir,
1970
+ workspaceRoot,
1971
+ verify,
1972
+ accept,
1973
+ workspaceRoot,
1974
+ )
1975
+ }
1976
+
1977
+ /** The copy-out: false, having written nothing, where it is not the case it serves. */
1978
+ private async copyTree(
1979
+ tree: string,
1980
+ rows: readonly OutputFileRow[],
1981
+ files: readonly OutputFileRow[],
1982
+ projectDir: string,
1983
+ accept: ((name: string) => boolean) | undefined,
1984
+ ): Promise<boolean> {
1985
+ const tops = new Set<string>()
1986
+ for (const r of rows) {
1987
+ if (r.path.startsWith(WORKSPACE_OUTPUT_PREFIX) || r.mode === LINK_MODE) return false
1988
+ if (accept !== undefined && !r.path.endsWith('/') && !accept(`outputs/${r.path}`))
1989
+ return false
1990
+ tops.add(r.path.split('/')[0]!)
1991
+ }
1992
+ const held = await Promise.all(
1993
+ [...tops].map((t) =>
1994
+ lstat(path.join(projectDir, t)).then(
1995
+ () => true,
1996
+ () => false,
1997
+ ),
1998
+ ),
1999
+ )
2000
+ if (held.includes(true)) return false
2001
+ readUmask()
2002
+ const plain = restoredMode(0o666)
2003
+ try {
2004
+ const dirs = new Set<string>()
2005
+ for (const r of rows) {
2006
+ dirs.add(path.join(projectDir, r.path.endsWith('/') ? r.path : path.dirname(r.path)))
2007
+ }
2008
+ for (const d of dirs) await mkdir(d, { recursive: true })
2009
+ await Promise.all(
2010
+ files.map(async (r) => {
2011
+ const to = path.join(projectDir, r.path)
2012
+ await Bun.write(to, Bun.file(path.join(tree, treeName(r.path))), { createPath: false })
2013
+ const mode = restoredMode(r.mode)
2014
+ if (mode !== plain) await chmod(to, mode)
2015
+ const at = new Date(r.mtimeMs)
2016
+ await utimes(to, at, at)
2017
+ }),
2018
+ )
2019
+ } catch (err) {
2020
+ await Promise.all(
2021
+ [...tops].map((t) => rm(path.join(projectDir, t), { recursive: true, force: true })),
2022
+ )
2023
+ throw err
2024
+ }
2025
+ return true
2026
+ }
2027
+
2028
+ private stdoutText(hash: string): string {
2029
+ const row = this.db.query('SELECT stdout FROM entry_stdout WHERE hash = ?').get(hash) as {
2030
+ stdout: string | Uint8Array
2031
+ } | null
2032
+ if (row === null) return ''
2033
+ return typeof row.stdout === 'string' ? row.stdout : utf8Decode.decode(row.stdout)
2034
+ }
2035
+
1895
2036
  /** Remove one entry: its artifact first, then its rows (as `prune` orders them). */
1896
2037
  private async dropEntry(hash: string): Promise<void> {
1897
2038
  await rm(this.tarPath(hash), { force: true })
2039
+ await rm(this.treePath(hash), { recursive: true, force: true })
1898
2040
  const drop = this.db.transaction(() => {
1899
2041
  this.deleteBlob.run(hash)
1900
2042
  this.deleteEntryRow.run(hash)
@@ -1930,7 +2072,7 @@ export class Cache implements CacheLayer {
1930
2072
  // outputs, so a restore that did not fail here would report a green cache
1931
2073
  // hit over an emptied output tree. The artifact existed when `get()`
1932
2074
  // probed it, so its absence now means something removed it underneath us
1933
- // (a `vx cache clean` in another shell, or another workspace's retention
2075
+ // (a `vx cache clean` in another shell, here or for another workspace
1934
2076
  // on a shared `--cache-dir`) — `ArtifactVanishedError`, which the caller
1935
2077
  // turns into a miss: the task runs.
1936
2078
  //
@@ -1992,6 +2134,8 @@ export class Cache implements CacheLayer {
1992
2134
  // when every artifact is a one-file `dist/` (measured: 390 vs 355 ms
1993
2135
  // per 1 000). The local artifact was validated at ingest, so a missing
1994
2136
  // declared size is allowed; the output ceiling applies to both.
2137
+ const tree =
2138
+ inline === undefined && existsSync(this.treePath(hash)) ? this.treePath(hash) : undefined
1995
2139
  const endExtract = span('restore: extract')
1996
2140
  try {
1997
2141
  // Every step here is an async filesystem call on purpose: a
@@ -1999,15 +2143,13 @@ export class Cache implements CacheLayer {
1999
2143
  // 0.62 ms sequential, 2026-09-10) and 30% slower in the run, where
2000
2144
  // four workers overlap their round trips and a blocking one stalls
2001
2145
  // the other three (1,000-project restore row 1.1–1.2 s → 1.5 s).
2002
- const tar = await decodedTar(inline ?? Bun.file(src), hash, this.artifactCeiling)
2003
- await extractArtifactStream(
2004
- tar,
2005
- projectDir,
2006
- workspaceRoot,
2007
- verify,
2008
- outputs === undefined ? undefined : declaredName(outputs),
2009
- workspaceRoot,
2010
- )
2146
+ const accept = outputs === undefined ? undefined : declaredName(outputs)
2147
+ if (tree !== undefined) {
2148
+ await this.restoreTree(hash, tree, rows, projectDir, workspaceRoot, verify, accept)
2149
+ } else {
2150
+ const tar = await decodedTar(inline ?? Bun.file(src), hash, this.artifactCeiling)
2151
+ await extractArtifactStream(tar, projectDir, workspaceRoot, verify, accept, workspaceRoot)
2152
+ }
2011
2153
  } catch (err) {
2012
2154
  // A UserError here is the extractor naming the tree's fault (an
2013
2155
  // output directory that links out of the project).
@@ -2058,7 +2200,13 @@ export class Cache implements CacheLayer {
2058
2200
  // artifact is intact, the tree is not the process's to change.
2059
2201
  // A refusal to READ the artifact is the cache directory's, not the
2060
2202
  // outputs': the old line sent the reader to the wrong tree (A-40).
2061
- if (isFsRefusal(err) && err.path === src) {
2203
+ // A tree copy's error can name its source; no read fails ENOSPC.
2204
+ if (
2205
+ isFsRefusal(err) &&
2206
+ code !== 'ENOSPC' &&
2207
+ code !== 'EDQUOT' &&
2208
+ (err.path === src || (tree !== undefined && inTree(err.path, tree)))
2209
+ ) {
2062
2210
  throw new UserError(
2063
2211
  `restore of ${hash} could not read its artifact (${code}: ${err.message}). ` +
2064
2212
  `Make the cache directory readable by this user, or point cacheDir / --cache-dir at one that is.`,
@@ -2170,23 +2318,21 @@ export class Cache implements CacheLayer {
2170
2318
  // again in `writeArtifactAndIndex` cost every save two thread-pool
2171
2319
  // round trips for an EEXIST and a stat (item 630).
2172
2320
  const endPack = span('save: pack')
2173
- const packed = await this.packArtifactToTemp(this.tempPath(args.hash), args).catch(
2174
- (err: unknown) => {
2175
- // A save that could not open an output names the limit, not only
2176
- // the file, which is fine (A-39).
2177
- if (isOutOfFds(err)) {
2178
- throw new UserError(
2179
- `save of ${args.hash} could not open a file (${err.message}) — ${OUT_OF_FDS_HINT}`,
2180
- )
2181
- }
2182
- throw err
2183
- },
2184
- )
2321
+ const packed = await this.packForSave(args).catch((err: unknown) => {
2322
+ // A save that could not open an output names the limit, not only
2323
+ // the file, which is fine (A-39).
2324
+ if (isOutOfFds(err)) {
2325
+ throw new UserError(
2326
+ `save of ${args.hash} could not open a file (${err.message}) — ${OUT_OF_FDS_HINT}`,
2327
+ )
2328
+ }
2329
+ throw err
2330
+ })
2185
2331
  endPack()
2186
2332
  await this.guard(() =>
2187
2333
  this.writeArtifactAndIndex(
2188
2334
  args.hash,
2189
- 'tar' in packed ? packed.compressed : packed,
2335
+ 'tar' in packed ? packed.compressed : 'plan' in packed ? packed.saved : packed,
2190
2336
  {
2191
2337
  taskId: args.entry.taskId,
2192
2338
  command: args.entry.command,
@@ -2226,11 +2372,30 @@ export class Cache implements CacheLayer {
2226
2372
  * gone; `release` unlinks the name. An inline artifact is read once into
2227
2373
  * the body, which no re-save can change.
2228
2374
  */
2229
- pinArtifact(hash: string): { body: Blob; release: () => Promise<void> } {
2375
+ async pinArtifact(hash: string): Promise<{ body: Blob; release: () => Promise<void> }> {
2230
2376
  const blob = this.guard(() => this.selectBlob.get(hash) as { bytes: Uint8Array } | null)
2231
2377
  if (blob !== null) return { body: new Blob([blob.bytes]), release: async () => {} }
2232
2378
  const pinned = this.tempPath(hash)
2233
- linkSync(this.tarPath(hash), pinned)
2379
+ try {
2380
+ linkSync(this.tarPath(hash), pinned)
2381
+ } catch (err) {
2382
+ const tree = this.treePath(hash)
2383
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT' || !existsSync(tree)) throw err
2384
+ // A tree is packed into the artifact a remote expects here, in the
2385
+ // upload queue, off the save; a small one in memory.
2386
+ const plan = planOfTree(tree, this.stdoutText(hash))
2387
+ if (plan.size <= STREAM_DECODE_FROM) {
2388
+ const bytes = await zstdCompress(await packArtifactBytes(plan))
2389
+ return { body: new Blob([bytes]), release: async () => {} }
2390
+ }
2391
+ await Bun.write(
2392
+ pinned,
2393
+ new Response(packArtifactStream(plan).pipeThrough(zstdEncoder())),
2394
+ ).catch(async (e: unknown) => {
2395
+ await unlink(pinned).catch(() => undefined)
2396
+ throw e
2397
+ })
2398
+ }
2234
2399
  return { body: Bun.file(pinned), release: () => unlink(pinned).catch(() => undefined) }
2235
2400
  }
2236
2401
 
@@ -2239,7 +2404,7 @@ export class Cache implements CacheLayer {
2239
2404
  * `Response` and copies a file `Blob` without collecting either, so a
2240
2405
  * pull never holds the artifact. A body that fails mid-stream (a dropped
2241
2406
  * socket) leaves a partial temp behind it, removed here; validation
2242
- * removes its own. A body of at most `INLINE_MAX` bytes — by its size,
2407
+ * removes its own. A body of at most `cache.store.embeddings` bytes — by its size,
2243
2408
  * or counted as it streams — is read to memory and stored inline.
2244
2409
  */
2245
2410
  async ingest(hash: string, body: Blob | Response, meta: IngestMeta): Promise<void> {
@@ -2259,22 +2424,28 @@ export class Cache implements CacheLayer {
2259
2424
  let counted: ReadableStream<Uint8Array> | undefined
2260
2425
  let reader: ChunkReader | undefined
2261
2426
  let small: Uint8Array | undefined
2427
+ // A store of plain files holds a body up to the bulk decode's bound:
2428
+ // fetched whole, it is decoded in one call straight into the tree, with
2429
+ // no temp archive between (`writeArtifactAndIndex`).
2430
+ const held = this.store.decompress
2431
+ ? Math.max(this.store.embeddings, STREAM_DECODE_FROM)
2432
+ : this.store.embeddings
2262
2433
  try {
2263
2434
  const length =
2264
2435
  body instanceof Blob ? body.size : Number(body.headers.get('content-length') ?? 0)
2265
2436
  if (length > cap) throw past()
2266
2437
  if (body instanceof Blob) {
2267
- if (length <= INLINE_MAX) small = await body.bytes()
2438
+ if (length <= held && held > 0) small = await body.bytes()
2268
2439
  else await Bun.write(tmpPath, body)
2269
2440
  } else {
2270
2441
  let source = body.body
2271
2442
  if (source === null) small = new Uint8Array(0)
2272
- else if (length <= INLINE_MAX) {
2443
+ else if (length <= held && held > 0) {
2273
2444
  // Counted as it streams: a length the server did not send, or
2274
- // one it sent short, cannot make a large body inline.
2445
+ // one it sent short, cannot read a large body to memory.
2275
2446
  const r: ChunkReader = source.getReader()
2276
2447
  reader = r
2277
- const head = await readUpTo(r, INLINE_MAX)
2448
+ const head = await readUpTo(r, held)
2278
2449
  if (head.done) small = head.bytes
2279
2450
  else source = prepended(head.bytes, r)
2280
2451
  }
@@ -2412,6 +2583,36 @@ export class Cache implements CacheLayer {
2412
2583
  return { tmpPath }
2413
2584
  }
2414
2585
 
2586
+ /**
2587
+ * The save's local form: a small entry packed for the index when the
2588
+ * store embeds, else a `.tar.zst` (`decompress: false`) or a tree of the outputs
2589
+ * copied as they are.
2590
+ */
2591
+ private async packForSave(
2592
+ args: Parameters<Cache['packArtifact']>[0],
2593
+ ): Promise<
2594
+ | { compressed: Uint8Array; tar: Uint8Array }
2595
+ | { tmpPath: string }
2596
+ | { plan: true; saved: SavedTree }
2597
+ > {
2598
+ if (!this.store.decompress) return this.packArtifactToTemp(this.tempPath(args.hash), args)
2599
+ const plan = await this.planWithin(args)
2600
+ if (this.store.embeddings > 0 && plan.size <= this.store.embeddings) {
2601
+ const tar = await packArtifactBytes(plan)
2602
+ return { compressed: await zstdCompress(tar), tar }
2603
+ }
2604
+ const tree = this.tempTreePath(args.hash)
2605
+ try {
2606
+ const scan = await writeTree(plan, tree)
2607
+ let bytes = 0
2608
+ for (const e of scan.entries) bytes += e.size
2609
+ return { plan: true, saved: { tree, scan, bytes } }
2610
+ } catch (err) {
2611
+ await rm(tree, { recursive: true, force: true })
2612
+ throw err
2613
+ }
2614
+ }
2615
+
2415
2616
  /** tmp suffix mixes pid + hrtime + a random hex chunk so two saves of
2416
2617
  * the same hash from the same process (or from two forked workers that
2417
2618
  * happen to share a wall-clock ms) don't pick the same tmp filename and
@@ -2422,7 +2623,7 @@ export class Cache implements CacheLayer {
2422
2623
 
2423
2624
  /**
2424
2625
  * Atomically write `compressed` to `<hash>.tar.zst`, or inline into
2425
- * `artifacts` when it is at most `INLINE_MAX` bytes, and (re)build the
2626
+ * `artifacts` when it is at most `cache.store.embeddings` bytes, and (re)build the
2426
2627
  * entries + output_files SQL rows from the archive itself. Shared by
2427
2628
  * `save()` (we just packed the bytes) and `ingest()` (it streamed them
2428
2629
  * from the remote layer into a temp) — both index the identical values, because both
@@ -2430,7 +2631,7 @@ export class Cache implements CacheLayer {
2430
2631
  */
2431
2632
  private async writeArtifactAndIndex(
2432
2633
  hash: string,
2433
- compressed: Uint8Array | { tmpPath: string },
2634
+ given: Uint8Array | { tmpPath: string } | SavedTree,
2434
2635
  meta: IngestMeta,
2435
2636
  /**
2436
2637
  * The tar `compressed` was made from, when this process packed it: it is
@@ -2447,12 +2648,25 @@ export class Cache implements CacheLayer {
2447
2648
  // restore, so the size/mode/mtime fingerprint we store matches
2448
2649
  // what isOutputsCurrent will compare against post-restore.
2449
2650
  const finalPath = this.tarPath(hash)
2651
+ const saved = 'tree' in given ? given : undefined
2652
+ const compressed = 'tree' in given ? new Uint8Array(0) : given
2450
2653
  // An inline artifact is written in the rows' transaction below: no
2451
2654
  // temp, no rename.
2452
2655
  const inline =
2453
- compressed instanceof Uint8Array && compressed.byteLength <= INLINE_MAX ? compressed : null
2656
+ saved === undefined &&
2657
+ this.store.embeddings > 0 &&
2658
+ compressed instanceof Uint8Array &&
2659
+ compressed.byteLength <= this.store.embeddings
2660
+ ? compressed
2661
+ : null
2662
+ // A remote hit this store keeps as plain files is decoded once, into the
2663
+ // tree, and checked there: nothing names the tree until the rows go in.
2664
+ const oneTree =
2665
+ saved === undefined && inline === null && tar === undefined && this.store.decompress
2454
2666
  let tmpPath: string | undefined
2455
- if (inline === null && compressed instanceof Uint8Array) {
2667
+ if (saved !== undefined) {
2668
+ // The tree save wrote and scanned its own outputs.
2669
+ } else if (inline === null && compressed instanceof Uint8Array && !oneTree) {
2456
2670
  const temp = this.tempPath(hash)
2457
2671
  tmpPath = temp
2458
2672
  // The temp is written BEFORE validation so a large artifact can be
@@ -2476,9 +2690,35 @@ export class Cache implements CacheLayer {
2476
2690
  tmpPath = compressed.tmpPath
2477
2691
  }
2478
2692
  let scanned: Awaited<ReturnType<typeof scanArtifact>>
2693
+ let tree = saved?.tree
2479
2694
  try {
2480
2695
  const endScan = span('save: scan')
2481
- if (tar !== undefined) {
2696
+ if (saved !== undefined) {
2697
+ scanned = saved.scan
2698
+ } else if (oneTree) {
2699
+ const dir = this.tempTreePath(hash)
2700
+ tree = dir
2701
+ let scan: Awaited<ReturnType<typeof scanArtifact>> | undefined
2702
+ let raw: { meta: string; sum: string } | undefined
2703
+ await extractArtifactStream(
2704
+ await decodedTar(
2705
+ compressed instanceof Uint8Array ? compressed : Bun.file(tmpPath!),
2706
+ hash,
2707
+ this.artifactCeiling,
2708
+ ),
2709
+ path.join(dir, 'outputs'),
2710
+ path.join(dir, WORKSPACE_OUTPUT_PREFIX),
2711
+ undefined,
2712
+ undefined,
2713
+ undefined,
2714
+ (s, r) => {
2715
+ scan = s
2716
+ raw = r
2717
+ },
2718
+ )
2719
+ scanned = scan!
2720
+ await sealTree(dir, scanned, raw)
2721
+ } else if (tar !== undefined) {
2482
2722
  // Our own tar, in memory: read in place, on this thread.
2483
2723
  scanned = scanTarBytes(tar)
2484
2724
  } else {
@@ -2511,12 +2751,45 @@ export class Cache implements CacheLayer {
2511
2751
  ])
2512
2752
  } catch (err) {
2513
2753
  if (tmpPath !== undefined) await unlink(tmpPath).catch(() => undefined)
2754
+ if (tree !== undefined) await rm(tree, { recursive: true, force: true })
2514
2755
  if (err instanceof ArchiveSecurityError || err instanceof CorruptArtifactError) throw err
2515
2756
  throw new CorruptArtifactError(hash, 'artifact is not a readable archive', err)
2516
2757
  }
2517
2758
  const { entries } = scanned
2518
2759
  const totalBytes =
2519
- compressed instanceof Uint8Array ? compressed.byteLength : Bun.file(tmpPath!).size
2760
+ saved !== undefined
2761
+ ? saved.bytes
2762
+ : compressed instanceof Uint8Array
2763
+ ? compressed.byteLength
2764
+ : Bun.file(tmpPath!).size
2765
+ // A remote hit this store keeps as plain files: the archive passed its
2766
+ // checks above, so it is unpacked once here and its temp goes.
2767
+ if (oneTree && tmpPath !== undefined) {
2768
+ await unlink(tmpPath).catch(() => undefined)
2769
+ tmpPath = undefined
2770
+ }
2771
+ if (tree === undefined && inline === null && this.store.decompress) {
2772
+ const dir = this.tempTreePath(hash)
2773
+ try {
2774
+ const source =
2775
+ compressed instanceof Uint8Array && compressed.byteLength > 0
2776
+ ? compressed
2777
+ : Bun.file(tmpPath!)
2778
+ await extractArtifactStream(
2779
+ await decodedTar(source, hash, this.artifactCeiling),
2780
+ path.join(dir, 'outputs'),
2781
+ path.join(dir, WORKSPACE_OUTPUT_PREFIX),
2782
+ )
2783
+ await sealTree(dir, scanned)
2784
+ } catch (err) {
2785
+ await rm(dir, { recursive: true, force: true })
2786
+ if (tmpPath !== undefined) await unlink(tmpPath).catch(() => undefined)
2787
+ throw err
2788
+ }
2789
+ if (tmpPath !== undefined) await unlink(tmpPath).catch(() => undefined)
2790
+ tmpPath = undefined
2791
+ tree = dir
2792
+ }
2520
2793
  const outputFileRows: Array<[string, number, number, number]> = []
2521
2794
  // Per-output-file fingerprint rows feed the skip-restore check.
2522
2795
  // Row paths: project entries store the bare rel (`outputs/`
@@ -2606,19 +2879,27 @@ export class Cache implements CacheLayer {
2606
2879
  // bytes are in one place. An inline save moves aside whatever file the
2607
2880
  // key has, as a file save does: one rename that finds nothing on a
2608
2881
  // first save, where asking the index first was a statement per save.
2609
- let aside: string | undefined
2610
- let renamed = false
2882
+ let aside: string[] = []
2883
+ let renamed: string | undefined
2611
2884
  const upsertBlob = this.upsertBlob
2612
2885
  const deleteBlob = this.deleteBlob
2613
- const moveAside = (): string | undefined => {
2614
- const to = this.tempPath(hash)
2615
- try {
2616
- renameSync(finalPath, to)
2617
- return to
2618
- } catch (err) {
2619
- if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
2620
- return undefined
2886
+ const treePath = this.treePath(hash)
2887
+ // Both file forms are moved aside: a key re-saved under another
2888
+ // `cache.store` leaves one live artifact.
2889
+ const moveAside = (): string[] => {
2890
+ const moved: string[] = []
2891
+ for (const [from, to] of [
2892
+ [finalPath, this.tempPath(hash)],
2893
+ [treePath, this.tempTreePath(hash)],
2894
+ ] as const) {
2895
+ try {
2896
+ renameSync(from, to)
2897
+ moved.push(to)
2898
+ } catch (err) {
2899
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
2900
+ }
2621
2901
  }
2902
+ return moved
2622
2903
  }
2623
2904
  const tx = this.db.transaction(() => {
2624
2905
  if (inline !== null) {
@@ -2626,9 +2907,10 @@ export class Cache implements CacheLayer {
2626
2907
  upsertBlob.run(hash, now, inline)
2627
2908
  } else {
2628
2909
  aside = moveAside()
2629
- renameSync(tmpPath!, finalPath)
2630
- this.artifactNames?.add(path.basename(finalPath))
2631
- renamed = true
2910
+ const live = tree !== undefined ? treePath : finalPath
2911
+ renameSync(tree ?? tmpPath!, live)
2912
+ this.artifactNames?.add(path.basename(live))
2913
+ renamed = live
2632
2914
  deleteBlob.run(hash)
2633
2915
  }
2634
2916
  insertEntry.run(
@@ -2671,13 +2953,17 @@ export class Cache implements CacheLayer {
2671
2953
  tx.immediate()
2672
2954
  } catch (err) {
2673
2955
  endTx()
2674
- const left = renamed ? finalPath : tmpPath
2675
- if (left !== undefined) await unlink(left).catch(() => undefined)
2676
- if (aside !== undefined) await unlink(aside).catch(() => undefined)
2956
+ const left = renamed ?? tree ?? tmpPath
2957
+ if (left !== undefined) await rm(left, { recursive: true, force: true })
2958
+ for (const a of aside) await rm(a, { recursive: true, force: true })
2677
2959
  throw err
2678
2960
  }
2679
2961
  endTx()
2680
- if (aside !== undefined) await unlink(aside).catch(() => undefined)
2962
+ for (const a of aside) await rm(a, { recursive: true, force: true })
2963
+ }
2964
+
2965
+ private tempTreePath(hash: string): string {
2966
+ return `${this.treePath(hash)}.tmp-${process.pid}-${process.hrtime.bigint()}-${Math.random().toString(36).slice(2, 10)}`
2681
2967
  }
2682
2968
 
2683
2969
  /** Apply the deferred accessed_at bumps in one statement. */
@@ -2694,7 +2980,7 @@ export class Cache implements CacheLayer {
2694
2980
  const at = new Date(now)
2695
2981
  for (const hash of this.staleTimes) {
2696
2982
  try {
2697
- utimesSync(this.tarPath(hash), at, at)
2983
+ utimesSync(this.artifactStat(hash)?.path ?? this.tarPath(hash), at, at)
2698
2984
  } catch {
2699
2985
  // Gone, or another user's file: a sweep judges its old time.
2700
2986
  }
@@ -2783,67 +3069,6 @@ export class Cache implements CacheLayer {
2783
3069
  }
2784
3070
  }
2785
3071
 
2786
- /**
2787
- * The workspace's `cacheRetention`, applied at the end of a run: `prune()`
2788
- * with the same policy, but only when it would evict something, else the
2789
- * orphan sweep alone when an hour has passed since the last one. A run
2790
- * with nothing due pays the accessed-at flush it owed at close anyway, one
2791
- * scan of the index and one read of the sweep's clock — never the
2792
- * sweep's readdir. Null when nothing was due or the directory is not this
2793
- * handle's to write. The local WRITE axis does not gate it: under
2794
- * `--cache-policy=local:r,remote:rw` a save writes nothing here, but every remote
2795
- * hit is still ingested (`ingest`, ungated by design), and gated on that
2796
- * axis the retention never ran while the store grew hit by hit (A-7).
2797
- */
2798
- async evictIfDue(
2799
- policy: { maxAgeMs?: number; maxBytes?: number },
2800
- now: number = Date.now(),
2801
- ): Promise<PruneResult | null> {
2802
- return this.guard(() => this.evictIfDueNow(policy, now))
2803
- }
2804
-
2805
- private async evictIfDueNow(
2806
- policy: { maxAgeMs?: number; maxBytes?: number },
2807
- now: number,
2808
- ): Promise<PruneResult | null> {
2809
- if (this.writeBlocked !== null || this.inspecting) return null
2810
- // First: an entry this run restored still carries its old `accessed_at`
2811
- // until the deferred bump lands, and would read as due for eviction.
2812
- this.flushAccessed()
2813
- const olderThanMs = policy.maxAgeMs === undefined ? undefined : now - policy.maxAgeMs
2814
- const { oldest, bytes } = this.db
2815
- .prepare(
2816
- 'SELECT MIN(accessed_at) AS oldest, COALESCE(SUM(size_bytes), 0) AS bytes FROM entries',
2817
- )
2818
- .get() as { oldest: number | null; bytes: number }
2819
- const ageDue =
2820
- olderThanMs !== undefined && oldest !== null && oldest + ACCESS_REFRESH_MS < olderThanMs
2821
- const sizeDue = policy.maxBytes !== undefined && bytes > policy.maxBytes
2822
- if (ageDue || sizeDue) {
2823
- return this.prune({
2824
- ...(olderThanMs !== undefined ? { olderThanMs } : {}),
2825
- ...(policy.maxBytes !== undefined ? { maxBytes: policy.maxBytes } : {}),
2826
- })
2827
- }
2828
- // Row-less artifacts are bytes the index cannot count, so the policy
2829
- // above never sees them (upstream survey, nx#35483: 9 MiB of them sat
2830
- // under a 1 MB limit). Listing the directory to find them costs 0.5 ms
2831
- // per 1,000 entries (4.9 ms at 10,000), every run; the sweep's own
2832
- // clock in `schema_meta` costs one indexed read. The sweep is the
2833
- // policy's own prune, with the row-less files counted.
2834
- const swept = this.db.prepare('SELECT value FROM schema_meta WHERE key = ?').get(SWEPT_AT) as {
2835
- value: string
2836
- } | null
2837
- if (swept !== null && now - Number(swept.value) < ORPHAN_GRACE_MS) return null
2838
- return this.pruneIndex(
2839
- {
2840
- ...(olderThanMs !== undefined ? { olderThanMs } : {}),
2841
- ...(policy.maxBytes !== undefined ? { maxBytes: policy.maxBytes } : {}),
2842
- },
2843
- now,
2844
- )
2845
- }
2846
-
2847
3072
  async prune(options: PruneOptions): Promise<PruneResult> {
2848
3073
  return this.guard(() => this.pruneIndex(options))
2849
3074
  }
@@ -2897,7 +3122,12 @@ export class Cache implements CacheLayer {
2897
3122
  // and the one verb meant to free a full disk freed nothing (A-14). An
2898
3123
  // inline artifact goes with its rows, in their transaction.
2899
3124
  await Promise.all(
2900
- hashes.filter((h) => !inline.has(h)).map((h) => rm(this.tarPath(h), { force: true })),
3125
+ hashes
3126
+ .filter((h) => !inline.has(h))
3127
+ .flatMap((h) => [
3128
+ rm(this.tarPath(h), { force: true }),
3129
+ rm(this.treePath(h), { recursive: true, force: true }),
3130
+ ]),
2901
3131
  )
2902
3132
  const { test, params } = inHashes(rows)
2903
3133
  const deleteRows = this.db.transaction(() => {
@@ -2920,7 +3150,15 @@ export class Cache implements CacheLayer {
2920
3150
  } catch (err) {
2921
3151
  if (!isIndexFull(err)) throw err
2922
3152
  }
2923
- const orphans = await this.reapOrphans(picked.files, now)
3153
+ // History and memo rows a month old go with a prune, never a run's
3154
+ // close: five DELETEs on every run's exit was its slowest step (owner,
3155
+ // 2026-10-18). `invocations` shares the window with `runs`, or `vx last`
3156
+ // would list a run whose task rows are gone.
3157
+ const cutoff = now - HISTORY_RETENTION_MS
3158
+ this.history.pruneOlderThan(cutoff)
3159
+ this.files.pruneOlderThan(cutoff)
3160
+ this.folds.prune(cutoff)
3161
+ const orphans = await this.reapOrphans(picked.files)
2924
3162
  return { evicted: victims.size, bytesFreed, ...orphans }
2925
3163
  }
2926
3164
 
@@ -2935,16 +3173,7 @@ export class Cache implements CacheLayer {
2935
3173
  */
2936
3174
  private async reapOrphans(
2937
3175
  taken: readonly RowlessFile[],
2938
- now: number,
2939
3176
  ): Promise<{ orphans: number; orphanBytes: number }> {
2940
- try {
2941
- this.db
2942
- .prepare('INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)')
2943
- .run(SWEPT_AT, String(now))
2944
- } catch (err) {
2945
- // The sweep's clock: on a full disk the sweep runs again next time.
2946
- if (!isIndexFull(err)) throw err
2947
- }
2948
3177
  let orphans = 0
2949
3178
  let orphanBytes = 0
2950
3179
  const blobs = taken.filter((o) => o.blob !== undefined)
@@ -2973,7 +3202,7 @@ export class Cache implements CacheLayer {
2973
3202
  try {
2974
3203
  // unlink, not `rm({ force })`: force swallows ENOENT, and a file a
2975
3204
  // concurrent prune took first must not be counted as ours.
2976
- await unlink(o.file)
3205
+ await rm(o.file, { recursive: true })
2977
3206
  orphans += 1
2978
3207
  orphanBytes += o.size
2979
3208
  } catch {
@@ -3022,7 +3251,8 @@ export class Cache implements CacheLayer {
3022
3251
  const phantoms = new Map<string, number>()
3023
3252
  const stale: string[] = []
3024
3253
  for (const r of rows) {
3025
- if (r.inline === 1 || present.has(`${r.hash}.tar.zst`)) continue
3254
+ if (r.inline === 1 || present.has(`${r.hash}.tar.zst`) || present.has(`${r.hash}.tree`))
3255
+ continue
3026
3256
  phantoms.set(r.hash, r.size_bytes)
3027
3257
  if (r.accessed_at < graceStart) stale.push(r.hash)
3028
3258
  }
@@ -3074,34 +3304,12 @@ export class Cache implements CacheLayer {
3074
3304
  }
3075
3305
 
3076
3306
  close(): void {
3077
- // Run-history retention: the runs table grows by one row per
3078
- // executed task per invocation — a 2000-task repo accretes ~20k
3079
- // rows in days, inflating insert and checkpoint cost forever.
3080
- // 30 days comfortably covers `vx info` (24 h windows) and any
3081
- // CI-side analytics consumers. The `invocations` header table (one
3082
- // row per `vx run`) is pruned on the SAME window — otherwise a
3083
- // header would outlive its `runs` rows, so `vx info`/`vx last` would
3084
- // list an invocation whose task detail is already gone, and the
3085
- // table would grow unbounded on a long-lived checkout.
3086
- // A reading verb and a dry run delete nothing: `vx last --list` over
3087
- // month-old runs emptied the history it listed, and `vx cache clean
3088
- // --dry-run` pruned it (item 1004).
3089
- try {
3090
- if (!this.inspecting) {
3091
- const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000
3092
- this.history.pruneOlderThan(cutoff)
3093
- if (this.write) this.files.pruneOlderThan(cutoff)
3094
- }
3095
- } catch {
3096
- // Retention is best-effort; never block closing the handle.
3097
- }
3098
3307
  try {
3099
3308
  this.flushAccessed()
3100
3309
  this.outputs.flushOutputDirs()
3101
- this.folds.flush(Date.now() - 30 * 24 * 60 * 60 * 1000)
3310
+ this.folds.flush()
3102
3311
  } catch {
3103
- // Same contract as the retention prune above, which was already
3104
- // guarded while this sibling was not: `accessed_at` is LRU
3312
+ // `accessed_at` is LRU
3105
3313
  // bookkeeping, never correctness, and the run's results are
3106
3314
  // already recorded by the time we get here. Letting it throw also
3107
3315
  // SKIPPED `db.close()` below, leaking the handle. Reachable when
@@ -3115,6 +3323,21 @@ export class Cache implements CacheLayer {
3115
3323
  private tarPath(hash: string): string {
3116
3324
  return path.join(this.artifactDir, `${hash}.tar.zst`)
3117
3325
  }
3326
+
3327
+ private treePath(hash: string): string {
3328
+ return path.join(this.artifactDir, `${hash}.tree`)
3329
+ }
3330
+
3331
+ /** The key's file-backed artifact (tar or tree), the form this store writes asked first. */
3332
+ private artifactStat(hash: string): { path: string; tree: boolean; mtimeMs: number } | undefined {
3333
+ const order = this.store.decompress ? [true, false] : [false, true]
3334
+ for (const tree of order) {
3335
+ const at = tree ? this.treePath(hash) : this.tarPath(hash)
3336
+ const st = statSync(at, { throwIfNoEntry: false })
3337
+ if (st !== undefined) return { path: at, tree, mtimeMs: st.mtimeMs }
3338
+ }
3339
+ return undefined
3340
+ }
3118
3341
  }
3119
3342
 
3120
3343
  /**
@@ -3187,6 +3410,17 @@ function assertArtifactNames(
3187
3410
  }
3188
3411
  }
3189
3412
 
3413
+ /** A row's path as the tree names its file. */
3414
+ function treeName(rowPath: string): string {
3415
+ return rowPath.startsWith(WORKSPACE_OUTPUT_PREFIX) ? rowPath : `outputs/${rowPath}`
3416
+ }
3417
+
3418
+ function inTree(p: string | undefined, tree: string): boolean {
3419
+ return p !== undefined && (p === tree || p.startsWith(`${tree}/`))
3420
+ }
3421
+
3422
+ const utf8Decode = new TextDecoder('utf-8', { ignoreBOM: true })
3423
+
3190
3424
  /**
3191
3425
  * Whether an artifact entry names one of the task's declared outputs, as
3192
3426
  * the save could have packed it (`restorableOutput`: a `!` take-back, vx's