@vzn/vx 0.0.18 → 0.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (121) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +99 -65
  3. package/package.json +10 -20
  4. package/src/bin.ts +12 -2
  5. package/src/cache/archive.ts +120 -18
  6. package/src/cache/cache.ts +313 -1324
  7. package/src/cache/chained-cache.ts +0 -8
  8. package/src/cache/config-evals.ts +89 -0
  9. package/src/cache/file-hashes.ts +262 -0
  10. package/src/cache/git-inputs.ts +637 -0
  11. package/src/cache/index.ts +16 -7
  12. package/src/cache/inputs.ts +246 -733
  13. package/src/cache/layer.ts +604 -0
  14. package/src/cache/layered-cache.ts +12 -17
  15. package/src/cache/output-index.ts +224 -0
  16. package/src/cache/policy.ts +84 -0
  17. package/src/cache/run-history.ts +147 -0
  18. package/src/cache/zstd.ts +160 -0
  19. package/src/cli/cache.ts +39 -11
  20. package/src/cli/completions.ts +104 -0
  21. package/src/cli/core-alias.ts +25 -0
  22. package/src/cli/help.ts +64 -36
  23. package/src/cli/index.ts +54 -15
  24. package/src/cli/info.ts +120 -91
  25. package/src/cli/init.ts +75 -0
  26. package/src/cli/last.ts +38 -6
  27. package/src/cli/lock.ts +2 -1
  28. package/src/cli/plan-format.ts +14 -3
  29. package/src/cli/plugin-commands.ts +15 -8
  30. package/src/cli/run.ts +84 -302
  31. package/src/cli/select.ts +332 -0
  32. package/src/cli/show.ts +144 -69
  33. package/src/cli/upgrade.ts +2 -1
  34. package/src/cli/watch.ts +647 -97
  35. package/src/cli/why.ts +44 -14
  36. package/src/cli/workspace-config.ts +126 -0
  37. package/src/config.ts +156 -136
  38. package/src/exec/env.ts +11 -0
  39. package/src/exec/executor.ts +11 -26
  40. package/src/exec/index.ts +6 -2
  41. package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
  42. package/src/exec/runner.ts +57 -18
  43. package/src/exec/sandbox-binds.ts +182 -0
  44. package/src/exec/sandbox-paths.ts +50 -0
  45. package/src/exec/sandbox-runtime.ts +582 -370
  46. package/src/exec/sandbox-violations.ts +280 -0
  47. package/src/graph/index.ts +1 -9
  48. package/src/graph/scheduler.ts +158 -203
  49. package/src/graph/task-graph.ts +2 -2
  50. package/src/index.ts +97 -102
  51. package/src/orchestrator/admission.ts +137 -0
  52. package/src/orchestrator/deferred-outputs.ts +3 -0
  53. package/src/orchestrator/doctor.ts +255 -0
  54. package/src/orchestrator/events.ts +42 -8
  55. package/src/orchestrator/execute-task.ts +145 -554
  56. package/src/orchestrator/failure-mode.ts +169 -28
  57. package/src/orchestrator/framed-output.ts +41 -36
  58. package/src/orchestrator/history.ts +118 -98
  59. package/src/orchestrator/hit-restore.ts +221 -0
  60. package/src/orchestrator/index.ts +35 -4
  61. package/src/orchestrator/lockfile-claim.ts +281 -0
  62. package/src/orchestrator/logger.ts +13 -22
  63. package/src/orchestrator/metrics.ts +53 -20
  64. package/src/orchestrator/miss-save.ts +169 -0
  65. package/src/orchestrator/options.ts +34 -31
  66. package/src/orchestrator/persistent.ts +79 -0
  67. package/src/orchestrator/placement.ts +182 -0
  68. package/src/orchestrator/plugin-host.ts +195 -38
  69. package/src/orchestrator/plugin.ts +162 -9
  70. package/src/orchestrator/prepare.ts +77 -132
  71. package/src/orchestrator/projects.ts +259 -0
  72. package/src/orchestrator/remote-prefetch.ts +1 -1
  73. package/src/orchestrator/run-artifacts.ts +25 -3
  74. package/src/orchestrator/run-context.ts +1 -1
  75. package/src/orchestrator/run-records.ts +149 -0
  76. package/src/orchestrator/run-report.ts +2 -2
  77. package/src/orchestrator/run.ts +246 -597
  78. package/src/orchestrator/sandbox-request.ts +243 -0
  79. package/src/orchestrator/save-lane.ts +47 -0
  80. package/src/orchestrator/signals.ts +111 -0
  81. package/src/orchestrator/stable-keys.ts +60 -3
  82. package/src/orchestrator/status-line.ts +2 -1
  83. package/src/orchestrator/summary.ts +68 -25
  84. package/src/orchestrator/task-hash.ts +20 -18
  85. package/src/orchestrator/task-log-buffer.ts +8 -11
  86. package/src/orchestrator/telemetry-host.ts +11 -1
  87. package/src/orchestrator/telemetry.ts +3 -17
  88. package/src/util/cgroup.ts +160 -0
  89. package/src/util/edit-distance.ts +49 -0
  90. package/src/util/index.ts +11 -3
  91. package/src/util/paths.ts +23 -2
  92. package/src/util/settle.ts +19 -0
  93. package/src/util/verbs.ts +39 -0
  94. package/src/workspace/affected.ts +111 -11
  95. package/src/workspace/config-cache.ts +11 -2
  96. package/src/workspace/config-eval.ts +11 -4
  97. package/src/workspace/config-imports.ts +5 -4
  98. package/src/workspace/config-schema.ts +837 -0
  99. package/src/workspace/filter.ts +43 -6
  100. package/src/workspace/fingerprint.ts +37 -3
  101. package/src/workspace/index.ts +30 -3
  102. package/src/workspace/lockfile.ts +12 -7
  103. package/src/{cli → workspace}/migrate-scripts.ts +26 -19
  104. package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
  105. package/src/workspace/package-graph.ts +98 -28
  106. package/src/workspace/project-loader.ts +76 -746
  107. package/src/workspace/workspace.ts +98 -13
  108. package/plugins/local-cache/index.ts +0 -2
  109. package/plugins/local-executor/index.ts +0 -2
  110. package/plugins/schedule-history/index.ts +0 -2
  111. package/src/cache/cas-backend.ts +0 -115
  112. package/src/cache/digest.ts +0 -32
  113. package/src/cli/migrate-emit.ts +0 -21
  114. package/src/cli/migrate-nx.ts +0 -473
  115. package/src/cli/migrate-turbo.ts +0 -401
  116. package/src/cli/prune.ts +0 -291
  117. package/src/orchestrator/missing-plugin.ts +0 -9
  118. package/src/orchestrator/resources.ts +0 -45
  119. package/src/orchestrator/verify.ts +0 -174
  120. package/src/plugins/local-cache/index.ts +0 -11
  121. package/src/plugins/schedule-history/index.ts +0 -109
@@ -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,16 +8,15 @@ 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,
14
+ VX_RUN_TASK_ENV,
15
+ VX_RUN_WORKSPACE_ENV,
19
16
  runPersistent,
20
- resolveSandboxConfig,
21
17
  shellQuote,
18
+ releaseBridges,
19
+ wrapSandboxedCommand,
22
20
  signalExitCode,
23
21
  type CaptureConfig,
24
22
  type ExecuteRequest,
@@ -27,16 +25,13 @@ import {
27
25
  type TaskExecutor,
28
26
  type TaskInputs,
29
27
  } from '../exec/index.js'
30
- import { isGroupTask, type TaskNode, type TaskOutcome, type VerifyVerdict } from '../graph/index.js'
31
- import {
32
- classifyDeterminism,
33
- diffOutputTrees,
34
- foldFingerprint,
35
- hashOutputTree,
36
- outputRefs,
37
- undeclaredInputPaths,
38
- } from './verify.js'
39
- import { span, staticPrefix, wholeSubtreePrefixes } from '../util/index.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'
40
35
  import type { DeferredOutputs } from './deferred-outputs.js'
41
36
  import type { Logger } from './logger.js'
42
37
  import {
@@ -112,25 +107,6 @@ export interface ExecuteArgs {
112
107
  * `exec.timeout` wins. Threaded as an option only — never hashed.
113
108
  */
114
109
  timeout?: number
115
- /**
116
- * Cache-correctness verification (`vx run --verify`). When
117
- * `determinism` is set, an executed + cacheable task is re-run after its
118
- * save and its outputs are content-compared; a divergence flags the task
119
- * non-hermetic. When `inputs` is set, an executed + cacheable task is
120
- * forced through the declared-input baseline sandbox and any read outside
121
- * those inputs flags the declared `cache.inputs` as incomplete. When
122
- * `fingerprint` is set, the executed task's output tree is fingerprinted
123
- * and attached to the outcome (no re-run — the cross-machine diff feed).
124
- * `allow` exempts known-nondeterministic task ids from failing the run.
125
- * Pure side-channel — the re-run never saves, nothing is hashed.
126
- * Undefined = off.
127
- */
128
- verify?: {
129
- determinism: boolean
130
- inputs: boolean
131
- fingerprint: boolean
132
- allow: ReadonlySet<string>
133
- }
134
110
  /** Per-run memo for `git ls-files` (one entry per project dir). */
135
111
  gitFilesCache?: GitFilesCache
136
112
  /** Per-run memo for derived hashes (package.json bytes + task config). */
@@ -147,6 +123,37 @@ export interface ExecuteArgs {
147
123
  * Absent → probe lazily, exactly as today (unstable / unclassified).
148
124
  */
149
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
150
157
  }
151
158
 
152
159
  /**
@@ -211,13 +218,32 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
211
218
  // regex matcher sees the unmodified output. When it's absent the
212
219
  // task is "ready on spawn" — we can safely append forwardArgs in
213
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
+ }
214
245
  const persistentOpts: Parameters<typeof runPersistent>[0] = {
215
- command:
216
- step.persistent.readyWhen !== undefined
217
- ? step.command
218
- : effectiveForwardArgs.length > 0
219
- ? step.command + ' ' + effectiveForwardArgs.map(shellQuote).join(' ')
220
- : step.command,
246
+ command,
221
247
  cwd: node.projectDir,
222
248
  env,
223
249
  onStdout: (chunk) => log.taskStdout(node, chunk),
@@ -235,14 +261,23 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
235
261
  }
236
262
 
237
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
+ }
238
273
  try {
239
274
  await spawn.ready
240
275
  } catch (err) {
241
276
  const message = err instanceof Error ? err.message : String(err)
242
- // Surface readiness failure on stderr so the user sees what went
243
- // wrong; the buffered stdout/stderr already streamed live via the
244
- // logger callbacks during spawn.ready.
245
- 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`)
246
281
  return {
247
282
  node,
248
283
  status: 'failed',
@@ -288,6 +323,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
288
323
  const remoteOnly = args.remoteOnly === true
289
324
  const willRead = !remoteOnly && cfgCacheable && (policy.localRead || policy.remoteRead)
290
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
291
329
 
292
330
  // Retain only what is read back. `cache.save` below is the single consumer
293
331
  // of `result.stdout`, and it runs only when this task will WRITE an entry;
@@ -299,7 +337,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
299
337
  // Deferral is decided at plan time and only ever set for a remote-placed,
300
338
  // eligibility-cleared task; it suppresses this machine's clean AND save.
301
339
  const deferralRequested = args.download === 'deferred'
302
- const capture: CaptureConfig = { stdout: willWrite, stderr: false }
340
+ const capture: CaptureConfig = { stdout: willSave, stderr: false }
303
341
 
304
342
  const outputs = cacheCfg?.outputs.files ?? []
305
343
  const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
@@ -357,20 +395,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
357
395
  // dependents fold it — and the machine's ambient state (node_modules as
358
396
  // installed by the dev) serves dependents exactly as before the field.
359
397
  if (args.remoteOnlyNoop === true) {
360
- // Under a `--verify` proof the noop must be REPORTED, not silent: there
361
- // is no execution to sandbox or re-run, so the proof cannot cover this
362
- // task (the eleventh-wave residual — a silently unverified task reads
363
- // as a green proof over ground the proof never touched).
364
- const wantsProof =
365
- args.verify !== undefined &&
366
- (args.verify.inputs || args.verify.determinism || args.verify.fingerprint)
367
398
  return {
368
399
  node,
369
400
  status: 'success',
370
401
  exitCode: 0,
371
402
  durationMs: 0,
372
403
  hash,
373
- ...(wantsProof ? { verify: { kind: 'unverifiable-remote-only' as const } } : {}),
374
404
  wallclockStartNs: taskStartNs,
375
405
  wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
376
406
  }
@@ -431,18 +461,26 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
431
461
  })
432
462
  : undefined
433
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
+ }
434
479
 
435
- // Sandbox is opt-in per task via `sandbox: {}` (or `sandbox: {...}`)
436
- // in the task config. No CLI flag, no workspace inheritance — the
437
- // task config is the single source of truth. EXCEPTION: `--verify=inputs`
438
- // forces the declared-input baseline sandbox onto every executed, cacheable
439
- // task to prove input-completeness (a read outside the declared inputs is a
440
- // proof failure, not a task failure — so it flags the RUN via the verdict,
441
- // it does not flip the task's own exit code the way a user-declared sandbox
442
- // violation does).
443
- const userSandbox = cfg.sandbox !== undefined
444
- const verifyInputs = args.verify?.inputs === true && willWrite
445
- const useSandbox = userSandbox || verifyInputs
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
446
484
  let violations: SandboxViolation[] = []
447
485
 
448
486
  // Cache miss path (or caching disabled), up to `1 + retries` attempts.
@@ -458,8 +496,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
458
496
  // attempt's partial outputs can't leak into the next; gated on WRITES so a
459
497
  // `--no-cache` run leaves the user's tree alone), spawn, and classify the
460
498
  // exit (a sandbox violation on a 0 exit → fail; a timeout SIGTERM → the
461
- // streamed notice). Shared by the retry loop AND the `--verify` re-run so
462
- // 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.
463
501
  async function runAttempt(): Promise<{
464
502
  result: ExecuteResult
465
503
  exitCode: number
@@ -512,12 +550,16 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
512
550
  // before the strace pass shipped, false since, and the branch below is
513
551
  // live on Linux.) Violations surface via `TaskOutcome.sandboxViolationLines`.
514
552
  let code = res.exitCode
515
- // A USER-declared sandbox fails the task on any violation (that's its
516
- // fail-on-violation contract). A sandbox forced on ONLY by `--verify=inputs`
517
- // does NOT — the task ran fine; the incompleteness is surfaced as the
518
- // `undeclared-inputs` verdict (which reds the run) so the retry loop
519
- // 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.
520
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".
521
563
  // A child we SIGTERMed for exceeding the timeout is a genuine failure —
522
564
  // stream a clear line so the 143 exit reads as a timeout.
523
565
  if (res.timedOut) {
@@ -526,9 +568,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
526
568
  // still exited 0 (`trap 'exit 0' TERM`, a common graceful-shutdown
527
569
  // pattern). Without this a timed-out task is classified `success` and its
528
570
  // PARTIAL outputs are cached + replayed forever — the run even reports
529
- // green. The timeout is a real, retryable failure (matches the
530
- // `--verify && !result.timedOut` guard); a genuinely-killed child already
531
- // reports 143, so this only rewrites the trap-exit-0 case.
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.
532
573
  if (code === 0) code = signalExitCode('SIGTERM')
533
574
  }
534
575
  return { result: res, exitCode: code }
@@ -612,63 +653,14 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
612
653
  ...(cfgCacheable && !(policy.localRead || policy.remoteRead) ? { refresh: true } : {}),
613
654
  outputs: { files: outputs, workspaceFiles: wsOutputs },
614
655
  }
615
- if (!useSandbox) return base
616
- // Baseline allowRead = resolved cache.inputs.files (absolute paths)
617
- // Baseline allowWrite = static prefix of every cache.outputs.files glob
618
- // Baseline denyRead = the workspace root, so any read outside the
619
- // project's declared inputs trips the deny boundary.
620
- // The user's sandbox block extends each list with explicit additions.
621
- const resolved = await resolveInputs({
622
- projectDir: node.projectDir,
623
- workspaceRoot: args.workspaceRoot,
624
- envSource: process.env,
625
- inputs: cacheCfg?.inputs,
626
- ownOutputs: outputs,
627
- ownWorkspaceOutputs: wsOutputs,
628
- nestedProjectDirs: args.nestedProjectDirs,
629
- ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
630
- ...(args.hashCache !== undefined
631
- ? {
632
- runtimeCache: args.hashCache.runtime,
633
- workspaceRuntimeCache: args.hashCache.workspaceRuntime,
634
- }
635
- : {}),
636
- })
637
- const baseAllowWrite = [
638
- ...outputs.map((g) => path.join(node.projectDir, staticPrefix(g))),
639
- // Workspace outputs anchor their write prefixes at the root.
640
- ...wsOutputs.map((g) => path.join(args.workspaceRoot, staticPrefix(g))),
641
- ]
642
- // bwrap can't --bind a non-existent host path; the bind silently
643
- // becomes a no-op (or a tmpfs that evaporates on exit), and writes
644
- // to the path appear to succeed inside the sandbox but never land
645
- // on the host. Pre-create every output path so the binds resolve
646
- // to real fs entries: globbed outputs (`dist/**`) become empty
647
- // dirs; literal outputs (`out.txt`) become empty files.
648
- await prepareOutputsForBind(node.projectDir, outputs)
649
- await prepareOutputsForBind(args.workspaceRoot, wsOutputs)
650
- // Output paths are read+write — a task that declares `dist/**` as
651
- // output expects to read what it just wrote (e.g. `touch dist/x`
652
- // stats the file; `tsc --incremental` re-reads .tsbuildinfo).
653
- return {
654
- ...base,
655
- sandbox: {
656
- baseAllowRead: [...resolved.files, ...baseAllowWrite],
657
- baseAllowWrite,
658
- baseDenyRead: [args.workspaceRoot],
659
- config: resolveSandboxConfig(cfg.sandbox ?? {}, node.projectDir),
660
- settleOnCleanExit: verifyInputs,
661
- },
662
- }
656
+ if (!userSandbox) return base
657
+ await args.armSandbox?.()
658
+ return { ...base, sandbox: await sandboxRequestFor(node, step.sandbox!, args.workspaceRoot) }
663
659
  }
664
660
 
665
661
  const wallclockEndNs = process.hrtime.bigint() - args.runStartHrTimeNs
666
662
 
667
- // Attempt-1 output fingerprint for `--verify` (content OIDs by output key),
668
- // captured inside the save block while the tree is attempt-1's.
669
- let verifyFp1: Map<string, string> | undefined
670
-
671
- if (effectiveExitCode === 0 && willWrite && deferralRequested) {
663
+ if (effectiveExitCode === 0 && willSave && deferralRequested) {
672
664
  // The outputs never landed here: no artifact, no rows. A partial local
673
665
  // record (a row with no artifact) is exactly the corrupt-entry shape
674
666
  // `restoreOutputs` refuses, so writing none is the only clean answer.
@@ -684,217 +676,36 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
684
676
  command: step.command,
685
677
  durationMs: result.durationMs,
686
678
  stdout: result.stdout,
679
+ ...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
680
+ ...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
687
681
  },
688
682
  })
689
683
  }
690
- } else if (effectiveExitCode === 0 && willWrite) {
691
- const endResolve = span('miss: resolve outputs')
692
- const outputFiles = await resolveOutputs({
693
- projectDir: node.projectDir,
694
- outputs,
695
- nestedProjectDirs: args.nestedProjectDirs,
696
- })
697
- const wsOutputFiles = await resolveWorkspaceOutputs({
698
- workspaceRoot: args.workspaceRoot,
699
- outputs: wsOutputs,
700
- })
701
- endResolve()
702
- const endSave = span('miss: save')
703
- // Tier-3 input fingerprint: the digest rows captured by the pre-exec
704
- // describe above, persisted with the entry inside `cache.save`'s
705
- // transaction. Miss path only — the warm/hit path never reaches here.
706
- await cache.save({
684
+ } else if (effectiveExitCode === 0 && willSave) {
685
+ const { landed } = await saveMiss({
686
+ node,
707
687
  hash,
708
- projectDir: node.projectDir,
709
- outputFiles,
710
- ...(wsOutputFiles.length > 0
711
- ? { workspaceOutputFiles: wsOutputFiles, workspaceRoot: args.workspaceRoot }
712
- : {}),
713
- inputComponents: captured.map((c) => ({ entryHash: hash, ...c })),
714
- // No `exitCode`: the save only runs under `effectiveExitCode === 0`, and
715
- // the contract no longer accepts one — so the invariant is enforced by
716
- // the type rather than by every call site remembering the gate.
717
- entry: {
718
- taskId: node.id,
719
- command: step.command,
720
- durationMs: result.durationMs,
721
- stdout: result.stdout,
722
- },
688
+ cache,
689
+ log,
690
+ workspaceRoot: args.workspaceRoot,
691
+ nestedProjectDirs: args.nestedProjectDirs,
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,
723
703
  })
724
- endSave()
725
- {
726
- const savedDirPrefixes = wholeSubtreePrefixes(outputs)
727
- if (savedDirPrefixes !== null) {
728
- await cache.recordOutputDirs?.(hash, node.projectDir, savedDirPrefixes)
729
- }
730
- }
731
- // --verify: fingerprint attempt 1's outputs by CONTENT (raw bytes, never
732
- // the mtime+size memo). The determinism re-run below compares against it,
733
- // and the fingerprinting modes (`--verify`/`=all`/`=fingerprint`) fold it
734
- // into the shipped `outputFp` — the cross-machine diff feed.
735
- //
736
- // Gate on DECLARED outputs (`outputs`/`wsOutputs` globs), not the
737
- // RESOLVED file count: a task that declares `dist/**` but produces zero
738
- // files this run must still ship the EMPTY tree, or the cross-machine
739
- // diff can't pair it against a platform where the same glob DID match —
740
- // a platform-conditional glob producing nothing on one OS is exactly
741
- // the divergence Phase 4 exists to catch. The empty fold is well-defined
742
- // (a fixed sentinel tree), so two both-empty platforms stay silent.
743
- if (
744
- (args.verify?.fingerprint || args.verify?.determinism) &&
745
- outputs.length + wsOutputs.length > 0
746
- ) {
747
- verifyFp1 = await hashOutputTree(
748
- outputRefs(node.projectDir, outputFiles, args.workspaceRoot, wsOutputFiles),
749
- )
750
- }
751
- // This task just wrote outputs to the project's tree. Record the
752
- // exact declared-output paths as changed (same as the cache-hit
753
- // restore path) instead of dropping the whole snapshot: a downstream
754
- // same-project task then re-spawns git ONLY when its input globs can
755
- // actually see one of these paths. On a 1000-package cold run this
756
- // removes ~one synchronous `git ls-files` spawn per project (the
757
- // single largest cold-run cost — 22% of CPU in profiling). Contract:
758
- // outputs must be declared — an executed task that writes files
759
- // outside `cache.outputs.files` which a same-project downstream task
760
- // reads is undeclared behavior (the restore path already assumes it).
761
- if (outputFiles.length > 0) {
762
- args.gitFilesCache?.markOutputsChanged(
763
- node.projectDir,
764
- outputFiles.map((p) => path.relative(node.projectDir, p).split(path.sep).join('/')),
765
- )
766
- }
767
- // Declared workspace outputs may have landed inside OTHER
768
- // projects' dirs (no-boundary escape hatch) — mark the exact
769
- // paths against every partition that can see them.
770
- if (wsOutputFiles.length > 0) {
771
- args.gitFilesCache?.markWorkspaceOutputsChanged(
772
- args.workspaceRoot,
773
- wsOutputFiles.map((f) => path.relative(args.workspaceRoot, f).split(path.sep).join('/')),
774
- )
775
- }
776
- // The workspace-wide partition (when one exists) spans this
777
- // project's subtree, so it inherits the same "undeclared writes
778
- // are only visible to git" rule as the project drop above.
779
- if (outputFiles.length + wsOutputFiles.length > 0) {
780
- args.gitFilesCache?.invalidateWorkspacePartition()
781
- }
704
+ args.deferredSaves?.set(node.id, landed)
782
705
  }
783
706
 
784
- // Snapshot the WINNING attempt's sandbox violations before the verify re-run
785
- // (which reuses `runAttempt` and would clobber `violations`); the outcome
786
- // must report attempt 1's, not the re-run's.
787
707
  const finalViolations = violations
788
708
 
789
- let verify: VerifyVerdict | undefined
790
-
791
- // Input-completeness verification (`vx run --verify=inputs` / `=all`, Phase 2).
792
- // The task ran under the forced declared-input baseline sandbox. A read of a
793
- // workspace path outside the declared inputs proves `cache.inputs` is
794
- // incomplete — a future hit could serve stale bytes when that undeclared file
795
- // changes. This reds the run via the verdict (see run.ts `ok`), not the task's
796
- // own exit code. Checked FIRST: if the inputs are wrong, there's no point
797
- // re-running for determinism — fix the inputs (which changes the key) first.
798
- if (verifyInputs) {
799
- if (finalViolations.length > 0) {
800
- verify = {
801
- kind: 'undeclared-inputs',
802
- paths: undeclaredInputPaths(finalViolations, args.workspaceRoot),
803
- }
804
- } else if (effectiveExitCode === 0 && !args.verify?.determinism) {
805
- // Inputs-only run, inputs complete. (`=all` falls through to the stronger
806
- // determinism verdict below.)
807
- verify = { kind: 'proven-complete' }
808
- }
809
- }
810
-
811
- // Determinism verification (`vx run --verify` / `=all`). The task executed and
812
- // saved; re-run it fresh and content-compare its outputs against attempt 1. A
813
- // divergence proves the task is non-hermetic — its cache entry would replay
814
- // arbitrary past bytes. The re-run NEVER saves; the canonical (attempt-1)
815
- // bytes are restored afterward so disk == the cache regardless of verdict.
816
- if (
817
- verify === undefined &&
818
- args.verify?.determinism &&
819
- effectiveExitCode === 0 &&
820
- willWrite &&
821
- !result.timedOut
822
- ) {
823
- // Determinism verdict is keyed on RESOLVED outputs (unchanged): a task
824
- // that declares outputs but produced none this run has nothing to
825
- // content-compare, so it stays `no-outputs` with no re-run — even though
826
- // `verifyFp1` is now the (empty) declared-outputs fingerprint that
827
- // `--verify=fingerprint` ships for the cross-machine diff. `verifyFp1`
828
- // is keyed by resolved output file, so an empty map means "declared but
829
- // produced nothing" — the in-scope stand-in for the resolved-zero count.
830
- if (verifyFp1 === undefined || verifyFp1.size === 0) {
831
- verify = { kind: 'no-outputs' } // cacheable but nothing to replay (e.g. lint)
832
- } else {
833
- const rerun = await runAttempt()
834
- if (rerun.exitCode !== 0 || rerun.result.timedOut) {
835
- verify = { kind: 'rerun-failed', exitCode: rerun.exitCode }
836
- } else {
837
- const fp2 = await hashOutputTree(
838
- outputRefs(
839
- node.projectDir,
840
- await resolveOutputs({
841
- projectDir: node.projectDir,
842
- outputs,
843
- nestedProjectDirs: args.nestedProjectDirs,
844
- }),
845
- args.workspaceRoot,
846
- await resolveWorkspaceOutputs({
847
- workspaceRoot: args.workspaceRoot,
848
- outputs: wsOutputs,
849
- }),
850
- ),
851
- )
852
- verify = classifyDeterminism(
853
- diffOutputTrees(verifyFp1, fp2),
854
- args.verify.allow.has(node.id),
855
- )
856
- }
857
- // Put attempt 1's saved bytes back so disk == the cached artifact
858
- // REGARDLESS of the verdict. Mirror the restoreHit sequence exactly:
859
- // clean the declared globs FIRST (a nondeterministic re-run may have
860
- // written a stray filename the restore would never overwrite), then
861
- // restore, then record the exact changed paths so a downstream
862
- // same-project task's git snapshot can't go stale. fp1's keys ARE
863
- // attempt 1's saved rels (bare project rels + workspace-outputs/-
864
- // prefixed ws rels — the artifact namespace).
865
- let cleanedRels: string[] = []
866
- let cleanedWsRels: string[] = []
867
- if (outputs.length > 0) cleanedRels = await cleanOutputs(cleanArgs)
868
- if (wsOutputs.length > 0) cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
869
- await cache.restoreOutputs(
870
- hash,
871
- node.projectDir,
872
- wsOutputs.length > 0 ? args.workspaceRoot : undefined,
873
- )
874
- const savedKeys = [...verifyFp1.keys()]
875
- if (outputs.length > 0) {
876
- args.gitFilesCache?.markOutputsChanged(node.projectDir, [
877
- ...cleanedRels,
878
- ...savedKeys.filter((k) => !k.startsWith(WORKSPACE_OUTPUT_PREFIX)),
879
- ])
880
- }
881
- if (wsOutputs.length > 0) {
882
- args.gitFilesCache?.markWorkspaceOutputsChanged(args.workspaceRoot, [
883
- ...cleanedWsRels,
884
- ...savedKeys
885
- .filter((k) => k.startsWith(WORKSPACE_OUTPUT_PREFIX))
886
- .map((k) => k.slice(WORKSPACE_OUTPUT_PREFIX.length)),
887
- ])
888
- }
889
- }
890
- }
891
-
892
- // Cross-machine diff feed: ship attempt 1's fingerprint (the SAVED bytes —
893
- // the determinism re-run above never clobbers verifyFp1) so a connected
894
- // serve can pair it with other platforms' reports for the same cache key.
895
- const outputFp =
896
- args.verify?.fingerprint && verifyFp1 !== undefined ? foldFingerprint(verifyFp1) : undefined
897
-
898
709
  return {
899
710
  node,
900
711
  status: effectiveExitCode === 0 ? 'success' : 'failed',
@@ -910,8 +721,6 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
910
721
  : {}),
911
722
  wallclockStartNs,
912
723
  wallclockEndNs,
913
- ...(verify !== undefined ? { verify } : {}),
914
- ...(outputFp !== undefined ? { outputFp } : {}),
915
724
  ...(finalViolations.length > 0
916
725
  ? {
917
726
  sandboxViolations: finalViolations.length,
@@ -921,227 +730,6 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
921
730
  }
922
731
  }
923
732
 
924
- export interface RestoreHitArgs {
925
- args: ExecuteArgs
926
- hash: string
927
- hit: CacheEntry
928
- /** `performance.now()` when the cache op (probe) started — the
929
- * user-perceived restore duration is measured from here. */
930
- cacheOpStart: number
931
- /** ns offset from run start for the outcome's wallclock window. */
932
- taskStartNs: bigint
933
- }
934
-
935
- /**
936
- * Materialize a confirmed cache hit: decide skip-restore, clean +
937
- * restore declared outputs, mark the exact changed paths so downstream
938
- * same-project tasks needn't re-spawn git, replay stored stdout, and
939
- * build the cache-hit `TaskOutcome`. Extracted from `executeCachedTask`
940
- * so the local short-circuit can restore a stable-key hit ahead of the
941
- * schedule using the IDENTICAL logic — there is one restore path, not
942
- * two that could drift.
943
- */
944
- export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome> {
945
- const { args, hash, hit, cacheOpStart, taskStartNs } = restore
946
- const { node, log } = args
947
- const cacheCfg: CacheConfig | undefined = node.config.cache
948
- const outputs = cacheCfg?.outputs.files ?? []
949
- const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
950
- const anyOutputs = outputs.length > 0 || wsOutputs.length > 0
951
- const dirPrefixes = wholeSubtreePrefixes(outputs)
952
- const cleanArgs = {
953
- projectDir: node.projectDir,
954
- outputs,
955
- nestedProjectDirs: args.nestedProjectDirs,
956
- }
957
- const wsCleanArgs = { workspaceRoot: args.workspaceRoot, outputs: wsOutputs }
958
-
959
- // "Tree is already current" short-circuit — skip cleanOutputs
960
- // + restoreOutputs when the on-disk state matches what this
961
- // entry recorded at save time. Integrity-preserving: we
962
- // require BOTH that the output-glob walk yields exactly the
963
- // expected paths (no strays, no missing) AND that every file's
964
- // (size, mode, mtime) matches the stored fingerprint. Any
965
- // divergence falls through to a real clean + restore.
966
- //
967
- // The output-file fingerprints can't be batch-loaded at
968
- // prepareRun time — non-leaf task hashes depend on upstream
969
- // outputs that haven't been written yet, so hashes are
970
- // necessarily computed mid-run. We do one extra SELECT per
971
- // cache hit here. Still beats reading the manifest from the
972
- // tar (decompress + parse) at the same point.
973
- let skipRestore = false
974
- if (anyOutputs) {
975
- const endRows = span('output rows')
976
- const expected = hit.outputRows ?? args.cache.loadOutputFilesBatch([hash]).get(hash) ?? []
977
- endRows()
978
- if (expected.length > 0) {
979
- // Two namespaces in the rows: bare rels are project outputs,
980
- // `workspace-outputs/<rel>` rows anchor at the workspace root.
981
- const projExpected = expected.filter((e) => !e.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
982
- const wsExpected = expected
983
- .filter((e) => e.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
984
- .map((e) => ({ ...e, path: e.path.slice(WORKSPACE_OUTPUT_PREFIX.length) }))
985
- // The directory short-circuit: for whole-subtree globs, unchanged
986
- // mtimes on every directory recorded at the last save/restore prove
987
- // the output SET is unchanged (a file added or removed anywhere the
988
- // glob could see bumps a recorded directory), so the walk that cost
989
- // 0.36 ms per warm hit is replaced by a few stats. The per-file
990
- // fingerprint check below still runs; only the enumeration is skipped.
991
- let setKnown = false
992
- if (dirPrefixes !== null && wsOutputs.length === 0 && args.cache.outputDirsCurrent) {
993
- const endDirs = span('output dirs')
994
- // Loaded with the entry (batched by getMany, or with the lazy get).
995
- const dirRows = hit.outputDirRows ?? []
996
- const covers = dirPrefixes.every((pre) => dirRows.some((r) => r.path === pre))
997
- setKnown = covers && (await args.cache.outputDirsCurrent!(node.projectDir, dirRows))
998
- endDirs()
999
- }
1000
- let actualAbs: string[]
1001
- let actualWsAbs: string[]
1002
- if (setKnown) {
1003
- actualAbs = projExpected.map((e) => path.join(node.projectDir, e.path))
1004
- actualWsAbs = []
1005
- } else {
1006
- const endGlob = span('output glob')
1007
- actualAbs = await resolveOutputs({
1008
- projectDir: node.projectDir,
1009
- outputs,
1010
- nestedProjectDirs: args.nestedProjectDirs,
1011
- })
1012
- actualWsAbs = await resolveWorkspaceOutputs({
1013
- workspaceRoot: args.workspaceRoot,
1014
- outputs: wsOutputs,
1015
- })
1016
- endGlob()
1017
- }
1018
- const setsMatch = (
1019
- actual: readonly string[],
1020
- exp: ReadonlyArray<{ path: string }>,
1021
- ): boolean => {
1022
- const expSet = new Set(exp.map((e) => e.path))
1023
- return actual.length === expSet.size && actual.every((r) => expSet.has(r))
1024
- }
1025
- const actualRels = actualAbs.map((p) =>
1026
- path.relative(node.projectDir, p).split(path.sep).join('/'),
1027
- )
1028
- const actualWsRels = actualWsAbs.map((p) =>
1029
- path.relative(args.workspaceRoot, p).split(path.sep).join('/'),
1030
- )
1031
- if (setsMatch(actualRels, projExpected) && setsMatch(actualWsRels, wsExpected)) {
1032
- const endStat = span('output stat')
1033
- skipRestore =
1034
- (await args.cache.isOutputsCurrent(node.projectDir, projExpected)) &&
1035
- (await args.cache.isOutputsCurrent(args.workspaceRoot, wsExpected))
1036
- endStat()
1037
- // The walk ran and proved the tree current (rows absent, or a
1038
- // directory had moved — a benign touch): snapshot the directories
1039
- // so the next hit skips the walk.
1040
- if (skipRestore && !setKnown && dirPrefixes !== null) {
1041
- await args.cache.recordOutputDirs?.(hash, node.projectDir, dirPrefixes)
1042
- }
1043
- }
1044
- }
1045
- }
1046
- if (!skipRestore) {
1047
- let cleanedRels: string[] = []
1048
- let cleanedWsRels: string[] = []
1049
- if (outputs.length > 0) cleanedRels = await cleanOutputs(cleanArgs)
1050
- if (wsOutputs.length > 0) cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
1051
- await args.cache.restoreOutputs(hash, node.projectDir, args.workspaceRoot)
1052
- if (dirPrefixes !== null) {
1053
- await args.cache.recordOutputDirs?.(hash, node.projectDir, dirPrefixes)
1054
- }
1055
- // Restored outputs changed the project's tree — but on this
1056
- // path we know the EXACT changed paths (wiped declared
1057
- // outputs + the artifact's files). Record them instead of
1058
- // dropping the snapshot; downstream same-project tasks only
1059
- // re-spawn git when their input globs can actually see one of
1060
- // these paths. The cache-miss save path keeps the
1061
- // unconditional drop (an executed task may write undeclared
1062
- // files only git can see).
1063
- if (outputs.length > 0) {
1064
- args.gitFilesCache?.markOutputsChanged(node.projectDir, [
1065
- ...cleanedRels,
1066
- ...hit.outputFiles.filter((p) => !p.startsWith(WORKSPACE_OUTPUT_PREFIX)),
1067
- ])
1068
- }
1069
- if (wsOutputs.length > 0) {
1070
- args.gitFilesCache?.markWorkspaceOutputsChanged(args.workspaceRoot, [
1071
- ...cleanedWsRels,
1072
- ...hit.outputFiles
1073
- .filter((p) => p.startsWith(WORKSPACE_OUTPUT_PREFIX))
1074
- .map((p) => p.slice(WORKSPACE_OUTPUT_PREFIX.length)),
1075
- ])
1076
- }
1077
- }
1078
- if (hit.stdout) log.taskStdout(node, hit.stdout)
1079
- const status =
1080
- hit.exitCode !== 0 ? 'failed' : hit.source === 'remote' ? 'cache-hit-remote' : 'cache-hit'
1081
- // `restored` distinguishes "we just wrote files to disk" from
1082
- // "disk already matched the cached snapshot". Drives the
1083
- // "up-to-date" vs "local-cache" / "remote-cache" label in the
1084
- // framed block. Only meaningful when at least one output was
1085
- // declared — no-outputs tasks never materialize anything, so
1086
- // they're vacuously up-to-date.
1087
- const restored = !skipRestore && anyOutputs
1088
- return {
1089
- node,
1090
- status,
1091
- exitCode: hit.exitCode,
1092
- // What THIS run spent: probe + restore. NOT the stored exec time — see
1093
- // `storedDurationMs`, which is what the hit skipped.
1094
- durationMs: Math.round(performance.now() - cacheOpStart),
1095
- storedDurationMs: hit.durationMs,
1096
- hash,
1097
- restored,
1098
- // Under `--verify`, a cache hit didn't execute this run — so it wasn't
1099
- // proven. Flag it `not-verified` (use `--force` to re-execute + verify).
1100
- ...(args.verify?.determinism || args.verify?.inputs
1101
- ? { verify: { kind: 'not-verified' as const } }
1102
- : {}),
1103
- wallclockStartNs: taskStartNs,
1104
- wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
1105
- }
1106
- }
1107
-
1108
- /**
1109
- * Ensure each declared output path exists on the host as either an
1110
- * empty file (for literal output specs) or a directory (for globbed
1111
- * specs) so bwrap's --bind can find a real fs entry to mount. Without
1112
- * this, writes inside the sandbox to a non-existent allowWrite path
1113
- * silently disappear (bwrap creates a tmpfs that evaporates on exit).
1114
- *
1115
- * `cleanOutputs` ran just before this in the cache-enabled path, so
1116
- * we know any stale content was wiped; what's left is to materialize
1117
- * the empty skeleton.
1118
- */
1119
- async function prepareOutputsForBind(
1120
- projectDir: string,
1121
- outputs: readonly string[],
1122
- ): Promise<void> {
1123
- for (const g of outputs) {
1124
- const hasWildcard = /[*?[\]]/.test(g)
1125
- if (hasWildcard) {
1126
- const abs = path.join(projectDir, staticPrefix(g))
1127
- await mkdir(abs, { recursive: true })
1128
- } else {
1129
- const abs = path.join(projectDir, g)
1130
- await mkdir(path.dirname(abs), { recursive: true })
1131
- const f = Bun.file(abs)
1132
- if (!(await f.exists())) await Bun.write(abs, '')
1133
- }
1134
- }
1135
- }
1136
-
1137
- /**
1138
- * Return the longest prefix of a glob that contains no wildcards. Used
1139
- * to derive an allowWrite path from each `cache.outputs.files` entry:
1140
- * `dist/**` → `dist`, `build/output.js` → `build/output.js`, `**` →
1141
- * `.` (the project dir itself). bwrap binds at the directory level so
1142
- * a file path covers writes to that file; a dir path covers writes
1143
- * anywhere underneath.
1144
- */
1145
733
  /**
1146
734
  * Build the child-process env for one task. Same arguments at every call site
1147
735
  * (persistent + cached). Two `node_modules/.bin` directories are prepended to
@@ -1164,10 +752,13 @@ function taskEnv(node: TaskNode, step: ExecConfig, workspaceRoot: string): NodeJ
1164
752
  // Identical when the root is itself a project — dedupe rather than list it
1165
753
  // twice, so PATH reads the same either way.
1166
754
  if (rootBin !== bins[0]) bins.push(rootBin)
1167
- return buildIsolatedEnv({
755
+ const env = buildIsolatedEnv({
1168
756
  passThrough: step.env?.passThrough ?? [],
1169
757
  define: step.env?.define ?? {},
1170
758
  source: process.env,
1171
759
  binPaths: bins,
1172
760
  })
761
+ env[VX_RUN_WORKSPACE_ENV] = workspaceRoot
762
+ env[VX_RUN_TASK_ENV] = node.id
763
+ return env
1173
764
  }