This repository was archived by the owner on Jul 4, 2026. It is now read-only.
fix(codex_app_server): make /stop actually stop the codex turn - #9
Merged
Merged
Conversation
Two related bugs in the codex_app_server interrupt path: Bug 1: AIAgent.interrupt() never propagated to the codex session. ``CodexAppServerSession.request_interrupt()`` (the public API for signalling the running turn loop to bail out) is called only from tests. In production, ``/stop``, gateway-side cancellation, and Ctrl+C all set ``self._interrupt_requested = True`` and fan out to worker threads + child agents, but never reach the codex session. The result: the codex subprocess keeps grinding on the active turn until either the post-tool quiet watchdog (90 s) or the outer deadline (600 s) fires — ``/stop`` effectively does nothing for up to 10 minutes. Bug 2: User interrupt + ``<turn_aborted>`` left ``should_retire=False``. The other interrupt-out paths (post-tool watchdog, outer deadline, turn/start auth failure) all set ``should_retire = True`` so the caller in ``agent/codex_runtime.py`` retires the session and the next turn respawns codex from scratch. The user-interrupt path and the ``<turn_aborted>`` marker paths skipped this, leaving the session alive while codex was presumably mid-cleanup of the interrupted turn. The next ``turn/start`` then races against that cleanup. Fixes: - ``run_agent.py: AIAgent.interrupt()``: after the existing fan-out to worker threads + child agents, also call ``self._codex_session.request_interrupt()`` when an active session exists. Defensive ``getattr(...) `` so the default-runtime path and pre-first-turn state both work; ``Exception`` swallowed so a weird session state can't break the rest of the interrupt flow. - ``agent/transports/codex_app_server_session.py``: set ``result.should_retire = True`` on the user-interrupt path (line ~451) and both ``<turn_aborted>`` marker paths (normal loop ~573 and approval-drain ~518). Matches the post-tool and deadline paths. Tests: ``tests/run_agent/test_interrupt_codex_session.py`` (new): - ``test_interrupt_propagates_to_codex_session``: stub agent with a mock ``_codex_session``, call ``interrupt()``, assert ``request_interrupt`` was called exactly once. - ``test_interrupt_is_noop_when_no_codex_session``: same but with no ``_codex_session`` attribute (default-runtime / pre-first-turn). - ``test_interrupt_swallows_codex_session_exception``: if ``request_interrupt`` raises, the interrupt flow still completes. - ``test_interrupt_after_session_closed_uses_session_attr_as_is``: when ``_codex_session = None`` (after retire), interrupt skips. ``tests/agent/transports/test_codex_app_server_session.py``: - ``TestSessionRetirement.test_user_interrupt_marks_session_for_retirement``: call ``request_interrupt`` then ``run_turn``, assert ``should_retire is True``. - ``TestSessionRetirement.test_turn_aborted_marker_marks_session_for_retirement``: feed an agentMessage containing ``<turn_aborted>``, assert ``should_retire is True``. 81/81 pass (5 new + 76 pre-existing across the touched test files, no regressions). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
kingsleydon
pushed a commit
that referenced
this pull request
Jun 3, 2026
Two related bugs in the codex_app_server interrupt path: Bug 1: AIAgent.interrupt() never propagated to the codex session. ``CodexAppServerSession.request_interrupt()`` (the public API for signalling the running turn loop to bail out) is called only from tests. In production, ``/stop``, gateway-side cancellation, and Ctrl+C all set ``self._interrupt_requested = True`` and fan out to worker threads + child agents, but never reach the codex session. The result: the codex subprocess keeps grinding on the active turn until either the post-tool quiet watchdog (90 s) or the outer deadline (600 s) fires — ``/stop`` effectively does nothing for up to 10 minutes. Bug 2: User interrupt + ``<turn_aborted>`` left ``should_retire=False``. The other interrupt-out paths (post-tool watchdog, outer deadline, turn/start auth failure) all set ``should_retire = True`` so the caller in ``agent/codex_runtime.py`` retires the session and the next turn respawns codex from scratch. The user-interrupt path and the ``<turn_aborted>`` marker paths skipped this, leaving the session alive while codex was presumably mid-cleanup of the interrupted turn. The next ``turn/start`` then races against that cleanup. Fixes: - ``run_agent.py: AIAgent.interrupt()``: after the existing fan-out to worker threads + child agents, also call ``self._codex_session.request_interrupt()`` when an active session exists. Defensive ``getattr(...) `` so the default-runtime path and pre-first-turn state both work; ``Exception`` swallowed so a weird session state can't break the rest of the interrupt flow. - ``agent/transports/codex_app_server_session.py``: set ``result.should_retire = True`` on the user-interrupt path (line ~451) and both ``<turn_aborted>`` marker paths (normal loop ~573 and approval-drain ~518). Matches the post-tool and deadline paths. Tests: ``tests/run_agent/test_interrupt_codex_session.py`` (new): - ``test_interrupt_propagates_to_codex_session``: stub agent with a mock ``_codex_session``, call ``interrupt()``, assert ``request_interrupt`` was called exactly once. - ``test_interrupt_is_noop_when_no_codex_session``: same but with no ``_codex_session`` attribute (default-runtime / pre-first-turn). - ``test_interrupt_swallows_codex_session_exception``: if ``request_interrupt`` raises, the interrupt flow still completes. - ``test_interrupt_after_session_closed_uses_session_attr_as_is``: when ``_codex_session = None`` (after retire), interrupt skips. ``tests/agent/transports/test_codex_app_server_session.py``: - ``TestSessionRetirement.test_user_interrupt_marks_session_for_retirement``: call ``request_interrupt`` then ``run_turn``, assert ``should_retire is True``. - ``TestSessionRetirement.test_turn_aborted_marker_marks_session_for_retirement``: feed an agentMessage containing ``<turn_aborted>``, assert ``should_retire is True``. 81/81 pass (5 new + 76 pre-existing across the touched test files, no regressions). Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
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 subscribe to this conversation on GitHub.
Already have an account?
Sign in.
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.
Summary
Two related bugs in the codex_app_server interrupt path. Net effect today: `/stop` (and any gateway-side cancellation) does nothing on the codex_app_server runtime for up to 10 minutes, and the session lifecycle has races around the few interrupt paths that do work.
Bug 1 — `AIAgent.interrupt()` never propagated to the codex session
`CodexAppServerSession.request_interrupt()` is the public API for signalling the run-turn loop to bail. Tests call it; production never does. Grep:
```
$ grep -rn 'request_interrupt' --include='*.py' | grep -v test_ | grep -v pycache
agent/transports/codex_app_server_session.py:315: def request_interrupt(self) -> None:
```
Zero production callers. So when a user hits `/stop` (or the gateway cancels a Telegram conversation, etc.), `AIAgent.interrupt()` flips `self._interrupt_requested = True`, fans out signals to worker threads, walks child agents — but the codex subprocess never sees any of it. The turn runs until one of:
That's the user-visible "stop doesn't stop" symptom on this runtime.
Fix: in `AIAgent.interrupt()`, after the existing fan-out, also call `self._codex_session.request_interrupt()` when a session is attached. The session's run-turn loop already checks `_interrupt_event` at the top of every iteration and issues `turn/interrupt` to codex when it sees it — we just had to wake it up.
Bug 2 — User interrupt + `<turn_aborted>` left `should_retire=False`
Three out of four "we're done early" paths set `result.should_retire = True`:
The runtime caller in `agent/codex_runtime.py:98` only closes the session when `should_retire` is True. So a user-interrupted turn (or a `<turn_aborted>` one) leaves the codex subprocess alive while it's presumably still cleaning up the interrupted turn. The next `turn/start` races against that cleanup.
Fix: set `result.should_retire = True` on the user-interrupt path (1 line) and both `<turn_aborted>` paths (1 line each). Matches the existing post-tool and deadline behaviour.
Test plan
`tests/run_agent/test_interrupt_codex_session.py` (new, 4 tests):
`tests/agent/transports/test_codex_app_server_session.py: TestSessionRetirement` (2 new tests):
`pytest tests/agent/transports/test_codex_app_server_session.py tests/run_agent/test_interrupt_codex_session.py tests/run_agent/test_concurrent_interrupt.py tests/run_agent/test_memory_sync_interrupted.py` → 81/81 pass (5 new + 76 pre-existing, no regressions).
Smaller follow-ups not in scope
These came up during the audit but aren't broken behaviour, just suboptimal:
Happy to tackle these in a follow-up PR if you want.
🤖 Generated with Claude Code