@vzn/vx 0.0.646 → 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 (184) hide show
  1. package/README.md +29 -22
  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 +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  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 +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -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/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
@@ -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,
@@ -31,6 +33,7 @@ import {
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,17 +54,18 @@ import {
51
54
  detectColors,
52
55
  type ColorSupport,
53
56
  SecretBroker,
57
+ thrownMessage,
54
58
  type ui,
55
59
  } from '../util/index.js'
56
60
  import { keyedProjects } from './keyed-projects.js'
57
- import { isDefaultBuild } from './projects.js'
61
+ import { isDefaultBuild } from '../graph/index.js'
58
62
  import { commandWriteReach, prepareSandbox } from './sandbox-request.js'
59
63
  import { recordOutputDirSnapshots, type OutputDirSnapshot, type SaveFacts } from './miss-save.js'
60
64
  import { admitTasks, taintTracker } from './admission.js'
61
- import { unkeyedGroupOutcome, type ExecuteArgs } from './execute-task.js'
65
+ import { settleProbedHit, unkeyedGroupOutcome, type ExecuteArgs } from './execute-task.js'
62
66
  import { executeArgsOf, type RunScope } from './execute-args.js'
63
67
  import { excludedTaint } from './excluded-keys.js'
64
- import { keyUpstream } from './upstream.js'
68
+ import { dependedOn, keyUpstream } from './upstream.js'
65
69
  import {
66
70
  busLogger,
67
71
  createEventBus,
@@ -80,9 +84,16 @@ import {
80
84
  } from '../report/index.js'
81
85
  import { installPlugins, PluginSetupError, type RunReport } from './plugin.js'
82
86
  import { keepsFailures, reportOf, RunKeeper } from './run-report.js'
83
- 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'
84
94
  import { createSteps } from './steps.js'
85
- import { writeRunSteps } from './run-steps.js'
95
+ import { liveSocketOf, serveLive } from './run-live.js'
96
+ import { writeRunSteps, writeRunWarnings } from './run-steps.js'
86
97
  import { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
87
98
  import { claimOutput, guardedOutput } from './output-host.js'
88
99
  import { assembleRunSummary } from './telemetry.js'
@@ -90,14 +101,15 @@ import { isPassStatus } from '../graph/index.js'
90
101
  import type { RunContextRecord } from './telemetry.js'
91
102
  import { plan, type RunPlan } from './plan.js'
92
103
  import type { AffectedReason } from './affected-tasks.js'
93
- import { openRemotes, prepareRun, type PreparedRun } from './prepare.js'
104
+ import { namesRemote, openRemotes, prepareRun, type PreparedRun } from './prepare.js'
94
105
  import { acquireRunLock } from './run-lock.js'
95
106
  import { terminateChildren } from './signals.js'
96
- import { runStop } from './run-stop.js'
107
+ import { CYCLE_INTERRUPT, runStop } from './run-stop.js'
97
108
  import {
98
109
  hasPooledExecutor,
99
110
  placeTasks,
100
111
  planExecutorOf,
112
+ platformVerdict,
101
113
  poolOfPlacement,
102
114
  terminalHolders,
103
115
  locallyPlaced,
@@ -127,6 +139,7 @@ import {
127
139
  sessionEnd,
128
140
  takeHeldServers,
129
141
  } from './persistent.js'
142
+ import { serverKey } from './persistent-task.js'
130
143
  import { writeRunProfile } from './run-artifacts.js'
131
144
  import { createSaveLane } from './save-lane.js'
132
145
  import { commandWindows } from './command-windows.js'
@@ -188,18 +201,37 @@ export function shouldShortCircuit(nodes: Map<string, TaskNode>, policy: CachePo
188
201
  return nodes.size > 0
189
202
  }
190
203
 
191
- function maskInvocation(command: string): string {
192
- 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
193
220
  }
194
221
 
195
222
  /**
196
223
  * The command line a run hands to its telemetry sinks, with what follows
197
224
  * `--` counted, not quoted. Those are the task's arguments,
198
- * often a token (`-- --token=…`), and the line reached an OTLP span, the
199
- * GitHub job summary and a check-run posted over the API verbatim (item
200
- * 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.
201
227
  */
202
- 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
203
235
  const sep = argv.indexOf('--')
204
236
  if (sep === -1) return argv.join(' ')
205
237
  const rest = argv.length - sep - 1
@@ -313,8 +345,14 @@ export async function run(options: RunOptions): Promise<RunSummary> {
313
345
  // bytes as written, as the cache holds them: it formats its own output.
314
346
  const render = terminalSubscriber(terminal === null || colors.enabled ? sink : plainOutput(sink))
315
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
+ })
316
353
  const detach = (): void => {
317
354
  unsubscribeTerminal()
355
+ unsubscribeWarned()
318
356
  terminal?.settle()
319
357
  }
320
358
  // A plugin's `output` renders the run from its start; core's renderer
@@ -333,7 +371,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
333
371
  // vx's own steps are timed only where something renders them.
334
372
  const showsSteps =
335
373
  terminal !== null ? terminal.showsSteps : options.log?.stepStart !== undefined
336
- const summary = await runOnBus(options, bus, colors, terminal, showsSteps, takeOutput)
374
+ const summary = await runOnBus(options, bus, warned, colors, terminal, showsSteps, takeOutput)
337
375
  if (summary.persistent === undefined) return summary
338
376
  // Servers handed back still running still write: `vx watch dev` printed
339
377
  // none of its server's log while it idled, the renderer gone with this
@@ -355,6 +393,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
355
393
  async function runOnBus(
356
394
  options: RunOptions,
357
395
  bus: EventBus,
396
+ warned: readonly string[],
358
397
  colors: ColorSupport,
359
398
  terminal: DefaultLogger | null,
360
399
  showsSteps: boolean,
@@ -377,7 +416,6 @@ async function runOnBus(
377
416
  }
378
417
 
379
418
  const prepared = await step('vx:load', () => prepareRun(options, log))
380
- options = withRunSettings(options, prepared.workspaceConfig)
381
419
  mark('plugin stages')
382
420
  // Every exit from here tears the plugins down: their capability
383
421
  // factories ran in prepareRun, and teardown is what releases what those
@@ -392,6 +430,27 @@ async function runOnBus(
392
430
  const plugins = prepared.plugins.filter((p) => p !== except)
393
431
  await teardownPlugins(plugins, warnOf(log), report, step)
394
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
+ }
395
454
  // A task whose command re-enters `vx run` in the workspace running it
396
455
  // is refused. When the inner run reaches this task again it forks a run
397
456
  // per run until the machine gives out; when it does not (`ci` shelling
@@ -401,7 +460,8 @@ async function runOnBus(
401
460
  // name the task; the check is on the root so both shapes are caught.
402
461
  // Compared canonical: a root reached through a symlink (`run({ cwd })`,
403
462
  // macOS's /var/folders) and the inner vx's realpath'd cwd are one tree.
404
- 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
405
465
  if (outerRoot !== undefined && canonical(outerRoot) === canonical(prepared.workspaceRoot)) {
406
466
  await teardown()
407
467
  prepared.cache.close()
@@ -517,7 +577,6 @@ async function runOnBus(
517
577
  }
518
578
  const {
519
579
  workspaceRoot,
520
- workspaceConfig,
521
580
  cacheDir,
522
581
  cache,
523
582
  nodes,
@@ -527,12 +586,14 @@ async function runOnBus(
527
586
  hashCache,
528
587
  workspaceProjectCount,
529
588
  } = prepared
589
+ const maskInvocation = invocationMask(nodes.values())
530
590
  const concurrency = options.concurrency ?? machineParallelism()
531
591
  // An earlier cycle's servers: the ones this graph starts again unchanged
532
592
  // stay up; the others go now, before anything binds their ports. A stop
533
593
  // that lands meanwhile ends the run here, having run nothing: an aborted
534
594
  // graph printed a `not run` row and a footer above `stopped` (WD-22).
535
595
  const heldBefore = takeHeldServers(options.keep?.servers, nodes, options.forwardArgs)
596
+ for (const server of heldBefore.kept.values()) server.out.bus = bus
536
597
  if (heldBefore.stale.length > 0) {
537
598
  await terminateChildren(() => heldBefore.stale)
538
599
  if (options.signal?.aborted === true) {
@@ -578,8 +639,29 @@ async function runOnBus(
578
639
  warnOf(log)(`[vx] ${id}: exec.remote is 'only' and ${why} — nothing ran`)
579
640
  }
580
641
  }
581
- // Demand: what each executor still has left to run (`executorDemand`).
582
- 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))
583
665
 
584
666
  // `cache.download` (default `all`) decides ONCE per task, here, whether a
585
667
  // remote execution's outputs come home.
@@ -594,19 +676,11 @@ async function runOnBus(
594
676
 
595
677
  // `run.timeout` (ms), for any task WITHOUT its own `exec.timeout`.
596
678
  const taskTimeoutDefault = options.timeout
597
- // Read once, before any task: a bad value is refused here, not mid-run.
598
- const inputStability = {
599
- run: inputStabilityEnv(),
600
- ...(workspaceConfig?.cache?.inputStability !== undefined
601
- ? { workspace: workspaceConfig.cache.inputStability }
602
- : {}),
603
- }
604
-
605
- const cleanOutputs = resolveSetting('cache.outputs.clean', workspaceConfig).value as
606
- | boolean
607
- | undefined
608
679
 
680
+ // The held servers this run took over, spared by a `CYCLE_INTERRUPT`.
681
+ const handed = new Set<ReturnType<typeof Bun.spawn>>()
609
682
  const stop = runStop({
683
+ handed: (child) => handed.has(child),
610
684
  log,
611
685
  cache,
612
686
  external: options.signal,
@@ -630,11 +704,13 @@ async function runOnBus(
630
704
  // because the normal path closes before the persistent-task wait —
631
705
  // holding the handle open for a dev server's whole lifetime would be
632
706
  // worse.
707
+ let live: ReturnType<typeof serveLive> | undefined
633
708
  let cacheClosed = false
634
709
  const closeCache = async (): Promise<void> => {
635
710
  if (cacheClosed) return
636
711
  cacheClosed = true
637
712
  cache.close()
713
+ live?.unlisten()
638
714
  // Released with the handle: before a persistent task's wait, on every
639
715
  // exit path (see run-lock.ts for why a run holds one at all). AWAITED:
640
716
  // fired and forgotten, the release lost the race with the CLI's exit —
@@ -654,6 +730,16 @@ async function runOnBus(
654
730
  }),
655
731
  ),
656
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
+ })
657
743
  try {
658
744
  const runStartHrTimeNs = process.hrtime.bigint()
659
745
  const endedAtMsAtStart = Date.now()
@@ -666,9 +752,17 @@ async function runOnBus(
666
752
  // clean their outputs before every exec for a save that never
667
753
  // happened.
668
754
  const policy: CachePolicy = effectiveCachePolicy(prepared.cachePolicy, remoteCacheEnabled)
669
- 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) {
670
764
  warnOf(log)(
671
- '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`,
672
766
  )
673
767
  }
674
768
  const deferredOutputs = new DeferredOutputs({
@@ -716,9 +810,7 @@ async function runOnBus(
716
810
  // token after `--` to every sink verbatim (C-67).
717
811
  // Masked as the stored line is: a flag's secret value sits before
718
812
  // the `--` the count covers (L-44).
719
- command: maskInvocation(
720
- invocationCommand(options.command?.split(' ') ?? process.argv.slice(1)),
721
- ),
813
+ command: maskInvocation(invocationCommand(options.command ?? process.argv.slice(1))),
722
814
  requestedTasks: options.tasks,
723
815
  cachePolicy: formatCachePolicy(policy),
724
816
  concurrency,
@@ -752,7 +844,7 @@ async function runOnBus(
752
844
  !(policy.localRead || policy.remoteRead)
753
845
  // Only what runs here arms this machine's sandbox: a `sandbox` executor
754
846
  // enforces it where it runs the task, so a distributed run's
755
- // orchestrator needs none (owner, 2026-10-22).
847
+ // orchestrator needs none (2026-10-22).
756
848
  const sandboxed =
757
849
  sandboxArmer === null
758
850
  ? []
@@ -815,14 +907,17 @@ async function runOnBus(
815
907
 
816
908
  // Run context for the footer. The top-of-run header is gone — the
817
909
  // banner now lives in the summary, where the eye lands at the end.
910
+ const fault = executorFaults(warnOf(log))
818
911
  const footers = executors.filter((e) => e.footer !== undefined)
819
912
  const placers = executors.filter((e) => e.running !== undefined)
820
913
  const runContext = {
821
- ...(footers.length > 0 ? { executorRows: () => footers.flatMap(executorFooter) } : {}),
914
+ ...(footers.length > 0
915
+ ? { executorRows: () => footers.flatMap((e) => executorFooter(e, fault)) }
916
+ : {}),
822
917
  ...(placers.length > 0
823
918
  ? {
824
919
  whereRunning: (id: string) =>
825
- placers.map((e) => executorWhere(e, id)).find((w) => w !== undefined),
920
+ placers.map((e) => executorWhere(e, id, fault)).find((w) => w !== undefined),
826
921
  }
827
922
  : {}),
828
923
  version: VERSION,
@@ -840,8 +935,9 @@ async function runOnBus(
840
935
  },
841
936
  concurrency,
842
937
  workspaceProjectCount,
843
- // Live too: a long run is read while it runs (M, 2026-10-21).
938
+ // Live too: a long run is read while it runs (2026-10-21).
844
939
  runId,
940
+ warnings: () => warned,
845
941
  }
846
942
 
847
943
  // Asked only when a plugin declares `output`: a plain run pays nothing.
@@ -879,6 +975,7 @@ async function runOnBus(
879
975
  prepared.localCache.dbHandle(),
880
976
  undefined,
881
977
  prepared.branch(),
978
+ taskDefinitions(nodes.values()),
882
979
  ).p50sFor(ids)
883
980
  return createForecast(nodes, p50s, concurrency)
884
981
  },
@@ -892,9 +989,20 @@ async function runOnBus(
892
989
  // local-only runs never reach here, so their behavior + perf is
893
990
  // unchanged (no upfront key pass, no local probing).
894
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
895
997
  if (prepared.hasRemoteLayer) {
896
998
  prefetchDone = steps.background('vx:prefetch', () =>
897
999
  startRemotePrefetch({
1000
+ ...(demand.any
1001
+ ? {
1002
+ held: (ids: readonly string[], missed: readonly string[]) =>
1003
+ demand.settle(ids, missed),
1004
+ }
1005
+ : {}),
898
1006
  nodes,
899
1007
  cache,
900
1008
  workspaceRoot,
@@ -906,6 +1014,7 @@ async function runOnBus(
906
1014
  concurrency: options.downloadConcurrency ?? networkConcurrency(),
907
1015
  remoteRead: policy.remoteRead,
908
1016
  remoteWrite: policy.remoteWrite,
1017
+ spans: options.profile === undefined ? undefined : runSpans,
909
1018
  }),
910
1019
  )
911
1020
  }
@@ -960,6 +1069,19 @@ async function runOnBus(
960
1069
  if (!remote && sandboxed.some((n) => shortCircuit.preProbed.get(n.id)?.hit === null))
961
1070
  prewarm()
962
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
+ }
963
1085
 
964
1086
  // Whether this task runs behind a failure (`continueMode: 'always'`
965
1087
  // only) or on the key of a dependency `--exclude-dependencies` kept
@@ -985,8 +1107,7 @@ async function runOnBus(
985
1107
  const taint = taintTracker(options.continueMode === 'always', taintSeeds, nodes)
986
1108
  // Each task that ran tainted, and the task at the root of it.
987
1109
  const taintedRan = new Map<string, string>()
988
- const dependedOn = new Set<string>()
989
- for (const n of nodes.values()) for (const d of n.deps) dependedOn.add(d)
1110
+ const depended = dependedOn(nodes.values())
990
1111
 
991
1112
  // Why a task missed, for a sink: a read of the history per miss, so only
992
1113
  // when a sink exists.
@@ -1026,13 +1147,14 @@ async function runOnBus(
1026
1147
  if (explainMiss !== undefined) scope.explainMiss = explainMiss
1027
1148
  if (keepRun !== undefined) scope.keepRun = keepRun
1028
1149
  if (sandboxArmer !== null) scope.armSandbox = () => sandboxArmer.arm()
1150
+ if (demand.any) scope.executing = demand.executing
1029
1151
  const argsOf = executeArgsOf(scope, {
1030
1152
  log: (node) => (node.config.exec?.persistent !== undefined ? serverLog(node.id) : log),
1031
1153
  placements,
1032
1154
  nestedDirsByProject,
1033
1155
  downloadModeOf: download.modeOf,
1034
1156
  shortCircuit,
1035
- dependedOn,
1157
+ dependedOn: depended,
1036
1158
  holders,
1037
1159
  })
1038
1160
  const buildExecuteArgs = (
@@ -1112,6 +1234,7 @@ async function runOnBus(
1112
1234
  heldBefore.kept.delete(node.id)
1113
1235
  if (hasEnded(server.child)) return undefined
1114
1236
  server.handOver()
1237
+ handed.add(server.child)
1115
1238
  server.out.bus = bus
1116
1239
  serverOuts.set(node.id, server.out)
1117
1240
  persistentRegistry.set(node.id, server.child)
@@ -1147,7 +1270,14 @@ async function runOnBus(
1147
1270
  judgeFlaky(o)
1148
1271
  keeper?.taskDone(o)
1149
1272
  log.taskComplete(o.node, o)
1150
- 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
+ })
1151
1281
  },
1152
1282
  // A crash a hand-broken lock entry caused is named as that; asked
1153
1283
  // only here, so a run that does not crash pays nothing.
@@ -1159,24 +1289,36 @@ async function runOnBus(
1159
1289
  : line,
1160
1290
  ),
1161
1291
  execute: (node, upstream) => {
1292
+ const skip = platformSkip(node)
1293
+ if (skip !== undefined) return Promise.resolve(skip)
1162
1294
  const kept = keepServer(node)
1163
- return kept !== undefined
1164
- ? Promise.resolve(kept)
1165
- : 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 }))
1166
1301
  },
1167
- // An unkeyed group runs nothing: settled in place, unless a taint
1168
- // 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.
1169
1305
  ...(options.continueMode !== 'always' && taintSeeds.size === 0
1170
1306
  ? {
1171
- settleNow: (node: TaskNode, upstream: TaskOutcome[]) =>
1172
- isGroupTask(node) && node.config.inputs === undefined
1173
- ? 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(
1174
1313
  node,
1175
1314
  keyUpstream(node, upstream),
1176
1315
  shortCircuit.groupKeys.get(node.id),
1177
1316
  runStartHrTimeNs,
1178
1317
  )
1179
- : 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
+ },
1180
1322
  }
1181
1323
  : {}),
1182
1324
  // A `schedule` plugin's weights; the scheduler keeps its structural
@@ -1214,8 +1356,11 @@ async function runOnBus(
1214
1356
  // signal loop) and embedders that manage lifecycle themselves — both
1215
1357
  // expect run() to return, not block on a server.
1216
1358
  const foreground = options.log === undefined && (options.handleSignals ?? true)
1217
- // An aborted run's children are already being torn down: nothing to hold.
1218
- 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)
1219
1364
  const { keepAlive, crashed: crashedPersistent } = await disposeServers({
1220
1365
  registry: persistentRegistry,
1221
1366
  nodes,
@@ -1231,7 +1376,14 @@ async function runOnBus(
1231
1376
  // Clear the status line for good before the summary prints.
1232
1377
  log.runEnd?.()
1233
1378
  const list = [...outcomes.values()]
1234
- 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
+ )
1235
1387
 
1236
1388
  // The summary + artifact writers + recordRun pass all exclude group
1237
1389
  // tasks via the shared tallyOutcomes helper. We pass the full
@@ -1261,7 +1413,7 @@ async function runOnBus(
1261
1413
  const stillDeferred = deferredOutputs.pending()
1262
1414
  if (stillDeferred.length > 0) {
1263
1415
  log.status(
1264
- ` Deferred: left outputs remote (--cache-download=none): ${stillDeferred.join(', ')}`,
1416
+ ` Deferred: left outputs remote${downloadPolicy === 'all' ? '' : ` (--cache-download=${downloadPolicy})`}: ${stillDeferred.join(', ')}`,
1265
1417
  )
1266
1418
  }
1267
1419
 
@@ -1277,6 +1429,9 @@ async function runOnBus(
1277
1429
  target: profile,
1278
1430
  cwd: options.cwd,
1279
1431
  outcomes: list,
1432
+ runStartNs: runStartHrTimeNs,
1433
+ executors: executors.map((e) => executorProfile(e, fault)),
1434
+ spans: runSpans,
1280
1435
  }),
1281
1436
  )
1282
1437
  log.status(`vx: profile written to ${wrote}`)
@@ -1334,8 +1489,12 @@ async function runOnBus(
1334
1489
  // read `ok` over the exit 1 and fed the flaky list a pass (X-23).
1335
1490
  const historyAfterWait = keepAlive.children.length > 0 && !hold && stop.stoppedBy === undefined
1336
1491
  // A failed task's output, for `vx last` and an agent (run-failures.ts).
1337
- const recordFailures = (final: readonly TaskOutcome[]): void =>
1338
- 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
+ }
1339
1498
  if (stop.stoppedBy === undefined && !historyAfterWait) {
1340
1499
  stepSync('vx:history', () => {
1341
1500
  recordHistory(() => cache.recordRunBundle(records))
@@ -1343,11 +1502,12 @@ async function runOnBus(
1343
1502
  })
1344
1503
  } else if (stop.stoppedBy !== undefined) {
1345
1504
  // Stopped: not a run `vx last` lists (item 854), but what finished is
1346
- // measured, or the next run knows none of it (M, 2026-10-21).
1505
+ // measured, or the next run knows none of it (2026-10-21).
1347
1506
  const ended = list.filter((o) => o.status !== 'aborted' && o.status !== 'skipped')
1348
- stepSync('vx:history', () =>
1349
- recordHistory(() => prepared.localCache.recordRuns(recordsOf(ended, false).runs)),
1350
- )
1507
+ stepSync('vx:history', () => {
1508
+ recordHistory(() => prepared.localCache.recordRuns(recordsOf(ended, false).runs))
1509
+ recordFailures(ended)
1510
+ })
1351
1511
  }
1352
1512
  mark('record history')
1353
1513
  // Drain any still-in-flight background prefetches before closing the
@@ -1374,7 +1534,10 @@ async function runOnBus(
1374
1534
  await prefetchDone
1375
1535
  await Promise.all([cache.drainUploads?.(), keeper?.settled()])
1376
1536
  })
1377
- 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
+ }
1378
1541
  // An upload that failed after the rows went in: its row says so too.
1379
1542
  if (prepared.localCache.uploads.failed > 0 && stop.stoppedBy === undefined && !historyAfterWait)
1380
1543
  recordHistory(() => recordLateRemoteErrors(prepared.localCache, records.runs, runId))
@@ -1395,7 +1558,8 @@ async function runOnBus(
1395
1558
  abortedCount: list.filter((o) => o.status === 'aborted' && !isGroupTask(o.node)).length,
1396
1559
  stages: stageTimes(),
1397
1560
  uploads: prepared.localCache.uploads,
1398
- machines: executors.flatMap(executorMachines),
1561
+ machines: executors.flatMap((e) => executorMachines(e, fault)),
1562
+ idealMs: executors.map((e) => executorIdeal(e, fault)).find((ms) => ms !== undefined),
1399
1563
  })
1400
1564
  telemetry.emitSummary(summary)
1401
1565
  const sinks = telemetry
@@ -1433,16 +1597,20 @@ async function runOnBus(
1433
1597
  }
1434
1598
  })
1435
1599
 
1436
- // 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
1437
1601
  // the run's own work is done, history, uploads, plugin teardown and the
1438
1602
  // sandbox reset included, so any warning they raise lands above it. A
1439
1603
  // task's own facts (flaky, blocked) ride its row. Only a kept server's
1440
1604
  // own output follows.
1441
1605
  // The footer replaces the live region in place.
1442
1606
  log.runDone?.()
1443
- 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 }
1444
1612
  if (options.footer !== false)
1445
- 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)
1446
1614
 
1447
1615
  // Edge case the summary already reported: the user requested a
1448
1616
  // persistent task (dev server / watcher). The run is "done" in every
@@ -1465,7 +1633,6 @@ async function runOnBus(
1465
1633
  persistent: holdServers(keepAlive, {
1466
1634
  forwardArgs: options.forwardArgs,
1467
1635
  outOf: (id) => serverOuts.get(id) ?? { bus },
1468
- warn: warnOf(log),
1469
1636
  }),
1470
1637
  }
1471
1638
  }
@@ -1492,7 +1659,9 @@ async function runOnBus(
1492
1659
  if (stop.signal.aborted) await stop.aborting?.catch(() => {})
1493
1660
  else await terminateChildren(() => keepAlive.children)
1494
1661
  // The server that ended the session on its own, not cleanly, failed.
1495
- 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)
1496
1665
  const final = [...outcomes.values()]
1497
1666
  if (historyAfterWait) {
1498
1667
  recordHistory(() => {
@@ -1500,14 +1669,14 @@ async function runOnBus(
1500
1669
  // to its schema during the session, and a mismatch resets silently.
1501
1670
  const local = new Cache(prepared.cacheDir, undefined, prepared.workspaceRoot)
1502
1671
  try {
1503
- local.recordRunBundle(recordsOf(final, ok && first.code === 0))
1672
+ local.recordRunBundle(recordsOf(final, ok && !crashed))
1504
1673
  } finally {
1505
1674
  local.close()
1506
1675
  }
1507
1676
  })
1508
1677
  recordFailures(final)
1509
1678
  }
1510
- return { ok: ok && first.code === 0, outcomes: final }
1679
+ return { ok: ok && !crashed, outcomes: final }
1511
1680
  }
1512
1681
 
1513
1682
  return { ok, outcomes: list }
@@ -1521,6 +1690,7 @@ async function runOnBus(
1521
1690
  // floor for a throw a later change adds, not a path a row can drive.
1522
1691
  log.runEnd?.()
1523
1692
  log.runDone?.()
1693
+ live?.dispose()
1524
1694
  stop.detach()
1525
1695
  // Plugins installed at the top of run() get their bus subscriptions
1526
1696
  // released here. Idempotent; safe even if installPlugins threw.
@@ -1578,8 +1748,8 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1578
1748
  const log = options.log ?? defaultLogger(undefined, undefined, process.stderr)
1579
1749
  const reasons = new Map<string, AffectedReason>()
1580
1750
  const prepared = await prepareRun({ ...options, affectedReasons: reasons }, log)
1581
- options = withRunSettings(options, prepared.workspaceConfig)
1582
1751
  try {
1752
+ options = withRunSettings(options, prepared.workspaceConfig)
1583
1753
  if (prepared.unresolvedTasks.length > 0) {
1584
1754
  return {
1585
1755
  tasks: [],
@@ -1599,6 +1769,7 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1599
1769
  if (prepared.empty !== null) return { tasks: [] }
1600
1770
  // The plan orders nothing, but it refuses what the run would refuse.
1601
1771
  await prepared.priorities()
1772
+ terminalHolders(prepared.nodes, options.tty === true)
1602
1773
  // Its own mark: a dry run's plan (every task's hash, the cache lookups,
1603
1774
  // the history p50s) was booked under `close`, the next mark, and read
1604
1775
  // as 105 ms of closing a cache at 1,000 projects (item 601).
@@ -1621,13 +1792,14 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1621
1792
  prepared.localCache.dbHandle(),
1622
1793
  undefined,
1623
1794
  prepared.branch(),
1795
+ taskDefinitions(prepared.nodes.values()),
1624
1796
  ),
1625
1797
  // Placement, by the same rules the run applies. Only worth showing
1626
1798
  // when there is a choice to show — with one declared executor every
1627
1799
  // line would carry the same label. Resolving the executors here is
1628
1800
  // the same plugin-factory call `prepareRun` already makes for the
1629
1801
  // cache capability, so plan mode gains no new class of side effect.
1630
- ...(await planExecutorOf(prepared, log, options.download ?? 'all')),
1802
+ ...(await planExecutorOf(prepared, log, options.download ?? 'all', detectCi(process.env).ci)),
1631
1803
  ...(await planRestorable(prepared, policy, options.forwardArgs)),
1632
1804
  })
1633
1805
  mark('plan')
@@ -1698,12 +1870,15 @@ async function unresolvedHint(prepared: {
1698
1870
  hintProjects: ReadonlyMap<string, ProjectEntry>
1699
1871
  declaredElsewhere: readonly string[]
1700
1872
  anyProjectConfig: boolean
1873
+ taskless: (name: string) => boolean
1874
+ unlockedConfig: () => string | undefined
1701
1875
  }): Promise<string> {
1702
1876
  const elsewhere = elsewhereHint(prepared.declaredElsewhere)
1703
1877
  const near = didYouMean(
1704
1878
  prepared.unresolvedTasks,
1705
1879
  prepared.hintProjects,
1706
1880
  prepared.declaredElsewhere,
1881
+ prepared.taskless,
1707
1882
  )
1708
1883
  const init = await initHint(prepared)
1709
1884
  const lock =
@@ -1714,13 +1889,20 @@ async function unresolvedHint(prepared: {
1714
1889
  }
1715
1890
 
1716
1891
  /**
1717
- * The first-run case, told apart from a typo: no project in the workspace
1718
- * has a `vx.config.*` at all, so no name could have resolved. Reached only
1719
- * 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.
1720
1897
  */
1721
- async function initHint(prepared: { anyProjectConfig: boolean }): Promise<string> {
1898
+ async function initHint(prepared: {
1899
+ anyProjectConfig: boolean
1900
+ unlockedConfig: () => string | undefined
1901
+ }): Promise<string> {
1722
1902
  if (prepared.anyProjectConfig) return ''
1723
- 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}.`
1724
1906
  }
1725
1907
 
1726
1908
  /**
@@ -1733,7 +1915,8 @@ async function initHint(prepared: { anyProjectConfig: boolean }): Promise<string
1733
1915
  function didYouMean(
1734
1916
  unresolved: readonly string[],
1735
1917
  projects: ReadonlyMap<string, ProjectEntry>,
1736
- elsewhere: readonly string[] = [],
1918
+ elsewhere: readonly string[],
1919
+ taskless: (name: string) => boolean,
1737
1920
  ): string {
1738
1921
  const tasksOf = (p: ProjectEntry | undefined): string[] => Object.keys(p?.config.tasks ?? {})
1739
1922
  // What a bare name can select: a default `build` is no match (X-102).
@@ -1778,10 +1961,20 @@ function didYouMean(
1778
1961
  )
1779
1962
  continue
1780
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
+ }
1781
1970
  if (!projects.has(proj)) {
1782
1971
  const p = projectNamed(proj, projects) ?? nearest(proj, projects.keys())
1783
- if (p !== undefined && tasksOf(projects.get(p)).includes(task)) hints.add(`${p}#${task}`)
1784
- 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) {
1785
1978
  lists.add(`No project is named ${proj}; projects: ${listed(projects.keys())}.`)
1786
1979
  }
1787
1980
  continue
@@ -1868,8 +2061,25 @@ function canonical(dir: string): string {
1868
2061
  }
1869
2062
  }
1870
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
+
1871
2081
  /** An executor's footer rows; a plugin's throw or bad shape shows none. */
1872
- function executorFooter(e: TaskExecutor): (readonly [string, ui.Inline])[] {
2082
+ function executorFooter(e: TaskExecutor, fault: ExecutorFault): (readonly [string, ui.Inline])[] {
1873
2083
  try {
1874
2084
  const rows = e.footer?.()
1875
2085
  if (!Array.isArray(rows)) return []
@@ -1879,27 +2089,60 @@ function executorFooter(e: TaskExecutor): (readonly [string, ui.Inline])[] {
1879
2089
  typeof r[0] === 'string' &&
1880
2090
  (typeof r[1] === 'string' || (typeof r[1] === 'object' && r[1] !== null)),
1881
2091
  )
1882
- } catch {
2092
+ } catch (err) {
2093
+ fault(e, 'footer', err)
1883
2094
  return []
1884
2095
  }
1885
2096
  }
1886
2097
 
1887
- function executorMachines(e: TaskExecutor): readonly MachineReport[] {
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[] {
1888
2115
  try {
1889
2116
  const m = e.machines?.()
1890
2117
  return Array.isArray(m) ? m : []
1891
- } catch {
2118
+ } catch (err) {
2119
+ fault(e, 'machines', err)
1892
2120
  return []
1893
2121
  }
1894
2122
  }
1895
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
+
1896
2134
  /** Where `e` says a task runs, or `null` while it waits; a throw or anything else says nothing. */
1897
- function executorWhere(e: TaskExecutor, id: string): string | null | undefined {
2135
+ function executorWhere(
2136
+ e: TaskExecutor,
2137
+ id: string,
2138
+ fault: ExecutorFault,
2139
+ ): string | null | undefined {
1898
2140
  try {
1899
2141
  const w = e.running?.(id)
1900
2142
  if (w === null) return null
1901
2143
  return typeof w === 'string' && w !== '' ? w : undefined
1902
- } catch {
2144
+ } catch (err) {
2145
+ fault(e, 'running', err)
1903
2146
  return undefined
1904
2147
  }
1905
2148
  }