fix(gateway+codex): desktop/dashboard event-stream reliability — detached-session black hole, turn-end persistence, live codex events - #43127
Open
vashkartik wants to merge 3 commits into
Conversation
…rsist transcripts at turn end Two reliability holes in the gateway when it serves websocket clients (dashboard / desktop SPA): 1. Detached-session black hole. When a websocket drops (page reload, embedder respawn, transient disconnect), handle_ws parks every session it owned on the module-level _stdio_transport - which in dashboard mode has no reader - and nothing ever re-binds it. The agent finishes the turn, but every event (deltas, message.complete) streams into captured stdout. The client's busy state then sticks forever and later sends are silently swallowed. Fix: any session-scoped RPC arriving over a live WS upgrades a stdio-parked session to that transport (_rebind_ws_transport in _sess_nowait). Only stdio-parked sessions are upgraded: a second live client cannot hijack another websocket's stream, and real stdio gateways (Ink TUI) dispatch with _stdio_transport as the current transport and are left untouched. 2. Missing turn-end persistence. Turn results lived only in process memory until the NEXT prompt.submit persisted them, so any backend restart between turns (crash, idle reap, host-driven respawn) erased the completed exchange: the session row kept its generated title but message_count stayed 0 and resume painted an empty transcript. Fix: _persist_session_history writes replace_messages into the session's own state.db right after the turn's history lands (after any compression key rotation), mirroring _ensure_session_db_row's profile-home routing. Best-effort: a DB failure never kills the turn thread. Tests: re-bind upgrade, no-hijack, stdio-untouched, turn-end persist write, persist survives DB failure; the truncate test now asserts both the pre-turn rewrite and the turn-end persist. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… dedup for app-server turns Codex app-server turns were silent in every connected UI: clients got message.start then message.complete and nothing in between - no deltas, no reasoning, no tool activity - because (1) CodexAppServerSession's on_event hook was never wired, (2) the projector drops delta notifications by design (history-only), and (3) the return dict omitted last_reasoning even though the projector stashes reasoning on every spliced assistant message. - _codex_live_event: bridge notifications to the agent's existing display callbacks (the gateway re-binds them every turn): item/agentMessage/delta -> _fire_stream_delta (message.delta), item/reasoning/delta -> _fire_reasoning_delta (reasoning.delta), item/started|completed for command/fileChange/mcp/dynamic tools -> tool_start/tool_complete callbacks, mirroring the projector's names and deterministic call ids so live cards correlate with persisted history. Best-effort: display can never break a turn. - run_codex_app_server_turn returns last_reasoning (back-walk of the current turn's assistant messages) so message.complete carries payload.reasoning like the default chat-completions path. - session pre-drain (approval round-trips) now fires on_event too, so deltas aren't dropped while an approval is pending. - user-echo dedup: codex re-emits the submitted input as its own userMessage item while run_conversation has already appended it, so persisted/resumed transcripts showed every user message twice. Drop the projector's leading user echo before splicing. Tests: tests/agent/test_codex_runtime_live_events.py covers delta -> stream callback, reasoning delta -> reasoning callback, tool started/ completed -> tool cards with projector-deterministic call ids, non-tool items ignored, and callback exceptions never escaping the bridge. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nned-fork installs Lets embedders and fork maintainers install from a pinned fork branch without maintaining a divergent copy of this script. When only the HTTPS override is set it is used for the primary clone attempt too, so a fork-only branch never 404s against the default repo first. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
teknium1
reviewed
Jul 14, 2026
teknium1
left a comment
Contributor
There was a problem hiding this comment.
Thanks for the focused reliability work. The Codex portions remain relevant on current main: agent/codex_runtime.py:376-400 forwards only tool-progress notifications, while agent/transports/codex_event_projector.py:109-110 can still materialize a userMessage that agent/codex_runtime.py:448-449 appends after the standard user turn.
Problems
- The PR's new
on_eventassignment would replace current main's_on_codex_eventcallback, dropping its existingtool_progress_callbackbehavior (agent/codex_runtime.py:376-400). - The websocket and persistence hunks are based on superseded mechanics: current disconnects use
_detached_ws_transport(tui_gateway/server.py:820-824), and current finalize persistence is marker-based (tui_gateway/server.py:570-587,1a2f3aea9).
Suggested changes
- Salvage the Codex live-event bridge by composing it with
_on_codex_event, then retain the user-echo dedup with an actual projected-userMessageregression test. - Rework the gateway portions against current transport and persistence paths instead of cherry-picking them directly.
Automated hermes-sweeper review.
| @@ -207,6 +326,9 @@ def run_codex_app_server_turn( | |||
| agent._codex_session = CodexAppServerSession( | |||
Contributor
There was a problem hiding this comment.
When salvaging onto current main, compose this bridge with the existing on_event callback rather than replacing it: current agent/codex_runtime.py:376-400 uses that callback to emit tool_progress_callback breadcrumbs for item/started events.
19 tasks
14 tasks
19 tasks
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.
Summary
Four reliability fixes for gateway-served UIs (dashboard / desktop SPA) plus a small installer override, found while running the gateway under an embedding desktop app that reloads webviews and respawns the backend frequently. Each fix is independent; together they take websocket chat from "first turn works, everything after a reconnect dies silently" to fully durable.
1. Detached-session black hole (
tui_gateway/server.py)Problem. When a websocket drops (page reload, transient disconnect, host respawn),
handle_wsparks every session it owned on the module-level_stdio_transport— which in dashboard mode has no reader. Nothing ever re-binds the session. The agent finishes the turn (rollouts showtask_completewith real answers), but every event — deltas,message.complete— streams into captured stdout. The client's busy state sticks forever and all later sends are silently swallowed.Repro. Open a dashboard session, send a prompt, reload the page mid-turn, reconnect: the turn never completes client-side and subsequent sends do nothing.
Fix. Any session-scoped RPC arriving over a live WS upgrades a stdio-parked session to that transport (
_rebind_ws_transportin_sess_nowait). Only stdio-parked sessions are upgraded: a second live client cannot hijack another websocket's stream, and real stdio gateways (Ink TUI, where stdio has a genuine peer) dispatch with_stdio_transportas the current transport and are left untouched.2. Missing turn-end persistence (
tui_gateway/server.py)Problem. Turn results lived only in process memory until the next
prompt.submitpersisted them. Any backend restart between turns (crash, idle reap, host-driven respawn) erased the completed exchange: the session row kept its generated title butmessage_countstayed 0 andsession.resumepainted an empty transcript.Repro. Send one prompt, wait for the reply, kill the gateway process, restart, resume the session: empty transcript despite the titled session row.
Fix.
_persist_session_historywritesreplace_messagesinto the session's ownstate.dbright after the turn's history lands (after any compression key rotation), mirroring_ensure_session_db_row's profile-home routing. Best-effort: a DB failure never kills the turn thread.3. Silent codex app-server turns (
agent/codex_runtime.py,agent/transports/codex_app_server_session.py)Problem. Codex app-server turns were silent in every connected UI:
message.startthenmessage.completeand nothing in between — no deltas, no reasoning, no tool activity — because (1)CodexAppServerSession'son_eventhook was never wired, (2) the projector drops delta notifications by design (history-only), and (3) the return dict omittedlast_reasoningeven though the projector stashes reasoning on every spliced assistant message.Fix.
_codex_live_eventbridges notifications to the agent's existing display callbacks (the gateway re-binds them every turn):item/agentMessage/delta→_fire_stream_delta,item/reasoning/delta→_fire_reasoning_delta,item/started|completedfor command/fileChange/mcp/dynamic tools →tool_start/tool_complete, mirroring the projector's names and deterministic call ids so live tool cards correlate with persisted history.run_codex_app_server_turnnow returnslast_reasoningsomessage.completecarriespayload.reasoninglike the default chat-completions path. The session pre-drain (approval round-trips) fireson_eventtoo, so deltas aren't dropped while an approval is pending. Display is best-effort: nothing in the bridge can break a turn.4. Duplicate user echo (
agent/codex_runtime.py)Problem. Codex re-emits the submitted input as its own
userMessageitem whilerun_conversationhas already appended it — splicing both stored every user message twice, which showed up as doubled user bubbles in any persisted/resumed transcript.Fix. Drop the projector's leading user echo (matched by role + exact content) before splicing.
5. Installer repo override (
scripts/install.sh)HERMES_REPO_URL/HERMES_REPO_URL_SSHlet embedders and fork maintainers install from a pinned fork branch without carrying a divergent copy of the script. When only the HTTPS override is set it is used for the primary clone attempt too, so a fork-only branch never 404s against the default repo first.Test coverage
tests/test_tui_gateway_server.py: WS re-bind upgrade, no-hijack of a live transport, stdio gateways untouched, turn-end persist writes messages, persist survives DB failure, and the truncate test now asserts both the pre-turn rewrite and the turn-end persist.tests/agent/test_codex_runtime_live_events.py(new): delta → stream callback, reasoning delta → reasoning callback, tool started/completed → tool events with exit-code-tagged results, call ids match the projector's history ids, non-tool/junk items ignored, callback exceptions never escape the bridge.Also verified live against a running dashboard: tool.start with the real command, tool.complete with result+duration, token-by-token message.delta stream, then message.complete; and send → kill backend → fresh process →
session.resumereturns the full transcript with no duplicated user rows.🤖 Generated with Claude Code