Wall #10 of the pi bring-up (tracker #6564). GATE 2a is stdout+stderr byte-identical to node but exit code diverges: node rc=1, perry rc=0. The natural-exit process.exitCode consultation (#6666, PR #6671) is a necessary prerequisite but NOT the cause — a runtime trace shows process.exitCode is never assigned and process.exit() never called, because the value that would set it stays 0.
Confirmed symptom (fresh run, request_id masked)
cd secret-tests/pi-target/dist
HOME=$(mktemp -d) ./pi-native -p --provider anthropic --model claude-3-5-haiku-latest --api-key sk-ant-bogus-diff "hi"
- node: stdout empty, stderr = model-warning + deprecation +
401 {...authentication_error...}, rc=1
- perry: stdout + stderr byte-identical (req_id aside), rc=0
The chain (bundle line refs in pi-target/dist/pi-bundle.mjs)
- Streaming error handler (async, ~51092): on the 401 the
try throws, the catch (error40) runs and sets output.stopReason = "error", output.errorMessage = ..., then stream12.push({ type: "error", error: output }). That pushed error is what prints the 401 to stderr (via the runPrintMode subscription) — and it prints identically under perry, so the catch DID run and DID push.
runPrintMode (~292838, print-mode.js): after the prompt resolves, it reads session.state.messages' last message; if (assistantMsg.stopReason === "error" || ... "aborted") { console.error(...); exitCode = 1 }. Returns exitCode. Caller does if (exitCode !== 0) process.exitCode = exitCode.
The paradox that localizes the bug
stderr is byte-identical (the streamed error surfaced fine), yet runPrintMode returns 0 under perry — which means the assistant message committed to session.state.messages does not carry stopReason === "error", even though the catch that mutates output.stopReason = "error" provably executed (it produced the printed error). So: the catch's mutation of output is not visible on the object that lands in session.state under perry, or the message is snapshotted/committed to state at the wrong point relative to the async catch.
Leads (perry side, not pi)
Reproduction & assets
Binary pi-target/dist/pi-native (built on the #6666 branch — includes that fix; pi-native-dbg = debug symbols). Bundle+assets under pi-target/dist. Minimal-fixture target: an async function that awaits a stream/iterator, whose catch mutates a shared object then that object is read after the await — assert the mutation is visible, byte-identical to node.
Blocks GATE 2a full pass (rc parity). Does NOT block gate 2b/TUI investigation structurally, but exit-code correctness is core parity.
Wall #10 of the pi bring-up (tracker #6564). GATE 2a is stdout+stderr byte-identical to node but exit code diverges: node rc=1, perry rc=0. The natural-exit
process.exitCodeconsultation (#6666, PR #6671) is a necessary prerequisite but NOT the cause — a runtime trace showsprocess.exitCodeis never assigned andprocess.exit()never called, because the value that would set it stays 0.Confirmed symptom (fresh run, request_id masked)
401 {...authentication_error...}, rc=1The chain (bundle line refs in pi-target/dist/pi-bundle.mjs)
trythrows, thecatch (error40)runs and setsoutput.stopReason = "error",output.errorMessage = ..., thenstream12.push({ type: "error", error: output }). That pushed error is what prints the 401 to stderr (via the runPrintMode subscription) — and it prints identically under perry, so the catch DID run and DID push.runPrintMode(~292838, print-mode.js): after the prompt resolves, it readssession.state.messages' last message;if (assistantMsg.stopReason === "error" || ... "aborted") { console.error(...); exitCode = 1 }. ReturnsexitCode. Caller doesif (exitCode !== 0) process.exitCode = exitCode.The paradox that localizes the bug
stderr is byte-identical (the streamed error surfaced fine), yet
runPrintModereturns 0 under perry — which means the assistant message committed tosession.state.messagesdoes not carrystopReason === "error", even though the catch that mutatesoutput.stopReason = "error"provably executed (it produced the printed error). So: the catch's mutation ofoutputis not visible on the object that lands insession.stateunder perry, or the message is snapshotted/committed to state at the wrong point relative to the async catch.Leads (perry side, not pi)
async function*never caught → killed API-error classification — FIXED on main, but pi's shape — an async catch mutating a sharedoutputobject then pushing to a stream the consumer awaits — may hit a sibling gap) and the boxed-capture regression Regression on main: be2e23f86 (#6470 per-evaluation class captures) breaks boxed-capture reads — gap tests anon_shape_boxed_capture + 5952_mixin_factory_binding fail, every PR's conformance shards 5/7 red #6497.outputobject reference committed to state is a distinct copy from the one the catch mutates (aliasing/boxing of a captured local across the async boundary); (b) the catch's field writes race the state-commit ordering under perry's async scheduler; (c) the stream "done"/"error" event ordering differs so state capturesoutputpre-mutation.PERRY_REJECTION_DIAG=1exists (from runtime: pi one-shot (-p) dies with 'Uncaught (in promise) undefined' before first output; node completes full API 401 round-trip — pi wall #8 #6660); a similar targeted trace on the message-commit path + comparing theoutputidentity at catch-time vs state-commit-time would settle (a) vs (c).Reproduction & assets
Binary
pi-target/dist/pi-native(built on the #6666 branch — includes that fix;pi-native-dbg= debug symbols). Bundle+assets underpi-target/dist. Minimal-fixture target: an async function that awaits a stream/iterator, whosecatchmutates a shared object then that object is read after the await — assert the mutation is visible, byte-identical to node.Blocks GATE 2a full pass (rc parity). Does NOT block gate 2b/TUI investigation structurally, but exit-code correctness is core parity.