@vzn/vx 0.0.645 → 0.0.648

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/README.md +39 -50
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +127 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +19 -3
  12. package/src/bin.ts +54 -13
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +330 -65
  15. package/src/cache/git-inputs.ts +274 -97
  16. package/src/cache/index.ts +7 -2
  17. package/src/cache/inputs.ts +515 -83
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +74 -11
  20. package/src/cache/layered-cache.ts +154 -59
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +44 -14
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +100 -15
  26. package/src/cache/run-history.ts +77 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +42 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/core-alias.ts +5 -2
  34. package/src/cli/docs.ts +42 -16
  35. package/src/cli/help.ts +62 -25
  36. package/src/cli/index.ts +79 -45
  37. package/src/cli/info.ts +12 -6
  38. package/src/cli/init.ts +222 -34
  39. package/src/cli/last.ts +443 -47
  40. package/src/cli/lock.ts +197 -52
  41. package/src/cli/plan-format.ts +91 -49
  42. package/src/cli/plugin-commands.ts +160 -11
  43. package/src/cli/plugin-templates.ts +13 -13
  44. package/src/cli/registry.ts +209 -0
  45. package/src/cli/run.ts +111 -41
  46. package/src/cli/select.ts +148 -49
  47. package/src/cli/show.ts +59 -16
  48. package/src/cli/task-verb.ts +16 -6
  49. package/src/cli/upgrade.ts +155 -54
  50. package/src/cli/watch-cycle.ts +10 -5
  51. package/src/cli/watch-filter.ts +71 -3
  52. package/src/cli/watch-fs.ts +22 -6
  53. package/src/cli/watch-judge.ts +48 -6
  54. package/src/cli/watch.ts +163 -42
  55. package/src/cli/why.ts +211 -73
  56. package/src/cli/workspace-config.ts +54 -15
  57. package/src/config.ts +13 -1
  58. package/src/exec/env.ts +7 -2
  59. package/src/exec/executor.ts +262 -15
  60. package/src/exec/index.ts +8 -2
  61. package/src/exec/kill-tree.ts +15 -0
  62. package/src/exec/local-executor.ts +1 -1
  63. package/src/exec/proc-sample.ts +70 -6
  64. package/src/exec/runner.ts +256 -53
  65. package/src/exec/sandbox-binds.ts +7 -3
  66. package/src/exec/sandbox-paths.ts +1 -1
  67. package/src/exec/sandbox-runtime.ts +222 -28
  68. package/src/exec/sandbox-violations.ts +52 -20
  69. package/src/graph/critical-path.ts +79 -0
  70. package/src/graph/default-build.ts +19 -0
  71. package/src/graph/definition.ts +38 -0
  72. package/src/graph/folds.ts +38 -5
  73. package/src/graph/index.ts +4 -1
  74. package/src/graph/priorities.ts +74 -8
  75. package/src/graph/scheduler.ts +326 -65
  76. package/src/graph/task-graph.ts +228 -35
  77. package/src/handoff.ts +97 -9
  78. package/src/history/failure-mode.ts +6 -3
  79. package/src/history/history.ts +268 -50
  80. package/src/history/index.ts +2 -0
  81. package/src/history/metrics.ts +142 -45
  82. package/src/index.ts +42 -10
  83. package/src/orchestrator/admission.ts +10 -6
  84. package/src/orchestrator/affected-tasks.ts +11 -6
  85. package/src/orchestrator/deferred-outputs.ts +2 -0
  86. package/src/orchestrator/doctor.ts +134 -42
  87. package/src/orchestrator/execute-args.ts +6 -0
  88. package/src/orchestrator/execute-task.ts +155 -42
  89. package/src/orchestrator/fingerprint-watch.ts +1 -1
  90. package/src/orchestrator/graph-memo.ts +140 -0
  91. package/src/orchestrator/graph-stage.ts +18 -1
  92. package/src/orchestrator/held-servers.ts +86 -0
  93. package/src/orchestrator/hit-restore.ts +227 -122
  94. package/src/orchestrator/index.ts +20 -3
  95. package/src/orchestrator/local-floor.ts +198 -0
  96. package/src/orchestrator/local-shortcircuit.ts +51 -8
  97. package/src/orchestrator/lockfile-claim.ts +96 -16
  98. package/src/orchestrator/miss-save.ts +52 -12
  99. package/src/orchestrator/options.ts +8 -6
  100. package/src/orchestrator/output-host.ts +43 -14
  101. package/src/orchestrator/output-log.ts +90 -10
  102. package/src/orchestrator/persistent-task.ts +97 -28
  103. package/src/orchestrator/persistent.ts +16 -4
  104. package/src/orchestrator/placement.ts +257 -14
  105. package/src/orchestrator/plan.ts +64 -7
  106. package/src/orchestrator/plugin-host.ts +230 -40
  107. package/src/orchestrator/plugin.ts +150 -37
  108. package/src/orchestrator/prepare.ts +178 -32
  109. package/src/orchestrator/projects.ts +65 -40
  110. package/src/orchestrator/remote-prefetch.ts +119 -25
  111. package/src/orchestrator/run-artifacts.ts +95 -32
  112. package/src/orchestrator/run-context.ts +39 -41
  113. package/src/orchestrator/run-failures.ts +65 -23
  114. package/src/orchestrator/run-id.ts +6 -3
  115. package/src/orchestrator/run-keys.ts +300 -0
  116. package/src/orchestrator/run-live.ts +294 -0
  117. package/src/orchestrator/run-lock.ts +13 -7
  118. package/src/orchestrator/run-records.ts +35 -23
  119. package/src/orchestrator/run-report.ts +3 -3
  120. package/src/orchestrator/run-steps.ts +58 -22
  121. package/src/orchestrator/run-stop.ts +22 -5
  122. package/src/orchestrator/run.ts +409 -79
  123. package/src/orchestrator/sandbox-fix.ts +7 -5
  124. package/src/orchestrator/sandbox-request.ts +78 -22
  125. package/src/orchestrator/shell-verdict.ts +5 -5
  126. package/src/orchestrator/signals.ts +26 -1
  127. package/src/orchestrator/stable-keys.ts +92 -8
  128. package/src/orchestrator/steps.ts +2 -2
  129. package/src/orchestrator/task-artifacts.ts +206 -0
  130. package/src/orchestrator/task-hash.ts +89 -14
  131. package/src/orchestrator/task-log-buffer.ts +72 -36
  132. package/src/orchestrator/task-process.ts +22 -0
  133. package/src/orchestrator/telemetry-host.ts +24 -3
  134. package/src/orchestrator/telemetry.ts +113 -38
  135. package/src/orchestrator/upstream.ts +15 -0
  136. package/src/report/events.ts +32 -13
  137. package/src/report/forecast.ts +36 -35
  138. package/src/report/framed-output.ts +173 -46
  139. package/src/report/index.ts +1 -0
  140. package/src/report/logger.ts +217 -186
  141. package/src/report/path-links.ts +12 -4
  142. package/src/report/plain-output.ts +13 -5
  143. package/src/report/status-line.ts +94 -46
  144. package/src/report/summary.ts +174 -38
  145. package/src/report/terminal-signals.ts +13 -2
  146. package/src/settings.ts +14 -6
  147. package/src/util/bun-version.ts +4 -1
  148. package/src/util/colors.ts +20 -3
  149. package/src/util/config-frame.ts +41 -4
  150. package/src/{graph → util}/dependency-spec.ts +22 -13
  151. package/src/util/edit-distance.ts +13 -1
  152. package/src/util/env-flag.ts +9 -0
  153. package/src/util/errors.ts +59 -3
  154. package/src/util/essential-env.ts +57 -0
  155. package/src/util/git-ignored.ts +1 -1
  156. package/src/util/hash.ts +35 -0
  157. package/src/util/hide-env.ts +45 -5
  158. package/src/util/index.ts +22 -5
  159. package/src/util/num.ts +8 -0
  160. package/src/util/paths.ts +121 -21
  161. package/src/util/report.ts +6 -1
  162. package/src/util/secret-mask.ts +62 -1
  163. package/src/util/secrets.ts +7 -2
  164. package/src/util/size.ts +11 -3
  165. package/src/util/tail.ts +6 -1
  166. package/src/util/timing.ts +13 -1
  167. package/src/util/ui.ts +65 -4
  168. package/src/util/which.ts +1 -1
  169. package/src/util/write-file.ts +67 -0
  170. package/src/workspace/affected.ts +710 -107
  171. package/src/workspace/bracket-literal.ts +84 -0
  172. package/src/workspace/canonical-dir.ts +37 -0
  173. package/src/workspace/config-bytecode.ts +282 -0
  174. package/src/workspace/config-eval.ts +29 -8
  175. package/src/workspace/config-imports.ts +199 -58
  176. package/src/workspace/config-schema.ts +242 -39
  177. package/src/workspace/filter.ts +37 -8
  178. package/src/workspace/index.ts +11 -2
  179. package/src/workspace/json-data.ts +3 -4
  180. package/src/workspace/lockfile.ts +57 -8
  181. package/src/workspace/migration.ts +72 -49
  182. package/src/workspace/package-graph.ts +5 -4
  183. package/src/workspace/project-loader.ts +314 -47
  184. package/src/workspace/repo-id.ts +1 -1
  185. package/src/workspace/workspace.ts +176 -33
  186. package/src/cli/foreign-flags.ts +0 -727
@@ -2,8 +2,9 @@
2
2
  // run with caching. Each step delegates to a single-purpose sibling file
3
3
  // so the layers can be swapped without touching the others.
4
4
 
5
+ import type { InputStability } from '../config.js'
5
6
  import type { ProjectEntry } from '../workspace/index.js'
6
- import { inputStabilityEnv, LOCK_INVALID, lockEntryBroken } from '../workspace/index.js'
7
+ import { EMPTY_LOCK, inputStabilityEnv, LOCK_INVALID, lockEntryBroken } from '../workspace/index.js'
7
8
  import { realpathSync } from 'node:fs'
8
9
  import path from 'node:path'
9
10
  import {
@@ -17,6 +18,7 @@ import {
17
18
  } from '../cache/index.js'
18
19
  import { VERSION } from '../version.js'
19
20
  import {
21
+ hostPlatform,
20
22
  resetSandbox,
21
23
  signalExitCode,
22
24
  VX_RUN_TASK_ENV,
@@ -24,13 +26,14 @@ import {
24
26
  } from '../exec/index.js'
25
27
  import { DeferredOutputs } from './deferred-outputs.js'
26
28
  import { resolveDownloadModes } from './download-policy.js'
27
- import type { TaskExecutor } from '../exec/index.js'
29
+ import type { MachineReport, TaskExecutor } from '../exec/index.js'
28
30
  import {
29
31
  deadServerBehind,
30
32
  declaredTask,
31
33
  isGroupTask,
32
34
  markSurfacedDeps,
33
35
  runGraph,
36
+ taskDefinitions,
34
37
  type TaskNode,
35
38
  type TaskOutcome,
36
39
  } from '../graph/index.js'
@@ -51,16 +54,18 @@ import {
51
54
  detectColors,
52
55
  type ColorSupport,
53
56
  SecretBroker,
57
+ thrownMessage,
58
+ type ui,
54
59
  } from '../util/index.js'
55
60
  import { keyedProjects } from './keyed-projects.js'
56
- import { isDefaultBuild } from './projects.js'
61
+ import { isDefaultBuild } from '../graph/index.js'
57
62
  import { commandWriteReach, prepareSandbox } from './sandbox-request.js'
58
63
  import { recordOutputDirSnapshots, type OutputDirSnapshot, type SaveFacts } from './miss-save.js'
59
64
  import { admitTasks, taintTracker } from './admission.js'
60
- import { unkeyedGroupOutcome, type ExecuteArgs } from './execute-task.js'
65
+ import { settleProbedHit, unkeyedGroupOutcome, type ExecuteArgs } from './execute-task.js'
61
66
  import { executeArgsOf, type RunScope } from './execute-args.js'
62
67
  import { excludedTaint } from './excluded-keys.js'
63
- import { keyUpstream } from './upstream.js'
68
+ import { dependedOn, keyUpstream } from './upstream.js'
64
69
  import {
65
70
  busLogger,
66
71
  createEventBus,
@@ -79,9 +84,16 @@ import {
79
84
  } from '../report/index.js'
80
85
  import { installPlugins, PluginSetupError, type RunReport } from './plugin.js'
81
86
  import { keepsFailures, reportOf, RunKeeper } from './run-report.js'
82
- import { buildAdmission, executorDemand, resolveExecutors, teardownPlugins } from './plugin-host.js'
87
+ import {
88
+ buildAdmission,
89
+ executorDemand,
90
+ executorLabel,
91
+ resolveExecutors,
92
+ teardownPlugins,
93
+ } from './plugin-host.js'
83
94
  import { createSteps } from './steps.js'
84
- import { writeRunSteps } from './run-steps.js'
95
+ import { liveSocketOf, serveLive } from './run-live.js'
96
+ import { writeRunSteps, writeRunWarnings } from './run-steps.js'
85
97
  import { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
86
98
  import { claimOutput, guardedOutput } from './output-host.js'
87
99
  import { assembleRunSummary } from './telemetry.js'
@@ -89,14 +101,15 @@ import { isPassStatus } from '../graph/index.js'
89
101
  import type { RunContextRecord } from './telemetry.js'
90
102
  import { plan, type RunPlan } from './plan.js'
91
103
  import type { AffectedReason } from './affected-tasks.js'
92
- import { openRemotes, prepareRun, type PreparedRun } from './prepare.js'
104
+ import { namesRemote, openRemotes, prepareRun, type PreparedRun } from './prepare.js'
93
105
  import { acquireRunLock } from './run-lock.js'
94
106
  import { terminateChildren } from './signals.js'
95
- import { runStop } from './run-stop.js'
107
+ import { CYCLE_INTERRUPT, runStop } from './run-stop.js'
96
108
  import {
97
109
  hasPooledExecutor,
98
110
  placeTasks,
99
111
  planExecutorOf,
112
+ platformVerdict,
100
113
  poolOfPlacement,
101
114
  terminalHolders,
102
115
  locallyPlaced,
@@ -114,6 +127,7 @@ import {
114
127
  type ShortCircuit,
115
128
  } from './local-shortcircuit.js'
116
129
  import { deriveStableKeys, probesAfterWrites } from './stable-keys.js'
130
+ import { askRemote, serversToHold } from './held-servers.js'
117
131
 
118
132
  import { writeRunFailures } from './run-failures.js'
119
133
  import { assembleRunRecords, recordLateRemoteErrors } from './run-records.js'
@@ -125,6 +139,7 @@ import {
125
139
  sessionEnd,
126
140
  takeHeldServers,
127
141
  } from './persistent.js'
142
+ import { serverKey } from './persistent-task.js'
128
143
  import { writeRunProfile } from './run-artifacts.js'
129
144
  import { createSaveLane } from './save-lane.js'
130
145
  import { commandWindows } from './command-windows.js'
@@ -136,6 +151,7 @@ import {
136
151
  } from '../history/index.js'
137
152
  import type { RunOptions, RunSummary } from './options.js'
138
153
  import { withRunSettings } from './run-settings.js'
154
+ import { cacheFacts, taskArtifacts } from './task-artifacts.js'
139
155
  import { resolveSetting } from '../settings.js'
140
156
 
141
157
  // Per run, never shared: a `vx watch` process runs many, and a shared map
@@ -145,6 +161,7 @@ const emptyShortCircuit = (): ShortCircuit => ({
145
161
  restoreTier: new Set(),
146
162
  uncachedKeys: new Map(),
147
163
  groupKeys: new Map(),
164
+ remoteAsk: new Map(),
148
165
  })
149
166
 
150
167
  /**
@@ -184,18 +201,37 @@ export function shouldShortCircuit(nodes: Map<string, TaskNode>, policy: CachePo
184
201
  return nodes.size > 0
185
202
  }
186
203
 
187
- function maskInvocation(command: string): string {
188
- return secretMask([process.env])?.mask(command) ?? command
204
+ /**
205
+ * The run's own secrets: vx's environment and every selected task's
206
+ * `define` and `exec.env.secret`, as each task's output masks them. With
207
+ * the environment's alone, a secret only a config names reached the stored
208
+ * line `vx last` prints in full.
209
+ */
210
+ function invocationMask(nodes: Iterable<TaskNode>): (command: string) => string {
211
+ const sources: Array<Readonly<Record<string, string>>> = [process.env as Record<string, string>]
212
+ const named = new Set<string>()
213
+ for (const n of nodes) {
214
+ const env = n.config.exec?.env
215
+ if (env?.define !== undefined) sources.push(env.define)
216
+ for (const name of env?.secret ?? []) named.add(name)
217
+ }
218
+ const mask = secretMask(sources, [...named])
219
+ return (command) => mask?.mask(command) ?? command
189
220
  }
190
221
 
191
222
  /**
192
223
  * The command line a run hands to its telemetry sinks, with what follows
193
224
  * `--` counted, not quoted. Those are the task's arguments,
194
- * often a token (`-- --token=…`), and the line reached an OTLP span, the
195
- * GitHub job summary and a check-run posted over the API verbatim (item
196
- * 1057). Local history (`vx last`) keeps the whole line, on this machine.
225
+ * often a token (`-- --token=…`), and the line reached an OTLP span and
226
+ * the GitHub job summary verbatim (item 1057). Local history (`vx last`) keeps the whole line, on this machine.
197
227
  */
198
- export function invocationCommand(argv: readonly string[]): string {
228
+ export function invocationCommand(line: readonly string[] | string): string {
229
+ // A line is shell-quoted (the CLI's): its words as written, so `'a b'`
230
+ // counts one argument, not the two a split at blanks made.
231
+ const argv =
232
+ typeof line === 'string'
233
+ ? (line.match(/(?:[^\s'"\\]|\\.|'[^']*'|"(?:[^"\\]|\\.)*")+/g) ?? [])
234
+ : line
199
235
  const sep = argv.indexOf('--')
200
236
  if (sep === -1) return argv.join(' ')
201
237
  const rest = argv.length - sep - 1
@@ -309,8 +345,14 @@ export async function run(options: RunOptions): Promise<RunSummary> {
309
345
  // bytes as written, as the cache holds them: it formats its own output.
310
346
  const render = terminalSubscriber(terminal === null || colors.enabled ? sink : plainOutput(sink))
311
347
  let unsubscribeTerminal = bus.subscribe(render)
348
+ // What the run warned of, no task's: the footer repeats it (`warn`).
349
+ const warned: string[] = []
350
+ const unsubscribeWarned = bus.subscribe((e) => {
351
+ if (e.kind === 'run:status' && 'warn' in e) warned.push(e.line)
352
+ })
312
353
  const detach = (): void => {
313
354
  unsubscribeTerminal()
355
+ unsubscribeWarned()
314
356
  terminal?.settle()
315
357
  }
316
358
  // A plugin's `output` renders the run from its start; core's renderer
@@ -329,7 +371,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
329
371
  // vx's own steps are timed only where something renders them.
330
372
  const showsSteps =
331
373
  terminal !== null ? terminal.showsSteps : options.log?.stepStart !== undefined
332
- const summary = await runOnBus(options, bus, colors, terminal, showsSteps, takeOutput)
374
+ const summary = await runOnBus(options, bus, warned, colors, terminal, showsSteps, takeOutput)
333
375
  if (summary.persistent === undefined) return summary
334
376
  // Servers handed back still running still write: `vx watch dev` printed
335
377
  // none of its server's log while it idled, the renderer gone with this
@@ -351,6 +393,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
351
393
  async function runOnBus(
352
394
  options: RunOptions,
353
395
  bus: EventBus,
396
+ warned: readonly string[],
354
397
  colors: ColorSupport,
355
398
  terminal: DefaultLogger | null,
356
399
  showsSteps: boolean,
@@ -373,7 +416,6 @@ async function runOnBus(
373
416
  }
374
417
 
375
418
  const prepared = await step('vx:load', () => prepareRun(options, log))
376
- options = withRunSettings(options, prepared.workspaceConfig)
377
419
  mark('plugin stages')
378
420
  // Every exit from here tears the plugins down: their capability
379
421
  // factories ran in prepareRun, and teardown is what releases what those
@@ -388,6 +430,27 @@ async function runOnBus(
388
430
  const plugins = prepared.plugins.filter((p) => p !== except)
389
431
  await teardownPlugins(plugins, warnOf(log), report, step)
390
432
  }
433
+ // The setting variables are read before any task, so a bad value is
434
+ // refused here, not mid-run; an embedder's (the CLI refuses it first)
435
+ // threw past the teardown and the cache close.
436
+ let inputStability: { run: InputStability; workspace?: InputStability }
437
+ let cleanOutputs: boolean | undefined
438
+ try {
439
+ options = withRunSettings(options, prepared.workspaceConfig)
440
+ inputStability = {
441
+ run: inputStabilityEnv(),
442
+ ...(prepared.workspaceConfig?.cache?.inputStability !== undefined
443
+ ? { workspace: prepared.workspaceConfig.cache.inputStability }
444
+ : {}),
445
+ }
446
+ cleanOutputs = resolveSetting('cache.outputs.clean', prepared.workspaceConfig).value as
447
+ | boolean
448
+ | undefined
449
+ } catch (err) {
450
+ await teardown()
451
+ prepared.cache.close()
452
+ throw err
453
+ }
391
454
  // A task whose command re-enters `vx run` in the workspace running it
392
455
  // is refused. When the inner run reaches this task again it forks a run
393
456
  // per run until the machine gives out; when it does not (`ci` shelling
@@ -397,7 +460,8 @@ async function runOnBus(
397
460
  // name the task; the check is on the root so both shapes are caught.
398
461
  // Compared canonical: a root reached through a symlink (`run({ cwd })`,
399
462
  // macOS's /var/folders) and the inner vx's realpath'd cwd are one tree.
400
- const outerRoot = process.env[VX_RUN_WORKSPACE_ENV]
463
+ // Empty is unset: resolved, '' named the cwd.
464
+ const outerRoot = process.env[VX_RUN_WORKSPACE_ENV] || undefined
401
465
  if (outerRoot !== undefined && canonical(outerRoot) === canonical(prepared.workspaceRoot)) {
402
466
  await teardown()
403
467
  prepared.cache.close()
@@ -513,7 +577,6 @@ async function runOnBus(
513
577
  }
514
578
  const {
515
579
  workspaceRoot,
516
- workspaceConfig,
517
580
  cacheDir,
518
581
  cache,
519
582
  nodes,
@@ -523,12 +586,14 @@ async function runOnBus(
523
586
  hashCache,
524
587
  workspaceProjectCount,
525
588
  } = prepared
589
+ const maskInvocation = invocationMask(nodes.values())
526
590
  const concurrency = options.concurrency ?? machineParallelism()
527
591
  // An earlier cycle's servers: the ones this graph starts again unchanged
528
592
  // stay up; the others go now, before anything binds their ports. A stop
529
593
  // that lands meanwhile ends the run here, having run nothing: an aborted
530
594
  // graph printed a `not run` row and a footer above `stopped` (WD-22).
531
595
  const heldBefore = takeHeldServers(options.keep?.servers, nodes, options.forwardArgs)
596
+ for (const server of heldBefore.kept.values()) server.out.bus = bus
532
597
  if (heldBefore.stale.length > 0) {
533
598
  await terminateChildren(() => heldBefore.stale)
534
599
  if (options.signal?.aborted === true) {
@@ -547,6 +612,8 @@ async function runOnBus(
547
612
  cacheDir: prepared.cacheDir,
548
613
  warn: warnOf(log),
549
614
  concurrency,
615
+ artifacts: taskArtifacts(prepared.cache, prepared.workspaceRoot),
616
+ cacheFacts: () => cacheFacts(prepared.remotes, prepared.cacheRefs),
550
617
  }),
551
618
  )
552
619
  // Placement: decided ONCE per task, before scheduling, so the scheduler
@@ -572,8 +639,29 @@ async function runOnBus(
572
639
  warnOf(log)(`[vx] ${id}: exec.remote is 'only' and ${why} — nothing ran`)
573
640
  }
574
641
  }
575
- // Demand: what each executor still has left to run (`executorDemand`).
576
- const narrowDemand = executorDemand(placements.executors, warnOf(log))
642
+ const platforms = platformVerdict(placements, ciContext.ci)
643
+ if (platforms !== undefined && 'refuse' in platforms) {
644
+ await abandoning(() => {
645
+ throw new UserError(platforms.refuse)
646
+ })
647
+ }
648
+ if (platforms !== undefined && 'warn' in platforms) warnOf(log)(platforms.warn)
649
+ /** A task its `exec.platforms` skip here: it never starts, and nor does what depends on it. */
650
+ const platformSkip = (node: TaskNode): TaskOutcome | undefined => {
651
+ const needs = placements.otherPlatform.get(node.id)
652
+ return needs === undefined
653
+ ? undefined
654
+ : {
655
+ node,
656
+ status: 'skipped',
657
+ exitCode: 0,
658
+ durationMs: 0,
659
+ otherPlatform: { needs, host: hostPlatform() },
660
+ }
661
+ }
662
+ // Demand: what each executor still has left to run (`executorDemand`),
663
+ // told once the cache has said what it answers.
664
+ const demand = executorDemand(placements.executors, warnOf(log))
577
665
 
578
666
  // `cache.download` (default `all`) decides ONCE per task, here, whether a
579
667
  // remote execution's outputs come home.
@@ -588,19 +676,11 @@ async function runOnBus(
588
676
 
589
677
  // `run.timeout` (ms), for any task WITHOUT its own `exec.timeout`.
590
678
  const taskTimeoutDefault = options.timeout
591
- // Read once, before any task: a bad value is refused here, not mid-run.
592
- const inputStability = {
593
- run: inputStabilityEnv(),
594
- ...(workspaceConfig?.cache?.inputStability !== undefined
595
- ? { workspace: workspaceConfig.cache.inputStability }
596
- : {}),
597
- }
598
-
599
- const cleanOutputs = resolveSetting('cache.outputs.clean', workspaceConfig).value as
600
- | boolean
601
- | undefined
602
679
 
680
+ // The held servers this run took over, spared by a `CYCLE_INTERRUPT`.
681
+ const handed = new Set<ReturnType<typeof Bun.spawn>>()
603
682
  const stop = runStop({
683
+ handed: (child) => handed.has(child),
604
684
  log,
605
685
  cache,
606
686
  external: options.signal,
@@ -624,11 +704,13 @@ async function runOnBus(
624
704
  // because the normal path closes before the persistent-task wait —
625
705
  // holding the handle open for a dev server's whole lifetime would be
626
706
  // worse.
707
+ let live: ReturnType<typeof serveLive> | undefined
627
708
  let cacheClosed = false
628
709
  const closeCache = async (): Promise<void> => {
629
710
  if (cacheClosed) return
630
711
  cacheClosed = true
631
712
  cache.close()
713
+ live?.unlisten()
632
714
  // Released with the handle: before a persistent task's wait, on every
633
715
  // exit path (see run-lock.ts for why a run holds one at all). AWAITED:
634
716
  // fired and forgotten, the release lost the race with the CLI's exit —
@@ -648,6 +730,16 @@ async function runOnBus(
648
730
  }),
649
731
  ),
650
732
  )
733
+ // Answers `vx last --live` while the lock is held (run-live.ts).
734
+ live = serveLive({
735
+ socketPath: liveSocketOf(releaseRunLock.path),
736
+ bus,
737
+ runId,
738
+ command: maskInvocation(invocationCommand(options.command ?? process.argv.slice(1))),
739
+ startedAt: Date.now(),
740
+ nodes: () => nodes.values(),
741
+ warnings: () => warned,
742
+ })
651
743
  try {
652
744
  const runStartHrTimeNs = process.hrtime.bigint()
653
745
  const endedAtMsAtStart = Date.now()
@@ -660,9 +752,17 @@ async function runOnBus(
660
752
  // clean their outputs before every exec for a save that never
661
753
  // happened.
662
754
  const policy: CachePolicy = effectiveCachePolicy(prepared.cachePolicy, remoteCacheEnabled)
663
- if (options.remoteRequested === true && !prepared.hasRemoteLayer) {
755
+ // The workspace's `cache.policy` asks as a flag does: it was the one source never told.
756
+ const remoteNamed =
757
+ options.remoteRequested ??
758
+ (options.cache === undefined &&
759
+ options.cacheFlags?.noCache !== true &&
760
+ namesRemote(prepared.workspaceConfig?.cache?.policy)
761
+ ? 'cache.policy'
762
+ : undefined)
763
+ if (remoteNamed !== undefined && !prepared.hasRemoteLayer) {
664
764
  warnOf(log)(
665
- 'vx: --cache-policy named the remote cache, but no cache plugin in .vx/workspace.* supplies one — the remote axes are off for this run',
765
+ `vx: ${remoteNamed} named the remote cache, but no cache plugin in .vx/workspace.* supplies one — the remote axes are off for this run`,
666
766
  )
667
767
  }
668
768
  const deferredOutputs = new DeferredOutputs({
@@ -710,9 +810,7 @@ async function runOnBus(
710
810
  // token after `--` to every sink verbatim (C-67).
711
811
  // Masked as the stored line is: a flag's secret value sits before
712
812
  // the `--` the count covers (L-44).
713
- command: maskInvocation(
714
- invocationCommand(options.command?.split(' ') ?? process.argv.slice(1)),
715
- ),
813
+ command: maskInvocation(invocationCommand(options.command ?? process.argv.slice(1))),
716
814
  requestedTasks: options.tasks,
717
815
  cachePolicy: formatCachePolicy(policy),
718
816
  concurrency,
@@ -744,10 +842,19 @@ async function runOnBus(
744
842
  n.config.exec?.persistent !== undefined ||
745
843
  n.config.cache === undefined ||
746
844
  !(policy.localRead || policy.remoteRead)
845
+ // Only what runs here arms this machine's sandbox: a `sandbox` executor
846
+ // enforces it where it runs the task, so a distributed run's
847
+ // orchestrator needs none (2026-10-22).
747
848
  const sandboxed =
748
849
  sandboxArmer === null
749
850
  ? []
750
- : [...nodes.values()].filter((n) => n.config.exec?.sandbox !== undefined)
851
+ : [...nodes.values()].filter(
852
+ (n) =>
853
+ n.config.exec?.sandbox !== undefined &&
854
+ (n.config.exec.persistent === undefined
855
+ ? localPlaced.has(n.id)
856
+ : !placements.carried.has(n.id)),
857
+ )
751
858
  let prewarming: Promise<void> | undefined
752
859
  const prewarm = (): void => {
753
860
  prewarming ??= sandboxArmer?.arm().catch(() => {})
@@ -800,7 +907,19 @@ async function runOnBus(
800
907
 
801
908
  // Run context for the footer. The top-of-run header is gone — the
802
909
  // banner now lives in the summary, where the eye lands at the end.
910
+ const fault = executorFaults(warnOf(log))
911
+ const footers = executors.filter((e) => e.footer !== undefined)
912
+ const placers = executors.filter((e) => e.running !== undefined)
803
913
  const runContext = {
914
+ ...(footers.length > 0
915
+ ? { executorRows: () => footers.flatMap((e) => executorFooter(e, fault)) }
916
+ : {}),
917
+ ...(placers.length > 0
918
+ ? {
919
+ whereRunning: (id: string) =>
920
+ placers.map((e) => executorWhere(e, id, fault)).find((w) => w !== undefined),
921
+ }
922
+ : {}),
804
923
  version: VERSION,
805
924
  packageCount: packagesInScope.size,
806
925
  remoteCacheEnabled,
@@ -816,7 +935,9 @@ async function runOnBus(
816
935
  },
817
936
  concurrency,
818
937
  workspaceProjectCount,
819
- runId: undefined as string | undefined,
938
+ // Live too: a long run is read while it runs (2026-10-21).
939
+ runId,
940
+ warnings: () => warned,
820
941
  }
821
942
 
822
943
  // Asked only when a plugin declares `output`: a plain run pays nothing.
@@ -854,6 +975,7 @@ async function runOnBus(
854
975
  prepared.localCache.dbHandle(),
855
976
  undefined,
856
977
  prepared.branch(),
978
+ taskDefinitions(nodes.values()),
857
979
  ).p50sFor(ids)
858
980
  return createForecast(nodes, p50s, concurrency)
859
981
  },
@@ -867,9 +989,20 @@ async function runOnBus(
867
989
  // local-only runs never reach here, so their behavior + perf is
868
990
  // unchanged (no upfront key pass, no local probing).
869
991
  let prefetchDone: Promise<void> = Promise.resolve()
992
+ /** `--profile`'s run events beside the tasks: the cache probe's. */
993
+ const runSpans: Record<string, unknown>[] = []
994
+ // A remote that is read answers which tasks it holds: demand settles
995
+ // on that answer, a failed prefetch settling it on none.
996
+ const prefetchSettles = prepared.hasRemoteLayer && policy.remoteRead
870
997
  if (prepared.hasRemoteLayer) {
871
998
  prefetchDone = steps.background('vx:prefetch', () =>
872
999
  startRemotePrefetch({
1000
+ ...(demand.any
1001
+ ? {
1002
+ held: (ids: readonly string[], missed: readonly string[]) =>
1003
+ demand.settle(ids, missed),
1004
+ }
1005
+ : {}),
873
1006
  nodes,
874
1007
  cache,
875
1008
  workspaceRoot,
@@ -881,6 +1014,7 @@ async function runOnBus(
881
1014
  concurrency: options.downloadConcurrency ?? networkConcurrency(),
882
1015
  remoteRead: policy.remoteRead,
883
1016
  remoteWrite: policy.remoteWrite,
1017
+ spans: options.profile === undefined ? undefined : runSpans,
884
1018
  }),
885
1019
  )
886
1020
  }
@@ -902,10 +1036,19 @@ async function runOnBus(
902
1036
  shouldShortCircuit(nodes, policy) && (remote || cache === prepared.localCache)
903
1037
  ? prepared.localCache.holdsNothing()
904
1038
  : false
1039
+ // Behind a remote, a server's dependants need their keys up front even
1040
+ // on an empty store: the remote may hold them all, and then the server
1041
+ // never starts (`held-servers.ts`).
1042
+ const askForServers =
1043
+ remote &&
1044
+ policy.remoteRead &&
1045
+ [...nodes.values()].some(
1046
+ (n) => n.config.exec?.persistent !== undefined && !n.requested && n.surfaced !== true,
1047
+ )
905
1048
  // A remote may still hold what an empty local store does not: the
906
1049
  // prefetch asks it, and no confirmed miss warms the sandbox early.
907
- if (coldLocal && !remote) prewarm()
908
- else if (!coldLocal && shouldShortCircuit(nodes, policy)) {
1050
+ if (coldLocal && !remote && sandboxed.length > 0) prewarm()
1051
+ else if ((!coldLocal || askForServers) && shouldShortCircuit(nodes, policy)) {
909
1052
  shortCircuit = await step('vx:keys', () =>
910
1053
  startLocalShortCircuit({
911
1054
  nodes,
@@ -926,6 +1069,19 @@ async function runOnBus(
926
1069
  if (!remote && sandboxed.some((n) => shortCircuit.preProbed.get(n.id)?.hit === null))
927
1070
  prewarm()
928
1071
  }
1072
+ if (demand.any) {
1073
+ if (prefetchSettles) void prefetchDone.finally(() => demand.settle([]))
1074
+ else {
1075
+ const hits: string[] = []
1076
+ const missed: string[] = []
1077
+ for (const [id, probe] of shortCircuit.preProbed)
1078
+ (probe.hit !== null ? hits : missed).push(id)
1079
+ // An empty store was not asked: it holds none of them.
1080
+ if (coldLocal && !remote)
1081
+ for (const [id, n] of nodes) if (n.config.cache !== undefined) missed.push(id)
1082
+ demand.settle(hits, missed)
1083
+ }
1084
+ }
929
1085
 
930
1086
  // Whether this task runs behind a failure (`continueMode: 'always'`
931
1087
  // only) or on the key of a dependency `--exclude-dependencies` kept
@@ -951,8 +1107,7 @@ async function runOnBus(
951
1107
  const taint = taintTracker(options.continueMode === 'always', taintSeeds, nodes)
952
1108
  // Each task that ran tainted, and the task at the root of it.
953
1109
  const taintedRan = new Map<string, string>()
954
- const dependedOn = new Set<string>()
955
- for (const n of nodes.values()) for (const d of n.deps) dependedOn.add(d)
1110
+ const depended = dependedOn(nodes.values())
956
1111
 
957
1112
  // Why a task missed, for a sink: a read of the history per miss, so only
958
1113
  // when a sink exists.
@@ -992,13 +1147,14 @@ async function runOnBus(
992
1147
  if (explainMiss !== undefined) scope.explainMiss = explainMiss
993
1148
  if (keepRun !== undefined) scope.keepRun = keepRun
994
1149
  if (sandboxArmer !== null) scope.armSandbox = () => sandboxArmer.arm()
1150
+ if (demand.any) scope.executing = demand.executing
995
1151
  const argsOf = executeArgsOf(scope, {
996
1152
  log: (node) => (node.config.exec?.persistent !== undefined ? serverLog(node.id) : log),
997
1153
  placements,
998
1154
  nestedDirsByProject,
999
1155
  downloadModeOf: download.modeOf,
1000
1156
  shortCircuit,
1001
- dependedOn,
1157
+ dependedOn: depended,
1002
1158
  holders,
1003
1159
  })
1004
1160
  const buildExecuteArgs = (
@@ -1078,6 +1234,7 @@ async function runOnBus(
1078
1234
  heldBefore.kept.delete(node.id)
1079
1235
  if (hasEnded(server.child)) return undefined
1080
1236
  server.handOver()
1237
+ handed.add(server.child)
1081
1238
  server.out.bus = bus
1082
1239
  serverOuts.set(node.id, server.out)
1083
1240
  persistentRegistry.set(node.id, server.child)
@@ -1113,7 +1270,14 @@ async function runOnBus(
1113
1270
  judgeFlaky(o)
1114
1271
  keeper?.taskDone(o)
1115
1272
  log.taskComplete(o.node, o)
1116
- narrowDemand(o.node.id)
1273
+ demand.finished(o.node.id)
1274
+ // A server that dies while the graph runs is no longer running:
1275
+ // its pin stayed `running` under the line naming its exit.
1276
+ const server = o.status === 'success' ? persistentRegistry.get(o.node.id) : undefined
1277
+ if (server !== undefined && o.serverExit === undefined)
1278
+ void server.exited.then((code) => {
1279
+ if (code !== 0 && !graphOver && !stop.signal.aborted) o.serverExit = code
1280
+ })
1117
1281
  },
1118
1282
  // A crash a hand-broken lock entry caused is named as that; asked
1119
1283
  // only here, so a run that does not crash pays nothing.
@@ -1125,24 +1289,36 @@ async function runOnBus(
1125
1289
  : line,
1126
1290
  ),
1127
1291
  execute: (node, upstream) => {
1292
+ const skip = platformSkip(node)
1293
+ if (skip !== undefined) return Promise.resolve(skip)
1128
1294
  const kept = keepServer(node)
1129
- return kept !== undefined
1130
- ? Promise.resolve(kept)
1131
- : executeWithDedup(node, keyUpstream(node, upstream))
1295
+ if (kept === undefined) return executeWithDedup(node, keyUpstream(node, upstream))
1296
+ // Keyed as a started server is: a cached dependant folds its key,
1297
+ // and without it an edit to the server's sources hit stale.
1298
+ return Promise.resolve(
1299
+ serverKey(argsOf(node, keyUpstream(node, upstream), true, false)),
1300
+ ).then((hash) => (hash === undefined ? kept : { ...kept, hash }))
1132
1301
  },
1133
- // An unkeyed group runs nothing: settled in place, unless a taint
1134
- // may ride it, which `buildExecuteArgs` records on dispatch.
1302
+ // An unkeyed group runs nothing, and a restore-tier hit whose tree is
1303
+ // current restores nothing: settled in place, unless a taint may ride
1304
+ // it, which `buildExecuteArgs` records on dispatch.
1135
1305
  ...(options.continueMode !== 'always' && taintSeeds.size === 0
1136
1306
  ? {
1137
- settleNow: (node: TaskNode, upstream: TaskOutcome[]) =>
1138
- isGroupTask(node) && node.config.inputs === undefined
1139
- ? unkeyedGroupOutcome(
1307
+ settleNow: (node: TaskNode, upstream: TaskOutcome[]) => {
1308
+ const skip = platformSkip(node)
1309
+ if (skip !== undefined) return () => skip
1310
+ if (isGroupTask(node) && node.config.inputs === undefined)
1311
+ return () =>
1312
+ unkeyedGroupOutcome(
1140
1313
  node,
1141
1314
  keyUpstream(node, upstream),
1142
1315
  shortCircuit.groupKeys.get(node.id),
1143
1316
  runStartHrTimeNs,
1144
1317
  )
1145
- : undefined,
1318
+ if (!shortCircuit.restoreTier.has(node.id) || heldBefore.kept.has(node.id))
1319
+ return undefined
1320
+ return settleProbedHit(buildExecuteArgs(node, keyUpstream(node, upstream)))
1321
+ },
1146
1322
  }
1147
1323
  : {}),
1148
1324
  // A `schedule` plugin's weights; the scheduler keeps its structural
@@ -1158,6 +1334,16 @@ async function runOnBus(
1158
1334
  // runs ahead of their deps as low-priority worker-slot backfill.
1159
1335
  // Empty when the short-circuit didn't fire → byte-identical.
1160
1336
  restoreTier: shortCircuit.restoreTier,
1337
+ ...(askForServers
1338
+ ? {
1339
+ heldServers: askRemote(
1340
+ serversToHold(nodes, shortCircuit.restoreTier, shortCircuit.remoteAsk),
1341
+ nodes,
1342
+ shortCircuit,
1343
+ cache,
1344
+ ),
1345
+ }
1346
+ : {}),
1161
1347
  })
1162
1348
  stop.graphDone()
1163
1349
  reprioritize = undefined
@@ -1170,8 +1356,11 @@ async function runOnBus(
1170
1356
  // signal loop) and embedders that manage lifecycle themselves — both
1171
1357
  // expect run() to return, not block on a server.
1172
1358
  const foreground = options.log === undefined && (options.handleSignals ?? true)
1173
- // An aborted run's children are already being torn down: nothing to hold.
1174
- const hold = options.holdPersistent === true && !stop.signal.aborted
1359
+ // An aborted run's children are already being torn down: nothing to
1360
+ // hold, but the servers a `CYCLE_INTERRUPT` spared.
1361
+ const hold =
1362
+ options.holdPersistent === true &&
1363
+ (!stop.signal.aborted || stop.signal.reason === CYCLE_INTERRUPT)
1175
1364
  const { keepAlive, crashed: crashedPersistent } = await disposeServers({
1176
1365
  registry: persistentRegistry,
1177
1366
  nodes,
@@ -1187,7 +1376,14 @@ async function runOnBus(
1187
1376
  // Clear the status line for good before the summary prints.
1188
1377
  log.runEnd?.()
1189
1378
  const list = [...outcomes.values()]
1190
- const ok = list.every((o) => isPassStatus(o.status))
1379
+ // A platform skip, and a skip it blocked, is no failure: the task is not this machine's.
1380
+ const ok = list.every(
1381
+ (o) =>
1382
+ isPassStatus(o.status) ||
1383
+ (o.status === 'skipped' &&
1384
+ (o.otherPlatform !== undefined ||
1385
+ (o.blockedBy !== undefined && placements.otherPlatform.has(o.blockedBy)))),
1386
+ )
1191
1387
 
1192
1388
  // The summary + artifact writers + recordRun pass all exclude group
1193
1389
  // tasks via the shared tallyOutcomes helper. We pass the full
@@ -1217,7 +1413,7 @@ async function runOnBus(
1217
1413
  const stillDeferred = deferredOutputs.pending()
1218
1414
  if (stillDeferred.length > 0) {
1219
1415
  log.status(
1220
- ` Deferred: left outputs remote (--cache-download=none): ${stillDeferred.join(', ')}`,
1416
+ ` Deferred: left outputs remote${downloadPolicy === 'all' ? '' : ` (--cache-download=${downloadPolicy})`}: ${stillDeferred.join(', ')}`,
1221
1417
  )
1222
1418
  }
1223
1419
 
@@ -1233,6 +1429,9 @@ async function runOnBus(
1233
1429
  target: profile,
1234
1430
  cwd: options.cwd,
1235
1431
  outcomes: list,
1432
+ runStartNs: runStartHrTimeNs,
1433
+ executors: executors.map((e) => executorProfile(e, fault)),
1434
+ spans: runSpans,
1236
1435
  }),
1237
1436
  )
1238
1437
  log.status(`vx: profile written to ${wrote}`)
@@ -1290,16 +1489,27 @@ async function runOnBus(
1290
1489
  // read `ok` over the exit 1 and fed the flaky list a pass (X-23).
1291
1490
  const historyAfterWait = keepAlive.children.length > 0 && !hold && stop.stoppedBy === undefined
1292
1491
  // A failed task's output, for `vx last` and an agent (run-failures.ts).
1293
- const recordFailures = (final: readonly TaskOutcome[]): void =>
1294
- writeRunFailures(prepared.cacheDir, runId, final, warnOf(log))
1492
+ // A cache directory this user cannot write keeps none of the run's
1493
+ // records: the run's one notice said so.
1494
+ const keeps = prepared.localCache.readOnlyNotice() === null
1495
+ const recordFailures = (final: readonly TaskOutcome[]): void => {
1496
+ if (keeps) writeRunFailures(prepared.cacheDir, runId, final, warnOf(log))
1497
+ }
1295
1498
  if (stop.stoppedBy === undefined && !historyAfterWait) {
1296
1499
  stepSync('vx:history', () => {
1297
1500
  recordHistory(() => cache.recordRunBundle(records))
1298
1501
  recordFailures(list)
1299
1502
  })
1503
+ } else if (stop.stoppedBy !== undefined) {
1504
+ // Stopped: not a run `vx last` lists (item 854), but what finished is
1505
+ // measured, or the next run knows none of it (2026-10-21).
1506
+ const ended = list.filter((o) => o.status !== 'aborted' && o.status !== 'skipped')
1507
+ stepSync('vx:history', () => {
1508
+ recordHistory(() => prepared.localCache.recordRuns(recordsOf(ended, false).runs))
1509
+ recordFailures(ended)
1510
+ })
1300
1511
  }
1301
1512
  mark('record history')
1302
- runContext.runId = runId
1303
1513
  // Drain any still-in-flight background prefetches before closing the
1304
1514
  // cache handle — a prefetch ingesting into a closed SQLite DB would
1305
1515
  // throw. Tasks that resolved as local hits never awaited their
@@ -1324,7 +1534,10 @@ async function runOnBus(
1324
1534
  await prefetchDone
1325
1535
  await Promise.all([cache.drainUploads?.(), keeper?.settled()])
1326
1536
  })
1327
- writeRunSteps(prepared.cacheDir, runId, steps.ended, warnOf(log))
1537
+ if (keeps) {
1538
+ writeRunSteps(prepared.cacheDir, runId, steps.ended, warnOf(log))
1539
+ writeRunWarnings(prepared.cacheDir, runId, warned, warnOf(log))
1540
+ }
1328
1541
  // An upload that failed after the rows went in: its row says so too.
1329
1542
  if (prepared.localCache.uploads.failed > 0 && stop.stoppedBy === undefined && !historyAfterWait)
1330
1543
  recordHistory(() => recordLateRemoteErrors(prepared.localCache, records.runs, runId))
@@ -1345,6 +1558,8 @@ async function runOnBus(
1345
1558
  abortedCount: list.filter((o) => o.status === 'aborted' && !isGroupTask(o.node)).length,
1346
1559
  stages: stageTimes(),
1347
1560
  uploads: prepared.localCache.uploads,
1561
+ machines: executors.flatMap((e) => executorMachines(e, fault)),
1562
+ idealMs: executors.map((e) => executorIdeal(e, fault)).find((ms) => ms !== undefined),
1348
1563
  })
1349
1564
  telemetry.emitSummary(summary)
1350
1565
  const sinks = telemetry
@@ -1382,16 +1597,20 @@ async function runOnBus(
1382
1597
  }
1383
1598
  })
1384
1599
 
1385
- // The footer is the run's last word (owner, 2026-10-06): it prints once
1600
+ // The footer is the run's last word (2026-10-06): it prints once
1386
1601
  // the run's own work is done, history, uploads, plugin teardown and the
1387
1602
  // sandbox reset included, so any warning they raise lands above it. A
1388
1603
  // task's own facts (flaky, blocked) ride its row. Only a kept server's
1389
1604
  // own output follows.
1390
1605
  // The footer replaces the live region in place.
1391
1606
  log.runDone?.()
1392
- if (options.summaryTable === true) for (const line of formatOutcomeTable(list)) log.status(line)
1607
+ if (options.summaryTable === true)
1608
+ for (const line of formatOutcomeTable(list, colors)) log.status(line)
1609
+ // A run a signal stopped records nothing `vx last` opens: no id to copy.
1610
+ const footerContext =
1611
+ stop.stoppedBy === undefined ? runContext : { ...runContext, runId: undefined }
1393
1612
  if (options.footer !== false)
1394
- for (const line of formatRunSummary(list, totalMs, colors, runContext)) log.status(line)
1613
+ for (const line of formatRunSummary(list, totalMs, colors, footerContext)) log.status(line)
1395
1614
 
1396
1615
  // Edge case the summary already reported: the user requested a
1397
1616
  // persistent task (dev server / watcher). The run is "done" in every
@@ -1414,7 +1633,6 @@ async function runOnBus(
1414
1633
  persistent: holdServers(keepAlive, {
1415
1634
  forwardArgs: options.forwardArgs,
1416
1635
  outOf: (id) => serverOuts.get(id) ?? { bus },
1417
- warn: warnOf(log),
1418
1636
  }),
1419
1637
  }
1420
1638
  }
@@ -1441,7 +1659,9 @@ async function runOnBus(
1441
1659
  if (stop.signal.aborted) await stop.aborting?.catch(() => {})
1442
1660
  else await terminateChildren(() => keepAlive.children)
1443
1661
  // The server that ended the session on its own, not cleanly, failed.
1444
- if (!stop.signal.aborted && first.code !== 0) failServer(outcomes, node.id, first.code)
1662
+ // A stop's own kill is no crash: the user ended the session.
1663
+ const crashed = !stop.signal.aborted && first.code !== 0
1664
+ if (crashed) failServer(outcomes, node.id, first.code)
1445
1665
  const final = [...outcomes.values()]
1446
1666
  if (historyAfterWait) {
1447
1667
  recordHistory(() => {
@@ -1449,14 +1669,14 @@ async function runOnBus(
1449
1669
  // to its schema during the session, and a mismatch resets silently.
1450
1670
  const local = new Cache(prepared.cacheDir, undefined, prepared.workspaceRoot)
1451
1671
  try {
1452
- local.recordRunBundle(recordsOf(final, ok && first.code === 0))
1672
+ local.recordRunBundle(recordsOf(final, ok && !crashed))
1453
1673
  } finally {
1454
1674
  local.close()
1455
1675
  }
1456
1676
  })
1457
1677
  recordFailures(final)
1458
1678
  }
1459
- return { ok: ok && first.code === 0, outcomes: final }
1679
+ return { ok: ok && !crashed, outcomes: final }
1460
1680
  }
1461
1681
 
1462
1682
  return { ok, outcomes: list }
@@ -1470,6 +1690,7 @@ async function runOnBus(
1470
1690
  // floor for a throw a later change adds, not a path a row can drive.
1471
1691
  log.runEnd?.()
1472
1692
  log.runDone?.()
1693
+ live?.dispose()
1473
1694
  stop.detach()
1474
1695
  // Plugins installed at the top of run() get their bus subscriptions
1475
1696
  // released here. Idempotent; safe even if installPlugins threw.
@@ -1527,8 +1748,8 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1527
1748
  const log = options.log ?? defaultLogger(undefined, undefined, process.stderr)
1528
1749
  const reasons = new Map<string, AffectedReason>()
1529
1750
  const prepared = await prepareRun({ ...options, affectedReasons: reasons }, log)
1530
- options = withRunSettings(options, prepared.workspaceConfig)
1531
1751
  try {
1752
+ options = withRunSettings(options, prepared.workspaceConfig)
1532
1753
  if (prepared.unresolvedTasks.length > 0) {
1533
1754
  return {
1534
1755
  tasks: [],
@@ -1548,6 +1769,7 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1548
1769
  if (prepared.empty !== null) return { tasks: [] }
1549
1770
  // The plan orders nothing, but it refuses what the run would refuse.
1550
1771
  await prepared.priorities()
1772
+ terminalHolders(prepared.nodes, options.tty === true)
1551
1773
  // Its own mark: a dry run's plan (every task's hash, the cache lookups,
1552
1774
  // the history p50s) was booked under `close`, the next mark, and read
1553
1775
  // as 105 ms of closing a cache at 1,000 projects (item 601).
@@ -1570,13 +1792,14 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1570
1792
  prepared.localCache.dbHandle(),
1571
1793
  undefined,
1572
1794
  prepared.branch(),
1795
+ taskDefinitions(prepared.nodes.values()),
1573
1796
  ),
1574
1797
  // Placement, by the same rules the run applies. Only worth showing
1575
1798
  // when there is a choice to show — with one declared executor every
1576
1799
  // line would carry the same label. Resolving the executors here is
1577
1800
  // the same plugin-factory call `prepareRun` already makes for the
1578
1801
  // cache capability, so plan mode gains no new class of side effect.
1579
- ...(await planExecutorOf(prepared, log, options.download ?? 'all')),
1802
+ ...(await planExecutorOf(prepared, log, options.download ?? 'all', detectCi(process.env).ci)),
1580
1803
  ...(await planRestorable(prepared, policy, options.forwardArgs)),
1581
1804
  })
1582
1805
  mark('plan')
@@ -1647,12 +1870,15 @@ async function unresolvedHint(prepared: {
1647
1870
  hintProjects: ReadonlyMap<string, ProjectEntry>
1648
1871
  declaredElsewhere: readonly string[]
1649
1872
  anyProjectConfig: boolean
1873
+ taskless: (name: string) => boolean
1874
+ unlockedConfig: () => string | undefined
1650
1875
  }): Promise<string> {
1651
1876
  const elsewhere = elsewhereHint(prepared.declaredElsewhere)
1652
1877
  const near = didYouMean(
1653
1878
  prepared.unresolvedTasks,
1654
1879
  prepared.hintProjects,
1655
1880
  prepared.declaredElsewhere,
1881
+ prepared.taskless,
1656
1882
  )
1657
1883
  const init = await initHint(prepared)
1658
1884
  const lock =
@@ -1663,13 +1889,20 @@ async function unresolvedHint(prepared: {
1663
1889
  }
1664
1890
 
1665
1891
  /**
1666
- * The first-run case, told apart from a typo: no project in the workspace
1667
- * has a `vx.config.*` at all, so no name could have resolved. Reached only
1668
- * on the error path.
1892
+ * The first-run case, told apart from a typo: the lock holds no project,
1893
+ * so no name could have resolved. Said of the lock, the only thing a run
1894
+ * reads: "no directory holds a vx.config" was false beside configs never
1895
+ * locked. A config written after the lock is no first run: its fix is
1896
+ * `vx lock`. Reached only on the error path.
1669
1897
  */
1670
- async function initHint(prepared: { anyProjectConfig: boolean }): Promise<string> {
1898
+ async function initHint(prepared: {
1899
+ anyProjectConfig: boolean
1900
+ unlockedConfig: () => string | undefined
1901
+ }): Promise<string> {
1671
1902
  if (prepared.anyProjectConfig) return ''
1672
- return ' No directory holds a vx.config — run `vx init` to write them, then `vx lock`.'
1903
+ const unlocked = prepared.unlockedConfig()
1904
+ if (unlocked !== undefined) return ` ${unlocked} is not in the lock yet — run \`vx lock\`.`
1905
+ return ` ${EMPTY_LOCK}.`
1673
1906
  }
1674
1907
 
1675
1908
  /**
@@ -1682,7 +1915,8 @@ async function initHint(prepared: { anyProjectConfig: boolean }): Promise<string
1682
1915
  function didYouMean(
1683
1916
  unresolved: readonly string[],
1684
1917
  projects: ReadonlyMap<string, ProjectEntry>,
1685
- elsewhere: readonly string[] = [],
1918
+ elsewhere: readonly string[],
1919
+ taskless: (name: string) => boolean,
1686
1920
  ): string {
1687
1921
  const tasksOf = (p: ProjectEntry | undefined): string[] => Object.keys(p?.config.tasks ?? {})
1688
1922
  // What a bare name can select: a default `build` is no match (X-102).
@@ -1727,10 +1961,20 @@ function didYouMean(
1727
1961
  )
1728
1962
  continue
1729
1963
  }
1964
+ if (taskless(proj)) {
1965
+ lists.add(
1966
+ `${proj} has no vx config, and no configured project depends on it: it has no tasks.`,
1967
+ )
1968
+ continue
1969
+ }
1730
1970
  if (!projects.has(proj)) {
1731
1971
  const p = projectNamed(proj, projects) ?? nearest(proj, projects.keys())
1732
- if (p !== undefined && tasksOf(projects.get(p)).includes(task)) hints.add(`${p}#${task}`)
1733
- else if (p === undefined && projects.size > 0) {
1972
+ if (p !== undefined) {
1973
+ const tasks = tasksOf(projects.get(p))
1974
+ const t = tasks.includes(task) ? task : nearest(task, tasks)
1975
+ if (t !== undefined) hints.add(`${p}#${t}`)
1976
+ else lists.add(`No project is named ${proj}; ${p}'s tasks: ${listed(tasks)}.`)
1977
+ } else if (projects.size > 0) {
1734
1978
  lists.add(`No project is named ${proj}; projects: ${listed(projects.keys())}.`)
1735
1979
  }
1736
1980
  continue
@@ -1816,3 +2060,89 @@ function canonical(dir: string): string {
1816
2060
  return path.resolve(dir)
1817
2061
  }
1818
2062
  }
2063
+
2064
+ /** What an executor's report threw: said once by `executorFaults`, the report left out. */
2065
+ type ExecutorFault = (e: TaskExecutor, what: string, err: unknown) => void
2066
+
2067
+ /**
2068
+ * Each executor report that throws is said once and left out: isolated,
2069
+ * it was dropped with nothing said, a summary short its machines.
2070
+ */
2071
+ function executorFaults(warn: (line: string) => void): ExecutorFault {
2072
+ const said = new Set<string>()
2073
+ return (e, what, err) => {
2074
+ const k = `${e.name}\0${what}`
2075
+ if (said.has(k)) return
2076
+ said.add(k)
2077
+ warn(`[vx] ${executorLabel(e)}: ${what}() threw: ${thrownMessage(err)} — left out`)
2078
+ }
2079
+ }
2080
+
2081
+ /** An executor's footer rows; a plugin's throw or bad shape shows none. */
2082
+ function executorFooter(e: TaskExecutor, fault: ExecutorFault): (readonly [string, ui.Inline])[] {
2083
+ try {
2084
+ const rows = e.footer?.()
2085
+ if (!Array.isArray(rows)) return []
2086
+ return rows.filter(
2087
+ (r): r is readonly [string, ui.Inline] =>
2088
+ Array.isArray(r) &&
2089
+ typeof r[0] === 'string' &&
2090
+ (typeof r[1] === 'string' || (typeof r[1] === 'object' && r[1] !== null)),
2091
+ )
2092
+ } catch (err) {
2093
+ fault(e, 'footer', err)
2094
+ return []
2095
+ }
2096
+ }
2097
+
2098
+ /** An executor's `--profile` events; a throw or a bad shape adds none. */
2099
+ function executorProfile(
2100
+ e: TaskExecutor,
2101
+ fault: ExecutorFault,
2102
+ ): readonly Record<string, unknown>[] {
2103
+ try {
2104
+ const events = e.profile?.()
2105
+ return Array.isArray(events)
2106
+ ? events.filter((x): x is Record<string, unknown> => typeof x === 'object' && x !== null)
2107
+ : []
2108
+ } catch (err) {
2109
+ fault(e, 'profile', err)
2110
+ return []
2111
+ }
2112
+ }
2113
+
2114
+ function executorMachines(e: TaskExecutor, fault: ExecutorFault): readonly MachineReport[] {
2115
+ try {
2116
+ const m = e.machines?.()
2117
+ return Array.isArray(m) ? m : []
2118
+ } catch (err) {
2119
+ fault(e, 'machines', err)
2120
+ return []
2121
+ }
2122
+ }
2123
+
2124
+ function executorIdeal(e: TaskExecutor, fault: ExecutorFault): number | undefined {
2125
+ try {
2126
+ const ms = e.idealMs?.()
2127
+ return typeof ms === 'number' && Number.isFinite(ms) && ms >= 0 ? Math.round(ms) : undefined
2128
+ } catch (err) {
2129
+ fault(e, 'idealMs', err)
2130
+ return undefined
2131
+ }
2132
+ }
2133
+
2134
+ /** Where `e` says a task runs, or `null` while it waits; a throw or anything else says nothing. */
2135
+ function executorWhere(
2136
+ e: TaskExecutor,
2137
+ id: string,
2138
+ fault: ExecutorFault,
2139
+ ): string | null | undefined {
2140
+ try {
2141
+ const w = e.running?.(id)
2142
+ if (w === null) return null
2143
+ return typeof w === 'string' && w !== '' ? w : undefined
2144
+ } catch (err) {
2145
+ fault(e, 'running', err)
2146
+ return undefined
2147
+ }
2148
+ }