@vzn/vx 0.0.18 → 0.0.22

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 (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
@@ -3,19 +3,14 @@
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 { loadWorkspace, unreachedHint, unreachedPackages } from '../workspace/index.js'
7
+ import path from 'node:path'
8
+ import { type CacheLayer, type CachePolicy, FULL_CACHE_POLICY } from '../cache/index.js'
14
9
  import { VERSION } from '../version.js'
15
- import { initSandbox, probeSandbox, resetSandbox, signalExitCode } from '../exec/index.js'
10
+ import { resetSandbox, VX_RUN_TASK_ENV, VX_RUN_WORKSPACE_ENV } from '../exec/index.js'
16
11
  import { DeferredOutputs } from './deferred-outputs.js'
17
12
  import { resolveDownloadModes } from './download-policy.js'
18
- import { selectExecutor, type TaskExecutor } from '../exec/index.js'
13
+ import type { TaskExecutor } from '../exec/index.js'
19
14
  import {
20
15
  isGroupTask,
21
16
  markSurfacedDeps,
@@ -23,22 +18,53 @@ import {
23
18
  type TaskNode,
24
19
  type TaskOutcome,
25
20
  } 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'
30
- import { busLogger, createEventBus, terminalSubscriber } from './events.js'
21
+ import {
22
+ formatBytes,
23
+ mark,
24
+ killGraceMs,
25
+ MAX_TIMEOUT_MS,
26
+ parseDuration,
27
+ parseSize,
28
+ printTimings,
29
+ ulid,
30
+ nearest,
31
+ UserError,
32
+ machineParallelism,
33
+ teardownTimeoutMs,
34
+ } from '../util/index.js'
35
+ import { keyedProjects } from './keyed-projects.js'
36
+ import { prepareSandbox } from './sandbox-request.js'
37
+ import type { OutputDirSnapshot } from './miss-save.js'
38
+ import { admitTasks, taintTracker } from './admission.js'
39
+ import { excludedTaint } from './excluded-keys.js'
40
+ import { keyUpstream } from './upstream.js'
41
+ import { busLogger, createEventBus, terminalSubscriber, type EventBus } from './events.js'
31
42
  import { installPlugins } from './plugin.js'
32
- import { resolveExecutors, teardownPlugins } from './plugin-host.js'
43
+ import { buildAdmission, resolveExecutors, teardownPlugins } from './plugin-host.js'
33
44
  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'
45
+ import { assembleRunSummary, isPassStatus } from './telemetry.js'
46
+ import type { RunContextRecord } from './telemetry.js'
36
47
  import { defaultLogger, resolveOutputView, type Logger } from './logger.js'
37
- import { detectColors } from './colors.js'
48
+ import { detectColors, type ColorSupport } from './colors.js'
38
49
  import { formatPersistentList } from './framed-output.js'
39
50
  import { LocalHistoryProvider } from './history.js'
40
51
  import { plan, type RunPlan } from './plan.js'
41
- import { prepareRun } from './prepare.js'
52
+ import { prepareRun, type PreparedRun } from './prepare.js'
53
+ import { acquireRunLock } from './run-lock.js'
54
+ import {
55
+ forwardedSignal,
56
+ forwardSignals,
57
+ SIGNAL_SHUTDOWN_GRACE_MS,
58
+ terminateChildren,
59
+ } from './signals.js'
60
+ import {
61
+ hasPooledExecutor,
62
+ placeTasks,
63
+ planExecutorOf,
64
+ poolOfPlacement,
65
+ UNPLACED_EXECUTOR,
66
+ locallyPlaced,
67
+ } from './placement.js'
42
68
  import {
43
69
  captureDefaultBranch,
44
70
  captureGitContext,
@@ -48,21 +74,24 @@ import {
48
74
  } from './run-context.js'
49
75
  import { startRemotePrefetch } from './remote-prefetch.js'
50
76
  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
77
 
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
78
+ import { assembleRunRecords } from './run-records.js'
79
+ import { hasEnded, selectKeepAlive, shutdownPersistent } from './persistent.js'
62
80
  import { writeRunProfile, writeRunSummary } from './run-artifacts.js'
63
- import { formatAbortedSection, formatRunSummary } from './summary.js'
81
+ import { createSaveLane } from './save-lane.js'
82
+ import {
83
+ formatAbortedSection,
84
+ formatFlakySection,
85
+ formatRunSummary,
86
+ formatSkippedSection,
87
+ } from './summary.js'
88
+ import { detectFlaky, type FlakyCandidate } from './failure-mode.js'
64
89
  import type { RunOptions, RunSummary } from './options.js'
65
90
 
91
+ // Per run, never shared: a `vx watch` process runs many, and a shared map
92
+ // is one `preProbed.set` away from leaking a hit across cycles.
93
+ const emptyShortCircuit = (): ShortCircuit => ({ preProbed: new Map(), restoreTier: new Set() })
94
+
66
95
  /**
67
96
  * Parse the `VX_TASK_TIMEOUT` env var (ms) — the "global" run-level task
68
97
  * timeout default. A missing/empty/non-positive-integer value yields
@@ -159,15 +188,49 @@ export async function run(options: RunOptions): Promise<RunSummary> {
159
188
  // additional subscribers. The fan-out is synchronous and order-
160
189
  // preserving, so terminal output is byte-identical to a direct call.
161
190
  // See docs/design/event-stream-2026-06.md.
162
- const sink = options.log ?? defaultLogger(colors, resolveOutputView(options))
163
- // An injected bus (e.g. from `--ui`) already has surfaces subscribed;
164
- // we just add the terminal renderer. Otherwise a fresh internal bus.
191
+ const terminal =
192
+ options.log === undefined
193
+ ? defaultLogger(colors, resolveOutputView(options), process.stdout, { coalesce: true })
194
+ : null
195
+ const sink = options.log ?? terminal!
196
+ // An injected bus already has surfaces subscribed; we add the terminal
197
+ // renderer for this run and take it off again on every way out: the bus
198
+ // outlives the run, and a renderer left behind reported the next run on
199
+ // it twice (item 635). Otherwise a fresh internal bus.
165
200
  const bus = options.bus ?? createEventBus()
166
- bus.subscribe(terminalSubscriber(sink))
201
+ const unsubscribeTerminal = bus.subscribe(terminalSubscriber(sink))
202
+ try {
203
+ return await runOnBus(options, bus, colors, () => terminal?.failureRecap() ?? [])
204
+ } finally {
205
+ unsubscribeTerminal()
206
+ terminal?.settle()
207
+ }
208
+ }
209
+
210
+ async function runOnBus(
211
+ options: RunOptions,
212
+ bus: EventBus,
213
+ colors: ColorSupport,
214
+ failureRecap: () => string[],
215
+ ): Promise<RunSummary> {
167
216
  const log = busLogger(bus)
168
217
 
169
218
  const prepared = await prepareRun(options, log)
170
- mark('prepare (graph)')
219
+ mark('plugin stages')
220
+ // A task whose command re-enters `vx run` in the workspace running it
221
+ // is refused. When the inner run reaches this task again it forks a run
222
+ // per run until the machine gives out; when it does not (`ci` shelling
223
+ // out to `vx run lint`) it is a nested run the outer graph cannot see —
224
+ // its tasks escape the schedule, the concurrency budget and this task's
225
+ // cache key. The markers `taskEnv` sets on every child (exec/env.ts)
226
+ // name the task; the check is on the root so both shapes are caught.
227
+ const outerRoot = process.env[VX_RUN_WORKSPACE_ENV]
228
+ if (outerRoot !== undefined && path.resolve(outerRoot) === path.resolve(prepared.workspaceRoot)) {
229
+ prepared.cache.close()
230
+ throw new UserError(
231
+ `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.`,
232
+ )
233
+ }
171
234
  // A requested name that matched no project is a typo (or a stray
172
235
  // positional from an `=`-only flag written with a space). Failing the
173
236
  // whole run — even when OTHER requested tasks resolved — is the point:
@@ -176,7 +239,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
176
239
  // case too; the message is identical, so that branch stays below.
177
240
  if (prepared.unresolvedTasks.length > 0) {
178
241
  log.status(
179
- `No projects declare task(s): ${prepared.unresolvedTasks.join(', ')}.${didYouMean(prepared.unresolvedTasks, prepared.projects)}`,
242
+ `No projects declare task(s): ${prepared.unresolvedTasks.join(', ')}.${didYouMean(prepared.unresolvedTasks, prepared.projects)}${await initHint(prepared)}`,
180
243
  )
181
244
  prepared.cache.close()
182
245
  return { ok: false, outcomes: [] }
@@ -188,7 +251,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
188
251
  // buildTaskGraph semantics but logged just in case.
189
252
  const msg =
190
253
  prepared.empty === 'no-tasks-declared'
191
- ? `No projects declare task(s): ${options.tasks.join(', ')}.`
254
+ ? `No projects declare task(s): ${options.tasks.join(', ')}.${await initHint(prepared)}`
192
255
  : 'No tasks to run.'
193
256
  log.status(msg)
194
257
  prepared.cache.close()
@@ -197,8 +260,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
197
260
  // Install user plugins as additional bus subscribers BEFORE the run
198
261
  // starts emitting events. `installPlugins` runs each plugin's optional
199
262
  // `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.
263
+ // the plugin. A plugin without `setup` subscribes nothing.
202
264
  let disposePlugins: (() => void) | undefined
203
265
  let telemetry: TelemetryHandle | undefined
204
266
  try {
@@ -221,19 +283,16 @@ export async function run(options: RunOptions): Promise<RunSummary> {
221
283
  cache,
222
284
  nodes,
223
285
  workspaceFingerprint,
286
+ fingerprintWatch,
224
287
  nestedDirsByProject,
225
288
  gitFilesCache,
226
289
  hashCache,
227
290
  workspaceProjectCount,
228
291
  } = prepared
229
- const concurrency =
230
- options.concurrency ??
231
- workspaceConfig?.concurrency ??
232
- Math.max(1, navigator.hardwareConcurrency)
292
+ const concurrency = options.concurrency ?? workspaceConfig?.concurrency ?? machineParallelism()
233
293
 
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.
294
+ // Resolved ONCE per run, in declaration order, the local executor last.
295
+ // A broken factory aborts here, before any task starts.
237
296
  let executors: readonly TaskExecutor[]
238
297
  try {
239
298
  executors = await resolveExecutors(prepared.plugins, {
@@ -251,13 +310,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
251
310
  // can admit a remote-pooled task against its pool instead of a local
252
311
  // worker slot. Group tasks run nothing; persistent tasks never reach an
253
312
  // 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)
313
+ const placements = placeTasks(nodes, executors)
261
314
  // A `remote: 'only'` task nobody takes succeeds WITHOUT running. That is
262
315
  // deliberate — on a machine with no remote pool the ambient state already
263
316
  // is what the task would have produced — but it must not be SILENT: a task
@@ -300,27 +353,9 @@ export async function run(options: RunOptions): Promise<RunSummary> {
300
353
  }
301
354
 
302
355
  // `--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
- }
321
- const localPlaced = new Set(
322
- [...nodes.keys()].filter((id) => placements.executors.get(id)?.remote !== true),
323
- )
356
+ // remote execution's outputs come home.
357
+ const downloadPolicy = options.download ?? 'all'
358
+ const localPlaced = locallyPlaced(placements, nodes.keys())
324
359
  const download = resolveDownloadModes({
325
360
  nodes,
326
361
  policy: downloadPolicy,
@@ -344,39 +379,59 @@ export async function run(options: RunOptions): Promise<RunSummary> {
344
379
  // exit; ownership moves here so the orchestrator can SIGTERM
345
380
  // them once the rest of the graph finishes.
346
381
  //
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.
382
+ // A SIGINT/SIGTERM mid-run forwards that signal to everything in both
383
+ // (`signals.ts`); the handlers are removed in the finally below so
384
+ // repeated run() calls (test suites) never stack listeners.
354
385
  const liveChildren = new Set<ReturnType<typeof Bun.spawn>>()
355
386
  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)
387
+ // One stop for the run: an embedder's `RunOptions.signal` and a process
388
+ // signal both abort it. The scheduler stops dispatching (it reads the
389
+ // signal), the children are torn down, and run() returns through its own
390
+ // end-of-run path. Detached in the finally below.
391
+ const stopRun = new AbortController()
392
+ const forwardAbort = (): void => stopRun.abort(options.signal?.reason)
393
+ if (options.signal?.aborted === true) forwardAbort()
394
+ else options.signal?.addEventListener('abort', forwardAbort, { once: true })
395
+ // A run a process signal stopped records no history: until item 849 the
396
+ // handler exited before any outcome landed, and a Ctrl-C'd run then read
397
+ // in `vx last` as FAILED with 0 tasks (item 854). An embedder's abort is
398
+ // its own call and keeps its record.
399
+ let stoppedBySignal = false
400
+ let leftRun = (): void => {}
401
+ const runLeft = new Promise<void>((resolve) => {
402
+ leftRun = resolve
403
+ })
404
+ const signals = forwardSignals({
405
+ enabled: options.handleSignals ?? true,
406
+ log,
407
+ cache,
408
+ liveChildren,
409
+ persistentRegistry,
410
+ stop: (signal) => {
411
+ stoppedBySignal = true
412
+ stopRun.abort(signal)
413
+ },
414
+ done: runLeft,
415
+ // The grace, then one flush and each teardown at their own bound, and
416
+ // slack for the summary and the cache close.
417
+ boundMs:
418
+ killGraceMs(SIGNAL_SHUTDOWN_GRACE_MS) +
419
+ teardownTimeoutMs() * (1 + prepared.plugins.filter((p) => p.teardown).length) +
420
+ 2_000,
421
+ })
422
+ // Awaited before run() leaves: a task's shell can die of the signal at
423
+ // once while a child it backgrounded ignores it and dies only to the
424
+ // SIGKILL after the grace, and a run that returned first left that child
425
+ // running past vx's exit (turborepo#14043's class).
426
+ let aborting: Promise<void> | undefined
427
+ const onAbort = (): void => {
428
+ aborting = terminateChildren(
429
+ () => [...liveChildren, ...persistentRegistry.values()],
430
+ forwardedSignal(stopRun.signal.reason),
431
+ )
379
432
  }
433
+ if (stopRun.signal.aborted) onAbort()
434
+ else stopRun.signal.addEventListener('abort', onAbort, { once: true })
380
435
  // The cache handle must be released on EVERY exit path, not just the
381
436
  // happy one: `close()` is also where the run's deferred `accessed_at`
382
437
  // bumps are flushed, so a throw between opening the cache and the
@@ -387,11 +442,25 @@ export async function run(options: RunOptions): Promise<RunSummary> {
387
442
  // holding the handle open for a dev server's whole lifetime would be
388
443
  // worse — and because close() re-runs its retention DELETEs.
389
444
  let cacheClosed = false
390
- const closeCache = (): void => {
445
+ const closeCache = async (): Promise<void> => {
391
446
  if (cacheClosed) return
392
447
  cacheClosed = true
393
448
  cache.close()
449
+ // Released with the handle: before a persistent task's wait, on every
450
+ // exit path (see run-lock.ts for why a run holds one at all). AWAITED:
451
+ // fired and forgotten, the release lost the race with the CLI's exit —
452
+ // `bin.ts` ends the process as soon as run() resolves — and every CLI
453
+ // run left its lock directory in the temp dir (658 of them after one
454
+ // day's gates, 2026-09-16); the next run reclaimed it, so nothing
455
+ // waited, but a directory per run is litter the box never sheds.
456
+ await releaseRunLock()
394
457
  }
458
+ // Taken after the early exits above (nothing they do touches a tree) and
459
+ // before the schedule: from here on tasks clean, restore and write.
460
+ const releaseRunLock = await acquireRunLock(prepared.workspaceRoot, {
461
+ log: (m) => log.status(m),
462
+ signal: stopRun.signal,
463
+ })
395
464
  try {
396
465
  // One run-id per `vx run` invocation. Every task in the resulting
397
466
  // graph carries it so analytics queries can group by invocation.
@@ -399,17 +468,20 @@ export async function run(options: RunOptions): Promise<RunSummary> {
399
468
  const runStartHrTimeNs = process.hrtime.bigint()
400
469
  const endedAtMsAtStart = Date.now()
401
470
  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).
471
+ // Normalised ONCE so every consumer — execute-task, the dedup
472
+ // predicate, the recorded invocation row — reads the policy that
473
+ // actually governed the run. Reading the raw request here made tasks
474
+ // clean their outputs before every exec for a save that never
475
+ // happened.
409
476
  const policy: CachePolicy = effectiveCachePolicy(
410
477
  options.cache ?? FULL_CACHE_POLICY,
411
478
  prepared.hasRemoteLayer,
412
479
  )
480
+ if (options.remoteRequested === true && !prepared.hasRemoteLayer) {
481
+ log.status(
482
+ 'vx: --cache named the remote cache, but no cache plugin in vx.workspace.* supplies one — the remote axes are off for this run',
483
+ )
484
+ }
413
485
  const deferredOutputs = new DeferredOutputs({
414
486
  nodes,
415
487
  cache,
@@ -419,29 +491,6 @@ export async function run(options: RunOptions): Promise<RunSummary> {
419
491
  localWrite: policy.localWrite,
420
492
  })
421
493
 
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
494
  // Per-run context for the Tier-3 `invocations` header row. Captured
446
495
  // ONCE (git is ONE spawn for commit+branch, behind try/catch; never
447
496
  // fails a run). `dirty` reuses the `git status --porcelain` the
@@ -501,26 +550,16 @@ export async function run(options: RunOptions): Promise<RunSummary> {
501
550
  )
502
551
  }
503
552
 
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
- }
553
+ const sandboxArmer = prepareSandbox(nodes.values())
554
+ const keyed = keyedProjects(nodes)
555
+ const outputDirSnapshots: OutputDirSnapshot[] = []
556
+ // Saves run off the execution slot, twice the cap at once (memory:
557
+ // each pack holds an artifact's bytes); a failed save is a miss next
558
+ // time, said once — the task's work ran.
559
+ const saveLane = createSaveLane(2 * concurrency, (err) =>
560
+ log.status(`[vx] cache save failed: ${err instanceof Error ? err.message : String(err)}`),
561
+ )
562
+ const deferredSaves = new Map<string, Promise<void>>()
524
563
 
525
564
  // Focused flow: a requested GROUP has no output of its own, so
526
565
  // surface the same-project, non-group tasks it chains (one level)
@@ -544,16 +583,10 @@ export async function run(options: RunOptions): Promise<RunSummary> {
544
583
  if (node.requested || node.surfaced === true) requestedCount++
545
584
  }
546
585
  }
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)
586
+ // Admission over the worker count is a plugin's (`admit`): built once
587
+ // into the predicate the scheduler asks at every local dispatch. No
588
+ // plugin answers → undefined → the count-only path, byte for byte.
589
+ const admit = buildAdmission(prepared.plugins, nodes, concurrency, (m) => log.status(m))
557
590
 
558
591
  // Run context for the footer. The top-of-run header is gone — the
559
592
  // banner now lives in the summary, where the eye lands at the end.
@@ -563,7 +596,6 @@ export async function run(options: RunOptions): Promise<RunSummary> {
563
596
  remoteCacheEnabled,
564
597
  concurrency,
565
598
  workspaceProjectCount,
566
- ...(resourceCosts.size > 0 ? { cpuBudget: concurrency, memBudget } : {}),
567
599
  }
568
600
 
569
601
  // Lifecycle hooks drive the default logger's dynamic status line
@@ -607,7 +639,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
607
639
  // each probe, so there is no second cache.get. Gated by
608
640
  // shouldShortCircuit (local reads on, no remote layer); when off, both
609
641
  // maps are empty and the run is byte-identical.
610
- let shortCircuit: ShortCircuit = EMPTY_SHORT_CIRCUIT
642
+ let shortCircuit: ShortCircuit = emptyShortCircuit()
611
643
  if (shouldShortCircuit(nodes, policy, cache)) {
612
644
  shortCircuit = await startLocalShortCircuit({
613
645
  nodes,
@@ -622,8 +654,23 @@ export async function run(options: RunOptions): Promise<RunSummary> {
622
654
  })
623
655
  }
624
656
 
657
+ // Whether this task runs behind a failure (`continueMode: 'always'`
658
+ // only) or on the key of a dependency `--exclude-dependencies` kept
659
+ // from running — its save is withheld and the taint propagates; see
660
+ // admission.ts.
661
+ const excluded = excludedTaint(nodes)
662
+ if (excluded.unsaved > 0) {
663
+ log.status(
664
+ `[vx] --exclude-dependencies: ${excluded.unsaved} cached task(s) build on a skipped dependency; what they build is not saved`,
665
+ )
666
+ }
667
+ const isTainted = taintTracker(options.continueMode === 'always', excluded.seeds)
668
+ const dependedOn = new Set<string>()
669
+ for (const n of nodes.values()) for (const d of n.deps) dependedOn.add(d)
670
+
625
671
  const buildExecuteArgs = (node: TaskNode, upstream: TaskOutcome[], reuseProbe = true) => {
626
672
  const probe = reuseProbe ? shortCircuit.preProbed.get(node.id) : undefined
673
+ const taint = isTainted(node, upstream)
627
674
  return {
628
675
  node,
629
676
  upstream,
@@ -634,7 +681,6 @@ export async function run(options: RunOptions): Promise<RunSummary> {
634
681
  forwardArgs: options.forwardArgs,
635
682
  ...(options.retries !== undefined ? { retries: options.retries } : {}),
636
683
  ...(taskTimeoutDefault !== undefined ? { timeout: taskTimeoutDefault } : {}),
637
- ...(options.verify !== undefined ? { verify: options.verify } : {}),
638
684
  log,
639
685
  executor: placements.executors.get(node.id) ?? UNPLACED_EXECUTOR,
640
686
  ...(download.modeOf.get(node.id) === 'deferred' ? { download: 'deferred' as const } : {}),
@@ -648,85 +694,43 @@ export async function run(options: RunOptions): Promise<RunSummary> {
648
694
  gitFilesCache,
649
695
  hashCache,
650
696
  ...(probe !== undefined ? { preProbed: probe } : {}),
697
+ ...(taint ? { taintedUpstream: true } : {}),
698
+ ...(dependedOn.has(node.id) ? {} : { noDependants: true as const }),
699
+ fingerprintWatch,
700
+ ...(sandboxArmer !== null ? { armSandbox: () => sandboxArmer.arm() } : {}),
701
+ keyedProjects: keyed,
702
+ outputDirSnapshots,
703
+ deferSave: saveLane.defer,
704
+ deferredSaves,
705
+ stopSignal: stopRun.signal,
651
706
  }
652
707
  }
653
708
 
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,
709
+ const executeWithDedup = admitTasks({
710
+ inflight: options.inflight,
711
+ policy,
712
+ shortCircuit,
713
+ hashArgs: {
686
714
  workspaceRoot,
687
715
  workspaceFingerprint,
688
716
  cache,
689
717
  forwardArgs: options.forwardArgs,
690
- nestedProjectDirs: nestedDirsByProject.get(node.projectName) ?? [],
718
+ nestedDirsByProject,
691
719
  gitFilesCache,
692
720
  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
- }
721
+ },
722
+ buildExecuteArgs,
723
+ })
722
724
 
723
725
  mark('classify + probe')
724
726
  const outcomes = await runGraph({
725
727
  nodes,
726
728
  concurrency,
729
+ settledOf: (o) => deferredSaves.get(o.node.id),
727
730
  ...(hasPooledExecutor(executors) ? { poolOf: poolOfPlacement(placements) } : {}),
728
- ...(resourceCosts.size > 0 ? { resourceCosts, cpuBudget: concurrency, memBudget } : {}),
731
+ ...(admit !== undefined ? { admit } : {}),
729
732
  ...(options.continueMode !== undefined ? { continueMode: options.continueMode } : {}),
733
+ signal: stopRun.signal,
730
734
  onStart: (node) => {
731
735
  log.taskStart?.(node)
732
736
  },
@@ -734,7 +738,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
734
738
  log.taskComplete(o.node, o)
735
739
  narrowDemand(o.node.id)
736
740
  },
737
- execute: executeWithDedup,
741
+ execute: (node, upstream) => executeWithDedup(node, keyUpstream(node, upstream)),
738
742
  // A `schedule` plugin's weights; the scheduler keeps its structural
739
743
  // baseline as the tie-break. Empty map → baseline only.
740
744
  ...(prepared.priorities.size > 0 ? { priorities: prepared.priorities } : {}),
@@ -744,72 +748,24 @@ export async function run(options: RunOptions): Promise<RunSummary> {
744
748
  restoreTier: shortCircuit.restoreTier,
745
749
  })
746
750
 
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.
751
+ // Which persistent children outlive the graph, and the bounded SIGTERM
752
+ // of the rest, before the summary prints. Scoped to the real CLI
753
+ // foreground: `options.log === undefined` means the default logger (a
754
+ // `vx run` invocation), and `handleSignals` excludes watch mode (own
755
+ // signal loop) and embedders that manage lifecycle themselves — both
756
+ // expect run() to return, not block on a server.
757
757
  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
- }
758
+ // An aborted run's children are already being torn down: nothing to hold.
759
+ const hold = options.holdPersistent === true && !stopRun.signal.aborted
760
+ const keepAlive = selectKeepAlive(persistentRegistry, nodes, foreground || hold)
761
+ const crashedPersistent = await shutdownPersistent(persistentRegistry, keepAlive.children)
796
762
 
797
763
  mark('run graph')
798
764
  // Clear the status line for good before the summary prints.
799
765
  log.runEnd?.()
800
766
 
801
767
  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
- )
768
+ const ok = list.every((o) => isPassStatus(o.status)) && crashedPersistent.length === 0
813
769
 
814
770
  // The summary + artifact writers + recordRun pass all exclude group
815
771
  // tasks via the shared tallyOutcomes helper. We pass the full
@@ -818,13 +774,28 @@ export async function run(options: RunOptions): Promise<RunSummary> {
818
774
  const totalMs = Number(process.hrtime.bigint() - runStartHrTimeNs) / 1_000_000
819
775
  // Foreground dev mode: between the task frame and the footer, list
820
776
  // 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)
777
+ // Only the ones still up: a requested server that crashed while its
778
+ // dependants ran is dead, and the wait below says so.
779
+ const stillUp = keepAlive.nodes.filter((_, i) => !hasEnded(keepAlive.children[i]!))
780
+ if (stillUp.length > 0) {
781
+ for (const line of formatPersistentList(stillUp, colors)) log.status(line)
823
782
  }
824
783
  for (const line of formatRunSummary(list, totalMs, colors, runContext)) log.status(line)
825
784
  // A task killed by a shutdown signal is in no bucket above, yet it makes
826
785
  // `ok` false — name it, or the red exit is undiagnosable.
827
786
  for (const line of formatAbortedSection(list)) log.status(line)
787
+ // Likewise a dependency-only server that died before the end of the
788
+ // graph stopped it: its outcome says `success` (it became ready).
789
+ for (const c of crashedPersistent)
790
+ log.status(`vx: ${c.id} exited with code ${c.code} before the run stopped it`)
791
+ // The footer's "N skipped" names no task; this names each under the
792
+ // failure that blocked it.
793
+ for (const line of formatSkippedSection(list)) log.status(line)
794
+ // Judged against the history BEFORE this run's rows land, so the query
795
+ // is one scan over the executed tasks' keys and nothing at all on a run
796
+ // that executed none (every hit, every skip).
797
+ const flaky = detectFlaky(prepared.localCache.dbHandle(), flakyCandidates(list))
798
+ for (const line of formatFlakySection(flaky)) log.status(line)
828
799
  // Outputs that never came home are not an error, but a silent `dist/`
829
800
  // that is empty-or-stale would be: name every task whose bytes are
830
801
  // still remote.
@@ -835,24 +806,8 @@ export async function run(options: RunOptions): Promise<RunSummary> {
835
806
  ` Deferred: ${stillDeferred.length} task(s) left outputs remote (--download=none): ${stillDeferred.join(', ')}`,
836
807
  )
837
808
  }
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
- }
809
+ // Last, where a long log's reader lands: each failure's own last lines.
810
+ for (const line of failureRecap()) log.status(line)
856
811
 
857
812
  // Optional artifacts. Errors are surfaced to the user but don't
858
813
  // change the run's exit code — the run already happened.
@@ -868,6 +823,7 @@ export async function run(options: RunOptions): Promise<RunSummary> {
868
823
  totalMs,
869
824
  ok,
870
825
  outcomes: list,
826
+ flaky,
871
827
  })
872
828
  log.status(`vx: summary written to ${wrote}`)
873
829
  } catch (err) {
@@ -890,119 +846,41 @@ export async function run(options: RunOptions): Promise<RunSummary> {
890
846
  }
891
847
 
892
848
  // 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 = {
849
+ // (one fsync instead of N), with the invocation header row alongside,
850
+ // atomically via recordRunBundle. The Tier-3 input-fingerprint rows
851
+ // (entry_inputs) are NOT built here — they're persisted inside each
852
+ // entry's save transaction (miss path only), so a warm all-cache-hit
853
+ // run does no extra recording work. The telemetry mirror is built in
854
+ // the same pass, only when a sink is active.
855
+ const records = assembleRunRecords({
856
+ outcomes: list,
979
857
  runId,
858
+ startedAtMs: endedAtMsAtStart,
859
+ endedAtMs,
860
+ totalMs,
861
+ ok,
980
862
  command: options.command ?? process.argv.slice(1).join(' '),
981
- requestedTasks: JSON.stringify([...options.tasks]),
863
+ requestedTasks: options.tasks,
982
864
  cachePolicy: compactCachePolicy(policy),
983
865
  concurrency,
984
866
  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 ?? {}),
867
+ forwardArgs: options.forwardArgs,
868
+ tags: options.tags ?? {},
869
+ git: gitContext,
870
+ ci: ciContext,
871
+ host: hostContext,
872
+ withTelemetry: telemetry !== undefined,
873
+ })
874
+ try {
875
+ if (!stoppedBySignal) cache.recordRunBundle(records)
876
+ } catch (err) {
877
+ // History is observability: a full cache disk at the very end must
878
+ // not turn a finished run's verdict into a stack and exit 1 (seen as
879
+ // an unprivileged user on a 2 MiB disk, 2026-09-16). The run said
880
+ // what happened; `vx last` and the flaky list will not know this one.
881
+ const message = err instanceof Error ? err.message : String(err)
882
+ log.status(`[vx] run history not recorded: ${message} — the verdict above stands`)
1004
883
  }
1005
- cache.recordRunBundle({ runs: toRecord, invocation })
1006
884
  mark('record history')
1007
885
  // Hand the per-run summary to the telemetry sinks + drain them. Only
1008
886
  // when a sink is active (telemetry !== undefined) — otherwise this
@@ -1010,11 +888,12 @@ export async function run(options: RunOptions): Promise<RunSummary> {
1010
888
  // emitSummary/flush are crash-isolated, so a faulty sink can't fail
1011
889
  // the run; flush is the sink's last chance to ship buffered records.
1012
890
  if (telemetry !== undefined && runContextRecord !== undefined) {
1013
- const summary = assembleRunSummary(runContextRecord, summaryTasks, {
891
+ const summary = assembleRunSummary(runContextRecord, records.telemetryTasks, {
1014
892
  startedAt: endedAtMsAtStart,
1015
893
  endedAt: endedAtMs,
1016
894
  totalDurationMs: Math.round(totalMs),
1017
895
  exitOk: ok,
896
+ abortedCount: list.filter((o) => o.status === 'aborted' && !isGroupTask(o.node)).length,
1018
897
  })
1019
898
  telemetry.emitSummary(summary)
1020
899
  await telemetry.flush()
@@ -1039,16 +918,36 @@ export async function run(options: RunOptions): Promise<RunSummary> {
1039
918
  // write with nothing but a warning. The seam's contract is that a
1040
919
  // cache layer stays usable until the run's uploads have settled.
1041
920
  await prefetchDone
921
+ // Every deferred save has settled by here: the scheduler finishes a
922
+ // task only once its `settledOf` promise (the lane's `landed`) has,
923
+ // so `runGraph` resolved with every save in the cache. A second
924
+ // drain of the lane stood here until item 634; deleting it changed
925
+ // nothing the suite could see, because this gate is the rule.
1042
926
  await cache.drainUploads?.()
927
+ // The miss path's output-directory snapshots, taken now that the
928
+ // directories are old enough for the snapshot's racy window (see
929
+ // miss-save.ts). A few at a time: each is an lstat + readdir per
930
+ // prefix; the rows land together at close, in one transaction.
931
+ for (let i = 0; i < outputDirSnapshots.length; i += 32) {
932
+ await Promise.all(
933
+ outputDirSnapshots
934
+ .slice(i, i + 32)
935
+ .map((s) => cache.recordOutputDirs?.(s.hash, s.projectDir, s.prefixes)),
936
+ )
937
+ }
938
+ mark('output dir snapshots')
939
+ // Not on a stopped run: one stopped while it waited on another run's
940
+ // lock never held it, and its prune evicted under that run (item 858).
941
+ if (!stopRun.signal.aborted) await applyCacheRetention(prepared, log)
1043
942
  await teardownPlugins(prepared.plugins, (m) => log.status(m))
1044
- closeCache()
943
+ await closeCache()
1045
944
  mark('close')
1046
945
  printTimings()
1047
946
 
1048
947
  // Tear down SRT's network bridge + (on macOS) log monitor. No-op if
1049
948
  // no task was sandboxed; otherwise SRT keeps proxy servers alive and
1050
949
  // the next vx run would init on top of stale state.
1051
- if (anySandboxed) {
950
+ if (sandboxArmer?.armed) {
1052
951
  try {
1053
952
  await resetSandbox()
1054
953
  } catch (err) {
@@ -1061,21 +960,61 @@ export async function run(options: RunOptions): Promise<RunSummary> {
1061
960
  // persistent task (dev server / watcher). The run is "done" in every
1062
961
  // bookkeeping sense — summary printed, history recorded — but the
1063
962
  // 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')
963
+ // until ONE of them exits: Ctrl-C hits the whole process group (the
964
+ // server dies; our SIGINT handler also exits 130), and a crash ends
965
+ // the session — the others are torn down (SIGTERM, grace, SIGKILL)
966
+ // and a non-zero exit makes the run not ok, so `vx run dev` in a
967
+ // script fails when the server it started fell over. Until
968
+ // 2026-09-10 this waited for EVERY server, so the SIGTERM after it
969
+ // was dead code and a crashed server left the rest running under a
970
+ // run that never returned. One status line names the server that
971
+ // ended the session and its code — the summary above said `success`
972
+ // for it, and an exit 1 with no word about why is a mystery in a log.
973
+ if (keepAlive.children.length > 0 && hold) {
974
+ const held = keepAlive.children
975
+ return {
976
+ ok,
977
+ outcomes: list,
978
+ persistent: {
979
+ ids: keepAlive.nodes.map((n) => n.id),
980
+ stop: (signal) => terminateChildren(() => held, signal),
981
+ },
982
+ }
983
+ }
984
+ if (keepAlive.children.length > 0) {
985
+ const first = await Promise.race(
986
+ keepAlive.children.map((c, i) => c.exited.then((code) => ({ code, i }))),
987
+ )
988
+ const node = keepAlive.nodes[first.i]!
989
+ const others = keepAlive.nodes.length - 1
990
+ // Not when the run was stopped: the server ended because the user
991
+ // stopped it, and "exited with code 130" read as a crash after every
992
+ // Ctrl-C once a stop let run() finish its own path (item 852).
993
+ if (!stopRun.signal.aborted) {
994
+ log.status(
995
+ `vx: ${node.id} exited with code ${first.code}` +
996
+ (others > 0
997
+ ? `; stopping ${others} other persistent task${others === 1 ? '' : 's'}`
998
+ : ''),
999
+ )
1000
+ }
1001
+ await terminateChildren(() => keepAlive.children)
1002
+ return { ok: ok && first.code === 0, outcomes: list }
1070
1003
  }
1071
1004
 
1072
1005
  return { ok, outcomes: list }
1073
1006
  } finally {
1074
- // Idempotent; also reached on mid-run throws, so a crashed cycle
1075
- // can't leave a live status-line ticker behind.
1007
+ // Idempotent. The status-line ticker starts in runStart, and every
1008
+ // call between it and the success path's runEnd is crash-isolated
1009
+ // today (admit, the observers, execute's rejection arm, the
1010
+ // short-circuit, the prefetch, killTree; the bus swallows a
1011
+ // subscriber's throw, so even a logger's own runStart cannot reach
1012
+ // here), so deleting this line reddens nothing (item 635). It is the
1013
+ // floor for a throw a later change adds, not a path a row can drive.
1076
1014
  log.runEnd?.()
1077
- process.off('SIGINT', onSigint)
1078
- process.off('SIGTERM', onSigterm)
1015
+ signals.remove()
1016
+ options.signal?.removeEventListener('abort', forwardAbort)
1017
+ stopRun.signal.removeEventListener('abort', onAbort)
1079
1018
  // Plugins installed at the top of run() get their bus subscriptions
1080
1019
  // released here. Idempotent; safe even if installPlugins threw.
1081
1020
  disposePlugins?.()
@@ -1086,10 +1025,50 @@ export async function run(options: RunOptions): Promise<RunSummary> {
1086
1025
  // NOT drained here: they hold no database state, and awaiting a
1087
1026
  // wedged remote would turn a failing run into a hanging one.
1088
1027
  try {
1089
- closeCache()
1028
+ await closeCache()
1090
1029
  } catch {
1091
1030
  // teardown must not throw on the way out
1092
1031
  }
1032
+ await aborting?.catch(() => {})
1033
+ // Last: a process signal waits for this before it exits.
1034
+ leftRun()
1035
+ }
1036
+ }
1037
+
1038
+ /**
1039
+ * The workspace's `cacheRetention`, after every save and upload of this run
1040
+ * has landed (so nothing this run wrote is mid-flight) and before the cache
1041
+ * closes. Housekeeping, not the run's work: a failure is one warning, never
1042
+ * a failed run. Declared nowhere → one property read.
1043
+ */
1044
+ async function applyCacheRetention(prepared: PreparedRun, log: Logger): Promise<void> {
1045
+ const retention = prepared.workspaceConfig?.cacheRetention
1046
+ if (retention === undefined) return
1047
+ // Validated at load (`validateRetention`), so both parse.
1048
+ const maxAgeMs =
1049
+ retention.olderThan === undefined ? undefined : parseDuration(retention.olderThan)!
1050
+ const maxBytes = retention.maxSize === undefined ? undefined : parseSize(retention.maxSize)!
1051
+ try {
1052
+ const result = await prepared.localCache.evictIfDue({
1053
+ ...(maxAgeMs !== undefined ? { maxAgeMs } : {}),
1054
+ ...(maxBytes !== undefined ? { maxBytes } : {}),
1055
+ })
1056
+ if (result !== null && (result.evicted > 0 || result.orphans > 0)) {
1057
+ const said: string[] = []
1058
+ if (result.evicted > 0) {
1059
+ said.push(
1060
+ `evicted ${result.evicted} entr${result.evicted === 1 ? 'y' : 'ies'} (${formatBytes(result.bytesFreed)})`,
1061
+ )
1062
+ }
1063
+ if (result.orphans > 0) {
1064
+ said.push(
1065
+ `reaped ${result.orphans} orphaned artifact${result.orphans === 1 ? '' : 's'} (${formatBytes(result.orphanBytes)})`,
1066
+ )
1067
+ }
1068
+ log.status(`vx: cache retention ${said.join(', ')}`)
1069
+ }
1070
+ } catch (err) {
1071
+ log.status(`vx: cache retention skipped: ${err instanceof Error ? err.message : String(err)}`)
1093
1072
  }
1094
1073
  }
1095
1074
 
@@ -1112,7 +1091,10 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1112
1091
  return { tasks: [], unresolvedTasks: prepared.unresolvedTasks }
1113
1092
  }
1114
1093
  if (prepared.empty !== null) return { tasks: [] }
1115
- return await plan({
1094
+ // Its own mark: a dry run's plan (every task's hash, the cache lookups,
1095
+ // the history p50s) was booked under `close`, the next mark, and read
1096
+ // as 105 ms of closing a cache at 1,000 projects (item 601).
1097
+ const planned = await plan({
1116
1098
  nodes: prepared.nodes,
1117
1099
  workspaceRoot: prepared.workspaceRoot,
1118
1100
  workspaceFingerprint: prepared.workspaceFingerprint,
@@ -1138,174 +1120,38 @@ export async function planRun(options: RunOptions): Promise<RunPlan> {
1138
1120
  // cache capability, so plan mode gains no new class of side effect.
1139
1121
  ...(await planExecutorOf(prepared, log, options.download ?? 'all')),
1140
1122
  })
1123
+ mark('plan')
1124
+ return planned
1141
1125
  } finally {
1142
1126
  prepared.cache.close()
1127
+ // The same table a run prints: a dry run is how the prepare stages
1128
+ // (discovery, config load, the git enumeration) get profiled on a
1129
+ // real repo that has no install and no cache to run against.
1130
+ mark('close')
1131
+ printTimings()
1143
1132
  }
1144
1133
  }
1145
1134
 
1146
1135
  /**
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.
1136
+ * The first-run case, told apart from a typo: no package in the workspace
1137
+ * has a `vx.config.*` at all, so no name could have resolved. Reached only
1138
+ * on the error path. A workspace whose package globs match NOTHING is
1139
+ * told that instead — `vx init` would find no package to write for.
1288
1140
  */
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 }
1141
+ async function initHint(prepared: {
1142
+ anyProjectConfig: boolean
1143
+ workspaceProjectCount: number
1144
+ workspaceRoot: string
1145
+ }): Promise<string> {
1146
+ if (prepared.anyProjectConfig) return ''
1147
+ if (prepared.workspaceProjectCount === 0) {
1148
+ return " No package matched the workspace's package globs — check `workspaces` in package.json (or pnpm-workspace.yaml)."
1308
1149
  }
1150
+ // Single-project mode with packages the root's missing `workspaces`
1151
+ // never reaches: `vx init` would find no scripts either (item 248).
1152
+ const unreached = await unreachedPackages(await loadWorkspace(prepared.workspaceRoot))
1153
+ if (unreached.length > 0) return ` ${unreachedHint(unreached)}`
1154
+ return ' No package declares a vx.config — run `vx init` to write one per package from its package.json scripts.'
1309
1155
  }
1310
1156
 
1311
1157
  /**
@@ -1319,37 +1165,47 @@ function didYouMean(
1319
1165
  unresolved: readonly string[],
1320
1166
  projects: ReadonlyMap<string, ProjectEntry>,
1321
1167
  ): 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
1168
  const tasksOf = (p: ProjectEntry | undefined): string[] => Object.keys(p?.config.tasks ?? {})
1335
1169
  const allTasks = new Set<string>()
1336
1170
  for (const p of projects.values()) for (const t of tasksOf(p)) allTasks.add(t)
1337
- const hints: string[] = []
1171
+ // A Set: two typos of the same task hint it once, not once per typo.
1172
+ const hints = new Set<string>()
1338
1173
  for (const spec of unresolved) {
1339
1174
  const at = spec.indexOf('#')
1340
1175
  if (at < 0) {
1341
1176
  const t = nearest(spec, allTasks)
1342
- if (t !== undefined) hints.push(t)
1177
+ if (t !== undefined) hints.add(t)
1343
1178
  continue
1344
1179
  }
1345
1180
  const [proj, task] = [spec.slice(0, at), spec.slice(at + 1)]
1346
1181
  if (!projects.has(proj)) {
1347
1182
  const p = nearest(proj, projects.keys())
1348
- if (p !== undefined && tasksOf(projects.get(p)).includes(task)) hints.push(`${p}#${task}`)
1183
+ if (p !== undefined && tasksOf(projects.get(p)).includes(task)) hints.add(`${p}#${task}`)
1349
1184
  continue
1350
1185
  }
1351
1186
  const t = nearest(task, tasksOf(projects.get(proj)))
1352
- if (t !== undefined) hints.push(`${proj}#${t}`)
1187
+ if (t !== undefined) hints.add(`${proj}#${t}`)
1188
+ }
1189
+ return hints.size === 0 ? '' : ` Did you mean ${[...hints].join(', ')}?`
1190
+ }
1191
+
1192
+ /** The executed, keyed outcomes of a run — what flakiness is judged on. */
1193
+ function flakyCandidates(outcomes: readonly TaskOutcome[]): FlakyCandidate[] {
1194
+ const out: FlakyCandidate[] = []
1195
+ for (const o of outcomes) {
1196
+ if (o.status !== 'success' && o.status !== 'failed') continue
1197
+ // "Same inputs, different outcome" is a claim only a task with declared
1198
+ // inputs can make: a task with no `cache` block keys on its config alone
1199
+ // and runs every time, so one bad network day would read as a flake for
1200
+ // thirty days. Groups do no work.
1201
+ if (o.hash === undefined || o.node.config.cache === undefined || isGroupTask(o.node)) continue
1202
+ out.push({
1203
+ project: o.node.projectName,
1204
+ task: o.node.taskName,
1205
+ hash: o.hash,
1206
+ status: o.status,
1207
+ attempts: o.attempts ?? 1,
1208
+ })
1353
1209
  }
1354
- return hints.length === 0 ? '' : ` Did you mean ${hints.join(', ')}?`
1210
+ return out
1355
1211
  }