@vzn/vx 0.0.18 → 0.0.21

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 (121) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +99 -65
  3. package/package.json +10 -20
  4. package/src/bin.ts +12 -2
  5. package/src/cache/archive.ts +120 -18
  6. package/src/cache/cache.ts +313 -1324
  7. package/src/cache/chained-cache.ts +0 -8
  8. package/src/cache/config-evals.ts +89 -0
  9. package/src/cache/file-hashes.ts +262 -0
  10. package/src/cache/git-inputs.ts +637 -0
  11. package/src/cache/index.ts +16 -7
  12. package/src/cache/inputs.ts +246 -733
  13. package/src/cache/layer.ts +604 -0
  14. package/src/cache/layered-cache.ts +12 -17
  15. package/src/cache/output-index.ts +224 -0
  16. package/src/cache/policy.ts +84 -0
  17. package/src/cache/run-history.ts +147 -0
  18. package/src/cache/zstd.ts +160 -0
  19. package/src/cli/cache.ts +39 -11
  20. package/src/cli/completions.ts +104 -0
  21. package/src/cli/core-alias.ts +25 -0
  22. package/src/cli/help.ts +64 -36
  23. package/src/cli/index.ts +54 -15
  24. package/src/cli/info.ts +120 -91
  25. package/src/cli/init.ts +75 -0
  26. package/src/cli/last.ts +38 -6
  27. package/src/cli/lock.ts +2 -1
  28. package/src/cli/plan-format.ts +14 -3
  29. package/src/cli/plugin-commands.ts +15 -8
  30. package/src/cli/run.ts +84 -302
  31. package/src/cli/select.ts +332 -0
  32. package/src/cli/show.ts +144 -69
  33. package/src/cli/upgrade.ts +2 -1
  34. package/src/cli/watch.ts +647 -97
  35. package/src/cli/why.ts +44 -14
  36. package/src/cli/workspace-config.ts +126 -0
  37. package/src/config.ts +156 -136
  38. package/src/exec/env.ts +11 -0
  39. package/src/exec/executor.ts +11 -26
  40. package/src/exec/index.ts +6 -2
  41. package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
  42. package/src/exec/runner.ts +57 -18
  43. package/src/exec/sandbox-binds.ts +182 -0
  44. package/src/exec/sandbox-paths.ts +50 -0
  45. package/src/exec/sandbox-runtime.ts +582 -370
  46. package/src/exec/sandbox-violations.ts +280 -0
  47. package/src/graph/index.ts +1 -9
  48. package/src/graph/scheduler.ts +158 -203
  49. package/src/graph/task-graph.ts +2 -2
  50. package/src/index.ts +97 -102
  51. package/src/orchestrator/admission.ts +137 -0
  52. package/src/orchestrator/deferred-outputs.ts +3 -0
  53. package/src/orchestrator/doctor.ts +255 -0
  54. package/src/orchestrator/events.ts +42 -8
  55. package/src/orchestrator/execute-task.ts +145 -554
  56. package/src/orchestrator/failure-mode.ts +169 -28
  57. package/src/orchestrator/framed-output.ts +41 -36
  58. package/src/orchestrator/history.ts +118 -98
  59. package/src/orchestrator/hit-restore.ts +221 -0
  60. package/src/orchestrator/index.ts +35 -4
  61. package/src/orchestrator/lockfile-claim.ts +281 -0
  62. package/src/orchestrator/logger.ts +13 -22
  63. package/src/orchestrator/metrics.ts +53 -20
  64. package/src/orchestrator/miss-save.ts +169 -0
  65. package/src/orchestrator/options.ts +34 -31
  66. package/src/orchestrator/persistent.ts +79 -0
  67. package/src/orchestrator/placement.ts +182 -0
  68. package/src/orchestrator/plugin-host.ts +195 -38
  69. package/src/orchestrator/plugin.ts +162 -9
  70. package/src/orchestrator/prepare.ts +77 -132
  71. package/src/orchestrator/projects.ts +259 -0
  72. package/src/orchestrator/remote-prefetch.ts +1 -1
  73. package/src/orchestrator/run-artifacts.ts +25 -3
  74. package/src/orchestrator/run-context.ts +1 -1
  75. package/src/orchestrator/run-records.ts +149 -0
  76. package/src/orchestrator/run-report.ts +2 -2
  77. package/src/orchestrator/run.ts +246 -597
  78. package/src/orchestrator/sandbox-request.ts +243 -0
  79. package/src/orchestrator/save-lane.ts +47 -0
  80. package/src/orchestrator/signals.ts +111 -0
  81. package/src/orchestrator/stable-keys.ts +60 -3
  82. package/src/orchestrator/status-line.ts +2 -1
  83. package/src/orchestrator/summary.ts +68 -25
  84. package/src/orchestrator/task-hash.ts +20 -18
  85. package/src/orchestrator/task-log-buffer.ts +8 -11
  86. package/src/orchestrator/telemetry-host.ts +11 -1
  87. package/src/orchestrator/telemetry.ts +3 -17
  88. package/src/util/cgroup.ts +160 -0
  89. package/src/util/edit-distance.ts +49 -0
  90. package/src/util/index.ts +11 -3
  91. package/src/util/paths.ts +23 -2
  92. package/src/util/settle.ts +19 -0
  93. package/src/util/verbs.ts +39 -0
  94. package/src/workspace/affected.ts +111 -11
  95. package/src/workspace/config-cache.ts +11 -2
  96. package/src/workspace/config-eval.ts +11 -4
  97. package/src/workspace/config-imports.ts +5 -4
  98. package/src/workspace/config-schema.ts +837 -0
  99. package/src/workspace/filter.ts +43 -6
  100. package/src/workspace/fingerprint.ts +37 -3
  101. package/src/workspace/index.ts +30 -3
  102. package/src/workspace/lockfile.ts +12 -7
  103. package/src/{cli → workspace}/migrate-scripts.ts +26 -19
  104. package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
  105. package/src/workspace/package-graph.ts +98 -28
  106. package/src/workspace/project-loader.ts +76 -746
  107. package/src/workspace/workspace.ts +98 -13
  108. package/plugins/local-cache/index.ts +0 -2
  109. package/plugins/local-executor/index.ts +0 -2
  110. package/plugins/schedule-history/index.ts +0 -2
  111. package/src/cache/cas-backend.ts +0 -115
  112. package/src/cache/digest.ts +0 -32
  113. package/src/cli/migrate-emit.ts +0 -21
  114. package/src/cli/migrate-nx.ts +0 -473
  115. package/src/cli/migrate-turbo.ts +0 -401
  116. package/src/cli/prune.ts +0 -291
  117. package/src/orchestrator/missing-plugin.ts +0 -9
  118. package/src/orchestrator/resources.ts +0 -45
  119. package/src/orchestrator/verify.ts +0 -174
  120. package/src/plugins/local-cache/index.ts +0 -11
  121. package/src/plugins/schedule-history/index.ts +0 -109
@@ -3,19 +3,13 @@
3
3
  // so the layers can be swapped without touching the others.
4
4
 
5
5
  import type { ProjectEntry } from '../workspace/index.js'
6
- import os from 'node:os'
7
- import {
8
- type CacheLayer,
9
- type CachePolicy,
10
- FULL_CACHE_POLICY,
11
- type InvocationRecord,
12
- type RunRecord,
13
- } from '../cache/index.js'
6
+ import path from 'node:path'
7
+ import { type CacheLayer, type CachePolicy, FULL_CACHE_POLICY } from '../cache/index.js'
14
8
  import { VERSION } from '../version.js'
15
- import { initSandbox, probeSandbox, resetSandbox, signalExitCode } from '../exec/index.js'
9
+ import { resetSandbox, VX_RUN_TASK_ENV, VX_RUN_WORKSPACE_ENV } from '../exec/index.js'
16
10
  import { DeferredOutputs } from './deferred-outputs.js'
17
11
  import { resolveDownloadModes } from './download-policy.js'
18
- import { selectExecutor, type TaskExecutor } from '../exec/index.js'
12
+ import type { TaskExecutor } from '../exec/index.js'
19
13
  import {
20
14
  isGroupTask,
21
15
  markSurfacedDeps,
@@ -23,22 +17,38 @@ import {
23
17
  type TaskNode,
24
18
  type TaskOutcome,
25
19
  } from '../graph/index.js'
26
- import { mark, MAX_TIMEOUT_MS, printTimings, ulid, UserError, editDistance } from '../util/index.js'
27
- import { executeTask } from './execute-task.js'
28
- import { resolveResourceCosts } from './resources.js'
29
- import { computeTaskHash } from './task-hash.js'
20
+ import {
21
+ mark,
22
+ MAX_TIMEOUT_MS,
23
+ printTimings,
24
+ ulid,
25
+ nearest,
26
+ UserError,
27
+ machineParallelism,
28
+ } from '../util/index.js'
29
+ import { prepareSandbox } from './sandbox-request.js'
30
+ import type { OutputDirSnapshot } from './miss-save.js'
31
+ import { admitTasks, taintTracker } from './admission.js'
30
32
  import { busLogger, createEventBus, terminalSubscriber } from './events.js'
31
33
  import { installPlugins } from './plugin.js'
32
- import { resolveExecutors, teardownPlugins } from './plugin-host.js'
34
+ import { buildAdmission, resolveExecutors, teardownPlugins } from './plugin-host.js'
33
35
  import { subscribeTelemetry, type TelemetryHandle } from './telemetry-host.js'
34
- import { assembleRunSummary, deriveCacheSource, isCacheHit, isPassStatus } from './telemetry.js'
35
- import type { RunContextRecord, TaskTelemetry } from './telemetry.js'
36
- import { defaultLogger, resolveOutputView, type Logger } from './logger.js'
36
+ import { assembleRunSummary, isPassStatus } from './telemetry.js'
37
+ import type { RunContextRecord } from './telemetry.js'
38
+ import { defaultLogger, resolveOutputView } from './logger.js'
37
39
  import { detectColors } from './colors.js'
38
40
  import { formatPersistentList } from './framed-output.js'
39
41
  import { LocalHistoryProvider } from './history.js'
40
42
  import { plan, type RunPlan } from './plan.js'
41
43
  import { prepareRun } from './prepare.js'
44
+ import { forwardSignals, terminateChildren } from './signals.js'
45
+ import {
46
+ hasPooledExecutor,
47
+ placeTasks,
48
+ planExecutorOf,
49
+ poolOfPlacement,
50
+ UNPLACED_EXECUTOR,
51
+ } from './placement.js'
42
52
  import {
43
53
  captureDefaultBranch,
44
54
  captureGitContext,
@@ -48,21 +58,19 @@ import {
48
58
  } from './run-context.js'
49
59
  import { startRemotePrefetch } from './remote-prefetch.js'
50
60
  import { startLocalShortCircuit, type ShortCircuit } from './local-shortcircuit.js'
51
- import { formatVerifySection } from './verify.js'
52
-
53
- const EMPTY_SHORT_CIRCUIT: ShortCircuit = { preProbed: new Map(), restoreTier: new Set() }
54
61
 
55
- /**
56
- * Grace after SIGTERMing the dependency-only persistent tasks at end-of-run
57
- * before force-killing any that trap or ignore it — so a wedged mock server
58
- * can't hang a normal run at completion. Well-behaved servers exit far under
59
- * this, so the happy path never waits it out.
60
- */
61
- const PERSISTENT_SHUTDOWN_GRACE_MS = 2000
62
+ import { assembleRunRecords } from './run-records.js'
63
+ import { selectKeepAlive, shutdownPersistent } from './persistent.js'
62
64
  import { writeRunProfile, writeRunSummary } from './run-artifacts.js'
63
- import { formatAbortedSection, formatRunSummary } from './summary.js'
65
+ import { createSaveLane } from './save-lane.js'
66
+ import { formatAbortedSection, formatFlakySection, formatRunSummary } from './summary.js'
67
+ import { detectFlaky, type FlakyCandidate } from './failure-mode.js'
64
68
  import type { RunOptions, RunSummary } from './options.js'
65
69
 
70
+ // Per run, never shared: a `vx watch` process runs many, and a shared map
71
+ // is one `preProbed.set` away from leaking a hit across cycles.
72
+ const emptyShortCircuit = (): ShortCircuit => ({ preProbed: new Map(), restoreTier: new Set() })
73
+
66
74
  /**
67
75
  * Parse the `VX_TASK_TIMEOUT` env var (ms) — the "global" run-level task
68
76
  * timeout default. A missing/empty/non-positive-integer value yields
@@ -167,7 +175,21 @@ export async function run(options: RunOptions): Promise<RunSummary> {
167
175
  const log = busLogger(bus)
168
176
 
169
177
  const prepared = await prepareRun(options, log)
170
- mark('prepare (graph)')
178
+ mark('plugin stages')
179
+ // A task whose command re-enters `vx run` in the workspace running it
180
+ // is refused. When the inner run reaches this task again it forks a run
181
+ // per run until the machine gives out; when it does not (`ci` shelling
182
+ // out to `vx run lint`) it is a nested run the outer graph cannot see —
183
+ // its tasks escape the schedule, the concurrency budget and this task's
184
+ // cache key. The markers `taskEnv` sets on every child (exec/env.ts)
185
+ // name the task; the check is on the root so both shapes are caught.
186
+ const outerRoot = process.env[VX_RUN_WORKSPACE_ENV]
187
+ if (outerRoot !== undefined && path.resolve(outerRoot) === path.resolve(prepared.workspaceRoot)) {
188
+ prepared.cache.close()
189
+ throw new UserError(
190
+ `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.`,
191
+ )
192
+ }
171
193
  // A requested name that matched no project is a typo (or a stray
172
194
  // positional from an `=`-only flag written with a space). Failing the
173
195
  // whole run — even when OTHER requested tasks resolved — is the point:
@@ -176,7 +198,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
176
198
  // case too; the message is identical, so that branch stays below.
177
199
  if (prepared.unresolvedTasks.length > 0) {
178
200
  log.status(
179
- `No projects declare task(s): ${prepared.unresolvedTasks.join(', ')}.${didYouMean(prepared.unresolvedTasks, prepared.projects)}`,
201
+ `No projects declare task(s): ${prepared.unresolvedTasks.join(', ')}.${didYouMean(prepared.unresolvedTasks, prepared.projects)}${initHint(prepared)}`,
180
202
  )
181
203
  prepared.cache.close()
182
204
  return { ok: false, outcomes: [] }
@@ -188,7 +210,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
188
210
  // buildTaskGraph semantics but logged just in case.
189
211
  const msg =
190
212
  prepared.empty === 'no-tasks-declared'
191
- ? `No projects declare task(s): ${options.tasks.join(', ')}.`
213
+ ? `No projects declare task(s): ${options.tasks.join(', ')}.${initHint(prepared)}`
192
214
  : 'No tasks to run.'
193
215
  log.status(msg)
194
216
  prepared.cache.close()
@@ -197,8 +219,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
197
219
  // Install user plugins as additional bus subscribers BEFORE the run
198
220
  // starts emitting events. `installPlugins` runs each plugin's optional
199
221
  // `setup` hook and fails fast on a throw with a clean UserError naming
200
- // the plugin. A plugin without `setup` (the local executor and cache)
201
- // subscribes nothing.
222
+ // the plugin. A plugin without `setup` subscribes nothing.
202
223
  let disposePlugins: (() => void) | undefined
203
224
  let telemetry: TelemetryHandle | undefined
204
225
  try {
@@ -226,14 +247,10 @@ export async function run(options: RunOptions): Promise<RunSummary> {
226
247
  hashCache,
227
248
  workspaceProjectCount,
228
249
  } = prepared
229
- const concurrency =
230
- options.concurrency ??
231
- workspaceConfig?.concurrency ??
232
- Math.max(1, navigator.hardwareConcurrency)
250
+ const concurrency = options.concurrency ?? workspaceConfig?.concurrency ?? machineParallelism()
233
251
 
234
- // Resolved ONCE per run, in declaration order. A broken factory — or a
235
- // workspace that declared no executor — aborts here, before any task
236
- // starts.
252
+ // Resolved ONCE per run, in declaration order, the local executor last.
253
+ // A broken factory aborts here, before any task starts.
237
254
  let executors: readonly TaskExecutor[]
238
255
  try {
239
256
  executors = await resolveExecutors(prepared.plugins, {
@@ -251,13 +268,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
251
268
  // can admit a remote-pooled task against its pool instead of a local
252
269
  // worker slot. Group tasks run nothing; persistent tasks never reach an
253
270
  // executor (local by construction) — both stay off the map.
254
- //
255
- // `--verify=inputs` pins EVERYTHING local: the input-completeness proof
256
- // is the OS sandbox, which is local machinery a remote executor silently
257
- // ignores — so a remotely-executed task would pass the verify VACUOUSLY,
258
- // leaky or not. A verify run is a local proof procedure by definition;
259
- // determinism/fingerprint modes are unaffected (no sandbox involved).
260
- const placements = placeTasks(nodes, executors, options.verify?.inputs === true)
271
+ const placements = placeTasks(nodes, executors, false)
261
272
  // A `remote: 'only'` task nobody takes succeeds WITHOUT running. That is
262
273
  // deliberate — on a machine with no remote pool the ambient state already
263
274
  // is what the task would have produced — but it must not be SILENT: a task
@@ -300,24 +311,8 @@ export async function run(options: RunOptions): Promise<RunSummary> {
300
311
  }
301
312
 
302
313
  // `--download` (default `all`) decides ONCE per task, here, whether a
303
- // remote execution's outputs come home. `--verify` pins every task local
304
- // (so nothing defers under a proof) and `all` keeps today's behaviour
305
- // byte for byte.
306
- // A proof must observe what it proves. `--verify=inputs` already pins
307
- // every task LOCAL (so nothing could defer), but determinism and
308
- // fingerprint modes do NOT — and a deferred task's outputs are absent
309
- // when the verifier looks, which reported `no-outputs` for a task that
310
- // declares outputs: an n/a verdict for work the proof never examined.
311
- // Deferral is transfer tuning; a verify run is a rare, deliberate
312
- // correctness run. Eager wins, and says so when it overrides.
313
- const downloadPolicy = options.verify !== undefined ? 'all' : (options.download ?? 'all')
314
- if (
315
- options.verify !== undefined &&
316
- options.download !== undefined &&
317
- options.download !== 'all'
318
- ) {
319
- log.status(`vx: --verify observes outputs on disk — ignoring --download=${options.download}`)
320
- }
314
+ // remote execution's outputs come home.
315
+ const downloadPolicy = options.download ?? 'all'
321
316
  const localPlaced = new Set(
322
317
  [...nodes.keys()].filter((id) => placements.executors.get(id)?.remote !== true),
323
318
  )
@@ -344,39 +339,25 @@ export async function run(options: RunOptions): Promise<RunSummary> {
344
339
  // exit; ownership moves here so the orchestrator can SIGTERM
345
340
  // them once the rest of the graph finishes.
346
341
  //
347
- // A SIGINT/SIGTERM mid-run forwards SIGTERM to everything live,
348
- // closes the cache handle, and exits 128+signo (130/143). Without
349
- // this, a programmatic signal to the vx process alone (CI
350
- // cancellation, `kill <pid>`) orphans every running child —
351
- // terminal Ctrl-C only worked via process-group propagation. The
352
- // handlers are removed in the finally below so repeated run()
353
- // calls (test suites) never stack listeners.
342
+ // A SIGINT/SIGTERM mid-run forwards SIGTERM to everything in both
343
+ // (`signals.ts`); the handlers are removed in the finally below so
344
+ // repeated run() calls (test suites) never stack listeners.
354
345
  const liveChildren = new Set<ReturnType<typeof Bun.spawn>>()
355
346
  const persistentRegistry = new Map<string, ReturnType<typeof Bun.spawn>>()
356
- const onSignal = (signal: 'SIGINT' | 'SIGTERM'): void => {
357
- // Clear the live worker/status region BEFORE exiting so a TTY isn't
358
- // left with a frozen region frozen in the scrollback (the documented
359
- // KNOWN-OPEN). runEnd is idempotent and a no-op for non-TTY loggers.
360
- try {
361
- log.runEnd?.()
362
- } catch {
363
- // teardown must not throw on the way out
364
- }
365
- for (const child of liveChildren) child.kill('SIGTERM')
366
- for (const child of persistentRegistry.values()) child.kill('SIGTERM')
367
- try {
368
- cache.close()
369
- } catch {
370
- // double-close race with the normal path; we're exiting anyway
371
- }
372
- process.exit(signalExitCode(signal))
373
- }
374
- const onSigint = (): void => onSignal('SIGINT')
375
- const onSigterm = (): void => onSignal('SIGTERM')
376
- if (options.handleSignals ?? true) {
377
- process.on('SIGINT', onSigint)
378
- process.on('SIGTERM', onSigterm)
347
+ const signals = forwardSignals({
348
+ enabled: options.handleSignals ?? true,
349
+ log,
350
+ cache,
351
+ liveChildren,
352
+ persistentRegistry,
353
+ })
354
+ // `RunOptions.signal`: the same teardown the process handler runs, minus
355
+ // the exit — the scheduler stops dispatching (it reads the signal) and
356
+ // run() returns to its caller. Detached in the finally below.
357
+ const onAbort = (): void => {
358
+ void terminateChildren(() => [...liveChildren, ...persistentRegistry.values()])
379
359
  }
360
+ options.signal?.addEventListener('abort', onAbort, { once: true })
380
361
  // The cache handle must be released on EVERY exit path, not just the
381
362
  // happy one: `close()` is also where the run's deferred `accessed_at`
382
363
  // bumps are flushed, so a throw between opening the cache and the
@@ -399,17 +380,20 @@ export async function run(options: RunOptions): Promise<RunSummary> {
399
380
  const runStartHrTimeNs = process.hrtime.bigint()
400
381
  const endedAtMsAtStart = Date.now()
401
382
  const remoteCacheEnabled = prepared.hasRemoteLayer
402
- // Normalised ONCE so every consumer — the verify gate, execute-task,
403
- // the dedup predicate, the recorded invocation row — reads the policy
404
- // that actually governed the run. Reading the raw request here made
405
- // tasks clean their outputs before every exec for a save that never
406
- // happened, and `--verify` clean them AGAIN and restore an artifact
407
- // that was never written (wiping a successful build's tree and
408
- // reporting it failed).
383
+ // Normalised ONCE so every consumer — execute-task, the dedup
384
+ // predicate, the recorded invocation row — reads the policy that
385
+ // actually governed the run. Reading the raw request here made tasks
386
+ // clean their outputs before every exec for a save that never
387
+ // happened.
409
388
  const policy: CachePolicy = effectiveCachePolicy(
410
389
  options.cache ?? FULL_CACHE_POLICY,
411
390
  prepared.hasRemoteLayer,
412
391
  )
392
+ if (options.remoteRequested === true && !prepared.hasRemoteLayer) {
393
+ log.status(
394
+ 'vx: --cache named the remote cache, but no cache plugin in vx.workspace.* supplies one — the remote axes are off for this run',
395
+ )
396
+ }
413
397
  const deferredOutputs = new DeferredOutputs({
414
398
  nodes,
415
399
  cache,
@@ -419,29 +403,6 @@ export async function run(options: RunOptions): Promise<RunSummary> {
419
403
  localWrite: policy.localWrite,
420
404
  })
421
405
 
422
- // `--verify` observes the miss-then-save path and then RESTORES attempt
423
- // 1 from the artifact that save wrote, so the tree ends byte-identical
424
- // to what was cached regardless of the verdict. That restore reads the
425
- // LOCAL artifact file — `restoreOutputs` is a local extraction on every
426
- // layer, by design — so without the local WRITE axis there is nothing to
427
- // restore from: verify cleaned a successful build's declared outputs and
428
- // then failed the run on the missing artifact. A remote fallback would
429
- // not fix that, only narrow it to "whenever the remote is reachable at
430
- // that instant", turning a guarantee into a coin flip with data loss on
431
- // the losing side. So the honest gate is the local write axis, and
432
- // `--no-cache` / `--cache=local:,…` / `--cache=local:r,…` are refused
433
- // loudly — silently verifying nothing is the one failure mode
434
- // verification must never have (same platform-honesty rule as the
435
- // sandbox-unavailable error). `--force --verify` re-verifies a warm graph.
436
- if (options.verify !== undefined && !policy.localWrite) {
437
- throw new UserError(
438
- '--verify needs the LOCAL cache write axis: it re-runs the task, then restores ' +
439
- 'attempt 1 from the local artifact so the outputs on disk are the ones that were ' +
440
- 'cached. Enable local writes (e.g. --cache=local:w,remote:rw), drop --no-cache, ' +
441
- 'or use --force --verify to re-execute and verify a warm graph',
442
- )
443
- }
444
-
445
406
  // Per-run context for the Tier-3 `invocations` header row. Captured
446
407
  // ONCE (git is ONE spawn for commit+branch, behind try/catch; never
447
408
  // fails a run). `dirty` reuses the `git status --porcelain` the
@@ -501,26 +462,15 @@ export async function run(options: RunOptions): Promise<RunSummary> {
501
462
  )
502
463
  }
503
464
 
504
- // Lazy SRT init: fire it up if at least one task opts into sandboxing
505
- // via its `sandbox: {...}` block, OR `--verify=inputs`/`=all` is on (it
506
- // forces the declared-input baseline sandbox onto every cacheable task to
507
- // prove input-completeness). Tasks that need sandboxing on an unsupported
508
- // platform get a hard error so they don't silently run unsandboxed —
509
- // `--verify=inputs` in particular must fail loud, never falsely "pass".
510
- const verifyInputs = options.verify?.inputs === true
511
- const anySandboxed =
512
- verifyInputs || [...nodes.values()].some((n) => n.config.sandbox !== undefined)
513
- if (anySandboxed) {
514
- const avail = await probeSandbox()
515
- if (!avail.available) {
516
- throw new UserError(
517
- verifyInputs
518
- ? `--verify=inputs needs the sandbox, which is not available: ${avail.reason}`
519
- : `sandbox not available: ${avail.reason}`,
520
- )
521
- }
522
- await initSandbox()
523
- }
465
+ const sandboxArmer = prepareSandbox(nodes.values())
466
+ const outputDirSnapshots: OutputDirSnapshot[] = []
467
+ // Saves run off the execution slot, twice the cap at once (memory:
468
+ // each pack holds an artifact's bytes); a failed save is a miss next
469
+ // time, said once — the task's work ran.
470
+ const saveLane = createSaveLane(2 * concurrency, (err) =>
471
+ log.status(`[vx] cache save failed: ${err instanceof Error ? err.message : String(err)}`),
472
+ )
473
+ const deferredSaves = new Map<string, Promise<void>>()
524
474
 
525
475
  // Focused flow: a requested GROUP has no output of its own, so
526
476
  // surface the same-project, non-group tasks it chains (one level)
@@ -544,16 +494,10 @@ export async function run(options: RunOptions): Promise<RunSummary> {
544
494
  if (node.requested || node.surfaced === true) requestedCount++
545
495
  }
546
496
  }
547
- // Resource-aware admission: resolve every task's `exec.resources`
548
- // into absolute costs ONCE, up front (percent forms against the
549
- // budgets), so the scheduler's inner loop is a plain Map.get. The
550
- // CPU budget is the run's concurrency; the memory budget is
551
- // os.totalmem() unless `--memory` overrides it (pass `--memory` in
552
- // cgroup-limited containers — totalmem() reports the HOST's RAM).
553
- // Nothing declared → empty map → fields omitted from the scheduler
554
- // AND the footer → byte-identical legacy path.
555
- const memBudget = options.memory ?? os.totalmem()
556
- const resourceCosts = resolveResourceCosts(nodes)
497
+ // Admission over the worker count is a plugin's (`admit`): built once
498
+ // into the predicate the scheduler asks at every local dispatch. No
499
+ // plugin answers → undefined → the count-only path, byte for byte.
500
+ const admit = buildAdmission(prepared.plugins, nodes, concurrency, (m) => log.status(m))
557
501
 
558
502
  // Run context for the footer. The top-of-run header is gone — the
559
503
  // banner now lives in the summary, where the eye lands at the end.
@@ -563,7 +507,6 @@ export async function run(options: RunOptions): Promise<RunSummary> {
563
507
  remoteCacheEnabled,
564
508
  concurrency,
565
509
  workspaceProjectCount,
566
- ...(resourceCosts.size > 0 ? { cpuBudget: concurrency, memBudget } : {}),
567
510
  }
568
511
 
569
512
  // Lifecycle hooks drive the default logger's dynamic status line
@@ -607,7 +550,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
607
550
  // each probe, so there is no second cache.get. Gated by
608
551
  // shouldShortCircuit (local reads on, no remote layer); when off, both
609
552
  // maps are empty and the run is byte-identical.
610
- let shortCircuit: ShortCircuit = EMPTY_SHORT_CIRCUIT
553
+ let shortCircuit: ShortCircuit = emptyShortCircuit()
611
554
  if (shouldShortCircuit(nodes, policy, cache)) {
612
555
  shortCircuit = await startLocalShortCircuit({
613
556
  nodes,
@@ -622,8 +565,14 @@ export async function run(options: RunOptions): Promise<RunSummary> {
622
565
  })
623
566
  }
624
567
 
568
+ // Whether this task runs behind a failure (`continueMode: 'always'`
569
+ // only) — its save is withheld and the taint propagates; see
570
+ // admission.ts.
571
+ const isTainted = taintTracker(options.continueMode === 'always')
572
+
625
573
  const buildExecuteArgs = (node: TaskNode, upstream: TaskOutcome[], reuseProbe = true) => {
626
574
  const probe = reuseProbe ? shortCircuit.preProbed.get(node.id) : undefined
575
+ const taint = isTainted(node, upstream)
627
576
  return {
628
577
  node,
629
578
  upstream,
@@ -634,7 +583,6 @@ export async function run(options: RunOptions): Promise<RunSummary> {
634
583
  forwardArgs: options.forwardArgs,
635
584
  ...(options.retries !== undefined ? { retries: options.retries } : {}),
636
585
  ...(taskTimeoutDefault !== undefined ? { timeout: taskTimeoutDefault } : {}),
637
- ...(options.verify !== undefined ? { verify: options.verify } : {}),
638
586
  log,
639
587
  executor: placements.executors.get(node.id) ?? UNPLACED_EXECUTOR,
640
588
  ...(download.modeOf.get(node.id) === 'deferred' ? { download: 'deferred' as const } : {}),
@@ -648,85 +596,39 @@ export async function run(options: RunOptions): Promise<RunSummary> {
648
596
  gitFilesCache,
649
597
  hashCache,
650
598
  ...(probe !== undefined ? { preProbed: probe } : {}),
599
+ ...(taint ? { taintedUpstream: true } : {}),
600
+ ...(sandboxArmer !== null ? { armSandbox: () => sandboxArmer.arm() } : {}),
601
+ outputDirSnapshots,
602
+ deferSave: saveLane.defer,
603
+ deferredSaves,
651
604
  }
652
605
  }
653
606
 
654
- // In-flight dedup. Only when a service supplies a shared `inflight`
655
- // registry (concurrent runs in one `vx serve`); a stateless `vx run`
656
- // passes none and takes the untouched path. Gated to cacheable tasks —
657
- // the join works by waiting for the sibling to populate the cache, then
658
- // letting executeTask cache-hit on it. executeTask stays unchanged.
659
- const inflight = options.inflight
660
- const executeWithDedup = async (
661
- node: TaskNode,
662
- upstream: TaskOutcome[],
663
- ): Promise<TaskOutcome> => {
664
- // Dedup only helps when the sibling will WRITE the artifact and
665
- // this task can READ it back — i.e. both axes effectively on.
666
- const canRead = policy.localRead || policy.remoteRead
667
- const canWrite = policy.localWrite || policy.remoteWrite
668
- const cacheable =
669
- !isGroupTask(node) &&
670
- node.config.exec?.persistent === undefined &&
671
- node.config.cache !== undefined &&
672
- canRead &&
673
- canWrite
674
- // A restore-tier task (confirmed local hit, may run before its
675
- // deps) needs no dedup — it's a restore, not an executor, and its
676
- // live `upstream` is incomplete, so the dedup hash recompute would
677
- // be wrong. Route it straight to executeTask, which reuses the
678
- // up-front probe.
679
- const restorable = shortCircuit.restoreTier.has(node.id)
680
- if (inflight === undefined || !cacheable || restorable) {
681
- return executeTask(buildExecuteArgs(node, upstream))
682
- }
683
- const hash = await computeTaskHash({
684
- node,
685
- upstream,
607
+ const executeWithDedup = admitTasks({
608
+ inflight: options.inflight,
609
+ policy,
610
+ shortCircuit,
611
+ hashArgs: {
686
612
  workspaceRoot,
687
613
  workspaceFingerprint,
688
614
  cache,
689
615
  forwardArgs: options.forwardArgs,
690
- nestedProjectDirs: nestedDirsByProject.get(node.projectName) ?? [],
616
+ nestedDirsByProject,
691
617
  gitFilesCache,
692
618
  hashCache,
693
- })
694
- const existing = inflight.get(hash)
695
- if (existing !== undefined) {
696
- // Join a sibling already computing this exact task: wait, then
697
- // executeTask cache-hits on the artifact it just saved. The
698
- // up-front probe (preProbed) predates the sibling's save — its
699
- // "confirmed stable miss" would skip the lazy cache.get and
700
- // re-execute, defeating the dedup — so the join path drops it
701
- // and lets executeTask probe fresh.
702
- await existing.catch(() => {})
703
- return executeTask(buildExecuteArgs(node, upstream, false))
704
- }
705
- // Become the executor: register a barrier siblings await. get→set has
706
- // no await between, so registration is atomic — at most one executor
707
- // per hash. Released on every exit (success / failure / throw).
708
- let release!: () => void
709
- inflight.set(
710
- hash,
711
- new Promise<void>((resolve) => {
712
- release = resolve
713
- }),
714
- )
715
- try {
716
- return await executeTask(buildExecuteArgs(node, upstream))
717
- } finally {
718
- inflight.delete(hash)
719
- release()
720
- }
721
- }
619
+ },
620
+ buildExecuteArgs,
621
+ })
722
622
 
723
623
  mark('classify + probe')
724
624
  const outcomes = await runGraph({
725
625
  nodes,
726
626
  concurrency,
627
+ settledOf: (o) => deferredSaves.get(o.node.id),
727
628
  ...(hasPooledExecutor(executors) ? { poolOf: poolOfPlacement(placements) } : {}),
728
- ...(resourceCosts.size > 0 ? { resourceCosts, cpuBudget: concurrency, memBudget } : {}),
629
+ ...(admit !== undefined ? { admit } : {}),
729
630
  ...(options.continueMode !== undefined ? { continueMode: options.continueMode } : {}),
631
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
730
632
  onStart: (node) => {
731
633
  log.taskStart?.(node)
732
634
  },
@@ -744,72 +646,22 @@ export async function run(options: RunOptions): Promise<RunSummary> {
744
646
  restoreTier: shortCircuit.restoreTier,
745
647
  })
746
648
 
747
- // A persistent task the user REQUESTED (a dev server / watcher) is
748
- // the run's whole purpose — don't tear it down the instant it's
749
- // ready. We leave those running and block on them at the very end
750
- // (after the normal summary prints); everything else (persistent
751
- // tasks pulled in only as dependencies of now-finished work) is
752
- // SIGTERMed here as before. Scoped to the real CLI foreground:
753
- // `options.log === undefined` means the default logger (a `vx run`
754
- // invocation), and `handleSignals` excludes watch mode (own signal
755
- // loop) and embedders that manage lifecycle themselves — both expect
756
- // run() to return, not block on a server.
649
+ // Which persistent children outlive the graph, and the bounded SIGTERM
650
+ // of the rest, before the summary prints. Scoped to the real CLI
651
+ // foreground: `options.log === undefined` means the default logger (a
652
+ // `vx run` invocation), and `handleSignals` excludes watch mode (own
653
+ // signal loop) and embedders that manage lifecycle themselves — both
654
+ // expect run() to return, not block on a server.
757
655
  const foreground = options.log === undefined && (options.handleSignals ?? true)
758
- const keepAliveNodes: TaskNode[] = []
759
- const keepAlive: ReturnType<typeof Bun.spawn>[] = []
760
- if (foreground) {
761
- for (const [id, child] of persistentRegistry) {
762
- const n = nodes.get(id)
763
- if (n !== undefined && (n.requested || n.surfaced === true)) {
764
- keepAliveNodes.push(n)
765
- keepAlive.push(child)
766
- }
767
- }
768
- }
769
- const keepAliveSet = new Set(keepAlive)
770
-
771
- // Shut down the dependency-only persistent tasks before reporting the final
772
- // summary. SIGTERM gives well-behaved servers (vite, next, esbuild --watch)
773
- // a moment to clean up. Bun's Subprocess.kill is idempotent on an
774
- // already-exited child. Bound the wait: a persistent dep that traps or
775
- // ignores SIGTERM (a wedged mock server) would otherwise hang the run at
776
- // NORMAL completion forever — after a grace, SIGKILL the stragglers and move
777
- // on. Well-behaved servers exit in well under the grace, so the happy path
778
- // pays nothing; the timer is cleared + unref'd so a fast shutdown never
779
- // delays CLI exit.
780
- const dyingChildren = [...persistentRegistry.values()].filter((c) => !keepAliveSet.has(c))
781
- for (const child of dyingChildren) child.kill('SIGTERM')
782
- const allExited = Promise.allSettled(dyingChildren.map((c) => c.exited))
783
- let graceTimer: ReturnType<typeof setTimeout> | undefined
784
- const winner = await Promise.race([
785
- allExited.then(() => 'exited' as const),
786
- new Promise<'grace'>((resolve) => {
787
- graceTimer = setTimeout(() => resolve('grace'), PERSISTENT_SHUTDOWN_GRACE_MS)
788
- graceTimer.unref?.()
789
- }),
790
- ])
791
- if (graceTimer !== undefined) clearTimeout(graceTimer)
792
- if (winner === 'grace') {
793
- for (const child of dyingChildren) child.kill('SIGKILL')
794
- await allExited
795
- }
656
+ const keepAlive = selectKeepAlive(persistentRegistry, nodes, foreground)
657
+ await shutdownPersistent(persistentRegistry, keepAlive.children)
796
658
 
797
659
  mark('run graph')
798
660
  // Clear the status line for good before the summary prints.
799
661
  log.runEnd?.()
800
662
 
801
663
  const list = [...outcomes.values()]
802
- const ok =
803
- list.every((o) => isPassStatus(o.status)) &&
804
- // `--verify`: a provably-unsafe cache entry (non-deterministic outputs, a
805
- // re-run that failed, or a read of undeclared inputs) turns the run red so
806
- // CI catches it.
807
- !list.some(
808
- (o) =>
809
- o.verify?.kind === 'nondeterministic' ||
810
- o.verify?.kind === 'rerun-failed' ||
811
- o.verify?.kind === 'undeclared-inputs',
812
- )
664
+ const ok = list.every((o) => isPassStatus(o.status))
813
665
 
814
666
  // The summary + artifact writers + recordRun pass all exclude group
815
667
  // tasks via the shared tallyOutcomes helper. We pass the full
@@ -818,13 +670,18 @@ export async function run(options: RunOptions): Promise<RunSummary> {
818
670
  const totalMs = Number(process.hrtime.bigint() - runStartHrTimeNs) / 1_000_000
819
671
  // Foreground dev mode: between the task frame and the footer, list
820
672
  // the persistent tasks still running (see the keep-alive block below).
821
- if (keepAliveNodes.length > 0) {
822
- for (const line of formatPersistentList(keepAliveNodes, colors)) log.status(line)
673
+ if (keepAlive.nodes.length > 0) {
674
+ for (const line of formatPersistentList(keepAlive.nodes, colors)) log.status(line)
823
675
  }
824
676
  for (const line of formatRunSummary(list, totalMs, colors, runContext)) log.status(line)
825
677
  // A task killed by a shutdown signal is in no bucket above, yet it makes
826
678
  // `ok` false — name it, or the red exit is undiagnosable.
827
679
  for (const line of formatAbortedSection(list)) log.status(line)
680
+ // Judged against the history BEFORE this run's rows land, so the query
681
+ // is one scan over the executed tasks' keys and nothing at all on a run
682
+ // that executed none (every hit, every skip).
683
+ const flaky = detectFlaky(prepared.localCache.dbHandle(), flakyCandidates(list))
684
+ for (const line of formatFlakySection(flaky)) log.status(line)
828
685
  // Outputs that never came home are not an error, but a silent `dist/`
829
686
  // that is empty-or-stale would be: name every task whose bytes are
830
687
  // still remote.
@@ -835,24 +692,6 @@ export async function run(options: RunOptions): Promise<RunSummary> {
835
692
  ` Deferred: ${stillDeferred.length} task(s) left outputs remote (--download=none): ${stillDeferred.join(', ')}`,
836
693
  )
837
694
  }
838
- if (options.verify !== undefined) {
839
- for (const line of formatVerifySection(list)) log.status(line)
840
- // A fingerprint-only run attaches no verdicts, so the verdict-driven
841
- // section above prints nothing — report what actually happened.
842
- if (options.verify.fingerprint && !options.verify.determinism && !options.verify.inputs) {
843
- const n = list.filter((o) => o.outputFp !== undefined).length
844
- log.status('')
845
- log.status(
846
- ` Verify: fingerprinted ${n} task output trees (cross-machine diff via a connected serve)`,
847
- )
848
- // Only EXECUTED tasks fingerprint — a warm all-hit run reports 0.
849
- // A per-platform matrix wired without `--force` produces nothing
850
- // forever, so name the cause instead of a bare 0.
851
- if (n === 0) {
852
- log.status(' (0 executed — cache hits do not fingerprint; pair with --force)')
853
- }
854
- }
855
- }
856
695
 
857
696
  // Optional artifacts. Errors are surfaced to the user but don't
858
697
  // change the run's exit code — the run already happened.
@@ -868,6 +707,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
868
707
  totalMs,
869
708
  ok,
870
709
  outcomes: list,
710
+ flaky,
871
711
  })
872
712
  log.status(`vx: summary written to ${wrote}`)
873
713
  } catch (err) {
@@ -890,119 +730,32 @@ export async function run(options: RunOptions): Promise<RunSummary> {
890
730
  }
891
731
 
892
732
  // Record each task to the run history in a single SQLite transaction
893
- // (one fsync instead of N). Group tasks (no `exec`) are skipped —
894
- // they aren't real runs and the `runs` table is analytics-focused.
895
- // One invocation header row is written alongside, atomically via
896
- // recordRunBundle. The Tier-3 input-fingerprint rows (entry_inputs)
897
- // are NOT built here — they're persisted inside each entry's save
898
- // transaction (miss path only), so a warm all-cache-hit run does no
899
- // extra recording work.
900
- const now = endedAtMs
901
- const toRecord: RunRecord[] = []
902
- // Per-task telemetry mirrors `toRecord` 1:1 — built only when a
903
- // telemetry sink is active, so a no-telemetry run allocates nothing.
904
- const summaryTasks: TaskTelemetry[] = []
905
- let failedCount = 0
906
- let hitLocalCount = 0
907
- let hitRemoteCount = 0
908
- for (const o of list) {
909
- if (isGroupTask(o.node)) continue
910
- // aborted (killed by a shutdown signal) isn't a real run.
911
- if (o.status === 'aborted') continue
912
- if (telemetry !== undefined) {
913
- const t: TaskTelemetry = {
914
- taskId: o.node.id,
915
- project: o.node.projectName,
916
- task: o.node.taskName,
917
- status: o.status,
918
- cacheSource: deriveCacheSource(o.status),
919
- exitCode: o.exitCode,
920
- durationMs: o.durationMs,
921
- }
922
- if (o.hash !== undefined) t.hash = o.hash
923
- if (o.cpuMs !== undefined) t.cpuMs = o.cpuMs
924
- if (o.peakRssBytes !== undefined) t.peakRssBytes = o.peakRssBytes
925
- if (o.where !== undefined) t.where = o.where
926
- if (o.outputs !== undefined) t.outputs = o.outputs
927
- if (o.attempts !== undefined) t.attempts = o.attempts
928
- if (o.verify !== undefined) t.verify = o.verify
929
- if (o.outputFp !== undefined) t.outputFp = o.outputFp
930
- if (o.wallclockStartNs !== undefined) t.wallclockStartNs = o.wallclockStartNs.toString()
931
- if (o.wallclockEndNs !== undefined) t.wallclockEndNs = o.wallclockEndNs.toString()
932
- summaryTasks.push(t)
933
- }
934
- // Every non-group, non-aborted outcome gets a row — the same set
935
- // `tallyOutcomes` counts, so `invocations.task_count` equals both the
936
- // terminal's "N total" and `COUNT(*) FROM runs WHERE run_id = ?`.
937
- // An outcome with NO hash (a `skipped` task never probed the cache; a
938
- // `persistent` one is never cacheable) used to be dropped here because
939
- // `runs.hash` is NOT NULL — which made a failing persistent task record
940
- // `0 tasks, 0 failures` on a run the terminal called red, and a failed
941
- // task with a skipped dependent record 1 of 2. `bindRun` stores `''` for
942
- // those instead; the key-diff readers guard it.
943
- toRecord.push({
944
- ...(o.hash !== undefined ? { hash: o.hash } : {}),
945
- project: o.node.projectName,
946
- task: o.node.taskName,
947
- status: o.status,
948
- exitCode: o.exitCode,
949
- durationMs: o.durationMs,
950
- ...(options.forwardArgs !== undefined ? { forwardArgs: options.forwardArgs } : {}),
951
- // Anchor to the REAL per-task wall-clock window: run-start wall time +
952
- // the task's ns offset (captured for hits and executed tasks alike).
953
- // The `now - duration` fallback applies to outcomes without an offset —
954
- // today only `skipped`, which the scheduler finishes synchronously with
955
- // no span, so it collapses to a zero-width mark at the run's end. Using
956
- // run-end-minus-duration for EVERYTHING was the old bug that piled every
957
- // task at the right edge of the timeline.
958
- startedAt:
959
- o.wallclockStartNs !== undefined
960
- ? endedAtMsAtStart + Math.round(Number(o.wallclockStartNs) / 1e6)
961
- : now - o.durationMs,
962
- endedAt:
963
- o.wallclockEndNs !== undefined
964
- ? endedAtMsAtStart + Math.round(Number(o.wallclockEndNs) / 1e6)
965
- : now,
966
- runId,
967
- ...(o.cpuMs !== undefined ? { cpuMs: o.cpuMs } : {}),
968
- ...(o.peakRssBytes !== undefined ? { peakRssBytes: o.peakRssBytes } : {}),
969
- ...(o.wallclockStartNs !== undefined ? { wallclockStartNs: o.wallclockStartNs } : {}),
970
- ...(o.wallclockEndNs !== undefined ? { wallclockEndNs: o.wallclockEndNs } : {}),
971
- cacheHit: isCacheHit(o.status),
972
- ...(o.attempts !== undefined ? { attempts: o.attempts } : {}),
973
- })
974
- if (o.status === 'failed') failedCount++
975
- if (o.status === 'cache-hit') hitLocalCount++
976
- if (o.status === 'cache-hit-remote') hitRemoteCount++
977
- }
978
- const invocation: InvocationRecord = {
733
+ // (one fsync instead of N), with the invocation header row alongside,
734
+ // atomically via recordRunBundle. The Tier-3 input-fingerprint rows
735
+ // (entry_inputs) are NOT built here — they're persisted inside each
736
+ // entry's save transaction (miss path only), so a warm all-cache-hit
737
+ // run does no extra recording work. The telemetry mirror is built in
738
+ // the same pass, only when a sink is active.
739
+ const records = assembleRunRecords({
740
+ outcomes: list,
979
741
  runId,
742
+ startedAtMs: endedAtMsAtStart,
743
+ endedAtMs,
744
+ totalMs,
745
+ ok,
980
746
  command: options.command ?? process.argv.slice(1).join(' '),
981
- requestedTasks: JSON.stringify([...options.tasks]),
747
+ requestedTasks: options.tasks,
982
748
  cachePolicy: compactCachePolicy(policy),
983
749
  concurrency,
984
750
  flow: options.flow ?? null,
985
- startedAt: endedAtMsAtStart,
986
- endedAt: endedAtMs,
987
- totalDurationMs: Math.round(totalMs),
988
- taskCount: toRecord.length,
989
- failedCount,
990
- hitCount: hitLocalCount + hitRemoteCount,
991
- hitLocalCount,
992
- hitRemoteCount,
993
- exitOk: ok,
994
- commitSha: gitContext.commitSha,
995
- branch: gitContext.branch,
996
- dirty: gitContext.dirty,
997
- ci: ciContext.ci,
998
- ciProvider: ciContext.provider,
999
- host: hostContext.host,
1000
- os: hostContext.os,
1001
- arch: hostContext.arch,
1002
- vxVersion: VERSION,
1003
- tags: JSON.stringify(options.tags ?? {}),
1004
- }
1005
- cache.recordRunBundle({ runs: toRecord, invocation })
751
+ forwardArgs: options.forwardArgs,
752
+ tags: options.tags ?? {},
753
+ git: gitContext,
754
+ ci: ciContext,
755
+ host: hostContext,
756
+ withTelemetry: telemetry !== undefined,
757
+ })
758
+ cache.recordRunBundle(records)
1006
759
  mark('record history')
1007
760
  // Hand the per-run summary to the telemetry sinks + drain them. Only
1008
761
  // when a sink is active (telemetry !== undefined) — otherwise this
@@ -1010,7 +763,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
1010
763
  // emitSummary/flush are crash-isolated, so a faulty sink can't fail
1011
764
  // the run; flush is the sink's last chance to ship buffered records.
1012
765
  if (telemetry !== undefined && runContextRecord !== undefined) {
1013
- const summary = assembleRunSummary(runContextRecord, summaryTasks, {
766
+ const summary = assembleRunSummary(runContextRecord, records.telemetryTasks, {
1014
767
  startedAt: endedAtMsAtStart,
1015
768
  endedAt: endedAtMs,
1016
769
  totalDurationMs: Math.round(totalMs),
@@ -1039,7 +792,24 @@ export async function run(options: RunOptions): Promise<RunSummary> {
1039
792
  // write with nothing but a warning. The seam's contract is that a
1040
793
  // cache layer stays usable until the run's uploads have settled.
1041
794
  await prefetchDone
795
+ // Every deferred save settles first: the upload drain below carries
796
+ // the entries the saves queued, and the snapshot loop after it reads
797
+ // what the saves pushed.
798
+ await saveLane.drain()
799
+ mark('save lane')
1042
800
  await cache.drainUploads?.()
801
+ // The miss path's output-directory snapshots, taken now that the
802
+ // directories are old enough for the snapshot's racy window (see
803
+ // miss-save.ts). A few at a time: each is an lstat + readdir per
804
+ // prefix and one index transaction.
805
+ for (let i = 0; i < outputDirSnapshots.length; i += 32) {
806
+ await Promise.all(
807
+ outputDirSnapshots
808
+ .slice(i, i + 32)
809
+ .map((s) => cache.recordOutputDirs?.(s.hash, s.projectDir, s.prefixes)),
810
+ )
811
+ }
812
+ mark('output dir snapshots')
1043
813
  await teardownPlugins(prepared.plugins, (m) => log.status(m))
1044
814
  closeCache()
1045
815
  mark('close')
@@ -1048,7 +818,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
1048
818
  // Tear down SRT's network bridge + (on macOS) log monitor. No-op if
1049
819
  // no task was sandboxed; otherwise SRT keeps proxy servers alive and
1050
820
  // the next vx run would init on top of stale state.
1051
- if (anySandboxed) {
821
+ if (sandboxArmer?.armed) {
1052
822
  try {
1053
823
  await resetSandbox()
1054
824
  } catch (err) {
@@ -1061,12 +831,30 @@ export async function run(options: RunOptions): Promise<RunSummary> {
1061
831
  // persistent task (dev server / watcher). The run is "done" in every
1062
832
  // bookkeeping sense — summary printed, history recorded — but the
1063
833
  // server is still up and that's the point. Stay in the foreground
1064
- // until it exits: Ctrl-C hits the whole process group (the server
1065
- // dies; our SIGINT handler also exits 130), and a crash resolves the
1066
- // wait so the run returns. Nothing here prints — the UI is unchanged.
1067
- if (keepAlive.length > 0) {
1068
- await Promise.allSettled(keepAlive.map((c) => c.exited))
1069
- for (const child of keepAlive) child.kill('SIGTERM')
834
+ // until ONE of them exits: Ctrl-C hits the whole process group (the
835
+ // server dies; our SIGINT handler also exits 130), and a crash ends
836
+ // the session — the others are torn down (SIGTERM, grace, SIGKILL)
837
+ // and a non-zero exit makes the run not ok, so `vx run dev` in a
838
+ // script fails when the server it started fell over. Until
839
+ // 2026-09-10 this waited for EVERY server, so the SIGTERM after it
840
+ // was dead code and a crashed server left the rest running under a
841
+ // run that never returned. One status line names the server that
842
+ // ended the session and its code — the summary above said `success`
843
+ // for it, and an exit 1 with no word about why is a mystery in a log.
844
+ if (keepAlive.children.length > 0) {
845
+ const first = await Promise.race(
846
+ keepAlive.children.map((c, i) => c.exited.then((code) => ({ code, i }))),
847
+ )
848
+ const node = keepAlive.nodes[first.i]!
849
+ const others = keepAlive.nodes.length - 1
850
+ log.status(
851
+ `vx: ${node.id} exited with code ${first.code}` +
852
+ (others > 0
853
+ ? `; stopping ${others} other persistent task${others === 1 ? '' : 's'}`
854
+ : ''),
855
+ )
856
+ await terminateChildren(() => keepAlive.children)
857
+ return { ok: ok && first.code === 0, outcomes: list }
1070
858
  }
1071
859
 
1072
860
  return { ok, outcomes: list }
@@ -1074,8 +862,8 @@ export async function run(options: RunOptions): Promise<RunSummary> {
1074
862
  // Idempotent; also reached on mid-run throws, so a crashed cycle
1075
863
  // can't leave a live status-line ticker behind.
1076
864
  log.runEnd?.()
1077
- process.off('SIGINT', onSigint)
1078
- process.off('SIGTERM', onSigterm)
865
+ signals.remove()
866
+ options.signal?.removeEventListener('abort', onAbort)
1079
867
  // Plugins installed at the top of run() get their bus subscriptions
1080
868
  // released here. Idempotent; safe even if installPlugins threw.
1081
869
  disposePlugins?.()
@@ -1140,172 +928,23 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1140
928
  })
1141
929
  } finally {
1142
930
  prepared.cache.close()
931
+ // The same table a run prints: a dry run is how the prepare stages
932
+ // (discovery, config load, the git enumeration) get profiled on a
933
+ // real repo that has no install and no cache to run against.
934
+ mark('close')
935
+ printTimings()
1143
936
  }
1144
937
  }
1145
938
 
1146
939
  /**
1147
- * A task is pinned to this machine when it is persistent, transitively
1148
- * depends on a persistent task (a worker cannot reach a port on the
1149
- * submitter), or declares `exec.remote: false`.
1150
- */
1151
- function pinnedLocalSet(nodes: Map<string, TaskNode>): Set<string> {
1152
- const pinned = new Set<string>()
1153
- const memo = new Map<string, boolean>()
1154
- const visit = (id: string): boolean => {
1155
- const known = memo.get(id)
1156
- if (known !== undefined) return known
1157
- const node = nodes.get(id)
1158
- if (node === undefined) return false
1159
- memo.set(id, false) // cycle guard; the graph builder already rejects cycles
1160
- const result =
1161
- node.config.exec?.persistent !== undefined ||
1162
- node.config.exec?.remote === false ||
1163
- node.deps.some((d) => visit(d))
1164
- memo.set(id, result)
1165
- if (result) pinned.add(id)
1166
- return result
1167
- }
1168
- for (const id of nodes.keys()) visit(id)
1169
- return pinned
1170
- }
1171
-
1172
- interface Placements {
1173
- executors: Map<string, TaskExecutor>
1174
- /**
1175
- * `exec.remote: 'only'` tasks that no REMOTE executor took — a NO-OP on
1176
- * this machine: never executed, declared outputs never cleaned or
1177
- * restored. The task exists to produce a remote input tree; without a
1178
- * remote pool, dependents use the machine's ambient state exactly as they
1179
- * did before the field existed.
1180
- */
1181
- remoteOnlyNoop: Set<string>
1182
- /** `'only'` tasks a remote executor DID take — executed remotely, outputs stay remote. */
1183
- remoteOnly: Set<string>
1184
- }
1185
-
1186
- function placeTasks(
1187
- nodes: Map<string, TaskNode>,
1188
- executors: readonly TaskExecutor[],
1189
- pinAllLocal = false,
1190
- ): Placements {
1191
- const pinned = pinnedLocalSet(nodes)
1192
- const placements: Placements = {
1193
- executors: new Map(),
1194
- remoteOnlyNoop: new Set(),
1195
- remoteOnly: new Set(),
1196
- }
1197
- for (const node of nodes.values()) {
1198
- if (isGroupTask(node) || node.config.exec?.persistent !== undefined) continue
1199
- const executor = selectExecutor(executors, {
1200
- taskId: node.id,
1201
- projectName: node.projectName,
1202
- projectDir: node.projectDir,
1203
- command: node.config.exec!.command,
1204
- pinnedLocal: pinAllLocal || pinned.has(node.id),
1205
- cacheable: node.config.cache !== undefined,
1206
- ...(node.config.exec?.resources === undefined
1207
- ? {}
1208
- : { resources: node.config.exec.resources }),
1209
- })
1210
- placements.executors.set(node.id, executor)
1211
- if (node.config.exec?.remote === 'only') {
1212
- // A pinned 'only' task (it transitively depends on a persistent one)
1213
- // lands here too: pinning wins, so it noops rather than shipping.
1214
- if (executor.remote === true) placements.remoteOnly.add(node.id)
1215
- else placements.remoteOnlyNoop.add(node.id)
1216
- }
1217
- }
1218
- return placements
1219
- }
1220
-
1221
- /**
1222
- * `executorOf` for `planRun`, or nothing. Declining plugins, a single
1223
- * executor, or a resolution error all yield nothing: `--dry` is an
1224
- * inspection command and must not fail over a label.
1225
- */
1226
- async function planExecutorOf(
1227
- prepared: Awaited<ReturnType<typeof prepareRun>>,
1228
- log: Logger,
1229
- policy: 'all' | 'toplevel' | 'none',
1230
- ): Promise<{
1231
- executorOf?: (id: string) => string | undefined
1232
- downloadOf?: (id: string) => 'eager' | 'deferred' | 'never' | undefined
1233
- downloadDowngrades?: ReadonlyArray<{ taskId: string; reason: string }>
1234
- }> {
1235
- let executors: readonly TaskExecutor[]
1236
- try {
1237
- executors = await resolveExecutors(prepared.plugins, {
1238
- workspaceRoot: prepared.workspaceRoot,
1239
- cacheDir: prepared.cacheDir,
1240
- warn: (m: string) => log.status(m),
1241
- concurrency: Math.max(1, navigator.hardwareConcurrency),
1242
- })
1243
- } catch {
1244
- return {}
1245
- }
1246
- const placements = placeTasks(prepared.nodes, executors)
1247
- // Download modes need placement regardless of how many executors there
1248
- // are (a single REMOTE one still defers); executor LABELS only earn their
1249
- // column when there is a choice to report.
1250
- const download =
1251
- policy === 'all'
1252
- ? undefined
1253
- : resolveDownloadModes({
1254
- nodes: prepared.nodes,
1255
- policy,
1256
- localPlaced: new Set(
1257
- [...prepared.nodes.keys()].filter(
1258
- (id) => placements.executors.get(id)?.remote !== true,
1259
- ),
1260
- ),
1261
- remoteOnly: placements.remoteOnly,
1262
- })
1263
- return {
1264
- ...(executors.length < 2
1265
- ? {}
1266
- : {
1267
- executorOf: (id: string) =>
1268
- placements.remoteOnlyNoop.has(id) ? 'noop' : placements.executors.get(id)?.name,
1269
- }),
1270
- ...(download === undefined
1271
- ? {}
1272
- : {
1273
- downloadOf: (id: string) => download.modeOf.get(id),
1274
- downloadDowngrades: [...download.downgrades].map(([taskId, reason]) => ({
1275
- taskId,
1276
- reason,
1277
- })),
1278
- }),
1279
- }
1280
- }
1281
-
1282
- /**
1283
- * Stands in for the executor of a task that was never placed — a group task
1284
- * (runs nothing) or a persistent one (`executePersistentTask` owns it and
1285
- * never reads this field). It THROWS rather than silently picking some
1286
- * executor from the list, so a refactor that routes such a task through the
1287
- * exec path fails loudly instead of shipping a localhost server to a worker.
940
+ * The first-run case, told apart from a typo: no package in the workspace
941
+ * has a `vx.config.*` at all, so no name could have resolved. Reached only
942
+ * on the error path.
1288
943
  */
1289
- const UNPLACED_EXECUTOR: TaskExecutor = {
1290
- name: 'unplaced',
1291
- execute: (req) => {
1292
- throw new Error(`internal error: ${req.taskId} reached an executor without being placed`)
1293
- },
1294
- }
1295
-
1296
- function hasPooledExecutor(executors: readonly TaskExecutor[]): boolean {
1297
- return executors.some((e) => e.capacity !== undefined)
1298
- }
1299
-
1300
- function poolOfPlacement(
1301
- placements: Placements,
1302
- ): (id: string) => { name: string; capacity: number } | undefined {
1303
- return (id) => {
1304
- const executor = placements.executors.get(id)
1305
- return executor?.capacity === undefined
1306
- ? undefined
1307
- : { name: executor.name, capacity: executor.capacity }
1308
- }
944
+ function initHint(prepared: { anyProjectConfig: boolean }): string {
945
+ return prepared.anyProjectConfig
946
+ ? ''
947
+ : ' No package declares a vx.config — run `vx init` to write one per package from its package.json scripts.'
1309
948
  }
1310
949
 
1311
950
  /**
@@ -1319,37 +958,47 @@ function didYouMean(
1319
958
  unresolved: readonly string[],
1320
959
  projects: ReadonlyMap<string, ProjectEntry>,
1321
960
  ): string {
1322
- const nearest = (name: string, candidates: Iterable<string>): string | undefined => {
1323
- let best: string | undefined
1324
- let bestD = 3
1325
- for (const c of candidates) {
1326
- const d = editDistance(name, c)
1327
- if (d < bestD) {
1328
- bestD = d
1329
- best = c
1330
- }
1331
- }
1332
- return best === name ? undefined : best
1333
- }
1334
961
  const tasksOf = (p: ProjectEntry | undefined): string[] => Object.keys(p?.config.tasks ?? {})
1335
962
  const allTasks = new Set<string>()
1336
963
  for (const p of projects.values()) for (const t of tasksOf(p)) allTasks.add(t)
1337
- const hints: string[] = []
964
+ // A Set: two typos of the same task hint it once, not once per typo.
965
+ const hints = new Set<string>()
1338
966
  for (const spec of unresolved) {
1339
967
  const at = spec.indexOf('#')
1340
968
  if (at < 0) {
1341
969
  const t = nearest(spec, allTasks)
1342
- if (t !== undefined) hints.push(t)
970
+ if (t !== undefined) hints.add(t)
1343
971
  continue
1344
972
  }
1345
973
  const [proj, task] = [spec.slice(0, at), spec.slice(at + 1)]
1346
974
  if (!projects.has(proj)) {
1347
975
  const p = nearest(proj, projects.keys())
1348
- if (p !== undefined && tasksOf(projects.get(p)).includes(task)) hints.push(`${p}#${task}`)
976
+ if (p !== undefined && tasksOf(projects.get(p)).includes(task)) hints.add(`${p}#${task}`)
1349
977
  continue
1350
978
  }
1351
979
  const t = nearest(task, tasksOf(projects.get(proj)))
1352
- if (t !== undefined) hints.push(`${proj}#${t}`)
980
+ if (t !== undefined) hints.add(`${proj}#${t}`)
981
+ }
982
+ return hints.size === 0 ? '' : ` Did you mean ${[...hints].join(', ')}?`
983
+ }
984
+
985
+ /** The executed, keyed outcomes of a run — what flakiness is judged on. */
986
+ function flakyCandidates(outcomes: readonly TaskOutcome[]): FlakyCandidate[] {
987
+ const out: FlakyCandidate[] = []
988
+ for (const o of outcomes) {
989
+ if (o.status !== 'success' && o.status !== 'failed') continue
990
+ // "Same inputs, different outcome" is a claim only a task with declared
991
+ // inputs can make: a task with no `cache` block keys on its config alone
992
+ // and runs every time, so one bad network day would read as a flake for
993
+ // thirty days. Groups do no work.
994
+ if (o.hash === undefined || o.node.config.cache === undefined || isGroupTask(o.node)) continue
995
+ out.push({
996
+ project: o.node.projectName,
997
+ task: o.node.taskName,
998
+ hash: o.hash,
999
+ status: o.status,
1000
+ attempts: o.attempts ?? 1,
1001
+ })
1353
1002
  }
1354
- return hints.length === 0 ? '' : ` Did you mean ${hints.join(', ')}?`
1003
+ return out
1355
1004
  }