@vzn/vx 0.0.638 → 0.0.640

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 (134) hide show
  1. package/README.md +1 -1
  2. package/docs/caching.md +152 -95
  3. package/docs/cli.md +225 -372
  4. package/docs/execution.md +22 -47
  5. package/docs/features.md +14 -16
  6. package/docs/patterns.md +6 -8
  7. package/docs/schema.md +268 -215
  8. package/docs/security.md +1 -1
  9. package/package.json +5 -5
  10. package/schemas/last.json +7 -4
  11. package/schemas/show.json +14 -14
  12. package/skills/vx/SKILL.md +10 -7
  13. package/src/cache/cache.ts +44 -11
  14. package/src/cache/file-hashes.ts +7 -12
  15. package/src/cache/fs-clock.ts +62 -0
  16. package/src/cache/git-inputs.ts +8 -2
  17. package/src/cache/index.ts +5 -2
  18. package/src/cache/inputs.ts +154 -83
  19. package/src/cache/key-fold.ts +23 -3
  20. package/src/cache/layer.ts +33 -18
  21. package/src/cache/layered-cache.ts +108 -315
  22. package/src/cache/linked-inputs.ts +87 -0
  23. package/src/cache/plugin-cache.ts +2 -10
  24. package/src/cache/remote-lane.ts +383 -0
  25. package/src/cache/run-history.ts +24 -8
  26. package/src/cache/run-store.ts +4 -4
  27. package/src/cache/schema.ts +6 -4
  28. package/src/cache/scoped-cache.ts +7 -0
  29. package/src/cli/completions.ts +2 -2
  30. package/src/cli/foreign-flags.ts +11 -23
  31. package/src/cli/help.ts +6 -31
  32. package/src/cli/info.ts +2 -1
  33. package/src/cli/init.ts +23 -11
  34. package/src/cli/last.ts +30 -4
  35. package/src/cli/plan-format.ts +5 -58
  36. package/src/cli/plugin-commands.ts +2 -2
  37. package/src/cli/plugin-templates.ts +2 -2
  38. package/src/cli/run.ts +23 -185
  39. package/src/cli/select.ts +16 -6
  40. package/src/cli/show.ts +15 -6
  41. package/src/cli/upgrade.ts +215 -55
  42. package/src/cli/watch-cycle.ts +2 -1
  43. package/src/cli/watch-filter.ts +22 -6
  44. package/src/cli/watch-judge.ts +49 -5
  45. package/src/cli/watch-set.ts +18 -8
  46. package/src/cli/watch.ts +103 -16
  47. package/src/cli/why.ts +9 -7
  48. package/src/config.ts +19 -21
  49. package/src/exec/executor.ts +5 -5
  50. package/src/exec/sandbox-runtime.ts +1 -1
  51. package/src/exec/sandbox-violations.ts +3 -1
  52. package/src/graph/dependency-spec.ts +2 -2
  53. package/src/graph/folds.ts +236 -0
  54. package/src/graph/index.ts +17 -2
  55. package/src/graph/priorities.ts +1 -1
  56. package/src/graph/scheduler.ts +45 -31
  57. package/src/graph/task-graph.ts +190 -58
  58. package/src/graph/task-status.ts +95 -0
  59. package/src/{orchestrator → history}/failure-mode.ts +1 -1
  60. package/src/{orchestrator → history}/history.ts +1 -1
  61. package/src/history/index.ts +48 -0
  62. package/src/{orchestrator → history}/metrics.ts +7 -20
  63. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  64. package/src/index.ts +11 -29
  65. package/src/orchestrator/affected-tasks.ts +20 -19
  66. package/src/orchestrator/doctor.ts +1 -1
  67. package/src/orchestrator/download-policy.ts +16 -22
  68. package/src/orchestrator/excluded-keys.ts +4 -5
  69. package/src/orchestrator/execute-args.ts +80 -0
  70. package/src/orchestrator/execute-task.ts +50 -411
  71. package/src/orchestrator/fingerprint-watch.ts +2 -2
  72. package/src/orchestrator/index.ts +2 -73
  73. package/src/orchestrator/keyed-projects.ts +12 -51
  74. package/src/orchestrator/local-shortcircuit.ts +1 -1
  75. package/src/orchestrator/miss-save.ts +33 -3
  76. package/src/orchestrator/options.ts +1 -36
  77. package/src/orchestrator/output-host.ts +4 -3
  78. package/src/orchestrator/persistent-task.ts +321 -0
  79. package/src/orchestrator/persistent.ts +130 -3
  80. package/src/orchestrator/placement.ts +5 -5
  81. package/src/orchestrator/plan.ts +19 -6
  82. package/src/orchestrator/plugin-host.ts +129 -69
  83. package/src/orchestrator/plugin.ts +18 -8
  84. package/src/orchestrator/prepare.ts +40 -34
  85. package/src/orchestrator/projects.ts +7 -10
  86. package/src/orchestrator/remote-prefetch.ts +14 -1
  87. package/src/orchestrator/run-artifacts.ts +5 -177
  88. package/src/orchestrator/run-context.ts +55 -0
  89. package/src/orchestrator/run-failures.ts +15 -3
  90. package/src/orchestrator/run-records.ts +35 -3
  91. package/src/orchestrator/run-report.ts +151 -171
  92. package/src/orchestrator/run-settings.ts +1 -16
  93. package/src/orchestrator/run-steps.ts +57 -0
  94. package/src/orchestrator/run-stop.ts +153 -0
  95. package/src/orchestrator/run.ts +293 -604
  96. package/src/orchestrator/sandbox-fix.ts +1 -17
  97. package/src/orchestrator/sandbox-request.ts +2 -2
  98. package/src/orchestrator/signals.ts +14 -6
  99. package/src/orchestrator/stable-keys.ts +164 -27
  100. package/src/orchestrator/steps.ts +70 -0
  101. package/src/orchestrator/task-hash.ts +34 -25
  102. package/src/orchestrator/task-log-buffer.ts +1 -1
  103. package/src/orchestrator/task-process.ts +94 -0
  104. package/src/orchestrator/telemetry-host.ts +4 -4
  105. package/src/orchestrator/telemetry.ts +14 -86
  106. package/src/orchestrator/upstream.ts +4 -181
  107. package/src/{orchestrator → report}/events.ts +41 -8
  108. package/src/{orchestrator → report}/forecast.ts +35 -2
  109. package/src/{orchestrator → report}/framed-output.ts +40 -34
  110. package/src/report/index.ts +36 -0
  111. package/src/{orchestrator → report}/logger.ts +158 -20
  112. package/src/{orchestrator → report}/status-line.ts +88 -7
  113. package/src/{orchestrator → report}/summary.ts +126 -35
  114. package/src/report/tally.ts +83 -0
  115. package/src/settings.ts +12 -39
  116. package/src/util/colors.ts +44 -4
  117. package/src/util/git-ignored.ts +143 -0
  118. package/src/util/index.ts +19 -4
  119. package/src/util/paths.ts +1 -1
  120. package/src/util/report.ts +13 -5
  121. package/src/util/settle.ts +14 -39
  122. package/src/util/tail.ts +1 -1
  123. package/src/util/ui.ts +17 -6
  124. package/src/workspace/affected.ts +81 -2
  125. package/src/workspace/config-imports.ts +1 -1
  126. package/src/workspace/config-schema.ts +142 -143
  127. package/src/workspace/lockfile.ts +1 -1
  128. package/src/workspace/migration.ts +19 -3
  129. package/schemas/summary.json +0 -133
  130. package/src/cache/chained-cache.ts +0 -280
  131. package/src/orchestrator/tally.ts +0 -112
  132. /package/src/{orchestrator → report}/path-links.ts +0 -0
  133. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  134. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
@@ -1,12 +1,14 @@
1
- // LayeredCache — composes the local cache with a remote HTTP cache.
1
+ // LayeredCache — the local cache with the declared remotes behind it.
2
2
  //
3
- // Read path: try local. On miss, try remote; on remote hit, stream
4
- // the artifact body into local so the next read is a local hit.
3
+ // Read path: try local. On miss, ask each readable remote in declared
4
+ // order; the first hit streams its body into local so the next read is a
5
+ // local hit.
5
6
  //
6
7
  // Write path: write to local synchronously. Upload the local artifact
7
- // to remote as a fire-and-forget background task; failures log a
8
- // warning but never fail the user's run (the task already succeeded;
9
- // the only loss is the remote cache entry).
8
+ // to every writable remote as a fire-and-forget background task; failures
9
+ // log a warning but never fail the user's run (the task already
10
+ // succeeded; the only loss is the remote cache entry). Each remote's
11
+ // boundary, upload pool and warnings are its `RemoteLane`.
10
12
  //
11
13
  // The local and remote layers share the SAME artifact format — the
12
14
  // `<hash>.tar.zst` bytes ship across the wire verbatim. Metadata
@@ -21,7 +23,6 @@ import type {
21
23
  CacheGetContext,
22
24
  CacheKeyInput,
23
25
  CacheLayer,
24
- CachePolicy,
25
26
  CacheStats,
26
27
  CacheStatsOptions,
27
28
  IngestMeta,
@@ -32,7 +33,8 @@ import type {
32
33
  RunRecord,
33
34
  SaveArgs,
34
35
  } from './cache.js'
35
- import { FULL_CACHE_POLICY, type OutputDirRow } from './cache.js'
36
+ import type { OutputDirRow } from './cache.js'
37
+ import { RemoteLane, type ResolvedRemote } from './remote-lane.js'
36
38
 
37
39
  /**
38
40
  * What a remote cache layer must provide — THE plugin seam for remote
@@ -40,8 +42,8 @@ import { FULL_CACHE_POLICY, type OutputDirRow } from './cache.js'
40
42
  * (or an embedder passes one as `RunOptions.remoteCache`), speaking
41
43
  * whatever protocol it wants, and `LayeredCache` owns everything else:
42
44
  * policy gating, in-flight dedup, remote provenance, and the never-fail
43
- * contract (implementations THROW on failure; LayeredCache degrades every
44
- * throw to a cache miss via `onRemoteError`). The artifact bytes are the
45
+ * contract (implementations THROW on failure; every throw degrades to a
46
+ * cache miss, reported to the remote's `onError`). The artifact bytes are the
45
47
  * local `<hash>.tar.zst` verbatim. The wires live in plugin packages
46
48
  * (`@vzn/vx-migrate`'s `turboCache()` and `nxCache()`, `@vzn/vx-reapi`); see
47
49
  * docs/modules/layered-cache.md.
@@ -64,15 +66,15 @@ export interface RemoteCacheLayer {
64
66
  * token): core clears the run's remote axis it lacks. Omitted → both.
65
67
  */
66
68
  readonly access?: { readonly read?: boolean; readonly write?: boolean }
67
- /** Existence probe (drives the plan path's `--dry` remote prediction). */
69
+ /** Existence probe: `--dry` and `--force` ask it per key when the remote cannot batch. */
68
70
  has(hash: string): Promise<boolean>
69
71
  /**
70
72
  * Optional batch existence probe: given N hashes, return the subset stored
71
- * remotely in ONE round-trip. Lets the prefetch pass collapse N per-hash
72
- * network probes into one, then fetch only the hits. A remote that can't
73
- * batch omits this method (or returns `null`) and the layer falls back to
74
- * the per-hash path. Never throws for control flow — `null` means "no batch
75
- * info; use per-hash".
73
+ * remotely in ONE round-trip. Its presence says the wire can batch: a run
74
+ * asks it once for its keys, `--dry` before its per-key answers, and
75
+ * `--force` in the background to skip uploads of held keys. Without it a
76
+ * run GETs each key directly (no `has`). Never throws for control flow:
77
+ * `null` means "no batch info; use per-hash".
76
78
  */
77
79
  hasMany?(hashes: readonly string[]): Promise<Set<string> | null>
78
80
  /**
@@ -89,155 +91,50 @@ export interface RemoteCacheLayer {
89
91
  * Store an artifact (fire-and-forget from LayeredCache's perspective).
90
92
  * `body` is file-backed (`Bun.file`) when the local store holds the
91
93
  * artifact, so a plugin that streams it never holds it whole, and every
92
- * read of it sees the same bytes.
94
+ * read of it sees the same bytes. An entry is immutable: a `put` of a
95
+ * key the remote holds keeps the stored bytes. Core never asks `has`
96
+ * first; a wire that would overwrite checks inside.
93
97
  */
94
98
  put(hash: string, body: Blob, meta: { durationMs: number }): Promise<void>
95
99
  }
96
100
 
97
- /**
98
- * What a remote layer resolves is a plugin's, so its shape is a boundary
99
- * (item 252): a `get` that resolved `{ body: 'abc' }` was reported as
100
- * "corrupt artifact … not a readable archive" — the bytes blamed for the
101
- * plugin's shape — and a `hasMany` that resolved an array reached the
102
- * prefetch pass's `.has()`. Named here and degraded to a miss, like every
103
- * other remote failure.
104
- */
105
- function invalidRemoteResult(what: string): Error {
106
- return new Error(
107
- `remote cache layer returned an invalid result: ${what} — a plugin bug, degraded to a miss`,
108
- )
109
- }
110
-
111
- function isBody(value: unknown): value is Blob | Response {
112
- return value instanceof Blob || value instanceof Response
113
- }
114
-
115
- function describeValue(value: unknown): string {
116
- if (value === null) return 'null'
117
- if (Array.isArray(value)) return 'an array'
118
- // The pre-stream contract's shape, named so an unported layer reads its fix.
119
- if (value instanceof ArrayBuffer || ArrayBuffer.isView(value))
120
- return `a ${value.constructor.name}`
121
- return typeof value === 'object' ? 'an object' : typeof value
122
- }
123
-
124
- /**
125
- * Cap on concurrent background PUTs. Keeps a burst of cache misses from
126
- * opening one socket per task; excess uploads queue and drain FIFO.
127
- */
128
- const UPLOAD_CONCURRENCY = 4
129
-
130
- /** The seam call a failure happened in, as the warning names it. */
131
- type RemoteOp = 'probe' | 'download' | 'upload'
132
-
133
- const PREPOSITION: Record<RemoteOp, string> = { probe: 'at', download: 'from', upload: 'to' }
134
-
135
- /** The layer's `endpoint` as it may be printed: no userinfo, query or fragment. */
136
- function printableEndpoint(endpoint: unknown): string | undefined {
137
- if (typeof endpoint !== 'string' || endpoint === '') return undefined
138
- if (!URL.canParse(endpoint)) return endpoint
139
- const u = new URL(endpoint)
140
- if (u.username === '' && u.password === '' && u.search === '' && u.hash === '') return endpoint
141
- u.username = ''
142
- u.password = ''
143
- u.search = ''
144
- u.hash = ''
145
- return u.toString()
146
- }
147
-
148
- /**
149
- * What makes two failures "the same way": the error's `code` when it
150
- * carries one (a gRPC status, an errno, Bun's `ConnectionRefused`), because
151
- * gRPC writes the elapsed time into its message and no two deadline messages
152
- * match; else the message with the artifact's hash taken out, so a 500 on
153
- * one hash and a 500 on the next are one class. The operation is not part of
154
- * it: an unreachable server fails the probe, the download and the upload
155
- * alike, and that is one fact.
156
- */
157
- function failureClass(err: Error, hash: string | undefined): string {
158
- const code = (err as { code?: unknown }).code
159
- if (typeof code === 'string' || typeof code === 'number') return `${err.name}\0${code}`
160
- return hash === undefined ? err.message : err.message.replaceAll(hash, '\0')
161
- }
162
-
163
- /** A batch probe names no one hash, so its line counts them. */
164
- function batchOf(hashes: readonly string[]): { batch: string } {
165
- return { batch: `of ${hashes.length} artifact${hashes.length === 1 ? '' : 's'}` }
166
- }
167
-
168
- export interface LayeredCacheOptions {
169
- /**
170
- * Called for the remote failures the layer degrades to a miss, once per
171
- * failure class per layer (`upload <hash> to <endpoint> failed: <cause>`,
172
- * with the original error as `cause`); the repeats are counted and said
173
- * once more at `close()` (`<n> more requests failed the same way: <cause>`).
174
- */
175
- onRemoteError?: (err: Error) => void
176
- /**
177
- * The 4-axis read/write policy. The local slice (read/write) is
178
- * already applied to the inner `Cache` by the caller; this layer reads
179
- * `remoteRead` / `remoteWrite` to gate its OWN remote operations.
180
- * Default: everything on.
181
- */
182
- policy?: CachePolicy
183
- }
184
-
185
101
  export class LayeredCache implements CacheLayer {
186
102
  /** A remote layer is composed in by construction — see `CacheLayer.hasRemote`. */
187
103
  readonly hasRemote = true
188
104
 
105
+ private readonly lanes: readonly RemoteLane[]
106
+ private readonly readable: readonly RemoteLane[]
107
+
189
108
  /**
190
109
  * In-flight remote pulls keyed by hash. `prefetch` and `get` both go
191
110
  * through here, so a key probed concurrently by both resolves a
192
- * SINGLE remote GET. Each promise resolves `true` iff the artifact
193
- * was successfully ingested into local. Entries are retained for the
194
- * run's lifetime: a settled `false` records "remote already had no
195
- * such artifact (or it was corrupt)", which lets `get` skip a second
196
- * lazy probe of the same dead hash. The map is bounded by the number
197
- * of distinct task keys in a run — negligible.
111
+ * SINGLE walk of the remotes. Each promise resolves `true` iff the
112
+ * artifact was successfully ingested into local. Entries are retained
113
+ * for the run's lifetime: a settled `false` records "no remote had
114
+ * such an artifact (or it was corrupt)", which lets `get` skip a
115
+ * second lazy probe of the same dead hash. The map is bounded by the
116
+ * number of distinct task keys in a run — negligible.
198
117
  */
199
118
  private readonly inflight = new Map<string, Promise<boolean>>()
200
119
 
201
120
  /**
202
- * Hashes whose local artifact was materialized FROM the remote layer
203
- * this run (by `prefetch` or `get`'s read-through). A later `get`
204
- * finds them as a local hit, but the work was still saved by the
205
- * remote cache — so we flip `source` to `'remote'` and the
206
- * orchestrator reports `cache-hit-remote`. Without this, a prefetch
207
- * followed by a `get` would mislabel a genuine remote hit as local.
208
- * Each maps to how long its download and ingest took.
121
+ * Hashes whose local artifact was materialized FROM a remote this run
122
+ * (by `prefetch` or `get`'s read-through). A later `get` finds them as
123
+ * a local hit, but the work was still saved by the remote cache — so
124
+ * we flip `source` to `'remote'` and the orchestrator reports
125
+ * `cache-hit-remote`. Without this, a prefetch followed by a `get`
126
+ * would mislabel a genuine remote hit as local. Each maps to how long
127
+ * its download and ingest took.
209
128
  */
210
129
  private readonly remoteSourced = new Map<string, number>()
211
130
 
212
- /**
213
- * Background write-through uploads. `save()` returns after the local
214
- * write + byte capture; the PUT runs here so a cache-miss task never
215
- * holds its scheduler worker slot for the upload round-trip. Bounded
216
- * at UPLOAD_CONCURRENCY; `run()` awaits `drainUploads()` before
217
- * `cache.close()`.
218
- */
219
- private readonly uploadQueue: Array<() => Promise<void>> = []
220
- private activeUploads = 0
221
- private drainWaiters: Array<() => void> = []
222
-
223
- private readonly policy: CachePolicy
224
- private readonly endpoint: string | undefined
225
-
226
- /**
227
- * One entry per failure class said: its first line went out, `repeats`
228
- * counts the ones held back. A run's requests are concurrent, so an
229
- * unreachable server otherwise printed its bare runtime message once per
230
- * request (three lines for one task over the Turbo wire, item 749).
231
- */
232
- private readonly reported = new Map<string, { cause: string; repeats: number }>()
233
-
131
+ /** Remotes in declared order: the first readable one holding a hash answers it. */
234
132
  constructor(
235
133
  readonly local: Cache,
236
- private readonly remote: RemoteCacheLayer,
237
- private readonly options: LayeredCacheOptions = {},
134
+ remotes: readonly ResolvedRemote[],
238
135
  ) {
239
- this.policy = options.policy ?? FULL_CACHE_POLICY
240
- this.endpoint = printableEndpoint(remote.endpoint)
136
+ this.lanes = remotes.map((r) => new RemoteLane(r, local.traffic))
137
+ this.readable = this.lanes.filter((l) => l.read)
241
138
  }
242
139
 
243
140
  key(input: CacheKeyInput): Promise<string> {
@@ -245,8 +142,8 @@ export class LayeredCache implements CacheLayer {
245
142
  }
246
143
 
247
144
  async prefetch(hash: string, ctx?: CacheGetContext): Promise<boolean> {
248
- // No-op when remote reads are off — there's nothing to warm from.
249
- if (!this.policy.remoteRead) return false
145
+ // No-op when no remote may be read — there's nothing to warm from.
146
+ if (this.readable.length === 0) return false
250
147
  // The local-first skip lives in doPullFromRemote (the shared choke
251
148
  // point): pullFromRemote registers the `inflight` entry SYNCHRONOUSLY,
252
149
  // so a concurrent markRemoteAbsent can't clobber a pending pull — a
@@ -259,43 +156,34 @@ export class LayeredCache implements CacheLayer {
259
156
  }
260
157
 
261
158
  /**
262
- * Batch existence probe over the remote layer — the subset of `hashes`
263
- * present remotely, in one round-trip, or `null` when the remote can't
264
- * batch (no `hasMany`, reads disabled, or an error). The prefetch pass uses
265
- * this to fetch only the hits and to pre-mark the misses (`markRemoteAbsent`)
266
- * so their lazy `get` skips the network. Never throws — a batch failure
267
- * degrades to "no batch info" and the caller falls back to per-hash.
159
+ * Batch existence probe over the readable remotes — the subset of
160
+ * `hashes` some remote holds, one round-trip each, or `null` when any
161
+ * readable remote can't batch (no `hasMany`, or an error), or none may
162
+ * be read. The prefetch pass treats a non-null answer as complete: it
163
+ * fetches only the hits and pre-marks the rest (`markRemoteAbsent`) so
164
+ * their lazy `get` skips the network, which is sound only when every
165
+ * readable remote answered. Each answering remote keeps its own
166
+ * absences either way, so a hash only a sibling holds costs it no GET.
167
+ * Never throws.
268
168
  */
269
169
  async remoteHasMany(hashes: readonly string[]): Promise<Set<string> | null> {
270
- if (!this.policy.remoteRead || this.remote.hasMany === undefined) return null
271
- try {
272
- // `return await`, deliberately: the catch below is the never-fail
273
- // contract, and a returned promise's rejection would sail past it.
274
- const found: unknown = await this.remote.hasMany(hashes)
275
- if (found !== null && found !== undefined && !(found instanceof Set)) {
276
- this.reportRemoteError(
277
- 'probe',
278
- batchOf(hashes),
279
- invalidRemoteResult(
280
- `hasMany() resolved ${describeValue(found)} (expected a Set of the hashes present, or null)`,
281
- ),
282
- )
283
- return null
284
- }
285
- return found ?? null
286
- } catch (err) {
287
- this.reportRemoteError('probe', batchOf(hashes), err)
288
- return null
170
+ if (this.readable.length === 0) return null
171
+ let out: Set<string> | null = new Set()
172
+ for (const lane of this.readable) {
173
+ const found = await lane.hasMany(hashes)
174
+ if (found === null) out = null
175
+ else if (out !== null) for (const h of found) out.add(h)
289
176
  }
177
+ return out
290
178
  }
291
179
 
292
180
  /**
293
- * Record that the remote layer has NO artifact for each of `hashes` (from a
294
- * batch probe), so a later `get`/`prefetch` short-circuits to a miss WITHOUT
295
- * a network round-trip. Byte-for-byte equivalent to a background prefetch GET
296
- * having resolved `false` into `inflight` — same at-most-once semantics, same
297
- * point-in-time staleness window — but without spending the GET. Never
298
- * overwrites an entry already in flight.
181
+ * Record that no remote has an artifact for each of `hashes` (from a
182
+ * batch probe), so a later `get`/`prefetch` short-circuits to a miss
183
+ * WITHOUT a network round-trip. Byte-for-byte equivalent to a
184
+ * background prefetch having resolved `false` into `inflight` — same
185
+ * at-most-once semantics, same point-in-time staleness window — but
186
+ * without spending the GET. Never overwrites an entry already in flight.
299
187
  */
300
188
  markRemoteAbsent(hashes: Iterable<string>): void {
301
189
  for (const hash of hashes) {
@@ -303,6 +191,10 @@ export class LayeredCache implements CacheLayer {
303
191
  }
304
192
  }
305
193
 
194
+ probeBeforeUpload(hashes: Promise<readonly string[]>): void {
195
+ for (const lane of this.lanes) lane.probeBeforeUpload(hashes)
196
+ }
197
+
306
198
  async get(hash: string, ctx?: CacheGetContext): Promise<CacheEntry | null> {
307
199
  const localHit = await this.local.get(hash, ctx)
308
200
  if (localHit) {
@@ -313,12 +205,12 @@ export class LayeredCache implements CacheLayer {
313
205
  }
314
206
 
315
207
  // Remote reads disabled (e.g. `--cache-policy=remote:`): a local miss is a
316
- // real miss; never touch the remote layer.
317
- if (!this.policy.remoteRead) return null
208
+ // real miss; never touch a remote.
209
+ if (this.readable.length === 0) return null
318
210
 
319
211
  // A prefetch may already have probed this hash (resolved) or be
320
212
  // mid-flight. Awaiting the shared promise guarantees AT MOST ONE
321
- // remote GET per key: a settled `false` means remote had nothing
213
+ // remote walk per key: a settled `false` means no remote had it
322
214
  // (no second probe), and a settled/in-flight `true` means the
323
215
  // artifact was/will-be ingested locally — re-read below.
324
216
  const ingested = await this.pullFromRemote(hash, ctx)
@@ -348,27 +240,23 @@ export class LayeredCache implements CacheLayer {
348
240
  return fetchMs === undefined ? entry : { ...entry, source: 'remote', fetchMs }
349
241
  }
350
242
 
351
- // Existence probe: local first, then a remote HEAD — no body
243
+ // Existence probe: local first, then each remote's HEAD — no body
352
244
  // transfer, no ingest, so the plan path stays read-only even against
353
245
  // a remote cache. Remote errors degrade to a miss (never throw).
354
246
  async has(hash: string): Promise<'local' | 'remote' | null> {
355
247
  if ((await this.local.has(hash)) === 'local') return 'local'
356
- if (!this.policy.remoteRead) return null
357
- try {
358
- return (await this.remote.has(hash)) ? 'remote' : null
359
- } catch (err) {
360
- this.reportRemoteError('probe', hash, err)
361
- return null
362
- }
248
+ for (const lane of this.readable) if (await lane.has(hash)) return 'remote'
249
+ return null
363
250
  }
364
251
 
365
252
  /**
366
253
  * Single implementation of the remote read-through, shared by
367
254
  * `prefetch` and `get`. Idempotent per hash via `inflight`: the first
368
- * caller starts the GET + validate + ingest; concurrent and later
255
+ * caller starts the walk + validate + ingest; concurrent and later
369
256
  * callers await the same promise. Resolves `true` when the artifact
370
- * ends up in local, `false` on a remote miss / error / corruption
371
- * (degrades to a cache miss; the error is reported, never thrown).
257
+ * ends up in local, `false` on a miss in every remote / error /
258
+ * corruption (degrades to a cache miss; the error is reported, never
259
+ * thrown).
372
260
  */
373
261
  private pullFromRemote(hash: string, ctx?: CacheGetContext): Promise<boolean> {
374
262
  const existing = this.inflight.get(hash)
@@ -388,31 +276,16 @@ export class LayeredCache implements CacheLayer {
388
276
  // caller re-reads it, keeping whatever provenance it already had (local,
389
277
  // or remote if a concurrent prefetch set it).
390
278
  if ((await this.local.has(hash)) === 'local') return true
279
+ for (const lane of this.readable) {
280
+ if (await this.pullFrom(lane, hash, ctx)) return true
281
+ }
282
+ return false
283
+ }
391
284
 
285
+ private async pullFrom(lane: RemoteLane, hash: string, ctx?: CacheGetContext): Promise<boolean> {
392
286
  const fetchStart = performance.now()
393
- let remoteResult: unknown
394
- try {
395
- remoteResult = await this.remote.get(hash)
396
- } catch (err) {
397
- this.reportRemoteError('download', hash, err)
398
- return false
399
- }
400
- if (!remoteResult) return false
401
- if (typeof remoteResult !== 'object' || !isBody((remoteResult as { body?: unknown }).body)) {
402
- const shape =
403
- typeof remoteResult !== 'object'
404
- ? describeValue(remoteResult)
405
- : `body is ${describeValue((remoteResult as { body?: unknown }).body)}`
406
- this.reportRemoteError(
407
- 'download',
408
- hash,
409
- invalidRemoteResult(
410
- `get() resolved ${shape} (expected { body: Blob | Response, durationMs } or null)`,
411
- ),
412
- )
413
- return false
414
- }
415
- const remoteBody = remoteResult as { body: Blob | Response; durationMs?: number }
287
+ const remoteBody = await lane.get(hash)
288
+ if (remoteBody === null) return false
416
289
 
417
290
  // Ingest the remote body into local using the caller-supplied
418
291
  // taskId/command plus the remote-reported durationMs, which `get`
@@ -438,7 +311,7 @@ export class LayeredCache implements CacheLayer {
438
311
  // artifact must degrade to a cache miss (task re-executes), not
439
312
  // crash the run. A corrupt LOCAL artifact is a miss too, one level
440
313
  // down: `Cache.restoreOutputs` drops it (A-52).
441
- this.reportRemoteError('download', hash, err)
314
+ lane.report('download', hash, err)
442
315
  return false
443
316
  }
444
317
  this.remoteSourced.set(hash, Math.round(performance.now() - fetchStart))
@@ -463,17 +336,17 @@ export class LayeredCache implements CacheLayer {
463
336
 
464
337
  async save(args: SaveArgs): Promise<void> {
465
338
  // Local write honors its own gates inside Cache.save (no-op when local
466
- // writes are disabled, or when ChainedCache marked this a duplicate save
467
- // to a shared local handle — see `skipLocalWrite` on the contract).
339
+ // writes are disabled).
468
340
  await this.local.save(args)
469
- if (!this.policy.remoteWrite) return
470
- // Write-through upload, OFF the task's critical path: the PUT runs in
471
- // the bounded background pool so the task's worker slot is released
472
- // immediately, and `run()` awaits `drainUploads()` before closing the
473
- // cache. Errors are logged, not propagated: the task already
474
- // succeeded; we don't fail it on cache-server issues.
341
+ const writable = this.lanes.filter((l) => l.write)
342
+ if (writable.length === 0) return
343
+ // Write-through upload, OFF the task's critical path: each PUT runs in
344
+ // its remote's bounded background pool so the task's worker slot is
345
+ // released immediately, and `run()` awaits `drainUploads()` before
346
+ // closing the cache. Errors are logged, not propagated: the task
347
+ // already succeeded; we don't fail it on cache-server issues.
475
348
  //
476
- // The job hands the plugin a file-backed Blob over the local artifact,
349
+ // Each job hands the plugin a file-backed Blob over the local artifact,
477
350
  // pinned under a private name when the job starts (`pinArtifact`): a
478
351
  // queued job holds a path, not a buffer, a plugin that streams the Blob
479
352
  // never holds it whole, and every read of it sees the same bytes though
@@ -493,61 +366,27 @@ export class LayeredCache implements CacheLayer {
493
366
  try {
494
367
  packed = await this.local.packArtifactBytes(args)
495
368
  } catch (err) {
496
- this.reportRemoteError('upload', hash, err)
369
+ for (const lane of writable) lane.report('upload', hash, err)
497
370
  return
498
371
  }
499
372
  }
500
- this.enqueueUpload(async () => {
501
- const tally = this.local.uploads
502
- const start = performance.now()
503
- let release: (() => Promise<void>) | undefined
504
- try {
505
- let body: Blob
506
- if (packed !== undefined) body = new Blob([packed])
507
- else ({ body, release } = this.local.pinArtifact(hash))
508
- await this.remote.put(hash, body, { durationMs })
509
- tally.count++
510
- tally.bytes += body.size
511
- tally.ms += Math.round(performance.now() - start)
512
- } catch (err) {
513
- tally.failed++
514
- this.reportRemoteError('upload', hash, err)
515
- } finally {
516
- await release?.()
517
- }
518
- })
373
+ const body = (): { body: Blob; release?: () => Promise<void> } =>
374
+ packed !== undefined ? { body: new Blob([packed]) } : this.local.pinArtifact(hash)
375
+ for (const lane of writable) lane.upload(hash, durationMs, body, this.local.uploads)
519
376
  }
520
377
 
521
378
  /**
522
- * Resolves once every queued + in-flight background upload settles.
523
- * `run()` calls this next to the prefetch drain, before
379
+ * Resolves once every remote's queued + in-flight background upload
380
+ * settles. `run()` calls this next to the prefetch drain, before
524
381
  * `cache.close()` — an upload reading state after close would race.
525
382
  */
526
383
  async drainUploads(): Promise<void> {
527
- if (this.activeUploads === 0 && this.uploadQueue.length === 0) return
528
- await new Promise<void>((resolve) => this.drainWaiters.push(resolve))
529
- }
530
-
531
- private enqueueUpload(job: () => Promise<void>): void {
532
- this.uploadQueue.push(job)
533
- this.pumpUploads()
384
+ await Promise.all(this.lanes.map((l) => l.drain()))
534
385
  }
535
386
 
536
- private pumpUploads(): void {
537
- while (this.activeUploads < UPLOAD_CONCURRENCY && this.uploadQueue.length > 0) {
538
- const job = this.uploadQueue.shift()!
539
- this.activeUploads++
540
- // Jobs never reject (each wraps its PUT in the never-fail guard).
541
- void job().finally(() => {
542
- this.activeUploads--
543
- this.pumpUploads()
544
- })
545
- }
546
- if (this.activeUploads === 0 && this.uploadQueue.length === 0 && this.drainWaiters.length > 0) {
547
- const waiters = this.drainWaiters
548
- this.drainWaiters = []
549
- for (const w of waiters) w()
550
- }
387
+ /** A stopped run starts no more uploads and stops waiting on those in flight. */
388
+ stopUploads(): void {
389
+ for (const lane of this.lanes) lane.stop()
551
390
  }
552
391
 
553
392
  async ingest(hash: string, body: Blob | Response, meta: IngestMeta): Promise<void> {
@@ -598,55 +437,9 @@ export class LayeredCache implements CacheLayer {
598
437
 
599
438
  /** `run()` drains the uploads and prefetches first, so the repeat counts are final here. */
600
439
  close(): void {
601
- for (const { cause, repeats } of this.reported.values()) {
602
- if (repeats === 0) continue
603
- this.emit(
604
- new Error(
605
- `${repeats} more request${repeats === 1 ? '' : 's'} failed the same way: ${cause}`,
606
- ),
607
- )
608
- }
609
- this.reported.clear()
440
+ for (const lane of this.lanes) lane.close()
610
441
  this.local.close()
611
442
  }
612
-
613
- /**
614
- * `subject` is the artifact's hash, or the batch for a `hasMany`. The
615
- * wire's own message says what went wrong but not on what or where: an
616
- * upload deadline read `The operation timed out.` with no upload, hash or
617
- * server in it (item 749).
618
- */
619
- private reportRemoteError(op: RemoteOp, subject: string | { batch: string }, err: unknown): void {
620
- const e = err instanceof Error ? err : new Error(String(err))
621
- const hash = typeof subject === 'string' ? subject : undefined
622
- const cls = failureClass(e, hash)
623
- const seen = this.reported.get(cls)
624
- if (seen !== undefined) {
625
- seen.repeats++
626
- return
627
- }
628
- this.reported.set(cls, { cause: e.message, repeats: 0 })
629
- const what = typeof subject === 'string' ? subject : subject.batch
630
- const where = this.endpoint === undefined ? '' : ` ${PREPOSITION[op]} ${this.endpoint}`
631
- this.emit(new Error(`${op} ${what}${where} failed: ${e.message}`, { cause: e }))
632
- }
633
-
634
- private emit(e: Error): void {
635
- // The remote cache is fully optional: NO remote failure — a 500, a
636
- // network drop, a corrupt artifact, even a throwing onRemoteError
637
- // callback — may ever fail the run. We report and degrade to a
638
- // cache miss. The callback is guarded so a buggy reporter can't
639
- // turn an optional-cache hiccup into a run failure.
640
- try {
641
- if (this.options.onRemoteError) {
642
- this.options.onRemoteError(e)
643
- } else {
644
- process.stderr.write(`[vx] remote cache: ${e.message}\n`)
645
- }
646
- } catch {
647
- // swallow — reporting must never escalate
648
- }
649
- }
650
443
  }
651
444
 
652
445
  function durationOf(ms: unknown): number {