@vzn/vx 0.0.21 → 0.0.23

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 (123) hide show
  1. package/README.md +21 -18
  2. package/package.json +6 -6
  3. package/src/bin.ts +52 -3
  4. package/src/cache/archive.ts +170 -21
  5. package/src/cache/cache.ts +667 -288
  6. package/src/cache/chained-cache.ts +14 -2
  7. package/src/cache/config-evals.ts +29 -6
  8. package/src/cache/file-hashes.ts +63 -35
  9. package/src/cache/git-inputs.ts +398 -69
  10. package/src/cache/index.ts +8 -1
  11. package/src/cache/inputs.ts +403 -102
  12. package/src/cache/key-fold.ts +232 -0
  13. package/src/cache/layer.ts +81 -11
  14. package/src/cache/layered-cache.ts +199 -32
  15. package/src/cache/output-index.ts +109 -10
  16. package/src/cache/run-history.ts +8 -3
  17. package/src/cache/zstd.ts +53 -30
  18. package/src/cli/cache.ts +51 -21
  19. package/src/cli/format.ts +4 -15
  20. package/src/cli/help.ts +13 -3
  21. package/src/cli/index.ts +35 -9
  22. package/src/cli/info.ts +49 -7
  23. package/src/cli/init.ts +17 -5
  24. package/src/cli/last.ts +96 -21
  25. package/src/cli/lock.ts +34 -17
  26. package/src/cli/plan-format.ts +4 -4
  27. package/src/cli/plugin-commands.ts +20 -5
  28. package/src/cli/run.ts +55 -12
  29. package/src/cli/select.ts +23 -13
  30. package/src/cli/show.ts +9 -4
  31. package/src/cli/upgrade.ts +134 -14
  32. package/src/cli/watch.ts +622 -102
  33. package/src/cli/why.ts +8 -16
  34. package/src/cli/workspace-config.ts +21 -9
  35. package/src/config.ts +46 -9
  36. package/src/exec/executor.ts +51 -5
  37. package/src/exec/index.ts +10 -0
  38. package/src/exec/kill-tree.ts +281 -0
  39. package/src/exec/local-executor.ts +1 -1
  40. package/src/exec/runner.ts +327 -82
  41. package/src/exec/sandbox-binds.ts +34 -5
  42. package/src/exec/sandbox-paths.ts +22 -2
  43. package/src/exec/sandbox-runtime.ts +627 -102
  44. package/src/exec/sandbox-violations.ts +36 -23
  45. package/src/graph/dependency-spec.ts +14 -14
  46. package/src/graph/index.ts +12 -2
  47. package/src/graph/priorities.ts +130 -0
  48. package/src/graph/scheduler.ts +182 -134
  49. package/src/graph/task-graph.ts +552 -188
  50. package/src/index.ts +16 -2
  51. package/src/orchestrator/admission.ts +41 -17
  52. package/src/orchestrator/colors.ts +8 -3
  53. package/src/orchestrator/deferred-outputs.ts +22 -7
  54. package/src/orchestrator/doctor.ts +122 -14
  55. package/src/orchestrator/events.ts +71 -2
  56. package/src/orchestrator/excluded-keys.ts +155 -0
  57. package/src/orchestrator/execute-task.ts +331 -37
  58. package/src/orchestrator/failure-recap.ts +121 -0
  59. package/src/orchestrator/fingerprint-watch.ts +89 -0
  60. package/src/orchestrator/framed-output.ts +81 -14
  61. package/src/orchestrator/history.ts +6 -7
  62. package/src/orchestrator/hit-restore.ts +82 -18
  63. package/src/orchestrator/index.ts +7 -1
  64. package/src/orchestrator/keyed-projects.ts +89 -0
  65. package/src/orchestrator/local-shortcircuit.ts +83 -23
  66. package/src/orchestrator/lockfile-claim.ts +152 -32
  67. package/src/orchestrator/logger.ts +116 -20
  68. package/src/orchestrator/metrics.ts +156 -60
  69. package/src/orchestrator/miss-save.ts +106 -43
  70. package/src/orchestrator/options.ts +30 -4
  71. package/src/orchestrator/persistent.ts +41 -21
  72. package/src/orchestrator/placement.ts +55 -25
  73. package/src/orchestrator/plan.ts +24 -11
  74. package/src/orchestrator/plugin-host.ts +17 -23
  75. package/src/orchestrator/plugin.ts +31 -8
  76. package/src/orchestrator/prepare.ts +99 -13
  77. package/src/orchestrator/projects.ts +19 -8
  78. package/src/orchestrator/remote-prefetch.ts +21 -1
  79. package/src/orchestrator/run-artifacts.ts +9 -0
  80. package/src/orchestrator/run-context.ts +19 -4
  81. package/src/orchestrator/run-lock.ts +349 -0
  82. package/src/orchestrator/run-records.ts +6 -23
  83. package/src/orchestrator/run-report.ts +4 -2
  84. package/src/orchestrator/run.ts +264 -57
  85. package/src/orchestrator/sandbox-request.ts +388 -62
  86. package/src/orchestrator/save-lane.ts +4 -7
  87. package/src/orchestrator/shell-verdict.ts +117 -0
  88. package/src/orchestrator/signals.ts +86 -30
  89. package/src/orchestrator/stable-keys.ts +202 -10
  90. package/src/orchestrator/status-line.ts +33 -1
  91. package/src/orchestrator/summary.ts +49 -2
  92. package/src/orchestrator/task-hash.ts +87 -7
  93. package/src/orchestrator/task-log-buffer.ts +7 -7
  94. package/src/orchestrator/telemetry-host.ts +16 -7
  95. package/src/orchestrator/telemetry.ts +69 -28
  96. package/src/orchestrator/upstream.ts +91 -45
  97. package/src/util/bun-version.ts +43 -0
  98. package/src/util/errors.ts +86 -0
  99. package/src/util/hash.ts +10 -1
  100. package/src/util/index.ts +32 -3
  101. package/src/util/paths.ts +147 -5
  102. package/src/util/procfs.ts +20 -0
  103. package/src/util/settle.ts +4 -2
  104. package/src/util/size.ts +33 -0
  105. package/src/util/task-id.ts +20 -0
  106. package/src/util/timing.ts +11 -1
  107. package/src/util/which.ts +48 -0
  108. package/src/workspace/affected.ts +272 -64
  109. package/src/workspace/config-cache.ts +262 -32
  110. package/src/workspace/config-eval.ts +49 -12
  111. package/src/workspace/config-imports.ts +126 -5
  112. package/src/workspace/config-schema.ts +237 -41
  113. package/src/workspace/filter.ts +33 -18
  114. package/src/workspace/fingerprint.ts +29 -11
  115. package/src/workspace/index.ts +7 -0
  116. package/src/workspace/json-data.ts +90 -0
  117. package/src/workspace/load-reads.ts +32 -0
  118. package/src/workspace/lockfile.ts +33 -11
  119. package/src/workspace/migrate-scripts.ts +60 -7
  120. package/src/workspace/migration.ts +40 -1
  121. package/src/workspace/package-graph.ts +154 -16
  122. package/src/workspace/project-loader.ts +227 -77
  123. package/src/workspace/workspace.ts +173 -23
@@ -4,9 +4,19 @@
4
4
  // process exits. cpuMs / peakRssBytes are then surfaced on RunResult and
5
5
  // folded into the v11 `runs` table by the orchestrator.
6
6
 
7
- import { readFileSync } from 'node:fs'
7
+ import { existsSync, readFileSync } from 'node:fs'
8
8
  import { constants as osConstants } from 'node:os'
9
- import { killGraceMs } from '../util/index.js'
9
+ import { executablePath, isExecutableMissing, killGraceMs } from '../util/index.js'
10
+ import {
11
+ closeSignalChannel,
12
+ holdGroups,
13
+ killTree,
14
+ markGroupIfGone,
15
+ releaseGroup,
16
+ signalThrough,
17
+ spawnGuarded,
18
+ untilGroupsGone,
19
+ } from './kill-tree.js'
10
20
 
11
21
  export interface RunResult {
12
22
  exitCode: number
@@ -126,21 +136,30 @@ const SHELL_BUILTINS = new Set([
126
136
  /**
127
137
  * Prepend `exec ` to a command that is a single EXTERNAL program (no
128
138
  * shell control characters, not a builtin, no leading env-assignment).
129
- * `exec` REPLACES the wrapping `sh` with the program, so on a teardown
130
- * SIGTERM there is no intermediate shell whose death would orphan the
131
- * real process — the documented grandchild-orphan limitation, resolved
132
- * for the common single-command case (dev servers: `astro dev`, `vite`,
133
- * `next dev`; one-shot compilers). It also makes `resourceUsage` measure
134
- * the program itself rather than the shell. Compound commands, builtins,
135
- * and `FOO=bar cmd` forms keep the shell (compound-command grandchildren
136
- * still orphan on a hard programmatic kill — the residual limit every
137
- * non-cgroup runner shares).
139
+ * `exec` REPLACES the wrapping `sh` with the program: `resourceUsage`
140
+ * measures the program itself rather than the shell, a signal lands on
141
+ * the program directly, and there is one process fewer per task.
142
+ * (Until kill-tree.ts this was also what kept a single command's real
143
+ * process from orphaning on a teardown; the process group covers every
144
+ * shape now.) Compound commands, builtins, and `FOO=bar cmd` forms keep
145
+ * the shell.
138
146
  */
139
147
  export function execWrap(command: string): string {
140
- if (SHELL_CONTROL.test(command)) return command
148
+ const first = execWord(command)
149
+ return first === undefined ? command : `exec ${command}`
150
+ }
151
+
152
+ /**
153
+ * The one program a command runs when it is a plain `word args…` — the
154
+ * word `execWrap` execs, and the word the shell names in "not found" when
155
+ * it exits 127. Undefined for a pipeline, a builtin, an assignment: there
156
+ * the 127 could be any segment's.
157
+ */
158
+ export function execWord(command: string): string | undefined {
159
+ if (SHELL_CONTROL.test(command)) return undefined
141
160
  const first = command.trimStart().split(/\s+/)[0] ?? ''
142
- if (first === '' || first.includes('=') || SHELL_BUILTINS.has(first)) return command
143
- return `exec ${command}`
161
+ if (first === '' || first.includes('=') || SHELL_BUILTINS.has(first)) return undefined
162
+ return first
144
163
  }
145
164
 
146
165
  /**
@@ -154,6 +173,23 @@ export function signalExitCode(signal: string): number {
154
173
  return num === undefined ? 130 : 128 + num
155
174
  }
156
175
 
176
+ /**
177
+ * The reverse: the signal an exit above 128 stands for (137 → SIGKILL),
178
+ * by the platform's numbering; undefined for a plain exit. The shell
179
+ * reports 128 + n for a death by signal n, so the read is the shell's
180
+ * convention, not proof — a command may exit 137 on its own.
181
+ */
182
+ export function exitSignal(code: number): string | undefined {
183
+ if (code <= 128 || code >= 128 + 65) return undefined
184
+ const signals = osConstants.signals as Partial<Record<string, number>>
185
+ const num = code - 128
186
+ return Object.keys(signals).find((k) => signals[k] === num && !SIGNAL_ALIASES.has(k))
187
+ }
188
+
189
+ // Second names for one number (SIGIOT = SIGABRT, SIGPOLL = SIGIO); the
190
+ // canonical one reads.
191
+ const SIGNAL_ALIASES = new Set(['SIGIOT', 'SIGPOLL', 'SIGCLD'])
192
+
157
193
  /**
158
194
  * Grace after a timeout SIGTERM before escalating to SIGKILL — a child that
159
195
  * ignores SIGTERM must still be bounded. Matches the persistent-shutdown grace.
@@ -164,34 +200,42 @@ const TIMEOUT_SIGKILL_GRACE_MS = 2000
164
200
  * Arm a SIGTERM timeout on a spawned child. Returns a handle whose
165
201
  * `timedOut()` reports whether the timer fired — so the caller can
166
202
  * classify the resulting SIGTERM as a real failure rather than a
167
- * Ctrl-C abort — and `clear()` cancels the timer once the child exits
168
- * on its own. A no-op (never fires, nothing to clear) when `timeoutMs`
169
- * is undefined.
203
+ * Ctrl-C abort — and `settle()`, awaited once the child has exited:
204
+ * it cancels the timers and, when the timeout fired, waits out the rest
205
+ * of the grace for the child's GROUP and SIGKILLs whoever is left. The
206
+ * shell dying on the SIGTERM is not the tree dying: a backgrounded
207
+ * process that ignores it lived on under init after vx exited (nx#11782's
208
+ * sibling, reproduced on vx 2026-09-24). A no-op (never fires, nothing
209
+ * to settle) when `timeoutMs` is undefined.
170
210
  */
171
211
  export function armTimeout(
172
212
  proc: ReturnType<typeof Bun.spawn>,
173
213
  timeoutMs: number | undefined,
174
- ): { timedOut: () => boolean; clear: () => void } {
175
- if (timeoutMs === undefined) return { timedOut: () => false, clear: () => {} }
176
- let fired = false
214
+ ): { timedOut: () => boolean; settle: () => Promise<void> } {
215
+ if (timeoutMs === undefined) return { timedOut: () => false, settle: async () => {} }
216
+ let firedAt: number | undefined
177
217
  let killTimer: ReturnType<typeof setTimeout> | undefined
218
+ const graceMs = killGraceMs(TIMEOUT_SIGKILL_GRACE_MS)
178
219
  const timer = setTimeout(() => {
179
- fired = true
180
- proc.kill('SIGTERM')
220
+ firedAt = Date.now()
221
+ killTree(proc, 'SIGTERM')
181
222
  // Escalate to SIGKILL after a grace: a child that TRAPS+IGNORES SIGTERM
182
223
  // (`trap '' TERM`) would otherwise defeat the timeout entirely and hang
183
224
  // `await proc.exited` until its natural exit — there is no run-level
184
225
  // timeout, so a wedged child hangs the whole run forever. Mirrors the
185
226
  // end-of-run persistent-shutdown escalation. Unref'd so it never keeps
186
227
  // the CLI alive.
187
- killTimer = setTimeout(() => proc.kill('SIGKILL'), killGraceMs(TIMEOUT_SIGKILL_GRACE_MS))
228
+ killTimer = setTimeout(() => killTree(proc, 'SIGKILL'), graceMs)
188
229
  killTimer.unref?.()
189
230
  }, timeoutMs)
190
231
  return {
191
- timedOut: () => fired,
192
- clear: () => {
232
+ timedOut: () => firedAt !== undefined,
233
+ settle: async () => {
193
234
  clearTimeout(timer)
194
235
  if (killTimer !== undefined) clearTimeout(killTimer)
236
+ if (firedAt === undefined) return
237
+ const left = await untilGroupsGone([proc], Math.max(0, firedAt + graceMs - Date.now()))
238
+ for (const child of left) killTree(child, 'SIGKILL')
195
239
  },
196
240
  }
197
241
  }
@@ -205,27 +249,44 @@ export function armTimeout(
205
249
  * at once, so the readers win this race immediately and normal tasks pay
206
250
  * nothing; only a stuck reader waits out the grace. Residual buffered output is
207
251
  * bounded by the OS pipe buffer (streamToString drains continuously DURING the
208
- * run), so this is far longer than any real drain needs.
252
+ * run), so this is far longer than any real drain needs. What the leftover
253
+ * process writes after the bound is lost, and `POST_EXIT_CUT_LINE` says so.
209
254
  */
210
255
  const POST_EXIT_DRAIN_MS = 250
211
256
 
257
+ /**
258
+ * The line a task's frame gets when the drain bound cut its pipes. The cut
259
+ * was silent: a backgrounded child's late output vanished from the frame and
260
+ * the cached replay with no word (nx#35302 reproduced on vx, 2026-09-24).
261
+ * The bound stays, since it is what keeps a leftover server from hanging the
262
+ * run; the line makes the loss visible.
263
+ */
264
+ export const POST_EXIT_CUT_LINE =
265
+ `\n[vx] output after the task's shell exited was cut: a process it left running ` +
266
+ `still held its stdout/stderr ${POST_EXIT_DRAIN_MS} ms later\n`
267
+
212
268
  /**
213
269
  * After the direct child has exited, wait for the stdout/stderr readers to
214
270
  * reach EOF — but bound it: an orphaned grandchild holding the pipe open would
215
271
  * hang the run forever (there is no default task timeout). If the grace expires
216
272
  * first, abort the reader signal so `streamToString` cancels its read and
217
- * returns whatever it captured. The timer is cleared AND unref'd so it can
218
- * never keep the CLI alive after the readers settle (the plugin-flush lesson).
273
+ * returns whatever it captured, and resolve true: the caller adds
274
+ * `POST_EXIT_CUT_LINE` to the task's stderr. The timer is unref'd so it can
275
+ * never keep the CLI alive after the readers settle (the plugin-flush
276
+ * lesson); it is not cleared, because a late resolve on a race already won
277
+ * by the drain changes nothing (item 636 deleted the clear and nothing
278
+ * reddened).
219
279
  */
220
- export async function drainOrAbort(streams: Promise<unknown>, ac: AbortController): Promise<void> {
221
- let timer: ReturnType<typeof setTimeout> | undefined
280
+ export async function drainOrAbort(
281
+ streams: Promise<unknown>,
282
+ ac: AbortController,
283
+ ): Promise<boolean> {
222
284
  const deadline = new Promise<'timeout'>((resolve) => {
223
- timer = setTimeout(() => resolve('timeout'), POST_EXIT_DRAIN_MS)
224
- timer.unref?.()
285
+ setTimeout(() => resolve('timeout'), POST_EXIT_DRAIN_MS).unref?.()
225
286
  })
226
287
  const winner = await Promise.race([streams.then(() => 'drained' as const), deadline])
227
- if (timer !== undefined) clearTimeout(timer)
228
288
  if (winner === 'timeout') ac.abort()
289
+ return winner === 'timeout'
229
290
  }
230
291
 
231
292
  /**
@@ -235,6 +296,22 @@ export async function drainOrAbort(streams: Promise<unknown>, ac: AbortControlle
235
296
  */
236
297
  const READY_MATCH_WINDOW_CHARS = 64 * 1024
237
298
 
299
+ /**
300
+ * Why a persistent task never became ready — the reason every label and
301
+ * record reads instead of a fabricated exit (item 270). `exitCode` is the
302
+ * child's own when it exited before the pattern matched.
303
+ */
304
+ export class PersistentReadyError extends Error {
305
+ readonly reason: 'timeout' | 'exited' | 'spawn'
306
+ readonly exitCode: number | undefined
307
+ constructor(message: string, reason: 'timeout' | 'exited' | 'spawn', exitCode?: number) {
308
+ super(message)
309
+ this.name = 'PersistentReadyError'
310
+ this.reason = reason
311
+ this.exitCode = exitCode
312
+ }
313
+ }
314
+
238
315
  export interface PersistentSpawn {
239
316
  /** Underlying Bun subprocess so the orchestrator can SIGTERM it later. */
240
317
  child: ReturnType<typeof Bun.spawn>
@@ -266,6 +343,12 @@ export interface PersistentOptions extends Omit<RunOptions, 'forwardArgs' | 'cap
266
343
  * resolves before the timer can fire).
267
344
  */
268
345
  timeoutMs?: number
346
+ /**
347
+ * Spawn it with fd 3 as its signal channel and route its polite signals
348
+ * there (`signalThrough`): a Linux sandboxed command, whose group signal
349
+ * would kill bwrap instead (`wrapSandboxedCommand`'s `forwardsSignals`).
350
+ */
351
+ signalChannel?: boolean
269
352
  }
270
353
 
271
354
  /**
@@ -285,18 +368,38 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
285
368
 
286
369
  let child: ReturnType<typeof Bun.spawn>
287
370
  try {
288
- child = Bun.spawn(['sh', '-c', execWrap(opts.command)], {
289
- cwd: opts.cwd,
290
- env: opts.env as Record<string, string>,
291
- stdin: 'ignore',
292
- stdout: 'pipe',
293
- stderr: 'pipe',
294
- })
371
+ child = spawnGuarded(() =>
372
+ Bun.spawn([executablePath('sh'), '-c', execWrap(opts.command)], {
373
+ argv0: 'sh',
374
+ cwd: opts.cwd,
375
+ env: opts.env as Record<string, string>,
376
+ // A pipe vx holds and never writes: stdin stays open while vx
377
+ // lives and ends when it does. A dev server that exits on stdin
378
+ // EOF (esbuild --watch, Vite's case in turborepo#8915) became ready
379
+ // and exited 0 under 'ignore'. Not the terminal: several servers
380
+ // would steal each other's keystrokes, and a CI's /dev/null stdin
381
+ // is the same EOF. The one-shot spawn below keeps 'ignore', so a
382
+ // task that reads stdin can never hang CI.
383
+ stdio:
384
+ opts.signalChannel === true ? ['pipe', 'pipe', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'],
385
+ // Its own session and process group, so a kill reaches what it
386
+ // forked (kill-tree.ts). stdin is a pipe, so a background group
387
+ // never stops on a terminal read.
388
+ detached: true,
389
+ }),
390
+ )
391
+ if (opts.signalChannel === true) {
392
+ signalThrough(child, child.stdio[3] as number)
393
+ const spawned = child
394
+ void spawned.exited.then(() => closeSignalChannel(spawned))
395
+ }
295
396
  } catch (err) {
296
397
  const message = err instanceof Error ? err.message : String(err)
297
398
  return {
298
399
  child: undefined as unknown as ReturnType<typeof Bun.spawn>,
299
- ready: Promise.reject(new Error(`failed to spawn persistent task: ${message}`)),
400
+ ready: Promise.reject(
401
+ new PersistentReadyError(`failed to spawn persistent task: ${message}`, 'spawn'),
402
+ ),
300
403
  readyMs: () => Date.now() - start,
301
404
  }
302
405
  }
@@ -391,19 +494,25 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
391
494
  readyTimer = setTimeout(() => {
392
495
  if (readyAt === undefined) {
393
496
  rejectReady(
394
- new Error(
497
+ new PersistentReadyError(
395
498
  `persistent task not ready within ${opts.timeoutMs}ms — ` +
396
499
  `readyWhen pattern never matched; child killed`,
500
+ 'timeout',
397
501
  ),
398
502
  )
399
- child.kill('SIGTERM')
503
+ // Listed on the group guard until the SIGKILL: the shell may die
504
+ // on the SIGTERM and let the group go while the server runs out
505
+ // the grace, and a vx that exits inside it (the timer is unref'd)
506
+ // leaves the server to the guard (kill-tree.ts, item 865).
507
+ const letGo = holdGroups([child])
508
+ killTree(child, 'SIGTERM')
400
509
  // Same escalation as `armTimeout`: a server that traps TERM and
401
510
  // never became ready is not in the persistent registry, so nothing
402
511
  // else would ever kill it — it outlived the run under init.
403
- const killTimer = setTimeout(
404
- () => child.kill('SIGKILL'),
405
- killGraceMs(TIMEOUT_SIGKILL_GRACE_MS),
406
- )
512
+ const killTimer = setTimeout(() => {
513
+ killTree(child, 'SIGKILL')
514
+ letGo()
515
+ }, killGraceMs(TIMEOUT_SIGKILL_GRACE_MS))
407
516
  killTimer.unref?.()
408
517
  }
409
518
  }, opts.timeoutMs)
@@ -413,12 +522,16 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
413
522
  // — reject the ready promise so the caller can surface it.
414
523
  void child.exited.then((code) => {
415
524
  opts.liveChildren?.delete(child)
525
+ markGroupIfGone(child)
526
+ releaseGroup(child)
416
527
  if (readyTimer !== undefined) clearTimeout(readyTimer)
417
528
  if (readyAt === undefined) {
418
529
  rejectReady(
419
- new Error(
530
+ new PersistentReadyError(
420
531
  `persistent task exited before becoming ready (exit ${code ?? '?'})` +
421
532
  (readyRe ? ` — readyWhen pattern never matched` : ''),
533
+ 'exited',
534
+ code ?? undefined,
422
535
  ),
423
536
  )
424
537
  }
@@ -435,6 +548,22 @@ export function runPersistent(opts: PersistentOptions): PersistentSpawn {
435
548
  }
436
549
  }
437
550
 
551
+ /**
552
+ * What a task's frame says when `Bun.spawn` itself threw. The text goes
553
+ * through `onStderr` AND onto the result: the orchestrator retains no
554
+ * stderr (execute-task's capture), so a reason that only sat on the result
555
+ * reached nobody — a box without `sh` showed "failed (exit 127)" under a
556
+ * bare `$ <command>` and nothing else (2026-09-16). An ENOENT with the
557
+ * working directory in place is the shell; Bun's own text names the rest.
558
+ */
559
+ export function spawnFailureText(err: unknown, cwd: string, what = 'task'): string {
560
+ if (isExecutableMissing(err) && existsSync(cwd)) {
561
+ return `\n[vx] vx runs each task with sh -c: failed to spawn 'sh' (working dir: ${cwd}). Install a POSIX sh and re-run.\n`
562
+ }
563
+ const message = err instanceof Error ? err.message : String(err)
564
+ return `\n[vx] failed to spawn ${what}: ${message}\n`
565
+ }
566
+
438
567
  export async function runCommand(opts: RunOptions): Promise<RunResult> {
439
568
  const start = Date.now()
440
569
  const fullCommand =
@@ -444,21 +573,24 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
444
573
 
445
574
  let proc: ReturnType<typeof Bun.spawn>
446
575
  try {
447
- proc = Bun.spawn(['sh', '-c', execWrap(fullCommand)], {
448
- cwd: opts.cwd,
449
- env: opts.env as Record<string, string>,
450
- stdin: 'ignore',
451
- stdout: 'pipe',
452
- stderr: 'pipe',
453
- })
576
+ proc = spawnGuarded(() =>
577
+ Bun.spawn([executablePath('sh'), '-c', execWrap(fullCommand)], {
578
+ argv0: 'sh',
579
+ cwd: opts.cwd,
580
+ env: opts.env as Record<string, string>,
581
+ stdin: 'ignore',
582
+ stdout: 'pipe',
583
+ stderr: 'pipe',
584
+ // Its own session and process group, so a kill reaches what it
585
+ // forked (kill-tree.ts). stdin is ignored, so a background group
586
+ // never stops on a terminal read.
587
+ detached: true,
588
+ }),
589
+ )
454
590
  } catch (err) {
455
- const message = err instanceof Error ? err.message : String(err)
456
- return {
457
- exitCode: 127,
458
- durationMs: Date.now() - start,
459
- stdout: '',
460
- stderr: `\n[vx] failed to spawn: ${message}\n`,
461
- }
591
+ const stderr = spawnFailureText(err, opts.cwd)
592
+ opts.onStderr?.(stderr)
593
+ return { exitCode: 127, durationMs: Date.now() - start, stdout: '', stderr }
462
594
  }
463
595
 
464
596
  opts.liveChildren?.add(proc)
@@ -475,11 +607,15 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
475
607
  // lets a clean exit EOF immediately and only cuts off a stuck reader after a
476
608
  // brief grace — without this the run hangs forever.
477
609
  await proc.exited
478
- timeout.clear()
610
+ await timeout.settle()
611
+ let cut = false
479
612
  if (timeout.timedOut()) ac.abort()
480
- else await drainOrAbort(streams, ac)
481
- const [stdout, stderr] = await streams
613
+ else cut = await drainOrAbort(streams, ac)
614
+ const [stdout, streamed] = await streams
615
+ if (cut) opts.onStderr?.(POST_EXIT_CUT_LINE)
616
+ const stderr = cut ? streamed + POST_EXIT_CUT_LINE : streamed
482
617
  opts.liveChildren?.delete(proc)
618
+ releaseGroup(proc)
483
619
  const exitCode = proc.exitCode ?? (proc.signalCode ? signalExitCode(proc.signalCode) : 1)
484
620
  return {
485
621
  exitCode,
@@ -516,6 +652,75 @@ export function ownRssHighWater(): number {
516
652
  return process.memoryUsage.rss()
517
653
  }
518
654
 
655
+ /**
656
+ * What a task's captured output keeps: the first `CAPTURE_HEAD_CHARS` and
657
+ * the last `CAPTURE_TAIL_CHARS` characters, the dropped middle counted and
658
+ * named where it was. The live stream is never bounded — every byte still
659
+ * reaches the terminal as the task writes it — only the copy vx retains
660
+ * for the cache entry and its replay. Unbounded, a task printing 200 MB
661
+ * cost vx 620 MB of RSS on the miss AND on every hit, and its stdout sat
662
+ * whole in `cache.db` (measured 2026-09-16); a realistic chatty suite is
663
+ * 5–20 MB, so the bound is above what a replay is worth reading anyway.
664
+ */
665
+ export const CAPTURE_HEAD_CHARS = 8 * 1024 * 1024
666
+ export const CAPTURE_TAIL_CHARS = 8 * 1024 * 1024
667
+
668
+ const mib = (n: number): string => `${(n / (1024 * 1024)).toFixed(1)} MiB`
669
+
670
+ /** The line that stands where the dropped middle was. */
671
+ export function droppedOutputLine(dropped: number): string {
672
+ return (
673
+ `\n[vx] ${mib(dropped)} of output not kept — vx keeps the first ${mib(CAPTURE_HEAD_CHARS)} ` +
674
+ `and the last ${mib(CAPTURE_TAIL_CHARS)} of a task's output for its cache entry and replay\n`
675
+ )
676
+ }
677
+
678
+ /**
679
+ * A head-and-tail accumulator: the head fills once, the tail is a ring
680
+ * of chunks trimmed from the front, so memory is bounded by the two
681
+ * limits plus one chunk whatever the task prints.
682
+ */
683
+ class BoundedCapture {
684
+ private head = ''
685
+ private readonly tail: string[] = []
686
+ private tailLen = 0
687
+ private dropped = 0
688
+
689
+ push(chunk: string): void {
690
+ if (this.head.length < CAPTURE_HEAD_CHARS) {
691
+ const room = CAPTURE_HEAD_CHARS - this.head.length
692
+ if (chunk.length <= room) {
693
+ this.head += chunk
694
+ return
695
+ }
696
+ this.head += chunk.slice(0, room)
697
+ chunk = chunk.slice(room)
698
+ }
699
+ this.tail.push(chunk)
700
+ this.tailLen += chunk.length
701
+ while (this.tailLen > CAPTURE_TAIL_CHARS) {
702
+ const first = this.tail[0]!
703
+ const excess = this.tailLen - CAPTURE_TAIL_CHARS
704
+ if (first.length <= excess) {
705
+ this.tail.shift()
706
+ this.tailLen -= first.length
707
+ this.dropped += first.length
708
+ } else {
709
+ this.tail[0] = first.slice(excess)
710
+ this.tailLen -= excess
711
+ this.dropped += excess
712
+ }
713
+ }
714
+ }
715
+
716
+ text(): string {
717
+ const tail = this.tail.join('')
718
+ return this.dropped === 0
719
+ ? this.head + tail
720
+ : this.head + droppedOutputLine(this.dropped) + tail
721
+ }
722
+ }
723
+
519
724
  /**
520
725
  * Drain a `Bun.spawn` stdout/stderr stream while invoking the live
521
726
  * callback per UTF-8 chunk. Returns the accumulated string, or `''` when
@@ -535,7 +740,7 @@ export async function streamToString(
535
740
  // so the runtime value is always a ReadableStream; the `number` branch
536
741
  // is unreachable but typed.
537
742
  if (!stream || typeof stream === 'number') return ''
538
- let full = ''
743
+ const full = new BoundedCapture()
539
744
  const reader = stream.getReader()
540
745
  const decoder = new TextDecoder()
541
746
  // On abort, cancel the read so a pending `reader.read()` resolves
@@ -551,19 +756,63 @@ export async function streamToString(
551
756
  const { value, done } = await reader.read()
552
757
  if (done) break
553
758
  const chunk = decoder.decode(value, { stream: true })
554
- if (retain) full += chunk
759
+ if (retain) full.push(chunk)
555
760
  onChunk?.(chunk)
556
761
  }
557
762
  const tail = decoder.decode()
558
763
  if (tail.length > 0) {
559
- if (retain) full += tail
764
+ if (retain) full.push(tail)
560
765
  onChunk?.(tail)
561
766
  }
562
767
  } finally {
563
768
  signal?.removeEventListener('abort', onAbort)
564
769
  reader.releaseLock()
565
770
  }
566
- return full
771
+ return full.text()
772
+ }
773
+
774
+ /**
775
+ * How far above the parent's own mark a child's `ru_maxrss` must read to
776
+ * count as the child's. A light child inherits the parent's footprint at
777
+ * exec, so its reading sits ON the floor by construction, and the kernel's
778
+ * per-thread RSS counters lag by up to 64 pages between syncs — a `true`
779
+ * spawned from a 300 MB parent read 376 MB against a floor a few pages
780
+ * lower on one CI run in twelve (2026-09-16). Four MiB is above any
781
+ * accounting jitter and below what any reservation resolves (64 MB steps).
782
+ */
783
+ export const RSS_FLOOR_SLACK_BYTES = 4 * 1024 * 1024
784
+
785
+ const MIN_PLAUSIBLE_PEAK_BYTES = 1024 * 1024
786
+
787
+ /**
788
+ * A child's `maxRSS` in BYTES, whatever unit the runtime reported it in.
789
+ *
790
+ * Bun >= 1.4 — vx's declared floor — normalizes the kernel's `ru_maxrss`
791
+ * (kilobytes on Linux, bytes on macOS) before handing it over, and this file
792
+ * used to state that as "BYTES on every platform". It is not true of every
793
+ * Bun: 1.3.11 passes the raw kilobytes through, so every peak read 1024×
794
+ * SMALL, fell under the parent floor and recorded nothing. The opposite
795
+ * mistake is in this file's own history — an unconditional ×1024 on Linux
796
+ * made a 64 MB suite read as 64 GB and, once reservations were learned from
797
+ * that history, ran every task alone (2026-09-12). Both directions come from
798
+ * asserting a platform unit instead of deciding it from the number in hand.
799
+ *
800
+ * The number decides it. No process peaks under a megabyte — a bare `true`
801
+ * costs a couple — so a reading below that is not bytes, it is the kernel's
802
+ * kilobytes, and multiplying is right by the same margin that makes the test
803
+ * safe. A real byte figure is never near the threshold, and neither is a real
804
+ * kilobyte one: the two live 1024× apart.
805
+ *
806
+ * Calibrating against this process's OWN `process.resourceUsage()` was the
807
+ * first attempt and it was wrong: that is the node-compatible API, which
808
+ * reports kilobytes even where `Subprocess.resourceUsage()` reports bytes, so
809
+ * the ratio read 1024 on a runtime already handing over bytes and doubled the
810
+ * old 64 GB defect. It passed here, where both APIs use kilobytes, and turned
811
+ * CI red on both platforms (2026-09-20). Compare like with like, or do not
812
+ * compare at all.
813
+ */
814
+ export function peakRssBytes(maxRSS: number): number {
815
+ return maxRSS > 0 && maxRSS < MIN_PLAUSIBLE_PEAK_BYTES ? maxRSS * 1024 : maxRSS
567
816
  }
568
817
 
569
818
  /**
@@ -580,17 +829,13 @@ export function resourceUsageToCpuRss(
580
829
  if (!usage) return {}
581
830
  // cpuTime.total is microseconds as a bigint → ms.
582
831
  const cpuMs = Number(usage.cpuTime.total) / 1000
583
- // `maxRSS` is BYTES on every platform: Bun normalizes the kernel's
584
- // `ru_maxrss` (kilobytes on Linux, bytes on macOS) before handing it
585
- // over, as its typing says. This used to multiply by 1024 on Linux on
586
- // the belief that Bun passed the raw value through, and every Linux
587
- // peak was 1024× too big — a 64 MB `bun test` recorded as 64 GB in
588
- // telemetry, `--summarize` and the run history, and once reservations
589
- // were learned from that history (2026-09-12) every task with a
590
- // recorded peak was over any budget and ran alone. Measured, not
591
- // assumed: `tests/runner.test.ts` allocates a known number of bytes
592
- // and reads the peak back within a bounded factor of it.
593
- // A reading at or under the parent's own mark is the parent's (see
594
- // `ownRssHighWater`): the child's peak is unknown, bounded by it.
595
- return usage.maxRSS > floorBytes ? { cpuMs, peakRssBytes: usage.maxRSS } : { cpuMs }
832
+ // `maxRSS` in whatever unit this runtime reports, in bytes by the rule
833
+ // above. `tests/runner.test.ts` allocates a known number of bytes and
834
+ // reads the peak back within a bounded factor of it, which is the
835
+ // end-to-end check on both the unit and the floor.
836
+ // A reading at or within the slack of the parent's own mark is the
837
+ // parent's (see `ownRssHighWater`, `RSS_FLOOR_SLACK_BYTES`): the child's
838
+ // peak is unknown, bounded by it.
839
+ const peak = peakRssBytes(usage.maxRSS)
840
+ return peak > floorBytes + RSS_FLOOR_SLACK_BYTES ? { cpuMs, peakRssBytes: peak } : { cpuMs }
596
841
  }
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { lstatSync, readdirSync, statSync } from 'node:fs'
7
7
  import path from 'node:path'
8
- import { localBindingOn, unique } from './sandbox-paths.js'
8
+ import { isMountableLiteral, localBindingOn, unique } from './sandbox-paths.js'
9
9
  import type { SandboxedRunArgs } from './sandbox-runtime.js'
10
10
 
11
11
  type SrtModule = typeof import('@anthropic-ai/sandbox-runtime')
@@ -26,11 +26,11 @@ type SrtModule = typeof import('@anthropic-ai/sandbox-runtime')
26
26
  * the task cannot produce. macOS needs none of this (seatbelt matches
27
27
  * paths, it does not mount), so the grant stays exact there.
28
28
  */
29
- function bindableWrites(paths: readonly string[]): string[] {
29
+ export function bindableWrites(paths: readonly string[]): string[] {
30
30
  if (process.platform !== 'linux') return [...paths]
31
31
  return unique(
32
32
  paths.map((p) => {
33
- if (/[*?[\]]/.test(p)) return p
33
+ if (!isMountableLiteral(p)) return p
34
34
  try {
35
35
  if (statSync(p).isDirectory()) return p
36
36
  } catch {
@@ -42,6 +42,36 @@ function bindableWrites(paths: readonly string[]): string[] {
42
42
  )
43
43
  }
44
44
 
45
+ /**
46
+ * A read grant with the WALLS inside it cut out: the directories of the
47
+ * projects nested in this one, the repository and vx's own directory. A
48
+ * root project's `read: ['.']` bound every nested project, `.git` and the
49
+ * cache readable, and the key, which excludes nested projects, replayed a
50
+ * nested file's old bytes (item 1010). The cut is the punch above, a wall
51
+ * dropped where a write path is bound: its siblings are granted, the wall
52
+ * is not. A grant that IS a wall, or lies inside one, names it on purpose
53
+ * and stays. Linux only, as the punch is; a grant with no wall under it
54
+ * costs nothing.
55
+ */
56
+ export function punchWalls(readPath: string, walls: readonly string[]): string[] {
57
+ if (process.platform !== 'linux') return [readPath]
58
+ const under = walls.filter((w) => w !== readPath && w.startsWith(readPath + path.sep))
59
+ if (under.length === 0) return [readPath]
60
+ let entries: string[]
61
+ try {
62
+ entries = readdirSync(readPath)
63
+ } catch {
64
+ return [readPath]
65
+ }
66
+ const out: string[] = []
67
+ for (const entry of entries) {
68
+ const child = path.join(readPath, entry)
69
+ if (under.includes(child)) continue
70
+ out.push(...punchWalls(child, under))
71
+ }
72
+ return out
73
+ }
74
+
45
75
  /** Directories already warned about below — once per process, not per spawn. */
46
76
  const warnedSymlinkPunch = new Set<string>()
47
77
 
@@ -135,13 +165,12 @@ export function buildCustomConfig(
135
165
  args: Pick<SandboxedRunArgs, 'config'>,
136
166
  baselines: {
137
167
  allowRead: readonly string[]
138
- allowWrite: readonly string[]
139
168
  denyRead: readonly string[]
140
169
  },
141
170
  ): Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2] {
142
171
  const c = args.config
143
172
  const denyRead = unique([...baselines.denyRead])
144
- const allowWrite = bindableWrites(unique([...baselines.allowWrite, ...c.allowWrite]))
173
+ const allowWrite = bindableWrites(unique([...c.allowWrite]))
145
174
  const allowRead = unique(
146
175
  [...baselines.allowRead, ...c.allowRead].flatMap((r) => punchWritePaths(r, allowWrite)),
147
176
  )