@vzn/vx 0.0.15 → 0.0.18

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 (111) hide show
  1. package/README.md +91 -130
  2. package/index.ts +10 -0
  3. package/launcher.mjs +10 -11
  4. package/package.json +19 -5
  5. package/plugins/local-cache/index.ts +2 -0
  6. package/plugins/local-executor/index.ts +2 -0
  7. package/plugins/schedule-history/index.ts +2 -0
  8. package/src/bin.ts +2 -2
  9. package/src/cache/archive.ts +557 -0
  10. package/src/cache/cache.ts +929 -250
  11. package/src/cache/cas-backend.ts +20 -2
  12. package/src/cache/chained-cache.ts +202 -0
  13. package/src/cache/digest.ts +0 -15
  14. package/src/cache/index.ts +13 -3
  15. package/src/cache/inputs.ts +445 -41
  16. package/src/cache/layered-cache.ts +129 -30
  17. package/src/cache/tar-stream.ts +358 -0
  18. package/src/cli/cache.ts +36 -10
  19. package/src/cli/help.ts +167 -128
  20. package/src/cli/index.ts +74 -43
  21. package/src/cli/info.ts +30 -0
  22. package/src/cli/last.ts +140 -0
  23. package/src/cli/migrate-emit.ts +21 -0
  24. package/src/cli/migrate-nx.ts +23 -0
  25. package/src/cli/migrate-scripts.ts +127 -0
  26. package/src/cli/migrate-turbo.ts +43 -6
  27. package/src/cli/migrate.ts +119 -33
  28. package/src/cli/plan-format.ts +58 -3
  29. package/src/cli/plugin-commands.ts +96 -0
  30. package/src/cli/prune.ts +291 -0
  31. package/src/cli/run.ts +272 -80
  32. package/src/cli/upgrade.ts +7 -6
  33. package/src/cli/watch.ts +259 -61
  34. package/src/cli/why.ts +193 -0
  35. package/src/config.ts +40 -23
  36. package/src/exec/env.ts +7 -1
  37. package/src/exec/executor.ts +239 -0
  38. package/src/exec/index.ts +19 -1
  39. package/src/exec/runner.ts +116 -29
  40. package/src/exec/sandbox-runtime.ts +194 -24
  41. package/src/graph/index.ts +3 -1
  42. package/src/graph/scheduler.ts +92 -18
  43. package/src/graph/task-graph.ts +220 -6
  44. package/src/index.ts +82 -84
  45. package/src/orchestrator/deferred-outputs.ts +144 -0
  46. package/src/orchestrator/download-policy.ts +171 -0
  47. package/src/orchestrator/events.ts +30 -0
  48. package/src/orchestrator/execute-task.ts +328 -94
  49. package/src/orchestrator/failure-mode.ts +61 -0
  50. package/src/orchestrator/framed-output.ts +87 -35
  51. package/src/orchestrator/history.ts +78 -31
  52. package/src/orchestrator/index.ts +43 -90
  53. package/src/orchestrator/local-shortcircuit.ts +22 -0
  54. package/src/orchestrator/logger.ts +196 -14
  55. package/src/orchestrator/metrics.ts +57 -1376
  56. package/src/orchestrator/missing-plugin.ts +9 -0
  57. package/src/orchestrator/options.ts +22 -4
  58. package/src/orchestrator/plan.ts +144 -1
  59. package/src/orchestrator/plugin-host.ts +199 -119
  60. package/src/orchestrator/plugin.ts +144 -57
  61. package/src/orchestrator/prepare.ts +220 -69
  62. package/src/orchestrator/remote-prefetch.ts +27 -8
  63. package/src/orchestrator/resources.ts +20 -34
  64. package/src/orchestrator/run-artifacts.ts +41 -16
  65. package/src/orchestrator/run-context.ts +167 -29
  66. package/src/orchestrator/run-report.ts +73 -46
  67. package/src/orchestrator/run.ts +590 -122
  68. package/src/orchestrator/stable-keys.ts +74 -33
  69. package/src/orchestrator/status-line.ts +47 -8
  70. package/src/orchestrator/summary.ts +22 -1
  71. package/src/orchestrator/tally.ts +78 -35
  72. package/src/orchestrator/task-hash.ts +105 -11
  73. package/src/orchestrator/task-log-buffer.ts +291 -0
  74. package/src/orchestrator/telemetry-host.ts +24 -6
  75. package/src/orchestrator/telemetry.ts +181 -21
  76. package/src/orchestrator/upstream.ts +72 -31
  77. package/src/orchestrator/verify.ts +12 -2
  78. package/src/plugins/local-cache/index.ts +11 -0
  79. package/src/plugins/local-executor/index.ts +45 -0
  80. package/src/plugins/schedule-history/index.ts +109 -0
  81. package/src/util/edit-distance.ts +21 -0
  82. package/src/util/errors.ts +15 -1
  83. package/src/util/index.ts +7 -2
  84. package/src/util/num.ts +37 -0
  85. package/src/util/paths.ts +43 -0
  86. package/src/util/settle.ts +48 -0
  87. package/src/util/size.ts +5 -1
  88. package/src/util/tail.ts +66 -0
  89. package/src/util/timing.ts +63 -0
  90. package/src/workspace/affected.ts +142 -15
  91. package/src/workspace/config-cache.ts +304 -0
  92. package/src/workspace/config-eval.ts +210 -0
  93. package/src/workspace/config-imports.ts +161 -0
  94. package/src/workspace/filter.ts +22 -4
  95. package/src/workspace/fingerprint.ts +16 -1
  96. package/src/workspace/index.ts +15 -3
  97. package/src/workspace/lockfile.ts +13 -2
  98. package/src/workspace/nested-dirs.ts +21 -7
  99. package/src/workspace/package-graph.ts +23 -20
  100. package/src/workspace/project-loader.ts +503 -59
  101. package/src/workspace/workspace.ts +213 -72
  102. package/src/cache/tar.ts +0 -343
  103. package/src/cli/backend.ts +0 -23
  104. package/src/cli/mcp-rpc.ts +0 -292
  105. package/src/cli/mcp.ts +0 -92
  106. package/src/orchestrator/devframe-surface.ts +0 -74
  107. package/src/orchestrator/predict.ts +0 -106
  108. package/src/orchestrator/protocol.ts +0 -156
  109. package/src/orchestrator/run-state.ts +0 -145
  110. package/src/orchestrator/wire-render.ts +0 -92
  111. package/src/orchestrator/wire.ts +0 -212
@@ -16,11 +16,16 @@ import {
16
16
  } from '../cache/index.js'
17
17
  import {
18
18
  buildIsolatedEnv,
19
- runCommand,
20
19
  runPersistent,
21
- runSandboxed,
22
20
  resolveSandboxConfig,
21
+ shellQuote,
22
+ signalExitCode,
23
+ type CaptureConfig,
24
+ type ExecuteRequest,
25
+ type ExecuteResult,
23
26
  type SandboxViolation,
27
+ type TaskExecutor,
28
+ type TaskInputs,
24
29
  } from '../exec/index.js'
25
30
  import { isGroupTask, type TaskNode, type TaskOutcome, type VerifyVerdict } from '../graph/index.js'
26
31
  import {
@@ -31,10 +36,13 @@ import {
31
36
  outputRefs,
32
37
  undeclaredInputPaths,
33
38
  } from './verify.js'
39
+ import { span, staticPrefix, wholeSubtreePrefixes } from '../util/index.js'
40
+ import type { DeferredOutputs } from './deferred-outputs.js'
34
41
  import type { Logger } from './logger.js'
35
42
  import {
36
43
  computeGroupHash,
37
44
  computeTaskHash,
45
+ describeTaskInputs,
38
46
  type HashCache,
39
47
  type TaskInputComponent,
40
48
  } from './task-hash.js'
@@ -49,6 +57,32 @@ export interface ExecuteArgs {
49
57
  cachePolicy?: CachePolicy
50
58
  forwardArgs?: readonly string[] | undefined
51
59
  log: Logger
60
+ /** The executor this task was PLACED on (run.ts, before scheduling). */
61
+ executor: TaskExecutor
62
+ /**
63
+ * `exec.remote: 'only'` with no remote executor to take it: succeed
64
+ * WITHOUT executing, cleaning, restoring or saving anything on this
65
+ * machine. The hash is still computed — dependents fold it.
66
+ */
67
+ remoteOnlyNoop?: boolean
68
+ /**
69
+ * `exec.remote: 'only'` placed on a remote executor: execute remotely,
70
+ * but this machine's disk is untouched — no output clean, no vx cache
71
+ * probe/restore/save. The task's outputs live in the REMOTE store and
72
+ * reach dependents' input trees by reference (the executor's job).
73
+ */
74
+ remoteOnly?: boolean
75
+ /**
76
+ * Plan-time `--download` decision for THIS task (run.ts). `'deferred'`
77
+ * means: do not clean, do not save — the executor is expected to leave
78
+ * the outputs in the remote store and hand back a closure. An executor
79
+ * that ignores it and materialises anyway simply gets no local entry;
80
+ * the outputs are on disk but unindexed, which is its contract to keep.
81
+ */
82
+ download?: 'eager' | 'deferred'
83
+ /** Run-scoped deferral registry — where deferred closures land, and
84
+ * where a locally-placed consumer fetches its producers from. */
85
+ deferred?: DeferredOutputs
52
86
  nestedProjectDirs: string[]
53
87
  /** Anchor for hrtime spans across all tasks in this run. */
54
88
  runStartHrTimeNs: bigint
@@ -141,6 +175,9 @@ function executeGroupTask(args: ExecuteArgs): TaskOutcome {
141
175
  exitCode: 0,
142
176
  durationMs: 0,
143
177
  hash: computeGroupHash(args.upstream),
178
+ // What this group stands for. A dependent expands it to describe the
179
+ // real tasks in its input closure — see `TaskOutcome.groupUpstream`.
180
+ groupUpstream: args.upstream,
144
181
  wallclockStartNs: wallclockNs,
145
182
  wallclockEndNs: wallclockNs,
146
183
  }
@@ -155,6 +192,9 @@ function executeGroupTask(args: ExecuteArgs): TaskOutcome {
155
192
  * Never reads or writes the cache — the project loader rejects
156
193
  * `cache + persistent` at config-load time, so by the time we get
157
194
  * here, `cache` is guaranteed undefined.
195
+ *
196
+ * Never routed through an executor: a persistent task is local by
197
+ * construction (its port lives on this machine).
158
198
  */
159
199
  async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
160
200
  const { node, log } = args
@@ -164,7 +204,7 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
164
204
  persistent: NonNullable<ExecConfig['persistent']>
165
205
  }
166
206
  const effectiveForwardArgs = node.requested ? (args.forwardArgs ?? []) : []
167
- const env = taskEnv(node, step)
207
+ const env = taskEnv(node, step, args.workspaceRoot)
168
208
  const wallclockStartNs = process.hrtime.bigint() - args.runStartHrTimeNs
169
209
 
170
210
  // When readyWhen is set we leave the command untouched so the
@@ -176,7 +216,7 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
176
216
  step.persistent.readyWhen !== undefined
177
217
  ? step.command
178
218
  : effectiveForwardArgs.length > 0
179
- ? step.command + ' ' + effectiveForwardArgs.map((s) => JSON.stringify(s)).join(' ')
219
+ ? step.command + ' ' + effectiveForwardArgs.map(shellQuote).join(' ')
180
220
  : step.command,
181
221
  cwd: node.projectDir,
182
222
  env,
@@ -241,8 +281,25 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
241
281
  // on. Output management (clean before exec) keys off writes — so
242
282
  // `--no-cache` (all off) leaves the user's tree alone, while `--force`
243
283
  // (reads off, writes on) still wipes + repopulates a clean snapshot.
244
- const willRead = cfgCacheable && (policy.localRead || policy.remoteRead)
245
- const willWrite = cfgCacheable && (policy.localWrite || policy.remoteWrite)
284
+ // A remote-only task never touches this machine's disk: no probe/restore
285
+ // (restoring node_modules onto a dev machine is exactly what the field
286
+ // exists to prevent), no output clean, no local artifact save. Its result
287
+ // lives in the remote executor's own store.
288
+ const remoteOnly = args.remoteOnly === true
289
+ const willRead = !remoteOnly && cfgCacheable && (policy.localRead || policy.remoteRead)
290
+ const willWrite = !remoteOnly && cfgCacheable && (policy.localWrite || policy.remoteWrite)
291
+
292
+ // Retain only what is read back. `cache.save` below is the single consumer
293
+ // of `result.stdout`, and it runs only when this task will WRITE an entry;
294
+ // `result.stderr` has no consumer at all (a failing task's stderr reaches
295
+ // the user through the live `onStderr` callback, and the cache has never
296
+ // stored stderr — see the v17 artifact format). Both streams are still
297
+ // drained and still stream chunk-by-chunk to the logger; only the retained
298
+ // copy is dropped, which for a chatty task is its full byte size in heap.
299
+ // Deferral is decided at plan time and only ever set for a remote-placed,
300
+ // eligibility-cleared task; it suppresses this machine's clean AND save.
301
+ const deferralRequested = args.download === 'deferred'
302
+ const capture: CaptureConfig = { stdout: willWrite, stderr: false }
246
303
 
247
304
  const outputs = cacheCfg?.outputs.files ?? []
248
305
  const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
@@ -295,6 +352,30 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
295
352
  ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
296
353
  })
297
354
 
355
+ // The local no-op half of `exec.remote: 'only'`: no remote executor took
356
+ // the task, so it succeeds without running. The hash was still computed —
357
+ // dependents fold it — and the machine's ambient state (node_modules as
358
+ // installed by the dev) serves dependents exactly as before the field.
359
+ 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
+ return {
368
+ node,
369
+ status: 'success',
370
+ exitCode: 0,
371
+ durationMs: 0,
372
+ hash,
373
+ ...(wantsProof ? { verify: { kind: 'unverifiable-remote-only' as const } } : {}),
374
+ wallclockStartNs: taskStartNs,
375
+ wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
376
+ }
377
+ }
378
+
298
379
  const cleanArgs = {
299
380
  projectDir: node.projectDir,
300
381
  outputs,
@@ -316,16 +397,41 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
316
397
  }
317
398
  // Confirmed stable miss — skip the probe, fall through to run.
318
399
  } else {
400
+ const endProbe = span('cache.get')
319
401
  const hit = await cache.get(hash, { taskId: node.id, command: step.command })
402
+ endProbe()
320
403
  if (hit) {
321
404
  return restoreHit({ args, hash, hit, cacheOpStart, taskStartNs })
322
405
  }
323
406
  }
324
407
  }
325
408
 
326
- const env = taskEnv(node, step)
409
+ const env = taskEnv(node, step, args.workspaceRoot)
327
410
  const wallclockStartNs = process.hrtime.bigint() - args.runStartHrTimeNs
328
411
 
412
+ // Miss path: describe the input set ONCE — the executor gets the values
413
+ // (what a remote worker must reproduce) and the save below reuses the
414
+ // captured digest rows for the Tier-3 `entry_inputs` fingerprint, so the
415
+ // fold runs once here instead of once after the subprocess. The HashCache
416
+ // memos + gitFilesCache OID map make this a fold + array pushes, no I/O.
417
+ // A non-cacheable task folds nothing and ships no inputs.
418
+ const captured: TaskInputComponent[] = []
419
+ const described = cfgCacheable
420
+ ? await describeTaskInputs({
421
+ node,
422
+ upstream,
423
+ workspaceRoot: args.workspaceRoot,
424
+ workspaceFingerprint: args.workspaceFingerprint,
425
+ cache,
426
+ forwardArgs: args.forwardArgs,
427
+ nestedProjectDirs: args.nestedProjectDirs,
428
+ captureInto: captured,
429
+ ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
430
+ ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
431
+ })
432
+ : undefined
433
+ const inputs: TaskInputs | undefined = described?.inputs
434
+
329
435
  // Sandbox is opt-in per task via `sandbox: {}` (or `sandbox: {...}`)
330
436
  // in the task config. No CLI flag, no workspace inheritance — the
331
437
  // task config is the single source of truth. EXCEPTION: `--verify=inputs`
@@ -344,7 +450,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
344
450
  // an explicit `retries: 0`.
345
451
  const maxAttempts = 1 + (step.retries ?? args.retries ?? 0)
346
452
  let attempt = 0
347
- let result: Awaited<ReturnType<typeof runCommand>>
453
+ let result: ExecuteResult
348
454
  let effectiveExitCode: number
349
455
 
350
456
  // One task attempt: clean the declared outputs (before EVERY attempt — so a
@@ -355,23 +461,56 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
355
461
  // streamed notice). Shared by the retry loop AND the `--verify` re-run so
356
462
  // the two can never drift on the spawn/clean/classify path.
357
463
  async function runAttempt(): Promise<{
358
- result: Awaited<ReturnType<typeof runCommand>>
464
+ result: ExecuteResult
359
465
  exitCode: number
360
466
  }> {
361
- if (willWrite && outputs.length > 0) await cleanOutputs(cleanArgs)
362
- if (willWrite && wsOutputs.length > 0) {
467
+ // A deferred task's outputs are deliberately NOT coming, so wiping the
468
+ // tree would replace a stale build with nothing at all. The eligibility
469
+ // gate guarantees no key in this run can see what stays behind, and the
470
+ // summary names every deferred task so `dist/` is not silently stale.
471
+ if (willWrite && !deferralRequested && outputs.length > 0) {
472
+ // Mark the wiped paths, exactly as the workspace twin below does. The
473
+ // git snapshot still lists them with their committed index OIDs, and
474
+ // resolveFiles SKIPS its existence probe for any path carrying a
475
+ // trusted OID — so a declared output the producer deletes and does not
476
+ // re-create would stay in a same-project consumer's input set, keeping
477
+ // that consumer's key unchanged while the file is gone from disk.
478
+ const endClean = span('miss: clean outputs')
479
+ const cleanedRels = await cleanOutputs(cleanArgs)
480
+ endClean()
481
+ args.gitFilesCache?.markOutputsChanged(node.projectDir, cleanedRels)
482
+ }
483
+ if (willWrite && !deferralRequested && wsOutputs.length > 0) {
363
484
  // Root-anchored deletions can land in other projects' dirs; mark
364
485
  // them so stale per-project git snapshots can't survive the wipe.
365
486
  const cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
366
487
  args.gitFilesCache?.markWorkspaceOutputsChanged(args.workspaceRoot, cleanedWsRels)
367
488
  }
368
489
  violations = []
369
- const res = useSandbox ? await runSandboxedTask() : await runUnsandboxedTask()
370
- // Fail-on-violation. macOS's structured violation store lets us turn a
371
- // passing exit code into a failure when the task tripped the boundary;
372
- // Linux relies on the child failing naturally on ENOENT (violations is
373
- // always 0 there, but the task is already exit != 0 if it needed the
374
- // missing file). Violations surface via `TaskOutcome.sandboxViolationLines`.
490
+ const endReq = span('miss: build request')
491
+ const req = await buildRequest()
492
+ endReq()
493
+ // An executor that THROWS produces no captured output, so the task's
494
+ // frame would print the command and nothing else while the reason went
495
+ // straight to stderr and scrolled away in a broad run. Put it in the
496
+ // task's own stream first: the frame is where a reader looks for why a
497
+ // task failed, and a remote executor's failures are exactly the ones with
498
+ // no other trace. Rethrown unchanged — the scheduler still classifies it,
499
+ // and still prints it plainly for a UserError.
500
+ 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
+ })
506
+ endExec()
507
+ violations = [...res.violations]
508
+ // Fail-on-violation, on BOTH platforms: macOS reads SRT's structured
509
+ // violation store, Linux parses the strace log the sandboxed spawn
510
+ // writes. (This comment used to claim Linux violations are "always 0"
511
+ // and that enforcement there rests on the child failing naturally — true
512
+ // before the strace pass shipped, false since, and the branch below is
513
+ // live on Linux.) Violations surface via `TaskOutcome.sandboxViolationLines`.
375
514
  let code = res.exitCode
376
515
  // A USER-declared sandbox fails the task on any violation (that's its
377
516
  // fail-on-violation contract). A sandbox forced on ONLY by `--verify=inputs`
@@ -383,10 +522,42 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
383
522
  // stream a clear line so the 143 exit reads as a timeout.
384
523
  if (res.timedOut) {
385
524
  log.taskStderr(node, `\n[vx] timed out after ${effectiveTimeout}ms — killed (SIGTERM)\n`)
525
+ // Force a non-zero classification even if the child TRAPPED SIGTERM and
526
+ // still exited 0 (`trap 'exit 0' TERM`, a common graceful-shutdown
527
+ // pattern). Without this a timed-out task is classified `success` and its
528
+ // 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.
532
+ if (code === 0) code = signalExitCode('SIGTERM')
386
533
  }
387
534
  return { result: res, exitCode: code }
388
535
  }
389
536
 
537
+ // This task is about to run a command HERE, and it missed the cache, so
538
+ // whatever its dependency closure produced has to actually be on disk.
539
+ // Producers left deferred are fetched now — once per run, concurrently,
540
+ // on this task's own worker slot, because they are its real critical
541
+ // path. A remote-placed task needs nothing: its worker grafts the
542
+ // upstream bytes by reference. A cache HIT reads no inputs at all.
543
+ if (args.executor.remote !== true && args.deferred !== undefined) {
544
+ try {
545
+ await args.deferred.materializeFor(node)
546
+ } catch (err) {
547
+ const msg = err instanceof Error ? err.message : String(err)
548
+ log.taskStderr(node, `\n[vx] ${msg}\n`)
549
+ return {
550
+ node,
551
+ status: 'failed',
552
+ exitCode: 1,
553
+ durationMs: 0,
554
+ hash,
555
+ wallclockStartNs: taskStartNs,
556
+ wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
557
+ }
558
+ }
559
+ }
560
+
390
561
  for (;;) {
391
562
  attempt++
392
563
  const a = await runAttempt()
@@ -418,20 +589,30 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
418
589
  )
419
590
  }
420
591
 
421
- async function runUnsandboxedTask(): ReturnType<typeof runCommand> {
422
- return runCommand({
592
+ async function buildRequest(): Promise<ExecuteRequest> {
593
+ const base: ExecuteRequest = {
594
+ taskId: node.id,
595
+ workspaceRoot: args.workspaceRoot,
423
596
  command: step.command,
597
+ forwardArgs: effectiveForwardArgs,
424
598
  cwd: node.projectDir,
425
599
  env,
426
- forwardArgs: effectiveForwardArgs,
600
+ envDefine: step.env?.define ?? {},
601
+ capture,
427
602
  onStdout: (chunk) => log.taskStdout(node, chunk),
428
603
  onStderr: (chunk) => log.taskStderr(node, chunk),
429
604
  ...(args.liveChildren !== undefined ? { liveChildren: args.liveChildren } : {}),
430
605
  ...(effectiveTimeout !== undefined ? { timeoutMs: effectiveTimeout } : {}),
431
- })
432
- }
433
-
434
- async function runSandboxedTask(): ReturnType<typeof runCommand> {
606
+ ...(inputs !== undefined ? { inputs } : {}),
607
+ ...(cfgCacheable ? { cacheKey: hash } : {}),
608
+ ...(remoteOnly ? { remoteOnly: true } : {}),
609
+ ...(deferralRequested ? { download: 'deferred' as const } : {}),
610
+ // `--force`/`--no-cache` reach a remote executor's private record
611
+ // through this flag — the policy gates above only cover vx's OWN cache.
612
+ ...(cfgCacheable && !(policy.localRead || policy.remoteRead) ? { refresh: true } : {}),
613
+ outputs: { files: outputs, workspaceFiles: wsOutputs },
614
+ }
615
+ if (!useSandbox) return base
435
616
  // Baseline allowRead = resolved cache.inputs.files (absolute paths)
436
617
  // Baseline allowWrite = static prefix of every cache.outputs.files glob
437
618
  // Baseline denyRead = the workspace root, so any read outside the
@@ -468,26 +649,17 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
468
649
  await prepareOutputsForBind(args.workspaceRoot, wsOutputs)
469
650
  // Output paths are read+write — a task that declares `dist/**` as
470
651
  // output expects to read what it just wrote (e.g. `touch dist/x`
471
- // stats the file; `tsc --incremental` re-reads .tsbuildinfo). This
472
- // isn't magic — the user already declared these paths; we're just
473
- // honoring the natural read-write symmetry of an output directory.
474
- const sandboxResult = await runSandboxed({
475
- command: step.command,
476
- cwd: node.projectDir,
477
- env,
478
- forwardArgs: effectiveForwardArgs,
479
- onStdout: (chunk) => log.taskStdout(node, chunk),
480
- onStderr: (chunk) => log.taskStderr(node, chunk),
481
- ...(args.liveChildren !== undefined ? { liveChildren: args.liveChildren } : {}),
482
- ...(effectiveTimeout !== undefined ? { timeoutMs: effectiveTimeout } : {}),
483
- baseAllowRead: [...resolved.files, ...baseAllowWrite],
484
- baseAllowWrite,
485
- baseDenyRead: [args.workspaceRoot],
486
- config: resolveSandboxConfig(cfg.sandbox ?? {}, node.projectDir),
487
- })
488
- violations = sandboxResult.violations
489
- const { violations: _v, ...runResult } = sandboxResult
490
- return runResult
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
+ }
491
663
  }
492
664
 
493
665
  const wallclockEndNs = process.hrtime.bigint() - args.runStartHrTimeNs
@@ -496,7 +668,27 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
496
668
  // captured inside the save block while the tree is attempt-1's.
497
669
  let verifyFp1: Map<string, string> | undefined
498
670
 
499
- if (effectiveExitCode === 0 && willWrite) {
671
+ if (effectiveExitCode === 0 && willWrite && deferralRequested) {
672
+ // The outputs never landed here: no artifact, no rows. A partial local
673
+ // record (a row with no artifact) is exactly the corrupt-entry shape
674
+ // `restoreOutputs` refuses, so writing none is the only clean answer.
675
+ // The closure is what a later local consumer — or a later eager run —
676
+ // pulls the bytes with.
677
+ const materialize = result.outputs?.kind === 'deferred' ? result.outputs.materialize : undefined
678
+ if (materialize !== undefined) {
679
+ args.deferred?.register(node.id, {
680
+ materialize,
681
+ hash,
682
+ entry: {
683
+ taskId: node.id,
684
+ command: step.command,
685
+ durationMs: result.durationMs,
686
+ stdout: result.stdout,
687
+ },
688
+ })
689
+ }
690
+ } else if (effectiveExitCode === 0 && willWrite) {
691
+ const endResolve = span('miss: resolve outputs')
500
692
  const outputFiles = await resolveOutputs({
501
693
  projectDir: node.projectDir,
502
694
  outputs,
@@ -506,26 +698,11 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
506
698
  workspaceRoot: args.workspaceRoot,
507
699
  outputs: wsOutputs,
508
700
  })
509
- // Tier-3 input fingerprint: capture the cache-key components for
510
- // THIS entry, persisted with the entry inside `cache.save`'s
511
- // transaction. Miss path only — the warm/hit path never reaches
512
- // here, so it allocates no array and pushes nothing. The HashCache
513
- // memos + gitFilesCache OID map make this second `computeTaskHash`
514
- // a fold + array pushes (no re-stat / re-hash I/O), negligible next
515
- // to the subprocess this task just ran.
516
- const captured: TaskInputComponent[] = []
517
- await computeTaskHash({
518
- node,
519
- upstream,
520
- workspaceRoot: args.workspaceRoot,
521
- workspaceFingerprint: args.workspaceFingerprint,
522
- cache,
523
- forwardArgs: args.forwardArgs,
524
- nestedProjectDirs: args.nestedProjectDirs,
525
- captureInto: captured,
526
- ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
527
- ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
528
- })
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.
529
706
  await cache.save({
530
707
  hash,
531
708
  projectDir: node.projectDir,
@@ -534,14 +711,23 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
534
711
  ? { workspaceOutputFiles: wsOutputFiles, workspaceRoot: args.workspaceRoot }
535
712
  : {}),
536
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.
537
717
  entry: {
538
718
  taskId: node.id,
539
719
  command: step.command,
540
- exitCode: effectiveExitCode,
541
720
  durationMs: result.durationMs,
542
721
  stdout: result.stdout,
543
722
  },
544
723
  })
724
+ endSave()
725
+ {
726
+ const savedDirPrefixes = wholeSubtreePrefixes(outputs)
727
+ if (savedDirPrefixes !== null) {
728
+ await cache.recordOutputDirs?.(hash, node.projectDir, savedDirPrefixes)
729
+ }
730
+ }
545
731
  // --verify: fingerprint attempt 1's outputs by CONTENT (raw bytes, never
546
732
  // the mtime+size memo). The determinism re-run below compares against it,
547
733
  // and the fingerprinting modes (`--verify`/`=all`/`=fingerprint`) fold it
@@ -718,6 +904,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
718
904
  ...(attempt > 1 ? { attempts: attempt } : {}),
719
905
  ...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
720
906
  ...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
907
+ ...(result.where !== undefined ? { where: result.where } : {}),
908
+ ...(deferralRequested && result.outputs?.kind === 'deferred'
909
+ ? { outputs: 'deferred' as const }
910
+ : {}),
721
911
  wallclockStartNs,
722
912
  wallclockEndNs,
723
913
  ...(verify !== undefined ? { verify } : {}),
@@ -758,6 +948,7 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
758
948
  const outputs = cacheCfg?.outputs.files ?? []
759
949
  const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
760
950
  const anyOutputs = outputs.length > 0 || wsOutputs.length > 0
951
+ const dirPrefixes = wholeSubtreePrefixes(outputs)
761
952
  const cleanArgs = {
762
953
  projectDir: node.projectDir,
763
954
  outputs,
@@ -781,7 +972,9 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
781
972
  // tar (decompress + parse) at the same point.
782
973
  let skipRestore = false
783
974
  if (anyOutputs) {
784
- const expected = args.cache.loadOutputFilesBatch([hash]).get(hash) ?? []
975
+ const endRows = span('output rows')
976
+ const expected = hit.outputRows ?? args.cache.loadOutputFilesBatch([hash]).get(hash) ?? []
977
+ endRows()
785
978
  if (expected.length > 0) {
786
979
  // Two namespaces in the rows: bare rels are project outputs,
787
980
  // `workspace-outputs/<rel>` rows anchor at the workspace root.
@@ -789,15 +982,39 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
789
982
  const wsExpected = expected
790
983
  .filter((e) => e.path.startsWith(WORKSPACE_OUTPUT_PREFIX))
791
984
  .map((e) => ({ ...e, path: e.path.slice(WORKSPACE_OUTPUT_PREFIX.length) }))
792
- const actualAbs = await resolveOutputs({
793
- projectDir: node.projectDir,
794
- outputs,
795
- nestedProjectDirs: args.nestedProjectDirs,
796
- })
797
- const actualWsAbs = await resolveWorkspaceOutputs({
798
- workspaceRoot: args.workspaceRoot,
799
- outputs: wsOutputs,
800
- })
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
+ }
801
1018
  const setsMatch = (
802
1019
  actual: readonly string[],
803
1020
  exp: ReadonlyArray<{ path: string }>,
@@ -812,9 +1029,17 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
812
1029
  path.relative(args.workspaceRoot, p).split(path.sep).join('/'),
813
1030
  )
814
1031
  if (setsMatch(actualRels, projExpected) && setsMatch(actualWsRels, wsExpected)) {
1032
+ const endStat = span('output stat')
815
1033
  skipRestore =
816
1034
  (await args.cache.isOutputsCurrent(node.projectDir, projExpected)) &&
817
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
+ }
818
1043
  }
819
1044
  }
820
1045
  }
@@ -824,6 +1049,9 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
824
1049
  if (outputs.length > 0) cleanedRels = await cleanOutputs(cleanArgs)
825
1050
  if (wsOutputs.length > 0) cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
826
1051
  await args.cache.restoreOutputs(hash, node.projectDir, args.workspaceRoot)
1052
+ if (dirPrefixes !== null) {
1053
+ await args.cache.recordOutputDirs?.(hash, node.projectDir, dirPrefixes)
1054
+ }
827
1055
  // Restored outputs changed the project's tree — but on this
828
1056
  // path we know the EXACT changed paths (wiped declared
829
1057
  // outputs + the artifact's files). Record them instead of
@@ -861,7 +1089,10 @@ export async function restoreHit(restore: RestoreHitArgs): Promise<TaskOutcome>
861
1089
  node,
862
1090
  status,
863
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.
864
1094
  durationMs: Math.round(performance.now() - cacheOpStart),
1095
+ storedDurationMs: hit.durationMs,
865
1096
  hash,
866
1097
  restored,
867
1098
  // Under `--verify`, a cache hit didn't execute this run — so it wasn't
@@ -911,29 +1142,32 @@ async function prepareOutputsForBind(
911
1142
  * a file path covers writes to that file; a dir path covers writes
912
1143
  * anywhere underneath.
913
1144
  */
914
- function staticPrefix(glob: string): string {
915
- const wildcardIdx = glob.search(/[*?[\]]/)
916
- if (wildcardIdx === -1) return glob
917
- // Trim back to the last separator before the wildcard so we keep
918
- // only complete path components (e.g. `dist/sub-**` → `dist`, not
919
- // `dist/sub-`).
920
- const head = glob.slice(0, wildcardIdx)
921
- const lastSep = head.lastIndexOf('/')
922
- if (lastSep === -1) return '.'
923
- return head.slice(0, lastSep) || '/'
924
- }
925
-
926
1145
  /**
927
- * Build the child-process env for one task. Same arguments at every
928
- * call site (persistent + cached); the project's own
929
- * `node_modules/.bin` is prepended to PATH — never the workspace
930
- * root's, never sibling projects' (per the project-isolation rule).
1146
+ * Build the child-process env for one task. Same arguments at every call site
1147
+ * (persistent + cached). Two `node_modules/.bin` directories are prepended to
1148
+ * PATH: the project's own, then the WORKSPACE ROOT's. Never a sibling
1149
+ * project's, and never an arbitrary ancestor — that is the project-isolation
1150
+ * rule, and the root is not a sibling.
1151
+ *
1152
+ * The root entry is where a monorepo's shared tooling actually lives: declare
1153
+ * `oxlint` once as a root devDependency and every member's `.bin` is empty of
1154
+ * it. Without the root on PATH such a task exits 127, which is what this
1155
+ * repo's own gate did the moment core stopped BEING the root and the two
1156
+ * paths stopped coinciding. It also removes a divergence that mattered more:
1157
+ * the REAPI executor already rebuilds both entries in the action's command,
1158
+ * so a task that resolved on a worker failed on the machine that submitted
1159
+ * it. npm/pnpm/yarn all put the ancestor chain on PATH for the same reason.
931
1160
  */
932
- function taskEnv(node: TaskNode, step: ExecConfig): NodeJS.ProcessEnv {
1161
+ 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)
933
1167
  return buildIsolatedEnv({
934
1168
  passThrough: step.env?.passThrough ?? [],
935
1169
  define: step.env?.define ?? {},
936
1170
  source: process.env,
937
- binPaths: [path.join(node.projectDir, 'node_modules', '.bin')],
1171
+ binPaths: bins,
938
1172
  })
939
1173
  }