@vzn/vx 0.0.636 → 0.0.638

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 (151) hide show
  1. package/README.md +39 -11
  2. package/docs/caching.md +246 -213
  3. package/docs/cli.md +791 -705
  4. package/docs/execution.md +68 -76
  5. package/docs/features.md +47 -42
  6. package/docs/patterns.md +26 -25
  7. package/docs/schema.md +392 -205
  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 +4 -4
  16. package/schemas/show.json +5 -3
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +28 -13
  19. package/src/bin.ts +30 -9
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +124 -53
  22. package/src/cache/chained-cache.ts +4 -0
  23. package/src/cache/git-inputs.ts +277 -63
  24. package/src/cache/index.ts +15 -1
  25. package/src/cache/inputs.ts +102 -36
  26. package/src/cache/key-fold.ts +8 -7
  27. package/src/cache/layer.ts +15 -18
  28. package/src/cache/layered-cache.ts +20 -102
  29. package/src/cache/plugin-cache.ts +248 -0
  30. package/src/cache/policy.ts +32 -27
  31. package/src/cache/run-history.ts +104 -14
  32. package/src/cache/run-store.ts +77 -0
  33. package/src/cache/schema.ts +58 -31
  34. package/src/cache/scoped-cache.ts +237 -0
  35. package/src/cli/cache.ts +173 -34
  36. package/src/cli/completions.ts +11 -7
  37. package/src/cli/core-alias.ts +1 -1
  38. package/src/cli/foreign-flags.ts +13 -12
  39. package/src/cli/help.ts +110 -70
  40. package/src/cli/index.ts +67 -24
  41. package/src/cli/info.ts +43 -27
  42. package/src/cli/init.ts +115 -407
  43. package/src/cli/last.ts +157 -9
  44. package/src/cli/lock.ts +356 -122
  45. package/src/cli/plan-format.ts +7 -5
  46. package/src/cli/plugin-commands.ts +81 -14
  47. package/src/cli/plugin-templates.ts +11 -11
  48. package/src/cli/run.ts +116 -160
  49. package/src/cli/select.ts +50 -47
  50. package/src/cli/show.ts +83 -59
  51. package/src/cli/task-verb.ts +6 -13
  52. package/src/cli/upgrade.ts +415 -149
  53. package/src/cli/watch-cycle.ts +9 -2
  54. package/src/cli/watch-filter.ts +39 -61
  55. package/src/cli/watch-fs.ts +29 -16
  56. package/src/cli/watch-judge.ts +18 -4
  57. package/src/cli/watch-set.ts +16 -42
  58. package/src/cli/watch.ts +227 -230
  59. package/src/cli/why.ts +38 -18
  60. package/src/cli/workspace-config.ts +33 -70
  61. package/src/config.ts +169 -70
  62. package/src/exec/env.ts +1 -31
  63. package/src/exec/executor.ts +1 -5
  64. package/src/exec/kill-tree.ts +2 -1
  65. package/src/exec/sandbox-runtime.ts +12 -1
  66. package/src/exec/sandbox-violations.ts +9 -1
  67. package/src/graph/dependency-spec.ts +1 -1
  68. package/src/graph/index.ts +1 -0
  69. package/src/graph/priorities.ts +1 -1
  70. package/src/graph/scheduler.ts +27 -7
  71. package/src/graph/task-graph.ts +76 -24
  72. package/src/handoff.ts +169 -0
  73. package/src/index.ts +35 -58
  74. package/src/orchestrator/affected-tasks.ts +15 -8
  75. package/src/orchestrator/command-windows.ts +44 -0
  76. package/src/orchestrator/deferred-outputs.ts +12 -10
  77. package/src/orchestrator/doctor.ts +98 -110
  78. package/src/orchestrator/download-policy.ts +3 -3
  79. package/src/orchestrator/events.ts +19 -3
  80. package/src/orchestrator/excluded-keys.ts +0 -2
  81. package/src/orchestrator/execute-task.ts +147 -69
  82. package/src/orchestrator/failure-mode.ts +19 -10
  83. package/src/orchestrator/fingerprint-watch.ts +18 -15
  84. package/src/orchestrator/forecast.ts +10 -2
  85. package/src/orchestrator/framed-output.ts +9 -5
  86. package/src/orchestrator/graph-stage.ts +5 -5
  87. package/src/orchestrator/history.ts +126 -30
  88. package/src/orchestrator/hit-restore.ts +15 -3
  89. package/src/orchestrator/index.ts +20 -5
  90. package/src/orchestrator/local-shortcircuit.ts +2 -1
  91. package/src/orchestrator/lockfile-claim.ts +79 -20
  92. package/src/orchestrator/logger.ts +80 -41
  93. package/src/orchestrator/metrics.ts +78 -9
  94. package/src/orchestrator/miss-save.ts +3 -0
  95. package/src/orchestrator/options.ts +42 -29
  96. package/src/orchestrator/output-host.ts +72 -0
  97. package/src/orchestrator/plan.ts +6 -8
  98. package/src/orchestrator/plugin-host.ts +100 -66
  99. package/src/orchestrator/plugin.ts +173 -67
  100. package/src/orchestrator/prepare.ts +163 -78
  101. package/src/orchestrator/projects.ts +105 -99
  102. package/src/orchestrator/remote-prefetch.ts +11 -6
  103. package/src/orchestrator/run-context.ts +25 -6
  104. package/src/orchestrator/run-failures.ts +17 -8
  105. package/src/orchestrator/run-lock.ts +1 -1
  106. package/src/orchestrator/run-records.ts +1 -1
  107. package/src/orchestrator/run-report.ts +9 -9
  108. package/src/orchestrator/run-settings.ts +48 -0
  109. package/src/orchestrator/run.ts +244 -119
  110. package/src/orchestrator/sandbox-fix.ts +39 -20
  111. package/src/orchestrator/sandbox-request.ts +84 -13
  112. package/src/orchestrator/signals.ts +1 -1
  113. package/src/orchestrator/stable-keys.ts +13 -11
  114. package/src/orchestrator/status-line.ts +49 -7
  115. package/src/orchestrator/summary.ts +115 -106
  116. package/src/orchestrator/task-hash.ts +12 -83
  117. package/src/orchestrator/telemetry.ts +4 -4
  118. package/src/settings.ts +428 -0
  119. package/src/{orchestrator → util}/colors.ts +5 -0
  120. package/src/util/config-frame.ts +31 -2
  121. package/src/util/errors.ts +33 -1
  122. package/src/util/essential-env.ts +31 -0
  123. package/src/util/hide-env.ts +107 -0
  124. package/src/util/index.ts +16 -3
  125. package/src/util/paths.ts +10 -0
  126. package/src/util/refusal-notice.ts +111 -0
  127. package/src/util/report.ts +31 -0
  128. package/src/util/settle.ts +49 -30
  129. package/src/util/size.ts +23 -4
  130. package/src/util/timing.ts +3 -2
  131. package/src/util/ui.ts +186 -0
  132. package/src/util/ulid.ts +1 -1
  133. package/src/util/verbs.ts +0 -15
  134. package/src/workspace/affected.ts +84 -232
  135. package/src/workspace/config-eval.ts +15 -20
  136. package/src/workspace/config-imports.ts +78 -3
  137. package/src/workspace/config-schema.ts +209 -268
  138. package/src/workspace/dependency-rules.ts +65 -0
  139. package/src/workspace/filter.ts +2 -2
  140. package/src/workspace/fingerprint.ts +11 -121
  141. package/src/workspace/index.ts +20 -27
  142. package/src/workspace/load-reads.ts +4 -4
  143. package/src/workspace/lockfile.ts +96 -140
  144. package/src/workspace/migration.ts +307 -215
  145. package/src/workspace/package-graph.ts +30 -216
  146. package/src/workspace/project-loader.ts +80 -285
  147. package/src/workspace/repo-id.ts +2 -1
  148. package/src/workspace/workspace.ts +363 -831
  149. package/src/cache/config-evals.ts +0 -130
  150. package/src/workspace/config-cache.ts +0 -661
  151. package/src/workspace/migrate-scripts.ts +0 -1121
@@ -6,6 +6,7 @@ import {
6
6
  type CacheLayer,
7
7
  type CachePolicy,
8
8
  cleanOutputs,
9
+ committedFiles,
9
10
  cleanWorkspaceOutputs,
10
11
  type OutputStamp,
11
12
  ownOutputsSince,
@@ -14,6 +15,7 @@ import {
14
15
  stampWorkspaceOutputs,
15
16
  FULL_CACHE_POLICY,
16
17
  type GitFilesCache,
18
+ racyWindowMs,
17
19
  } from '../cache/index.js'
18
20
  import {
19
21
  buildIsolatedEnv,
@@ -51,7 +53,9 @@ import { isGroupTask, RestoreDemoted, type TaskNode, type TaskOutcome } from '..
51
53
  import {
52
54
  killGraceMs,
53
55
  MASKED,
56
+ parseDuration,
54
57
  maskedEmitter,
58
+ outputMatcher,
55
59
  printable,
56
60
  relPosix,
57
61
  secretMask,
@@ -61,12 +65,15 @@ import {
61
65
  } from '../util/index.js'
62
66
  import { forwardedSignal, SIGNAL_SHUTDOWN_GRACE_MS, terminateChildren } from './signals.js'
63
67
  import { executorLabel, nameExecutorFailure } from './plugin-host.js'
68
+ import type { CommandWindows } from './command-windows.js'
64
69
  import {
70
+ commandWriteReach,
65
71
  mayWriteFingerprint,
66
72
  type Placeholder,
67
73
  placeholderSweeper,
68
74
  reachedWithheld,
69
75
  sandboxRequestFor,
76
+ sandboxReadable,
70
77
  sweepPlaceholders,
71
78
  undeclaredWriteReach,
72
79
  untouchedPlaceholderLine,
@@ -88,6 +95,7 @@ import {
88
95
  computeTaskHash,
89
96
  describeTaskInputs,
90
97
  type HashCache,
98
+ type InputFact,
91
99
  movedInput,
92
100
  type TaskInputComponent,
93
101
  type UpfrontGroupKey,
@@ -99,7 +107,6 @@ export interface ExecuteArgs {
99
107
  node: TaskNode
100
108
  upstream: TaskOutcome[]
101
109
  workspaceRoot: string
102
- workspaceFingerprint: string
103
110
  cache: CacheLayer
104
111
  /** Granular cache read/write policy. Undefined → everything on. */
105
112
  cachePolicy?: CachePolicy
@@ -121,7 +128,7 @@ export interface ExecuteArgs {
121
128
  */
122
129
  remoteOnly?: boolean
123
130
  /**
124
- * Plan-time `--download` decision for THIS task (run.ts). `'deferred'`
131
+ * Plan-time `--cache-download` decision for THIS task (run.ts). `'deferred'`
125
132
  * means: do not clean, do not save — the executor is expected to leave
126
133
  * the outputs in the remote store and hand back a closure. An executor
127
134
  * that ignores it and materialises anyway simply gets no local entry;
@@ -163,26 +170,29 @@ export interface ExecuteArgs {
163
170
  /** Name what a missed key changed (`createMissExplainer`); absent when nobody listens. */
164
171
  explainMiss?: MissExplainer
165
172
  /**
166
- * Run-level retry default (`--retry <n>` / `RunOptions.retries`).
173
+ * Run-level retry default (`--retries <n>` / `RunOptions.retries`).
167
174
  * Explicit `exec.retries` wins, including an explicit 0. Threaded as
168
175
  * an option only — never folded into any hash, so cache keys are
169
176
  * byte-identical with and without it.
170
177
  */
171
178
  retries?: number
172
179
  /**
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.
180
+ * `run.timeout` (ms), already resolved from flag, variable and
181
+ * workspace. Per-task `exec.timeout` wins. Never hashed.
176
182
  */
177
183
  timeout?: number
184
+ /** `run.readyNotice` (ms), resolved like `timeout`; unset, 10 s. */
185
+ readyNotice?: number
178
186
  /**
179
187
  * The run's `inputStability` layers around the task's own: `run` from
180
- * the env vars (above the task), `workspace` from `vx.workspace` (below).
188
+ * the env vars (above the task), `workspace` from `.vx/workspace` (below).
181
189
  */
182
190
  inputStability?: { run: InputStability; workspace?: InputStability }
191
+ /** `cache.outputs.clean` for tasks without their own: `.vx/workspace`. */
192
+ cleanOutputs?: boolean
183
193
  /** Per-run memo for `git ls-files` (one entry per project dir). */
184
194
  gitFilesCache?: GitFilesCache
185
- /** Per-run memo for derived hashes (package.json bytes + task config). */
195
+ /** Per-run memo for derived hashes (task config, file lists). */
186
196
  hashCache?: HashCache
187
197
  /** The run's `probesAfterWrites`, handed to every key this task takes. */
188
198
  probesAfterWrites?: ReadonlySet<string>
@@ -247,8 +257,14 @@ export interface ExecuteArgs {
247
257
  */
248
258
  taintedUpstream?: boolean
249
259
  /**
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
260
+ * The run keeps its bytes in a remote (run-store.ts): an attempt captures
261
+ * its output at the entry's bounds, and an outcome that saves no entry
262
+ * (a failure, an uncached run) carries it as `runLog`.
263
+ */
264
+ keepRunLog?: boolean
265
+ /**
266
+ * Whether a task has rewritten a root file a plugin claims (whose bytes
267
+ * the claimant folded) since the run read it (fingerprint-watch.ts): past that, no key taken
252
268
  * on the old digest is probed or saved.
253
269
  */
254
270
  fingerprintWatch?: FingerprintWatch
@@ -260,6 +276,8 @@ export interface ExecuteArgs {
260
276
  noDependants?: true
261
277
  /** The task holds vx's terminal (`terminalHolders`): its stdio is vx's own. */
262
278
  terminal?: true
279
+ /** When each command of the run ran and where it may write (command-windows.ts). */
280
+ commandWindows?: CommandWindows
263
281
  }
264
282
 
265
283
  /**
@@ -360,9 +378,14 @@ function inputStabilityOf(
360
378
  }
361
379
  }
362
380
 
381
+ /** The root files the plugins claim, as the run's watch holds them. */
382
+ function claimedFiles(args: ExecuteArgs): readonly string[] {
383
+ return args.fingerprintWatch?.names ?? []
384
+ }
385
+
363
386
  /**
364
- * The workspace fingerprint every key here folded has moved (a task
365
- * rewrote the lockfile), said once per run.
387
+ * A claimed file every key here folded through its claimant has moved (a
388
+ * task rewrote the lockfile), said once per run.
366
389
  */
367
390
  function fingerprintMoved(args: ExecuteArgs): boolean {
368
391
  if (args.fingerprintWatch?.moved() === undefined) return false
@@ -390,7 +413,6 @@ async function executeGroupTask(args: ExecuteArgs): Promise<TaskOutcome> {
390
413
  node: args.node,
391
414
  upstream: args.upstream,
392
415
  workspaceRoot: args.workspaceRoot,
393
- workspaceFingerprint: args.workspaceFingerprint,
394
416
  cache: args.cache,
395
417
  forwardArgs: args.forwardArgs,
396
418
  nestedProjectDirs: args.nestedProjectDirs,
@@ -486,7 +508,6 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
486
508
  node,
487
509
  upstream: args.upstream,
488
510
  workspaceRoot: args.workspaceRoot,
489
- workspaceFingerprint: args.workspaceFingerprint,
490
511
  cache: args.cache,
491
512
  forwardArgs: args.forwardArgs,
492
513
  nestedProjectDirs: args.nestedProjectDirs,
@@ -548,6 +569,8 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
548
569
  const serverSecrets = secretMask([process.env, env, step.env?.define], step.env?.secret)
549
570
  const serverOut = serverSecrets && maskedEmitter(serverSecrets, (t) => log.taskStdout(node, t))
550
571
  const serverErr = serverSecrets && maskedEmitter(serverSecrets, (t) => log.taskStderr(node, t))
572
+ // A server writes until the run stops it: its window stays open.
573
+ args.commandWindows?.open(node.id, node.projectDir, commandWriteReach(node, args.workspaceRoot))
551
574
  const persistentOpts: Parameters<typeof runPersistent>[0] = {
552
575
  command,
553
576
  cwd: node.projectDir,
@@ -566,7 +589,7 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
566
589
  }
567
590
  // For a persistent task the timeout bounds the readiness wait. Per-task
568
591
  // `exec.timeout` wins; else the run-level default (env/workspace/--timeout).
569
- const effectiveTimeout = step.timeout ?? args.timeout
592
+ const effectiveTimeout = taskTimeoutMs(step) ?? args.timeout
570
593
  if (effectiveTimeout !== undefined) {
571
594
  persistentOpts.timeoutMs = effectiveTimeout
572
595
  }
@@ -617,7 +640,8 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
617
640
  // it fails, and with no `exec.timeout` the wait never ends, so a run
618
641
  // whose `readyWhen` never matched showed nothing at all.
619
642
  const readyWhen = step.persistent.readyWhen
620
- const noticeMs = readyNoticeMs()
643
+ // How long a server may take to match `readyWhen` before vx says it waits.
644
+ const noticeMs = args.readyNotice ?? 10_000
621
645
  const notice =
622
646
  readyWhen === undefined
623
647
  ? undefined
@@ -660,7 +684,7 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
660
684
  const message = err instanceof Error ? err.message : String(err)
661
685
  // The task's OWN stream, not the process's: the frame is where a
662
686
  // 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.
687
+ // (an embedder) never saw a bare stderr write at all.
664
688
  log.taskStderr(node, `\n[vx] ${node.id}: persistent task failed to become ready: ${message}\n`)
665
689
  // A server is never traced, so the sandbox's refusals reach no report:
666
690
  // a dev server that died on a file outside its grants read only as the
@@ -724,7 +748,9 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
724
748
  }
725
749
  args.persistentRegistry?.set(node.id, spawn.child)
726
750
  forgetUndeclaredWrites(args, undeclaredWriteReach(node, args.workspaceRoot))
727
- if (mayWriteFingerprint(node, args.workspaceRoot)) args.fingerprintWatch?.wrote()
751
+ if (mayWriteFingerprint(node, args.workspaceRoot, claimedFiles(args))) {
752
+ args.fingerprintWatch?.wrote()
753
+ }
728
754
  return {
729
755
  node,
730
756
  status: 'success',
@@ -795,10 +821,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
795
821
  const outputs = cacheCfg?.outputs.files ?? []
796
822
  const wsOutputs = cacheCfg?.outputs.workspaceFiles ?? []
797
823
  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
824
+ // Per-task `exec.timeout` beats `run.timeout` (flag, variable,
825
+ // workspace; resolved into `args.timeout` by run.ts).
826
+ const effectiveTimeout = taskTimeoutMs(step) ?? args.timeout
802
827
  // `signal` stops a plugin executor on the run's stop AND on
803
828
  // `exec.timeout`: core cannot kill a process an executor spawned, so a
804
829
  // declared timeout meant nothing on one that kept its own clock (H-12).
@@ -910,7 +935,6 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
910
935
  node,
911
936
  upstream,
912
937
  workspaceRoot: args.workspaceRoot,
913
- workspaceFingerprint: args.workspaceFingerprint,
914
938
  cache,
915
939
  forwardArgs: args.forwardArgs,
916
940
  nestedProjectDirs: args.nestedProjectDirs,
@@ -941,8 +965,13 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
941
965
  projectDir: node.projectDir,
942
966
  outputs,
943
967
  nestedProjectDirs: args.nestedProjectDirs,
968
+ committed: committedFiles(args.gitFilesCache, node.projectDir),
969
+ }
970
+ const wsCleanArgs = {
971
+ workspaceRoot: args.workspaceRoot,
972
+ outputs: wsOutputs,
973
+ committed: committedFiles(args.gitFilesCache, args.workspaceRoot),
944
974
  }
945
- const wsCleanArgs = { workspaceRoot: args.workspaceRoot, outputs: wsOutputs }
946
975
  // An ADDITIVE task (its outputs overlap an upstream's, with the edge that
947
976
  // orders them; item 588) does not clean by glob — the glob selects the
948
977
  // upstream's files it adds beside. Its outputs are stamped once before
@@ -952,6 +981,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
952
981
  const additive = (node.addsToOutputsOf?.length ?? 0) > 0
953
982
  let stampedBefore: ReadonlyMap<string, OutputStamp> | undefined
954
983
  let wsStampedBefore: ReadonlyMap<string, OutputStamp> | undefined
984
+ // `cache.outputs.clean: false` leaves the outputs to the first attempt's
985
+ // command, and the save stores whatever they hold after it, a leftover
986
+ // included: the user opted out of the clean-tree guarantee (M,
987
+ // 2026-10-15). A retry still starts clean. An additive task never cleans
988
+ // by glob anyway.
989
+ const keepOutputs = !additive && (cacheCfg?.outputs.clean ?? args.cleanOutputs ?? true) === false
955
990
 
956
991
  // Cache lookup. On hit, time the user-perceived restore op
957
992
  // (clean+restore+log-replay) — that's what the framed-block footer
@@ -990,7 +1025,6 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
990
1025
  node,
991
1026
  upstream,
992
1027
  workspaceRoot: args.workspaceRoot,
993
- workspaceFingerprint: args.workspaceFingerprint,
994
1028
  cache,
995
1029
  forwardArgs: args.forwardArgs,
996
1030
  nestedProjectDirs: args.nestedProjectDirs,
@@ -1002,6 +1036,41 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1002
1036
  : {}),
1003
1037
  })
1004
1038
  : undefined
1039
+ /** The other command that may have put a moved input back (`ownRewrite`). */
1040
+ let rewroteBy: string | undefined
1041
+ /**
1042
+ * Under `'mutable'`, a write during the command that left the key's bytes
1043
+ * is the task's own identical rewrite only if no other command that may
1044
+ * write the file ran between the describe and that write: one that did
1045
+ * may have changed it and put it back while this command read it.
1046
+ */
1047
+ const ownRewrite = (file: string, ctimeMs: number): boolean => {
1048
+ rewroteBy = args.commandWindows?.writerDuring(
1049
+ node.id,
1050
+ file,
1051
+ described!.describedAt,
1052
+ ctimeMs + racyWindowMs(ctimeMs, 0),
1053
+ )
1054
+ return rewroteBy === undefined
1055
+ }
1056
+
1057
+ /**
1058
+ * A committed file under the task's own outputs is keyed (`committedFiles`)
1059
+ * and the task may rewrite it (TanStack Router's `routeTree.gen.ts`): the
1060
+ * bytes it leaves are an output the entry saves and a restore writes, and
1061
+ * they follow from the key, so their change is no moved input.
1062
+ */
1063
+ const ownOutput = outputs.length === 0 ? undefined : outputMatcher(outputs)
1064
+ const ownWsOutput = wsOutputs.length === 0 ? undefined : outputMatcher(wsOutputs)
1065
+ const keyedInputs = (facts: readonly InputFact[]): readonly InputFact[] =>
1066
+ ownOutput === undefined && ownWsOutput === undefined
1067
+ ? facts
1068
+ : facts.filter(
1069
+ (f) =>
1070
+ ownOutput?.(relPosix(node.projectDir, f.path)) !== true &&
1071
+ ownWsOutput?.(relPosix(args.workspaceRoot, f.path)) !== true,
1072
+ )
1073
+
1005
1074
  // A name only `exec.env.secret` makes secret is not one the name rule
1006
1075
  // sees later: its row carries the mark, so `vx why` hides its hash (M-63).
1007
1076
  const named = node.config.exec?.env?.secret
@@ -1061,7 +1130,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1061
1130
  let withheld: WithheldLink[] = []
1062
1131
 
1063
1132
  // Cache miss path (or caching disabled), up to `1 + retries` attempts.
1064
- // Explicit config wins over the run-level `--retry` default, including
1133
+ // Explicit config wins over the run-level `--retries` default, including
1065
1134
  // an explicit `retries: 0`.
1066
1135
  const maxAttempts = 1 + (step.retries ?? args.retries ?? 0)
1067
1136
  let attempt = 0
@@ -1084,6 +1153,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1084
1153
  exitCode: number
1085
1154
  withdrawn?: true
1086
1155
  }> {
1156
+ // The first attempt under `clean: false` finds its last outputs (tsc's `.tsbuildinfo`).
1157
+ const clean = willWrite && !deferralRequested && !(keepOutputs && attempt === 1)
1087
1158
  // A deferred task's outputs are deliberately NOT coming, so wiping the
1088
1159
  // tree would replace a stale build with nothing at all. The eligibility
1089
1160
  // gate guarantees no key in this run can see what stays behind, and the
@@ -1092,7 +1163,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1092
1163
  const endStamp = span('miss: stamp outputs')
1093
1164
  stampedBefore ??= await stampOutputs(cleanArgs)
1094
1165
  endStamp()
1095
- } else if (willWrite && !deferralRequested && outputs.length > 0) {
1166
+ } else if (clean && outputs.length > 0) {
1096
1167
  // Mark the wiped paths, exactly as the workspace twin below does. The
1097
1168
  // git snapshot still lists them with their committed index OIDs, and
1098
1169
  // resolveFiles SKIPS its existence probe for any path carrying a
@@ -1102,7 +1173,6 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1102
1173
  const endClean = span('miss: clean outputs')
1103
1174
  const cleanedRels = await cleanOutputs(cleanArgs)
1104
1175
  endClean()
1105
- args.gitFilesCache?.noteClean(node.id, node.projectDir, cleanedRels)
1106
1176
  args.gitFilesCache?.markOutputsChanged(node.projectDir, cleanedRels)
1107
1177
  }
1108
1178
  if (additive && willWrite && !deferralRequested && wsOutputs.length > 0) {
@@ -1110,7 +1180,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1110
1180
  // glob clean deleted the upstream's files before this task, which
1111
1181
  // reads them, ran (A-43).
1112
1182
  wsStampedBefore ??= await stampWorkspaceOutputs(wsCleanArgs)
1113
- } else if (willWrite && !deferralRequested && wsOutputs.length > 0) {
1183
+ } else if (clean && wsOutputs.length > 0) {
1114
1184
  // Root-anchored deletions can land in other projects' dirs; mark
1115
1185
  // them so stale per-project git snapshots can't survive the wipe.
1116
1186
  const cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
@@ -1139,6 +1209,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1139
1209
  // the throw is its rejection, the value its resolution, so both reach
1140
1210
  // the naming catch and the cleanup in finally below.
1141
1211
  let running: Promise<unknown>
1212
+ const closeWindow =
1213
+ args.commandWindows?.open(
1214
+ node.id,
1215
+ node.projectDir,
1216
+ commandWriteReach(node, args.workspaceRoot),
1217
+ ) ?? (() => {})
1142
1218
  try {
1143
1219
  running = Promise.resolve(args.executor.execute(req))
1144
1220
  } catch (thrown) {
@@ -1177,6 +1253,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1177
1253
  throw err
1178
1254
  })
1179
1255
  .finally(() => {
1256
+ closeWindow()
1180
1257
  clearTimeout(timeoutTimer)
1181
1258
  unlistenStop?.()
1182
1259
  unlistenStop = undefined
@@ -1267,18 +1344,6 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1267
1344
  // The line quotes the command's first word, which a config may have
1268
1345
  // built from a secret: masked as the task's own output is (L-37).
1269
1346
  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
1347
  }
1283
1348
  return { result: res, exitCode: code }
1284
1349
  }
@@ -1327,7 +1392,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1327
1392
  const writeReach =
1328
1393
  args.executor.remote === true ? 'none' : undeclaredWriteReach(node, args.workspaceRoot)
1329
1394
  const writesFingerprint =
1330
- args.executor.remote !== true && mayWriteFingerprint(node, args.workspaceRoot)
1395
+ args.executor.remote !== true &&
1396
+ mayWriteFingerprint(node, args.workspaceRoot, claimedFiles(args))
1331
1397
  // A stop that landed during the awaits above (the key, the probe, the
1332
1398
  // input description): the first attempt would wipe the last build and
1333
1399
  // hand the executor a request after the run had stopped. Through a call,
@@ -1404,9 +1470,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1404
1470
  }
1405
1471
 
1406
1472
  async function buildRequest(local: boolean): Promise<ExecuteRequest> {
1407
- const kept = willSave
1408
- ? new BoundedCapture()
1409
- : new BoundedCapture(FAILED_OUTPUT_HEAD_CHARS, FAILED_OUTPUT_TAIL_CHARS)
1473
+ const kept =
1474
+ willSave || args.keepRunLog === true
1475
+ ? new BoundedCapture()
1476
+ : new BoundedCapture(FAILED_OUTPUT_HEAD_CHARS, FAILED_OUTPUT_TAIL_CHARS)
1410
1477
  output = kept
1411
1478
  const toOut = (t: string): void => {
1412
1479
  kept.push(t)
@@ -1474,8 +1541,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1474
1541
  const wallclockEndNs = process.hrtime.bigint() - args.runStartHrTimeNs
1475
1542
 
1476
1543
  const stability = inputStabilityOf(args.node.config.cache, args.inputStability)
1477
- let movedFails = false
1544
+ let movedFails: string | undefined
1478
1545
  let own: Awaited<ReturnType<typeof ownOutputs>>
1546
+ let saved = false
1479
1547
  if (effectiveExitCode === 0 && willSave && deferralRequested) {
1480
1548
  // The outputs never landed here: no artifact, no rows. A partial local
1481
1549
  // record (a row with no artifact) is exactly the corrupt-entry shape
@@ -1527,8 +1595,19 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1527
1595
  deferSave: args.deferSave,
1528
1596
  // A sink listens: say what the save cost.
1529
1597
  measure: args.explainMiss !== undefined,
1598
+ ...(userSandbox
1599
+ ? {
1600
+ readable: sandboxReadable(
1601
+ node,
1602
+ cfg.exec!.sandbox!,
1603
+ args.workspaceRoot,
1604
+ args.nestedProjectDirs,
1605
+ ),
1606
+ }
1607
+ : {}),
1530
1608
  })
1531
1609
  args.deferredSaves?.set(node.id, landed)
1610
+ saved = true
1532
1611
  } else if (cfgCacheable && !remoteOnly && !deferralRequested && args.noDependants !== true) {
1533
1612
  // Ran here and saves nothing — failed, a read-only policy, a tainted
1534
1613
  // upstream, a key that no longer held — yet it wrote what a save would
@@ -1586,10 +1665,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1586
1665
  if (described!.hash !== hash) return null
1587
1666
  return (
1588
1667
  (await movedInput(
1589
- described!.facts,
1668
+ keyedInputs(described!.facts),
1590
1669
  cache,
1591
1670
  described!.describedAt,
1592
- stability.strategy === 'mutable',
1671
+ stability.strategy === 'mutable' ? ownRewrite : undefined,
1593
1672
  )) ?? described!.added()
1594
1673
  )
1595
1674
  }
@@ -1619,24 +1698,25 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1619
1698
  endCheck()
1620
1699
  if (moved === undefined) return true
1621
1700
  const what =
1622
- moved === null ? 'its inputs' : `\`${printable(relPosix(args.workspaceRoot, moved))}\``
1701
+ (moved === null ? 'its inputs' : `\`${printable(relPosix(args.workspaceRoot, moved))}\``) +
1702
+ ' changed after its key was taken' +
1703
+ (rewroteBy === undefined ? '' : ` while ${rewroteBy} ran`)
1623
1704
  // A task that regenerates its own input (TanStack Router's committed
1624
1705
  // `routeTree.gen.ts`, rewritten to the same bytes by every build) is
1625
1706
  // 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
1707
  const hint =
1628
- moved === null || path.basename(moved) === 'package.json'
1708
+ moved === null || rewroteBy !== undefined
1629
1709
  ? ''
1630
1710
  : '; if the task writes it, declare it in cache.outputs'
1631
1711
  if (stability.severity === 'error') {
1632
1712
  log.status(
1633
- `[vx] ${node.id}: ${what} changed after its key was taken — the result is not saved, ` +
1713
+ `[vx] ${node.id}: ${what} — the result is not saved, ` +
1634
1714
  `and inputStability.severity is 'error', so the task fails${hint}`,
1635
1715
  )
1636
- movedFails = true
1716
+ movedFails = moved === null ? '' : printable(relPosix(args.workspaceRoot, moved))
1637
1717
  } else {
1638
1718
  log.status(
1639
- `[vx] ${node.id}: ${what} changed after its key was taken — the result stands, ` +
1719
+ `[vx] ${node.id}: ${what} — the result stands, ` +
1640
1720
  `but is not saved under a key that no longer describes it${hint}`,
1641
1721
  )
1642
1722
  }
@@ -1645,7 +1725,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1645
1725
  return false
1646
1726
  }
1647
1727
 
1648
- if (movedFails) effectiveExitCode = 1
1728
+ if (movedFails !== undefined) effectiveExitCode = 1
1649
1729
 
1650
1730
  if (unkeyedUpstream !== undefined && willWrite && effectiveExitCode === 0) {
1651
1731
  log.status(
@@ -1670,10 +1750,15 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1670
1750
  : {}),
1671
1751
  ...(inputChanges !== undefined ? { inputChanges } : {}),
1672
1752
  ...(unkeyed ? { unkeyed: true as const } : {}),
1753
+ ...(saved ? { saved: true as const } : {}),
1673
1754
  ...(effectiveExitCode !== 0 && output !== undefined
1674
- ? { failedOutput: failedOutputLog(output, willSave) }
1755
+ ? { failedOutput: failedOutputLog(output, willSave || args.keepRunLog === true) }
1756
+ : {}),
1757
+ ...(args.keepRunLog === true && (effectiveExitCode !== 0 || !willSave) && output !== undefined
1758
+ ? { runLog: storedOutput() }
1675
1759
  : {}),
1676
1760
  ...(result.timedOut === true && effectiveExitCode !== 0 ? { timedOut: true as const } : {}),
1761
+ ...(movedFails !== undefined ? { inputMoved: movedFails } : {}),
1677
1762
  ...(result.cpuMs !== undefined ? { cpuMs: result.cpuMs } : {}),
1678
1763
  ...(result.peakRssBytes !== undefined ? { peakRssBytes: result.peakRssBytes } : {}),
1679
1764
  ...(result.where !== undefined ? { where: result.where } : {}),
@@ -1704,7 +1789,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1704
1789
  /**
1705
1790
  * Drop the run's facts about files a task may have written without
1706
1791
  * declaring them (`undeclaredWriteReach`): the project's git snapshot and
1707
- * its index OIDs, and its `package.json` digest. The next reader
1792
+ * its index OIDs. The next reader
1708
1793
  * re-enumerates the project (one `git ls-files`) and hashes its files by
1709
1794
  * content; the per-declaration file-list memos are keyed on the snapshot
1710
1795
  * array, so they miss with it (item 743).
@@ -1713,12 +1798,10 @@ function forgetUndeclaredWrites(args: ExecuteArgs, reach: 'none' | 'project' | '
1713
1798
  if (reach === 'none') return
1714
1799
  if (reach === 'workspace') {
1715
1800
  args.gitFilesCache?.clear()
1716
- args.hashCache?.packageJson.clear()
1717
1801
  return
1718
1802
  }
1719
1803
  args.gitFilesCache?.delete(args.node.projectDir)
1720
1804
  args.gitFilesCache?.invalidateWorkspacePartition()
1721
- args.hashCache?.packageJson.delete(args.node.projectDir)
1722
1805
  }
1723
1806
 
1724
1807
  /** The two bin directories a task's PATH starts with: its own, then the root's (once when they coincide). */
@@ -1765,16 +1848,11 @@ function taskEnv(node: TaskNode, step: ExecConfig, workspaceRoot: string): NodeJ
1765
1848
  return env
1766
1849
  }
1767
1850
 
1768
- function isAborted(signal: AbortSignal | undefined): boolean {
1769
- return signal?.aborted === true
1851
+ /** A task's own `exec.timeout` in ms; the schema has checked its form. */
1852
+ function taskTimeoutMs(step: ExecConfig): number | undefined {
1853
+ return step.timeout === undefined ? undefined : parseDuration(step.timeout)!
1770
1854
  }
1771
1855
 
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
1856
+ function isAborted(signal: AbortSignal | undefined): boolean {
1857
+ return signal?.aborted === true
1780
1858
  }
@@ -4,7 +4,9 @@
4
4
  // drift into opposite verdicts on identical data. Both build on
5
5
  // `mixedOutcomeKeysSql` + `failureModeOf`, so the rule cannot fork again.
6
6
  // The per-run detector (`detectFlaky`) and the doctor's list (`flakyTasks`)
7
- // read the same per-key projection (`keyOutcomesSql`).
7
+ // read the same per-key projection (`keyOutcomesSql`) over the shared
8
+ // store's flaky record (`task_outcomes`, v38), so every worktree of a repo
9
+ // judges a key on what all of them saw.
8
10
 
9
11
  import type { Database } from 'bun:sqlite'
10
12
  import { KEYED_RUNS_SQL } from '../cache/index.js'
@@ -17,6 +19,9 @@ const HIT_STATUSES = `(${TASK_STATUSES.filter(isCacheHit)
17
19
  .map((s) => `'${s}'`)
18
20
  .join(', ')})`
19
21
 
22
+ /** The shared store's flaky record: what `detectFlaky` and `flakyTasks` read. */
23
+ const OUTCOMES = 'task_outcomes'
24
+
20
25
  export type FailureMode = 'stable' | 'flaky-recoverable' | 'flaky-fatal'
21
26
 
22
27
  /**
@@ -33,6 +38,10 @@ export type FailureMode = 'stable' | 'flaky-recoverable' | 'flaky-fatal'
33
38
  * projection.
34
39
  */
35
40
  function keyOutcomesSql(source: string, where = ''): string {
41
+ // The flaky record keeps only rows that observed their key (run-history.ts
42
+ // `observesKey`), so it carries none of the filter's columns.
43
+ const keyed =
44
+ source === OUTCOMES ? '1' : `${KEYED_RUNS_SQL} AND cached IS NOT 0 AND blocked_by IS NULL`
36
45
  return `SELECT project, task, hash, SUM(failed) AS failures, SUM(passed) AS passes,
37
46
  SUM(retried) AS retried_failures,
38
47
  SUM(CASE WHEN failed = 1 AND id > first_pass THEN 1 ELSE 0 END) AS relapses
@@ -41,10 +50,10 @@ function keyOutcomesSql(source: string, where = ''): string {
41
50
  FROM (SELECT project, task, hash, id,
42
51
  CASE WHEN status = 'failed' THEN 1 ELSE 0 END AS failed,
43
52
  CASE WHEN status = 'success' AND attempts > 1 THEN attempts - 1 ELSE 0 END AS retried,
44
- CASE WHEN status = 'success' OR status IN ${HIT_STATUSES} OR cache_hit = 1
53
+ CASE WHEN status = 'success' OR status IN ${HIT_STATUSES}${source === OUTCOMES ? '' : ' OR cache_hit = 1'}
45
54
  THEN 1 ELSE 0 END AS passed
46
55
  FROM ${source}
47
- WHERE ${KEYED_RUNS_SQL} AND cached IS NOT 0 AND blocked_by IS NULL${where}))
56
+ WHERE ${keyed}${where}))
48
57
  GROUP BY project, task, hash`
49
58
  }
50
59
 
@@ -136,9 +145,9 @@ const keyOf = (c: { project: string; task: string; hash: string }): string =>
136
145
  * failures is a recovery, not a flake. The counts fold this run in.
137
146
  *
138
147
  * The cost follows the run's colour. No candidate (every task a hit or a
139
- * skip): no query. A green miss: one probe of the `runs_failed` partial
140
- * index for the keys that ever failed — failures are the rare rows, so the
141
- * probe is microseconds however long the history. Only a key that DID fail
148
+ * skip): no query. A green miss: one probe of the `task_outcomes_hash`
149
+ * index for the keys that ever failed — a key holds a handful of rows, so
150
+ * the probe is microseconds however long the history. Only a key that DID fail
142
151
  * before, or a task failing now, pays the projection scan over its own
143
152
  * keys, and a run that is failing has more to look at than a scan.
144
153
  */
@@ -149,8 +158,8 @@ export function detectFlaky(db: Database, candidates: readonly FlakyCandidate[])
149
158
  chunked(passedNow, (chunk) => {
150
159
  const rows = db
151
160
  .query(
152
- `SELECT DISTINCT project, task, hash FROM runs
153
- WHERE status = 'failed' AND blocked_by IS NULL AND hash IN (${chunk.map(() => '?').join(', ')})`,
161
+ `SELECT DISTINCT project, task, hash FROM ${OUTCOMES}
162
+ WHERE status = 'failed' AND hash IN (${chunk.map(() => '?').join(', ')})`,
154
163
  )
155
164
  .all(...chunk.map((c) => c.hash)) as { project: string; task: string; hash: string }[]
156
165
  for (const r of rows) everFailed.add(keyOf(r))
@@ -162,7 +171,7 @@ export function detectFlaky(db: Database, candidates: readonly FlakyCandidate[])
162
171
  const counts = new Map<string, { passes: number; failures: number; relapses: number }>()
163
172
  chunked(suspects, (chunk) => {
164
173
  const rows = db
165
- .query(keyOutcomesSql('runs', ` AND hash IN (${chunk.map(() => '?').join(', ')})`))
174
+ .query(keyOutcomesSql(OUTCOMES, ` AND hash IN (${chunk.map(() => '?').join(', ')})`))
166
175
  .all(...chunk.map((c) => c.hash)) as {
167
176
  project: string
168
177
  task: string
@@ -212,7 +221,7 @@ export function flakyTasks(db: Database): FlakyTask[] {
212
221
  .query(
213
222
  `SELECT project, task, COUNT(*) AS keys, SUM(passes) AS passes, SUM(failures) AS failures
214
223
  FROM (SELECT project, task, passes, failures + retried_failures AS failures
215
- FROM (${keyOutcomesSql('runs')})
224
+ FROM (${keyOutcomesSql(OUTCOMES)})
216
225
  WHERE relapses > 0 OR retried_failures > 0)
217
226
  GROUP BY project, task
218
227
  ORDER BY failures DESC, passes DESC, project, task`,