Summary
When an adapter emits events and then exits immediately, the daemon can drop the final events and mark the session terminal from the adapter process's exit code instead. The user-visible result is a session that shows done ✓ while its last events — including Error and Status{Errored} — never reach the transcript.
Why it happens
Two independent tasks feed one mpsc::Sender<AdapterMessage>:
crates/daemon/src/adapter.rs — the reader task turns adapter stdout notifications into AdapterMessage::Event.
crates/daemon/src/adapter.rs — a separate wait task sends AdapterMessage::Closed { exit_code } as soon as child.wait() returns.
Ordering between two senders on one channel isn't guaranteed, so Closed can overtake events the reader hasn't dispatched yet. In crates/daemon/src/session.rs, drain_adapter's Closed arm ends in break, so anything still queued behind it is discarded. The same arm derives the terminal state from the adapter process's exit code — which is 0 whenever the adapter returns Ok(()), regardless of what the session actually did — so the session lands on Done rather than Errored.
Repro
Any adapter that emits an event and then returns promptly. Concretely, with a headless codex adapter that emits Error + Status{Errored} + Done{1} and then exits (the shape proposed in #1073), 10 runs of the identical scenario gave:
- 4/10 —
errored, error event present in the transcript
- 6/10 —
done ✓, zero error events, transcript truncated at the last stdout line
Inserting a 300 ms sleep after the final Done emit makes it 6/6 correct; removing it restores the flakiness. That timing dependence is the race.
Impact
- Failure signaling from any adapter that exits right after reporting is unreliable — the failure is silently reported as success.
- It also means transcript tails can be truncated for short-lived adapter runs generally, not just error paths.
Suggested fix
Make Closed a drain barrier rather than a cut-off: have the reader task signal completion and have drain_adapter process everything the reader dispatched before applying Closed (e.g. Closed flows through the reader task after EOF, or the wait task waits on the reader's join handle before sending). Deriving a session's terminal state from the adapter process's exit code is also worth revisiting — an adapter that reports a failed session still exits 0.
Found while reviewing #1073.
Summary
When an adapter emits events and then exits immediately, the daemon can drop the final events and mark the session terminal from the adapter process's exit code instead. The user-visible result is a session that shows
done ✓while its last events — includingErrorandStatus{Errored}— never reach the transcript.Why it happens
Two independent tasks feed one
mpsc::Sender<AdapterMessage>:crates/daemon/src/adapter.rs— the reader task turns adapter stdout notifications intoAdapterMessage::Event.crates/daemon/src/adapter.rs— a separate wait task sendsAdapterMessage::Closed { exit_code }as soon aschild.wait()returns.Ordering between two senders on one channel isn't guaranteed, so
Closedcan overtake events the reader hasn't dispatched yet. Incrates/daemon/src/session.rs,drain_adapter'sClosedarm ends inbreak, so anything still queued behind it is discarded. The same arm derives the terminal state from the adapter process's exit code — which is0whenever the adapter returnsOk(()), regardless of what the session actually did — so the session lands onDonerather thanErrored.Repro
Any adapter that emits an event and then returns promptly. Concretely, with a headless codex adapter that emits
Error+Status{Errored}+Done{1}and then exits (the shape proposed in #1073), 10 runs of the identical scenario gave:errored, error event present in the transcriptdone ✓, zero error events, transcript truncated at the last stdout lineInserting a 300 ms sleep after the final
Doneemit makes it 6/6 correct; removing it restores the flakiness. That timing dependence is the race.Impact
Suggested fix
Make
Closeda drain barrier rather than a cut-off: have the reader task signal completion and havedrain_adapterprocess everything the reader dispatched before applyingClosed(e.g.Closedflows through the reader task after EOF, or the wait task waits on the reader's join handle before sending). Deriving a session's terminal state from the adapter process's exit code is also worth revisiting — an adapter that reports a failed session still exits0.Found while reviewing #1073.