Report a self-requested Codex stop as interrupted, not a provider error - #246
Merged
hamzamerzic merged 3 commits intoJul 27, 2026
Merged
Conversation
Co-authored-by: Möbius Agent <mobius-agent@users.noreply.github.com>
Co-authored-by: Möbius Agent <mobius-agent@users.noreply.github.com>
Co-authored-by: Möbius Agent <mobius-agent@users.noreply.github.com>
This was referenced Jul 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
When a turn is stopped — the owner pressing Stop, the stall watchdog, or a newer turn superseding this one — Möbius interrupts the turn and, if that interrupt times out, escalates to SIGTERM against the turn's private process group. The Codex transport then dies mid-stream instead of delivering
turn/completed, and the runner's genericexceptpath reported that death as a provider error string (e.g."Codex process closed stdout. stderr_tail=").That string is not merely wrong attribution — it is destructive.
chat.pypublishes a non-nullerroras an error block, andevents.process_eventcoalesces error blocks latest-wins: the newer block pops everyERROR_PASSTHROUGH_FIELDSkey it does not itself carry. The stop/stall pause note published moments earlier is an error block too, so it is overwritten and its one-tap Resume disappears. The owner is left with an unexplained provider error and no way back into the conversation.Cause
run_codex_sdk_turn'sexcept Exceptionpath had no way to tell "the provider broke" from "we killed this turn ourselves". Both arrive as an exception out of the stream, and both were returned as{"error": str(exc)}.Telling them apart needs two facts the runner already had but never combined:
ErrorNotificationas a plainRuntimeError(message), and those messages routinely contain closed-ish phrasing such as"MCP server 'x' is not running";active_turn.interrupt_requested, or the superseded-generation abort, which can be true before anActiveCodexTurnexists at all.The existing
_is_closed_turn_erroris deliberately loose about the first: it accepts bareRuntimeErrortext because its only caller is the steer path, where a false positive costs a refused steer. It is far too loose to decide whether an error reaches the owner.Fix
backend/app/codex_sdk_runner.py_is_transport_death()out of_is_closed_turn_error(). The new predicate matches only the SDK's ownTransportClosedError(byisinstanceagainst the real symbol from_sdk_imports(), so genuine subclasses match and same-named impostors do not) or an RPC error about a closed/dead channel._is_closed_turn_errorstays wide and keeps its steer-path caller.stop_requested()— one definition of "we did this to ourselves", shared by the terminal-validation path (which sees a cleanTurnStatus.interrupted) and the except path (which sees the transport die becauseforce_stopkilled the process group). It includes the superseded-generation abort, which can be true beforeactive_turnexists: a teardown during startup is no more the provider's fault than one mid-stream.with_usage()and route the error returns through it, so a turn that spent tokens and then ended reports them however it ended.interruptedterminal status witherror: Noneinstead of a raw provider string. Logged at WARNING: the owner still receives a clean interrupted outcome, while a coincident real transport crash remains visible to operators because the transport's dying words are the only forensics left aftererroris intentionally cleared downstream._sdk_imports()now exposesTransportClosedError, and the guard around it catchesImportErrorrather thanModuleNotFoundError: an SDK that renames or drops the symbol fails thefrom … importthe same way a missing package does, and this predicate runs inside the turn's except handler — raising there would mask the very exception it was asked to classify.Verification
The containerised runner was unavailable here, so pytest ran directly with an isolated tmp
DATA_DIR/SQLite DB asconftest.pyprovisions.test_codex_sdk_runner.py+test_codex_sdk_contract.py+test_runner_registry.py+test_runner_registry_integration.py→ 112 passedtest_chat*.py→ 318 passedtest_events.py+test_runner_registry.py→ 90 passedEvery new test is mutation-verified — each mutation applied, suite re-run, then reverted:
_is_closed_turn_errorisinstancereplaced by class-name comparisonwith_usagedropped from the sibling error returnand stop_requested()droppedstop_requested()narrowed to the interrupt flag onlyA new contract test pins that the SDK still exposes
TransportClosedError, so a future SDK that renames it fails loudly rather than silently degrading.Residual notes
isinstancebinding is exercised against a real subclass of the stand-in. The contract test (skipped without the SDK) is what pins the real symbol.stop_requested(), so any future exception that is genuinely a transport death but is neither aTransportClosedErrorsubclass nor a closed/not-running/broken-pipe RPC error keeps being reported as a provider error — the safe direction.