@vzn/vx 0.0.18 → 0.0.22

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 (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
@@ -1,42 +1,56 @@
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 {
4
+ ArtifactVanishedError,
5
5
  type CacheEntry,
6
6
  type CacheLayer,
7
7
  type CachePolicy,
8
8
  cleanOutputs,
9
9
  cleanWorkspaceOutputs,
10
+ type OutputStamp,
11
+ ownOutputsSince,
12
+ stampOutputs,
10
13
  FULL_CACHE_POLICY,
11
14
  type GitFilesCache,
12
- resolveInputs,
13
- resolveOutputs,
14
- resolveWorkspaceOutputs,
15
- WORKSPACE_OUTPUT_PREFIX,
16
15
  } from '../cache/index.js'
17
16
  import {
18
17
  buildIsolatedEnv,
18
+ VX_RUN_TASK_ENV,
19
+ VX_RUN_WORKSPACE_ENV,
19
20
  runPersistent,
20
- resolveSandboxConfig,
21
21
  shellQuote,
22
+ releaseBridges,
23
+ wrapSandboxedCommand,
22
24
  signalExitCode,
23
25
  type CaptureConfig,
24
26
  type ExecuteRequest,
27
+ assertExecuteResult,
25
28
  type ExecuteResult,
26
29
  type SandboxViolation,
27
30
  type TaskExecutor,
28
31
  type TaskInputs,
32
+ PersistentReadyError,
29
33
  } from '../exec/index.js'
30
- import { isGroupTask, type TaskNode, type TaskOutcome, type VerifyVerdict } from '../graph/index.js'
34
+ import { isGroupTask, RestoreDemoted, type TaskNode, type TaskOutcome } from '../graph/index.js'
35
+ import { relPosix, span } from '../util/index.js'
31
36
  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'
37
+ mayWriteFingerprint,
38
+ type Placeholder,
39
+ placeholderSweeper,
40
+ reachedWithheld,
41
+ sandboxRequestFor,
42
+ sweepPlaceholders,
43
+ undeclaredWriteReach,
44
+ untouchedPlaceholderLine,
45
+ type WithheldLink,
46
+ withheldLinkLine,
47
+ } from './sandbox-request.js'
48
+ import { markUnsaved, saveMiss, type OutputDirSnapshot } from './miss-save.js'
49
+ import type { FingerprintWatch } from './fingerprint-watch.js'
50
+ import { restoreHit } from './hit-restore.js'
51
+ import { shellVerdict } from './shell-verdict.js'
52
+ // The hit path's entry stays importable from here (tests).
53
+ export { restoreHit, type RestoreHitArgs } from './hit-restore.js'
40
54
  import type { DeferredOutputs } from './deferred-outputs.js'
41
55
  import type { Logger } from './logger.js'
42
56
  import {
@@ -44,8 +58,10 @@ import {
44
58
  computeTaskHash,
45
59
  describeTaskInputs,
46
60
  type HashCache,
61
+ movedInput,
47
62
  type TaskInputComponent,
48
63
  } from './task-hash.js'
64
+ import { getContext } from './remote-prefetch.js'
49
65
 
50
66
  export interface ExecuteArgs {
51
67
  node: TaskNode
@@ -86,6 +102,13 @@ export interface ExecuteArgs {
86
102
  nestedProjectDirs: string[]
87
103
  /** Anchor for hrtime spans across all tasks in this run. */
88
104
  runStartHrTimeNs: bigint
105
+ /**
106
+ * The run's stop (a process signal or the embedder's abort). An attempt
107
+ * that ends while it is aborted is `aborted`, whatever its exit: a task
108
+ * that traps the forwarded SIGINT and exits 0 did not finish on its own
109
+ * terms (item 962).
110
+ */
111
+ stopSignal?: AbortSignal
89
112
  /**
90
113
  * Registry the orchestrator owns. For each persistent task we
91
114
  * spawn, we stash the subprocess handle here so the orchestrator
@@ -112,25 +135,6 @@ export interface ExecuteArgs {
112
135
  * `exec.timeout` wins. Threaded as an option only — never hashed.
113
136
  */
114
137
  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
138
  /** Per-run memo for `git ls-files` (one entry per project dir). */
135
139
  gitFilesCache?: GitFilesCache
136
140
  /** Per-run memo for derived hashes (package.json bytes + task config). */
@@ -147,6 +151,55 @@ export interface ExecuteArgs {
147
151
  * Absent → probe lazily, exactly as today (unstable / unclassified).
148
152
  */
149
153
  preProbed?: { hash: string; hit: CacheEntry | null }
154
+ /**
155
+ * Start the sandbox runtime, on the first task that executes inside one
156
+ * (run.ts, `prepareSandbox`). Absent when no task in the run declares a
157
+ * sandbox. A cache hit never calls it: a hit needs no sandbox.
158
+ */
159
+ armSandbox?: () => Promise<void>
160
+ /**
161
+ * The run's keyed-set lookup (keyed-projects.ts): the project directories
162
+ * a task's key answers for. A sandboxed task that declares `cache` is
163
+ * granted a linked workspace package only when it is in this set.
164
+ */
165
+ keyedProjects: (node: TaskNode) => ReadonlySet<string>
166
+ /**
167
+ * Run-scoped list the miss path appends its output-directory snapshot
168
+ * request to, taken at run end (run.ts) instead of right after the save:
169
+ * a directory written milliseconds ago is inside the snapshot's racy
170
+ * window and would be refused, and the next hit would walk the tree.
171
+ */
172
+ outputDirSnapshots?: OutputDirSnapshot[]
173
+ /** The run's save lane: a miss's cache save runs off the execution slot (save-lane.ts). */
174
+ deferSave?: (save: () => Promise<void>) => Promise<void>
175
+ /**
176
+ * Where a deferred save's `landed` promise is parked by task id, for the
177
+ * in-flight join (admission.ts): a duplicate of this task in another run
178
+ * must not probe the cache before the entry is there.
179
+ */
180
+ deferredSaves?: Map<string, Promise<void>>
181
+ /**
182
+ * `continueMode: 'always'` let this task run although an upstream —
183
+ * directly or through a chain of successes — failed or aborted. It still
184
+ * cleans its outputs and runs, and a cache HIT still restores (a hit is a
185
+ * healthy run's bytes), but its own result is never saved: the key it
186
+ * derives is the one a healthy run derives (pure-input hashing), while
187
+ * the bytes were built on a partial tree, so a save here is the next
188
+ * clean run's stale hit.
189
+ */
190
+ taintedUpstream?: boolean
191
+ /**
192
+ * Whether a task has rewritten a file the workspace fingerprint folded
193
+ * since the run read it (fingerprint-watch.ts): past that, no key taken
194
+ * on the old digest is probed or saved.
195
+ */
196
+ fingerprintWatch?: FingerprintWatch
197
+ /**
198
+ * No task in the run depends on this one, so none is ordered after it to
199
+ * read what it wrote: a miss that saves nothing marks nothing (the output
200
+ * walk cost 1,000 read-only misses 1.62 → 1.78 s, item 750).
201
+ */
202
+ noDependants?: true
150
203
  }
151
204
 
152
205
  /**
@@ -211,18 +264,56 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
211
264
  // regex matcher sees the unmodified output. When it's absent the
212
265
  // task is "ready on spawn" — we can safely append forwardArgs in
213
266
  // the same way runCommand does.
267
+ const plainCommand =
268
+ step.persistent.readyWhen !== undefined
269
+ ? step.command
270
+ : effectiveForwardArgs.length > 0
271
+ ? step.command + ' ' + effectiveForwardArgs.map(shellQuote).join(' ')
272
+ : step.command
273
+ // A persistent task declaring `exec.sandbox` runs inside it like any
274
+ // other: the same grants, the same walls. What it cannot have is the
275
+ // violation REPORT — that reads the trace after the child exits, and a
276
+ // server exits when the run tears it down. Enforced, not reported.
277
+ // (Until 2026-09-09 the block was accepted and silently ignored.)
278
+ let command = plainCommand
279
+ let bridgeTag: string | undefined
280
+ let signalChannel = false
281
+ // The empty files the request pre-created for a literal write grant; a
282
+ // server that never writes one gets it taken back when it exits, and a
283
+ // server that dies before readiness is told the directory spelling — the
284
+ // same trap and the same answer as a one-shot task's (sandbox-request.ts).
285
+ let placeholders: Placeholder[] = []
286
+ if (step.sandbox !== undefined) {
287
+ await args.armSandbox?.()
288
+ // A persistent task never declares `cache` (the loader refuses it), so
289
+ // it keeps the whole linked grant: no key of its can be stale.
290
+ const sb = await sandboxRequestFor(
291
+ node,
292
+ step.sandbox,
293
+ args.workspaceRoot,
294
+ undefined,
295
+ args.nestedProjectDirs,
296
+ )
297
+ placeholders = sb.placeholders
298
+ const wrapped = await wrapSandboxedCommand({
299
+ command: plainCommand,
300
+ cwd: node.projectDir,
301
+ env,
302
+ ...sb.sandbox,
303
+ server: true,
304
+ })
305
+ command = wrapped.wrapped
306
+ bridgeTag = wrapped.tag
307
+ signalChannel = wrapped.forwardsSignals
308
+ }
214
309
  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,
310
+ command,
221
311
  cwd: node.projectDir,
222
312
  env,
223
313
  onStdout: (chunk) => log.taskStdout(node, chunk),
224
314
  onStderr: (chunk) => log.taskStderr(node, chunk),
225
315
  ...(args.liveChildren !== undefined ? { liveChildren: args.liveChildren } : {}),
316
+ ...(signalChannel ? { signalChannel } : {}),
226
317
  }
227
318
  if (step.persistent.readyWhen !== undefined) {
228
319
  persistentOpts.readyWhen = step.persistent.readyWhen
@@ -235,25 +326,61 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
235
326
  }
236
327
 
237
328
  const spawn = runPersistent(persistentOpts)
329
+ // The host side of a port bridge lives exactly as long as the server:
330
+ // released on the child's exit, whether the run tore it down or it died.
331
+ // A placeholder the server never wrote goes back the same way.
332
+ // ONE sweep, shared by the exit handler and the readiness failure below
333
+ // — whichever asks second must get the SAME list. A second sweep cannot
334
+ // produce it: the first one's `rm` already happened, so the file is gone
335
+ // and `sweepPlaceholders` skips it. Collecting both lists and unioning
336
+ // them does not close that, because the union only covers an exit
337
+ // handler that FINISHED; one still between its `rm` and its return has
338
+ // published nothing yet, and the readiness path then reports no
339
+ // placeholder at all. The failure then says only "File exists", which is
340
+ // the message the hint exists to explain (seen under the gate's parallel
341
+ // load, and reproduced deterministically by delaying each side, item 450).
342
+ const sweptUntouched = placeholderSweeper(placeholders)
343
+ if (bridgeTag !== undefined || placeholders.length > 0) {
344
+ const tag = bridgeTag
345
+ const onExit = async (): Promise<void> => {
346
+ if (tag !== undefined) releaseBridges(tag)
347
+ await sweptUntouched()
348
+ }
349
+ // A spawn that failed has no child to wait on: release now, or the
350
+ // tag would hold the sandbox's reset for the rest of the process.
351
+ if (spawn.child === undefined) void onExit()
352
+ else void spawn.child.exited.then(onExit, onExit)
353
+ }
238
354
  try {
239
355
  await spawn.ready
240
356
  } catch (err) {
241
357
  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`)
358
+ // The task's OWN stream, not the process's: the frame is where a
359
+ // reader looks for why a task failed, and a run with a custom logger
360
+ // (an embedder, the MCP server) never saw a bare stderr write at all.
361
+ log.taskStderr(node, `\n[vx] ${node.id}: persistent task failed to become ready: ${message}\n`)
362
+ // The server is dead or being torn down, so the sweep on its exit may
363
+ // already be running: ask the shared one rather than starting a second.
364
+ for (const p of await sweptUntouched()) {
365
+ log.taskStderr(node, `${untouchedPlaceholderLine(node.projectDir, p)}\n`)
366
+ }
367
+ // The reason rides the outcome (every label reads it), and a child that
368
+ // exited before ready keeps its own exit code rather than a made-up 1.
369
+ const ready = err instanceof PersistentReadyError ? err : undefined
246
370
  return {
247
371
  node,
248
372
  status: 'failed',
249
- exitCode: 1,
373
+ exitCode: ready?.exitCode ?? 1,
250
374
  durationMs: spawn.readyMs(),
375
+ ...(ready !== undefined ? { notReady: ready.reason } : {}),
251
376
  wallclockStartNs,
252
377
  wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
253
378
  }
254
379
  }
255
380
 
256
381
  args.persistentRegistry?.set(node.id, spawn.child)
382
+ forgetUndeclaredWrites(args, undeclaredWriteReach(node, args.workspaceRoot))
383
+ if (mayWriteFingerprint(node, args.workspaceRoot)) args.fingerprintWatch?.wrote()
257
384
  return {
258
385
  node,
259
386
  status: 'success',
@@ -288,6 +415,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
288
415
  const remoteOnly = args.remoteOnly === true
289
416
  const willRead = !remoteOnly && cfgCacheable && (policy.localRead || policy.remoteRead)
290
417
  const willWrite = !remoteOnly && cfgCacheable && (policy.localWrite || policy.remoteWrite)
418
+ // `willWrite` governs output hygiene (clean before exec); the save itself
419
+ // is also withheld from a task downstream of a failure (see `taintedUpstream`).
420
+ const willSave = willWrite && args.taintedUpstream !== true
291
421
 
292
422
  // Retain only what is read back. `cache.save` below is the single consumer
293
423
  // of `result.stdout`, and it runs only when this task will WRITE an entry;
@@ -299,7 +429,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
299
429
  // Deferral is decided at plan time and only ever set for a remote-placed,
300
430
  // eligibility-cleared task; it suppresses this machine's clean AND save.
301
431
  const deferralRequested = args.download === 'deferred'
302
- const capture: CaptureConfig = { stdout: willWrite, stderr: false }
432
+ const capture: CaptureConfig = { stdout: willSave, stderr: false }
303
433
 
304
434
  const outputs = cacheCfg?.outputs.files ?? []
305
435
  const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
@@ -357,20 +487,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
357
487
  // dependents fold it — and the machine's ambient state (node_modules as
358
488
  // installed by the dev) serves dependents exactly as before the field.
359
489
  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
490
  return {
368
491
  node,
369
492
  status: 'success',
370
493
  exitCode: 0,
371
494
  durationMs: 0,
372
495
  hash,
373
- ...(wantsProof ? { verify: { kind: 'unverifiable-remote-only' as const } } : {}),
374
496
  wallclockStartNs: taskStartNs,
375
497
  wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
376
498
  }
@@ -382,6 +504,14 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
382
504
  nestedProjectDirs: args.nestedProjectDirs,
383
505
  }
384
506
  const wsCleanArgs = { workspaceRoot: args.workspaceRoot, outputs: wsOutputs }
507
+ // An ADDITIVE task (its outputs overlap an upstream's, with the edge that
508
+ // orders them; item 588) does not clean by glob — the glob selects the
509
+ // upstream's files it adds beside. Its outputs are stamped once before
510
+ // the first attempt, and after a 0 exit its own set is what the run
511
+ // added or changed against that stamp. Stale files of its own from an
512
+ // earlier run are its command's to clean, as they are under Turbo.
513
+ const additive = (node.addsToOutputsOf?.length ?? 0) > 0
514
+ let stampedBefore: ReadonlyMap<string, OutputStamp> | undefined
385
515
 
386
516
  // Cache lookup. On hit, time the user-perceived restore op
387
517
  // (clean+restore+log-replay) — that's what the framed-block footer
@@ -391,17 +521,37 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
391
521
  // to the run path as a known stable miss. No second cache.get.
392
522
  if (willRead) {
393
523
  const cacheOpStart = performance.now()
524
+ // A hit whose artifact was removed after the probe (a prune, another
525
+ // workspace's retention on a shared cache directory) is a miss. The
526
+ // restore has wiped the declared outputs by then; the run path cleans
527
+ // them again and the task writes them.
528
+ const restoreOrMiss = async (hit: CacheEntry): Promise<TaskOutcome | null> => {
529
+ try {
530
+ return await restoreHit({ args, hash, hit, cacheOpStart, taskStartNs })
531
+ } catch (err) {
532
+ if (!(err instanceof ArtifactVanishedError)) throw err
533
+ log.status(`[vx] ${node.id}: ${err.message} — running it`)
534
+ return null
535
+ }
536
+ }
394
537
  if (preProbed !== undefined) {
395
538
  if (preProbed.hit !== null) {
396
- return restoreHit({ args, hash, hit: preProbed.hit, cacheOpStart, taskStartNs })
539
+ const restored = await restoreOrMiss(preProbed.hit)
540
+ if (restored !== null) return restored
541
+ // The up-front probe's hit may be restoring AHEAD of this task's
542
+ // deps, and a command run now would build from outputs they have
543
+ // not written yet and save that under the good key. The scheduler
544
+ // runs it again once they are done (admission drops the probe).
545
+ throw new RestoreDemoted(node.id)
397
546
  }
398
547
  // Confirmed stable miss — skip the probe, fall through to run.
399
- } else {
548
+ } else if (!fingerprintMoved()) {
400
549
  const endProbe = span('cache.get')
401
- const hit = await cache.get(hash, { taskId: node.id, command: step.command })
550
+ const hit = await cache.get(hash, getContext(node, step.command))
402
551
  endProbe()
403
552
  if (hit) {
404
- return restoreHit({ args, hash, hit, cacheOpStart, taskStartNs })
553
+ const restored = await restoreOrMiss(hit)
554
+ if (restored !== null) return restored
405
555
  }
406
556
  }
407
557
  }
@@ -431,19 +581,33 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
431
581
  })
432
582
  : undefined
433
583
  const inputs: TaskInputs | undefined = described?.inputs
584
+ // A declared input set that resolves to NOTHING is the quiet stale hit:
585
+ // the key stops moving with this project's source and every later run
586
+ // is a hit. Almost always a glob written against the wrong directory
587
+ // (`lib/**` in a package that builds `src/`). Said once per miss, on the
588
+ // run's status line — the task itself succeeds.
589
+ const declaredInputs = [
590
+ ...(cacheCfg?.inputs.files ?? []),
591
+ ...(cacheCfg?.inputs.workspaceFiles ?? []),
592
+ ]
593
+ if (inputs !== undefined && declaredInputs.length > 0 && inputs.files.length === 0) {
594
+ log.status(
595
+ `[vx] ${node.id}: cache.inputs matched no files (${declaredInputs.join(', ')}) — ` +
596
+ `the key will not change when this project's source does`,
597
+ )
598
+ }
434
599
 
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
600
+ // Sandbox is opt-in per task via `exec.sandbox: {}` (or `{...}`) in the
601
+ // task config. No CLI flag, no workspace inheritance — the task config is
602
+ // the single source of truth, and a violation fails the task (below).
603
+ const userSandbox = cfg.exec?.sandbox !== undefined
446
604
  let violations: SandboxViolation[] = []
605
+ // The empty files the sandbox request created for a literal write grant;
606
+ // swept after the attempt (`sweepPlaceholders`).
607
+ let placeholders: Placeholder[] = []
608
+ // The linked workspace packages the request withheld: the key does not
609
+ // answer for them.
610
+ let withheld: WithheldLink[] = []
447
611
 
448
612
  // Cache miss path (or caching disabled), up to `1 + retries` attempts.
449
613
  // Explicit config wins over the run-level `--retry` default, including
@@ -458,8 +622,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
458
622
  // attempt's partial outputs can't leak into the next; gated on WRITES so a
459
623
  // `--no-cache` run leaves the user's tree alone), spawn, and classify the
460
624
  // 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.
625
+ // streamed notice). Shared by every attempt of the retry loop, so they
626
+ // can never drift on the spawn/clean/classify path.
463
627
  async function runAttempt(): Promise<{
464
628
  result: ExecuteResult
465
629
  exitCode: number
@@ -468,7 +632,11 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
468
632
  // tree would replace a stale build with nothing at all. The eligibility
469
633
  // gate guarantees no key in this run can see what stays behind, and the
470
634
  // summary names every deferred task so `dist/` is not silently stale.
471
- if (willWrite && !deferralRequested && outputs.length > 0) {
635
+ if (additive && willWrite && !deferralRequested && outputs.length > 0) {
636
+ const endStamp = span('miss: stamp outputs')
637
+ stampedBefore ??= await stampOutputs(cleanArgs)
638
+ endStamp()
639
+ } else if (willWrite && !deferralRequested && outputs.length > 0) {
472
640
  // Mark the wiped paths, exactly as the workspace twin below does. The
473
641
  // git snapshot still lists them with their committed index OIDs, and
474
642
  // resolveFiles SKIPS its existence probe for any path carrying a
@@ -498,13 +666,38 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
498
666
  // no other trace. Rethrown unchanged — the scheduler still classifies it,
499
667
  // and still prints it plainly for a UserError.
500
668
  const endExec = span('miss: execute')
501
- const res = await args.executor.execute(req).catch((err: unknown) => {
502
- const message = err instanceof Error ? err.message : String(err)
503
- log.taskStderr(node, `${message}\n`)
504
- throw err
505
- })
669
+ const res = await args.executor
670
+ .execute(req)
671
+ .then((r: unknown) => {
672
+ assertExecuteResult(args.executor.name, node.id, r)
673
+ return r
674
+ })
675
+ .catch(async (err: unknown) => {
676
+ const message = err instanceof Error ? err.message : String(err)
677
+ log.taskStderr(node, `${message}\n`)
678
+ await sweepPlaceholders(placeholders)
679
+ throw err
680
+ })
506
681
  endExec()
507
682
  violations = [...res.violations]
683
+ // A denial under a dependency the key does not answer for says only
684
+ // ENOENT; the line beside it names the package and how to key it. Only
685
+ // with a denial there, so it never reddens a pass.
686
+ for (const w of reachedWithheld(withheld, violations)) {
687
+ violations.push({ timestamp: new Date(), line: withheldLinkLine(node.id, w) })
688
+ }
689
+ // A placeholder the task never wrote is not its output: take it back
690
+ // before the outputs are collected. On a failure it is also the one
691
+ // clue to a grant that meant a directory — say so beside the failure.
692
+ const untouched = await sweepPlaceholders(placeholders)
693
+ if (res.exitCode !== 0 && violations.length === 0) {
694
+ for (const p of untouched) {
695
+ violations.push({
696
+ timestamp: new Date(),
697
+ line: untouchedPlaceholderLine(node.projectDir, p),
698
+ })
699
+ }
700
+ }
508
701
  // Fail-on-violation, on BOTH platforms: macOS reads SRT's structured
509
702
  // violation store, Linux parses the strace log the sandboxed spawn
510
703
  // writes. (This comment used to claim Linux violations are "always 0"
@@ -512,12 +705,16 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
512
705
  // before the strace pass shipped, false since, and the branch below is
513
706
  // live on Linux.) Violations surface via `TaskOutcome.sandboxViolationLines`.
514
707
  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.
708
+ // ANY violation fails the task (owner, 2026-09-05). A sandboxed task
709
+ // declares what it touches; a denial means it touched something else,
710
+ // and an artifact built while reading — or failing to read — something
711
+ // the cache key never folded is not a safe thing to replay. A task that
712
+ // survives the denial is the dangerous case, not the harmless one:
713
+ // that is precisely the run that succeeds and caches a wrong result.
520
714
  if (userSandbox && violations.length > 0 && code === 0) code = 1
715
+ // A declared sandbox fails the task on any violation — that is its
716
+ // whole contract. `userSandbox` is the only way a task is sandboxed,
717
+ // so this reads as "sandboxed and it tripped".
521
718
  // A child we SIGTERMed for exceeding the timeout is a genuine failure —
522
719
  // stream a clear line so the 143 exit reads as a timeout.
523
720
  if (res.timedOut) {
@@ -526,11 +723,24 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
526
723
  // still exited 0 (`trap 'exit 0' TERM`, a common graceful-shutdown
527
724
  // pattern). Without this a timed-out task is classified `success` and its
528
725
  // 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.
726
+ // green. The timeout is a real, retryable failure; a genuinely-killed
727
+ // child already reports 143, so this only rewrites the trap-exit-0 case.
532
728
  if (code === 0) code = signalExitCode('SIGTERM')
533
729
  }
730
+ // The shell's 127 and 126 name the word and nothing about why — the
731
+ // PATH vx built, or a `#!` line the file itself carries (items 257,
732
+ // 258) — and an exit above 128 is a signal's number and nothing
733
+ // about what sent it (259). One frame line names the rule.
734
+ if (!res.timedOut) {
735
+ const verdict = shellVerdict({
736
+ code,
737
+ command: step.command,
738
+ cwd: node.projectDir,
739
+ bins: taskBinDirs(node, args.workspaceRoot),
740
+ signal: res.signal,
741
+ })
742
+ if (verdict !== undefined) log.taskStderr(node, `\n${verdict}\n`)
743
+ }
534
744
  return { result: res, exitCode: code }
535
745
  }
536
746
 
@@ -558,19 +768,33 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
558
768
  }
559
769
  }
560
770
 
771
+ // Pass or fail, the command may have written where its project's
772
+ // run-start facts describe; a remote executor wrote on its own disk.
773
+ const writeReach =
774
+ args.executor.remote === true ? 'none' : undeclaredWriteReach(node, args.workspaceRoot)
775
+ const writesFingerprint =
776
+ args.executor.remote !== true && mayWriteFingerprint(node, args.workspaceRoot)
561
777
  for (;;) {
562
778
  attempt++
563
779
  const a = await runAttempt()
780
+ forgetUndeclaredWrites(args, writeReach)
781
+ if (writesFingerprint) args.fingerprintWatch?.wrote()
564
782
  result = a.result
565
783
  effectiveExitCode = a.exitCode
566
784
 
567
- // A child killed by a shutdown signal (Ctrl-C / SIGTERM teardown)
568
- // never finished on its own terms — revert it to aborted so it's
569
- // neither cached, counted, shown, nor RETRIED (the run is tearing
570
- // down). SIGKILL (OOM, forced) stays a real failure. A timeout also
571
- // SIGTERMs, but `timedOut` marks it as our own deadline, not a
572
- // shutdown — so it stays a real (retryable) failure.
573
- if ((result.signal === 'SIGINT' || result.signal === 'SIGTERM') && !result.timedOut) {
785
+ // An attempt that ended in a shutdown never finished on its own terms —
786
+ // it is aborted, so it is neither cached, counted, shown, nor RETRIED.
787
+ // Two ways to know: the child died of SIGINT / SIGTERM (Ctrl-C, or a
788
+ // `kill` vx never saw; a timeout also SIGTERMs, but `timedOut` marks it
789
+ // as our own deadline, a real and retryable failure), or the RUN is
790
+ // stopping. The child alone was not enough: one that traps the
791
+ // forwarded SIGINT and exits 0 was cached as a success with its partial
792
+ // outputs, one that exits 1 was retried after Ctrl-C, and one SIGKILLed
793
+ // at the end of the grace read as an OOM failure (item 962).
794
+ const shutdown =
795
+ args.stopSignal?.aborted === true ||
796
+ ((result.signal === 'SIGINT' || result.signal === 'SIGTERM') && !result.timedOut)
797
+ if (shutdown) {
574
798
  return {
575
799
  node,
576
800
  status: 'aborted',
@@ -585,7 +809,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
585
809
  if (effectiveExitCode === 0 || attempt >= maxAttempts) break
586
810
  log.taskStderr(
587
811
  node,
588
- `vx: retrying ${node.id} (attempt ${attempt + 1}/${maxAttempts}) after exit ${effectiveExitCode}\n`,
812
+ `vx: retrying ${node.id} (attempt ${attempt + 1}/${maxAttempts}) after ${result.timedOut === true ? 'a timeout' : `exit ${effectiveExitCode}`}\n`,
589
813
  )
590
814
  }
591
815
 
@@ -612,63 +836,23 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
612
836
  ...(cfgCacheable && !(policy.localRead || policy.remoteRead) ? { refresh: true } : {}),
613
837
  outputs: { files: outputs, workspaceFiles: wsOutputs },
614
838
  }
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
- }
839
+ if (!userSandbox) return base
840
+ await args.armSandbox?.()
841
+ const sb = await sandboxRequestFor(
842
+ node,
843
+ step.sandbox!,
844
+ args.workspaceRoot,
845
+ cfgCacheable ? args.keyedProjects(node) : undefined,
846
+ args.nestedProjectDirs,
847
+ )
848
+ placeholders = sb.placeholders
849
+ withheld = sb.withheld
850
+ return { ...base, sandbox: sb.sandbox }
663
851
  }
664
852
 
665
853
  const wallclockEndNs = process.hrtime.bigint() - args.runStartHrTimeNs
666
854
 
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) {
855
+ if (effectiveExitCode === 0 && willSave && deferralRequested) {
672
856
  // The outputs never landed here: no artifact, no rows. A partial local
673
857
  // record (a row with no artifact) is exactly the corrupt-entry shape
674
858
  // `restoreOutputs` refuses, so writing none is the only clean answer.
@@ -684,216 +868,110 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
684
868
  command: step.command,
685
869
  durationMs: result.durationMs,
686
870
  stdout: result.stdout,
871
+ ...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
872
+ ...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
687
873
  },
688
874
  })
689
875
  }
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({
876
+ } else if (effectiveExitCode === 0 && willSave && (await keyStillTrue())) {
877
+ const ownOutputFiles =
878
+ additive && stampedBefore !== undefined
879
+ ? await ownOutputsSince(cleanArgs, stampedBefore)
880
+ : undefined
881
+ const { landed } = await saveMiss({
882
+ ...(ownOutputFiles !== undefined ? { ownOutputFiles } : {}),
883
+ node,
707
884
  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
- },
885
+ cache,
886
+ log,
887
+ workspaceRoot: args.workspaceRoot,
888
+ nestedProjectDirs: args.nestedProjectDirs,
889
+ gitFilesCache: args.gitFilesCache,
890
+ outputs,
891
+ wsOutputs,
892
+ captured,
893
+ command: step.command,
894
+ durationMs: result.durationMs,
895
+ stdout: result.stdout,
896
+ ...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
897
+ ...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
898
+ outputDirSnapshots: args.outputDirSnapshots,
899
+ deferSave: args.deferSave,
723
900
  })
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()
901
+ args.deferredSaves?.set(node.id, landed)
902
+ } else if (cfgCacheable && !remoteOnly && !deferralRequested && args.noDependants !== true) {
903
+ // Ran here and saves nothing — failed, a read-only policy, a tainted
904
+ // upstream, a key that no longer held — yet it wrote what a save would
905
+ // have marked, and may have rewritten an input the re-check before a
906
+ // save catches. A same-run reader keyed from the snapshot's OIDs for
907
+ // either replayed the bytes from before the command (item 750). A
908
+ // withheld save already dropped the project; this finds the same move
909
+ // again and drops it again.
910
+ const endCheck = span('miss: recheck inputs')
911
+ const moved = await movedSinceKey()
912
+ endCheck()
913
+ if (moved !== undefined) {
914
+ forgetUndeclaredWrites(args, wsOutputs.length > 0 ? 'workspace' : 'project')
915
+ } else {
916
+ await markUnsaved({
917
+ node,
918
+ workspaceRoot: args.workspaceRoot,
919
+ nestedProjectDirs: args.nestedProjectDirs,
920
+ gitFilesCache: args.gitFilesCache,
921
+ outputs,
922
+ wsOutputs,
923
+ })
781
924
  }
782
925
  }
783
926
 
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
- const finalViolations = violations
788
-
789
- let verify: VerifyVerdict | undefined
927
+ /**
928
+ * The workspace fingerprint every key here folded has moved (a task
929
+ * rewrote the lockfile), said once per run.
930
+ */
931
+ function fingerprintMoved(): boolean {
932
+ if (args.fingerprintWatch?.moved() === undefined) return false
933
+ args.fingerprintWatch.say(log)
934
+ return true
935
+ }
790
936
 
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
- }
937
+ /**
938
+ * The input that no longer holds what the key folded: `null` when the
939
+ * key re-derived just before the command already differed (the file
940
+ * unnamed), undefined when none moved.
941
+ */
942
+ async function movedSinceKey(): Promise<string | null | undefined> {
943
+ return described!.hash !== hash
944
+ ? null
945
+ : await movedInput(described!.facts, cache, described!.describedAt)
809
946
  }
810
947
 
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
- }
948
+ /**
949
+ * Does the key still describe the inputs the command ran over? It was
950
+ * taken before the command — up front, or before `describeTaskInputs` —
951
+ * and a save files the outputs under it. A user's edit mid-run, or a
952
+ * task rewriting its own input (a formatter), saved bytes built from one
953
+ * state under the key of another: restore the old state and the next run
954
+ * replayed them as up-to-date (turborepo#10111, #1146, item 743). The
955
+ * result stands; only the entry is withheld, and the facts about the
956
+ * project go, since something wrote there. A lockfile a task rewrote
957
+ * withholds it too: the key folded the old one.
958
+ */
959
+ async function keyStillTrue(): Promise<boolean> {
960
+ if (fingerprintMoved()) return false
961
+ const endCheck = span('miss: recheck inputs')
962
+ const moved = await movedSinceKey()
963
+ endCheck()
964
+ if (moved === undefined) return true
965
+ const what = moved === null ? 'its inputs' : `\`${relPosix(args.workspaceRoot, moved)}\``
966
+ log.status(
967
+ `[vx] ${node.id}: ${what} changed after its key was taken — the result stands, ` +
968
+ `but is not saved under a key that no longer describes it`,
969
+ )
970
+ forgetUndeclaredWrites(args, wsOutputs.length > 0 ? 'workspace' : 'project')
971
+ return false
890
972
  }
891
973
 
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
974
+ const finalViolations = violations
897
975
 
898
976
  return {
899
977
  node,
@@ -902,6 +980,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
902
980
  durationMs: result.durationMs,
903
981
  hash,
904
982
  ...(attempt > 1 ? { attempts: attempt } : {}),
983
+ ...(result.timedOut === true && effectiveExitCode !== 0 ? { timedOut: true as const } : {}),
905
984
  ...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
906
985
  ...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
907
986
  ...(result.where !== undefined ? { where: result.where } : {}),
@@ -910,8 +989,6 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
910
989
  : {}),
911
990
  wallclockStartNs,
912
991
  wallclockEndNs,
913
- ...(verify !== undefined ? { verify } : {}),
914
- ...(outputFp !== undefined ? { outputFp } : {}),
915
992
  ...(finalViolations.length > 0
916
993
  ? {
917
994
  sandboxViolations: finalViolations.length,
@@ -921,227 +998,36 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
921
998
  }
922
999
  }
923
1000
 
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
1001
  /**
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.
1002
+ * Drop the run's facts about files a task may have written without
1003
+ * declaring them (`undeclaredWriteReach`): the project's git snapshot and
1004
+ * its index OIDs, and its `package.json` digest. The next reader
1005
+ * re-enumerates the project (one `git ls-files`) and hashes its files by
1006
+ * content; the per-declaration file-list memos are keyed on the snapshot
1007
+ * array, so they miss with it (item 743).
943
1008
  */
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,
1009
+ function forgetUndeclaredWrites(args: ExecuteArgs, reach: 'none' | 'project' | 'workspace'): void {
1010
+ if (reach === 'none') return
1011
+ if (reach === 'workspace') {
1012
+ args.gitFilesCache?.clear()
1013
+ args.hashCache?.packageJson.clear()
1014
+ return
1105
1015
  }
1016
+ args.gitFilesCache?.delete(args.node.projectDir)
1017
+ args.gitFilesCache?.invalidateWorkspacePartition()
1018
+ args.hashCache?.packageJson.delete(args.node.projectDir)
1106
1019
  }
1107
1020
 
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
- }
1021
+ /** The two bin directories a task's PATH starts with: its own, then the root's (once when they coincide). */
1022
+ function taskBinDirs(node: TaskNode, workspaceRoot: string): string[] {
1023
+ const bins = [path.join(node.projectDir, 'node_modules', '.bin')]
1024
+ const rootBin = path.join(workspaceRoot, 'node_modules', '.bin')
1025
+ // Identical when the root is itself a project — dedupe rather than list it
1026
+ // twice, so PATH reads the same either way.
1027
+ if (rootBin !== bins[0]) bins.push(rootBin)
1028
+ return bins
1135
1029
  }
1136
1030
 
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
1031
  /**
1146
1032
  * Build the child-process env for one task. Same arguments at every call site
1147
1033
  * (persistent + cached). Two `node_modules/.bin` directories are prepended to
@@ -1159,15 +1045,14 @@ async function prepareOutputsForBind(
1159
1045
  * it. npm/pnpm/yarn all put the ancestor chain on PATH for the same reason.
1160
1046
  */
1161
1047
  function taskEnv(node: TaskNode, step: ExecConfig, workspaceRoot: string): NodeJS.ProcessEnv {
1162
- const bins = [path.join(node.projectDir, 'node_modules', '.bin')]
1163
- const rootBin = path.join(workspaceRoot, 'node_modules', '.bin')
1164
- // Identical when the root is itself a project — dedupe rather than list it
1165
- // twice, so PATH reads the same either way.
1166
- if (rootBin !== bins[0]) bins.push(rootBin)
1167
- return buildIsolatedEnv({
1048
+ const bins = taskBinDirs(node, workspaceRoot)
1049
+ const env = buildIsolatedEnv({
1168
1050
  passThrough: step.env?.passThrough ?? [],
1169
1051
  define: step.env?.define ?? {},
1170
1052
  source: process.env,
1171
1053
  binPaths: bins,
1172
1054
  })
1055
+ env[VX_RUN_WORKSPACE_ENV] = workspaceRoot
1056
+ env[VX_RUN_TASK_ENV] = node.id
1057
+ return env
1173
1058
  }