@vzn/vx 0.0.652 → 0.0.653

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (167) hide show
  1. package/README.md +2 -2
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +1 -0
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +25 -3
  8. package/src/cache/archive.ts +196 -31
  9. package/src/cache/cache.ts +863 -225
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +22 -5
  16. package/src/cache/index.ts +10 -1
  17. package/src/cache/inputs.ts +573 -152
  18. package/src/cache/key-fold.ts +1 -1
  19. package/src/cache/layer.ts +23 -10
  20. package/src/cache/layered-cache.ts +28 -7
  21. package/src/cache/output-index.ts +106 -44
  22. package/src/cache/plugin-cache.ts +2 -2
  23. package/src/cache/policy.ts +6 -2
  24. package/src/cache/remote-lane.ts +3 -1
  25. package/src/cache/run-history.ts +50 -10
  26. package/src/cache/schema.ts +42 -9
  27. package/src/cli/cache.ts +147 -34
  28. package/src/cli/completions.ts +2 -2
  29. package/src/cli/core-alias.ts +9 -2
  30. package/src/cli/docs.ts +4 -2
  31. package/src/cli/help.ts +25 -2
  32. package/src/cli/index.ts +38 -7
  33. package/src/cli/info.ts +4 -3
  34. package/src/cli/init.ts +55 -20
  35. package/src/cli/last.ts +184 -41
  36. package/src/cli/lock.ts +187 -24
  37. package/src/cli/plan-format.ts +24 -21
  38. package/src/cli/plugin-commands.ts +6 -1
  39. package/src/cli/plugin-templates.ts +1 -1
  40. package/src/cli/run.ts +68 -34
  41. package/src/cli/select.ts +160 -64
  42. package/src/cli/show.ts +63 -18
  43. package/src/cli/task-verb.ts +12 -7
  44. package/src/cli/upgrade.ts +210 -76
  45. package/src/cli/watch-filter.ts +88 -15
  46. package/src/cli/watch-fs.ts +34 -47
  47. package/src/cli/watch-judge.ts +72 -8
  48. package/src/cli/watch-set.ts +6 -4
  49. package/src/cli/watch.ts +179 -25
  50. package/src/cli/why.ts +107 -33
  51. package/src/cli/workspace-config.ts +11 -1
  52. package/src/config.ts +9 -0
  53. package/src/exec/env.ts +3 -1
  54. package/src/exec/executor.ts +76 -15
  55. package/src/exec/kill-tree.ts +7 -7
  56. package/src/exec/runner.ts +270 -108
  57. package/src/exec/sandbox-binds.ts +109 -18
  58. package/src/exec/sandbox-runtime.ts +134 -37
  59. package/src/exec/sandbox-violations.ts +250 -46
  60. package/src/exec/spawn-off.ts +10 -5
  61. package/src/exec/warm-shell.ts +29 -7
  62. package/src/graph/folds.ts +89 -16
  63. package/src/graph/index.ts +8 -0
  64. package/src/graph/priorities.ts +8 -5
  65. package/src/graph/scheduler.ts +173 -38
  66. package/src/graph/stand-ins.ts +36 -0
  67. package/src/graph/task-graph.ts +327 -141
  68. package/src/graph/task-status.ts +5 -1
  69. package/src/handoff.ts +45 -10
  70. package/src/history/failure-mode.ts +15 -5
  71. package/src/history/history.ts +103 -30
  72. package/src/history/index.ts +1 -0
  73. package/src/history/metrics.ts +189 -69
  74. package/src/index.ts +2 -1
  75. package/src/orchestrator/admission.ts +13 -9
  76. package/src/orchestrator/affected-tasks.ts +35 -13
  77. package/src/orchestrator/command-windows.ts +8 -5
  78. package/src/orchestrator/deferred-outputs.ts +41 -12
  79. package/src/orchestrator/doctor.ts +21 -9
  80. package/src/orchestrator/download-policy.ts +3 -3
  81. package/src/orchestrator/excluded-keys.ts +14 -2
  82. package/src/orchestrator/execute-args.ts +9 -2
  83. package/src/orchestrator/execute-task.ts +564 -123
  84. package/src/orchestrator/graph-memo.ts +26 -4
  85. package/src/orchestrator/graph-stage.ts +27 -4
  86. package/src/orchestrator/held-servers.ts +36 -14
  87. package/src/orchestrator/hit-restore.ts +136 -87
  88. package/src/orchestrator/index.ts +4 -1
  89. package/src/orchestrator/keyed-projects.ts +25 -0
  90. package/src/orchestrator/local-floor.ts +21 -3
  91. package/src/orchestrator/lockfile-claim.ts +30 -4
  92. package/src/orchestrator/miss-save.ts +33 -53
  93. package/src/orchestrator/options.ts +13 -0
  94. package/src/orchestrator/output-host.ts +16 -4
  95. package/src/orchestrator/output-log.ts +1 -54
  96. package/src/orchestrator/persistent-task.ts +31 -17
  97. package/src/orchestrator/persistent.ts +152 -15
  98. package/src/orchestrator/placement.ts +81 -15
  99. package/src/orchestrator/plan.ts +37 -15
  100. package/src/orchestrator/plugin-host.ts +232 -108
  101. package/src/orchestrator/plugin.ts +53 -27
  102. package/src/orchestrator/prepare.ts +194 -38
  103. package/src/orchestrator/projects.ts +89 -10
  104. package/src/orchestrator/read-only.ts +115 -0
  105. package/src/orchestrator/run-context.ts +6 -2
  106. package/src/orchestrator/run-failures.ts +43 -14
  107. package/src/orchestrator/run-id.ts +13 -2
  108. package/src/orchestrator/run-keys.ts +87 -28
  109. package/src/orchestrator/run-live.ts +16 -13
  110. package/src/orchestrator/run-lock.ts +27 -4
  111. package/src/orchestrator/run-records.ts +12 -10
  112. package/src/orchestrator/run-report.ts +2 -1
  113. package/src/orchestrator/run-stop.ts +17 -9
  114. package/src/orchestrator/run.ts +443 -107
  115. package/src/orchestrator/sandbox-fix.ts +40 -12
  116. package/src/orchestrator/sandbox-request.ts +286 -59
  117. package/src/orchestrator/save-lane.ts +24 -13
  118. package/src/orchestrator/shell-verdict.ts +20 -1
  119. package/src/orchestrator/signals.ts +103 -8
  120. package/src/orchestrator/stable-keys.ts +17 -9
  121. package/src/orchestrator/task-artifacts.ts +15 -2
  122. package/src/orchestrator/task-hash.ts +187 -30
  123. package/src/orchestrator/telemetry-host.ts +25 -6
  124. package/src/orchestrator/telemetry.ts +80 -12
  125. package/src/orchestrator/upstream.ts +3 -1
  126. package/src/report/events.ts +20 -3
  127. package/src/report/forecast.ts +103 -37
  128. package/src/report/framed-output.ts +165 -157
  129. package/src/report/index.ts +2 -1
  130. package/src/report/logger.ts +107 -30
  131. package/src/report/plain-output.ts +13 -9
  132. package/src/report/status-line.ts +28 -55
  133. package/src/report/summary.ts +57 -14
  134. package/src/settings.ts +42 -6
  135. package/src/ui/components.ts +36 -42
  136. package/src/ui/index.ts +3 -1
  137. package/src/ui/live-region.ts +67 -8
  138. package/src/ui/terminal.ts +12 -0
  139. package/src/ui/theme.ts +16 -1
  140. package/src/util/config-frame.ts +17 -3
  141. package/src/util/edit-distance.ts +17 -1
  142. package/src/util/errors.ts +23 -7
  143. package/src/util/foreign-cache-dir.ts +60 -0
  144. package/src/util/git-ignored.ts +59 -5
  145. package/src/util/index.ts +15 -1
  146. package/src/util/paths.ts +50 -6
  147. package/src/util/plain-text.ts +19 -0
  148. package/src/util/secret-mask.ts +57 -1
  149. package/src/util/settle.ts +16 -0
  150. package/src/util/size.ts +12 -2
  151. package/src/util/write-file.ts +86 -9
  152. package/src/workspace/affected.ts +225 -27
  153. package/src/workspace/config-bytecode.ts +23 -4
  154. package/src/workspace/config-eval.ts +90 -16
  155. package/src/workspace/config-imports.ts +96 -41
  156. package/src/workspace/config-schema.ts +126 -25
  157. package/src/workspace/dependencies-lock.ts +7 -14
  158. package/src/workspace/filter.ts +10 -1
  159. package/src/workspace/index.ts +3 -1
  160. package/src/workspace/json-data.ts +34 -8
  161. package/src/workspace/lockfile.ts +86 -37
  162. package/src/workspace/migration.ts +46 -25
  163. package/src/workspace/module-state.ts +62 -0
  164. package/src/workspace/nested-dirs.ts +8 -0
  165. package/src/workspace/project-loader.ts +276 -32
  166. package/src/workspace/repo-id.ts +29 -6
  167. package/src/workspace/workspace.ts +190 -30
@@ -4,7 +4,15 @@
4
4
 
5
5
  import type { InputStability } from '../config.js'
6
6
  import type { ProjectEntry } from '../workspace/index.js'
7
- import { EMPTY_LOCK, inputStabilityEnv, LOCK_INVALID, lockEntryBroken } from '../workspace/index.js'
7
+ import {
8
+ EMPTY_LOCK,
9
+ findProjectConfigs,
10
+ inputStabilityEnv,
11
+ LOCK_INVALID,
12
+ lockEntryBroken,
13
+ lockKey,
14
+ readLockfile,
15
+ } from '../workspace/index.js'
8
16
  import { realpathSync } from 'node:fs'
9
17
  import path from 'node:path'
10
18
  import {
@@ -14,6 +22,7 @@ import {
14
22
  type CachePolicy,
15
23
  cachesNothing,
16
24
  formatCachePolicy,
25
+ holdCacheDir,
17
26
  HistoryStream,
18
27
  networkConcurrency,
19
28
  keepRunNotes,
@@ -29,6 +38,7 @@ import {
29
38
  VX_RUN_WORKSPACE_ENV,
30
39
  } from '../exec/index.js'
31
40
  import { DeferredOutputs } from './deferred-outputs.js'
41
+ import { workspaceCleanOf } from './task-hash.js'
32
42
  import { resolveDownloadModes } from './download-policy.js'
33
43
  import type { MachineReport, TaskExecutor, WorkerAdvice } from '../exec/index.js'
34
44
  import {
@@ -59,6 +69,7 @@ import {
59
69
  isUserError,
60
70
  SecretBroker,
61
71
  thrownMessage,
72
+ relPosix,
62
73
  } from '../util/index.js'
63
74
  import * as ui from '../ui/index.js'
64
75
 
@@ -68,7 +79,7 @@ function printTimings(): void {
68
79
  if (lines !== undefined) ui.print('err', ui.trace(lines))
69
80
  }
70
81
  import { detectColors, type ColorSupport } from '../ui/index.js'
71
- import { keyedProjects, readableDeps } from './keyed-projects.js'
82
+ import { keyedProjects, nestedDirsOf, readableDeps } from './keyed-projects.js'
72
83
  import { isDefaultBuild } from '../graph/index.js'
73
84
  import { commandWriteReach, prepareSandbox } from './sandbox-request.js'
74
85
  import { recordOutputDirSnapshots, type OutputDirSnapshot, type SaveFacts } from './miss-save.js'
@@ -89,13 +100,16 @@ import {
89
100
  formatPersistentList,
90
101
  formatRunSummary,
91
102
  type Logger,
103
+ neverStarted,
92
104
  plainOutput,
93
105
  resolveOutputView,
106
+ type RunEvent,
94
107
  terminalSubscriber,
95
108
  warnOf,
96
109
  } from '../report/index.js'
97
110
  import { installPlugins, PluginSetupError, type RunReport } from './plugin.js'
98
111
  import { keepsFailures, reportOf, RunKeeper } from './run-report.js'
112
+ import { freezeDeep } from './read-only.js'
99
113
  import {
100
114
  buildAdmission,
101
115
  executorDemand,
@@ -112,9 +126,21 @@ import { isPassStatus } from '../graph/index.js'
112
126
  import type { RunContextRecord } from './telemetry.js'
113
127
  import { plan, type RunPlan } from './plan.js'
114
128
  import type { AffectedReason } from './affected-tasks.js'
115
- import { namesRemote, openRemotes, prepareRun, type PreparedRun } from './prepare.js'
116
- import { acquireRunLock } from './run-lock.js'
117
- import { terminateChildren } from './signals.js'
129
+ import {
130
+ namesRemote,
131
+ openRemotes,
132
+ outputWallsOf,
133
+ prepareRunLocked,
134
+ type PreparedRun,
135
+ } from './prepare.js'
136
+ import { acquireRunLock, runLockPath, type RunLockRelease } from './run-lock.js'
137
+ import {
138
+ exitOfSignal,
139
+ type HeldSignals,
140
+ holdSignals,
141
+ terminateChildren,
142
+ unlessStopped,
143
+ } from './signals.js'
118
144
  import { CYCLE_INTERRUPT, runStop } from './run-stop.js'
119
145
  import {
120
146
  hasPooledExecutor,
@@ -153,7 +179,10 @@ import {
153
179
  failServer,
154
180
  holdServers,
155
181
  sessionEnd,
182
+ serverUsers,
183
+ stopServer,
156
184
  takeHeldServers,
185
+ unneededServers,
157
186
  } from './persistent.js'
158
187
  import { serverKey } from './persistent-task.js'
159
188
  import { writeRunProfile } from './run-artifacts.js'
@@ -389,11 +418,21 @@ export async function run(options: RunOptions): Promise<RunSummary> {
389
418
  unsubscribeTerminal = bus.subscribe(guardedOutput(claimed, render))
390
419
  }
391
420
  let held = false
421
+ const signals: { held?: HeldSignals } = {}
392
422
  try {
393
423
  // vx's own steps are timed only where something renders them.
394
424
  const showsSteps =
395
425
  terminal !== null ? terminal.showsSteps : options.log?.stepStart !== undefined
396
- const summary = await runOnBus(options, bus, warned, colors, terminal, showsSteps, takeOutput)
426
+ const summary = await runOnBus(
427
+ options,
428
+ bus,
429
+ warned,
430
+ colors,
431
+ terminal,
432
+ showsSteps,
433
+ signals,
434
+ takeOutput,
435
+ )
397
436
  if (summary.persistent === undefined) return summary
398
437
  // Servers handed back still running still write: `vx watch dev` printed
399
438
  // none of its server's log while it idled, the renderer gone with this
@@ -409,6 +448,11 @@ export async function run(options: RunOptions): Promise<RunSummary> {
409
448
  }
410
449
  } finally {
411
450
  if (!held) detach()
451
+ // A signal heard before the run's stop took over: the run left before
452
+ // its tasks (a refusal, a setup that threw), its plugins torn down and
453
+ // its lock released, and vx leaves as that signal says.
454
+ const heard = signals.held?.takeOver()
455
+ if (heard !== undefined) await exitOfSignal(heard)
412
456
  }
413
457
  }
414
458
 
@@ -419,6 +463,7 @@ async function runOnBus(
419
463
  colors: ColorSupport,
420
464
  terminal: DefaultLogger | null,
421
465
  showsSteps: boolean,
466
+ signals: { held?: HeldSignals },
422
467
  takeOutput?: (claimed: { logger: Logger; owner: string }) => void,
423
468
  ): Promise<RunSummary> {
424
469
  const log = busLogger(bus)
@@ -427,17 +472,82 @@ async function runOnBus(
427
472
  // A server's output goes through a cell of its own: a later watch cycle
428
473
  // that keeps the server points it at that cycle's bus (`RunOptions.keep`).
429
474
  const serverOuts = new Map<string, { bus: EventBus }>()
475
+ // What a server writes from ready until its outcome is logged: renderers
476
+ // keep output before the outcome for its frame, which a ready server's
477
+ // row never prints, so its first lines since ready were lost.
478
+ const sinceReady = new Map<string, RunEvent[]>()
430
479
  const serverLog = (id: string): Logger => {
431
480
  const out = { bus }
432
481
  serverOuts.set(id, out)
482
+ const emit = (event: RunEvent): void => {
483
+ const held = sinceReady.get(id)
484
+ if (held === undefined) out.bus.emit(event)
485
+ else held.push(event)
486
+ }
433
487
  return {
434
488
  ...log,
435
- taskStdout: (node, chunk) => out.bus.emit({ kind: 'task:stdout', node, chunk }),
436
- taskStderr: (node, chunk) => out.bus.emit({ kind: 'task:stderr', node, chunk }),
489
+ taskStdout: (node, chunk) => emit({ kind: 'task:stdout', node, chunk }),
490
+ taskStderr: (node, chunk) => emit({ kind: 'task:stderr', node, chunk }),
437
491
  }
438
492
  }
493
+ const releaseSinceReady = (id: string): void => {
494
+ const held = sinceReady.get(id)
495
+ if (held === undefined) return
496
+ sinceReady.delete(id)
497
+ const out = serverOuts.get(id) ?? { bus }
498
+ for (const event of held) out.bus.emit(event)
499
+ }
439
500
 
440
- const prepared = await step('vx:load', () => prepareRun(options, log))
501
+ // Taken before the cache opens, never after: a run that held the store
502
+ // while it waited here, and a `vx cache clean` that holds this lock while
503
+ // it waits for every process with the store open, waited on each other
504
+ // for ever; and a clear deleted the store under a run opening it.
505
+ let heldLock: RunLockRelease | undefined
506
+ const takeRunLock = async (root: string, sharedCacheDir: string | undefined): Promise<void> => {
507
+ // A nested run is refused below with its own reason, holding nothing.
508
+ const outer = process.env[VX_RUN_WORKSPACE_ENV] || undefined
509
+ if (outer !== undefined && canonical(outer) === canonical(root)) return
510
+ // Heard from here, not only from the stop below: a signal between the
511
+ // lock and that stop killed vx with the lock held (`holdSignals`).
512
+ if (options.handleSignals ?? true) signals.held = holdSignals()
513
+ const held = signals.held?.signal
514
+ const signal =
515
+ held !== undefined && options.signal !== undefined
516
+ ? AbortSignal.any([options.signal, held])
517
+ : (held ?? options.signal)
518
+ const lock = await step('vx:run-lock', () => acquireRunLock(root, { log: warnOf(log), signal }))
519
+ heldLock = lock
520
+ // Another workspace's clean of a shared cache directory takes no lock of
521
+ // this one's: it waits for this hold, and this for it. Released with the
522
+ // run lock.
523
+ if (sharedCacheDir !== undefined) {
524
+ const dir = await holdCacheDir(sharedCacheDir, 'shared', warnOf(log), signal)
525
+ heldLock = Object.assign(
526
+ async (): Promise<void> => {
527
+ dir()
528
+ await lock()
529
+ },
530
+ { path: lock.path },
531
+ )
532
+ }
533
+ // A wait the signal ended holds no lock: opening the store now could
534
+ // meet a `vx cache clean` deleting it. run() exits of the signal.
535
+ if (held?.aborted === true) throw new Error(`stopped by ${String(held.reason)}`)
536
+ }
537
+ let prepared: PreparedRun
538
+ try {
539
+ prepared = await step('vx:load', () => prepareRunLocked(options, log, takeRunLock, true))
540
+ } catch (err) {
541
+ await heldLock?.()
542
+ throw err
543
+ }
544
+ const releaseRunLock: RunLockRelease =
545
+ heldLock ?? Object.assign(async () => {}, { path: runLockPath(prepared.workspaceRoot) })
546
+ // The cache and the lock go together on every exit before the run's own.
547
+ const closeUnrun = async (): Promise<void> => {
548
+ prepared.cache.close()
549
+ await releaseRunLock()
550
+ }
441
551
  mark('plugin stages')
442
552
  // Every exit from here tears the plugins down: their capability
443
553
  // factories ran in prepareRun, and teardown is what releases what those
@@ -465,12 +575,11 @@ async function runOnBus(
465
575
  ? { workspace: prepared.workspaceConfig.inputs.stability }
466
576
  : {}),
467
577
  }
468
- cleanOutputs = resolveSetting('outputs.clean', prepared.workspaceConfig).value as
469
- | boolean
470
- | undefined
578
+ // Resolved once, beside the keys that fold it (prepare.ts).
579
+ cleanOutputs = workspaceCleanOf(prepared.hashCache)
471
580
  } catch (err) {
472
581
  await teardown()
473
- prepared.cache.close()
582
+ await closeUnrun()
474
583
  throw err
475
584
  }
476
585
  // A task whose command re-enters `vx run` in the workspace running it
@@ -486,7 +595,7 @@ async function runOnBus(
486
595
  const outerRoot = process.env[VX_RUN_WORKSPACE_ENV] || undefined
487
596
  if (outerRoot !== undefined && canonical(outerRoot) === canonical(prepared.workspaceRoot)) {
488
597
  await teardown()
489
- prepared.cache.close()
598
+ await closeUnrun()
490
599
  throw new UserError(
491
600
  `task ${process.env[VX_RUN_TASK_ENV] ?? '<unknown>'} runs \`vx run\` inside its own workspace: a nested run is invisible to the outer graph (its tasks escape the schedule, the concurrency budget and the cache key) and a loop back to this task forks without bound. Declare what it needs with dependsOn instead.`,
492
601
  )
@@ -502,13 +611,13 @@ async function runOnBus(
502
611
  if (prepared.unresolvedTasks.length > 0) {
503
612
  const refused = `no projects declare task(s): ${prepared.unresolvedTasks.join(', ')}.${await unresolvedHint(prepared)}`
504
613
  await teardown()
505
- prepared.cache.close()
614
+ await closeUnrun()
506
615
  return { ok: false, outcomes: [], refused }
507
616
  }
508
617
  if (prepared.empty === 'none-affected') {
509
618
  log.status(noneAffected(prepared, options))
510
619
  await teardown()
511
- prepared.cache.close()
620
+ await closeUnrun()
512
621
  return { ok: true, outcomes: [] }
513
622
  }
514
623
  if (prepared.empty !== null) {
@@ -521,7 +630,7 @@ async function runOnBus(
521
630
  ? `no projects declare task(s): ${options.tasks.join(', ')}.${elsewhereHint(prepared.declaredElsewhere)}${await initHint(prepared)}`
522
631
  : 'no tasks to run.'
523
632
  await teardown()
524
- prepared.cache.close()
633
+ await closeUnrun()
525
634
  return { ok: false, outcomes: [], refused }
526
635
  }
527
636
  // Install user plugins as additional bus subscribers BEFORE the run
@@ -534,6 +643,7 @@ async function runOnBus(
534
643
  // carries it so analytics queries can group by invocation; plugins get it
535
644
  // in their teardown's `RunReport`.
536
645
  const runId = ulid()
646
+ prepared.localCache.runId = runId
537
647
  // A killed run's restore staging (`.vx/tmp/<pid>-0`), once per run.
538
648
  sweepStaging(prepared.workspaceRoot)
539
649
  const head = captureGitContext(prepared.workspaceRoot)
@@ -553,7 +663,7 @@ async function runOnBus(
553
663
  : new RunKeeper({
554
664
  runId,
555
665
  workspaceRoot: prepared.workspaceRoot,
556
- nestedDirsByProject: prepared.nestedDirsByProject,
666
+ nestedDirsByProject: outputWallsOf(prepared.nestedDirsByProject),
557
667
  keep: prepared.keepRunEntry,
558
668
  failures: keepRun.failures,
559
669
  traffic: prepared.localCache.traffic,
@@ -567,6 +677,7 @@ async function runOnBus(
567
677
  terminal === null ? undefined : () => terminal.holdStatus(),
568
678
  )
569
679
  const remotesClosed = await openRemotes(prepared.remotes, ciContext.ci, secrets)
680
+ signals.held?.arm()
570
681
  try {
571
682
  disposePlugins = await installPlugins({
572
683
  plugins: prepared.plugins,
@@ -579,11 +690,12 @@ async function runOnBus(
579
690
  branch: prepared.branch(),
580
691
  secrets,
581
692
  step,
693
+ signal: signals.held?.signal,
582
694
  })
583
695
  } catch (err) {
584
696
  disposePlugins?.()
585
697
  await teardown(err instanceof PluginSetupError ? err.plugin : undefined)
586
- prepared.cache.close()
698
+ await closeUnrun()
587
699
  throw err
588
700
  }
589
701
  // Until the run's own try below, a throw leaves through here.
@@ -593,7 +705,7 @@ async function runOnBus(
593
705
  } catch (err) {
594
706
  disposePlugins?.()
595
707
  await teardown()
596
- prepared.cache.close()
708
+ await closeUnrun()
597
709
  throw err
598
710
  }
599
711
  }
@@ -621,22 +733,26 @@ async function runOnBus(
621
733
  if (options.signal?.aborted === true) {
622
734
  disposePlugins?.()
623
735
  await teardown()
624
- prepared.cache.close()
736
+ await closeUnrun()
625
737
  return { ok: true, outcomes: [] }
626
738
  }
627
739
  }
628
740
 
629
741
  // Resolved ONCE per run, in declaration order, the local executor last.
630
- // A broken factory aborts here, before any task starts.
742
+ // A broken factory aborts here, before any task starts, and a held
743
+ // signal ends the wait on one that never settles.
631
744
  const executors: readonly TaskExecutor[] = await abandoning(() =>
632
- resolveExecutors(prepared.plugins, {
633
- workspaceRoot: prepared.workspaceRoot,
634
- cacheDir: prepared.cacheDir,
635
- warn: warnOf(log),
636
- concurrency,
637
- artifacts: taskArtifacts(prepared.cache, prepared.workspaceRoot),
638
- cacheFacts: () => cacheFacts(prepared.remotes, prepared.cacheRefs),
639
- }),
745
+ unlessStopped(
746
+ resolveExecutors(prepared.plugins, {
747
+ workspaceRoot: prepared.workspaceRoot,
748
+ cacheDir: prepared.cacheDir,
749
+ warn: warnOf(log),
750
+ concurrency,
751
+ artifacts: taskArtifacts(prepared.cache, prepared.workspaceRoot),
752
+ cacheFacts: () => cacheFacts(prepared.remotes, prepared.cacheRefs),
753
+ }),
754
+ signals.held?.signal,
755
+ ),
640
756
  )
641
757
  // Placement: decided ONCE per task, before scheduling, so the scheduler
642
758
  // can admit a remote-pooled task against its pool instead of a local
@@ -688,26 +804,28 @@ async function runOnBus(
688
804
  // `cache.download` (default `all`) decides ONCE per task, here, whether a
689
805
  // remote execution's outputs come home.
690
806
  const downloadPolicy = options.download ?? 'all'
691
- const localPlaced = locallyPlaced(placements, nodes.keys())
692
- const download = resolveDownloadModes({
693
- nodes,
694
- policy: downloadPolicy,
695
- localPlaced,
696
- remoteOnly: placements.remoteOnly,
697
- })
807
+ const localPlaced = locallyPlaced(placements)
808
+ // `all` keeps every task eager: no per-task map to say so, as `--dry`'s.
809
+ const download =
810
+ downloadPolicy === 'all'
811
+ ? undefined
812
+ : resolveDownloadModes({
813
+ nodes,
814
+ policy: downloadPolicy,
815
+ localPlaced,
816
+ remoteOnly: placements.remoteOnly,
817
+ })
698
818
 
699
819
  // `run.timeout` (ms), for any task WITHOUT its own `exec.timeout`.
700
820
  const taskTimeoutDefault = options.timeout
701
821
 
702
- // The held servers this run took over, spared by a `CYCLE_INTERRUPT`.
703
- const handed = new Set<ReturnType<typeof Bun.spawn>>()
704
822
  const stop = runStop({
705
- handed: (child) => handed.has(child),
706
823
  log,
707
824
  cache,
708
825
  external: options.signal,
709
826
  handleSignals: options.handleSignals ?? true,
710
827
  handsTerminal: holders.size > 0,
828
+ held: signals.held,
711
829
  teardowns: prepared.plugins.filter((p) => p.teardown).length,
712
830
  onStop: () => {
713
831
  log.runStop?.()
@@ -731,6 +849,7 @@ async function runOnBus(
731
849
  const closeCache = async (): Promise<void> => {
732
850
  if (cacheClosed) return
733
851
  cacheClosed = true
852
+ await prepared.localCache.stopCheckpoints()
734
853
  cache.close()
735
854
  live?.unlisten()
736
855
  // Released with the handle: before a persistent task's wait, on every
@@ -742,16 +861,6 @@ async function runOnBus(
742
861
  // waited, but a directory per run is litter the box never sheds.
743
862
  await releaseRunLock()
744
863
  }
745
- // Taken after the early exits above (nothing they do touches a tree) and
746
- // before the schedule: from here on tasks clean, restore and write.
747
- const releaseRunLock = await abandoning(() =>
748
- step('vx:run-lock', () =>
749
- acquireRunLock(prepared.workspaceRoot, {
750
- log: warnOf(log),
751
- signal: stop.signal,
752
- }),
753
- ),
754
- )
755
864
  // Answers `vx last --live` while the lock is held (run-live.ts).
756
865
  live = serveLive({
757
866
  socketPath: liveSocketOf(releaseRunLock.path),
@@ -774,26 +883,17 @@ async function runOnBus(
774
883
  // clean their outputs before every exec for a save that never
775
884
  // happened.
776
885
  const policy: CachePolicy = effectiveCachePolicy(prepared.cachePolicy, remoteCacheEnabled)
777
- // The workspace's `cache.policy` asks as a flag does: it was the one source never told.
778
- const remoteNamed =
779
- options.remoteRequested ??
780
- (options.cache === undefined &&
781
- options.cacheFlags?.noCache !== true &&
782
- namesRemote(prepared.workspaceConfig?.cache?.policy)
783
- ? 'cache.policy'
784
- : undefined)
785
- if (remoteNamed !== undefined && !prepared.hasRemoteLayer) {
786
- warnOf(log)(
787
- `vx: ${remoteNamed} named the remote cache, but no cache plugin in .vx/workspace.* supplies one — the remote axes are off for this run`,
788
- )
789
- }
886
+ warnNoRemote(prepared, options, log)
887
+ const windows = options.commandWindows ?? commandWindows()
790
888
  const deferredOutputs = new DeferredOutputs({
791
889
  nodes,
792
890
  cache,
793
891
  workspaceRoot,
794
- nestedDirsByProject,
892
+ nestedDirsByProject: outputWallsOf(nestedDirsByProject),
795
893
  ...(gitFilesCache !== undefined ? { gitFilesCache } : {}),
796
894
  localWrite: policy.localWrite,
895
+ log,
896
+ commandWindows: windows,
797
897
  })
798
898
 
799
899
  // Per-run context for the Tier-3 `invocations` header row. Captured
@@ -874,7 +974,7 @@ async function runOnBus(
874
974
  (n) =>
875
975
  n.config.exec?.sandbox !== undefined &&
876
976
  (n.config.exec.persistent === undefined
877
- ? localPlaced.has(n.id)
977
+ ? localPlaced(n.id)
878
978
  : !placements.carried.has(n.id)),
879
979
  )
880
980
  let prewarming: Promise<void> | undefined
@@ -887,12 +987,19 @@ async function runOnBus(
887
987
  const outputDirSnapshots: OutputDirSnapshot[] = []
888
988
  // Saves run off the execution slot, twice the cap at once (memory:
889
989
  // each pack holds an artifact's bytes); a failed save is a miss next
890
- // time, said once — the task's work ran.
891
- const saveLane = createSaveLane(2 * concurrency, (err) =>
892
- warnOf(log)(`[vx] cache save failed: ${err instanceof Error ? err.message : String(err)}`),
893
- )
990
+ // time, said once — the task's work ran. A full disk fails every save
991
+ // after the first the same way: the first is said with its task, the
992
+ // rest named once the graph ends.
993
+ const unsaved: string[] = []
994
+ const saveLane = createSaveLane(2 * concurrency, (err, taskId) => {
995
+ if (unsaved.push(taskId) > 1) return
996
+ const msg = err instanceof Error ? err.message : String(err)
997
+ // The artifact ceiling's refusal names its task itself.
998
+ warnOf(log)(
999
+ `[vx] cache save failed: ${msg.startsWith(`${taskId} `) ? msg : `${taskId}: ${msg}`}`,
1000
+ )
1001
+ })
894
1002
  const deferredSaves = new Map<string, Promise<SaveFacts>>()
895
- const windows = commandWindows()
896
1003
  // A server held from an earlier run (`vx watch`) runs through this one
897
1004
  // too, so it may write a mutable input while it is described.
898
1005
  for (const id of heldBefore.kept.keys()) {
@@ -934,9 +1041,9 @@ async function runOnBus(
934
1041
  const footers = executors.filter((e) => e.footer !== undefined)
935
1042
  const placers = executors.filter((e) => e.running !== undefined)
936
1043
  // The scheduler's dependents count, and every row's `blocks` (the live
937
- // list's and the results'): one closure over the graph, built on the
938
- // first that asks.
939
- let counted: ReadonlyMap<string, number> | undefined
1044
+ // list's and the results'): one closure over the graph, counted by
1045
+ // `prepareRun` beside git when it could, else on the first that asks.
1046
+ let counted: ReadonlyMap<string, number> | undefined = prepared.dependents
940
1047
  const blocksNow = (): ReadonlyMap<string, number> =>
941
1048
  (counted ??= graphCounts(nodes, hashCache.folds, hashCache.graph))
942
1049
  // Every row's `usual` and the forecast's timings: history read once, on
@@ -1149,9 +1256,16 @@ async function runOnBus(
1149
1256
  // dead runs on a failure the outcomes do not hold yet (the server's
1150
1257
  // says `success` until the run ends), so it is seeded here, at its
1151
1258
  // dispatch, and the tracker carries it to what is built on it.
1259
+ // A server can also die under a task that is running (`serverDiedUnder`),
1260
+ // which seeds it then, and so does a task that left a process running
1261
+ // (`leftRunning`).
1152
1262
  const taintSeeds = new Set(excluded.seeds)
1153
- const taint = taintTracker(options.continueMode === 'always', taintSeeds, nodes)
1154
- // Each task that ran tainted, and the task at the root of it.
1263
+ const hasServers = [...nodes.values()].some((n) => n.config.exec?.persistent !== undefined)
1264
+ const continueAlways = options.continueMode === 'always'
1265
+ let taint = taintTracker(continueAlways, taintSeeds, nodes, hasServers)
1266
+ let taintOn = continueAlways || taintSeeds.size > 0 || hasServers
1267
+ // Each task that ran tainted, and the task at the root of it: its
1268
+ // outcome's `taintedBy` once it finishes.
1155
1269
  const taintedRan = new Map<string, string>()
1156
1270
  const depended = dependedOn(nodes.values())
1157
1271
 
@@ -1181,6 +1295,7 @@ async function runOnBus(
1181
1295
  fingerprintWatch,
1182
1296
  keyedProjects: keyed,
1183
1297
  readableDeps: readable,
1298
+ nestedDirsOf: nestedDirsOf(nodes, prepared.keyOnly, nestedDirsByProject),
1184
1299
  outputDirSnapshots,
1185
1300
  deferSave: saveLane.defer,
1186
1301
  deferredSaves,
@@ -1188,6 +1303,40 @@ async function runOnBus(
1188
1303
  stopSignal: stop.signal,
1189
1304
  failFast: failFast.signal,
1190
1305
  inputStability,
1306
+ serverReady: (id) => void sinceReady.set(id, []),
1307
+ }
1308
+ if (hasServers)
1309
+ scope.serverDiedUnder = async (node) => {
1310
+ const behind = (pick: (id: string) => boolean): string | undefined => {
1311
+ for (const d of node.deps) {
1312
+ const s = deadServerBehind(nodes, pick, d)
1313
+ if (s !== undefined) return s
1314
+ }
1315
+ return undefined
1316
+ }
1317
+ let dead = behind(serverDied)
1318
+ // A server that died just before the task ended may be heard after
1319
+ // it: both exits can land in one turn of the event loop, the task's
1320
+ // handled first and its save decided in that turn's microtasks. The
1321
+ // next turn has handled the rest.
1322
+ if (dead === undefined && behind((id) => persistentRegistry.has(id)) !== undefined) {
1323
+ await new Promise<void>((resolve) => setImmediate(resolve))
1324
+ dead = behind(serverDied)
1325
+ }
1326
+ if (dead === undefined) return false
1327
+ taintSeeds.add(node.id)
1328
+ taintedRan.set(node.id, dead)
1329
+ return true
1330
+ }
1331
+ scope.leftRunning = (node) => {
1332
+ // The check stays off until the first leftover: it is seeded before
1333
+ // the task settles, so every outcome a dependent's judge reads is
1334
+ // recorded by the tracker made here.
1335
+ if (!taintOn) {
1336
+ taint = taintTracker(continueAlways, taintSeeds, nodes, true)
1337
+ taintOn = true
1338
+ }
1339
+ taintSeeds.add(node.id)
1191
1340
  }
1192
1341
  if (options.retries !== undefined) scope.retries = options.retries
1193
1342
  scope.knownFlaky = (node, hash) => {
@@ -1212,13 +1361,14 @@ async function runOnBus(
1212
1361
  }
1213
1362
  if (explainMiss !== undefined) scope.explainMiss = explainMiss
1214
1363
  if (keepRun !== undefined) scope.keepRun = keepRun
1364
+ if (options.watch === true) scope.watch = true
1215
1365
  if (sandboxArmer !== null) scope.armSandbox = () => sandboxArmer.arm()
1216
1366
  if (demand.any) scope.executing = demand.executing
1217
1367
  const argsOf = executeArgsOf(scope, {
1218
1368
  log: (node) => (node.config.exec?.persistent !== undefined ? serverLog(node.id) : log),
1219
1369
  placements,
1220
1370
  nestedDirsByProject,
1221
- downloadModeOf: download.modeOf,
1371
+ downloadModeOf: download?.modeOf,
1222
1372
  shortCircuit,
1223
1373
  dependedOn: depended,
1224
1374
  holders,
@@ -1287,7 +1437,7 @@ async function runOnBus(
1287
1437
  const historyDb = indexDb(prepared.localCache)
1288
1438
  const cacheOff = cachesNothing(policy)
1289
1439
  const judgeFlaky = (o: TaskOutcome): void => {
1290
- const candidates = flakyCandidates([o], taintedRan)
1440
+ const candidates = flakyCandidates([o])
1291
1441
  if (candidates.length === 0) return
1292
1442
  try {
1293
1443
  const found = detectFlaky(historyDb, candidates)[0]
@@ -1303,7 +1453,6 @@ async function runOnBus(
1303
1453
  heldBefore.kept.delete(node.id)
1304
1454
  if (hasEnded(server.child)) return undefined
1305
1455
  server.handOver()
1306
- handed.add(server.child)
1307
1456
  server.out.bus = bus
1308
1457
  serverOuts.set(node.id, server.out)
1309
1458
  persistentRegistry.set(node.id, server.child)
@@ -1319,11 +1468,15 @@ async function runOnBus(
1319
1468
  }
1320
1469
  let lockBroken: boolean | undefined
1321
1470
  prepared.localCache.storeHeldBlobSizes()
1471
+ prepared.localCache.checkpointOffThread()
1322
1472
  // A hit's row is whole when it finishes, so it is written while the
1323
1473
  // graph runs (history-stream.ts); every other row waits for the bundle.
1324
1474
  // A row with forward args is the bundle's too: only it digests them.
1325
1475
  const historyAt = historyFiles(prepared.localCache)
1326
- const stream = historyAt === undefined ? undefined : new HistoryStream<TaskOutcome>(historyAt)
1476
+ let expectedHits = 0
1477
+ for (const probe of shortCircuit.preProbed.values()) if (probe.hit !== null) expectedHits++
1478
+ const stream =
1479
+ historyAt === undefined ? undefined : new HistoryStream<TaskOutcome>(historyAt, expectedHits)
1327
1480
  const forwards = (options.forwardArgs?.length ?? 0) > 0
1328
1481
  const streamHit = (o: TaskOutcome): void => {
1329
1482
  if (stream === undefined || isGroupTask(o.node)) return
@@ -1333,7 +1486,6 @@ async function runOnBus(
1333
1486
  stream.add(
1334
1487
  o,
1335
1488
  runRecordOf(o as RecordedOutcome, {
1336
- tainted: taintedRan,
1337
1489
  runId,
1338
1490
  startedAtMs: endedAtMsAtStart,
1339
1491
  endedAtMs: endedAtMsAtStart,
@@ -1342,6 +1494,50 @@ async function runOnBus(
1342
1494
  }),
1343
1495
  )
1344
1496
  }
1497
+ // A dependency-only server stops once no task left uses it, not at the
1498
+ // end of the graph: it held a worker's cores for nothing. Not under a
1499
+ // hold, whose next cycle may reuse it.
1500
+ const unneeded =
1501
+ options.holdPersistent === true
1502
+ ? undefined
1503
+ : unneededServers(nodes, options.log === undefined && (options.handleSignals ?? true))
1504
+ const serving = executors.filter((e) => e.servers === true && e.unneeded !== undefined)
1505
+ const released = new Set<string>()
1506
+ const stopping: Promise<void>[] = []
1507
+ // A server stops after the servers using it (`serverUsers`), which this
1508
+ // same release reached first.
1509
+ const usersOf = unneeded === undefined ? undefined : serverUsers(nodes)
1510
+ const stops = new Map<string, Promise<void>>()
1511
+ const release = (id: string): void => {
1512
+ // A stop signals every server once and waits out its grace: a second
1513
+ // signal here cut a server's cleanup short.
1514
+ if (stop.signal.aborted) return
1515
+ for (const s of unneeded?.(id) ?? []) {
1516
+ // Copies an executor started beside the dependants it ran.
1517
+ for (const e of serving)
1518
+ try {
1519
+ e.unneeded!(s)
1520
+ } catch {
1521
+ // Its copies stop at its teardown, as before.
1522
+ }
1523
+ const child = persistentRegistry.get(s)
1524
+ // One that died on its own stays: the end of the graph reports it.
1525
+ if (child === undefined || hasEnded(child)) continue
1526
+ released.add(s)
1527
+ persistentRegistry.delete(s)
1528
+ const users: Promise<void>[] = []
1529
+ for (const u of usersOf?.get(s) ?? []) {
1530
+ const p = stops.get(u)
1531
+ if (p !== undefined) users.push(p)
1532
+ }
1533
+ const p =
1534
+ users.length === 0
1535
+ ? stopServer(child)
1536
+ : Promise.allSettled(users).then(() => stopServer(child))
1537
+ stops.set(s, p)
1538
+ stopping.push(p)
1539
+ }
1540
+ }
1345
1541
  const outcomes = await runGraph({
1346
1542
  nodes,
1347
1543
  concurrency,
@@ -1356,22 +1552,30 @@ async function runOnBus(
1356
1552
  onFailFast: () => failFast.abort(),
1357
1553
  signal: stop.signal,
1358
1554
  onStart: (node) => {
1555
+ // A platform skip never starts, like a skip blocked upstream: its
1556
+ // start opened a single task's frame that nothing closed.
1557
+ if (placements.otherPlatform.has(node.id)) return
1359
1558
  log.taskStart?.(node)
1360
1559
  },
1361
1560
  onFinish: (o) => {
1362
1561
  if (cacheOff) o.cacheOff = true
1562
+ const root = taintedRan.get(o.node.id)
1563
+ if (root !== undefined && o.blockedBy === undefined) o.taintedBy = root
1363
1564
  taint.settled(o)
1364
1565
  judgeFlaky(o)
1365
1566
  keeper?.taskDone(o)
1366
1567
  log.taskComplete(o.node, o)
1568
+ if (sinceReady.size > 0) releaseSinceReady(o.node.id)
1367
1569
  streamHit(o)
1368
1570
  demand.finished(o.node.id)
1571
+ release(o.node.id)
1369
1572
  // A server that dies while the graph runs is no longer running:
1370
1573
  // its pin stayed `running` under the line naming its exit.
1371
1574
  const server = o.status === 'success' ? persistentRegistry.get(o.node.id) : undefined
1372
1575
  if (server !== undefined && o.serverExit === undefined)
1373
1576
  void server.exited.then((code) => {
1374
- if (code !== 0 && !graphOver && !stop.signal.aborted) o.serverExit = code
1577
+ if (code !== 0 && !graphOver && !stop.signal.aborted && !released.has(o.node.id))
1578
+ o.serverExit = code
1375
1579
  })
1376
1580
  },
1377
1581
  // A crash a hand-broken lock entry caused is named as that; asked
@@ -1443,6 +1647,14 @@ async function runOnBus(
1443
1647
  stop.graphDone()
1444
1648
  reprioritize = undefined
1445
1649
  graphOver = true
1650
+ // Each task finishes once its save settles (`settledOf`): the list is whole.
1651
+ if (unsaved.length > 1) {
1652
+ const more = unsaved.slice(1)
1653
+ const named = more.slice(0, 5).join(', ') + (more.length > 5 ? ', …' : '')
1654
+ warnOf(log)(
1655
+ `[vx] cache save failed for ${more.length} more task${more.length === 1 ? '' : 's'}: ${named}`,
1656
+ )
1657
+ }
1446
1658
 
1447
1659
  // Which persistent children outlive the graph, and the bounded SIGTERM
1448
1660
  // of the rest, before the summary prints. Scoped to the real CLI
@@ -1459,6 +1671,7 @@ async function runOnBus(
1459
1671
  // A stop already signalled every server once and waits out its grace:
1460
1672
  // a SIGTERM here, while a server ran its cleanup, cut it short.
1461
1673
  if (stop.signal.aborted) await stop.aborting?.catch(() => {})
1674
+ await Promise.all(stopping)
1462
1675
  const { keepAlive, crashed: crashedPersistent } = await disposeServers({
1463
1676
  registry: persistentRegistry,
1464
1677
  nodes,
@@ -1551,7 +1764,6 @@ async function runOnBus(
1551
1764
  const recordsOf = (final: TaskOutcome[], runOk: boolean) =>
1552
1765
  assembleRunRecords({
1553
1766
  outcomes: final,
1554
- tainted: taintedRan,
1555
1767
  runId,
1556
1768
  startedAtMs: endedAtMsAtStart,
1557
1769
  endedAtMs,
@@ -1591,6 +1803,11 @@ async function runOnBus(
1591
1803
  // cache closes: its history is written once the wait has decided, or it
1592
1804
  // read `ok` over the exit 1 and fed the flaky list a pass (X-23).
1593
1805
  const historyAfterWait = keepAlive.children.length > 0 && !hold && stop.stoppedBy === undefined
1806
+ // The servers this run is done with end now; the kept ones once the wait decides.
1807
+ telemetry?.endServers(
1808
+ list,
1809
+ historyAfterWait ? new Set(keepAlive.nodes.map((n) => n.id)) : undefined,
1810
+ )
1594
1811
  // A failed task's output, for `vx last` and an agent (run-failures.ts).
1595
1812
  // A cache directory this user cannot write keeps none of the run's
1596
1813
  // records: the run's one notice said so.
@@ -1611,7 +1828,7 @@ async function runOnBus(
1611
1828
  // measured, or the next run knows none of it (2026-10-21).
1612
1829
  const ended = list.filter((o) => o.status !== 'aborted' && o.status !== 'skipped')
1613
1830
  stepSync('vx:history', () => {
1614
- recordHistory(() => prepared.localCache.recordRuns(recordsOf(ended, false).runs))
1831
+ recordHistory(() => prepared.localCache.recordRuns(recordsOf(ended, false).runs, head))
1615
1832
  recordFailures(ended)
1616
1833
  })
1617
1834
  }
@@ -1663,7 +1880,14 @@ async function runOnBus(
1663
1880
  runId,
1664
1881
  ),
1665
1882
  )
1666
- const report = reportOf(list, { runId, commit: head.commitSha, ciRun: ciContext, ok }, keeper)
1883
+ // A stop passes none (`VxPlugin.teardown`): the run did not reach its end.
1884
+ // Built and frozen only for a teardown that reads it.
1885
+ const report =
1886
+ stop.stoppedBy === undefined && prepared.plugins.some((p) => p.teardown !== undefined)
1887
+ ? freezeDeep(
1888
+ reportOf(list, { runId, commit: head.commitSha, ciRun: ciContext, ok }, keeper),
1889
+ )
1890
+ : undefined
1667
1891
  // Hand the per-run summary to the telemetry sinks + drain them. Only
1668
1892
  // when a sink is active (telemetry !== undefined) — otherwise this
1669
1893
  // whole block is skipped and the run is byte-identical to before.
@@ -1671,23 +1895,37 @@ async function runOnBus(
1671
1895
  // the run; flush is the sink's last chance to ship buffered records.
1672
1896
  // After the drain, so the summary says what the uploads moved; a live
1673
1897
  // sink's task records went out as each task ended.
1674
- if (telemetry !== undefined && runContextRecord !== undefined) {
1675
- const summary = assembleRunSummary(runContextRecord, records.telemetryTasks, {
1898
+ const sinks = telemetry
1899
+ const summarize = async (
1900
+ final: TaskOutcome[],
1901
+ runOk: boolean,
1902
+ timed?: typeof step,
1903
+ ended = { at: endedAtMs, totalMs },
1904
+ ): Promise<void> => {
1905
+ if (sinks === undefined || runContextRecord === undefined) return
1906
+ sinks.endServers(final)
1907
+ const tasks = final === list ? records.telemetryTasks : recordsOf(final, runOk).telemetryTasks
1908
+ const summary = assembleRunSummary(runContextRecord, tasks, {
1676
1909
  startedAt: endedAtMsAtStart,
1677
- endedAt: endedAtMs,
1678
- totalDurationMs: Math.round(totalMs),
1679
- exitOk: ok,
1680
- abortedCount: list.filter((o) => o.status === 'aborted' && !isGroupTask(o.node)).length,
1910
+ endedAt: ended.at,
1911
+ totalDurationMs: Math.round(ended.totalMs),
1912
+ exitOk: runOk,
1913
+ // As the terminal counts them: `1 aborted · 1 not run`.
1914
+ abortedCount: tasks.filter((t) => t.status === 'aborted').length,
1915
+ notRunCount: final.filter((o) => neverStarted(o) && !isGroupTask(o.node)).length,
1681
1916
  stages: stageTimes(),
1682
1917
  uploads: prepared.localCache.uploads,
1683
1918
  machines: executors.flatMap((e) => executorMachines(e, fault)),
1684
1919
  idealMs: executors.map((e) => executorIdeal(e, fault)).find((ms) => ms !== undefined),
1685
1920
  advice: executors.flatMap((e) => executorAdvice(e, fault)),
1686
1921
  })
1687
- telemetry.emitSummary(summary)
1688
- const sinks = telemetry
1689
- await sinks.flush(step)
1922
+ sinks.emitSummary(summary)
1923
+ await sinks.flush(timed)
1690
1924
  }
1925
+ // A kept server can still fail the run in the wait below: its summary
1926
+ // goes out once the wait has decided, as its history does, or every
1927
+ // sink (vx-ci's job summary) read a pass over the exit 1.
1928
+ if (!historyAfterWait) await summarize(list, ok, step)
1691
1929
  // End-of-run plugin lifecycle: each plugin's teardown(). Crash-isolated
1692
1930
  // + time-bounded inside teardownPlugins, so a faulty plugin can neither
1693
1931
  // fail nor hang the run. Here on the normal path, after the drains
@@ -1798,6 +2036,28 @@ async function runOnBus(
1798
2036
  local.close()
1799
2037
  }
1800
2038
  })
2039
+ // After the footer and the plugins' teardown: no step line there.
2040
+ // The run ended with the session, not with the summary above. A stop
2041
+ // reads as one, as a stop anywhere else does: the servers it took
2042
+ // down are aborted there. The history keeps them as a dev session's
2043
+ // usual end (item 854).
2044
+ const stopped = stop.signal.aborted
2045
+ const told = stopped
2046
+ ? final.map((o) =>
2047
+ keepAlive.nodes.some((n) => n.id === o.node.id) && isPassStatus(o.status)
2048
+ ? {
2049
+ ...o,
2050
+ status: 'aborted' as const,
2051
+ exitCode:
2052
+ typeof first.code === 'number' ? first.code : signalExitCode(first.code),
2053
+ }
2054
+ : o,
2055
+ )
2056
+ : final
2057
+ await summarize(told, ok && !crashed && !stopped, undefined, {
2058
+ at: Date.now(),
2059
+ totalMs: Number(process.hrtime.bigint() - runStartHrTimeNs) / 1_000_000,
2060
+ })
1801
2061
  }
1802
2062
  return { ok: ok && !crashed, outcomes: final }
1803
2063
  }
@@ -1853,16 +2113,38 @@ function noneAffected(prepared: PreparedRun, options: RunOptions): string {
1853
2113
  : `No affected project declares task(s): ${options.tasks.join(', ')}.`
1854
2114
  }
1855
2115
 
2116
+ /**
2117
+ * One line when a policy names the remote cache and no plugin supplies
2118
+ * one; a plan (`--dry`) gets it as the run does. The workspace's
2119
+ * `cache.policy` asks as a flag does: it was the one source never told.
2120
+ */
2121
+ function warnNoRemote(prepared: PreparedRun, options: RunOptions, log: Logger): void {
2122
+ if (prepared.hasRemoteLayer) return
2123
+ const remoteNamed =
2124
+ options.remoteRequested ??
2125
+ (options.cache === undefined &&
2126
+ options.cacheFlags?.noCache !== true &&
2127
+ namesRemote(prepared.workspaceConfig?.cache?.policy)
2128
+ ? 'cache.policy'
2129
+ : undefined)
2130
+ if (remoteNamed === undefined) return
2131
+ warnOf(log)(
2132
+ `vx: ${remoteNamed} named the remote cache, but no cache plugin in .vx/workspace.* supplies one — the remote axes are off for this run`,
2133
+ )
2134
+ }
2135
+
1856
2136
  /**
1857
2137
  * Planning mode. Same setup as `run()` — workspace discovery, config
1858
2138
  * load, package graph, task graph — but stops short of execution.
1859
2139
  * Returns a `RunPlan` predicting the cache hit/miss outcome of every
1860
2140
  * task. Used by `--dry`.
1861
2141
  *
1862
- * Side-effects are limited to opening + closing the local Cache handle
1863
- * (and running `inputs.runtime` probe commands, which key
1864
- * derivation requires). Cache probing is the byte-free `cache.has()`
1865
- * existence check — no artifact download, no ingest, no accessed_at bump.
2142
+ * Side-effects are limited to running `inputs.runtime` probe commands,
2143
+ * which key derivation requires: with no remote the local cache opens to
2144
+ * read only (`prepareRunLocked`'s `planning`); with one, the remote hits
2145
+ * found are kept in the index for the run after. Probing is the
2146
+ * byte-free `cache.has()` existence check — no artifact download, no
2147
+ * ingest, no accessed_at bump.
1866
2148
  */
1867
2149
  export async function planRun(options: RunOptions): Promise<RunPlan> {
1868
2150
  refuseRunOptions(options)
@@ -1870,7 +2152,13 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1870
2152
  // what a stage says on the way goes to stderr (C-6).
1871
2153
  const log = options.log ?? defaultLogger(undefined, undefined, process.stderr)
1872
2154
  const reasons = new Map<string, AffectedReason>()
1873
- const prepared = await prepareRun({ ...options, affectedReasons: reasons }, log)
2155
+ const prepared = await prepareRunLocked(
2156
+ { ...options, affectedReasons: reasons },
2157
+ log,
2158
+ undefined,
2159
+ false,
2160
+ true,
2161
+ )
1874
2162
  try {
1875
2163
  options = withRunSettings(options, prepared.workspaceConfig)
1876
2164
  if (prepared.unresolvedTasks.length > 0) {
@@ -1890,6 +2178,7 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1890
2178
  if (prepared.empty === 'none-affected')
1891
2179
  return { tasks: [], noneAffected: noneAffected(prepared, options) }
1892
2180
  if (prepared.empty !== null) return { tasks: [] }
2181
+ warnNoRemote(prepared, options, log)
1893
2182
  // The plan orders nothing, but it refuses what the run would refuse.
1894
2183
  await prepared.priorities()
1895
2184
  terminalHolders(prepared.nodes, options.tty === true)
@@ -1980,7 +2269,7 @@ async function planRestorable(
1980
2269
  /** The names a project outside the run's selection declares, and how to reach them. */
1981
2270
  function elsewhereHint(names: readonly string[]): string {
1982
2271
  if (names.length === 0) return ''
1983
- return ` Only projects outside the selection declare ${names.join(', ')} — pass --all, or --filter to pick them.`
2272
+ return ` Only projects outside the selection declare ${names.join(', ')} — pass --all, or a --filter that includes them.`
1984
2273
  }
1985
2274
 
1986
2275
  /**
@@ -1989,6 +2278,7 @@ function elsewhereHint(names: readonly string[]): string {
1989
2278
  * from: a task just added to a vx.config is not in it until `vx lock`.
1990
2279
  */
1991
2280
  async function unresolvedHint(prepared: {
2281
+ workspaceRoot: string
1992
2282
  unresolvedTasks: readonly string[]
1993
2283
  hintProjects: ReadonlyMap<string, ProjectEntry>
1994
2284
  declaredElsewhere: readonly string[]
@@ -2002,10 +2292,18 @@ async function unresolvedHint(prepared: {
2002
2292
  prepared.hintProjects,
2003
2293
  prepared.declaredElsewhere,
2004
2294
  prepared.taskless,
2295
+ prepared.workspaceRoot,
2005
2296
  )
2006
2297
  const init = await initHint(prepared)
2298
+ // "No project declares a task yet" beside configs that declare one: the
2299
+ // lock holds none of them (a `vx lock` that failed), so say that.
2300
+ if (near.includes(NO_TASK)) {
2301
+ const unlocked = await unlockedAnywhere(prepared.workspaceRoot)
2302
+ if (unlocked !== undefined)
2303
+ return `${elsewhere}${near.replace(NO_TASK, `The lock holds no project for ${unlocked}: run \`vx lock\` (did it fail?).`)}${init}`
2304
+ }
2007
2305
  const lock =
2008
- init === '' && elsewhere === '' && !near.startsWith(' Did you mean')
2306
+ init === '' && elsewhere === '' && !near.startsWith(' Did you mean') && !near.includes(NO_TASK)
2009
2307
  ? ' A run reads the lock: after editing a vx.config, run `vx lock`.'
2010
2308
  : ''
2011
2309
  return `${elsewhere}${near}${init}${lock}`
@@ -2028,6 +2326,19 @@ async function initHint(prepared: {
2028
2326
  return ` ${EMPTY_LOCK}.`
2029
2327
  }
2030
2328
 
2329
+ /** A project config the lock holds no project for, found by the walk `vx lock` makes. */
2330
+ async function unlockedAnywhere(root: string): Promise<string | undefined> {
2331
+ const [{ configs }, lock] = await Promise.all([findProjectConfigs(root), readLockfile(root)])
2332
+ const held = new Set(Object.keys(lock?.projects ?? {}))
2333
+ const c = configs.find((x) => !held.has(lockKey(root, x.dir)))
2334
+ return c === undefined ? undefined : relPosix(root, c.configPath)
2335
+ }
2336
+
2337
+ // The state right after `vx init`: the lock is current, so the lock hint
2338
+ // alone sent a new user to a command that changed nothing.
2339
+ const NO_TASK =
2340
+ 'No project declares a task yet: add one under `tasks` in a vx.config, then run `vx lock`.'
2341
+
2031
2342
  /**
2032
2343
  * A typo's nearest declared name, when one is within two edits — the
2033
2344
  * message names the fix instead of only the mistake. A bare name is
@@ -2040,6 +2351,7 @@ function didYouMean(
2040
2351
  projects: ReadonlyMap<string, ProjectEntry>,
2041
2352
  elsewhere: readonly string[],
2042
2353
  taskless: (name: string) => boolean,
2354
+ root: string,
2043
2355
  ): string {
2044
2356
  // A default `build` is no declared task (X-102): neither a bare name's
2045
2357
  // match nor a menu item or hint for `p#typo`, which led to `p#build`
@@ -2068,7 +2380,7 @@ function didYouMean(
2068
2380
  // A name outside the selection is already said, with `--all`.
2069
2381
  else if (allTasks.size > 0 && !elsewhere.includes(spec)) {
2070
2382
  lists.add(`Tasks: ${listed(allTasks)}.`)
2071
- }
2383
+ } else if (allTasks.size === 0 && elsewhere.length === 0) lists.add(NO_TASK)
2072
2384
  continue
2073
2385
  }
2074
2386
  const [proj, task] = [spec.slice(0, at), spec.slice(at + 1)]
@@ -2094,7 +2406,20 @@ function didYouMean(
2094
2406
  continue
2095
2407
  }
2096
2408
  if (!projects.has(proj)) {
2097
- const p = projectNamed(proj, projects) ?? nearest(proj, projects.keys())
2409
+ // NFC typed, NFD on disk (or back): two edits apart, so no hint, and
2410
+ // the list named the project exactly as typed.
2411
+ const form = [...projects.keys()].find((n) => n.normalize('NFC') === proj.normalize('NFC'))
2412
+ if (form !== undefined) {
2413
+ lists.add(
2414
+ `${proj} is not the project ${form}, though they look the same: the two are written in different Unicode forms (NFC and NFD). Set \`name\` in its vx.config to the form you type.`,
2415
+ )
2416
+ continue
2417
+ }
2418
+ // A project's directory first: by edit distance, `p/a` hinted `p/b`.
2419
+ const p =
2420
+ projectNamed(proj, projects) ??
2421
+ projectAtDir(proj, root, projects.values()) ??
2422
+ nearest(proj, projects.keys())
2098
2423
  if (p !== undefined) {
2099
2424
  const tasks = tasksOf(projects.get(p))
2100
2425
  const t = tasks.includes(task) ? task : nearest(task, tasks)
@@ -2150,11 +2475,22 @@ export function projectNamed(
2150
2475
  return scoped.length === 1 ? scoped[0] : undefined
2151
2476
  }
2152
2477
 
2478
+ /**
2479
+ * The project whose directory a typed name is, relative to the workspace
2480
+ * root, as `--filter p/a` reads it: `alpha` for `p/a`.
2481
+ */
2482
+ export function projectAtDir(
2483
+ typed: string,
2484
+ root: string,
2485
+ projects: Iterable<{ name: string; dir: string }>,
2486
+ ): string | undefined {
2487
+ const dir = path.resolve(root, typed)
2488
+ for (const p of projects) if (path.resolve(p.dir) === dir) return p.name
2489
+ return undefined
2490
+ }
2491
+
2153
2492
  /** The executed, keyed outcomes of a run — what flakiness is judged on. */
2154
- function flakyCandidates(
2155
- outcomes: readonly TaskOutcome[],
2156
- tainted: ReadonlyMap<string, string>,
2157
- ): FlakyCandidate[] {
2493
+ function flakyCandidates(outcomes: readonly TaskOutcome[]): FlakyCandidate[] {
2158
2494
  const out: FlakyCandidate[] = []
2159
2495
  for (const o of outcomes) {
2160
2496
  if (o.status !== 'success' && o.status !== 'failed') continue
@@ -2166,7 +2502,7 @@ function flakyCandidates(
2166
2502
  // Behind a failed dependency the outcome is not the key's: a failure is
2167
2503
  // the dependency's, and a pass built on bytes the key never named, so a
2168
2504
  // later failure relapsed from it and read as a flake for thirty days.
2169
- if (tainted.has(o.node.id)) continue
2505
+ if (o.taintedBy !== undefined) continue
2170
2506
  out.push({
2171
2507
  project: o.node.projectName,
2172
2508
  task: o.node.taskName,