From b6d17a4be5cf6b685bd76478934a5d18cd1b6b9a Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Fri, 28 Aug 2026 01:37:48 -0700 Subject: [PATCH] fix(stream): a child's pipe must not kill the run that owns it A killed child's stdout and stderr can emit `error` after the fact, and a stream with no error listener throws to the top of the process. Measured: a 42-cell study died at cell 3 on an unhandled EPIPE, after two cells had already cost twenty minutes each. `stdin` was handled and reported the policy that stopped reading; the other two pipes had `resume()` and a data listener and no error listener at all. The child's fate already reaches an operator through `health()`, so these listeners record into the same bounded buffer that explains a dead agent and do not rethrow. Both launchers get the same treatment, because a launcher's job is to outlive its agent long enough to publish the meter. --- drivers/stream-sandbox.ts | 11 +++++++++++ harnesses/author-policy | 4 ++++ harnesses/claude-code | 4 ++++ 3 files changed, 19 insertions(+) diff --git a/drivers/stream-sandbox.ts b/drivers/stream-sandbox.ts index e7ec70c..31ce654 100644 --- a/drivers/stream-sandbox.ts +++ b/drivers/stream-sandbox.ts @@ -285,6 +285,17 @@ export function createStreamSandboxDriver(options: StreamSandboxDriverOptions): // The agent's own output is not an action channel: actions arrive through // the file. Its stderr is still kept, bounded, because it is the only thing // that can explain an agent which never wrote an action. + // A killed child's pipes can emit `error` after the fact, and a stream with + // no error listener throws to the top of the process. MEASURED: a 42-cell + // study died at cell 3 on an unhandled `EPIPE` from one of these, after two + // cells had already cost twenty minutes each. The child's fate is already + // reported through `health()`, so these listeners record and do not rethrow. + started.stdout?.on('error', (error: Error) => { + if (agentStderr.length < 4096) agentStderr += `\n[stdout] ${error.message}` + }) + started.stderr?.on('error', (error: Error) => { + if (agentStderr.length < 4096) agentStderr += `\n[stderr] ${error.message}` + }) started.stdout?.resume() started.stderr?.setEncoding('utf8') started.stderr?.on('data', (chunk: string) => { diff --git a/harnesses/author-policy b/harnesses/author-policy index 2e9a94d..9bc533c 100755 --- a/harnesses/author-policy +++ b/harnesses/author-policy @@ -214,6 +214,10 @@ function account(event) { publish() const child = spawn(command, args, { cwd: sandbox, stdio: ['ignore', 'pipe', 'inherit'] }) +// A pipe to a killed child emits `error`, and a stream with no listener throws +// to the top of the process. This launcher's whole job is to outlive its agent +// long enough to publish the meter, so it cannot die on the agent's pipe. +child.stdout.on('error', (error) => console.error(`playproof: agent stdout: ${error.message}`)) let carry = '' child.stdout.setEncoding('utf8') child.stdout.on('data', (chunk) => { diff --git a/harnesses/claude-code b/harnesses/claude-code index 9d88b2f..358d569 100755 --- a/harnesses/claude-code +++ b/harnesses/claude-code @@ -223,6 +223,10 @@ function account(event) { publish() const child = spawn(command, args, { cwd: sandbox, stdio: ['ignore', 'pipe', 'inherit'] }) +// A pipe to a killed child emits `error`, and a stream with no listener throws +// to the top of the process. This launcher's whole job is to outlive its agent +// long enough to publish the meter, so it cannot die on the agent's pipe. +child.stdout.on('error', (error) => console.error(`playproof: agent stdout: ${error.message}`)) let carry = '' child.stdout.setEncoding('utf8') child.stdout.on('data', (chunk) => {