@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
@@ -4,16 +4,19 @@
4
4
  // RS (U+001E) + `e` / `o` where the stream switches to stderr / stdout, and
5
5
  // a literal RS doubled. Output that never touched stderr is its own text.
6
6
 
7
- import { BoundedCapture, type CapturedChunk } from '../exec/index.js'
7
+ import { type BoundedCapture, type CapturedChunk, isDroppedOutputChunk } from '../exec/index.js'
8
+ import { MASKED, type SecretMask } from '../util/index.js'
8
9
 
9
10
  const RS = '\x1e'
10
11
 
11
- /** `chunks` as one stored string; it starts on stdout. */
12
- export function encodeOutputLog(chunks: readonly CapturedChunk[]): string {
12
+ /**
13
+ * `chunks` as one stored string; it starts on stdout. `mask` (the task's
14
+ * secrets) is applied to each stream's whole text (`maskStreams`).
15
+ */
16
+ export function encodeOutputLog(chunks: readonly CapturedChunk[], mask?: SecretMask): string {
13
17
  let out = ''
14
18
  let err = false
15
- for (const c of chunks) {
16
- if (c.text.length === 0) continue
19
+ for (const c of maskStreams(chunks, mask)) {
17
20
  if (c.err !== err) {
18
21
  out += RS + (c.err ? 'e' : 'o')
19
22
  err = c.err
@@ -23,6 +26,76 @@ export function encodeOutputLog(chunks: readonly CapturedChunk[]): string {
23
26
  return out
24
27
  }
25
28
 
29
+ /**
30
+ * `encodeOutputLog` with `mask` applied, for a capture of raw output: a
31
+ * value the capture's dropped middle cut left a piece either side, which
32
+ * only `maskCut` sees, as `maskCaptured` does for core's own capture.
33
+ */
34
+ export function maskedOutputLog(chunks: readonly CapturedChunk[], mask: SecretMask): string {
35
+ const at = chunks.findIndex(isDroppedOutputChunk)
36
+ if (at === -1) return encodeOutputLog(chunks, mask)
37
+ const before = chunks.slice(0, at)
38
+ const tail = encodeOutputLog(chunks.slice(at + 1))
39
+ // The dropped line is on stdout, where the tail's own log starts.
40
+ const lead = tail.startsWith(RS + 'e') ? RS + 'e' : ''
41
+ const [h, t] = mask.maskCut(encodeOutputLog(before), tail.slice(lead.length))
42
+ const back = before.findLast((c) => c.text.length > 0)?.err === true ? RS + 'o' : ''
43
+ return h + back + chunks[at]!.text + lead + t
44
+ }
45
+
46
+ /**
47
+ * Each stream's run of adjacent non-empty chunks joined, with `mask`
48
+ * applied to each stream's text as a whole: a value is masked where it
49
+ * starts and its rest dropped from the runs it reaches. The live stream
50
+ * masks chunk by chunk and lets a held piece go after a pause, so a value
51
+ * written in two pieces reached the capture in two chunks, and a write to
52
+ * the other stream between them made them two runs. A hit replays each
53
+ * stream to its own descriptor, so `vx run > log` joined them again.
54
+ */
55
+ export function maskStreams(
56
+ chunks: readonly CapturedChunk[],
57
+ mask: SecretMask | undefined,
58
+ ): CapturedChunk[] {
59
+ if (mask === undefined) return chunks.filter((c) => c.text.length > 0)
60
+ const runs: CapturedChunk[] = []
61
+ for (const c of chunks) {
62
+ if (c.text.length === 0) continue
63
+ const last = runs.at(-1)
64
+ if (last !== undefined && last.err === c.err) last.text += c.text
65
+ else runs.push({ text: c.text, err: c.err })
66
+ }
67
+ for (const err of [false, true]) {
68
+ const own = runs.filter((r) => r.err === err)
69
+ const spans = mask.spans(own.map((r) => r.text).join(''))
70
+ if (spans.length === 0) continue
71
+ let at = 0
72
+ let i = 0
73
+ for (const r of own) {
74
+ const end = at + r.text.length
75
+ let text = ''
76
+ let from = at
77
+ for (; i < spans.length && spans[i]![0] < end; i++) {
78
+ const [s, e] = spans[i]!
79
+ if (s >= from) text += r.text.slice(from - at, s - at) + MASKED
80
+ from = Math.max(from, Math.min(e, end))
81
+ // A value that runs past this run is cut from the next one too.
82
+ if (e > end) break
83
+ }
84
+ r.text = text + r.text.slice(from - at)
85
+ at = end
86
+ }
87
+ }
88
+ // A run emptied whole leaves its neighbours, the other stream, adjacent.
89
+ const kept: CapturedChunk[] = []
90
+ for (const r of runs) {
91
+ if (r.text.length === 0) continue
92
+ const last = kept.at(-1)
93
+ if (last !== undefined && last.err === r.err) last.text += r.text
94
+ else kept.push(r)
95
+ }
96
+ return kept
97
+ }
98
+
26
99
  /** The chunks `encodeOutputLog` wrote, adjacent same-stream text joined. */
27
100
  export function decodeOutputLog(log: string): CapturedChunk[] {
28
101
  if (!log.includes(RS)) return log.length === 0 ? [] : [{ text: log, err: false }]
@@ -58,9 +131,16 @@ export const FAILED_OUTPUT_HEAD_CHARS = 8 * 1024
58
131
  export const FAILED_OUTPUT_TAIL_CHARS = 56 * 1024
59
132
 
60
133
  /** A failed attempt's capture, re-bounded to the failure's limits when it is the entry's. */
61
- export function failedOutputLog(capture: BoundedCapture, entrySized: boolean): string {
62
- if (!entrySized) return encodeOutputLog(capture.chunks())
63
- const small = new BoundedCapture(FAILED_OUTPUT_HEAD_CHARS, FAILED_OUTPUT_TAIL_CHARS)
64
- for (const c of capture.chunks()) small.push(c.text, c.err)
65
- return encodeOutputLog(small.chunks())
134
+ export function failedOutputLog(
135
+ capture: BoundedCapture,
136
+ entrySized: boolean,
137
+ mask?: SecretMask,
138
+ ): string {
139
+ if (!entrySized) return encodeOutputLog(capture.chunks(), mask)
140
+ // Masked before the re-bound: its cut must not split a value first.
141
+ return encodeOutputLog(
142
+ capture
143
+ .rebound(FAILED_OUTPUT_HEAD_CHARS, FAILED_OUTPUT_TAIL_CHARS, (c) => maskStreams(c, mask))
144
+ .chunks(),
145
+ )
66
146
  }
@@ -4,15 +4,17 @@
4
4
 
5
5
  import type { ExecConfig } from '../config.js'
6
6
  import {
7
+ lineEnds,
7
8
  PersistentReadyError,
8
9
  releaseBridges,
9
10
  runPersistent,
11
+ sandboxReads,
10
12
  signalExitCode,
11
13
  withForwardArgs,
12
14
  wrapSandboxedCommand,
13
15
  } from '../exec/index.js'
14
16
  import type { TaskOutcome } from '../graph/index.js'
15
- import { maskedEmitter, secretMask, testTimerMs } from '../util/index.js'
17
+ import { maskedStreams, secretMask, testTimerMs } from '../util/index.js'
16
18
  import { warnOf } from '../report/index.js'
17
19
  import type { ExecuteArgs } from './execute-task.js'
18
20
  import {
@@ -25,13 +27,43 @@ import {
25
27
  undeclaredWriteReach,
26
28
  untouchedPlaceholderLine,
27
29
  } from './sandbox-request.js'
30
+ import { shellVerdict } from './shell-verdict.js'
28
31
  import { forwardedSignal, terminateChildren } from './signals.js'
29
32
  import { computeTaskHash } from './task-hash.js'
30
- import { forgetUndeclaredWrites, isAborted, taskEnv, taskTimeoutMs } from './task-process.js'
33
+ import {
34
+ forgetUndeclaredWrites,
35
+ isAborted,
36
+ taskBinDirs,
37
+ taskEnv,
38
+ taskTimeoutMs,
39
+ } from './task-process.js'
31
40
 
32
41
  /** How long a server may take to match `readyWhen` before vx says it waits. */
33
42
  const READY_NOTICE_MS = 10_000
34
43
 
44
+ /**
45
+ * A server is keyed on its inputs like any task, so a cached dependant
46
+ * behind a dev server re-runs when the server's sources change; with none
47
+ * it has no key and the dependant always runs (A-17). A server the watch
48
+ * loop keeps up is keyed too: its dependant folds the key either way.
49
+ */
50
+ export function serverKey(args: ExecuteArgs): Promise<string | undefined> | string | undefined {
51
+ if (args.noDependants === true) return undefined
52
+ return (
53
+ args.upfrontKey ??
54
+ computeTaskHash({
55
+ node: args.node,
56
+ upstream: args.upstream,
57
+ workspaceRoot: args.workspaceRoot,
58
+ cache: args.cache,
59
+ forwardArgs: args.forwardArgs,
60
+ nestedProjectDirs: args.nestedProjectDirs,
61
+ ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
62
+ ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
63
+ })
64
+ )
65
+ }
66
+
35
67
  /**
36
68
  * Persistent task: dev server / file watcher / daemon. Spawn, wait
37
69
  * for ready (regex match or immediate), stash the subprocess in the
@@ -55,26 +87,10 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
55
87
  const effectiveForwardArgs = node.requested ? (args.forwardArgs ?? []) : []
56
88
  const env = taskEnv(node, step, args.workspaceRoot)
57
89
  const wallclockStartNs = process.hrtime.bigint() - args.runStartHrTimeNs
58
- // Keyed on its inputs like any task, so a cached dependant behind a dev
59
- // server re-runs when the server's sources change; with none it has no
60
- // key and the dependant always runs (A-17).
61
- const hash =
62
- args.noDependants === true
63
- ? undefined
64
- : (args.upfrontKey ??
65
- (await computeTaskHash({
66
- node,
67
- upstream: args.upstream,
68
- workspaceRoot: args.workspaceRoot,
69
- cache: args.cache,
70
- forwardArgs: args.forwardArgs,
71
- nestedProjectDirs: args.nestedProjectDirs,
72
- ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
73
- ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
74
- })))
90
+ const hash = await serverKey(args)
75
91
 
76
92
  // Every dependant starts it where it runs (`ServerSpec`): nothing to spawn
77
- // here, and nothing that ran (owner, 2026-10-22: nothing runs on a
93
+ // here, and nothing that ran (2026-10-22: nothing runs on a
78
94
  // distributed run's orchestrator).
79
95
  if (args.carried === true)
80
96
  return {
@@ -82,6 +98,7 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
82
98
  status: 'success',
83
99
  exitCode: 0,
84
100
  durationMs: 0,
101
+ carried: true,
85
102
  ...(hash === undefined ? {} : { hash }),
86
103
  wallclockStartNs,
87
104
  wallclockEndNs: wallclockStartNs,
@@ -106,6 +123,7 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
106
123
  // server that dies before readiness is told the directory spelling — the
107
124
  // same trap and the same answer as a one-shot task's (sandbox-request.ts).
108
125
  let placeholders: Placeholder[] = []
126
+ let serverSandbox: Awaited<ReturnType<typeof sandboxRequestFor>>['sandbox'] | undefined
109
127
  if (step.sandbox !== undefined) {
110
128
  await args.armSandbox?.()
111
129
  // A persistent task never declares `cache` (the loader refuses it), so
@@ -119,6 +137,7 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
119
137
  args.cacheDir,
120
138
  )
121
139
  placeholders = sb.placeholders
140
+ serverSandbox = sb.sandbox
122
141
  // A wrap that refuses (a port the host holds) leaves the server unspawned
123
142
  // and the placeholders vx made for it in the project for every later run.
124
143
  const wrapped = await wrapSandboxedCommand({
@@ -139,16 +158,21 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
139
158
  // is matched on the raw chunks, before this. Its held tail is flushed by
140
159
  // the idle timer: a server never ends on the way to a flush.
141
160
  const serverSecrets = secretMask([process.env, env, step.env?.define], step.env?.secret)
142
- const serverOut = serverSecrets && maskedEmitter(serverSecrets, (t) => log.taskStdout(node, t))
143
- const serverErr = serverSecrets && maskedEmitter(serverSecrets, (t) => log.taskStderr(node, t))
144
161
  // A server writes until the run stops it: its window stays open.
145
162
  args.commandWindows?.open(node.id, node.projectDir, commandWriteReach(node, args.workspaceRoot))
163
+ // Whether the server's output ends mid-line, for vx's never-ready note.
164
+ const ends = lineEnds(
165
+ (t) => log.taskStdout(node, t),
166
+ (t) => log.taskStderr(node, t),
167
+ )
168
+ // Masked as one: a stream's held tail goes out before the other's next chunk.
169
+ const serverMasked = serverSecrets && maskedStreams(serverSecrets, ends.onStdout!, ends.onStderr!)
146
170
  const persistentOpts: Parameters<typeof runPersistent>[0] = {
147
171
  command,
148
172
  cwd: node.projectDir,
149
173
  env,
150
- onStdout: serverOut ? (chunk) => serverOut.push(chunk) : (chunk) => log.taskStdout(node, chunk),
151
- onStderr: serverErr ? (chunk) => serverErr.push(chunk) : (chunk) => log.taskStderr(node, chunk),
174
+ onStdout: serverMasked ? serverMasked.out : ends.onStdout!,
175
+ onStderr: serverMasked ? serverMasked.err : ends.onStderr!,
152
176
  ...(args.liveChildren !== undefined ? { liveChildren: args.liveChildren } : {}),
153
177
  ...(args.track !== undefined
154
178
  ? { onSpawn: (pid: number) => void args.track!(node.id, pid) }
@@ -235,8 +259,11 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
235
259
  // A shell that exited before ready can leave its group running
236
260
  // (`server & …; exit`): never registered, so no teardown reached it,
237
261
  // and it held its port against the next `vx watch` cycle's server.
238
- if (err instanceof PersistentReadyError && err.reason === 'exited')
239
- await terminateChildren(() => [spawn.child])
262
+ if (err instanceof PersistentReadyError && err.reason === 'exited' && spawn.child !== undefined)
263
+ await terminateChildren(() => [spawn.child!])
264
+ // What the masked streams hold back (a tail that could begin a secret)
265
+ // belongs above vx's own line: left to the idle flush, it came after it.
266
+ serverMasked?.end()
240
267
  // A server the run's stop killed while it started is aborted, as any
241
268
  // task the stop kills (item 962): it read `failed (never ready:
242
269
  // exited, exit 130)` with a recap after every Ctrl-C (C-62). The stop
@@ -257,7 +284,30 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
257
284
  // The task's OWN stream, not the process's: the frame is where a
258
285
  // reader looks for why a task failed, and a run with a custom logger
259
286
  // (an embedder) never saw a bare stderr write at all.
260
- log.taskStderr(node, `\n[vx] ${node.id}: persistent task failed to become ready: ${message}\n`)
287
+ log.taskStderr(
288
+ node,
289
+ ends.own(`[vx] ${node.id}: persistent task failed to become ready: ${message}\n`),
290
+ )
291
+ // A server's 127, 126 or signal death needs the line a one-shot task's
292
+ // gets (execute-task.ts): a dev server whose tool sits in a sibling's
293
+ // bin said only "exit 127".
294
+ if (
295
+ err instanceof PersistentReadyError &&
296
+ err.reason === 'exited' &&
297
+ err.exitCode !== undefined
298
+ ) {
299
+ const signal = spawn.child?.signalCode
300
+ const verdict = shellVerdict({
301
+ code: err.exitCode,
302
+ command: step.command,
303
+ cwd: node.projectDir,
304
+ bins: taskBinDirs(node, args.workspaceRoot),
305
+ signal: signal ?? undefined,
306
+ hidden: serverSandbox && ((f: string) => !sandboxReads(serverSandbox, f)),
307
+ })
308
+ if (verdict !== undefined)
309
+ log.taskStderr(node, `${serverSecrets?.mask(verdict) ?? verdict}\n`)
310
+ }
261
311
  // A server is never traced, so the sandbox's refusals reach no report:
262
312
  // a dev server that died on a file outside its grants read only as the
263
313
  // tool's own "not found" (2026-10-03). Said for a sandboxed server that
@@ -294,7 +344,7 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
294
344
  // A server that traps the TERM and exits 0 still timed out, as a
295
345
  // one-shot task that does so reports 143 (X-116).
296
346
  exitCode:
297
- ready?.reason === 'timeout'
347
+ ready?.reason === 'timeout' && spawn.child !== undefined
298
348
  ? (await spawn.child.exited) || signalExitCode('SIGTERM')
299
349
  : (ready?.exitCode ?? 1),
300
350
  durationMs: spawn.readyMs(),
@@ -318,16 +368,21 @@ export async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutc
318
368
  wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
319
369
  }
320
370
  }
321
- args.persistentRegistry?.set(node.id, spawn.child)
371
+ // Ready, so it spawned: only a failed spawn has no child.
372
+ const child = spawn.child!
373
+ args.persistentRegistry?.set(node.id, child)
322
374
  forgetUndeclaredWrites(args, undeclaredWriteReach(node, args.workspaceRoot))
323
375
  if (mayWriteFingerprint(node, args.workspaceRoot, args.fingerprintWatch?.names ?? [])) {
324
376
  args.fingerprintWatch?.wrote()
325
377
  }
378
+ // Ready by its clean exit (a setup step): nothing is running any more.
379
+ const exited = child.exitCode
326
380
  return {
327
381
  node,
328
382
  status: 'success',
329
383
  exitCode: 0,
330
384
  durationMs: spawn.readyMs(),
385
+ ...(exited === null ? {} : { serverExit: exited }),
331
386
  ...(hash !== undefined ? { hash } : {}),
332
387
  wallclockStartNs,
333
388
  wallclockEndNs: process.hrtime.bigint() - args.runStartHrTimeNs,
@@ -176,9 +176,8 @@ export function sessionEnd(keepAlive: KeepAlive): Promise<{ code: number; i: num
176
176
 
177
177
  export interface HoldArgs {
178
178
  forwardArgs: readonly string[] | undefined
179
- /** Where a held server's output goes. */
179
+ /** Where a held server's output, and the notice of its death, go. */
180
180
  outOf: (id: string) => HeldServer['out']
181
- warn: (message: string) => void
182
181
  }
183
182
 
184
183
  /** The kept servers handed to the caller (`RunOptions.hold`) instead of waited on. */
@@ -193,17 +192,24 @@ export function holdServers(keepAlive: KeepAlive, args: HoldArgs): HeldPersisten
193
192
  const servers = new Map<string, HeldServer>()
194
193
  keepAlive.nodes.forEach((n, i) => {
195
194
  const child = held[i]!
195
+ const out = args.outOf(n.id)
196
196
  servers.set(n.id, {
197
197
  child,
198
198
  config: serverConfig(n, args.forwardArgs),
199
- out: args.outOf(n.id),
199
+ out,
200
200
  handOver: () => void handed.add(child),
201
201
  })
202
202
  // One that died during the graph was said then.
203
203
  if (hasEnded(child)) return
204
+ // Said where its output goes: a later run that keeps it owns the
205
+ // terminal from its start, and this run's renderer wrote over its rows.
204
206
  void child.exited.then((code) => {
205
207
  if (!stopping && !handed.has(child) && code !== 0)
206
- args.warn(`vx: ${n.id} exited with code ${code}`)
208
+ out.bus.emit({
209
+ kind: 'run:status',
210
+ line: `vx: ${n.id} exited with code ${code}`,
211
+ warn: true,
212
+ })
207
213
  })
208
214
  })
209
215
  return {
@@ -260,6 +266,12 @@ export async function disposeServers(args: {
260
266
  endedBeforeStop: ReadonlySet<Child> | undefined
261
267
  }): Promise<{ keepAlive: KeepAlive; crashed: CrashedPersistent[] }> {
262
268
  const { registry, outcomes, endedBeforeStop } = args
269
+ // One turn of the event loop first, so Bun reaps what has already died:
270
+ // `exitCode` stays null until it does. A server dead at once, with
271
+ // nothing between its spawn and here to yield, was judged still up: the
272
+ // footer counted it a success, pinned it `running`, and a teardown read
273
+ // the run ok over vx's exit 1.
274
+ if (registry.size > 0) await new Promise((resolve) => setTimeout(resolve, 0))
263
275
  let keepAlive = selectKeepAlive(registry, args.nodes, args.keep)
264
276
  const failedElsewhere =
265
277
  ![...outcomes.values()].every((o) => isPassStatus(o.status)) ||