@vzn/vx 0.0.635 → 0.0.637

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 (141) hide show
  1. package/README.md +47 -19
  2. package/docs/caching.md +222 -210
  3. package/docs/cli.md +699 -664
  4. package/docs/execution.md +67 -75
  5. package/docs/features.md +44 -40
  6. package/docs/patterns.md +31 -32
  7. package/docs/schema.md +418 -202
  8. package/docs/security.md +76 -39
  9. package/launcher.sh +43 -0
  10. package/package.json +5 -5
  11. package/schemas/cache.json +41 -19
  12. package/schemas/info.json +23 -11
  13. package/schemas/last.json +48 -2
  14. package/schemas/lock.json +1 -1
  15. package/schemas/plan.json +2 -2
  16. package/schemas/show.json +13 -4
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +16 -12
  19. package/src/bin.ts +17 -2
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +105 -53
  22. package/src/cache/git-inputs.ts +277 -63
  23. package/src/cache/index.ts +7 -1
  24. package/src/cache/inputs.ts +125 -35
  25. package/src/cache/key-fold.ts +8 -7
  26. package/src/cache/layer.ts +8 -17
  27. package/src/cache/layered-cache.ts +16 -102
  28. package/src/cache/policy.ts +25 -29
  29. package/src/cache/run-history.ts +36 -14
  30. package/src/cache/schema.ts +21 -23
  31. package/src/cache/scoped-cache.ts +224 -0
  32. package/src/cli/cache.ts +150 -28
  33. package/src/cli/completions.ts +2 -2
  34. package/src/cli/core-alias.ts +1 -1
  35. package/src/cli/foreign-flags.ts +13 -12
  36. package/src/cli/help.ts +94 -61
  37. package/src/cli/index.ts +42 -13
  38. package/src/cli/info.ts +38 -23
  39. package/src/cli/init.ts +115 -406
  40. package/src/cli/last.ts +145 -4
  41. package/src/cli/lock.ts +333 -111
  42. package/src/cli/plan-format.ts +2 -2
  43. package/src/cli/plugin-commands.ts +5 -9
  44. package/src/cli/plugin-templates.ts +10 -10
  45. package/src/cli/run.ts +123 -144
  46. package/src/cli/select.ts +33 -39
  47. package/src/cli/show.ts +17 -19
  48. package/src/cli/task-verb.ts +2 -9
  49. package/src/cli/upgrade.ts +402 -145
  50. package/src/cli/watch-cycle.ts +9 -2
  51. package/src/cli/watch-filter.ts +33 -60
  52. package/src/cli/watch-fs.ts +29 -16
  53. package/src/cli/watch-judge.ts +12 -3
  54. package/src/cli/watch-set.ts +16 -42
  55. package/src/cli/watch.ts +188 -215
  56. package/src/cli/why.ts +28 -17
  57. package/src/cli/workspace-config.ts +31 -68
  58. package/src/config.ts +191 -71
  59. package/src/exec/env.ts +1 -31
  60. package/src/exec/executor.ts +1 -5
  61. package/src/exec/kill-tree.ts +2 -1
  62. package/src/exec/sandbox-runtime.ts +12 -1
  63. package/src/exec/sandbox-violations.ts +9 -1
  64. package/src/graph/dependency-spec.ts +1 -1
  65. package/src/graph/index.ts +1 -0
  66. package/src/graph/priorities.ts +1 -1
  67. package/src/graph/scheduler.ts +19 -7
  68. package/src/graph/task-graph.ts +44 -20
  69. package/src/handoff.ts +169 -0
  70. package/src/index.ts +25 -57
  71. package/src/orchestrator/affected-tasks.ts +7 -5
  72. package/src/orchestrator/command-windows.ts +44 -0
  73. package/src/orchestrator/deferred-outputs.ts +12 -10
  74. package/src/orchestrator/doctor.ts +98 -110
  75. package/src/orchestrator/download-policy.ts +3 -3
  76. package/src/orchestrator/events.ts +19 -3
  77. package/src/orchestrator/excluded-keys.ts +0 -2
  78. package/src/orchestrator/execute-task.ts +192 -70
  79. package/src/orchestrator/fingerprint-watch.ts +18 -15
  80. package/src/orchestrator/forecast.ts +10 -2
  81. package/src/orchestrator/framed-output.ts +8 -3
  82. package/src/orchestrator/graph-stage.ts +5 -5
  83. package/src/orchestrator/history.ts +70 -30
  84. package/src/orchestrator/hit-restore.ts +15 -3
  85. package/src/orchestrator/index.ts +18 -4
  86. package/src/orchestrator/local-shortcircuit.ts +2 -1
  87. package/src/orchestrator/lockfile-claim.ts +79 -20
  88. package/src/orchestrator/logger.ts +71 -39
  89. package/src/orchestrator/metrics.ts +71 -6
  90. package/src/orchestrator/miss-save.ts +3 -0
  91. package/src/orchestrator/options.ts +43 -28
  92. package/src/orchestrator/output-host.ts +72 -0
  93. package/src/orchestrator/plan.ts +6 -8
  94. package/src/orchestrator/plugin-host.ts +87 -63
  95. package/src/orchestrator/plugin.ts +94 -60
  96. package/src/orchestrator/prepare.ts +114 -73
  97. package/src/orchestrator/projects.ts +102 -99
  98. package/src/orchestrator/remote-prefetch.ts +2 -1
  99. package/src/orchestrator/run-context.ts +25 -6
  100. package/src/orchestrator/run-failures.ts +17 -8
  101. package/src/orchestrator/run-lock.ts +1 -1
  102. package/src/orchestrator/run-report.ts +9 -9
  103. package/src/orchestrator/run-settings.ts +48 -0
  104. package/src/orchestrator/run.ts +127 -88
  105. package/src/orchestrator/sandbox-fix.ts +39 -20
  106. package/src/orchestrator/sandbox-request.ts +84 -13
  107. package/src/orchestrator/signals.ts +1 -1
  108. package/src/orchestrator/stable-keys.ts +13 -11
  109. package/src/orchestrator/status-line.ts +48 -6
  110. package/src/orchestrator/summary.ts +46 -62
  111. package/src/orchestrator/task-hash.ts +12 -83
  112. package/src/orchestrator/telemetry.ts +3 -3
  113. package/src/settings.ts +417 -0
  114. package/src/util/errors.ts +1 -1
  115. package/src/util/essential-env.ts +31 -0
  116. package/src/util/hide-env.ts +107 -0
  117. package/src/util/index.ts +8 -2
  118. package/src/util/paths.ts +10 -0
  119. package/src/util/settle.ts +49 -30
  120. package/src/util/size.ts +23 -4
  121. package/src/util/timing.ts +3 -2
  122. package/src/util/ulid.ts +1 -1
  123. package/src/util/verbs.ts +0 -15
  124. package/src/workspace/affected.ts +77 -222
  125. package/src/workspace/config-eval.ts +15 -20
  126. package/src/workspace/config-imports.ts +78 -3
  127. package/src/workspace/config-schema.ts +251 -241
  128. package/src/workspace/dependency-rules.ts +57 -0
  129. package/src/workspace/filter.ts +2 -2
  130. package/src/workspace/fingerprint.ts +11 -121
  131. package/src/workspace/index.ts +22 -27
  132. package/src/workspace/load-reads.ts +4 -4
  133. package/src/workspace/lockfile.ts +96 -140
  134. package/src/workspace/migration.ts +242 -146
  135. package/src/workspace/package-graph.ts +30 -216
  136. package/src/workspace/project-loader.ts +80 -285
  137. package/src/workspace/repo-id.ts +2 -1
  138. package/src/workspace/workspace.ts +362 -831
  139. package/src/cache/config-evals.ts +0 -130
  140. package/src/workspace/config-cache.ts +0 -661
  141. package/src/workspace/migrate-scripts.ts +0 -1121
@@ -1,11 +1,13 @@
1
1
  import path from 'node:path'
2
- import type { ExecConfig, TaskConfig, CacheConfig } from '../config.js'
2
+ import type { ExecConfig, TaskConfig, CacheConfig, InputStability } from '../config.js'
3
3
  import {
4
4
  ArtifactVanishedError,
5
5
  type CacheEntry,
6
6
  type CacheLayer,
7
7
  type CachePolicy,
8
+ anyUntouched,
8
9
  cleanOutputs,
10
+ committedFiles,
9
11
  cleanWorkspaceOutputs,
10
12
  type OutputStamp,
11
13
  ownOutputsSince,
@@ -14,6 +16,7 @@ import {
14
16
  stampWorkspaceOutputs,
15
17
  FULL_CACHE_POLICY,
16
18
  type GitFilesCache,
19
+ racyWindowMs,
17
20
  } from '../cache/index.js'
18
21
  import {
19
22
  buildIsolatedEnv,
@@ -51,7 +54,9 @@ import { isGroupTask, RestoreDemoted, type TaskNode, type TaskOutcome } from '..
51
54
  import {
52
55
  killGraceMs,
53
56
  MASKED,
57
+ parseDuration,
54
58
  maskedEmitter,
59
+ outputMatcher,
55
60
  printable,
56
61
  relPosix,
57
62
  secretMask,
@@ -61,12 +66,15 @@ import {
61
66
  } from '../util/index.js'
62
67
  import { forwardedSignal, SIGNAL_SHUTDOWN_GRACE_MS, terminateChildren } from './signals.js'
63
68
  import { executorLabel, nameExecutorFailure } from './plugin-host.js'
69
+ import type { CommandWindows } from './command-windows.js'
64
70
  import {
71
+ commandWriteReach,
65
72
  mayWriteFingerprint,
66
73
  type Placeholder,
67
74
  placeholderSweeper,
68
75
  reachedWithheld,
69
76
  sandboxRequestFor,
77
+ sandboxReadable,
70
78
  sweepPlaceholders,
71
79
  undeclaredWriteReach,
72
80
  untouchedPlaceholderLine,
@@ -88,6 +96,7 @@ import {
88
96
  computeTaskHash,
89
97
  describeTaskInputs,
90
98
  type HashCache,
99
+ type InputFact,
91
100
  movedInput,
92
101
  type TaskInputComponent,
93
102
  type UpfrontGroupKey,
@@ -99,7 +108,6 @@ export interface ExecuteArgs {
99
108
  node: TaskNode
100
109
  upstream: TaskOutcome[]
101
110
  workspaceRoot: string
102
- workspaceFingerprint: string
103
111
  cache: CacheLayer
104
112
  /** Granular cache read/write policy. Undefined → everything on. */
105
113
  cachePolicy?: CachePolicy
@@ -121,7 +129,7 @@ export interface ExecuteArgs {
121
129
  */
122
130
  remoteOnly?: boolean
123
131
  /**
124
- * Plan-time `--download` decision for THIS task (run.ts). `'deferred'`
132
+ * Plan-time `--cache-download` decision for THIS task (run.ts). `'deferred'`
125
133
  * means: do not clean, do not save — the executor is expected to leave
126
134
  * the outputs in the remote store and hand back a closure. An executor
127
135
  * that ignores it and materialises anyway simply gets no local entry;
@@ -163,21 +171,29 @@ export interface ExecuteArgs {
163
171
  /** Name what a missed key changed (`createMissExplainer`); absent when nobody listens. */
164
172
  explainMiss?: MissExplainer
165
173
  /**
166
- * Run-level retry default (`--retry <n>` / `RunOptions.retries`).
174
+ * Run-level retry default (`--retries <n>` / `RunOptions.retries`).
167
175
  * Explicit `exec.retries` wins, including an explicit 0. Threaded as
168
176
  * an option only — never folded into any hash, so cache keys are
169
177
  * byte-identical with and without it.
170
178
  */
171
179
  retries?: number
172
180
  /**
173
- * Run-level default task timeout (ms) — the already-resolved
174
- * `VX_TASK_TIMEOUT`/workspace/`--timeout` fallback. Per-task
175
- * `exec.timeout` wins. Threaded as an option only — never hashed.
181
+ * `run.timeout` (ms), already resolved from flag, variable and
182
+ * workspace. Per-task `exec.timeout` wins. Never hashed.
176
183
  */
177
184
  timeout?: number
185
+ /** `run.readyNotice` (ms), resolved like `timeout`; unset, 10 s. */
186
+ readyNotice?: number
187
+ /**
188
+ * The run's `inputStability` layers around the task's own: `run` from
189
+ * the env vars (above the task), `workspace` from `.vx/workspace` (below).
190
+ */
191
+ inputStability?: { run: InputStability; workspace?: InputStability }
192
+ /** `cache.outputs.clean` for tasks without their own: its variable, else `.vx/workspace`. */
193
+ cleanOutputs?: boolean
178
194
  /** Per-run memo for `git ls-files` (one entry per project dir). */
179
195
  gitFilesCache?: GitFilesCache
180
- /** Per-run memo for derived hashes (package.json bytes + task config). */
196
+ /** Per-run memo for derived hashes (task config, file lists). */
181
197
  hashCache?: HashCache
182
198
  /** The run's `probesAfterWrites`, handed to every key this task takes. */
183
199
  probesAfterWrites?: ReadonlySet<string>
@@ -242,8 +258,8 @@ export interface ExecuteArgs {
242
258
  */
243
259
  taintedUpstream?: boolean
244
260
  /**
245
- * Whether a task has rewritten a file the workspace fingerprint folded
246
- * since the run read it (fingerprint-watch.ts): past that, no key taken
261
+ * Whether a task has rewritten a root file a plugin claims (whose bytes
262
+ * the claimant folded) since the run read it (fingerprint-watch.ts): past that, no key taken
247
263
  * on the old digest is probed or saved.
248
264
  */
249
265
  fingerprintWatch?: FingerprintWatch
@@ -255,6 +271,8 @@ export interface ExecuteArgs {
255
271
  noDependants?: true
256
272
  /** The task holds vx's terminal (`terminalHolders`): its stdio is vx's own. */
257
273
  terminal?: true
274
+ /** When each command of the run ran and where it may write (command-windows.ts). */
275
+ commandWindows?: CommandWindows
258
276
  }
259
277
 
260
278
  /**
@@ -338,20 +356,31 @@ async function restoreOrMiss(
338
356
  }
339
357
 
340
358
  /**
341
- * Does a write to an input during the command count as unchanged when it
342
- * left the bytes the key folded (`movedInput`'s `writesByBytes`)? Only when
343
- * the task says `compare`, or says nothing and `VX_REWRITTEN_INPUTS` is
344
- * `compare`: by default any such write withholds the save, on CI too,
345
- * since a concurrent task can change an input and put it back (owner,
346
- * 2026-10-12).
359
+ * What a write to an input during the command means, each field highest
360
+ * first: the env var (`layers.run`), the task's `cache.inputStability`,
361
+ * the workspace's, then `'immutable'` and `'warn'` (owner, 2026-10-12), so
362
+ * by default any such write withholds the save, on CI too, since a
363
+ * concurrent task can change an input and put it back.
347
364
  */
348
- function rewritesByBytes(cache: CacheConfig | undefined): boolean {
349
- return (cache?.rewrittenInputs ?? process.env['VX_REWRITTEN_INPUTS']) === 'compare'
365
+ function inputStabilityOf(
366
+ cache: CacheConfig | undefined,
367
+ layers: ExecuteArgs['inputStability'],
368
+ ): Required<InputStability> {
369
+ const task = cache?.inputStability
370
+ return {
371
+ strategy: layers?.run.strategy ?? task?.strategy ?? layers?.workspace?.strategy ?? 'immutable',
372
+ severity: layers?.run.severity ?? task?.severity ?? layers?.workspace?.severity ?? 'warn',
373
+ }
374
+ }
375
+
376
+ /** The root files the plugins claim, as the run's watch holds them. */
377
+ function claimedFiles(args: ExecuteArgs): readonly string[] {
378
+ return args.fingerprintWatch?.names ?? []
350
379
  }
351
380
 
352
381
  /**
353
- * The workspace fingerprint every key here folded has moved (a task
354
- * rewrote the lockfile), said once per run.
382
+ * A claimed file every key here folded through its claimant has moved (a
383
+ * task rewrote the lockfile), said once per run.
355
384
  */
356
385
  function fingerprintMoved(args: ExecuteArgs): boolean {
357
386
  if (args.fingerprintWatch?.moved() === undefined) return false
@@ -379,7 +408,6 @@ async function executeGroupTask(args: ExecuteArgs): Promise<TaskOutcome> {
379
408
  node: args.node,
380
409
  upstream: args.upstream,
381
410
  workspaceRoot: args.workspaceRoot,
382
- workspaceFingerprint: args.workspaceFingerprint,
383
411
  cache: args.cache,
384
412
  forwardArgs: args.forwardArgs,
385
413
  nestedProjectDirs: args.nestedProjectDirs,
@@ -475,7 +503,6 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
475
503
  node,
476
504
  upstream: args.upstream,
477
505
  workspaceRoot: args.workspaceRoot,
478
- workspaceFingerprint: args.workspaceFingerprint,
479
506
  cache: args.cache,
480
507
  forwardArgs: args.forwardArgs,
481
508
  nestedProjectDirs: args.nestedProjectDirs,
@@ -537,6 +564,8 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
537
564
  const serverSecrets = secretMask([process.env, env, step.env?.define], step.env?.secret)
538
565
  const serverOut = serverSecrets && maskedEmitter(serverSecrets, (t) => log.taskStdout(node, t))
539
566
  const serverErr = serverSecrets && maskedEmitter(serverSecrets, (t) => log.taskStderr(node, t))
567
+ // A server writes until the run stops it: its window stays open.
568
+ args.commandWindows?.open(node.id, node.projectDir, commandWriteReach(node, args.workspaceRoot))
540
569
  const persistentOpts: Parameters<typeof runPersistent>[0] = {
541
570
  command,
542
571
  cwd: node.projectDir,
@@ -555,7 +584,7 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
555
584
  }
556
585
  // For a persistent task the timeout bounds the readiness wait. Per-task
557
586
  // `exec.timeout` wins; else the run-level default (env/workspace/--timeout).
558
- const effectiveTimeout = step.timeout ?? args.timeout
587
+ const effectiveTimeout = taskTimeoutMs(step) ?? args.timeout
559
588
  if (effectiveTimeout !== undefined) {
560
589
  persistentOpts.timeoutMs = effectiveTimeout
561
590
  }
@@ -606,7 +635,8 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
606
635
  // it fails, and with no `exec.timeout` the wait never ends, so a run
607
636
  // whose `readyWhen` never matched showed nothing at all.
608
637
  const readyWhen = step.persistent.readyWhen
609
- const noticeMs = readyNoticeMs()
638
+ // How long a server may take to match `readyWhen` before vx says it waits.
639
+ const noticeMs = args.readyNotice ?? 10_000
610
640
  const notice =
611
641
  readyWhen === undefined
612
642
  ? undefined
@@ -649,7 +679,7 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
649
679
  const message = err instanceof Error ? err.message : String(err)
650
680
  // The task's OWN stream, not the process's: the frame is where a
651
681
  // reader looks for why a task failed, and a run with a custom logger
652
- // (an embedder, the MCP server) never saw a bare stderr write at all.
682
+ // (an embedder) never saw a bare stderr write at all.
653
683
  log.taskStderr(node, `\n[vx] ${node.id}: persistent task failed to become ready: ${message}\n`)
654
684
  // A server is never traced, so the sandbox's refusals reach no report:
655
685
  // a dev server that died on a file outside its grants read only as the
@@ -713,7 +743,9 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
713
743
  }
714
744
  args.persistentRegistry?.set(node.id, spawn.child)
715
745
  forgetUndeclaredWrites(args, undeclaredWriteReach(node, args.workspaceRoot))
716
- if (mayWriteFingerprint(node, args.workspaceRoot)) args.fingerprintWatch?.wrote()
746
+ if (mayWriteFingerprint(node, args.workspaceRoot, claimedFiles(args))) {
747
+ args.fingerprintWatch?.wrote()
748
+ }
717
749
  return {
718
750
  node,
719
751
  status: 'success',
@@ -784,10 +816,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
784
816
  const outputs = cacheCfg?.outputs.files ?? []
785
817
  const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
786
818
  const effectiveForwardArgs = node.requested ? (args.forwardArgs ?? []) : []
787
- // Timeout precedence: per-task `exec.timeout` → run-level default
788
- // (`--timeout`/`RunOptions.timeout` → `VX_TASK_TIMEOUT` → workspace
789
- // `timeout`, already collapsed into `args.timeout` by run.ts).
790
- const effectiveTimeout = step.timeout ?? args.timeout
819
+ // Per-task `exec.timeout` beats `run.timeout` (flag, variable,
820
+ // workspace; resolved into `args.timeout` by run.ts).
821
+ const effectiveTimeout = taskTimeoutMs(step) ?? args.timeout
791
822
  // `signal` stops a plugin executor on the run's stop AND on
792
823
  // `exec.timeout`: core cannot kill a process an executor spawned, so a
793
824
  // declared timeout meant nothing on one that kept its own clock (H-12).
@@ -899,7 +930,6 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
899
930
  node,
900
931
  upstream,
901
932
  workspaceRoot: args.workspaceRoot,
902
- workspaceFingerprint: args.workspaceFingerprint,
903
933
  cache,
904
934
  forwardArgs: args.forwardArgs,
905
935
  nestedProjectDirs: args.nestedProjectDirs,
@@ -930,8 +960,13 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
930
960
  projectDir: node.projectDir,
931
961
  outputs,
932
962
  nestedProjectDirs: args.nestedProjectDirs,
963
+ committed: committedFiles(args.gitFilesCache, node.projectDir),
964
+ }
965
+ const wsCleanArgs = {
966
+ workspaceRoot: args.workspaceRoot,
967
+ outputs: wsOutputs,
968
+ committed: committedFiles(args.gitFilesCache, args.workspaceRoot),
933
969
  }
934
- const wsCleanArgs = { workspaceRoot: args.workspaceRoot, outputs: wsOutputs }
935
970
  // An ADDITIVE task (its outputs overlap an upstream's, with the edge that
936
971
  // orders them; item 588) does not clean by glob — the glob selects the
937
972
  // upstream's files it adds beside. Its outputs are stamped once before
@@ -941,6 +976,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
941
976
  const additive = (node.addsToOutputsOf?.length ?? 0) > 0
942
977
  let stampedBefore: ReadonlyMap<string, OutputStamp> | undefined
943
978
  let wsStampedBefore: ReadonlyMap<string, OutputStamp> | undefined
979
+ // `cache.outputs.clean: false` leaves the outputs to the first attempt's
980
+ // command, stamped, so a save can tell what it found and never wrote.
981
+ // An additive task never cleans by glob anyway.
982
+ const keepOutputs = !additive && (cacheCfg?.outputs.clean ?? args.cleanOutputs ?? true) === false
983
+ let keptBefore: ReadonlyMap<string, OutputStamp> | undefined
984
+ let wsKeptBefore: ReadonlyMap<string, OutputStamp> | undefined
944
985
 
945
986
  // Cache lookup. On hit, time the user-perceived restore op
946
987
  // (clean+restore+log-replay) — that's what the framed-block footer
@@ -979,7 +1020,6 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
979
1020
  node,
980
1021
  upstream,
981
1022
  workspaceRoot: args.workspaceRoot,
982
- workspaceFingerprint: args.workspaceFingerprint,
983
1023
  cache,
984
1024
  forwardArgs: args.forwardArgs,
985
1025
  nestedProjectDirs: args.nestedProjectDirs,
@@ -991,6 +1031,41 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
991
1031
  : {}),
992
1032
  })
993
1033
  : undefined
1034
+ /** The other command that may have put a moved input back (`ownRewrite`). */
1035
+ let rewroteBy: string | undefined
1036
+ /**
1037
+ * Under `'mutable'`, a write during the command that left the key's bytes
1038
+ * is the task's own identical rewrite only if no other command that may
1039
+ * write the file ran between the describe and that write: one that did
1040
+ * may have changed it and put it back while this command read it.
1041
+ */
1042
+ const ownRewrite = (file: string, ctimeMs: number): boolean => {
1043
+ rewroteBy = args.commandWindows?.writerDuring(
1044
+ node.id,
1045
+ file,
1046
+ described!.describedAt,
1047
+ ctimeMs + racyWindowMs(ctimeMs, 0),
1048
+ )
1049
+ return rewroteBy === undefined
1050
+ }
1051
+
1052
+ /**
1053
+ * A committed file under the task's own outputs is keyed (`committedFiles`)
1054
+ * and the task may rewrite it (TanStack Router's `routeTree.gen.ts`): the
1055
+ * bytes it leaves are an output the entry saves and a restore writes, and
1056
+ * they follow from the key, so their change is no moved input.
1057
+ */
1058
+ const ownOutput = outputs.length === 0 ? undefined : outputMatcher(outputs)
1059
+ const ownWsOutput = wsOutputs.length === 0 ? undefined : outputMatcher(wsOutputs)
1060
+ const keyedInputs = (facts: readonly InputFact[]): readonly InputFact[] =>
1061
+ ownOutput === undefined && ownWsOutput === undefined
1062
+ ? facts
1063
+ : facts.filter(
1064
+ (f) =>
1065
+ ownOutput?.(relPosix(node.projectDir, f.path)) !== true &&
1066
+ ownWsOutput?.(relPosix(args.workspaceRoot, f.path)) !== true,
1067
+ )
1068
+
994
1069
  // A name only `exec.env.secret` makes secret is not one the name rule
995
1070
  // sees later: its row carries the mark, so `vx why` hides its hash (M-63).
996
1071
  const named = node.config.exec?.env?.secret
@@ -1050,7 +1125,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1050
1125
  let withheld: WithheldLink[] = []
1051
1126
 
1052
1127
  // Cache miss path (or caching disabled), up to `1 + retries` attempts.
1053
- // Explicit config wins over the run-level `--retry` default, including
1128
+ // Explicit config wins over the run-level `--retries` default, including
1054
1129
  // an explicit `retries: 0`.
1055
1130
  const maxAttempts = 1 + (step.retries ?? args.retries ?? 0)
1056
1131
  let attempt = 0
@@ -1081,7 +1156,16 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1081
1156
  const endStamp = span('miss: stamp outputs')
1082
1157
  stampedBefore ??= await stampOutputs(cleanArgs)
1083
1158
  endStamp()
1159
+ } else if (
1160
+ keepOutputs &&
1161
+ attempt === 1 &&
1162
+ willWrite &&
1163
+ !deferralRequested &&
1164
+ outputs.length > 0
1165
+ ) {
1166
+ keptBefore = await stampOutputs(cleanArgs)
1084
1167
  } else if (willWrite && !deferralRequested && outputs.length > 0) {
1168
+ keptBefore = undefined
1085
1169
  // Mark the wiped paths, exactly as the workspace twin below does. The
1086
1170
  // git snapshot still lists them with their committed index OIDs, and
1087
1171
  // resolveFiles SKIPS its existence probe for any path carrying a
@@ -1091,7 +1175,6 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1091
1175
  const endClean = span('miss: clean outputs')
1092
1176
  const cleanedRels = await cleanOutputs(cleanArgs)
1093
1177
  endClean()
1094
- args.gitFilesCache?.noteClean(node.id, node.projectDir, cleanedRels)
1095
1178
  args.gitFilesCache?.markOutputsChanged(node.projectDir, cleanedRels)
1096
1179
  }
1097
1180
  if (additive && willWrite && !deferralRequested && wsOutputs.length > 0) {
@@ -1099,7 +1182,16 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1099
1182
  // glob clean deleted the upstream's files before this task, which
1100
1183
  // reads them, ran (A-43).
1101
1184
  wsStampedBefore ??= await stampWorkspaceOutputs(wsCleanArgs)
1185
+ } else if (
1186
+ keepOutputs &&
1187
+ attempt === 1 &&
1188
+ willWrite &&
1189
+ !deferralRequested &&
1190
+ wsOutputs.length > 0
1191
+ ) {
1192
+ wsKeptBefore = await stampWorkspaceOutputs(wsCleanArgs)
1102
1193
  } else if (willWrite && !deferralRequested && wsOutputs.length > 0) {
1194
+ wsKeptBefore = undefined
1103
1195
  // Root-anchored deletions can land in other projects' dirs; mark
1104
1196
  // them so stale per-project git snapshots can't survive the wipe.
1105
1197
  const cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
@@ -1128,6 +1220,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1128
1220
  // the throw is its rejection, the value its resolution, so both reach
1129
1221
  // the naming catch and the cleanup in finally below.
1130
1222
  let running: Promise<unknown>
1223
+ const closeWindow =
1224
+ args.commandWindows?.open(
1225
+ node.id,
1226
+ node.projectDir,
1227
+ commandWriteReach(node, args.workspaceRoot),
1228
+ ) ?? (() => {})
1131
1229
  try {
1132
1230
  running = Promise.resolve(args.executor.execute(req))
1133
1231
  } catch (thrown) {
@@ -1166,6 +1264,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1166
1264
  throw err
1167
1265
  })
1168
1266
  .finally(() => {
1267
+ closeWindow()
1169
1268
  clearTimeout(timeoutTimer)
1170
1269
  unlistenStop?.()
1171
1270
  unlistenStop = undefined
@@ -1256,18 +1355,6 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1256
1355
  // The line quotes the command's first word, which a config may have
1257
1356
  // built from a secret: masked as the task's own output is (L-37).
1258
1357
  if (verdict !== undefined) log.taskStderr(node, `\n${secrets?.mask(verdict) ?? verdict}\n`)
1259
- // A committed file another task's clean removed, still gone: vx
1260
- // cleans outputs before a run where Turbo does not, so a reader with
1261
- // no edge to the producer fails naming only the file (A-48).
1262
- if (code !== 0) {
1263
- for (const c of (args.gitFilesCache?.trackedCleansMissing(node.id) ?? []).slice(0, 3)) {
1264
- const rel = relPosix(args.workspaceRoot, c.path)
1265
- log.taskStderr(
1266
- node,
1267
- `\n[vx] ${rel} is tracked by git, and ${c.by} removed it as an output before its run; it is still missing. A task that reads it needs dependsOn on ${c.by}.\n`,
1268
- )
1269
- }
1270
- }
1271
1358
  }
1272
1359
  return { result: res, exitCode: code }
1273
1360
  }
@@ -1316,7 +1403,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1316
1403
  const writeReach =
1317
1404
  args.executor.remote === true ? 'none' : undeclaredWriteReach(node, args.workspaceRoot)
1318
1405
  const writesFingerprint =
1319
- args.executor.remote !== true && mayWriteFingerprint(node, args.workspaceRoot)
1406
+ args.executor.remote !== true &&
1407
+ mayWriteFingerprint(node, args.workspaceRoot, claimedFiles(args))
1320
1408
  // A stop that landed during the awaits above (the key, the probe, the
1321
1409
  // input description): the first attempt would wipe the last build and
1322
1410
  // hand the executor a request after the run had stopped. Through a call,
@@ -1462,6 +1550,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1462
1550
 
1463
1551
  const wallclockEndNs = process.hrtime.bigint() - args.runStartHrTimeNs
1464
1552
 
1553
+ const stability = inputStabilityOf(args.node.config.cache, args.inputStability)
1554
+ let movedFails: string | undefined
1465
1555
  let own: Awaited<ReturnType<typeof ownOutputs>>
1466
1556
  if (effectiveExitCode === 0 && willSave && deferralRequested) {
1467
1557
  // The outputs never landed here: no artifact, no rows. A partial local
@@ -1490,6 +1580,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1490
1580
  } else if (
1491
1581
  effectiveExitCode === 0 &&
1492
1582
  willSave &&
1583
+ !leftBehind() &&
1493
1584
  (await keyStillTrue()) &&
1494
1585
  (own = await ownOutputs()) !== undefined
1495
1586
  ) {
@@ -1514,6 +1605,16 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1514
1605
  deferSave: args.deferSave,
1515
1606
  // A sink listens: say what the save cost.
1516
1607
  measure: args.explainMiss !== undefined,
1608
+ ...(userSandbox
1609
+ ? {
1610
+ readable: sandboxReadable(
1611
+ node,
1612
+ cfg.exec!.sandbox!,
1613
+ args.workspaceRoot,
1614
+ args.nestedProjectDirs,
1615
+ ),
1616
+ }
1617
+ : {}),
1517
1618
  })
1518
1619
  args.deferredSaves?.set(node.id, landed)
1519
1620
  } else if (cfgCacheable && !remoteOnly && !deferralRequested && args.noDependants !== true) {
@@ -1541,6 +1642,18 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1541
1642
  }
1542
1643
  }
1543
1644
 
1645
+ /**
1646
+ * A file the kept outputs held that the run left as it was: saving would
1647
+ * file a leftover from an older build as this run's output, so nothing is
1648
+ * saved (`cache.outputs.clean: false`).
1649
+ */
1650
+ function leftBehind(): boolean {
1651
+ return (
1652
+ (keptBefore !== undefined && anyUntouched(keptBefore)) ||
1653
+ (wsKeptBefore !== undefined && anyUntouched(wsKeptBefore))
1654
+ )
1655
+ }
1656
+
1544
1657
  /**
1545
1658
  * What an additive run saves as its own, or undefined when it removed a
1546
1659
  * file it found: no entry replays a removal (`ownOutputsSince`), so the
@@ -1573,10 +1686,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1573
1686
  if (described!.hash !== hash) return null
1574
1687
  return (
1575
1688
  (await movedInput(
1576
- described!.facts,
1689
+ keyedInputs(described!.facts),
1577
1690
  cache,
1578
1691
  described!.describedAt,
1579
- rewritesByBytes(args.node.config.cache),
1692
+ stability.strategy === 'mutable' ? ownRewrite : undefined,
1580
1693
  )) ?? described!.added()
1581
1694
  )
1582
1695
  }
@@ -1597,29 +1710,44 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1597
1710
  unkeyed = true
1598
1711
  return false
1599
1712
  }
1713
+ // `'unchecked'` skips the check of the command's own window; a key the
1714
+ // re-derive before the command already disagreed with still withholds,
1715
+ // since it never described the bytes the command read.
1716
+ if (stability.strategy === 'unchecked' && described!.hash === hash) return true
1600
1717
  const endCheck = span('miss: recheck inputs')
1601
1718
  const moved = await movedSinceKey()
1602
1719
  endCheck()
1603
1720
  if (moved === undefined) return true
1604
1721
  const what =
1605
- moved === null ? 'its inputs' : `\`${printable(relPosix(args.workspaceRoot, moved))}\``
1722
+ (moved === null ? 'its inputs' : `\`${printable(relPosix(args.workspaceRoot, moved))}\``) +
1723
+ ' changed after its key was taken' +
1724
+ (rewroteBy === undefined ? '' : ` while ${rewroteBy} ran`)
1606
1725
  // A task that regenerates its own input (TanStack Router's committed
1607
1726
  // `routeTree.gen.ts`, rewritten to the same bytes by every build) is
1608
1727
  // never saved (item 1015), and the line said so without the way out.
1609
- // Not for `package.json`, which an output may not cover.
1610
1728
  const hint =
1611
- moved === null || path.basename(moved) === 'package.json'
1729
+ moved === null || rewroteBy !== undefined
1612
1730
  ? ''
1613
1731
  : '; if the task writes it, declare it in cache.outputs'
1614
- log.status(
1615
- `[vx] ${node.id}: ${what} changed after its key was taken — the result stands, ` +
1616
- `but is not saved under a key that no longer describes it${hint}`,
1617
- )
1732
+ if (stability.severity === 'error') {
1733
+ log.status(
1734
+ `[vx] ${node.id}: ${what} — the result is not saved, ` +
1735
+ `and inputStability.severity is 'error', so the task fails${hint}`,
1736
+ )
1737
+ movedFails = moved === null ? '' : printable(relPosix(args.workspaceRoot, moved))
1738
+ } else {
1739
+ log.status(
1740
+ `[vx] ${node.id}: ${what} — the result stands, ` +
1741
+ `but is not saved under a key that no longer describes it${hint}`,
1742
+ )
1743
+ }
1618
1744
  forgetUndeclaredWrites(args, wsOutputs.length > 0 ? 'workspace' : 'project')
1619
1745
  unkeyed = true
1620
1746
  return false
1621
1747
  }
1622
1748
 
1749
+ if (movedFails !== undefined) effectiveExitCode = 1
1750
+
1623
1751
  if (unkeyedUpstream !== undefined && willWrite && effectiveExitCode === 0) {
1624
1752
  log.status(
1625
1753
  `[vx] ${node.id}: ran over ${unkeyedUpstream.node.id}'s outputs, which its key no longer ` +
@@ -1647,6 +1775,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1647
1775
  ? { failedOutput: failedOutputLog(output, willSave) }
1648
1776
  : {}),
1649
1777
  ...(result.timedOut === true && effectiveExitCode !== 0 ? { timedOut: true as const } : {}),
1778
+ ...(movedFails !== undefined ? { inputMoved: movedFails } : {}),
1650
1779
  ...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
1651
1780
  ...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
1652
1781
  ...(result.where !== undefined ? { where: result.where } : {}),
@@ -1677,7 +1806,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1677
1806
  /**
1678
1807
  * Drop the run's facts about files a task may have written without
1679
1808
  * declaring them (`undeclaredWriteReach`): the project's git snapshot and
1680
- * its index OIDs, and its `package.json` digest. The next reader
1809
+ * its index OIDs. The next reader
1681
1810
  * re-enumerates the project (one `git ls-files`) and hashes its files by
1682
1811
  * content; the per-declaration file-list memos are keyed on the snapshot
1683
1812
  * array, so they miss with it (item 743).
@@ -1686,12 +1815,10 @@ function forgetUndeclaredWrites(args: ExecuteArgs, reach: 'none' | 'project' | '
1686
1815
  if (reach === 'none') return
1687
1816
  if (reach === 'workspace') {
1688
1817
  args.gitFilesCache?.clear()
1689
- args.hashCache?.packageJson.clear()
1690
1818
  return
1691
1819
  }
1692
1820
  args.gitFilesCache?.delete(args.node.projectDir)
1693
1821
  args.gitFilesCache?.invalidateWorkspacePartition()
1694
- args.hashCache?.packageJson.delete(args.node.projectDir)
1695
1822
  }
1696
1823
 
1697
1824
  /** The two bin directories a task's PATH starts with: its own, then the root's (once when they coincide). */
@@ -1738,16 +1865,11 @@ function taskEnv(node: TaskNode, step: ExecConfig, workspaceRoot: string): NodeJ
1738
1865
  return env
1739
1866
  }
1740
1867
 
1741
- function isAborted(signal: AbortSignal | undefined): boolean {
1742
- return signal?.aborted === true
1868
+ /** A task's own `exec.timeout` in ms; the schema has checked its form. */
1869
+ function taskTimeoutMs(step: ExecConfig): number | undefined {
1870
+ return step.timeout === undefined ? undefined : parseDuration(step.timeout)!
1743
1871
  }
1744
1872
 
1745
- /**
1746
- * How long a server may take to match `readyWhen` before vx says it waits.
1747
- * `VX_READY_NOTICE_MS` overrides it, as `VX_KILL_GRACE_MS` does the grace.
1748
- */
1749
- function readyNoticeMs(): number {
1750
- const raw = process.env['VX_READY_NOTICE_MS']
1751
- if (raw !== undefined && /^[0-9]+$/.test(raw) && Number(raw) > 0) return Number(raw)
1752
- return 10_000
1873
+ function isAborted(signal: AbortSignal | undefined): boolean {
1874
+ return signal?.aborted === true
1753
1875
  }
@@ -1,18 +1,17 @@
1
- // The workspace fingerprint is read once per run, before any task, and
2
- // every key folds it (and a lockfile plugin's per-project parts, taken from
3
- // the same files). A task in the root project may rewrite one of those
4
- // files — `pnpm install` without `--frozen-lockfile` — and every key taken
1
+ // The files the plugins claim (`VxPlugin.fingerprint`) are read once per
2
+ // run, before any task, and every key folds what their claimant takes
3
+ // from them (a lockfile plugin's per-project parts). A task in the root
4
+ // project may rewrite one of those files — `pnpm install` without `--frozen-lockfile` — and every key taken
5
5
  // before it then names a lockfile the tree no longer holds: a save under
6
6
  // one filed bytes built against the new install under the old lockfile's
7
7
  // key, replayed whenever the tree went back (item 750). The watch answers
8
- // "has a fingerprinted file moved since the run read it", re-learning the
8
+ // "has a claimed file moved since the run read it", re-learning the
9
9
  // fact only after a task that may have written one ran (`wrote`), so a run
10
10
  // with no such task never touches the files again.
11
11
 
12
12
  import { readFileSync, statSync } from 'node:fs'
13
13
  import path from 'node:path'
14
14
  import { FILE_HASH_RACY_MS, racyWindowMs } from '../cache/index.js'
15
- import { WORKSPACE_FINGERPRINT_FILES, type WorkspaceFingerprints } from '../workspace/index.js'
16
15
  import type { Logger } from './logger.js'
17
16
 
18
17
  export class FingerprintWatch {
@@ -20,22 +19,26 @@ export class FingerprintWatch {
20
19
  private movedFiles: string[] | undefined
21
20
  private said = false
22
21
 
23
- /** `at`: when the run started reading the files (ms since the epoch). */
22
+ /**
23
+ * `names`: the claimed files; `read`: the bytes of those that existed;
24
+ * `at`: when the run started reading them (ms since the epoch).
25
+ */
24
26
  constructor(
25
27
  private readonly workspaceRoot: string,
26
- private readonly read: WorkspaceFingerprints,
28
+ readonly names: readonly string[],
29
+ private readonly read: ReadonlyMap<string, Uint8Array>,
27
30
  private readonly at: number,
28
31
  ) {}
29
32
 
30
- /** A task that may rewrite a fingerprinted file (`mayWriteFingerprint`) ran a command. */
33
+ /** A task that may rewrite a claimed file (`mayWriteFingerprint`) ran a command. */
31
34
  wrote(): void {
32
35
  this.pending = true
33
36
  }
34
37
 
35
38
  /**
36
- * The fingerprinted files that no longer hold what the run read, or
39
+ * The claimed files that no longer hold what the run read, or
37
40
  * undefined. Once one has, the run stays moved: every key already taken
38
- * folded the old bytes. One `stat` per table entry after each writer; a
41
+ * folded the old bytes. One `stat` per claimed file after each writer; a
39
42
  * file written since the read (by ctime, with git's racy window) is read
40
43
  * and compared. The stat follows a link as both reads do: a lockfile
41
44
  * that is a symlink, rewritten through it, moves its target's ctime and
@@ -46,9 +49,9 @@ export class FingerprintWatch {
46
49
  if (this.movedFiles !== undefined || !this.pending) return this.movedFiles
47
50
  this.pending = false
48
51
  const moved: string[] = []
49
- for (const f of WORKSPACE_FINGERPRINT_FILES) {
52
+ for (const f of this.names) {
50
53
  const abs = path.join(this.workspaceRoot, f)
51
- const before = this.read.files.get(f)
54
+ const before = this.read.get(f)
52
55
  let ctimeMs: number
53
56
  try {
54
57
  ctimeMs = statSync(abs).ctimeMs
@@ -81,8 +84,8 @@ export class FingerprintWatch {
81
84
  if (this.said || this.movedFiles === undefined) return
82
85
  this.said = true
83
86
  log.status(
84
- `[vx] a task rewrote \`${this.movedFiles.join('`, `')}\` during the run, which every key ` +
85
- `folds (the workspace fingerprint): nothing keyed before it is restored or saved from here on`,
87
+ `[vx] a task rewrote \`${this.movedFiles.join('`, `')}\` during the run, which a plugin ` +
88
+ `folds into every key: nothing keyed before it is restored or saved from here on`,
86
89
  )
87
90
  }
88
91
  }