diff --git a/gateway/session.py b/gateway/session.py index b964adddb7aa..5b71e1581f26 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -1582,6 +1582,19 @@ class SessionStore: "Session DB expiry_finalized write failed for %s: %s", entry.session_id, exc, ) + try: + # Expiry finalization is a real conversation boundary. Without + # a durable ``session_reset`` end_reason, later agent cleanup can + # close the row as ``agent_close``; stale-route recovery treats + # that as resumable and resurrects the expired full history. + # Reopen first because SessionDB.end_session is first-writer-wins. + self._db.reopen_session(entry.session_id) + self._db.end_session(entry.session_id, "session_reset") + except Exception as exc: + logger.debug( + "Session DB end_session(session_reset) failed for %s: %s", + entry.session_id, exc, + ) def _is_session_expired(self, entry: SessionEntry) -> bool: """Check if a session has expired based on its reset policy. diff --git a/tests/gateway/test_session_store_expiry_finalized.py b/tests/gateway/test_session_store_expiry_finalized.py new file mode 100644 index 000000000000..f777eca2ebb5 --- /dev/null +++ b/tests/gateway/test_session_store_expiry_finalized.py @@ -0,0 +1,67 @@ +"""Session expiry finalization closes sessions as session_reset. + +Regression coverage for #61220: the expiry watcher marks a session expired, +then agent cleanup can close it as ``agent_close``. Stale routing recovery treats +``agent_close`` as recoverable, so expired sessions were reopened with full +history unless expiry finalization also persisted the real conversation boundary +as ``end_reason='session_reset'``. +""" + +from datetime import datetime, timedelta +from unittest.mock import MagicMock, patch + +from gateway.config import GatewayConfig, Platform, SessionResetPolicy +from gateway.session import SessionEntry, SessionStore + + +def _make_store_with_db(tmp_path, db_mock) -> SessionStore: + config = GatewayConfig(default_reset_policy=SessionResetPolicy(mode="daily")) + with patch("gateway.session.SessionStore._ensure_loaded"): + store = SessionStore(sessions_dir=tmp_path, config=config) + store._db = db_mock + store._loaded = True + return store + + +def _entry(session_id: str = "sid-expired") -> SessionEntry: + now = datetime.now() + return SessionEntry( + session_key="agent:main:telegram:dm:8494508720", + session_id=session_id, + created_at=now - timedelta(days=1), + updated_at=now - timedelta(days=1), + platform=Platform.TELEGRAM, + chat_type="dm", + model_override={"provider": "openrouter", "model": "test/model"}, + ) + + +def test_set_expiry_finalized_persists_session_reset_boundary(tmp_path): + db = MagicMock() + db.set_expiry_finalized.return_value = None + db.reopen_session.return_value = None + db.end_session.return_value = None + store = _make_store_with_db(tmp_path, db) + entry = _entry() + + store.set_expiry_finalized(entry) + + assert entry.expiry_finalized is True + assert entry.model_override is None + db.set_expiry_finalized.assert_called_once_with("sid-expired", True) + db.reopen_session.assert_called_once_with("sid-expired") + db.end_session.assert_called_once_with("sid-expired", "session_reset") + + +def test_set_expiry_finalized_still_sets_flag_if_end_session_fails(tmp_path): + db = MagicMock() + db.set_expiry_finalized.return_value = None + db.reopen_session.side_effect = RuntimeError("database locked") + store = _make_store_with_db(tmp_path, db) + entry = _entry() + + store.set_expiry_finalized(entry) + + assert entry.expiry_finalized is True + db.set_expiry_finalized.assert_called_once_with("sid-expired", True) + db.end_session.assert_not_called()