@vzn/vx 0.0.16 → 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 (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
@@ -1,4 +1,3 @@
1
- import { mkdir } from 'node:fs/promises'
2
1
  import path from 'node:path'
3
2
  import type { ExecConfig, TaskConfig, CacheConfig } from '../config.js'
4
3
  import {
@@ -9,32 +8,36 @@ import {
9
8
  cleanWorkspaceOutputs,
10
9
  FULL_CACHE_POLICY,
11
10
  type GitFilesCache,
12
- resolveInputs,
13
- resolveOutputs,
14
- resolveWorkspaceOutputs,
15
- WORKSPACE_OUTPUT_PREFIX,
16
11
  } from '../cache/index.js'
17
12
  import {
18
13
  buildIsolatedEnv,
19
- runCommand,
14
+ VX_RUN_TASK_ENV,
15
+ VX_RUN_WORKSPACE_ENV,
20
16
  runPersistent,
21
- runSandboxed,
22
- resolveSandboxConfig,
17
+ shellQuote,
18
+ releaseBridges,
19
+ wrapSandboxedCommand,
20
+ signalExitCode,
21
+ type CaptureConfig,
22
+ type ExecuteRequest,
23
+ type ExecuteResult,
23
24
  type SandboxViolation,
25
+ type TaskExecutor,
26
+ type TaskInputs,
24
27
  } from '../exec/index.js'
25
- import { isGroupTask, type TaskNode, type TaskOutcome, type VerifyVerdict } from '../graph/index.js'
26
- import {
27
- classifyDeterminism,
28
- diffOutputTrees,
29
- foldFingerprint,
30
- hashOutputTree,
31
- outputRefs,
32
- undeclaredInputPaths,
33
- } from './verify.js'
28
+ import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
29
+ import { span } from '../util/index.js'
30
+ import { sandboxRequestFor } from './sandbox-request.js'
31
+ import { saveMiss, type OutputDirSnapshot } from './miss-save.js'
32
+ import { restoreHit } from './hit-restore.js'
33
+ // The hit path's entry stays importable from here (tests).
34
+ export { restoreHit, type RestoreHitArgs } from './hit-restore.js'
35
+ import type { DeferredOutputs } from './deferred-outputs.js'
34
36
  import type { Logger } from './logger.js'
35
37
  import {
36
38
  computeGroupHash,
37
39
  computeTaskHash,
40
+ describeTaskInputs,
38
41
  type HashCache,
39
42
  type TaskInputComponent,
40
43
  } from './task-hash.js'
@@ -49,6 +52,32 @@ export interface ExecuteArgs {
49
52
  cachePolicy?: CachePolicy
50
53
  forwardArgs?: readonly string[] | undefined
51
54
  log: Logger
55
+ /** The executor this task was PLACED on (run.ts, before scheduling). */
56
+ executor: TaskExecutor
57
+ /**
58
+ * `exec.remote: 'only'` with no remote executor to take it: succeed
59
+ * WITHOUT executing, cleaning, restoring or saving anything on this
60
+ * machine. The hash is still computed — dependents fold it.
61
+ */
62
+ remoteOnlyNoop?: boolean
63
+ /**
64
+ * `exec.remote: 'only'` placed on a remote executor: execute remotely,
65
+ * but this machine's disk is untouched — no output clean, no vx cache
66
+ * probe/restore/save. The task's outputs live in the REMOTE store and
67
+ * reach dependents' input trees by reference (the executor's job).
68
+ */
69
+ remoteOnly?: boolean
70
+ /**
71
+ * Plan-time `--download` decision for THIS task (run.ts). `'deferred'`
72
+ * means: do not clean, do not save — the executor is expected to leave
73
+ * the outputs in the remote store and hand back a closure. An executor
74
+ * that ignores it and materialises anyway simply gets no local entry;
75
+ * the outputs are on disk but unindexed, which is its contract to keep.
76
+ */
77
+ download?: 'eager' | 'deferred'
78
+ /** Run-scoped deferral registry — where deferred closures land, and
79
+ * where a locally-placed consumer fetches its producers from. */
80
+ deferred?: DeferredOutputs
52
81
  nestedProjectDirs: string[]
53
82
  /** Anchor for hrtime spans across all tasks in this run. */
54
83
  runStartHrTimeNs: bigint
@@ -78,25 +107,6 @@ export interface ExecuteArgs {
78
107
  * `exec.timeout` wins. Threaded as an option only — never hashed.
79
108
  */
80
109
  timeout?: number
81
- /**
82
- * Cache-correctness verification (`vx run --verify`). When
83
- * `determinism` is set, an executed + cacheable task is re-run after its
84
- * save and its outputs are content-compared; a divergence flags the task
85
- * non-hermetic. When `inputs` is set, an executed + cacheable task is
86
- * forced through the declared-input baseline sandbox and any read outside
87
- * those inputs flags the declared `cache.inputs` as incomplete. When
88
- * `fingerprint` is set, the executed task's output tree is fingerprinted
89
- * and attached to the outcome (no re-run — the cross-machine diff feed).
90
- * `allow` exempts known-nondeterministic task ids from failing the run.
91
- * Pure side-channel — the re-run never saves, nothing is hashed.
92
- * Undefined = off.
93
- */
94
- verify?: {
95
- determinism: boolean
96
- inputs: boolean
97
- fingerprint: boolean
98
- allow: ReadonlySet<string>
99
- }
100
110
  /** Per-run memo for `git ls-files` (one entry per project dir). */
101
111
  gitFilesCache?: GitFilesCache
102
112
  /** Per-run memo for derived hashes (package.json bytes + task config). */
@@ -113,6 +123,37 @@ export interface ExecuteArgs {
113
123
  * Absent → probe lazily, exactly as today (unstable / unclassified).
114
124
  */
115
125
  preProbed?: { hash: string; hit: CacheEntry | null }
126
+ /**
127
+ * Start the sandbox runtime, on the first task that executes inside one
128
+ * (run.ts, `prepareSandbox`). Absent when no task in the run declares a
129
+ * sandbox. A cache hit never calls it: a hit needs no sandbox.
130
+ */
131
+ armSandbox?: () => Promise<void>
132
+ /**
133
+ * Run-scoped list the miss path appends its output-directory snapshot
134
+ * request to, taken at run end (run.ts) instead of right after the save:
135
+ * a directory written milliseconds ago is inside the snapshot's racy
136
+ * window and would be refused, and the next hit would walk the tree.
137
+ */
138
+ outputDirSnapshots?: OutputDirSnapshot[]
139
+ /** The run's save lane: a miss's cache save runs off the execution slot (save-lane.ts). */
140
+ deferSave?: (save: () => Promise<void>) => Promise<void>
141
+ /**
142
+ * Where a deferred save's `landed` promise is parked by task id, for the
143
+ * in-flight join (admission.ts): a duplicate of this task in another run
144
+ * must not probe the cache before the entry is there.
145
+ */
146
+ deferredSaves?: Map<string, Promise<void>>
147
+ /**
148
+ * `continueMode: 'always'` let this task run although an upstream —
149
+ * directly or through a chain of successes — failed or aborted. It still
150
+ * cleans its outputs and runs, and a cache HIT still restores (a hit is a
151
+ * healthy run's bytes), but its own result is never saved: the key it
152
+ * derives is the one a healthy run derives (pure-input hashing), while
153
+ * the bytes were built on a partial tree, so a save here is the next
154
+ * clean run's stale hit.
155
+ */
156
+ taintedUpstream?: boolean
116
157
  }
117
158
 
118
159
  /**
@@ -141,6 +182,9 @@ function executeGroupTask(args: ExecuteArgs): TaskOutcome {
141
182
  exitCode: 0,
142
183
  durationMs: 0,
143
184
  hash: computeGroupHash(args.upstream),
185
+ // What this group stands for. A dependent expands it to describe the
186
+ // real tasks in its input closure — see `TaskOutcome.groupUpstream`.
187
+ groupUpstream: args.upstream,
144
188
  wallclockStartNs: wallclockNs,
145
189
  wallclockEndNs: wallclockNs,
146
190
  }
@@ -155,6 +199,9 @@ function executeGroupTask(args: ExecuteArgs): TaskOutcome {
155
199
  * Never reads or writes the cache — the project loader rejects
156
200
  * `cache + persistent` at config-load time, so by the time we get
157
201
  * here, `cache` is guaranteed undefined.
202
+ *
203
+ * Never routed through an executor: a persistent task is local by
204
+ * construction (its port lives on this machine).
158
205
  */
159
206
  async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
160
207
  const { node, log } = args
@@ -164,20 +211,39 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
164
211
  persistent: NonNullable<ExecConfig['persistent']>
165
212
  }
166
213
  const effectiveForwardArgs = node.requested ? (args.forwardArgs ?? []) : []
167
- const env = taskEnv(node, step)
214
+ const env = taskEnv(node, step, args.workspaceRoot)
168
215
  const wallclockStartNs = process.hrtime.bigint() - args.runStartHrTimeNs
169
216
 
170
217
  // When readyWhen is set we leave the command untouched so the
171
218
  // regex matcher sees the unmodified output. When it's absent the
172
219
  // task is "ready on spawn" — we can safely append forwardArgs in
173
220
  // the same way runCommand does.
221
+ const plainCommand =
222
+ step.persistent.readyWhen !== undefined
223
+ ? step.command
224
+ : effectiveForwardArgs.length > 0
225
+ ? step.command + ' ' + effectiveForwardArgs.map(shellQuote).join(' ')
226
+ : step.command
227
+ // A persistent task declaring `exec.sandbox` runs inside it like any
228
+ // other: the same grants, the same walls. What it cannot have is the
229
+ // violation REPORT — that reads the trace after the child exits, and a
230
+ // server exits when the run tears it down. Enforced, not reported.
231
+ // (Until 2026-09-09 the block was accepted and silently ignored.)
232
+ let command = plainCommand
233
+ let bridgeTag: string | undefined
234
+ if (step.sandbox !== undefined) {
235
+ await args.armSandbox?.()
236
+ const sb = await sandboxRequestFor(node, step.sandbox, args.workspaceRoot)
237
+ const wrapped = await wrapSandboxedCommand({
238
+ command: plainCommand,
239
+ cwd: node.projectDir,
240
+ ...sb,
241
+ })
242
+ command = wrapped.wrapped
243
+ bridgeTag = wrapped.tag
244
+ }
174
245
  const persistentOpts: Parameters<typeof runPersistent>[0] = {
175
- command:
176
- step.persistent.readyWhen !== undefined
177
- ? step.command
178
- : effectiveForwardArgs.length > 0
179
- ? step.command + ' ' + effectiveForwardArgs.map((s) => JSON.stringify(s)).join(' ')
180
- : step.command,
246
+ command,
181
247
  cwd: node.projectDir,
182
248
  env,
183
249
  onStdout: (chunk) => log.taskStdout(node, chunk),
@@ -195,14 +261,23 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
195
261
  }
196
262
 
197
263
  const spawn = runPersistent(persistentOpts)
264
+ // The host side of a port bridge lives exactly as long as the server:
265
+ // released on the child's exit, whether the run tore it down or it died.
266
+ if (bridgeTag !== undefined) {
267
+ const tag = bridgeTag
268
+ void spawn.child?.exited?.then(
269
+ () => releaseBridges(tag),
270
+ () => releaseBridges(tag),
271
+ )
272
+ }
198
273
  try {
199
274
  await spawn.ready
200
275
  } catch (err) {
201
276
  const message = err instanceof Error ? err.message : String(err)
202
- // Surface readiness failure on stderr so the user sees what went
203
- // wrong; the buffered stdout/stderr already streamed live via the
204
- // logger callbacks during spawn.ready.
205
- process.stderr.write(`\n[vx] ${node.id}: persistent task failed to become ready: ${message}\n`)
277
+ // The task's OWN stream, not the process's: the frame is where a
278
+ // reader looks for why a task failed, and a run with a custom logger
279
+ // (an embedder, the MCP server) never saw a bare stderr write at all.
280
+ log.taskStderr(node, `\n[vx] ${node.id}: persistent task failed to become ready: ${message}\n`)
206
281
  return {
207
282
  node,
208
283
  status: 'failed',
@@ -241,8 +316,28 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
241
316
  // on. Output management (clean before exec) keys off writes — so
242
317
  // `--no-cache` (all off) leaves the user's tree alone, while `--force`
243
318
  // (reads off, writes on) still wipes + repopulates a clean snapshot.
244
- const willRead = cfgCacheable && (policy.localRead || policy.remoteRead)
245
- const willWrite = cfgCacheable && (policy.localWrite || policy.remoteWrite)
319
+ // A remote-only task never touches this machine's disk: no probe/restore
320
+ // (restoring node_modules onto a dev machine is exactly what the field
321
+ // exists to prevent), no output clean, no local artifact save. Its result
322
+ // lives in the remote executor's own store.
323
+ const remoteOnly = args.remoteOnly === true
324
+ const willRead = !remoteOnly && cfgCacheable && (policy.localRead || policy.remoteRead)
325
+ const willWrite = !remoteOnly && cfgCacheable && (policy.localWrite || policy.remoteWrite)
326
+ // `willWrite` governs output hygiene (clean before exec); the save itself
327
+ // is also withheld from a task downstream of a failure (see `taintedUpstream`).
328
+ const willSave = willWrite && args.taintedUpstream !== true
329
+
330
+ // Retain only what is read back. `cache.save` below is the single consumer
331
+ // of `result.stdout`, and it runs only when this task will WRITE an entry;
332
+ // `result.stderr` has no consumer at all (a failing task's stderr reaches
333
+ // the user through the live `onStderr` callback, and the cache has never
334
+ // stored stderr — see the v17 artifact format). Both streams are still
335
+ // drained and still stream chunk-by-chunk to the logger; only the retained
336
+ // copy is dropped, which for a chatty task is its full byte size in heap.
337
+ // Deferral is decided at plan time and only ever set for a remote-placed,
338
+ // eligibility-cleared task; it suppresses this machine's clean AND save.
339
+ const deferralRequested = args.download === 'deferred'
340
+ const capture: CaptureConfig = { stdout: willSave, stderr: false }
246
341
 
247
342
  const outputs = cacheCfg?.outputs.files ?? []
248
343
  const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
@@ -295,6 +390,22 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
295
390
  ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
296
391
  })
297
392
 
393
+ // The local no-op half of `exec.remote: 'only'`: no remote executor took
394
+ // the task, so it succeeds without running. The hash was still computed —
395
+ // dependents fold it — and the machine's ambient state (node_modules as
396
+ // installed by the dev) serves dependents exactly as before the field.
397
+ if (args.remoteOnlyNoop === true) {
398
+ return {
399
+ node,
400
+ status: 'success',
401
+ exitCode: 0,
402
+ durationMs: 0,
403
+ hash,
404
+ wallclockStartNs: taskStartNs,
405
+ wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
406
+ }
407
+ }
408
+
298
409
  const cleanArgs = {
299
410
  projectDir: node.projectDir,
300
411
  outputs,
@@ -316,27 +427,60 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
316
427
  }
317
428
  // Confirmed stable miss — skip the probe, fall through to run.
318
429
  } else {
430
+ const endProbe = span('cache.get')
319
431
  const hit = await cache.get(hash, { taskId: node.id, command: step.command })
432
+ endProbe()
320
433
  if (hit) {
321
434
  return restoreHit({ args, hash, hit, cacheOpStart, taskStartNs })
322
435
  }
323
436
  }
324
437
  }
325
438
 
326
- const env = taskEnv(node, step)
439
+ const env = taskEnv(node, step, args.workspaceRoot)
327
440
  const wallclockStartNs = process.hrtime.bigint() - args.runStartHrTimeNs
328
441
 
329
- // Sandbox is opt-in per task via `sandbox: {}` (or `sandbox: {...}`)
330
- // in the task config. No CLI flag, no workspace inheritance — the
331
- // task config is the single source of truth. EXCEPTION: `--verify=inputs`
332
- // forces the declared-input baseline sandbox onto every executed, cacheable
333
- // task to prove input-completeness (a read outside the declared inputs is a
334
- // proof failure, not a task failure — so it flags the RUN via the verdict,
335
- // it does not flip the task's own exit code the way a user-declared sandbox
336
- // violation does).
337
- const userSandbox = cfg.sandbox !== undefined
338
- const verifyInputs = args.verify?.inputs === true && willWrite
339
- const useSandbox = userSandbox || verifyInputs
442
+ // Miss path: describe the input set ONCE — the executor gets the values
443
+ // (what a remote worker must reproduce) and the save below reuses the
444
+ // captured digest rows for the Tier-3 `entry_inputs` fingerprint, so the
445
+ // fold runs once here instead of once after the subprocess. The HashCache
446
+ // memos + gitFilesCache OID map make this a fold + array pushes, no I/O.
447
+ // A non-cacheable task folds nothing and ships no inputs.
448
+ const captured: TaskInputComponent[] = []
449
+ const described = cfgCacheable
450
+ ? await describeTaskInputs({
451
+ node,
452
+ upstream,
453
+ workspaceRoot: args.workspaceRoot,
454
+ workspaceFingerprint: args.workspaceFingerprint,
455
+ cache,
456
+ forwardArgs: args.forwardArgs,
457
+ nestedProjectDirs: args.nestedProjectDirs,
458
+ captureInto: captured,
459
+ ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
460
+ ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
461
+ })
462
+ : undefined
463
+ const inputs: TaskInputs | undefined = described?.inputs
464
+ // A declared input set that resolves to NOTHING is the quiet stale hit:
465
+ // the key stops moving with this project's source and every later run
466
+ // is a hit. Almost always a glob written against the wrong directory
467
+ // (`lib/**` in a package that builds `src/`). Said once per miss, on the
468
+ // run's status line — the task itself succeeds.
469
+ const declaredInputs = [
470
+ ...(cacheCfg?.inputs.files ?? []),
471
+ ...(cacheCfg?.inputs.workspaceFiles ?? []),
472
+ ]
473
+ if (inputs !== undefined && declaredInputs.length > 0 && inputs.files.length === 0) {
474
+ log.status(
475
+ `[vx] ${node.id}: cache.inputs matched no files (${declaredInputs.join(', ')}) — ` +
476
+ `the key will not change when this project's source does`,
477
+ )
478
+ }
479
+
480
+ // Sandbox is opt-in per task via `exec.sandbox: {}` (or `{...}`) in the
481
+ // task config. No CLI flag, no workspace inheritance — the task config is
482
+ // the single source of truth, and a violation fails the task (below).
483
+ const userSandbox = cfg.exec?.sandbox !== undefined
340
484
  let violations: SandboxViolation[] = []
341
485
 
342
486
  // Cache miss path (or caching disabled), up to `1 + retries` attempts.
@@ -344,7 +488,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
344
488
  // an explicit `retries: 0`.
345
489
  const maxAttempts = 1 + (step.retries ?? args.retries ?? 0)
346
490
  let attempt = 0
347
- let result: Awaited<ReturnType<typeof runCommand>>
491
+ let result: ExecuteResult
348
492
  let effectiveExitCode: number
349
493
 
350
494
  // One task attempt: clean the declared outputs (before EVERY attempt — so a
@@ -352,41 +496,109 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
352
496
  // attempt's partial outputs can't leak into the next; gated on WRITES so a
353
497
  // `--no-cache` run leaves the user's tree alone), spawn, and classify the
354
498
  // exit (a sandbox violation on a 0 exit → fail; a timeout SIGTERM → the
355
- // streamed notice). Shared by the retry loop AND the `--verify` re-run so
356
- // the two can never drift on the spawn/clean/classify path.
499
+ // streamed notice). Shared by every attempt of the retry loop, so they
500
+ // can never drift on the spawn/clean/classify path.
357
501
  async function runAttempt(): Promise<{
358
- result: Awaited<ReturnType<typeof runCommand>>
502
+ result: ExecuteResult
359
503
  exitCode: number
360
504
  }> {
361
- if (willWrite && outputs.length > 0) await cleanOutputs(cleanArgs)
362
- if (willWrite && wsOutputs.length > 0) {
505
+ // A deferred task's outputs are deliberately NOT coming, so wiping the
506
+ // tree would replace a stale build with nothing at all. The eligibility
507
+ // gate guarantees no key in this run can see what stays behind, and the
508
+ // summary names every deferred task so `dist/` is not silently stale.
509
+ if (willWrite && !deferralRequested && outputs.length > 0) {
510
+ // Mark the wiped paths, exactly as the workspace twin below does. The
511
+ // git snapshot still lists them with their committed index OIDs, and
512
+ // resolveFiles SKIPS its existence probe for any path carrying a
513
+ // trusted OID — so a declared output the producer deletes and does not
514
+ // re-create would stay in a same-project consumer's input set, keeping
515
+ // that consumer's key unchanged while the file is gone from disk.
516
+ const endClean = span('miss: clean outputs')
517
+ const cleanedRels = await cleanOutputs(cleanArgs)
518
+ endClean()
519
+ args.gitFilesCache?.markOutputsChanged(node.projectDir, cleanedRels)
520
+ }
521
+ if (willWrite && !deferralRequested && wsOutputs.length > 0) {
363
522
  // Root-anchored deletions can land in other projects' dirs; mark
364
523
  // them so stale per-project git snapshots can't survive the wipe.
365
524
  const cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
366
525
  args.gitFilesCache?.markWorkspaceOutputsChanged(args.workspaceRoot, cleanedWsRels)
367
526
  }
368
527
  violations = []
369
- const res = useSandbox ? await runSandboxedTask() : await runUnsandboxedTask()
370
- // Fail-on-violation. macOS's structured violation store lets us turn a
371
- // passing exit code into a failure when the task tripped the boundary;
372
- // Linux relies on the child failing naturally on ENOENT (violations is
373
- // always 0 there, but the task is already exit != 0 if it needed the
374
- // missing file). Violations surface via `TaskOutcome.sandboxViolationLines`.
528
+ const endReq = span('miss: build request')
529
+ const req = await buildRequest()
530
+ endReq()
531
+ // An executor that THROWS produces no captured output, so the task's
532
+ // frame would print the command and nothing else while the reason went
533
+ // straight to stderr and scrolled away in a broad run. Put it in the
534
+ // task's own stream first: the frame is where a reader looks for why a
535
+ // task failed, and a remote executor's failures are exactly the ones with
536
+ // no other trace. Rethrown unchanged — the scheduler still classifies it,
537
+ // and still prints it plainly for a UserError.
538
+ const endExec = span('miss: execute')
539
+ const res = await args.executor.execute(req).catch((err: unknown) => {
540
+ const message = err instanceof Error ? err.message : String(err)
541
+ log.taskStderr(node, `${message}\n`)
542
+ throw err
543
+ })
544
+ endExec()
545
+ violations = [...res.violations]
546
+ // Fail-on-violation, on BOTH platforms: macOS reads SRT's structured
547
+ // violation store, Linux parses the strace log the sandboxed spawn
548
+ // writes. (This comment used to claim Linux violations are "always 0"
549
+ // and that enforcement there rests on the child failing naturally — true
550
+ // before the strace pass shipped, false since, and the branch below is
551
+ // live on Linux.) Violations surface via `TaskOutcome.sandboxViolationLines`.
375
552
  let code = res.exitCode
376
- // A USER-declared sandbox fails the task on any violation (that's its
377
- // fail-on-violation contract). A sandbox forced on ONLY by `--verify=inputs`
378
- // does NOT — the task ran fine; the incompleteness is surfaced as the
379
- // `undeclared-inputs` verdict (which reds the run) so the retry loop
380
- // doesn't pointlessly re-run and the task isn't mislabeled failed.
553
+ // ANY violation fails the task (owner, 2026-09-05). A sandboxed task
554
+ // declares what it touches; a denial means it touched something else,
555
+ // and an artifact built while reading — or failing to read — something
556
+ // the cache key never folded is not a safe thing to replay. A task that
557
+ // survives the denial is the dangerous case, not the harmless one:
558
+ // that is precisely the run that succeeds and caches a wrong result.
381
559
  if (userSandbox && violations.length > 0 && code === 0) code = 1
560
+ // A declared sandbox fails the task on any violation — that is its
561
+ // whole contract. `userSandbox` is the only way a task is sandboxed,
562
+ // so this reads as "sandboxed and it tripped".
382
563
  // A child we SIGTERMed for exceeding the timeout is a genuine failure —
383
564
  // stream a clear line so the 143 exit reads as a timeout.
384
565
  if (res.timedOut) {
385
566
  log.taskStderr(node, `\n[vx] timed out after ${effectiveTimeout}ms — killed (SIGTERM)\n`)
567
+ // Force a non-zero classification even if the child TRAPPED SIGTERM and
568
+ // still exited 0 (`trap 'exit 0' TERM`, a common graceful-shutdown
569
+ // pattern). Without this a timed-out task is classified `success` and its
570
+ // PARTIAL outputs are cached + replayed forever — the run even reports
571
+ // green. The timeout is a real, retryable failure; a genuinely-killed
572
+ // child already reports 143, so this only rewrites the trap-exit-0 case.
573
+ if (code === 0) code = signalExitCode('SIGTERM')
386
574
  }
387
575
  return { result: res, exitCode: code }
388
576
  }
389
577
 
578
+ // This task is about to run a command HERE, and it missed the cache, so
579
+ // whatever its dependency closure produced has to actually be on disk.
580
+ // Producers left deferred are fetched now — once per run, concurrently,
581
+ // on this task's own worker slot, because they are its real critical
582
+ // path. A remote-placed task needs nothing: its worker grafts the
583
+ // upstream bytes by reference. A cache HIT reads no inputs at all.
584
+ if (args.executor.remote !== true && args.deferred !== undefined) {
585
+ try {
586
+ await args.deferred.materializeFor(node)
587
+ } catch (err) {
588
+ const msg = err instanceof Error ? err.message : String(err)
589
+ log.taskStderr(node, `\n[vx] ${msg}\n`)
590
+ return {
591
+ node,
592
+ status: 'failed',
593
+ exitCode: 1,
594
+ durationMs: 0,
595
+ hash,
596
+ wallclockStartNs: taskStartNs,
597
+ wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
598
+ }
599
+ }
600
+ }
601
+
390
602
  for (;;) {
391
603
  attempt++
392
604
  const a = await runAttempt()
@@ -418,297 +630,82 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
418
630
  )
419
631
  }
420
632
 
421
- async function runUnsandboxedTask(): ReturnType<typeof runCommand> {
422
- return runCommand({
423
- command: step.command,
424
- cwd: node.projectDir,
425
- env,
426
- forwardArgs: effectiveForwardArgs,
427
- onStdout: (chunk) => log.taskStdout(node, chunk),
428
- onStderr: (chunk) => log.taskStderr(node, chunk),
429
- ...(args.liveChildren !== undefined ? { liveChildren: args.liveChildren } : {}),
430
- ...(effectiveTimeout !== undefined ? { timeoutMs: effectiveTimeout } : {}),
431
- })
432
- }
433
-
434
- async function runSandboxedTask(): ReturnType<typeof runCommand> {
435
- // Baseline allowRead = resolved cache.inputs.files (absolute paths)
436
- // Baseline allowWrite = static prefix of every cache.outputs.files glob
437
- // Baseline denyRead = the workspace root, so any read outside the
438
- // project's declared inputs trips the deny boundary.
439
- // The user's sandbox block extends each list with explicit additions.
440
- const resolved = await resolveInputs({
441
- projectDir: node.projectDir,
633
+ async function buildRequest(): Promise<ExecuteRequest> {
634
+ const base: ExecuteRequest = {
635
+ taskId: node.id,
442
636
  workspaceRoot: args.workspaceRoot,
443
- envSource: process.env,
444
- inputs: cacheCfg?.inputs,
445
- ownOutputs: outputs,
446
- ownWorkspaceOutputs: wsOutputs,
447
- nestedProjectDirs: args.nestedProjectDirs,
448
- ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
449
- ...(args.hashCache !== undefined
450
- ? {
451
- runtimeCache: args.hashCache.runtime,
452
- workspaceRuntimeCache: args.hashCache.workspaceRuntime,
453
- }
454
- : {}),
455
- })
456
- const baseAllowWrite = [
457
- ...outputs.map((g) => path.join(node.projectDir, staticPrefix(g))),
458
- // Workspace outputs anchor their write prefixes at the root.
459
- ...wsOutputs.map((g) => path.join(args.workspaceRoot, staticPrefix(g))),
460
- ]
461
- // bwrap can't --bind a non-existent host path; the bind silently
462
- // becomes a no-op (or a tmpfs that evaporates on exit), and writes
463
- // to the path appear to succeed inside the sandbox but never land
464
- // on the host. Pre-create every output path so the binds resolve
465
- // to real fs entries: globbed outputs (`dist/**`) become empty
466
- // dirs; literal outputs (`out.txt`) become empty files.
467
- await prepareOutputsForBind(node.projectDir, outputs)
468
- await prepareOutputsForBind(args.workspaceRoot, wsOutputs)
469
- // Output paths are read+write — a task that declares `dist/**` as
470
- // output expects to read what it just wrote (e.g. `touch dist/x`
471
- // stats the file; `tsc --incremental` re-reads .tsbuildinfo). This
472
- // isn't magic — the user already declared these paths; we're just
473
- // honoring the natural read-write symmetry of an output directory.
474
- const sandboxResult = await runSandboxed({
475
637
  command: step.command,
638
+ forwardArgs: effectiveForwardArgs,
476
639
  cwd: node.projectDir,
477
640
  env,
478
- forwardArgs: effectiveForwardArgs,
641
+ envDefine: step.env?.define ?? {},
642
+ capture,
479
643
  onStdout: (chunk) => log.taskStdout(node, chunk),
480
644
  onStderr: (chunk) => log.taskStderr(node, chunk),
481
645
  ...(args.liveChildren !== undefined ? { liveChildren: args.liveChildren } : {}),
482
646
  ...(effectiveTimeout !== undefined ? { timeoutMs: effectiveTimeout } : {}),
483
- baseAllowRead: [...resolved.files, ...baseAllowWrite],
484
- baseAllowWrite,
485
- baseDenyRead: [args.workspaceRoot],
486
- config: resolveSandboxConfig(cfg.sandbox ?? {}, node.projectDir),
487
- })
488
- violations = sandboxResult.violations
489
- const { violations: _v, ...runResult } = sandboxResult
490
- return runResult
647
+ ...(inputs !== undefined ? { inputs } : {}),
648
+ ...(cfgCacheable ? { cacheKey: hash } : {}),
649
+ ...(remoteOnly ? { remoteOnly: true } : {}),
650
+ ...(deferralRequested ? { download: 'deferred' as const } : {}),
651
+ // `--force`/`--no-cache` reach a remote executor's private record
652
+ // through this flag — the policy gates above only cover vx's OWN cache.
653
+ ...(cfgCacheable && !(policy.localRead || policy.remoteRead) ? { refresh: true } : {}),
654
+ outputs: { files: outputs, workspaceFiles: wsOutputs },
655
+ }
656
+ if (!userSandbox) return base
657
+ await args.armSandbox?.()
658
+ return { ...base, sandbox: await sandboxRequestFor(node, step.sandbox!, args.workspaceRoot) }
491
659
  }
492
660
 
493
661
  const wallclockEndNs = process.hrtime.bigint() - args.runStartHrTimeNs
494
662
 
495
- // Attempt-1 output fingerprint for `--verify` (content OIDs by output key),
496
- // captured inside the save block while the tree is attempt-1's.
497
- let verifyFp1: Map<string, string> | undefined
498
-
499
- if (effectiveExitCode === 0 && willWrite) {
500
- const outputFiles = await resolveOutputs({
501
- projectDir: node.projectDir,
502
- outputs,
503
- nestedProjectDirs: args.nestedProjectDirs,
504
- })
505
- const wsOutputFiles = await resolveWorkspaceOutputs({
506
- workspaceRoot: args.workspaceRoot,
507
- outputs: wsOutputs,
508
- })
509
- // Tier-3 input fingerprint: capture the cache-key components for
510
- // THIS entry, persisted with the entry inside `cache.save`'s
511
- // transaction. Miss path only — the warm/hit path never reaches
512
- // here, so it allocates no array and pushes nothing. The HashCache
513
- // memos + gitFilesCache OID map make this second `computeTaskHash`
514
- // a fold + array pushes (no re-stat / re-hash I/O), negligible next
515
- // to the subprocess this task just ran.
516
- const captured: TaskInputComponent[] = []
517
- await computeTaskHash({
663
+ if (effectiveExitCode === 0 && willSave && deferralRequested) {
664
+ // The outputs never landed here: no artifact, no rows. A partial local
665
+ // record (a row with no artifact) is exactly the corrupt-entry shape
666
+ // `restoreOutputs` refuses, so writing none is the only clean answer.
667
+ // The closure is what a later local consumer — or a later eager run —
668
+ // pulls the bytes with.
669
+ const materialize = result.outputs?.kind === 'deferred' ? result.outputs.materialize : undefined
670
+ if (materialize !== undefined) {
671
+ args.deferred?.register(node.id, {
672
+ materialize,
673
+ hash,
674
+ entry: {
675
+ taskId: node.id,
676
+ command: step.command,
677
+ durationMs: result.durationMs,
678
+ stdout: result.stdout,
679
+ ...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
680
+ ...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
681
+ },
682
+ })
683
+ }
684
+ } else if (effectiveExitCode === 0 && willSave) {
685
+ const { landed } = await saveMiss({
518
686
  node,
519
- upstream,
520
- workspaceRoot: args.workspaceRoot,
521
- workspaceFingerprint: args.workspaceFingerprint,
687
+ hash,
522
688
  cache,
523
- forwardArgs: args.forwardArgs,
689
+ log,
690
+ workspaceRoot: args.workspaceRoot,
524
691
  nestedProjectDirs: args.nestedProjectDirs,
525
- captureInto: captured,
526
- ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
527
- ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
528
- })
529
- await cache.save({
530
- hash,
531
- projectDir: node.projectDir,
532
- outputFiles,
533
- ...(wsOutputFiles.length > 0
534
- ? { workspaceOutputFiles: wsOutputFiles, workspaceRoot: args.workspaceRoot }
535
- : {}),
536
- inputComponents: captured.map((c) => ({ entryHash: hash, ...c })),
537
- entry: {
538
- taskId: node.id,
539
- command: step.command,
540
- exitCode: effectiveExitCode,
541
- durationMs: result.durationMs,
542
- stdout: result.stdout,
543
- },
692
+ gitFilesCache: args.gitFilesCache,
693
+ outputs,
694
+ wsOutputs,
695
+ captured,
696
+ command: step.command,
697
+ durationMs: result.durationMs,
698
+ stdout: result.stdout,
699
+ ...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
700
+ ...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
701
+ outputDirSnapshots: args.outputDirSnapshots,
702
+ deferSave: args.deferSave,
544
703
  })
545
- // --verify: fingerprint attempt 1's outputs by CONTENT (raw bytes, never
546
- // the mtime+size memo). The determinism re-run below compares against it,
547
- // and the fingerprinting modes (`--verify`/`=all`/`=fingerprint`) fold it
548
- // into the shipped `outputFp` — the cross-machine diff feed.
549
- //
550
- // Gate on DECLARED outputs (`outputs`/`wsOutputs` globs), not the
551
- // RESOLVED file count: a task that declares `dist/**` but produces zero
552
- // files this run must still ship the EMPTY tree, or the cross-machine
553
- // diff can't pair it against a platform where the same glob DID match —
554
- // a platform-conditional glob producing nothing on one OS is exactly
555
- // the divergence Phase 4 exists to catch. The empty fold is well-defined
556
- // (a fixed sentinel tree), so two both-empty platforms stay silent.
557
- if (
558
- (args.verify?.fingerprint || args.verify?.determinism) &&
559
- outputs.length + wsOutputs.length > 0
560
- ) {
561
- verifyFp1 = await hashOutputTree(
562
- outputRefs(node.projectDir, outputFiles, args.workspaceRoot, wsOutputFiles),
563
- )
564
- }
565
- // This task just wrote outputs to the project's tree. Record the
566
- // exact declared-output paths as changed (same as the cache-hit
567
- // restore path) instead of dropping the whole snapshot: a downstream
568
- // same-project task then re-spawns git ONLY when its input globs can
569
- // actually see one of these paths. On a 1000-package cold run this
570
- // removes ~one synchronous `git ls-files` spawn per project (the
571
- // single largest cold-run cost — 22% of CPU in profiling). Contract:
572
- // outputs must be declared — an executed task that writes files
573
- // outside `cache.outputs.files` which a same-project downstream task
574
- // reads is undeclared behavior (the restore path already assumes it).
575
- if (outputFiles.length > 0) {
576
- args.gitFilesCache?.markOutputsChanged(
577
- node.projectDir,
578
- outputFiles.map((p) => path.relative(node.projectDir, p).split(path.sep).join('/')),
579
- )
580
- }
581
- // Declared workspace outputs may have landed inside OTHER
582
- // projects' dirs (no-boundary escape hatch) — mark the exact
583
- // paths against every partition that can see them.
584
- if (wsOutputFiles.length > 0) {
585
- args.gitFilesCache?.markWorkspaceOutputsChanged(
586
- args.workspaceRoot,
587
- wsOutputFiles.map((f) => path.relative(args.workspaceRoot, f).split(path.sep).join('/')),
588
- )
589
- }
590
- // The workspace-wide partition (when one exists) spans this
591
- // project's subtree, so it inherits the same "undeclared writes
592
- // are only visible to git" rule as the project drop above.
593
- if (outputFiles.length + wsOutputFiles.length > 0) {
594
- args.gitFilesCache?.invalidateWorkspacePartition()
595
- }
704
+ args.deferredSaves?.set(node.id, landed)
596
705
  }
597
706
 
598
- // Snapshot the WINNING attempt's sandbox violations before the verify re-run
599
- // (which reuses `runAttempt` and would clobber `violations`); the outcome
600
- // must report attempt 1's, not the re-run's.
601
707
  const finalViolations = violations
602
708
 
603
- let verify: VerifyVerdict | undefined
604
-
605
- // Input-completeness verification (`vx run --verify=inputs` / `=all`, Phase 2).
606
- // The task ran under the forced declared-input baseline sandbox. A read of a
607
- // workspace path outside the declared inputs proves `cache.inputs` is
608
- // incomplete — a future hit could serve stale bytes when that undeclared file
609
- // changes. This reds the run via the verdict (see run.ts `ok`), not the task's
610
- // own exit code. Checked FIRST: if the inputs are wrong, there's no point
611
- // re-running for determinism — fix the inputs (which changes the key) first.
612
- if (verifyInputs) {
613
- if (finalViolations.length > 0) {
614
- verify = {
615
- kind: 'undeclared-inputs',
616
- paths: undeclaredInputPaths(finalViolations, args.workspaceRoot),
617
- }
618
- } else if (effectiveExitCode === 0 && !args.verify?.determinism) {
619
- // Inputs-only run, inputs complete. (`=all` falls through to the stronger
620
- // determinism verdict below.)
621
- verify = { kind: 'proven-complete' }
622
- }
623
- }
624
-
625
- // Determinism verification (`vx run --verify` / `=all`). The task executed and
626
- // saved; re-run it fresh and content-compare its outputs against attempt 1. A
627
- // divergence proves the task is non-hermetic — its cache entry would replay
628
- // arbitrary past bytes. The re-run NEVER saves; the canonical (attempt-1)
629
- // bytes are restored afterward so disk == the cache regardless of verdict.
630
- if (
631
- verify === undefined &&
632
- args.verify?.determinism &&
633
- effectiveExitCode === 0 &&
634
- willWrite &&
635
- !result.timedOut
636
- ) {
637
- // Determinism verdict is keyed on RESOLVED outputs (unchanged): a task
638
- // that declares outputs but produced none this run has nothing to
639
- // content-compare, so it stays `no-outputs` with no re-run — even though
640
- // `verifyFp1` is now the (empty) declared-outputs fingerprint that
641
- // `--verify=fingerprint` ships for the cross-machine diff. `verifyFp1`
642
- // is keyed by resolved output file, so an empty map means "declared but
643
- // produced nothing" — the in-scope stand-in for the resolved-zero count.
644
- if (verifyFp1 === undefined || verifyFp1.size === 0) {
645
- verify = { kind: 'no-outputs' } // cacheable but nothing to replay (e.g. lint)
646
- } else {
647
- const rerun = await runAttempt()
648
- if (rerun.exitCode !== 0 || rerun.result.timedOut) {
649
- verify = { kind: 'rerun-failed', exitCode: rerun.exitCode }
650
- } else {
651
- const fp2 = await hashOutputTree(
652
- outputRefs(
653
- node.projectDir,
654
- await resolveOutputs({
655
- projectDir: node.projectDir,
656
- outputs,
657
- nestedProjectDirs: args.nestedProjectDirs,
658
- }),
659
- args.workspaceRoot,
660
- await resolveWorkspaceOutputs({
661
- workspaceRoot: args.workspaceRoot,
662
- outputs: wsOutputs,
663
- }),
664
- ),
665
- )
666
- verify = classifyDeterminism(
667
- diffOutputTrees(verifyFp1, fp2),
668
- args.verify.allow.has(node.id),
669
- )
670
- }
671
- // Put attempt 1's saved bytes back so disk == the cached artifact
672
- // REGARDLESS of the verdict. Mirror the restoreHit sequence exactly:
673
- // clean the declared globs FIRST (a nondeterministic re-run may have
674
- // written a stray filename the restore would never overwrite), then
675
- // restore, then record the exact changed paths so a downstream
676
- // same-project task's git snapshot can't go stale. fp1's keys ARE
677
- // attempt 1's saved rels (bare project rels + workspace-outputs/-
678
- // prefixed ws rels — the artifact namespace).
679
- let cleanedRels: string[] = []
680
- let cleanedWsRels: string[] = []
681
- if (outputs.length > 0) cleanedRels = await cleanOutputs(cleanArgs)
682
- if (wsOutputs.length > 0) cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
683
- await cache.restoreOutputs(
684
- hash,
685
- node.projectDir,
686
- wsOutputs.length > 0 ? args.workspaceRoot : undefined,
687
- )
688
- const savedKeys = [...verifyFp1.keys()]
689
- if (outputs.length > 0) {
690
- args.gitFilesCache?.markOutputsChanged(node.projectDir, [
691
- ...cleanedRels,
692
- ...savedKeys.filter((k) => !k.startsWith(WORKSPACE_OUTPUT_PREFIX)),
693
- ])
694
- }
695
- if (wsOutputs.length > 0) {
696
- args.gitFilesCache?.markWorkspaceOutputsChanged(args.workspaceRoot, [
697
- ...cleanedWsRels,
698
- ...savedKeys
699
- .filter((k) => k.startsWith(WORKSPACE_OUTPUT_PREFIX))
700
- .map((k) => k.slice(WORKSPACE_OUTPUT_PREFIX.length)),
701
- ])
702
- }
703
- }
704
- }
705
-
706
- // Cross-machine diff feed: ship attempt 1's fingerprint (the SAVED bytes —
707
- // the determinism re-run above never clobbers verifyFp1) so a connected
708
- // serve can pair it with other platforms' reports for the same cache key.
709
- const outputFp =
710
- args.verify?.fingerprint && verifyFp1 !== undefined ? foldFingerprint(verifyFp1) : undefined
711
-
712
709
  return {
713
710
  node,
714
711
  status: effectiveExitCode === 0 ? 'success' : 'failed',
@@ -718,10 +715,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
718
715
  ...(attempt > 1 ? { attempts: attempt } : {}),
719
716
  ...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
720
717
  ...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
718
+ ...(result.where !== undefined ? { where: result.where } : {}),
719
+ ...(deferralRequested && result.outputs?.kind === 'deferred'
720
+ ? { outputs: 'deferred' as const }
721
+ : {}),
721
722
  wallclockStartNs,
722
723
  wallclockEndNs,
723
- ...(verify !== undefined ? { verify } : {}),
724
- ...(outputFp !== undefined ? { outputFp } : {}),
725
724
  ...(finalViolations.length > 0
726
725
  ? {
727
726
  sandboxViolations: finalViolations.length,
@@ -731,209 +730,35 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
731
730
  }
732
731
  }
733
732
 
734
- export interface RestoreHitArgs {
735
- args: ExecuteArgs
736
- hash: string
737
- hit: CacheEntry
738
- /** `performance.now()` when the cache op (probe) started — the
739
- * user-perceived restore duration is measured from here. */
740
- cacheOpStart: number
741
- /** ns offset from run start for the outcome's wallclock window. */
742
- taskStartNs: bigint
743
- }
744
-
745
733
  /**
746
- * Materialize a confirmed cache hit: decide skip-restore, clean +
747
- * restore declared outputs, mark the exact changed paths so downstream
748
- * same-project tasks needn't re-spawn git, replay stored stdout, and
749
- * build the cache-hit `TaskOutcome`. Extracted from `executeCachedTask`
750
- * so the local short-circuit can restore a stable-key hit ahead of the
751
- * schedule using the IDENTICAL logic — there is one restore path, not
752
- * two that could drift.
753
- */
754
- export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome> {
755
- const { args, hash, hit, cacheOpStart, taskStartNs } = restore
756
- const { node, log } = args
757
- const cacheCfg: CacheConfig | undefined = node.config.cache
758
- const outputs = cacheCfg?.outputs.files ?? []
759
- const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
760
- const anyOutputs = outputs.length > 0 || wsOutputs.length > 0
761
- const cleanArgs = {
762
- projectDir: node.projectDir,
763
- outputs,
764
- nestedProjectDirs: args.nestedProjectDirs,
765
- }
766
- const wsCleanArgs = { workspaceRoot: args.workspaceRoot, outputs: wsOutputs }
767
-
768
- // "Tree is already current" short-circuit — skip cleanOutputs
769
- // + restoreOutputs when the on-disk state matches what this
770
- // entry recorded at save time. Integrity-preserving: we
771
- // require BOTH that the output-glob walk yields exactly the
772
- // expected paths (no strays, no missing) AND that every file's
773
- // (size, mode, mtime) matches the stored fingerprint. Any
774
- // divergence falls through to a real clean + restore.
775
- //
776
- // The output-file fingerprints can't be batch-loaded at
777
- // prepareRun time — non-leaf task hashes depend on upstream
778
- // outputs that haven't been written yet, so hashes are
779
- // necessarily computed mid-run. We do one extra SELECT per
780
- // cache hit here. Still beats reading the manifest from the
781
- // tar (decompress + parse) at the same point.
782
- let skipRestore = false
783
- if (anyOutputs) {
784
- const expected = args.cache.loadOutputFilesBatch([hash]).get(hash) ?? []
785
- if (expected.length > 0) {
786
- // Two namespaces in the rows: bare rels are project outputs,
787
- // `workspace-outputs/<rel>` rows anchor at the workspace root.
788
- const projExpected = expected.filter((e) => !e.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
789
- const wsExpected = expected
790
- .filter((e) => e.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
791
- .map((e) => ({ ...e, path: e.path.slice(WORKSPACE_OUTPUT_PREFIX.length) }))
792
- const actualAbs = await resolveOutputs({
793
- projectDir: node.projectDir,
794
- outputs,
795
- nestedProjectDirs: args.nestedProjectDirs,
796
- })
797
- const actualWsAbs = await resolveWorkspaceOutputs({
798
- workspaceRoot: args.workspaceRoot,
799
- outputs: wsOutputs,
800
- })
801
- const setsMatch = (
802
- actual: readonly string[],
803
- exp: ReadonlyArray<{ path: string }>,
804
- ): boolean => {
805
- const expSet = new Set(exp.map((e) => e.path))
806
- return actual.length === expSet.size && actual.every((r) => expSet.has(r))
807
- }
808
- const actualRels = actualAbs.map((p) =>
809
- path.relative(node.projectDir, p).split(path.sep).join('/'),
810
- )
811
- const actualWsRels = actualWsAbs.map((p) =>
812
- path.relative(args.workspaceRoot, p).split(path.sep).join('/'),
813
- )
814
- if (setsMatch(actualRels, projExpected) && setsMatch(actualWsRels, wsExpected)) {
815
- skipRestore =
816
- (await args.cache.isOutputsCurrent(node.projectDir, projExpected)) &&
817
- (await args.cache.isOutputsCurrent(args.workspaceRoot, wsExpected))
818
- }
819
- }
820
- }
821
- if (!skipRestore) {
822
- let cleanedRels: string[] = []
823
- let cleanedWsRels: string[] = []
824
- if (outputs.length > 0) cleanedRels = await cleanOutputs(cleanArgs)
825
- if (wsOutputs.length > 0) cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
826
- await args.cache.restoreOutputs(hash, node.projectDir, args.workspaceRoot)
827
- // Restored outputs changed the project's tree — but on this
828
- // path we know the EXACT changed paths (wiped declared
829
- // outputs + the artifact's files). Record them instead of
830
- // dropping the snapshot; downstream same-project tasks only
831
- // re-spawn git when their input globs can actually see one of
832
- // these paths. The cache-miss save path keeps the
833
- // unconditional drop (an executed task may write undeclared
834
- // files only git can see).
835
- if (outputs.length > 0) {
836
- args.gitFilesCache?.markOutputsChanged(node.projectDir, [
837
- ...cleanedRels,
838
- ...hit.outputFiles.filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX)),
839
- ])
840
- }
841
- if (wsOutputs.length > 0) {
842
- args.gitFilesCache?.markWorkspaceOutputsChanged(args.workspaceRoot, [
843
- ...cleanedWsRels,
844
- ...hit.outputFiles
845
- .filter((p) => p.startsWith(WORKSPACE_OUTPUT_PREFIX))
846
- .map((p) => p.slice(WORKSPACE_OUTPUT_PREFIX.length)),
847
- ])
848
- }
849
- }
850
- if (hit.stdout) log.taskStdout(node, hit.stdout)
851
- const status =
852
- hit.exitCode !== 0 ? 'failed' : hit.source === 'remote' ? 'cache-hit-remote' : 'cache-hit'
853
- // `restored` distinguishes "we just wrote files to disk" from
854
- // "disk already matched the cached snapshot". Drives the
855
- // "up-to-date" vs "local-cache" / "remote-cache" label in the
856
- // framed block. Only meaningful when at least one output was
857
- // declared — no-outputs tasks never materialize anything, so
858
- // they're vacuously up-to-date.
859
- const restored = !skipRestore && anyOutputs
860
- return {
861
- node,
862
- status,
863
- exitCode: hit.exitCode,
864
- durationMs: Math.round(performance.now() - cacheOpStart),
865
- hash,
866
- restored,
867
- // Under `--verify`, a cache hit didn't execute this run — so it wasn't
868
- // proven. Flag it `not-verified` (use `--force` to re-execute + verify).
869
- ...(args.verify?.determinism || args.verify?.inputs
870
- ? { verify: { kind: 'not-verified' as const } }
871
- : {}),
872
- wallclockStartNs: taskStartNs,
873
- wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
874
- }
875
- }
876
-
877
- /**
878
- * Ensure each declared output path exists on the host as either an
879
- * empty file (for literal output specs) or a directory (for globbed
880
- * specs) so bwrap's --bind can find a real fs entry to mount. Without
881
- * this, writes inside the sandbox to a non-existent allowWrite path
882
- * silently disappear (bwrap creates a tmpfs that evaporates on exit).
734
+ * Build the child-process env for one task. Same arguments at every call site
735
+ * (persistent + cached). Two `node_modules/.bin` directories are prepended to
736
+ * PATH: the project's own, then the WORKSPACE ROOT's. Never a sibling
737
+ * project's, and never an arbitrary ancestor — that is the project-isolation
738
+ * rule, and the root is not a sibling.
883
739
  *
884
- * `cleanOutputs` ran just before this in the cache-enabled path, so
885
- * we know any stale content was wiped; what's left is to materialize
886
- * the empty skeleton.
887
- */
888
- async function prepareOutputsForBind(
889
- projectDir: string,
890
- outputs: readonly string[],
891
- ): Promise<void> {
892
- for (const g of outputs) {
893
- const hasWildcard = /[*?[\]]/.test(g)
894
- if (hasWildcard) {
895
- const abs = path.join(projectDir, staticPrefix(g))
896
- await mkdir(abs, { recursive: true })
897
- } else {
898
- const abs = path.join(projectDir, g)
899
- await mkdir(path.dirname(abs), { recursive: true })
900
- const f = Bun.file(abs)
901
- if (!(await f.exists())) await Bun.write(abs, '')
902
- }
903
- }
904
- }
905
-
906
- /**
907
- * Return the longest prefix of a glob that contains no wildcards. Used
908
- * to derive an allowWrite path from each `cache.outputs.files` entry:
909
- * `dist/**` → `dist`, `build/output.js` → `build/output.js`, `**` →
910
- * `.` (the project dir itself). bwrap binds at the directory level so
911
- * a file path covers writes to that file; a dir path covers writes
912
- * anywhere underneath.
913
- */
914
- function staticPrefix(glob: string): string {
915
- const wildcardIdx = glob.search(/[*?[\]]/)
916
- if (wildcardIdx === -1) return glob
917
- // Trim back to the last separator before the wildcard so we keep
918
- // only complete path components (e.g. `dist/sub-**` → `dist`, not
919
- // `dist/sub-`).
920
- const head = glob.slice(0, wildcardIdx)
921
- const lastSep = head.lastIndexOf('/')
922
- if (lastSep === -1) return '.'
923
- return head.slice(0, lastSep) || '/'
924
- }
925
-
926
- /**
927
- * Build the child-process env for one task. Same arguments at every
928
- * call site (persistent + cached); the project's own
929
- * `node_modules/.bin` is prepended to PATH — never the workspace
930
- * root's, never sibling projects' (per the project-isolation rule).
740
+ * The root entry is where a monorepo's shared tooling actually lives: declare
741
+ * `oxlint` once as a root devDependency and every member's `.bin` is empty of
742
+ * it. Without the root on PATH such a task exits 127, which is what this
743
+ * repo's own gate did the moment core stopped BEING the root and the two
744
+ * paths stopped coinciding. It also removes a divergence that mattered more:
745
+ * the REAPI executor already rebuilds both entries in the action's command,
746
+ * so a task that resolved on a worker failed on the machine that submitted
747
+ * it. npm/pnpm/yarn all put the ancestor chain on PATH for the same reason.
931
748
  */
932
- function taskEnv(node: TaskNode, step: ExecConfig): NodeJS.ProcessEnv {
933
- return buildIsolatedEnv({
749
+ function taskEnv(node: TaskNode, step: ExecConfig, workspaceRoot: string): NodeJS.ProcessEnv {
750
+ const bins = [path.join(node.projectDir, 'node_modules', '.bin')]
751
+ const rootBin = path.join(workspaceRoot, 'node_modules', '.bin')
752
+ // Identical when the root is itself a project — dedupe rather than list it
753
+ // twice, so PATH reads the same either way.
754
+ if (rootBin !== bins[0]) bins.push(rootBin)
755
+ const env = buildIsolatedEnv({
934
756
  passThrough: step.env?.passThrough ?? [],
935
757
  define: step.env?.define ?? {},
936
758
  source: process.env,
937
- binPaths: [path.join(node.projectDir, 'node_modules', '.bin')],
759
+ binPaths: bins,
938
760
  })
761
+ env[VX_RUN_WORKSPACE_ENV] = workspaceRoot
762
+ env[VX_RUN_TASK_ENV] = node.id
763
+ return env
939
764
  }