fix(anthropic): tolerate null exit code when result event was seen (PR-B sandbox)

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-28 17:38:27 +10:00
co-authored by Claude Opus 4.7
parent 28642756b5
commit 497b2550e6
+15 -1
View File
@@ -1107,7 +1107,21 @@ async function* _spawnAndStream(irRequest, authContext, spawnImpl) {
// Process close — safety net if no `result` event was emitted // Process close — safety net if no `result` event was emitted
// (e.g. version drift where stream-json is not supported without -p). // (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. // 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
// '<bwrap-args> ... 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}`; const errMsg = accumulatedStderr.slice(0, 300) || `claude exit ${exitCode}`;
throw new ProviderError(errMsg, 'SPAWN_FAILED'); throw new ProviderError(errMsg, 'SPAWN_FAILED');
} }