@vzn/vx 0.0.650 → 0.0.651

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 (115) hide show
  1. package/README.md +5 -20
  2. package/package.json +5 -5
  3. package/schemas/last.json +15 -0
  4. package/src/bin.ts +9 -31
  5. package/src/cache/cache.ts +202 -25
  6. package/src/cache/file-hashes.ts +3 -0
  7. package/src/cache/git-inputs.ts +188 -39
  8. package/src/cache/index.ts +1 -0
  9. package/src/cache/inputs.ts +3 -0
  10. package/src/cache/layer.ts +11 -0
  11. package/src/cache/layered-cache.ts +4 -0
  12. package/src/cache/linked-inputs.ts +3 -0
  13. package/src/cache/lockfile-memo.ts +3 -2
  14. package/src/cache/output-index.ts +5 -2
  15. package/src/cache/remote-lane.ts +2 -1
  16. package/src/cache/run-history.ts +11 -1
  17. package/src/cache/schema.ts +11 -1
  18. package/src/cli/cache.ts +26 -16
  19. package/src/cli/completions.ts +4 -2
  20. package/src/cli/docs.ts +10 -11
  21. package/src/cli/help.ts +12 -23
  22. package/src/cli/index.ts +4 -9
  23. package/src/cli/info.ts +15 -7
  24. package/src/cli/init.ts +23 -15
  25. package/src/cli/last.ts +213 -203
  26. package/src/cli/lock.ts +55 -5
  27. package/src/cli/plan-format.ts +100 -118
  28. package/src/cli/plugin-commands.ts +8 -2
  29. package/src/cli/plugin-templates.ts +2 -2
  30. package/src/cli/run.ts +46 -27
  31. package/src/cli/select.ts +43 -5
  32. package/src/cli/show.ts +38 -42
  33. package/src/cli/task-verb.ts +22 -0
  34. package/src/cli/upgrade.ts +3 -1
  35. package/src/cli/watch-fs.ts +6 -1
  36. package/src/cli/watch-judge.ts +2 -1
  37. package/src/cli/watch.ts +83 -44
  38. package/src/cli/why.ts +123 -89
  39. package/src/cli/workspace-config.ts +2 -1
  40. package/src/exec/executor.ts +2 -1
  41. package/src/exec/kill-tree.ts +58 -5
  42. package/src/exec/local-executor.ts +1 -1
  43. package/src/exec/pipes.ts +152 -0
  44. package/src/exec/runner.ts +199 -182
  45. package/src/exec/sandbox-binds.ts +1 -1
  46. package/src/exec/sandbox-runtime.ts +11 -2
  47. package/src/exec/spawn-off.ts +154 -0
  48. package/src/exec/warm-shell.ts +146 -0
  49. package/src/graph/critical-path.ts +40 -28
  50. package/src/graph/priorities.ts +98 -44
  51. package/src/graph/scheduler.ts +14 -10
  52. package/src/graph/task-graph.ts +27 -1
  53. package/src/handoff.ts +10 -7
  54. package/src/history/history.ts +5 -0
  55. package/src/history/metrics.ts +9 -1
  56. package/src/index.ts +3 -1
  57. package/src/orchestrator/doctor.ts +1 -1
  58. package/src/orchestrator/execute-task.ts +16 -2
  59. package/src/orchestrator/graph-memo.ts +50 -1
  60. package/src/orchestrator/graph-stage.ts +6 -0
  61. package/src/orchestrator/index.ts +3 -0
  62. package/src/orchestrator/live-usage.ts +16 -11
  63. package/src/orchestrator/locked-digests.ts +63 -0
  64. package/src/orchestrator/lockfile-claim.ts +108 -29
  65. package/src/orchestrator/miss-save.ts +31 -3
  66. package/src/orchestrator/persistent-task.ts +3 -1
  67. package/src/orchestrator/plan.ts +5 -1
  68. package/src/orchestrator/plugin-host.ts +61 -0
  69. package/src/orchestrator/plugin.ts +47 -2
  70. package/src/orchestrator/prepare.ts +16 -1
  71. package/src/orchestrator/projects.ts +4 -1
  72. package/src/orchestrator/run-context.ts +29 -10
  73. package/src/orchestrator/run-failures.ts +1 -1
  74. package/src/orchestrator/run-records.ts +10 -0
  75. package/src/orchestrator/run.ts +53 -22
  76. package/src/orchestrator/sandbox-request.ts +2 -2
  77. package/src/orchestrator/signals.ts +8 -5
  78. package/src/orchestrator/task-hash.ts +17 -3
  79. package/src/report/events.ts +3 -11
  80. package/src/report/forecast.ts +10 -32
  81. package/src/report/framed-output.ts +272 -173
  82. package/src/report/index.ts +8 -3
  83. package/src/report/logger.ts +73 -38
  84. package/src/report/plain-output.ts +1 -1
  85. package/src/report/status-line.ts +49 -357
  86. package/src/report/summary.ts +100 -131
  87. package/src/ui/ansi.ts +48 -0
  88. package/src/ui/components.ts +506 -0
  89. package/src/ui/index.ts +79 -0
  90. package/src/ui/kit.ts +37 -0
  91. package/src/ui/live-region.ts +363 -0
  92. package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
  93. package/src/ui/terminal.ts +51 -0
  94. package/src/ui/theme.ts +224 -0
  95. package/src/ui/version-line.ts +28 -0
  96. package/src/util/config-frame.ts +65 -10
  97. package/src/util/edit-distance.ts +6 -1
  98. package/src/util/index.ts +2 -15
  99. package/src/util/paths.ts +20 -2
  100. package/src/util/secret-mask.ts +39 -3
  101. package/src/util/settle.ts +31 -0
  102. package/src/util/timing.ts +4 -16
  103. package/src/workspace/affected.ts +89 -30
  104. package/src/workspace/config-schema.ts +4 -2
  105. package/src/workspace/dependencies-lock.ts +191 -0
  106. package/src/workspace/filter.ts +28 -0
  107. package/src/workspace/index.ts +9 -0
  108. package/src/workspace/lockfile.ts +159 -6
  109. package/src/workspace/migration.ts +4 -3
  110. package/src/workspace/workspace.ts +3 -0
  111. package/src/util/colors.ts +0 -133
  112. package/src/util/report.ts +0 -44
  113. package/src/util/ui.ts +0 -258
  114. /package/src/{report/path-links.ts → ui/links.ts} +0 -0
  115. /package/src/{report → ui}/terminal-signals.ts +0 -0
@@ -4,8 +4,7 @@
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 type { Pointer } from 'bun:ffi'
8
- import { closeSync, existsSync, readFileSync } from 'node:fs'
7
+ import { existsSync, readFileSync } from 'node:fs'
9
8
  import { constants as osConstants } from 'node:os'
10
9
  import {
11
10
  formatBytes,
@@ -14,10 +13,12 @@ import {
14
13
  isOutOfFds,
15
14
  killGraceMs,
16
15
  OUT_OF_FDS_HINT,
16
+ testTimerMs,
17
17
  helperEnv,
18
18
  type SecretMask,
19
19
  } from '../util/index.js'
20
20
  import {
21
+ type Child,
21
22
  closeSignalChannel,
22
23
  holdGroups,
23
24
  killTree,
@@ -28,6 +29,10 @@ import {
28
29
  spawnGuarded,
29
30
  untilGroupsGone,
30
31
  } from './kill-tree.js'
32
+ import { type TreeUsage, watchTree } from './proc-sample.js'
33
+ import { TaskPipes } from './pipes.js'
34
+ import { spawnOff } from './spawn-off.js'
35
+ import { runOnSpare, spareScript, takeSpare } from './warm-shell.js'
31
36
 
32
37
  /** `sh` names itself in its errors. */
33
38
  const SH_ARGV0 = { argv0: 'sh' }
@@ -96,6 +101,8 @@ export interface RunOptions {
96
101
  * handler can SIGTERM everything still alive mid-run.
97
102
  */
98
103
  liveChildren?: Set<ReturnType<typeof Bun.spawn>>
104
+ /** The run's stop (`ExecuteRequest.signal`): one that lands before the child is in `liveChildren` stops it here. */
105
+ signal?: AbortSignal
99
106
  /** Told the child's pid once it is spawned (telemetry samples its tree). */
100
107
  onSpawn?: (pid: number) => void
101
108
  /**
@@ -113,153 +120,6 @@ function taskStdio(terminal: boolean | undefined, pipes: TaskPipes): [Fd, Fd, Fd
113
120
  return terminal === true ? ['inherit', 'inherit', 'inherit'] : [pipes.stdin, ...pipes.stdio]
114
121
  }
115
122
 
116
- const O_NONBLOCK = 0o4000
117
- const O_CLOEXEC = 0o2000000
118
- const F_SETFL = 4
119
- const F_SETPIPE_SZ = 1031
120
- /**
121
- * A pipe holds 64 KiB by default, and a 200 MB stdout drained 13% slower
122
- * through one than through Bun's socketpair (min of 7: 293 vs 259 ms);
123
- * at 256 KiB it drained in 230. Past the user's pipe quota the kernel
124
- * refuses the resize and the pipe keeps its 64 KiB.
125
- */
126
- const PIPE_BYTES = 256 * 1024
127
-
128
- type PipeCalls = {
129
- pipe2: (fds: Pointer, flags: number) => number
130
- fcntl: (fd: number, cmd: number, arg: number) => number
131
- ptr: (view: Int32Array, byteOffset?: number) => Pointer
132
- }
133
- let pipeCalls: PipeCalls | null | undefined
134
-
135
- /** libc's `pipe2` and `fcntl`, bound once; null off Linux or where libc will not load. */
136
- function linuxPipeCalls(): PipeCalls | null {
137
- if (pipeCalls !== undefined) return pipeCalls
138
- pipeCalls = null
139
- if (process.platform !== 'linux') return null
140
- try {
141
- // Loaded at the first spawn, not with the module: a static import cost
142
- // every vx start 0.3 ms, a warm run that spawns nothing included.
143
- const { dlopen, FFIType, ptr } = require('bun:ffi') as typeof import('bun:ffi')
144
- // glibc's soname; musl's loader answers any `libc.` name with itself
145
- // (ldso/dynlink.c's reserved names).
146
- const { symbols } = dlopen('libc.so.6', {
147
- pipe2: { args: [FFIType.ptr, FFIType.i32], returns: FFIType.i32 },
148
- fcntl: { args: [FFIType.i32, FFIType.i32, FFIType.i32], returns: FFIType.i32 },
149
- })
150
- pipeCalls = { pipe2: symbols.pipe2, fcntl: symbols.fcntl, ptr }
151
- } catch {
152
- // Bun's socketpair, as before: only `/dev/stdout` by path fails on it.
153
- }
154
- return pipeCalls
155
- }
156
-
157
- /**
158
- * Two pipes, `[out read, out write, err read, err write]`, or undefined
159
- * when libc is not bound or a descriptor is not to be had (Bun's own
160
- * socketpair then fails the spawn with the reason).
161
- */
162
- function realPipes(): Int32Array | undefined {
163
- const calls = linuxPipeCalls()
164
- if (calls === null) return undefined
165
- const fds = new Int32Array(4)
166
- // Bun's spawn closes every descriptor it is not handed (probed, Bun
167
- // 1.4.2); O_CLOEXEC keeps any other exec from holding a write end open.
168
- if (calls.pipe2(calls.ptr(fds), O_CLOEXEC) !== 0) return undefined
169
- if (calls.pipe2(calls.ptr(fds, 8), O_CLOEXEC) !== 0) {
170
- closeSync(fds[0]!)
171
- closeSync(fds[1]!)
172
- return undefined
173
- }
174
- // vx's ends only: a task whose stdout is non-blocking fails its writes with EAGAIN.
175
- calls.fcntl(fds[0]!, F_SETFL, O_NONBLOCK)
176
- calls.fcntl(fds[2]!, F_SETFL, O_NONBLOCK)
177
- calls.fcntl(fds[0]!, F_SETPIPE_SZ, PIPE_BYTES)
178
- calls.fcntl(fds[2]!, F_SETPIPE_SZ, PIPE_BYTES)
179
- return fds
180
- }
181
-
182
- /** One pipe, `[read, write]`, both close-on-exec, or undefined as {@link realPipes}. */
183
- function heldPipe(): Int32Array | undefined {
184
- const calls = linuxPipeCalls()
185
- if (calls === null) return undefined
186
- const fds = new Int32Array(2)
187
- return calls.pipe2(calls.ptr(fds), O_CLOEXEC) === 0 ? fds : undefined
188
- }
189
-
190
- /**
191
- * A task's stdout and stderr. Bun's `'pipe'` is a socketpair, and Linux
192
- * opens `/dev/stdout` through `/proc/self/fd/1`, which a socket refuses
193
- * (ENXIO): `echo x > /dev/stdout` and `cmd | tee /dev/stderr` failed in
194
- * every task, where a terminal or Turbo hands real pipes (X-113). So on
195
- * Linux vx makes the pipes (`pipe2` through `bun:ffi`) and hands the child
196
- * the write ends. macOS opens `/dev/fd/N` as a dup of the descriptor,
197
- * which a socket allows, so Bun's pipe stays there.
198
- *
199
- * `held`: stdin is a pipe vx keeps open and never writes (a persistent
200
- * task's, schema.md), for the same reason: `cat /dev/stdin` on Bun's
201
- * socketpair failed with ENXIO instead of blocking. Otherwise `'ignore'`.
202
- */
203
- export class TaskPipes {
204
- /** Descriptor 0 for `Bun.spawn`'s `stdio`. */
205
- readonly stdin: number | 'pipe' | 'ignore'
206
- /** Descriptors 1 and 2 for `Bun.spawn`'s `stdio`. */
207
- readonly stdio: readonly [number | 'pipe', number | 'pipe']
208
- readonly #read: readonly number[]
209
- readonly #stdinWrite: number | undefined
210
-
211
- constructor(terminal?: boolean, held?: boolean) {
212
- const fds = terminal === true ? undefined : realPipes()
213
- this.stdio = fds === undefined ? ['pipe', 'pipe'] : [fds[1]!, fds[3]!]
214
- this.#read = fds === undefined ? [] : [fds[0]!, fds[2]!]
215
- const inFds = held === true && terminal !== true ? heldPipe() : undefined
216
- this.stdin = held !== true ? 'ignore' : inFds === undefined ? 'pipe' : inFds[0]!
217
- this.#stdinWrite = inFds?.[1]
218
- }
219
-
220
- /**
221
- * Close vx's copies of the child's ends once the spawn has returned or
222
- * thrown: the child holds its own, and a write end left here is a writer
223
- * the reader's EOF would wait for.
224
- */
225
- spawned(): void {
226
- for (const fd of [this.stdin, ...this.stdio]) if (typeof fd === 'number') closeSync(fd)
227
- }
228
-
229
- /** The child's stdout and stderr. */
230
- streams(
231
- child: ReturnType<typeof Bun.spawn>,
232
- ): [
233
- ReadableStream<Uint8Array> | number | undefined,
234
- ReadableStream<Uint8Array> | number | undefined,
235
- ] {
236
- const [out, err] = this.#read
237
- return out === undefined
238
- ? [child.stdout, child.stderr]
239
- : [Bun.file(out).stream(), Bun.file(err!).stream()]
240
- }
241
-
242
- /**
243
- * Close the read ends, once both streams have ended or been cancelled.
244
- * Bun reads a dup of each, but takes it on the stream's first pull: a
245
- * number closed before then was reused by the next spawn's pipe, and
246
- * the stream read THAT (probed, Bun 1.4.2).
247
- */
248
- close(): void {
249
- for (const fd of this.#read) closeSync(fd)
250
- }
251
-
252
- /**
253
- * Close the held stdin's write end, once the child has exited and its
254
- * output has ended. Not with the output alone: a server that sends it
255
- * to a log (`exec >log 2>&1`) ended its streams at once, read stdin EOF
256
- * and exited.
257
- */
258
- closeStdin(): void {
259
- if (this.#stdinWrite !== undefined) closeSync(this.#stdinWrite)
260
- }
261
- }
262
-
263
123
  /**
264
124
  * Read the terminal's modes before a task that holds it runs, and return
265
125
  * what puts them back once it exits: a task killed in raw mode (or one
@@ -711,10 +571,28 @@ export const POST_EXIT_CUT_LINE =
711
571
  `still held its stdout/stderr ${POST_EXIT_DRAIN_MS} ms later\n`
712
572
 
713
573
  /**
714
- * A task's two output callbacks, wrapped to learn whether its output so far
715
- * ends mid-line, so a line vx adds after it (`POST_EXIT_CUT_LINE`) breaks
716
- * the line only then: a newline always before it printed a blank line under
717
- * the task's last line. An absent callback stays absent (nothing decodes).
574
+ * How long one stream's output waits on the other's open line. dash writes
575
+ * the pieces of `sh: 1: cannot open /dev/tty: …` back to back; a line open
576
+ * longer is a prompt or a progress line, and the other stream's lines are
577
+ * shown live rather than held behind it.
578
+ */
579
+ export const OPEN_LINE_WAIT_MS = 50
580
+ /** And how much of it waits: the hold never costs more than this per task. */
581
+ export const OPEN_LINE_HOLD_CHARS = 64 * 1024
582
+
583
+ /**
584
+ * A task's two output callbacks, merged a line at a time. They are two
585
+ * pipes, read in the order they arrive, and dash's stderr line written in
586
+ * pieces took a stdout write inside it: `sh: /dev/ttyNO-TTY\n\n` (CI,
587
+ * 2026-10-29). So a chunk on one stream waits while the other's line is
588
+ * open, and goes once that line ends; what of a chunk follows its last
589
+ * newline goes after the held output. Past `OPEN_LINE_WAIT_MS`,
590
+ * `OPEN_LINE_HOLD_CHARS` or `end` it goes as written, the line unbroken:
591
+ * a newline put there became the stream's own text, and a hit replays
592
+ * each stream to its own descriptor (`2> log` read one the task never
593
+ * wrote). `own` breaks the line before vx's own text only. A stream
594
+ * writing alone is never held, so a task that writes one stream pays a
595
+ * comparison per chunk. An absent callback stays absent (nothing decodes).
718
596
  */
719
597
  export function lineEnds(
720
598
  onStdout?: (chunk: string) => void,
@@ -724,18 +602,74 @@ export function lineEnds(
724
602
  onStderr?: (chunk: string) => void
725
603
  /** `text` on a line of its own. */
726
604
  own(text: string): string
605
+ /** Lets what waits go: the task's streams ended. */
606
+ end(): void
727
607
  } {
728
- let mid = false
729
- const track =
730
- (f: (chunk: string) => void) =>
731
- (chunk: string): void => {
732
- if (chunk.length > 0) mid = !chunk.endsWith('\n')
733
- f(chunk)
608
+ // A test lengthens it to drive the hold itself on a loaded runner, where
609
+ // a `sleep` between two writes outlasted 50 ms (macOS CI, PR #4867).
610
+ const waitMs = testTimerMs('OPEN_LINE_WAIT', OPEN_LINE_WAIT_MS)
611
+ const sinks = [onStdout, onStderr]
612
+ /** The stream whose line is open (0 stdout, 1 stderr). */
613
+ let open: 0 | 1 | undefined
614
+ /** What the other stream wrote while that line was open: one side at most. */
615
+ const held = ['', '']
616
+ /** When what waits began to: the bound counts from there. */
617
+ let heldAt = 0
618
+ // One timer, re-armed only when it fires early: a hold set and cleared
619
+ // its own per line, and a task interleaving short lines paid a timer each
620
+ // (+26 ms of CPU on 20 000 lines, 2026-10-29).
621
+ let timer: ReturnType<typeof setTimeout> | undefined
622
+ const arm = (ms: number): void => {
623
+ timer = setTimeout(due, ms)
624
+ timer.unref()
625
+ }
626
+ const due = (): void => {
627
+ timer = undefined
628
+ const s = held[0]!.length > 0 ? 0 : held[1]!.length > 0 ? 1 : undefined
629
+ if (s === undefined) return
630
+ const left = heldAt + waitMs - performance.now()
631
+ if (left > 0) arm(left)
632
+ else release(s)
633
+ }
634
+ const write = (s: 0 | 1, text: string): void => {
635
+ open = text.endsWith('\n') ? undefined : s
636
+ sinks[s]?.(text)
637
+ }
638
+ const release = (s: 0 | 1): void => {
639
+ const text = held[s]!
640
+ if (text.length === 0) return
641
+ held[s] = ''
642
+ write(s, text)
643
+ }
644
+ const push = (s: 0 | 1, chunk: string): void => {
645
+ if (chunk.length === 0) return
646
+ if (open === undefined || open === s) {
647
+ const other = s === 0 ? 1 : 0
648
+ const nl = held[other]!.length === 0 ? -1 : chunk.lastIndexOf('\n')
649
+ if (nl === -1 || nl === chunk.length - 1) {
650
+ write(s, chunk)
651
+ if (nl !== -1) release(other)
652
+ return
653
+ }
654
+ write(s, chunk.slice(0, nl + 1))
655
+ release(other)
656
+ return push(s, chunk.slice(nl + 1))
734
657
  }
658
+ if (held[s]!.length === 0) heldAt = performance.now()
659
+ held[s] += chunk
660
+ if (held[s]!.length > OPEN_LINE_HOLD_CHARS) return release(s)
661
+ if (timer === undefined) arm(waitMs)
662
+ }
735
663
  return {
736
- ...(onStdout && { onStdout: track(onStdout) }),
737
- ...(onStderr && { onStderr: track(onStderr) }),
738
- own: (text) => (mid ? `\n${text}` : text),
664
+ ...(onStdout && { onStdout: (chunk: string) => push(0, chunk) }),
665
+ ...(onStderr && { onStderr: (chunk: string) => push(1, chunk) }),
666
+ own: (text) => (open !== undefined ? `\n${text}` : text),
667
+ end: () => {
668
+ clearTimeout(timer)
669
+ timer = undefined
670
+ release(0)
671
+ release(1)
672
+ },
739
673
  }
740
674
  }
741
675
 
@@ -1151,37 +1085,92 @@ function argTooLongHint(command: string, wrapped: boolean): string {
1151
1085
  )
1152
1086
  }
1153
1087
 
1088
+ /** A stop's own sweep, for a child it missed: the polite signal, then SIGKILL past the grace. */
1089
+ async function stopLate(proc: Child, signal: 'SIGINT' | 'SIGTERM'): Promise<void> {
1090
+ const letGo = holdGroups([proc])
1091
+ try {
1092
+ killTree(proc, signal)
1093
+ for (const c of await untilGroupsGone([proc], killGraceMs(TIMEOUT_SIGKILL_GRACE_MS)))
1094
+ killTree(c, 'SIGKILL')
1095
+ } finally {
1096
+ letGo()
1097
+ }
1098
+ }
1099
+
1154
1100
  export async function runCommand(opts: RunOptions): Promise<RunResult> {
1155
1101
  const start = Date.now()
1156
1102
  const fullCommand = withForwardArgs(opts.command, opts.forwardArgs)
1157
1103
 
1158
- let proc: ReturnType<typeof Bun.spawn>
1159
- const pipes = new TaskPipes(opts.terminal)
1104
+ let proc: Child
1105
+ // A spare shell takes the task when there is one (warm-shell.ts).
1106
+ const script =
1107
+ opts.terminal === true ? undefined : spareScript(opts.cwd, opts.env, execWrap(fullCommand))
1108
+ const spare = script === undefined ? undefined : takeSpare()
1109
+ const onSpare = spare === undefined ? undefined : await runOnSpare(spare, script!)
1110
+ const pipes = onSpare === undefined ? new TaskPipes(opts.terminal) : spare!.pipes
1160
1111
  const restoreModes = opts.terminal === true ? keepTerminalModes() : undefined
1161
- try {
1162
- proc = spawnGuarded((guard) =>
1163
- Bun.spawn(shellArgv((guard === undefined ? '' : guardLine(3)) + execWrap(fullCommand)), {
1112
+ if (onSpare !== undefined) proc = onSpare as unknown as Child
1113
+ else
1114
+ try {
1115
+ const stdio = taskStdio(opts.terminal, pipes)
1116
+ const argv = (guard: number | undefined): string[] =>
1117
+ shellArgv((guard === undefined ? '' : guardLine(3)) + execWrap(fullCommand))
1118
+ // Its own session and process group, so a kill reaches what it forked
1119
+ // (kill-tree.ts). A terminal handed over is not this session's
1120
+ // controlling one, so no read stops the group.
1121
+ const spawnOpts = {
1164
1122
  ...SH_ARGV0,
1165
1123
  cwd: opts.cwd,
1166
1124
  env: opts.env as Record<string, string>,
1167
- stdio: [...taskStdio(opts.terminal, pipes), ...(guard === undefined ? [] : [guard])],
1168
- // Its own session and process group, so a kill reaches what it
1169
- // forked (kill-tree.ts). A terminal handed over is not this
1170
- // session's controlling one, so no read stops the group.
1171
1125
  detached: true,
1172
- }),
1173
- )
1174
- } catch (err) {
1175
- pipes.close()
1176
- const stderr = spawnFailureText(err, opts.cwd, 'task', fullCommand)
1177
- opts.onStderr?.(stderr)
1178
- return { exitCode: 127, durationMs: Date.now() - start, stdout: '', stderr, spawnFailed: true }
1179
- } finally {
1180
- pipes.spawned()
1181
- }
1126
+ }
1127
+ // Descriptors cross to the spawn thread; Bun's own pipes (macOS) and a
1128
+ // terminal stay on this one.
1129
+ const fds = stdio.filter(
1130
+ (fd): fd is number | 'ignore' => typeof fd === 'number' || fd === 'ignore',
1131
+ )
1132
+ proc =
1133
+ fds.length === stdio.length
1134
+ ? // What vx reads of a child (pid, exit, usage, kill) is the same off
1135
+ // the thread; its streams are the pipes vx made (`TaskPipes`).
1136
+ ((await spawnGuarded((guard) =>
1137
+ spawnOff({
1138
+ cmd: argv(guard),
1139
+ ...spawnOpts,
1140
+ stdio: [...fds, ...(guard === undefined ? [] : [guard])],
1141
+ }),
1142
+ )) as unknown as Child)
1143
+ : spawnGuarded((guard) =>
1144
+ Bun.spawn(argv(guard), {
1145
+ ...spawnOpts,
1146
+ stdio: [...stdio, ...(guard === undefined ? [] : [guard])],
1147
+ }),
1148
+ )
1149
+ } catch (err) {
1150
+ pipes.close()
1151
+ const stderr = spawnFailureText(err, opts.cwd, 'task', fullCommand)
1152
+ opts.onStderr?.(stderr)
1153
+ return {
1154
+ exitCode: 127,
1155
+ durationMs: Date.now() - start,
1156
+ stdout: '',
1157
+ stderr,
1158
+ spawnFailed: true,
1159
+ }
1160
+ } finally {
1161
+ pipes.spawned()
1162
+ }
1182
1163
 
1183
1164
  opts.liveChildren?.add(proc)
1165
+ // The spawn is awaited (off the thread, or on a spare shell), and the
1166
+ // command runs meanwhile: a stop that landed then swept a live set
1167
+ // without it, and the run waited out the whole command (CI, 2026-11-04).
1168
+ if (opts.signal?.aborted === true) void stopLate(proc, stopSignal(opts.signal.reason))
1184
1169
  opts.onSpawn?.(proc.pid)
1170
+ // Linux: the wait's peak includes vx's own, inherited at exec, so a task
1171
+ // lighter than vx reads none (`rssFloorFor`); the tree looked at while it
1172
+ // runs has the task's own.
1173
+ const watch = process.platform === 'linux' ? watchTree(proc.pid) : undefined
1185
1174
  const timeout = armTimeout(proc, opts.timeoutMs)
1186
1175
  const ac = new AbortController()
1187
1176
  const [out, err] = pipes.streams(proc)
@@ -1203,12 +1192,30 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
1203
1192
  if (timeout.timedOut()) ac.abort()
1204
1193
  else cut = await drainOrAbort(streams, ac)
1205
1194
  const [stdout, streamed] = await streams
1195
+ ends.end()
1206
1196
  const cutLine = ends.own(POST_EXIT_CUT_LINE)
1207
1197
  if (cut) opts.onStderr?.(cutLine)
1208
1198
  const stderr = cut ? streamed + cutLine : streamed
1209
1199
  opts.liveChildren?.delete(proc)
1210
1200
  releaseGroup(proc)
1211
1201
  const exited = proc.exitCode ?? (proc.signalCode ? signalExitCode(proc.signalCode) : 1)
1202
+ // The spare's `cd` failed: the task never ran, as a spawn into a missing
1203
+ // directory throws. A 127 is the shell's own otherwise.
1204
+ if (onSpare !== undefined && exited === 127 && !existsSync(opts.cwd)) {
1205
+ const text = spawnFailureText(
1206
+ Object.assign(new Error('ENOENT'), { code: 'ENOENT' }),
1207
+ opts.cwd,
1208
+ 'task',
1209
+ )
1210
+ opts.onStderr?.(text)
1211
+ return {
1212
+ exitCode: 127,
1213
+ durationMs: Date.now() - start,
1214
+ stdout,
1215
+ stderr: stderr + text,
1216
+ spawnFailed: true,
1217
+ }
1218
+ }
1212
1219
  // A timeout is a failure, whatever the child said: one that traps the
1213
1220
  // TERM and exits 0 reported success to every executor built on this.
1214
1221
  const exitCode = exited === 0 && timeout.timedOut() ? signalExitCode('SIGTERM') : exited
@@ -1220,10 +1227,20 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
1220
1227
  ...(proc.signalCode != null ? { signal: proc.signalCode } : {}),
1221
1228
  ...(timeout.timedOut() ? { timedOut: true } : {}),
1222
1229
  ...(cut ? { leftRunning: true } : {}),
1223
- ...resourceUsageToCpuRss(proc.resourceUsage(), rssFloorFor),
1230
+ ...usageOf(proc, watch?.stop()),
1224
1231
  }
1225
1232
  }
1226
1233
 
1234
+ /** The CPU and peak a task used: the wait's, and the peak the looks saw when the wait's is vx's own. */
1235
+ function usageOf(
1236
+ proc: Child,
1237
+ looked: TreeUsage | undefined,
1238
+ ): { cpuMs?: number; peakRssBytes?: number } {
1239
+ const usage = resourceUsageToCpuRss(proc.resourceUsage(), rssFloorFor)
1240
+ if (looked === undefined || looked.rssBytes <= 0) return usage
1241
+ return { ...usage, peakRssBytes: Math.max(usage.peakRssBytes ?? 0, looked.rssBytes) }
1242
+ }
1243
+
1227
1244
  /**
1228
1245
  * This process's own RSS high-water mark, in bytes: the floor under which
1229
1246
  * a child's `ru_maxrss` says nothing about the child. Linux folds the
@@ -14,7 +14,7 @@ import {
14
14
  unique,
15
15
  } from './sandbox-paths.js'
16
16
  import type { SandboxedRunArgs } from './sandbox-runtime.js'
17
- import { ui } from '../util/index.js'
17
+ import * as ui from '../ui/index.js'
18
18
 
19
19
  type SrtModule = typeof import('@anthropic-ai/sandbox-runtime')
20
20
 
@@ -58,10 +58,10 @@ import {
58
58
  spawnFailureText,
59
59
  streamToString,
60
60
  resourceUsageToCpuRss,
61
- TaskPipes,
62
61
  type CaptureConfig,
63
62
  type RunResult,
64
63
  } from './runner.js'
64
+ import { TaskPipes } from './pipes.js'
65
65
  import { watchTree } from './proc-sample.js'
66
66
  import {
67
67
  BUN_GLOB_WILDCARDS,
@@ -73,10 +73,10 @@ import {
73
73
  isTmpdirRefusal,
74
74
  procfsIsOwn,
75
75
  TMPDIR_HINT,
76
- ui,
77
76
  UserError,
78
77
  xxh3hex,
79
78
  } from '../util/index.js'
79
+ import * as ui from '../ui/index.js'
80
80
  import {
81
81
  bindableReads,
82
82
  bindableWrites,
@@ -2290,6 +2290,7 @@ async function runSandboxedOnce(
2290
2290
  if (timeout.timedOut()) ac.abort()
2291
2291
  else cut = await drainOrAbort(streams, ac)
2292
2292
  const [stdout, streamed] = await streams
2293
+ ends.end()
2293
2294
  const cutLine = ends.own(POST_EXIT_CUT_LINE)
2294
2295
  if (cut) args.onStderr?.(cutLine)
2295
2296
  const stderr = cut ? streamed + cutLine : streamed
@@ -2323,6 +2324,14 @@ async function runSandboxedOnce(
2323
2324
  // (2026-09-05). A sandboxed task that fails must say what it was denied;
2324
2325
  // deciding on the user's behalf that a record was "just traversal" is how
2325
2326
  // a failure ends up with an empty violations section and no explanation.
2327
+ // Linux: the write observer's records reach the store over a Unix socket
2328
+ // this process serves, not with the child's exit. The observer sent them
2329
+ // before the task exited, but a task that ends quickly is seen to exit
2330
+ // in the turn its connection is accepted, before it is read: a turn to
2331
+ // accept and a turn to read take them in (40 fast refused mkdirs at
2332
+ // once: 38 of 200 unreported, 33 with one turn, none with two).
2333
+ if (process.platform === 'linux')
2334
+ for (let turn = 0; turn < 2; turn++) await new Promise((r) => setImmediate(r))
2326
2335
  const records = takeRecords()
2327
2336
  // Read as is on darwin only. Since SRT 0.0.75 the store is fed on Linux
2328
2337
  // too, by the seccomp helper's write observer — but SRT judges those
@@ -0,0 +1,154 @@
1
+ // A task's spawn on a thread of its own. Bun's spawn holds the calling
2
+ // thread until the child has exec'd (vfork), ~0.6 ms a task, and on a cold
3
+ // run that thread is the scheduler's: 2,000 tasks with 1.8 ms of the
4
+ // scheduler's own work each took 4.96 s spawned there and 3.71 s spawned
5
+ // here, the 3.6 s of that work the floor (2026-10-29). The child's
6
+ // descriptors are numbers in the one table every thread shares, so its
7
+ // pipes are made and read where they were; only the spawn and the wait
8
+ // for its exit move.
9
+
10
+ import type { ResourceUsage } from 'bun'
11
+
12
+ /** What `runCommand` reads of a child spawned here. */
13
+ export interface OffChild {
14
+ readonly pid: number
15
+ readonly exited: Promise<number>
16
+ readonly exitCode: number | null
17
+ readonly signalCode: NodeJS.Signals | null
18
+ resourceUsage(): ResourceUsage | undefined
19
+ kill(signal?: NodeJS.Signals | number): void
20
+ /** Hold the process open from now until the child exits: a spawn with `held: false` does not. */
21
+ hold(): void
22
+ }
23
+
24
+ export interface OffSpawn {
25
+ cmd: string[]
26
+ argv0?: string
27
+ cwd?: string
28
+ env?: Record<string, string>
29
+ /** Each a descriptor number or `'ignore'`: a stream cannot cross threads. */
30
+ stdio: Array<number | 'ignore'>
31
+ detached?: boolean
32
+ /** False: the child keeps no run alive until `hold()` (a spare shell waiting for its task). */
33
+ held?: boolean
34
+ }
35
+
36
+ // The worker as source, not a file: a compiled vx carries only its entry
37
+ // point's graph, and a worker file would need an entry point of its own.
38
+ const WORKER = `
39
+ const live = new Map()
40
+ self.onmessage = (e) => {
41
+ const m = e.data
42
+ if (m.kill !== undefined) { live.get(m.kill)?.kill(m.signal); return }
43
+ let p
44
+ try {
45
+ p = Bun.spawn(m.opts.cmd, m.opts)
46
+ } catch (err) {
47
+ postMessage({ id: m.id, error: { message: String(err?.message ?? err), code: err?.code } })
48
+ return
49
+ }
50
+ live.set(m.id, p)
51
+ postMessage({ id: m.id, pid: p.pid })
52
+ p.exited.then(() => {
53
+ live.delete(m.id)
54
+ const u = p.resourceUsage()
55
+ // Its two fields vx reads: the object itself does not cross threads.
56
+ const usage = u === undefined ? undefined : { cpuTime: { user: u.cpuTime.user, system: u.cpuTime.system, total: u.cpuTime.total }, maxRSS: u.maxRSS }
57
+ postMessage({ id: m.id, exitCode: p.exitCode, signalCode: p.signalCode, usage })
58
+ })
59
+ }
60
+ `
61
+
62
+ interface Pending {
63
+ held: () => boolean
64
+ spawned: (pid: number) => void
65
+ failed: (err: Error) => void
66
+ exit: (m: {
67
+ exitCode: number | null
68
+ signalCode: NodeJS.Signals | null
69
+ usage?: ResourceUsage
70
+ }) => void
71
+ }
72
+
73
+ let worker: Worker | undefined
74
+ let nextId = 0
75
+ const pending = new Map<number, Pending>()
76
+ let liveCount = 0
77
+
78
+ function thread(): Worker {
79
+ if (worker !== undefined) return worker
80
+ const w = new Worker(URL.createObjectURL(new Blob([WORKER], { type: 'text/javascript' })))
81
+ w.onmessage = (e: MessageEvent) => {
82
+ const m = e.data
83
+ const p = pending.get(m.id)
84
+ if (p === undefined) return
85
+ if (m.error !== undefined) {
86
+ pending.delete(m.id)
87
+ const err = new Error(m.error.message) as NodeJS.ErrnoException
88
+ if (m.error.code !== undefined) err.code = m.error.code
89
+ if (p.held()) settleLive(-1)
90
+ p.failed(err)
91
+ } else if (m.pid !== undefined) p.spawned(m.pid)
92
+ else {
93
+ pending.delete(m.id)
94
+ if (p.held()) settleLive(-1)
95
+ p.exit(m)
96
+ }
97
+ }
98
+ // An idle thread keeps no run alive.
99
+ w.unref()
100
+ worker = w
101
+ return w
102
+ }
103
+
104
+ /** Hold the process open while a child spawned here runs, as a spawn on the main thread does. */
105
+ function settleLive(delta: number): void {
106
+ liveCount += delta
107
+ if (liveCount > 0) worker?.ref()
108
+ else worker?.unref()
109
+ }
110
+
111
+ /** Spawn `opts` on the spawn thread; resolves once the child is running, rejects when the spawn threw. */
112
+ export function spawnOff(opts: OffSpawn): Promise<OffChild> {
113
+ const w = thread()
114
+ const id = nextId++
115
+ let held = opts.held !== false
116
+ if (held) settleLive(1)
117
+ const { held: _, ...spawn } = opts
118
+ return new Promise((resolve, reject) => {
119
+ let exitCode: number | null = null
120
+ let signalCode: NodeJS.Signals | null = null
121
+ let usage: ResourceUsage | undefined
122
+ let onExit!: (code: number) => void
123
+ const exited = new Promise<number>((r) => (onExit = r))
124
+ pending.set(id, {
125
+ held: () => held,
126
+ failed: reject,
127
+ spawned: (pid) =>
128
+ resolve({
129
+ pid,
130
+ exited,
131
+ get exitCode() {
132
+ return exitCode
133
+ },
134
+ get signalCode() {
135
+ return signalCode
136
+ },
137
+ resourceUsage: () => usage,
138
+ kill: (signal) => w.postMessage({ kill: id, signal }),
139
+ hold: () => {
140
+ if (held || exitCode !== null || signalCode !== null) return
141
+ held = true
142
+ settleLive(1)
143
+ },
144
+ }),
145
+ exit: (m) => {
146
+ exitCode = m.exitCode
147
+ signalCode = m.signalCode
148
+ usage = m.usage
149
+ onExit(exitCode ?? 1)
150
+ },
151
+ })
152
+ w.postMessage({ id, opts: spawn })
153
+ })
154
+ }