fix(gateway): don't end durable session row on automatic Desktop cleanup
ws_orphan_reap, idle_timeout, lru_evict, ws_disconnect, and tui_shutdown are runtime/connection GC — not user intent. Ending the durable session row during these automatic cleanup reasons confuses GC with user action, causing secondary bot chats to vanish from the sidebar even though the transcript is intact in state.db. The canonical Bot Chat already has resurrection paths for accidental ws_orphan_reap ends, but non-canonical secondary chats do not, so they are hit harder. Skip db.end_session() when _desktop_automatic_cleanup is True (automatic cleanup reason + Desktop source). Runtime is still reclaimed, the session.reclaimed event still fires, and explicit user close/archive/reset still ends the durable row normally. Fixes #105588
This commit is contained in:
@@ -344,7 +344,8 @@ def test_automatic_cleanup_reclaims_own_orphan_lease_not_treated_as_sibling(
|
||||
|
||||
server._finalize_session(session, end_reason="ws_orphan_reap")
|
||||
|
||||
assert ended == [(session_id, "ws_orphan_reap")]
|
||||
# Automatic Desktop cleanup must NOT end the durable row (#105588).
|
||||
assert ended == []
|
||||
assert active_session_registry_snapshot(registry_home=profile_home) == []
|
||||
|
||||
|
||||
@@ -501,6 +502,9 @@ def test_automatic_desktop_cleanup_preserves_sibling_and_ends_sole_owner(
|
||||
server._finalize_session(_session(sole_lease), end_reason=reason)
|
||||
assert active_session_registry_snapshot(registry_home=profile_home) == []
|
||||
|
||||
assert ended == [(session_id, reason) for reason in reasons]
|
||||
# Automatic Desktop cleanup must NOT end the durable row, even for
|
||||
# sole owners — the conversation stays open until the user explicitly
|
||||
# closes or archives it. (#105588)
|
||||
assert ended == []
|
||||
finally:
|
||||
_stop_child(child, release_file)
|
||||
|
||||
@@ -268,3 +268,136 @@ class TestOnSessionEndHook:
|
||||
model="claude-sonnet-4",
|
||||
platform="tui",
|
||||
)
|
||||
|
||||
|
||||
class TestDesktopAutomaticCleanupPreservesDurableRow:
|
||||
"""Automatic Desktop cleanup (ws_orphan_reap, idle_timeout, etc.) must NOT
|
||||
end the durable session row — the conversation stays open and resumable
|
||||
until the user explicitly closes or archives it.
|
||||
|
||||
Regression test for #105588.
|
||||
"""
|
||||
|
||||
@patch("tui_gateway.server._other_runtime_lease_guard")
|
||||
@patch("tui_gateway.server._get_db")
|
||||
@patch("tui_gateway.server._session_source", return_value="desktop")
|
||||
def test_ws_orphan_reap_skips_end_session(
|
||||
self, _mock_source, mock_get_db, mock_lease_guard
|
||||
):
|
||||
"""ws_orphan_reap on a Desktop session must not call db.end_session."""
|
||||
from tui_gateway.server import _finalize_session
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_get_db.return_value = mock_db
|
||||
# Simulate no other runtime owning the lifecycle (TUI owns it).
|
||||
mock_lease_guard.__enter__ = MagicMock(return_value=False)
|
||||
mock_lease_guard.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
agent = _make_agent(session_id="sess_orphan_001")
|
||||
session = _make_session(
|
||||
agent=agent,
|
||||
history=[{"role": "user", "content": "hello"}],
|
||||
)
|
||||
session["source"] = "desktop"
|
||||
|
||||
_finalize_session(session, end_reason="ws_orphan_reap")
|
||||
|
||||
mock_db.end_session.assert_not_called()
|
||||
|
||||
@patch("tui_gateway.server._other_runtime_lease_guard")
|
||||
@patch("tui_gateway.server._get_db")
|
||||
@patch("tui_gateway.server._session_source", return_value="desktop")
|
||||
def test_idle_timeout_skips_end_session(
|
||||
self, _mock_source, mock_get_db, mock_lease_guard
|
||||
):
|
||||
"""idle_timeout on a Desktop session must not call db.end_session."""
|
||||
from tui_gateway.server import _finalize_session
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_get_db.return_value = mock_db
|
||||
mock_lease_guard.__enter__ = MagicMock(return_value=False)
|
||||
mock_lease_guard.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
agent = _make_agent(session_id="sess_idle_001")
|
||||
session = _make_session(
|
||||
agent=agent,
|
||||
history=[{"role": "user", "content": "hello"}],
|
||||
)
|
||||
session["source"] = "desktop"
|
||||
|
||||
_finalize_session(session, end_reason="idle_timeout")
|
||||
|
||||
mock_db.end_session.assert_not_called()
|
||||
|
||||
@patch("tui_gateway.server._other_runtime_lease_guard")
|
||||
@patch("tui_gateway.server._get_db")
|
||||
@patch("tui_gateway.server._session_source", return_value="desktop")
|
||||
def test_lru_evict_skips_end_session(
|
||||
self, _mock_source, mock_get_db, mock_lease_guard
|
||||
):
|
||||
"""lru_evict on a Desktop session must not call db.end_session."""
|
||||
from tui_gateway.server import _finalize_session
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_get_db.return_value = mock_db
|
||||
mock_lease_guard.__enter__ = MagicMock(return_value=False)
|
||||
mock_lease_guard.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
agent = _make_agent(session_id="sess_lru_001")
|
||||
session = _make_session(
|
||||
agent=agent,
|
||||
history=[{"role": "user", "content": "hello"}],
|
||||
)
|
||||
session["source"] = "desktop"
|
||||
|
||||
_finalize_session(session, end_reason="lru_evict")
|
||||
|
||||
mock_db.end_session.assert_not_called()
|
||||
|
||||
@patch("tui_gateway.server._other_runtime_lease_guard")
|
||||
@patch("tui_gateway.server._get_db")
|
||||
@patch("tui_gateway.server._session_source", return_value="desktop")
|
||||
def test_explicit_close_still_calls_end_session(
|
||||
self, _mock_source, mock_get_db, _mock_lease_guard
|
||||
):
|
||||
"""Explicit user close (tui_close) must still call db.end_session —
|
||||
only automatic cleanup reasons skip it."""
|
||||
from tui_gateway.server import _finalize_session
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_get_db.return_value = mock_db
|
||||
|
||||
agent = _make_agent(session_id="sess_close_001")
|
||||
session = _make_session(
|
||||
agent=agent,
|
||||
history=[{"role": "user", "content": "hello"}],
|
||||
)
|
||||
session["source"] = "desktop"
|
||||
|
||||
_finalize_session(session, end_reason="tui_close")
|
||||
|
||||
mock_db.end_session.assert_called_once_with("sess_close_001", "tui_close")
|
||||
|
||||
@patch("tui_gateway.server._other_runtime_lease_guard")
|
||||
@patch("tui_gateway.server._get_db")
|
||||
@patch("tui_gateway.server._session_source", return_value="tui")
|
||||
def test_non_desktop_source_still_calls_end_session(
|
||||
self, _mock_source, mock_get_db, _mock_lease_guard
|
||||
):
|
||||
"""ws_orphan_reap on a NON-desktop source (e.g. TUI) must still call
|
||||
db.end_session — the fix only applies to Desktop sessions."""
|
||||
from tui_gateway.server import _finalize_session
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_get_db.return_value = mock_db
|
||||
|
||||
agent = _make_agent(session_id="sess_tui_001")
|
||||
session = _make_session(
|
||||
agent=agent,
|
||||
history=[{"role": "user", "content": "hello"}],
|
||||
)
|
||||
# No source set → falls back to platform which we patch as "tui"
|
||||
|
||||
_finalize_session(session, end_reason="ws_orphan_reap")
|
||||
|
||||
mock_db.end_session.assert_called_once_with("sess_tui_001", "ws_orphan_reap")
|
||||
|
||||
@@ -271,7 +271,10 @@ def _finalize_session(session: dict | None, end_reason: str = "tui_close") -> No
|
||||
# compression splits back to the reaped child, forever).
|
||||
if _is_gateway_owned_source((db.get_session(session_id) or {}).get("source", "")):
|
||||
_tui_owns_lifecycle = False
|
||||
elif _tui_owns_lifecycle:
|
||||
elif _tui_owns_lifecycle and not _desktop_automatic_cleanup:
|
||||
# Automatic Desktop cleanup (ws_orphan_reap, idle_timeout, etc.) reclaims
|
||||
# runtime but must not end the durable row — the conversation stays open
|
||||
# and resumable until the user explicitly closes or archives it. #105588
|
||||
db.end_session(session_id, end_reason)
|
||||
# In-flight async delegations end WITH the session (no return address left). Always interrupt by THIS live UI
|
||||
# sid; by durable session_key only when the TUI owns the lifecycle — a viewer tab must not kill gateway work.
|
||||
|
||||
Reference in New Issue
Block a user