fix(tui-gateway): retry and log unflushed-message persist failures before teardown discards them - #79109
Open
sakshamzip2-sys wants to merge 1 commit into
Conversation
…fore teardown discards them _finalize_session sets its _finalized idempotency guard before running its cleanup steps, including the one that persists any unflushed conversation turns via agent._persist_session(). That persist call was wrapped in a bare except Exception: pass, so a transient failure (e.g. a momentary SQLite lock) was swallowed with no log line and no signal to the caller. Because _finalized was already set, the failure could never be retried through _finalize_session again. _teardown_session then unconditionally calls agent.close(), which unconditionally clears agent._session_messages - so a transient persist failure followed by the normal teardown sequence permanently and silently discarded the only copy of that turn's messages, with nothing in the logs to diagnose it after the fact. _finalize_session now logs the persist failure loudly and records session["_persist_failed"] = True. _teardown_session checks that flag before calling agent.close() and retries the persist once; if the retry succeeds the flag clears and teardown proceeds normally, and if it also fails the discard is logged loudly (naming how many messages are lost) before still calling agent.close(), so teardown never hangs waiting on a database to recover. Neither function's _finalized idempotency contract changes. This is a minimal, scoped fix for the one severe, concretely reproducible data-loss path - it does not change the other best-effort cleanup steps in _finalize_session (on_session_end hook, memory commit, ending the DB row, interrupting delegations, closing the slash-worker), which are notifications/cleanup rather than the sole record of a conversation. Adds five regression tests to tests/tui_gateway/test_finalize_session_persist.py covering: a persist failure is logged and flagged, the happy path does not flag a failure, a successful retry clears the flag before close() runs, a failed retry logs loudly and leaves the flag set, and the common case (no flagged failure) skips the retry entirely.
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.
What does this PR do?
Fixes a silent data-loss path in TUI session teardown.
tui_gateway/server.py's_finalize_sessionsets its idempotency guard before doing any of its cleanup work:Everything after that — including the step that persists unsaved conversation turns to disk
— runs after the guard is already latched, and the persist call is wrapped in a bare
except Exception: pass:_teardown_session(the caller) then unconditionally callsagent.close(), andAIAgent.close()unconditionally doesself._session_messages = []as a documented step ofits teardown sequence.
Chain a transient persist failure (a momentary SQLite lock — the exact scenario the
surrounding comment says this persist call exists for, referencing issue #13121) with this
ordering: persist fails silently → the
_finalizedguard means it can never be retriedthrough
_finalize_sessionagain →agent.close()runs anyway and wipes the only remainingcopy of the messages. The conversation just vanishes — no exception surfaces, no log line,
nothing to tell an operator this happened. A user who force-quits, or whose connection drops
at the wrong moment during a transient DB lock, silently loses that turn.
Reproduced this concretely (not just from reading the code) with a mock agent whose
_persist_sessionraises once, run through the real_teardown_session: the 2-messageconversation ends up gone from both disk (never persisted) and memory (wiped by
close()),with no exception, no log output, and
_teardown_sessionreturning normally.Related Issue
No existing issue found for this specific defect. I searched both open and merged PRs and
issues (
gh search prs/gh search issues: "teardown", "finalize_session", "unflushed","_session_messages", "_finalized retry", plus a broad "teardown data loss" sweep). The
closest related PR is #62052 ("fix(tui): persist dashboard/TUI conversations on WS
disconnect/restart", merged), which fixed a different bug — the persist call writing
zero rows because
snapshot/conversation_historyaliased the same list — and is alreadypresent on
main(this PR builds on top of that fix). #62052 didn't address what happenswhen the persist call actually raises, which is what this PR fixes.
Fixes #
Type of Change
Changes Made
tui_gateway/server.py:_finalize_session: on a persist exception, log it loudly (logger.error(..., exc_info=True)) instead of swallowing it, and recordsession["_persist_failed"] = True._teardown_session: before callingagent.close(), if_persist_failedis set, retrythe persist once. If the retry succeeds, clear the flag and proceed as normal. If it also
fails, log loudly (naming how many messages are about to be discarded) and still call
agent.close()— teardown must not hang forever waiting for a database to recover._finalizedis set, and does not touch any of the ~6 otherunrelated best-effort cleanup steps in
_finalize_session(theon_session_endpluginhook, memory commit, ending the DB row, interrupting delegations, closing the
slash-worker subprocess) — those are notifications/cleanup, not the sole record of a
conversation, and hardening all of them uniformly felt like scope creep for a first,
reviewable PR. Happy to follow up on those separately if useful.
tests/tui_gateway/test_finalize_session_persist.py: five new tests in two new classes —persist failure is logged and flagged; the happy path doesn't flag a failure; a successful
retry clears the flag before
close()runs; a failed retry logs loudly and leaves the flagset; the common case (no flagged failure) skips the retry entirely.
How to Test
_persist_sessionraises once, withunflushed
_session_messages._teardown_session.no error surfaced anywhere.
_teardown_sessionretries the persist once beforeagent.close()runs, and if the retry also fails, a loud log line names exactly how manymessages are about to be discarded.
Ran locally (macOS, shared dev venv, pytest 9.0.2):
pytest tests/tui_gateway/test_finalize_session_persist.py→ 14 passed (9 pre-existing + 5new).
pytest tests/test_tui_gateway_server.py -k "teardown or finalize or close_session or session_close"→ 12 passed, 505 deselected (targeted subset by name, not the full 500+ testfile, to keep load down — a maintainer's CI should run the whole file).
pytest tests/tui_gateway/test_session_reclaim_notify.py tests/tui_gateway/test_delegation_session_lifecycle.py→ 21 passed (both call_teardown_sessiondirectly and were unaffected).Checklist
Code
fix(scope):,feat(scope):, etc.)pytest tests/ -qand all tests pass — I did not run the full suite (tests/test_tui_gateway_server.pyalone is 500+ tests). See "How to Test" above for the targeted runs I did perform.Documentation & Housekeeping
docs/, docstrings) — N/Acli-config.yaml.exampleif I added/changed config keys — N/A, no config keys changedCONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/ANotes for reviewers
path (persist failure +
_finalizedlatch + unconditionalclose()), not the general"every step in
_finalize_sessionis best-effort and swallows exceptions" pattern. Saidexplicitly here rather than hidden as an oversight.
_teardown_sessionis a deliberately small designchoice, not a full retry-queue/backoff mechanism — trading robustness for a minimal diff
and preserving the existing single-call idempotency contract both functions document. A
more thorough mechanism (e.g. background retry after teardown completes) is a reasonable
follow-up if you'd prefer it.
_finalize_session/_teardown_sessionacross the whole repo for a path that might bypass the ordering this fix assumes — I
confirmed both are documented in their own docstrings as the single chokepoints for this
lifecycle, and checked the test files this PR touches.