@vzn/vx 0.0.651 → 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 (78) hide show
  1. package/README.md +2 -3
  2. package/package.json +5 -5
  3. package/schemas/show.json +14 -20
  4. package/src/bin.ts +12 -0
  5. package/src/cache/archive.ts +1 -1
  6. package/src/cache/cache.ts +19 -5
  7. package/src/cache/history-stream.ts +134 -0
  8. package/src/cache/history-writer.ts +76 -0
  9. package/src/cache/index.ts +2 -0
  10. package/src/cache/inputs.ts +74 -14
  11. package/src/cache/layer.ts +6 -6
  12. package/src/cache/lockfile-memo.ts +2 -1
  13. package/src/cache/run-history.ts +46 -17
  14. package/src/cache/schema.ts +4 -1
  15. package/src/cli/cache.ts +2 -2
  16. package/src/cli/help.ts +2 -2
  17. package/src/cli/index.ts +24 -0
  18. package/src/cli/info.ts +1 -1
  19. package/src/cli/last.ts +34 -2
  20. package/src/cli/plugin-templates.ts +36 -45
  21. package/src/cli/show.ts +7 -10
  22. package/src/cli/watch-judge.ts +1 -1
  23. package/src/cli/watch-set.ts +4 -4
  24. package/src/cli/watch.ts +11 -8
  25. package/src/config.ts +47 -30
  26. package/src/exec/executor.ts +40 -1
  27. package/src/exec/index.ts +1 -0
  28. package/src/exec/sandbox-paths.ts +3 -1
  29. package/src/exec/sandbox-runtime.ts +314 -29
  30. package/src/exec/task-proxy.ts +93 -0
  31. package/src/graph/definition.ts +16 -3
  32. package/src/graph/scheduler.ts +3 -1
  33. package/src/graph/task-graph.ts +24 -23
  34. package/src/history/failure-mode.ts +24 -1
  35. package/src/history/index.ts +1 -0
  36. package/src/history/metrics.ts +5 -5
  37. package/src/index.ts +4 -2
  38. package/src/orchestrator/admission.ts +1 -1
  39. package/src/orchestrator/command-windows.ts +1 -1
  40. package/src/orchestrator/deferred-outputs.ts +1 -1
  41. package/src/orchestrator/download-policy.ts +5 -7
  42. package/src/orchestrator/excluded-keys.ts +1 -1
  43. package/src/orchestrator/execute-task.ts +51 -18
  44. package/src/orchestrator/hit-restore.ts +5 -6
  45. package/src/orchestrator/keyed-projects.ts +28 -0
  46. package/src/orchestrator/local-shortcircuit.ts +2 -2
  47. package/src/orchestrator/miss-save.ts +27 -8
  48. package/src/orchestrator/persistent-task.ts +2 -0
  49. package/src/orchestrator/placement.ts +1 -1
  50. package/src/orchestrator/plan.ts +2 -2
  51. package/src/orchestrator/plugin-host.ts +6 -2
  52. package/src/orchestrator/plugin.ts +6 -0
  53. package/src/orchestrator/remote-prefetch.ts +1 -1
  54. package/src/orchestrator/run-failures.ts +61 -12
  55. package/src/orchestrator/run-keys.ts +1 -1
  56. package/src/orchestrator/run-records.ts +86 -67
  57. package/src/orchestrator/run-report.ts +1 -1
  58. package/src/orchestrator/run.ts +92 -19
  59. package/src/orchestrator/sandbox-request.ts +50 -7
  60. package/src/orchestrator/shell-verdict.ts +9 -0
  61. package/src/orchestrator/stable-keys.ts +17 -18
  62. package/src/orchestrator/task-artifacts.ts +1 -1
  63. package/src/orchestrator/task-hash.ts +4 -4
  64. package/src/orchestrator/telemetry.ts +5 -1
  65. package/src/report/events.ts +3 -3
  66. package/src/report/framed-output.ts +16 -7
  67. package/src/report/logger.ts +1 -1
  68. package/src/report/plain-output.ts +2 -0
  69. package/src/report/summary.ts +5 -5
  70. package/src/settings.ts +8 -8
  71. package/src/util/config-frame.ts +128 -6
  72. package/src/util/paths.ts +1 -1
  73. package/src/util/settle.ts +2 -1
  74. package/src/util/tail.ts +4 -1
  75. package/src/workspace/bracket-literal.ts +2 -2
  76. package/src/workspace/config-schema.ts +66 -61
  77. package/src/workspace/filter.ts +8 -1
  78. package/src/workspace/migration.ts +12 -6
@@ -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,
@@ -65,7 +68,7 @@ function printTimings(): void {
65
68
  if (lines !== undefined) ui.print('err', ui.trace(lines))
66
69
  }
67
70
  import { detectColors, type ColorSupport } from '../ui/index.js'
68
- import { keyedProjects } from './keyed-projects.js'
71
+ import { keyedProjects, readableDeps } from './keyed-projects.js'
69
72
  import { isDefaultBuild } from '../graph/index.js'
70
73
  import { commandWriteReach, prepareSandbox } from './sandbox-request.js'
71
74
  import { recordOutputDirSnapshots, type OutputDirSnapshot, type SaveFacts } from './miss-save.js'
@@ -138,7 +141,12 @@ import { deriveStableKeys, probesAfterWrites } from './stable-keys.js'
138
141
  import { askRemote, serversToHold } from './held-servers.js'
139
142
 
140
143
  import { writeRunFailures } from './run-failures.js'
141
- import { assembleRunRecords, recordLateRemoteErrors } from './run-records.js'
144
+ import {
145
+ assembleRunRecords,
146
+ recordLateRemoteErrors,
147
+ type RecordedOutcome,
148
+ runRecordOf,
149
+ } from './run-records.js'
142
150
  import {
143
151
  hasEnded,
144
152
  disposeServers,
@@ -155,6 +163,7 @@ import {
155
163
  createMissExplainer,
156
164
  detectFlaky,
157
165
  type FlakyCandidate,
166
+ knownFlaky,
158
167
  LocalHistoryProvider,
159
168
  } from '../history/index.js'
160
169
  import type { RunOptions, RunSummary } from './options.js'
@@ -452,11 +461,11 @@ async function runOnBus(
452
461
  options = withRunSettings(options, prepared.workspaceConfig)
453
462
  inputStability = {
454
463
  run: inputStabilityEnv(),
455
- ...(prepared.workspaceConfig?.cache?.inputStability !== undefined
456
- ? { workspace: prepared.workspaceConfig.cache.inputStability }
464
+ ...(prepared.workspaceConfig?.inputs?.stability !== undefined
465
+ ? { workspace: prepared.workspaceConfig.inputs.stability }
457
466
  : {}),
458
467
  }
459
- cleanOutputs = resolveSetting('cache.outputs.clean', prepared.workspaceConfig).value as
468
+ cleanOutputs = resolveSetting('outputs.clean', prepared.workspaceConfig).value as
460
469
  | boolean
461
470
  | undefined
462
471
  } catch (err) {
@@ -639,15 +648,15 @@ async function runOnBus(
639
648
  // deliberate — on a machine with no remote pool the ambient state already
640
649
  // is what the task would have produced — but it must not be SILENT: a task
641
650
  // reporting success while doing nothing is indistinguishable from one that
642
- // 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
643
652
  // describe its inputs to a worker) cannot be read off the outcome.
644
653
  if (placements.remoteOnlyNoop.size > 0) {
645
654
  const anyRemote = executors.some((e) => e.remote === true)
646
655
  for (const id of placements.remoteOnlyNoop) {
647
656
  const why = !anyRemote
648
657
  ? 'no remote executor is declared'
649
- : nodes.get(id)?.config.cache === undefined
650
- ? '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'
651
660
  : 'no remote executor accepted it'
652
661
  warnOf(log)(`[vx] ${id}: exec.remote is 'only' and ${why} — nothing ran`)
653
662
  }
@@ -853,7 +862,7 @@ async function runOnBus(
853
862
  // SRT's proxies holding the process open.
854
863
  const surelyRuns = (n: TaskNode): boolean =>
855
864
  n.config.exec?.persistent !== undefined ||
856
- n.config.cache === undefined ||
865
+ n.config.cache !== true ||
857
866
  !(policy.localRead || policy.remoteRead)
858
867
  // Only what runs here arms this machine's sandbox: a `sandbox` executor
859
868
  // enforces it where it runs the task, so a distributed run's
@@ -874,6 +883,7 @@ async function runOnBus(
874
883
  }
875
884
  if (sandboxed.some(surelyRuns)) prewarm()
876
885
  const keyed = keyedProjects(nodes, prepared.keyOnly)
886
+ const readable = readableDeps(nodes, prepared.keyOnly)
877
887
  const outputDirSnapshots: OutputDirSnapshot[] = []
878
888
  // Saves run off the execution slot, twice the cap at once (memory:
879
889
  // each pack holds an artifact's bytes); a failed save is a miss next
@@ -1114,7 +1124,7 @@ async function runOnBus(
1114
1124
  (probe.hit !== null ? hits : missed).push(id)
1115
1125
  // An empty store was not asked: it holds none of them.
1116
1126
  if (coldLocal && !remote)
1117
- 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)
1118
1128
  demand.settle(hits, missed)
1119
1129
  }
1120
1130
  }
@@ -1153,6 +1163,8 @@ async function runOnBus(
1153
1163
  // The scheduler's fail-fast stop, as the retry loop of a task already
1154
1164
  // in flight sees it.
1155
1165
  const failFast = new AbortController()
1166
+ // Tasks a plugin's shared record names flaky (`ctx.flaky`).
1167
+ const sharedFlaky = new Set<string>()
1156
1168
  const scope: RunScope = {
1157
1169
  workspaceRoot,
1158
1170
  cache,
@@ -1168,6 +1180,7 @@ async function runOnBus(
1168
1180
  probesAfterWrites: lateProbes,
1169
1181
  fingerprintWatch,
1170
1182
  keyedProjects: keyed,
1183
+ readableDeps: readable,
1171
1184
  outputDirSnapshots,
1172
1185
  deferSave: saveLane.defer,
1173
1186
  deferredSaves,
@@ -1177,6 +1190,15 @@ async function runOnBus(
1177
1190
  inputStability,
1178
1191
  }
1179
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
+ }
1180
1202
  if (taskTimeoutDefault !== undefined) scope.timeout = taskTimeoutDefault
1181
1203
  if (cleanOutputs !== undefined) scope.cleanOutputs = cleanOutputs
1182
1204
  const sinkTrack = telemetry?.track
@@ -1253,6 +1275,9 @@ async function runOnBus(
1253
1275
  if (reprioritize !== undefined) reprioritize(merged)
1254
1276
  else if (!graphOver) refinedEarly = merged
1255
1277
  },
1278
+ flaky: (ids) => {
1279
+ for (const id of ids) if (nodes.has(id)) sharedFlaky.add(id)
1280
+ },
1256
1281
  })
1257
1282
  mark('schedule')
1258
1283
  // Judged as each task finishes, against the history BEFORE this run's
@@ -1294,6 +1319,29 @@ async function runOnBus(
1294
1319
  }
1295
1320
  let lockBroken: boolean | undefined
1296
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
+ }
1297
1345
  const outcomes = await runGraph({
1298
1346
  nodes,
1299
1347
  concurrency,
@@ -1316,6 +1364,7 @@ async function runOnBus(
1316
1364
  judgeFlaky(o)
1317
1365
  keeper?.taskDone(o)
1318
1366
  log.taskComplete(o.node, o)
1367
+ streamHit(o)
1319
1368
  demand.finished(o.node.id)
1320
1369
  // A server that dies while the graph runs is no longer running:
1321
1370
  // its pin stayed `running` under the line naming its exit.
@@ -1420,6 +1469,9 @@ async function runOnBus(
1420
1469
  endedBeforeStop: stop.endedBeforeStop,
1421
1470
  })
1422
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))
1423
1475
  mark('run graph')
1424
1476
 
1425
1477
  // Clear the status line for good before the summary prints.
@@ -1520,6 +1572,7 @@ async function runOnBus(
1520
1572
  withTelemetry: telemetry !== undefined,
1521
1573
  blocks: blocksNow(),
1522
1574
  remoteTraffic: prepared.localCache.traffic.keys,
1575
+ landed,
1523
1576
  })
1524
1577
  const records = recordsOf(list, ok)
1525
1578
  const recordHistory = (write: () => void): void => {
@@ -1603,7 +1656,13 @@ async function runOnBus(
1603
1656
  }
1604
1657
  // An upload that failed after the rows went in: its row says so too.
1605
1658
  if (prepared.localCache.uploads.failed > 0 && stop.stoppedBy === undefined && !historyAfterWait)
1606
- 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
+ )
1607
1666
  const report = reportOf(list, { runId, commit: head.commitSha, ciRun: ciContext, ok }, keeper)
1608
1667
  // Hand the per-run summary to the telemetry sinks + drain them. Only
1609
1668
  // when a sink is active (telemetry !== undefined) — otherwise this
@@ -1623,6 +1682,7 @@ async function runOnBus(
1623
1682
  uploads: prepared.localCache.uploads,
1624
1683
  machines: executors.flatMap((e) => executorMachines(e, fault)),
1625
1684
  idealMs: executors.map((e) => executorIdeal(e, fault)).find((ms) => ms !== undefined),
1685
+ advice: executors.flatMap((e) => executorAdvice(e, fault)),
1626
1686
  })
1627
1687
  telemetry.emitSummary(summary)
1628
1688
  const sinks = telemetry
@@ -1981,12 +2041,15 @@ function didYouMean(
1981
2041
  elsewhere: readonly string[],
1982
2042
  taskless: (name: string) => boolean,
1983
2043
  ): string {
1984
- const tasksOf = (p: ProjectEntry | undefined): string[] => Object.keys(p?.config.tasks ?? {})
1985
- // 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)
1986
2051
  const allTasks = new Set<string>()
1987
- for (const p of projects.values()) {
1988
- for (const t of tasksOf(p)) if (!isDefaultBuild(p.config.tasks![t])) allTasks.add(t)
1989
- }
2052
+ for (const p of projects.values()) for (const t of tasksOf(p)) allTasks.add(t)
1990
2053
  // A Set: two typos of the same task hint it once, not once per typo.
1991
2054
  const hints = new Set<string>()
1992
2055
  // A typo past two edits named nothing to pick from (M-56): with no near
@@ -2096,10 +2159,10 @@ function flakyCandidates(
2096
2159
  for (const o of outcomes) {
2097
2160
  if (o.status !== 'success' && o.status !== 'failed') continue
2098
2161
  // "Same inputs, failed after passing" is a claim only a task with declared
2099
- // 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
2100
2163
  // and runs every time, so one bad network day would read as a flake for
2101
2164
  // thirty days. Groups do no work.
2102
- 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
2103
2166
  // Behind a failed dependency the outcome is not the key's: a failure is
2104
2167
  // the dependency's, and a pass built on bytes the key never named, so a
2105
2168
  // later failure relapsed from it and read as a flake for thirty days.
@@ -2184,6 +2247,16 @@ function executorMachines(e: TaskExecutor, fault: ExecutorFault): readonly Machi
2184
2247
  }
2185
2248
  }
2186
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
+
2187
2260
  function executorIdeal(e: TaskExecutor, fault: ExecutorFault): number | undefined {
2188
2261
  try {
2189
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
@@ -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}"`
@@ -89,14 +89,14 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
89
89
  // reached through a no-output intermediate still poisons the key, so each
90
90
  // dep's accumulated producers fold forward.
91
91
  // - outputProjects: the project names of every upstream task declaring
92
- // cache.outputs.files (project-relative outputs land in the producer's
93
- // own dir), and of every upstream with no cache block that may write
92
+ // outputs.files (project-relative outputs land in the producer's
93
+ // own dir), and of every upstream with no `cache: true` that may write
94
94
  // in its own project (`undeclaredWriteReach`, item 743). A bitset over
95
95
  // project indexes: a union per dep was a string Set copied tasks ×
96
96
  // deps × projects times, 101 ms of a 513 ms warm run at 476 packages
97
97
  // (item 744).
98
- // - wsOutputUpstream: any upstream declares cache.outputs.workspaceFiles
99
- // (root-anchored, boundary-ignoring outputs), or has no cache block
98
+ // - wsOutputUpstream: any upstream declares outputs.workspaceFiles
99
+ // (root-anchored, boundary-ignoring outputs), or has no `cache: true`
100
100
  // and a sandbox write grant elsewhere in the workspace, or may rewrite
101
101
  // a root file a plugin claims (a root `pnpm install`): every key folds
102
102
  // what the claimant took from it, once per run.
@@ -125,7 +125,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
125
125
  // of what any one reader's upstream producers declare.
126
126
  const outputsOf = new Map<string, string[]>()
127
127
  for (const n of args.nodes.values()) {
128
- const files = n.config.cache?.outputs.files ?? []
128
+ const files = n.config.outputs?.files ?? []
129
129
  if (files.length === 0) continue
130
130
  const list = outputsOf.get(n.projectName)
131
131
  if (list === undefined) outputsOf.set(n.projectName, [...files])
@@ -185,10 +185,10 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
185
185
  const wideOfDep = wideById.get(dep)
186
186
  if (wideOfDep !== undefined) wideUnion.addAll(wideOfDep)
187
187
  if (wsOutputUpstreamById.get(dep) === true) wsOutputUpstream = true
188
- const depOut = depNode.config.cache?.outputs
188
+ const depOut = depNode.config.outputs
189
189
  if ((depOut?.files?.length ?? 0) > 0) outputs.add(depNode.projectName)
190
190
  if ((depOut?.workspaceFiles?.length ?? 0) > 0) wsOutputUpstream = true
191
- // An uncached producer declares no outputs, yet a same-project reader
191
+ // An uncached producer is held to no outputs, yet a same-project reader
192
192
  // after it (`tasks: []` decouples the key, not the order) read the
193
193
  // bytes it wrote: its key taken up front was the pre-producer one, and
194
194
  // the fourth run of seeds A,B,B,A replayed B over A (turborepo#13788).
@@ -198,7 +198,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
198
198
  wideUnion.add(depNode.projectName)
199
199
  } else if (reach === 'workspace') wsOutputUpstream = true
200
200
  if (
201
- depNode.config.cache === undefined &&
201
+ depNode.config.cache !== true &&
202
202
  mayWriteFingerprint(depNode, args.workspaceRoot, args.claimedFiles)
203
203
  )
204
204
  wsOutputUpstream = true
@@ -219,7 +219,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
219
219
  for (const dep of anyUnfolded ? node.deps : []) {
220
220
  const depNode = args.nodes.get(dep)
221
221
  if (!depNode) continue
222
- const cached = depNode.config.cache !== undefined
222
+ const cached = depNode.config.cache === true
223
223
  // The cached twin: a formatter rewriting its own input ahead of a
224
224
  // reader that folds no key of it replayed seed B's build under seed A
225
225
  // on the fourth run, the same way (item 750).
@@ -323,7 +323,7 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
323
323
  )
324
324
  if (unstable) unstableById.add(id)
325
325
 
326
- const cacheEnabled = node.config.cache !== undefined
326
+ const cacheEnabled = node.config.cache === true
327
327
  if (cacheEnabled && !unstable) stableKeys.push({ hash, node })
328
328
  // An uncached key stays preliminary behind any upstream that may write
329
329
  // into this project, or anywhere: none of its writes is declared.
@@ -438,7 +438,7 @@ export function gatedOutputs(
438
438
  // 1,000 projects' tasks spent 5-8 ms here re-splitting the same globs.
439
439
  const outputsOf = new Map<string, ProjectOutputs>()
440
440
  for (const n of nodes.values()) {
441
- const files = n.config.cache?.outputs.files
441
+ const files = n.config.outputs?.files
442
442
  if (files === undefined || files.length === 0) continue
443
443
  let po = outputsOf.get(n.projectName)
444
444
  if (po === undefined) outputsOf.set(n.projectName, (po = new ProjectOutputs()))
@@ -554,8 +554,8 @@ export function probesAfterWrites(
554
554
  for (const n of nodes.values()) {
555
555
  if (
556
556
  commandWriteReach(n, workspaceRoot) !== 'none' ||
557
- (n.config.cache?.outputs.files.length ?? 0) > 0 ||
558
- (n.config.cache?.outputs.workspaceFiles?.length ?? 0) > 0
557
+ (n.config.outputs?.files.length ?? 0) > 0 ||
558
+ (n.config.outputs?.workspaceFiles?.length ?? 0) > 0
559
559
  )
560
560
  writers.add(n.id)
561
561
  }
@@ -581,7 +581,7 @@ export function probesAfterWrites(
581
581
  }
582
582
  if (writer) writerBelow.add(id)
583
583
  if (unfolded) unfoldedBelow.add(id)
584
- if (unfolded && node.config.cache !== undefined && probes(node)) late.add(id)
584
+ if (unfolded && node.config.cache === true && probes(node)) late.add(id)
585
585
  }
586
586
  return late
587
587
  }
@@ -628,8 +628,8 @@ function synthUpstream(
628
628
  * Conservative stability check: does any TRANSITIVE-upstream task whose
629
629
  * outputs could land where this task reads its inputs make this task's
630
630
  * key preliminary? The producers are pre-folded by `deriveStableKeys`
631
- * (`upstreamOutputProjects` = projects declaring `cache.outputs.files`
632
- * upstream; `hasWsOutputUpstream` = any `cache.outputs.workspaceFiles`
631
+ * (`upstreamOutputProjects` = projects declaring `outputs.files`
632
+ * upstream; `hasWsOutputUpstream` = any `outputs.workspaceFiles`
633
633
  * upstream), so a producer reached through a no-output intermediate is
634
634
  * caught too.
635
635
  *
@@ -663,7 +663,6 @@ export function dependsOnSiblingOutputs(
663
663
  readableOf?: ReadonlyMap<string, readonly string[]>
664
664
  },
665
665
  ): boolean {
666
- const cache = node.config.cache
667
666
  const declared = node.config.inputs
668
667
  // A task with no inputs has no key to take up front.
669
668
  if (declared === undefined) return false
@@ -697,7 +696,7 @@ export function dependsOnSiblingOutputs(
697
696
  // that producer, its restore raced the producer's clean and extract
698
697
  // (M-5; invariant 2 of docs/design/overlapping-outputs-2026-09.md).
699
698
  // `outputsOf` holds this task's own globs too; set them aside once.
700
- const own = cache?.outputs.files ?? []
699
+ const own = node.config.outputs?.files ?? []
701
700
  if (own.length > 0) {
702
701
  const others = [...outputs]
703
702
  for (const g of own) {
@@ -22,7 +22,7 @@ export interface ArtifactTask {
22
22
  /** Absolute. */
23
23
  readonly projectDir: string
24
24
  readonly command: string
25
- /** Its `cache.outputs`: project-relative `files`, root-relative `workspaceFiles`. */
25
+ /** Its `outputs`: project-relative `files`, root-relative `workspaceFiles`. */
26
26
  readonly outputs: {
27
27
  readonly files: readonly string[]
28
28
  readonly workspaceFiles: readonly string[]
@@ -292,7 +292,7 @@ export async function describeTaskInputs(args: ComputeHashArgs): Promise<{
292
292
  * input changed and changed BACK while the command ran matched its digest
293
293
  * again, and the entry saved the other content's output under the key (item
294
294
  * 1015). A write before it — an upstream's — is judged by content, as before.
295
- * `byBytes` (`inputStability.strategy: 'mutable'`) judges a write during
295
+ * `byBytes` (`inputs.stability.strategy: 'mutable'`) judges a write during
296
296
  * the command by content too, when it answers true for that file and its
297
297
  * ctime: the task says the same bytes are its own identical rewrite
298
298
  * (tsdown's package.json), not an edit undone, unless another command that
@@ -353,14 +353,14 @@ export function taskFilesToken(
353
353
  /** What `resolveInputs` is asked for a task's key. */
354
354
  function inputsArgs(args: ComputeHashArgs): ResolveInputsArgs {
355
355
  const cfg = args.node.config
356
- const cacheCfg = cfg.cache
356
+ const cfgOut = cfg.outputs
357
357
  return {
358
358
  projectDir: args.node.projectDir,
359
359
  workspaceRoot: args.workspaceRoot,
360
360
  envSource: process.env,
361
361
  inputs: cfg.inputs,
362
- ownOutputs: cacheCfg?.outputs.files ?? [],
363
- ownWorkspaceOutputs: cacheCfg?.outputs.workspaceFiles ?? [],
362
+ ownOutputs: cfgOut?.files ?? [],
363
+ ownWorkspaceOutputs: cfgOut?.workspaceFiles ?? [],
364
364
  nestedProjectDirs: args.nestedProjectDirs,
365
365
  ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
366
366
  ...(args.probesAfterWrites?.has(args.node.id) === true ? { runtimeScope: args.node.id } : {}),
@@ -16,7 +16,7 @@
16
16
  // what or how tasks run. Contrast `cache`/`executor`, which return objects
17
17
  // core calls INTO; those are the behavior capabilities, kept separate.
18
18
 
19
- import { sampleTrees, type MachineReport } from '../exec/index.js'
19
+ import { sampleTrees, type MachineReport, type WorkerAdvice } from '../exec/index.js'
20
20
  import {
21
21
  type CacheSource,
22
22
  deriveCacheSource,
@@ -301,6 +301,8 @@ export interface RunSummaryRecord {
301
301
  * (`TaskExecutor.idealMs`), beside `totalDurationMs`. Additive.
302
302
  */
303
303
  idealMs?: number
304
+ /** The workers that would have run it best (`TaskExecutor.advice`). Additive. */
305
+ advice?: readonly WorkerAdvice[]
304
306
  }
305
307
 
306
308
  /** One stage of a run, a wall window in epoch ms. */
@@ -334,6 +336,7 @@ export function assembleRunSummary(
334
336
  uploads?: { count: number; bytes: number; ms: number; failed: number }
335
337
  machines?: readonly MachineReport[]
336
338
  idealMs?: number | undefined
339
+ advice?: readonly WorkerAdvice[]
337
340
  },
338
341
  ): RunSummaryRecord {
339
342
  let failedCount = 0
@@ -376,6 +379,7 @@ export function assembleRunSummary(
376
379
  ? { machines: timing.machines }
377
380
  : {}),
378
381
  ...(timing.idealMs !== undefined ? { idealMs: timing.idealMs } : {}),
382
+ ...(timing.advice !== undefined && timing.advice.length > 0 ? { advice: timing.advice } : {}),
379
383
  }
380
384
  }
381
385
 
@@ -14,12 +14,12 @@ import type { Logger } from './logger.js'
14
14
  import type { RunContext } from './summary.js'
15
15
 
16
16
  /**
17
- * A task no cache answered for: it declares no `cache`, or the run's policy
17
+ * A task no cache answered for: it has no `cache: true`, or the run's policy
18
18
  * read and wrote nothing. Its run is `no-cache`, never a miss, on every
19
19
  * surface — `--dry`'s plan, the frame, the footer, the run history.
20
20
  */
21
21
  export function ranNoCache(o: { node: TaskNode; cacheOff?: boolean }): boolean {
22
- return o.node.config.cache === undefined || o.cacheOff === true
22
+ return o.node.config.cache !== true || o.cacheOff === true
23
23
  }
24
24
 
25
25
  /** Payload of the `run:start` event — mirrors the Logger.runStart hook. */
@@ -281,7 +281,7 @@ export interface OutcomeView {
281
281
  * and counting groups as executed tasks drifts from the terminal.
282
282
  */
283
283
  isGroup?: boolean
284
- /** Executed with no `cache` block: never consulted the cache, not a miss. */
284
+ /** Executed without `cache: true`: never consulted the cache, not a miss. */
285
285
  noCache?: boolean
286
286
  /** Cache hits only: the exec time the entry was stored with — the work
287
287
  * this hit skipped, as opposed to `durationMs`, the restore it cost. */