@vzn/vx 0.0.638 → 0.0.640

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 (134) hide show
  1. package/README.md +1 -1
  2. package/docs/caching.md +152 -95
  3. package/docs/cli.md +225 -372
  4. package/docs/execution.md +22 -47
  5. package/docs/features.md +14 -16
  6. package/docs/patterns.md +6 -8
  7. package/docs/schema.md +268 -215
  8. package/docs/security.md +1 -1
  9. package/package.json +5 -5
  10. package/schemas/last.json +7 -4
  11. package/schemas/show.json +14 -14
  12. package/skills/vx/SKILL.md +10 -7
  13. package/src/cache/cache.ts +44 -11
  14. package/src/cache/file-hashes.ts +7 -12
  15. package/src/cache/fs-clock.ts +62 -0
  16. package/src/cache/git-inputs.ts +8 -2
  17. package/src/cache/index.ts +5 -2
  18. package/src/cache/inputs.ts +154 -83
  19. package/src/cache/key-fold.ts +23 -3
  20. package/src/cache/layer.ts +33 -18
  21. package/src/cache/layered-cache.ts +108 -315
  22. package/src/cache/linked-inputs.ts +87 -0
  23. package/src/cache/plugin-cache.ts +2 -10
  24. package/src/cache/remote-lane.ts +383 -0
  25. package/src/cache/run-history.ts +24 -8
  26. package/src/cache/run-store.ts +4 -4
  27. package/src/cache/schema.ts +6 -4
  28. package/src/cache/scoped-cache.ts +7 -0
  29. package/src/cli/completions.ts +2 -2
  30. package/src/cli/foreign-flags.ts +11 -23
  31. package/src/cli/help.ts +6 -31
  32. package/src/cli/info.ts +2 -1
  33. package/src/cli/init.ts +23 -11
  34. package/src/cli/last.ts +30 -4
  35. package/src/cli/plan-format.ts +5 -58
  36. package/src/cli/plugin-commands.ts +2 -2
  37. package/src/cli/plugin-templates.ts +2 -2
  38. package/src/cli/run.ts +23 -185
  39. package/src/cli/select.ts +16 -6
  40. package/src/cli/show.ts +15 -6
  41. package/src/cli/upgrade.ts +215 -55
  42. package/src/cli/watch-cycle.ts +2 -1
  43. package/src/cli/watch-filter.ts +22 -6
  44. package/src/cli/watch-judge.ts +49 -5
  45. package/src/cli/watch-set.ts +18 -8
  46. package/src/cli/watch.ts +103 -16
  47. package/src/cli/why.ts +9 -7
  48. package/src/config.ts +19 -21
  49. package/src/exec/executor.ts +5 -5
  50. package/src/exec/sandbox-runtime.ts +1 -1
  51. package/src/exec/sandbox-violations.ts +3 -1
  52. package/src/graph/dependency-spec.ts +2 -2
  53. package/src/graph/folds.ts +236 -0
  54. package/src/graph/index.ts +17 -2
  55. package/src/graph/priorities.ts +1 -1
  56. package/src/graph/scheduler.ts +45 -31
  57. package/src/graph/task-graph.ts +190 -58
  58. package/src/graph/task-status.ts +95 -0
  59. package/src/{orchestrator → history}/failure-mode.ts +1 -1
  60. package/src/{orchestrator → history}/history.ts +1 -1
  61. package/src/history/index.ts +48 -0
  62. package/src/{orchestrator → history}/metrics.ts +7 -20
  63. package/src/{orchestrator → history}/miss-reason.ts +1 -1
  64. package/src/index.ts +11 -29
  65. package/src/orchestrator/affected-tasks.ts +20 -19
  66. package/src/orchestrator/doctor.ts +1 -1
  67. package/src/orchestrator/download-policy.ts +16 -22
  68. package/src/orchestrator/excluded-keys.ts +4 -5
  69. package/src/orchestrator/execute-args.ts +80 -0
  70. package/src/orchestrator/execute-task.ts +50 -411
  71. package/src/orchestrator/fingerprint-watch.ts +2 -2
  72. package/src/orchestrator/index.ts +2 -73
  73. package/src/orchestrator/keyed-projects.ts +12 -51
  74. package/src/orchestrator/local-shortcircuit.ts +1 -1
  75. package/src/orchestrator/miss-save.ts +33 -3
  76. package/src/orchestrator/options.ts +1 -36
  77. package/src/orchestrator/output-host.ts +4 -3
  78. package/src/orchestrator/persistent-task.ts +321 -0
  79. package/src/orchestrator/persistent.ts +130 -3
  80. package/src/orchestrator/placement.ts +5 -5
  81. package/src/orchestrator/plan.ts +19 -6
  82. package/src/orchestrator/plugin-host.ts +129 -69
  83. package/src/orchestrator/plugin.ts +18 -8
  84. package/src/orchestrator/prepare.ts +40 -34
  85. package/src/orchestrator/projects.ts +7 -10
  86. package/src/orchestrator/remote-prefetch.ts +14 -1
  87. package/src/orchestrator/run-artifacts.ts +5 -177
  88. package/src/orchestrator/run-context.ts +55 -0
  89. package/src/orchestrator/run-failures.ts +15 -3
  90. package/src/orchestrator/run-records.ts +35 -3
  91. package/src/orchestrator/run-report.ts +151 -171
  92. package/src/orchestrator/run-settings.ts +1 -16
  93. package/src/orchestrator/run-steps.ts +57 -0
  94. package/src/orchestrator/run-stop.ts +153 -0
  95. package/src/orchestrator/run.ts +293 -604
  96. package/src/orchestrator/sandbox-fix.ts +1 -17
  97. package/src/orchestrator/sandbox-request.ts +2 -2
  98. package/src/orchestrator/signals.ts +14 -6
  99. package/src/orchestrator/stable-keys.ts +164 -27
  100. package/src/orchestrator/steps.ts +70 -0
  101. package/src/orchestrator/task-hash.ts +34 -25
  102. package/src/orchestrator/task-log-buffer.ts +1 -1
  103. package/src/orchestrator/task-process.ts +94 -0
  104. package/src/orchestrator/telemetry-host.ts +4 -4
  105. package/src/orchestrator/telemetry.ts +14 -86
  106. package/src/orchestrator/upstream.ts +4 -181
  107. package/src/{orchestrator → report}/events.ts +41 -8
  108. package/src/{orchestrator → report}/forecast.ts +35 -2
  109. package/src/{orchestrator → report}/framed-output.ts +40 -34
  110. package/src/report/index.ts +36 -0
  111. package/src/{orchestrator → report}/logger.ts +158 -20
  112. package/src/{orchestrator → report}/status-line.ts +88 -7
  113. package/src/{orchestrator → report}/summary.ts +126 -35
  114. package/src/report/tally.ts +83 -0
  115. package/src/settings.ts +12 -39
  116. package/src/util/colors.ts +44 -4
  117. package/src/util/git-ignored.ts +143 -0
  118. package/src/util/index.ts +19 -4
  119. package/src/util/paths.ts +1 -1
  120. package/src/util/report.ts +13 -5
  121. package/src/util/settle.ts +14 -39
  122. package/src/util/tail.ts +1 -1
  123. package/src/util/ui.ts +17 -6
  124. package/src/workspace/affected.ts +81 -2
  125. package/src/workspace/config-imports.ts +1 -1
  126. package/src/workspace/config-schema.ts +142 -143
  127. package/src/workspace/lockfile.ts +1 -1
  128. package/src/workspace/migration.ts +19 -3
  129. package/schemas/summary.json +0 -133
  130. package/src/cache/chained-cache.ts +0 -280
  131. package/src/orchestrator/tally.ts +0 -112
  132. /package/src/{orchestrator → report}/path-links.ts +0 -0
  133. /package/src/{orchestrator → report}/plain-output.ts +0 -0
  134. /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
@@ -12,10 +12,6 @@ import {
12
12
  type CachePolicy,
13
13
  cachesNothing,
14
14
  formatCachePolicy,
15
- packRunOutputs,
16
- resolveOutputs,
17
- resolveWorkspaceOutputs,
18
- runEntryName,
19
15
  stopRuntimeProbes,
20
16
  } from '../cache/index.js'
21
17
  import { VERSION } from '../version.js'
@@ -40,7 +36,6 @@ import {
40
36
  import {
41
37
  mark,
42
38
  stageTimes,
43
- killGraceMs,
44
39
  MAX_TIMEOUT_MS,
45
40
  printTimings,
46
41
  ulid,
@@ -49,60 +44,65 @@ import {
49
44
  nearest,
50
45
  UserError,
51
46
  machineParallelism,
52
- teardownTimeoutMs,
53
47
  secretMask,
54
48
  isFsRefusal,
55
49
  isUserError,
56
50
  detectColors,
57
- relPosix,
58
51
  type ColorSupport,
59
52
  } from '../util/index.js'
60
53
  import { keyedProjects } from './keyed-projects.js'
61
54
  import { isDefaultBuild } from './projects.js'
62
55
  import { commandWriteReach, prepareSandbox } from './sandbox-request.js'
63
- import type { OutputDirSnapshot, SaveFacts } from './miss-save.js'
56
+ import { recordOutputDirSnapshots, type OutputDirSnapshot, type SaveFacts } from './miss-save.js'
64
57
  import { admitTasks, taintTracker } from './admission.js'
65
58
  import { unkeyedGroupOutcome, type ExecuteArgs } from './execute-task.js'
59
+ import { executeArgsOf, type RunScope } from './execute-args.js'
66
60
  import { excludedTaint } from './excluded-keys.js'
67
61
  import { keyUpstream } from './upstream.js'
68
- import { busLogger, createEventBus, terminalSubscriber, type EventBus } from './events.js'
69
- import { installPlugins, PluginSetupError, type RunReport, type RunReportTask } from './plugin.js'
70
- import { decodeOutputLog } from './output-log.js'
71
- 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 { keepsFailures, reportOf, RunKeeper } from './run-report.js'
80
+ import { buildAdmission, executorDemand, resolveExecutors, teardownPlugins } from './plugin-host.js'
81
+ import { createSteps } from './steps.js'
82
+ import { writeRunSteps } from './run-steps.js'
72
83
  import { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
73
84
  import { claimOutput, guardedOutput } from './output-host.js'
74
- import { assembleRunSummary, isPassStatus } from './telemetry.js'
85
+ import { assembleRunSummary } from './telemetry.js'
86
+ import { isPassStatus } from '../graph/index.js'
75
87
  import type { RunContextRecord } from './telemetry.js'
76
- import { defaultLogger, type DefaultLogger, resolveOutputView, type Logger } from './logger.js'
77
- import { plainOutput } from './plain-output.js'
78
- import { formatPersistentList } from './framed-output.js'
79
- import { createForecast } from './forecast.js'
80
- import { LocalHistoryProvider } from './history.js'
81
88
  import { plan, type RunPlan } from './plan.js'
82
89
  import type { AffectedReason } from './affected-tasks.js'
83
90
  import { prepareRun, type PreparedRun } from './prepare.js'
84
91
  import { acquireRunLock } from './run-lock.js'
85
- import {
86
- forwardedSignal,
87
- type StopSignal,
88
- forwardSignals,
89
- SIGNAL_SHUTDOWN_GRACE_MS,
90
- terminateChildren,
91
- } from './signals.js'
92
+ import { terminateChildren } from './signals.js'
93
+ import { runStop } from './run-stop.js'
92
94
  import {
93
95
  hasPooledExecutor,
94
96
  placeTasks,
95
97
  planExecutorOf,
96
98
  poolOfPlacement,
97
99
  terminalHolders,
98
- UNPLACED_EXECUTOR,
99
100
  locallyPlaced,
100
101
  } from './placement.js'
101
102
  import {
102
- captureDefaultBranch,
103
+ telemetryRunContext,
103
104
  captureGitContext,
104
105
  captureHostContext,
105
- captureWorkspaceIdentity,
106
106
  detectCi,
107
107
  } from './run-context.js'
108
108
  import { startRemotePrefetch } from './remote-prefetch.js'
@@ -114,26 +114,25 @@ import {
114
114
  import { deriveStableKeys, probesAfterWrites } from './stable-keys.js'
115
115
 
116
116
  import { writeRunFailures } from './run-failures.js'
117
- import { assembleRunRecords } from './run-records.js'
117
+ import { assembleRunRecords, recordLateRemoteErrors } from './run-records.js'
118
118
  import {
119
119
  hasEnded,
120
- selectKeepAlive,
121
- serverConfig,
122
- shutdownPersistent,
120
+ disposeServers,
121
+ failServer,
122
+ holdServers,
123
+ sessionEnd,
123
124
  takeHeldServers,
124
125
  } from './persistent.js'
125
- import {
126
- runSummaryJson,
127
- writeRunProfile,
128
- writeRunSummary,
129
- type RunSummaryJson,
130
- } from './run-artifacts.js'
126
+ import { writeRunProfile } from './run-artifacts.js'
131
127
  import { createSaveLane } from './save-lane.js'
132
128
  import { commandWindows } from './command-windows.js'
133
- import { formatOutcomeTable, formatRunSummary } from './summary.js'
134
- import { detectFlaky, type FlakyCandidate, type FlakyFinding } from './failure-mode.js'
135
- import { createMissExplainer } from './miss-reason.js'
136
- import type { HeldServer, RunOptions, RunSummary } from './options.js'
129
+ import {
130
+ createMissExplainer,
131
+ detectFlaky,
132
+ type FlakyCandidate,
133
+ LocalHistoryProvider,
134
+ } from '../history/index.js'
135
+ import type { RunOptions, RunSummary } from './options.js'
137
136
  import { withRunSettings } from './run-settings.js'
138
137
  import { resolveSetting } from '../settings.js'
139
138
 
@@ -146,21 +145,6 @@ const emptyShortCircuit = (): ShortCircuit => ({
146
145
  groupKeys: new Map(),
147
146
  })
148
147
 
149
- /** The files `node`'s declared outputs match on disk now, workspace-relative and sorted. */
150
- async function outputsLeft(
151
- node: TaskNode,
152
- workspaceRoot: string,
153
- nestedProjectDirs: string[],
154
- ): Promise<string[]> {
155
- const declared = node.config.cache?.outputs
156
- if (declared === undefined) return []
157
- const [own, ws] = await Promise.all([
158
- resolveOutputs({ projectDir: node.projectDir, outputs: declared.files, nestedProjectDirs }),
159
- resolveWorkspaceOutputs({ workspaceRoot, outputs: declared.workspaceFiles ?? [] }),
160
- ])
161
- return [...new Set([...own, ...ws].map((p) => relPosix(workspaceRoot, p)))].sort()
162
- }
163
-
164
148
  /**
165
149
  * The policy that will actually govern the run. The remote axes are inert
166
150
  * without a remote layer to serve them: a `remote:w` policy over a bare
@@ -210,17 +194,6 @@ function maskInvocation(command: string): string {
210
194
  return secretMask([process.env])?.mask(command) ?? command
211
195
  }
212
196
 
213
- /**
214
- * A run's `--tag`s, stored on its history row and handed to telemetry
215
- * (`vx.tag.<key>`): a tag that carries a secret is masked as the
216
- * invocation line that spells it is (L-38).
217
- */
218
- function maskTags(tags: Readonly<Record<string, string>>): Record<string, string> {
219
- const mask = secretMask([process.env])
220
- if (mask === null) return { ...tags }
221
- return Object.fromEntries(Object.entries(tags).map(([k, v]) => [mask.mask(k), mask.mask(v)]))
222
- }
223
-
224
197
  /**
225
198
  * The command line a run hands to its telemetry sinks, with what follows
226
199
  * `--` counted, not quoted. Those are the task's arguments,
@@ -295,15 +268,11 @@ function refuseRunOptions(options: RunOptions): void {
295
268
  refuse('concurrency', concurrency, 'a positive integer')
296
269
  if (retries !== undefined && !(Number.isInteger(retries) && retries >= 0))
297
270
  refuse('retries', retries, 'a non-negative integer')
298
- for (const [name, ms] of [
299
- ['timeout', timeout],
300
- ['killGrace', options.killGrace],
301
- ['teardownTimeout', options.teardownTimeout],
302
- ['readyNotice', options.readyNotice],
303
- ] as const) {
304
- if (ms !== undefined && !(Number.isInteger(ms) && ms > 0 && ms <= MAX_TIMEOUT_MS))
305
- refuse(name, ms, `a positive integer of ms, at most ${MAX_TIMEOUT_MS}`)
306
- }
271
+ if (
272
+ timeout !== undefined &&
273
+ !(Number.isInteger(timeout) && timeout > 0 && timeout <= MAX_TIMEOUT_MS)
274
+ )
275
+ refuse('timeout', timeout, `a positive integer of ms, at most ${MAX_TIMEOUT_MS}`)
307
276
  }
308
277
 
309
278
  export async function run(options: RunOptions): Promise<RunSummary> {
@@ -324,17 +293,12 @@ export async function run(options: RunOptions): Promise<RunSummary> {
324
293
  // See docs/design/event-stream-2026-06.md.
325
294
  const terminal =
326
295
  options.log === undefined
327
- ? defaultLogger(
328
- colors,
329
- resolveOutputView(options),
330
- options.json === true ? process.stderr : process.stdout,
331
- {
332
- coalesce: true,
333
- ...(options.tty === true ? { tty: true } : {}),
334
- ...(options.forwardArgs !== undefined ? { forwardArgs: options.forwardArgs } : {}),
335
- ...(options.footer === false ? { footer: false } : {}),
336
- },
337
- )
296
+ ? defaultLogger(colors, resolveOutputView(options), process.stdout, {
297
+ coalesce: true,
298
+ ...(options.tty === true ? { tty: true } : {}),
299
+ ...(options.forwardArgs !== undefined ? { forwardArgs: options.forwardArgs } : {}),
300
+ ...(options.footer === false ? { footer: false } : {}),
301
+ })
338
302
  : null
339
303
  const sink = options.log ?? terminal!
340
304
  // An injected bus already has surfaces subscribed; we add the terminal
@@ -364,7 +328,10 @@ export async function run(options: RunOptions): Promise<RunSummary> {
364
328
  }
365
329
  let held = false
366
330
  try {
367
- const summary = await runOnBus(options, bus, colors, terminal, takeOutput)
331
+ // vx's own steps are timed only where something renders them.
332
+ const showsSteps =
333
+ terminal !== null ? terminal.showsSteps : options.log?.stepStart !== undefined
334
+ const summary = await runOnBus(options, bus, colors, terminal, showsSteps, takeOutput)
368
335
  if (summary.persistent === undefined) return summary
369
336
  // Servers handed back still running still write: `vx watch dev` printed
370
337
  // none of its server's log while it idled, the renderer gone with this
@@ -388,9 +355,12 @@ async function runOnBus(
388
355
  bus: EventBus,
389
356
  colors: ColorSupport,
390
357
  terminal: DefaultLogger | null,
358
+ showsSteps: boolean,
391
359
  takeOutput?: (claimed: { logger: Logger; owner: string }) => void,
392
360
  ): Promise<RunSummary> {
393
361
  const log = busLogger(bus)
362
+ const steps = createSteps(log, showsSteps)
363
+ const { step, stepSync } = steps
394
364
  // A server's output goes through a cell of its own: a later watch cycle
395
365
  // that keeps the server points it at that cycle's bus (`RunOptions.keep`).
396
366
  const serverOuts = new Map<string, { bus: EventBus }>()
@@ -404,7 +374,7 @@ async function runOnBus(
404
374
  }
405
375
  }
406
376
 
407
- const prepared = await prepareRun(options, log)
377
+ const prepared = await step('vx:load', () => prepareRun(options, log))
408
378
  options = withRunSettings(options, prepared.workspaceConfig)
409
379
  mark('plugin stages')
410
380
  // Every exit from here tears the plugins down: their capability
@@ -418,7 +388,7 @@ async function runOnBus(
418
388
  if (tornDown) return
419
389
  tornDown = true
420
390
  const plugins = prepared.plugins.filter((p) => p !== except)
421
- await teardownPlugins(plugins, (m) => log.status(m), report)
391
+ await teardownPlugins(plugins, warnOf(log), report, step)
422
392
  }
423
393
  // A task whose command re-enters `vx run` in the workspace running it
424
394
  // is refused. When the inner run reaches this task again it forks a run
@@ -482,17 +452,36 @@ async function runOnBus(
482
452
  const runId = ulid()
483
453
  const head = captureGitContext(prepared.workspaceRoot)
484
454
  const ciContext = detectCi(process.env)
485
- const keepRunLog = prepared.keepRunEntry !== undefined
455
+ const keepRun =
456
+ prepared.keepRunEntry === undefined
457
+ ? undefined
458
+ : {
459
+ failures: keepsFailures(
460
+ resolveSetting('cache.upload.failures', prepared.workspaceConfig).value,
461
+ ciContext.ci,
462
+ ),
463
+ }
464
+ const keeper =
465
+ prepared.keepRunEntry === undefined || keepRun === undefined
466
+ ? undefined
467
+ : new RunKeeper({
468
+ runId,
469
+ workspaceRoot: prepared.workspaceRoot,
470
+ nestedDirsByProject: prepared.nestedDirsByProject,
471
+ keep: prepared.keepRunEntry,
472
+ failures: keepRun.failures,
473
+ })
486
474
  try {
487
475
  disposePlugins = await installPlugins({
488
476
  plugins: prepared.plugins,
489
477
  bus,
490
478
  workspaceRoot: prepared.workspaceRoot,
491
479
  cacheDir: prepared.cacheDir,
492
- warn: (m) => log.status(m),
480
+ warn: warnOf(log),
493
481
  cache: prepared.cacheFor,
494
482
  ci: ciContext.ci,
495
483
  branch: prepared.branch(),
484
+ step,
496
485
  })
497
486
  } catch (err) {
498
487
  disposePlugins?.()
@@ -545,7 +534,7 @@ async function runOnBus(
545
534
  resolveExecutors(prepared.plugins, {
546
535
  workspaceRoot: prepared.workspaceRoot,
547
536
  cacheDir: prepared.cacheDir,
548
- warn: (m: string) => log.status(m),
537
+ warn: warnOf(log),
549
538
  concurrency,
550
539
  }),
551
540
  )
@@ -569,51 +558,11 @@ async function runOnBus(
569
558
  : nodes.get(id)?.config.cache === undefined
570
559
  ? 'no remote executor accepted it — the task declares no `cache` block, so there is no input set to ship'
571
560
  : 'no remote executor accepted it'
572
- log.status(`[vx] ${id}: exec.remote is 'only' and ${why} — nothing ran`)
561
+ warnOf(log)(`[vx] ${id}: exec.remote is 'only' and ${why} — nothing ran`)
573
562
  }
574
563
  }
575
- // Demand: what each executor still has left to run. An executor that
576
- // provisions per task (a container, an allocation, a pod) otherwise has to
577
- // guess from `capacity` alone and hold everything until teardown. Built
578
- // ONLY for executors that asked — with none declared there is no map, no
579
- // set, and no call in the completion path.
580
- const demandOf = new Map<TaskExecutor, Set<string>>()
581
- for (const [id, executor] of placements.executors) {
582
- if (executor.demand === undefined) continue
583
- let remaining = demandOf.get(executor)
584
- if (remaining === undefined) demandOf.set(executor, (remaining = new Set()))
585
- remaining.add(id)
586
- }
587
- // The SAME set is handed back every time, narrowed in place: a consumer
588
- // reading it later sees the truth rather than a stale snapshot. A hint,
589
- // as `admit` is: one that throws is named once and asked no more, where
590
- // it surfaced as a bare stack, or mid-run from the completion path
591
- // (item 1022).
592
- const tellDemand = (executor: TaskExecutor, remaining: ReadonlySet<string>): void => {
593
- const failed = (err: unknown): void => {
594
- if (!demandOf.delete(executor)) return
595
- const m = err instanceof Error ? err.message : String(err)
596
- log.status(`[vx] ${executorLabel(executor)} failed in demand: ${m}; not asked again this run`)
597
- }
598
- try {
599
- // An `async demand` that rejects did so where no one listened: an
600
- // unhandled rejection, a stack, the run killed. Its rejection is a
601
- // throw like any other.
602
- const ret: unknown = executor.demand!(remaining)
603
- if (ret instanceof Promise) ret.catch(failed)
604
- } catch (err) {
605
- failed(err)
606
- }
607
- }
608
- for (const [executor, remaining] of demandOf) tellDemand(executor, remaining)
609
- const narrowDemand =
610
- demandOf.size === 0
611
- ? (): void => {}
612
- : (id: string): void => {
613
- for (const [executor, remaining] of demandOf) {
614
- if (remaining.delete(id)) tellDemand(executor, remaining)
615
- }
616
- }
564
+ // Demand: what each executor still has left to run (`executorDemand`).
565
+ const narrowDemand = executorDemand(placements.executors, warnOf(log))
617
566
 
618
567
  // `cache.download` (default `all`) decides ONCE per task, here, whether a
619
568
  // remote execution's outputs come home.
@@ -640,88 +589,20 @@ async function runOnBus(
640
589
  | boolean
641
590
  | undefined
642
591
 
643
- // Run-scoped registries of live subprocesses:
644
- // - `liveChildren`: in-flight children. The runner adds/removes
645
- // each child around its spawn (persistent children stay until
646
- // they exit).
647
- // - `persistentRegistry`: ready persistent tasks (dev servers,
648
- // watchers). executeTask spawns them but does NOT await their
649
- // exit; ownership moves here so the orchestrator can SIGTERM
650
- // them once the rest of the graph finishes.
651
- //
652
- // A SIGINT/SIGTERM mid-run forwards that signal to everything in both
653
- // (`signals.ts`); the handlers are removed in the finally below so
654
- // repeated run() calls (test suites) never stack listeners.
655
- const liveChildren = new Set<ReturnType<typeof Bun.spawn>>()
656
- // A server that ends on its own while the graph still runs is said at
657
- // once: its dependants were failing against it, and the end of the run
658
- // was the first word of it (C-69). Not once the graph is done (the end
659
- // of the run and the keep-alive wait say it) nor under a stop.
660
- let graphDone = false
661
- const persistentRegistry = new (class extends Map<string, ReturnType<typeof Bun.spawn>> {
662
- override set(id: string, child: ReturnType<typeof Bun.spawn>): this {
663
- super.set(id, child)
664
- void child.exited.then((code) => {
665
- if (code !== 0 && !graphDone && !stopRun.signal.aborted)
666
- log.status(`vx: ${id} exited with code ${code} while the run went on`)
667
- })
668
- return this
669
- }
670
- })()
671
- // One stop for the run: an embedder's `RunOptions.signal` and a process
672
- // signal both abort it. The scheduler stops dispatching (it reads the
673
- // signal), the children are torn down, and run() returns through its own
674
- // end-of-run path. Detached in the finally below.
675
- const stopRun = new AbortController()
676
- const forwardAbort = (): void => stopRun.abort(options.signal?.reason)
677
- if (options.signal?.aborted === true) forwardAbort()
678
- else options.signal?.addEventListener('abort', forwardAbort, { once: true })
679
- // A run a process signal stopped records no history: until item 849 the
680
- // handler exited before any outcome landed, and a Ctrl-C'd run then read
681
- // in `vx last` as FAILED with 0 tasks (item 854). An embedder's abort is
682
- // its own call and keeps its record.
683
- let stoppedBy: StopSignal | undefined
684
- let leftRun = (): void => {}
685
- const runLeft = new Promise<void>((resolve) => {
686
- leftRun = resolve
687
- })
688
- const signals = forwardSignals({
689
- enabled: options.handleSignals ?? true,
592
+ const stop = runStop({
690
593
  log,
691
594
  cache,
692
- liveChildren,
693
- persistentRegistry,
694
- stop: (signal) => {
695
- stoppedBy = signal
696
- stopRun.abort(signal)
697
- },
698
- done: runLeft,
595
+ external: options.signal,
596
+ handleSignals: options.handleSignals ?? true,
699
597
  handsTerminal: holders.size > 0,
700
- // The grace, then one flush and each teardown at their own bound, and
701
- // slack for the summary and the cache close.
702
- boundMs:
703
- killGraceMs(SIGNAL_SHUTDOWN_GRACE_MS) +
704
- teardownTimeoutMs() * (1 + prepared.plugins.filter((p) => p.teardown).length) +
705
- 2_000,
598
+ teardowns: prepared.plugins.filter((p) => p.teardown).length,
599
+ onStop: () => {
600
+ stopRuntimeProbes(hashCache.runtime, hashCache.workspaceRuntime)
601
+ cache.stopUploads?.()
602
+ keeper?.stop()
603
+ },
706
604
  })
707
- // Awaited before run() leaves: a task's shell can die of the signal at
708
- // once while a child it backgrounded ignores it and dies only to the
709
- // SIGKILL after the grace, and a run that returned first left that child
710
- // running past vx's exit (turborepo#14043's class).
711
- let aborting: Promise<void> | undefined
712
- // The servers that had ended when the stop landed: those crashed. One that
713
- // ends after it is the stop's own kill, and was named a crash (item 1061).
714
- let endedBeforeStop: ReadonlySet<ReturnType<typeof Bun.spawn>> | undefined
715
- const onAbort = (): void => {
716
- stopRuntimeProbes(hashCache.runtime, hashCache.workspaceRuntime)
717
- endedBeforeStop = new Set([...persistentRegistry.values()].filter(hasEnded))
718
- aborting = terminateChildren(
719
- () => [...liveChildren, ...persistentRegistry.values()],
720
- forwardedSignal(stopRun.signal.reason),
721
- )
722
- }
723
- if (stopRun.signal.aborted) onAbort()
724
- else stopRun.signal.addEventListener('abort', onAbort, { once: true })
605
+ const { liveChildren, persistentRegistry } = stop
725
606
  // The cache handle must be released on EVERY exit path, not just the
726
607
  // happy one: `close()` is also where the run's deferred `accessed_at`
727
608
  // bumps are flushed, so a throw between opening the cache and the
@@ -748,10 +629,12 @@ async function runOnBus(
748
629
  // Taken after the early exits above (nothing they do touches a tree) and
749
630
  // before the schedule: from here on tasks clean, restore and write.
750
631
  const releaseRunLock = await abandoning(() =>
751
- acquireRunLock(prepared.workspaceRoot, {
752
- log: (m) => log.status(m),
753
- signal: stopRun.signal,
754
- }),
632
+ step('vx:run-lock', () =>
633
+ acquireRunLock(prepared.workspaceRoot, {
634
+ log: warnOf(log),
635
+ signal: stop.signal,
636
+ }),
637
+ ),
755
638
  )
756
639
  try {
757
640
  const runStartHrTimeNs = process.hrtime.bigint()
@@ -764,7 +647,7 @@ async function runOnBus(
764
647
  // happened.
765
648
  const policy: CachePolicy = effectiveCachePolicy(prepared.cachePolicy, prepared.hasRemoteLayer)
766
649
  if (options.remoteRequested === true && !prepared.hasRemoteLayer) {
767
- log.status(
650
+ warnOf(log)(
768
651
  'vx: --cache-policy named the remote cache, but no cache plugin in .vx/workspace.* supplies one — the remote axes are off for this run',
769
652
  )
770
653
  }
@@ -807,51 +690,29 @@ async function runOnBus(
807
690
  // but declines still pays — its answer is only knowable by asking.
808
691
  const hasTelemetryPlugin = prepared.plugins.some((p) => p.telemetry !== undefined)
809
692
  if (hasTelemetryPlugin || options.telemetrySinks !== undefined) {
810
- // Workspace identity (telemetry v2), paid only when a telemetry
811
- // consumer can exist — a plain run never reaches here. The remote URL
812
- // is the enumeration's `git var -l`: a spawn here cost ~4 ms.
813
- const wsIdentity = captureWorkspaceIdentity(
814
- workspaceRoot,
815
- gitFilesCache.configValue('remote.origin.url'),
816
- )
817
- runContextRecord = {
693
+ runContextRecord = telemetryRunContext({
818
694
  runId,
819
- vxVersion: VERSION,
820
695
  // An embedder's `command` is redacted as the argv is: it passed a
821
696
  // token after `--` to every sink verbatim (C-67).
822
- // Masked as the stored line is: `--tag key=$DEPLOY_KEY` sits before
697
+ // Masked as the stored line is: a flag's secret value sits before
823
698
  // the `--` the count covers (L-44).
824
699
  command: maskInvocation(
825
700
  invocationCommand(options.command?.split(' ') ?? process.argv.slice(1)),
826
701
  ),
827
- requestedTasks: [...options.tasks],
702
+ requestedTasks: options.tasks,
828
703
  cachePolicy: formatCachePolicy(policy),
829
704
  concurrency,
830
705
  flow: options.flow ?? null,
831
- commitSha: gitContext.commitSha,
832
- branch: gitContext.branch,
833
- defaultBranch: captureDefaultBranch(process.env, workspaceRoot),
834
- dirty: gitContext.dirty,
835
- ci: ciContext.ci,
836
- ciProvider: ciContext.provider,
837
- ...(ciContext.runUrl !== undefined ? { ciRunUrl: ciContext.runUrl } : {}),
838
- ...(ciContext.change !== undefined ? { ciChange: ciContext.change } : {}),
839
- ...(ciContext.pipeline !== undefined ? { ciPipeline: ciContext.pipeline } : {}),
840
- ...(ciContext.job !== undefined ? { ciJob: ciContext.job } : {}),
841
- ...(ciContext.attempt !== undefined ? { ciAttempt: ciContext.attempt } : {}),
842
- host: hostContext.host,
843
- os: hostContext.os,
844
- arch: hostContext.arch,
845
- workspaceId: wsIdentity.id,
846
- workspaceName: wsIdentity.name,
847
- ...(wsIdentity.repository !== undefined ? { repository: wsIdentity.repository } : {}),
848
- ...(wsIdentity.path !== undefined ? { workspacePath: wsIdentity.path } : {}),
849
- tags: maskTags(options.tags ?? {}),
850
- }
706
+ git: gitContext,
707
+ ci: ciContext,
708
+ host: hostContext,
709
+ workspaceRoot,
710
+ originUrl: gitFilesCache.configValue('remote.origin.url'),
711
+ })
851
712
  telemetry = await subscribeTelemetry(
852
713
  prepared.plugins,
853
714
  bus,
854
- { workspaceRoot, cacheDir, warn: (m: string) => log.status(m) },
715
+ { workspaceRoot, cacheDir, warn: warnOf(log) },
855
716
  runContextRecord,
856
717
  options.telemetrySinks,
857
718
  nodes,
@@ -884,7 +745,7 @@ async function runOnBus(
884
745
  // each pack holds an artifact's bytes); a failed save is a miss next
885
746
  // time, said once — the task's work ran.
886
747
  const saveLane = createSaveLane(2 * concurrency, (err) =>
887
- log.status(`[vx] cache save failed: ${err instanceof Error ? err.message : String(err)}`),
748
+ warnOf(log)(`[vx] cache save failed: ${err instanceof Error ? err.message : String(err)}`),
888
749
  )
889
750
  const deferredSaves = new Map<string, Promise<SaveFacts>>()
890
751
  const windows = commandWindows()
@@ -900,9 +761,10 @@ async function runOnBus(
900
761
  // for display. Marks `node.surfaced`; never touches `requested`.
901
762
  markSurfacedDeps(nodes)
902
763
 
903
- // Header counts: unique projects covered by the graph (including
904
- // dependsOn-pulled deps, not just the user-requested set), and the
905
- // total number of real (non-group) task executions. Mirrors the
764
+ // Header counts: unique projects with a real (non-group) task in the
765
+ // graph (including dependsOn-pulled deps, not just the user-requested
766
+ // set; a `^build` placeholder in a project with no build is not one),
767
+ // and the total number of real task executions. Mirrors the
906
768
  // count the end-of-run summary reports under "total". The
907
769
  // requested count drives the focused logger's live-vs-buffered
908
770
  // decision, so surfaced nodes count toward it too — they display
@@ -911,8 +773,8 @@ async function runOnBus(
911
773
  let taskCount = 0
912
774
  let requestedCount = 0
913
775
  for (const node of nodes.values()) {
914
- packagesInScope.add(node.projectName)
915
776
  if (!isGroupTask(node)) {
777
+ packagesInScope.add(node.projectName)
916
778
  taskCount++
917
779
  if (node.requested || node.surfaced === true) requestedCount++
918
780
  }
@@ -920,7 +782,7 @@ async function runOnBus(
920
782
  // Admission over the worker count is a plugin's (`admit`): built once
921
783
  // into the predicate the scheduler asks at every local dispatch. No
922
784
  // plugin answers → undefined → the count-only path, byte for byte.
923
- const admit = buildAdmission(prepared.plugins, nodes, concurrency, (m) => log.status(m))
785
+ const admit = buildAdmission(prepared.plugins, nodes, concurrency, warnOf(log))
924
786
 
925
787
  // Run context for the footer. The top-of-run header is gone — the
926
788
  // banner now lives in the summary, where the eye lands at the end.
@@ -932,7 +794,7 @@ async function runOnBus(
932
794
  local: policy.localRead || policy.localWrite,
933
795
  localWrite: policy.localWrite,
934
796
  remote: remoteCacheEnabled && (policy.remoteRead || policy.remoteWrite),
935
- uploads: prepared.localCache.uploads,
797
+ traffic: prepared.localCache.traffic,
936
798
  notDownloaded: () => deferredOutputs.pending().length,
937
799
  },
938
800
  concurrency,
@@ -941,14 +803,12 @@ async function runOnBus(
941
803
 
942
804
  // Asked only when a plugin declares `output`: a plain run pays nothing.
943
805
  if (takeOutput !== undefined && prepared.plugins.some((p) => p.output !== undefined)) {
944
- const json = options.json === true
945
806
  const claimed = await claimOutput(prepared.plugins, {
946
807
  workspaceRoot,
947
808
  cacheDir,
948
- warn: (m: string) => log.status(m),
949
- isTTY: (json ? process.stderr : process.stdout).isTTY === true,
809
+ warn: warnOf(log),
810
+ isTTY: process.stdout.isTTY === true,
950
811
  ci: ciContext.ci,
951
- json,
952
812
  })
953
813
  if (claimed !== undefined) takeOutput(claimed)
954
814
  }
@@ -984,18 +844,21 @@ async function runOnBus(
984
844
  // unchanged (no upfront key pass, no local probing).
985
845
  let prefetchDone: Promise<void> = Promise.resolve()
986
846
  if (prepared.hasRemoteLayer) {
987
- prefetchDone = startRemotePrefetch({
988
- nodes,
989
- cache,
990
- workspaceRoot,
991
- claimedFiles: fingerprintWatch.names,
992
- forwardArgs: options.forwardArgs,
993
- nestedDirsByProject,
994
- gitFilesCache,
995
- hashCache,
996
- concurrency,
997
- remoteRead: policy.remoteRead,
998
- })
847
+ prefetchDone = steps.background('vx:prefetch', () =>
848
+ startRemotePrefetch({
849
+ nodes,
850
+ cache,
851
+ workspaceRoot,
852
+ claimedFiles: fingerprintWatch.names,
853
+ forwardArgs: options.forwardArgs,
854
+ nestedDirsByProject,
855
+ gitFilesCache,
856
+ hashCache,
857
+ concurrency,
858
+ remoteRead: policy.remoteRead,
859
+ remoteWrite: policy.remoteWrite,
860
+ }),
861
+ )
999
862
  }
1000
863
 
1001
864
  // Local cache short-circuit (default-on). Up-front classify: derive
@@ -1016,17 +879,19 @@ async function runOnBus(
1016
879
  prepared.localCache.holdsNothing()
1017
880
  if (coldLocal) prewarm()
1018
881
  else if (shouldShortCircuit(nodes, policy, cache)) {
1019
- shortCircuit = await startLocalShortCircuit({
1020
- nodes,
1021
- cache,
1022
- workspaceRoot,
1023
- claimedFiles: fingerprintWatch.names,
1024
- forwardArgs: options.forwardArgs,
1025
- nestedDirsByProject,
1026
- gitFilesCache,
1027
- hashCache,
1028
- concurrency,
1029
- })
882
+ shortCircuit = await step('vx:keys', () =>
883
+ startLocalShortCircuit({
884
+ nodes,
885
+ cache,
886
+ workspaceRoot,
887
+ claimedFiles: fingerprintWatch.names,
888
+ forwardArgs: options.forwardArgs,
889
+ nestedDirsByProject,
890
+ gitFilesCache,
891
+ hashCache,
892
+ concurrency,
893
+ }),
894
+ )
1030
895
  // A confirmed miss runs too.
1031
896
  if (sandboxed.some((n) => shortCircuit.preProbed.get(n.id)?.hit === null)) prewarm()
1032
897
  }
@@ -1037,7 +902,7 @@ async function runOnBus(
1037
902
  // admission.ts.
1038
903
  const excluded = excludedTaint(nodes)
1039
904
  if (excluded.unsaved > 0) {
1040
- log.status(
905
+ warnOf(log)(
1041
906
  `[vx] --exclude-dependencies: ${excluded.unsaved} cached task(s) build on a skipped dependency; what they build is not saved`,
1042
907
  )
1043
908
  }
@@ -1066,19 +931,50 @@ async function runOnBus(
1066
931
  // The scheduler's fail-fast stop, as the retry loop of a task already
1067
932
  // in flight sees it.
1068
933
  const failFast = new AbortController()
1069
- // A literal of the fields every task carries, then each optional one
1070
- // assigned only when present: twelve conditional spreads per task cost
1071
- // ~18 ms of object copying on a 1,090-package warm run (X-149).
1072
- const track = telemetry?.track
1073
- const armSandbox = sandboxArmer !== null ? () => sandboxArmer.arm() : undefined
934
+ const scope: RunScope = {
935
+ workspaceRoot,
936
+ cache,
937
+ cachePolicy: policy,
938
+ forwardArgs: options.forwardArgs,
939
+ deferred: deferredOutputs,
940
+ cacheDir,
941
+ runStartHrTimeNs,
942
+ persistentRegistry,
943
+ liveChildren,
944
+ gitFilesCache,
945
+ hashCache,
946
+ probesAfterWrites: lateProbes,
947
+ fingerprintWatch,
948
+ keyedProjects: keyed,
949
+ outputDirSnapshots,
950
+ deferSave: saveLane.defer,
951
+ deferredSaves,
952
+ commandWindows: windows,
953
+ stopSignal: stop.signal,
954
+ failFast: failFast.signal,
955
+ inputStability,
956
+ }
957
+ if (options.retries !== undefined) scope.retries = options.retries
958
+ if (taskTimeoutDefault !== undefined) scope.timeout = taskTimeoutDefault
959
+ if (cleanOutputs !== undefined) scope.cleanOutputs = cleanOutputs
960
+ if (telemetry?.track !== undefined) scope.track = telemetry.track
961
+ if (explainMiss !== undefined) scope.explainMiss = explainMiss
962
+ if (keepRun !== undefined) scope.keepRun = keepRun
963
+ if (sandboxArmer !== null) scope.armSandbox = () => sandboxArmer.arm()
964
+ const argsOf = executeArgsOf(scope, {
965
+ log: (node) => (node.config.exec?.persistent !== undefined ? serverLog(node.id) : log),
966
+ placements,
967
+ nestedDirsByProject,
968
+ downloadModeOf: download.modeOf,
969
+ shortCircuit,
970
+ dependedOn,
971
+ holders,
972
+ })
1074
973
  const buildExecuteArgs = (
1075
974
  node: TaskNode,
1076
975
  upstream: TaskOutcome[],
1077
976
  reuseProbe = true,
1078
977
  ): ExecuteArgs => {
1079
- const probe = reuseProbe ? shortCircuit.preProbed.get(node.id) : undefined
1080
- const upfrontKey = shortCircuit.uncachedKeys.get(node.id)
1081
- const upfrontGroupKey = shortCircuit.groupKeys.get(node.id)
1082
978
  let deadServer: string | undefined
1083
979
  if (options.continueMode === 'always') {
1084
980
  for (const d of node.deps) {
@@ -1089,52 +985,7 @@ async function runOnBus(
1089
985
  }
1090
986
  const tainted = taint.judge(node, upstream)
1091
987
  if (tainted) taintedRan.set(node.id, deadServer ?? taint.cause(node, upstream))
1092
- const a: ExecuteArgs = {
1093
- node,
1094
- upstream,
1095
- workspaceRoot,
1096
- cache,
1097
- cachePolicy: policy,
1098
- forwardArgs: options.forwardArgs,
1099
- log: node.config.exec?.persistent !== undefined ? serverLog(node.id) : log,
1100
- executor: placements.executors.get(node.id) ?? UNPLACED_EXECUTOR,
1101
- deferred: deferredOutputs,
1102
- nestedProjectDirs: nestedDirsByProject.get(node.projectName) ?? [],
1103
- cacheDir,
1104
- runStartHrTimeNs,
1105
- persistentRegistry,
1106
- liveChildren,
1107
- gitFilesCache,
1108
- hashCache,
1109
- probesAfterWrites: lateProbes,
1110
- fingerprintWatch,
1111
- keyedProjects: keyed,
1112
- outputDirSnapshots,
1113
- deferSave: saveLane.defer,
1114
- deferredSaves,
1115
- commandWindows: windows,
1116
- stopSignal: stopRun.signal,
1117
- failFast: failFast.signal,
1118
- }
1119
- if (options.retries !== undefined) a.retries = options.retries
1120
- if (taskTimeoutDefault !== undefined) a.timeout = taskTimeoutDefault
1121
- if (options.readyNotice !== undefined) a.readyNotice = options.readyNotice
1122
- a.inputStability = inputStability
1123
- if (cleanOutputs !== undefined) a.cleanOutputs = cleanOutputs
1124
- if (download.modeOf.get(node.id) === 'deferred') a.download = 'deferred'
1125
- if (placements.remoteOnlyNoop.has(node.id)) a.remoteOnlyNoop = true
1126
- if (placements.remoteOnly.has(node.id)) a.remoteOnly = true
1127
- if (track !== undefined) a.track = track
1128
- if (explainMiss !== undefined) a.explainMiss = explainMiss
1129
- if (probe !== undefined) a.preProbed = probe
1130
- if (upfrontKey !== undefined) a.upfrontKey = upfrontKey
1131
- if (upfrontGroupKey !== undefined) a.upfrontGroupKey = upfrontGroupKey
1132
- if (tainted) a.taintedUpstream = true
1133
- if (keepRunLog) a.keepRunLog = true
1134
- if (!dependedOn.has(node.id)) a.noDependants = true
1135
- if (holders.has(node.id)) a.terminal = true
1136
- if (armSandbox !== undefined) a.armSandbox = armSandbox
1137
- return a
988
+ return argsOf(node, upstream, reuseProbe, tainted)
1138
989
  }
1139
990
 
1140
991
  const executeWithDedup = admitTasks({
@@ -1156,17 +1007,27 @@ async function runOnBus(
1156
1007
  mark('classify + probe')
1157
1008
  // A run whose every task is a confirmed local hit dispatches only
1158
1009
  // restores, which no weight orders: the `schedule` stage is not asked.
1010
+ // A plugin's later weights (`ctx.refine`) re-rank the ready queues
1011
+ // while the graph runs; any that land before it starts, at its start.
1012
+ let reprioritize: ((weights: ReadonlyMap<string, number>) => void) | undefined
1013
+ let refinedEarly: ReadonlyMap<string, number> | undefined
1014
+ let graphOver = false
1159
1015
  const priorities = [...nodes.values()].every(
1160
1016
  (n) => n.config.exec?.command === undefined || shortCircuit.restoreTier.has(n.id),
1161
1017
  )
1162
1018
  ? new Map<string, number>()
1163
- : await prepared.priorities()
1019
+ : await prepared.priorities({
1020
+ step,
1021
+ refined: (merged) => {
1022
+ if (reprioritize !== undefined) reprioritize(merged)
1023
+ else if (!graphOver) refinedEarly = merged
1024
+ },
1025
+ })
1164
1026
  mark('schedule')
1165
1027
  // Judged as each task finishes, against the history BEFORE this run's
1166
1028
  // rows land, so the row that prints next carries the verdict: nothing
1167
1029
  // prints below the footer. A hit or a skip asks nothing; a green miss
1168
1030
  // is one probe of the failed-row index.
1169
- const flaky: FlakyFinding[] = []
1170
1031
  const historyDb = prepared.localCache.dbHandle()
1171
1032
  const cacheOff = cachesNothing(policy)
1172
1033
  const judgeFlaky = (o: TaskOutcome): void => {
@@ -1175,7 +1036,6 @@ async function runOnBus(
1175
1036
  try {
1176
1037
  const found = detectFlaky(historyDb, candidates)[0]
1177
1038
  if (found === undefined) return
1178
- flaky.push(found)
1179
1039
  o.flaky = { passes: found.passes, failures: found.failures }
1180
1040
  } catch {
1181
1041
  // History is observability: an unreadable one judges nothing.
@@ -1211,7 +1071,7 @@ async function runOnBus(
1211
1071
  ...(options.continueMode !== undefined ? { continueMode: options.continueMode } : {}),
1212
1072
  serverDied,
1213
1073
  onFailFast: () => failFast.abort(),
1214
- signal: stopRun.signal,
1074
+ signal: stop.signal,
1215
1075
  onStart: (node) => {
1216
1076
  log.taskStart?.(node)
1217
1077
  },
@@ -1219,6 +1079,7 @@ async function runOnBus(
1219
1079
  if (cacheOff) o.cacheOff = true
1220
1080
  taint.settled(o)
1221
1081
  judgeFlaky(o)
1082
+ keeper?.taskDone(o)
1222
1083
  log.taskComplete(o.node, o)
1223
1084
  narrowDemand(o.node.id)
1224
1085
  },
@@ -1242,7 +1103,7 @@ async function runOnBus(
1242
1103
  ...(options.continueMode !== 'always' && taintSeeds.size === 0
1243
1104
  ? {
1244
1105
  settleNow: (node: TaskNode, upstream: TaskOutcome[]) =>
1245
- isGroupTask(node) && node.config.cache === undefined
1106
+ isGroupTask(node) && node.config.inputs === undefined
1246
1107
  ? unkeyedGroupOutcome(
1247
1108
  node,
1248
1109
  keyUpstream(node, upstream),
@@ -1255,6 +1116,10 @@ async function runOnBus(
1255
1116
  // A `schedule` plugin's weights; the scheduler keeps its structural
1256
1117
  // baseline as the tie-break. Empty map → baseline only.
1257
1118
  ...(priorities.size > 0 ? { priorities } : {}),
1119
+ reprioritize: (apply) => {
1120
+ if (refinedEarly !== undefined) apply(refinedEarly)
1121
+ reprioritize = apply
1122
+ },
1258
1123
  // The baseline under them; `most-work` when the workspace names none.
1259
1124
  ...(options.schedule !== undefined ? { strategy: options.schedule } : {}),
1260
1125
  // Local short-circuit: confirmed stable local hits the scheduler
@@ -1262,7 +1127,9 @@ async function runOnBus(
1262
1127
  // Empty when the short-circuit didn't fire → byte-identical.
1263
1128
  restoreTier: shortCircuit.restoreTier,
1264
1129
  })
1265
- graphDone = true
1130
+ stop.graphDone()
1131
+ reprioritize = undefined
1132
+ graphOver = true
1266
1133
 
1267
1134
  // Which persistent children outlive the graph, and the bounded SIGTERM
1268
1135
  // of the rest, before the summary prints. Scoped to the real CLI
@@ -1272,49 +1139,19 @@ async function runOnBus(
1272
1139
  // expect run() to return, not block on a server.
1273
1140
  const foreground = options.log === undefined && (options.handleSignals ?? true)
1274
1141
  // An aborted run's children are already being torn down: nothing to hold.
1275
- const hold = options.holdPersistent === true && !stopRun.signal.aborted
1276
- // A run that failed elsewhere holds nothing: `vx run dev --all` with one
1277
- // server that never became ready sat on the others for good, so a
1278
- // script hung, and the Ctrl-C that ended it read 130 over the failure
1279
- // (C-60). A kept server's own crash ends the wait below as before, and
1280
- // `--continue=always` asked to keep going, and does. The watch loop
1281
- // (`holdPersistent`) still holds: a failing test in a cycle stopped
1282
- // the dev server the next change would restart anyway.
1283
- let keepAlive = selectKeepAlive(persistentRegistry, nodes, foreground || hold)
1284
- const failedElsewhere =
1285
- ![...outcomes.values()].every((o) => isPassStatus(o.status)) ||
1286
- [...persistentRegistry.values()].some(
1287
- (c) => hasEnded(c) && c.exitCode !== 0 && !keepAlive.children.includes(c),
1288
- )
1289
- if (failedElsewhere && !hold && options.continueMode !== 'always')
1290
- keepAlive = { nodes: [], children: [] }
1291
- const crashedPersistent = (
1292
- await shutdownPersistent(persistentRegistry, keepAlive.children)
1293
- ).filter((c) => endedBeforeStop?.has(persistentRegistry.get(c.id)!) ?? true)
1142
+ const hold = options.holdPersistent === true && !stop.signal.aborted
1143
+ const { keepAlive, crashed: crashedPersistent } = await disposeServers({
1144
+ registry: persistentRegistry,
1145
+ nodes,
1146
+ outcomes,
1147
+ keep: foreground || hold,
1148
+ hold,
1149
+ continueAlways: options.continueMode === 'always',
1150
+ endedBeforeStop: stop.endedBeforeStop,
1151
+ })
1294
1152
 
1295
1153
  mark('run graph')
1296
1154
 
1297
- // A server that died on its own before the stop failed, whatever its
1298
- // outcome said when it became ready: the footer counted it a success
1299
- // over the run's red exit (item 1071). A kept one that has already
1300
- // died is the same; the keep-alive wait below names it. Its code is its
1301
- // own, or the shell's report of the signal that took it.
1302
- const failServer = (id: string, code: number | string): void => {
1303
- const o = outcomes.get(id)
1304
- if (o === undefined) return
1305
- // In place: the renderer holds this outcome (events carry live refs)
1306
- // and closes the server's output block from it at runEnd, where a
1307
- // copy left it reading `running` under its exit line (WD-10).
1308
- o.status = 'failed'
1309
- o.exitCode = typeof code === 'number' ? code : signalExitCode(code)
1310
- }
1311
- for (const c of crashedPersistent) failServer(c.id, c.code)
1312
- keepAlive.nodes.forEach((n, i) => {
1313
- const child = keepAlive.children[i]!
1314
- if (!hasEnded(child) || child.exitCode === 0) return
1315
- if (endedBeforeStop !== undefined && !endedBeforeStop.has(child)) return
1316
- failServer(n.id, child.exitCode ?? child.signalCode ?? 'unknown')
1317
- })
1318
1155
  // Clear the status line for good before the summary prints.
1319
1156
  log.runEnd?.()
1320
1157
  const list = [...outcomes.values()]
@@ -1340,7 +1177,7 @@ async function runOnBus(
1340
1177
  // 1102).
1341
1178
  for (const c of crashedPersistent) {
1342
1179
  const code = typeof c.code === 'number' ? c.code : signalExitCode(c.code)
1343
- log.status(`vx: ${c.id} exited with code ${code} before the run stopped it`)
1180
+ warnOf(log)(`vx: ${c.id} exited with code ${code} before the run stopped it`)
1344
1181
  }
1345
1182
  // Outputs that never came home are not an error, but a silent `dist/`
1346
1183
  // that is empty-or-stale would be: name every task whose bytes are
@@ -1356,105 +1193,20 @@ async function runOnBus(
1356
1193
  // change the run's exit code — the run already happened.
1357
1194
  // Written again after the keep-alive wait: a kept server's crash or a
1358
1195
  // Ctrl-C there is the process's exit, and the first write said ok.
1359
- // The run's own bytes, kept in a remote that takes its writes (owner
1360
- // 2026-10-15): a failed task's log and the files its outputs left, an
1361
- // uncached task's log. Then the report every plugin's teardown reads.
1362
- const keepRunBytes = async (final: readonly TaskOutcome[]): Promise<RunReport> => {
1363
- const keep = prepared.keepRunEntry
1364
- const tasks: RunReportTask[] = []
1365
- const work: (() => Promise<void>)[] = []
1366
- for (const o of final) {
1367
- if (isGroupTask(o.node)) continue
1368
- const task: { -readonly [K in keyof RunReportTask]: RunReportTask[K] } = {
1369
- id: o.node.id,
1370
- status: o.status,
1371
- durationMs: Math.round(o.durationMs),
1372
- ...(o.hash !== undefined ? { key: o.hash } : {}),
1373
- }
1374
- tasks.push(task)
1375
- if (keep === undefined) continue
1376
- const runLog = o.runLog
1377
- if (runLog !== undefined) {
1378
- work.push(async () => {
1379
- const text = decodeOutputLog(runLog)
1380
- .map((c) => c.text)
1381
- .join('')
1382
- if (text.length === 0) return
1383
- const name = runEntryName(runId, o.node.id, 'log')
1384
- if (await keep(name, new TextEncoder().encode(text))) task.log = name
1385
- })
1386
- }
1387
- if (o.status === 'failed' && o.where === undefined) {
1388
- work.push(async () => {
1389
- const bytes = await packRunOutputs(
1390
- prepared.workspaceRoot,
1391
- await outputsLeft(
1392
- o.node,
1393
- prepared.workspaceRoot,
1394
- prepared.nestedDirsByProject.get(o.node.projectName) ?? [],
1395
- ),
1396
- )
1397
- if (bytes === null) return
1398
- const name = runEntryName(runId, o.node.id, 'outputs')
1399
- if (await keep(name, bytes)) task.outputs = name
1400
- })
1401
- }
1402
- }
1403
- for (let i = 0; i < work.length; i += 8) {
1404
- await Promise.all(work.slice(i, i + 8).map((w) => w()))
1405
- }
1406
- return { runId, commit: head.commitSha, ciRun: ciContext, ok, tasks }
1407
- }
1408
- let json: RunSummaryJson | undefined
1409
- const summarize = async (runOk: boolean, final = list): Promise<void> => {
1410
- if (options.json === true) {
1411
- json = runSummaryJson({
1412
- runId,
1413
- startedAtMs: endedAtMsAtStart,
1414
- endedAtMs,
1415
- totalMs,
1416
- ok: runOk,
1417
- ...(stoppedBy !== undefined && { exitCode: signalExitCode(stoppedBy) }),
1418
- outcomes: final,
1419
- flaky,
1420
- })
1421
- }
1422
- if (options.summarize === undefined) return
1423
- // The rewrite after the keep-alive wait says nothing: the footer is
1424
- // the run's last word.
1425
- const quiet = final !== list
1426
- try {
1427
- const wrote = await writeRunSummary({
1428
- target: options.summarize,
1429
- cacheDir,
1430
- cwd: options.cwd,
1431
- runId,
1432
- startedAtMs: endedAtMsAtStart,
1433
- endedAtMs,
1434
- totalMs,
1435
- ok: runOk,
1436
- ...(stoppedBy !== undefined && { exitCode: signalExitCode(stoppedBy) }),
1437
- outcomes: final,
1438
- flaky,
1439
- })
1440
- if (!quiet) log.status(`vx: summary written to ${wrote}`)
1441
- } catch (err) {
1442
- const msg = err instanceof Error ? err.message : String(err)
1443
- log.status(`vx: failed to write summary: ${msg}`)
1444
- }
1445
- }
1446
- await summarize(ok)
1447
- if (options.profile !== undefined) {
1196
+ const profile = options.profile
1197
+ if (profile !== undefined) {
1448
1198
  try {
1449
- const wrote = await writeRunProfile({
1450
- target: options.profile,
1451
- cwd: options.cwd,
1452
- outcomes: list,
1453
- })
1199
+ const wrote = await step('vx:profile', () =>
1200
+ writeRunProfile({
1201
+ target: profile,
1202
+ cwd: options.cwd,
1203
+ outcomes: list,
1204
+ }),
1205
+ )
1454
1206
  log.status(`vx: profile written to ${wrote}`)
1455
1207
  } catch (err) {
1456
1208
  const msg = err instanceof Error ? err.message : String(err)
1457
- log.status(`vx: failed to write profile: ${msg}`)
1209
+ warnOf(log)(`vx: failed to write profile: ${msg}`)
1458
1210
  }
1459
1211
  }
1460
1212
  // Record each task to the run history in a single SQLite transaction
@@ -1482,11 +1234,11 @@ async function runOnBus(
1482
1234
  concurrency,
1483
1235
  flow: options.flow ?? null,
1484
1236
  forwardArgs: options.forwardArgs,
1485
- tags: maskTags(options.tags ?? {}),
1486
1237
  git: gitContext,
1487
1238
  ci: ciContext,
1488
1239
  host: hostContext,
1489
1240
  withTelemetry: telemetry !== undefined,
1241
+ remoteTraffic: prepared.localCache.traffic.keys,
1490
1242
  })
1491
1243
  const records = recordsOf(list, ok)
1492
1244
  const recordHistory = (write: () => void): void => {
@@ -1498,27 +1250,21 @@ async function runOnBus(
1498
1250
  // an unprivileged user on a 2 MiB disk, 2026-09-16). The run said
1499
1251
  // what happened; `vx last` and the flaky list will not know this one.
1500
1252
  const message = err instanceof Error ? err.message : String(err)
1501
- log.status(`[vx] run history not recorded: ${message} — the verdict above stands`)
1253
+ warnOf(log)(`[vx] run history not recorded: ${message} — the verdict above stands`)
1502
1254
  }
1503
1255
  }
1504
1256
  // A server kept in the foreground can still fail the run after the
1505
1257
  // cache closes: its history is written once the wait has decided, or it
1506
1258
  // read `ok` over the exit 1 and fed the flaky list a pass (X-23).
1507
- const historyAfterWait = keepAlive.children.length > 0 && !hold && stoppedBy === undefined
1508
- // A failed task's output, for `vx last` and an agent: files beside the
1509
- // index, so a refused write (a full disk, a read-only cache dir) costs
1510
- // only that output.
1511
- const recordFailures = (final: readonly TaskOutcome[]): void => {
1512
- try {
1513
- writeRunFailures(prepared.cacheDir, runId, final)
1514
- } catch (err) {
1515
- if (!isFsRefusal(err)) throw err
1516
- log.status(`[vx] failed tasks' output not kept: ${err.message} — the verdict above stands`)
1517
- }
1518
- }
1519
- if (stoppedBy === undefined && !historyAfterWait) {
1520
- recordHistory(() => cache.recordRunBundle(records))
1521
- recordFailures(list)
1259
+ const historyAfterWait = keepAlive.children.length > 0 && !hold && stop.stoppedBy === undefined
1260
+ // A failed task's output, for `vx last` and an agent (run-failures.ts).
1261
+ const recordFailures = (final: readonly TaskOutcome[]): void =>
1262
+ writeRunFailures(prepared.cacheDir, runId, final, warnOf(log))
1263
+ if (stop.stoppedBy === undefined && !historyAfterWait) {
1264
+ stepSync('vx:history', () => {
1265
+ recordHistory(() => cache.recordRunBundle(records))
1266
+ recordFailures(list)
1267
+ })
1522
1268
  }
1523
1269
  mark('record history')
1524
1270
  // Drain any still-in-flight background prefetches before closing the
@@ -1536,14 +1282,20 @@ async function runOnBus(
1536
1282
  // channel shut from under the upload — silently losing every remote
1537
1283
  // write with nothing but a warning. The seam's contract is that a
1538
1284
  // cache layer stays usable until the run's uploads have settled.
1539
- await prefetchDone
1540
1285
  // Every deferred save has settled by here: the scheduler finishes a
1541
1286
  // task only once its `settledOf` promise (the lane's `landed`) has,
1542
1287
  // so `runGraph` resolved with every save in the cache. A second
1543
1288
  // drain of the lane stood here until item 634; deleting it changed
1544
1289
  // nothing the suite could see, because this gate is the rule.
1545
- await cache.drainUploads?.()
1546
- const report = await keepRunBytes(list)
1290
+ await step('vx:uploads', async () => {
1291
+ await prefetchDone
1292
+ await Promise.all([cache.drainUploads?.(), keeper?.settled()])
1293
+ })
1294
+ writeRunSteps(prepared.cacheDir, runId, steps.ended, warnOf(log))
1295
+ // An upload that failed after the rows went in: its row says so too.
1296
+ if (prepared.localCache.uploads.failed > 0 && stop.stoppedBy === undefined && !historyAfterWait)
1297
+ recordHistory(() => recordLateRemoteErrors(prepared.localCache, records.runs, runId))
1298
+ const report = reportOf(list, { runId, commit: head.commitSha, ciRun: ciContext, ok }, keeper)
1547
1299
  // Hand the per-run summary to the telemetry sinks + drain them. Only
1548
1300
  // when a sink is active (telemetry !== undefined) — otherwise this
1549
1301
  // whole block is skipped and the run is byte-identical to before.
@@ -1562,72 +1314,52 @@ async function runOnBus(
1562
1314
  uploads: prepared.localCache.uploads,
1563
1315
  })
1564
1316
  telemetry.emitSummary(summary)
1565
- await telemetry.flush()
1317
+ const sinks = telemetry
1318
+ await sinks.flush(step)
1566
1319
  }
1567
1320
  // End-of-run plugin lifecycle: each plugin's teardown(). Crash-isolated
1568
1321
  // + time-bounded inside teardownPlugins, so a faulty plugin can neither
1569
1322
  // fail nor hang the run. Here on the normal path, after the drains
1570
1323
  // above; every other exit takes it through `teardown` too.
1571
- // The miss path's output-directory snapshots, taken now that the
1572
- // directories are old enough for the snapshot's racy window (see
1573
- // miss-save.ts). A few at a time: each is an lstat + readdir per
1574
- // prefix; the rows land together at close, in one transaction.
1575
- // Each walk is held to its entry's rows (item 1087), read here in one
1576
- // batch: read per snapshot, each read also flushed the snapshots before
1577
- // it, one transaction apiece (+110 ms at 1,000).
1578
- const snapshotRows = new Map<string, ReadonlyArray<{ path: string }>>()
1579
- for (let i = 0; i < outputDirSnapshots.length; i += 900) {
1580
- const batch = cache.loadOutputFilesBatch(
1581
- outputDirSnapshots.slice(i, i + 900).map((s) => s.hash),
1582
- )
1583
- for (const [hash, rows] of batch) snapshotRows.set(hash, rows)
1584
- }
1585
- for (let i = 0; i < outputDirSnapshots.length; i += 32) {
1586
- await Promise.all(
1587
- outputDirSnapshots
1588
- .slice(i, i + 32)
1589
- .map((s) =>
1590
- cache.recordOutputDirs?.(s.hash, s.projectDir, s.prefixes, (files) =>
1591
- s.holds(files, snapshotRows.get(s.hash) ?? []),
1592
- ),
1593
- ),
1594
- )
1595
- }
1324
+ // The miss path's output-directory snapshots first (miss-save.ts).
1325
+ await step('vx:snapshots', () => recordOutputDirSnapshots(cache, outputDirSnapshots))
1596
1326
  mark('output dir snapshots')
1597
1327
  // Not on a stopped run: one stopped while it waited on another run's
1598
1328
  // lock never held it, and its prune evicted under that run (item 858).
1599
- if (!stopRun.signal.aborted) await applyCacheRetention(prepared)
1329
+ if (!stop.signal.aborted) await step('vx:retention', () => applyCacheRetention(prepared))
1600
1330
  // A plugin hears the run until its teardown and nothing after: released
1601
1331
  // only in the finally, its handlers heard a kept server through the
1602
1332
  // whole keep-alive wait below (C-66). Idempotent; the finally's stay.
1603
1333
  disposePlugins?.()
1604
1334
  telemetry?.dispose()
1605
1335
  await teardown(undefined, report)
1606
- await closeCache()
1336
+ await step('vx:close', closeCache)
1607
1337
  mark('close')
1608
1338
  printTimings()
1609
1339
 
1610
1340
  // Tear down SRT's network bridge + (on macOS) log monitor. No-op if
1611
1341
  // no task was sandboxed; otherwise SRT keeps proxy servers alive and
1612
1342
  // the next vx run would init on top of stale state.
1613
- await prewarming
1614
- if (sandboxArmer?.armed) {
1615
- try {
1616
- await resetSandbox()
1617
- } catch (err) {
1618
- const msg = err instanceof Error ? err.message : String(err)
1619
- log.status(`vx: sandbox cleanup failed: ${msg}`)
1343
+ await step('vx:sandbox', async () => {
1344
+ await prewarming
1345
+ if (sandboxArmer?.armed) {
1346
+ try {
1347
+ await resetSandbox()
1348
+ } catch (err) {
1349
+ const msg = err instanceof Error ? err.message : String(err)
1350
+ warnOf(log)(`vx: sandbox cleanup failed: ${msg}`)
1351
+ }
1620
1352
  }
1621
- }
1353
+ })
1622
1354
 
1623
1355
  // The footer is the run's last word (owner, 2026-10-06): it prints once
1624
1356
  // the run's own work is done, history, uploads, plugin teardown and the
1625
1357
  // sandbox reset included, so any warning they raise lands above it. A
1626
1358
  // task's own facts (flaky, blocked) ride its row. Only a kept server's
1627
1359
  // own output follows.
1360
+ // The footer replaces the live region in place.
1361
+ log.runDone?.()
1628
1362
  if (options.summaryTable === true) for (const line of formatOutcomeTable(list)) log.status(line)
1629
- const above = options.beforeFooter?.(list, ok)
1630
- if (above) log.status(above.replace(/\n$/, ''))
1631
1363
  if (options.footer !== false)
1632
1364
  for (const line of formatRunSummary(list, totalMs, colors, runContext)) log.status(line)
1633
1365
 
@@ -1646,57 +1378,18 @@ async function runOnBus(
1646
1378
  // ended the session and its code — the summary above said `success`
1647
1379
  // for it, and an exit 1 with no word about why is a mystery in a log.
1648
1380
  if (keepAlive.children.length > 0 && hold) {
1649
- const held = keepAlive.children
1650
- // A held server that dies on its own is said, as the foreground's
1651
- // keep-alive says it: `vx watch` sat on "watching" over a dead dev
1652
- // server. The holder's own stop() is not such a death.
1653
- let stopping = false
1654
- // Taken by a later run (`RunOptions.keep`): kept or stopped there.
1655
- const handed = new Set<ReturnType<typeof Bun.spawn>>()
1656
- const servers = new Map<string, HeldServer>()
1657
- keepAlive.nodes.forEach((n, i) => {
1658
- const child = held[i]!
1659
- servers.set(n.id, {
1660
- child,
1661
- config: serverConfig(n, options.forwardArgs),
1662
- out: serverOuts.get(n.id) ?? { bus },
1663
- handOver: () => void handed.add(child),
1664
- })
1665
- // One that died during the graph was said then.
1666
- if (hasEnded(child)) return
1667
- void child.exited.then((code) => {
1668
- if (!stopping && !handed.has(child) && code !== 0)
1669
- log.status(`vx: ${n.id} exited with code ${code}`)
1670
- })
1671
- })
1672
1381
  return {
1673
1382
  ok,
1674
1383
  outcomes: list,
1675
- persistent: {
1676
- ids: keepAlive.nodes.map((n) => n.id),
1677
- servers,
1678
- stop: (signal) => {
1679
- stopping = true
1680
- return terminateChildren(() => held.filter((c) => !handed.has(c)), signal)
1681
- },
1682
- },
1384
+ persistent: holdServers(keepAlive, {
1385
+ forwardArgs: options.forwardArgs,
1386
+ outOf: (id) => serverOuts.get(id) ?? { bus },
1387
+ warn: warnOf(log),
1388
+ }),
1683
1389
  }
1684
1390
  }
1685
1391
  if (keepAlive.children.length > 0) {
1686
- // A server kept only as a dependency that exits 0 is a daemon that
1687
- // forked and returned (schema.md), not the end of the session: a
1688
- // `db: 'docker compose up -d'` under `app#dev` stopped the dev server
1689
- // right after the summary (WD-3). It ends the wait only as the last.
1690
- const first = await new Promise<{ code: number; i: number }>((resolve) => {
1691
- let left = keepAlive.children.length
1692
- keepAlive.children.forEach((c, i) => {
1693
- void c.exited.then((code) => {
1694
- left--
1695
- const n = keepAlive.nodes[i]!
1696
- if (code !== 0 || n.requested || n.surfaced === true || left === 0) resolve({ code, i })
1697
- })
1698
- })
1699
- })
1392
+ const first = await sessionEnd(keepAlive)
1700
1393
  const node = keepAlive.nodes[first.i]!
1701
1394
  // Only the ones still up are stopped: a kept server already dead was
1702
1395
  // counted as one (WD-11).
@@ -1704,8 +1397,8 @@ async function runOnBus(
1704
1397
  // Not when the run was stopped: the server ended because the user
1705
1398
  // stopped it, and "exited with code 130" read as a crash after every
1706
1399
  // Ctrl-C once a stop let run() finish its own path (item 852).
1707
- if (!stopRun.signal.aborted) {
1708
- log.status(
1400
+ if (!stop.signal.aborted) {
1401
+ warnOf(log)(
1709
1402
  `vx: ${node.id} exited with code ${first.code}` +
1710
1403
  (others > 0
1711
1404
  ? `; stopping ${others} other persistent task${others === 1 ? '' : 's'}`
@@ -1715,14 +1408,11 @@ async function runOnBus(
1715
1408
  // Under a stop the abort is already taking them down with the stop's
1716
1409
  // signal; a second SIGTERM in its grace cut short the graceful
1717
1410
  // shutdown of a server that reads it as "quit now" (WD-16).
1718
- if (stopRun.signal.aborted) await aborting?.catch(() => {})
1411
+ if (stop.signal.aborted) await stop.aborting?.catch(() => {})
1719
1412
  else await terminateChildren(() => keepAlive.children)
1720
- // The server that ended the session on its own, not cleanly, failed:
1721
- // the rewritten summary said `ok: false` over every task `success`
1722
- // and `failed: 0`, and so did the outcomes `--report` renders (C-53).
1723
- if (!stopRun.signal.aborted && first.code !== 0) failServer(node.id, first.code)
1413
+ // The server that ended the session on its own, not cleanly, failed.
1414
+ if (!stop.signal.aborted && first.code !== 0) failServer(outcomes, node.id, first.code)
1724
1415
  const final = [...outcomes.values()]
1725
- await summarize(ok && first.code === 0, final)
1726
1416
  if (historyAfterWait) {
1727
1417
  recordHistory(() => {
1728
1418
  // A writer's open: another vx version may have reset the index
@@ -1736,10 +1426,10 @@ async function runOnBus(
1736
1426
  })
1737
1427
  recordFailures(final)
1738
1428
  }
1739
- return { ok: ok && first.code === 0, outcomes: final, ...(json && { json }) }
1429
+ return { ok: ok && first.code === 0, outcomes: final }
1740
1430
  }
1741
1431
 
1742
- return { ok, outcomes: list, ...(json && { json }) }
1432
+ return { ok, outcomes: list }
1743
1433
  } finally {
1744
1434
  // Idempotent. The status-line ticker starts in runStart, and every
1745
1435
  // call between it and the success path's runEnd is crash-isolated
@@ -1749,9 +1439,8 @@ async function runOnBus(
1749
1439
  // here), so deleting this line reddens nothing (item 635). It is the
1750
1440
  // floor for a throw a later change adds, not a path a row can drive.
1751
1441
  log.runEnd?.()
1752
- signals.remove()
1753
- options.signal?.removeEventListener('abort', forwardAbort)
1754
- stopRun.signal.removeEventListener('abort', onAbort)
1442
+ log.runDone?.()
1443
+ stop.detach()
1755
1444
  // Plugins installed at the top of run() get their bus subscriptions
1756
1445
  // released here. Idempotent; safe even if installPlugins threw.
1757
1446
  disposePlugins?.()
@@ -1768,9 +1457,9 @@ async function runOnBus(
1768
1457
  } catch {
1769
1458
  // teardown must not throw on the way out
1770
1459
  }
1771
- await aborting?.catch(() => {})
1460
+ await stop.aborting?.catch(() => {})
1772
1461
  // Last: a process signal waits for this before it exits.
1773
- leftRun()
1462
+ stop.left()
1774
1463
  }
1775
1464
  }
1776
1465
 
@@ -1815,10 +1504,10 @@ function noneAffected(prepared: PreparedRun, options: RunOptions): string {
1815
1504
  * Planning mode. Same setup as `run()` — workspace discovery, config
1816
1505
  * load, package graph, task graph — but stops short of execution.
1817
1506
  * Returns a `RunPlan` predicting the cache hit/miss outcome of every
1818
- * task. Used by `--dry-run` and `--graph`.
1507
+ * task. Used by `--dry`.
1819
1508
  *
1820
1509
  * Side-effects are limited to opening + closing the local Cache handle
1821
- * (and running `cache.inputs.runtime` probe commands, which key
1510
+ * (and running `inputs.runtime` probe commands, which key
1822
1511
  * derivation requires). Cache probing is the byte-free `cache.has()`
1823
1512
  * existence check — no artifact download, no ingest, no accessed_at bump.
1824
1513
  */
@@ -1890,7 +1579,7 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1890
1579
  } finally {
1891
1580
  // The plan called the cache and executor factories; teardown releases
1892
1581
  // what they opened, as a run's end does (item 1021).
1893
- await teardownPlugins(prepared.plugins, (m) => log.status(m))
1582
+ await teardownPlugins(prepared.plugins, warnOf(log))
1894
1583
  prepared.cache.close()
1895
1584
  // The same table a run prints: a dry run is how the prepare stages
1896
1585
  // (discovery, config load, the git enumeration) get profiled on a