@vzn/vx 0.0.21 → 0.0.22

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 (123) hide show
  1. package/README.md +21 -18
  2. package/package.json +6 -6
  3. package/src/bin.ts +52 -3
  4. package/src/cache/archive.ts +170 -21
  5. package/src/cache/cache.ts +667 -288
  6. package/src/cache/chained-cache.ts +14 -2
  7. package/src/cache/config-evals.ts +29 -6
  8. package/src/cache/file-hashes.ts +63 -35
  9. package/src/cache/git-inputs.ts +398 -69
  10. package/src/cache/index.ts +8 -1
  11. package/src/cache/inputs.ts +403 -102
  12. package/src/cache/key-fold.ts +232 -0
  13. package/src/cache/layer.ts +81 -11
  14. package/src/cache/layered-cache.ts +199 -32
  15. package/src/cache/output-index.ts +109 -10
  16. package/src/cache/run-history.ts +8 -3
  17. package/src/cache/zstd.ts +53 -30
  18. package/src/cli/cache.ts +51 -21
  19. package/src/cli/format.ts +4 -15
  20. package/src/cli/help.ts +13 -3
  21. package/src/cli/index.ts +35 -9
  22. package/src/cli/info.ts +49 -7
  23. package/src/cli/init.ts +17 -5
  24. package/src/cli/last.ts +96 -21
  25. package/src/cli/lock.ts +34 -17
  26. package/src/cli/plan-format.ts +4 -4
  27. package/src/cli/plugin-commands.ts +20 -5
  28. package/src/cli/run.ts +55 -12
  29. package/src/cli/select.ts +23 -13
  30. package/src/cli/show.ts +9 -4
  31. package/src/cli/upgrade.ts +134 -14
  32. package/src/cli/watch.ts +611 -102
  33. package/src/cli/why.ts +8 -16
  34. package/src/cli/workspace-config.ts +21 -9
  35. package/src/config.ts +46 -9
  36. package/src/exec/executor.ts +51 -5
  37. package/src/exec/index.ts +10 -0
  38. package/src/exec/kill-tree.ts +281 -0
  39. package/src/exec/local-executor.ts +1 -1
  40. package/src/exec/runner.ts +327 -82
  41. package/src/exec/sandbox-binds.ts +34 -5
  42. package/src/exec/sandbox-paths.ts +22 -2
  43. package/src/exec/sandbox-runtime.ts +627 -102
  44. package/src/exec/sandbox-violations.ts +36 -23
  45. package/src/graph/dependency-spec.ts +14 -14
  46. package/src/graph/index.ts +12 -2
  47. package/src/graph/priorities.ts +130 -0
  48. package/src/graph/scheduler.ts +182 -134
  49. package/src/graph/task-graph.ts +552 -188
  50. package/src/index.ts +16 -2
  51. package/src/orchestrator/admission.ts +41 -17
  52. package/src/orchestrator/colors.ts +8 -3
  53. package/src/orchestrator/deferred-outputs.ts +22 -7
  54. package/src/orchestrator/doctor.ts +122 -14
  55. package/src/orchestrator/events.ts +71 -2
  56. package/src/orchestrator/excluded-keys.ts +155 -0
  57. package/src/orchestrator/execute-task.ts +331 -37
  58. package/src/orchestrator/failure-recap.ts +121 -0
  59. package/src/orchestrator/fingerprint-watch.ts +89 -0
  60. package/src/orchestrator/framed-output.ts +81 -14
  61. package/src/orchestrator/history.ts +6 -7
  62. package/src/orchestrator/hit-restore.ts +82 -18
  63. package/src/orchestrator/index.ts +7 -1
  64. package/src/orchestrator/keyed-projects.ts +89 -0
  65. package/src/orchestrator/local-shortcircuit.ts +83 -23
  66. package/src/orchestrator/lockfile-claim.ts +152 -32
  67. package/src/orchestrator/logger.ts +116 -20
  68. package/src/orchestrator/metrics.ts +156 -60
  69. package/src/orchestrator/miss-save.ts +106 -43
  70. package/src/orchestrator/options.ts +30 -4
  71. package/src/orchestrator/persistent.ts +41 -21
  72. package/src/orchestrator/placement.ts +55 -25
  73. package/src/orchestrator/plan.ts +24 -11
  74. package/src/orchestrator/plugin-host.ts +17 -23
  75. package/src/orchestrator/plugin.ts +31 -8
  76. package/src/orchestrator/prepare.ts +99 -13
  77. package/src/orchestrator/projects.ts +19 -8
  78. package/src/orchestrator/remote-prefetch.ts +21 -1
  79. package/src/orchestrator/run-artifacts.ts +9 -0
  80. package/src/orchestrator/run-context.ts +19 -4
  81. package/src/orchestrator/run-lock.ts +349 -0
  82. package/src/orchestrator/run-records.ts +6 -23
  83. package/src/orchestrator/run-report.ts +4 -2
  84. package/src/orchestrator/run.ts +264 -57
  85. package/src/orchestrator/sandbox-request.ts +388 -62
  86. package/src/orchestrator/save-lane.ts +4 -7
  87. package/src/orchestrator/shell-verdict.ts +117 -0
  88. package/src/orchestrator/signals.ts +86 -30
  89. package/src/orchestrator/stable-keys.ts +202 -10
  90. package/src/orchestrator/status-line.ts +33 -1
  91. package/src/orchestrator/summary.ts +49 -2
  92. package/src/orchestrator/task-hash.ts +87 -7
  93. package/src/orchestrator/task-log-buffer.ts +7 -7
  94. package/src/orchestrator/telemetry-host.ts +16 -7
  95. package/src/orchestrator/telemetry.ts +69 -28
  96. package/src/orchestrator/upstream.ts +91 -45
  97. package/src/util/bun-version.ts +43 -0
  98. package/src/util/errors.ts +86 -0
  99. package/src/util/hash.ts +10 -1
  100. package/src/util/index.ts +32 -3
  101. package/src/util/paths.ts +147 -5
  102. package/src/util/procfs.ts +20 -0
  103. package/src/util/settle.ts +4 -2
  104. package/src/util/size.ts +33 -0
  105. package/src/util/task-id.ts +20 -0
  106. package/src/util/timing.ts +11 -1
  107. package/src/util/which.ts +48 -0
  108. package/src/workspace/affected.ts +272 -64
  109. package/src/workspace/config-cache.ts +262 -32
  110. package/src/workspace/config-eval.ts +49 -12
  111. package/src/workspace/config-imports.ts +126 -5
  112. package/src/workspace/config-schema.ts +237 -41
  113. package/src/workspace/filter.ts +33 -18
  114. package/src/workspace/fingerprint.ts +29 -11
  115. package/src/workspace/index.ts +7 -0
  116. package/src/workspace/json-data.ts +90 -0
  117. package/src/workspace/load-reads.ts +32 -0
  118. package/src/workspace/lockfile.ts +33 -11
  119. package/src/workspace/migrate-scripts.ts +60 -7
  120. package/src/workspace/migration.ts +40 -1
  121. package/src/workspace/package-graph.ts +154 -16
  122. package/src/workspace/project-loader.ts +227 -77
  123. package/src/workspace/workspace.ts +173 -23
@@ -103,8 +103,14 @@ export interface VxPlugin {
103
103
  * fingerprint every key sees (the config-evaluation cache still keys on
104
104
  * it — a config may import a dependency), and `--affected` asks
105
105
  * `affected` which projects a change to it touches instead of selecting
106
- * every project. A file has one claimant; a name core does not fold is
107
- * refused, since there is nothing to take out.
106
+ * every project. A file has one claimant.
107
+ *
108
+ * A root file core does NOT fold is claimed the same way when the
109
+ * plugin's stages read it: `turbo()` shapes every task from
110
+ * `turbo.json`, so an edit to it re-keys tasks through their resolved
111
+ * configs while no project owns the path. Nothing is taken out;
112
+ * `--affected` asks `affected` (undefined: every project) and `vx watch`
113
+ * re-runs on an edit (item 961). A claim is a bare name at the root.
108
114
  */
109
115
  readonly fingerprint?: FingerprintClaim
110
116
 
@@ -187,9 +193,13 @@ export interface VxPlugin {
187
193
  | Promise<TelemetrySink | TelemetrySink[] | undefined>
188
194
 
189
195
  /**
190
- * Optional one-time setup before any capability is consulted (validate the
191
- * workspace, open a connection, read a token). Throwing aborts the run with
192
- * a clean UserError naming the plugin — same contract as the old setup().
196
+ * Optional one-time setup per run (validate the workspace, open a
197
+ * connection, read a token). Throwing aborts the run with a clean
198
+ * UserError naming the plugin. It is not a guard over the pipeline: the
199
+ * config, project, cache, graph, key and schedule stages have run by the
200
+ * time it is called, and a plan (`--dry`, `planRun`) never calls it. It
201
+ * precedes the executors, the telemetry sinks, admission and the first
202
+ * task (tests/plugin-e2e.test.ts).
193
203
  */
194
204
  setup?(ctx: PluginSetupContext): void | Promise<void>
195
205
 
@@ -252,7 +262,7 @@ export interface KeyHookContext extends BaseContext {}
252
262
 
253
263
  /** A plugin's claim on workspace fingerprint files — see `VxPlugin.fingerprint`. */
254
264
  export interface FingerprintClaim {
255
- /** Root-relative names from `WORKSPACE_FINGERPRINT_FILES`, e.g. `['pnpm-lock.yaml']`. */
265
+ /** Bare names at the workspace root: a lockfile core folds (`pnpm-lock.yaml`) or a file the plugin reads (`turbo.json`). */
256
266
  readonly files: readonly string[]
257
267
  /**
258
268
  * The projects (package names) a change to a claimed file affects. Called
@@ -308,7 +318,10 @@ export interface PluginContext {
308
318
  readonly workspaceRoot: string
309
319
  /** Where vx's cache lives — read-only as far as the plugin is concerned. */
310
320
  readonly cacheDir: string
311
- /** The run event bus. A plugin can subscribe directly if its needs exceed the hooks. */
321
+ /**
322
+ * The run event bus. A plugin can subscribe directly if its needs exceed
323
+ * the hooks; the subscription ends with the run, as a hook's does.
324
+ */
312
325
  readonly bus: EventBus
313
326
  /**
314
327
  * Convenience: register a typed handler keyed off `RunEvent.kind`.
@@ -460,7 +473,17 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
460
473
  const ctx: PluginContext = {
461
474
  workspaceRoot,
462
475
  cacheDir,
463
- bus,
476
+ // A subscription made past the hooks leaves with the run like a
477
+ // hook's does: the bus can outlive the run (`RunOptions.bus`), and
478
+ // one left behind hears every later run on it (item 635).
479
+ bus: {
480
+ emit: (event) => bus.emit(event),
481
+ subscribe(subscriber) {
482
+ const dispose = bus.subscribe(subscriber)
483
+ disposers.push(dispose)
484
+ return dispose
485
+ },
486
+ },
464
487
  on(hook, handler) {
465
488
  const dispose = bus.subscribe((event) => {
466
489
  if (disabled.has(plugin.name)) return
@@ -28,6 +28,7 @@ import {
28
28
  computeNestedProjectDirs,
29
29
  computeWorkspaceFingerprints,
30
30
  findWorkspaceRoot,
31
+ type LoadReads,
31
32
  listProjects,
32
33
  loadWorkspace,
33
34
  FROZEN_WITHOUT_LOCK,
@@ -37,8 +38,10 @@ import {
37
38
  } from '../workspace/index.js'
38
39
  import {
39
40
  buildTaskGraph,
41
+ excludeDependencies,
40
42
  expandRequested,
41
43
  type TaskNode,
44
+ undeclaredDepsError,
42
45
  unresolvedRequests,
43
46
  } from '../graph/index.js'
44
47
  import {
@@ -50,6 +53,8 @@ import {
50
53
  resolveCache,
51
54
  } from './plugin-host.js'
52
55
  import { loadProjects, loadWorkspacePlugins, type LoadedProjects } from './projects.js'
56
+ import { keyExcludedDependencies } from './excluded-keys.js'
57
+ import { FingerprintWatch } from './fingerprint-watch.js'
53
58
  import type { VxPlugin } from './plugin.js'
54
59
  import { createHashCache, type HashCache } from './task-hash.js'
55
60
  import type { Logger } from './logger.js'
@@ -99,6 +104,8 @@ export interface PreparedRun {
99
104
  */
100
105
  anyProjectConfig: boolean
101
106
  workspaceFingerprint: string
107
+ /** Whether a task has since rewritten what that fingerprint folded (fingerprint-watch.ts). */
108
+ fingerprintWatch: FingerprintWatch
102
109
  nestedDirsByProject: Map<string, string[]>
103
110
  /**
104
111
  * Per-run memo for `git ls-files` output, keyed by project dir.
@@ -143,7 +150,10 @@ export interface PreparedRun {
143
150
  */
144
151
  export async function prepareRun(options: RunOptions, log: Logger): Promise<PreparedRun> {
145
152
  mark('startup')
146
- const workspaceRoot = await findWorkspaceRoot(options.cwd)
153
+ // The root manifest is read once for the root, the globs and the
154
+ // fingerprint. A watch cycle is a new run and reads it afresh.
155
+ const reads: LoadReads = new Map()
156
+ const workspaceRoot = await findWorkspaceRoot(options.cwd, reads)
147
157
  // An UNSCOPED run (no explicit scope, at least one bare task name)
148
158
  // enumerates the whole tree whatever the configs say, so git starts
149
159
  // HERE — the walk needs only the root — and overlaps the workspace
@@ -160,7 +170,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
160
170
  // handler keeps that from surfacing as an unhandled rejection (the real
161
171
  // await further down still sees the error).
162
172
  earlyGit?.catch(() => {})
163
- const workspace = await loadWorkspace(workspaceRoot)
173
+ const workspace = await loadWorkspace(workspaceRoot, reads)
164
174
  const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, (m) =>
165
175
  log.status(m),
166
176
  )
@@ -191,6 +201,11 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
191
201
  }
192
202
  const seeds: 'all' | string[] =
193
203
  options.projects !== undefined ? [...options.projects, ...anchored] : hasBare ? 'all' : anchored
204
+ // Its own mark: the package graph is the bulk of what used to be charged
205
+ // to `open cache` (measured on a 1,000-project tree, 2026-09-20 — the
206
+ // graph 3-8 ms against the cache open's ~1 ms and the fingerprints' ~0.3),
207
+ // so a reader profiling that stage was sent to the wrong code.
208
+ mark('package graph')
194
209
 
195
210
  // The local cache opens BEFORE the configs load: it is also where their
196
211
  // cached evaluations live. `--cache-dir <path>` (RunOptions.cacheDir)
@@ -200,14 +215,22 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
200
215
  const cacheDir = options.cacheDir
201
216
  ? path.resolve(options.cwd, options.cacheDir)
202
217
  : resolveCacheDir(workspaceRoot, workspaceConfig)
203
- const localCache = new Cache(cacheDir, { read: policy.localRead, write: policy.localWrite })
218
+ const localCache = new Cache(
219
+ cacheDir,
220
+ { read: policy.localRead, write: policy.localWrite },
221
+ workspaceRoot,
222
+ options.artifactCeiling,
223
+ )
224
+ localCache.assertWritable()
204
225
  noteSchemaReset(localCache, (m) => log.status(m))
205
226
  // Two digests from one read: the config-evaluation cache keys on every
206
227
  // file (a config may import a dependency), the task keys on the files no
207
228
  // plugin claims (`VxPlugin.fingerprint`).
229
+ const fingerprintsAt = Date.now()
208
230
  const fingerprints = await computeWorkspaceFingerprints(
209
231
  workspaceRoot,
210
232
  new Set(fingerprintClaims(plugins).keys()),
233
+ reads,
211
234
  )
212
235
  const workspaceFingerprint = fingerprints.unclaimed
213
236
  mark('open cache')
@@ -224,19 +247,22 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
224
247
  throw new UserError(FROZEN_WITHOUT_LOCK)
225
248
  }
226
249
 
250
+ const loadArgs = {
251
+ workspaceRoot,
252
+ cacheDir,
253
+ plugins,
254
+ projectMetas,
255
+ packageGraph,
256
+ lock,
257
+ evalCache: { store: localCache, workspaceFingerprint: fingerprints.all },
258
+ warn: (m: string) => log.status(m),
259
+ }
227
260
  let loaded: LoadedProjects
228
261
  try {
229
262
  loaded = await loadProjects({
230
- workspaceRoot,
231
- cacheDir,
232
- plugins,
233
- projectMetas,
234
- packageGraph,
263
+ ...loadArgs,
235
264
  seeds,
236
265
  closure: true,
237
- lock,
238
- evalCache: { store: localCache, workspaceFingerprint: fingerprints.all },
239
- warn: (m) => log.status(m),
240
266
  ...(options.staged !== undefined ? { staged: options.staged } : {}),
241
267
  })
242
268
  } catch (err) {
@@ -319,6 +345,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
319
345
  applyGitEnumeration(enumeration, workspaceRoot, projectDirs, gitFilesCache, usesWorkspaceInputs)
320
346
  mark('git enumeration')
321
347
  const hashCache = createHashCache()
348
+ const fingerprintWatch = new FingerprintWatch(workspaceRoot, fingerprints, fingerprintsAt)
322
349
 
323
350
  // Empty-cases bookkeeping. We still construct the cache + fingerprint
324
351
  // so the caller's try/finally pattern can close it uniformly.
@@ -337,6 +364,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
337
364
  projects,
338
365
  anyProjectConfig: projectsWithConfigs.length > 0,
339
366
  workspaceFingerprint,
367
+ fingerprintWatch,
340
368
  nestedDirsByProject,
341
369
  gitFilesCache,
342
370
  hashCache,
@@ -345,14 +373,26 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
345
373
  }
346
374
  }
347
375
 
376
+ // The whole graph, whatever `--exclude-dependencies` says: the stages
377
+ // below shape and key the tasks it drops as a full run would, because
378
+ // a dropped task is still keyed (excluded-keys.ts). A scoped load is not
379
+ // the whole workspace: a `^name` nothing loaded declares may be declared
380
+ // by a config the scope left out, so the builder hands it back instead of
381
+ // refusing it, and the rest decide.
382
+ const unproven: Array<[taskId: string, name: string]> = []
348
383
  const nodes = buildTaskGraph({
349
384
  projects,
350
385
  packageGraph,
351
386
  requested,
352
- ...(options.excludeDependencies !== undefined
353
- ? { excludeDependencies: options.excludeDependencies }
387
+ ...(projects.size < projectsWithConfigs.length
388
+ ? { undeclaredDeps: (id: string, name: string) => void unproven.push([id, name]) }
354
389
  : {}),
355
390
  })
391
+ if (unproven.length > 0) {
392
+ await refuseUndeclaredDeps(unproven, () =>
393
+ loadProjects({ ...loadArgs, seeds: 'all', closure: false, staged: projects }),
394
+ )
395
+ }
356
396
  // The graph is built; what follows is the plugins' (graph, key,
357
397
  // schedule). Two rows, so a plugin's key stage reads as its own cost
358
398
  // and not as graph building — a lockfile plugin's 1000 stats per run
@@ -369,6 +409,24 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
369
409
  if (hasHook(plugins, 'key')) {
370
410
  await applyKeyHooks(plugins, nodes, { workspaceRoot, cacheDir, warn: (m) => log.status(m) })
371
411
  }
412
+ const exclude = options.excludeDependencies
413
+ if (exclude !== undefined && (exclude === 'all' || exclude.length > 0)) {
414
+ const { keyOnly, dropped } = excludeDependencies(nodes, exclude)
415
+ if (dropped.size > 0) {
416
+ await keyExcludedDependencies({
417
+ nodes,
418
+ keyOnly,
419
+ dropped,
420
+ cache,
421
+ workspaceRoot,
422
+ workspaceFingerprint,
423
+ forwardArgs: options.forwardArgs,
424
+ nestedDirsByProject,
425
+ gitFilesCache,
426
+ hashCache,
427
+ })
428
+ }
429
+ }
372
430
  let priorities: ReadonlyMap<string, number> = new Map()
373
431
  if (hasHook(plugins, 'schedule')) {
374
432
  priorities = await applyScheduleHooks(plugins, nodes, {
@@ -393,6 +451,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
393
451
  projects,
394
452
  anyProjectConfig: projectsWithConfigs.length > 0,
395
453
  workspaceFingerprint,
454
+ fingerprintWatch,
396
455
  nestedDirsByProject,
397
456
  gitFilesCache,
398
457
  hashCache,
@@ -400,3 +459,30 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
400
459
  empty: nodes.size === 0 ? 'empty-graph' : null,
401
460
  }
402
461
  }
462
+
463
+ /**
464
+ * Refuses the first `^name` in `unproven` that no project in the whole
465
+ * workspace declares. `load` evaluates the configs a scoped run left out;
466
+ * it is reached only when a `^name` found no holder and nothing loaded
467
+ * declares it, so a run that names only declared tasks never pays for it.
468
+ * The rest failing to load leaves the names unjudged: an out-of-scope
469
+ * broken config does not fail a scoped run.
470
+ */
471
+ async function refuseUndeclaredDeps(
472
+ unproven: ReadonlyArray<readonly [taskId: string, name: string]>,
473
+ load: () => Promise<LoadedProjects>,
474
+ ): Promise<void> {
475
+ let all: LoadedProjects
476
+ try {
477
+ all = await load()
478
+ } catch {
479
+ return
480
+ }
481
+ const declared = new Set<string>()
482
+ for (const p of all.projects.values()) {
483
+ for (const t of Object.keys(p.config.tasks ?? {})) declared.add(t)
484
+ }
485
+ for (const [id, name] of unproven) {
486
+ if (!declared.has(name)) throw undeclaredDepsError(id, name)
487
+ }
488
+ }
@@ -10,6 +10,7 @@ import {
10
10
  loadProjectConfigs,
11
11
  validateProjectConfig,
12
12
  type LoadProjectConfigOptions,
13
+ type LoadReads,
13
14
  type Lockfile,
14
15
  type PackageGraph,
15
16
  type ProjectEntry,
@@ -113,19 +114,28 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
113
114
  const metaByName = new Map<string, ProjectMeta>(configured.map((m) => [m.name, m]))
114
115
  const needed = new Set<string>()
115
116
  const pending: ProjectMeta[] = []
117
+ // Counted, not read off `pending`: a round empties `pending`, and a guard
118
+ // on its length let every cross edge after the first round walk its
119
+ // whole closure again (1,500 walks at 300 projects, item 932).
120
+ let neededConfigured = 0
116
121
  const consider = (name: string): void => {
117
122
  if (needed.has(name)) return
118
123
  needed.add(name)
119
124
  const meta = metaByName.get(name)
120
- if (meta) pending.push(meta)
125
+ if (meta) {
126
+ pending.push(meta)
127
+ neededConfigured++
128
+ }
121
129
  }
130
+ const walked = new Set<string>()
122
131
  const considerWithDeps = (name: string): void => {
123
132
  consider(name)
124
- // Every config-bearing project already pending: the closure can add
125
- // nothing, and asking for it would build the package graph's
133
+ // Every config-bearing project already considered: the closure can
134
+ // add nothing, and asking for it would build the package graph's
126
135
  // transitive bitsets — a cost the unscoped run (every project a seed)
127
- // otherwise never pays.
128
- if (pending.length === metaByName.size) return
136
+ // otherwise never pays. A closure already walked adds nothing either.
137
+ if (neededConfigured === metaByName.size || walked.has(name)) return
138
+ walked.add(name)
129
139
  for (const dep of packageGraph.transitiveDeps(name)) consider(dep)
130
140
  }
131
141
  const seeds = args.seeds === 'all' ? metaByName.keys() : [...args.seeds]
@@ -231,10 +241,11 @@ export async function loadResolvedProjects(
231
241
  opts: { scope?: 'all' | readonly string[]; warn?: (message: string) => void } = {},
232
242
  ): Promise<Map<string, ProjectEntry>> {
233
243
  const warn = opts.warn ?? ((): void => {})
234
- const metas = await listProjects(await loadWorkspace(workspaceRoot))
244
+ const reads: LoadReads = new Map()
245
+ const metas = await listProjects(await loadWorkspace(workspaceRoot, reads))
235
246
  const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, warn)
236
247
  const cacheDir = resolveCacheDir(workspaceRoot, workspaceConfig)
237
- const cache = new Cache(cacheDir)
248
+ const cache = new Cache(cacheDir, { read: true, write: true }, workspaceRoot)
238
249
  noteSchemaReset(cache, warn)
239
250
  try {
240
251
  const loaded = await loadProjects({
@@ -248,7 +259,7 @@ export async function loadResolvedProjects(
248
259
  lock: null,
249
260
  evalCache: {
250
261
  store: cache,
251
- workspaceFingerprint: await computeWorkspaceFingerprint(workspaceRoot),
262
+ workspaceFingerprint: await computeWorkspaceFingerprint(workspaceRoot, reads),
252
263
  },
253
264
  warn,
254
265
  })
@@ -78,6 +78,11 @@ async function runPrefetch(args: PrefetchArgs): Promise<void> {
78
78
  const present = (await args.cache.remoteHasMany?.(uniqueHashes)) ?? null
79
79
  if (present !== null) {
80
80
  args.cache.markRemoteAbsent?.(uniqueHashes.filter((h) => !present.has(h)))
81
+ // The pre-mark above already answers a pull for an absent hash from
82
+ // the in-flight map, so on the wire this filter is the pump's
83
+ // economy: deleting it alone GETs nothing extra, deleting both GETs
84
+ // every absent key. What it decides is which hashes the pump asks
85
+ // the layer for, and that is what the row pins (item 643, 645).
81
86
  toPrefetch = stableKeys.filter((k) => present.has(k.hash))
82
87
  if (toPrefetch.length === 0) return
83
88
  }
@@ -92,9 +97,24 @@ async function runPrefetch(args: PrefetchArgs): Promise<void> {
92
97
  while (next < toPrefetch.length) {
93
98
  const { hash, node } = toPrefetch[next++]!
94
99
  await args.cache
95
- .prefetch(hash, { taskId: node.id, command: node.config.exec?.command ?? '' })
100
+ .prefetch(hash, getContext(node, node.config.exec?.command ?? ''))
96
101
  .catch(() => false)
97
102
  }
98
103
  }
99
104
  await Promise.all(Array.from({ length: workers }, () => pump()))
100
105
  }
106
+
107
+ /**
108
+ * What a cache lookup is told about the task: its id and command for the
109
+ * row a remote hit inserts, and its declared outputs, the only names a
110
+ * remote artifact may carry (item 942). Shared with execute-task's own
111
+ * lookup, the other one that can reach a remote.
112
+ */
113
+ export function getContext(node: TaskNode, command: string) {
114
+ const outputs = node.config.cache?.outputs
115
+ return {
116
+ taskId: node.id,
117
+ command,
118
+ outputs: { files: outputs?.files ?? [], workspaceFiles: outputs?.workspaceFiles ?? [] },
119
+ }
120
+ }
@@ -64,6 +64,15 @@ function taskEntry(o: TaskOutcome, flaky?: FlakyFinding): Record<string, unknown
64
64
  ...(o.storedPeakRssBytes !== undefined ? { storedPeakRssBytes: o.storedPeakRssBytes } : {}),
65
65
  // Present only when an `admit` policy held the task with a worker free.
66
66
  ...(o.admissionHeldMs !== undefined ? { admissionHeldMs: o.admissionHeldMs } : {}),
67
+ // A skipped row names the failed (or aborted) task at the root of what
68
+ // blocked it; a fail-fast skip has none.
69
+ ...(o.blockedBy !== undefined ? { blockedBy: o.blockedBy } : {}),
70
+ // A failure vx's own `timeout` killed; absent on every other row.
71
+ ...(o.timedOut === true ? { timedOut: true } : {}),
72
+ // The sandbox's violation count; present only on a task it recorded any for.
73
+ ...(o.sandboxViolations !== undefined ? { sandboxViolations: o.sandboxViolations } : {}),
74
+ // A persistent task that never became ready, and why.
75
+ ...(o.notReady !== undefined ? { notReady: o.notReady } : {}),
67
76
  // hrtime spans are bigints → emit as strings so JSON.parse on
68
77
  // the consumer side doesn't truncate the ns precision.
69
78
  ...(o.wallclockStartNs !== undefined ? { wallclockStartNs: String(o.wallclockStartNs) } : {}),
@@ -11,7 +11,7 @@
11
11
  import fs from 'node:fs'
12
12
  import os from 'node:os'
13
13
  import path from 'node:path'
14
- import { ulid, xxh3hex } from '../util/index.js'
14
+ import { executablePath, ulid, xxh3hex } from '../util/index.js'
15
15
 
16
16
  export interface GitContext {
17
17
  /** `git rev-parse HEAD`, or null outside a repo / on failure. */
@@ -65,7 +65,15 @@ export function captureGitContext(
65
65
  }
66
66
  try {
67
67
  const proc = Bun.spawnSync({
68
- cmd: ['git', '-C', workspaceRoot, 'rev-parse', 'HEAD', '--abbrev-ref', 'HEAD'],
68
+ cmd: [
69
+ executablePath('git'),
70
+ '-C',
71
+ workspaceRoot,
72
+ 'rev-parse',
73
+ 'HEAD',
74
+ '--abbrev-ref',
75
+ 'HEAD',
76
+ ],
69
77
  stdout: 'pipe',
70
78
  stderr: 'ignore',
71
79
  })
@@ -205,7 +213,14 @@ export function captureDefaultBranch(
205
213
 
206
214
  try {
207
215
  const proc = Bun.spawnSync({
208
- cmd: ['git', '-C', workspaceRoot, 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'],
216
+ cmd: [
217
+ executablePath('git'),
218
+ '-C',
219
+ workspaceRoot,
220
+ 'symbolic-ref',
221
+ '--short',
222
+ 'refs/remotes/origin/HEAD',
223
+ ],
209
224
  stdout: 'pipe',
210
225
  stderr: 'ignore',
211
226
  })
@@ -310,7 +325,7 @@ export function captureWorkspaceIdentity(workspaceRoot: string): WorkspaceIdenti
310
325
  // rewrites, so two developers mirroring the same repo through
311
326
  // different proxies would derive different workspace ids. The raw
312
327
  // configured URL is the identity.
313
- cmd: ['git', '-C', workspaceRoot, 'config', '--get', 'remote.origin.url'],
328
+ cmd: [executablePath('git'), '-C', workspaceRoot, 'config', '--get', 'remote.origin.url'],
314
329
  stdout: 'pipe',
315
330
  stderr: 'ignore',
316
331
  })