@vzn/vx 0.0.651 → 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 (177) hide show
  1. package/README.md +3 -4
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +15 -20
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +37 -3
  8. package/src/cache/archive.ts +197 -32
  9. package/src/cache/cache.ts +882 -230
  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 +151 -0
  16. package/src/cache/history-writer.ts +76 -0
  17. package/src/cache/index.ts +12 -1
  18. package/src/cache/inputs.ts +616 -135
  19. package/src/cache/key-fold.ts +1 -1
  20. package/src/cache/layer.ts +29 -16
  21. package/src/cache/layered-cache.ts +28 -7
  22. package/src/cache/lockfile-memo.ts +2 -1
  23. package/src/cache/output-index.ts +106 -44
  24. package/src/cache/plugin-cache.ts +2 -2
  25. package/src/cache/policy.ts +6 -2
  26. package/src/cache/remote-lane.ts +3 -1
  27. package/src/cache/run-history.ts +94 -25
  28. package/src/cache/schema.ts +46 -10
  29. package/src/cli/cache.ts +149 -36
  30. package/src/cli/completions.ts +2 -2
  31. package/src/cli/core-alias.ts +9 -2
  32. package/src/cli/docs.ts +4 -2
  33. package/src/cli/help.ts +27 -4
  34. package/src/cli/index.ts +62 -7
  35. package/src/cli/info.ts +5 -4
  36. package/src/cli/init.ts +55 -20
  37. package/src/cli/last.ts +218 -43
  38. package/src/cli/lock.ts +187 -24
  39. package/src/cli/plan-format.ts +24 -21
  40. package/src/cli/plugin-commands.ts +6 -1
  41. package/src/cli/plugin-templates.ts +36 -45
  42. package/src/cli/run.ts +68 -34
  43. package/src/cli/select.ts +160 -64
  44. package/src/cli/show.ts +70 -28
  45. package/src/cli/task-verb.ts +12 -7
  46. package/src/cli/upgrade.ts +210 -76
  47. package/src/cli/watch-filter.ts +88 -15
  48. package/src/cli/watch-fs.ts +34 -47
  49. package/src/cli/watch-judge.ts +73 -9
  50. package/src/cli/watch-set.ts +10 -8
  51. package/src/cli/watch.ts +190 -33
  52. package/src/cli/why.ts +107 -33
  53. package/src/cli/workspace-config.ts +11 -1
  54. package/src/config.ts +56 -30
  55. package/src/exec/env.ts +3 -1
  56. package/src/exec/executor.ts +116 -16
  57. package/src/exec/index.ts +1 -0
  58. package/src/exec/kill-tree.ts +7 -7
  59. package/src/exec/runner.ts +270 -108
  60. package/src/exec/sandbox-binds.ts +109 -18
  61. package/src/exec/sandbox-paths.ts +3 -1
  62. package/src/exec/sandbox-runtime.ts +446 -64
  63. package/src/exec/sandbox-violations.ts +250 -46
  64. package/src/exec/spawn-off.ts +10 -5
  65. package/src/exec/task-proxy.ts +93 -0
  66. package/src/exec/warm-shell.ts +29 -7
  67. package/src/graph/definition.ts +16 -3
  68. package/src/graph/folds.ts +89 -16
  69. package/src/graph/index.ts +8 -0
  70. package/src/graph/priorities.ts +8 -5
  71. package/src/graph/scheduler.ts +176 -39
  72. package/src/graph/stand-ins.ts +36 -0
  73. package/src/graph/task-graph.ts +347 -160
  74. package/src/graph/task-status.ts +5 -1
  75. package/src/handoff.ts +45 -10
  76. package/src/history/failure-mode.ts +39 -6
  77. package/src/history/history.ts +103 -30
  78. package/src/history/index.ts +2 -0
  79. package/src/history/metrics.ts +193 -73
  80. package/src/index.ts +6 -3
  81. package/src/orchestrator/admission.ts +14 -10
  82. package/src/orchestrator/affected-tasks.ts +35 -13
  83. package/src/orchestrator/command-windows.ts +8 -5
  84. package/src/orchestrator/deferred-outputs.ts +42 -13
  85. package/src/orchestrator/doctor.ts +21 -9
  86. package/src/orchestrator/download-policy.ts +8 -10
  87. package/src/orchestrator/excluded-keys.ts +15 -3
  88. package/src/orchestrator/execute-args.ts +9 -2
  89. package/src/orchestrator/execute-task.ts +612 -138
  90. package/src/orchestrator/graph-memo.ts +26 -4
  91. package/src/orchestrator/graph-stage.ts +27 -4
  92. package/src/orchestrator/held-servers.ts +36 -14
  93. package/src/orchestrator/hit-restore.ts +141 -93
  94. package/src/orchestrator/index.ts +4 -1
  95. package/src/orchestrator/keyed-projects.ts +53 -0
  96. package/src/orchestrator/local-floor.ts +21 -3
  97. package/src/orchestrator/local-shortcircuit.ts +2 -2
  98. package/src/orchestrator/lockfile-claim.ts +30 -4
  99. package/src/orchestrator/miss-save.ts +60 -61
  100. package/src/orchestrator/options.ts +13 -0
  101. package/src/orchestrator/output-host.ts +16 -4
  102. package/src/orchestrator/output-log.ts +1 -54
  103. package/src/orchestrator/persistent-task.ts +32 -16
  104. package/src/orchestrator/persistent.ts +152 -15
  105. package/src/orchestrator/placement.ts +82 -16
  106. package/src/orchestrator/plan.ts +39 -17
  107. package/src/orchestrator/plugin-host.ts +238 -110
  108. package/src/orchestrator/plugin.ts +59 -27
  109. package/src/orchestrator/prepare.ts +194 -38
  110. package/src/orchestrator/projects.ts +89 -10
  111. package/src/orchestrator/read-only.ts +115 -0
  112. package/src/orchestrator/remote-prefetch.ts +1 -1
  113. package/src/orchestrator/run-context.ts +6 -2
  114. package/src/orchestrator/run-failures.ts +101 -23
  115. package/src/orchestrator/run-id.ts +13 -2
  116. package/src/orchestrator/run-keys.ts +87 -28
  117. package/src/orchestrator/run-live.ts +16 -13
  118. package/src/orchestrator/run-lock.ts +27 -4
  119. package/src/orchestrator/run-records.ts +95 -74
  120. package/src/orchestrator/run-report.ts +3 -2
  121. package/src/orchestrator/run-stop.ts +17 -9
  122. package/src/orchestrator/run.ts +531 -122
  123. package/src/orchestrator/sandbox-fix.ts +40 -12
  124. package/src/orchestrator/sandbox-request.ts +314 -44
  125. package/src/orchestrator/save-lane.ts +24 -13
  126. package/src/orchestrator/shell-verdict.ts +29 -1
  127. package/src/orchestrator/signals.ts +103 -8
  128. package/src/orchestrator/stable-keys.ts +34 -27
  129. package/src/orchestrator/task-artifacts.ts +16 -3
  130. package/src/orchestrator/task-hash.ts +191 -34
  131. package/src/orchestrator/telemetry-host.ts +25 -6
  132. package/src/orchestrator/telemetry.ts +85 -13
  133. package/src/orchestrator/upstream.ts +3 -1
  134. package/src/report/events.ts +23 -6
  135. package/src/report/forecast.ts +103 -37
  136. package/src/report/framed-output.ts +179 -162
  137. package/src/report/index.ts +2 -1
  138. package/src/report/logger.ts +108 -31
  139. package/src/report/plain-output.ts +15 -9
  140. package/src/report/status-line.ts +28 -55
  141. package/src/report/summary.ts +61 -18
  142. package/src/settings.ts +50 -14
  143. package/src/ui/components.ts +36 -42
  144. package/src/ui/index.ts +3 -1
  145. package/src/ui/live-region.ts +67 -8
  146. package/src/ui/terminal.ts +12 -0
  147. package/src/ui/theme.ts +16 -1
  148. package/src/util/config-frame.ts +142 -6
  149. package/src/util/edit-distance.ts +17 -1
  150. package/src/util/errors.ts +23 -7
  151. package/src/util/foreign-cache-dir.ts +60 -0
  152. package/src/util/git-ignored.ts +59 -5
  153. package/src/util/index.ts +15 -1
  154. package/src/util/paths.ts +51 -7
  155. package/src/util/plain-text.ts +19 -0
  156. package/src/util/secret-mask.ts +57 -1
  157. package/src/util/settle.ts +18 -1
  158. package/src/util/size.ts +12 -2
  159. package/src/util/tail.ts +4 -1
  160. package/src/util/write-file.ts +86 -9
  161. package/src/workspace/affected.ts +225 -27
  162. package/src/workspace/bracket-literal.ts +2 -2
  163. package/src/workspace/config-bytecode.ts +23 -4
  164. package/src/workspace/config-eval.ts +90 -16
  165. package/src/workspace/config-imports.ts +96 -41
  166. package/src/workspace/config-schema.ts +192 -86
  167. package/src/workspace/dependencies-lock.ts +7 -14
  168. package/src/workspace/filter.ts +18 -2
  169. package/src/workspace/index.ts +3 -1
  170. package/src/workspace/json-data.ts +34 -8
  171. package/src/workspace/lockfile.ts +86 -37
  172. package/src/workspace/migration.ts +58 -31
  173. package/src/workspace/module-state.ts +62 -0
  174. package/src/workspace/nested-dirs.ts +8 -0
  175. package/src/workspace/project-loader.ts +276 -32
  176. package/src/workspace/repo-id.ts +29 -6
  177. 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
- import type { ExecConfig, TaskConfig, CacheConfig, InputStability } from '../config.js'
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,
@@ -58,6 +80,7 @@ import { executorLabel, nameExecutorFailure } from './plugin-host.js'
58
80
  import type { CommandWindows } from './command-windows.js'
59
81
  import {
60
82
  commandWriteReach,
83
+ dependencyReads,
61
84
  mayWriteFingerprint,
62
85
  type Placeholder,
63
86
  reachedWithheld,
@@ -93,6 +116,7 @@ import {
93
116
  type HashCache,
94
117
  type InputFact,
95
118
  isKeyless,
119
+ keyOf,
96
120
  movedInput,
97
121
  type UpfrontGroupKey,
98
122
  } from './task-hash.js'
@@ -127,6 +151,12 @@ export interface ExecuteArgs {
127
151
  * machine. The hash is still computed — dependents fold it.
128
152
  */
129
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
130
160
  /**
131
161
  * `exec.remote: 'only'` placed on a remote executor: execute remotely,
132
162
  * but this machine's disk is untouched — no output clean, no vx cache
@@ -146,6 +176,11 @@ export interface ExecuteArgs {
146
176
  * where a locally-placed consumer fetches its producers from. */
147
177
  deferred?: DeferredOutputs
148
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[]
149
184
  /** The run's cache directory: walled off from a sandboxed task's grants. */
150
185
  cacheDir?: string
151
186
  /** Anchor for hrtime spans across all tasks in this run. */
@@ -183,17 +218,23 @@ export interface ExecuteArgs {
183
218
  * byte-identical with and without it.
184
219
  */
185
220
  retries?: number
221
+ /**
222
+ * Whether the run history knows the task as flaky (`knownFlaky`): a keyed
223
+ * task with no `retries` of its own or of the run that fails gets ONE more
224
+ * attempt when it does. Decided at run time, so never in the key.
225
+ */
226
+ knownFlaky?: (node: TaskNode, hash: string) => boolean
186
227
  /**
187
228
  * `run.timeout` (ms), already resolved from flag, variable and
188
229
  * workspace. Per-task `exec.timeout` wins. Never hashed.
189
230
  */
190
231
  timeout?: number
191
232
  /**
192
- * The run's `inputStability` layers around the task's own: `run` from
233
+ * The run's `inputs.stability` layers around the task's own: `run` from
193
234
  * the env vars (above the task), `workspace` from `.vx/workspace` (below).
194
235
  */
195
236
  inputStability?: { run: InputStability; workspace?: InputStability }
196
- /** `cache.outputs.clean` for tasks without their own: `.vx/workspace`. */
237
+ /** `outputs.clean` for tasks without their own: `.vx/workspace`. */
197
238
  cleanOutputs?: boolean
198
239
  /** Per-run memo for `git ls-files` (one entry per project dir). */
199
240
  gitFilesCache?: GitFilesCache
@@ -236,6 +277,17 @@ export interface ExecuteArgs {
236
277
  * granted a linked workspace package only when it is in this set.
237
278
  */
238
279
  keyedProjects: (node: TaskNode) => ReadonlySet<string>
280
+ /**
281
+ * The run's lookup of the tasks whose outputs a sandboxed task may read
282
+ * (keyed-projects.ts `readableDeps`). Absent: none.
283
+ */
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[]
239
291
  /**
240
292
  * Run-scoped list the miss path appends its output-directory snapshot
241
293
  * request to, taken at run end (run.ts) instead of right after the save:
@@ -244,7 +296,7 @@ export interface ExecuteArgs {
244
296
  */
245
297
  outputDirSnapshots?: OutputDirSnapshot[]
246
298
  /** The run's save lane: a miss's cache save runs off the execution slot (save-lane.ts). */
247
- deferSave?: (save: () => Promise<void>) => Promise<boolean>
299
+ deferSave?: (save: () => Promise<void>, taskId: string) => Promise<boolean>
248
300
  /**
249
301
  * Where a deferred save's `landed` promise is parked by task id, for the
250
302
  * in-flight join (admission.ts): a duplicate of this task in another run
@@ -290,6 +342,26 @@ export interface ExecuteArgs {
290
342
  serversNested?: readonly (readonly string[])[]
291
343
  /** When each command of the run ran and where it may write (command-windows.ts). */
292
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
293
365
  }
294
366
 
295
367
  /**
@@ -345,7 +417,7 @@ function readsCache(args: ExecuteArgs): boolean {
345
417
  const policy = args.cachePolicy ?? FULL_CACHE_POLICY
346
418
  return (
347
419
  args.remoteOnly !== true &&
348
- args.node.config.cache !== undefined &&
420
+ args.node.config.cache === true &&
349
421
  (policy.localRead || policy.remoteRead)
350
422
  )
351
423
  }
@@ -377,7 +449,8 @@ async function restoreProbed(
377
449
  * A hit restored, or null when its artifact was removed after the probe (a
378
450
  * `vx cache clean`, here or for a workspace sharing the cache directory): a
379
451
  * miss. The restore has wiped the declared outputs by then; the run path
380
- * 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.
381
454
  */
382
455
  async function restoreOrMiss(
383
456
  args: ExecuteArgs,
@@ -389,7 +462,7 @@ async function restoreOrMiss(
389
462
  try {
390
463
  return await restoreHit({ args, hash, hit, cacheOpStart, taskStartNs })
391
464
  } catch (err) {
392
- if (!(err instanceof ArtifactVanishedError)) throw err
465
+ if (!(err instanceof ArtifactVanishedError)) throw new RestoreFailed(err)
393
466
  warnOf(args.log)(`[vx] ${args.node.id}: ${err.message} — running it`)
394
467
  return null
395
468
  }
@@ -397,16 +470,16 @@ async function restoreOrMiss(
397
470
 
398
471
  /**
399
472
  * What a write to an input during the command means, each field highest
400
- * first: the env var (`layers.run`), the task's `cache.inputStability`,
473
+ * first: the env var (`layers.run`), the task's `inputs.stability`,
401
474
  * the workspace's, then `'immutable'` and `'warn'` (2026-10-12), so
402
475
  * by default any such write withholds the save, on CI too, since a
403
476
  * concurrent task can change an input and put it back.
404
477
  */
405
478
  function inputStabilityOf(
406
- cache: CacheConfig | undefined,
479
+ inputs: InputsConfig | undefined,
407
480
  layers: ExecuteArgs['inputStability'],
408
481
  ): Required<InputStability> {
409
- const task = cache?.inputStability
482
+ const task = inputs?.stability
410
483
  return {
411
484
  strategy: layers?.run.strategy ?? task?.strategy ?? layers?.workspace?.strategy ?? 'immutable',
412
485
  severity: layers?.run.severity ?? task?.severity ?? layers?.workspace?.severity ?? 'warn',
@@ -444,16 +517,18 @@ async function executeGroupTask(args: ExecuteArgs): Promise<TaskOutcome> {
444
517
  )
445
518
  }
446
519
  const wallclockNs = process.hrtime.bigint() - args.runStartHrTimeNs
447
- const hash = await computeTaskHash({
448
- node: args.node,
449
- upstream: args.upstream,
450
- workspaceRoot: args.workspaceRoot,
451
- cache: args.cache,
452
- forwardArgs: args.forwardArgs,
453
- nestedProjectDirs: args.nestedProjectDirs,
454
- ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
455
- ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
456
- })
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
+ )
457
532
  return groupOutcome(args.node, args.upstream, hash, wallclockNs)
458
533
  }
459
534
 
@@ -518,9 +593,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
518
593
  const { node, upstream, cache, log } = args
519
594
  const cfg: TaskConfig = node.config
520
595
  const step = cfg.exec as ExecConfig // dispatcher guarantees exec is present
521
- const cacheCfg: CacheConfig | undefined = cfg.cache
596
+ const cfgOut = cfg.outputs
522
597
  const policy = args.cachePolicy ?? FULL_CACHE_POLICY
523
- const cfgCacheable = cacheCfg !== undefined
598
+ const cfgCacheable = cfg.cache === true
524
599
  // We may READ when at least one read axis is on (the cache layer
525
600
  // refines local vs remote), and WRITE when at least one write axis is
526
601
  // on. Output management (clean before exec) keys off writes — so
@@ -535,7 +610,13 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
535
610
  // nothing to read under it, and nothing a later run could find.
536
611
  const present = upstream.filter((u): u is TaskOutcome => u !== undefined)
537
612
  const folded = new Set(
538
- 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),
539
620
  )
540
621
  const foldedTasks = expandGroupUpstream(present.filter((u) => folded.has(u.node.id)))
541
622
  const keylessBy = isKeyless(cfg)
@@ -560,7 +641,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
560
641
  // task's unfinished deps).
561
642
  const unkeyedUpstream = foldedTasks.find((u) => u.unkeyed === true)
562
643
  let unkeyed = unkeyedUpstream !== undefined
563
- 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
564
648
 
565
649
  // The executor retains nothing: the entry's output is the attempt's
566
650
  // `output` log, both streams in the order they reached the logger, kept
@@ -571,8 +655,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
571
655
  const deferralRequested = args.download === 'deferred'
572
656
  const capture: CaptureConfig = { stdout: false, stderr: false }
573
657
 
574
- const outputs = cacheCfg?.outputs.files ?? []
575
- const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
658
+ const outputs = cfgOut?.files ?? []
659
+ const wsOutputs = cfgOut?.workspaceFiles ?? []
576
660
  const effectiveForwardArgs = node.requested ? (args.forwardArgs ?? []) : []
577
661
  // Per-task `exec.timeout` beats `run.timeout` (flag, variable,
578
662
  // workspace; resolved into `args.timeout` by run.ts).
@@ -587,6 +671,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
587
671
  // does an executor that declares `timeouts`.
588
672
  let timeoutTimer: ReturnType<typeof setTimeout> | undefined
589
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
590
678
  // The attempt's listener on the run's stop signal, taken off once the
591
679
  // attempt settles. Left on, every task of the run added one: the list
592
680
  // grew to the task count, and each add scans it for a duplicate, ~100 ms
@@ -597,12 +685,32 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
597
685
  const abort = (): void => stop.abort(args.stopSignal?.reason)
598
686
  unlistenStop?.()
599
687
  unlistenStop = undefined
688
+ unbegun = false
689
+ withdrawUnbegun = undefined
600
690
  if (args.stopSignal?.aborted === true) abort()
601
691
  else if (args.stopSignal !== undefined) {
602
692
  const run = args.stopSignal
603
693
  run.addEventListener('abort', abort, { once: true })
604
694
  unlistenStop = () => run.removeEventListener('abort', abort)
605
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
+ }
606
714
  clearTimeout(timeoutTimer)
607
715
  timeoutFired = false
608
716
  if (effectiveTimeout !== undefined && !local && args.executor.timeouts !== true) {
@@ -623,7 +731,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
623
731
  signal: AbortSignal | undefined,
624
732
  ): Promise<unknown> {
625
733
  if (signal === undefined || isLocalExecutor(args.executor)) return running
626
- const started = Date.now()
734
+ const started = performance.now()
627
735
  let graceTimer: ReturnType<typeof setTimeout> | undefined
628
736
  let onAbort: (() => void) | undefined
629
737
  const abandoned = new Promise<ExecuteResult>((resolve) => {
@@ -638,10 +746,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
638
746
  const reason: unknown = signal.reason
639
747
  resolve({
640
748
  exitCode: signalExitCode(reason === 'SIGINT' ? 'SIGINT' : 'SIGTERM'),
641
- durationMs: Date.now() - started,
749
+ durationMs: Math.round(performance.now() - started),
642
750
  stdout: '',
643
751
  stderr: '',
644
752
  violations: [],
753
+ // What it started may still run and write the retry's outputs.
754
+ leftRunning: true,
645
755
  ...(timeoutFired ? { timedOut: true } : {}),
646
756
  })
647
757
  }, graceMs)
@@ -685,19 +795,21 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
685
795
  const hash =
686
796
  preProbed?.hash ??
687
797
  args.upfrontKey ??
688
- (await computeTaskHash({
689
- node,
690
- upstream,
691
- workspaceRoot: args.workspaceRoot,
692
- cache,
693
- forwardArgs: args.forwardArgs,
694
- nestedProjectDirs: args.nestedProjectDirs,
695
- ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
696
- ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
697
- ...(args.probesAfterWrites !== undefined
698
- ? { probesAfterWrites: args.probesAfterWrites }
699
- : {}),
700
- }))
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
+ ))
701
813
 
702
814
  // The local no-op half of `exec.remote: 'only'`: no remote executor took
703
815
  // the task, so it succeeds without running. The hash was still computed —
@@ -715,32 +827,48 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
715
827
  }
716
828
  }
717
829
 
830
+ const ownInputs = {
831
+ projectDir: node.projectDir,
832
+ workspaceRoot: args.workspaceRoot,
833
+ declared: node.config.inputs,
834
+ }
718
835
  const cleanArgs = {
719
836
  projectDir: node.projectDir,
720
837
  outputs,
721
- nestedProjectDirs: args.nestedProjectDirs,
838
+ nestedProjectDirs: args.outputNestedDirs ?? args.nestedProjectDirs,
839
+ inputs: ownInputs,
722
840
  committed: committedFiles(args.gitFilesCache, node.projectDir),
723
841
  }
724
842
  const wsCleanArgs = {
725
843
  workspaceRoot: args.workspaceRoot,
726
844
  outputs: wsOutputs,
845
+ inputs: ownInputs,
727
846
  committed: committedFiles(args.gitFilesCache, args.workspaceRoot),
728
847
  }
729
848
  // An ADDITIVE task (its outputs overlap an upstream's, with the edge that
730
849
  // orders them; item 588) does not clean by glob — the glob selects the
731
850
  // upstream's files it adds beside. Its outputs are stamped once before
732
851
  // the first attempt, and after a 0 exit its own set is what the run
733
- // 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
734
854
  // earlier run are its command's to clean, as they are under Turbo.
735
855
  const additive = (node.addsToOutputsOf?.length ?? 0) > 0
736
856
  let stampedBefore: ReadonlyMap<string, OutputStamp> | undefined
737
857
  let wsStampedBefore: ReadonlyMap<string, OutputStamp> | undefined
738
- // `cache.outputs.clean: false` leaves the outputs to the first attempt's
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
866
+ // `outputs.clean: false` leaves the outputs to the first attempt's
739
867
  // command, and the save stores whatever they hold after it, a leftover
740
868
  // included: the user opted out of the clean-tree guarantee
741
869
  // (2026-10-15). A retry still starts clean. An additive task never cleans
742
870
  // by glob anyway.
743
- const keepOutputs = !additive && (cacheCfg?.outputs.clean ?? args.cleanOutputs ?? true) === false
871
+ const keepOutputs = !additive && (cfgOut?.clean ?? args.cleanOutputs ?? true) === false
744
872
 
745
873
  // Cache lookup. On hit, time the user-perceived restore op
746
874
  // (clean+restore+log-replay) — that's what the framed-block footer
@@ -790,6 +918,14 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
790
918
  : {}),
791
919
  })
792
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
793
929
  /** The other command that may have put a moved input back (`ownRewrite`). */
794
930
  let rewroteBy: string | undefined
795
931
  /**
@@ -834,7 +970,11 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
834
970
  c.hash = MASKED + c.hash
835
971
  // The values of secret-named variables, masked in what the task prints
836
972
  // and what the cache keeps of it (L-11); null when there are none.
837
- 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()
838
978
  // A runtime probe's row is named by its command, which a TS config may
839
979
  // build from `process.env`: the run history stored it and `vx why`
840
980
  // printed it (L-11).
@@ -856,16 +996,22 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
856
996
  const inputChanges = described !== undefined ? args.explainMiss?.(node.id, captured) : undefined
857
997
  // A plugin may keep the request past the run (the cache closed): it gets
858
998
  // the upstream outputs read now. Only the local floor leaves them unread.
859
- if (inputs !== undefined && !isLocalExecutor(args.executor)) void inputs.upstream
999
+ if (inputs !== undefined && !isLocalExecutor(args.executor)) await described!.readUpstream()
860
1000
  // A declared input set that resolves to NOTHING is the quiet stale hit:
861
1001
  // the key stops moving with this project's source and every later run
862
1002
  // is a hit. Almost always a glob written against the wrong directory
863
1003
  // (`lib/**` in a package that builds `src/`). Said once per miss, on the
864
1004
  // run's status line — the task itself succeeds.
865
1005
  const declaredInputs = [...(cfg.inputs?.files ?? []), ...(cfg.inputs?.workspaceFiles ?? [])]
866
- 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) {
867
1013
  warnOf(log)(
868
- `[vx] ${node.id}: inputs matched no files (${declaredInputs.join(', ')}) — ` +
1014
+ `[vx] ${node.id}: inputs matched no files (${unmatched.join(', ')}) — ` +
869
1015
  `the key will not change when this project's source does` +
870
1016
  bracketLiteralHint([
871
1017
  ...(cfg.inputs?.files ?? []).map((g) => [node.projectDir, g] as const),
@@ -889,7 +1035,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
889
1035
  // Cache miss path (or caching disabled), up to `1 + retries` attempts.
890
1036
  // Explicit config wins over the run-level `--retries` default, including
891
1037
  // an explicit `retries: 0`.
892
- const maxAttempts = 1 + (step.retries ?? args.retries ?? 0)
1038
+ const retriesSet = step.retries !== undefined || args.retries !== undefined
1039
+ let maxAttempts = 1 + (step.retries ?? args.retries ?? 0)
893
1040
  let attempt = 0
894
1041
  let result: ExecuteResult
895
1042
  let effectiveExitCode: number
@@ -897,6 +1044,36 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
897
1044
  // the last attempt alone read `max 407ms` beside a footer of 837 (item
898
1045
  // 1101). A saved entry keeps the attempt that produced it, what a hit saves.
899
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
+ }
900
1077
 
901
1078
  // One task attempt: clean the declared outputs (before EVERY attempt — so a
902
1079
  // stale prior-build artifact can't survive into a fresh run, and a failed
@@ -909,6 +1086,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
909
1086
  result: ExecuteResult
910
1087
  exitCode: number
911
1088
  withdrawn?: true
1089
+ unbegun?: true
912
1090
  }> {
913
1091
  // The first attempt under `clean: false` finds its last outputs (tsc's `.tsbuildinfo`).
914
1092
  const clean = willWrite && !deferralRequested && !(keepOutputs && attempt === 1)
@@ -916,33 +1094,92 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
916
1094
  // tree would replace a stale build with nothing at all. The eligibility
917
1095
  // gate guarantees no key in this run can see what stays behind, and the
918
1096
  // summary names every deferred task so `dist/` is not silently stale.
919
- if (additive && willWrite && !deferralRequested && outputs.length > 0) {
920
- const endStamp = span('miss: stamp outputs')
921
- stampedBefore ??= await stampOutputs(cleanArgs)
922
- endStamp()
923
- } else if (clean && outputs.length > 0) {
924
- // Mark the wiped paths, exactly as the workspace twin below does. The
925
- // git snapshot still lists them with their committed index OIDs, and
926
- // resolveFiles SKIPS its existence probe for any path carrying a
927
- // trusted OID — so a declared output the producer deletes and does not
928
- // re-create would stay in a same-project consumer's input set, keeping
929
- // that consumer's key unchanged while the file is gone from disk.
930
- const endClean = span('miss: clean outputs')
931
- const cleanedRels = await cleanOutputs(cleanArgs)
932
- endClean()
933
- args.gitFilesCache?.markOutputsChanged(node.projectDir, cleanedRels)
934
- }
935
- if (additive && willWrite && !deferralRequested && wsOutputs.length > 0) {
936
- // A root-anchored addition is stamped as the project one above: a
937
- // glob clean deleted the upstream's files before this task, which
938
- // reads them, ran (A-43).
939
- wsStampedBefore ??= await stampWorkspaceOutputs(wsCleanArgs)
940
- } else if (clean && wsOutputs.length > 0) {
941
- // Root-anchored deletions can land in other projects' dirs; mark
942
- // them so stale per-project git snapshots can't survive the wipe.
943
- const cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
944
- args.gitFilesCache?.markWorkspaceOutputsChanged(args.workspaceRoot, cleanedWsRels)
945
- }
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
+ })
946
1183
  // What the attempt before this one left, put back if this one is
947
1184
  // withdrawn: it stands, with its output and its refusals.
948
1185
  const standing = { output, violations, flushMasked }
@@ -984,6 +1221,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
984
1221
  } catch (thrown) {
985
1222
  running = Promise.reject(thrown)
986
1223
  }
1224
+ // A failure that landed while the request was built: the executor holds it now.
1225
+ if (args.failFast?.aborted === true) withdrawUnbegun?.()
987
1226
  let res = await boundAfterAbort(running, req.signal)
988
1227
  .then((r: unknown) => {
989
1228
  assertExecuteResult(args.executor.name, node.id, r)
@@ -1036,6 +1275,15 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1036
1275
  throw thrown
1037
1276
  })
1038
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
+ }
1039
1287
  if (secrets !== null)
1040
1288
  res = {
1041
1289
  ...res,
@@ -1087,12 +1335,18 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1087
1335
  // A child we SIGTERMed for exceeding the timeout is a genuine failure —
1088
1336
  // stream a clear line so the 143 exit reads as a timeout. One that outlived
1089
1337
  // the grace died of the SIGKILL, and the line said SIGTERM over an exit
1090
- // 137 (item 1063).
1338
+ // 137 (item 1063); so did a shell dead of the SIGTERM whose leftover the
1339
+ // SIGKILL took.
1091
1340
  if (res.timedOut) {
1092
- 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)'
1093
1347
  log.taskStderr(
1094
1348
  node,
1095
- `${midLine ? '\n' : ''}[vx] timed out after ${effectiveTimeout}ms — killed (${how})\n`,
1349
+ `${midLine ? '\n' : ''}[vx] timed out after ${effectiveTimeout}ms — ${how}\n`,
1096
1350
  )
1097
1351
  // Force a non-zero classification even if the child TRAPPED SIGTERM and
1098
1352
  // still exited 0 (`trap 'exit 0' TERM`, a common graceful-shutdown
@@ -1125,6 +1379,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1125
1379
  bins: taskBinDirs(node, args.workspaceRoot),
1126
1380
  signal: res.signal,
1127
1381
  hidden: req.sandbox && ((f: string) => !sandboxReads(req.sandbox!, f)),
1382
+ stderr: errTail,
1128
1383
  })
1129
1384
  // The line quotes the command's first word, which a config may have
1130
1385
  // built from a secret: masked as the task's own output is (L-37).
@@ -1166,13 +1421,18 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1166
1421
  // printed a blank line under the OUTPUT rule or the task's last line. Glued, a frame read
1167
1422
  // `partialvx: retrying …`.
1168
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 = ''
1169
1426
  // The attempt's output as the logger got it (masked): the entry's when
1170
1427
  // the task saves, else only what a failure reports (`failedOutput`).
1171
1428
  let output: BoundedCapture | undefined
1172
1429
  // Masked again whole: the live stream lets a held piece go after a pause.
1173
- const storeMask = secrets ?? undefined
1174
- const storedOutput = (): string => encodeOutputLog(output?.chunks() ?? [], storeMask)
1430
+ const storedOutput = (): string => encodeOutputLog(output?.chunks() ?? [], secrets ?? undefined)
1175
1431
  const failedAttempts: { endedAt: number; exitCode: number; timedOut?: true }[] = []
1432
+ // Each failed attempt's output, beside `failedAttempts`: kept for `vx last --log`.
1433
+ const attemptOutputs: string[] = []
1434
+ // Any attempt's shell left a process running, not only the last one's.
1435
+ let leftRunning = false
1176
1436
  // The sampling of the attempt's process tree, stopped once it settles.
1177
1437
  let untrack: (() => TreeLoad | undefined) | undefined
1178
1438
  // What the last attempt's samples saw, for the run history.
@@ -1206,13 +1466,33 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1206
1466
  for (;;) {
1207
1467
  attempt++
1208
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
+ }
1209
1481
  result = a.result
1210
1482
  effectiveExitCode = a.exitCode
1211
1483
  if (a.withdrawn) {
1212
1484
  attempt--
1213
1485
  failedAttempts.pop()
1486
+ attemptOutputs.pop()
1214
1487
  break
1215
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
+ }
1216
1496
  forgetUndeclaredWrites(args, writeReach)
1217
1497
  if (writesFingerprint) args.fingerprintWatch?.wrote()
1218
1498
  spentMs += result.durationMs
@@ -1252,25 +1532,77 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1252
1532
  }
1253
1533
  }
1254
1534
 
1535
+ // A known flake gets the one retry its config did not ask for; a
1536
+ // timeout always fails, and a task whose key names nothing (no cache
1537
+ // block, or keyed on a keyless upstream) has no record to know it by.
1538
+ if (
1539
+ effectiveExitCode !== 0 &&
1540
+ attempt === 1 &&
1541
+ !retriesSet &&
1542
+ result.timedOut !== true &&
1543
+ cfgCacheable &&
1544
+ !keyless &&
1545
+ args.failFast?.aborted !== true &&
1546
+ args.knownFlaky?.(node, hash) === true
1547
+ )
1548
+ maxAttempts = 2
1255
1549
  if (effectiveExitCode === 0 || attempt >= maxAttempts || args.failFast?.aborted === true) break
1256
1550
  failedAttempts.push({
1257
1551
  endedAt: Date.now(),
1258
1552
  exitCode: effectiveExitCode,
1259
1553
  ...(result.timedOut === true ? { timedOut: true as const } : {}),
1260
1554
  })
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}`
1261
1582
  log.taskStderr(
1262
1583
  node,
1263
- `${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`,
1264
1585
  )
1265
1586
  }
1266
1587
 
1267
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
+ }
1268
1599
  const kept =
1269
1600
  willSave || args.keepRun !== undefined
1270
1601
  ? new BoundedCapture()
1271
1602
  : new BoundedCapture(FAILED_OUTPUT_HEAD_CHARS, FAILED_OUTPUT_TAIL_CHARS)
1272
1603
  output = kept
1273
1604
  midLine = false
1605
+ errTail = ''
1274
1606
  const toOut = (t: string): void => {
1275
1607
  kept.push(t)
1276
1608
  if (t.length > 0) midLine = !t.endsWith('\n')
@@ -1279,18 +1611,26 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1279
1611
  const toErr = (t: string): void => {
1280
1612
  kept.push(t, true)
1281
1613
  if (t.length > 0) midLine = !t.endsWith('\n')
1614
+ errTail = (errTail + t).slice(-2048)
1282
1615
  log.taskStderr(node, t)
1283
1616
  }
1284
1617
  const masked = secrets && maskedStreams(secrets, toOut, toErr)
1285
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 })
1286
1625
  const base: ExecuteRequest = {
1287
1626
  taskId: node.id,
1627
+ ...(attempt > 1 ? { attempt } : {}),
1288
1628
  workspaceRoot: args.workspaceRoot,
1289
1629
  command: step.command,
1290
- forwardArgs: effectiveForwardArgs,
1630
+ forwardArgs: own(effectiveForwardArgs),
1291
1631
  cwd: node.projectDir,
1292
1632
  env,
1293
- envDefine: step.env?.define ?? {},
1633
+ envDefine: own(step.env?.define ?? {}),
1294
1634
  capture,
1295
1635
  onStdout: masked ? masked.out : toOut,
1296
1636
  onStderr: masked ? masked.err : toErr,
@@ -1309,11 +1649,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1309
1649
  ...(cfgCacheable && !remoteOnly && !willSave ? { unsaved: true } : {}),
1310
1650
  ...(remoteOnly ? { remoteOnly: true } : {}),
1311
1651
  ...(node.platform !== undefined ? { platform: node.platform } : {}),
1652
+ ...(node.worker !== undefined ? { worker: node.worker } : {}),
1312
1653
  ...(deferralRequested ? { download: 'deferred' as const } : {}),
1313
1654
  // `--force`/`--no-cache` reach a remote executor's private record
1314
1655
  // through this flag — the policy gates above only cover vx's OWN cache.
1315
1656
  ...(cfgCacheable && !(policy.localRead || policy.remoteRead) ? { refresh: true } : {}),
1316
- outputs: { files: outputs, workspaceFiles: wsOutputs },
1657
+ outputs: { files: own(outputs), workspaceFiles: own(wsOutputs) },
1317
1658
  ...(args.terminal === true ? { terminal: true as const } : {}),
1318
1659
  ...(args.servers === undefined
1319
1660
  ? {}
@@ -1324,11 +1665,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1324
1665
  }),
1325
1666
  }
1326
1667
  // The signal last: it arms a plugin's `exec.timeout`, and the sandbox's arming
1327
- // 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
1328
1669
  // expired before the spawn and failed `echo` as timed out, unrun.
1329
1670
  if (!userSandbox) return { ...base, signal: requestSignal(local) }
1330
- // A remote executor enforces it where it runs the task.
1331
- if (args.executor.remote !== true) await args.armSandbox?.()
1671
+ const deps = await dependencyReads(args.readableDeps?.(node) ?? [], args.workspaceRoot)
1332
1672
  const sb = await sandboxRequestFor(
1333
1673
  node,
1334
1674
  step.sandbox!,
@@ -1336,6 +1676,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1336
1676
  cfgCacheable ? args.keyedProjects(node) : undefined,
1337
1677
  args.nestedProjectDirs,
1338
1678
  args.cacheDir,
1679
+ deps.reads,
1680
+ deps.reported,
1681
+ args.nestedDirsOf,
1339
1682
  )
1340
1683
  placeholders = sb.placeholders
1341
1684
  withheld = sb.withheld
@@ -1344,7 +1687,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1344
1687
 
1345
1688
  const wallclockEndNs = process.hrtime.bigint() - args.runStartHrTimeNs
1346
1689
 
1347
- const stability = inputStabilityOf(args.node.config.cache, args.inputStability)
1690
+ const stability = inputStabilityOf(args.node.config.inputs, args.inputStability)
1348
1691
  let movedFails: string | undefined
1349
1692
  let own: Awaited<ReturnType<typeof ownOutputs>>
1350
1693
  let saved = false
@@ -1364,7 +1707,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1364
1707
  // edit's bytes under the old key (X-123).
1365
1708
  const materialize = result.outputs?.kind === 'deferred' ? result.outputs.materialize : undefined
1366
1709
  if (materialize !== undefined) {
1367
- const keyed = await keyStillTrue()
1710
+ const keyed = (await keyStillTrue()) && (await args.serverDiedUnder?.(node)) !== true
1368
1711
  args.deferred?.register(node.id, {
1369
1712
  materialize,
1370
1713
  ...(keyed ? { hash } : {}),
@@ -1383,50 +1726,53 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1383
1726
  willSave &&
1384
1727
  nothingLeftRunning() &&
1385
1728
  (await keyStillTrue()) &&
1729
+ (await args.serverDiedUnder?.(node)) !== true &&
1386
1730
  (own = await ownOutputs()) !== undefined
1387
1731
  ) {
1388
- const { landed } = await saveMiss({
1389
- ...own,
1390
- node,
1391
- hash,
1392
- cache,
1393
- log,
1394
- workspaceRoot: args.workspaceRoot,
1395
- nestedProjectDirs: args.nestedProjectDirs,
1396
- gitFilesCache: args.gitFilesCache,
1397
- outputs,
1398
- wsOutputs,
1399
- captured,
1400
- command: storedCommand,
1401
- durationMs: result.durationMs,
1402
- stdout: storedOutput(),
1403
- ...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
1404
- ...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
1405
- outputDirSnapshots: args.outputDirSnapshots,
1406
- deferSave: args.deferSave,
1407
- // A sink listens: say what the save cost.
1408
- measure: args.explainMiss !== undefined,
1409
- })
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
+ )
1410
1756
  // A run that settles the outcome on `landed` learns there whether the
1411
- // 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).
1412
1758
  if (args.deferredSaves !== undefined) args.deferredSaves.set(node.id, landed)
1413
- else saved = true
1759
+ else saved = (await landed).saved === true
1414
1760
  } else if (cfgCacheable && !remoteOnly && !deferralRequested && args.noDependants === true) {
1415
1761
  // Nothing reads after it, so nothing to mark: only its row's `outputs`.
1416
1762
  if (effectiveExitCode === 0)
1417
1763
  unsavedBytes = await unsavedOutputBytes({
1418
1764
  node,
1419
1765
  workspaceRoot: args.workspaceRoot,
1420
- nestedProjectDirs: args.nestedProjectDirs,
1766
+ nestedProjectDirs: args.outputNestedDirs ?? args.nestedProjectDirs,
1421
1767
  outputs,
1422
1768
  wsOutputs,
1423
1769
  })
1424
1770
  } else if (cfgCacheable && !remoteOnly && !deferralRequested) {
1425
1771
  // Ran here and saves nothing — failed, a read-only policy, a tainted
1426
- // upstream, a key that no longer held — yet it wrote what a save would
1427
- // have marked, and may have rewritten an input the re-check before a
1428
- // save catches. A same-run reader keyed from the snapshot's OIDs for
1429
- // 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
1430
1776
  // withheld save already dropped the project; this finds the same move
1431
1777
  // again and drops it again.
1432
1778
  const endCheck = span('miss: recheck inputs')
@@ -1438,7 +1784,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1438
1784
  unsavedBytes = await markUnsaved({
1439
1785
  node,
1440
1786
  workspaceRoot: args.workspaceRoot,
1441
- nestedProjectDirs: args.nestedProjectDirs,
1787
+ nestedProjectDirs: args.outputNestedDirs ?? args.nestedProjectDirs,
1442
1788
  gitFilesCache: args.gitFilesCache,
1443
1789
  outputs,
1444
1790
  wsOutputs,
@@ -1449,11 +1795,13 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1449
1795
  /**
1450
1796
  * What an additive run saves as its own, or undefined when it removed a
1451
1797
  * file it found: no entry replays a removal (`ownOutputsSince`), so the
1452
- * 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`).
1453
1800
  */
1454
1801
  async function ownOutputs(): Promise<
1455
1802
  { ownOutputFiles?: string[]; ownWsOutputFiles?: string[] } | undefined
1456
1803
  > {
1804
+ if (failedTouchedFound) return undefined
1457
1805
  const mine: { ownOutputFiles?: string[]; ownWsOutputFiles?: string[] } = {}
1458
1806
  if (stampedBefore !== undefined) {
1459
1807
  const files = await ownOutputsSince(cleanArgs, stampedBefore)
@@ -1468,13 +1816,36 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1468
1816
  return mine
1469
1817
  }
1470
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
+
1471
1842
  /**
1472
1843
  * A process the shell left behind still held its output when the drain
1473
1844
  * bound cut it, so it may still write outputs: a save now would file a
1474
1845
  * tree the run does not leave, and a hit elsewhere would replay it.
1475
1846
  */
1476
1847
  function nothingLeftRunning(): boolean {
1477
- if (result.leftRunning !== true) return true
1848
+ if (!leftRunning) return true
1478
1849
  warnOf(log)(
1479
1850
  `[vx] ${node.id}: a process it left running may still write its outputs — ` +
1480
1851
  `the result stands, but is not saved; wait for it in the command (\`wait\`)`,
@@ -1495,12 +1866,22 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1495
1866
  const changed = new Set(result.changed.map((f) => path.join(args.workspaceRoot, f)))
1496
1867
  const there = keyedInputs(described!.facts).find((f) => changed.has(f.path))
1497
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
+ }
1498
1879
  }
1499
1880
  return (
1500
1881
  (await movedInput(
1501
1882
  keyedInputs(described!.facts),
1502
1883
  cache,
1503
- described!.describedAt,
1884
+ commandFrom,
1504
1885
  stability.strategy === 'mutable' ? ownRewrite : undefined,
1505
1886
  )) ?? described!.added()
1506
1887
  )
@@ -1525,36 +1906,77 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1525
1906
  // `'unchecked'` skips the check of the command's own window; a key the
1526
1907
  // re-derive before the command already disagreed with still withholds,
1527
1908
  // since it never described the bytes the command read.
1528
- 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
+ }
1529
1920
  const endCheck = span('miss: recheck inputs')
1530
1921
  const moved = await movedSinceKey()
1531
1922
  endCheck()
1532
1923
  if (moved === undefined) return true
1533
- const what =
1534
- (moved === null ? 'its inputs' : `\`${printable(relPosix(args.workspaceRoot, moved))}\``) +
1535
- ' changed after its key was taken' +
1536
- (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`)
1537
1939
  // A task that regenerates its own input (TanStack Router's committed
1538
1940
  // `routeTree.gen.ts`, rewritten to the same bytes by every build) is
1539
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.
1540
1952
  const hint =
1541
- moved === null || rewroteBy !== undefined
1542
- ? ''
1543
- : '; if the task writes it, declare it in cache.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
+ : ''))
1544
1962
  if (stability.severity === 'error') {
1545
1963
  warnOf(log)(
1546
1964
  `[vx] ${node.id}: ${what} — the result is not saved, ` +
1547
- `and inputStability.severity is 'error', so the task fails${hint}`,
1965
+ `and inputs.stability.severity is 'error', so the task fails${hint}`,
1548
1966
  )
1549
1967
  movedFails = moved === null ? '' : printable(relPosix(args.workspaceRoot, moved))
1550
1968
  } else {
1551
1969
  warnOf(log)(
1552
- `[vx] ${node.id}: ${what} — the result stands, ` +
1553
- `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,
1554
1975
  )
1555
1976
  }
1556
1977
  forgetUndeclaredWrites(args, wsOutputs.length > 0 ? 'workspace' : 'project')
1557
1978
  unkeyed = true
1979
+ driftedInput = moved
1558
1980
  return false
1559
1981
  }
1560
1982
 
@@ -1577,17 +1999,24 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1577
1999
  durationMs: spentMs,
1578
2000
  hash,
1579
2001
  ...(attempt > 1 ? { attempts: attempt } : {}),
1580
- ...(failedAttempts.length > 0 ? { failedAttempts } : {}),
2002
+ ...(failedAttempts.length > 0 ? { failedAttempts, attemptOutputs } : {}),
1581
2003
  ...(described !== undefined
1582
2004
  ? { inputFiles: captured.reduce((n, c) => (c.kind === 'file' ? n + 1 : n), 0) }
1583
2005
  : {}),
1584
2006
  ...(inputChanges !== undefined ? { inputChanges } : {}),
1585
2007
  ...(unkeyed ? { unkeyed: true as const } : {}),
2008
+ ...(driftedInput !== undefined ? { driftedInput } : {}),
1586
2009
  ...(keylessBy !== undefined ? { keylessBy } : {}),
1587
2010
  ...(saved ? { saved: true as const } : {}),
1588
2011
  ...(unsavedBytes !== undefined ? { outputBytes: unsavedBytes } : {}),
1589
2012
  ...(effectiveExitCode !== 0 && output !== undefined
1590
- ? { failedOutput: failedOutputLog(output, willSave || args.keepRun !== undefined, storeMask) }
2013
+ ? {
2014
+ failedOutput: failedOutputLog(
2015
+ output,
2016
+ willSave || args.keepRun !== undefined,
2017
+ secrets ?? undefined,
2018
+ ),
2019
+ }
1591
2020
  : {}),
1592
2021
  ...(args.keepRun !== undefined &&
1593
2022
  (effectiveExitCode !== 0 ? args.keepRun.failures : !willSave) &&
@@ -1628,3 +2057,48 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1628
2057
  : {}),
1629
2058
  }
1630
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
+ }