@vzn/vx 0.0.646 → 0.0.648

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 (184) hide show
  1. package/README.md +29 -22
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
@@ -1,9 +1,7 @@
1
1
  // The cache key, derived: `foldKey` is the whole of what `Cache.key` computes,
2
2
  // with the two things it read off the class (the file hasher and the
3
3
  // workspace-relative path) passed in. Nothing here touches a store, so the
4
- // fold runs wherever xxh3 does: the CLI, a remote layer, and the site's
5
- // playground, which bundles this file to derive the keys the CLI would
6
- // (design/playground-spike-2026-09.md, P1; item 691).
4
+ // fold runs wherever xxh3 does: the CLI and a remote layer (item 691).
7
5
 
8
6
  import { xxh3, xxh3hex } from '../util/index.js'
9
7
  import type { CacheKeyInput } from './layer.js'
@@ -113,14 +111,21 @@ import type { CacheKeyInput } from './layer.js'
113
111
  // v44: the sidecar lists the empty directories under the outputs; a v43
114
112
  // artifact holds none and a hit left them out (Growth's parity, 2026-10-16).
115
113
  // v45: the sidecar lists the symlinks under the outputs and a hit makes
116
- // them; a v44 artifact holds a link as a copy of its target (M, 2026-10-16).
114
+ // them; a v44 artifact holds a link as a copy of its target (2026-10-16).
117
115
  // v46: vx's names are reserved only at the project root; a v45 artifact
118
116
  // left out a build's own `dist/.git/**`, `dist/.vx/**` and nested
119
117
  // `vx.lock.json` (Growth's parity, 2026-10-17).
120
118
  // v47: the local store keeps plain files (`cache.store`).
121
- // v48: a task glob is standard again (M, 2026-10-18): `app/[id]/page.js` is
119
+ // v48: a task glob is standard again (2026-10-18): `app/[id]/page.js` is
122
120
  // a class, and a v47 entry under its unchanged key holds the literal route.
123
- export const CACHE_VERSION = 'vx-cache-v48'
121
+ // v49: each brace alternative is read alone (2026-10-28): a v48 entry for
122
+ // `outputs: ['{dist,lib}']` under its unchanged key holds nothing.
123
+ // v49, on another branch at once: the sidecar keeps the mode of an output
124
+ // directory that withholds a permission 0755 grants, and a hit makes it so;
125
+ // a v48 artifact keeps none, and a hit gave `chmod 700 dist/keys` back as 0755.
126
+ // v50: both v49 changes in one build (2026-10-29): a v49 entry was written
127
+ // by a vx that had only one of them.
128
+ export const CACHE_VERSION = 'vx-cache-v51'
124
129
 
125
130
  /**
126
131
  * The digest a key folds for an input gone between its enumeration and its
@@ -321,5 +326,9 @@ export function foldHead(
321
326
  for (const [n, v] of pluginParts) cap.push({ kind: 'plugin', name: n, hash: digestValue(v) })
322
327
  }
323
328
  }
329
+ if (input.platform !== undefined) {
330
+ h = xxh3(`platform:${input.platform}`, h)
331
+ if (cap) cap.push({ kind: 'platform', name: 'platform', hash: input.platform })
332
+ }
324
333
  return h
325
334
  }
@@ -89,6 +89,12 @@ export interface CacheKeyInput {
89
89
  * derives exactly the key it always did.
90
90
  */
91
91
  pluginParts?: ReadonlyArray<readonly [name: string, value: string]>
92
+ /**
93
+ * The platform a task that declares `exec.platforms` runs on: its
94
+ * outputs are that platform's. Folded only when set, so a task that
95
+ * declares none derives the key it always did.
96
+ */
97
+ platform?: string
92
98
  /**
93
99
  * When set, `key()` pushes each component (kind, name, hash) it folds
94
100
  * — at the same fold sites, in fold order. Pure SIDE-CHANNEL: it does
@@ -219,9 +225,13 @@ export interface RunRecord {
219
225
  placement?: string
220
226
  /** The task with no `inputs` its key folded, so it never read or saved (v46). */
221
227
  keylessBy?: string
228
+ /** The task's definition (`taskDefinition`): what its history is kept under (v48). */
229
+ def?: string
222
230
  /** That machine's cores and memory (v44). */
223
231
  placementCores?: number
224
232
  placementMemoryBytes?: number
233
+ /** The share of a core each runnable thread got there (`ExecuteResult.machine.cpuShare`, v49). */
234
+ cpuShare?: number
225
235
  }
226
236
 
227
237
  /**
@@ -317,7 +327,7 @@ export interface PruneOptions {
317
327
  * Pick the victims and count the orphans, delete nothing: the result
318
328
  * says what a real prune with the same policy would reap right now.
319
329
  */
320
- dryRun?: boolean
330
+ dry?: boolean
321
331
  }
322
332
 
323
333
  export interface PruneResult {
@@ -574,12 +584,28 @@ export interface CacheLayer {
574
584
  * batch omits it and the prefetch pass falls back to per-hash GETs.
575
585
  */
576
586
  remoteHasMany?(hashes: readonly string[]): Promise<Map<string, ArtifactInfo> | null>
587
+ /**
588
+ * `remoteHasMany`, with a remote that answers no batch query asked each
589
+ * key, `concurrency` at a time: a complete answer or `null`. For a run
590
+ * that plans on the answer (an executor's demand); others pull instead.
591
+ */
592
+ remoteHasEach?(
593
+ hashes: readonly string[],
594
+ concurrency: number,
595
+ ): Promise<Map<string, ArtifactInfo> | null>
577
596
  /**
578
597
  * The subset of `hashes` the local store already holds. The prefetch
579
598
  * pass probes the remote only for the rest, so a run the local cache
580
599
  * answers makes no remote call. Absent reads as "none held".
581
600
  */
582
601
  localHasMany?(hashes: readonly string[]): Promise<Set<string>>
602
+ /**
603
+ * The subset of `hashes` a remote said it holds, as the local index
604
+ * keeps it across runs and processes: trusted now, with no round-trip.
605
+ * A pull of one starts at once, waiting on no batch probe. Absent, a
606
+ * run learns its remote hits from `remoteHasMany` alone.
607
+ */
608
+ remoteKnown?(hashes: readonly string[]): Map<string, ArtifactInfo>
583
609
  /**
584
610
  * Record that the remote has NO artifact for each of `hashes` (from a
585
611
  * batch probe), so a later `get`/`prefetch` short-circuits to a miss with
@@ -593,12 +619,6 @@ export interface CacheLayer {
593
619
  * `hasRemote`.
594
620
  */
595
621
  probeBeforeUpload?(hashes: Promise<readonly string[]>): void
596
- /**
597
- * The run's batch probe starts when `started` settles: until then a pull
598
- * waits, so a lookup that lands first does not GET a key the probe is
599
- * about to call absent. Only meaningful with `hasRemote`.
600
- */
601
- probeFirst?(started: Promise<void>): void
602
622
  /**
603
623
  * Resolve once every background write-through upload settles. The
604
624
  * orchestrator awaits it before `close()` — an upload reading layer state
@@ -616,6 +636,22 @@ export interface CacheLayer {
616
636
  * A layer without it is read through `get`.
617
637
  */
618
638
  getSaved?(hash: string, ctx?: CacheGetContext): Promise<CacheEntry | null>
639
+ /**
640
+ * True once a remote the run reads holds entry `hash`, the local entry
641
+ * uploaded to those it writes when none does (`TaskArtifacts.share`). A
642
+ * layer without it shares nothing.
643
+ */
644
+ share?(hash: string): Promise<boolean>
645
+ /**
646
+ * The local entry uploaded over each written remote's copy, which another
647
+ * machine found corrupt (`TaskArtifacts.resend`). True when one took it.
648
+ */
649
+ resend?(hash: string): Promise<boolean>
650
+ /**
651
+ * Whether a remote this run writes took entry `hash` when `save` uploaded
652
+ * it, or already held it; undefined when this run sent it to none.
653
+ */
654
+ uploaded?(hash: string): Promise<boolean | undefined>
619
655
  /**
620
656
  * Optional batched `get` (same answers, fewer round trips). The
621
657
  * short-circuit probe uses it when a layer offers one; a layer without it
@@ -670,7 +706,7 @@ export interface CacheLayer {
670
706
  * out-of-band file edits or deletions and falls through to a real
671
707
  * restore.
672
708
  */
673
- isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): Promise<boolean>
709
+ isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): boolean
674
710
  /**
675
711
  * The directory-mtime short-circuit behind a warm hit (optional — a layer
676
712
  * without it keeps the output walk). `recordOutputDirs` snapshots every
@@ -699,7 +735,7 @@ export interface CacheLayer {
699
735
  */
700
736
  recordOutputStamps?(hash: string, projectDir: string, workspaceRoot: string): void
701
737
  loadOutputDirsBatch?(hashes: readonly string[]): Map<string, OutputDirRow[]>
702
- outputDirsCurrent?(projectDir: string, rows: readonly OutputDirRow[]): Promise<boolean>
738
+ outputDirsCurrent?(projectDir: string, rows: readonly OutputDirRow[]): boolean
703
739
  /**
704
740
  * Extract the artifact's `outputs/` entries into `projectDir` and —
705
741
  * when `workspaceRoot` is given — its `workspace-outputs/` entries
@@ -736,6 +772,8 @@ export interface CacheLayer {
736
772
  * format.
737
773
  */
738
774
  workspaceOutputFiles?: string[]
775
+ /** The empty directories under `workspaceOutputFiles`' globs (absolute). */
776
+ workspaceOutputDirs?: string[]
739
777
  workspaceRoot?: string
740
778
  /**
741
779
  * Cache-key components for this entry (the Tier-3 input
@@ -745,6 +783,8 @@ export interface CacheLayer {
745
783
  * Only computed on the miss/save path — never on a hit.
746
784
  */
747
785
  inputComponents?: readonly TaskInputRow[]
786
+ /** No remote held `hash` when the run asked: an upload asks none first. */
787
+ absent?: boolean
748
788
  }): Promise<void>
749
789
  /**
750
790
  * Adopt an artifact produced elsewhere — the remote-hit path. Streams
@@ -35,6 +35,7 @@ import type {
35
35
  SaveArgs,
36
36
  } from './cache.js'
37
37
  import type { OutputDirRow } from './cache.js'
38
+ import { CorruptArtifactError } from './layer.js'
38
39
  import { RemoteLane, type ResolvedRemote } from './remote-lane.js'
39
40
  import type { PluginSecrets } from '../util/index.js'
40
41
 
@@ -57,6 +58,7 @@ function endsOnStop(
57
58
  // which the flag turns into an error rather than a short body.
58
59
  void stopped.then(() => {
59
60
  stop = true
61
+ // quiet: stopping: a reader already done has nothing to cancel
60
62
  void reader.cancel().catch(() => undefined)
61
63
  })
62
64
  const stream = new ReadableStream<Uint8Array>({
@@ -80,6 +82,15 @@ const judged = (i: ArtifactInfo): i is { size: number; durationMs: number } =>
80
82
  /** How often a task waiting on its download weighs running instead. */
81
83
  const JUDGE_EVERY_MS = 25
82
84
 
85
+ /**
86
+ * The least a download is given against running its task. A stored
87
+ * duration is the command's alone, in whole ms (Turbo's wire sends 0 for
88
+ * a sub-ms task), and a budget under one weighing is none: a duration of
89
+ * 0 gave up every body not in by the first, 99% in or not, and ran the
90
+ * task beside the bytes already here.
91
+ */
92
+ const LEAST_BUDGET_MS = JUDGE_EVERY_MS
93
+
83
94
  interface Download {
84
95
  readonly start: number
85
96
  received: number
@@ -186,9 +197,16 @@ export interface RemoteCacheLayer {
186
197
  * artifact, so a plugin that streams it never holds it whole, and every
187
198
  * read of it sees the same bytes. An entry is immutable: a `put` of a
188
199
  * key the remote holds keeps the stored bytes. Core never asks `has`
189
- * first; a wire that would overwrite checks inside.
200
+ * first; a wire that would overwrite checks inside, unless told `missing`.
201
+ * `false`: nothing was sent and nothing failed (a layer that writes
202
+ * nothing this run, such as one with no token), so no upload is counted.
203
+ */
204
+ put(hash: string, body: Blob, meta: { durationMs: number }): Promise<void | false>
205
+ /**
206
+ * The run found `hash` in no remote, and a `put` of it follows: a wire
207
+ * that checks before it overwrites can skip that request.
190
208
  */
191
- put(hash: string, body: Blob, meta: { durationMs: number }): Promise<void>
209
+ missing?(hash: string): void
192
210
  }
193
211
 
194
212
  export class LayeredCache implements CacheLayer {
@@ -196,6 +214,8 @@ export class LayeredCache implements CacheLayer {
196
214
  readonly hasRemote = true
197
215
 
198
216
  private readonly lanes: readonly RemoteLane[]
217
+ /** Each key `save` uploaded: whether a remote took it (`uploaded`). */
218
+ private readonly sent = new Map<string, Promise<boolean>>()
199
219
 
200
220
  /**
201
221
  * In-flight remote pulls keyed by hash. `prefetch` and `get` both go
@@ -209,17 +229,6 @@ export class LayeredCache implements CacheLayer {
209
229
  */
210
230
  private readonly inflight = new Map<string, Promise<boolean>>()
211
231
 
212
- /**
213
- * Hashes a batch probe is asking about, each resolving whether a pull may
214
- * still find it (false: no remote holds it). A `get` that lands while the
215
- * probe is out waits for its answer instead of spending a GET on a key
216
- * the probe is about to call absent.
217
- */
218
- private readonly probing = new Map<string, Promise<boolean>>()
219
-
220
- /** Settles once the run's batch probe has started (`probeFirst`); a pull waits on it. */
221
- private probeStarted: Promise<void> | null = null
222
-
223
232
  /**
224
233
  * Hashes whose local artifact was materialized FROM a remote this run
225
234
  * (by `prefetch` or `get`'s read-through). A later `get` finds them as
@@ -267,6 +276,13 @@ export class LayeredCache implements CacheLayer {
267
276
  return this.local.localHasMany(hashes)
268
277
  }
269
278
 
279
+ remoteKnown(hashes: readonly string[]): Map<string, ArtifactInfo> {
280
+ if (this.readable.length === 0) return new Map()
281
+ const known = this.local.remoteHits.get(hashes)
282
+ for (const [h, info] of known) if (!this.infos.has(h)) this.infos.set(h, info)
283
+ return known
284
+ }
285
+
270
286
  /**
271
287
  * Batch existence probe over the readable remotes — the subset of
272
288
  * `hashes` some remote holds, one round-trip each, or `null` when any
@@ -280,27 +296,34 @@ export class LayeredCache implements CacheLayer {
280
296
  */
281
297
  async remoteHasMany(hashes: readonly string[]): Promise<Map<string, ArtifactInfo> | null> {
282
298
  if (this.readable.length === 0) return null
283
- const answer = this.probeMany(hashes).then((found) => {
284
- if (found !== null) for (const [h, info] of found) this.infos.set(h, info)
285
- return found
286
- })
287
- const waiting = hashes.filter((h) => !this.inflight.has(h) && !this.probing.has(h))
288
- for (const h of waiting) {
289
- this.probing.set(
290
- h,
291
- answer.then(
292
- (found) => {
293
- this.probing.delete(h)
294
- return found === null || found.has(h)
295
- },
296
- () => {
297
- this.probing.delete(h)
298
- return true
299
- },
300
- ),
301
- )
299
+ const found = await this.probeMany(hashes)
300
+ if (found !== null) {
301
+ for (const [h, info] of found) this.infos.set(h, info)
302
+ // The whole answer, kept for the next run and the next process.
303
+ this.local.remoteHits.record(hashes, found)
304
+ }
305
+ return found
306
+ }
307
+
308
+ /**
309
+ * Each readable remote asked key by key (`RemoteLane.hasEach`), a key
310
+ * its batch answer already covered not asked again: a complete answer,
311
+ * for a run that plans on it. Null when a probe failed or none may be read.
312
+ */
313
+ async remoteHasEach(
314
+ hashes: readonly string[],
315
+ concurrency: number,
316
+ ): Promise<Map<string, ArtifactInfo> | null> {
317
+ if (this.readable.length === 0) return null
318
+ const out = new Map<string, ArtifactInfo>()
319
+ for (const lane of this.readable) {
320
+ const found = await lane.hasEach(hashes, concurrency)
321
+ if (found === null) return null
322
+ for (const [h, info] of found) if (!out.has(h)) out.set(h, info)
302
323
  }
303
- return answer
324
+ for (const [h, info] of out) this.infos.set(h, info)
325
+ this.local.remoteHits.record(hashes, out)
326
+ return out
304
327
  }
305
328
 
306
329
  private async probeMany(hashes: readonly string[]): Promise<Map<string, ArtifactInfo> | null> {
@@ -327,17 +350,6 @@ export class LayeredCache implements CacheLayer {
327
350
  }
328
351
  }
329
352
 
330
- probeFirst(started: Promise<void>): void {
331
- const gate = started.then(
332
- () => {},
333
- () => {},
334
- )
335
- this.probeStarted = gate
336
- void gate.then(() => {
337
- if (this.probeStarted === gate) this.probeStarted = null
338
- })
339
- }
340
-
341
353
  probeBeforeUpload(hashes: Promise<readonly string[]>): void {
342
354
  for (const lane of this.lanes) lane.probeBeforeUpload(hashes)
343
355
  }
@@ -347,7 +359,9 @@ export class LayeredCache implements CacheLayer {
347
359
  if (localHit) {
348
360
  // A prefetch this run may have materialized this entry FROM
349
361
  // remote; the local row exists now, but the work was saved by
350
- // the remote cache, so the provenance stays 'remote'.
362
+ // the remote cache, so the provenance stays 'remote'. Its row lands
363
+ // a tick before the pull settles and says so: wait for that.
364
+ await this.inflight.get(hash)
351
365
  return this.asRemote(hash, localHit)
352
366
  }
353
367
 
@@ -360,6 +374,9 @@ export class LayeredCache implements CacheLayer {
360
374
  // remote walk per key: a settled `false` means no remote had it
361
375
  // (no second probe), and a settled/in-flight `true` means the
362
376
  // artifact was/will-be ingested locally — re-read below.
377
+ // A lookup never waits on the run's batch probe: a hit then paid two
378
+ // round-trips before its first byte (the probe, then the GET), ~300 ms
379
+ // an up-to-date task on CI. A GET answers a miss as fast as the probe.
363
380
  const ingested = await this.awaitOrRun(hash, this.pullFromRemote(hash, ctx))
364
381
  if (!ingested) return null
365
382
 
@@ -407,7 +424,7 @@ export class LayeredCache implements CacheLayer {
407
424
  const info = this.infos.get(hash)
408
425
  if (info === undefined) continue
409
426
  if (!judged(info)) return pull
410
- const budget = info.durationMs * 2
427
+ const budget = Math.max(info.durationMs * 2, LEAST_BUDGET_MS)
411
428
  const dl = this.downloads.get(hash)
412
429
  if (dl === undefined) continue
413
430
  const elapsed = performance.now() - dl.start
@@ -451,11 +468,6 @@ export class LayeredCache implements CacheLayer {
451
468
  private pullFromRemote(hash: string, ctx?: CacheGetContext): Promise<boolean> {
452
469
  const existing = this.inflight.get(hash)
453
470
  if (existing) return existing
454
- const probed = this.probing.get(hash)
455
- if (probed !== undefined)
456
- return probed.then((found) => (found ? this.pullFromRemote(hash, ctx) : false))
457
- if (this.probeStarted !== null)
458
- return this.probeStarted.then(() => this.pullFromRemote(hash, ctx))
459
471
  const p = this.doPullFromRemote(hash, ctx)
460
472
  this.inflight.set(hash, p)
461
473
  return p
@@ -471,9 +483,14 @@ export class LayeredCache implements CacheLayer {
471
483
  // caller re-reads it, keeping whatever provenance it already had (local,
472
484
  // or remote if a concurrent prefetch set it).
473
485
  if ((await this.local.has(hash)) === 'local') return true
486
+ let absent = true
474
487
  for (const lane of this.readable) {
475
- if (await this.pullFrom(lane, hash, ctx)) return true
488
+ const got = await this.pullFrom(lane, hash, ctx)
489
+ if (got === true) return true
490
+ if (got === false) absent = false
476
491
  }
492
+ // Every remote answered it holds none: a hit the index kept is gone.
493
+ if (absent) this.local.remoteHits.forget(hash)
477
494
  return false
478
495
  }
479
496
 
@@ -482,10 +499,10 @@ export class LayeredCache implements CacheLayer {
482
499
  hash: string,
483
500
  ctx?: CacheGetContext,
484
501
  fresh = false,
485
- ): Promise<boolean> {
502
+ ): Promise<boolean | null> {
486
503
  const fetchStart = performance.now()
487
504
  // A stopped run waits on no read, as on no upload: a lookup or download
488
- // in flight held a Ctrl-C until its last byte (M, 2026-10-17). The
505
+ // in flight held a Ctrl-C until its last byte (2026-10-17). The
489
506
  // plugin's request is left to the exit, and its body is cancelled.
490
507
  const stopped = lane.whenStopped.then((): typeof STOPPED => STOPPED)
491
508
  const pending = lane.get(hash, fresh)
@@ -495,7 +512,9 @@ export class LayeredCache implements CacheLayer {
495
512
  void pending.then((r) => (r?.body instanceof Response ? r.body.body?.cancel() : undefined))
496
513
  return false
497
514
  }
498
- if (remoteBody === null) return false
515
+ // The remote holds none: the one answer that says the key is gone.
516
+ if (remoteBody === null) return null
517
+ if (remoteBody === undefined) return false
499
518
 
500
519
  // Ingest the remote body into local using the caller-supplied
501
520
  // taskId/command plus the remote-reported durationMs, which `get`
@@ -525,6 +544,7 @@ export class LayeredCache implements CacheLayer {
525
544
  endsOnStop(remoteBody.body, ends, (n) => (dl.received += n)),
526
545
  meta,
527
546
  )
547
+ // quiet: raced and awaited below, where its error is reported
528
548
  ingest.catch(() => undefined)
529
549
  if ((await Promise.race([ingest, ends])) === STOPPED) return false
530
550
  } catch (err) {
@@ -533,6 +553,9 @@ export class LayeredCache implements CacheLayer {
533
553
  // crash the run. A corrupt LOCAL artifact is a miss too, one level
534
554
  // down: `Cache.restoreOutputs` drops it (A-52).
535
555
  lane.report('download', hash, err)
556
+ // Bad bytes there: the re-run's save sends good ones over them, or
557
+ // every machine rebuilt the task and none repaired it (2026-10-24).
558
+ if (err instanceof CorruptArtifactError) lane.distrust(hash)
536
559
  return false
537
560
  } finally {
538
561
  this.downloads.delete(hash)
@@ -598,6 +621,7 @@ export class LayeredCache implements CacheLayer {
598
621
  packed = await this.local.packArtifactBytes(args)
599
622
  } catch (err) {
600
623
  for (const lane of writable) lane.report('upload', hash, err)
624
+ this.sent.set(hash, Promise.resolve(false))
601
625
  return
602
626
  }
603
627
  }
@@ -605,7 +629,48 @@ export class LayeredCache implements CacheLayer {
605
629
  packed !== undefined
606
630
  ? Promise.resolve({ body: new Blob([packed]) })
607
631
  : this.local.pinArtifact(hash)
608
- for (const lane of writable) lane.upload(hash, durationMs, body, this.local.uploads)
632
+ const sent = writable.map((lane) =>
633
+ lane.upload(hash, durationMs, body, this.local.uploads, args.absent === true),
634
+ )
635
+ this.sent.set(
636
+ hash,
637
+ Promise.all(sent).then((held) => held.includes(true)),
638
+ )
639
+ }
640
+
641
+ uploaded(hash: string): Promise<boolean | undefined> {
642
+ return this.sent.get(hash) ?? Promise.resolve(undefined)
643
+ }
644
+
645
+ /**
646
+ * True once a remote this run reads holds entry `hash`, uploading the
647
+ * local entry to each one it also writes when none does. Another machine
648
+ * of the run (a DTE worker) restores from the remotes alone: a hit only
649
+ * this cache held, the remote having evicted it, failed its dependant
650
+ * there.
651
+ */
652
+ async share(hash: string): Promise<boolean> {
653
+ for (const lane of this.readable) if ((await lane.has(hash)) !== null) return true
654
+ return this.upload(hash, false)
655
+ }
656
+
657
+ /** `share`, over a remote copy another machine of the run found corrupt. */
658
+ resend(hash: string): Promise<boolean> {
659
+ return this.upload(hash, true)
660
+ }
661
+
662
+ private async upload(hash: string, over: boolean): Promise<boolean> {
663
+ const lanes = this.readable.filter((l) => l.write)
664
+ if (lanes.length === 0) return false
665
+ const entry = await this.local.getIngested(hash)
666
+ if (entry === null) return false
667
+ if (over) for (const lane of lanes) lane.distrust(hash)
668
+ const held = await Promise.all(
669
+ lanes.map((l) =>
670
+ l.upload(hash, entry.durationMs, () => this.local.pinArtifact(hash), this.local.uploads),
671
+ ),
672
+ )
673
+ return held.includes(true)
609
674
  }
610
675
 
611
676
  /**
@@ -632,7 +697,7 @@ export class LayeredCache implements CacheLayer {
632
697
  return this.local.loadOutputFilesBatch(hashes)
633
698
  }
634
699
 
635
- async isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): Promise<boolean> {
700
+ isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): boolean {
636
701
  return this.local.isOutputsCurrent(projectDir, expected)
637
702
  }
638
703
 
@@ -652,7 +717,7 @@ export class LayeredCache implements CacheLayer {
652
717
  return this.local.loadOutputDirsBatch(hashes)
653
718
  }
654
719
 
655
- outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): Promise<boolean> {
720
+ outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): boolean {
656
721
  return this.local.outputDirsCurrent(projectDir, rows)
657
722
  }
658
723
 
@@ -1,5 +1,5 @@
1
1
  // A symlinked input keys the content behind it, as Nx reads a linked file
2
- // where it points (owner, 2026-10-16: "Do like nx"). Its own fold stays the
2
+ // where it points (2026-10-16: "Do like nx"). Its own fold stays the
3
3
  // blob of its target STRING (file-hashes.ts), so a retarget to equal bytes
4
4
  // moves the key; this is what follows it. The target string alone let an
5
5
  // edit behind the link replay the old output (Growth, 2026-10-16), wherever
@@ -37,6 +37,12 @@ import {
37
37
  */
38
38
  export const LINK_MODE = 0o120000
39
39
 
40
+ /**
41
+ * The type bits a directory row carries beside a mode the restore applies
42
+ * (`MetaFile.dirModes`); an empty directory's row without one is mode 0.
43
+ */
44
+ export const DIR_MODE = 0o040000
45
+
40
46
  /** A link row is current while a link with that target stands there; never followed. */
41
47
  function linkCurrent(abs: string, e: { size: number; mtimeMs: number }): boolean {
42
48
  if (!lstatSync(abs).isSymbolicLink()) return false
@@ -75,7 +81,15 @@ export class OutputIndex {
75
81
  */
76
82
  private readonly held = new Map<string, readonly OutputFileRow[]>()
77
83
 
78
- constructor(private readonly db: Database) {
84
+ /**
85
+ * `memo` false: a cache directory this run cannot write. A restore still
86
+ * serves the hit, but its stamps and directory snapshots are not queued:
87
+ * the next lookup flushed them and failed its task (SQLITE_READONLY).
88
+ */
89
+ constructor(
90
+ private readonly db: Database,
91
+ private readonly memo = true,
92
+ ) {
79
93
  this.insertOutputFile = lazyStatement(
80
94
  this.db,
81
95
  `
@@ -206,7 +220,7 @@ export class OutputIndex {
206
220
  return out
207
221
  }
208
222
 
209
- async isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): Promise<boolean> {
223
+ isOutputsCurrent(projectDir: string, expected: readonly OutputFileRow[]): boolean {
210
224
  // Empty manifest case (a task produced no outputs) → trivially
211
225
  // current; the on-disk tree under projectDir is whatever it was,
212
226
  // and nothing was supposed to land there.
@@ -271,6 +285,7 @@ export class OutputIndex {
271
285
  prefixes: readonly string[],
272
286
  holds?: (files: readonly string[]) => boolean,
273
287
  ): Promise<void> {
288
+ if (!this.memo) return
274
289
  const rows: Array<[string, number]> = []
275
290
  const files: string[] = []
276
291
  let overCap = false
@@ -352,6 +367,7 @@ export class OutputIndex {
352
367
  * Written with the directory snapshots, in one transaction.
353
368
  */
354
369
  recordOutputStamps(hash: string, projectDir: string, workspaceRoot: string): void {
370
+ if (!this.memo) return
355
371
  const rows = this.rowsOf(hash)
356
372
  const stamps: Array<[string, number, number]> = []
357
373
  for (const e of rows) {
@@ -442,7 +458,7 @@ export class OutputIndex {
442
458
  }
443
459
 
444
460
  /** True iff every recorded directory exists with its recorded mtime (ms). Same forged-mtime trade as the file check. */
445
- async outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): Promise<boolean> {
461
+ outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): boolean {
446
462
  if (rows.length === 0) return false
447
463
  const results = rows.map((r) => {
448
464
  let st