@vzn/vx 0.0.636 → 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 +39 -11
  2. package/docs/caching.md +213 -207
  3. package/docs/cli.md +699 -655
  4. package/docs/execution.md +67 -75
  5. package/docs/features.md +44 -40
  6. package/docs/patterns.md +26 -25
  7. package/docs/schema.md +376 -198
  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 +5 -3
  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 -63
  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 +103 -149
  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 +169 -70
  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 +24 -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 +158 -63
  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 +42 -29
  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 +113 -72
  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 +121 -95
  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 +209 -268
  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 +20 -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
@@ -5,7 +5,9 @@ import {
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,26 +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
178
187
  /**
179
188
  * The run's `inputStability` layers around the task's own: `run` from
180
- * the env vars (above the task), `workspace` from `vx.workspace` (below).
189
+ * the env vars (above the task), `workspace` from `.vx/workspace` (below).
181
190
  */
182
191
  inputStability?: { run: InputStability; workspace?: InputStability }
192
+ /** `cache.outputs.clean` for tasks without their own: its variable, else `.vx/workspace`. */
193
+ cleanOutputs?: boolean
183
194
  /** Per-run memo for `git ls-files` (one entry per project dir). */
184
195
  gitFilesCache?: GitFilesCache
185
- /** Per-run memo for derived hashes (package.json bytes + task config). */
196
+ /** Per-run memo for derived hashes (task config, file lists). */
186
197
  hashCache?: HashCache
187
198
  /** The run's `probesAfterWrites`, handed to every key this task takes. */
188
199
  probesAfterWrites?: ReadonlySet<string>
@@ -247,8 +258,8 @@ export interface ExecuteArgs {
247
258
  */
248
259
  taintedUpstream?: boolean
249
260
  /**
250
- * Whether a task has rewritten a file the workspace fingerprint folded
251
- * 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
252
263
  * on the old digest is probed or saved.
253
264
  */
254
265
  fingerprintWatch?: FingerprintWatch
@@ -260,6 +271,8 @@ export interface ExecuteArgs {
260
271
  noDependants?: true
261
272
  /** The task holds vx's terminal (`terminalHolders`): its stdio is vx's own. */
262
273
  terminal?: true
274
+ /** When each command of the run ran and where it may write (command-windows.ts). */
275
+ commandWindows?: CommandWindows
263
276
  }
264
277
 
265
278
  /**
@@ -360,9 +373,14 @@ function inputStabilityOf(
360
373
  }
361
374
  }
362
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 ?? []
379
+ }
380
+
363
381
  /**
364
- * The workspace fingerprint every key here folded has moved (a task
365
- * 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.
366
384
  */
367
385
  function fingerprintMoved(args: ExecuteArgs): boolean {
368
386
  if (args.fingerprintWatch?.moved() === undefined) return false
@@ -390,7 +408,6 @@ async function executeGroupTask(args: ExecuteArgs): Promise<TaskOutcome> {
390
408
  node: args.node,
391
409
  upstream: args.upstream,
392
410
  workspaceRoot: args.workspaceRoot,
393
- workspaceFingerprint: args.workspaceFingerprint,
394
411
  cache: args.cache,
395
412
  forwardArgs: args.forwardArgs,
396
413
  nestedProjectDirs: args.nestedProjectDirs,
@@ -486,7 +503,6 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
486
503
  node,
487
504
  upstream: args.upstream,
488
505
  workspaceRoot: args.workspaceRoot,
489
- workspaceFingerprint: args.workspaceFingerprint,
490
506
  cache: args.cache,
491
507
  forwardArgs: args.forwardArgs,
492
508
  nestedProjectDirs: args.nestedProjectDirs,
@@ -548,6 +564,8 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
548
564
  const serverSecrets = secretMask([process.env, env, step.env?.define], step.env?.secret)
549
565
  const serverOut = serverSecrets && maskedEmitter(serverSecrets, (t) => log.taskStdout(node, t))
550
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))
551
569
  const persistentOpts: Parameters<typeof runPersistent>[0] = {
552
570
  command,
553
571
  cwd: node.projectDir,
@@ -566,7 +584,7 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
566
584
  }
567
585
  // For a persistent task the timeout bounds the readiness wait. Per-task
568
586
  // `exec.timeout` wins; else the run-level default (env/workspace/--timeout).
569
- const effectiveTimeout = step.timeout ?? args.timeout
587
+ const effectiveTimeout = taskTimeoutMs(step) ?? args.timeout
570
588
  if (effectiveTimeout !== undefined) {
571
589
  persistentOpts.timeoutMs = effectiveTimeout
572
590
  }
@@ -617,7 +635,8 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
617
635
  // it fails, and with no `exec.timeout` the wait never ends, so a run
618
636
  // whose `readyWhen` never matched showed nothing at all.
619
637
  const readyWhen = step.persistent.readyWhen
620
- 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
621
640
  const notice =
622
641
  readyWhen === undefined
623
642
  ? undefined
@@ -660,7 +679,7 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
660
679
  const message = err instanceof Error ? err.message : String(err)
661
680
  // The task's OWN stream, not the process's: the frame is where a
662
681
  // reader looks for why a task failed, and a run with a custom logger
663
- // (an embedder, the MCP server) never saw a bare stderr write at all.
682
+ // (an embedder) never saw a bare stderr write at all.
664
683
  log.taskStderr(node, `\n[vx] ${node.id}: persistent task failed to become ready: ${message}\n`)
665
684
  // A server is never traced, so the sandbox's refusals reach no report:
666
685
  // a dev server that died on a file outside its grants read only as the
@@ -724,7 +743,9 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
724
743
  }
725
744
  args.persistentRegistry?.set(node.id, spawn.child)
726
745
  forgetUndeclaredWrites(args, undeclaredWriteReach(node, args.workspaceRoot))
727
- if (mayWriteFingerprint(node, args.workspaceRoot)) args.fingerprintWatch?.wrote()
746
+ if (mayWriteFingerprint(node, args.workspaceRoot, claimedFiles(args))) {
747
+ args.fingerprintWatch?.wrote()
748
+ }
728
749
  return {
729
750
  node,
730
751
  status: 'success',
@@ -795,10 +816,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
795
816
  const outputs = cacheCfg?.outputs.files ?? []
796
817
  const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
797
818
  const effectiveForwardArgs = node.requested ? (args.forwardArgs ?? []) : []
798
- // Timeout precedence: per-task `exec.timeout` → run-level default
799
- // (`--timeout`/`RunOptions.timeout` → `VX_TASK_TIMEOUT` → workspace
800
- // `timeout`, already collapsed into `args.timeout` by run.ts).
801
- 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
802
822
  // `signal` stops a plugin executor on the run's stop AND on
803
823
  // `exec.timeout`: core cannot kill a process an executor spawned, so a
804
824
  // declared timeout meant nothing on one that kept its own clock (H-12).
@@ -910,7 +930,6 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
910
930
  node,
911
931
  upstream,
912
932
  workspaceRoot: args.workspaceRoot,
913
- workspaceFingerprint: args.workspaceFingerprint,
914
933
  cache,
915
934
  forwardArgs: args.forwardArgs,
916
935
  nestedProjectDirs: args.nestedProjectDirs,
@@ -941,8 +960,13 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
941
960
  projectDir: node.projectDir,
942
961
  outputs,
943
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),
944
969
  }
945
- const wsCleanArgs = { workspaceRoot: args.workspaceRoot, outputs: wsOutputs }
946
970
  // An ADDITIVE task (its outputs overlap an upstream's, with the edge that
947
971
  // orders them; item 588) does not clean by glob — the glob selects the
948
972
  // upstream's files it adds beside. Its outputs are stamped once before
@@ -952,6 +976,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
952
976
  const additive = (node.addsToOutputsOf?.length ?? 0) > 0
953
977
  let stampedBefore: ReadonlyMap<string, OutputStamp> | undefined
954
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
955
985
 
956
986
  // Cache lookup. On hit, time the user-perceived restore op
957
987
  // (clean+restore+log-replay) — that's what the framed-block footer
@@ -990,7 +1020,6 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
990
1020
  node,
991
1021
  upstream,
992
1022
  workspaceRoot: args.workspaceRoot,
993
- workspaceFingerprint: args.workspaceFingerprint,
994
1023
  cache,
995
1024
  forwardArgs: args.forwardArgs,
996
1025
  nestedProjectDirs: args.nestedProjectDirs,
@@ -1002,6 +1031,41 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1002
1031
  : {}),
1003
1032
  })
1004
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
+
1005
1069
  // A name only `exec.env.secret` makes secret is not one the name rule
1006
1070
  // sees later: its row carries the mark, so `vx why` hides its hash (M-63).
1007
1071
  const named = node.config.exec?.env?.secret
@@ -1061,7 +1125,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1061
1125
  let withheld: WithheldLink[] = []
1062
1126
 
1063
1127
  // Cache miss path (or caching disabled), up to `1 + retries` attempts.
1064
- // Explicit config wins over the run-level `--retry` default, including
1128
+ // Explicit config wins over the run-level `--retries` default, including
1065
1129
  // an explicit `retries: 0`.
1066
1130
  const maxAttempts = 1 + (step.retries ?? args.retries ?? 0)
1067
1131
  let attempt = 0
@@ -1092,7 +1156,16 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1092
1156
  const endStamp = span('miss: stamp outputs')
1093
1157
  stampedBefore ??= await stampOutputs(cleanArgs)
1094
1158
  endStamp()
1159
+ } else if (
1160
+ keepOutputs &&
1161
+ attempt === 1 &&
1162
+ willWrite &&
1163
+ !deferralRequested &&
1164
+ outputs.length > 0
1165
+ ) {
1166
+ keptBefore = await stampOutputs(cleanArgs)
1095
1167
  } else if (willWrite && !deferralRequested && outputs.length > 0) {
1168
+ keptBefore = undefined
1096
1169
  // Mark the wiped paths, exactly as the workspace twin below does. The
1097
1170
  // git snapshot still lists them with their committed index OIDs, and
1098
1171
  // resolveFiles SKIPS its existence probe for any path carrying a
@@ -1102,7 +1175,6 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1102
1175
  const endClean = span('miss: clean outputs')
1103
1176
  const cleanedRels = await cleanOutputs(cleanArgs)
1104
1177
  endClean()
1105
- args.gitFilesCache?.noteClean(node.id, node.projectDir, cleanedRels)
1106
1178
  args.gitFilesCache?.markOutputsChanged(node.projectDir, cleanedRels)
1107
1179
  }
1108
1180
  if (additive && willWrite && !deferralRequested && wsOutputs.length > 0) {
@@ -1110,7 +1182,16 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1110
1182
  // glob clean deleted the upstream's files before this task, which
1111
1183
  // reads them, ran (A-43).
1112
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)
1113
1193
  } else if (willWrite && !deferralRequested && wsOutputs.length > 0) {
1194
+ wsKeptBefore = undefined
1114
1195
  // Root-anchored deletions can land in other projects' dirs; mark
1115
1196
  // them so stale per-project git snapshots can't survive the wipe.
1116
1197
  const cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
@@ -1139,6 +1220,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1139
1220
  // the throw is its rejection, the value its resolution, so both reach
1140
1221
  // the naming catch and the cleanup in finally below.
1141
1222
  let running: Promise<unknown>
1223
+ const closeWindow =
1224
+ args.commandWindows?.open(
1225
+ node.id,
1226
+ node.projectDir,
1227
+ commandWriteReach(node, args.workspaceRoot),
1228
+ ) ?? (() => {})
1142
1229
  try {
1143
1230
  running = Promise.resolve(args.executor.execute(req))
1144
1231
  } catch (thrown) {
@@ -1177,6 +1264,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1177
1264
  throw err
1178
1265
  })
1179
1266
  .finally(() => {
1267
+ closeWindow()
1180
1268
  clearTimeout(timeoutTimer)
1181
1269
  unlistenStop?.()
1182
1270
  unlistenStop = undefined
@@ -1267,18 +1355,6 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1267
1355
  // The line quotes the command's first word, which a config may have
1268
1356
  // built from a secret: masked as the task's own output is (L-37).
1269
1357
  if (verdict !== undefined) log.taskStderr(node, `\n${secrets?.mask(verdict) ?? verdict}\n`)
1270
- // A committed file another task's clean removed, still gone: vx
1271
- // cleans outputs before a run where Turbo does not, so a reader with
1272
- // no edge to the producer fails naming only the file (A-48).
1273
- if (code !== 0) {
1274
- for (const c of (args.gitFilesCache?.trackedCleansMissing(node.id) ?? []).slice(0, 3)) {
1275
- const rel = relPosix(args.workspaceRoot, c.path)
1276
- log.taskStderr(
1277
- node,
1278
- `\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`,
1279
- )
1280
- }
1281
- }
1282
1358
  }
1283
1359
  return { result: res, exitCode: code }
1284
1360
  }
@@ -1327,7 +1403,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1327
1403
  const writeReach =
1328
1404
  args.executor.remote === true ? 'none' : undeclaredWriteReach(node, args.workspaceRoot)
1329
1405
  const writesFingerprint =
1330
- args.executor.remote !== true && mayWriteFingerprint(node, args.workspaceRoot)
1406
+ args.executor.remote !== true &&
1407
+ mayWriteFingerprint(node, args.workspaceRoot, claimedFiles(args))
1331
1408
  // A stop that landed during the awaits above (the key, the probe, the
1332
1409
  // input description): the first attempt would wipe the last build and
1333
1410
  // hand the executor a request after the run had stopped. Through a call,
@@ -1474,7 +1551,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1474
1551
  const wallclockEndNs = process.hrtime.bigint() - args.runStartHrTimeNs
1475
1552
 
1476
1553
  const stability = inputStabilityOf(args.node.config.cache, args.inputStability)
1477
- let movedFails = false
1554
+ let movedFails: string | undefined
1478
1555
  let own: Awaited<ReturnType<typeof ownOutputs>>
1479
1556
  if (effectiveExitCode === 0 && willSave && deferralRequested) {
1480
1557
  // The outputs never landed here: no artifact, no rows. A partial local
@@ -1503,6 +1580,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1503
1580
  } else if (
1504
1581
  effectiveExitCode === 0 &&
1505
1582
  willSave &&
1583
+ !leftBehind() &&
1506
1584
  (await keyStillTrue()) &&
1507
1585
  (own = await ownOutputs()) !== undefined
1508
1586
  ) {
@@ -1527,6 +1605,16 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1527
1605
  deferSave: args.deferSave,
1528
1606
  // A sink listens: say what the save cost.
1529
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
+ : {}),
1530
1618
  })
1531
1619
  args.deferredSaves?.set(node.id, landed)
1532
1620
  } else if (cfgCacheable && !remoteOnly && !deferralRequested && args.noDependants !== true) {
@@ -1554,6 +1642,18 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1554
1642
  }
1555
1643
  }
1556
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
+
1557
1657
  /**
1558
1658
  * What an additive run saves as its own, or undefined when it removed a
1559
1659
  * file it found: no entry replays a removal (`ownOutputsSince`), so the
@@ -1586,10 +1686,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1586
1686
  if (described!.hash !== hash) return null
1587
1687
  return (
1588
1688
  (await movedInput(
1589
- described!.facts,
1689
+ keyedInputs(described!.facts),
1590
1690
  cache,
1591
1691
  described!.describedAt,
1592
- stability.strategy === 'mutable',
1692
+ stability.strategy === 'mutable' ? ownRewrite : undefined,
1593
1693
  )) ?? described!.added()
1594
1694
  )
1595
1695
  }
@@ -1619,24 +1719,25 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1619
1719
  endCheck()
1620
1720
  if (moved === undefined) return true
1621
1721
  const what =
1622
- 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`)
1623
1725
  // A task that regenerates its own input (TanStack Router's committed
1624
1726
  // `routeTree.gen.ts`, rewritten to the same bytes by every build) is
1625
1727
  // never saved (item 1015), and the line said so without the way out.
1626
- // Not for `package.json`, which an output may not cover.
1627
1728
  const hint =
1628
- moved === null || path.basename(moved) === 'package.json'
1729
+ moved === null || rewroteBy !== undefined
1629
1730
  ? ''
1630
1731
  : '; if the task writes it, declare it in cache.outputs'
1631
1732
  if (stability.severity === 'error') {
1632
1733
  log.status(
1633
- `[vx] ${node.id}: ${what} changed after its key was taken — the result is not saved, ` +
1734
+ `[vx] ${node.id}: ${what} — the result is not saved, ` +
1634
1735
  `and inputStability.severity is 'error', so the task fails${hint}`,
1635
1736
  )
1636
- movedFails = true
1737
+ movedFails = moved === null ? '' : printable(relPosix(args.workspaceRoot, moved))
1637
1738
  } else {
1638
1739
  log.status(
1639
- `[vx] ${node.id}: ${what} changed after its key was taken — the result stands, ` +
1740
+ `[vx] ${node.id}: ${what} — the result stands, ` +
1640
1741
  `but is not saved under a key that no longer describes it${hint}`,
1641
1742
  )
1642
1743
  }
@@ -1645,7 +1746,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1645
1746
  return false
1646
1747
  }
1647
1748
 
1648
- if (movedFails) effectiveExitCode = 1
1749
+ if (movedFails !== undefined) effectiveExitCode = 1
1649
1750
 
1650
1751
  if (unkeyedUpstream !== undefined && willWrite && effectiveExitCode === 0) {
1651
1752
  log.status(
@@ -1674,6 +1775,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1674
1775
  ? { failedOutput: failedOutputLog(output, willSave) }
1675
1776
  : {}),
1676
1777
  ...(result.timedOut === true && effectiveExitCode !== 0 ? { timedOut: true as const } : {}),
1778
+ ...(movedFails !== undefined ? { inputMoved: movedFails } : {}),
1677
1779
  ...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
1678
1780
  ...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
1679
1781
  ...(result.where !== undefined ? { where: result.where } : {}),
@@ -1704,7 +1806,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1704
1806
  /**
1705
1807
  * Drop the run's facts about files a task may have written without
1706
1808
  * declaring them (`undeclaredWriteReach`): the project's git snapshot and
1707
- * its index OIDs, and its `package.json` digest. The next reader
1809
+ * its index OIDs. The next reader
1708
1810
  * re-enumerates the project (one `git ls-files`) and hashes its files by
1709
1811
  * content; the per-declaration file-list memos are keyed on the snapshot
1710
1812
  * array, so they miss with it (item 743).
@@ -1713,12 +1815,10 @@ function forgetUndeclaredWrites(args: ExecuteArgs, reach: 'none' | 'project' | '
1713
1815
  if (reach === 'none') return
1714
1816
  if (reach === 'workspace') {
1715
1817
  args.gitFilesCache?.clear()
1716
- args.hashCache?.packageJson.clear()
1717
1818
  return
1718
1819
  }
1719
1820
  args.gitFilesCache?.delete(args.node.projectDir)
1720
1821
  args.gitFilesCache?.invalidateWorkspacePartition()
1721
- args.hashCache?.packageJson.delete(args.node.projectDir)
1722
1822
  }
1723
1823
 
1724
1824
  /** The two bin directories a task's PATH starts with: its own, then the root's (once when they coincide). */
@@ -1765,16 +1865,11 @@ function taskEnv(node: TaskNode, step: ExecConfig, workspaceRoot: string): NodeJ
1765
1865
  return env
1766
1866
  }
1767
1867
 
1768
- function isAborted(signal: AbortSignal | undefined): boolean {
1769
- 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)!
1770
1871
  }
1771
1872
 
1772
- /**
1773
- * How long a server may take to match `readyWhen` before vx says it waits.
1774
- * `VX_READY_NOTICE_MS` overrides it, as `VX_KILL_GRACE_MS` does the grace.
1775
- */
1776
- function readyNoticeMs(): number {
1777
- const raw = process.env['VX_READY_NOTICE_MS']
1778
- if (raw !== undefined && /^[0-9]+$/.test(raw) && Number(raw) > 0) return Number(raw)
1779
- return 10_000
1873
+ function isAborted(signal: AbortSignal | undefined): boolean {
1874
+ return signal?.aborted === true
1780
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
  }
@@ -12,6 +12,8 @@ export interface LiveForecast {
12
12
  critical?: string
13
13
  /** Each running task's unfinished dependents, direct and transitive. */
14
14
  blocks: ReadonlyMap<string, number>
15
+ /** Each running task's p50, where history knows it: the row's expected duration. */
16
+ expected?: ReadonlyMap<string, number>
15
17
  }
16
18
 
17
19
  export type Forecast = (
@@ -90,7 +92,12 @@ export function createForecast(
90
92
 
91
93
  return (finished, running, nowMs) => {
92
94
  const blocks = new Map<string, number>()
93
- for (const id of running.keys()) blocks.set(id, blocked(id, finished))
95
+ const expected = new Map<string, number>()
96
+ for (const id of running.keys()) {
97
+ blocks.set(id, blocked(id, finished))
98
+ const p = cost.get(id)
99
+ if (p !== undefined) expected.set(id, p)
100
+ }
94
101
  const end = new Map<string, number>()
95
102
  // The running task each node's longest chain waits on.
96
103
  const head = new Map<string, string>()
@@ -128,12 +135,13 @@ export function createForecast(
128
135
  }
129
136
  work += own
130
137
  }
131
- if (!known || overrun) return { blocks }
138
+ if (!known || overrun) return { blocks, expected }
132
139
  const critical = chainEnd === undefined ? undefined : head.get(chainEnd)
133
140
  return {
134
141
  etaMs: Math.max(chain, work / workers),
135
142
  ...(critical !== undefined ? { critical } : {}),
136
143
  blocks,
144
+ expected,
137
145
  }
138
146
  }
139
147
  }