@vzn/vx 0.0.645 → 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 (186) hide show
  1. package/README.md +39 -50
  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 +127 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +19 -3
  12. package/src/bin.ts +54 -13
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +330 -65
  15. package/src/cache/git-inputs.ts +274 -97
  16. package/src/cache/index.ts +7 -2
  17. package/src/cache/inputs.ts +515 -83
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +74 -11
  20. package/src/cache/layered-cache.ts +154 -59
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +44 -14
  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 +100 -15
  26. package/src/cache/run-history.ts +77 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +42 -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/core-alias.ts +5 -2
  34. package/src/cli/docs.ts +42 -16
  35. package/src/cli/help.ts +62 -25
  36. package/src/cli/index.ts +79 -45
  37. package/src/cli/info.ts +12 -6
  38. package/src/cli/init.ts +222 -34
  39. package/src/cli/last.ts +443 -47
  40. package/src/cli/lock.ts +197 -52
  41. package/src/cli/plan-format.ts +91 -49
  42. package/src/cli/plugin-commands.ts +160 -11
  43. package/src/cli/plugin-templates.ts +13 -13
  44. package/src/cli/registry.ts +209 -0
  45. package/src/cli/run.ts +111 -41
  46. package/src/cli/select.ts +148 -49
  47. package/src/cli/show.ts +59 -16
  48. package/src/cli/task-verb.ts +16 -6
  49. package/src/cli/upgrade.ts +155 -54
  50. package/src/cli/watch-cycle.ts +10 -5
  51. package/src/cli/watch-filter.ts +71 -3
  52. package/src/cli/watch-fs.ts +22 -6
  53. package/src/cli/watch-judge.ts +48 -6
  54. package/src/cli/watch.ts +163 -42
  55. package/src/cli/why.ts +211 -73
  56. package/src/cli/workspace-config.ts +54 -15
  57. package/src/config.ts +13 -1
  58. package/src/exec/env.ts +7 -2
  59. package/src/exec/executor.ts +262 -15
  60. package/src/exec/index.ts +8 -2
  61. package/src/exec/kill-tree.ts +15 -0
  62. package/src/exec/local-executor.ts +1 -1
  63. package/src/exec/proc-sample.ts +70 -6
  64. package/src/exec/runner.ts +256 -53
  65. package/src/exec/sandbox-binds.ts +7 -3
  66. package/src/exec/sandbox-paths.ts +1 -1
  67. package/src/exec/sandbox-runtime.ts +222 -28
  68. package/src/exec/sandbox-violations.ts +52 -20
  69. package/src/graph/critical-path.ts +79 -0
  70. package/src/graph/default-build.ts +19 -0
  71. package/src/graph/definition.ts +38 -0
  72. package/src/graph/folds.ts +38 -5
  73. package/src/graph/index.ts +4 -1
  74. package/src/graph/priorities.ts +74 -8
  75. package/src/graph/scheduler.ts +326 -65
  76. package/src/graph/task-graph.ts +228 -35
  77. package/src/handoff.ts +97 -9
  78. package/src/history/failure-mode.ts +6 -3
  79. package/src/history/history.ts +268 -50
  80. package/src/history/index.ts +2 -0
  81. package/src/history/metrics.ts +142 -45
  82. package/src/index.ts +42 -10
  83. package/src/orchestrator/admission.ts +10 -6
  84. package/src/orchestrator/affected-tasks.ts +11 -6
  85. package/src/orchestrator/deferred-outputs.ts +2 -0
  86. package/src/orchestrator/doctor.ts +134 -42
  87. package/src/orchestrator/execute-args.ts +6 -0
  88. package/src/orchestrator/execute-task.ts +155 -42
  89. package/src/orchestrator/fingerprint-watch.ts +1 -1
  90. package/src/orchestrator/graph-memo.ts +140 -0
  91. package/src/orchestrator/graph-stage.ts +18 -1
  92. package/src/orchestrator/held-servers.ts +86 -0
  93. package/src/orchestrator/hit-restore.ts +227 -122
  94. package/src/orchestrator/index.ts +20 -3
  95. package/src/orchestrator/local-floor.ts +198 -0
  96. package/src/orchestrator/local-shortcircuit.ts +51 -8
  97. package/src/orchestrator/lockfile-claim.ts +96 -16
  98. package/src/orchestrator/miss-save.ts +52 -12
  99. package/src/orchestrator/options.ts +8 -6
  100. package/src/orchestrator/output-host.ts +43 -14
  101. package/src/orchestrator/output-log.ts +90 -10
  102. package/src/orchestrator/persistent-task.ts +97 -28
  103. package/src/orchestrator/persistent.ts +16 -4
  104. package/src/orchestrator/placement.ts +257 -14
  105. package/src/orchestrator/plan.ts +64 -7
  106. package/src/orchestrator/plugin-host.ts +230 -40
  107. package/src/orchestrator/plugin.ts +150 -37
  108. package/src/orchestrator/prepare.ts +178 -32
  109. package/src/orchestrator/projects.ts +65 -40
  110. package/src/orchestrator/remote-prefetch.ts +119 -25
  111. package/src/orchestrator/run-artifacts.ts +95 -32
  112. package/src/orchestrator/run-context.ts +39 -41
  113. package/src/orchestrator/run-failures.ts +65 -23
  114. package/src/orchestrator/run-id.ts +6 -3
  115. package/src/orchestrator/run-keys.ts +300 -0
  116. package/src/orchestrator/run-live.ts +294 -0
  117. package/src/orchestrator/run-lock.ts +13 -7
  118. package/src/orchestrator/run-records.ts +35 -23
  119. package/src/orchestrator/run-report.ts +3 -3
  120. package/src/orchestrator/run-steps.ts +58 -22
  121. package/src/orchestrator/run-stop.ts +22 -5
  122. package/src/orchestrator/run.ts +409 -79
  123. package/src/orchestrator/sandbox-fix.ts +7 -5
  124. package/src/orchestrator/sandbox-request.ts +78 -22
  125. package/src/orchestrator/shell-verdict.ts +5 -5
  126. package/src/orchestrator/signals.ts +26 -1
  127. package/src/orchestrator/stable-keys.ts +92 -8
  128. package/src/orchestrator/steps.ts +2 -2
  129. package/src/orchestrator/task-artifacts.ts +206 -0
  130. package/src/orchestrator/task-hash.ts +89 -14
  131. package/src/orchestrator/task-log-buffer.ts +72 -36
  132. package/src/orchestrator/task-process.ts +22 -0
  133. package/src/orchestrator/telemetry-host.ts +24 -3
  134. package/src/orchestrator/telemetry.ts +113 -38
  135. package/src/orchestrator/upstream.ts +15 -0
  136. package/src/report/events.ts +32 -13
  137. package/src/report/forecast.ts +36 -35
  138. package/src/report/framed-output.ts +173 -46
  139. package/src/report/index.ts +1 -0
  140. package/src/report/logger.ts +217 -186
  141. package/src/report/path-links.ts +12 -4
  142. package/src/report/plain-output.ts +13 -5
  143. package/src/report/status-line.ts +94 -46
  144. package/src/report/summary.ts +174 -38
  145. package/src/report/terminal-signals.ts +13 -2
  146. package/src/settings.ts +14 -6
  147. package/src/util/bun-version.ts +4 -1
  148. package/src/util/colors.ts +20 -3
  149. package/src/util/config-frame.ts +41 -4
  150. package/src/{graph → util}/dependency-spec.ts +22 -13
  151. package/src/util/edit-distance.ts +13 -1
  152. package/src/util/env-flag.ts +9 -0
  153. package/src/util/errors.ts +59 -3
  154. package/src/util/essential-env.ts +57 -0
  155. package/src/util/git-ignored.ts +1 -1
  156. package/src/util/hash.ts +35 -0
  157. package/src/util/hide-env.ts +45 -5
  158. package/src/util/index.ts +22 -5
  159. package/src/util/num.ts +8 -0
  160. package/src/util/paths.ts +121 -21
  161. package/src/util/report.ts +6 -1
  162. package/src/util/secret-mask.ts +62 -1
  163. package/src/util/secrets.ts +7 -2
  164. package/src/util/size.ts +11 -3
  165. package/src/util/tail.ts +6 -1
  166. package/src/util/timing.ts +13 -1
  167. package/src/util/ui.ts +65 -4
  168. package/src/util/which.ts +1 -1
  169. package/src/util/write-file.ts +67 -0
  170. package/src/workspace/affected.ts +710 -107
  171. package/src/workspace/bracket-literal.ts +84 -0
  172. package/src/workspace/canonical-dir.ts +37 -0
  173. package/src/workspace/config-bytecode.ts +282 -0
  174. package/src/workspace/config-eval.ts +29 -8
  175. package/src/workspace/config-imports.ts +199 -58
  176. package/src/workspace/config-schema.ts +242 -39
  177. package/src/workspace/filter.ts +37 -8
  178. package/src/workspace/index.ts +11 -2
  179. package/src/workspace/json-data.ts +3 -4
  180. package/src/workspace/lockfile.ts +57 -8
  181. package/src/workspace/migration.ts +72 -49
  182. package/src/workspace/package-graph.ts +5 -4
  183. package/src/workspace/project-loader.ts +314 -47
  184. package/src/workspace/repo-id.ts +1 -1
  185. package/src/workspace/workspace.ts +176 -33
  186. package/src/cli/foreign-flags.ts +0 -727
@@ -18,11 +18,11 @@ const FILE_PREFIX_MTIME = -2
18
18
  import type { Database } from 'bun:sqlite'
19
19
  import { inHashes, lazyStatement } from './schema.js'
20
20
  import { modeMatches } from './umask.js'
21
- import { lstatSync, readlinkSync, statSync } from 'node:fs'
22
- import { lstat, readdir } from 'node:fs/promises'
21
+ import { lstatSync, readdirSync, readlinkSync, statSync } from 'node:fs'
23
22
  import path from 'node:path'
24
23
  import {
25
24
  OUTPUT_DIRS_CAP,
25
+ OUTPUT_DIRS_OVER_CAP,
26
26
  OUTPUT_DIRS_RACY_MS,
27
27
  type OutputDirRow,
28
28
  isIndexFull,
@@ -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.
@@ -262,8 +276,8 @@ export class OutputIndex {
262
276
  * process may have written into them: `holds` is asked with the files
263
277
  * the walk saw, and a tree it refuses records nothing (item 1087).
264
278
  * Symlinked directories are not descended (the output walk refuses them
265
- * too). Over `OUTPUT_DIRS_CAP` directories, or on any error, the rows
266
- * are cleared and the next hit keeps the walk.
279
+ * too). Over `OUTPUT_DIRS_CAP` directories the one row left says so; on
280
+ * any error the rows are cleared. Either way the next hit keeps the walk.
267
281
  */
268
282
  async recordOutputDirs(
269
283
  hash: string,
@@ -271,13 +285,19 @@ 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
- const walk = async (rel: string, isPrefix = false): Promise<boolean> => {
291
+ let overCap = false
292
+ // Synchronous, like the stats of `isOutputsCurrent`: an async lstat and
293
+ // readdir per directory are two thread-pool round trips, and a
294
+ // root-anchored glob's snapshot of solid's 47 directories took 4 ms of
295
+ // the run's end that way (2026-10-22).
296
+ const walk = (rel: string, isPrefix = false): boolean => {
277
297
  const abs = path.join(projectDir, rel)
278
298
  let st
279
299
  try {
280
- st = await lstat(abs)
300
+ st = lstatSync(abs)
281
301
  } catch {
282
302
  // A declared prefix the task never produced (`build/**` beside
283
303
  // `dist/**`, medusa's `.medusa/**`) is recorded ABSENT: the check
@@ -299,29 +319,38 @@ export class OutputIndex {
299
319
  return true
300
320
  }
301
321
  rows.push([rel, st.mtimeMs])
302
- if (rows.length > OUTPUT_DIRS_CAP) return false
322
+ if (rows.length > OUTPUT_DIRS_CAP) {
323
+ overCap = true
324
+ return false
325
+ }
303
326
  let entries
304
327
  try {
305
- entries = await readdir(abs, { withFileTypes: true })
328
+ entries = readdirSync(abs, { withFileTypes: true })
306
329
  } catch {
307
330
  return false
308
331
  }
309
332
  for (const e of entries) {
333
+ // The project itself is the prefix `''` of a root-anchored glob.
334
+ const child = rel === '' ? e.name : `${rel}/${e.name}`
310
335
  if (e.isDirectory() && !e.isSymbolicLink()) {
311
- if (!(await walk(`${rel}/${e.name}`))) return false
336
+ if (!walk(child)) return false
312
337
  } else {
313
- files.push(`${rel}/${e.name}`)
338
+ files.push(child)
314
339
  }
315
340
  }
316
341
  return true
317
342
  }
318
343
  let ok = true
319
344
  for (const prefix of prefixes) {
320
- if (!(await walk(prefix, true))) {
345
+ if (!walk(prefix, true)) {
321
346
  ok = false
322
347
  break
323
348
  }
324
349
  }
350
+ if (overCap) {
351
+ this.pendingDirs.set(hash, [[prefixes[0]!, OUTPUT_DIRS_OVER_CAP]])
352
+ return
353
+ }
325
354
  // All or nothing: a racy directory dropped alone would leave its
326
355
  // parent trusted while an addition inside it bumps only the dropped one.
327
356
  const now = Date.now()
@@ -338,6 +367,7 @@ export class OutputIndex {
338
367
  * Written with the directory snapshots, in one transaction.
339
368
  */
340
369
  recordOutputStamps(hash: string, projectDir: string, workspaceRoot: string): void {
370
+ if (!this.memo) return
341
371
  const rows = this.rowsOf(hash)
342
372
  const stamps: Array<[string, number, number]> = []
343
373
  for (const e of rows) {
@@ -428,7 +458,7 @@ export class OutputIndex {
428
458
  }
429
459
 
430
460
  /** True iff every recorded directory exists with its recorded mtime (ms). Same forged-mtime trade as the file check. */
431
- async outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): Promise<boolean> {
461
+ outputDirsCurrent(projectDir: string, rows: readonly OutputDirRow[]): boolean {
432
462
  if (rows.length === 0) return false
433
463
  const results = rows.map((r) => {
434
464
  let st
@@ -1,5 +1,5 @@
1
1
  // A plugin's own entries in the cache the workspace declares (`ctx.cache`,
2
- // owner 2026-10-14): the local store first, then each remote in declared
2
+ // 2026-10-14): the local store first, then each remote in declared
3
3
  // order, under the run's policy and scope. The plugin speaks names; core
4
4
  // derives the key, so no plugin reaches another's entries or a task's. An
5
5
  // entry is written once and never replaced (the cache is immutable). Every
@@ -7,9 +7,9 @@
7
7
  // warnings): what a plugin keeps here is a hint.
8
8
 
9
9
  import type { Database } from 'bun:sqlite'
10
- import { xxh3hex } from '../util/index.js'
10
+ import { thrownMessage, xxh3hex } from '../util/index.js'
11
11
  import type { CachePolicy } from './policy.js'
12
- import type { ResolvedRemote } from './remote-lane.js'
12
+ import { sayRemoteFault, type ResolvedRemote } from './remote-lane.js'
13
13
  import { scopedKey } from './scoped-cache.js'
14
14
  import { runReadKeys } from './run-store.js'
15
15
 
@@ -73,6 +73,7 @@ async function capped(body: unknown, max = PLUGIN_ENTRY_MAX_BYTES): Promise<Uint
73
73
  if (done) break
74
74
  size += value.byteLength
75
75
  if (size > max) {
76
+ // quiet: the body is refused for its size, said by the miss; its cancel adds nothing
76
77
  await reader.cancel().catch(() => {})
77
78
  return null
78
79
  }
@@ -90,15 +91,30 @@ async function capped(body: unknown, max = PLUGIN_ENTRY_MAX_BYTES): Promise<Uint
90
91
 
91
92
  /**
92
93
  * `plugin`'s view of the cache. The local store never knows a scope
93
- * (owner, 2026-10-17): it keeps every entry under its name's key. A scoped
94
+ * (2026-10-17): it keeps every entry under its name's key. A scoped
94
95
  * run writes a remote only under the scope's key, and reads one key: the
95
- * scope's, or with `scoped` false the default (M, 2026-10-18: no request
96
+ * scope's, or with `scoped` false the default (2026-10-18: no request
96
97
  * twice).
97
98
  */
98
99
  export function pluginCache(plugin: string, host: PluginCacheHost): PluginCache {
99
100
  const { db, policy, scope } = host
100
- const readers = host.remotes.filter((r) => r.read)
101
+ // A plugin's entries are not task artifacts: a policy that reads no
102
+ // remote artifact (a cold run, `--force`) still reads them, so the run is
103
+ // planned on the default branch's timings. A cold CI run asked workers
104
+ // for 128 tasks with 0 measured, at 25% of their cores (2026-10-23).
105
+ const readers = host.remotes.filter((r) => r.read || r.write)
101
106
  const writers = host.remotes.filter((r) => r.write)
107
+ // Each remote's fault said once per kind: unsaid, a remote down left DTE
108
+ // planning on no timings and no line said why.
109
+ const said = new Map<ResolvedRemote, Set<string>>()
110
+ const fault = (r: ResolvedRemote, op: string, what: string, err: unknown): void => {
111
+ const message = thrownMessage(err)
112
+ const seen = said.get(r) ?? new Set<string>()
113
+ said.set(r, seen)
114
+ if (seen.has(`${op}\0${message}`)) return
115
+ seen.add(`${op}\0${message}`)
116
+ sayRemoteFault(r, new Error(`${op} of ${plugin}'s ${what} failed: ${message}`, { cause: err }))
117
+ }
102
118
  const readKey = (name: string, scoped: boolean): string => {
103
119
  const key = entryKey(plugin, name)
104
120
  return scope === undefined || !scoped ? key : scopedKey(scope, key)
@@ -115,6 +131,7 @@ export function pluginCache(plugin: string, host: PluginCacheHost): PluginCache
115
131
  bytes: Uint8Array
116
132
  } | null
117
133
  return row === null ? null : new Uint8Array(row.bytes)
134
+ // quiet: the local index, not a remote: an unreadable one holds nothing
118
135
  } catch {
119
136
  return null
120
137
  }
@@ -127,6 +144,7 @@ export function pluginCache(plugin: string, host: PluginCacheHost): PluginCache
127
144
  .query(`SELECT key FROM plugin_blobs WHERE key IN (${keys.map(() => '?').join(', ')})`)
128
145
  .all(...keys) as Array<{ key: string }>
129
146
  for (const r of rows) found.add(r.key)
147
+ // quiet: the local index, not a remote
130
148
  } catch {
131
149
  // an unreadable index holds nothing
132
150
  }
@@ -139,6 +157,7 @@ export function pluginCache(plugin: string, host: PluginCacheHost): PluginCache
139
157
  bytes,
140
158
  Date.now(),
141
159
  )
160
+ // quiet: the local index, not a remote
142
161
  } catch {
143
162
  // a read-only or busy index keeps nothing
144
163
  }
@@ -150,16 +169,17 @@ export function pluginCache(plugin: string, host: PluginCacheHost): PluginCache
150
169
  const hit = localGet(local)
151
170
  if (hit !== null) return hit
152
171
  const key = readKey(name, scoped)
153
- for (const { remote } of readers) {
172
+ for (const r of readers) {
154
173
  try {
155
- const got = await remote.get(key)
174
+ const got = await r.remote.get(key)
156
175
  if (got === null) continue
157
176
  const bytes = await capped(got.body)
158
177
  if (bytes === null) continue
159
178
  if (policy.localWrite) localPut(local, bytes)
160
179
  return bytes
161
- } catch {
180
+ } catch (err) {
162
181
  // a failing remote is a miss there
182
+ fault(r, 'get', name, err)
163
183
  }
164
184
  }
165
185
  return null
@@ -169,11 +189,12 @@ export function pluginCache(plugin: string, host: PluginCacheHost): PluginCache
169
189
  if (bytes.byteLength === 0 || bytes.byteLength > PLUGIN_ENTRY_MAX_BYTES) return
170
190
  const key = writeKey(name)
171
191
  if (policy.localWrite) localPut(entryKey(plugin, name), bytes)
172
- for (const { remote } of writers) {
192
+ for (const r of writers) {
173
193
  try {
174
- await remote.put(key, new Blob([bytes]), { durationMs: 0 })
175
- } catch {
194
+ await r.remote.put(key, new Blob([bytes]), { durationMs: 0 })
195
+ } catch (err) {
176
196
  // the other layers still keep it
197
+ fault(r, 'put', name, err)
177
198
  }
178
199
  }
179
200
  },
@@ -182,12 +203,13 @@ export function pluginCache(plugin: string, host: PluginCacheHost): PluginCache
182
203
  if (localHas([entryKey(plugin, name)]).size > 0) return true
183
204
  const key = readKey(name, scoped)
184
205
  let answered = policy.localRead
185
- for (const { remote } of readers) {
206
+ for (const r of readers) {
186
207
  try {
187
- if (await remote.has(key)) return true
208
+ if (await r.remote.has(key)) return true
188
209
  answered = true
189
- } catch {
210
+ } catch (err) {
190
211
  // no answer from this remote
212
+ fault(r, 'has', name, err)
191
213
  }
192
214
  }
193
215
  return answered ? false : null
@@ -198,17 +220,20 @@ export function pluginCache(plugin: string, host: PluginCacheHost): PluginCache
198
220
  for (const name of names) byKey.set(readKey(name, scoped), name)
199
221
  const keys = [...byKey.keys()]
200
222
  const found = new Set<string>()
201
- for (const key of localHas(names.map((n) => entryKey(plugin, n)))) found.add(byKey.get(key)!)
202
- for (const { remote } of readers) {
223
+ // The local store keys entries unscoped (`entryKey`), not by `readKey`.
224
+ const byLocal = new Map(names.map((n) => [entryKey(plugin, n), n]))
225
+ for (const key of localHas([...byLocal.keys()])) found.add(byLocal.get(key)!)
226
+ for (const r of readers) {
203
227
  const ask = keys.filter((k) => !found.has(byKey.get(k)!))
204
228
  if (ask.length === 0) break
205
- if (remote.hasMany === undefined) return null
229
+ if (r.remote.hasMany === undefined) return null
206
230
  try {
207
- const got: unknown = await remote.hasMany(ask)
231
+ const got: unknown = await r.remote.hasMany(ask)
208
232
  if (!(got instanceof Map)) return null
209
233
  for (const key of got.keys())
210
234
  if (byKey.has(key as string)) found.add(byKey.get(key as string)!)
211
- } catch {
235
+ } catch (err) {
236
+ fault(r, 'hasMany', `${ask.length} entries`, err)
212
237
  return null
213
238
  }
214
239
  }
@@ -216,15 +241,16 @@ export function pluginCache(plugin: string, host: PluginCacheHost): PluginCache
216
241
  },
217
242
 
218
243
  async getRun(name) {
219
- for (const { remote } of readers) {
244
+ for (const r of readers) {
220
245
  for (const key of runReadKeys(name, scope)) {
221
246
  try {
222
- const got = await remote.get(key)
247
+ const got = await r.remote.get(key)
223
248
  if (got === null) continue
224
249
  const bytes = await capped(got.body, Infinity)
225
250
  if (bytes !== null) return bytes
226
- } catch {
251
+ } catch (err) {
227
252
  // a failing remote is a miss there
253
+ fault(r, 'get', name, err)
228
254
  }
229
255
  }
230
256
  }
@@ -0,0 +1,72 @@
1
+ // What the remotes said they hold, kept across runs and processes: `vx dte
2
+ // plan` and `vx run` are separate commands on GitLab, and a run trusts this
3
+ // at once instead of waiting on a has-many (2026-10-23). Only hits are
4
+ // kept: a key not here is a miss. Every has-many that answers rewrites the
5
+ // keys it asked, and a GET that finds nothing forgets its key.
6
+
7
+ import type { Database } from 'bun:sqlite'
8
+ import { inHashes } from './schema.js'
9
+
10
+ export interface RemoteHit {
11
+ readonly size?: number
12
+ readonly durationMs?: number
13
+ }
14
+
15
+ export class RemoteHits {
16
+ constructor(
17
+ private readonly db: Database,
18
+ /** Off for a reading verb or an index this user cannot write. */
19
+ private readonly write: boolean,
20
+ ) {}
21
+
22
+ /** The keys of `hashes` a remote last said it holds. */
23
+ get(hashes: readonly string[]): Map<string, RemoteHit> {
24
+ const out = new Map<string, RemoteHit>()
25
+ if (hashes.length === 0) return out
26
+ const { test, params } = inHashes(hashes)
27
+ try {
28
+ const rows = this.db
29
+ .query(`SELECT hash, size, duration_ms FROM remote_hits WHERE hash ${test}`)
30
+ .all(...params) as Array<{ hash: string; size: number | null; duration_ms: number | null }>
31
+ for (const r of rows)
32
+ out.set(r.hash, {
33
+ ...(r.size === null ? {} : { size: r.size }),
34
+ ...(r.duration_ms === null ? {} : { durationMs: r.duration_ms }),
35
+ })
36
+ } catch {
37
+ // An unreadable or busy index knows no hit.
38
+ }
39
+ return out
40
+ }
41
+
42
+ /** A has-many's whole answer: `found` are hits, the rest of `asked` no longer are. */
43
+ record(asked: readonly string[], found: ReadonlyMap<string, RemoteHit>, now = Date.now()): void {
44
+ if (!this.write || asked.length === 0) return
45
+ try {
46
+ const upsert = this.db.query(
47
+ 'INSERT OR REPLACE INTO remote_hits(hash, size, duration_ms, seen_at) VALUES (?, ?, ?, ?)',
48
+ )
49
+ const gone = asked.filter((h) => !found.has(h))
50
+ this.db.transaction(() => {
51
+ for (const [hash, info] of found)
52
+ upsert.run(hash, info.size ?? null, info.durationMs ?? null, now)
53
+ if (gone.length > 0) {
54
+ const { test, params } = inHashes(gone)
55
+ this.db.query(`DELETE FROM remote_hits WHERE hash ${test}`).run(...params)
56
+ }
57
+ })()
58
+ } catch {
59
+ // A busy index keeps nothing: the next run asks again.
60
+ }
61
+ }
62
+
63
+ /** No remote served `hash`: a miss from now on. */
64
+ forget(hash: string): void {
65
+ if (!this.write) return
66
+ try {
67
+ this.db.query('DELETE FROM remote_hits WHERE hash = ?').run(hash)
68
+ } catch {
69
+ // A busy index keeps the row: its next GET forgets it.
70
+ }
71
+ }
72
+ }
@@ -4,7 +4,7 @@
4
4
  // class with the endpoint named. The order across remotes, the local store
5
5
  // and provenance are `LayeredCache`'s; nothing here knows another remote.
6
6
 
7
- import { machineParallelism } from '../util/index.js'
7
+ import { machineParallelism, thrownMessage, ui } from '../util/index.js'
8
8
  import type { RemoteKeyTraffic, RemoteTraffic } from './cache.js'
9
9
  import type { ArtifactInfo, RemoteCacheLayer } from './layered-cache.js'
10
10
 
@@ -20,6 +20,19 @@ export interface ResolvedRemote {
20
20
  readonly onError?: (err: Error) => void
21
21
  }
22
22
 
23
+ /**
24
+ * A fault of `r` that a caller outside its lane degrades to a miss, said as
25
+ * the lane says its own: to its `onError`, else stderr. Reporting never
26
+ * escalates.
27
+ */
28
+ export function sayRemoteFault(r: ResolvedRemote, e: Error): void {
29
+ try {
30
+ if (r.onError) r.onError(e)
31
+ else ui.say('warn', `[vx] remote cache: ${e.message}\n`)
32
+ // quiet: a throwing reporter must never fail the run
33
+ } catch {}
34
+ }
35
+
23
36
  /**
24
37
  * Requests at once to a remote, by default: pnpm's network concurrency
25
38
  * (Bun's is a fixed 64). A request waits on the network, not a core, so
@@ -84,7 +97,7 @@ function printableEndpoint(endpoint: unknown): string | undefined {
84
97
 
85
98
  /**
86
99
  * What makes two failures "the same way": the error's `code` when it
87
- * carries one (a gRPC status, an errno, Bun's `ConnectionRefused`), because
100
+ * carries one (a gRPC status, an errno, Bun's `ECONNREFUSED`), because
88
101
  * gRPC writes the elapsed time into its message and no two deadline messages
89
102
  * match; else the message with the artifact's hash taken out, so a 500 on
90
103
  * one hash and a 500 on the next are one class. The operation is not part of
@@ -115,6 +128,8 @@ export class RemoteLane {
115
128
  private readonly absent = new Set<string>()
116
129
  /** What a `hasMany` said this remote holds, as `absent` records the rest. */
117
130
  private readonly held = new Map<string, ArtifactInfo>()
131
+ /** Keys whose bytes here did not read back (`distrust`). */
132
+ private readonly distrusted = new Set<string>()
118
133
 
119
134
  /**
120
135
  * A remote the run writes but does not read (`--force`): which of the
@@ -228,17 +243,63 @@ export class RemoteLane {
228
243
  return out
229
244
  }
230
245
 
231
- /** The artifact's body, or null on a miss, a known absence, a failure or a malformed answer. */
232
- async get(hash: string): Promise<{ body: Blob | Response; durationMs?: number } | null> {
233
- if (this.absent.has(hash)) return null
246
+ /**
247
+ * `hashes` asked one by one (`has`), `concurrency` at a time: what a
248
+ * remote that answered no batch query holds, recording the rest as
249
+ * absent. Null when any probe failed: then the answer is not complete.
250
+ */
251
+ async hasEach(
252
+ hashes: readonly string[],
253
+ concurrency: number,
254
+ ): Promise<Map<string, ArtifactInfo> | null> {
255
+ const out = new Map<string, ArtifactInfo>()
256
+ const absent: string[] = []
257
+ let failed = false
258
+ let next = 0
259
+ const one = async (): Promise<void> => {
260
+ while (!failed && next < hashes.length) {
261
+ const h = hashes[next++]!
262
+ let found: unknown
263
+ try {
264
+ found = this.held.get(h) ?? (this.absent.has(h) ? null : await this.remote.has(h))
265
+ } catch (err) {
266
+ this.report('probe', h, err)
267
+ failed = true
268
+ return
269
+ }
270
+ if (found === null || found === undefined) absent.push(h)
271
+ else out.set(h, artifactInfo(found) ?? {})
272
+ }
273
+ }
274
+ await Promise.all(Array.from({ length: Math.max(1, concurrency) }, one))
275
+ if (failed) return null
276
+ for (const h of absent) this.absent.add(h)
277
+ for (const [h, info] of out) this.held.set(h, info)
278
+ return out
279
+ }
280
+
281
+ /**
282
+ * The artifact's body, null on a miss or a known absence (unless
283
+ * `fresh`: saved since the probe), undefined on a failure or a
284
+ * malformed answer: the key may still be there.
285
+ */
286
+ async get(
287
+ hash: string,
288
+ fresh = false,
289
+ ): Promise<{ body: Blob | Response; durationMs?: number } | null | undefined> {
290
+ if (!fresh && this.absent.has(hash)) return null
234
291
  let result: unknown
235
292
  try {
236
293
  result = await this.remote.get(hash)
237
294
  } catch (err) {
238
295
  this.report('download', hash, err)
296
+ return undefined
297
+ }
298
+ if (!result) {
299
+ // Gone, whatever a probe said: the re-run's save uploads it.
300
+ this.held.delete(hash)
239
301
  return null
240
302
  }
241
- if (!result) return null
242
303
  if (typeof result !== 'object' || !isBody((result as { body?: unknown }).body)) {
243
304
  const shape =
244
305
  typeof result !== 'object'
@@ -251,9 +312,17 @@ export class RemoteLane {
251
312
  `get() resolved ${shape} (expected { body: Blob | Response, durationMs } or null)`,
252
313
  ),
253
314
  )
254
- return null
315
+ return undefined
255
316
  }
256
- return result as { body: Blob | Response; durationMs?: number }
317
+ // It holds the key: a save of it after this restore uploads nothing.
318
+ const got = result as { body: Blob | Response; durationMs?: number }
319
+ this.held.set(hash, got.durationMs === undefined ? {} : { durationMs: got.durationMs })
320
+ return got
321
+ }
322
+
323
+ /** `hash`'s bytes here are unreadable: a save of it uploads, held or not. */
324
+ distrust(hash: string): void {
325
+ this.distrusted.add(hash)
257
326
  }
258
327
 
259
328
  /**
@@ -284,27 +353,41 @@ export class RemoteLane {
284
353
  return (await this.has(hash)) !== null
285
354
  }
286
355
 
287
- /** Queue a PUT in this remote's bounded pool; `body` is asked for when the job starts. */
356
+ /**
357
+ * Queue a PUT in this remote's bounded pool; `body` is asked for when the
358
+ * job starts. Resolves true once this remote holds `hash`: put now, or held already.
359
+ * `absent`: no remote held it when the run asked, so none is asked again.
360
+ */
288
361
  upload(
289
362
  hash: string,
290
363
  durationMs: number,
291
364
  body: () => Promise<{ body: Blob; release?: () => Promise<void> }>,
292
365
  tally: { count: number; bytes: number; ms: number; failed: number },
293
- ): void {
366
+ absent = false,
367
+ ): Promise<boolean> {
294
368
  const key = this.trafficOf(hash)
295
369
  key.uploading++
296
370
  this.traffic.inFlight++
371
+ const { promise, resolve } = Promise.withResolvers<boolean>()
297
372
  this.uploadQueue.push(async () => {
298
373
  let release: (() => Promise<void>) | undefined
374
+ let holds = false
299
375
  try {
300
376
  if (this.stopped) return
301
- if (this.held.has(hash)) return
302
- if (!this.read && (await this.holds(hash))) return
377
+ // A key whose bytes here did not read back is sent over them.
378
+ const distrusted = this.distrusted.has(hash)
379
+ const known = !distrusted && (absent || this.absent.has(hash))
380
+ if (!distrusted) {
381
+ if (this.held.has(hash)) return void (holds = true)
382
+ if (!known && !this.read && (await this.holds(hash))) return void (holds = true)
383
+ }
303
384
  if (this.stopped) return
304
385
  const start = performance.now()
305
386
  const got = await body()
306
387
  release = got.release
307
- await this.remote.put(hash, got.body, { durationMs })
388
+ if (known) this.remote.missing?.(hash)
389
+ if ((await this.remote.put(hash, got.body, { durationMs })) === false) return
390
+ holds = true
308
391
  tally.count++
309
392
  tally.bytes += got.body.size
310
393
  tally.ms += Math.round(performance.now() - start)
@@ -320,9 +403,11 @@ export class RemoteLane {
320
403
  // stopped first): no traffic, so the footer counts no upload.
321
404
  if (key.uploading === 0 && !key.landed && key.failures.length === 0)
322
405
  this.traffic.keys.delete(hash)
406
+ resolve(holds)
323
407
  }
324
408
  })
325
409
  this.pump()
410
+ return promise
326
411
  }
327
412
 
328
413
  /** Settles when the run is stopped. */
@@ -372,7 +457,7 @@ export class RemoteLane {
372
457
  * server in it (item 749).
373
458
  */
374
459
  report(op: RemoteOp, subject: string | { batch: string }, err: unknown): void {
375
- const e = err instanceof Error ? err : new Error(String(err))
460
+ const e = err instanceof Error ? err : new Error(thrownMessage(err))
376
461
  const hash = typeof subject === 'string' ? subject : undefined
377
462
  // A batch probe's failure is no one task's: each of its hashes is asked
378
463
  // again on its own, and that ask is the one counted.
@@ -426,7 +511,7 @@ export class RemoteLane {
426
511
  // hiccup into a run failure.
427
512
  try {
428
513
  if (this.onError) this.onError(e)
429
- else process.stderr.write(`[vx] remote cache: ${e.message}\n`)
514
+ else ui.say('warn', `[vx] remote cache: ${e.message}\n`)
430
515
  } catch {
431
516
  // swallow — reporting must never escalate
432
517
  }