@vzn/vx 0.0.637 → 0.0.639

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 (126) hide show
  1. package/docs/caching.md +84 -28
  2. package/docs/cli.md +273 -408
  3. package/docs/execution.md +19 -44
  4. package/docs/features.md +13 -15
  5. package/docs/patterns.md +5 -7
  6. package/docs/schema.md +68 -61
  7. package/package.json +5 -5
  8. package/schemas/last.json +7 -4
  9. package/schemas/plan.json +2 -2
  10. package/skills/vx/SKILL.md +19 -6
  11. package/src/bin.ts +13 -7
  12. package/src/cache/cache.ts +60 -11
  13. package/src/cache/git-inputs.ts +6 -0
  14. package/src/cache/index.ts +10 -2
  15. package/src/cache/inputs.ts +0 -24
  16. package/src/cache/layer.ts +32 -12
  17. package/src/cache/layered-cache.ts +112 -315
  18. package/src/cache/plugin-cache.ts +240 -0
  19. package/src/cache/policy.ts +9 -0
  20. package/src/cache/remote-lane.ts +383 -0
  21. package/src/cache/run-history.ts +92 -8
  22. package/src/cache/run-store.ts +77 -0
  23. package/src/cache/schema.ts +44 -13
  24. package/src/cache/scoped-cache.ts +22 -2
  25. package/src/cli/cache.ts +33 -16
  26. package/src/cli/completions.ts +11 -7
  27. package/src/cli/foreign-flags.ts +10 -22
  28. package/src/cli/help.ts +22 -38
  29. package/src/cli/index.ts +25 -11
  30. package/src/cli/info.ts +8 -6
  31. package/src/cli/init.ts +14 -15
  32. package/src/cli/last.ts +20 -10
  33. package/src/cli/lock.ts +39 -27
  34. package/src/cli/plan-format.ts +10 -61
  35. package/src/cli/plugin-commands.ts +77 -6
  36. package/src/cli/plugin-templates.ts +1 -1
  37. package/src/cli/run.ts +31 -191
  38. package/src/cli/select.ts +18 -9
  39. package/src/cli/show.ts +72 -40
  40. package/src/cli/task-verb.ts +4 -4
  41. package/src/cli/upgrade.ts +132 -39
  42. package/src/cli/watch-cycle.ts +2 -1
  43. package/src/cli/watch-filter.ts +7 -2
  44. package/src/cli/watch-judge.ts +6 -1
  45. package/src/cli/watch.ts +73 -34
  46. package/src/cli/why.ts +14 -5
  47. package/src/cli/workspace-config.ts +2 -2
  48. package/src/config.ts +0 -6
  49. package/src/exec/executor.ts +1 -1
  50. package/src/exec/sandbox-runtime.ts +1 -1
  51. package/src/exec/sandbox-violations.ts +3 -1
  52. package/src/graph/index.ts +9 -1
  53. package/src/graph/priorities.ts +1 -1
  54. package/src/graph/scheduler.ts +12 -16
  55. package/src/graph/task-graph.ts +119 -21
  56. package/src/graph/task-status.ts +95 -0
  57. package/src/{orchestrator → history}/failure-mode.ts +20 -11
  58. package/src/{orchestrator → history}/history.ts +61 -5
  59. package/src/history/index.ts +48 -0
  60. package/src/{orchestrator → history}/metrics.ts +14 -23
  61. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  62. package/src/index.ts +21 -29
  63. package/src/orchestrator/affected-tasks.ts +8 -3
  64. package/src/orchestrator/doctor.ts +2 -2
  65. package/src/orchestrator/execute-args.ts +80 -0
  66. package/src/orchestrator/execute-task.ts +47 -429
  67. package/src/orchestrator/fingerprint-watch.ts +2 -2
  68. package/src/orchestrator/index.ts +3 -74
  69. package/src/orchestrator/miss-save.ts +33 -3
  70. package/src/orchestrator/options.ts +1 -36
  71. package/src/orchestrator/output-host.ts +4 -3
  72. package/src/orchestrator/persistent-task.ts +321 -0
  73. package/src/orchestrator/persistent.ts +130 -3
  74. package/src/orchestrator/placement.ts +3 -3
  75. package/src/orchestrator/plan.ts +18 -5
  76. package/src/orchestrator/plugin-host.ts +77 -47
  77. package/src/orchestrator/plugin.ts +86 -14
  78. package/src/orchestrator/prepare.ts +74 -31
  79. package/src/orchestrator/projects.ts +3 -0
  80. package/src/orchestrator/remote-prefetch.ts +23 -6
  81. package/src/orchestrator/run-artifacts.ts +5 -177
  82. package/src/orchestrator/run-context.ts +55 -0
  83. package/src/orchestrator/run-failures.ts +15 -3
  84. package/src/orchestrator/run-records.ts +36 -4
  85. package/src/orchestrator/run-report.ts +81 -179
  86. package/src/orchestrator/run-settings.ts +1 -16
  87. package/src/orchestrator/run-stop.ts +153 -0
  88. package/src/orchestrator/run.ts +313 -555
  89. package/src/orchestrator/sandbox-fix.ts +0 -16
  90. package/src/orchestrator/signals.ts +14 -6
  91. package/src/orchestrator/stable-keys.ts +107 -3
  92. package/src/orchestrator/steps.ts +50 -0
  93. package/src/orchestrator/task-hash.ts +3 -16
  94. package/src/orchestrator/task-log-buffer.ts +1 -1
  95. package/src/orchestrator/task-process.ts +94 -0
  96. package/src/orchestrator/telemetry-host.ts +1 -1
  97. package/src/orchestrator/telemetry.ts +6 -83
  98. package/src/{orchestrator → report}/events.ts +41 -8
  99. package/src/{orchestrator → report}/forecast.ts +35 -2
  100. package/src/{orchestrator → report}/framed-output.ts +39 -34
  101. package/src/report/index.ts +36 -0
  102. package/src/{orchestrator → report}/logger.ts +166 -21
  103. package/src/{orchestrator → report}/status-line.ts +84 -7
  104. package/src/{orchestrator → report}/summary.ts +179 -63
  105. package/src/report/tally.ts +83 -0
  106. package/src/settings.ts +13 -23
  107. package/src/{orchestrator → util}/colors.ts +49 -4
  108. package/src/util/config-frame.ts +31 -2
  109. package/src/util/errors.ts +32 -0
  110. package/src/util/git-ignored.ts +143 -0
  111. package/src/util/index.ts +25 -3
  112. package/src/util/refusal-notice.ts +111 -0
  113. package/src/util/report.ts +39 -0
  114. package/src/util/settle.ts +14 -39
  115. package/src/util/tail.ts +1 -1
  116. package/src/util/ui.ts +197 -0
  117. package/src/workspace/affected.ts +20 -23
  118. package/src/workspace/dependency-rules.ts +12 -4
  119. package/src/workspace/migration.ts +87 -91
  120. package/src/workspace/workspace.ts +5 -4
  121. package/schemas/summary.json +0 -133
  122. package/src/cache/chained-cache.ts +0 -276
  123. package/src/orchestrator/tally.ts +0 -112
  124. /package/src/{orchestrator → report}/path-links.ts +0 -0
  125. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  126. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
@@ -11,6 +11,7 @@ import {
11
11
  type CacheLayer,
12
12
  type CachePolicy,
13
13
  cachesNothing,
14
+ formatCachePolicy,
14
15
  stopRuntimeProbes,
15
16
  } from '../cache/index.js'
16
17
  import { VERSION } from '../version.js'
@@ -35,7 +36,6 @@ import {
35
36
  import {
36
37
  mark,
37
38
  stageTimes,
38
- killGraceMs,
39
39
  MAX_TIMEOUT_MS,
40
40
  printTimings,
41
41
  ulid,
@@ -44,57 +44,64 @@ import {
44
44
  nearest,
45
45
  UserError,
46
46
  machineParallelism,
47
- teardownTimeoutMs,
48
47
  secretMask,
49
48
  isFsRefusal,
50
49
  isUserError,
50
+ detectColors,
51
+ type ColorSupport,
51
52
  } from '../util/index.js'
52
53
  import { keyedProjects } from './keyed-projects.js'
53
54
  import { isDefaultBuild } from './projects.js'
54
55
  import { commandWriteReach, prepareSandbox } from './sandbox-request.js'
55
- import type { OutputDirSnapshot, SaveFacts } from './miss-save.js'
56
+ import { recordOutputDirSnapshots, type OutputDirSnapshot, type SaveFacts } from './miss-save.js'
56
57
  import { admitTasks, taintTracker } from './admission.js'
57
58
  import { unkeyedGroupOutcome, type ExecuteArgs } from './execute-task.js'
59
+ import { executeArgsOf, type RunScope } from './execute-args.js'
58
60
  import { excludedTaint } from './excluded-keys.js'
59
61
  import { keyUpstream } from './upstream.js'
60
- import { busLogger, createEventBus, terminalSubscriber, type EventBus } from './events.js'
61
- import { installPlugins, PluginSetupError } from './plugin.js'
62
- import { buildAdmission, executorLabel, resolveExecutors, teardownPlugins } from './plugin-host.js'
62
+ import {
63
+ busLogger,
64
+ createEventBus,
65
+ createForecast,
66
+ defaultLogger,
67
+ type DefaultLogger,
68
+ type EventBus,
69
+ formatOutcomeTable,
70
+ formatPersistentList,
71
+ formatRunSummary,
72
+ type Logger,
73
+ plainOutput,
74
+ resolveOutputView,
75
+ terminalSubscriber,
76
+ warnOf,
77
+ } from '../report/index.js'
78
+ import { installPlugins, PluginSetupError, type RunReport } from './plugin.js'
79
+ import { keepRunBytes } from './run-report.js'
80
+ import { buildAdmission, executorDemand, resolveExecutors, teardownPlugins } from './plugin-host.js'
81
+ import { createSteps } from './steps.js'
63
82
  import { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
64
83
  import { claimOutput, guardedOutput } from './output-host.js'
65
- import { assembleRunSummary, isPassStatus } from './telemetry.js'
84
+ import { assembleRunSummary } from './telemetry.js'
85
+ import { isPassStatus } from '../graph/index.js'
66
86
  import type { RunContextRecord } from './telemetry.js'
67
- import { defaultLogger, type DefaultLogger, resolveOutputView, type Logger } from './logger.js'
68
- import { detectColors, type ColorSupport } from './colors.js'
69
- import { plainOutput } from './plain-output.js'
70
- import { formatPersistentList } from './framed-output.js'
71
- import { createForecast } from './forecast.js'
72
- import { LocalHistoryProvider } from './history.js'
73
87
  import { plan, type RunPlan } from './plan.js'
74
88
  import type { AffectedReason } from './affected-tasks.js'
75
89
  import { prepareRun, type PreparedRun } from './prepare.js'
76
90
  import { acquireRunLock } from './run-lock.js'
77
- import {
78
- forwardedSignal,
79
- type StopSignal,
80
- forwardSignals,
81
- SIGNAL_SHUTDOWN_GRACE_MS,
82
- terminateChildren,
83
- } from './signals.js'
91
+ import { terminateChildren } from './signals.js'
92
+ import { runStop } from './run-stop.js'
84
93
  import {
85
94
  hasPooledExecutor,
86
95
  placeTasks,
87
96
  planExecutorOf,
88
97
  poolOfPlacement,
89
98
  terminalHolders,
90
- UNPLACED_EXECUTOR,
91
99
  locallyPlaced,
92
100
  } from './placement.js'
93
101
  import {
94
- captureDefaultBranch,
102
+ telemetryRunContext,
95
103
  captureGitContext,
96
104
  captureHostContext,
97
- captureWorkspaceIdentity,
98
105
  detectCi,
99
106
  } from './run-context.js'
100
107
  import { startRemotePrefetch } from './remote-prefetch.js'
@@ -106,26 +113,25 @@ import {
106
113
  import { deriveStableKeys, probesAfterWrites } from './stable-keys.js'
107
114
 
108
115
  import { writeRunFailures } from './run-failures.js'
109
- import { assembleRunRecords } from './run-records.js'
116
+ import { assembleRunRecords, recordLateRemoteErrors } from './run-records.js'
110
117
  import {
111
118
  hasEnded,
112
- selectKeepAlive,
113
- serverConfig,
114
- shutdownPersistent,
119
+ disposeServers,
120
+ failServer,
121
+ holdServers,
122
+ sessionEnd,
115
123
  takeHeldServers,
116
124
  } from './persistent.js'
117
- import {
118
- runSummaryJson,
119
- writeRunProfile,
120
- writeRunSummary,
121
- type RunSummaryJson,
122
- } from './run-artifacts.js'
125
+ import { writeRunProfile } from './run-artifacts.js'
123
126
  import { createSaveLane } from './save-lane.js'
124
127
  import { commandWindows } from './command-windows.js'
125
- import { formatOutcomeTable, formatRunSummary } from './summary.js'
126
- import { detectFlaky, type FlakyCandidate, type FlakyFinding } from './failure-mode.js'
127
- import { createMissExplainer } from './miss-reason.js'
128
- import type { HeldServer, RunOptions, RunSummary } from './options.js'
128
+ import {
129
+ createMissExplainer,
130
+ detectFlaky,
131
+ type FlakyCandidate,
132
+ LocalHistoryProvider,
133
+ } from '../history/index.js'
134
+ import type { RunOptions, RunSummary } from './options.js'
129
135
  import { withRunSettings } from './run-settings.js'
130
136
  import { resolveSetting } from '../settings.js'
131
137
 
@@ -138,20 +144,6 @@ const emptyShortCircuit = (): ShortCircuit => ({
138
144
  groupKeys: new Map(),
139
145
  })
140
146
 
141
- /**
142
- * Compact the 4-axis cache policy into the `invocations.cache_policy`
143
- * column string. Each enabled axis contributes its flag; a fully-on
144
- * policy reads `'lR,lW,rR,rW'`. Pure presentation — never affects the key.
145
- */
146
- function compactCachePolicy(p: CachePolicy): string {
147
- const parts: string[] = []
148
- if (p.localRead) parts.push('lR')
149
- if (p.localWrite) parts.push('lW')
150
- if (p.remoteRead) parts.push('rR')
151
- if (p.remoteWrite) parts.push('rW')
152
- return parts.join(',')
153
- }
154
-
155
147
  /**
156
148
  * The policy that will actually govern the run. The remote axes are inert
157
149
  * without a remote layer to serve them: a `remote:w` policy over a bare
@@ -201,17 +193,6 @@ function maskInvocation(command: string): string {
201
193
  return secretMask([process.env])?.mask(command) ?? command
202
194
  }
203
195
 
204
- /**
205
- * A run's `--tag`s, stored on its history row and handed to telemetry
206
- * (`vx.tag.<key>`): a tag that carries a secret is masked as the
207
- * invocation line that spells it is (L-38).
208
- */
209
- function maskTags(tags: Readonly<Record<string, string>>): Record<string, string> {
210
- const mask = secretMask([process.env])
211
- if (mask === null) return { ...tags }
212
- return Object.fromEntries(Object.entries(tags).map(([k, v]) => [mask.mask(k), mask.mask(v)]))
213
- }
214
-
215
196
  /**
216
197
  * The command line a run hands to its telemetry sinks, with what follows
217
198
  * `--` counted, not quoted. Those are the task's arguments,
@@ -286,15 +267,11 @@ function refuseRunOptions(options: RunOptions): void {
286
267
  refuse('concurrency', concurrency, 'a positive integer')
287
268
  if (retries !== undefined && !(Number.isInteger(retries) && retries >= 0))
288
269
  refuse('retries', retries, 'a non-negative integer')
289
- for (const [name, ms] of [
290
- ['timeout', timeout],
291
- ['killGrace', options.killGrace],
292
- ['teardownTimeout', options.teardownTimeout],
293
- ['readyNotice', options.readyNotice],
294
- ] as const) {
295
- if (ms !== undefined && !(Number.isInteger(ms) && ms > 0 && ms <= MAX_TIMEOUT_MS))
296
- refuse(name, ms, `a positive integer of ms, at most ${MAX_TIMEOUT_MS}`)
297
- }
270
+ if (
271
+ timeout !== undefined &&
272
+ !(Number.isInteger(timeout) && timeout > 0 && timeout <= MAX_TIMEOUT_MS)
273
+ )
274
+ refuse('timeout', timeout, `a positive integer of ms, at most ${MAX_TIMEOUT_MS}`)
298
275
  }
299
276
 
300
277
  export async function run(options: RunOptions): Promise<RunSummary> {
@@ -315,17 +292,12 @@ export async function run(options: RunOptions): Promise<RunSummary> {
315
292
  // See docs/design/event-stream-2026-06.md.
316
293
  const terminal =
317
294
  options.log === undefined
318
- ? defaultLogger(
319
- colors,
320
- resolveOutputView(options),
321
- options.json === true ? process.stderr : process.stdout,
322
- {
323
- coalesce: true,
324
- ...(options.tty === true ? { tty: true } : {}),
325
- ...(options.forwardArgs !== undefined ? { forwardArgs: options.forwardArgs } : {}),
326
- ...(options.footer === false ? { footer: false } : {}),
327
- },
328
- )
295
+ ? defaultLogger(colors, resolveOutputView(options), process.stdout, {
296
+ coalesce: true,
297
+ ...(options.tty === true ? { tty: true } : {}),
298
+ ...(options.forwardArgs !== undefined ? { forwardArgs: options.forwardArgs } : {}),
299
+ ...(options.footer === false ? { footer: false } : {}),
300
+ })
329
301
  : null
330
302
  const sink = options.log ?? terminal!
331
303
  // An injected bus already has surfaces subscribed; we add the terminal
@@ -355,7 +327,10 @@ export async function run(options: RunOptions): Promise<RunSummary> {
355
327
  }
356
328
  let held = false
357
329
  try {
358
- const summary = await runOnBus(options, bus, colors, terminal, takeOutput)
330
+ // vx's own steps are timed only where something renders them.
331
+ const showsSteps =
332
+ terminal !== null ? terminal.showsSteps : options.log?.stepStart !== undefined
333
+ const summary = await runOnBus(options, bus, colors, terminal, showsSteps, takeOutput)
359
334
  if (summary.persistent === undefined) return summary
360
335
  // Servers handed back still running still write: `vx watch dev` printed
361
336
  // none of its server's log while it idled, the renderer gone with this
@@ -379,9 +354,11 @@ async function runOnBus(
379
354
  bus: EventBus,
380
355
  colors: ColorSupport,
381
356
  terminal: DefaultLogger | null,
357
+ showsSteps: boolean,
382
358
  takeOutput?: (claimed: { logger: Logger; owner: string }) => void,
383
359
  ): Promise<RunSummary> {
384
360
  const log = busLogger(bus)
361
+ const { step, stepSync } = createSteps(log, showsSteps)
385
362
  // A server's output goes through a cell of its own: a later watch cycle
386
363
  // that keeps the server points it at that cycle's bus (`RunOptions.keep`).
387
364
  const serverOuts = new Map<string, { bus: EventBus }>()
@@ -395,7 +372,7 @@ async function runOnBus(
395
372
  }
396
373
  }
397
374
 
398
- const prepared = await prepareRun(options, log)
375
+ const prepared = await step('vx:load', () => prepareRun(options, log))
399
376
  options = withRunSettings(options, prepared.workspaceConfig)
400
377
  mark('plugin stages')
401
378
  // Every exit from here tears the plugins down: their capability
@@ -405,11 +382,11 @@ async function runOnBus(
405
382
  // per cycle under `vx watch`. A plugin whose own setup threw set up
406
383
  // nothing, and is the one left out.
407
384
  let tornDown = false
408
- const teardown = async (except?: unknown): Promise<void> => {
385
+ const teardown = async (except?: unknown, report?: RunReport): Promise<void> => {
409
386
  if (tornDown) return
410
387
  tornDown = true
411
388
  const plugins = prepared.plugins.filter((p) => p !== except)
412
- await teardownPlugins(plugins, (m) => log.status(m))
389
+ await teardownPlugins(plugins, warnOf(log), report, step)
413
390
  }
414
391
  // A task whose command re-enters `vx run` in the workspace running it
415
392
  // is refused. When the inner run reaches this task again it forks a run
@@ -437,7 +414,7 @@ async function runOnBus(
437
414
  // A refusal is the caller's to print (on stderr, as `vx run:`): as a
438
415
  // status line it went to stdout, into whatever a script piped there.
439
416
  if (prepared.unresolvedTasks.length > 0) {
440
- const refused = `no projects declare task(s): ${prepared.unresolvedTasks.join(', ')}.${elsewhereHint(prepared.declaredElsewhere)}${didYouMean(prepared.unresolvedTasks, prepared.hintProjects, prepared.declaredElsewhere)}${await initHint(prepared)}`
417
+ const refused = `no projects declare task(s): ${prepared.unresolvedTasks.join(', ')}.${await unresolvedHint(prepared)}`
441
418
  await teardown()
442
419
  prepared.cache.close()
443
420
  return { ok: false, outcomes: [], refused }
@@ -467,13 +444,24 @@ async function runOnBus(
467
444
  // the plugin. A plugin without `setup` subscribes nothing.
468
445
  let disposePlugins: (() => void) | undefined
469
446
  let telemetry: TelemetryHandle | undefined
447
+ // One run-id per `vx run` invocation. Every task in the resulting graph
448
+ // carries it so analytics queries can group by invocation; plugins get it
449
+ // in their teardown's `RunReport`.
450
+ const runId = ulid()
451
+ const head = captureGitContext(prepared.workspaceRoot)
452
+ const ciContext = detectCi(process.env)
453
+ const keepRunLog = prepared.keepRunEntry !== undefined
470
454
  try {
471
455
  disposePlugins = await installPlugins({
472
456
  plugins: prepared.plugins,
473
457
  bus,
474
458
  workspaceRoot: prepared.workspaceRoot,
475
459
  cacheDir: prepared.cacheDir,
476
- warn: (m) => log.status(m),
460
+ warn: warnOf(log),
461
+ cache: prepared.cacheFor,
462
+ ci: ciContext.ci,
463
+ branch: prepared.branch(),
464
+ step,
477
465
  })
478
466
  } catch (err) {
479
467
  disposePlugins?.()
@@ -526,7 +514,7 @@ async function runOnBus(
526
514
  resolveExecutors(prepared.plugins, {
527
515
  workspaceRoot: prepared.workspaceRoot,
528
516
  cacheDir: prepared.cacheDir,
529
- warn: (m: string) => log.status(m),
517
+ warn: warnOf(log),
530
518
  concurrency,
531
519
  }),
532
520
  )
@@ -550,51 +538,11 @@ async function runOnBus(
550
538
  : nodes.get(id)?.config.cache === undefined
551
539
  ? 'no remote executor accepted it — the task declares no `cache` block, so there is no input set to ship'
552
540
  : 'no remote executor accepted it'
553
- log.status(`[vx] ${id}: exec.remote is 'only' and ${why} — nothing ran`)
554
- }
555
- }
556
- // Demand: what each executor still has left to run. An executor that
557
- // provisions per task (a container, an allocation, a pod) otherwise has to
558
- // guess from `capacity` alone and hold everything until teardown. Built
559
- // ONLY for executors that asked — with none declared there is no map, no
560
- // set, and no call in the completion path.
561
- const demandOf = new Map<TaskExecutor, Set<string>>()
562
- for (const [id, executor] of placements.executors) {
563
- if (executor.demand === undefined) continue
564
- let remaining = demandOf.get(executor)
565
- if (remaining === undefined) demandOf.set(executor, (remaining = new Set()))
566
- remaining.add(id)
567
- }
568
- // The SAME set is handed back every time, narrowed in place: a consumer
569
- // reading it later sees the truth rather than a stale snapshot. A hint,
570
- // as `admit` is: one that throws is named once and asked no more, where
571
- // it surfaced as a bare stack, or mid-run from the completion path
572
- // (item 1022).
573
- const tellDemand = (executor: TaskExecutor, remaining: ReadonlySet<string>): void => {
574
- const failed = (err: unknown): void => {
575
- if (!demandOf.delete(executor)) return
576
- const m = err instanceof Error ? err.message : String(err)
577
- log.status(`[vx] ${executorLabel(executor)} failed in demand: ${m}; not asked again this run`)
578
- }
579
- try {
580
- // An `async demand` that rejects did so where no one listened: an
581
- // unhandled rejection, a stack, the run killed. Its rejection is a
582
- // throw like any other.
583
- const ret: unknown = executor.demand!(remaining)
584
- if (ret instanceof Promise) ret.catch(failed)
585
- } catch (err) {
586
- failed(err)
541
+ warnOf(log)(`[vx] ${id}: exec.remote is 'only' and ${why} — nothing ran`)
587
542
  }
588
543
  }
589
- for (const [executor, remaining] of demandOf) tellDemand(executor, remaining)
590
- const narrowDemand =
591
- demandOf.size === 0
592
- ? (): void => {}
593
- : (id: string): void => {
594
- for (const [executor, remaining] of demandOf) {
595
- if (remaining.delete(id)) tellDemand(executor, remaining)
596
- }
597
- }
544
+ // Demand: what each executor still has left to run (`executorDemand`).
545
+ const narrowDemand = executorDemand(placements.executors, warnOf(log))
598
546
 
599
547
  // `cache.download` (default `all`) decides ONCE per task, here, whether a
600
548
  // remote execution's outputs come home.
@@ -621,88 +569,19 @@ async function runOnBus(
621
569
  | boolean
622
570
  | undefined
623
571
 
624
- // Run-scoped registries of live subprocesses:
625
- // - `liveChildren`: in-flight children. The runner adds/removes
626
- // each child around its spawn (persistent children stay until
627
- // they exit).
628
- // - `persistentRegistry`: ready persistent tasks (dev servers,
629
- // watchers). executeTask spawns them but does NOT await their
630
- // exit; ownership moves here so the orchestrator can SIGTERM
631
- // them once the rest of the graph finishes.
632
- //
633
- // A SIGINT/SIGTERM mid-run forwards that signal to everything in both
634
- // (`signals.ts`); the handlers are removed in the finally below so
635
- // repeated run() calls (test suites) never stack listeners.
636
- const liveChildren = new Set<ReturnType<typeof Bun.spawn>>()
637
- // A server that ends on its own while the graph still runs is said at
638
- // once: its dependants were failing against it, and the end of the run
639
- // was the first word of it (C-69). Not once the graph is done (the end
640
- // of the run and the keep-alive wait say it) nor under a stop.
641
- let graphDone = false
642
- const persistentRegistry = new (class extends Map<string, ReturnType<typeof Bun.spawn>> {
643
- override set(id: string, child: ReturnType<typeof Bun.spawn>): this {
644
- super.set(id, child)
645
- void child.exited.then((code) => {
646
- if (code !== 0 && !graphDone && !stopRun.signal.aborted)
647
- log.status(`vx: ${id} exited with code ${code} while the run went on`)
648
- })
649
- return this
650
- }
651
- })()
652
- // One stop for the run: an embedder's `RunOptions.signal` and a process
653
- // signal both abort it. The scheduler stops dispatching (it reads the
654
- // signal), the children are torn down, and run() returns through its own
655
- // end-of-run path. Detached in the finally below.
656
- const stopRun = new AbortController()
657
- const forwardAbort = (): void => stopRun.abort(options.signal?.reason)
658
- if (options.signal?.aborted === true) forwardAbort()
659
- else options.signal?.addEventListener('abort', forwardAbort, { once: true })
660
- // A run a process signal stopped records no history: until item 849 the
661
- // handler exited before any outcome landed, and a Ctrl-C'd run then read
662
- // in `vx last` as FAILED with 0 tasks (item 854). An embedder's abort is
663
- // its own call and keeps its record.
664
- let stoppedBy: StopSignal | undefined
665
- let leftRun = (): void => {}
666
- const runLeft = new Promise<void>((resolve) => {
667
- leftRun = resolve
668
- })
669
- const signals = forwardSignals({
670
- enabled: options.handleSignals ?? true,
572
+ const stop = runStop({
671
573
  log,
672
574
  cache,
673
- liveChildren,
674
- persistentRegistry,
675
- stop: (signal) => {
676
- stoppedBy = signal
677
- stopRun.abort(signal)
678
- },
679
- done: runLeft,
575
+ external: options.signal,
576
+ handleSignals: options.handleSignals ?? true,
680
577
  handsTerminal: holders.size > 0,
681
- // The grace, then one flush and each teardown at their own bound, and
682
- // slack for the summary and the cache close.
683
- boundMs:
684
- killGraceMs(SIGNAL_SHUTDOWN_GRACE_MS) +
685
- teardownTimeoutMs() * (1 + prepared.plugins.filter((p) => p.teardown).length) +
686
- 2_000,
578
+ teardowns: prepared.plugins.filter((p) => p.teardown).length,
579
+ onStop: () => {
580
+ stopRuntimeProbes(hashCache.runtime, hashCache.workspaceRuntime)
581
+ cache.stopUploads?.()
582
+ },
687
583
  })
688
- // Awaited before run() leaves: a task's shell can die of the signal at
689
- // once while a child it backgrounded ignores it and dies only to the
690
- // SIGKILL after the grace, and a run that returned first left that child
691
- // running past vx's exit (turborepo#14043's class).
692
- let aborting: Promise<void> | undefined
693
- // The servers that had ended when the stop landed: those crashed. One that
694
- // ends after it is the stop's own kill, and was named a crash (item 1061).
695
- let endedBeforeStop: ReadonlySet<ReturnType<typeof Bun.spawn>> | undefined
696
- const onAbort = (): void => {
697
- stopRuntimeProbes(hashCache.runtime, hashCache.workspaceRuntime)
698
- endedBeforeStop = new Set([...persistentRegistry.values()].filter(hasEnded))
699
- aborting = terminateChildren(
700
- () => [...liveChildren, ...persistentRegistry.values()],
701
- forwardedSignal(stopRun.signal.reason),
702
- )
703
- }
704
- if (stopRun.signal.aborted) onAbort()
705
- else stopRun.signal.addEventListener('abort', onAbort, { once: true })
584
+ const { liveChildren, persistentRegistry } = stop
706
585
  // The cache handle must be released on EVERY exit path, not just the
707
586
  // happy one: `close()` is also where the run's deferred `accessed_at`
708
587
  // bumps are flushed, so a throw between opening the cache and the
@@ -729,15 +608,14 @@ async function runOnBus(
729
608
  // Taken after the early exits above (nothing they do touches a tree) and
730
609
  // before the schedule: from here on tasks clean, restore and write.
731
610
  const releaseRunLock = await abandoning(() =>
732
- acquireRunLock(prepared.workspaceRoot, {
733
- log: (m) => log.status(m),
734
- signal: stopRun.signal,
735
- }),
611
+ step('vx:run-lock', () =>
612
+ acquireRunLock(prepared.workspaceRoot, {
613
+ log: warnOf(log),
614
+ signal: stop.signal,
615
+ }),
616
+ ),
736
617
  )
737
618
  try {
738
- // One run-id per `vx run` invocation. Every task in the resulting
739
- // graph carries it so analytics queries can group by invocation.
740
- const runId = ulid()
741
619
  const runStartHrTimeNs = process.hrtime.bigint()
742
620
  const endedAtMsAtStart = Date.now()
743
621
  const remoteCacheEnabled = prepared.hasRemoteLayer
@@ -748,7 +626,7 @@ async function runOnBus(
748
626
  // happened.
749
627
  const policy: CachePolicy = effectiveCachePolicy(prepared.cachePolicy, prepared.hasRemoteLayer)
750
628
  if (options.remoteRequested === true && !prepared.hasRemoteLayer) {
751
- log.status(
629
+ warnOf(log)(
752
630
  'vx: --cache-policy named the remote cache, but no cache plugin in .vx/workspace.* supplies one — the remote axes are off for this run',
753
631
  )
754
632
  }
@@ -768,10 +646,10 @@ async function runOnBus(
768
646
  // second status spawn.
769
647
  // `run.branch` names the branch over git's (`prepared.branch`).
770
648
  const gitContext = {
771
- ...captureGitContext(workspaceRoot, gitFilesCache.worktreeDirty),
649
+ ...head,
650
+ dirty: gitFilesCache.worktreeDirty,
772
651
  branch: prepared.branch(),
773
652
  }
774
- const ciContext = detectCi(process.env)
775
653
  const hostContext = captureHostContext()
776
654
 
777
655
  // The canonical run-context record — the same git/CI/host data the
@@ -791,51 +669,29 @@ async function runOnBus(
791
669
  // but declines still pays — its answer is only knowable by asking.
792
670
  const hasTelemetryPlugin = prepared.plugins.some((p) => p.telemetry !== undefined)
793
671
  if (hasTelemetryPlugin || options.telemetrySinks !== undefined) {
794
- // Workspace identity (telemetry v2), paid only when a telemetry
795
- // consumer can exist — a plain run never reaches here. The remote URL
796
- // is the enumeration's `git var -l`: a spawn here cost ~4 ms.
797
- const wsIdentity = captureWorkspaceIdentity(
798
- workspaceRoot,
799
- gitFilesCache.configValue('remote.origin.url'),
800
- )
801
- runContextRecord = {
672
+ runContextRecord = telemetryRunContext({
802
673
  runId,
803
- vxVersion: VERSION,
804
674
  // An embedder's `command` is redacted as the argv is: it passed a
805
675
  // token after `--` to every sink verbatim (C-67).
806
- // Masked as the stored line is: `--tag key=$DEPLOY_KEY` sits before
676
+ // Masked as the stored line is: a flag's secret value sits before
807
677
  // the `--` the count covers (L-44).
808
678
  command: maskInvocation(
809
679
  invocationCommand(options.command?.split(' ') ?? process.argv.slice(1)),
810
680
  ),
811
- requestedTasks: [...options.tasks],
812
- cachePolicy: compactCachePolicy(policy),
681
+ requestedTasks: options.tasks,
682
+ cachePolicy: formatCachePolicy(policy),
813
683
  concurrency,
814
684
  flow: options.flow ?? null,
815
- commitSha: gitContext.commitSha,
816
- branch: gitContext.branch,
817
- defaultBranch: captureDefaultBranch(process.env, workspaceRoot),
818
- dirty: gitContext.dirty,
819
- ci: ciContext.ci,
820
- ciProvider: ciContext.provider,
821
- ...(ciContext.runUrl !== undefined ? { ciRunUrl: ciContext.runUrl } : {}),
822
- ...(ciContext.change !== undefined ? { ciChange: ciContext.change } : {}),
823
- ...(ciContext.pipeline !== undefined ? { ciPipeline: ciContext.pipeline } : {}),
824
- ...(ciContext.job !== undefined ? { ciJob: ciContext.job } : {}),
825
- ...(ciContext.attempt !== undefined ? { ciAttempt: ciContext.attempt } : {}),
826
- host: hostContext.host,
827
- os: hostContext.os,
828
- arch: hostContext.arch,
829
- workspaceId: wsIdentity.id,
830
- workspaceName: wsIdentity.name,
831
- ...(wsIdentity.repository !== undefined ? { repository: wsIdentity.repository } : {}),
832
- ...(wsIdentity.path !== undefined ? { workspacePath: wsIdentity.path } : {}),
833
- tags: maskTags(options.tags ?? {}),
834
- }
685
+ git: gitContext,
686
+ ci: ciContext,
687
+ host: hostContext,
688
+ workspaceRoot,
689
+ originUrl: gitFilesCache.configValue('remote.origin.url'),
690
+ })
835
691
  telemetry = await subscribeTelemetry(
836
692
  prepared.plugins,
837
693
  bus,
838
- { workspaceRoot, cacheDir, warn: (m: string) => log.status(m) },
694
+ { workspaceRoot, cacheDir, warn: warnOf(log) },
839
695
  runContextRecord,
840
696
  options.telemetrySinks,
841
697
  nodes,
@@ -868,7 +724,7 @@ async function runOnBus(
868
724
  // each pack holds an artifact's bytes); a failed save is a miss next
869
725
  // time, said once — the task's work ran.
870
726
  const saveLane = createSaveLane(2 * concurrency, (err) =>
871
- log.status(`[vx] cache save failed: ${err instanceof Error ? err.message : String(err)}`),
727
+ warnOf(log)(`[vx] cache save failed: ${err instanceof Error ? err.message : String(err)}`),
872
728
  )
873
729
  const deferredSaves = new Map<string, Promise<SaveFacts>>()
874
730
  const windows = commandWindows()
@@ -884,9 +740,10 @@ async function runOnBus(
884
740
  // for display. Marks `node.surfaced`; never touches `requested`.
885
741
  markSurfacedDeps(nodes)
886
742
 
887
- // Header counts: unique projects covered by the graph (including
888
- // dependsOn-pulled deps, not just the user-requested set), and the
889
- // total number of real (non-group) task executions. Mirrors the
743
+ // Header counts: unique projects with a real (non-group) task in the
744
+ // graph (including dependsOn-pulled deps, not just the user-requested
745
+ // set; a `^build` placeholder in a project with no build is not one),
746
+ // and the total number of real task executions. Mirrors the
890
747
  // count the end-of-run summary reports under "total". The
891
748
  // requested count drives the focused logger's live-vs-buffered
892
749
  // decision, so surfaced nodes count toward it too — they display
@@ -895,8 +752,8 @@ async function runOnBus(
895
752
  let taskCount = 0
896
753
  let requestedCount = 0
897
754
  for (const node of nodes.values()) {
898
- packagesInScope.add(node.projectName)
899
755
  if (!isGroupTask(node)) {
756
+ packagesInScope.add(node.projectName)
900
757
  taskCount++
901
758
  if (node.requested || node.surfaced === true) requestedCount++
902
759
  }
@@ -904,7 +761,7 @@ async function runOnBus(
904
761
  // Admission over the worker count is a plugin's (`admit`): built once
905
762
  // into the predicate the scheduler asks at every local dispatch. No
906
763
  // plugin answers → undefined → the count-only path, byte for byte.
907
- const admit = buildAdmission(prepared.plugins, nodes, concurrency, (m) => log.status(m))
764
+ const admit = buildAdmission(prepared.plugins, nodes, concurrency, warnOf(log))
908
765
 
909
766
  // Run context for the footer. The top-of-run header is gone — the
910
767
  // banner now lives in the summary, where the eye lands at the end.
@@ -912,20 +769,25 @@ async function runOnBus(
912
769
  version: VERSION,
913
770
  packageCount: packagesInScope.size,
914
771
  remoteCacheEnabled,
772
+ cache: {
773
+ local: policy.localRead || policy.localWrite,
774
+ localWrite: policy.localWrite,
775
+ remote: remoteCacheEnabled && (policy.remoteRead || policy.remoteWrite),
776
+ traffic: prepared.localCache.traffic,
777
+ notDownloaded: () => deferredOutputs.pending().length,
778
+ },
915
779
  concurrency,
916
780
  workspaceProjectCount,
917
781
  }
918
782
 
919
783
  // Asked only when a plugin declares `output`: a plain run pays nothing.
920
784
  if (takeOutput !== undefined && prepared.plugins.some((p) => p.output !== undefined)) {
921
- const json = options.json === true
922
785
  const claimed = await claimOutput(prepared.plugins, {
923
786
  workspaceRoot,
924
787
  cacheDir,
925
- warn: (m: string) => log.status(m),
926
- isTTY: (json ? process.stderr : process.stdout).isTTY === true,
788
+ warn: warnOf(log),
789
+ isTTY: process.stdout.isTTY === true,
927
790
  ci: ciContext.ci,
928
- json,
929
791
  })
930
792
  if (claimed !== undefined) takeOutput(claimed)
931
793
  }
@@ -961,18 +823,21 @@ async function runOnBus(
961
823
  // unchanged (no upfront key pass, no local probing).
962
824
  let prefetchDone: Promise<void> = Promise.resolve()
963
825
  if (prepared.hasRemoteLayer) {
964
- prefetchDone = startRemotePrefetch({
965
- nodes,
966
- cache,
967
- workspaceRoot,
968
- claimedFiles: fingerprintWatch.names,
969
- forwardArgs: options.forwardArgs,
970
- nestedDirsByProject,
971
- gitFilesCache,
972
- hashCache,
973
- concurrency,
974
- remoteRead: policy.remoteRead,
975
- })
826
+ prefetchDone = step('vx:prefetch', () =>
827
+ startRemotePrefetch({
828
+ nodes,
829
+ cache,
830
+ workspaceRoot,
831
+ claimedFiles: fingerprintWatch.names,
832
+ forwardArgs: options.forwardArgs,
833
+ nestedDirsByProject,
834
+ gitFilesCache,
835
+ hashCache,
836
+ concurrency,
837
+ remoteRead: policy.remoteRead,
838
+ remoteWrite: policy.remoteWrite,
839
+ }),
840
+ )
976
841
  }
977
842
 
978
843
  // Local cache short-circuit (default-on). Up-front classify: derive
@@ -993,17 +858,19 @@ async function runOnBus(
993
858
  prepared.localCache.holdsNothing()
994
859
  if (coldLocal) prewarm()
995
860
  else if (shouldShortCircuit(nodes, policy, cache)) {
996
- shortCircuit = await startLocalShortCircuit({
997
- nodes,
998
- cache,
999
- workspaceRoot,
1000
- claimedFiles: fingerprintWatch.names,
1001
- forwardArgs: options.forwardArgs,
1002
- nestedDirsByProject,
1003
- gitFilesCache,
1004
- hashCache,
1005
- concurrency,
1006
- })
861
+ shortCircuit = await step('vx:keys', () =>
862
+ startLocalShortCircuit({
863
+ nodes,
864
+ cache,
865
+ workspaceRoot,
866
+ claimedFiles: fingerprintWatch.names,
867
+ forwardArgs: options.forwardArgs,
868
+ nestedDirsByProject,
869
+ gitFilesCache,
870
+ hashCache,
871
+ concurrency,
872
+ }),
873
+ )
1007
874
  // A confirmed miss runs too.
1008
875
  if (sandboxed.some((n) => shortCircuit.preProbed.get(n.id)?.hit === null)) prewarm()
1009
876
  }
@@ -1014,7 +881,7 @@ async function runOnBus(
1014
881
  // admission.ts.
1015
882
  const excluded = excludedTaint(nodes)
1016
883
  if (excluded.unsaved > 0) {
1017
- log.status(
884
+ warnOf(log)(
1018
885
  `[vx] --exclude-dependencies: ${excluded.unsaved} cached task(s) build on a skipped dependency; what they build is not saved`,
1019
886
  )
1020
887
  }
@@ -1043,19 +910,50 @@ async function runOnBus(
1043
910
  // The scheduler's fail-fast stop, as the retry loop of a task already
1044
911
  // in flight sees it.
1045
912
  const failFast = new AbortController()
1046
- // A literal of the fields every task carries, then each optional one
1047
- // assigned only when present: twelve conditional spreads per task cost
1048
- // ~18 ms of object copying on a 1,090-package warm run (X-149).
1049
- const track = telemetry?.track
1050
- const armSandbox = sandboxArmer !== null ? () => sandboxArmer.arm() : undefined
913
+ const scope: RunScope = {
914
+ workspaceRoot,
915
+ cache,
916
+ cachePolicy: policy,
917
+ forwardArgs: options.forwardArgs,
918
+ deferred: deferredOutputs,
919
+ cacheDir,
920
+ runStartHrTimeNs,
921
+ persistentRegistry,
922
+ liveChildren,
923
+ gitFilesCache,
924
+ hashCache,
925
+ probesAfterWrites: lateProbes,
926
+ fingerprintWatch,
927
+ keyedProjects: keyed,
928
+ outputDirSnapshots,
929
+ deferSave: saveLane.defer,
930
+ deferredSaves,
931
+ commandWindows: windows,
932
+ stopSignal: stop.signal,
933
+ failFast: failFast.signal,
934
+ inputStability,
935
+ }
936
+ if (options.retries !== undefined) scope.retries = options.retries
937
+ if (taskTimeoutDefault !== undefined) scope.timeout = taskTimeoutDefault
938
+ if (cleanOutputs !== undefined) scope.cleanOutputs = cleanOutputs
939
+ if (telemetry?.track !== undefined) scope.track = telemetry.track
940
+ if (explainMiss !== undefined) scope.explainMiss = explainMiss
941
+ if (keepRunLog) scope.keepRunLog = true
942
+ if (sandboxArmer !== null) scope.armSandbox = () => sandboxArmer.arm()
943
+ const argsOf = executeArgsOf(scope, {
944
+ log: (node) => (node.config.exec?.persistent !== undefined ? serverLog(node.id) : log),
945
+ placements,
946
+ nestedDirsByProject,
947
+ downloadModeOf: download.modeOf,
948
+ shortCircuit,
949
+ dependedOn,
950
+ holders,
951
+ })
1051
952
  const buildExecuteArgs = (
1052
953
  node: TaskNode,
1053
954
  upstream: TaskOutcome[],
1054
955
  reuseProbe = true,
1055
956
  ): ExecuteArgs => {
1056
- const probe = reuseProbe ? shortCircuit.preProbed.get(node.id) : undefined
1057
- const upfrontKey = shortCircuit.uncachedKeys.get(node.id)
1058
- const upfrontGroupKey = shortCircuit.groupKeys.get(node.id)
1059
957
  let deadServer: string | undefined
1060
958
  if (options.continueMode === 'always') {
1061
959
  for (const d of node.deps) {
@@ -1066,51 +964,7 @@ async function runOnBus(
1066
964
  }
1067
965
  const tainted = taint.judge(node, upstream)
1068
966
  if (tainted) taintedRan.set(node.id, deadServer ?? taint.cause(node, upstream))
1069
- const a: ExecuteArgs = {
1070
- node,
1071
- upstream,
1072
- workspaceRoot,
1073
- cache,
1074
- cachePolicy: policy,
1075
- forwardArgs: options.forwardArgs,
1076
- log: node.config.exec?.persistent !== undefined ? serverLog(node.id) : log,
1077
- executor: placements.executors.get(node.id) ?? UNPLACED_EXECUTOR,
1078
- deferred: deferredOutputs,
1079
- nestedProjectDirs: nestedDirsByProject.get(node.projectName) ?? [],
1080
- cacheDir,
1081
- runStartHrTimeNs,
1082
- persistentRegistry,
1083
- liveChildren,
1084
- gitFilesCache,
1085
- hashCache,
1086
- probesAfterWrites: lateProbes,
1087
- fingerprintWatch,
1088
- keyedProjects: keyed,
1089
- outputDirSnapshots,
1090
- deferSave: saveLane.defer,
1091
- deferredSaves,
1092
- commandWindows: windows,
1093
- stopSignal: stopRun.signal,
1094
- failFast: failFast.signal,
1095
- }
1096
- if (options.retries !== undefined) a.retries = options.retries
1097
- if (taskTimeoutDefault !== undefined) a.timeout = taskTimeoutDefault
1098
- if (options.readyNotice !== undefined) a.readyNotice = options.readyNotice
1099
- a.inputStability = inputStability
1100
- if (cleanOutputs !== undefined) a.cleanOutputs = cleanOutputs
1101
- if (download.modeOf.get(node.id) === 'deferred') a.download = 'deferred'
1102
- if (placements.remoteOnlyNoop.has(node.id)) a.remoteOnlyNoop = true
1103
- if (placements.remoteOnly.has(node.id)) a.remoteOnly = true
1104
- if (track !== undefined) a.track = track
1105
- if (explainMiss !== undefined) a.explainMiss = explainMiss
1106
- if (probe !== undefined) a.preProbed = probe
1107
- if (upfrontKey !== undefined) a.upfrontKey = upfrontKey
1108
- if (upfrontGroupKey !== undefined) a.upfrontGroupKey = upfrontGroupKey
1109
- if (tainted) a.taintedUpstream = true
1110
- if (!dependedOn.has(node.id)) a.noDependants = true
1111
- if (holders.has(node.id)) a.terminal = true
1112
- if (armSandbox !== undefined) a.armSandbox = armSandbox
1113
- return a
967
+ return argsOf(node, upstream, reuseProbe, tainted)
1114
968
  }
1115
969
 
1116
970
  const executeWithDedup = admitTasks({
@@ -1136,13 +990,12 @@ async function runOnBus(
1136
990
  (n) => n.config.exec?.command === undefined || shortCircuit.restoreTier.has(n.id),
1137
991
  )
1138
992
  ? new Map<string, number>()
1139
- : await prepared.priorities()
993
+ : await step('vx:schedule', () => prepared.priorities())
1140
994
  mark('schedule')
1141
995
  // Judged as each task finishes, against the history BEFORE this run's
1142
996
  // rows land, so the row that prints next carries the verdict: nothing
1143
997
  // prints below the footer. A hit or a skip asks nothing; a green miss
1144
998
  // is one probe of the failed-row index.
1145
- const flaky: FlakyFinding[] = []
1146
999
  const historyDb = prepared.localCache.dbHandle()
1147
1000
  const cacheOff = cachesNothing(policy)
1148
1001
  const judgeFlaky = (o: TaskOutcome): void => {
@@ -1151,7 +1004,6 @@ async function runOnBus(
1151
1004
  try {
1152
1005
  const found = detectFlaky(historyDb, candidates)[0]
1153
1006
  if (found === undefined) return
1154
- flaky.push(found)
1155
1007
  o.flaky = { passes: found.passes, failures: found.failures }
1156
1008
  } catch {
1157
1009
  // History is observability: an unreadable one judges nothing.
@@ -1187,7 +1039,7 @@ async function runOnBus(
1187
1039
  ...(options.continueMode !== undefined ? { continueMode: options.continueMode } : {}),
1188
1040
  serverDied,
1189
1041
  onFailFast: () => failFast.abort(),
1190
- signal: stopRun.signal,
1042
+ signal: stop.signal,
1191
1043
  onStart: (node) => {
1192
1044
  log.taskStart?.(node)
1193
1045
  },
@@ -1238,7 +1090,7 @@ async function runOnBus(
1238
1090
  // Empty when the short-circuit didn't fire → byte-identical.
1239
1091
  restoreTier: shortCircuit.restoreTier,
1240
1092
  })
1241
- graphDone = true
1093
+ stop.graphDone()
1242
1094
 
1243
1095
  // Which persistent children outlive the graph, and the bounded SIGTERM
1244
1096
  // of the rest, before the summary prints. Scoped to the real CLI
@@ -1248,49 +1100,19 @@ async function runOnBus(
1248
1100
  // expect run() to return, not block on a server.
1249
1101
  const foreground = options.log === undefined && (options.handleSignals ?? true)
1250
1102
  // An aborted run's children are already being torn down: nothing to hold.
1251
- const hold = options.holdPersistent === true && !stopRun.signal.aborted
1252
- // A run that failed elsewhere holds nothing: `vx run dev --all` with one
1253
- // server that never became ready sat on the others for good, so a
1254
- // script hung, and the Ctrl-C that ended it read 130 over the failure
1255
- // (C-60). A kept server's own crash ends the wait below as before, and
1256
- // `--continue=always` asked to keep going, and does. The watch loop
1257
- // (`holdPersistent`) still holds: a failing test in a cycle stopped
1258
- // the dev server the next change would restart anyway.
1259
- let keepAlive = selectKeepAlive(persistentRegistry, nodes, foreground || hold)
1260
- const failedElsewhere =
1261
- ![...outcomes.values()].every((o) => isPassStatus(o.status)) ||
1262
- [...persistentRegistry.values()].some(
1263
- (c) => hasEnded(c) && c.exitCode !== 0 && !keepAlive.children.includes(c),
1264
- )
1265
- if (failedElsewhere && !hold && options.continueMode !== 'always')
1266
- keepAlive = { nodes: [], children: [] }
1267
- const crashedPersistent = (
1268
- await shutdownPersistent(persistentRegistry, keepAlive.children)
1269
- ).filter((c) => endedBeforeStop?.has(persistentRegistry.get(c.id)!) ?? true)
1103
+ const hold = options.holdPersistent === true && !stop.signal.aborted
1104
+ const { keepAlive, crashed: crashedPersistent } = await disposeServers({
1105
+ registry: persistentRegistry,
1106
+ nodes,
1107
+ outcomes,
1108
+ keep: foreground || hold,
1109
+ hold,
1110
+ continueAlways: options.continueMode === 'always',
1111
+ endedBeforeStop: stop.endedBeforeStop,
1112
+ })
1270
1113
 
1271
1114
  mark('run graph')
1272
1115
 
1273
- // A server that died on its own before the stop failed, whatever its
1274
- // outcome said when it became ready: the footer counted it a success
1275
- // over the run's red exit (item 1071). A kept one that has already
1276
- // died is the same; the keep-alive wait below names it. Its code is its
1277
- // own, or the shell's report of the signal that took it.
1278
- const failServer = (id: string, code: number | string): void => {
1279
- const o = outcomes.get(id)
1280
- if (o === undefined) return
1281
- // In place: the renderer holds this outcome (events carry live refs)
1282
- // and closes the server's output block from it at runEnd, where a
1283
- // copy left it reading `running` under its exit line (WD-10).
1284
- o.status = 'failed'
1285
- o.exitCode = typeof code === 'number' ? code : signalExitCode(code)
1286
- }
1287
- for (const c of crashedPersistent) failServer(c.id, c.code)
1288
- keepAlive.nodes.forEach((n, i) => {
1289
- const child = keepAlive.children[i]!
1290
- if (!hasEnded(child) || child.exitCode === 0) return
1291
- if (endedBeforeStop !== undefined && !endedBeforeStop.has(child)) return
1292
- failServer(n.id, child.exitCode ?? child.signalCode ?? 'unknown')
1293
- })
1294
1116
  // Clear the status line for good before the summary prints.
1295
1117
  log.runEnd?.()
1296
1118
  const list = [...outcomes.values()]
@@ -1316,7 +1138,7 @@ async function runOnBus(
1316
1138
  // 1102).
1317
1139
  for (const c of crashedPersistent) {
1318
1140
  const code = typeof c.code === 'number' ? c.code : signalExitCode(c.code)
1319
- log.status(`vx: ${c.id} exited with code ${code} before the run stopped it`)
1141
+ warnOf(log)(`vx: ${c.id} exited with code ${code} before the run stopped it`)
1320
1142
  }
1321
1143
  // Outputs that never came home are not an error, but a silent `dist/`
1322
1144
  // that is empty-or-stale would be: name every task whose bytes are
@@ -1324,7 +1146,7 @@ async function runOnBus(
1324
1146
  const stillDeferred = deferredOutputs.pending()
1325
1147
  if (stillDeferred.length > 0) {
1326
1148
  log.status(
1327
- ` Deferred: ${stillDeferred.length} task(s) left outputs remote (--cache-download=none): ${stillDeferred.join(', ')}`,
1149
+ ` Deferred: left outputs remote (--cache-download=none): ${stillDeferred.join(', ')}`,
1328
1150
  )
1329
1151
  }
1330
1152
 
@@ -1332,56 +1154,20 @@ async function runOnBus(
1332
1154
  // change the run's exit code — the run already happened.
1333
1155
  // Written again after the keep-alive wait: a kept server's crash or a
1334
1156
  // Ctrl-C there is the process's exit, and the first write said ok.
1335
- let json: RunSummaryJson | undefined
1336
- const summarize = async (runOk: boolean, final = list): Promise<void> => {
1337
- if (options.json === true) {
1338
- json = runSummaryJson({
1339
- runId,
1340
- startedAtMs: endedAtMsAtStart,
1341
- endedAtMs,
1342
- totalMs,
1343
- ok: runOk,
1344
- ...(stoppedBy !== undefined && { exitCode: signalExitCode(stoppedBy) }),
1345
- outcomes: final,
1346
- flaky,
1347
- })
1348
- }
1349
- if (options.summarize === undefined) return
1350
- // The rewrite after the keep-alive wait says nothing: the footer is
1351
- // the run's last word.
1352
- const quiet = final !== list
1353
- try {
1354
- const wrote = await writeRunSummary({
1355
- target: options.summarize,
1356
- cacheDir,
1357
- cwd: options.cwd,
1358
- runId,
1359
- startedAtMs: endedAtMsAtStart,
1360
- endedAtMs,
1361
- totalMs,
1362
- ok: runOk,
1363
- ...(stoppedBy !== undefined && { exitCode: signalExitCode(stoppedBy) }),
1364
- outcomes: final,
1365
- flaky,
1366
- })
1367
- if (!quiet) log.status(`vx: summary written to ${wrote}`)
1368
- } catch (err) {
1369
- const msg = err instanceof Error ? err.message : String(err)
1370
- log.status(`vx: failed to write summary: ${msg}`)
1371
- }
1372
- }
1373
- await summarize(ok)
1374
- if (options.profile !== undefined) {
1157
+ const profile = options.profile
1158
+ if (profile !== undefined) {
1375
1159
  try {
1376
- const wrote = await writeRunProfile({
1377
- target: options.profile,
1378
- cwd: options.cwd,
1379
- outcomes: list,
1380
- })
1160
+ const wrote = await step('vx:profile', () =>
1161
+ writeRunProfile({
1162
+ target: profile,
1163
+ cwd: options.cwd,
1164
+ outcomes: list,
1165
+ }),
1166
+ )
1381
1167
  log.status(`vx: profile written to ${wrote}`)
1382
1168
  } catch (err) {
1383
1169
  const msg = err instanceof Error ? err.message : String(err)
1384
- log.status(`vx: failed to write profile: ${msg}`)
1170
+ warnOf(log)(`vx: failed to write profile: ${msg}`)
1385
1171
  }
1386
1172
  }
1387
1173
  // Record each task to the run history in a single SQLite transaction
@@ -1405,15 +1191,15 @@ async function runOnBus(
1405
1191
  // own output masks it, so cache.db holds no plaintext value.
1406
1192
  command: maskInvocation(options.command ?? process.argv.slice(1).join(' ')),
1407
1193
  requestedTasks: options.tasks,
1408
- cachePolicy: compactCachePolicy(policy),
1194
+ cachePolicy: formatCachePolicy(policy),
1409
1195
  concurrency,
1410
1196
  flow: options.flow ?? null,
1411
1197
  forwardArgs: options.forwardArgs,
1412
- tags: maskTags(options.tags ?? {}),
1413
1198
  git: gitContext,
1414
1199
  ci: ciContext,
1415
1200
  host: hostContext,
1416
1201
  withTelemetry: telemetry !== undefined,
1202
+ remoteTraffic: prepared.localCache.traffic.keys,
1417
1203
  })
1418
1204
  const records = recordsOf(list, ok)
1419
1205
  const recordHistory = (write: () => void): void => {
@@ -1425,27 +1211,21 @@ async function runOnBus(
1425
1211
  // an unprivileged user on a 2 MiB disk, 2026-09-16). The run said
1426
1212
  // what happened; `vx last` and the flaky list will not know this one.
1427
1213
  const message = err instanceof Error ? err.message : String(err)
1428
- log.status(`[vx] run history not recorded: ${message} — the verdict above stands`)
1214
+ warnOf(log)(`[vx] run history not recorded: ${message} — the verdict above stands`)
1429
1215
  }
1430
1216
  }
1431
1217
  // A server kept in the foreground can still fail the run after the
1432
1218
  // cache closes: its history is written once the wait has decided, or it
1433
1219
  // read `ok` over the exit 1 and fed the flaky list a pass (X-23).
1434
- const historyAfterWait = keepAlive.children.length > 0 && !hold && stoppedBy === undefined
1435
- // A failed task's output, for `vx last` and an agent: files beside the
1436
- // index, so a refused write (a full disk, a read-only cache dir) costs
1437
- // only that output.
1438
- const recordFailures = (final: readonly TaskOutcome[]): void => {
1439
- try {
1440
- writeRunFailures(prepared.cacheDir, runId, final)
1441
- } catch (err) {
1442
- if (!isFsRefusal(err)) throw err
1443
- log.status(`[vx] failed tasks' output not kept: ${err.message} — the verdict above stands`)
1444
- }
1445
- }
1446
- if (stoppedBy === undefined && !historyAfterWait) {
1447
- recordHistory(() => cache.recordRunBundle(records))
1448
- recordFailures(list)
1220
+ const historyAfterWait = keepAlive.children.length > 0 && !hold && stop.stoppedBy === undefined
1221
+ // A failed task's output, for `vx last` and an agent (run-failures.ts).
1222
+ const recordFailures = (final: readonly TaskOutcome[]): void =>
1223
+ writeRunFailures(prepared.cacheDir, runId, final, warnOf(log))
1224
+ if (stop.stoppedBy === undefined && !historyAfterWait) {
1225
+ stepSync('vx:history', () => {
1226
+ recordHistory(() => cache.recordRunBundle(records))
1227
+ recordFailures(list)
1228
+ })
1449
1229
  }
1450
1230
  mark('record history')
1451
1231
  // Drain any still-in-flight background prefetches before closing the
@@ -1463,13 +1243,29 @@ async function runOnBus(
1463
1243
  // channel shut from under the upload — silently losing every remote
1464
1244
  // write with nothing but a warning. The seam's contract is that a
1465
1245
  // cache layer stays usable until the run's uploads have settled.
1466
- await prefetchDone
1467
1246
  // Every deferred save has settled by here: the scheduler finishes a
1468
1247
  // task only once its `settledOf` promise (the lane's `landed`) has,
1469
1248
  // so `runGraph` resolved with every save in the cache. A second
1470
1249
  // drain of the lane stood here until item 634; deleting it changed
1471
1250
  // nothing the suite could see, because this gate is the rule.
1472
- await cache.drainUploads?.()
1251
+ await step('vx:uploads', async () => {
1252
+ await prefetchDone
1253
+ await cache.drainUploads?.()
1254
+ })
1255
+ // An upload that failed after the rows went in: its row says so too.
1256
+ if (prepared.localCache.uploads.failed > 0 && stop.stoppedBy === undefined && !historyAfterWait)
1257
+ recordHistory(() => recordLateRemoteErrors(prepared.localCache, records.runs, runId))
1258
+ const report = await step('vx:keep-run', () =>
1259
+ keepRunBytes(list, {
1260
+ runId,
1261
+ commit: head.commitSha,
1262
+ ciRun: ciContext,
1263
+ ok,
1264
+ workspaceRoot: prepared.workspaceRoot,
1265
+ nestedDirsByProject: prepared.nestedDirsByProject,
1266
+ keep: prepared.keepRunEntry,
1267
+ }),
1268
+ )
1473
1269
  // Hand the per-run summary to the telemetry sinks + drain them. Only
1474
1270
  // when a sink is active (telemetry !== undefined) — otherwise this
1475
1271
  // whole block is skipped and the run is byte-identical to before.
@@ -1488,72 +1284,52 @@ async function runOnBus(
1488
1284
  uploads: prepared.localCache.uploads,
1489
1285
  })
1490
1286
  telemetry.emitSummary(summary)
1491
- await telemetry.flush()
1287
+ const sinks = telemetry
1288
+ await step('vx:telemetry', () => sinks.flush())
1492
1289
  }
1493
1290
  // End-of-run plugin lifecycle: each plugin's teardown(). Crash-isolated
1494
1291
  // + time-bounded inside teardownPlugins, so a faulty plugin can neither
1495
1292
  // fail nor hang the run. Here on the normal path, after the drains
1496
1293
  // above; every other exit takes it through `teardown` too.
1497
- // The miss path's output-directory snapshots, taken now that the
1498
- // directories are old enough for the snapshot's racy window (see
1499
- // miss-save.ts). A few at a time: each is an lstat + readdir per
1500
- // prefix; the rows land together at close, in one transaction.
1501
- // Each walk is held to its entry's rows (item 1087), read here in one
1502
- // batch: read per snapshot, each read also flushed the snapshots before
1503
- // it, one transaction apiece (+110 ms at 1,000).
1504
- const snapshotRows = new Map<string, ReadonlyArray<{ path: string }>>()
1505
- for (let i = 0; i < outputDirSnapshots.length; i += 900) {
1506
- const batch = cache.loadOutputFilesBatch(
1507
- outputDirSnapshots.slice(i, i + 900).map((s) => s.hash),
1508
- )
1509
- for (const [hash, rows] of batch) snapshotRows.set(hash, rows)
1510
- }
1511
- for (let i = 0; i < outputDirSnapshots.length; i += 32) {
1512
- await Promise.all(
1513
- outputDirSnapshots
1514
- .slice(i, i + 32)
1515
- .map((s) =>
1516
- cache.recordOutputDirs?.(s.hash, s.projectDir, s.prefixes, (files) =>
1517
- s.holds(files, snapshotRows.get(s.hash) ?? []),
1518
- ),
1519
- ),
1520
- )
1521
- }
1294
+ // The miss path's output-directory snapshots first (miss-save.ts).
1295
+ await step('vx:snapshots', () => recordOutputDirSnapshots(cache, outputDirSnapshots))
1522
1296
  mark('output dir snapshots')
1523
1297
  // Not on a stopped run: one stopped while it waited on another run's
1524
1298
  // lock never held it, and its prune evicted under that run (item 858).
1525
- if (!stopRun.signal.aborted) await applyCacheRetention(prepared)
1299
+ if (!stop.signal.aborted) await step('vx:retention', () => applyCacheRetention(prepared))
1526
1300
  // A plugin hears the run until its teardown and nothing after: released
1527
1301
  // only in the finally, its handlers heard a kept server through the
1528
1302
  // whole keep-alive wait below (C-66). Idempotent; the finally's stay.
1529
1303
  disposePlugins?.()
1530
1304
  telemetry?.dispose()
1531
- await teardown()
1532
- await closeCache()
1305
+ await teardown(undefined, report)
1306
+ await step('vx:close', closeCache)
1533
1307
  mark('close')
1534
1308
  printTimings()
1535
1309
 
1536
1310
  // Tear down SRT's network bridge + (on macOS) log monitor. No-op if
1537
1311
  // no task was sandboxed; otherwise SRT keeps proxy servers alive and
1538
1312
  // the next vx run would init on top of stale state.
1539
- await prewarming
1540
- if (sandboxArmer?.armed) {
1541
- try {
1542
- await resetSandbox()
1543
- } catch (err) {
1544
- const msg = err instanceof Error ? err.message : String(err)
1545
- log.status(`vx: sandbox cleanup failed: ${msg}`)
1313
+ await step('vx:sandbox', async () => {
1314
+ await prewarming
1315
+ if (sandboxArmer?.armed) {
1316
+ try {
1317
+ await resetSandbox()
1318
+ } catch (err) {
1319
+ const msg = err instanceof Error ? err.message : String(err)
1320
+ warnOf(log)(`vx: sandbox cleanup failed: ${msg}`)
1321
+ }
1546
1322
  }
1547
- }
1323
+ })
1548
1324
 
1549
1325
  // The footer is the run's last word (owner, 2026-10-06): it prints once
1550
1326
  // the run's own work is done, history, uploads, plugin teardown and the
1551
1327
  // sandbox reset included, so any warning they raise lands above it. A
1552
1328
  // task's own facts (flaky, blocked) ride its row. Only a kept server's
1553
1329
  // own output follows.
1330
+ // The footer replaces the live region in place.
1331
+ log.runDone?.()
1554
1332
  if (options.summaryTable === true) for (const line of formatOutcomeTable(list)) log.status(line)
1555
- const above = options.beforeFooter?.(list, ok)
1556
- if (above) log.status(above.replace(/\n$/, ''))
1557
1333
  if (options.footer !== false)
1558
1334
  for (const line of formatRunSummary(list, totalMs, colors, runContext)) log.status(line)
1559
1335
 
@@ -1572,57 +1348,18 @@ async function runOnBus(
1572
1348
  // ended the session and its code — the summary above said `success`
1573
1349
  // for it, and an exit 1 with no word about why is a mystery in a log.
1574
1350
  if (keepAlive.children.length > 0 && hold) {
1575
- const held = keepAlive.children
1576
- // A held server that dies on its own is said, as the foreground's
1577
- // keep-alive says it: `vx watch` sat on "watching" over a dead dev
1578
- // server. The holder's own stop() is not such a death.
1579
- let stopping = false
1580
- // Taken by a later run (`RunOptions.keep`): kept or stopped there.
1581
- const handed = new Set<ReturnType<typeof Bun.spawn>>()
1582
- const servers = new Map<string, HeldServer>()
1583
- keepAlive.nodes.forEach((n, i) => {
1584
- const child = held[i]!
1585
- servers.set(n.id, {
1586
- child,
1587
- config: serverConfig(n, options.forwardArgs),
1588
- out: serverOuts.get(n.id) ?? { bus },
1589
- handOver: () => void handed.add(child),
1590
- })
1591
- // One that died during the graph was said then.
1592
- if (hasEnded(child)) return
1593
- void child.exited.then((code) => {
1594
- if (!stopping && !handed.has(child) && code !== 0)
1595
- log.status(`vx: ${n.id} exited with code ${code}`)
1596
- })
1597
- })
1598
1351
  return {
1599
1352
  ok,
1600
1353
  outcomes: list,
1601
- persistent: {
1602
- ids: keepAlive.nodes.map((n) => n.id),
1603
- servers,
1604
- stop: (signal) => {
1605
- stopping = true
1606
- return terminateChildren(() => held.filter((c) => !handed.has(c)), signal)
1607
- },
1608
- },
1354
+ persistent: holdServers(keepAlive, {
1355
+ forwardArgs: options.forwardArgs,
1356
+ outOf: (id) => serverOuts.get(id) ?? { bus },
1357
+ warn: warnOf(log),
1358
+ }),
1609
1359
  }
1610
1360
  }
1611
1361
  if (keepAlive.children.length > 0) {
1612
- // A server kept only as a dependency that exits 0 is a daemon that
1613
- // forked and returned (schema.md), not the end of the session: a
1614
- // `db: 'docker compose up -d'` under `app#dev` stopped the dev server
1615
- // right after the summary (WD-3). It ends the wait only as the last.
1616
- const first = await new Promise<{ code: number; i: number }>((resolve) => {
1617
- let left = keepAlive.children.length
1618
- keepAlive.children.forEach((c, i) => {
1619
- void c.exited.then((code) => {
1620
- left--
1621
- const n = keepAlive.nodes[i]!
1622
- if (code !== 0 || n.requested || n.surfaced === true || left === 0) resolve({ code, i })
1623
- })
1624
- })
1625
- })
1362
+ const first = await sessionEnd(keepAlive)
1626
1363
  const node = keepAlive.nodes[first.i]!
1627
1364
  // Only the ones still up are stopped: a kept server already dead was
1628
1365
  // counted as one (WD-11).
@@ -1630,8 +1367,8 @@ async function runOnBus(
1630
1367
  // Not when the run was stopped: the server ended because the user
1631
1368
  // stopped it, and "exited with code 130" read as a crash after every
1632
1369
  // Ctrl-C once a stop let run() finish its own path (item 852).
1633
- if (!stopRun.signal.aborted) {
1634
- log.status(
1370
+ if (!stop.signal.aborted) {
1371
+ warnOf(log)(
1635
1372
  `vx: ${node.id} exited with code ${first.code}` +
1636
1373
  (others > 0
1637
1374
  ? `; stopping ${others} other persistent task${others === 1 ? '' : 's'}`
@@ -1641,14 +1378,11 @@ async function runOnBus(
1641
1378
  // Under a stop the abort is already taking them down with the stop's
1642
1379
  // signal; a second SIGTERM in its grace cut short the graceful
1643
1380
  // shutdown of a server that reads it as "quit now" (WD-16).
1644
- if (stopRun.signal.aborted) await aborting?.catch(() => {})
1381
+ if (stop.signal.aborted) await stop.aborting?.catch(() => {})
1645
1382
  else await terminateChildren(() => keepAlive.children)
1646
- // The server that ended the session on its own, not cleanly, failed:
1647
- // the rewritten summary said `ok: false` over every task `success`
1648
- // and `failed: 0`, and so did the outcomes `--report` renders (C-53).
1649
- if (!stopRun.signal.aborted && first.code !== 0) failServer(node.id, first.code)
1383
+ // The server that ended the session on its own, not cleanly, failed.
1384
+ if (!stop.signal.aborted && first.code !== 0) failServer(outcomes, node.id, first.code)
1650
1385
  const final = [...outcomes.values()]
1651
- await summarize(ok && first.code === 0, final)
1652
1386
  if (historyAfterWait) {
1653
1387
  recordHistory(() => {
1654
1388
  // A writer's open: another vx version may have reset the index
@@ -1662,10 +1396,10 @@ async function runOnBus(
1662
1396
  })
1663
1397
  recordFailures(final)
1664
1398
  }
1665
- return { ok: ok && first.code === 0, outcomes: final, ...(json && { json }) }
1399
+ return { ok: ok && first.code === 0, outcomes: final }
1666
1400
  }
1667
1401
 
1668
- return { ok, outcomes: list, ...(json && { json }) }
1402
+ return { ok, outcomes: list }
1669
1403
  } finally {
1670
1404
  // Idempotent. The status-line ticker starts in runStart, and every
1671
1405
  // call between it and the success path's runEnd is crash-isolated
@@ -1675,9 +1409,8 @@ async function runOnBus(
1675
1409
  // here), so deleting this line reddens nothing (item 635). It is the
1676
1410
  // floor for a throw a later change adds, not a path a row can drive.
1677
1411
  log.runEnd?.()
1678
- signals.remove()
1679
- options.signal?.removeEventListener('abort', forwardAbort)
1680
- stopRun.signal.removeEventListener('abort', onAbort)
1412
+ log.runDone?.()
1413
+ stop.detach()
1681
1414
  // Plugins installed at the top of run() get their bus subscriptions
1682
1415
  // released here. Idempotent; safe even if installPlugins threw.
1683
1416
  disposePlugins?.()
@@ -1694,9 +1427,9 @@ async function runOnBus(
1694
1427
  } catch {
1695
1428
  // teardown must not throw on the way out
1696
1429
  }
1697
- await aborting?.catch(() => {})
1430
+ await stop.aborting?.catch(() => {})
1698
1431
  // Last: a process signal waits for this before it exits.
1699
- leftRun()
1432
+ stop.left()
1700
1433
  }
1701
1434
  }
1702
1435
 
@@ -1741,7 +1474,7 @@ function noneAffected(prepared: PreparedRun, options: RunOptions): string {
1741
1474
  * Planning mode. Same setup as `run()` — workspace discovery, config
1742
1475
  * load, package graph, task graph — but stops short of execution.
1743
1476
  * Returns a `RunPlan` predicting the cache hit/miss outcome of every
1744
- * task. Used by `--dry-run` and `--graph`.
1477
+ * task. Used by `--dry`.
1745
1478
  *
1746
1479
  * Side-effects are limited to opening + closing the local Cache handle
1747
1480
  * (and running `cache.inputs.runtime` probe commands, which key
@@ -1761,7 +1494,7 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1761
1494
  return {
1762
1495
  tasks: [],
1763
1496
  unresolvedTasks: prepared.unresolvedTasks,
1764
- unresolvedHint: `${elsewhereHint(prepared.declaredElsewhere)}${didYouMean(prepared.unresolvedTasks, prepared.hintProjects, prepared.declaredElsewhere)}${await initHint(prepared)}`,
1497
+ unresolvedHint: await unresolvedHint(prepared),
1765
1498
  }
1766
1499
  }
1767
1500
  if (prepared.empty === 'no-tasks-declared' && prepared.declaredElsewhere.length > 0) {
@@ -1816,7 +1549,7 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1816
1549
  } finally {
1817
1550
  // The plan called the cache and executor factories; teardown releases
1818
1551
  // what they opened, as a run's end does (item 1021).
1819
- await teardownPlugins(prepared.plugins, (m) => log.status(m))
1552
+ await teardownPlugins(prepared.plugins, warnOf(log))
1820
1553
  prepared.cache.close()
1821
1554
  // The same table a run prints: a dry run is how the prepare stages
1822
1555
  // (discovery, config load, the git enumeration) get profiled on a
@@ -1865,6 +1598,31 @@ function elsewhereHint(names: readonly string[]): string {
1865
1598
  return ` Only projects outside the selection declare ${names.join(', ')} — pass --all, or --filter to pick them.`
1866
1599
  }
1867
1600
 
1601
+ /**
1602
+ * What follows "no projects declare task(s): …": where the name is, its
1603
+ * nearest spelling, and, with neither, the lock a run reads its configs
1604
+ * from: a task just added to a vx.config is not in it until `vx lock`.
1605
+ */
1606
+ async function unresolvedHint(prepared: {
1607
+ unresolvedTasks: readonly string[]
1608
+ hintProjects: ReadonlyMap<string, ProjectEntry>
1609
+ declaredElsewhere: readonly string[]
1610
+ anyProjectConfig: boolean
1611
+ }): Promise<string> {
1612
+ const elsewhere = elsewhereHint(prepared.declaredElsewhere)
1613
+ const near = didYouMean(
1614
+ prepared.unresolvedTasks,
1615
+ prepared.hintProjects,
1616
+ prepared.declaredElsewhere,
1617
+ )
1618
+ const init = await initHint(prepared)
1619
+ const lock =
1620
+ init === '' && elsewhere === '' && !near.startsWith(' Did you mean')
1621
+ ? ' A run reads the lock: after editing a vx.config, run `vx lock`.'
1622
+ : ''
1623
+ return `${elsewhere}${near}${init}${lock}`
1624
+ }
1625
+
1868
1626
  /**
1869
1627
  * The first-run case, told apart from a typo: no project in the workspace
1870
1628
  * has a `vx.config.*` at all, so no name could have resolved. Reached only