@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
@@ -20,6 +20,7 @@ import { formatDuration, formatSummarySection, type RunContext } from './summary
20
20
  import { isGroupTask } from '../graph/index.js'
21
21
  import { appendTail, createTail, resetTail, tailText, type Tail } from '../util/index.js'
22
22
  import { isCacheHit } from './telemetry.js'
23
+ import { outcomeWord } from './events.js'
23
24
 
24
25
  export interface Logger {
25
26
  /** Header / footer / status text. Written verbatim, one trailing \n added. */
@@ -124,19 +125,8 @@ function ghaFence(body: string, token: string): string {
124
125
  return `::stop-commands::${token}\n${body}::${token}::\n`
125
126
  }
126
127
 
127
- /** The unified outcome vocabulary word for a non-failed outcome. */
128
- function outcomeWord(o: TaskOutcome): string {
129
- switch (o.status) {
130
- case 'success':
131
- return 'success'
132
- case 'cache-hit':
133
- return o.restored === false ? 'up-to-date' : 'restored-local'
134
- case 'cache-hit-remote':
135
- return o.restored === false ? 'up-to-date' : 'restored-remote'
136
- default:
137
- return o.status
138
- }
139
- }
128
+ const cacheWordOf = (n: TaskNode): 'miss' | 'no-cache' =>
129
+ n.config.cache === undefined ? 'no-cache' : 'miss'
140
130
 
141
131
  export function resolveOutputView(
142
132
  options: {
@@ -182,8 +172,6 @@ export function defaultLogger(
182
172
  // a one-liner or streamed output since the last block does.
183
173
  let lineEmitted = false
184
174
  let streamedSinceBlock = false
185
- // Ids whose output went straight to the terminal (focused mode).
186
- const streamed = new Set<string>()
187
175
  // True while the live stream sits mid-line (chunk without trailing
188
176
  // newline) — the frame close must not glue onto partial output.
189
177
  let streamMidLine = false
@@ -257,6 +245,8 @@ export function defaultLogger(
257
245
  let slots: (WorkerSlot | null)[] = []
258
246
  const slotQueue: WorkerSlot[] = []
259
247
  let succeeded = 0
248
+ // Executed with no `cache` block: never a miss, counted apart.
249
+ let noCache = 0
260
250
  let upToDate = 0
261
251
  let restoredLocal = 0
262
252
  let restoredRemote = 0
@@ -311,7 +301,8 @@ export function defaultLogger(
311
301
  upToDate,
312
302
  restoredLocal,
313
303
  restoredRemote,
314
- miss: succeeded + failed,
304
+ miss: succeeded + failed - noCache,
305
+ noCache,
315
306
  left: total - done,
316
307
  spread:
317
308
  spreadCount > 0
@@ -472,7 +463,6 @@ export function defaultLogger(
472
463
  taskStdout(node, chunk) {
473
464
  if (discardsOutput) return
474
465
  if (streamsLive(node)) {
475
- streamed.add(node.id)
476
466
  streamedSinceBlock = true
477
467
  if (chunk.length > 0) streamMidLine = !chunk.endsWith('\n')
478
468
  writer.write(chunk)
@@ -488,7 +478,6 @@ export function defaultLogger(
488
478
  taskStderr(node, chunk) {
489
479
  if (discardsOutput) return
490
480
  if (streamsLive(node)) {
491
- streamed.add(node.id)
492
481
  streamedSinceBlock = true
493
482
  if (chunk.length > 0) streamMidLine = !chunk.endsWith('\n')
494
483
  writer.write(chunk)
@@ -534,6 +523,7 @@ export function defaultLogger(
534
523
  done++
535
524
  if (outcome.status === 'failed') {
536
525
  failed++
526
+ if (node.config.cache === undefined) noCache++
537
527
  } else if (node.config.exec?.persistent !== undefined && outcome.status === 'success') {
538
528
  // A persistent task's outcome arrives at READY; the child
539
529
  // keeps running until the orchestrator SIGTERMs it at run
@@ -557,6 +547,7 @@ export function defaultLogger(
557
547
  switch (outcome.status) {
558
548
  case 'success':
559
549
  succeeded++
550
+ if (node.config.cache === undefined) noCache++
560
551
  break
561
552
  case 'cache-hit':
562
553
  if (outcome.restored === false) upToDate++
@@ -593,7 +584,7 @@ export function defaultLogger(
593
584
  }
594
585
  case 'errors-only':
595
586
  if (outcome.status !== 'failed') return
596
- emitLine(formatFailureLine(node.id, outcome.durationMs, colors))
587
+ emitLine(formatFailureLine(node.id, outcome.durationMs, colors, cacheWordOf(node)))
597
588
  deferredFailures.push(
598
589
  formatTaskBlock(node, outcome, { stdout, stderr, ...dropped }, colors),
599
590
  )
@@ -605,7 +596,7 @@ export function defaultLogger(
605
596
  // surface in the end-of-run summary.
606
597
  if (outcome.status === 'failed') {
607
598
  // ✗ marker now; the full frame replays at runEnd.
608
- emitLine(formatFailureLine(node.id, outcome.durationMs, colors))
599
+ emitLine(formatFailureLine(node.id, outcome.durationMs, colors, cacheWordOf(node)))
609
600
  deferredFailures.push(
610
601
  formatTaskBlock(node, outcome, { stdout, stderr, ...dropped }, colors),
611
602
  )
@@ -640,7 +631,7 @@ export function defaultLogger(
640
631
  // to runEnd like everywhere else; everything else emits
641
632
  // ONE atomic block from the buffered output.
642
633
  if (outcome.status === 'failed') {
643
- emitLine(formatFailureLine(node.id, outcome.durationMs, colors))
634
+ emitLine(formatFailureLine(node.id, outcome.durationMs, colors, cacheWordOf(node)))
644
635
  deferredFailures.push(
645
636
  formatTaskBlock(node, outcome, { stdout, stderr, ...dropped }, colors),
646
637
  )
@@ -654,7 +645,7 @@ export function defaultLogger(
654
645
  // Dependency-pulled nodes: silent on success; failures get
655
646
  // the ✗ marker now and their frame replayed at runEnd.
656
647
  if (outcome.status === 'failed') {
657
- emitLine(formatFailureLine(node.id, outcome.durationMs, colors))
648
+ emitLine(formatFailureLine(node.id, outcome.durationMs, colors, cacheWordOf(node)))
658
649
  deferredFailures.push(
659
650
  formatTaskBlock(node, outcome, { stdout, stderr, ...dropped }, colors),
660
651
  )
@@ -30,6 +30,8 @@ export interface RunSummaryRow {
30
30
  startedAt: number
31
31
  endedAt: number
32
32
  cacheHit: boolean | null
33
+ /** Whether the task declared a `cache` block; null on rows older than the column. */
34
+ cached: boolean | null
33
35
  hash: string
34
36
  cpuMs: number | null
35
37
  peakRssBytes: number | null
@@ -65,8 +67,12 @@ export function listRuns(db: Database, args: ListRunsArgs = {}): RunSummaryRow[]
65
67
  params.push(args.runId)
66
68
  }
67
69
  const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''
68
- type RawRow = Omit<RunSummaryRow, 'cacheHit' | 'wallclockStartNs' | 'wallclockEndNs'> & {
70
+ type RawRow = Omit<
71
+ RunSummaryRow,
72
+ 'cacheHit' | 'cached' | 'wallclockStartNs' | 'wallclockEndNs'
73
+ > & {
69
74
  cacheHit: number | null
75
+ cached: number | null
70
76
  wallclockStartNs: bigint | null
71
77
  wallclockEndNs: bigint | null
72
78
  }
@@ -74,7 +80,7 @@ export function listRuns(db: Database, args: ListRunsArgs = {}): RunSummaryRow[]
74
80
  .query(
75
81
  `SELECT run_id AS runId, project, task, status, exit_code AS exitCode,
76
82
  duration_ms AS durationMs, started_at AS startedAt, ended_at AS endedAt,
77
- cache_hit AS cacheHit, hash,
83
+ cache_hit AS cacheHit, cached, hash,
78
84
  cpu_ms AS cpuMs, peak_rss_bytes AS peakRssBytes,
79
85
  wallclock_start_ns AS wallclockStartNs, wallclock_end_ns AS wallclockEndNs
80
86
  FROM runs ${clause} ORDER BY started_at DESC LIMIT ?`,
@@ -83,6 +89,7 @@ export function listRuns(db: Database, args: ListRunsArgs = {}): RunSummaryRow[]
83
89
  return rows.map((r) => ({
84
90
  ...r,
85
91
  cacheHit: r.cacheHit === null ? null : Boolean(r.cacheHit),
92
+ cached: r.cached === null ? null : Boolean(r.cached),
86
93
  wallclockStartNs: r.wallclockStartNs === null ? null : r.wallclockStartNs.toString(),
87
94
  wallclockEndNs: r.wallclockEndNs === null ? null : r.wallclockEndNs.toString(),
88
95
  }))
@@ -350,7 +357,14 @@ export interface WhyDidThisRerun {
350
357
  runId: string
351
358
  taskId: string
352
359
  found: boolean
353
- thisRun?: { hash: string; status: string; cacheHit: boolean | null; startedAt: number }
360
+ thisRun?: {
361
+ hash: string
362
+ status: string
363
+ cacheHit: boolean | null
364
+ /** Whether the task declared a `cache` block; null on rows older than the column. */
365
+ cached: boolean | null
366
+ startedAt: number
367
+ }
354
368
  previousRun?: { hash: string; status: string; cacheHit: boolean | null; startedAt: number } | null
355
369
  hashChanged?: boolean | null
356
370
  note: string
@@ -370,11 +384,17 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
370
384
  const [project, task] = splitTaskId(taskId)
371
385
  const this_ = db
372
386
  .query(
373
- `SELECT hash, status, cache_hit AS cacheHit, started_at AS startedAt
387
+ `SELECT hash, status, cache_hit AS cacheHit, cached, started_at AS startedAt
374
388
  FROM runs WHERE run_id = ? AND project = ? AND task = ?`,
375
389
  )
376
390
  .get(runId, project, task) as
377
- | { hash: string; status: string; cacheHit: number | null; startedAt: number }
391
+ | {
392
+ hash: string
393
+ status: string
394
+ cacheHit: number | null
395
+ cached: number | null
396
+ startedAt: number
397
+ }
378
398
  | undefined
379
399
  if (!this_) {
380
400
  return {
@@ -403,24 +423,32 @@ export function whyDidThisRerun(db: Database, runId: string, taskId: string): Wh
403
423
  runId,
404
424
  taskId,
405
425
  found: true,
406
- thisRun: { ...this_, cacheHit: this_.cacheHit === null ? null : Boolean(this_.cacheHit) },
426
+ thisRun: {
427
+ ...this_,
428
+ cacheHit: this_.cacheHit === null ? null : Boolean(this_.cacheHit),
429
+ cached: this_.cached === null ? null : Boolean(this_.cached),
430
+ },
407
431
  previousRun: prev
408
432
  ? { ...prev, cacheHit: prev.cacheHit === null ? null : Boolean(prev.cacheHit) }
409
433
  : null,
410
434
  hashChanged: prev && !noKey ? prev.hash !== this_.hash : null,
411
435
  note: noKey
412
436
  ? 'this task recorded no cache key (skipped, or a persistent task) — nothing to compare'
413
- : prev && prev.hash !== this_.hash
414
- ? 'cache key changed between the previous run and this one (inputs differ)'
415
- : prev
416
- ? // An unchanged key has two very different endings, and calling
417
- // both a "re-run" answered the question wrong: a cache HIT did not
418
- // re-run at all, so blaming `--no-cache` named a cause that cannot
419
- // have applied. Only a run that EXECUTED on an unchanged key is
420
- // the case this verb exists to explain. A row with no recorded
421
- // cacheHit (older rows) is neither — say that, do not guess.
422
- unchangedKeyNote(this_.cacheHit)
423
- : 'no prior run for this (project, task)',
437
+ : this_.cached === 0
438
+ ? // Not a cache decision at all: the task runs every time by design,
439
+ // and its key is derived only so dependents can fold it.
440
+ 'this task declares no `cache` block — it runs on every invocation; its key is folded by dependents only'
441
+ : prev && prev.hash !== this_.hash
442
+ ? 'cache key changed between the previous run and this one (inputs differ)'
443
+ : prev
444
+ ? // An unchanged key has two very different endings, and calling
445
+ // both a "re-run" answered the question wrong: a cache HIT did not
446
+ // re-run at all, so blaming `--no-cache` named a cause that cannot
447
+ // have applied. Only a run that EXECUTED on an unchanged key is
448
+ // the case this verb exists to explain. A row with no recorded
449
+ // cacheHit (older rows) is neither — say that, do not guess.
450
+ unchangedKeyNote(this_.cacheHit)
451
+ : 'no prior run for this (project, task)',
424
452
  }
425
453
  }
426
454
 
@@ -483,9 +511,11 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
483
511
  const [project, task] = splitTaskId(taskId)
484
512
  const this_ = db
485
513
  .query(
486
- 'SELECT hash, started_at AS startedAt FROM runs WHERE run_id = ? AND project = ? AND task = ?',
514
+ 'SELECT hash, cached, started_at AS startedAt FROM runs WHERE run_id = ? AND project = ? AND task = ?',
487
515
  )
488
- .get(runId, project, task) as { hash: string; startedAt: number } | undefined
516
+ .get(runId, project, task) as
517
+ | { hash: string; cached: number | null; startedAt: number }
518
+ | undefined
489
519
  if (!this_) {
490
520
  return {
491
521
  runId,
@@ -559,7 +589,10 @@ export function cacheKeyDiff(db: Database, runId: string, taskId: string): Cache
559
589
  previousRunId: prev.runId,
560
590
  entries: [],
561
591
  unchangedCount: 0,
562
- note: 'cache key changed but input fingerprints are unavailable (entry pruned); only the hash change is known',
592
+ note:
593
+ this_.cached === 0
594
+ ? 'this task declares no `cache` block — it runs every time; its key moved, but no input fingerprints exist for an uncached task'
595
+ : 'cache key changed but input fingerprints are unavailable — the entry was pruned (or the row predates the `cached` column); only the key change is known',
563
596
  }
564
597
  }
565
598
 
@@ -0,0 +1,169 @@
1
+ // What a MISS leaves behind once the command exited 0 and the task will
2
+ // save: the declared outputs resolved, the artifact and its rows saved in
3
+ // one transaction, the whole-subtree output prefixes recorded for the
4
+ // next hit's skip-restore, and the exact written paths marked against the
5
+ // git snapshot so a same-project consumer re-spawns git only when its
6
+ // globs can see them. Stale-hit-critical: a line moved here changes what
7
+ // a later run replays. Moved out of execute-task.ts 2026-09-09 as pure
8
+ // code motion.
9
+
10
+ import path from 'node:path'
11
+ import {
12
+ type CacheLayer,
13
+ type GitFilesCache,
14
+ resolveOutputs,
15
+ resolveWorkspaceOutputs,
16
+ } from '../cache/index.js'
17
+ import type { TaskNode } from '../graph/index.js'
18
+ import { span, wholeSubtreePrefixes } from '../util/index.js'
19
+ import type { Logger } from './logger.js'
20
+ import type { TaskInputComponent } from './task-hash.js'
21
+
22
+ /** A snapshot request for `recordOutputDirs`, taken at run end. */
23
+ export interface OutputDirSnapshot {
24
+ hash: string
25
+ projectDir: string
26
+ prefixes: readonly string[]
27
+ }
28
+
29
+ export interface SaveMissArgs {
30
+ node: TaskNode
31
+ hash: string
32
+ cache: CacheLayer
33
+ log: Logger
34
+ workspaceRoot: string
35
+ nestedProjectDirs: string[]
36
+ gitFilesCache?: GitFilesCache | undefined
37
+ /** Declared `cache.outputs.files` / `.workspaceFiles`. */
38
+ outputs: string[]
39
+ wsOutputs: string[]
40
+ /** The Tier-3 input fingerprint rows captured by the pre-exec describe. */
41
+ captured: readonly TaskInputComponent[]
42
+ command: string
43
+ durationMs: number
44
+ stdout: string
45
+ /** The execution's usage, when the runner reported it — rides the artifact's sidecar. */
46
+ cpuMs?: number | undefined
47
+ peakRssBytes?: number | undefined
48
+ /** When present, the directory snapshot is queued here instead of taken now. */
49
+ outputDirSnapshots?: OutputDirSnapshot[] | undefined
50
+ /**
51
+ * When present, the cache save itself runs off the execution slot: the
52
+ * outputs are resolved and the git snapshot marked here, in the slot
53
+ * (a same-project downstream task reads both), and the pack + write +
54
+ * index go to the run's save lane (save-lane.ts).
55
+ */
56
+ deferSave?: ((save: () => Promise<void>) => Promise<void>) | undefined
57
+ }
58
+
59
+ /** `landed` settles when the entry is in the cache — at once without a lane. */
60
+ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<void> }> {
61
+ const { node, cache, log } = a
62
+ const endResolve = span('miss: resolve outputs')
63
+ const outputFiles = await resolveOutputs({
64
+ projectDir: node.projectDir,
65
+ outputs: a.outputs,
66
+ nestedProjectDirs: a.nestedProjectDirs,
67
+ })
68
+ const wsOutputFiles = await resolveWorkspaceOutputs({
69
+ workspaceRoot: a.workspaceRoot,
70
+ outputs: a.wsOutputs,
71
+ })
72
+ endResolve()
73
+ // The mirror of the input warning: declared outputs that resolve to
74
+ // nothing save an empty artifact, and the next hit "restores" it —
75
+ // the build that ran nowhere looks like a build that ran. `files: []`
76
+ // is a deliberate cached no-op and says nothing; a glob that matched
77
+ // nothing is a glob against the wrong directory.
78
+ if (
79
+ a.outputs.length + a.wsOutputs.length > 0 &&
80
+ outputFiles.length + wsOutputFiles.length === 0
81
+ ) {
82
+ log.status(
83
+ `[vx] ${node.id}: cache.outputs matched no files (${[...a.outputs, ...a.wsOutputs].join(', ')}) — ` +
84
+ `an empty artifact is saved; a later hit restores nothing`,
85
+ )
86
+ }
87
+ const save = async (): Promise<void> => {
88
+ const endSave = span('miss: save')
89
+ // Tier-3 input fingerprint: the digest rows captured by the pre-exec
90
+ // describe above, persisted with the entry inside `cache.save`'s
91
+ // transaction. Miss path only — the warm/hit path never reaches here.
92
+ await cache.save({
93
+ hash: a.hash,
94
+ projectDir: node.projectDir,
95
+ outputFiles,
96
+ ...(wsOutputFiles.length > 0
97
+ ? { workspaceOutputFiles: wsOutputFiles, workspaceRoot: a.workspaceRoot }
98
+ : {}),
99
+ inputComponents: a.captured.map((c) => ({ entryHash: a.hash, ...c })),
100
+ // No `exitCode`: the save only runs under `effectiveExitCode === 0`, and
101
+ // the contract no longer accepts one — so the invariant is enforced by
102
+ // the type rather than by every call site remembering the gate.
103
+ entry: {
104
+ taskId: node.id,
105
+ command: a.command,
106
+ durationMs: a.durationMs,
107
+ stdout: a.stdout,
108
+ ...(a.cpuMs !== undefined ? { cpuMs: a.cpuMs } : {}),
109
+ ...(a.peakRssBytes !== undefined ? { peakRssBytes: a.peakRssBytes } : {}),
110
+ },
111
+ })
112
+ endSave()
113
+ // The directory snapshot behind the next hit's skip-restore. Taken at
114
+ // run end when the run keeps a list: the task wrote these directories
115
+ // milliseconds ago, inside the snapshot's racy window, so a snapshot
116
+ // taken here was refused and the next hit walked every output tree —
117
+ // 1,000 walks, 296 ms accumulated, on the first warm run after a cold
118
+ // build of the 1,000-project bench (2026-09-10).
119
+ const savedDirPrefixes = wholeSubtreePrefixes(a.outputs)
120
+ if (savedDirPrefixes !== null) {
121
+ if (a.outputDirSnapshots !== undefined) {
122
+ a.outputDirSnapshots.push({
123
+ hash: a.hash,
124
+ projectDir: node.projectDir,
125
+ prefixes: savedDirPrefixes,
126
+ })
127
+ } else {
128
+ await cache.recordOutputDirs?.(a.hash, node.projectDir, savedDirPrefixes)
129
+ }
130
+ }
131
+ }
132
+ // This task just wrote outputs to the project's tree. Record the
133
+ // exact declared-output paths as changed (same as the cache-hit
134
+ // restore path) instead of dropping the whole snapshot: a downstream
135
+ // same-project task then re-spawns git ONLY when its input globs can
136
+ // actually see one of these paths. On a 1000-package cold run this
137
+ // removes ~one synchronous `git ls-files` spawn per project (the
138
+ // single largest cold-run cost — 22% of CPU in profiling). Contract:
139
+ // outputs must be declared — an executed task that writes files
140
+ // outside `cache.outputs.files` which a same-project downstream task
141
+ // reads is undeclared behavior (the restore path already assumes it).
142
+ if (outputFiles.length > 0) {
143
+ a.gitFilesCache?.markOutputsChanged(
144
+ node.projectDir,
145
+ outputFiles.map((p) => path.relative(node.projectDir, p).split(path.sep).join('/')),
146
+ )
147
+ }
148
+ // Declared workspace outputs may have landed inside OTHER
149
+ // projects' dirs (no-boundary escape hatch) — mark the exact
150
+ // paths against every partition that can see them.
151
+ if (wsOutputFiles.length > 0) {
152
+ a.gitFilesCache?.markWorkspaceOutputsChanged(
153
+ a.workspaceRoot,
154
+ wsOutputFiles.map((f) => path.relative(a.workspaceRoot, f).split(path.sep).join('/')),
155
+ )
156
+ }
157
+ // The workspace-wide partition (when one exists) spans this
158
+ // project's subtree, so it inherits the same "undeclared writes
159
+ // are only visible to git" rule as the project drop above.
160
+ if (outputFiles.length + wsOutputFiles.length > 0) {
161
+ a.gitFilesCache?.invalidateWorkspacePartition()
162
+ }
163
+ // Off the slot when the run keeps a lane (the save lane bounds and
164
+ // drains it); in the slot otherwise — an embedder without a lane gets
165
+ // the entry before the outcome.
166
+ if (a.deferSave !== undefined) return { landed: a.deferSave(save) }
167
+ await save()
168
+ return { landed: Promise.resolve() }
169
+ }
@@ -8,6 +8,7 @@ import type { TaskOutcome } from '../graph/index.js'
8
8
  import type { EventBus } from './events.js'
9
9
  import type { TelemetrySink } from './telemetry.js'
10
10
  import type { Logger } from './logger.js'
11
+ import type { ProjectEntry } from '../workspace/index.js'
11
12
 
12
13
  export interface RunOptions {
13
14
  cwd: string
@@ -19,6 +20,16 @@ export interface RunOptions {
19
20
  */
20
21
  tasks: readonly string[]
21
22
  projects?: string[]
23
+ /**
24
+ * Configs a selection pass in this same process already loaded and
25
+ * staged with the same `cacheDir` and `frozen` (the CLI walks every
26
+ * `pkg#task` edge for `--filter 'app...'` and `--affected`). The run
27
+ * seeds and scopes as it always does and reuses these entries instead
28
+ * of evaluating and staging them again. One run only — never carry a
29
+ * map across runs, a config can change between them (`vx watch` drops
30
+ * it per cycle).
31
+ */
32
+ staged?: ReadonlyMap<string, ProjectEntry>
22
33
  concurrency?: number
23
34
  /**
24
35
  * Cache directory override (`--cache-dir <path>`). Absolute, or relative
@@ -34,6 +45,13 @@ export interface RunOptions {
34
45
  * both reads false (re-execute, still refresh the cache).
35
46
  */
36
47
  cache?: CachePolicy
48
+ /**
49
+ * The caller asked for a remote axis by name (`--cache=remote:rw`). With
50
+ * no cache plugin supplying a remote layer the axes are inert, and a CI
51
+ * job that believes it is filling a shared cache deserves one line
52
+ * saying it is not.
53
+ */
54
+ remoteRequested?: boolean
37
55
  /**
38
56
  * CI mode: load configs FROM the committed vx-lock.json instead of
39
57
  * evaluating them (frozen-env reproducibility). Requires the lock
@@ -62,7 +80,7 @@ export interface RunOptions {
62
80
  * task in the run actually needs them (Bazel's "build without the
63
81
  * bytes").
64
82
  * Pure transfer tuning: it can never change what a command produces,
65
- * so it is NEVER folded into a cache key (a RunOption, like --verify).
83
+ * so it is NEVER folded into a cache key: it is a RunOption, not config.
66
84
  * Locally-placed tasks always write in place and ignore this.
67
85
  */
68
86
  download?: 'all' | 'toplevel' | 'none'
@@ -82,24 +100,6 @@ export interface RunOptions {
82
100
  * the same run with and without `--retry` derives identical keys.
83
101
  */
84
102
  retries?: number
85
- /**
86
- * Cache-correctness verification (`vx run --verify`). When set, an
87
- * executed + cacheable task is re-run after its save and its outputs are
88
- * content-compared: a divergence means the task is non-hermetic and its
89
- * cache entry is provably unsafe (see docs/design/cache-correctness).
90
- * `fingerprint` computes + ships an output-tree fingerprint on executed
91
- * tasks (no re-run — the cross-machine diff feed; `--verify`/`=all` set
92
- * it too, for free). `allow` (from `--verify-allow=<pkg#task>,…`) exempts
93
- * known-nondeterministic tasks from failing the run. A pure side-channel —
94
- * never folded into a cache key; the re-run never saves. Undefined = off
95
- * (a plain run is byte-identical).
96
- */
97
- verify?: {
98
- determinism: boolean
99
- inputs: boolean
100
- fingerprint: boolean
101
- allow: ReadonlySet<string>
102
- }
103
103
  /**
104
104
  * Run-level default task timeout (ms), for tasks that declare no
105
105
  * `exec.timeout`. Highest of the run-level defaults: `--timeout` /
@@ -109,14 +109,6 @@ export interface RunOptions {
109
109
  * cached), so a `--timeout` run cache-hits a plain run's entry.
110
110
  */
111
111
  timeout?: number
112
- /**
113
- * Memory budget (bytes) that per-task `exec.resources.memory`
114
- * reservations pack against (`--memory <size>`). Defaults to
115
- * `os.totalmem()` — override it in cgroup-limited containers, where
116
- * totalmem() reports the HOST's RAM, not the limit. A per-run
117
- * scheduling knob only — never folded into any cache key.
118
- */
119
- memory?: number
120
112
  /**
121
113
  * Failure propagation: 'never' stops dispatch on the first failure
122
114
  * (in-flight tasks finish; everything queued skips), 'deps-ok'
@@ -154,6 +146,16 @@ export interface RunOptions {
154
146
  * exit the process out from under it.
155
147
  */
156
148
  handleSignals?: boolean
149
+ /**
150
+ * Abort the run from outside. On `abort` every live and persistent
151
+ * child is SIGTERMed, given the kill grace, then SIGKILLed; nothing
152
+ * further is dispatched; run() returns with the killed tasks and the
153
+ * never-started ones `aborted`. The seam for an embedder that owns its
154
+ * process's lifetime instead of `handleSignals` — the watch loop aborts
155
+ * its in-flight cycle on SIGINT/SIGTERM this way, so a cancellation
156
+ * never orphans a cycle's children.
157
+ */
158
+ signal?: AbortSignal
157
159
  log?: Logger
158
160
  /**
159
161
  * Inject the run's event bus. When provided, the orchestrator emits
@@ -168,7 +170,8 @@ export interface RunOptions {
168
170
  bus?: EventBus
169
171
  /**
170
172
  * Shared in-flight execution registry, keyed by task hash. Supplied by a
171
- * long-lived service (`vx serve`) so concurrent runs DEDUP work: a task
173
+ * long-lived embedder (a daemon built on the façade; core ships none) so
174
+ * concurrent runs DEDUP work: a task
172
175
  * already executing for one run is awaited by another (which then
173
176
  * restores the just-saved artifact from cache) instead of re-running.
174
177
  * A stateless `vx run` passes none and is byte-identical to before.
@@ -183,8 +186,8 @@ export interface RunOptions {
183
186
  /**
184
187
  * Additional observe-only telemetry sinks, merged with whatever the
185
188
  * workspace plugins contribute. The embedder seam: a host executing
186
- * runs on behalf of others (a serve recording delegated runs
187
- * into its ingest store) attaches a sink without needing a workspace
189
+ * runs on behalf of others (a daemon recording delegated runs
190
+ * into its own store) attaches a sink without needing a workspace
188
191
  * plugin. Undefined → zero cost, identical to before; the telemetry
189
192
  * host's zero-sink invariant still applies when both sources are
190
193
  * empty.
@@ -200,7 +203,7 @@ export interface RunOptions {
200
203
  /**
201
204
  * An injected remote cache layer — the embedder seam mirroring
202
205
  * `telemetrySinks`: a host that already holds a wire client (a
203
- * distribution agent, a serve executing on behalf of a submitter)
206
+ * distribution agent, a daemon executing on behalf of a submitter)
204
207
  * passes it here and run() composes `LayeredCache(local, injected)`.
205
208
  * Explicit injection WINS over the plugin `cache` capability (the
206
209
  * host knows best; prevents double-wrapping when the workspace also
@@ -0,0 +1,79 @@
1
+ // End-of-run disposition of persistent children (dev servers, watchers).
2
+ // `executeTask` spawns them and returns at "ready" without awaiting exit;
3
+ // ownership moves to the run's registry, and this is the one place that
4
+ // decides which of them outlive the graph and how the rest go down.
5
+
6
+ import type { TaskNode } from '../graph/index.js'
7
+ import { killGraceMs } from '../util/index.js'
8
+
9
+ type Child = ReturnType<typeof Bun.spawn>
10
+
11
+ /**
12
+ * Grace after SIGTERMing the dependency-only persistent tasks before
13
+ * force-killing any that trap or ignore it — so a wedged mock server can't
14
+ * hang a normal run at completion. Well-behaved servers exit far under
15
+ * this, so the happy path never waits it out.
16
+ */
17
+ export const PERSISTENT_SHUTDOWN_GRACE_MS = 2000
18
+
19
+ export interface KeepAlive {
20
+ nodes: TaskNode[]
21
+ children: Child[]
22
+ }
23
+
24
+ /**
25
+ * A persistent task the user REQUESTED (or one surfaced for display) is the
26
+ * run's whole purpose — it is left running and blocked on at the very end,
27
+ * after the summary. Only in the real CLI foreground: a custom logger or
28
+ * `handleSignals: false` (watch mode, embedders) expects `run()` to return,
29
+ * not to sit on a server.
30
+ */
31
+ export function selectKeepAlive(
32
+ registry: ReadonlyMap<string, Child>,
33
+ nodes: ReadonlyMap<string, TaskNode>,
34
+ foreground: boolean,
35
+ ): KeepAlive {
36
+ const out: KeepAlive = { nodes: [], children: [] }
37
+ if (!foreground) return out
38
+ for (const [id, child] of registry) {
39
+ const n = nodes.get(id)
40
+ if (n !== undefined && (n.requested || n.surfaced === true)) {
41
+ out.nodes.push(n)
42
+ out.children.push(child)
43
+ }
44
+ }
45
+ return out
46
+ }
47
+
48
+ /**
49
+ * SIGTERM every persistent child not kept alive and wait for them, bounded:
50
+ * SIGTERM gives well-behaved servers (vite, next, esbuild --watch) a moment
51
+ * to clean up; past the grace the stragglers are SIGKILLed so the run's
52
+ * normal completion cannot hang on one that traps the signal. Bun's
53
+ * `kill` is idempotent on an exited child; the timer is cleared and
54
+ * unref'd so a fast shutdown never delays CLI exit.
55
+ */
56
+ export async function shutdownPersistent(
57
+ registry: ReadonlyMap<string, Child>,
58
+ keepAlive: readonly Child[],
59
+ graceMs: number = killGraceMs(PERSISTENT_SHUTDOWN_GRACE_MS),
60
+ ): Promise<void> {
61
+ const kept = new Set(keepAlive)
62
+ const dying = [...registry.values()].filter((c) => !kept.has(c))
63
+ if (dying.length === 0) return
64
+ for (const child of dying) child.kill('SIGTERM')
65
+ const allExited = Promise.allSettled(dying.map((c) => c.exited))
66
+ let graceTimer: ReturnType<typeof setTimeout> | undefined
67
+ const winner = await Promise.race([
68
+ allExited.then(() => 'exited' as const),
69
+ new Promise<'grace'>((resolve) => {
70
+ graceTimer = setTimeout(() => resolve('grace'), graceMs)
71
+ graceTimer.unref?.()
72
+ }),
73
+ ])
74
+ if (graceTimer !== undefined) clearTimeout(graceTimer)
75
+ if (winner === 'grace') {
76
+ for (const child of dying) child.kill('SIGKILL')
77
+ await allExited
78
+ }
79
+ }