@vzn/vx 0.0.651 → 0.0.653

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/README.md +3 -4
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +15 -20
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +37 -3
  8. package/src/cache/archive.ts +197 -32
  9. package/src/cache/cache.ts +882 -230
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +151 -0
  16. package/src/cache/history-writer.ts +76 -0
  17. package/src/cache/index.ts +12 -1
  18. package/src/cache/inputs.ts +616 -135
  19. package/src/cache/key-fold.ts +1 -1
  20. package/src/cache/layer.ts +29 -16
  21. package/src/cache/layered-cache.ts +28 -7
  22. package/src/cache/lockfile-memo.ts +2 -1
  23. package/src/cache/output-index.ts +106 -44
  24. package/src/cache/plugin-cache.ts +2 -2
  25. package/src/cache/policy.ts +6 -2
  26. package/src/cache/remote-lane.ts +3 -1
  27. package/src/cache/run-history.ts +94 -25
  28. package/src/cache/schema.ts +46 -10
  29. package/src/cli/cache.ts +149 -36
  30. package/src/cli/completions.ts +2 -2
  31. package/src/cli/core-alias.ts +9 -2
  32. package/src/cli/docs.ts +4 -2
  33. package/src/cli/help.ts +27 -4
  34. package/src/cli/index.ts +62 -7
  35. package/src/cli/info.ts +5 -4
  36. package/src/cli/init.ts +55 -20
  37. package/src/cli/last.ts +218 -43
  38. package/src/cli/lock.ts +187 -24
  39. package/src/cli/plan-format.ts +24 -21
  40. package/src/cli/plugin-commands.ts +6 -1
  41. package/src/cli/plugin-templates.ts +36 -45
  42. package/src/cli/run.ts +68 -34
  43. package/src/cli/select.ts +160 -64
  44. package/src/cli/show.ts +70 -28
  45. package/src/cli/task-verb.ts +12 -7
  46. package/src/cli/upgrade.ts +210 -76
  47. package/src/cli/watch-filter.ts +88 -15
  48. package/src/cli/watch-fs.ts +34 -47
  49. package/src/cli/watch-judge.ts +73 -9
  50. package/src/cli/watch-set.ts +10 -8
  51. package/src/cli/watch.ts +190 -33
  52. package/src/cli/why.ts +107 -33
  53. package/src/cli/workspace-config.ts +11 -1
  54. package/src/config.ts +56 -30
  55. package/src/exec/env.ts +3 -1
  56. package/src/exec/executor.ts +116 -16
  57. package/src/exec/index.ts +1 -0
  58. package/src/exec/kill-tree.ts +7 -7
  59. package/src/exec/runner.ts +270 -108
  60. package/src/exec/sandbox-binds.ts +109 -18
  61. package/src/exec/sandbox-paths.ts +3 -1
  62. package/src/exec/sandbox-runtime.ts +446 -64
  63. package/src/exec/sandbox-violations.ts +250 -46
  64. package/src/exec/spawn-off.ts +10 -5
  65. package/src/exec/task-proxy.ts +93 -0
  66. package/src/exec/warm-shell.ts +29 -7
  67. package/src/graph/definition.ts +16 -3
  68. package/src/graph/folds.ts +89 -16
  69. package/src/graph/index.ts +8 -0
  70. package/src/graph/priorities.ts +8 -5
  71. package/src/graph/scheduler.ts +176 -39
  72. package/src/graph/stand-ins.ts +36 -0
  73. package/src/graph/task-graph.ts +347 -160
  74. package/src/graph/task-status.ts +5 -1
  75. package/src/handoff.ts +45 -10
  76. package/src/history/failure-mode.ts +39 -6
  77. package/src/history/history.ts +103 -30
  78. package/src/history/index.ts +2 -0
  79. package/src/history/metrics.ts +193 -73
  80. package/src/index.ts +6 -3
  81. package/src/orchestrator/admission.ts +14 -10
  82. package/src/orchestrator/affected-tasks.ts +35 -13
  83. package/src/orchestrator/command-windows.ts +8 -5
  84. package/src/orchestrator/deferred-outputs.ts +42 -13
  85. package/src/orchestrator/doctor.ts +21 -9
  86. package/src/orchestrator/download-policy.ts +8 -10
  87. package/src/orchestrator/excluded-keys.ts +15 -3
  88. package/src/orchestrator/execute-args.ts +9 -2
  89. package/src/orchestrator/execute-task.ts +612 -138
  90. package/src/orchestrator/graph-memo.ts +26 -4
  91. package/src/orchestrator/graph-stage.ts +27 -4
  92. package/src/orchestrator/held-servers.ts +36 -14
  93. package/src/orchestrator/hit-restore.ts +141 -93
  94. package/src/orchestrator/index.ts +4 -1
  95. package/src/orchestrator/keyed-projects.ts +53 -0
  96. package/src/orchestrator/local-floor.ts +21 -3
  97. package/src/orchestrator/local-shortcircuit.ts +2 -2
  98. package/src/orchestrator/lockfile-claim.ts +30 -4
  99. package/src/orchestrator/miss-save.ts +60 -61
  100. package/src/orchestrator/options.ts +13 -0
  101. package/src/orchestrator/output-host.ts +16 -4
  102. package/src/orchestrator/output-log.ts +1 -54
  103. package/src/orchestrator/persistent-task.ts +32 -16
  104. package/src/orchestrator/persistent.ts +152 -15
  105. package/src/orchestrator/placement.ts +82 -16
  106. package/src/orchestrator/plan.ts +39 -17
  107. package/src/orchestrator/plugin-host.ts +238 -110
  108. package/src/orchestrator/plugin.ts +59 -27
  109. package/src/orchestrator/prepare.ts +194 -38
  110. package/src/orchestrator/projects.ts +89 -10
  111. package/src/orchestrator/read-only.ts +115 -0
  112. package/src/orchestrator/remote-prefetch.ts +1 -1
  113. package/src/orchestrator/run-context.ts +6 -2
  114. package/src/orchestrator/run-failures.ts +101 -23
  115. package/src/orchestrator/run-id.ts +13 -2
  116. package/src/orchestrator/run-keys.ts +87 -28
  117. package/src/orchestrator/run-live.ts +16 -13
  118. package/src/orchestrator/run-lock.ts +27 -4
  119. package/src/orchestrator/run-records.ts +95 -74
  120. package/src/orchestrator/run-report.ts +3 -2
  121. package/src/orchestrator/run-stop.ts +17 -9
  122. package/src/orchestrator/run.ts +531 -122
  123. package/src/orchestrator/sandbox-fix.ts +40 -12
  124. package/src/orchestrator/sandbox-request.ts +314 -44
  125. package/src/orchestrator/save-lane.ts +24 -13
  126. package/src/orchestrator/shell-verdict.ts +29 -1
  127. package/src/orchestrator/signals.ts +103 -8
  128. package/src/orchestrator/stable-keys.ts +34 -27
  129. package/src/orchestrator/task-artifacts.ts +16 -3
  130. package/src/orchestrator/task-hash.ts +191 -34
  131. package/src/orchestrator/telemetry-host.ts +25 -6
  132. package/src/orchestrator/telemetry.ts +85 -13
  133. package/src/orchestrator/upstream.ts +3 -1
  134. package/src/report/events.ts +23 -6
  135. package/src/report/forecast.ts +103 -37
  136. package/src/report/framed-output.ts +179 -162
  137. package/src/report/index.ts +2 -1
  138. package/src/report/logger.ts +108 -31
  139. package/src/report/plain-output.ts +15 -9
  140. package/src/report/status-line.ts +28 -55
  141. package/src/report/summary.ts +61 -18
  142. package/src/settings.ts +50 -14
  143. package/src/ui/components.ts +36 -42
  144. package/src/ui/index.ts +3 -1
  145. package/src/ui/live-region.ts +67 -8
  146. package/src/ui/terminal.ts +12 -0
  147. package/src/ui/theme.ts +16 -1
  148. package/src/util/config-frame.ts +142 -6
  149. package/src/util/edit-distance.ts +17 -1
  150. package/src/util/errors.ts +23 -7
  151. package/src/util/foreign-cache-dir.ts +60 -0
  152. package/src/util/git-ignored.ts +59 -5
  153. package/src/util/index.ts +15 -1
  154. package/src/util/paths.ts +51 -7
  155. package/src/util/plain-text.ts +19 -0
  156. package/src/util/secret-mask.ts +57 -1
  157. package/src/util/settle.ts +18 -1
  158. package/src/util/size.ts +12 -2
  159. package/src/util/tail.ts +4 -1
  160. package/src/util/write-file.ts +86 -9
  161. package/src/workspace/affected.ts +225 -27
  162. package/src/workspace/bracket-literal.ts +2 -2
  163. package/src/workspace/config-bytecode.ts +23 -4
  164. package/src/workspace/config-eval.ts +90 -16
  165. package/src/workspace/config-imports.ts +96 -41
  166. package/src/workspace/config-schema.ts +192 -86
  167. package/src/workspace/dependencies-lock.ts +7 -14
  168. package/src/workspace/filter.ts +18 -2
  169. package/src/workspace/index.ts +3 -1
  170. package/src/workspace/json-data.ts +34 -8
  171. package/src/workspace/lockfile.ts +86 -37
  172. package/src/workspace/migration.ts +58 -31
  173. package/src/workspace/module-state.ts +62 -0
  174. package/src/workspace/nested-dirs.ts +8 -0
  175. package/src/workspace/project-loader.ts +276 -32
  176. package/src/workspace/repo-id.ts +29 -6
  177. package/src/workspace/workspace.ts +190 -30
@@ -4,15 +4,26 @@
4
4
 
5
5
  import type { InputStability } from '../config.js'
6
6
  import type { ProjectEntry } from '../workspace/index.js'
7
- import { EMPTY_LOCK, inputStabilityEnv, LOCK_INVALID, lockEntryBroken } from '../workspace/index.js'
7
+ import {
8
+ EMPTY_LOCK,
9
+ findProjectConfigs,
10
+ inputStabilityEnv,
11
+ LOCK_INVALID,
12
+ lockEntryBroken,
13
+ lockKey,
14
+ readLockfile,
15
+ } from '../workspace/index.js'
8
16
  import { realpathSync } from 'node:fs'
9
17
  import path from 'node:path'
10
18
  import {
19
+ historyFiles,
11
20
  indexDb,
12
21
  Cache,
13
22
  type CachePolicy,
14
23
  cachesNothing,
15
24
  formatCachePolicy,
25
+ holdCacheDir,
26
+ HistoryStream,
16
27
  networkConcurrency,
17
28
  keepRunNotes,
18
29
  stopRuntimeProbes,
@@ -27,11 +38,13 @@ import {
27
38
  VX_RUN_WORKSPACE_ENV,
28
39
  } from '../exec/index.js'
29
40
  import { DeferredOutputs } from './deferred-outputs.js'
41
+ import { workspaceCleanOf } from './task-hash.js'
30
42
  import { resolveDownloadModes } from './download-policy.js'
31
- import type { MachineReport, TaskExecutor } from '../exec/index.js'
43
+ import type { MachineReport, TaskExecutor, WorkerAdvice } from '../exec/index.js'
32
44
  import {
33
45
  deadServerBehind,
34
46
  declaredTask,
47
+ isCacheHit,
35
48
  isGroupTask,
36
49
  markSurfacedDeps,
37
50
  runGraph,
@@ -56,6 +69,7 @@ import {
56
69
  isUserError,
57
70
  SecretBroker,
58
71
  thrownMessage,
72
+ relPosix,
59
73
  } from '../util/index.js'
60
74
  import * as ui from '../ui/index.js'
61
75
 
@@ -65,7 +79,7 @@ function printTimings(): void {
65
79
  if (lines !== undefined) ui.print('err', ui.trace(lines))
66
80
  }
67
81
  import { detectColors, type ColorSupport } from '../ui/index.js'
68
- import { keyedProjects } from './keyed-projects.js'
82
+ import { keyedProjects, nestedDirsOf, readableDeps } from './keyed-projects.js'
69
83
  import { isDefaultBuild } from '../graph/index.js'
70
84
  import { commandWriteReach, prepareSandbox } from './sandbox-request.js'
71
85
  import { recordOutputDirSnapshots, type OutputDirSnapshot, type SaveFacts } from './miss-save.js'
@@ -86,13 +100,16 @@ import {
86
100
  formatPersistentList,
87
101
  formatRunSummary,
88
102
  type Logger,
103
+ neverStarted,
89
104
  plainOutput,
90
105
  resolveOutputView,
106
+ type RunEvent,
91
107
  terminalSubscriber,
92
108
  warnOf,
93
109
  } from '../report/index.js'
94
110
  import { installPlugins, PluginSetupError, type RunReport } from './plugin.js'
95
111
  import { keepsFailures, reportOf, RunKeeper } from './run-report.js'
112
+ import { freezeDeep } from './read-only.js'
96
113
  import {
97
114
  buildAdmission,
98
115
  executorDemand,
@@ -109,9 +126,21 @@ import { isPassStatus } from '../graph/index.js'
109
126
  import type { RunContextRecord } from './telemetry.js'
110
127
  import { plan, type RunPlan } from './plan.js'
111
128
  import type { AffectedReason } from './affected-tasks.js'
112
- import { namesRemote, openRemotes, prepareRun, type PreparedRun } from './prepare.js'
113
- import { acquireRunLock } from './run-lock.js'
114
- import { terminateChildren } from './signals.js'
129
+ import {
130
+ namesRemote,
131
+ openRemotes,
132
+ outputWallsOf,
133
+ prepareRunLocked,
134
+ type PreparedRun,
135
+ } from './prepare.js'
136
+ import { acquireRunLock, runLockPath, type RunLockRelease } from './run-lock.js'
137
+ import {
138
+ exitOfSignal,
139
+ type HeldSignals,
140
+ holdSignals,
141
+ terminateChildren,
142
+ unlessStopped,
143
+ } from './signals.js'
115
144
  import { CYCLE_INTERRUPT, runStop } from './run-stop.js'
116
145
  import {
117
146
  hasPooledExecutor,
@@ -138,14 +167,22 @@ import { deriveStableKeys, probesAfterWrites } from './stable-keys.js'
138
167
  import { askRemote, serversToHold } from './held-servers.js'
139
168
 
140
169
  import { writeRunFailures } from './run-failures.js'
141
- import { assembleRunRecords, recordLateRemoteErrors } from './run-records.js'
170
+ import {
171
+ assembleRunRecords,
172
+ recordLateRemoteErrors,
173
+ type RecordedOutcome,
174
+ runRecordOf,
175
+ } from './run-records.js'
142
176
  import {
143
177
  hasEnded,
144
178
  disposeServers,
145
179
  failServer,
146
180
  holdServers,
147
181
  sessionEnd,
182
+ serverUsers,
183
+ stopServer,
148
184
  takeHeldServers,
185
+ unneededServers,
149
186
  } from './persistent.js'
150
187
  import { serverKey } from './persistent-task.js'
151
188
  import { writeRunProfile } from './run-artifacts.js'
@@ -155,6 +192,7 @@ import {
155
192
  createMissExplainer,
156
193
  detectFlaky,
157
194
  type FlakyCandidate,
195
+ knownFlaky,
158
196
  LocalHistoryProvider,
159
197
  } from '../history/index.js'
160
198
  import type { RunOptions, RunSummary } from './options.js'
@@ -380,11 +418,21 @@ export async function run(options: RunOptions): Promise<RunSummary> {
380
418
  unsubscribeTerminal = bus.subscribe(guardedOutput(claimed, render))
381
419
  }
382
420
  let held = false
421
+ const signals: { held?: HeldSignals } = {}
383
422
  try {
384
423
  // vx's own steps are timed only where something renders them.
385
424
  const showsSteps =
386
425
  terminal !== null ? terminal.showsSteps : options.log?.stepStart !== undefined
387
- const summary = await runOnBus(options, bus, warned, colors, terminal, showsSteps, takeOutput)
426
+ const summary = await runOnBus(
427
+ options,
428
+ bus,
429
+ warned,
430
+ colors,
431
+ terminal,
432
+ showsSteps,
433
+ signals,
434
+ takeOutput,
435
+ )
388
436
  if (summary.persistent === undefined) return summary
389
437
  // Servers handed back still running still write: `vx watch dev` printed
390
438
  // none of its server's log while it idled, the renderer gone with this
@@ -400,6 +448,11 @@ export async function run(options: RunOptions): Promise<RunSummary> {
400
448
  }
401
449
  } finally {
402
450
  if (!held) detach()
451
+ // A signal heard before the run's stop took over: the run left before
452
+ // its tasks (a refusal, a setup that threw), its plugins torn down and
453
+ // its lock released, and vx leaves as that signal says.
454
+ const heard = signals.held?.takeOver()
455
+ if (heard !== undefined) await exitOfSignal(heard)
403
456
  }
404
457
  }
405
458
 
@@ -410,6 +463,7 @@ async function runOnBus(
410
463
  colors: ColorSupport,
411
464
  terminal: DefaultLogger | null,
412
465
  showsSteps: boolean,
466
+ signals: { held?: HeldSignals },
413
467
  takeOutput?: (claimed: { logger: Logger; owner: string }) => void,
414
468
  ): Promise<RunSummary> {
415
469
  const log = busLogger(bus)
@@ -418,17 +472,82 @@ async function runOnBus(
418
472
  // A server's output goes through a cell of its own: a later watch cycle
419
473
  // that keeps the server points it at that cycle's bus (`RunOptions.keep`).
420
474
  const serverOuts = new Map<string, { bus: EventBus }>()
475
+ // What a server writes from ready until its outcome is logged: renderers
476
+ // keep output before the outcome for its frame, which a ready server's
477
+ // row never prints, so its first lines since ready were lost.
478
+ const sinceReady = new Map<string, RunEvent[]>()
421
479
  const serverLog = (id: string): Logger => {
422
480
  const out = { bus }
423
481
  serverOuts.set(id, out)
482
+ const emit = (event: RunEvent): void => {
483
+ const held = sinceReady.get(id)
484
+ if (held === undefined) out.bus.emit(event)
485
+ else held.push(event)
486
+ }
424
487
  return {
425
488
  ...log,
426
- taskStdout: (node, chunk) => out.bus.emit({ kind: 'task:stdout', node, chunk }),
427
- taskStderr: (node, chunk) => out.bus.emit({ kind: 'task:stderr', node, chunk }),
489
+ taskStdout: (node, chunk) => emit({ kind: 'task:stdout', node, chunk }),
490
+ taskStderr: (node, chunk) => emit({ kind: 'task:stderr', node, chunk }),
428
491
  }
429
492
  }
493
+ const releaseSinceReady = (id: string): void => {
494
+ const held = sinceReady.get(id)
495
+ if (held === undefined) return
496
+ sinceReady.delete(id)
497
+ const out = serverOuts.get(id) ?? { bus }
498
+ for (const event of held) out.bus.emit(event)
499
+ }
430
500
 
431
- const prepared = await step('vx:load', () => prepareRun(options, log))
501
+ // Taken before the cache opens, never after: a run that held the store
502
+ // while it waited here, and a `vx cache clean` that holds this lock while
503
+ // it waits for every process with the store open, waited on each other
504
+ // for ever; and a clear deleted the store under a run opening it.
505
+ let heldLock: RunLockRelease | undefined
506
+ const takeRunLock = async (root: string, sharedCacheDir: string | undefined): Promise<void> => {
507
+ // A nested run is refused below with its own reason, holding nothing.
508
+ const outer = process.env[VX_RUN_WORKSPACE_ENV] || undefined
509
+ if (outer !== undefined && canonical(outer) === canonical(root)) return
510
+ // Heard from here, not only from the stop below: a signal between the
511
+ // lock and that stop killed vx with the lock held (`holdSignals`).
512
+ if (options.handleSignals ?? true) signals.held = holdSignals()
513
+ const held = signals.held?.signal
514
+ const signal =
515
+ held !== undefined && options.signal !== undefined
516
+ ? AbortSignal.any([options.signal, held])
517
+ : (held ?? options.signal)
518
+ const lock = await step('vx:run-lock', () => acquireRunLock(root, { log: warnOf(log), signal }))
519
+ heldLock = lock
520
+ // Another workspace's clean of a shared cache directory takes no lock of
521
+ // this one's: it waits for this hold, and this for it. Released with the
522
+ // run lock.
523
+ if (sharedCacheDir !== undefined) {
524
+ const dir = await holdCacheDir(sharedCacheDir, 'shared', warnOf(log), signal)
525
+ heldLock = Object.assign(
526
+ async (): Promise<void> => {
527
+ dir()
528
+ await lock()
529
+ },
530
+ { path: lock.path },
531
+ )
532
+ }
533
+ // A wait the signal ended holds no lock: opening the store now could
534
+ // meet a `vx cache clean` deleting it. run() exits of the signal.
535
+ if (held?.aborted === true) throw new Error(`stopped by ${String(held.reason)}`)
536
+ }
537
+ let prepared: PreparedRun
538
+ try {
539
+ prepared = await step('vx:load', () => prepareRunLocked(options, log, takeRunLock, true))
540
+ } catch (err) {
541
+ await heldLock?.()
542
+ throw err
543
+ }
544
+ const releaseRunLock: RunLockRelease =
545
+ heldLock ?? Object.assign(async () => {}, { path: runLockPath(prepared.workspaceRoot) })
546
+ // The cache and the lock go together on every exit before the run's own.
547
+ const closeUnrun = async (): Promise<void> => {
548
+ prepared.cache.close()
549
+ await releaseRunLock()
550
+ }
432
551
  mark('plugin stages')
433
552
  // Every exit from here tears the plugins down: their capability
434
553
  // factories ran in prepareRun, and teardown is what releases what those
@@ -452,16 +571,15 @@ async function runOnBus(
452
571
  options = withRunSettings(options, prepared.workspaceConfig)
453
572
  inputStability = {
454
573
  run: inputStabilityEnv(),
455
- ...(prepared.workspaceConfig?.cache?.inputStability !== undefined
456
- ? { workspace: prepared.workspaceConfig.cache.inputStability }
574
+ ...(prepared.workspaceConfig?.inputs?.stability !== undefined
575
+ ? { workspace: prepared.workspaceConfig.inputs.stability }
457
576
  : {}),
458
577
  }
459
- cleanOutputs = resolveSetting('cache.outputs.clean', prepared.workspaceConfig).value as
460
- | boolean
461
- | undefined
578
+ // Resolved once, beside the keys that fold it (prepare.ts).
579
+ cleanOutputs = workspaceCleanOf(prepared.hashCache)
462
580
  } catch (err) {
463
581
  await teardown()
464
- prepared.cache.close()
582
+ await closeUnrun()
465
583
  throw err
466
584
  }
467
585
  // A task whose command re-enters `vx run` in the workspace running it
@@ -477,7 +595,7 @@ async function runOnBus(
477
595
  const outerRoot = process.env[VX_RUN_WORKSPACE_ENV] || undefined
478
596
  if (outerRoot !== undefined && canonical(outerRoot) === canonical(prepared.workspaceRoot)) {
479
597
  await teardown()
480
- prepared.cache.close()
598
+ await closeUnrun()
481
599
  throw new UserError(
482
600
  `task ${process.env[VX_RUN_TASK_ENV] ?? '<unknown>'} runs \`vx run\` inside its own workspace: a nested run is invisible to the outer graph (its tasks escape the schedule, the concurrency budget and the cache key) and a loop back to this task forks without bound. Declare what it needs with dependsOn instead.`,
483
601
  )
@@ -493,13 +611,13 @@ async function runOnBus(
493
611
  if (prepared.unresolvedTasks.length > 0) {
494
612
  const refused = `no projects declare task(s): ${prepared.unresolvedTasks.join(', ')}.${await unresolvedHint(prepared)}`
495
613
  await teardown()
496
- prepared.cache.close()
614
+ await closeUnrun()
497
615
  return { ok: false, outcomes: [], refused }
498
616
  }
499
617
  if (prepared.empty === 'none-affected') {
500
618
  log.status(noneAffected(prepared, options))
501
619
  await teardown()
502
- prepared.cache.close()
620
+ await closeUnrun()
503
621
  return { ok: true, outcomes: [] }
504
622
  }
505
623
  if (prepared.empty !== null) {
@@ -512,7 +630,7 @@ async function runOnBus(
512
630
  ? `no projects declare task(s): ${options.tasks.join(', ')}.${elsewhereHint(prepared.declaredElsewhere)}${await initHint(prepared)}`
513
631
  : 'no tasks to run.'
514
632
  await teardown()
515
- prepared.cache.close()
633
+ await closeUnrun()
516
634
  return { ok: false, outcomes: [], refused }
517
635
  }
518
636
  // Install user plugins as additional bus subscribers BEFORE the run
@@ -525,6 +643,7 @@ async function runOnBus(
525
643
  // carries it so analytics queries can group by invocation; plugins get it
526
644
  // in their teardown's `RunReport`.
527
645
  const runId = ulid()
646
+ prepared.localCache.runId = runId
528
647
  // A killed run's restore staging (`.vx/tmp/<pid>-0`), once per run.
529
648
  sweepStaging(prepared.workspaceRoot)
530
649
  const head = captureGitContext(prepared.workspaceRoot)
@@ -544,7 +663,7 @@ async function runOnBus(
544
663
  : new RunKeeper({
545
664
  runId,
546
665
  workspaceRoot: prepared.workspaceRoot,
547
- nestedDirsByProject: prepared.nestedDirsByProject,
666
+ nestedDirsByProject: outputWallsOf(prepared.nestedDirsByProject),
548
667
  keep: prepared.keepRunEntry,
549
668
  failures: keepRun.failures,
550
669
  traffic: prepared.localCache.traffic,
@@ -558,6 +677,7 @@ async function runOnBus(
558
677
  terminal === null ? undefined : () => terminal.holdStatus(),
559
678
  )
560
679
  const remotesClosed = await openRemotes(prepared.remotes, ciContext.ci, secrets)
680
+ signals.held?.arm()
561
681
  try {
562
682
  disposePlugins = await installPlugins({
563
683
  plugins: prepared.plugins,
@@ -570,11 +690,12 @@ async function runOnBus(
570
690
  branch: prepared.branch(),
571
691
  secrets,
572
692
  step,
693
+ signal: signals.held?.signal,
573
694
  })
574
695
  } catch (err) {
575
696
  disposePlugins?.()
576
697
  await teardown(err instanceof PluginSetupError ? err.plugin : undefined)
577
- prepared.cache.close()
698
+ await closeUnrun()
578
699
  throw err
579
700
  }
580
701
  // Until the run's own try below, a throw leaves through here.
@@ -584,7 +705,7 @@ async function runOnBus(
584
705
  } catch (err) {
585
706
  disposePlugins?.()
586
707
  await teardown()
587
- prepared.cache.close()
708
+ await closeUnrun()
588
709
  throw err
589
710
  }
590
711
  }
@@ -612,22 +733,26 @@ async function runOnBus(
612
733
  if (options.signal?.aborted === true) {
613
734
  disposePlugins?.()
614
735
  await teardown()
615
- prepared.cache.close()
736
+ await closeUnrun()
616
737
  return { ok: true, outcomes: [] }
617
738
  }
618
739
  }
619
740
 
620
741
  // Resolved ONCE per run, in declaration order, the local executor last.
621
- // A broken factory aborts here, before any task starts.
742
+ // A broken factory aborts here, before any task starts, and a held
743
+ // signal ends the wait on one that never settles.
622
744
  const executors: readonly TaskExecutor[] = await abandoning(() =>
623
- resolveExecutors(prepared.plugins, {
624
- workspaceRoot: prepared.workspaceRoot,
625
- cacheDir: prepared.cacheDir,
626
- warn: warnOf(log),
627
- concurrency,
628
- artifacts: taskArtifacts(prepared.cache, prepared.workspaceRoot),
629
- cacheFacts: () => cacheFacts(prepared.remotes, prepared.cacheRefs),
630
- }),
745
+ unlessStopped(
746
+ resolveExecutors(prepared.plugins, {
747
+ workspaceRoot: prepared.workspaceRoot,
748
+ cacheDir: prepared.cacheDir,
749
+ warn: warnOf(log),
750
+ concurrency,
751
+ artifacts: taskArtifacts(prepared.cache, prepared.workspaceRoot),
752
+ cacheFacts: () => cacheFacts(prepared.remotes, prepared.cacheRefs),
753
+ }),
754
+ signals.held?.signal,
755
+ ),
631
756
  )
632
757
  // Placement: decided ONCE per task, before scheduling, so the scheduler
633
758
  // can admit a remote-pooled task against its pool instead of a local
@@ -639,15 +764,15 @@ async function runOnBus(
639
764
  // deliberate — on a machine with no remote pool the ambient state already
640
765
  // is what the task would have produced — but it must not be SILENT: a task
641
766
  // reporting success while doing nothing is indistinguishable from one that
642
- // worked, and the usual cause (no `cache` block, so no executor can
767
+ // worked, and the usual cause (no `cache: true`, so no executor can
643
768
  // describe its inputs to a worker) cannot be read off the outcome.
644
769
  if (placements.remoteOnlyNoop.size > 0) {
645
770
  const anyRemote = executors.some((e) => e.remote === true)
646
771
  for (const id of placements.remoteOnlyNoop) {
647
772
  const why = !anyRemote
648
773
  ? 'no remote executor is declared'
649
- : nodes.get(id)?.config.cache === undefined
650
- ? 'no remote executor accepted it — the task declares no `cache` block, so there is no input set to ship'
774
+ : nodes.get(id)?.config.cache !== true
775
+ ? 'no remote executor accepted it — the task has no `cache: true`, so there is no input set to ship'
651
776
  : 'no remote executor accepted it'
652
777
  warnOf(log)(`[vx] ${id}: exec.remote is 'only' and ${why} — nothing ran`)
653
778
  }
@@ -679,26 +804,28 @@ async function runOnBus(
679
804
  // `cache.download` (default `all`) decides ONCE per task, here, whether a
680
805
  // remote execution's outputs come home.
681
806
  const downloadPolicy = options.download ?? 'all'
682
- const localPlaced = locallyPlaced(placements, nodes.keys())
683
- const download = resolveDownloadModes({
684
- nodes,
685
- policy: downloadPolicy,
686
- localPlaced,
687
- remoteOnly: placements.remoteOnly,
688
- })
807
+ const localPlaced = locallyPlaced(placements)
808
+ // `all` keeps every task eager: no per-task map to say so, as `--dry`'s.
809
+ const download =
810
+ downloadPolicy === 'all'
811
+ ? undefined
812
+ : resolveDownloadModes({
813
+ nodes,
814
+ policy: downloadPolicy,
815
+ localPlaced,
816
+ remoteOnly: placements.remoteOnly,
817
+ })
689
818
 
690
819
  // `run.timeout` (ms), for any task WITHOUT its own `exec.timeout`.
691
820
  const taskTimeoutDefault = options.timeout
692
821
 
693
- // The held servers this run took over, spared by a `CYCLE_INTERRUPT`.
694
- const handed = new Set<ReturnType<typeof Bun.spawn>>()
695
822
  const stop = runStop({
696
- handed: (child) => handed.has(child),
697
823
  log,
698
824
  cache,
699
825
  external: options.signal,
700
826
  handleSignals: options.handleSignals ?? true,
701
827
  handsTerminal: holders.size > 0,
828
+ held: signals.held,
702
829
  teardowns: prepared.plugins.filter((p) => p.teardown).length,
703
830
  onStop: () => {
704
831
  log.runStop?.()
@@ -722,6 +849,7 @@ async function runOnBus(
722
849
  const closeCache = async (): Promise<void> => {
723
850
  if (cacheClosed) return
724
851
  cacheClosed = true
852
+ await prepared.localCache.stopCheckpoints()
725
853
  cache.close()
726
854
  live?.unlisten()
727
855
  // Released with the handle: before a persistent task's wait, on every
@@ -733,16 +861,6 @@ async function runOnBus(
733
861
  // waited, but a directory per run is litter the box never sheds.
734
862
  await releaseRunLock()
735
863
  }
736
- // Taken after the early exits above (nothing they do touches a tree) and
737
- // before the schedule: from here on tasks clean, restore and write.
738
- const releaseRunLock = await abandoning(() =>
739
- step('vx:run-lock', () =>
740
- acquireRunLock(prepared.workspaceRoot, {
741
- log: warnOf(log),
742
- signal: stop.signal,
743
- }),
744
- ),
745
- )
746
864
  // Answers `vx last --live` while the lock is held (run-live.ts).
747
865
  live = serveLive({
748
866
  socketPath: liveSocketOf(releaseRunLock.path),
@@ -765,26 +883,17 @@ async function runOnBus(
765
883
  // clean their outputs before every exec for a save that never
766
884
  // happened.
767
885
  const policy: CachePolicy = effectiveCachePolicy(prepared.cachePolicy, remoteCacheEnabled)
768
- // The workspace's `cache.policy` asks as a flag does: it was the one source never told.
769
- const remoteNamed =
770
- options.remoteRequested ??
771
- (options.cache === undefined &&
772
- options.cacheFlags?.noCache !== true &&
773
- namesRemote(prepared.workspaceConfig?.cache?.policy)
774
- ? 'cache.policy'
775
- : undefined)
776
- if (remoteNamed !== undefined && !prepared.hasRemoteLayer) {
777
- warnOf(log)(
778
- `vx: ${remoteNamed} named the remote cache, but no cache plugin in .vx/workspace.* supplies one — the remote axes are off for this run`,
779
- )
780
- }
886
+ warnNoRemote(prepared, options, log)
887
+ const windows = options.commandWindows ?? commandWindows()
781
888
  const deferredOutputs = new DeferredOutputs({
782
889
  nodes,
783
890
  cache,
784
891
  workspaceRoot,
785
- nestedDirsByProject,
892
+ nestedDirsByProject: outputWallsOf(nestedDirsByProject),
786
893
  ...(gitFilesCache !== undefined ? { gitFilesCache } : {}),
787
894
  localWrite: policy.localWrite,
895
+ log,
896
+ commandWindows: windows,
788
897
  })
789
898
 
790
899
  // Per-run context for the Tier-3 `invocations` header row. Captured
@@ -853,7 +962,7 @@ async function runOnBus(
853
962
  // SRT's proxies holding the process open.
854
963
  const surelyRuns = (n: TaskNode): boolean =>
855
964
  n.config.exec?.persistent !== undefined ||
856
- n.config.cache === undefined ||
965
+ n.config.cache !== true ||
857
966
  !(policy.localRead || policy.remoteRead)
858
967
  // Only what runs here arms this machine's sandbox: a `sandbox` executor
859
968
  // enforces it where it runs the task, so a distributed run's
@@ -865,7 +974,7 @@ async function runOnBus(
865
974
  (n) =>
866
975
  n.config.exec?.sandbox !== undefined &&
867
976
  (n.config.exec.persistent === undefined
868
- ? localPlaced.has(n.id)
977
+ ? localPlaced(n.id)
869
978
  : !placements.carried.has(n.id)),
870
979
  )
871
980
  let prewarming: Promise<void> | undefined
@@ -874,15 +983,23 @@ async function runOnBus(
874
983
  }
875
984
  if (sandboxed.some(surelyRuns)) prewarm()
876
985
  const keyed = keyedProjects(nodes, prepared.keyOnly)
986
+ const readable = readableDeps(nodes, prepared.keyOnly)
877
987
  const outputDirSnapshots: OutputDirSnapshot[] = []
878
988
  // Saves run off the execution slot, twice the cap at once (memory:
879
989
  // each pack holds an artifact's bytes); a failed save is a miss next
880
- // time, said once — the task's work ran.
881
- const saveLane = createSaveLane(2 * concurrency, (err) =>
882
- warnOf(log)(`[vx] cache save failed: ${err instanceof Error ? err.message : String(err)}`),
883
- )
990
+ // time, said once — the task's work ran. A full disk fails every save
991
+ // after the first the same way: the first is said with its task, the
992
+ // rest named once the graph ends.
993
+ const unsaved: string[] = []
994
+ const saveLane = createSaveLane(2 * concurrency, (err, taskId) => {
995
+ if (unsaved.push(taskId) > 1) return
996
+ const msg = err instanceof Error ? err.message : String(err)
997
+ // The artifact ceiling's refusal names its task itself.
998
+ warnOf(log)(
999
+ `[vx] cache save failed: ${msg.startsWith(`${taskId} `) ? msg : `${taskId}: ${msg}`}`,
1000
+ )
1001
+ })
884
1002
  const deferredSaves = new Map<string, Promise<SaveFacts>>()
885
- const windows = commandWindows()
886
1003
  // A server held from an earlier run (`vx watch`) runs through this one
887
1004
  // too, so it may write a mutable input while it is described.
888
1005
  for (const id of heldBefore.kept.keys()) {
@@ -924,9 +1041,9 @@ async function runOnBus(
924
1041
  const footers = executors.filter((e) => e.footer !== undefined)
925
1042
  const placers = executors.filter((e) => e.running !== undefined)
926
1043
  // The scheduler's dependents count, and every row's `blocks` (the live
927
- // list's and the results'): one closure over the graph, built on the
928
- // first that asks.
929
- let counted: ReadonlyMap<string, number> | undefined
1044
+ // list's and the results'): one closure over the graph, counted by
1045
+ // `prepareRun` beside git when it could, else on the first that asks.
1046
+ let counted: ReadonlyMap<string, number> | undefined = prepared.dependents
930
1047
  const blocksNow = (): ReadonlyMap<string, number> =>
931
1048
  (counted ??= graphCounts(nodes, hashCache.folds, hashCache.graph))
932
1049
  // Every row's `usual` and the forecast's timings: history read once, on
@@ -1114,7 +1231,7 @@ async function runOnBus(
1114
1231
  (probe.hit !== null ? hits : missed).push(id)
1115
1232
  // An empty store was not asked: it holds none of them.
1116
1233
  if (coldLocal && !remote)
1117
- for (const [id, n] of nodes) if (n.config.cache !== undefined) missed.push(id)
1234
+ for (const [id, n] of nodes) if (n.config.cache === true) missed.push(id)
1118
1235
  demand.settle(hits, missed)
1119
1236
  }
1120
1237
  }
@@ -1139,9 +1256,16 @@ async function runOnBus(
1139
1256
  // dead runs on a failure the outcomes do not hold yet (the server's
1140
1257
  // says `success` until the run ends), so it is seeded here, at its
1141
1258
  // dispatch, and the tracker carries it to what is built on it.
1259
+ // A server can also die under a task that is running (`serverDiedUnder`),
1260
+ // which seeds it then, and so does a task that left a process running
1261
+ // (`leftRunning`).
1142
1262
  const taintSeeds = new Set(excluded.seeds)
1143
- const taint = taintTracker(options.continueMode === 'always', taintSeeds, nodes)
1144
- // Each task that ran tainted, and the task at the root of it.
1263
+ const hasServers = [...nodes.values()].some((n) => n.config.exec?.persistent !== undefined)
1264
+ const continueAlways = options.continueMode === 'always'
1265
+ let taint = taintTracker(continueAlways, taintSeeds, nodes, hasServers)
1266
+ let taintOn = continueAlways || taintSeeds.size > 0 || hasServers
1267
+ // Each task that ran tainted, and the task at the root of it: its
1268
+ // outcome's `taintedBy` once it finishes.
1145
1269
  const taintedRan = new Map<string, string>()
1146
1270
  const depended = dependedOn(nodes.values())
1147
1271
 
@@ -1153,6 +1277,8 @@ async function runOnBus(
1153
1277
  // The scheduler's fail-fast stop, as the retry loop of a task already
1154
1278
  // in flight sees it.
1155
1279
  const failFast = new AbortController()
1280
+ // Tasks a plugin's shared record names flaky (`ctx.flaky`).
1281
+ const sharedFlaky = new Set<string>()
1156
1282
  const scope: RunScope = {
1157
1283
  workspaceRoot,
1158
1284
  cache,
@@ -1168,6 +1294,8 @@ async function runOnBus(
1168
1294
  probesAfterWrites: lateProbes,
1169
1295
  fingerprintWatch,
1170
1296
  keyedProjects: keyed,
1297
+ readableDeps: readable,
1298
+ nestedDirsOf: nestedDirsOf(nodes, prepared.keyOnly, nestedDirsByProject),
1171
1299
  outputDirSnapshots,
1172
1300
  deferSave: saveLane.defer,
1173
1301
  deferredSaves,
@@ -1175,8 +1303,51 @@ async function runOnBus(
1175
1303
  stopSignal: stop.signal,
1176
1304
  failFast: failFast.signal,
1177
1305
  inputStability,
1306
+ serverReady: (id) => void sinceReady.set(id, []),
1307
+ }
1308
+ if (hasServers)
1309
+ scope.serverDiedUnder = async (node) => {
1310
+ const behind = (pick: (id: string) => boolean): string | undefined => {
1311
+ for (const d of node.deps) {
1312
+ const s = deadServerBehind(nodes, pick, d)
1313
+ if (s !== undefined) return s
1314
+ }
1315
+ return undefined
1316
+ }
1317
+ let dead = behind(serverDied)
1318
+ // A server that died just before the task ended may be heard after
1319
+ // it: both exits can land in one turn of the event loop, the task's
1320
+ // handled first and its save decided in that turn's microtasks. The
1321
+ // next turn has handled the rest.
1322
+ if (dead === undefined && behind((id) => persistentRegistry.has(id)) !== undefined) {
1323
+ await new Promise<void>((resolve) => setImmediate(resolve))
1324
+ dead = behind(serverDied)
1325
+ }
1326
+ if (dead === undefined) return false
1327
+ taintSeeds.add(node.id)
1328
+ taintedRan.set(node.id, dead)
1329
+ return true
1330
+ }
1331
+ scope.leftRunning = (node) => {
1332
+ // The check stays off until the first leftover: it is seeded before
1333
+ // the task settles, so every outcome a dependent's judge reads is
1334
+ // recorded by the tracker made here.
1335
+ if (!taintOn) {
1336
+ taint = taintTracker(continueAlways, taintSeeds, nodes, true)
1337
+ taintOn = true
1338
+ }
1339
+ taintSeeds.add(node.id)
1178
1340
  }
1179
1341
  if (options.retries !== undefined) scope.retries = options.retries
1342
+ scope.knownFlaky = (node, hash) => {
1343
+ if (sharedFlaky.has(node.id)) return true
1344
+ try {
1345
+ return knownFlaky(indexDb(prepared.localCache), node.projectName, node.taskName, hash)
1346
+ } catch {
1347
+ // History is observability: an unreadable one retries nothing.
1348
+ return false
1349
+ }
1350
+ }
1180
1351
  if (taskTimeoutDefault !== undefined) scope.timeout = taskTimeoutDefault
1181
1352
  if (cleanOutputs !== undefined) scope.cleanOutputs = cleanOutputs
1182
1353
  const sinkTrack = telemetry?.track
@@ -1190,13 +1361,14 @@ async function runOnBus(
1190
1361
  }
1191
1362
  if (explainMiss !== undefined) scope.explainMiss = explainMiss
1192
1363
  if (keepRun !== undefined) scope.keepRun = keepRun
1364
+ if (options.watch === true) scope.watch = true
1193
1365
  if (sandboxArmer !== null) scope.armSandbox = () => sandboxArmer.arm()
1194
1366
  if (demand.any) scope.executing = demand.executing
1195
1367
  const argsOf = executeArgsOf(scope, {
1196
1368
  log: (node) => (node.config.exec?.persistent !== undefined ? serverLog(node.id) : log),
1197
1369
  placements,
1198
1370
  nestedDirsByProject,
1199
- downloadModeOf: download.modeOf,
1371
+ downloadModeOf: download?.modeOf,
1200
1372
  shortCircuit,
1201
1373
  dependedOn: depended,
1202
1374
  holders,
@@ -1253,6 +1425,9 @@ async function runOnBus(
1253
1425
  if (reprioritize !== undefined) reprioritize(merged)
1254
1426
  else if (!graphOver) refinedEarly = merged
1255
1427
  },
1428
+ flaky: (ids) => {
1429
+ for (const id of ids) if (nodes.has(id)) sharedFlaky.add(id)
1430
+ },
1256
1431
  })
1257
1432
  mark('schedule')
1258
1433
  // Judged as each task finishes, against the history BEFORE this run's
@@ -1262,7 +1437,7 @@ async function runOnBus(
1262
1437
  const historyDb = indexDb(prepared.localCache)
1263
1438
  const cacheOff = cachesNothing(policy)
1264
1439
  const judgeFlaky = (o: TaskOutcome): void => {
1265
- const candidates = flakyCandidates([o], taintedRan)
1440
+ const candidates = flakyCandidates([o])
1266
1441
  if (candidates.length === 0) return
1267
1442
  try {
1268
1443
  const found = detectFlaky(historyDb, candidates)[0]
@@ -1278,7 +1453,6 @@ async function runOnBus(
1278
1453
  heldBefore.kept.delete(node.id)
1279
1454
  if (hasEnded(server.child)) return undefined
1280
1455
  server.handOver()
1281
- handed.add(server.child)
1282
1456
  server.out.bus = bus
1283
1457
  serverOuts.set(node.id, server.out)
1284
1458
  persistentRegistry.set(node.id, server.child)
@@ -1294,6 +1468,76 @@ async function runOnBus(
1294
1468
  }
1295
1469
  let lockBroken: boolean | undefined
1296
1470
  prepared.localCache.storeHeldBlobSizes()
1471
+ prepared.localCache.checkpointOffThread()
1472
+ // A hit's row is whole when it finishes, so it is written while the
1473
+ // graph runs (history-stream.ts); every other row waits for the bundle.
1474
+ // A row with forward args is the bundle's too: only it digests them.
1475
+ const historyAt = historyFiles(prepared.localCache)
1476
+ let expectedHits = 0
1477
+ for (const probe of shortCircuit.preProbed.values()) if (probe.hit !== null) expectedHits++
1478
+ const stream =
1479
+ historyAt === undefined ? undefined : new HistoryStream<TaskOutcome>(historyAt, expectedHits)
1480
+ const forwards = (options.forwardArgs?.length ?? 0) > 0
1481
+ const streamHit = (o: TaskOutcome): void => {
1482
+ if (stream === undefined || isGroupTask(o.node)) return
1483
+ if (!isCacheHit(o.status)) return
1484
+ if (o.wallclockStartNs === undefined || o.wallclockEndNs === undefined) return
1485
+ if (forwards && o.node.requested) return
1486
+ stream.add(
1487
+ o,
1488
+ runRecordOf(o as RecordedOutcome, {
1489
+ runId,
1490
+ startedAtMs: endedAtMsAtStart,
1491
+ endedAtMs: endedAtMsAtStart,
1492
+ blocks: blocksNow(),
1493
+ remoteTraffic: prepared.localCache.traffic.keys,
1494
+ }),
1495
+ )
1496
+ }
1497
+ // A dependency-only server stops once no task left uses it, not at the
1498
+ // end of the graph: it held a worker's cores for nothing. Not under a
1499
+ // hold, whose next cycle may reuse it.
1500
+ const unneeded =
1501
+ options.holdPersistent === true
1502
+ ? undefined
1503
+ : unneededServers(nodes, options.log === undefined && (options.handleSignals ?? true))
1504
+ const serving = executors.filter((e) => e.servers === true && e.unneeded !== undefined)
1505
+ const released = new Set<string>()
1506
+ const stopping: Promise<void>[] = []
1507
+ // A server stops after the servers using it (`serverUsers`), which this
1508
+ // same release reached first.
1509
+ const usersOf = unneeded === undefined ? undefined : serverUsers(nodes)
1510
+ const stops = new Map<string, Promise<void>>()
1511
+ const release = (id: string): void => {
1512
+ // A stop signals every server once and waits out its grace: a second
1513
+ // signal here cut a server's cleanup short.
1514
+ if (stop.signal.aborted) return
1515
+ for (const s of unneeded?.(id) ?? []) {
1516
+ // Copies an executor started beside the dependants it ran.
1517
+ for (const e of serving)
1518
+ try {
1519
+ e.unneeded!(s)
1520
+ } catch {
1521
+ // Its copies stop at its teardown, as before.
1522
+ }
1523
+ const child = persistentRegistry.get(s)
1524
+ // One that died on its own stays: the end of the graph reports it.
1525
+ if (child === undefined || hasEnded(child)) continue
1526
+ released.add(s)
1527
+ persistentRegistry.delete(s)
1528
+ const users: Promise<void>[] = []
1529
+ for (const u of usersOf?.get(s) ?? []) {
1530
+ const p = stops.get(u)
1531
+ if (p !== undefined) users.push(p)
1532
+ }
1533
+ const p =
1534
+ users.length === 0
1535
+ ? stopServer(child)
1536
+ : Promise.allSettled(users).then(() => stopServer(child))
1537
+ stops.set(s, p)
1538
+ stopping.push(p)
1539
+ }
1540
+ }
1297
1541
  const outcomes = await runGraph({
1298
1542
  nodes,
1299
1543
  concurrency,
@@ -1308,21 +1552,30 @@ async function runOnBus(
1308
1552
  onFailFast: () => failFast.abort(),
1309
1553
  signal: stop.signal,
1310
1554
  onStart: (node) => {
1555
+ // A platform skip never starts, like a skip blocked upstream: its
1556
+ // start opened a single task's frame that nothing closed.
1557
+ if (placements.otherPlatform.has(node.id)) return
1311
1558
  log.taskStart?.(node)
1312
1559
  },
1313
1560
  onFinish: (o) => {
1314
1561
  if (cacheOff) o.cacheOff = true
1562
+ const root = taintedRan.get(o.node.id)
1563
+ if (root !== undefined && o.blockedBy === undefined) o.taintedBy = root
1315
1564
  taint.settled(o)
1316
1565
  judgeFlaky(o)
1317
1566
  keeper?.taskDone(o)
1318
1567
  log.taskComplete(o.node, o)
1568
+ if (sinceReady.size > 0) releaseSinceReady(o.node.id)
1569
+ streamHit(o)
1319
1570
  demand.finished(o.node.id)
1571
+ release(o.node.id)
1320
1572
  // A server that dies while the graph runs is no longer running:
1321
1573
  // its pin stayed `running` under the line naming its exit.
1322
1574
  const server = o.status === 'success' ? persistentRegistry.get(o.node.id) : undefined
1323
1575
  if (server !== undefined && o.serverExit === undefined)
1324
1576
  void server.exited.then((code) => {
1325
- if (code !== 0 && !graphOver && !stop.signal.aborted) o.serverExit = code
1577
+ if (code !== 0 && !graphOver && !stop.signal.aborted && !released.has(o.node.id))
1578
+ o.serverExit = code
1326
1579
  })
1327
1580
  },
1328
1581
  // A crash a hand-broken lock entry caused is named as that; asked
@@ -1394,6 +1647,14 @@ async function runOnBus(
1394
1647
  stop.graphDone()
1395
1648
  reprioritize = undefined
1396
1649
  graphOver = true
1650
+ // Each task finishes once its save settles (`settledOf`): the list is whole.
1651
+ if (unsaved.length > 1) {
1652
+ const more = unsaved.slice(1)
1653
+ const named = more.slice(0, 5).join(', ') + (more.length > 5 ? ', …' : '')
1654
+ warnOf(log)(
1655
+ `[vx] cache save failed for ${more.length} more task${more.length === 1 ? '' : 's'}: ${named}`,
1656
+ )
1657
+ }
1397
1658
 
1398
1659
  // Which persistent children outlive the graph, and the bounded SIGTERM
1399
1660
  // of the rest, before the summary prints. Scoped to the real CLI
@@ -1410,6 +1671,7 @@ async function runOnBus(
1410
1671
  // A stop already signalled every server once and waits out its grace:
1411
1672
  // a SIGTERM here, while a server ran its cleanup, cut it short.
1412
1673
  if (stop.signal.aborted) await stop.aborting?.catch(() => {})
1674
+ await Promise.all(stopping)
1413
1675
  const { keepAlive, crashed: crashedPersistent } = await disposeServers({
1414
1676
  registry: persistentRegistry,
1415
1677
  nodes,
@@ -1420,6 +1682,9 @@ async function runOnBus(
1420
1682
  endedBeforeStop: stop.endedBeforeStop,
1421
1683
  })
1422
1684
 
1685
+ // Every batch the stream sent has answered: what landed is not written again.
1686
+ const streamed = await stream?.finish()
1687
+ const landed = new Set(streamed?.landed.map((s) => s.item))
1423
1688
  mark('run graph')
1424
1689
 
1425
1690
  // Clear the status line for good before the summary prints.
@@ -1499,7 +1764,6 @@ async function runOnBus(
1499
1764
  const recordsOf = (final: TaskOutcome[], runOk: boolean) =>
1500
1765
  assembleRunRecords({
1501
1766
  outcomes: final,
1502
- tainted: taintedRan,
1503
1767
  runId,
1504
1768
  startedAtMs: endedAtMsAtStart,
1505
1769
  endedAtMs,
@@ -1520,6 +1784,7 @@ async function runOnBus(
1520
1784
  withTelemetry: telemetry !== undefined,
1521
1785
  blocks: blocksNow(),
1522
1786
  remoteTraffic: prepared.localCache.traffic.keys,
1787
+ landed,
1523
1788
  })
1524
1789
  const records = recordsOf(list, ok)
1525
1790
  const recordHistory = (write: () => void): void => {
@@ -1538,6 +1803,11 @@ async function runOnBus(
1538
1803
  // cache closes: its history is written once the wait has decided, or it
1539
1804
  // read `ok` over the exit 1 and fed the flaky list a pass (X-23).
1540
1805
  const historyAfterWait = keepAlive.children.length > 0 && !hold && stop.stoppedBy === undefined
1806
+ // The servers this run is done with end now; the kept ones once the wait decides.
1807
+ telemetry?.endServers(
1808
+ list,
1809
+ historyAfterWait ? new Set(keepAlive.nodes.map((n) => n.id)) : undefined,
1810
+ )
1541
1811
  // A failed task's output, for `vx last` and an agent (run-failures.ts).
1542
1812
  // A cache directory this user cannot write keeps none of the run's
1543
1813
  // records: the run's one notice said so.
@@ -1558,7 +1828,7 @@ async function runOnBus(
1558
1828
  // measured, or the next run knows none of it (2026-10-21).
1559
1829
  const ended = list.filter((o) => o.status !== 'aborted' && o.status !== 'skipped')
1560
1830
  stepSync('vx:history', () => {
1561
- recordHistory(() => prepared.localCache.recordRuns(recordsOf(ended, false).runs))
1831
+ recordHistory(() => prepared.localCache.recordRuns(recordsOf(ended, false).runs, head))
1562
1832
  recordFailures(ended)
1563
1833
  })
1564
1834
  }
@@ -1603,8 +1873,21 @@ async function runOnBus(
1603
1873
  }
1604
1874
  // An upload that failed after the rows went in: its row says so too.
1605
1875
  if (prepared.localCache.uploads.failed > 0 && stop.stoppedBy === undefined && !historyAfterWait)
1606
- recordHistory(() => recordLateRemoteErrors(prepared.localCache, records.runs, runId))
1607
- const report = reportOf(list, { runId, commit: head.commitSha, ciRun: ciContext, ok }, keeper)
1876
+ recordHistory(() =>
1877
+ recordLateRemoteErrors(
1878
+ prepared.localCache,
1879
+ [...records.runs, ...(streamed?.landed.map((s) => s.record) ?? [])],
1880
+ runId,
1881
+ ),
1882
+ )
1883
+ // A stop passes none (`VxPlugin.teardown`): the run did not reach its end.
1884
+ // Built and frozen only for a teardown that reads it.
1885
+ const report =
1886
+ stop.stoppedBy === undefined && prepared.plugins.some((p) => p.teardown !== undefined)
1887
+ ? freezeDeep(
1888
+ reportOf(list, { runId, commit: head.commitSha, ciRun: ciContext, ok }, keeper),
1889
+ )
1890
+ : undefined
1608
1891
  // Hand the per-run summary to the telemetry sinks + drain them. Only
1609
1892
  // when a sink is active (telemetry !== undefined) — otherwise this
1610
1893
  // whole block is skipped and the run is byte-identical to before.
@@ -1612,22 +1895,37 @@ async function runOnBus(
1612
1895
  // the run; flush is the sink's last chance to ship buffered records.
1613
1896
  // After the drain, so the summary says what the uploads moved; a live
1614
1897
  // sink's task records went out as each task ended.
1615
- if (telemetry !== undefined && runContextRecord !== undefined) {
1616
- const summary = assembleRunSummary(runContextRecord, records.telemetryTasks, {
1898
+ const sinks = telemetry
1899
+ const summarize = async (
1900
+ final: TaskOutcome[],
1901
+ runOk: boolean,
1902
+ timed?: typeof step,
1903
+ ended = { at: endedAtMs, totalMs },
1904
+ ): Promise<void> => {
1905
+ if (sinks === undefined || runContextRecord === undefined) return
1906
+ sinks.endServers(final)
1907
+ const tasks = final === list ? records.telemetryTasks : recordsOf(final, runOk).telemetryTasks
1908
+ const summary = assembleRunSummary(runContextRecord, tasks, {
1617
1909
  startedAt: endedAtMsAtStart,
1618
- endedAt: endedAtMs,
1619
- totalDurationMs: Math.round(totalMs),
1620
- exitOk: ok,
1621
- abortedCount: list.filter((o) => o.status === 'aborted' && !isGroupTask(o.node)).length,
1910
+ endedAt: ended.at,
1911
+ totalDurationMs: Math.round(ended.totalMs),
1912
+ exitOk: runOk,
1913
+ // As the terminal counts them: `1 aborted · 1 not run`.
1914
+ abortedCount: tasks.filter((t) => t.status === 'aborted').length,
1915
+ notRunCount: final.filter((o) => neverStarted(o) && !isGroupTask(o.node)).length,
1622
1916
  stages: stageTimes(),
1623
1917
  uploads: prepared.localCache.uploads,
1624
1918
  machines: executors.flatMap((e) => executorMachines(e, fault)),
1625
1919
  idealMs: executors.map((e) => executorIdeal(e, fault)).find((ms) => ms !== undefined),
1920
+ advice: executors.flatMap((e) => executorAdvice(e, fault)),
1626
1921
  })
1627
- telemetry.emitSummary(summary)
1628
- const sinks = telemetry
1629
- await sinks.flush(step)
1922
+ sinks.emitSummary(summary)
1923
+ await sinks.flush(timed)
1630
1924
  }
1925
+ // A kept server can still fail the run in the wait below: its summary
1926
+ // goes out once the wait has decided, as its history does, or every
1927
+ // sink (vx-ci's job summary) read a pass over the exit 1.
1928
+ if (!historyAfterWait) await summarize(list, ok, step)
1631
1929
  // End-of-run plugin lifecycle: each plugin's teardown(). Crash-isolated
1632
1930
  // + time-bounded inside teardownPlugins, so a faulty plugin can neither
1633
1931
  // fail nor hang the run. Here on the normal path, after the drains
@@ -1738,6 +2036,28 @@ async function runOnBus(
1738
2036
  local.close()
1739
2037
  }
1740
2038
  })
2039
+ // After the footer and the plugins' teardown: no step line there.
2040
+ // The run ended with the session, not with the summary above. A stop
2041
+ // reads as one, as a stop anywhere else does: the servers it took
2042
+ // down are aborted there. The history keeps them as a dev session's
2043
+ // usual end (item 854).
2044
+ const stopped = stop.signal.aborted
2045
+ const told = stopped
2046
+ ? final.map((o) =>
2047
+ keepAlive.nodes.some((n) => n.id === o.node.id) && isPassStatus(o.status)
2048
+ ? {
2049
+ ...o,
2050
+ status: 'aborted' as const,
2051
+ exitCode:
2052
+ typeof first.code === 'number' ? first.code : signalExitCode(first.code),
2053
+ }
2054
+ : o,
2055
+ )
2056
+ : final
2057
+ await summarize(told, ok && !crashed && !stopped, undefined, {
2058
+ at: Date.now(),
2059
+ totalMs: Number(process.hrtime.bigint() - runStartHrTimeNs) / 1_000_000,
2060
+ })
1741
2061
  }
1742
2062
  return { ok: ok && !crashed, outcomes: final }
1743
2063
  }
@@ -1793,16 +2113,38 @@ function noneAffected(prepared: PreparedRun, options: RunOptions): string {
1793
2113
  : `No affected project declares task(s): ${options.tasks.join(', ')}.`
1794
2114
  }
1795
2115
 
2116
+ /**
2117
+ * One line when a policy names the remote cache and no plugin supplies
2118
+ * one; a plan (`--dry`) gets it as the run does. The workspace's
2119
+ * `cache.policy` asks as a flag does: it was the one source never told.
2120
+ */
2121
+ function warnNoRemote(prepared: PreparedRun, options: RunOptions, log: Logger): void {
2122
+ if (prepared.hasRemoteLayer) return
2123
+ const remoteNamed =
2124
+ options.remoteRequested ??
2125
+ (options.cache === undefined &&
2126
+ options.cacheFlags?.noCache !== true &&
2127
+ namesRemote(prepared.workspaceConfig?.cache?.policy)
2128
+ ? 'cache.policy'
2129
+ : undefined)
2130
+ if (remoteNamed === undefined) return
2131
+ warnOf(log)(
2132
+ `vx: ${remoteNamed} named the remote cache, but no cache plugin in .vx/workspace.* supplies one — the remote axes are off for this run`,
2133
+ )
2134
+ }
2135
+
1796
2136
  /**
1797
2137
  * Planning mode. Same setup as `run()` — workspace discovery, config
1798
2138
  * load, package graph, task graph — but stops short of execution.
1799
2139
  * Returns a `RunPlan` predicting the cache hit/miss outcome of every
1800
2140
  * task. Used by `--dry`.
1801
2141
  *
1802
- * Side-effects are limited to opening + closing the local Cache handle
1803
- * (and running `inputs.runtime` probe commands, which key
1804
- * derivation requires). Cache probing is the byte-free `cache.has()`
1805
- * existence check — no artifact download, no ingest, no accessed_at bump.
2142
+ * Side-effects are limited to running `inputs.runtime` probe commands,
2143
+ * which key derivation requires: with no remote the local cache opens to
2144
+ * read only (`prepareRunLocked`'s `planning`); with one, the remote hits
2145
+ * found are kept in the index for the run after. Probing is the
2146
+ * byte-free `cache.has()` existence check — no artifact download, no
2147
+ * ingest, no accessed_at bump.
1806
2148
  */
1807
2149
  export async function planRun(options: RunOptions): Promise<RunPlan> {
1808
2150
  refuseRunOptions(options)
@@ -1810,7 +2152,13 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1810
2152
  // what a stage says on the way goes to stderr (C-6).
1811
2153
  const log = options.log ?? defaultLogger(undefined, undefined, process.stderr)
1812
2154
  const reasons = new Map<string, AffectedReason>()
1813
- const prepared = await prepareRun({ ...options, affectedReasons: reasons }, log)
2155
+ const prepared = await prepareRunLocked(
2156
+ { ...options, affectedReasons: reasons },
2157
+ log,
2158
+ undefined,
2159
+ false,
2160
+ true,
2161
+ )
1814
2162
  try {
1815
2163
  options = withRunSettings(options, prepared.workspaceConfig)
1816
2164
  if (prepared.unresolvedTasks.length > 0) {
@@ -1830,6 +2178,7 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1830
2178
  if (prepared.empty === 'none-affected')
1831
2179
  return { tasks: [], noneAffected: noneAffected(prepared, options) }
1832
2180
  if (prepared.empty !== null) return { tasks: [] }
2181
+ warnNoRemote(prepared, options, log)
1833
2182
  // The plan orders nothing, but it refuses what the run would refuse.
1834
2183
  await prepared.priorities()
1835
2184
  terminalHolders(prepared.nodes, options.tty === true)
@@ -1920,7 +2269,7 @@ async function planRestorable(
1920
2269
  /** The names a project outside the run's selection declares, and how to reach them. */
1921
2270
  function elsewhereHint(names: readonly string[]): string {
1922
2271
  if (names.length === 0) return ''
1923
- return ` Only projects outside the selection declare ${names.join(', ')} — pass --all, or --filter to pick them.`
2272
+ return ` Only projects outside the selection declare ${names.join(', ')} — pass --all, or a --filter that includes them.`
1924
2273
  }
1925
2274
 
1926
2275
  /**
@@ -1929,6 +2278,7 @@ function elsewhereHint(names: readonly string[]): string {
1929
2278
  * from: a task just added to a vx.config is not in it until `vx lock`.
1930
2279
  */
1931
2280
  async function unresolvedHint(prepared: {
2281
+ workspaceRoot: string
1932
2282
  unresolvedTasks: readonly string[]
1933
2283
  hintProjects: ReadonlyMap<string, ProjectEntry>
1934
2284
  declaredElsewhere: readonly string[]
@@ -1942,10 +2292,18 @@ async function unresolvedHint(prepared: {
1942
2292
  prepared.hintProjects,
1943
2293
  prepared.declaredElsewhere,
1944
2294
  prepared.taskless,
2295
+ prepared.workspaceRoot,
1945
2296
  )
1946
2297
  const init = await initHint(prepared)
2298
+ // "No project declares a task yet" beside configs that declare one: the
2299
+ // lock holds none of them (a `vx lock` that failed), so say that.
2300
+ if (near.includes(NO_TASK)) {
2301
+ const unlocked = await unlockedAnywhere(prepared.workspaceRoot)
2302
+ if (unlocked !== undefined)
2303
+ return `${elsewhere}${near.replace(NO_TASK, `The lock holds no project for ${unlocked}: run \`vx lock\` (did it fail?).`)}${init}`
2304
+ }
1947
2305
  const lock =
1948
- init === '' && elsewhere === '' && !near.startsWith(' Did you mean')
2306
+ init === '' && elsewhere === '' && !near.startsWith(' Did you mean') && !near.includes(NO_TASK)
1949
2307
  ? ' A run reads the lock: after editing a vx.config, run `vx lock`.'
1950
2308
  : ''
1951
2309
  return `${elsewhere}${near}${init}${lock}`
@@ -1968,6 +2326,19 @@ async function initHint(prepared: {
1968
2326
  return ` ${EMPTY_LOCK}.`
1969
2327
  }
1970
2328
 
2329
+ /** A project config the lock holds no project for, found by the walk `vx lock` makes. */
2330
+ async function unlockedAnywhere(root: string): Promise<string | undefined> {
2331
+ const [{ configs }, lock] = await Promise.all([findProjectConfigs(root), readLockfile(root)])
2332
+ const held = new Set(Object.keys(lock?.projects ?? {}))
2333
+ const c = configs.find((x) => !held.has(lockKey(root, x.dir)))
2334
+ return c === undefined ? undefined : relPosix(root, c.configPath)
2335
+ }
2336
+
2337
+ // The state right after `vx init`: the lock is current, so the lock hint
2338
+ // alone sent a new user to a command that changed nothing.
2339
+ const NO_TASK =
2340
+ 'No project declares a task yet: add one under `tasks` in a vx.config, then run `vx lock`.'
2341
+
1971
2342
  /**
1972
2343
  * A typo's nearest declared name, when one is within two edits — the
1973
2344
  * message names the fix instead of only the mistake. A bare name is
@@ -1980,13 +2351,17 @@ function didYouMean(
1980
2351
  projects: ReadonlyMap<string, ProjectEntry>,
1981
2352
  elsewhere: readonly string[],
1982
2353
  taskless: (name: string) => boolean,
2354
+ root: string,
1983
2355
  ): string {
1984
- const tasksOf = (p: ProjectEntry | undefined): string[] => Object.keys(p?.config.tasks ?? {})
1985
- // What a bare name can select: a default `build` is no match (X-102).
2356
+ // A default `build` is no declared task (X-102): neither a bare name's
2357
+ // match nor a menu item or hint for `p#typo`, which led to `p#build`
2358
+ // and its refusal ("p declares no build").
2359
+ const tasksOf = (p: ProjectEntry | undefined): string[] =>
2360
+ Object.entries(p?.config.tasks ?? {})
2361
+ .filter(([, c]) => !isDefaultBuild(c))
2362
+ .map(([t]) => t)
1986
2363
  const allTasks = new Set<string>()
1987
- for (const p of projects.values()) {
1988
- for (const t of tasksOf(p)) if (!isDefaultBuild(p.config.tasks![t])) allTasks.add(t)
1989
- }
2364
+ for (const p of projects.values()) for (const t of tasksOf(p)) allTasks.add(t)
1990
2365
  // A Set: two typos of the same task hint it once, not once per typo.
1991
2366
  const hints = new Set<string>()
1992
2367
  // A typo past two edits named nothing to pick from (M-56): with no near
@@ -2005,7 +2380,7 @@ function didYouMean(
2005
2380
  // A name outside the selection is already said, with `--all`.
2006
2381
  else if (allTasks.size > 0 && !elsewhere.includes(spec)) {
2007
2382
  lists.add(`Tasks: ${listed(allTasks)}.`)
2008
- }
2383
+ } else if (allTasks.size === 0 && elsewhere.length === 0) lists.add(NO_TASK)
2009
2384
  continue
2010
2385
  }
2011
2386
  const [proj, task] = [spec.slice(0, at), spec.slice(at + 1)]
@@ -2031,7 +2406,20 @@ function didYouMean(
2031
2406
  continue
2032
2407
  }
2033
2408
  if (!projects.has(proj)) {
2034
- const p = projectNamed(proj, projects) ?? nearest(proj, projects.keys())
2409
+ // NFC typed, NFD on disk (or back): two edits apart, so no hint, and
2410
+ // the list named the project exactly as typed.
2411
+ const form = [...projects.keys()].find((n) => n.normalize('NFC') === proj.normalize('NFC'))
2412
+ if (form !== undefined) {
2413
+ lists.add(
2414
+ `${proj} is not the project ${form}, though they look the same: the two are written in different Unicode forms (NFC and NFD). Set \`name\` in its vx.config to the form you type.`,
2415
+ )
2416
+ continue
2417
+ }
2418
+ // A project's directory first: by edit distance, `p/a` hinted `p/b`.
2419
+ const p =
2420
+ projectNamed(proj, projects) ??
2421
+ projectAtDir(proj, root, projects.values()) ??
2422
+ nearest(proj, projects.keys())
2035
2423
  if (p !== undefined) {
2036
2424
  const tasks = tasksOf(projects.get(p))
2037
2425
  const t = tasks.includes(task) ? task : nearest(task, tasks)
@@ -2087,23 +2475,34 @@ export function projectNamed(
2087
2475
  return scoped.length === 1 ? scoped[0] : undefined
2088
2476
  }
2089
2477
 
2478
+ /**
2479
+ * The project whose directory a typed name is, relative to the workspace
2480
+ * root, as `--filter p/a` reads it: `alpha` for `p/a`.
2481
+ */
2482
+ export function projectAtDir(
2483
+ typed: string,
2484
+ root: string,
2485
+ projects: Iterable<{ name: string; dir: string }>,
2486
+ ): string | undefined {
2487
+ const dir = path.resolve(root, typed)
2488
+ for (const p of projects) if (path.resolve(p.dir) === dir) return p.name
2489
+ return undefined
2490
+ }
2491
+
2090
2492
  /** The executed, keyed outcomes of a run — what flakiness is judged on. */
2091
- function flakyCandidates(
2092
- outcomes: readonly TaskOutcome[],
2093
- tainted: ReadonlyMap<string, string>,
2094
- ): FlakyCandidate[] {
2493
+ function flakyCandidates(outcomes: readonly TaskOutcome[]): FlakyCandidate[] {
2095
2494
  const out: FlakyCandidate[] = []
2096
2495
  for (const o of outcomes) {
2097
2496
  if (o.status !== 'success' && o.status !== 'failed') continue
2098
2497
  // "Same inputs, failed after passing" is a claim only a task with declared
2099
- // inputs can make: a task with no `cache` block keys on its config alone
2498
+ // inputs can make: a task without `cache: true` keys on its config alone
2100
2499
  // and runs every time, so one bad network day would read as a flake for
2101
2500
  // thirty days. Groups do no work.
2102
- if (o.hash === undefined || o.node.config.cache === undefined || isGroupTask(o.node)) continue
2501
+ if (o.hash === undefined || o.node.config.cache !== true || isGroupTask(o.node)) continue
2103
2502
  // Behind a failed dependency the outcome is not the key's: a failure is
2104
2503
  // the dependency's, and a pass built on bytes the key never named, so a
2105
2504
  // later failure relapsed from it and read as a flake for thirty days.
2106
- if (tainted.has(o.node.id)) continue
2505
+ if (o.taintedBy !== undefined) continue
2107
2506
  out.push({
2108
2507
  project: o.node.projectName,
2109
2508
  task: o.node.taskName,
@@ -2184,6 +2583,16 @@ function executorMachines(e: TaskExecutor, fault: ExecutorFault): readonly Machi
2184
2583
  }
2185
2584
  }
2186
2585
 
2586
+ function executorAdvice(e: TaskExecutor, fault: ExecutorFault): readonly WorkerAdvice[] {
2587
+ try {
2588
+ const a = e.advice?.()
2589
+ return Array.isArray(a) ? a : []
2590
+ } catch (err) {
2591
+ fault(e, 'advice', err)
2592
+ return []
2593
+ }
2594
+ }
2595
+
2187
2596
  function executorIdeal(e: TaskExecutor, fault: ExecutorFault): number | undefined {
2188
2597
  try {
2189
2598
  const ms = e.idealMs?.()