From 497b2550e644790dcbde7f8bf2652f5d52731e40 Mon Sep 17 00:00:00 2001 From: dtzp555 Date: Thu, 28 May 2026 17:38:27 +1000 Subject: [PATCH] fix(anthropic): tolerate null exit code when result event was seen (PR-B sandbox) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /bin/sh -c wrapping that sandbox-runtime applies (bwrap argv prefix + inner claude command) can return exit code null on cleanup even when the underlying claude process completed cleanly and emitted the `result` event. The previous logic treated any non-zero exit as fatal and threw ProviderError, causing the HTTP handler to discard the already-yielded chunks and respond with content:null. resultEventSeen=true is the authoritative success signal — if it's set, the model completed and the stop chunk was yielded. Abnormal exit after that is sandbox bookkeeping noise. Live PI231 evidence (2026-05-28 commit 2864275 deploy): Direct provider test (bypasses HTTP): chunk: {"type":"delta","content":"DIRECT_PROOF",...} chunk: {"type":"stop","finish_reason":"stop"} ERR: claude exit null ← throw after chunks yielded HTTP response: choices[0].message.content == null (chunks lost when consumer received throw) Fix: only throw on non-zero exit when resultEventSeen=false. With the guard, the smoke `reply: SANDBOX_PROOF` request now returns the proper content through the HTTP layer. The pre-PR-B (non-sandbox) path is unaffected: that path runs claude directly (no /bin/sh wrap), so exit is always 0 when the model completes, and the resultEventSeen check is a no-op for that path. Authority: - live PI231 2026-05-28 transcript (direct vs HTTP path divergence) - ADR 0009 Amendment 1 § "NDJSON event handling" — result event is the terminal-success indicator - ADR 0014 § PR-B — sandbox wrap introduces /bin/sh layer Tests: unchanged at 813 (this is a pure-defensive code path; Suite 44 on PI231 will now exercise the corrected path on E2E run). Co-Authored-By: Claude Opus 4.7 --- lib/providers/anthropic.mjs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/lib/providers/anthropic.mjs b/lib/providers/anthropic.mjs index 715e3a6..23feee6 100644 --- a/lib/providers/anthropic.mjs +++ b/lib/providers/anthropic.mjs @@ -1107,7 +1107,21 @@ async function* _spawnAndStream(irRequest, authContext, spawnImpl) { // Process close — safety net if no `result` event was emitted // (e.g. version drift where stream-json is not supported without -p). // Only throw on non-zero exit; zero exit with no result → emit synthetic stop. - if (exitCode !== 0 && !spawnTimedOut) { + // + // 2026-05-28 PR-B fold-in: when the spawn is sandbox-wrapped via /bin/sh -c + // ' ... claude ...', the wrapping shell can exit with code + // `null` (signal-cleanup) even though the model completed and emitted the + // `result` event. resultEventSeen=true is the authoritative signal that + // the model finished successfully; an abnormal exit *after* that is just + // sandbox bookkeeping noise, not a real failure. Without this guard, the + // HTTP layer would receive a thrown ProviderError despite chunks having + // been yielded, and the response would come back with content:null. + // + // Live PI231 evidence (2026-05-28 commit 2864275 deploy): + // stream NDJSON: delta(DIRECT_PROOF) → stop → close(null) + // without this guard: ProviderError 'claude exit null' → empty response + // with this guard: chunks propagate normally, content arrives + if (exitCode !== 0 && !spawnTimedOut && !resultEventSeen) { const errMsg = accumulatedStderr.slice(0, 300) || `claude exit ${exitCode}`; throw new ProviderError(errMsg, 'SPAWN_FAILED'); }