src/exec/runner.ts — child process invocation + rusage capture
Purpose
Section titled “Purpose”Spawn a shell command, stream stdout/stderr live, capture full text,
and surface CPU + peak RSS from Bun.spawn().resourceUsage(). Also
hosts the runPersistent variant for long-running tasks that don’t
exit before the rest of the graph finishes.
Public surface
Section titled “Public surface”export interface RunResult { exitCode: number durationMs: number stdout: string // full captured text stderr: string cpuMs?: number // user + system, from Bun.spawn().resourceUsage() peakRssBytes?: number // maxRSS * 1024 (Bun normalizes KB; we → bytes)}
export interface RunOptions { command: string // single shell string cwd: string // absolute working dir env: NodeJS.ProcessEnv forwardArgs?: readonly string[] // appended shell-quoted to `command` onStdout?: (chunk: string) => void onStderr?: (chunk: string) => void liveChildren?: Set<ReturnType<typeof Bun.spawn>> // run-scoped registry; child added on spawn, removed on exit}
export function runCommand(opts: RunOptions): Promise<RunResult>
// POSIX "terminated by signal N" → exit 128+N (SIGINT → 130, SIGTERM → 143).export function signalExitCode(signal: NodeJS.Signals): number
export interface PersistentOptions extends Omit<RunOptions, 'forwardArgs'> { readyWhen?: string // string regex; matched against streamed output}
export interface PersistentSpawn { child: ReturnType<typeof Bun.spawn> ready: Promise<void> // resolves once "ready"; rejects if exit before ready bufferedStdout: () => string // captured stdout up to current moment bufferedStderr: () => string readyMs: () => number // ms from spawn to ready (or now)}
export function runPersistent(opts: PersistentOptions): PersistentSpawn
export function shellQuote(arg: string): stringexport function signalExitCode(signal: string): number // 128 + signo; 130 fallbackexport function streamToString( stream: ReadableStream<Uint8Array> | number | undefined, onChunk?: (s: string) => void,): Promise<string>export function resourceUsageToCpuRss( usage: ReturnType<ReturnType<typeof Bun.spawn>['resourceUsage']>,): { cpuMs?: number; peakRssBytes?: number }Spawning rules
Section titled “Spawning rules”- Shell:
Bun.spawn(['sh', '-c', command], ...). POSIX-shell only; Windows is unsupported (nocmd.exebranch). - stdio:
stdin: 'ignore'(no interactive prompts);stdout: 'pipe',stderr: 'pipe'. - forwardArgs are appended to
commandafter a single space, each quoted viashellQuote(arg)(i.e.'...'-quoted when not safe). - Encoding: UTF-8 via
TextDecoder({ stream: true }). Non-UTF8 bytes are corrupted.
The promise from runCommand always resolves (never rejects) with a
RunResult:
- Normal exit →
exitCodeis the child’s exit code. - Signal-killed →
exitCode = signalExitCode(signalCode)— the POSIX 128 + signo convention (SIGTERM → 143, SIGKILL → 137), falling back to 130 for signal names missing fromos.constants.signals. The sandboxed runner (sandbox-runtime.ts) uses the same helper. Bun.spawnitself throwing →exitCode = 127, stderr augmented with[vx] failed to spawn: <message>.
Resource usage
Section titled “Resource usage”resourceUsageToCpuRss(proc.resourceUsage()) converts Bun’s shape
into our schema:
cpuTime.totalis a microseconds bigint →cpuMs = Number(...) / 1000.maxRSSis kilobytes on Linux/macOS →peakRssBytes = maxRSS * 1024.
Returns {} (no fields) when resourceUsage() is unavailable; the
orchestrator persists NULLs in the runs table for that task.
runPersistent — long-running tasks
Section titled “runPersistent — long-running tasks”Spawns the child but returns immediately with a PersistentSpawn
descriptor. The ready promise:
- Resolves on the first stdout/stderr output that matches the
compiled
readyWhenregex. - Resolves immediately on successful spawn when
readyWhenis undefined. - Rejects if the child exits before either condition is met (with a
message identifying the exit code and noting whether
readyWhenever matched).
The pattern matcher buffers across chunk boundaries and tests the whole pending fragment — complete lines plus the trailing partial line — so neither a match split across two reads nor a prompt-style marker without a trailing newline is missed. Complete lines that didn’t match are discarded after each test to bound memory.
A never-matching readyWhen on a child that keeps running would hang
the run forever — bound the wait with readyTimeoutMs: when set, a
timer SIGTERMs the child and rejects ready with a clear timeout
message once the window passes. The timer is cleared the moment ready
fires, so a healthy server is never killed late. No default — opting
into a readiness signal is explicit, and so is bounding it.
Stream readers run for the child’s lifetime; the caller owns the
child handle and is responsible for SIGTERMing it. The orchestrator
does this via its persistentRegistry at end-of-run.
bufferedStdout() / bufferedStderr() return everything captured so
far — useful for surfacing pre-ready output on a fail-before-ready
outcome.
What this does NOT do
Section titled “What this does NOT do”- Doesn’t time out
runCommand. One-shot commands run as long as they run; only persistent readiness has a bound (readyTimeoutMs). - Doesn’t sandbox. The child has full process privileges. A bwrap sandbox was tried and reverted (Ubuntu 24 AppArmor breaks it in CI; design-doc/sandbox.md was removed).
- Doesn’t install signal handlers. Signal shutdown is the
orchestrator’s job: it owns the
liveChildrenset this module populates, SIGTERMs everything in it on SIGINT/SIGTERM, and exitssignalExitCode(signal). The runner only maintains the registry. No process-group setup — only direct children are signalled, so a task that double-forks can still leave grandchildren behind. - Doesn’t strip ANSI. Color sequences pass through verbatim, enabling color-preserving cache-hit replays.
- No Windows support.
sh -conly.
tests/runner.test.ts covers:
- Success path returns exit 0 + captured stdout + rusage fields.
- Failure returns non-zero + captured stderr.
- Streaming callbacks fire per chunk.
- Spawn failure (
/bin/shmissing scenarios) surfaces as exit 127. - Signal-killed children map to 128 + signo (
SIGKILL→ 137,SIGTERM→ 143) plussignalExitCodeunit coverage. shellQuotecovers the safe-char and unsafe-char paths.runPersistent: marker without trailing newline, marker split across chunks, newline-terminated marker, reject-on-exit-before- ready. Ready-on-spawn + orchestrator wiring are covered by the persistent e2e suite (tests/persistent.test.ts).
Replacing this module
Section titled “Replacing this module”- Container execution — replace the
Bun.spawncall with a Docker / podman / containerd invocation. Keep theRunResultshape. Inputs / outputs need volume mounts. - Remote execution — RPC to a build farm. Same contract; latency becomes the dominant cost.
- Per-step timeouts — easy addition: add
timeoutMs?toRunOptions; schedulechild.kill()then race againstexited. - Different shell — replace
['sh', '-c', cmd]with['bash', '-c', cmd]or a parsed argv. Cache keys would shift if the shell semantics differ (you’d want to fold the choice into the key).
Preserve the RunResult shape — the rest of the codebase depends on
exitCode, durationMs, stdout, stderr, cpuMs, peakRssBytes
being populated consistently.