@vzn/vx 0.0.650 → 0.0.652

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 (146) hide show
  1. package/README.md +7 -23
  2. package/package.json +5 -5
  3. package/schemas/last.json +15 -0
  4. package/schemas/show.json +14 -20
  5. package/src/bin.ts +21 -31
  6. package/src/cache/archive.ts +1 -1
  7. package/src/cache/cache.ts +221 -30
  8. package/src/cache/file-hashes.ts +3 -0
  9. package/src/cache/git-inputs.ts +188 -39
  10. package/src/cache/history-stream.ts +134 -0
  11. package/src/cache/history-writer.ts +76 -0
  12. package/src/cache/index.ts +3 -0
  13. package/src/cache/inputs.ts +77 -14
  14. package/src/cache/layer.ts +17 -6
  15. package/src/cache/layered-cache.ts +4 -0
  16. package/src/cache/linked-inputs.ts +3 -0
  17. package/src/cache/lockfile-memo.ts +5 -3
  18. package/src/cache/output-index.ts +5 -2
  19. package/src/cache/remote-lane.ts +2 -1
  20. package/src/cache/run-history.ts +56 -17
  21. package/src/cache/schema.ts +15 -2
  22. package/src/cli/cache.ts +28 -18
  23. package/src/cli/completions.ts +4 -2
  24. package/src/cli/docs.ts +10 -11
  25. package/src/cli/help.ts +14 -25
  26. package/src/cli/index.ts +28 -9
  27. package/src/cli/info.ts +16 -8
  28. package/src/cli/init.ts +23 -15
  29. package/src/cli/last.ts +246 -204
  30. package/src/cli/lock.ts +55 -5
  31. package/src/cli/plan-format.ts +100 -118
  32. package/src/cli/plugin-commands.ts +8 -2
  33. package/src/cli/plugin-templates.ts +36 -45
  34. package/src/cli/run.ts +46 -27
  35. package/src/cli/select.ts +43 -5
  36. package/src/cli/show.ts +45 -52
  37. package/src/cli/task-verb.ts +22 -0
  38. package/src/cli/upgrade.ts +3 -1
  39. package/src/cli/watch-fs.ts +6 -1
  40. package/src/cli/watch-judge.ts +3 -2
  41. package/src/cli/watch-set.ts +4 -4
  42. package/src/cli/watch.ts +94 -52
  43. package/src/cli/why.ts +123 -89
  44. package/src/cli/workspace-config.ts +2 -1
  45. package/src/config.ts +47 -30
  46. package/src/exec/executor.ts +42 -2
  47. package/src/exec/index.ts +1 -0
  48. package/src/exec/kill-tree.ts +58 -5
  49. package/src/exec/local-executor.ts +1 -1
  50. package/src/exec/pipes.ts +152 -0
  51. package/src/exec/runner.ts +199 -182
  52. package/src/exec/sandbox-binds.ts +1 -1
  53. package/src/exec/sandbox-paths.ts +3 -1
  54. package/src/exec/sandbox-runtime.ts +317 -23
  55. package/src/exec/spawn-off.ts +154 -0
  56. package/src/exec/task-proxy.ts +93 -0
  57. package/src/exec/warm-shell.ts +146 -0
  58. package/src/graph/critical-path.ts +40 -28
  59. package/src/graph/definition.ts +16 -3
  60. package/src/graph/priorities.ts +98 -44
  61. package/src/graph/scheduler.ts +17 -11
  62. package/src/graph/task-graph.ts +51 -24
  63. package/src/handoff.ts +10 -7
  64. package/src/history/failure-mode.ts +24 -1
  65. package/src/history/history.ts +5 -0
  66. package/src/history/index.ts +1 -0
  67. package/src/history/metrics.ts +14 -6
  68. package/src/index.ts +7 -3
  69. package/src/orchestrator/admission.ts +1 -1
  70. package/src/orchestrator/command-windows.ts +1 -1
  71. package/src/orchestrator/deferred-outputs.ts +1 -1
  72. package/src/orchestrator/doctor.ts +1 -1
  73. package/src/orchestrator/download-policy.ts +5 -7
  74. package/src/orchestrator/excluded-keys.ts +1 -1
  75. package/src/orchestrator/execute-task.ts +67 -20
  76. package/src/orchestrator/graph-memo.ts +50 -1
  77. package/src/orchestrator/graph-stage.ts +6 -0
  78. package/src/orchestrator/hit-restore.ts +5 -6
  79. package/src/orchestrator/index.ts +3 -0
  80. package/src/orchestrator/keyed-projects.ts +28 -0
  81. package/src/orchestrator/live-usage.ts +16 -11
  82. package/src/orchestrator/local-shortcircuit.ts +2 -2
  83. package/src/orchestrator/locked-digests.ts +63 -0
  84. package/src/orchestrator/lockfile-claim.ts +108 -29
  85. package/src/orchestrator/miss-save.ts +58 -11
  86. package/src/orchestrator/persistent-task.ts +5 -1
  87. package/src/orchestrator/placement.ts +1 -1
  88. package/src/orchestrator/plan.ts +7 -3
  89. package/src/orchestrator/plugin-host.ts +67 -2
  90. package/src/orchestrator/plugin.ts +53 -2
  91. package/src/orchestrator/prepare.ts +16 -1
  92. package/src/orchestrator/projects.ts +4 -1
  93. package/src/orchestrator/remote-prefetch.ts +1 -1
  94. package/src/orchestrator/run-context.ts +29 -10
  95. package/src/orchestrator/run-failures.ts +62 -13
  96. package/src/orchestrator/run-keys.ts +1 -1
  97. package/src/orchestrator/run-records.ts +88 -59
  98. package/src/orchestrator/run-report.ts +1 -1
  99. package/src/orchestrator/run.ts +145 -41
  100. package/src/orchestrator/sandbox-request.ts +52 -9
  101. package/src/orchestrator/shell-verdict.ts +9 -0
  102. package/src/orchestrator/signals.ts +8 -5
  103. package/src/orchestrator/stable-keys.ts +17 -18
  104. package/src/orchestrator/task-artifacts.ts +1 -1
  105. package/src/orchestrator/task-hash.ts +21 -7
  106. package/src/orchestrator/telemetry.ts +5 -1
  107. package/src/report/events.ts +6 -14
  108. package/src/report/forecast.ts +10 -32
  109. package/src/report/framed-output.ts +283 -175
  110. package/src/report/index.ts +8 -3
  111. package/src/report/logger.ts +74 -39
  112. package/src/report/plain-output.ts +3 -1
  113. package/src/report/status-line.ts +49 -357
  114. package/src/report/summary.ts +105 -136
  115. package/src/settings.ts +8 -8
  116. package/src/ui/ansi.ts +48 -0
  117. package/src/ui/components.ts +506 -0
  118. package/src/ui/index.ts +79 -0
  119. package/src/ui/kit.ts +37 -0
  120. package/src/ui/live-region.ts +363 -0
  121. package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
  122. package/src/ui/terminal.ts +51 -0
  123. package/src/ui/theme.ts +224 -0
  124. package/src/ui/version-line.ts +28 -0
  125. package/src/util/config-frame.ts +191 -14
  126. package/src/util/edit-distance.ts +6 -1
  127. package/src/util/index.ts +2 -15
  128. package/src/util/paths.ts +21 -3
  129. package/src/util/secret-mask.ts +39 -3
  130. package/src/util/settle.ts +33 -1
  131. package/src/util/tail.ts +4 -1
  132. package/src/util/timing.ts +4 -16
  133. package/src/workspace/affected.ts +89 -30
  134. package/src/workspace/bracket-literal.ts +2 -2
  135. package/src/workspace/config-schema.ts +70 -63
  136. package/src/workspace/dependencies-lock.ts +191 -0
  137. package/src/workspace/filter.ts +36 -1
  138. package/src/workspace/index.ts +9 -0
  139. package/src/workspace/lockfile.ts +159 -6
  140. package/src/workspace/migration.ts +16 -9
  141. package/src/workspace/workspace.ts +3 -0
  142. package/src/util/colors.ts +0 -133
  143. package/src/util/report.ts +0 -44
  144. package/src/util/ui.ts +0 -258
  145. /package/src/{report/path-links.ts → ui/links.ts} +0 -0
  146. /package/src/{report → ui}/terminal-signals.ts +0 -0
@@ -8,11 +8,13 @@ import { EMPTY_LOCK, inputStabilityEnv, LOCK_INVALID, lockEntryBroken } from '..
8
8
  import { realpathSync } from 'node:fs'
9
9
  import path from 'node:path'
10
10
  import {
11
+ historyFiles,
11
12
  indexDb,
12
13
  Cache,
13
14
  type CachePolicy,
14
15
  cachesNothing,
15
16
  formatCachePolicy,
17
+ HistoryStream,
16
18
  networkConcurrency,
17
19
  keepRunNotes,
18
20
  stopRuntimeProbes,
@@ -28,10 +30,11 @@ import {
28
30
  } from '../exec/index.js'
29
31
  import { DeferredOutputs } from './deferred-outputs.js'
30
32
  import { resolveDownloadModes } from './download-policy.js'
31
- import type { MachineReport, TaskExecutor } from '../exec/index.js'
33
+ import type { MachineReport, TaskExecutor, WorkerAdvice } from '../exec/index.js'
32
34
  import {
33
35
  deadServerBehind,
34
36
  declaredTask,
37
+ isCacheHit,
35
38
  isGroupTask,
36
39
  markSurfacedDeps,
37
40
  runGraph,
@@ -39,11 +42,12 @@ import {
39
42
  type TaskNode,
40
43
  type TaskOutcome,
41
44
  } from '../graph/index.js'
45
+ import { graphCounts } from './graph-memo.js'
42
46
  import {
43
47
  mark,
44
48
  stageTimes,
45
49
  MAX_TIMEOUT_MS,
46
- printTimings,
50
+ timingLines,
47
51
  ulid,
48
52
  isLiteralPattern,
49
53
  listed,
@@ -53,13 +57,18 @@ import {
53
57
  secretMask,
54
58
  isFsRefusal,
55
59
  isUserError,
56
- detectColors,
57
- type ColorSupport,
58
60
  SecretBroker,
59
61
  thrownMessage,
60
- type ui,
61
62
  } from '../util/index.js'
62
- import { keyedProjects } from './keyed-projects.js'
63
+ import * as ui from '../ui/index.js'
64
+
65
+ /** The stage table `VX_DEBUG_TIMING=1` asks for, on stderr. */
66
+ function printTimings(): void {
67
+ const lines = timingLines()
68
+ if (lines !== undefined) ui.print('err', ui.trace(lines))
69
+ }
70
+ import { detectColors, type ColorSupport } from '../ui/index.js'
71
+ import { keyedProjects, readableDeps } from './keyed-projects.js'
63
72
  import { isDefaultBuild } from '../graph/index.js'
64
73
  import { commandWriteReach, prepareSandbox } from './sandbox-request.js'
65
74
  import { recordOutputDirSnapshots, type OutputDirSnapshot, type SaveFacts } from './miss-save.js'
@@ -70,7 +79,6 @@ import { excludedTaint } from './excluded-keys.js'
70
79
  import { dependedOn, keyUpstream } from './upstream.js'
71
80
  import { createLiveUsage } from './live-usage.js'
72
81
  import {
73
- blocksCounts,
74
82
  busLogger,
75
83
  createEventBus,
76
84
  createForecast,
@@ -133,7 +141,12 @@ import { deriveStableKeys, probesAfterWrites } from './stable-keys.js'
133
141
  import { askRemote, serversToHold } from './held-servers.js'
134
142
 
135
143
  import { writeRunFailures } from './run-failures.js'
136
- import { assembleRunRecords, recordLateRemoteErrors } from './run-records.js'
144
+ import {
145
+ assembleRunRecords,
146
+ recordLateRemoteErrors,
147
+ type RecordedOutcome,
148
+ runRecordOf,
149
+ } from './run-records.js'
137
150
  import {
138
151
  hasEnded,
139
152
  disposeServers,
@@ -150,6 +163,7 @@ import {
150
163
  createMissExplainer,
151
164
  detectFlaky,
152
165
  type FlakyCandidate,
166
+ knownFlaky,
153
167
  LocalHistoryProvider,
154
168
  } from '../history/index.js'
155
169
  import type { RunOptions, RunSummary } from './options.js'
@@ -356,6 +370,11 @@ export async function run(options: RunOptions): Promise<RunSummary> {
356
370
  const detach = (): void => {
357
371
  unsubscribeTerminal()
358
372
  unsubscribeWarned()
373
+ // A run refused or thrown before its tasks (an unknown task, a plugin
374
+ // stage that threw) never reached the end that takes the live region
375
+ // down: a load slow enough to show its step left `vx:load running`
376
+ // on screen, and the CLI's refusal was written onto its last line.
377
+ terminal?.runDone?.()
359
378
  terminal?.settle()
360
379
  }
361
380
  // A plugin's `output` renders the run from its start; core's renderer
@@ -442,11 +461,11 @@ async function runOnBus(
442
461
  options = withRunSettings(options, prepared.workspaceConfig)
443
462
  inputStability = {
444
463
  run: inputStabilityEnv(),
445
- ...(prepared.workspaceConfig?.cache?.inputStability !== undefined
446
- ? { workspace: prepared.workspaceConfig.cache.inputStability }
464
+ ...(prepared.workspaceConfig?.inputs?.stability !== undefined
465
+ ? { workspace: prepared.workspaceConfig.inputs.stability }
447
466
  : {}),
448
467
  }
449
- cleanOutputs = resolveSetting('cache.outputs.clean', prepared.workspaceConfig).value as
468
+ cleanOutputs = resolveSetting('outputs.clean', prepared.workspaceConfig).value as
450
469
  | boolean
451
470
  | undefined
452
471
  } catch (err) {
@@ -629,15 +648,15 @@ async function runOnBus(
629
648
  // deliberate — on a machine with no remote pool the ambient state already
630
649
  // is what the task would have produced — but it must not be SILENT: a task
631
650
  // reporting success while doing nothing is indistinguishable from one that
632
- // worked, and the usual cause (no `cache` block, so no executor can
651
+ // worked, and the usual cause (no `cache: true`, so no executor can
633
652
  // describe its inputs to a worker) cannot be read off the outcome.
634
653
  if (placements.remoteOnlyNoop.size > 0) {
635
654
  const anyRemote = executors.some((e) => e.remote === true)
636
655
  for (const id of placements.remoteOnlyNoop) {
637
656
  const why = !anyRemote
638
657
  ? 'no remote executor is declared'
639
- : nodes.get(id)?.config.cache === undefined
640
- ? 'no remote executor accepted it — the task declares no `cache` block, so there is no input set to ship'
658
+ : nodes.get(id)?.config.cache !== true
659
+ ? 'no remote executor accepted it — the task has no `cache: true`, so there is no input set to ship'
641
660
  : 'no remote executor accepted it'
642
661
  warnOf(log)(`[vx] ${id}: exec.remote is 'only' and ${why} — nothing ran`)
643
662
  }
@@ -843,7 +862,7 @@ async function runOnBus(
843
862
  // SRT's proxies holding the process open.
844
863
  const surelyRuns = (n: TaskNode): boolean =>
845
864
  n.config.exec?.persistent !== undefined ||
846
- n.config.cache === undefined ||
865
+ n.config.cache !== true ||
847
866
  !(policy.localRead || policy.remoteRead)
848
867
  // Only what runs here arms this machine's sandbox: a `sandbox` executor
849
868
  // enforces it where it runs the task, so a distributed run's
@@ -864,6 +883,7 @@ async function runOnBus(
864
883
  }
865
884
  if (sandboxed.some(surelyRuns)) prewarm()
866
885
  const keyed = keyedProjects(nodes, prepared.keyOnly)
886
+ const readable = readableDeps(nodes, prepared.keyOnly)
867
887
  const outputDirSnapshots: OutputDirSnapshot[] = []
868
888
  // Saves run off the execution slot, twice the cap at once (memory:
869
889
  // each pack holds an artifact's bytes); a failed save is a miss next
@@ -913,14 +933,39 @@ async function runOnBus(
913
933
  const fault = executorFaults(warnOf(log))
914
934
  const footers = executors.filter((e) => e.footer !== undefined)
915
935
  const placers = executors.filter((e) => e.running !== undefined)
916
- // Every row's `blocks`, the live list's and the results': one closure
917
- // over the graph, built on the first row that asks.
918
- let blocks: ReadonlyMap<string, number> | undefined
919
- const blocksNow = (): ReadonlyMap<string, number> => (blocks ??= blocksCounts(nodes))
936
+ // The scheduler's dependents count, and every row's `blocks` (the live
937
+ // list's and the results'): one closure over the graph, built on the
938
+ // first that asks.
939
+ let counted: ReadonlyMap<string, number> | undefined
940
+ const blocksNow = (): ReadonlyMap<string, number> =>
941
+ (counted ??= graphCounts(nodes, hashCache.folds, hashCache.graph))
942
+ // Every row's `usual` and the forecast's timings: history read once, on
943
+ // the first row that asks, so a run that executes nothing never reads
944
+ // it. It was read only a second into a live run, so rows before that,
945
+ // a short run's and every log's showed none (2026-10-29).
946
+ let p50s: ReadonlyMap<string, number> | undefined
947
+ const p50sNow = (): ReadonlyMap<string, number> => {
948
+ if (p50s !== undefined) return p50s
949
+ const ids: string[] = []
950
+ for (const n of nodes.values()) if (!isGroupTask(n)) ids.push(n.id)
951
+ try {
952
+ p50s = new LocalHistoryProvider(
953
+ indexDb(prepared.localCache),
954
+ undefined,
955
+ prepared.branch(),
956
+ taskDefinitions(nodes.values()),
957
+ ).p50sNow(ids)
958
+ } catch {
959
+ // History is observability: an unreadable one knows no task.
960
+ p50s = new Map()
961
+ }
962
+ return p50s
963
+ }
920
964
  // The live rows' `cpu` and `mem`; it looks only once the live list asks.
921
965
  const liveUsage = createLiveUsage()
922
966
  const runContext = {
923
967
  blocks: blocksNow,
968
+ usual: (id: string) => p50sNow().get(id),
924
969
  usage: liveUsage.usage,
925
970
  ...(footers.length > 0
926
971
  ? { executorRows: () => footers.flatMap((e) => executorFooter(e, fault)) }
@@ -979,17 +1024,7 @@ async function runOnBus(
979
1024
  // never reads history.
980
1025
  terminal?.forecast(
981
1026
  () => createForecast(nodes, new Map(), concurrency, blocksNow()),
982
- async () => {
983
- const ids: string[] = []
984
- for (const n of nodes.values()) if (!isGroupTask(n)) ids.push(n.id)
985
- const p50s = await new LocalHistoryProvider(
986
- indexDb(prepared.localCache),
987
- undefined,
988
- prepared.branch(),
989
- taskDefinitions(nodes.values()),
990
- ).p50sFor(ids)
991
- return createForecast(nodes, p50s, concurrency, blocksNow())
992
- },
1027
+ async () => createForecast(nodes, p50sNow(), concurrency, blocksNow()),
993
1028
  )
994
1029
 
995
1030
  // Remote-only: kick off background prefetches so remote-GET latency
@@ -1089,7 +1124,7 @@ async function runOnBus(
1089
1124
  (probe.hit !== null ? hits : missed).push(id)
1090
1125
  // An empty store was not asked: it holds none of them.
1091
1126
  if (coldLocal && !remote)
1092
- for (const [id, n] of nodes) if (n.config.cache !== undefined) missed.push(id)
1127
+ for (const [id, n] of nodes) if (n.config.cache === true) missed.push(id)
1093
1128
  demand.settle(hits, missed)
1094
1129
  }
1095
1130
  }
@@ -1128,6 +1163,8 @@ async function runOnBus(
1128
1163
  // The scheduler's fail-fast stop, as the retry loop of a task already
1129
1164
  // in flight sees it.
1130
1165
  const failFast = new AbortController()
1166
+ // Tasks a plugin's shared record names flaky (`ctx.flaky`).
1167
+ const sharedFlaky = new Set<string>()
1131
1168
  const scope: RunScope = {
1132
1169
  workspaceRoot,
1133
1170
  cache,
@@ -1143,6 +1180,7 @@ async function runOnBus(
1143
1180
  probesAfterWrites: lateProbes,
1144
1181
  fingerprintWatch,
1145
1182
  keyedProjects: keyed,
1183
+ readableDeps: readable,
1146
1184
  outputDirSnapshots,
1147
1185
  deferSave: saveLane.defer,
1148
1186
  deferredSaves,
@@ -1152,6 +1190,15 @@ async function runOnBus(
1152
1190
  inputStability,
1153
1191
  }
1154
1192
  if (options.retries !== undefined) scope.retries = options.retries
1193
+ scope.knownFlaky = (node, hash) => {
1194
+ if (sharedFlaky.has(node.id)) return true
1195
+ try {
1196
+ return knownFlaky(indexDb(prepared.localCache), node.projectName, node.taskName, hash)
1197
+ } catch {
1198
+ // History is observability: an unreadable one retries nothing.
1199
+ return false
1200
+ }
1201
+ }
1155
1202
  if (taskTimeoutDefault !== undefined) scope.timeout = taskTimeoutDefault
1156
1203
  if (cleanOutputs !== undefined) scope.cleanOutputs = cleanOutputs
1157
1204
  const sinkTrack = telemetry?.track
@@ -1228,6 +1275,9 @@ async function runOnBus(
1228
1275
  if (reprioritize !== undefined) reprioritize(merged)
1229
1276
  else if (!graphOver) refinedEarly = merged
1230
1277
  },
1278
+ flaky: (ids) => {
1279
+ for (const id of ids) if (nodes.has(id)) sharedFlaky.add(id)
1280
+ },
1231
1281
  })
1232
1282
  mark('schedule')
1233
1283
  // Judged as each task finishes, against the history BEFORE this run's
@@ -1268,10 +1318,35 @@ async function runOnBus(
1268
1318
  }
1269
1319
  }
1270
1320
  let lockBroken: boolean | undefined
1321
+ prepared.localCache.storeHeldBlobSizes()
1322
+ // A hit's row is whole when it finishes, so it is written while the
1323
+ // graph runs (history-stream.ts); every other row waits for the bundle.
1324
+ // A row with forward args is the bundle's too: only it digests them.
1325
+ const historyAt = historyFiles(prepared.localCache)
1326
+ const stream = historyAt === undefined ? undefined : new HistoryStream<TaskOutcome>(historyAt)
1327
+ const forwards = (options.forwardArgs?.length ?? 0) > 0
1328
+ const streamHit = (o: TaskOutcome): void => {
1329
+ if (stream === undefined || isGroupTask(o.node)) return
1330
+ if (!isCacheHit(o.status)) return
1331
+ if (o.wallclockStartNs === undefined || o.wallclockEndNs === undefined) return
1332
+ if (forwards && o.node.requested) return
1333
+ stream.add(
1334
+ o,
1335
+ runRecordOf(o as RecordedOutcome, {
1336
+ tainted: taintedRan,
1337
+ runId,
1338
+ startedAtMs: endedAtMsAtStart,
1339
+ endedAtMs: endedAtMsAtStart,
1340
+ blocks: blocksNow(),
1341
+ remoteTraffic: prepared.localCache.traffic.keys,
1342
+ }),
1343
+ )
1344
+ }
1271
1345
  const outcomes = await runGraph({
1272
1346
  nodes,
1273
1347
  concurrency,
1274
1348
  restoreConcurrency: options.restoreConcurrency ?? 2 * concurrency,
1349
+ reverseDeps: blocksNow,
1275
1350
  settledOf: (o) => deferredSaves.get(o.node.id),
1276
1351
  ...(hasPooledExecutor(executors) ? { poolOf: poolOfPlacement(placements) } : {}),
1277
1352
  ...(admit !== undefined ? { admit } : {}),
@@ -1280,8 +1355,8 @@ async function runOnBus(
1280
1355
  serverDied,
1281
1356
  onFailFast: () => failFast.abort(),
1282
1357
  signal: stop.signal,
1283
- onStart: (node, queuedMs) => {
1284
- log.taskStart?.(node, queuedMs === undefined ? undefined : { waitedMs: queuedMs })
1358
+ onStart: (node) => {
1359
+ log.taskStart?.(node)
1285
1360
  },
1286
1361
  onFinish: (o) => {
1287
1362
  if (cacheOff) o.cacheOff = true
@@ -1289,6 +1364,7 @@ async function runOnBus(
1289
1364
  judgeFlaky(o)
1290
1365
  keeper?.taskDone(o)
1291
1366
  log.taskComplete(o.node, o)
1367
+ streamHit(o)
1292
1368
  demand.finished(o.node.id)
1293
1369
  // A server that dies while the graph runs is no longer running:
1294
1370
  // its pin stayed `running` under the line naming its exit.
@@ -1380,6 +1456,9 @@ async function runOnBus(
1380
1456
  const hold =
1381
1457
  options.holdPersistent === true &&
1382
1458
  (!stop.signal.aborted || stop.signal.reason === CYCLE_INTERRUPT)
1459
+ // A stop already signalled every server once and waits out its grace:
1460
+ // a SIGTERM here, while a server ran its cleanup, cut it short.
1461
+ if (stop.signal.aborted) await stop.aborting?.catch(() => {})
1383
1462
  const { keepAlive, crashed: crashedPersistent } = await disposeServers({
1384
1463
  registry: persistentRegistry,
1385
1464
  nodes,
@@ -1390,6 +1469,9 @@ async function runOnBus(
1390
1469
  endedBeforeStop: stop.endedBeforeStop,
1391
1470
  })
1392
1471
 
1472
+ // Every batch the stream sent has answered: what landed is not written again.
1473
+ const streamed = await stream?.finish()
1474
+ const landed = new Set(streamed?.landed.map((s) => s.item))
1393
1475
  mark('run graph')
1394
1476
 
1395
1477
  // Clear the status line for good before the summary prints.
@@ -1488,7 +1570,9 @@ async function runOnBus(
1488
1570
  ci: ciContext,
1489
1571
  host: hostContext,
1490
1572
  withTelemetry: telemetry !== undefined,
1573
+ blocks: blocksNow(),
1491
1574
  remoteTraffic: prepared.localCache.traffic.keys,
1575
+ landed,
1492
1576
  })
1493
1577
  const records = recordsOf(list, ok)
1494
1578
  const recordHistory = (write: () => void): void => {
@@ -1572,7 +1656,13 @@ async function runOnBus(
1572
1656
  }
1573
1657
  // An upload that failed after the rows went in: its row says so too.
1574
1658
  if (prepared.localCache.uploads.failed > 0 && stop.stoppedBy === undefined && !historyAfterWait)
1575
- recordHistory(() => recordLateRemoteErrors(prepared.localCache, records.runs, runId))
1659
+ recordHistory(() =>
1660
+ recordLateRemoteErrors(
1661
+ prepared.localCache,
1662
+ [...records.runs, ...(streamed?.landed.map((s) => s.record) ?? [])],
1663
+ runId,
1664
+ ),
1665
+ )
1576
1666
  const report = reportOf(list, { runId, commit: head.commitSha, ciRun: ciContext, ok }, keeper)
1577
1667
  // Hand the per-run summary to the telemetry sinks + drain them. Only
1578
1668
  // when a sink is active (telemetry !== undefined) — otherwise this
@@ -1592,6 +1682,7 @@ async function runOnBus(
1592
1682
  uploads: prepared.localCache.uploads,
1593
1683
  machines: executors.flatMap((e) => executorMachines(e, fault)),
1594
1684
  idealMs: executors.map((e) => executorIdeal(e, fault)).find((ms) => ms !== undefined),
1685
+ advice: executors.flatMap((e) => executorAdvice(e, fault)),
1595
1686
  })
1596
1687
  telemetry.emitSummary(summary)
1597
1688
  const sinks = telemetry
@@ -1950,12 +2041,15 @@ function didYouMean(
1950
2041
  elsewhere: readonly string[],
1951
2042
  taskless: (name: string) => boolean,
1952
2043
  ): string {
1953
- const tasksOf = (p: ProjectEntry | undefined): string[] => Object.keys(p?.config.tasks ?? {})
1954
- // What a bare name can select: a default `build` is no match (X-102).
2044
+ // A default `build` is no declared task (X-102): neither a bare name's
2045
+ // match nor a menu item or hint for `p#typo`, which led to `p#build`
2046
+ // and its refusal ("p declares no build").
2047
+ const tasksOf = (p: ProjectEntry | undefined): string[] =>
2048
+ Object.entries(p?.config.tasks ?? {})
2049
+ .filter(([, c]) => !isDefaultBuild(c))
2050
+ .map(([t]) => t)
1955
2051
  const allTasks = new Set<string>()
1956
- for (const p of projects.values()) {
1957
- for (const t of tasksOf(p)) if (!isDefaultBuild(p.config.tasks![t])) allTasks.add(t)
1958
- }
2052
+ for (const p of projects.values()) for (const t of tasksOf(p)) allTasks.add(t)
1959
2053
  // A Set: two typos of the same task hint it once, not once per typo.
1960
2054
  const hints = new Set<string>()
1961
2055
  // A typo past two edits named nothing to pick from (M-56): with no near
@@ -2065,10 +2159,10 @@ function flakyCandidates(
2065
2159
  for (const o of outcomes) {
2066
2160
  if (o.status !== 'success' && o.status !== 'failed') continue
2067
2161
  // "Same inputs, failed after passing" is a claim only a task with declared
2068
- // inputs can make: a task with no `cache` block keys on its config alone
2162
+ // inputs can make: a task without `cache: true` keys on its config alone
2069
2163
  // and runs every time, so one bad network day would read as a flake for
2070
2164
  // thirty days. Groups do no work.
2071
- if (o.hash === undefined || o.node.config.cache === undefined || isGroupTask(o.node)) continue
2165
+ if (o.hash === undefined || o.node.config.cache !== true || isGroupTask(o.node)) continue
2072
2166
  // Behind a failed dependency the outcome is not the key's: a failure is
2073
2167
  // the dependency's, and a pass built on bytes the key never named, so a
2074
2168
  // later failure relapsed from it and read as a flake for thirty days.
@@ -2153,6 +2247,16 @@ function executorMachines(e: TaskExecutor, fault: ExecutorFault): readonly Machi
2153
2247
  }
2154
2248
  }
2155
2249
 
2250
+ function executorAdvice(e: TaskExecutor, fault: ExecutorFault): readonly WorkerAdvice[] {
2251
+ try {
2252
+ const a = e.advice?.()
2253
+ return Array.isArray(a) ? a : []
2254
+ } catch (err) {
2255
+ fault(e, 'advice', err)
2256
+ return []
2257
+ }
2258
+ }
2259
+
2156
2260
  function executorIdeal(e: TaskExecutor, fault: ExecutorFault): number | undefined {
2157
2261
  try {
2158
2262
  const ms = e.idealMs?.()
@@ -183,6 +183,10 @@ export function prepareSandbox(nodes: Iterable<SandboxedNode>): SandboxArmer | n
183
183
  * anyone's (2026-09-24): `keyed` is the set of project directories the
184
184
  * task's key answers for (keyed-projects.ts), and `undefined` for a task
185
185
  * with no `cache`, which has no key to be stale.
186
+ *
187
+ * The other is the outputs of the tasks its key folds
188
+ * (`dependencyOutputs`, 2026-11-06): an edge the user drew, whose files
189
+ * this key already answers for, so the read can replay nothing stale.
186
190
  */
187
191
  export async function sandboxRequestFor(
188
192
  node: Pick<TaskNode, 'projectDir'>,
@@ -197,6 +201,8 @@ export async function sandboxRequestFor(
197
201
  * grant covered it could plant artifacts and index rows (L-24).
198
202
  */
199
203
  cacheDir?: string,
204
+ /** The dependencies' outputs it may read (`dependencyReads`): absolute paths. */
205
+ dependencyOutputs: readonly string[] = [],
200
206
  ): Promise<SandboxRequest> {
201
207
  // The runtime reads a path holding a bracket as a pattern: on Linux it
202
208
  // mounts no such write path (B-60); seatbelt's rules compile it as a
@@ -238,7 +244,7 @@ export async function sandboxRequestFor(
238
244
  // own `package.json` depends on (2026-09-05) — when the key
239
245
  // moves with it.
240
246
  const links = await linkedDeps(depDirs, node.projectDir, workspaceRoot, keyed)
241
- depDirs.push(...links.granted)
247
+ depDirs.push(...links.granted, ...dependencyOutputs)
242
248
  // bwrap cannot --bind a path that does not exist: the bind silently
243
249
  // becomes a no-op and writes to it appear to succeed but never land.
244
250
  // Pre-create what the task said it will write — after the grants are
@@ -269,7 +275,8 @@ export async function sandboxRequestFor(
269
275
  throw err
270
276
  }
271
277
  const request: NonNullable<ExecuteRequest['sandbox']> = {
272
- // Only what the task declared, plus node_modules. Write paths are
278
+ // Only what the task declared, plus node_modules and the outputs of
279
+ // the tasks its key folds (`dependencyReads`). Write paths are
273
280
  // readable too: a task that writes `dist/x` expects to read it back
274
281
  // (`tsc --incremental` re-reads .tsbuildinfo).
275
282
  baseAllowRead: depDirs,
@@ -304,10 +311,46 @@ export async function sandboxRequestFor(
304
311
  reportLinked: links.withheld.map((w) => w.dir),
305
312
  config,
306
313
  ...(keyed === undefined ? {} : { keyed: [...keyed] }),
314
+ ...(dependencyOutputs.length === 0 ? {} : { dependencyReads: [...dependencyOutputs] }),
307
315
  }
308
316
  return { sandbox: request, placeholders, withheld: links.withheld }
309
317
  }
310
318
 
319
+ /**
320
+ * The paths a sandboxed task may read of its dependencies' declared
321
+ * outputs (`readableDeps`): each output glob's fixed head, or, for a glob
322
+ * with none (`*.tsbuildinfo`), the files it matches now, so a root-level
323
+ * output never grants the dependency's whole project. Asked when the task
324
+ * runs, after its dependencies ran or were restored: a head that names
325
+ * nothing is left out, as bwrap binds no missing path.
326
+ */
327
+ export async function dependencyReads(
328
+ deps: readonly TaskNode[],
329
+ workspaceRoot: string,
330
+ ): Promise<string[]> {
331
+ const reads = new Set<string>()
332
+ for (const dep of deps) {
333
+ const outputs = dep.config.outputs
334
+ if (outputs === undefined) continue
335
+ const globs = [
336
+ ...outputs.files.map((g) => [dep.projectDir, g] as const),
337
+ ...(outputs.workspaceFiles ?? []).map((g) => [workspaceRoot, g] as const),
338
+ ]
339
+ for (const [base, glob] of globs) {
340
+ if (glob.startsWith('!')) continue
341
+ const head = grantPrefix(glob)
342
+ if (head !== '' && head !== '.') {
343
+ const abs = path.join(base, head)
344
+ if (existsSync(abs)) reads.add(abs)
345
+ continue
346
+ }
347
+ for await (const f of new Bun.Glob(glob).scan({ cwd: base, onlyFiles: true, dot: true }))
348
+ reads.add(path.join(base, f))
349
+ }
350
+ }
351
+ return [...reads]
352
+ }
353
+
311
354
  /**
312
355
  * The sandbox half of the request, plus the empty files vx created so a
313
356
  * literal write grant had something to bind (`placeholders`). They are
@@ -446,8 +489,8 @@ export function withheldLinkLine(taskId: string, w: WithheldLink): string {
446
489
  return (
447
490
  `vx: ${taskId} read \`${w.target}\` through \`${w.link}\`, and its key includes no task of ` +
448
491
  `${w.name}, so an edit there would not re-run it. Add a \`dependsOn\` edge that ` +
449
- `reaches one (\`^build\` where ${w.name}#build keys its sources, or a \`source\` task: ` +
450
- `\`${w.name}#source\`), or grant and key the files yourself (\`allow.read\` plus ` +
492
+ `reaches one (\`^build\`, where ${w.name}#build keys its sources), or grant and key ` +
493
+ `the files yourself (\`allow.read\` plus ` +
451
494
  `\`inputs.workspaceFiles\`).`
452
495
  )
453
496
  }
@@ -573,7 +616,7 @@ function wallOff(
573
616
  if (wall === undefined) continue
574
617
  throw new UserError(
575
618
  `exec.sandbox.allow.write: the grant binding ${bind} would make ${wall} writable — another ` +
576
- `project's directory, the repository or vx's own. A file grant binds its directory; ` +
619
+ `project's directory, .git or vx's own. A file grant binds its directory; ` +
577
620
  `grant a directory of the task's own instead, such as "dist/".`,
578
621
  )
579
622
  }
@@ -774,13 +817,13 @@ export type WriteReach = 'none' | 'project' | 'workspace'
774
817
  * names — the files whose run-start facts (the git snapshot, its index
775
818
  * OIDs) a later key must not reuse (item 743).
776
819
  *
777
- * A cached task declares its outputs and is held to them. A task with no
778
- * `cache` block can declare none, so it may have written anywhere
779
- * `commandWriteReach` says. An unsandboxed write into another project is
820
+ * A cached task declares its outputs and is held to them: its save checks
821
+ * them. A task with no `cache: true` is held to nothing, even when it
822
+ * declares outputs, so it may have written anywhere `commandWriteReach` says. An unsandboxed write into another project is
780
823
  * out of contract, as it is for a cached task.
781
824
  */
782
825
  export function undeclaredWriteReach(node: TaskNode, workspaceRoot: string): WriteReach {
783
- if (node.config.cache !== undefined) return 'none'
826
+ if (node.config.cache === true) return 'none'
784
827
  return commandWriteReach(node, workspaceRoot)
785
828
  }
786
829
 
@@ -43,6 +43,15 @@ export function shellVerdict(args: {
43
43
  if (args.code === 126) {
44
44
  return `[vx] exit 126 is the shell's "found but cannot execute": ${what} is not executable or is a directory — chmod +x it`
45
45
  }
46
+ // A program the host's PATH has may be one the sandbox hides (a
47
+ // hermetic task sees no /usr/bin): the reason is a grant, not an install.
48
+ const found =
49
+ word === undefined || args.hidden === undefined
50
+ ? null
51
+ : Bun.which(word, { PATH: [...args.bins, process.env['PATH'] ?? ''].join(path.delimiter) })
52
+ if (found !== null && args.hidden!(found)) {
53
+ return `[vx] exit 127 is the shell's "command not found": ${word} is ${found} on the host, but no sandbox grant reads it, so inside the sandbox it is not there — add it (or its directory) to exec.sandbox.allow.read`
54
+ }
46
55
  const unnamed = args.bins.find((dir) => dir.includes(path.delimiter))
47
56
  if (unnamed !== undefined) {
48
57
  return `[vx] exit 127 is the shell's "command not found": ${what} is not on this task's PATH — ${unnamed} holds "${path.delimiter}", which PATH reads as a separator, so vx cannot put it there; move the workspace to a path without "${path.delimiter}"`
@@ -14,7 +14,13 @@
14
14
  // them to the runner around every spawn.
15
15
 
16
16
  import { signalExitCode } from '../exec/index.js'
17
- import { claimExitForSignal, hangupIgnored, killGraceMs, settleWithin } from '../util/index.js'
17
+ import {
18
+ claimExitForSignal,
19
+ dieOf,
20
+ hangupIgnored,
21
+ killGraceMs,
22
+ settleWithin,
23
+ } from '../util/index.js'
18
24
  import {
19
25
  holdGroups,
20
26
  killTree,
@@ -154,10 +160,7 @@ export function forwardSignals(args: {
154
160
  // and a 130 exit ran the script's next line. The code stays the
155
161
  // fallback should the signal not land.
156
162
  if (args.handsTerminal !== true) {
157
- process.once('exit', () => {
158
- process.removeAllListeners(signal)
159
- process.kill(process.pid, signal)
160
- })
163
+ process.once('exit', () => dieOf(signal))
161
164
  }
162
165
  process.exit(signalExitCode(signal))
163
166
  }