@vzn/vx 0.0.652 → 0.0.653

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 (167) hide show
  1. package/README.md +2 -2
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +1 -0
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +25 -3
  8. package/src/cache/archive.ts +196 -31
  9. package/src/cache/cache.ts +863 -225
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +22 -5
  16. package/src/cache/index.ts +10 -1
  17. package/src/cache/inputs.ts +573 -152
  18. package/src/cache/key-fold.ts +1 -1
  19. package/src/cache/layer.ts +23 -10
  20. package/src/cache/layered-cache.ts +28 -7
  21. package/src/cache/output-index.ts +106 -44
  22. package/src/cache/plugin-cache.ts +2 -2
  23. package/src/cache/policy.ts +6 -2
  24. package/src/cache/remote-lane.ts +3 -1
  25. package/src/cache/run-history.ts +50 -10
  26. package/src/cache/schema.ts +42 -9
  27. package/src/cli/cache.ts +147 -34
  28. package/src/cli/completions.ts +2 -2
  29. package/src/cli/core-alias.ts +9 -2
  30. package/src/cli/docs.ts +4 -2
  31. package/src/cli/help.ts +25 -2
  32. package/src/cli/index.ts +38 -7
  33. package/src/cli/info.ts +4 -3
  34. package/src/cli/init.ts +55 -20
  35. package/src/cli/last.ts +184 -41
  36. package/src/cli/lock.ts +187 -24
  37. package/src/cli/plan-format.ts +24 -21
  38. package/src/cli/plugin-commands.ts +6 -1
  39. package/src/cli/plugin-templates.ts +1 -1
  40. package/src/cli/run.ts +68 -34
  41. package/src/cli/select.ts +160 -64
  42. package/src/cli/show.ts +63 -18
  43. package/src/cli/task-verb.ts +12 -7
  44. package/src/cli/upgrade.ts +210 -76
  45. package/src/cli/watch-filter.ts +88 -15
  46. package/src/cli/watch-fs.ts +34 -47
  47. package/src/cli/watch-judge.ts +72 -8
  48. package/src/cli/watch-set.ts +6 -4
  49. package/src/cli/watch.ts +179 -25
  50. package/src/cli/why.ts +107 -33
  51. package/src/cli/workspace-config.ts +11 -1
  52. package/src/config.ts +9 -0
  53. package/src/exec/env.ts +3 -1
  54. package/src/exec/executor.ts +76 -15
  55. package/src/exec/kill-tree.ts +7 -7
  56. package/src/exec/runner.ts +270 -108
  57. package/src/exec/sandbox-binds.ts +109 -18
  58. package/src/exec/sandbox-runtime.ts +134 -37
  59. package/src/exec/sandbox-violations.ts +250 -46
  60. package/src/exec/spawn-off.ts +10 -5
  61. package/src/exec/warm-shell.ts +29 -7
  62. package/src/graph/folds.ts +89 -16
  63. package/src/graph/index.ts +8 -0
  64. package/src/graph/priorities.ts +8 -5
  65. package/src/graph/scheduler.ts +173 -38
  66. package/src/graph/stand-ins.ts +36 -0
  67. package/src/graph/task-graph.ts +327 -141
  68. package/src/graph/task-status.ts +5 -1
  69. package/src/handoff.ts +45 -10
  70. package/src/history/failure-mode.ts +15 -5
  71. package/src/history/history.ts +103 -30
  72. package/src/history/index.ts +1 -0
  73. package/src/history/metrics.ts +189 -69
  74. package/src/index.ts +2 -1
  75. package/src/orchestrator/admission.ts +13 -9
  76. package/src/orchestrator/affected-tasks.ts +35 -13
  77. package/src/orchestrator/command-windows.ts +8 -5
  78. package/src/orchestrator/deferred-outputs.ts +41 -12
  79. package/src/orchestrator/doctor.ts +21 -9
  80. package/src/orchestrator/download-policy.ts +3 -3
  81. package/src/orchestrator/excluded-keys.ts +14 -2
  82. package/src/orchestrator/execute-args.ts +9 -2
  83. package/src/orchestrator/execute-task.ts +564 -123
  84. package/src/orchestrator/graph-memo.ts +26 -4
  85. package/src/orchestrator/graph-stage.ts +27 -4
  86. package/src/orchestrator/held-servers.ts +36 -14
  87. package/src/orchestrator/hit-restore.ts +136 -87
  88. package/src/orchestrator/index.ts +4 -1
  89. package/src/orchestrator/keyed-projects.ts +25 -0
  90. package/src/orchestrator/local-floor.ts +21 -3
  91. package/src/orchestrator/lockfile-claim.ts +30 -4
  92. package/src/orchestrator/miss-save.ts +33 -53
  93. package/src/orchestrator/options.ts +13 -0
  94. package/src/orchestrator/output-host.ts +16 -4
  95. package/src/orchestrator/output-log.ts +1 -54
  96. package/src/orchestrator/persistent-task.ts +31 -17
  97. package/src/orchestrator/persistent.ts +152 -15
  98. package/src/orchestrator/placement.ts +81 -15
  99. package/src/orchestrator/plan.ts +37 -15
  100. package/src/orchestrator/plugin-host.ts +232 -108
  101. package/src/orchestrator/plugin.ts +53 -27
  102. package/src/orchestrator/prepare.ts +194 -38
  103. package/src/orchestrator/projects.ts +89 -10
  104. package/src/orchestrator/read-only.ts +115 -0
  105. package/src/orchestrator/run-context.ts +6 -2
  106. package/src/orchestrator/run-failures.ts +43 -14
  107. package/src/orchestrator/run-id.ts +13 -2
  108. package/src/orchestrator/run-keys.ts +87 -28
  109. package/src/orchestrator/run-live.ts +16 -13
  110. package/src/orchestrator/run-lock.ts +27 -4
  111. package/src/orchestrator/run-records.ts +12 -10
  112. package/src/orchestrator/run-report.ts +2 -1
  113. package/src/orchestrator/run-stop.ts +17 -9
  114. package/src/orchestrator/run.ts +443 -107
  115. package/src/orchestrator/sandbox-fix.ts +40 -12
  116. package/src/orchestrator/sandbox-request.ts +286 -59
  117. package/src/orchestrator/save-lane.ts +24 -13
  118. package/src/orchestrator/shell-verdict.ts +20 -1
  119. package/src/orchestrator/signals.ts +103 -8
  120. package/src/orchestrator/stable-keys.ts +17 -9
  121. package/src/orchestrator/task-artifacts.ts +15 -2
  122. package/src/orchestrator/task-hash.ts +187 -30
  123. package/src/orchestrator/telemetry-host.ts +25 -6
  124. package/src/orchestrator/telemetry.ts +80 -12
  125. package/src/orchestrator/upstream.ts +3 -1
  126. package/src/report/events.ts +20 -3
  127. package/src/report/forecast.ts +103 -37
  128. package/src/report/framed-output.ts +165 -157
  129. package/src/report/index.ts +2 -1
  130. package/src/report/logger.ts +107 -30
  131. package/src/report/plain-output.ts +13 -9
  132. package/src/report/status-line.ts +28 -55
  133. package/src/report/summary.ts +57 -14
  134. package/src/settings.ts +42 -6
  135. package/src/ui/components.ts +36 -42
  136. package/src/ui/index.ts +3 -1
  137. package/src/ui/live-region.ts +67 -8
  138. package/src/ui/terminal.ts +12 -0
  139. package/src/ui/theme.ts +16 -1
  140. package/src/util/config-frame.ts +17 -3
  141. package/src/util/edit-distance.ts +17 -1
  142. package/src/util/errors.ts +23 -7
  143. package/src/util/foreign-cache-dir.ts +60 -0
  144. package/src/util/git-ignored.ts +59 -5
  145. package/src/util/index.ts +15 -1
  146. package/src/util/paths.ts +50 -6
  147. package/src/util/plain-text.ts +19 -0
  148. package/src/util/secret-mask.ts +57 -1
  149. package/src/util/settle.ts +16 -0
  150. package/src/util/size.ts +12 -2
  151. package/src/util/write-file.ts +86 -9
  152. package/src/workspace/affected.ts +225 -27
  153. package/src/workspace/config-bytecode.ts +23 -4
  154. package/src/workspace/config-eval.ts +90 -16
  155. package/src/workspace/config-imports.ts +96 -41
  156. package/src/workspace/config-schema.ts +126 -25
  157. package/src/workspace/dependencies-lock.ts +7 -14
  158. package/src/workspace/filter.ts +10 -1
  159. package/src/workspace/index.ts +3 -1
  160. package/src/workspace/json-data.ts +34 -8
  161. package/src/workspace/lockfile.ts +86 -37
  162. package/src/workspace/migration.ts +46 -25
  163. package/src/workspace/module-state.ts +62 -0
  164. package/src/workspace/nested-dirs.ts +8 -0
  165. package/src/workspace/project-loader.ts +276 -32
  166. package/src/workspace/repo-id.ts +29 -6
  167. package/src/workspace/workspace.ts +190 -30
@@ -45,17 +45,29 @@ import {
45
45
  readdirSync,
46
46
  readFileSync,
47
47
  renameSync,
48
+ type Stats,
48
49
  statSync,
49
50
  utimesSync,
50
51
  writeFileSync,
51
52
  } from 'node:fs'
52
- import { chmod, lstat, mkdir, readdir, rm, stat, unlink, utimes, writeFile } from 'node:fs/promises'
53
+ import {
54
+ chmod,
55
+ mkdir,
56
+ readdir,
57
+ rename,
58
+ rm,
59
+ stat,
60
+ unlink,
61
+ utimes,
62
+ writeFile,
63
+ } from 'node:fs/promises'
53
64
  import path from 'node:path'
54
65
  import {
55
66
  createArtifactTables,
56
67
  createTables,
57
68
  inHashes,
58
69
  lazyStatement,
70
+ RUNS_VIEW,
59
71
  SQLITE_FCNTL_PERSIST_WAL,
60
72
  } from './schema.js'
61
73
  import {
@@ -67,6 +79,8 @@ import {
67
79
  isFsRefusal,
68
80
  isOutOfFds,
69
81
  OUT_OF_FDS_HINT,
82
+ OWN_DIR_HINT,
83
+ refuseForeignCacheDir,
70
84
  restorableOutput,
71
85
  relPosix,
72
86
  span,
@@ -77,6 +91,7 @@ import {
77
91
  ArchiveSecurityError,
78
92
  type ArtifactPlan,
79
93
  type ExecUsage,
94
+ copyStaging,
80
95
  extractArtifactStream,
81
96
  scanArtifact,
82
97
  scanTarBytes,
@@ -85,9 +100,10 @@ import {
85
100
  planArtifact,
86
101
  planOfTree,
87
102
  sealTree,
103
+ stagesInto,
88
104
  writeTree,
89
105
  } from './archive.js'
90
- import { readUmask, restoredMode } from './umask.js'
106
+ import { modeMatches, readUmask, restoredMode } from './umask.js'
91
107
  import {
92
108
  ArtifactVanishedError,
93
109
  type CacheEntry,
@@ -126,6 +142,7 @@ import { CACHE_VERSION, foldKey } from './key-fold.js'
126
142
  import { type FoldMemo, IndexFoldMemo } from './fold-memo.js'
127
143
  import { IndexRemoteHits, type RemoteHits } from './remote-hits.js'
128
144
  import { behindLink } from './linked-inputs.js'
145
+ import { WalCheckpointer } from './checkpointer.js'
129
146
 
130
147
  /**
131
148
  * An artifact or temp file without an `entries` row is never reaped
@@ -138,6 +155,10 @@ import { behindLink } from './linked-inputs.js'
138
155
  */
139
156
  // About 1.3 ms of inserts per turn: a task's exit waits at most that.
140
157
  const HELD_SLICE = 250
158
+ /** SQLite's own default, back on a connection whose checkpoint thread failed. */
159
+ const WAL_AUTOCHECKPOINT = 1000
160
+ /** Asks whether an open database file is still the one at its name (`attachStore`). */
161
+ const SQLITE_FCNTL_HAS_MOVED = 20
141
162
 
142
163
  const ORPHAN_GRACE_MS = 60 * 60 * 1000
143
164
 
@@ -173,6 +194,7 @@ const VX_CACHE_NAMES = new Set([
173
194
  'cache.db-wal',
174
195
  'cache.db-shm',
175
196
  'cache.db-journal',
197
+ 'cache.lock',
176
198
  // Every record vx keeps lives in cache.db, and the config bundle always
177
199
  // in `.vx/cache/compiled`, so any directory here is the user's.
178
200
  ])
@@ -367,7 +389,23 @@ function cantOpenIndex(dbFile: string, err: unknown): UserError | undefined {
367
389
  // v51: runs.queued_ms, output_bytes, blocks, flaky_passes and
368
390
  // flaky_failures — the rest of what a results row shows, so `vx
369
391
  // last --sql` reads it. The cache KEY is unchanged.
370
- export const SCHEMA_VERSION = 'v51'
392
+ // v52: entry_stdout.stdout is a zstd frame of the log's UTF-8, not its
393
+ // text: stored logs were half a store's bytes, more than every
394
+ // inline artifact. The cache KEY is unchanged.
395
+ // v53: a runs row names its run by its row in run_ids, not by the
396
+ // 36-char text; a TEMP view `runs` shows run_id as before. An
397
+ // all-hit run of 500 tasks added 115 KB to the index. The cache
398
+ // KEY is unchanged.
399
+ // v54: entries.run_id — the run that wrote the entry, so `vx last
400
+ // --log` tells a run's own output from another run's under the same
401
+ // key. The cache KEY is unchanged.
402
+ // v55: output_stamps.mtime_ms — the mtime the file carried when
403
+ // stamped. A restore dates each file it writes now, as the task's
404
+ // write would, not with the entry's saved mtime: a make-style
405
+ // dependant took a restored upstream as older than its own output
406
+ // and saved bytes built from another upstream. The cache KEY and
407
+ // the stored artifact are unchanged.
408
+ export const SCHEMA_VERSION = 'v55'
371
409
 
372
410
  /**
373
411
  * An entry's key components as its one `entry_inputs` row (v34): JSON
@@ -419,38 +457,50 @@ const ARTIFACT_TABLES = ['artifacts', 'artifacts_meta']
419
457
  const ARTIFACT_LAYOUT = 'a2'
420
458
 
421
459
  /**
422
- * An index that records a schema other than this vx's: the one an opener
423
- * that writes resets. Reads the recorded version alone and touches
424
- * nothing; an unreadable file is left to the open to refuse.
460
+ * A preview's index connection and the schema it records, which the open
461
+ * keeps and reuses. A version other than this vx's is the index an opener
462
+ * that writes resets; an unreadable file is left to the open to refuse.
425
463
  */
426
- function indexOfAnotherSchema(dbFile: string): boolean {
427
- let found: string | undefined
464
+ function previewIndex(dbFile: string): { db: Database; version: string | undefined } | undefined {
465
+ let db: Database
428
466
  try {
429
- const db = new Database(dbFile, { readonly: true })
430
- try {
431
- found = (
432
- db.query("SELECT value FROM schema_meta WHERE key = 'version'").get() as {
433
- value: string
434
- } | null
435
- )?.value
436
- } finally {
437
- closeDb(db)
438
- }
467
+ db = new Database(dbFile, { create: true })
439
468
  } catch {
440
- return false
469
+ // The main open names why.
470
+ return undefined
471
+ }
472
+ try {
473
+ const version = (
474
+ db.prepare("SELECT value FROM schema_meta WHERE key = 'version'").get() as {
475
+ value: string
476
+ } | null
477
+ )?.value
478
+ return { db, version }
479
+ } catch {
480
+ // No stamp read (no table yet, busy, not a database): the main open
481
+ // reads it again and refuses what it must.
482
+ return { db, version: undefined }
441
483
  }
442
- return found !== undefined && found !== SCHEMA_VERSION
443
484
  }
444
485
 
486
+ /** The largest compressed log a probe loads with its entry. */
487
+ const LOG_INLINE_BYTES = 4096
488
+
445
489
  /**
446
- * Each asked key's entry row with its stdout, which lives apart (v29), none
447
- * stored reading as '', and its inline artifact's last use, null when the
448
- * artifact is a file. Driven by the keys, so a key with an inline artifact
449
- * and no row (a reset kept it) answers too, `hash` null: the miss learns
450
- * there is something to adopt in the statement that found no entry.
490
+ * Each asked key's entry row, its stored log while that is small, and its
491
+ * inline artifact's last use, null when the artifact is a file. A larger
492
+ * log is only flagged (`stdout_apart`), and read when its hit replays: a
493
+ * warm run probes every key in one batch and replays the hits as the
494
+ * scheduler reaches them, so every log joined here was held until its
495
+ * replay (500 hits printing 1 MiB each held 3.4 GB). A small one rides
496
+ * along, still compressed: a read apart per hit cost a warm run of 300
497
+ * short logs ~1 ms of 44. `length` reads a blob's size, not its pages.
498
+ * Driven by the keys, so a key with an inline artifact and no row (a reset
499
+ * kept it) answers too, `hash` null: the miss learns there is something to
500
+ * adopt in the statement that found no entry.
451
501
  */
452
502
  const selectEntries = (keys: string): string =>
453
- `SELECT q.value AS key, e.*, COALESCE(s.stdout, '') AS stdout, a.at AS inline_at FROM ${keys} q LEFT JOIN entries e ON e.hash = q.value LEFT JOIN entry_stdout s ON s.hash = e.hash LEFT JOIN artifacts a ON a.hash = q.value WHERE e.hash IS NOT NULL OR a.at IS NOT NULL`
503
+ `SELECT q.value AS key, e.*, CASE WHEN length(s.stdout) <= ${LOG_INLINE_BYTES} THEN s.stdout END AS stdout, length(s.stdout) > ${LOG_INLINE_BYTES} AS stdout_apart, a.at AS inline_at FROM ${keys} q LEFT JOIN entries e ON e.hash = q.value LEFT JOIN entry_stdout s ON s.hash = e.hash LEFT JOIN artifacts a ON a.hash = q.value WHERE e.hash IS NOT NULL OR a.at IS NOT NULL`
454
504
  const SELECT_ENTRY = selectEntries('(SELECT ? AS value)')
455
505
  const SELECT_ENTRIES = selectEntries('json_each(?)')
456
506
 
@@ -616,7 +666,10 @@ interface EntryRow {
616
666
  exit_code: number
617
667
  duration_ms: number
618
668
  size_bytes: number
619
- stdout: string
669
+ /** The compressed log when no more than `LOG_INLINE_BYTES`, else null. */
670
+ stdout: Uint8Array | null
671
+ /** 1 when the log is larger, and read apart; null when none was stored. */
672
+ stdout_apart: number | null
620
673
  created_at: number
621
674
  accessed_at: number
622
675
  cpu_ms: number | null
@@ -624,24 +677,45 @@ interface EntryRow {
624
677
  inline_at: number | null
625
678
  }
626
679
 
627
- function entryOf(row: LiveRow, fileRows: OutputFileRow[]): CacheEntry {
628
- return {
680
+ /**
681
+ * The entry a row answers. Its log is a getter, decompressed on each access
682
+ * and, past `LOG_INLINE_BYTES`, read from the index then (`log`), so an
683
+ * entry held from the probe to its replay holds no log text and the replay
684
+ * drops it once printed. A reader takes it once.
685
+ */
686
+ function entryOf(
687
+ row: LiveRow,
688
+ fileRows: OutputFileRow[],
689
+ log: (hash: string) => string,
690
+ ): CacheEntry {
691
+ // Assigned, not spread, and the date formatted only when read: a warm
692
+ // run's probe builds one per hit and reads neither (~4 ms of 8,002
693
+ // entries, 2026-11-07).
694
+ const entry: CacheEntry = {
629
695
  hash: row.hash,
630
696
  taskId: `${row.project}#${row.task}`,
631
697
  command: row.command,
632
698
  exitCode: row.exit_code,
633
699
  durationMs: row.duration_ms,
634
700
  sizeBytes: row.size_bytes,
635
- ...(row.cpu_ms !== null ? { cpuMs: row.cpu_ms } : {}),
636
- ...(row.peak_rss_bytes !== null ? { peakRssBytes: row.peak_rss_bytes } : {}),
637
701
  outputFiles: fileRows.map((r) => r.path),
638
702
  outputRows: fileRows,
639
- stdout: row.stdout,
640
- storedAt: new Date(row.created_at).toISOString(),
703
+ get stdout() {
704
+ return row.stdout_apart === 1 ? log(row.hash) : storedLog(row.stdout)
705
+ },
706
+ get storedAt() {
707
+ return new Date(row.created_at).toISOString()
708
+ },
641
709
  source: 'local',
642
710
  }
711
+ if (row.cpu_ms !== null) entry.cpuMs = row.cpu_ms
712
+ if (row.peak_rss_bytes !== null) entry.peakRssBytes = row.peak_rss_bytes
713
+ return entry
643
714
  }
644
715
 
716
+ /** Why a reading handle (`'inspect'`, `'preview'`) writes nothing: no index to read was there. */
717
+ const NO_INDEX = 'no index there yet'
718
+
645
719
  /** The sidecar's `exec` from a save's entry: only the axes the runner reported. */
646
720
  function usageOfEntry(entry: { cpuMs?: number; peakRssBytes?: number }): ExecUsage | undefined {
647
721
  if (entry.cpuMs === undefined && entry.peakRssBytes === undefined) return undefined
@@ -663,7 +737,8 @@ function usageOfEntry(entry: { cpuMs?: number; peakRssBytes?: number }): ExecUsa
663
737
  * failure is another question's answer: `access` on the directory is also
664
738
  * "does it exist" (made only on ENOENT), `access` on the database also "is
665
739
  * it there", and the ignore file is created exclusively instead of probed
666
- * first. An existing cache costs three calls; it cost six.
740
+ * first. An existing cache costs three calls, it cost six, and two more
741
+ * resolve it (`refuseForeignCacheDir`).
667
742
  */
668
743
  function openCacheDir(cacheDir: string, workspaceRoot?: string, index = 'cache.db'): string | null {
669
744
  const [ignore, ignoreText] = ignoreFileFor(cacheDir)
@@ -684,12 +759,17 @@ function openCacheDir(cacheDir: string, workspaceRoot?: string, index = 'cache.d
684
759
  if (code !== 'ENOENT') return errorText(err)
685
760
  refuseManifestDir(cacheDir, workspaceRoot)
686
761
  }
762
+ if (index === 'cache.db') refuseForeignCacheDir(cacheDir, workspaceRoot)
687
763
  writeIgnore(ignore, ignoreText)
688
764
  return null
689
765
  }
690
766
 
691
- /** `openCacheDir` for the shared store: why it cannot be used, or null. Never throws. */
692
- function storeBlocked(storeDir: string, storeRoot: string | null): string | null {
767
+ /**
768
+ * `openCacheDir` for the shared store: why it cannot be used, or null. Never
769
+ * throws. `make` false asks the same question and makes or changes
770
+ * nothing, so a reading verb settles where the entries are as a run does.
771
+ */
772
+ function storeBlocked(storeDir: string, storeRoot: string | null, make: boolean): string | null {
693
773
  try {
694
774
  if (storeRoot !== null) {
695
775
  // `~/.vx`, `~/.vx/<id>`, `~/.vx/<id>/cache`: owner-only, each level
@@ -697,21 +777,53 @@ function storeBlocked(storeDir: string, storeRoot: string | null): string | null
697
777
  // replayed into the workspace, so nobody else may write them.
698
778
  const vxHome = path.dirname(path.dirname(storeRoot))
699
779
  for (const dir of [vxHome, path.dirname(storeRoot), storeRoot]) {
700
- const refused = ensurePrivateDir(dir)
780
+ // The levels below one not made yet are made by this user, 0700.
781
+ if (!make && lstatSync(dir, { throwIfNoEntry: false }) === undefined)
782
+ return mkdirBlocked(dir, false)
783
+ const refused = ensurePrivateDir(dir, make)
701
784
  if (refused !== null) return refused
702
785
  }
703
786
  }
704
- return openCacheDir(storeDir, undefined, 'store.db')
787
+ if (make) return openCacheDir(storeDir, undefined, 'store.db')
788
+ try {
789
+ accessSync(storeDir, constants.W_OK)
790
+ } catch (err) {
791
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return errorText(err)
792
+ return mkdirBlocked(storeDir, true)
793
+ }
794
+ try {
795
+ accessSync(path.join(storeDir, 'store.db'), constants.W_OK)
796
+ } catch (err) {
797
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return errorText(err)
798
+ refuseManifestDir(storeDir, undefined)
799
+ }
800
+ return null
705
801
  } catch (err) {
706
802
  return errorText(err)
707
803
  }
708
804
  }
709
805
 
710
- function ensurePrivateDir(dir: string): string | null {
806
+ /** Why `mkdir` (`-p` with `recursive`) of `dir` would fail, asked without making it. */
807
+ function mkdirBlocked(dir: string, recursive: boolean): string | null {
808
+ let up = path.dirname(dir)
809
+ if (recursive)
810
+ while (lstatSync(up, { throwIfNoEntry: false }) === undefined) up = path.dirname(up)
711
811
  try {
712
- mkdirSync(dir, { mode: 0o700 })
812
+ if (!statSync(up).isDirectory()) return `${up} is not a directory`
813
+ accessSync(up, constants.W_OK)
814
+ return null
713
815
  } catch (err) {
714
- if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return errorText(err)
816
+ return errorText(err)
817
+ }
818
+ }
819
+
820
+ function ensurePrivateDir(dir: string, make: boolean): string | null {
821
+ if (make) {
822
+ try {
823
+ mkdirSync(dir, { mode: 0o700 })
824
+ } catch (err) {
825
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') return errorText(err)
826
+ }
715
827
  }
716
828
  const st = lstatSync(dir)
717
829
  if (!st.isDirectory()) return `${dir} is not a directory`
@@ -723,7 +835,7 @@ function ensurePrivateDir(dir: string): string | null {
723
835
  // The user's own directory open to others (a `~/.vx` made at the
724
836
  // default umask, 0755) is vx's to close: refusing it left the store
725
837
  // unused on a stock macOS home (2026-10-06: "you should own it").
726
- if ((st.mode & 0o077) !== 0) {
838
+ if (make && (st.mode & 0o077) !== 0) {
727
839
  try {
728
840
  chmodSync(dir, 0o700)
729
841
  } catch (err) {
@@ -819,8 +931,6 @@ const MANIFESTS = [
819
931
  ...['ts', 'mts', 'js', 'mjs', 'cts', 'cjs'].map((ext) => `vx.config.${ext}`),
820
932
  ...['ts', 'mts', 'js', 'mjs', 'cts', 'cjs'].map((ext) => `.vx/workspace.${ext}`),
821
933
  ]
822
- const OWN_DIR_HINT =
823
- 'Point `cache.dir` in .vx/workspace.ts (or --cache-dir) at a directory of its own, such as .vx/cache.'
824
934
 
825
935
  function makeCacheDir(cacheDir: string): void {
826
936
  try {
@@ -914,6 +1024,43 @@ export interface RemoteTraffic {
914
1024
  readonly keys: Map<string, RemoteKeyTraffic>
915
1025
  }
916
1026
 
1027
+ /**
1028
+ * Core's own cache's `hashFile`, also told the ctime of the stat its digest
1029
+ * came from (`hashFileSeen`). Kept off `CacheLayer`, a public type: another
1030
+ * layer has none here.
1031
+ */
1032
+ const seenHashers = new WeakMap<
1033
+ CacheLayer,
1034
+ {
1035
+ /** The layer's `hashFile` when it registered: one replaced since is not this. */
1036
+ method: CacheLayer['hashFile']
1037
+ hash: (file: string, seen: (ctimeMs: number) => void) => Promise<string>
1038
+ }
1039
+ >()
1040
+
1041
+ /**
1042
+ * `layer.hashFile(file)`, and `seen` told the ctime of the stat the digest
1043
+ * came from when the layer is core's own cache (the re-check's tie rule,
1044
+ * `InputFact.ctimeMs`). Any other layer tells nothing, and so does one
1045
+ * whose `hashFile` was replaced since.
1046
+ */
1047
+ export function hashFileSeen(
1048
+ layer: CacheLayer,
1049
+ file: string,
1050
+ seen: (ctimeMs: number) => void,
1051
+ ): Promise<string> {
1052
+ const own = seenHashers.get(layer)
1053
+ return own === undefined || own.method !== layer.hashFile
1054
+ ? layer.hashFile(file)
1055
+ : own.hash(file, seen)
1056
+ }
1057
+
1058
+ /** `to` answers `hashFileSeen` as `from` does: a layer over core's cache. */
1059
+ export function shareSeenHasher(from: CacheLayer, to: CacheLayer): void {
1060
+ const own = seenHashers.get(from)
1061
+ if (own !== undefined) seenHashers.set(to, { method: to.hashFile, hash: own.hash })
1062
+ }
1063
+
917
1064
  export class Cache implements CacheLayer {
918
1065
  /** This IS the local layer — there is nothing slower behind it. */
919
1066
  readonly hasRemote = false
@@ -934,6 +1081,8 @@ export class Cache implements CacheLayer {
934
1081
  private readonly selectEntry: ReturnType<Database['prepare']>
935
1082
  private readonly upsertStdout: ReturnType<Database['prepare']>
936
1083
  private readonly deleteStdout: ReturnType<Database['prepare']>
1084
+ private readonly selectStdout: ReturnType<Database['prepare']>
1085
+ private readonly logOf = (hash: string): string => this.guard(() => this.stdoutText(hash))
937
1086
  private readonly entryExists: ReturnType<Database['prepare']>
938
1087
  private readonly selectBlob: ReturnType<Database['prepare']>
939
1088
  private readonly upsertBlob: ReturnType<Database['prepare']>
@@ -948,6 +1097,12 @@ export class Cache implements CacheLayer {
948
1097
  * serves those rows only while the blob still carries it.
949
1098
  */
950
1099
  private readonly heldInlineAt = new Map<string, number>()
1100
+ /**
1101
+ * The mtime each file the last restore of a key left, by row path
1102
+ * (`extractArtifactStream`'s `dated`): its stamps take that, not the
1103
+ * row's saved one. Taken by `recordOutputStamps`.
1104
+ */
1105
+ private readonly datedMtimes = new Map<string, Map<string, number>>()
951
1106
  /** Keys the last lookups found an inline artifact for and no row: `adopt`'s to read. */
952
1107
  private readonly rowless = new Set<string>()
953
1108
  /** Row-less lookups that stat'ed their artifact path (`artifactFileMayExist`). */
@@ -958,7 +1113,14 @@ export class Cache implements CacheLayer {
958
1113
  private heldTimer: ReturnType<typeof setTimeout> | undefined
959
1114
  /** Saves' index writes waiting for this turn's one transaction (`indexInBatch`). */
960
1115
  private indexQueue: Array<{ write: () => void; settle: (err?: unknown) => void }> = []
961
- private heldRest: () => Array<[string, number]> = () => []
1116
+ /**
1117
+ * The held sizes being stored, `oid\0size` in OID order, and how many
1118
+ * are: fields, not a closure's, so the store lets them go.
1119
+ */
1120
+ private storing: string[] | undefined
1121
+ private stored = 0
1122
+ /** The checkpoint thread: `armed` until a save commits (`checkpointOffThread`). */
1123
+ private checkpoints: 'armed' | WalCheckpointer | undefined
962
1124
  private readonly insertEntryInput: ReturnType<Database['prepare']>
963
1125
  /** The per-file (mtime, size) → blob-OID memo behind `hashFile`. */
964
1126
  private readonly files: FileHashStore
@@ -969,6 +1131,10 @@ export class Cache implements CacheLayer {
969
1131
  * principle 9). Taken once, by `outputBytes`.
970
1132
  */
971
1133
  private readonly savedBytes = new Map<string, number>()
1134
+ /** Each key a `--force` save found stored and kept: no save of this run's. Taken once, by `kept`. */
1135
+ private readonly keptHashes = new Set<string>()
1136
+ /** The run this cache saves for: each entry it writes records it (`entries.run_id`). */
1137
+ runId: string | undefined
972
1138
  private readonly folds: IndexFoldMemo
973
1139
  private readonly history: RunHistory
974
1140
  /** What the remotes said they hold, across runs (remote-hits.ts). */
@@ -1004,13 +1170,6 @@ export class Cache implements CacheLayer {
1004
1170
  */
1005
1171
  readonly schemaReset: SchemaReset | null = null
1006
1172
 
1007
- /**
1008
- * Set when THIS open found entries written under another
1009
- * `CACHE_VERSION`: the index survives, but no old key is derived again,
1010
- * so every cached task misses once. Not printed, like `schemaReset`.
1011
- */
1012
- readonly formatChange: SchemaReset | null = null
1013
-
1014
1173
  /**
1015
1174
  * Set when THIS open found the shared store written under another
1016
1175
  * `SCHEMA_VERSION` and dropped its inventory; the artifacts stay and are
@@ -1039,9 +1198,10 @@ export class Cache implements CacheLayer {
1039
1198
  /**
1040
1199
  * `'inspect'`: a reading verb (`why`, `last`, `info`). It never resets
1041
1200
  * the index: a schema it cannot read is refused, named, and left as it
1042
- * was. `'preview'`: a dry prune, which reads such an index as the reset
1043
- * the real prune does first leaves it, empty, with the store still
1044
- * reached, so it names what that prune takes (item 1083).
1201
+ * was. `'preview'`: a dry prune or a `--dry` plan, which reads such an
1202
+ * index as the reset the real prune or run does first leaves it, empty,
1203
+ * with the store still reached, so it names what that prune takes
1204
+ * (item 1083).
1045
1205
  */
1046
1206
  mode: 'open' | 'inspect' | 'preview' = 'open',
1047
1207
  /**
@@ -1050,8 +1210,7 @@ export class Cache implements CacheLayer {
1050
1210
  * and the store's own schema is `store_meta.schema` (`matchStoreSchema`). Or
1051
1211
  * `null` for an index that holds its entries itself (a `cacheDir`).
1052
1212
  * Undefined follows the layout the index records: a reading verb, a
1053
- * plugin's handle. A reading open reads it only where the index
1054
- * records none (deleted, or never written beside a run's store).
1213
+ * plugin's handle.
1055
1214
  */
1056
1215
  storeRoot?: string | null,
1057
1216
  private readonly store: StoreOptions = DEFAULT_STORE,
@@ -1071,10 +1230,20 @@ export class Cache implements CacheLayer {
1071
1230
  // said "no recorded runs yet" (item 900).
1072
1231
  const dbFile = path.join(cacheDir, 'cache.db')
1073
1232
  this.dbFile = dbFile
1074
- const absent =
1075
- mode !== 'open' &&
1076
- (!existsSync(dbFile) || (mode === 'preview' && indexOfAnotherSchema(dbFile)))
1077
- this.writeBlocked = absent ? 'no index there yet' : openCacheDir(cacheDir, repoDir)
1233
+ let absent = mode !== 'open' && !existsSync(dbFile)
1234
+ // A preview reads an index of another schema as the empty one a reset
1235
+ // leaves. The version is read on the connection the index keeps, once:
1236
+ // a second connection for it cost a plan a statement (perf-guard).
1237
+ let early: { db: Database; version: string | undefined } | undefined
1238
+ if (mode === 'preview' && !absent) {
1239
+ early = previewIndex(dbFile)
1240
+ if (early !== undefined && early.version !== undefined && early.version !== SCHEMA_VERSION) {
1241
+ closeDb(early.db)
1242
+ early = undefined
1243
+ absent = true
1244
+ }
1245
+ }
1246
+ this.writeBlocked = absent ? NO_INDEX : openCacheDir(cacheDir, repoDir)
1078
1247
  this.write = localPolicy.write && this.writeBlocked === null
1079
1248
  // A directory this user cannot write with no index in it yet: SQLite
1080
1249
  // cannot make one, and its `unable to open database file` stack reached
@@ -1084,7 +1253,7 @@ export class Cache implements CacheLayer {
1084
1253
  const memory = absent || (this.writeBlocked !== null && !existsSync(dbFile))
1085
1254
  this.empty = memory
1086
1255
  try {
1087
- this.db = new Database(memory ? ':memory:' : dbFile, { create: true })
1256
+ this.db = early?.db ?? new Database(memory ? ':memory:' : dbFile, { create: true })
1088
1257
  } catch (err) {
1089
1258
  throw cantOpenIndex(dbFile, err) ?? err
1090
1259
  }
@@ -1179,7 +1348,7 @@ export class Cache implements CacheLayer {
1179
1348
  )
1180
1349
  }
1181
1350
  }
1182
- const current = readable(readVersion)
1351
+ const current = early?.version ?? readable(readVersion)
1183
1352
  if (current !== SCHEMA_VERSION) {
1184
1353
  try {
1185
1354
  if (current !== undefined) refuseUnreadable(current)
@@ -1193,7 +1362,7 @@ export class Cache implements CacheLayer {
1193
1362
  // keeps it so).
1194
1363
  if (found !== undefined) {
1195
1364
  this.db.exec(
1196
- 'DROP TABLE IF EXISTS main.entries; DROP TABLE IF EXISTS main.runs; DROP TABLE IF EXISTS main.file_hashes; DROP TABLE IF EXISTS main.blob_sizes; DROP TABLE IF EXISTS main.blob_verdicts; DROP TABLE IF EXISTS main.output_files; DROP TABLE IF EXISTS main.output_stamps; DROP TABLE IF EXISTS main.invocations; DROP TABLE IF EXISTS main.run_task_inputs; DROP TABLE IF EXISTS main.entry_inputs; DROP TABLE IF EXISTS main.config_evals; DROP TABLE IF EXISTS main.config_closures; DROP TABLE IF EXISTS main.output_dirs; DROP TABLE IF EXISTS main.entry_stdout; DROP TABLE IF EXISTS main.task_durations; DROP TABLE IF EXISTS main.plugin_blobs; DROP TABLE IF EXISTS main.task_outcomes; DROP TABLE IF EXISTS main.store_meta; DROP TABLE IF EXISTS main.fold_memo; DROP TABLE IF EXISTS main.remote_hits; DROP TABLE IF EXISTS main.steps; DROP TABLE IF EXISTS main.warnings; DROP TABLE IF EXISTS main.failures; DROP TABLE IF EXISTS main.lockfile_memos;',
1365
+ 'DROP TABLE IF EXISTS main.entries; DROP TABLE IF EXISTS main.runs; DROP TABLE IF EXISTS main.file_hashes; DROP TABLE IF EXISTS main.blob_sizes; DROP TABLE IF EXISTS main.blob_verdicts; DROP TABLE IF EXISTS main.output_files; DROP TABLE IF EXISTS main.output_stamps; DROP TABLE IF EXISTS main.invocations; DROP TABLE IF EXISTS main.run_task_inputs; DROP TABLE IF EXISTS main.entry_inputs; DROP TABLE IF EXISTS main.config_evals; DROP TABLE IF EXISTS main.config_closures; DROP TABLE IF EXISTS main.output_dirs; DROP TABLE IF EXISTS main.entry_stdout; DROP TABLE IF EXISTS main.task_durations; DROP TABLE IF EXISTS main.plugin_blobs; DROP TABLE IF EXISTS main.task_outcomes; DROP TABLE IF EXISTS main.store_meta; DROP TABLE IF EXISTS main.fold_memo; DROP TABLE IF EXISTS main.remote_hits; DROP TABLE IF EXISTS main.steps; DROP TABLE IF EXISTS main.warnings; DROP TABLE IF EXISTS main.failures; DROP TABLE IF EXISTS main.lockfile_memos; DROP TABLE IF EXISTS main.run_ids;',
1197
1366
  )
1198
1367
  }
1199
1368
  // The tables land under the same write lock as the stamp: made
@@ -1236,25 +1405,22 @@ export class Cache implements CacheLayer {
1236
1405
  } | null
1237
1406
  )?.value,
1238
1407
  )
1239
- // A dry prune names what the real one takes, so it reads the store a
1240
- // run opens (today's), not the one the index last recorded.
1241
- let storeDir =
1242
- mode === 'inspect'
1243
- ? (recorded ?? (typeof storeRoot === 'string' ? storeRoot : undefined))
1244
- : storeRoot === undefined
1245
- ? recorded
1246
- : storeRoot === null
1247
- ? undefined
1248
- : storeRoot
1249
- if (storeDir !== undefined && mode === 'open' && this.writeBlocked === null) {
1250
- const blocked = storeBlocked(storeDir, typeof storeRoot === 'string' ? storeRoot : null)
1251
- if (blocked !== null) {
1408
+ // A store named by the caller is the one a run opens (today's), and
1409
+ // every mode reads it: a dry prune names what the real one takes, and
1410
+ // `vx info` the store the next run fills, not one a moved HOME left.
1411
+ let storeDir = storeRoot === undefined ? recorded : (storeRoot ?? undefined)
1412
+ // Every mode settles the store as a run does, the reading ones without
1413
+ // touching it: `vx info` and a dry prune read the entries runs use, not
1414
+ // a store another user owns (one `sudo vx run`).
1415
+ const root = typeof storeRoot === 'string' ? storeRoot : null
1416
+ if (storeDir !== undefined && (this.writeBlocked === null || absent)) {
1417
+ if (storeBlocked(storeDir, root, mode === 'open') !== null) {
1252
1418
  // A home this user cannot write (a container, a read-only mount):
1253
1419
  // the entries stay in this workspace rather than fail the run.
1254
1420
  const fallback = cacheDir
1255
1421
  if (fallback !== storeDir) {
1256
1422
  storeDir = fallback
1257
- openCacheDir(fallback)
1423
+ if (mode === 'open') openCacheDir(fallback)
1258
1424
  }
1259
1425
  }
1260
1426
  }
@@ -1275,7 +1441,9 @@ export class Cache implements CacheLayer {
1275
1441
  this.storeDir = storeDir
1276
1442
  this.artifactDir = storeDir ?? cacheDir
1277
1443
  if (storeDir !== undefined) {
1278
- this.attachStore(storeDir, mode !== 'open')
1444
+ this.attachStore(storeDir, mode !== 'open', (dir) =>
1445
+ dir === cacheDir ? openCacheDir(dir) : storeBlocked(dir, root, true),
1446
+ )
1279
1447
  this.storeReset = this.matchStoreSchema(mode === 'open' && this.writeBlocked === null)
1280
1448
  } else {
1281
1449
  createTables(this.db, 'main')
@@ -1301,8 +1469,8 @@ export class Cache implements CacheLayer {
1301
1469
  this.insertEntry = lazyStatement(
1302
1470
  this.db,
1303
1471
  `
1304
- INSERT INTO entries(hash, project, task, command, exit_code, duration_ms, size_bytes, created_at, accessed_at, cpu_ms, peak_rss_bytes)
1305
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1472
+ INSERT INTO entries(hash, project, task, command, exit_code, duration_ms, size_bytes, created_at, accessed_at, cpu_ms, peak_rss_bytes, run_id)
1473
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1306
1474
  ON CONFLICT(hash) DO UPDATE SET
1307
1475
  project = excluded.project,
1308
1476
  task = excluded.task,
@@ -1312,14 +1480,17 @@ export class Cache implements CacheLayer {
1312
1480
  size_bytes = excluded.size_bytes,
1313
1481
  accessed_at = excluded.accessed_at,
1314
1482
  cpu_ms = excluded.cpu_ms,
1315
- peak_rss_bytes = excluded.peak_rss_bytes
1483
+ peak_rss_bytes = excluded.peak_rss_bytes,
1484
+ created_at = excluded.created_at,
1485
+ run_id = excluded.run_id
1316
1486
  `,
1317
1487
  )
1318
1488
  this.upsertStdout = lazyStatement(
1319
1489
  this.db,
1320
- 'INSERT INTO entry_stdout(hash, stdout) VALUES (?, CAST(? AS TEXT)) ON CONFLICT(hash) DO UPDATE SET stdout = excluded.stdout',
1490
+ 'INSERT INTO entry_stdout(hash, stdout) VALUES (?, ?) ON CONFLICT(hash) DO UPDATE SET stdout = excluded.stdout',
1321
1491
  )
1322
1492
  this.deleteStdout = lazyStatement(this.db, 'DELETE FROM entry_stdout WHERE hash = ?')
1493
+ this.selectStdout = lazyStatement(this.db, 'SELECT stdout FROM entry_stdout WHERE hash = ?')
1323
1494
  this.selectEntry = lazyStatement(this.db, SELECT_ENTRY)
1324
1495
  // `has` asks only whether the row is there and where its artifact is,
1325
1496
  // with no stdout joined. A column off the index, so the table row is
@@ -1348,23 +1519,23 @@ export class Cache implements CacheLayer {
1348
1519
  // The slices: each owns its statements over this handle and its table(s);
1349
1520
  // the schema above is the one place every table is declared.
1350
1521
  this.files = new FileHashStore(this.db, cacheDir, this.write, repoDir)
1522
+ seenHashers.set(this, {
1523
+ method: this.hashFile,
1524
+ hash: (file, seen) => this.guard(() => this.files.hashFile(file, seen)),
1525
+ })
1351
1526
  this.outputs = new OutputIndex(this.db, this.writeBlocked === null && !this.inspecting)
1352
1527
  this.folds = new IndexFoldMemo(this.db, this.write && !this.inspecting)
1353
1528
  this.history = new RunHistory(this.db, (v) => this.digestValue(v))
1354
1529
  this.remoteHits = new IndexRemoteHits(this.db, this.writeBlocked === null && !this.inspecting)
1355
1530
 
1356
1531
  // A CACHE_VERSION bump keeps the index but moves every key, so the run
1357
- // after an upgrade misses everything. Roadmap 3.3: that is announced,
1358
- // never silent. A store with no recorded version and no entries is
1359
- // new; one with entries predates the record (item 671).
1532
+ // after an upgrade misses everything, in silence: the cache is vx's to
1533
+ // keep (2026-10-06). The version is recorded for whoever reads the
1534
+ // index by hand; a reading verb writes nothing (item 1080).
1360
1535
  const format = this.db
1361
1536
  .prepare("SELECT value FROM schema_meta WHERE key = 'cache_version'")
1362
1537
  .get() as { value: string } | null
1363
1538
  if (format?.value !== CACHE_VERSION && this.writeBlocked === null && !this.inspecting) {
1364
- const hasEntries = this.db.prepare('SELECT 1 FROM entries LIMIT 1').get() != null
1365
- if (this.schemaReset === null && (format !== null || hasEntries)) {
1366
- this.formatChange = { from: format?.value ?? 'an earlier format', to: CACHE_VERSION }
1367
- }
1368
1539
  this.db
1369
1540
  .prepare(
1370
1541
  "INSERT INTO schema_meta(key, value) VALUES ('cache_version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
@@ -1478,25 +1649,59 @@ export class Cache implements CacheLayer {
1478
1649
  * WAL for both, so a workspace's run and another's share the store as
1479
1650
  * two runs on one `--cache-dir` share an index. A reading verb over a
1480
1651
  * store with no file or no tables yet reads an empty one in memory.
1652
+ *
1653
+ * Every checkout of a repository shares its store, each with a run lock
1654
+ * of its own, and a `vx cache clean` in one holds the store alone
1655
+ * (`holdStore`) until it has deleted it. A writing open meeting that hold
1656
+ * answers SQLITE_BUSY within a second, for its caller to wait and open
1657
+ * again (prepare.ts). One let in once the clean let go opened the
1658
+ * store.db the clean unlinked, and saved its entry into it: it makes the
1659
+ * store again (`remake`, private and ignored) and opens that. Once in,
1660
+ * its lock keeps a clean out until it closes.
1481
1661
  */
1482
- private attachStore(storeDir: string, inspecting: boolean): void {
1662
+ private attachStore(
1663
+ storeDir: string,
1664
+ inspecting: boolean,
1665
+ remake: (dir: string) => string | null,
1666
+ ): void {
1483
1667
  const storeFile = path.join(storeDir, 'store.db')
1484
1668
  let absent = inspecting && !existsSync(storeFile)
1485
1669
  try {
1486
- this.db.prepare('ATTACH DATABASE ? AS store').run(absent ? ':memory:' : storeFile)
1487
- // One with no tables yet is read empty in memory too: the journal
1488
- // switch and the table creation below are writes, under no write
1489
- // lock, racing an opener making that store.
1490
- if (
1491
- inspecting &&
1492
- !absent &&
1493
- this.db.prepare("SELECT 1 FROM store.sqlite_master WHERE name = 'entries'").get() == null
1494
- ) {
1670
+ for (let retried = false; ;) {
1671
+ // The attach reads the store's schema: its first lock on it.
1672
+ if (!inspecting) this.db.exec('PRAGMA busy_timeout = 1000')
1673
+ try {
1674
+ this.db.prepare('ATTACH DATABASE ? AS store').run(absent ? ':memory:' : storeFile)
1675
+ } catch (err) {
1676
+ // A file whose directory went: SQLite cannot open its -wal there.
1677
+ const busy = (err as { code?: unknown }).code === 'SQLITE_BUSY'
1678
+ if (inspecting || busy || retried || remake(storeDir) !== null) throw err
1679
+ retried = true
1680
+ continue
1681
+ }
1682
+ // One with no tables yet is read empty in memory too: the journal
1683
+ // switch and the table creation below are writes, under no write
1684
+ // lock, racing an opener making that store.
1685
+ if (
1686
+ inspecting &&
1687
+ !absent &&
1688
+ this.db.prepare("SELECT 1 FROM store.sqlite_master WHERE name = 'entries'").get() == null
1689
+ ) {
1690
+ this.db.exec('DETACH DATABASE store')
1691
+ this.db.prepare('ATTACH DATABASE ? AS store').run(':memory:')
1692
+ absent = true
1693
+ }
1694
+ this.db.exec('PRAGMA store.journal_mode = WAL')
1695
+ if (inspecting) break
1696
+ this.db.exec('PRAGMA busy_timeout = 5000')
1697
+ // One stat, SQLite's: is the file open still the one at its name?
1698
+ const moved = new Int32Array(2)
1699
+ this.db.fileControl('store', SQLITE_FCNTL_HAS_MOVED, moved)
1700
+ if (moved[0] === 0) break
1495
1701
  this.db.exec('DETACH DATABASE store')
1496
- this.db.prepare('ATTACH DATABASE ? AS store').run(':memory:')
1497
- absent = true
1702
+ const refused = remake(storeDir)
1703
+ if (refused !== null) throw new Error(`${storeFile} was deleted, and ${refused}`)
1498
1704
  }
1499
- this.db.exec('PRAGMA store.journal_mode = WAL')
1500
1705
  if (!absent) this.db.fileControl('store', SQLITE_FCNTL_PERSIST_WAL, 1)
1501
1706
  this.db.exec('PRAGMA store.journal_size_limit = 67108864')
1502
1707
  this.db.exec('PRAGMA store.synchronous = NORMAL')
@@ -1676,13 +1881,46 @@ export class Cache implements CacheLayer {
1676
1881
  const held = this.heldBlobSizes
1677
1882
  this.heldBlobSizes = undefined
1678
1883
  if (held === undefined || held.length === 0) return
1679
- let at = 0
1680
- const step = (): void => {
1681
- this.guard(() => this.files.rememberBlobSizes(new Map(held.slice(at, (at += HELD_SLICE)))))
1682
- this.heldTimer = at < held.length ? setTimeout(step, 0) : undefined
1884
+ // In OID order, so each slice appends at the key index's right edge:
1885
+ // in git's path order every slice dirtied pages across the whole
1886
+ // index, and the 160 commits of a cold 9,603-task run cost its main
1887
+ // thread ~5x more (2026-11-06). Sorted as `oid\0size` strings with no
1888
+ // comparator: a JS comparator over 40,000 entries was 32 ms of a cold
1889
+ // 8,002-task run's main thread before its first task, the native
1890
+ // string sort 8 (2026-11-07). `\0` sorts below every OID character,
1891
+ // so the order is the OIDs'. No entry outlives this: the strings are
1892
+ // all the store keeps.
1893
+ const keys: string[] = []
1894
+ for (let i = 0; i < held.length; i++) keys.push(`${held[i]![0]}\0${held[i]![1]}`)
1895
+ keys.sort()
1896
+ this.storing = keys
1897
+ this.stored = 0
1898
+ this.heldTimer = setTimeout(this.storeHeldSlice, 0)
1899
+ }
1900
+
1901
+ // A closure over the sizes kept all of them while anything held it (the
1902
+ // rest's getter, a fired timer's callback): a cold 9,603-task run's
1903
+ // 38,331 to its end, ~5 MB of heap (2026-11-06).
1904
+ private readonly storeHeldSlice = (): void => {
1905
+ const held = this.storing
1906
+ if (held === undefined) return
1907
+ const at = this.stored
1908
+ this.stored = at + HELD_SLICE
1909
+ this.guard(() => this.files.rememberBlobSizes(this.heldSizes(held, at, this.stored)))
1910
+ if (this.stored < held.length) this.heldTimer = setTimeout(this.storeHeldSlice, 0)
1911
+ else {
1912
+ this.heldTimer = undefined
1913
+ this.storing = undefined
1914
+ }
1915
+ }
1916
+ private heldSizes(held: readonly string[], from: number, to: number): Map<string, number> {
1917
+ const out = new Map<string, number>()
1918
+ for (let i = from; i < Math.min(to, held.length); i++) {
1919
+ const k = held[i]!
1920
+ const at = k.indexOf('\0')
1921
+ out.set(k.slice(0, at), Number(k.slice(at + 1)))
1683
1922
  }
1684
- this.heldRest = () => held.slice(at)
1685
- this.heldTimer = setTimeout(step, 0)
1923
+ return out
1686
1924
  }
1687
1925
  /** `BlobSizeMemo`: the paths an index with this digest distrusts, when known. */
1688
1926
  blobVerdict(digest: string): string[] | undefined {
@@ -1914,7 +2152,7 @@ export class Cache implements CacheLayer {
1914
2152
  // for four up-to-date hits on ~70 MB binaries). restoreOutputs
1915
2153
  // reads the artifact itself, only when extraction actually runs.
1916
2154
  const fileRows = this.loadOutputFilesBatch([hash]).get(hash) ?? []
1917
- const entry = entryOf(row as LiveRow, fileRows)
2155
+ const entry = entryOf(row as LiveRow, fileRows, this.logOf)
1918
2156
  entry.outputDirRows = this.loadOutputDirsBatch([hash]).get(hash) ?? []
1919
2157
  return entry
1920
2158
  }
@@ -1973,32 +2211,40 @@ export class Cache implements CacheLayer {
1973
2211
  ? [this.selectEntry.get(hashes[0]!)].filter((r) => r !== null)
1974
2212
  : this.db.query(SELECT_ENTRIES).all(JSON.stringify(hashes))
1975
2213
  ) as EntryRow[]
1976
- const rows: LiveRow[] = []
1977
- for (const r of found) {
1978
- if (r.hash === null) this.rowless.add(r.key)
1979
- else rows.push(r as LiveRow)
1980
- }
1981
- if (rows.length === 0) return out
1982
2214
  const graceStart = Date.now() - ORPHAN_GRACE_MS
1983
2215
  const refreshStart = Date.now() - ACCESS_REFRESH_MS
1984
- const live = rows.filter((r) => {
2216
+ // A hash asked twice is one row here: the output queries join from the
2217
+ // live hashes as given (`loadOutputFilesBatch`'s `distinct`).
2218
+ const seen = new Set<string>()
2219
+ const live: LiveRow[] = []
2220
+ const liveHashes: string[] = []
2221
+ for (const r of found) {
2222
+ if (r.hash === null) {
2223
+ this.rowless.add(r.key)
2224
+ continue
2225
+ }
2226
+ if (seen.has(r.hash)) continue
2227
+ seen.add(r.hash)
1985
2228
  if (r.inline_at !== null) {
1986
2229
  if (r.inline_at < graceStart) this.staleInline.add(r.hash)
1987
- return true
2230
+ } else {
2231
+ const st = this.artifactStat(r.hash)
2232
+ if (st === undefined) continue
2233
+ if (st.mtimeMs < graceStart) this.staleTimes.add(r.hash)
1988
2234
  }
1989
- const st = this.artifactStat(r.hash)
1990
- if (st !== undefined && st.mtimeMs < graceStart) this.staleTimes.add(r.hash)
1991
- return st !== undefined
1992
- })
1993
- const liveHashes = live.map((r) => r.hash)
1994
- const fileRows = this.loadOutputFilesBatch(liveHashes)
2235
+ live.push(r as LiveRow)
2236
+ liveHashes.push(r.hash)
2237
+ }
2238
+ if (seen.size === 0) return out
2239
+ const distinct = JSON.stringify(liveHashes)
2240
+ const fileRows = this.outputs.loadOutputFilesBatch(liveHashes, distinct)
1995
2241
  this.outputs.hold(fileRows)
1996
2242
  this.heldInlineAt.clear()
1997
2243
  for (const r of live) if (r.inline_at !== null) this.heldInlineAt.set(r.hash, r.inline_at)
1998
- const dirRows = this.loadOutputDirsBatch(liveHashes)
2244
+ const dirRows = this.outputs.loadOutputDirsBatch(liveHashes, distinct)
1999
2245
  for (const row of live) {
2000
2246
  if (row.accessed_at < refreshStart) this.touched.add(row.hash)
2001
- const entry = entryOf(row, fileRows.get(row.hash) ?? [])
2247
+ const entry = entryOf(row, fileRows.get(row.hash) ?? [], this.logOf)
2002
2248
  entry.outputDirRows = dirRows.get(row.hash) ?? []
2003
2249
  out.set(row.hash, entry)
2004
2250
  }
@@ -2060,6 +2306,9 @@ export class Cache implements CacheLayer {
2060
2306
  this.savedBytes.delete(hash)
2061
2307
  return bytes
2062
2308
  }
2309
+ kept(hash: string): boolean {
2310
+ return this.keptHashes.delete(hash)
2311
+ }
2063
2312
  artifactSize(hash: string): number | undefined {
2064
2313
  return this.guard(
2065
2314
  () =>
@@ -2080,7 +2329,9 @@ export class Cache implements CacheLayer {
2080
2329
  await this.outputs.recordOutputDirs(hash, projectDir, prefixes, holds)
2081
2330
  }
2082
2331
  recordOutputStamps(hash: string, projectDir: string, workspaceRoot: string): void {
2083
- this.outputs.recordOutputStamps(hash, projectDir, workspaceRoot)
2332
+ const dated = this.datedMtimes.get(hash)
2333
+ this.datedMtimes.delete(hash)
2334
+ this.outputs.recordOutputStamps(hash, projectDir, workspaceRoot, dated)
2084
2335
  }
2085
2336
  loadOutputDirsBatch(hashes: readonly string[]): Map<string, OutputDirRow[]> {
2086
2337
  return this.guard(() => this.outputs.loadOutputDirsBatch(hashes))
@@ -2094,23 +2345,52 @@ export class Cache implements CacheLayer {
2094
2345
  workspaceRoot?: string,
2095
2346
  outputs?: DeclaredOutputs,
2096
2347
  ): Promise<void> {
2348
+ const used: { rows: readonly OutputFileRow[] } = { rows: [] }
2097
2349
  try {
2098
- await this.restoreOutputsOnce(hash, projectDir, workspaceRoot, outputs)
2350
+ await this.restoreOutputsOnce(hash, projectDir, workspaceRoot, outputs, used, false)
2099
2351
  } catch (err) {
2100
2352
  if (!(err instanceof CorruptArtifactError)) throw err
2353
+ let failed = err
2354
+ // A file artifact has no `at` to say a re-save in another process
2355
+ // replaced it since the probe: the probe's rows then name another
2356
+ // build's files, and this one reads as corrupt. Rows that changed
2357
+ // since are that race, so the restore runs once more on the rows read
2358
+ // now, and an entry is dropped only while its rows are the ones that
2359
+ // failed: another process's fresh entry is never taken.
2360
+ if (this.entryChanged(hash, used.rows)) {
2361
+ try {
2362
+ await this.restoreOutputsOnce(hash, projectDir, workspaceRoot, outputs, used, true)
2363
+ return
2364
+ } catch (again) {
2365
+ if (!(again instanceof CorruptArtifactError)) throw again
2366
+ failed = again
2367
+ }
2368
+ }
2101
2369
  // Bad bytes under a good key failed the task on every run until
2102
2370
  // `--force`, since nothing rewrote them (J's lead, nx#30338). The
2103
2371
  // entry goes, and the caller runs the task, whose save stores it
2104
2372
  // again; running is always correct, only slower than a hit.
2105
- if (this.write) await this.dropEntry(hash)
2373
+ if (this.write && !(await this.dropUnchanged(hash, used.rows)))
2374
+ throw new ArtifactVanishedError(hash)
2106
2375
  throw new ArtifactVanishedError(
2107
2376
  hash,
2108
- `${err.message}; ${this.write ? 'dropped it' : 'left it (this cache is read-only)'}`,
2109
- err,
2377
+ `${failed.message}; ${this.write ? 'dropped it' : 'left it (this cache is read-only)'}`,
2378
+ failed,
2110
2379
  )
2111
2380
  }
2112
2381
  }
2113
2382
 
2383
+ /** Whether `hash`'s committed rows are no longer `rows` (path, size, mode, mtime). */
2384
+ private entryChanged(hash: string, rows: readonly OutputFileRow[]): boolean {
2385
+ const now = this.guard(() => this.outputs.loadOutputFilesBatch([hash]).get(hash) ?? [])
2386
+ if (now.length !== rows.length) return true
2387
+ const was = new Map(rows.map((r) => [r.path, r]))
2388
+ return now.some((r) => {
2389
+ const w = was.get(r.path)
2390
+ return w === undefined || w.size !== r.size || w.mode !== r.mode || w.mtimeMs !== r.mtimeMs
2391
+ })
2392
+ }
2393
+
2114
2394
  /**
2115
2395
  * Restore a tree-form entry: its files copied (or cloned) out one by
2116
2396
  * one where the outputs are absent, as on every restore after a clean. Anything else (a tree to
@@ -2120,11 +2400,13 @@ export class Cache implements CacheLayer {
2120
2400
  private async restoreTree(
2121
2401
  hash: string,
2122
2402
  tree: string,
2403
+ treeIno: number,
2123
2404
  rows: readonly OutputFileRow[],
2124
2405
  projectDir: string,
2125
2406
  workspaceRoot: string | undefined,
2126
2407
  verify: (provided: ReadonlySet<string>) => void,
2127
2408
  accept: ((name: string) => boolean) | undefined,
2409
+ dated: Map<string, number>,
2128
2410
  ): Promise<void> {
2129
2411
  const files = rows.filter((r) => !r.path.endsWith('/') && r.mode !== LINK_MODE)
2130
2412
  let bytes = 0
@@ -2135,7 +2417,20 @@ export class Cache implements CacheLayer {
2135
2417
  `its outputs hold ${formatBytes(bytes)}, past the ${formatBytes(this.artifactCeiling)} artifact ceiling`,
2136
2418
  )
2137
2419
  }
2138
- if (await this.copyTree(hash, tree, rows, files, projectDir, accept)) return
2420
+ if (
2421
+ await this.copyTree(
2422
+ hash,
2423
+ tree,
2424
+ treeIno,
2425
+ rows,
2426
+ files,
2427
+ projectDir,
2428
+ workspaceRoot,
2429
+ accept,
2430
+ dated,
2431
+ )
2432
+ )
2433
+ return
2139
2434
  await extractArtifactStream(
2140
2435
  packArtifactStream(this.treePlan(hash, tree, rows)),
2141
2436
  projectDir,
@@ -2143,6 +2438,8 @@ export class Cache implements CacheLayer {
2143
2438
  verify,
2144
2439
  accept,
2145
2440
  workspaceRoot,
2441
+ undefined,
2442
+ dated,
2146
2443
  )
2147
2444
  }
2148
2445
 
@@ -2177,15 +2474,23 @@ export class Cache implements CacheLayer {
2177
2474
  * and every directory on the way either is a real directory or is absent
2178
2475
  * and made here. solid's build outputs `store/dist/**` beside the sources
2179
2476
  * in `store/`; asking that each output's TOP be absent sent every such
2180
- * restore through the tar extractor, ~25 ms a task (2026-10-22).
2477
+ * restore through the tar extractor, ~25 ms a task (2026-10-22). The
2478
+ * files of each absent top directory are copied into the workspace's
2479
+ * `.vx/tmp/` (`copyStaging`) and the directory renamed in once, so a
2480
+ * reader finds it complete or not at all; one staging cannot rename into
2481
+ * as a write in place would land (another filesystem, a setgid
2482
+ * directory) is the extractor's.
2181
2483
  */
2182
2484
  private async copyTree(
2183
2485
  hash: string,
2184
2486
  tree: string,
2487
+ ino: number,
2185
2488
  rows: readonly OutputFileRow[],
2186
2489
  files: readonly OutputFileRow[],
2187
2490
  projectDir: string,
2491
+ workspaceRoot: string | undefined,
2188
2492
  accept: ((name: string) => boolean) | undefined,
2493
+ dated: Map<string, number>,
2189
2494
  ): Promise<boolean> {
2190
2495
  for (const r of rows) {
2191
2496
  if (r.path.startsWith(WORKSPACE_OUTPUT_PREFIX) || r.mode === LINK_MODE) return false
@@ -2194,14 +2499,19 @@ export class Cache implements CacheLayer {
2194
2499
  }
2195
2500
  // What stands at each path, asked once: a real directory, nothing, or
2196
2501
  // anything else (a file, a link), which the extractor's rules decide.
2197
- const standing = new Map<string, Promise<'dir' | 'absent' | 'other'>>()
2198
- const at = (rel: string): Promise<'dir' | 'absent' | 'other'> => {
2502
+ // Synchronous: the walk before this one looked these paths up, so each
2503
+ // answer is a cached dentry, and the async form cost a 1,600-restore run
2504
+ // ~40 ms of its main thread in promises (2026-11-07).
2505
+ const standing = new Map<string, 'dir' | 'absent' | 'other'>()
2506
+ const at = (rel: string): 'dir' | 'absent' | 'other' => {
2199
2507
  let known = standing.get(rel)
2200
2508
  if (known === undefined) {
2201
- known = lstat(path.join(projectDir, rel)).then(
2202
- (st) => (st.isDirectory() ? 'dir' : 'other'),
2203
- (err: NodeJS.ErrnoException) => (err.code === 'ENOENT' ? 'absent' : 'other'),
2204
- )
2509
+ try {
2510
+ const st = lstatSync(path.join(projectDir, rel), { throwIfNoEntry: false })
2511
+ known = st === undefined ? 'absent' : st.isDirectory() ? 'dir' : 'other'
2512
+ } catch {
2513
+ known = 'other'
2514
+ }
2205
2515
  standing.set(rel, known)
2206
2516
  }
2207
2517
  return known
@@ -2210,24 +2520,22 @@ export class Cache implements CacheLayer {
2210
2520
  // writes into a directory that stood: what a failure removes, and only that.
2211
2521
  const made = new Set<string>()
2212
2522
  const placed: string[] = []
2213
- const fits = await Promise.all(
2214
- rows.map(async (r) => {
2215
- const segs = r.path.replace(/\/$/, '').split('/')
2216
- for (let i = 1; i <= segs.length; i++) {
2217
- const rel = segs.slice(0, i).join('/')
2218
- const isLeaf = i === segs.length
2219
- const state = await at(rel)
2220
- if (state === 'absent') {
2221
- if (!isLeaf || r.path.endsWith('/')) made.add(rel)
2222
- else placed.push(rel)
2223
- return true
2224
- }
2225
- if (state === 'other' || (isLeaf && !r.path.endsWith('/'))) return false
2523
+ const fits = rows.every((r) => {
2524
+ const segs = r.path.replace(/\/$/, '').split('/')
2525
+ for (let i = 1; i <= segs.length; i++) {
2526
+ const rel = segs.slice(0, i).join('/')
2527
+ const isLeaf = i === segs.length
2528
+ const state = at(rel)
2529
+ if (state === 'absent') {
2530
+ if (!isLeaf || r.path.endsWith('/')) made.add(rel)
2531
+ else placed.push(rel)
2532
+ return true
2226
2533
  }
2227
- return true
2228
- }),
2229
- )
2230
- if (fits.includes(false)) return false
2534
+ if (state === 'other' || (isLeaf && !r.path.endsWith('/'))) return false
2535
+ }
2536
+ return true
2537
+ })
2538
+ if (!fits) return false
2231
2539
  // A directory made under another one made here is the outer one's.
2232
2540
  const roots = [...made].filter((d) => ![...made].some((o) => d.startsWith(`${o}/`)))
2233
2541
  readUmask()
@@ -2235,8 +2543,8 @@ export class Cache implements CacheLayer {
2235
2543
  // Another process's re-save of this key renames the tree aside and a
2236
2544
  // new one in mid-copy: the files copied after came from another build.
2237
2545
  // A changed inode is that race, a miss (the task runs), and the entry
2238
- // the other process saved is kept.
2239
- const ino = lstatSync(tree).ino
2546
+ // the other process saved is kept. `ino` is the tree's as the restore
2547
+ // found it, before this walk: a swap since is caught all the same.
2240
2548
  const swapped = (): boolean => lstatSync(tree, { throwIfNoEntry: false })?.ino !== ino
2241
2549
  // A standing directory of this user's that withholds its own write (a
2242
2550
  // task's `chmod 555 dist/ro`) gets owner write and search for the copy
@@ -2252,6 +2560,46 @@ export class Cache implements CacheLayer {
2252
2560
  chmodSync(dir, (st.mode & 0o7777) | 0o300)
2253
2561
  return true
2254
2562
  }
2563
+ // A missing top directory is built whole in staging and renamed in
2564
+ // once, as the extractor does: copied in place, a reader met its files
2565
+ // empty, then short. A file missing from a directory that stands is
2566
+ // copied straight to its place: a temp renamed in cost such a restore
2567
+ // a fifth to a third more (tmpfs, 3,000 files, 2026-11-06).
2568
+ if (roots.length === 0 && placed.length === 0) return true
2569
+ // A restore into a project directory not there yet makes it, as the
2570
+ // copy in place did.
2571
+ // Its stat also answers, below, what a rename into it keeps.
2572
+ let projectStat: Stats | undefined
2573
+ try {
2574
+ projectStat = statSync(projectDir, { throwIfNoEntry: false })
2575
+ } catch {
2576
+ projectStat = undefined
2577
+ }
2578
+ if (projectStat === undefined) await mkdir(projectDir, { recursive: true })
2579
+ const staging = roots.length > 0 ? await copyStaging(workspaceRoot) : undefined
2580
+ if (staging?.stats !== undefined) {
2581
+ const fits = new Map<string, boolean>()
2582
+ for (const rel of roots) {
2583
+ const dir = path.dirname(path.join(projectDir, rel))
2584
+ let fit = fits.get(dir)
2585
+ if (fit === undefined) {
2586
+ const top = !rel.includes('/') && projectStat !== undefined
2587
+ fit = stagesInto(
2588
+ staging.stats,
2589
+ top ? projectStat : statSync(dir, { throwIfNoEntry: false }),
2590
+ )
2591
+ fits.set(dir, fit)
2592
+ }
2593
+ if (!fit) return false
2594
+ }
2595
+ }
2596
+ const temps = new Map(roots.map((rel) => [rel, staging!.temp(path.join(projectDir, rel))]))
2597
+ const target = (rel: string): string => {
2598
+ for (const d of roots) {
2599
+ if (rel === d || rel.startsWith(`${d}/`)) return temps.get(d)! + rel.slice(d.length)
2600
+ }
2601
+ return path.join(projectDir, rel)
2602
+ }
2255
2603
  const copy = async (to: string, from: string): Promise<number> => {
2256
2604
  try {
2257
2605
  return await Bun.write(to, Bun.file(from), { createPath: false })
@@ -2260,29 +2608,49 @@ export class Cache implements CacheLayer {
2260
2608
  return await Bun.write(to, Bun.file(from), { createPath: false })
2261
2609
  }
2262
2610
  }
2611
+ const landed: string[] = []
2612
+ let landing = false
2613
+ // Every file here is written, so each is dated now (see `dated`).
2614
+ const now = Date.now()
2615
+ const nowDate = new Date(now)
2263
2616
  try {
2264
2617
  const dirs = new Set<string>()
2265
2618
  for (const r of rows) {
2266
- dirs.add(path.join(projectDir, r.path.endsWith('/') ? r.path : path.dirname(r.path)))
2619
+ dirs.add(target(r.path.endsWith('/') ? r.path.slice(0, -1) : path.posix.dirname(r.path)))
2267
2620
  }
2268
2621
  for (const d of dirs) await mkdir(d, { recursive: true })
2269
- await Promise.all(
2622
+ // Every copy settles before a failure is handled: one still in flight
2623
+ // would write its file again after the cleanup removed it.
2624
+ const copied = await Promise.allSettled(
2270
2625
  files.map(async (r) => {
2271
- const to = path.join(projectDir, r.path)
2626
+ const to = target(r.path)
2272
2627
  const n = await copy(to, path.join(tree, treeName(r.path)))
2273
2628
  if (n !== r.size)
2274
2629
  throw new CorruptArtifactError(hash, `${r.path} holds ${n} bytes, recorded ${r.size}`)
2275
2630
  const mode = restoredMode(r.mode)
2276
2631
  if (mode !== plain) await chmod(to, mode)
2277
- const at = new Date(r.mtimeMs)
2278
- await utimes(to, at, at)
2632
+ await utimes(to, nowDate, nowDate)
2633
+ dated.set(`outputs/${r.path}`, now)
2279
2634
  }),
2280
2635
  )
2636
+ const failed = copied.find((c) => c.status === 'rejected')
2637
+ if (failed !== undefined) throw failed.reason
2281
2638
  if (swapped()) throw new ArtifactVanishedError(hash)
2639
+ landing = true
2640
+ for (const rel of roots) {
2641
+ await rename(temps.get(rel)!, path.join(projectDir, rel))
2642
+ landed.push(rel)
2643
+ }
2282
2644
  } catch (err) {
2645
+ await Promise.all([...temps.values()].map((t) => rm(t, { recursive: true, force: true })))
2646
+ // Something made at a missing directory's place since the walk: what
2647
+ // landed holds the entry's bytes, and the extractor restores the rest.
2648
+ const code = landing ? (err as NodeJS.ErrnoException).code : undefined
2649
+ if (code === 'EEXIST' || code === 'ENOTEMPTY' || code === 'EISDIR' || code === 'ENOTDIR')
2650
+ return false
2283
2651
  await Promise.all([
2284
- ...roots.map((d) => rm(path.join(projectDir, d), { recursive: true, force: true })),
2285
- ...placed.map((f) => rm(path.join(projectDir, f), { force: true })),
2652
+ ...landed.map((rel) => rm(path.join(projectDir, rel), { recursive: true, force: true })),
2653
+ ...placed.map((rel) => rm(path.join(projectDir, rel), { force: true })),
2286
2654
  ])
2287
2655
  if (!(err instanceof ArtifactVanishedError) && swapped())
2288
2656
  throw new ArtifactVanishedError(hash)
@@ -2300,28 +2668,45 @@ export class Cache implements CacheLayer {
2300
2668
  }
2301
2669
 
2302
2670
  private stdoutText(hash: string): string {
2303
- const row = this.db.query('SELECT stdout FROM entry_stdout WHERE hash = ?').get(hash) as {
2304
- stdout: string | Uint8Array
2305
- } | null
2306
- if (row === null) return ''
2307
- return typeof row.stdout === 'string' ? row.stdout : utf8Decode.decode(row.stdout)
2671
+ const row = this.selectStdout.get(hash) as { stdout: Uint8Array } | null
2672
+ return storedLog(row?.stdout ?? null)
2308
2673
  }
2309
2674
 
2310
- /** Remove one entry: its artifact first, then its rows (as `prune` orders them). */
2311
- private async dropEntry(hash: string): Promise<void> {
2312
- await rm(this.tarPath(hash), { force: true })
2313
- await rm(this.treePath(hash), { recursive: true, force: true })
2675
+ /**
2676
+ * Remove one entry while its rows are still `rows`: false, touching
2677
+ * nothing, once another process re-saved the key. Checked and dropped
2678
+ * under the write lock a save renames its artifact in under, so no save
2679
+ * lands between; the artifact is moved aside inside, unlinked after.
2680
+ */
2681
+ private async dropUnchanged(hash: string, rows: readonly OutputFileRow[]): Promise<boolean> {
2682
+ const aside: string[] = []
2314
2683
  const drop = this.db.transaction(() => {
2684
+ if (this.entryChanged(hash, rows)) return false
2685
+ for (const [from, to] of [
2686
+ [this.tarPath(hash), this.tempPath(hash)],
2687
+ [this.treePath(hash), this.tempTreePath(hash)],
2688
+ ] as const) {
2689
+ try {
2690
+ renameSync(from, to)
2691
+ aside.push(to)
2692
+ } catch (err) {
2693
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
2694
+ }
2695
+ }
2315
2696
  this.deleteBlob.run(hash)
2316
2697
  this.deleteEntryRow.run(hash)
2317
2698
  this.outputs.forget([hash])
2699
+ return true
2318
2700
  })
2701
+ let dropped: boolean
2319
2702
  try {
2320
- drop()
2703
+ dropped = drop.immediate()
2321
2704
  } catch (err) {
2322
2705
  if (!isIndexFull(err)) throw err
2323
- drop()
2706
+ dropped = drop.immediate()
2324
2707
  }
2708
+ for (const a of aside) await rm(a, { recursive: true, force: true })
2709
+ return dropped
2325
2710
  }
2326
2711
 
2327
2712
  private async restoreOutputsOnce(
@@ -2329,6 +2714,8 @@ export class Cache implements CacheLayer {
2329
2714
  projectDir: string,
2330
2715
  workspaceRoot: string | undefined,
2331
2716
  outputs: DeclaredOutputs | undefined,
2717
+ used: { rows: readonly OutputFileRow[] },
2718
+ fresh: boolean,
2332
2719
  ): Promise<void> {
2333
2720
  // In-process extraction — no fork+exec on the hot path
2334
2721
  // (~5-10ms reclaimed per hit vs the prior subprocess `tar -xf`).
@@ -2372,24 +2759,56 @@ export class Cache implements CacheLayer {
2372
2759
  // in one commit, and two reads could pair its rows with the old bytes.
2373
2760
  // The probe's held rows (X-162) serve only while the blob carries the
2374
2761
  // `at` the probe saw: a re-save or a renewal since moves it, and then
2375
- // the rows are read again in the blob's snapshot.
2762
+ // the rows are read again in the blob's snapshot. A file artifact's
2763
+ // re-save is caught after, by its rows (`restoreOutputs`).
2376
2764
  let rows: readonly OutputFileRow[] = []
2377
2765
  let inline: Uint8Array | undefined
2378
- this.guard(() =>
2379
- this.db.transaction(() => {
2380
- const blob = this.selectBlob.get(hash) as { at: number; bytes: Uint8Array } | null
2381
- inline = blob?.bytes
2382
- rows =
2383
- blob === null || blob.at === this.heldInlineAt.get(hash)
2384
- ? this.outputs.rowsOf(hash)
2385
- : (this.outputs.loadOutputFilesBatch([hash]).get(hash) ?? [])
2386
- })(),
2387
- )
2766
+ // With the probe's rows held, no transaction: a file artifact's tree,
2767
+ // standing now, is the live one, read with no statement (an inline save
2768
+ // moves the tree aside in the commit that adds its blob, and one moved
2769
+ // aside mid-copy is the race `copyTree` catches); an inline one is the
2770
+ // blob read alone, its own snapshot. The blob read and its transaction
2771
+ // cost a 1,600-restore run ~70 ms of its main thread (2026-11-07).
2772
+ const held = fresh ? undefined : this.outputs.heldRowsOf(hash)
2773
+ const heldAt = this.heldInlineAt.get(hash)
2774
+ let treeIno: number | undefined
2775
+ let treeAsked = false
2776
+ if (held !== undefined && heldAt === undefined) {
2777
+ treeIno = lstatSync(this.treePath(hash), { throwIfNoEntry: false })?.ino
2778
+ treeAsked = true
2779
+ if (treeIno !== undefined) rows = held
2780
+ } else if (held !== undefined) {
2781
+ const blob = this.guard(() => this.selectBlob.get(hash)) as {
2782
+ at: number
2783
+ bytes: Uint8Array
2784
+ } | null
2785
+ if (blob?.at === heldAt) {
2786
+ inline = blob!.bytes
2787
+ rows = held
2788
+ }
2789
+ }
2790
+ if (treeIno === undefined && inline === undefined) {
2791
+ this.guard(() =>
2792
+ this.db.transaction(() => {
2793
+ const blob = this.selectBlob.get(hash) as { at: number; bytes: Uint8Array } | null
2794
+ inline = blob?.bytes
2795
+ rows =
2796
+ !fresh && (blob === null || blob.at === this.heldInlineAt.get(hash))
2797
+ ? this.outputs.rowsOf(hash)
2798
+ : (this.outputs.loadOutputFilesBatch([hash]).get(hash) ?? [])
2799
+ })(),
2800
+ )
2801
+ // A tree asked of above and absent: the artifact is the blob or the
2802
+ // tar, and a tree saved since is a miss at worst (the tar is gone).
2803
+ if (inline === undefined && !treeAsked)
2804
+ treeIno = lstatSync(this.treePath(hash), { throwIfNoEntry: false })?.ino
2805
+ }
2806
+ used.rows = rows
2388
2807
  const expected = rows
2389
2808
  .filter((r) => workspaceRoot !== undefined || !r.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
2390
2809
  .map((r) => (r.path.startsWith(WORKSPACE_OUTPUT_PREFIX) ? r.path : `outputs/${r.path}`))
2391
2810
  endRows()
2392
- const moded = absentModedDirs(rows, projectDir, workspaceRoot)
2811
+ const moded = modedDirs(rows, projectDir, workspaceRoot)
2393
2812
  const verify = (provided: ReadonlySet<string>): void => {
2394
2813
  const missing = expected.filter((name) => !provided.has(name))
2395
2814
  if (missing.length > 0) {
@@ -2409,8 +2828,8 @@ export class Cache implements CacheLayer {
2409
2828
  // when every artifact is a one-file `dist/` (measured: 390 vs 355 ms
2410
2829
  // per 1 000). The local artifact was validated at ingest, so a missing
2411
2830
  // declared size is allowed; the output ceiling applies to both.
2412
- const tree =
2413
- inline === undefined && existsSync(this.treePath(hash)) ? this.treePath(hash) : undefined
2831
+ const tree = treeIno === undefined ? undefined : this.treePath(hash)
2832
+ const written = new Map<string, number>()
2414
2833
  const endExtract = span('restore: extract')
2415
2834
  try {
2416
2835
  // Every step here is an async filesystem call on purpose: a
@@ -2420,11 +2839,38 @@ export class Cache implements CacheLayer {
2420
2839
  // the other three (1,000-project restore row 1.1–1.2 s → 1.5 s).
2421
2840
  const accept = outputs === undefined ? undefined : declaredName(outputs)
2422
2841
  if (tree !== undefined) {
2423
- await this.restoreTree(hash, tree, rows, projectDir, workspaceRoot, verify, accept)
2842
+ await this.restoreTree(
2843
+ hash,
2844
+ tree,
2845
+ treeIno!,
2846
+ rows,
2847
+ projectDir,
2848
+ workspaceRoot,
2849
+ verify,
2850
+ accept,
2851
+ written,
2852
+ )
2424
2853
  } else {
2425
2854
  const tar = await decodedTar(inline ?? Bun.file(src), hash, this.artifactCeiling)
2426
- await extractArtifactStream(tar, projectDir, workspaceRoot, verify, accept, workspaceRoot)
2855
+ await extractArtifactStream(
2856
+ tar,
2857
+ projectDir,
2858
+ workspaceRoot,
2859
+ verify,
2860
+ accept,
2861
+ workspaceRoot,
2862
+ undefined,
2863
+ written,
2864
+ )
2427
2865
  }
2866
+ // Keyed by row path: a project output's row drops the `outputs/`.
2867
+ const byRow = new Map<string, number>()
2868
+ for (const [name, t] of written)
2869
+ byRow.set(
2870
+ name.startsWith(WORKSPACE_OUTPUT_PREFIX) ? name : name.slice('outputs/'.length),
2871
+ t,
2872
+ )
2873
+ this.datedMtimes.set(hash, byRow)
2428
2874
  for (const [dir, mode] of moded) await chmod(dir, restoredMode(mode))
2429
2875
  } catch (err) {
2430
2876
  // A UserError here is the extractor naming the tree's fault (an
@@ -2463,7 +2909,7 @@ export class Cache implements CacheLayer {
2463
2909
  ? 'a directory stands where the entry holds a file'
2464
2910
  : 'a file stands where the entry needs a directory'
2465
2911
  throw new UserError(
2466
- `restore of ${hash} into ${projectDir} was blocked by what is on disk (${msg.startsWith(code) ? msg : `${code}: ${msg}`}): ${what}. ` +
2912
+ `restore of ${hash} into ${projectDir} was blocked by what is on disk (${codeFirst(code, msg)}): ${what}. ` +
2467
2913
  `A restore replaces what the output globs select, not this — remove it and re-run.`,
2468
2914
  )
2469
2915
  }
@@ -2489,7 +2935,7 @@ export class Cache implements CacheLayer {
2489
2935
  (err.path === src || (tree !== undefined && inTree(err.path, tree)))
2490
2936
  ) {
2491
2937
  throw new UserError(
2492
- `restore of ${hash} could not read its artifact (${code}: ${err.message}). ` +
2938
+ `restore of ${hash} could not read its artifact (${codeFirst(code, err.message)}). ` +
2493
2939
  `Make the cache directory readable by this user, or point cacheDir / --cache-dir at one that is.`,
2494
2940
  )
2495
2941
  }
@@ -2498,7 +2944,7 @@ export class Cache implements CacheLayer {
2498
2944
  ? 'Free space on that disk and re-run.'
2499
2945
  : 'Make the declared output paths writable by this user, or stop declaring them as outputs.'
2500
2946
  throw new UserError(
2501
- `restore of ${hash} into ${projectDir} could not write its outputs (${code}: ${err.message}). ${remedy}`,
2947
+ `restore of ${hash} into ${projectDir} could not write its outputs (${codeFirst(code, err.message)}). ${remedy}`,
2502
2948
  )
2503
2949
  }
2504
2950
  // The process is out of descriptors: the artifact is fine, and
@@ -2513,14 +2959,20 @@ export class Cache implements CacheLayer {
2513
2959
  if (code === 'ENOENT' && (err as NodeJS.ErrnoException).path === src) {
2514
2960
  throw new ArtifactVanishedError(hash)
2515
2961
  }
2516
- // So is a tree file: a prune or another process's re-save moved the
2517
- // tree aside mid-copy.
2518
- if (
2519
- code === 'ENOENT' &&
2520
- tree !== undefined &&
2521
- inTree((err as NodeJS.ErrnoException).path, tree)
2522
- ) {
2523
- throw new ArtifactVanishedError(hash)
2962
+ // So is a tree a prune or another process's re-save moved aside
2963
+ // mid-copy. The tree the restore found, still in place, lacking a
2964
+ // file is damage at rest (a hand-deleted file): called vanished "(a
2965
+ // vx cache clean)" that never ran, and kept, so the demoted task's
2966
+ // own probe hit it again and said so twice.
2967
+ const missing = (err as NodeJS.ErrnoException).path
2968
+ if (code === 'ENOENT' && tree !== undefined && inTree(missing, tree)) {
2969
+ if (lstatSync(tree, { throwIfNoEntry: false })?.ino !== treeIno)
2970
+ throw new ArtifactVanishedError(hash)
2971
+ const rel = path.relative(tree, missing!).split(path.sep).join('/')
2972
+ throw new CorruptArtifactError(
2973
+ hash,
2974
+ `${rel.startsWith('outputs/') ? rel.slice('outputs/'.length) : rel} is missing from its stored tree`,
2975
+ )
2524
2976
  }
2525
2977
  // A staged file the restore itself just wrote is gone before its
2526
2978
  // commit: another process cleaned the same outputs under us (two runs
@@ -2562,7 +3014,8 @@ export class Cache implements CacheLayer {
2562
3014
  * returns says so; an empty one has nothing to serve and refuses.
2563
3015
  */
2564
3016
  readOnlyNotice(): string | null {
2565
- if (this.writeBlocked === null) return null
3017
+ // A reading handle over no index: nothing was refused it.
3018
+ if (this.writeBlocked === null || this.writeBlocked === NO_INDEX) return null
2566
3019
  if (this.empty) this.assertWritable()
2567
3020
  return (
2568
3021
  `cache directory ${this.cacheDir} is not writable (${this.writeBlocked}) — serving hits ` +
@@ -2611,6 +3064,7 @@ export class Cache implements CacheLayer {
2611
3064
  args.hash,
2612
3065
  rows.reduce((n, r) => n + r.size, 0),
2613
3066
  )
3067
+ this.keptHashes.add(args.hash)
2614
3068
  return
2615
3069
  }
2616
3070
  // The cache directory is the constructor's (`mkdirSync`, with a
@@ -3160,7 +3614,10 @@ export class Cache implements CacheLayer {
3160
3614
  Bun.hash.crc32(bytes),
3161
3615
  ])
3162
3616
  }
3163
- const stdoutText = scanned.stdout
3617
+ // Compressed before the write lock is taken: logs are most of what the
3618
+ // index held as text (v52).
3619
+ const stdoutBlob =
3620
+ scanned.stdout === '' ? null : await zstdCompress(utf8.encode(scanned.stdout))
3164
3621
 
3165
3622
  const [project, task] = splitTaskId(meta.taskId)
3166
3623
  const now = Date.now()
@@ -3257,15 +3714,10 @@ export class Cache implements CacheLayer {
3257
3714
  // packed, an ingest what the producing machine packed.
3258
3715
  scanned.exec?.cpuMs ?? null,
3259
3716
  scanned.exec?.peakRssBytes ?? null,
3717
+ this.runId ?? null,
3260
3718
  )
3261
- if (stdoutText === '') deleteStdout.run(hash)
3262
- // bun:sqlite drops a leading U+FEFF from a bound string; its UTF-8
3263
- // bytes, cast to TEXT, keep it.
3264
- else
3265
- upsertStdout.run(
3266
- hash,
3267
- stdoutText.startsWith('\uFEFF') ? utf8.encode(stdoutText) : stdoutText,
3268
- )
3719
+ if (stdoutBlob === null) deleteStdout.run(hash)
3720
+ else upsertStdout.run(hash, stdoutBlob)
3269
3721
  outputBytes = outputs.replaceFileRows(hash, outputFileRows)
3270
3722
  // INSERT OR IGNORE: identical inputs derive this same hash, so a
3271
3723
  // re-save's row is identical — keep the first, skip the rest.
@@ -3338,9 +3790,36 @@ export class Cache implements CacheLayer {
3338
3790
  for (let i = 0; i < batch.length; i++) batch[i]!.settle(failed.get(i) ?? err)
3339
3791
  return
3340
3792
  }
3793
+ if (this.checkpoints === 'armed') this.startCheckpointer()
3341
3794
  for (let i = 0; i < batch.length; i++) batch[i]!.settle(failed.get(i))
3342
3795
  }
3343
3796
 
3797
+ /**
3798
+ * A run's saves from now on leave the WAL's checkpoints to a thread of
3799
+ * their own (checkpointer.ts), started by the first save's commit, so a
3800
+ * run that saves nothing starts none. `stopCheckpoints` ends it.
3801
+ */
3802
+ checkpointOffThread(): void {
3803
+ if (this.checkpoints === undefined && historyFiles(this) !== undefined)
3804
+ this.checkpoints = 'armed'
3805
+ }
3806
+
3807
+ private startCheckpointer(): void {
3808
+ const files = historyFiles(this)!
3809
+ this.db.exec('PRAGMA wal_autocheckpoint = 0')
3810
+ this.checkpoints = new WalCheckpointer(files, () => {
3811
+ // No thread: the run's connection checkpoints as it did.
3812
+ this.db.exec(`PRAGMA wal_autocheckpoint = ${WAL_AUTOCHECKPOINT}`)
3813
+ })
3814
+ }
3815
+
3816
+ /** Ends the checkpoint thread before `close`, so this connection's close is the last. */
3817
+ async stopCheckpoints(): Promise<void> {
3818
+ const c = this.checkpoints
3819
+ this.checkpoints = undefined
3820
+ if (c instanceof WalCheckpointer) await c.stop()
3821
+ }
3822
+
3344
3823
  private tempTreePath(hash: string): string {
3345
3824
  return `${this.treePath(hash)}.tmp-${process.pid}-${process.hrtime.bigint()}-${Math.random().toString(36).slice(2, 10)}`
3346
3825
  }
@@ -3392,9 +3871,12 @@ export class Cache implements CacheLayer {
3392
3871
  if (this.writeBlocked !== null) return
3393
3872
  return this.guard(() => this.history.recordRun(run))
3394
3873
  }
3395
- recordRuns(runs: readonly RunRecord[]): void {
3874
+ recordRuns(
3875
+ runs: readonly RunRecord[],
3876
+ head?: Pick<InvocationRecord, 'branch' | 'commitSha'>,
3877
+ ): void {
3396
3878
  if (this.writeBlocked !== null) return
3397
- return this.guard(() => this.history.recordRuns(runs))
3879
+ return this.guard(() => this.history.recordRuns(runs, head))
3398
3880
  }
3399
3881
  recordRunBundle(bundle: { runs: readonly RunRecord[]; invocation: InvocationRecord }): void {
3400
3882
  if (this.writeBlocked !== null) return
@@ -3424,7 +3906,7 @@ export class Cache implements CacheLayer {
3424
3906
  const since = Date.now() - 24 * 60 * 60 * 1000
3425
3907
  const runs = this.db
3426
3908
  .prepare(
3427
- `SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN status IN ('cache-hit', 'cache-hit-remote') THEN 1 ELSE 0 END), 0) AS hits, COALESCE(SUM(restored = 1), 0) AS restored FROM runs WHERE started_at >= ? AND ${EXECUTED_RUNS_SQL}${
3909
+ `SELECT COUNT(*) AS total, COALESCE(SUM(CASE WHEN status IN ('cache-hit', 'cache-hit-remote') THEN 1 ELSE 0 END), 0) AS hits, COALESCE(SUM(restored = 1), 0) AS restored FROM main.runs WHERE started_at >= ? AND ${EXECUTED_RUNS_SQL}${
3428
3910
  scoped ? ' AND project = ?' : ''
3429
3911
  }`,
3430
3912
  )
@@ -3542,7 +4024,12 @@ export class Cache implements CacheLayer {
3542
4024
  freelist_count: number
3543
4025
  }
3544
4026
  if (free === 0) return
3545
- this.db.exec(`VACUUM ${schema}`)
4027
+ this.db.exec('DROP VIEW IF EXISTS temp.runs')
4028
+ try {
4029
+ this.db.exec(`VACUUM ${schema}`)
4030
+ } finally {
4031
+ this.db.exec(RUNS_VIEW)
4032
+ }
3546
4033
  this.db.exec(`PRAGMA ${schema}.wal_checkpoint(TRUNCATE)`)
3547
4034
  } catch {
3548
4035
  // Another process holds the database, or the disk has no room for the copy.
@@ -3694,10 +4181,16 @@ export class Cache implements CacheLayer {
3694
4181
  try {
3695
4182
  this.flushIndex()
3696
4183
  clearTimeout(this.heldTimer)
3697
- const held = this.heldBlobSizes ?? (this.heldTimer !== undefined ? this.heldRest() : [])
4184
+ const held =
4185
+ this.heldBlobSizes !== undefined
4186
+ ? new Map(this.heldBlobSizes)
4187
+ : this.storing !== undefined
4188
+ ? this.heldSizes(this.storing, this.stored, this.storing.length)
4189
+ : undefined
3698
4190
  this.heldBlobSizes = undefined
4191
+ this.storing = undefined
3699
4192
  this.heldTimer = undefined
3700
- if (held.length > 0) this.files.rememberBlobSizes(new Map(held))
4193
+ if (held !== undefined && held.size > 0) this.files.rememberBlobSizes(held)
3701
4194
  this.flushAccessed()
3702
4195
  this.outputs.flushOutputDirs()
3703
4196
  this.folds.flush()
@@ -3710,6 +4203,8 @@ export class Cache implements CacheLayer {
3710
4203
  // `rm -rf .vx/cache`): macOS answers SQLITE_IOERR_VNODE on a
3711
4204
  // write to an unlinked file where Linux happily writes on.
3712
4205
  }
4206
+ if (this.checkpoints instanceof WalCheckpointer) this.checkpoints.terminate()
4207
+ this.checkpoints = undefined
3713
4208
  closeDb(this.db)
3714
4209
  }
3715
4210
 
@@ -3773,6 +4268,131 @@ export function closeDb(db: Database): void {
3773
4268
  }
3774
4269
  }
3775
4270
 
4271
+ /**
4272
+ * Wait until no other connection has the shared store in `storeDir` open,
4273
+ * then hold it alone until the release. Every checkout of a repository
4274
+ * shares its store and each has a run lock of its own, so a clean in one
4275
+ * deleted store.db under a run in another. A run holds the store open from
4276
+ * its run lock to its end (never while it waits for that lock, which a
4277
+ * clean holds while it waits here), and in WAL mode every open connection keeps a
4278
+ * shared lock on the file: SQLite grants an exclusive one only to a sole
4279
+ * holder, and a holder's lock dies with its process. The run path pays
4280
+ * nothing. Holds nothing where the store has no readable file.
4281
+ */
4282
+ export async function holdStore(
4283
+ storeDir: string,
4284
+ log: (line: string) => void,
4285
+ ): Promise<() => void> {
4286
+ const file = path.join(storeDir, 'store.db')
4287
+ if (!existsSync(file)) return () => {}
4288
+ let db: Database
4289
+ try {
4290
+ db = new Database(file)
4291
+ db.exec('PRAGMA busy_timeout = 0')
4292
+ db.exec('PRAGMA locking_mode = EXCLUSIVE')
4293
+ } catch {
4294
+ return () => {}
4295
+ }
4296
+ const started = performance.now()
4297
+ let said = false
4298
+ for (;;) {
4299
+ try {
4300
+ db.exec('BEGIN EXCLUSIVE')
4301
+ break
4302
+ } catch (err) {
4303
+ if ((err as { code?: string }).code !== 'SQLITE_BUSY') {
4304
+ // Not a store SQLite reads: nothing opens it to wait for.
4305
+ closeDb(db)
4306
+ return () => {}
4307
+ }
4308
+ }
4309
+ if (!said && performance.now() - started >= 1_000) {
4310
+ said = true
4311
+ log(`[vx] waiting for another vx process using the cache store ${storeDir} to finish…`)
4312
+ }
4313
+ await Bun.sleep(50)
4314
+ }
4315
+ return () => closeDb(db)
4316
+ }
4317
+
4318
+ /** The file in a cache directory with no store that its runs hold shared and a clean alone (`holdCacheDir`). */
4319
+ const DIR_LOCK = 'cache.lock'
4320
+
4321
+ /**
4322
+ * Hold a cache directory with no store — `--cache-dir`, `cache.dir`,
4323
+ * `VX_CACHE_DIR`, which workspaces may share — `shared` for a run, from
4324
+ * before its cache opens to its close, or `exclusive` for `vx cache clean`,
4325
+ * waiting for the other side. Each workspace's run lock is its own, and
4326
+ * `holdStore` waits only on a store.db, so a clean in one workspace deleted
4327
+ * the directory under a run of another opening it (ENOENT on its
4328
+ * `.gitignore`, SQLite's disk I/O error). The lock is SQLite's on
4329
+ * `cache.lock` in the directory: shared and exclusive, any spelling of the
4330
+ * directory meets it, and a holder's lock dies with its process. A clean
4331
+ * deletes the file with the rest while it holds it, so a taker checks the
4332
+ * name still is the file it locked. A directory with no index yet, or one
4333
+ * this user cannot write, holds nothing: there is nothing a clean deletes.
4334
+ * A default directory beside its store pays nothing.
4335
+ */
4336
+ export async function holdCacheDir(
4337
+ dir: string,
4338
+ mode: 'shared' | 'exclusive',
4339
+ log: (line: string) => void,
4340
+ signal?: AbortSignal,
4341
+ ): Promise<() => void> {
4342
+ const file = path.join(dir, DIR_LOCK)
4343
+ const started = performance.now()
4344
+ let said = false
4345
+ for (;;) {
4346
+ if (signal?.aborted === true) return () => {}
4347
+ let db: Database
4348
+ let before: Stats
4349
+ try {
4350
+ let seen = statSync(file, { throwIfNoEntry: false })
4351
+ if (seen === undefined) {
4352
+ // A directory with no index has nothing a clean deletes, and may be
4353
+ // one a first index is refused in (a project's own): nothing is
4354
+ // written there.
4355
+ if (!existsSync(path.join(dir, 'cache.db'))) return () => {}
4356
+ // Made before it is stat'ed: a file Database creates is not the one
4357
+ // a stat before it saw.
4358
+ writeFileSync(file, '', { flag: 'a' })
4359
+ seen = statSync(file)
4360
+ }
4361
+ before = seen
4362
+ db = new Database(file)
4363
+ db.exec('PRAGMA busy_timeout = 0')
4364
+ } catch {
4365
+ return () => {}
4366
+ }
4367
+ try {
4368
+ if (mode === 'exclusive') db.exec('BEGIN EXCLUSIVE')
4369
+ else {
4370
+ db.exec('BEGIN')
4371
+ db.query('SELECT count(*) FROM sqlite_master').get()
4372
+ }
4373
+ const now = statSync(file, { throwIfNoEntry: false })
4374
+ if (now !== undefined && now.ino === before.ino && now.dev === before.dev) {
4375
+ return () => closeDb(db)
4376
+ }
4377
+ // A clean deleted it while this waited: take the one there now.
4378
+ closeDb(db)
4379
+ continue
4380
+ } catch (err) {
4381
+ closeDb(db)
4382
+ if ((err as { code?: string }).code !== 'SQLITE_BUSY') return () => {}
4383
+ }
4384
+ if (!said && performance.now() - started >= 1_000) {
4385
+ said = true
4386
+ log(
4387
+ mode === 'exclusive'
4388
+ ? `[vx] waiting for another vx process using the cache directory ${dir} to finish…`
4389
+ : `[vx] waiting for a vx cache clean of ${dir} to finish…`,
4390
+ )
4391
+ }
4392
+ await Bun.sleep(50)
4393
+ }
4394
+ }
4395
+
3776
4396
  /**
3777
4397
  * Refuse an artifact whose file names no restore can materialise as one
3778
4398
  * tree, or (given the task's declared outputs) that names a file outside
@@ -3819,12 +4439,14 @@ function assertArtifactNames(
3819
4439
 
3820
4440
  /**
3821
4441
  * The directories with a mode to restore (`DIR_MODE` rows) that are absent
3822
- * before the restore, deepest first: the restore makes them with mkdir's
3823
- * default, and each is then given the mode the miss left, minus the umask
3824
- * as a file's is. One that stands is the tree's, left as it is. A mode
3825
- * only ever narrows mkdir's default, whatever an artifact says.
4442
+ * before the restore, or stand with another mode, deepest first: the
4443
+ * restore makes a missing one with mkdir's default, and each is then given
4444
+ * the mode the miss left, minus the umask as a file's is. A standing one
4445
+ * kept its own mode once, so a branch switch A → B → A left B's 0755 on
4446
+ * A's `chmod 700 dist/keys`. A symlink is never followed. A mode only ever
4447
+ * narrows mkdir's default, whatever an artifact says.
3826
4448
  */
3827
- function absentModedDirs(
4449
+ function modedDirs(
3828
4450
  rows: readonly OutputFileRow[],
3829
4451
  projectDir: string,
3830
4452
  workspaceRoot: string | undefined,
@@ -3838,7 +4460,9 @@ function absentModedDirs(
3838
4460
  const dir = ws
3839
4461
  ? path.join(workspaceRoot!, rel.slice(WORKSPACE_OUTPUT_PREFIX.length))
3840
4462
  : path.join(projectDir, rel)
3841
- if (lstatSync(dir, { throwIfNoEntry: false }) === undefined) out.push([dir, r.mode & 0o777])
4463
+ const st = lstatSync(dir, { throwIfNoEntry: false })
4464
+ if (st === undefined || (st.isDirectory() && !modeMatches(st.mode, r.mode)))
4465
+ out.push([dir, r.mode & 0o777])
3842
4466
  }
3843
4467
  return out.sort((a, b) => b[0].length - a[0].length)
3844
4468
  }
@@ -3848,12 +4472,26 @@ function treeName(rowPath: string): string {
3848
4472
  return rowPath.startsWith(WORKSPACE_OUTPUT_PREFIX) ? rowPath : `outputs/${rowPath}`
3849
4473
  }
3850
4474
 
4475
+ /** `code: message`, where Bun's message does not already open with the code. */
4476
+ function codeFirst(code: string | undefined, message: string): string {
4477
+ return code === undefined || message.startsWith(code) ? message : `${code}: ${message}`
4478
+ }
4479
+
3851
4480
  function inTree(p: string | undefined, tree: string): boolean {
3852
4481
  return p !== undefined && (p === tree || p.startsWith(`${tree}/`))
3853
4482
  }
3854
4483
 
3855
4484
  const utf8Decode = new TextDecoder('utf-8', { ignoreBOM: true })
3856
4485
 
4486
+ /**
4487
+ * An `entry_stdout` row's log (v52): a zstd frame of its UTF-8, which keeps
4488
+ * a leading U+FEFF a bound string loses. Null, no row, reads as ''. The
4489
+ * index is vx's own, so the frame is trusted, as a tree's files are.
4490
+ */
4491
+ export function storedLog(blob: Uint8Array | null): string {
4492
+ return blob === null ? '' : utf8Decode.decode(Bun.zstdDecompressSync(blob))
4493
+ }
4494
+
3857
4495
  /**
3858
4496
  * Whether an artifact entry names one of the task's declared outputs, as
3859
4497
  * the save could have packed it (`restorableOutput`: a `!` take-back, vx's