@vzn/vx 0.0.652 → 0.0.653

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 (167) hide show
  1. package/README.md +2 -2
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +1 -0
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +25 -3
  8. package/src/cache/archive.ts +196 -31
  9. package/src/cache/cache.ts +863 -225
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +22 -5
  16. package/src/cache/index.ts +10 -1
  17. package/src/cache/inputs.ts +573 -152
  18. package/src/cache/key-fold.ts +1 -1
  19. package/src/cache/layer.ts +23 -10
  20. package/src/cache/layered-cache.ts +28 -7
  21. package/src/cache/output-index.ts +106 -44
  22. package/src/cache/plugin-cache.ts +2 -2
  23. package/src/cache/policy.ts +6 -2
  24. package/src/cache/remote-lane.ts +3 -1
  25. package/src/cache/run-history.ts +50 -10
  26. package/src/cache/schema.ts +42 -9
  27. package/src/cli/cache.ts +147 -34
  28. package/src/cli/completions.ts +2 -2
  29. package/src/cli/core-alias.ts +9 -2
  30. package/src/cli/docs.ts +4 -2
  31. package/src/cli/help.ts +25 -2
  32. package/src/cli/index.ts +38 -7
  33. package/src/cli/info.ts +4 -3
  34. package/src/cli/init.ts +55 -20
  35. package/src/cli/last.ts +184 -41
  36. package/src/cli/lock.ts +187 -24
  37. package/src/cli/plan-format.ts +24 -21
  38. package/src/cli/plugin-commands.ts +6 -1
  39. package/src/cli/plugin-templates.ts +1 -1
  40. package/src/cli/run.ts +68 -34
  41. package/src/cli/select.ts +160 -64
  42. package/src/cli/show.ts +63 -18
  43. package/src/cli/task-verb.ts +12 -7
  44. package/src/cli/upgrade.ts +210 -76
  45. package/src/cli/watch-filter.ts +88 -15
  46. package/src/cli/watch-fs.ts +34 -47
  47. package/src/cli/watch-judge.ts +72 -8
  48. package/src/cli/watch-set.ts +6 -4
  49. package/src/cli/watch.ts +179 -25
  50. package/src/cli/why.ts +107 -33
  51. package/src/cli/workspace-config.ts +11 -1
  52. package/src/config.ts +9 -0
  53. package/src/exec/env.ts +3 -1
  54. package/src/exec/executor.ts +76 -15
  55. package/src/exec/kill-tree.ts +7 -7
  56. package/src/exec/runner.ts +270 -108
  57. package/src/exec/sandbox-binds.ts +109 -18
  58. package/src/exec/sandbox-runtime.ts +134 -37
  59. package/src/exec/sandbox-violations.ts +250 -46
  60. package/src/exec/spawn-off.ts +10 -5
  61. package/src/exec/warm-shell.ts +29 -7
  62. package/src/graph/folds.ts +89 -16
  63. package/src/graph/index.ts +8 -0
  64. package/src/graph/priorities.ts +8 -5
  65. package/src/graph/scheduler.ts +173 -38
  66. package/src/graph/stand-ins.ts +36 -0
  67. package/src/graph/task-graph.ts +327 -141
  68. package/src/graph/task-status.ts +5 -1
  69. package/src/handoff.ts +45 -10
  70. package/src/history/failure-mode.ts +15 -5
  71. package/src/history/history.ts +103 -30
  72. package/src/history/index.ts +1 -0
  73. package/src/history/metrics.ts +189 -69
  74. package/src/index.ts +2 -1
  75. package/src/orchestrator/admission.ts +13 -9
  76. package/src/orchestrator/affected-tasks.ts +35 -13
  77. package/src/orchestrator/command-windows.ts +8 -5
  78. package/src/orchestrator/deferred-outputs.ts +41 -12
  79. package/src/orchestrator/doctor.ts +21 -9
  80. package/src/orchestrator/download-policy.ts +3 -3
  81. package/src/orchestrator/excluded-keys.ts +14 -2
  82. package/src/orchestrator/execute-args.ts +9 -2
  83. package/src/orchestrator/execute-task.ts +564 -123
  84. package/src/orchestrator/graph-memo.ts +26 -4
  85. package/src/orchestrator/graph-stage.ts +27 -4
  86. package/src/orchestrator/held-servers.ts +36 -14
  87. package/src/orchestrator/hit-restore.ts +136 -87
  88. package/src/orchestrator/index.ts +4 -1
  89. package/src/orchestrator/keyed-projects.ts +25 -0
  90. package/src/orchestrator/local-floor.ts +21 -3
  91. package/src/orchestrator/lockfile-claim.ts +30 -4
  92. package/src/orchestrator/miss-save.ts +33 -53
  93. package/src/orchestrator/options.ts +13 -0
  94. package/src/orchestrator/output-host.ts +16 -4
  95. package/src/orchestrator/output-log.ts +1 -54
  96. package/src/orchestrator/persistent-task.ts +31 -17
  97. package/src/orchestrator/persistent.ts +152 -15
  98. package/src/orchestrator/placement.ts +81 -15
  99. package/src/orchestrator/plan.ts +37 -15
  100. package/src/orchestrator/plugin-host.ts +232 -108
  101. package/src/orchestrator/plugin.ts +53 -27
  102. package/src/orchestrator/prepare.ts +194 -38
  103. package/src/orchestrator/projects.ts +89 -10
  104. package/src/orchestrator/read-only.ts +115 -0
  105. package/src/orchestrator/run-context.ts +6 -2
  106. package/src/orchestrator/run-failures.ts +43 -14
  107. package/src/orchestrator/run-id.ts +13 -2
  108. package/src/orchestrator/run-keys.ts +87 -28
  109. package/src/orchestrator/run-live.ts +16 -13
  110. package/src/orchestrator/run-lock.ts +27 -4
  111. package/src/orchestrator/run-records.ts +12 -10
  112. package/src/orchestrator/run-report.ts +2 -1
  113. package/src/orchestrator/run-stop.ts +17 -9
  114. package/src/orchestrator/run.ts +443 -107
  115. package/src/orchestrator/sandbox-fix.ts +40 -12
  116. package/src/orchestrator/sandbox-request.ts +286 -59
  117. package/src/orchestrator/save-lane.ts +24 -13
  118. package/src/orchestrator/shell-verdict.ts +20 -1
  119. package/src/orchestrator/signals.ts +103 -8
  120. package/src/orchestrator/stable-keys.ts +17 -9
  121. package/src/orchestrator/task-artifacts.ts +15 -2
  122. package/src/orchestrator/task-hash.ts +187 -30
  123. package/src/orchestrator/telemetry-host.ts +25 -6
  124. package/src/orchestrator/telemetry.ts +80 -12
  125. package/src/orchestrator/upstream.ts +3 -1
  126. package/src/report/events.ts +20 -3
  127. package/src/report/forecast.ts +103 -37
  128. package/src/report/framed-output.ts +165 -157
  129. package/src/report/index.ts +2 -1
  130. package/src/report/logger.ts +107 -30
  131. package/src/report/plain-output.ts +13 -9
  132. package/src/report/status-line.ts +28 -55
  133. package/src/report/summary.ts +57 -14
  134. package/src/settings.ts +42 -6
  135. package/src/ui/components.ts +36 -42
  136. package/src/ui/index.ts +3 -1
  137. package/src/ui/live-region.ts +67 -8
  138. package/src/ui/terminal.ts +12 -0
  139. package/src/ui/theme.ts +16 -1
  140. package/src/util/config-frame.ts +17 -3
  141. package/src/util/edit-distance.ts +17 -1
  142. package/src/util/errors.ts +23 -7
  143. package/src/util/foreign-cache-dir.ts +60 -0
  144. package/src/util/git-ignored.ts +59 -5
  145. package/src/util/index.ts +15 -1
  146. package/src/util/paths.ts +50 -6
  147. package/src/util/plain-text.ts +19 -0
  148. package/src/util/secret-mask.ts +57 -1
  149. package/src/util/settle.ts +16 -0
  150. package/src/util/size.ts +12 -2
  151. package/src/util/write-file.ts +86 -9
  152. package/src/workspace/affected.ts +225 -27
  153. package/src/workspace/config-bytecode.ts +23 -4
  154. package/src/workspace/config-eval.ts +90 -16
  155. package/src/workspace/config-imports.ts +96 -41
  156. package/src/workspace/config-schema.ts +126 -25
  157. package/src/workspace/dependencies-lock.ts +7 -14
  158. package/src/workspace/filter.ts +10 -1
  159. package/src/workspace/index.ts +3 -1
  160. package/src/workspace/json-data.ts +34 -8
  161. package/src/workspace/lockfile.ts +86 -37
  162. package/src/workspace/migration.ts +46 -25
  163. package/src/workspace/module-state.ts +62 -0
  164. package/src/workspace/nested-dirs.ts +8 -0
  165. package/src/workspace/project-loader.ts +276 -32
  166. package/src/workspace/repo-id.ts +29 -6
  167. package/src/workspace/workspace.ts +190 -30
@@ -1,22 +1,32 @@
1
+ import { existsSync, lstatSync } from 'node:fs'
1
2
  import path from 'node:path'
2
3
  import type { ExecConfig, TaskConfig, InputsConfig, InputStability } from '../config.js'
3
4
  import {
5
+ ABSENT_INPUT,
4
6
  ArtifactVanishedError,
5
7
  type CacheEntry,
6
8
  type CacheLayer,
7
9
  type CachePolicy,
8
10
  cleanOutputs,
11
+ cleanOutputPaths,
9
12
  committedFiles,
10
13
  cleanWorkspaceOutputs,
11
14
  type OutputStamp,
12
15
  ownOutputsSince,
13
16
  ownWorkspaceOutputsSince,
17
+ resolveOutputs,
18
+ resolveWorkspaceOutputs,
14
19
  stampOutputs,
15
20
  stampWorkspaceOutputs,
21
+ stampCommitted,
22
+ committedMoved,
16
23
  FULL_CACHE_POLICY,
17
24
  type GitFilesCache,
18
25
  racyWindowMs,
26
+ stampGrainMs,
19
27
  type TaskInputComponent,
28
+ declaresInput,
29
+ fsClockNow,
20
30
  } from '../cache/index.js'
21
31
  import {
22
32
  signalExitCode,
@@ -40,8 +50,19 @@ import {
40
50
  FAILED_OUTPUT_TAIL_CHARS,
41
51
  failedOutputLog,
42
52
  } from './output-log.js'
43
- import { isGroupTask, RestoreDemoted, type TaskNode, type TaskOutcome } from '../graph/index.js'
44
53
  import {
54
+ isGroupTask,
55
+ OutputsFailed,
56
+ RestoreDemoted,
57
+ RestoreFailed,
58
+ standInsOf,
59
+ type TaskNode,
60
+ type TaskOutcome,
61
+ } from '../graph/index.js'
62
+ import {
63
+ anyTaskGlob,
64
+ heldSecrets,
65
+ isLiteralPattern,
45
66
  killGraceMs,
46
67
  MASKED,
47
68
  maskedStreams,
@@ -49,6 +70,7 @@ import {
49
70
  printable,
50
71
  relPosix,
51
72
  secretMask,
73
+ type SecretMask,
52
74
  secretNamed,
53
75
  span,
54
76
  UserError,
@@ -94,6 +116,7 @@ import {
94
116
  type HashCache,
95
117
  type InputFact,
96
118
  isKeyless,
119
+ keyOf,
97
120
  movedInput,
98
121
  type UpfrontGroupKey,
99
122
  } from './task-hash.js'
@@ -128,6 +151,12 @@ export interface ExecuteArgs {
128
151
  * machine. The hash is still computed — dependents fold it.
129
152
  */
130
153
  remoteOnlyNoop?: boolean
154
+ /**
155
+ * The task depends on a `remoteOnlyNoop` one, directly or through others
156
+ * (`Placements.belowNoop`): it saves nothing, since its key names the
157
+ * no-op's outputs, which never landed.
158
+ */
159
+ belowNoop?: boolean
131
160
  /**
132
161
  * `exec.remote: 'only'` placed on a remote executor: execute remotely,
133
162
  * but this machine's disk is untouched — no output clean, no vx cache
@@ -147,6 +176,11 @@ export interface ExecuteArgs {
147
176
  * where a locally-placed consumer fetches its producers from. */
148
177
  deferred?: DeferredOutputs
149
178
  nestedProjectDirs: string[]
179
+ /**
180
+ * Where its outputs stop when that is more than `nestedProjectDirs`: the
181
+ * nested workspaces under it too (prepare.ts `outputWallsOf`).
182
+ */
183
+ outputNestedDirs?: string[]
150
184
  /** The run's cache directory: walled off from a sandboxed task's grants. */
151
185
  cacheDir?: string
152
186
  /** Anchor for hrtime spans across all tasks in this run. */
@@ -248,6 +282,12 @@ export interface ExecuteArgs {
248
282
  * (keyed-projects.ts `readableDeps`). Absent: none.
249
283
  */
250
284
  readableDeps?: (node: TaskNode) => readonly TaskNode[]
285
+ /**
286
+ * The projects nested in a keyed one, by its dir (keyed-projects.ts
287
+ * `nestedDirsOf`): walled from a task granted that linked package.
288
+ * Absent: none.
289
+ */
290
+ nestedDirsOf?: (projectDir: string) => readonly string[]
251
291
  /**
252
292
  * Run-scoped list the miss path appends its output-directory snapshot
253
293
  * request to, taken at run end (run.ts) instead of right after the save:
@@ -256,7 +296,7 @@ export interface ExecuteArgs {
256
296
  */
257
297
  outputDirSnapshots?: OutputDirSnapshot[]
258
298
  /** The run's save lane: a miss's cache save runs off the execution slot (save-lane.ts). */
259
- deferSave?: (save: () => Promise<void>) => Promise<boolean>
299
+ deferSave?: (save: () => Promise<void>, taskId: string) => Promise<boolean>
260
300
  /**
261
301
  * Where a deferred save's `landed` promise is parked by task id, for the
262
302
  * in-flight join (admission.ts): a duplicate of this task in another run
@@ -302,6 +342,26 @@ export interface ExecuteArgs {
302
342
  serversNested?: readonly (readonly string[])[]
303
343
  /** When each command of the run ran and where it may write (command-windows.ts). */
304
344
  commandWindows?: CommandWindows
345
+ /**
346
+ * A persistent task matched `readyWhen` (or spawned, with none): what it
347
+ * writes from here on is its since-ready output (run.ts holds it until
348
+ * the task's outcome has been logged).
349
+ */
350
+ serverReady?: (id: string) => void
351
+ /**
352
+ * Asked just before a miss is saved: whether a server it depends on
353
+ * (through groups too) died while it ran, which then holds the save. Its
354
+ * key is a healthy run's; its bytes were built against a dead server.
355
+ */
356
+ serverDiedUnder?: (node: TaskNode) => Promise<boolean>
357
+ /**
358
+ * Told once when an attempt's shell left a process running: it may still
359
+ * write the outputs a dependent reads, so run.ts seeds the run's taint
360
+ * with this task and nothing built on it is saved.
361
+ */
362
+ leftRunning?: (node: TaskNode) => void
363
+ /** A `vx watch` cycle (`RunOptions.watch`): an input edited mid-run is the next cycle's. */
364
+ watch?: true
305
365
  }
306
366
 
307
367
  /**
@@ -389,7 +449,8 @@ async function restoreProbed(
389
449
  * A hit restored, or null when its artifact was removed after the probe (a
390
450
  * `vx cache clean`, here or for a workspace sharing the cache directory): a
391
451
  * miss. The restore has wiped the declared outputs by then; the run path
392
- * cleans them again and the task writes them.
452
+ * cleans them again and the task writes them. Any other throw fails the
453
+ * task as a failed restore (`RestoreFailed`), never as a miss.
393
454
  */
394
455
  async function restoreOrMiss(
395
456
  args: ExecuteArgs,
@@ -401,7 +462,7 @@ async function restoreOrMiss(
401
462
  try {
402
463
  return await restoreHit({ args, hash, hit, cacheOpStart, taskStartNs })
403
464
  } catch (err) {
404
- if (!(err instanceof ArtifactVanishedError)) throw err
465
+ if (!(err instanceof ArtifactVanishedError)) throw new RestoreFailed(err)
405
466
  warnOf(args.log)(`[vx] ${args.node.id}: ${err.message} — running it`)
406
467
  return null
407
468
  }
@@ -456,16 +517,18 @@ async function executeGroupTask(args: ExecuteArgs): Promise<TaskOutcome> {
456
517
  )
457
518
  }
458
519
  const wallclockNs = process.hrtime.bigint() - args.runStartHrTimeNs
459
- const hash = await computeTaskHash({
460
- node: args.node,
461
- upstream: args.upstream,
462
- workspaceRoot: args.workspaceRoot,
463
- cache: args.cache,
464
- forwardArgs: args.forwardArgs,
465
- nestedProjectDirs: args.nestedProjectDirs,
466
- ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
467
- ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
468
- })
520
+ const hash = await keyOf(
521
+ computeTaskHash({
522
+ node: args.node,
523
+ upstream: args.upstream,
524
+ workspaceRoot: args.workspaceRoot,
525
+ cache: args.cache,
526
+ forwardArgs: args.forwardArgs,
527
+ nestedProjectDirs: args.nestedProjectDirs,
528
+ ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
529
+ ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
530
+ }),
531
+ )
469
532
  return groupOutcome(args.node, args.upstream, hash, wallclockNs)
470
533
  }
471
534
 
@@ -547,7 +610,13 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
547
610
  // nothing to read under it, and nothing a later run could find.
548
611
  const present = upstream.filter((u): u is TaskOutcome => u !== undefined)
549
612
  const folded = new Set(
550
- filterUpstreamHashes(present, cfg.inputs?.tasks, node.projectName, node.id).map(([id]) => id),
613
+ filterUpstreamHashes(
614
+ present,
615
+ cfg.inputs?.tasks,
616
+ node.projectName,
617
+ node.id,
618
+ standInsOf(node),
619
+ ).map(([id]) => id),
551
620
  )
552
621
  const foldedTasks = expandGroupUpstream(present.filter((u) => folded.has(u.node.id)))
553
622
  const keylessBy = isKeyless(cfg)
@@ -572,7 +641,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
572
641
  // task's unfinished deps).
573
642
  const unkeyedUpstream = foldedTasks.find((u) => u.unkeyed === true)
574
643
  let unkeyed = unkeyedUpstream !== undefined
575
- const willSave = willWrite && args.taintedUpstream !== true && !unkeyed && !keyless
644
+ /** The input `keyStillTrue` found moved under the command (`TaskOutcome.driftedInput`). */
645
+ let driftedInput: string | null | undefined
646
+ const willSave =
647
+ willWrite && args.taintedUpstream !== true && !unkeyed && !keyless && args.belowNoop !== true
576
648
 
577
649
  // The executor retains nothing: the entry's output is the attempt's
578
650
  // `output` log, both streams in the order they reached the logger, kept
@@ -599,6 +671,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
599
671
  // does an executor that declares `timeouts`.
600
672
  let timeoutTimer: ReturnType<typeof setTimeout> | undefined
601
673
  let timeoutFired = false
674
+ // Fail-fast withdrew the attempt while its executor still held it for a
675
+ // machine to begin (`running` answered `null`): it never started.
676
+ let unbegun = false
677
+ let withdrawUnbegun: (() => void) | undefined
602
678
  // The attempt's listener on the run's stop signal, taken off once the
603
679
  // attempt settles. Left on, every task of the run added one: the list
604
680
  // grew to the task count, and each add scans it for a duplicate, ~100 ms
@@ -609,12 +685,32 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
609
685
  const abort = (): void => stop.abort(args.stopSignal?.reason)
610
686
  unlistenStop?.()
611
687
  unlistenStop = undefined
688
+ unbegun = false
689
+ withdrawUnbegun = undefined
612
690
  if (args.stopSignal?.aborted === true) abort()
613
691
  else if (args.stopSignal !== undefined) {
614
692
  const run = args.stopSignal
615
693
  run.addEventListener('abort', abort, { once: true })
616
694
  unlistenStop = () => run.removeEventListener('abort', abort)
617
695
  }
696
+ // `--continue=never` starts nothing after a failure: a task a plugin
697
+ // executor still holds for a machine is not yet started, and was sent
698
+ // to one after the failure (2026-11-06).
699
+ const failFast = args.failFast
700
+ if (!local && failFast !== undefined && args.executor.running !== undefined) {
701
+ const withdraw = (): void => {
702
+ if (stop.signal.aborted || args.executor.running?.(node.id) !== null) return
703
+ unbegun = true
704
+ stop.abort(new Error('fail-fast'))
705
+ }
706
+ withdrawUnbegun = withdraw
707
+ failFast.addEventListener('abort', withdraw, { once: true })
708
+ const unlisten = unlistenStop
709
+ unlistenStop = () => {
710
+ unlisten?.()
711
+ failFast.removeEventListener('abort', withdraw)
712
+ }
713
+ }
618
714
  clearTimeout(timeoutTimer)
619
715
  timeoutFired = false
620
716
  if (effectiveTimeout !== undefined && !local && args.executor.timeouts !== true) {
@@ -635,7 +731,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
635
731
  signal: AbortSignal | undefined,
636
732
  ): Promise<unknown> {
637
733
  if (signal === undefined || isLocalExecutor(args.executor)) return running
638
- const started = Date.now()
734
+ const started = performance.now()
639
735
  let graceTimer: ReturnType<typeof setTimeout> | undefined
640
736
  let onAbort: (() => void) | undefined
641
737
  const abandoned = new Promise<ExecuteResult>((resolve) => {
@@ -650,10 +746,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
650
746
  const reason: unknown = signal.reason
651
747
  resolve({
652
748
  exitCode: signalExitCode(reason === 'SIGINT' ? 'SIGINT' : 'SIGTERM'),
653
- durationMs: Date.now() - started,
749
+ durationMs: Math.round(performance.now() - started),
654
750
  stdout: '',
655
751
  stderr: '',
656
752
  violations: [],
753
+ // What it started may still run and write the retry's outputs.
754
+ leftRunning: true,
657
755
  ...(timeoutFired ? { timedOut: true } : {}),
658
756
  })
659
757
  }, graceMs)
@@ -697,19 +795,21 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
697
795
  const hash =
698
796
  preProbed?.hash ??
699
797
  args.upfrontKey ??
700
- (await computeTaskHash({
701
- node,
702
- upstream,
703
- workspaceRoot: args.workspaceRoot,
704
- cache,
705
- forwardArgs: args.forwardArgs,
706
- nestedProjectDirs: args.nestedProjectDirs,
707
- ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
708
- ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
709
- ...(args.probesAfterWrites !== undefined
710
- ? { probesAfterWrites: args.probesAfterWrites }
711
- : {}),
712
- }))
798
+ (await keyOf(
799
+ computeTaskHash({
800
+ node,
801
+ upstream,
802
+ workspaceRoot: args.workspaceRoot,
803
+ cache,
804
+ forwardArgs: args.forwardArgs,
805
+ nestedProjectDirs: args.nestedProjectDirs,
806
+ ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
807
+ ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
808
+ ...(args.probesAfterWrites !== undefined
809
+ ? { probesAfterWrites: args.probesAfterWrites }
810
+ : {}),
811
+ }),
812
+ ))
713
813
 
714
814
  // The local no-op half of `exec.remote: 'only'`: no remote executor took
715
815
  // the task, so it succeeds without running. The hash was still computed —
@@ -727,26 +827,42 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
727
827
  }
728
828
  }
729
829
 
830
+ const ownInputs = {
831
+ projectDir: node.projectDir,
832
+ workspaceRoot: args.workspaceRoot,
833
+ declared: node.config.inputs,
834
+ }
730
835
  const cleanArgs = {
731
836
  projectDir: node.projectDir,
732
837
  outputs,
733
- nestedProjectDirs: args.nestedProjectDirs,
838
+ nestedProjectDirs: args.outputNestedDirs ?? args.nestedProjectDirs,
839
+ inputs: ownInputs,
734
840
  committed: committedFiles(args.gitFilesCache, node.projectDir),
735
841
  }
736
842
  const wsCleanArgs = {
737
843
  workspaceRoot: args.workspaceRoot,
738
844
  outputs: wsOutputs,
845
+ inputs: ownInputs,
739
846
  committed: committedFiles(args.gitFilesCache, args.workspaceRoot),
740
847
  }
741
848
  // An ADDITIVE task (its outputs overlap an upstream's, with the edge that
742
849
  // orders them; item 588) does not clean by glob — the glob selects the
743
850
  // upstream's files it adds beside. Its outputs are stamped once before
744
851
  // the first attempt, and after a 0 exit its own set is what the run
745
- // added or changed against that stamp. Stale files of its own from an
852
+ // added or changed against that stamp; a retry first removes what the
853
+ // failed attempt added (`dropFailedAdditions`). Stale files of its own from an
746
854
  // earlier run are its command's to clean, as they are under Turbo.
747
855
  const additive = (node.addsToOutputsOf?.length ?? 0) > 0
748
856
  let stampedBefore: ReadonlyMap<string, OutputStamp> | undefined
749
857
  let wsStampedBefore: ReadonlyMap<string, OutputStamp> | undefined
858
+ // A failed attempt changed or removed a file the stamp found, or a
859
+ // committed file under the outputs (no clean deletes one): no retry can
860
+ // put it back, so no entry holds only the passing attempt's work.
861
+ let failedTouchedFound = false
862
+ // The committed files under the outputs as the first attempt found them,
863
+ // stamped only when a retry can follow.
864
+ let committedBefore: ReadonlyMap<string, OutputStamp> | undefined
865
+ let wsCommittedBefore: ReadonlyMap<string, OutputStamp> | undefined
750
866
  // `outputs.clean: false` leaves the outputs to the first attempt's
751
867
  // command, and the save stores whatever they hold after it, a leftover
752
868
  // included: the user opted out of the clean-tree guarantee
@@ -802,6 +918,14 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
802
918
  : {}),
803
919
  })
804
920
  : undefined
921
+ // The command's start for the re-check (`movedInput`), on the clock that
922
+ // stamps its writes: off `Date.now()`, a coarse kernel tick dated an edit
923
+ // in the command's first tick before it, and an edit undone by the end
924
+ // read as an upstream's write of the key's bytes. After the describe, so
925
+ // the stats that tell an upstream's write in its tick apart come first.
926
+ const commandFrom = cfgCacheable
927
+ ? fsClockNow(args.cacheDir ?? path.join(args.workspaceRoot, '.vx'))
928
+ : 0
805
929
  /** The other command that may have put a moved input back (`ownRewrite`). */
806
930
  let rewroteBy: string | undefined
807
931
  /**
@@ -846,7 +970,11 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
846
970
  c.hash = MASKED + c.hash
847
971
  // The values of secret-named variables, masked in what the task prints
848
972
  // and what the cache keeps of it (L-11); null when there are none.
849
- const secrets = secretMask([process.env, env, step.env?.define], step.env?.secret)
973
+ // Built again once the sandbox starts: its proxy token joins them.
974
+ const taskMask = (): SecretMask | null =>
975
+ secretMask([process.env, env, step.env?.define], step.env?.secret)
976
+ let secrets = taskMask()
977
+ let maskHeld = heldSecrets()
850
978
  // A runtime probe's row is named by its command, which a TS config may
851
979
  // build from `process.env`: the run history stored it and `vx why`
852
980
  // printed it (L-11).
@@ -868,16 +996,22 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
868
996
  const inputChanges = described !== undefined ? args.explainMiss?.(node.id, captured) : undefined
869
997
  // A plugin may keep the request past the run (the cache closed): it gets
870
998
  // the upstream outputs read now. Only the local floor leaves them unread.
871
- if (inputs !== undefined && !isLocalExecutor(args.executor)) void inputs.upstream
999
+ if (inputs !== undefined && !isLocalExecutor(args.executor)) await described!.readUpstream()
872
1000
  // A declared input set that resolves to NOTHING is the quiet stale hit:
873
1001
  // the key stops moving with this project's source and every later run
874
1002
  // is a hit. Almost always a glob written against the wrong directory
875
1003
  // (`lib/**` in a package that builds `src/`). Said once per miss, on the
876
1004
  // run's status line — the task itself succeeds.
877
1005
  const declaredInputs = [...(cfg.inputs?.files ?? []), ...(cfg.inputs?.workspaceFiles ?? [])]
878
- if (inputs !== undefined && declaredInputs.length > 0 && inputs.files.length === 0) {
1006
+ const unmatched =
1007
+ inputs === undefined || declaredInputs.length === 0
1008
+ ? []
1009
+ : inputs.files.length === 0
1010
+ ? declaredInputs
1011
+ : deadSourceGlobs(cfg.inputs, inputs.files, relPosix(args.workspaceRoot, node.projectDir))
1012
+ if (unmatched.length > 0) {
879
1013
  warnOf(log)(
880
- `[vx] ${node.id}: inputs matched no files (${declaredInputs.join(', ')}) — ` +
1014
+ `[vx] ${node.id}: inputs matched no files (${unmatched.join(', ')}) — ` +
881
1015
  `the key will not change when this project's source does` +
882
1016
  bracketLiteralHint([
883
1017
  ...(cfg.inputs?.files ?? []).map((g) => [node.projectDir, g] as const),
@@ -910,6 +1044,36 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
910
1044
  // the last attempt alone read `max 407ms` beside a footer of 837 (item
911
1045
  // 1101). A saved entry keeps the attempt that produced it, what a hit saves.
912
1046
  let spentMs = 0
1047
+ // A known flake gets a retry its config did not ask for (below).
1048
+ const retryCanFollow =
1049
+ maxAttempts > 1 || (!retriesSet && cfgCacheable && !keyless && args.knownFlaky !== undefined)
1050
+ // Stamps the committed files a clean found before the first attempt, and
1051
+ // before a retry taints the save if a failed attempt moved one.
1052
+ const checkCommitted = (
1053
+ root: string,
1054
+ rels: readonly string[],
1055
+ committed: (abs: string) => boolean,
1056
+ before: ReadonlyMap<string, OutputStamp> | undefined,
1057
+ ): ReadonlyMap<string, OutputStamp> | undefined => {
1058
+ if (!retryCanFollow) return undefined
1059
+ const files = rels.map((r) => path.resolve(root, r))
1060
+ if (before === undefined) return stampCommitted(files, committed)
1061
+ if (committedMoved(files, committed, before)) failedTouchedFound = true
1062
+ return before
1063
+ }
1064
+
1065
+ // vx's own work on the outputs, around a command that exited `exitCode`.
1066
+ async function outputsStep<T>(
1067
+ step: 'clean' | 'save',
1068
+ exitCode: number,
1069
+ work: () => Promise<T>,
1070
+ ): Promise<T> {
1071
+ try {
1072
+ return await work()
1073
+ } catch (err) {
1074
+ throw new OutputsFailed(err, step, exitCode, spentMs)
1075
+ }
1076
+ }
913
1077
 
914
1078
  // One task attempt: clean the declared outputs (before EVERY attempt — so a
915
1079
  // stale prior-build artifact can't survive into a fresh run, and a failed
@@ -922,6 +1086,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
922
1086
  result: ExecuteResult
923
1087
  exitCode: number
924
1088
  withdrawn?: true
1089
+ unbegun?: true
925
1090
  }> {
926
1091
  // The first attempt under `clean: false` finds its last outputs (tsc's `.tsbuildinfo`).
927
1092
  const clean = willWrite && !deferralRequested && !(keepOutputs && attempt === 1)
@@ -929,33 +1094,92 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
929
1094
  // tree would replace a stale build with nothing at all. The eligibility
930
1095
  // gate guarantees no key in this run can see what stays behind, and the
931
1096
  // summary names every deferred task so `dist/` is not silently stale.
932
- if (additive && willWrite && !deferralRequested && outputs.length > 0) {
933
- const endStamp = span('miss: stamp outputs')
934
- stampedBefore ??= await stampOutputs(cleanArgs)
935
- endStamp()
936
- } else if (clean && outputs.length > 0) {
937
- // Mark the wiped paths, exactly as the workspace twin below does. The
938
- // git snapshot still lists them with their committed index OIDs, and
939
- // resolveFiles SKIPS its existence probe for any path carrying a
940
- // trusted OID — so a declared output the producer deletes and does not
941
- // re-create would stay in a same-project consumer's input set, keeping
942
- // that consumer's key unchanged while the file is gone from disk.
943
- const endClean = span('miss: clean outputs')
944
- const cleanedRels = await cleanOutputs(cleanArgs)
945
- endClean()
946
- args.gitFilesCache?.markOutputsChanged(node.projectDir, cleanedRels)
947
- }
948
- if (additive && willWrite && !deferralRequested && wsOutputs.length > 0) {
949
- // A root-anchored addition is stamped as the project one above: a
950
- // glob clean deleted the upstream's files before this task, which
951
- // reads them, ran (A-43).
952
- wsStampedBefore ??= await stampWorkspaceOutputs(wsCleanArgs)
953
- } else if (clean && wsOutputs.length > 0) {
954
- // Root-anchored deletions can land in other projects' dirs; mark
955
- // them so stale per-project git snapshots can't survive the wipe.
956
- const cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
957
- args.gitFilesCache?.markWorkspaceOutputsChanged(args.workspaceRoot, cleanedWsRels)
958
- }
1097
+ // Its throw (a name vx cannot clear) fails the task before this
1098
+ // attempt's command: no exit of its own to name.
1099
+ await outputsStep('clean', attempt > 1 ? effectiveExitCode : 1, async () => {
1100
+ if (additive && willWrite && !deferralRequested && outputs.length > 0) {
1101
+ const endStamp = span('miss: stamp outputs')
1102
+ if (stampedBefore === undefined) stampedBefore = await stampOutputs(cleanArgs)
1103
+ else {
1104
+ const rels = await dropFailedAdditions(
1105
+ node.projectDir,
1106
+ outputs,
1107
+ stampedBefore,
1108
+ cleanArgs.committed,
1109
+ await ownOutputsSince(cleanArgs, stampedBefore),
1110
+ () => resolveOutputs(cleanArgs),
1111
+ )
1112
+ args.gitFilesCache?.markOutputsChanged(node.projectDir, rels)
1113
+ }
1114
+ endStamp()
1115
+ } else if (clean && outputs.length > 0) {
1116
+ // Mark the wiped paths, exactly as the workspace twin below does. The
1117
+ // git snapshot still lists them with their committed index OIDs, and
1118
+ // resolveFiles SKIPS its existence probe for any path carrying a
1119
+ // trusted OID — so a declared output the producer deletes and does not
1120
+ // re-create would stay in a same-project consumer's input set, keeping
1121
+ // that consumer's key unchanged while the file is gone from disk.
1122
+ const endClean = span('miss: clean outputs')
1123
+ const cleanedRels = await cleanOutputs(cleanArgs)
1124
+ endClean()
1125
+ args.gitFilesCache?.markOutputsChanged(node.projectDir, cleanedRels)
1126
+ committedBefore = checkCommitted(
1127
+ node.projectDir,
1128
+ cleanedRels,
1129
+ cleanArgs.committed,
1130
+ committedBefore,
1131
+ )
1132
+ } else if (keepOutputs && willWrite && !deferralRequested && outputs.length > 0) {
1133
+ committedBefore = checkCommitted(
1134
+ node.projectDir,
1135
+ retryCanFollow
1136
+ ? (await resolveOutputs(cleanArgs)).map((f) => relPosix(node.projectDir, f))
1137
+ : [],
1138
+ cleanArgs.committed,
1139
+ committedBefore,
1140
+ )
1141
+ }
1142
+ if (additive && willWrite && !deferralRequested && wsOutputs.length > 0) {
1143
+ // A root-anchored addition is stamped as the project one above: a
1144
+ // glob clean deleted the upstream's files before this task, which
1145
+ // reads them, ran (A-43).
1146
+ if (wsStampedBefore === undefined)
1147
+ wsStampedBefore = await stampWorkspaceOutputs(wsCleanArgs)
1148
+ else {
1149
+ const rels = await dropFailedAdditions(
1150
+ args.workspaceRoot,
1151
+ wsOutputs,
1152
+ wsStampedBefore,
1153
+ wsCleanArgs.committed,
1154
+ await ownWorkspaceOutputsSince(wsCleanArgs, wsStampedBefore),
1155
+ () => resolveWorkspaceOutputs(wsCleanArgs),
1156
+ )
1157
+ args.gitFilesCache?.markWorkspaceOutputsChanged(args.workspaceRoot, rels)
1158
+ }
1159
+ } else if (clean && wsOutputs.length > 0) {
1160
+ // Root-anchored deletions can land in other projects' dirs; mark
1161
+ // them so stale per-project git snapshots can't survive the wipe.
1162
+ const cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
1163
+ args.gitFilesCache?.markWorkspaceOutputsChanged(args.workspaceRoot, cleanedWsRels)
1164
+ wsCommittedBefore = checkCommitted(
1165
+ args.workspaceRoot,
1166
+ cleanedWsRels,
1167
+ wsCleanArgs.committed,
1168
+ wsCommittedBefore,
1169
+ )
1170
+ } else if (keepOutputs && willWrite && !deferralRequested && wsOutputs.length > 0) {
1171
+ wsCommittedBefore = checkCommitted(
1172
+ args.workspaceRoot,
1173
+ retryCanFollow
1174
+ ? (await resolveWorkspaceOutputs(wsCleanArgs)).map((f) =>
1175
+ relPosix(args.workspaceRoot, f),
1176
+ )
1177
+ : [],
1178
+ wsCleanArgs.committed,
1179
+ wsCommittedBefore,
1180
+ )
1181
+ }
1182
+ })
959
1183
  // What the attempt before this one left, put back if this one is
960
1184
  // withdrawn: it stands, with its output and its refusals.
961
1185
  const standing = { output, violations, flushMasked }
@@ -997,6 +1221,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
997
1221
  } catch (thrown) {
998
1222
  running = Promise.reject(thrown)
999
1223
  }
1224
+ // A failure that landed while the request was built: the executor holds it now.
1225
+ if (args.failFast?.aborted === true) withdrawUnbegun?.()
1000
1226
  let res = await boundAfterAbort(running, req.signal)
1001
1227
  .then((r: unknown) => {
1002
1228
  assertExecuteResult(args.executor.name, node.id, r)
@@ -1049,6 +1275,15 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1049
1275
  throw thrown
1050
1276
  })
1051
1277
  endExec()
1278
+ if (unbegun) {
1279
+ await sweepPlaceholders(placeholders)
1280
+ if (attempt === 1) return { result: res, exitCode: res.exitCode, unbegun: true }
1281
+ // A retry withdrawn: the attempt before it stands, as above.
1282
+ output = standing.output
1283
+ violations = standing.violations
1284
+ flushMasked = standing.flushMasked
1285
+ return { result, exitCode: effectiveExitCode, withdrawn: true }
1286
+ }
1052
1287
  if (secrets !== null)
1053
1288
  res = {
1054
1289
  ...res,
@@ -1100,12 +1335,18 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1100
1335
  // A child we SIGTERMed for exceeding the timeout is a genuine failure —
1101
1336
  // stream a clear line so the 143 exit reads as a timeout. One that outlived
1102
1337
  // the grace died of the SIGKILL, and the line said SIGTERM over an exit
1103
- // 137 (item 1063).
1338
+ // 137 (item 1063); so did a shell dead of the SIGTERM whose leftover the
1339
+ // SIGKILL took.
1104
1340
  if (res.timedOut) {
1105
- const how = res.signal === 'SIGKILL' ? 'SIGKILL after the SIGTERM grace' : 'SIGTERM'
1341
+ // A plugin executor's child is its own: core only aborted its request.
1342
+ const how = !isLocalExecutor(args.executor)
1343
+ ? 'its executor was aborted'
1344
+ : res.signal === 'SIGKILL' || res.killedAfterGrace === true
1345
+ ? 'killed (SIGKILL after the SIGTERM grace)'
1346
+ : 'killed (SIGTERM)'
1106
1347
  log.taskStderr(
1107
1348
  node,
1108
- `${midLine ? '\n' : ''}[vx] timed out after ${effectiveTimeout}ms — killed (${how})\n`,
1349
+ `${midLine ? '\n' : ''}[vx] timed out after ${effectiveTimeout}ms — ${how}\n`,
1109
1350
  )
1110
1351
  // Force a non-zero classification even if the child TRAPPED SIGTERM and
1111
1352
  // still exited 0 (`trap 'exit 0' TERM`, a common graceful-shutdown
@@ -1138,6 +1379,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1138
1379
  bins: taskBinDirs(node, args.workspaceRoot),
1139
1380
  signal: res.signal,
1140
1381
  hidden: req.sandbox && ((f: string) => !sandboxReads(req.sandbox!, f)),
1382
+ stderr: errTail,
1141
1383
  })
1142
1384
  // The line quotes the command's first word, which a config may have
1143
1385
  // built from a secret: masked as the task's own output is (L-37).
@@ -1179,15 +1421,18 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1179
1421
  // printed a blank line under the OUTPUT rule or the task's last line. Glued, a frame read
1180
1422
  // `partialvx: retrying …`.
1181
1423
  let midLine = false
1424
+ /** The attempt's last stderr: the shell's "not found" line names the word a compound command missed (shell-verdict.ts). */
1425
+ let errTail = ''
1182
1426
  // The attempt's output as the logger got it (masked): the entry's when
1183
1427
  // the task saves, else only what a failure reports (`failedOutput`).
1184
1428
  let output: BoundedCapture | undefined
1185
1429
  // Masked again whole: the live stream lets a held piece go after a pause.
1186
- const storeMask = secrets ?? undefined
1187
- const storedOutput = (): string => encodeOutputLog(output?.chunks() ?? [], storeMask)
1430
+ const storedOutput = (): string => encodeOutputLog(output?.chunks() ?? [], secrets ?? undefined)
1188
1431
  const failedAttempts: { endedAt: number; exitCode: number; timedOut?: true }[] = []
1189
1432
  // Each failed attempt's output, beside `failedAttempts`: kept for `vx last --log`.
1190
1433
  const attemptOutputs: string[] = []
1434
+ // Any attempt's shell left a process running, not only the last one's.
1435
+ let leftRunning = false
1191
1436
  // The sampling of the attempt's process tree, stopped once it settles.
1192
1437
  let untrack: (() => TreeLoad | undefined) | undefined
1193
1438
  // What the last attempt's samples saw, for the run history.
@@ -1221,6 +1466,18 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1221
1466
  for (;;) {
1222
1467
  attempt++
1223
1468
  const a = await runAttempt()
1469
+ // Never started: skipped, as fail-fast skips a task not yet dispatched.
1470
+ if (a.unbegun) {
1471
+ return {
1472
+ node,
1473
+ status: 'skipped',
1474
+ exitCode: 1,
1475
+ durationMs: 0,
1476
+ hash,
1477
+ wallclockStartNs,
1478
+ wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
1479
+ }
1480
+ }
1224
1481
  result = a.result
1225
1482
  effectiveExitCode = a.exitCode
1226
1483
  if (a.withdrawn) {
@@ -1229,6 +1486,13 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1229
1486
  attemptOutputs.pop()
1230
1487
  break
1231
1488
  }
1489
+ // A failed attempt's leftover may write into the retry's outputs too.
1490
+ // It is not killed, as a passing attempt's is not: only the save is
1491
+ // withheld (`nothingLeftRunning`), and its dependents' (`args.leftRunning`).
1492
+ if (result.leftRunning === true && !leftRunning) {
1493
+ leftRunning = true
1494
+ args.leftRunning?.(node)
1495
+ }
1232
1496
  forgetUndeclaredWrites(args, writeReach)
1233
1497
  if (writesFingerprint) args.fingerprintWatch?.wrote()
1234
1498
  spentMs += result.durationMs
@@ -1288,20 +1552,57 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1288
1552
  exitCode: effectiveExitCode,
1289
1553
  ...(result.timedOut === true ? { timedOut: true as const } : {}),
1290
1554
  })
1291
- attemptOutputs.push(output === undefined ? '' : failedOutputLog(output, true, storeMask))
1555
+ // The outcome's SANDBOX VIOLATIONS section is the last attempt's: a
1556
+ // retried attempt's refusals go into its own output, kept for
1557
+ // `vx last --log`, as the section prints them.
1558
+ const refused = new Set<string>()
1559
+ let denials = 0
1560
+ for (const v of violations) {
1561
+ const line = printable(secrets?.mask(v.line) ?? v.line)
1562
+ if (!refused.has(line) && v.hint !== true) denials++
1563
+ refused.add(line)
1564
+ }
1565
+ if (refused.size > 0 && output !== undefined) {
1566
+ const section = `${midLine ? '\n' : ''}${denials > 0 ? `SANDBOX VIOLATIONS (${denials})` : 'SANDBOX'}\n${[...refused].join('\n')}\n`
1567
+ output.push(section, true)
1568
+ log.taskStderr(node, section)
1569
+ midLine = false
1570
+ }
1571
+ attemptOutputs.push(
1572
+ output === undefined ? '' : failedOutputLog(output, true, secrets ?? undefined),
1573
+ )
1574
+ // The shell's own exit 0 failed only on the refusal: `exit 1` named
1575
+ // nothing that happened.
1576
+ const why =
1577
+ result.timedOut === true
1578
+ ? 'a timeout'
1579
+ : result.exitCode === 0 && denials > 0
1580
+ ? `${denials} sandbox violation${denials === 1 ? '' : 's'}`
1581
+ : `exit ${effectiveExitCode}`
1292
1582
  log.taskStderr(
1293
1583
  node,
1294
- `${midLine ? '\n' : ''}vx: retrying ${node.id} (attempt ${attempt + 1}/${maxAttempts}) after ${result.timedOut === true ? 'a timeout' : `exit ${effectiveExitCode}`}\n`,
1584
+ `${midLine ? '\n' : ''}vx: retrying ${node.id} (attempt ${attempt + 1}/${maxAttempts}) after ${why}\n`,
1295
1585
  )
1296
1586
  }
1297
1587
 
1298
1588
  async function buildRequest(local: boolean): Promise<ExecuteRequest> {
1589
+ // A remote executor enforces it where it runs the task. Armed before
1590
+ // the streams' mask: its start holds the proxy's token, which the
1591
+ // task's env carries (`initSandbox`).
1592
+ if (userSandbox && args.executor.remote !== true) {
1593
+ await args.armSandbox?.()
1594
+ if (maskHeld !== heldSecrets()) {
1595
+ maskHeld = heldSecrets()
1596
+ secrets = taskMask()
1597
+ }
1598
+ }
1299
1599
  const kept =
1300
1600
  willSave || args.keepRun !== undefined
1301
1601
  ? new BoundedCapture()
1302
1602
  : new BoundedCapture(FAILED_OUTPUT_HEAD_CHARS, FAILED_OUTPUT_TAIL_CHARS)
1303
1603
  output = kept
1304
1604
  midLine = false
1605
+ errTail = ''
1305
1606
  const toOut = (t: string): void => {
1306
1607
  kept.push(t)
1307
1608
  if (t.length > 0) midLine = !t.endsWith('\n')
@@ -1310,19 +1611,26 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1310
1611
  const toErr = (t: string): void => {
1311
1612
  kept.push(t, true)
1312
1613
  if (t.length > 0) midLine = !t.endsWith('\n')
1614
+ errTail = (errTail + t).slice(-2048)
1313
1615
  log.taskStderr(node, t)
1314
1616
  }
1315
1617
  const masked = secrets && maskedStreams(secrets, toOut, toErr)
1316
1618
  flushMasked = () => masked?.end()
1619
+ // A plugin executor gets frozen copies of what core goes on reading:
1620
+ // the outputs it saves, the config two tasks declared alike share, the
1621
+ // run's args. One that emptied its outputs saved an empty artifact a
1622
+ // later hit restored; one that set its define ran the next task with it.
1623
+ const own = <T extends object>(v: T): T =>
1624
+ local ? v : Object.freeze(Array.isArray(v) ? ([...v] as T) : { ...v })
1317
1625
  const base: ExecuteRequest = {
1318
1626
  taskId: node.id,
1319
1627
  ...(attempt > 1 ? { attempt } : {}),
1320
1628
  workspaceRoot: args.workspaceRoot,
1321
1629
  command: step.command,
1322
- forwardArgs: effectiveForwardArgs,
1630
+ forwardArgs: own(effectiveForwardArgs),
1323
1631
  cwd: node.projectDir,
1324
1632
  env,
1325
- envDefine: step.env?.define ?? {},
1633
+ envDefine: own(step.env?.define ?? {}),
1326
1634
  capture,
1327
1635
  onStdout: masked ? masked.out : toOut,
1328
1636
  onStderr: masked ? masked.err : toErr,
@@ -1341,11 +1649,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1341
1649
  ...(cfgCacheable && !remoteOnly && !willSave ? { unsaved: true } : {}),
1342
1650
  ...(remoteOnly ? { remoteOnly: true } : {}),
1343
1651
  ...(node.platform !== undefined ? { platform: node.platform } : {}),
1652
+ ...(node.worker !== undefined ? { worker: node.worker } : {}),
1344
1653
  ...(deferralRequested ? { download: 'deferred' as const } : {}),
1345
1654
  // `--force`/`--no-cache` reach a remote executor's private record
1346
1655
  // through this flag — the policy gates above only cover vx's OWN cache.
1347
1656
  ...(cfgCacheable && !(policy.localRead || policy.remoteRead) ? { refresh: true } : {}),
1348
- outputs: { files: outputs, workspaceFiles: wsOutputs },
1657
+ outputs: { files: own(outputs), workspaceFiles: own(wsOutputs) },
1349
1658
  ...(args.terminal === true ? { terminal: true as const } : {}),
1350
1659
  ...(args.servers === undefined
1351
1660
  ? {}
@@ -1356,11 +1665,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1356
1665
  }),
1357
1666
  }
1358
1667
  // The signal last: it arms a plugin's `exec.timeout`, and the sandbox's arming
1359
- // below is vx's work, not the task's. Armed first, a 60 ms timeout
1668
+ // above is vx's work, not the task's. Armed first, a 60 ms timeout
1360
1669
  // expired before the spawn and failed `echo` as timed out, unrun.
1361
1670
  if (!userSandbox) return { ...base, signal: requestSignal(local) }
1362
- // A remote executor enforces it where it runs the task.
1363
- if (args.executor.remote !== true) await args.armSandbox?.()
1671
+ const deps = await dependencyReads(args.readableDeps?.(node) ?? [], args.workspaceRoot)
1364
1672
  const sb = await sandboxRequestFor(
1365
1673
  node,
1366
1674
  step.sandbox!,
@@ -1368,7 +1676,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1368
1676
  cfgCacheable ? args.keyedProjects(node) : undefined,
1369
1677
  args.nestedProjectDirs,
1370
1678
  args.cacheDir,
1371
- await dependencyReads(args.readableDeps?.(node) ?? [], args.workspaceRoot),
1679
+ deps.reads,
1680
+ deps.reported,
1681
+ args.nestedDirsOf,
1372
1682
  )
1373
1683
  placeholders = sb.placeholders
1374
1684
  withheld = sb.withheld
@@ -1397,7 +1707,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1397
1707
  // edit's bytes under the old key (X-123).
1398
1708
  const materialize = result.outputs?.kind === 'deferred' ? result.outputs.materialize : undefined
1399
1709
  if (materialize !== undefined) {
1400
- const keyed = await keyStillTrue()
1710
+ const keyed = (await keyStillTrue()) && (await args.serverDiedUnder?.(node)) !== true
1401
1711
  args.deferred?.register(node.id, {
1402
1712
  materialize,
1403
1713
  ...(keyed ? { hash } : {}),
@@ -1416,50 +1726,53 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1416
1726
  willSave &&
1417
1727
  nothingLeftRunning() &&
1418
1728
  (await keyStillTrue()) &&
1729
+ (await args.serverDiedUnder?.(node)) !== true &&
1419
1730
  (own = await ownOutputs()) !== undefined
1420
1731
  ) {
1421
- const { landed } = await saveMiss({
1422
- ...own,
1423
- node,
1424
- hash,
1425
- cache,
1426
- log,
1427
- workspaceRoot: args.workspaceRoot,
1428
- nestedProjectDirs: args.nestedProjectDirs,
1429
- gitFilesCache: args.gitFilesCache,
1430
- outputs,
1431
- wsOutputs,
1432
- captured,
1433
- command: storedCommand,
1434
- durationMs: result.durationMs,
1435
- stdout: storedOutput(),
1436
- ...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
1437
- ...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
1438
- outputDirSnapshots: args.outputDirSnapshots,
1439
- deferSave: args.deferSave,
1440
- // A sink listens: say what the save cost.
1441
- measure: args.explainMiss !== undefined,
1442
- })
1732
+ const { landed } = await outputsStep('save', effectiveExitCode, () =>
1733
+ saveMiss({
1734
+ ...own,
1735
+ node,
1736
+ hash,
1737
+ cache,
1738
+ log,
1739
+ workspaceRoot: args.workspaceRoot,
1740
+ nestedProjectDirs: args.outputNestedDirs ?? args.nestedProjectDirs,
1741
+ gitFilesCache: args.gitFilesCache,
1742
+ outputs,
1743
+ wsOutputs,
1744
+ captured,
1745
+ command: storedCommand,
1746
+ durationMs: result.durationMs,
1747
+ stdout: storedOutput(),
1748
+ ...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
1749
+ ...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
1750
+ outputDirSnapshots: args.outputDirSnapshots,
1751
+ deferSave: args.deferSave,
1752
+ // A sink listens: say what the save cost.
1753
+ measure: args.explainMiss !== undefined,
1754
+ }),
1755
+ )
1443
1756
  // A run that settles the outcome on `landed` learns there whether the
1444
- // entry is in; without one, the save is taken as done.
1757
+ // entry is in; without one, the save is done (a `--force` save may have kept the entry it found).
1445
1758
  if (args.deferredSaves !== undefined) args.deferredSaves.set(node.id, landed)
1446
- else saved = true
1759
+ else saved = (await landed).saved === true
1447
1760
  } else if (cfgCacheable && !remoteOnly && !deferralRequested && args.noDependants === true) {
1448
1761
  // Nothing reads after it, so nothing to mark: only its row's `outputs`.
1449
1762
  if (effectiveExitCode === 0)
1450
1763
  unsavedBytes = await unsavedOutputBytes({
1451
1764
  node,
1452
1765
  workspaceRoot: args.workspaceRoot,
1453
- nestedProjectDirs: args.nestedProjectDirs,
1766
+ nestedProjectDirs: args.outputNestedDirs ?? args.nestedProjectDirs,
1454
1767
  outputs,
1455
1768
  wsOutputs,
1456
1769
  })
1457
1770
  } else if (cfgCacheable && !remoteOnly && !deferralRequested) {
1458
1771
  // Ran here and saves nothing — failed, a read-only policy, a tainted
1459
- // upstream, a key that no longer held — yet it wrote what a save would
1460
- // have marked, and may have rewritten an input the re-check before a
1461
- // save catches. A same-run reader keyed from the snapshot's OIDs for
1462
- // either replayed the bytes from before the command (item 750). A
1772
+ // upstream, a server that died under it, a key that no longer held —
1773
+ // yet it wrote what a save would have marked, and may have rewritten an
1774
+ // input the re-check before a save catches. A same-run reader keyed
1775
+ // from the snapshot's OIDs for either replayed the bytes from before the command (item 750). A
1463
1776
  // withheld save already dropped the project; this finds the same move
1464
1777
  // again and drops it again.
1465
1778
  const endCheck = span('miss: recheck inputs')
@@ -1471,7 +1784,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1471
1784
  unsavedBytes = await markUnsaved({
1472
1785
  node,
1473
1786
  workspaceRoot: args.workspaceRoot,
1474
- nestedProjectDirs: args.nestedProjectDirs,
1787
+ nestedProjectDirs: args.outputNestedDirs ?? args.nestedProjectDirs,
1475
1788
  gitFilesCache: args.gitFilesCache,
1476
1789
  outputs,
1477
1790
  wsOutputs,
@@ -1482,11 +1795,13 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1482
1795
  /**
1483
1796
  * What an additive run saves as its own, or undefined when it removed a
1484
1797
  * file it found: no entry replays a removal (`ownOutputsSince`), so the
1485
- * task saves nothing and runs again.
1798
+ * task saves nothing and runs again. Undefined too for any task when a
1799
+ * failed attempt moved a file no clean removed (`failedTouchedFound`).
1486
1800
  */
1487
1801
  async function ownOutputs(): Promise<
1488
1802
  { ownOutputFiles?: string[]; ownWsOutputFiles?: string[] } | undefined
1489
1803
  > {
1804
+ if (failedTouchedFound) return undefined
1490
1805
  const mine: { ownOutputFiles?: string[]; ownWsOutputFiles?: string[] } = {}
1491
1806
  if (stampedBefore !== undefined) {
1492
1807
  const files = await ownOutputsSince(cleanArgs, stampedBefore)
@@ -1501,13 +1816,36 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1501
1816
  return mine
1502
1817
  }
1503
1818
 
1819
+ /**
1820
+ * Before an additive task's retry: remove what the failed attempt added
1821
+ * against the first stamp, as an exclusive task's clean removes it, so
1822
+ * the stamp still tells the retry's work from what the task found. A
1823
+ * found file it changed or removed stays as it is and taints the save.
1824
+ * Returns the removed paths, `root`-relative.
1825
+ */
1826
+ async function dropFailedAdditions(
1827
+ root: string,
1828
+ outs: readonly string[],
1829
+ before: ReadonlyMap<string, OutputStamp>,
1830
+ committed: (abs: string) => boolean,
1831
+ changed: string[] | undefined,
1832
+ walk: () => Promise<string[]>,
1833
+ ): Promise<string[]> {
1834
+ const added = (changed ?? (await walk())).filter((f) => !before.has(f))
1835
+ if (changed === undefined || added.length < changed.length) failedTouchedFound = true
1836
+ const rels = added.map((f) => relPosix(root, f))
1837
+ if (rels.length > 0)
1838
+ await cleanOutputPaths({ projectDir: root, rels, outputs: outs, committed })
1839
+ return rels
1840
+ }
1841
+
1504
1842
  /**
1505
1843
  * A process the shell left behind still held its output when the drain
1506
1844
  * bound cut it, so it may still write outputs: a save now would file a
1507
1845
  * tree the run does not leave, and a hit elsewhere would replay it.
1508
1846
  */
1509
1847
  function nothingLeftRunning(): boolean {
1510
- if (result.leftRunning !== true) return true
1848
+ if (!leftRunning) return true
1511
1849
  warnOf(log)(
1512
1850
  `[vx] ${node.id}: a process it left running may still write its outputs — ` +
1513
1851
  `the result stands, but is not saved; wait for it in the command (\`wait\`)`,
@@ -1528,12 +1866,22 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1528
1866
  const changed = new Set(result.changed.map((f) => path.join(args.workspaceRoot, f)))
1529
1867
  const there = keyedInputs(described!.facts).find((f) => changed.has(f.path))
1530
1868
  if (there !== undefined) return there.path
1869
+ // One the key never folded, as this disk lacks it (untracked there),
1870
+ // that the declaration matches: the command read it, and its entry
1871
+ // here was every later run's hit (2026-11-07).
1872
+ const dir = relPosix(args.workspaceRoot, node.projectDir)
1873
+ for (const f of result.changed) {
1874
+ const own = relPosix(node.projectDir, path.join(args.workspaceRoot, f))
1875
+ const projectRel = own.startsWith('../') ? null : own
1876
+ if (declaresInput(cfg, projectRel, f, false, true, dir))
1877
+ return path.join(args.workspaceRoot, f)
1878
+ }
1531
1879
  }
1532
1880
  return (
1533
1881
  (await movedInput(
1534
1882
  keyedInputs(described!.facts),
1535
1883
  cache,
1536
- described!.describedAt,
1884
+ commandFrom,
1537
1885
  stability.strategy === 'mutable' ? ownRewrite : undefined,
1538
1886
  )) ?? described!.added()
1539
1887
  )
@@ -1558,22 +1906,59 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1558
1906
  // `'unchecked'` skips the check of the command's own window; a key the
1559
1907
  // re-derive before the command already disagreed with still withholds,
1560
1908
  // since it never described the bytes the command read.
1561
- if (stability.strategy === 'unchecked' && described!.hash === hash) return true
1909
+ // Unchecked, the command may still have rewritten an input in place: a
1910
+ // later reader keyed after it (stable-keys.ts) must not read the
1911
+ // project's run-start facts.
1912
+ if (stability.strategy === 'unchecked' && described!.hash === hash) {
1913
+ if (args.executor.remote !== true)
1914
+ forgetUndeclaredWrites(
1915
+ args,
1916
+ wsOutputs.length > 0 ? 'workspace' : commandWriteReach(node, args.workspaceRoot),
1917
+ )
1918
+ return true
1919
+ }
1562
1920
  const endCheck = span('miss: recheck inputs')
1563
1921
  const moved = await movedSinceKey()
1564
1922
  endCheck()
1565
1923
  if (moved === undefined) return true
1566
- const what =
1567
- (moved === null ? 'its inputs' : `\`${printable(relPosix(args.workspaceRoot, moved))}\``) +
1568
- ' changed after its key was taken' +
1569
- (rewroteBy === undefined ? '' : ` while ${rewroteBy} ran`)
1924
+ const name =
1925
+ moved === null ? 'its inputs' : `\`${printable(relPosix(args.workspaceRoot, moved))}\``
1926
+ // Only its ctime moved: a hard link (`cp -al src dist`), chmod or chown.
1927
+ // The line called that a change and offered a rewrite's way out. It is
1928
+ // still withheld: an edit undone with its mtime put back reads the same.
1929
+ // Not said of a change an executor reported from where it ran.
1930
+ const fact =
1931
+ moved === null || rewroteBy !== undefined || (result.changed?.length ?? 0) > 0
1932
+ ? undefined
1933
+ : keyedInputs(described!.facts).find((f) => f.path === moved)
1934
+ const ctimeOnly = fact !== undefined && (await onlyCtimeMoved(fact, commandFrom, cache))
1935
+ const what = ctimeOnly
1936
+ ? `${name}'s ctime moved while the task ran, but not its bytes or mtime (a hard link, chmod or chown)`
1937
+ : `${name} changed after its key was taken` +
1938
+ (rewroteBy === undefined ? '' : ` while ${rewroteBy} ran`)
1570
1939
  // A task that regenerates its own input (TanStack Router's committed
1571
1940
  // `routeTree.gen.ts`, rewritten to the same bytes by every build) is
1572
1941
  // never saved (item 1015), and the line said so without the way out.
1942
+ // A file the key read may instead be one the task rewrites in place (a
1943
+ // formatter's temp + rename), whose sources cannot be its outputs: vx
1944
+ // deletes outputs before the command. Its way out is judging the write
1945
+ // by its bytes. Neither is said of a file this disk lacks: one the task
1946
+ // deleted, or one only where an executor ran it.
1947
+ const read =
1948
+ moved !== null &&
1949
+ keyedInputs(described!.facts).some((f) => f.path === moved && f.digest !== ABSENT_INPUT)
1950
+ // Under watch the edit starts the next cycle, which runs on it: said
1951
+ // first, or the way out read as the fix for the user's own edit.
1573
1952
  const hint =
1574
- moved === null || rewroteBy !== undefined
1575
- ? ''
1576
- : '; if the task writes it, declare it in outputs'
1953
+ (args.watch === true && rewroteBy === undefined ? '; the next cycle runs on it' : '') +
1954
+ (ctimeOnly
1955
+ ? "; if the task hard-links it, copy it instead, or set inputs.stability: { strategy: 'mutable' }"
1956
+ : moved === null || rewroteBy !== undefined || !existsSync(moved)
1957
+ ? ''
1958
+ : '; if the task generates it, declare it in outputs' +
1959
+ (read && stability.strategy !== 'mutable'
1960
+ ? "; if it rewrites it in place, set inputs.stability: { strategy: 'mutable' }"
1961
+ : ''))
1577
1962
  if (stability.severity === 'error') {
1578
1963
  warnOf(log)(
1579
1964
  `[vx] ${node.id}: ${what} — the result is not saved, ` +
@@ -1582,12 +1967,16 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1582
1967
  movedFails = moved === null ? '' : printable(relPosix(args.workspaceRoot, moved))
1583
1968
  } else {
1584
1969
  warnOf(log)(
1585
- `[vx] ${node.id}: ${what} — the result stands, ` +
1586
- `but is not saved under a key that no longer describes it${hint}`,
1970
+ `[vx] ${node.id}: ${what} — ` +
1971
+ (ctimeOnly
1972
+ ? 'vx cannot tell that from an edit undone, so the result stands but is not saved'
1973
+ : 'the result stands, but is not saved under a key that no longer describes it') +
1974
+ hint,
1587
1975
  )
1588
1976
  }
1589
1977
  forgetUndeclaredWrites(args, wsOutputs.length > 0 ? 'workspace' : 'project')
1590
1978
  unkeyed = true
1979
+ driftedInput = moved
1591
1980
  return false
1592
1981
  }
1593
1982
 
@@ -1616,11 +2005,18 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1616
2005
  : {}),
1617
2006
  ...(inputChanges !== undefined ? { inputChanges } : {}),
1618
2007
  ...(unkeyed ? { unkeyed: true as const } : {}),
2008
+ ...(driftedInput !== undefined ? { driftedInput } : {}),
1619
2009
  ...(keylessBy !== undefined ? { keylessBy } : {}),
1620
2010
  ...(saved ? { saved: true as const } : {}),
1621
2011
  ...(unsavedBytes !== undefined ? { outputBytes: unsavedBytes } : {}),
1622
2012
  ...(effectiveExitCode !== 0 && output !== undefined
1623
- ? { failedOutput: failedOutputLog(output, willSave || args.keepRun !== undefined, storeMask) }
2013
+ ? {
2014
+ failedOutput: failedOutputLog(
2015
+ output,
2016
+ willSave || args.keepRun !== undefined,
2017
+ secrets ?? undefined,
2018
+ ),
2019
+ }
1624
2020
  : {}),
1625
2021
  ...(args.keepRun !== undefined &&
1626
2022
  (effectiveExitCode !== 0 ? args.keepRun.failures : !willSave) &&
@@ -1661,3 +2057,48 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1661
2057
  : {}),
1662
2058
  }
1663
2059
  }
2060
+
2061
+ /**
2062
+ * The wildcard input globs, when not one of them matched a file: the
2063
+ * literal files beside them did. A lockfile plugin appends `package.json`
2064
+ * to every task's inputs, so `scr/**` for `src/**` kept a non-empty set
2065
+ * and its warning went unsaid: an edit to `src/` then replayed a stale hit.
2066
+ * On the miss path only; stops at the first matched file.
2067
+ */
2068
+ function deadSourceGlobs(
2069
+ declared: InputsConfig | undefined,
2070
+ files: readonly { readonly path: string }[],
2071
+ projectRel: string,
2072
+ ): string[] {
2073
+ const wild = (globs: readonly string[] | undefined): string[] =>
2074
+ (globs ?? []).filter((g) => !g.startsWith('!') && !isLiteralPattern(g))
2075
+ const own = wild(declared?.files)
2076
+ const ws = wild(declared?.workspaceFiles)
2077
+ if (own.length === 0 && ws.length === 0) return []
2078
+ const ownMatch = anyTaskGlob(own)
2079
+ const wsMatch = anyTaskGlob(ws)
2080
+ const prefix = projectRel === '' ? '' : `${projectRel}/`
2081
+ for (const { path: p } of files) {
2082
+ if (wsMatch(p)) return []
2083
+ if (p.startsWith(prefix) && ownMatch(p.slice(prefix.length))) return []
2084
+ }
2085
+ return [...own, ...ws]
2086
+ }
2087
+
2088
+ /**
2089
+ * Whether a moved input kept the bytes and the mtime its key folded, so only
2090
+ * its ctime moved. Asked once, of the file a re-check named: the common
2091
+ * path pays nothing.
2092
+ */
2093
+ async function onlyCtimeMoved(fact: InputFact, from: number, cache: CacheLayer): Promise<boolean> {
2094
+ if (fact.digest === ABSENT_INPUT || fact.behind !== undefined) return false
2095
+ let st
2096
+ try {
2097
+ st = lstatSync(fact.path)
2098
+ } catch {
2099
+ return false
2100
+ }
2101
+ if (st.ctimeMs < from - stampGrainMs(st.ctimeMs)) return false
2102
+ if (st.mtimeMs >= from - stampGrainMs(st.mtimeMs)) return false
2103
+ return (await cache.hashFile(fact.path).catch(() => undefined)) === fact.digest
2104
+ }