@vzn/vx 0.0.646 → 0.0.649

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 (184) hide show
  1. package/README.md +29 -22
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
@@ -1,3 +1,4 @@
1
+ import path from 'node:path'
1
2
  import type { ExecConfig, TaskConfig, CacheConfig, InputStability } from '../config.js'
2
3
  import {
3
4
  ArtifactVanishedError,
@@ -43,7 +44,7 @@ import { isGroupTask, RestoreDemoted, type TaskNode, type TaskOutcome } from '..
43
44
  import {
44
45
  killGraceMs,
45
46
  MASKED,
46
- maskedEmitter,
47
+ maskedStreams,
47
48
  outputMatcher,
48
49
  printable,
49
50
  relPosix,
@@ -67,9 +68,15 @@ import {
67
68
  type WithheldLink,
68
69
  withheldLinkLine,
69
70
  } from './sandbox-request.js'
70
- import { markUnsaved, saveMiss, type OutputDirSnapshot, type SaveFacts } from './miss-save.js'
71
+ import {
72
+ bracketLiteralHint,
73
+ markUnsaved,
74
+ saveMiss,
75
+ type OutputDirSnapshot,
76
+ type SaveFacts,
77
+ } from './miss-save.js'
71
78
  import type { FingerprintWatch } from './fingerprint-watch.js'
72
- import { restoreHit } from './hit-restore.js'
79
+ import { currentHit, restoreHit } from './hit-restore.js'
73
80
  import type { MissExplainer } from '../history/index.js'
74
81
  import { shellVerdict } from './shell-verdict.js'
75
82
  import { sandboxFix } from './sandbox-fix.js'
@@ -111,6 +118,8 @@ export interface ExecuteArgs {
111
118
  log: Logger
112
119
  /** The executor this task was PLACED on (run.ts, before scheduling). */
113
120
  executor: TaskExecutor
121
+ /** Told the task executes, so the executor's demand counts it again (`ExecutorDemand.executing`). */
122
+ executing?: ((id: string) => void) | undefined
114
123
  /**
115
124
  * `exec.remote: 'only'` with no remote executor to take it: succeed
116
125
  * WITHOUT executing, cleaning, restoring or saving anything on this
@@ -276,6 +285,8 @@ export interface ExecuteArgs {
276
285
  carried?: true
277
286
  /** The persistent tasks its `servers` executor starts where it runs it. */
278
287
  servers?: readonly TaskNode[]
288
+ /** Each of `servers`' nested project dirs, in its order. */
289
+ serversNested?: readonly (readonly string[])[]
279
290
  /** When each command of the run ran and where it may write (command-windows.ts). */
280
291
  commandWindows?: CommandWindows
281
292
  }
@@ -306,6 +317,28 @@ export async function executeTask(args: ExecuteArgs): Promise<TaskOutcome> {
306
317
  return executeCachedTask(args)
307
318
  }
308
319
 
320
+ /**
321
+ * A probed hit `executeTask` would restore, settled now when its tree is
322
+ * already current (`currentHit`): the thunk replays its output and returns
323
+ * the outcome. Undefined dispatches it as usual. A dispatch, a slot and the
324
+ * promises around it were ~10 µs of each of a warm run's hits.
325
+ */
326
+ export function settleProbedHit(args: ExecuteArgs): (() => TaskOutcome) | undefined {
327
+ const probed = args.preProbed
328
+ if (probed?.hit == null || !readsCache(args) || args.remoteOnlyNoop === true) return undefined
329
+ if (isGroupTask(args.node) || args.node.config.exec?.persistent !== undefined) return undefined
330
+ // A moved fingerprint is said, and demotes the hit, on the dispatch.
331
+ if (args.fingerprintWatch?.moved() !== undefined) return undefined
332
+ const restore = {
333
+ args,
334
+ hash: probed.hash,
335
+ hit: probed.hit,
336
+ cacheOpStart: performance.now(),
337
+ taskStartNs: process.hrtime.bigint() - args.runStartHrTimeNs,
338
+ }
339
+ return currentHit(restore)
340
+ }
341
+
309
342
  /** Whether this task reads the cache: a cached, not remote-only task with a read axis on. */
310
343
  function readsCache(args: ExecuteArgs): boolean {
311
344
  const policy = args.cachePolicy ?? FULL_CACHE_POLICY
@@ -364,7 +397,7 @@ async function restoreOrMiss(
364
397
  /**
365
398
  * What a write to an input during the command means, each field highest
366
399
  * first: the env var (`layers.run`), the task's `cache.inputStability`,
367
- * the workspace's, then `'immutable'` and `'warn'` (owner, 2026-10-12), so
400
+ * the workspace's, then `'immutable'` and `'warn'` (2026-10-12), so
368
401
  * by default any such write withholds the save, on CI too, since a
369
402
  * concurrent task can change an input and put it back.
370
403
  */
@@ -549,7 +582,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
549
582
  // The local executor keeps its own timer from the spawn (it signals the
550
583
  // process group), so its request carries no clock: armed here, it also
551
584
  // timed the sandbox's wrap, and a wrap slower than the timeout aborted
552
- // the request before the spawn — a task reported killed, never run.
585
+ // the request before the spawn — a task reported killed, never run. So
586
+ // does an executor that declares `timeouts`.
553
587
  let timeoutTimer: ReturnType<typeof setTimeout> | undefined
554
588
  let timeoutFired = false
555
589
  // The attempt's listener on the run's stop signal, taken off once the
@@ -570,7 +604,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
570
604
  }
571
605
  clearTimeout(timeoutTimer)
572
606
  timeoutFired = false
573
- if (effectiveTimeout !== undefined && !local) {
607
+ if (effectiveTimeout !== undefined && !local && args.executor.timeouts !== true) {
574
608
  timeoutTimer = setTimeout(() => {
575
609
  timeoutFired = true
576
610
  stop.abort(new Error(`timed out after ${effectiveTimeout}ms`))
@@ -598,7 +632,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
598
632
  const why = timeoutFired ? `timeout (${effectiveTimeout}ms)` : 'stop'
599
633
  log.taskStderr(
600
634
  node,
601
- `vx: ${executorLabel(args.executor)} did not return within ${graceMs}ms of the ${why}; abandoned\n`,
635
+ `${midLine ? '\n' : ''}vx: ${executorLabel(args.executor)} did not return within ${graceMs}ms of the ${why}; abandoned\n`,
602
636
  )
603
637
  const reason: unknown = signal.reason
604
638
  resolve({
@@ -702,8 +736,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
702
736
  let wsStampedBefore: ReadonlyMap<string, OutputStamp> | undefined
703
737
  // `cache.outputs.clean: false` leaves the outputs to the first attempt's
704
738
  // command, and the save stores whatever they hold after it, a leftover
705
- // included: the user opted out of the clean-tree guarantee (M,
706
- // 2026-10-15). A retry still starts clean. An additive task never cleans
739
+ // included: the user opted out of the clean-tree guarantee
740
+ // (2026-10-15). A retry still starts clean. An additive task never cleans
707
741
  // by glob anyway.
708
742
  const keepOutputs = !additive && (cacheCfg?.outputs.clean ?? args.cleanOutputs ?? true) === false
709
743
 
@@ -797,11 +831,13 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
797
831
  for (const c of captured)
798
832
  if (c.kind === 'env' && named.includes(c.name) && !secretNamed(c.name))
799
833
  c.hash = MASKED + c.hash
834
+ // The values of secret-named variables, masked in what the task prints
835
+ // and what the cache keeps of it (L-11); null when there are none.
836
+ const secrets = secretMask([process.env, env, step.env?.define], step.env?.secret)
800
837
  // A runtime probe's row is named by its command, which a TS config may
801
838
  // build from `process.env`: the run history stored it and `vx why`
802
839
  // printed it (L-11).
803
- const nameSecrets =
804
- captured.length > 0 ? secretMask([process.env, env, step.env?.define], step.env?.secret) : null
840
+ const nameSecrets = captured.length > 0 ? secrets : null
805
841
  if (nameSecrets !== null) {
806
842
  // Two probes that differ only by a secret mask to one name, and a row
807
843
  // is stored once per name: numbered, the second's change still shows.
@@ -829,7 +865,11 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
829
865
  if (inputs !== undefined && declaredInputs.length > 0 && inputs.files.length === 0) {
830
866
  warnOf(log)(
831
867
  `[vx] ${node.id}: inputs matched no files (${declaredInputs.join(', ')}) — ` +
832
- `the key will not change when this project's source does`,
868
+ `the key will not change when this project's source does` +
869
+ bracketLiteralHint([
870
+ ...(cfg.inputs?.files ?? []).map((g) => [node.projectDir, g] as const),
871
+ ...(cfg.inputs?.workspaceFiles ?? []).map((g) => [args.workspaceRoot, g] as const),
872
+ ]),
833
873
  )
834
874
  }
835
875
 
@@ -902,6 +942,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
902
942
  const cleanedWsRels = await cleanWorkspaceOutputs(wsCleanArgs)
903
943
  args.gitFilesCache?.markWorkspaceOutputsChanged(args.workspaceRoot, cleanedWsRels)
904
944
  }
945
+ // What the attempt before this one left, put back if this one is
946
+ // withdrawn: it stands, with its output and its refusals.
947
+ const standing = { output, violations, flushMasked }
905
948
  violations = []
906
949
  const endReq = span('miss: build request')
907
950
  const req = await buildRequest(isLocalExecutor(args.executor))
@@ -910,6 +953,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
910
953
  // cleans or builds its request, after the loop's own check: the retry
911
954
  // is withdrawn and the attempt before it stands.
912
955
  if (attempt > 1 && args.failFast?.aborted === true) {
956
+ output = standing.output
957
+ violations = standing.violations
958
+ flushMasked = standing.flushMasked
913
959
  clearTimeout(timeoutTimer)
914
960
  unlistenStop?.()
915
961
  unlistenStop = undefined
@@ -931,6 +977,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
931
977
  node.projectDir,
932
978
  commandWriteReach(node, args.workspaceRoot),
933
979
  ) ?? (() => {})
980
+ args.executing?.(node.id)
934
981
  try {
935
982
  running = Promise.resolve(args.executor.execute(req))
936
983
  } catch (thrown) {
@@ -950,8 +997,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
950
997
  const why = secrets?.mask(raw.message) ?? raw.message
951
998
  if (remoteOnly) {
952
999
  raw = new UserError(`${why}, and remote: 'only' keeps it off this machine`)
1000
+ } else if (args.executor.exclusive === true) {
1001
+ raw = new UserError(`${why}, and ${args.executor.name} runs no task on this machine`)
953
1002
  } else {
954
- warnOf(log)(`[vx] ${node.id}: ${why} — running it here`)
1003
+ warnOf(log)(
1004
+ `[vx] ${node.id}: ${executorLabel(args.executor)} gave it back: ${why} — running it here`,
1005
+ )
955
1006
  const local = await localExecutor().execute(await buildRequest(true))
956
1007
  assertExecuteResult('local', node.id, local)
957
1008
  return local
@@ -977,6 +1028,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
977
1028
  load = untrack?.() ?? load
978
1029
  untrack = undefined
979
1030
  })
1031
+ .catch((thrown: unknown) => {
1032
+ // The scheduler prints the throw's line into the task's stream,
1033
+ // after the masked tail the finally above flushed.
1034
+ if (midLine) log.taskStderr(node, '\n')
1035
+ throw thrown
1036
+ })
980
1037
  endExec()
981
1038
  if (secrets !== null)
982
1039
  res = {
@@ -1016,7 +1073,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1016
1073
  // before the strace pass shipped, false since, and the branch below is
1017
1074
  // live on Linux.) Violations surface via `TaskOutcome.sandboxViolationLines`.
1018
1075
  let code = res.exitCode
1019
- // ANY violation fails the task (owner, 2026-09-05). A sandboxed task
1076
+ // ANY violation fails the task (2026-09-05). A sandboxed task
1020
1077
  // declares what it touches; a denial means it touched something else,
1021
1078
  // and an artifact built while reading — or failing to read — something
1022
1079
  // the cache key never folded is not a safe thing to replay. A task that
@@ -1032,7 +1089,10 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1032
1089
  // 137 (item 1063).
1033
1090
  if (res.timedOut) {
1034
1091
  const how = res.signal === 'SIGKILL' ? 'SIGKILL after the SIGTERM grace' : 'SIGTERM'
1035
- log.taskStderr(node, `\n[vx] timed out after ${effectiveTimeout}ms — killed (${how})\n`)
1092
+ log.taskStderr(
1093
+ node,
1094
+ `${midLine ? '\n' : ''}[vx] timed out after ${effectiveTimeout}ms — killed (${how})\n`,
1095
+ )
1036
1096
  // Force a non-zero classification even if the child TRAPPED SIGTERM and
1037
1097
  // still exited 0 (`trap 'exit 0' TERM`, a common graceful-shutdown
1038
1098
  // pattern). Without this a timed-out task is classified `success` and its
@@ -1047,7 +1107,15 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1047
1107
  // about what sent it (259). One frame line names the rule. A spawn that
1048
1108
  // threw ran no shell: its 127 is vx's, and the line would send the
1049
1109
  // reader after a command that exists (A-41). A run's stop killed it
1050
- // itself: the SIGKILL past the grace read as the OOM killer's.
1110
+ // itself: the SIGKILL past the grace read as the OOM killer's. A
1111
+ // SIGINT/SIGTERM death waits a turn for a pending handler first, as the
1112
+ // shutdown check below does: a Ctrl-C reaches the child and vx together.
1113
+ if (
1114
+ !res.timedOut &&
1115
+ (res.signal === 'SIGINT' || res.signal === 'SIGTERM') &&
1116
+ args.stopSignal?.aborted !== true
1117
+ )
1118
+ await new Promise((resolve) => setTimeout(resolve, 0))
1051
1119
  if (!res.timedOut && res.spawnFailed !== true && args.stopSignal?.aborted !== true) {
1052
1120
  const verdict = shellVerdict({
1053
1121
  code,
@@ -1059,7 +1127,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1059
1127
  })
1060
1128
  // The line quotes the command's first word, which a config may have
1061
1129
  // built from a secret: masked as the task's own output is (L-37).
1062
- if (verdict !== undefined) log.taskStderr(node, `\n${secrets?.mask(verdict) ?? verdict}\n`)
1130
+ if (verdict !== undefined)
1131
+ log.taskStderr(node, `${midLine ? '\n' : ''}${secrets?.mask(verdict) ?? verdict}\n`)
1063
1132
  }
1064
1133
  return { result: res, exitCode: code }
1065
1134
  }
@@ -1075,7 +1144,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1075
1144
  await args.deferred.materializeFor(node)
1076
1145
  } catch (err) {
1077
1146
  const msg = err instanceof Error ? err.message : String(err)
1078
- log.taskStderr(node, `\n[vx] ${msg}\n`)
1147
+ // Nothing ran: the note is the frame's first line.
1148
+ log.taskStderr(node, `[vx] ${msg}\n`)
1079
1149
  return {
1080
1150
  node,
1081
1151
  status: 'failed',
@@ -1088,14 +1158,19 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1088
1158
  }
1089
1159
  }
1090
1160
 
1091
- // The values of secret-named variables, masked in what the task prints
1092
- // and what the cache keeps of it (L-11); null when there are none.
1093
- const secrets = secretMask([process.env, env, step.env?.define], step.env?.secret)
1094
1161
  let flushMasked = (): void => {}
1162
+ // The attempt's output, both streams, last ended mid-line: vx's own line
1163
+ // (a retry, a timeout, the shell's verdict, an abandoned or thrown
1164
+ // executor) starts its own, and only then: a newline always before it
1165
+ // printed a blank line under the OUTPUT rule or the task's last line. Glued, a frame read
1166
+ // `partialvx: retrying …`.
1167
+ let midLine = false
1095
1168
  // The attempt's output as the logger got it (masked): the entry's when
1096
1169
  // the task saves, else only what a failure reports (`failedOutput`).
1097
1170
  let output: BoundedCapture | undefined
1098
- const storedOutput = (): string => encodeOutputLog(output?.chunks() ?? [])
1171
+ // Masked again whole: the live stream lets a held piece go after a pause.
1172
+ const storeMask = secrets ?? undefined
1173
+ const storedOutput = (): string => encodeOutputLog(output?.chunks() ?? [], storeMask)
1099
1174
  const failedAttempts: { endedAt: number; exitCode: number; timedOut?: true }[] = []
1100
1175
  // The sampling of the attempt's process tree, stopped once it settles.
1101
1176
  let untrack: (() => TreeLoad | undefined) | undefined
@@ -1184,7 +1259,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1184
1259
  })
1185
1260
  log.taskStderr(
1186
1261
  node,
1187
- `vx: retrying ${node.id} (attempt ${attempt + 1}/${maxAttempts}) after ${result.timedOut === true ? 'a timeout' : `exit ${effectiveExitCode}`}\n`,
1262
+ `${midLine ? '\n' : ''}vx: retrying ${node.id} (attempt ${attempt + 1}/${maxAttempts}) after ${result.timedOut === true ? 'a timeout' : `exit ${effectiveExitCode}`}\n`,
1188
1263
  )
1189
1264
  }
1190
1265
 
@@ -1194,20 +1269,19 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1194
1269
  ? new BoundedCapture()
1195
1270
  : new BoundedCapture(FAILED_OUTPUT_HEAD_CHARS, FAILED_OUTPUT_TAIL_CHARS)
1196
1271
  output = kept
1272
+ midLine = false
1197
1273
  const toOut = (t: string): void => {
1198
1274
  kept.push(t)
1275
+ if (t.length > 0) midLine = !t.endsWith('\n')
1199
1276
  log.taskStdout(node, t)
1200
1277
  }
1201
1278
  const toErr = (t: string): void => {
1202
1279
  kept.push(t, true)
1280
+ if (t.length > 0) midLine = !t.endsWith('\n')
1203
1281
  log.taskStderr(node, t)
1204
1282
  }
1205
- const out = secrets && maskedEmitter(secrets, toOut)
1206
- const err = secrets && maskedEmitter(secrets, toErr)
1207
- flushMasked = () => {
1208
- out?.end()
1209
- err?.end()
1210
- }
1283
+ const masked = secrets && maskedStreams(secrets, toOut, toErr)
1284
+ flushMasked = () => masked?.end()
1211
1285
  const base: ExecuteRequest = {
1212
1286
  taskId: node.id,
1213
1287
  workspaceRoot: args.workspaceRoot,
@@ -1217,8 +1291,8 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1217
1291
  env,
1218
1292
  envDefine: step.env?.define ?? {},
1219
1293
  capture,
1220
- onStdout: out ? (chunk) => out.push(chunk) : toOut,
1221
- onStderr: err ? (chunk) => err.push(chunk) : toErr,
1294
+ onStdout: masked ? masked.out : toOut,
1295
+ onStderr: masked ? masked.err : toErr,
1222
1296
  ...(args.liveChildren !== undefined ? { liveChildren: args.liveChildren } : {}),
1223
1297
  ...(args.track !== undefined
1224
1298
  ? {
@@ -1231,7 +1305,9 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1231
1305
  ...(effectiveTimeout !== undefined ? { timeoutMs: effectiveTimeout } : {}),
1232
1306
  ...(inputs !== undefined ? { inputs } : {}),
1233
1307
  ...(cfgCacheable ? { cacheKey: hash } : {}),
1308
+ ...(cfgCacheable && !remoteOnly && !willSave ? { unsaved: true } : {}),
1234
1309
  ...(remoteOnly ? { remoteOnly: true } : {}),
1310
+ ...(node.platform !== undefined ? { platform: node.platform } : {}),
1235
1311
  ...(deferralRequested ? { download: 'deferred' as const } : {}),
1236
1312
  // `--force`/`--no-cache` reach a remote executor's private record
1237
1313
  // through this flag — the policy gates above only cover vx's OWN cache.
@@ -1240,7 +1316,11 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1240
1316
  ...(args.terminal === true ? { terminal: true as const } : {}),
1241
1317
  ...(args.servers === undefined
1242
1318
  ? {}
1243
- : { servers: args.servers.map((s) => serverSpec(s, args.workspaceRoot)) }),
1319
+ : {
1320
+ servers: args.servers.map((s, i) =>
1321
+ serverSpec(s, args.workspaceRoot, args.serversNested?.[i] ?? []),
1322
+ ),
1323
+ }),
1244
1324
  }
1245
1325
  // The signal last: it arms a plugin's `exec.timeout`, and the sandbox's arming
1246
1326
  // below is vx's work, not the task's. Armed first, a 60 ms timeout
@@ -1267,7 +1347,11 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1267
1347
  let movedFails: string | undefined
1268
1348
  let own: Awaited<ReturnType<typeof ownOutputs>>
1269
1349
  let saved = false
1270
- if (effectiveExitCode === 0 && willSave && deferralRequested) {
1350
+ if (
1351
+ effectiveExitCode === 0 &&
1352
+ willSave &&
1353
+ (deferralRequested || result.outputs?.kind === 'deferred')
1354
+ ) {
1271
1355
  // The outputs never landed here: no artifact, no rows. A partial local
1272
1356
  // record (a row with no artifact) is exactly the corrupt-entry shape
1273
1357
  // `restoreOutputs` refuses, so writing none is the only clean answer.
@@ -1393,6 +1477,12 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1393
1477
  */
1394
1478
  async function movedSinceKey(): Promise<string | null | undefined> {
1395
1479
  if (described!.hash !== hash) return null
1480
+ // Changed where an executor ran it: this disk never shows it.
1481
+ if (result.changed !== undefined && result.changed.length > 0) {
1482
+ const changed = new Set(result.changed.map((f) => path.join(args.workspaceRoot, f)))
1483
+ const there = keyedInputs(described!.facts).find((f) => changed.has(f.path))
1484
+ if (there !== undefined) return there.path
1485
+ }
1396
1486
  return (
1397
1487
  (await movedInput(
1398
1488
  keyedInputs(described!.facts),
@@ -1483,7 +1573,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1483
1573
  ...(keylessBy !== undefined ? { keylessBy } : {}),
1484
1574
  ...(saved ? { saved: true as const } : {}),
1485
1575
  ...(effectiveExitCode !== 0 && output !== undefined
1486
- ? { failedOutput: failedOutputLog(output, willSave || args.keepRun !== undefined) }
1576
+ ? { failedOutput: failedOutputLog(output, willSave || args.keepRun !== undefined, storeMask) }
1487
1577
  : {}),
1488
1578
  ...(args.keepRun !== undefined &&
1489
1579
  (effectiveExitCode !== 0 ? args.keepRun.failures : !willSave) &&
@@ -1497,11 +1587,13 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1497
1587
  ...(load !== undefined ? { peakCores: load.peakCores, avgRssBytes: load.avgRssBytes } : {}),
1498
1588
  ...(result.where !== undefined ? { where: result.where } : {}),
1499
1589
  ...(result.where !== undefined && result.machine !== undefined
1500
- ? { whereCores: result.machine.cores, whereMemoryBytes: result.machine.memoryBytes }
1501
- : {}),
1502
- ...(deferralRequested && result.outputs?.kind === 'deferred'
1503
- ? { outputs: 'deferred' as const }
1590
+ ? {
1591
+ whereCores: result.machine.cores,
1592
+ whereMemoryBytes: result.machine.memoryBytes,
1593
+ ...(result.machine.cpuShare === undefined ? {} : { cpuShare: result.machine.cpuShare }),
1594
+ }
1504
1595
  : {}),
1596
+ ...(result.outputs?.kind === 'deferred' ? { outputs: 'deferred' as const } : {}),
1505
1597
  wallclockStartNs,
1506
1598
  wallclockEndNs,
1507
1599
  ...(finalViolations.length > 0
@@ -85,7 +85,7 @@ export class FingerprintWatch {
85
85
  this.said = true
86
86
  warnOf(log)(
87
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`,
88
+ `puts into every key: nothing keyed before it is restored or saved from here on`,
89
89
  )
90
90
  }
91
91
  }
@@ -0,0 +1,140 @@
1
+ // The task graph memo: the graph a run built, read back whole when nothing
2
+ // it is built from has moved. Building 1,206 tasks' graph was 4.5 ms of a
3
+ // warm run (2026-10-23), nearly all of it code a short process runs cold;
4
+ // reading it back is half that.
5
+ //
6
+ // A run's configs come from the lock and nowhere else when no plugin fills
7
+ // the `project` stage, so the token names them by the lock's bytes instead
8
+ // of serializing them (3 ms at 1,206 tasks): the bytes, the projects
9
+ // discovered and loaded, their package edges, the requested tasks, the
10
+ // rules, the root and the code are everything `buildTaskGraph` reads. A
11
+ // scoped load hands undeclared `^name`s back instead of refusing them, so
12
+ // it is never remembered.
13
+
14
+ import type { FoldMemo } from '../cache/index.js'
15
+ import { buildTaskGraph, declaredTask, type TaskNode } from '../graph/index.js'
16
+ import { xxh3hex } from '../util/index.js'
17
+ import type { ProjectMeta } from '../workspace/index.js'
18
+ import { codeIdentity } from './run-keys.js'
19
+
20
+ type BuildGraphOptions = Parameters<typeof buildTaskGraph>[0]
21
+
22
+ /** Moves every remembered graph: bump it when what a token stands for changes. */
23
+ const GRAPH_MEMO_VERSION = 'graph-memo-v1'
24
+
25
+ /** What names a run's configs: the lock they were read from and the projects discovered. */
26
+ export interface GraphMemo {
27
+ folds: FoldMemo
28
+ lock: string
29
+ metas: readonly ProjectMeta[]
30
+ }
31
+
32
+ /**
33
+ * A node as the memo holds it: its project and task, requested, its deps
34
+ * and the tasks it adds outputs to as positions in the list (ids in full
35
+ * were 123 KB for 1,206 tasks, and parsing them back cost what the walk
36
+ * saved), and the globs that add to its own outputs.
37
+ */
38
+ type Row = [
39
+ project: string,
40
+ task: string,
41
+ requested: 0 | 1,
42
+ deps: number[],
43
+ adds?: number[] | null,
44
+ added?: string[] | null,
45
+ ]
46
+
47
+ /**
48
+ * `buildTaskGraph`, answered from the memo when it built this graph before,
49
+ * and the token that names the graph (undefined when not remembered).
50
+ */
51
+ export function memoGraph(
52
+ options: BuildGraphOptions,
53
+ memo: GraphMemo | undefined,
54
+ ): { nodes: Map<string, TaskNode>; token: string | undefined } {
55
+ if (memo === undefined || options.undeclaredDeps !== undefined) {
56
+ return { nodes: buildTaskGraph(options), token: undefined }
57
+ }
58
+ const { projects, packageGraph } = options
59
+ const parts: string[] = [
60
+ GRAPH_MEMO_VERSION,
61
+ codeIdentity(),
62
+ memo.lock,
63
+ options.workspaceRoot ?? '',
64
+ JSON.stringify(options.rules ?? null),
65
+ String(packageGraph.edgeSource),
66
+ JSON.stringify(options.requested),
67
+ ]
68
+ for (const m of memo.metas) {
69
+ parts.push(
70
+ m.name,
71
+ m.dir,
72
+ m.configPath ?? '\u0001',
73
+ packageGraph.directDeps(m.name).join('\u0001'),
74
+ )
75
+ }
76
+ for (const name of projects.keys()) parts.push(name)
77
+ const id = `\0graph\0${xxh3hex(JSON.stringify(options.requested))}`
78
+ const token = xxh3hex(parts.join('\0'))
79
+ const row = memo.folds.get(id, token)
80
+ if (row !== undefined) {
81
+ const nodes = rebuilt(row, projects)
82
+ if (nodes !== undefined) return { nodes, token }
83
+ }
84
+ const nodes = buildTaskGraph(options)
85
+ memo.folds.set(id, token, JSON.stringify(rowsOf(nodes)))
86
+ return { nodes, token }
87
+ }
88
+
89
+ function rowsOf(nodes: Map<string, TaskNode>): Row[] {
90
+ const at = new Map<string, number>()
91
+ for (const id of nodes.keys()) at.set(id, at.size)
92
+ const pos = (ids: readonly string[]): number[] => ids.map((d) => at.get(d)!)
93
+ return [...nodes.values()].map((n) => {
94
+ const row: Row = [n.projectName, n.taskName, n.requested ? 1 : 0, pos(n.deps)]
95
+ if (n.addsToOutputsOf !== undefined || n.outputsAddedToBy !== undefined)
96
+ row.push(
97
+ n.addsToOutputsOf === undefined ? null : pos(n.addsToOutputsOf),
98
+ n.outputsAddedToBy ?? null,
99
+ )
100
+ return row
101
+ })
102
+ }
103
+
104
+ function rebuilt(
105
+ row: string,
106
+ projects: BuildGraphOptions['projects'],
107
+ ): Map<string, TaskNode> | undefined {
108
+ let rows: Row[]
109
+ try {
110
+ rows = JSON.parse(row)
111
+ } catch {
112
+ return undefined
113
+ }
114
+ const list: TaskNode[] = []
115
+ for (const [projectName, taskName, requested] of rows) {
116
+ const project = projects.get(projectName)
117
+ const config = declaredTask(project?.config, taskName)
118
+ if (project === undefined || config === undefined) return undefined
119
+ list.push({
120
+ id: `${projectName}#${taskName}`,
121
+ projectName,
122
+ projectDir: project.dir,
123
+ taskName,
124
+ config,
125
+ deps: [],
126
+ requested: requested === 1,
127
+ })
128
+ }
129
+ const ids = (at: readonly number[]): string[] => at.map((i) => list[i]!.id)
130
+ const nodes = new Map<string, TaskNode>()
131
+ for (let i = 0; i < rows.length; i++) {
132
+ const [, , , deps, adds, added] = rows[i]!
133
+ const node = list[i]!
134
+ node.deps = ids(deps)
135
+ if (adds != null) node.addsToOutputsOf = ids(adds)
136
+ if (added != null) node.outputsAddedToBy = added
137
+ nodes.set(node.id, node)
138
+ }
139
+ return nodes
140
+ }
@@ -2,11 +2,13 @@
2
2
  // CLI's `--affected` task selection (`vx show <task> --affected`), so both
3
3
  // select over the graph the hooks leave.
4
4
 
5
+ import path from 'node:path'
5
6
  import type { GraphRules, TaskNode } from '../graph/index.js'
7
+ import { relPosix, UserError } from '../util/index.js'
6
8
  import { type ProjectMeta, validateProjectConfig } from '../workspace/index.js'
7
9
  import { applyGraphHooks } from './plugin-host.js'
8
10
  import type { VxPlugin } from './plugin.js'
9
- import { isDefaultBuild } from './projects.js'
11
+ import { isDefaultBuild } from '../graph/index.js'
10
12
 
11
13
  export async function applyGraphStage(
12
14
  plugins: readonly VxPlugin[],
@@ -23,6 +25,10 @@ export async function applyGraphStage(
23
25
  // edit is held to what the loader accepts from a user: a misspelled
24
26
  // `exec` field ran as an empty command and failed with no reason.
25
27
  const configPaths = new Map(args.projectMetas.map((m) => [m.name, m.configPath]))
28
+ // A node under a directory no project owns, or another project's, ran,
29
+ // keyed and cleaned its outputs there: past the hard project boundary.
30
+ const dirs = new Map(args.projectMetas.map((m) => [m.name, path.resolve(m.dir)]))
31
+ const shown = (dir: string): string => relPosix(args.workspaceRoot, dir) || '.'
26
32
  await applyGraphHooks(
27
33
  plugins,
28
34
  nodes,
@@ -34,6 +40,17 @@ export async function applyGraphStage(
34
40
  },
35
41
  (plugin) => {
36
42
  for (const n of nodes.values()) {
43
+ const dir = dirs.get(n.projectName)
44
+ if (dir === undefined) {
45
+ throw new UserError(
46
+ `plugin '${plugin.name}' failed in graph: ${n.id}'s project ${n.projectName} is not a project in this workspace`,
47
+ )
48
+ }
49
+ if (path.resolve(n.projectDir) !== dir) {
50
+ throw new UserError(
51
+ `plugin '${plugin.name}' failed in graph: ${n.id}'s projectDir is ${shown(n.projectDir)}, not project ${n.projectName}'s directory ${shown(dir)}`,
52
+ )
53
+ }
37
54
  if (isDefaultBuild(n.config)) continue
38
55
  const own = configPaths.get(n.projectName) ?? null
39
56
  validateProjectConfig(
@@ -1,7 +1,7 @@
1
1
  // A server the run's local store cannot settle: some dependant missed here
2
2
  // but may be a hit the remote holds. The scheduler holds such a server
3
3
  // until the remote answers; when every dependant hit, the server never
4
- // starts (M 2026-10-22), as it never starts behind local hits.
4
+ // starts (2026-10-22), as it never starts behind local hits.
5
5
 
6
6
  import type { TaskNode } from '../graph/index.js'
7
7
  import type { CacheEntry, CacheLayer } from '../cache/index.js'