fix(tui_gateway): spare live-turn sessions from the shutdown finalize sweep - #86662
fix(tui_gateway): spare live-turn sessions from the shutdown finalize sweep#86662briandevans wants to merge 4 commits into
Conversation
… sweep
`_shutdown_sessions` is registered via `atexit` and closes every session
still in `_sessions` through `_close_session_by_id`, with no gate on the
session's turn state.
`_finalize_session` is a one-shot latch: it sets `session["_finalized"]`
and every later call returns immediately. Spending that latch while a
turn is running runs each finalize step against a live session —
`agent._persist_session` snapshots a truncated transcript,
`commit_memory_session` commits long-term memory from that truncated
history, `db.end_session` ends a live durable row, `on_session_end` fires
with `interrupted=True`, `_release_active_session_slot` drops the lease
under live work, and in-flight async delegations are interrupted. Nothing
joins the turn thread, so the tail it goes on to produce is thereafter
unpersistable: the session is permanently un-finalizable.
The handler is gated on nothing. `atexit` fires on stdin EOF and on any
`sys.exit(0)`, and `tui_gateway/entry.py::_log_signal` calls it on
SIGTERM/SIGHUP/SIGBREAK. So quitting the TUI, closing the desktop app or
stopping the dashboard backend mid-reply loses the in-flight answer and
its tool tail on the default configuration.
Every other automatic reclaim path in this module already refuses to
reclaim mid-turn — `_ws_session_is_orphaned`, `_session_is_evictable` and
`_session_is_lru_evictable` all return False on `session["running"]`.
The one path that runs on every exit did not.
Gate the sweep on the predicate `compute_host.HostRuntime.shutdown`'s own
NOTE names: `not session.get("_finalized") and not session.get("running")`.
`_close_session_by_id` already accepts `predicate=` and re-validates it
under `_sessions_lock` immediately before the ownership claim, so this
adds no new locking and no new teardown path. Leaving a live-turn session
unfinalized is the repo's established answer here — it stays recoverable
through the turn-end flush, which is exactly the trade the compute-host
drain already makes when its deadline expires.
The `_finalized` clause covers the sibling case: `flush_all_sessions`
finalizes sessions *without* popping them from `_sessions`, so a session
the drain already flushed would otherwise be re-torn-down at exit,
re-announcing a reclaim and closing an agent on a dying process.
`test_shutdown_sessions_closes_every_session_via_helper` stubs
`_close_session_by_id`; its stub gains the new keyword so it keeps
asserting that every eligible session is routed through the helper.
The NOTE described the atexit handler re-finalizing sessions the drain deliberately skipped as an open, accepted gap and proposed the predicate that would close it. That predicate is now implemented in `server._shutdown_session_is_reclaimable`, so the NOTE documented behaviour the module no longer has. Restate it as the current contract: the orphan path still bypasses atexit outright, and on the SIGTERM / stdin_closed paths the skip now survives the handler because the sweep re-checks `_finalized` and `running` itself.
Registers one `running=True` and one `running=False` session, stubs
`_teardown_session` so the real `_close_session_by_id` predicate path runs,
and calls `_shutdown_sessions`.
Asserts the idle session is still reclaimed as `tui_shutdown` and popped,
and that the live one is left in `_sessions` with its `_finalized` latch
unspent — the state that keeps it recoverable through the turn-end flush.
Fails on the unguarded sweep: both sessions are popped and torn down, so
`torn` also carries `("live", "tui_shutdown")`.
…ed session `compute_host.HostRuntime.flush_all_sessions` finalizes sessions in place and does not remove them from `server._sessions`, so a session the drain already flushed is still registered when the atexit sweep runs. Registers such a session (`_finalized` set, no live turn) and asserts `_shutdown_sessions` leaves it alone rather than driving a second teardown whose finalize is a no-op and whose remaining side effects — the reclaim announcement and `agent.close()` — land on a process that is exiting. Fails on the unguarded sweep, which tears the session down again.
There was a problem hiding this comment.
Pull request overview
This PR fixes a shutdown-time data-loss bug in the TUI gateway by ensuring the atexit session sweep does not finalize/tear down sessions that are mid-turn (or already finalized). This aligns the atexit path with the existing “never reclaim mid-turn” policy already enforced by other reapers in tui_gateway/server.py, preventing the one-shot _finalize_session latch from being spent while a turn is still producing output.
Changes:
- Add
_shutdown_session_is_reclaimable()and pass it aspredicate=to_close_session_by_id()inside_shutdown_sessions(), so theatexitsweep skipsrunningand_finalizedsessions. - Update
HostRuntime.shutdown()’s docstring NOTE to reflect the closed gap and the new predicate-based guarantee. - Add regression tests proving
_shutdown_sessions()skips (a) running sessions and (b) sessions already finalized by the drain flush.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated no comments.
| File | Description |
|---|---|
| tui_gateway/server.py | Gate the atexit shutdown sweep via predicate= to avoid mid-turn (or already-finalized) teardown. |
| tui_gateway/compute_host.py | Update shutdown NOTE to document the new atexit behavior contract and closed gap. |
| tests/test_tui_gateway_server.py | Add regression coverage for “skip running” and “skip already-finalized” during shutdown sweep. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
|
Thanks for checking, and confirming from this side: your read is right. This PR touches On the shared
Your If the signature does end up moving I'll flag it here before pushing. Likewise, if you'd rather land #85598 first, this side rebases cleanly either way. |
fix(tui_gateway): spare live-turn sessions from the shutdown finalize sweep The predicate is exactly the gap the old comment flagged ("A follow-up could gate
|
What does this PR do?
This fix is specified verbatim by our own merged code.
tui_gateway/compute_host.py:219-222onmain— merged in #77330 — ends theHostRuntime.shutdowndocstring with:This PR is that follow-up, with the exact predicate the NOTE names, plus the NOTE rewritten to describe the closed gap instead of the open one.
The gap
tui_gateway/server.py:1179-1187onmain:No gate on turn state.
_finalize_session(server.py:729-740) is a one-shot latch — it setssession["_finalized"] = Trueand every later call returns immediately. Spending that latch while a turn is running runs each step against a live session:server.pyagent._persist_session(snapshot):765commit_memory_session(history):795db.end_session(sid, end_reason):827invoke_hook("on_session_end", …):783completed=False, interrupted=Trueon a running turn_release_active_session_slot(session):741interrupt_for_session(...):851Nothing joins the turn thread, so the tail it goes on to produce is thereafter unpersistable — the session is permanently un-finalizable.
Why it fires for everyone
atexit.register(_shutdown_sessions)(server.py:1368) runs on stdin EOF and on anysys.exit(0), andtui_gateway/entry.py:155-160calls it directly from_log_signalon SIGTERM / SIGHUP / SIGBREAK, inside the grace-window hard-exit timer. No config key gates it. So quitting the TUI, closing the desktop app, or stopping the dashboard backend while the assistant is mid-reply loses that reply and any tool tail, on the default configuration — and MEMORY has already been committed from the truncated transcript.The in-file contrast
Every other automatic reclaim path in
server.pyalready refuses to reclaim mid-turn. The one that runs on every exit did not:_ws_session_is_orphaned:1021if session.get("running"): return False_session_is_evictable:1212if session.get("running") or _session_pending_kind(sid)_session_is_lru_evictable:1300:1301)_shutdown_sessions:1187The fix
_close_session_by_idalready acceptspredicate=and re-validates it under_sessions_lockimmediately before the ownership claim (server.py:983-1009), exactly as the idle and LRU reapers use it. So the change adds no new locking and no new teardown path:Leaving a live-turn session unfinalized is this repo's established answer, not a new judgement call:
eb4f514b2d3("retain live-turn sessions unfinalized when the drain deadline expires") makes the same trade one layer up. Unfinalized stays recoverable via the turn-end flush; latched mid-turn is permanent.The
_finalizedhalf of the predicate covers the sibling case:compute_host.flush_all_sessionsfinalizes sessions in place without popping them fromserver._sessions, so a session the drain already flushed would otherwise be torn down a second time at exit — re-announcing a reclaim and callingagent.close()on a dying process, with a finalize that is already a no-op.Related Issue
No filed issue — this is the follow-up named in the merged
compute_host.shutdownNOTE (#77330).Related:
eb4f514b2d3(drain-deadline live-turn skip), #77330 (the NOTE this closes).Type of Change
Changes Made
tui_gateway/server.py— add_shutdown_session_is_reclaimable(session)and pass it as_shutdown_sessions'predicate=, so the atexit sweep skips sessions that are mid-turn or already finalized.tui_gateway/compute_host.py— rewrite the now-staleHostRuntime.shutdownNOTE. It described the atexit re-finalize as an open accepted gap and proposed this predicate; it now records the contract that exists.tests/test_tui_gateway_server.py— two regression tests (below), plus thepredicate=Nonekeyword on the existingtest_shutdown_sessions_closes_every_session_via_helperstub so it keeps asserting that every eligible session is routed through the helper.Sibling sweep — all 6 reclaim sites, and the one deliberately excluded
Enumerated
_close_session_by_id(/_teardown_session(/_teardown_popped_session(/_finalize_session(acrosstui_gateway/:_reap_idle_sessions(:1233) — already gates via_session_is_evictable✓_enforce_session_cap(:1333) — already gates via_session_is_lru_evictable✓_schedule_ws_orphan_reap— already gates via_ws_session_is_orphaned✓_shutdown_sessions(:1187) — fixed hereclose_on_disconnectbranch of_close_sessions_for_transport(:1163) — same missing gate, DELIBERATELY EXCLUDED. Three open PRs already have hunks inside_close_sessions_for_transport, so touching it here would guarantee a conflict with work in flight: fix(tui-gateway): close WS disconnect/reconnect session race #77129 (@JoaoMarcos44) rewrites thatfor-loop body outright (@@ -1077,23 +1076,48 @@, including theclose_on_disconnectbranch itself), and fix(gateway): re-bind session transport to a surviving window on pop-out close #86039 (@ayushnangia) and fix(tui_gateway): close slash_worker on WS detach to prevent memory leak #57687 (@yingliang-zhang) each add hunks inside the same function. Flagging it rather than silently omitting it: it is a real sibling and it wants the same gate.session.closeRPC — user-initiated, not an automatic reclaim. Out of scope by design.Related open PRs, disclosed up front
tui_gateway/entry.py::_log_exitonly (adds aserver._shutdown_sessions()call), so there is no textual conflict with this diff. Buttest_shutdown_sessions_persists_tool_tail_and_marks_tui_shutdownbuilds arunning=Truesession and asserts_shutdown_sessionsends it astui_shutdown. Reconciliation: that PR's goal is to stop an interrupted turn being left for a later WS-orphan reap, andeb4f514b2d3has since established unfinalized-but-recoverable as the desired state for a live turn — the two goals meet by keeping the session registered and recoverable, not by latching it. If fix(tui-gateway): finalize sessions on clean pipe exit #60302 lands first, that assertion is the thing to update._finalize_session's body (persist retry). Different function, but adjacent enough that a textual conflict is possible._shutdown_sessions' loop body to background cleanup forsession.close. Different concern (blocking/new), and its base predates the_close_session_by_idfunnel entirely, so it conflicts withmainindependently of this PR._schedule_ws_orphan_reappath. Corroborating, not colliding.How to Test
SIGTERMthe gateway) while the assistant is still streaming. Onmainthe session's durable row is alreadyended, the reply tail is missing on reopen, and memory has been committed from the truncated transcript.main, pass with this change:test_shutdown_sessions_spares_a_session_whose_turn_is_running— registers onerunning=Trueand onerunning=Falsesession, stubs_teardown_sessionso the real_close_session_by_idpredicate path runs. Asserts the idle session is still reclaimed astui_shutdownand popped, the live one is still in_sessions, and its_finalizedlatch is unspent.Red on
main:assert [('live', 'tui_shutdown'), ('idle', 'tui_shutdown')] == [('idle', 'tui_shutdown')].test_shutdown_sessions_skips_a_session_the_drain_already_finalized— registers a session with_finalizedalready set (the statecompute_host.flush_all_sessionsleaves behind) and asserts the sweep does not tear it down again.Red on
main:assert ['flushed'] == [].Both directions were verified by restoring
tui_gateway/server.pyfromorigin/mainand re-running each test individually.Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests pass — ran thetui_gatewaysuites listed above (1060 passed, 1 skipped), not the full treeDocumentation & Housekeeping
docs/, docstrings) — thecompute_host.shutdownNOTE and the new_shutdown_session_is_reclaimabledocstring