Files
hermes-agent/tests/hermes_cli/test_web_server_sessiondb_eventloop.py
shali10 40523600b0 fix(sessions): refuse to delete a session row a live turn still owns (#123583)
Refactor entry-side deletion refusal to execute in-transaction via
`_write_guards_reject(conn, sid)` (#123583), per maintainer review:

- Underlying `delete_session` and `delete_sessions` now accept an opt-in
  kwarg `exclude_active_write_guards=True` running inside `_do` write
  transaction, eliminating the race condition where a turn acquires the lease
  between check and delete.
- Raises `SessionActiveWriteGuardError` when refusing single delete, leaving
  the row untouched; `delete_sessions` atomically skips active rows.
- Checks both active turn leases and compression locks via the existing
  reclaim-aware `_write_guards_reject` helper.
- Covers all user-facing delete sinks:
  * Web `DELETE /api/sessions/{id}` -> 409 Conflict
  * Web `POST /api/sessions/bulk-delete` -> skips active rows
  * Web / CLI `prune` -> passes `exclude_active_write_guards=True` so lineage
    parents of active conversations are not pruned
  * API Server `DELETE /api/sessions/{id}` -> 409 session_active_turn
  * CLI `hermes sessions delete` & `export --delete-after-verified` -> exits 1
  * CLI browse picker -> refuses active delete
  * TUI Gateway `session.delete` -> 4023 error
- Conforms to rubric with 2 targeted invariant tests in
  `tests/hermes_state/test_delete_session_write_guards.py`.
- Updates user guide and web dashboard docs for 409 / exit 1.

(cherry picked from commit 2c037a7a79dc211b49bacc72e3140951ccf900cf)
2026-09-27 20:49:04 +05:30

152 lines
4.7 KiB
Python

"""Dashboard session routes must run SessionDB open + work off the event loop.
Regression for #60747: /search, /stats, /{id} (and bulk delete) ran FTS and
SQLite inline on the loop thread, so a large state.db froze every other
dashboard request (rename too). These drive the real route handlers and record which
thread every SessionDB call lands on.
"""
import asyncio
import threading
import pytest
import hermes_cli.web_models as _web_models
import hermes_cli.web_routers.sessions as _rt_sessions
import hermes_cli.web_server_sessions as _web_server_sessions
def test_bulk_delete_sessiondb_work_runs_off_event_loop(monkeypatch):
loop_thread = threading.get_ident()
db_threads: list[int] = []
db_modes: list[bool] = []
class _DB:
def delete_sessions(self, ids, **kwargs):
db_threads.append(threading.get_ident())
assert ids == ["one", "two"]
return 2
def close(self):
db_threads.append(threading.get_ident())
def _open_db(profile=None, *, read_only):
assert profile is None
db_modes.append(read_only)
return _DB()
monkeypatch.setattr(_web_server_sessions, "_open_session_db_for_profile", _open_db)
result = asyncio.run(
_rt_sessions.bulk_delete_sessions_endpoint(
_web_models.BulkDeleteSessions(ids=["one", "two"])
)
)
assert result == {"ok": True, "deleted": 2}
assert db_modes == [False]
assert db_threads
assert all(thread_id != loop_thread for thread_id in db_threads)
class _ReadDB:
"""Read-only stand-in that records which thread every SessionDB call runs on."""
def __init__(self, threads: list[int]):
self._threads = threads
def _hit(self, value=None):
self._threads.append(threading.get_ident())
return value
def session_count(self, **_kw):
return self._hit(3)
def message_count(self):
return self._hit(7)
def session_count_by_source(self, **_kw):
return self._hit({"cli": 3})
def get_session(self, sid):
return self._hit({"id": sid, "title": "t"})
def get_session_rich_row(self, sid):
return self._hit(None)
def get_compression_tip(self, root_id):
return self._hit(None)
def search_sessions_by_id(self, q, **_kw):
return self._hit([{"id": "sess-1", "preview": "hello", "started_at": 1.0}])
def search_messages(self, **_kw):
return self._hit([])
def close(self):
self._hit()
@pytest.mark.parametrize(
"call",
[
pytest.param(lambda: _rt_sessions.search_sessions(q="hello"), id="search"),
pytest.param(lambda: _rt_sessions.get_session_stats(), id="stats"),
pytest.param(lambda: _rt_sessions.get_session_detail("sess-1"), id="detail"),
],
)
def test_session_read_handlers_run_sessiondb_work_off_event_loop(monkeypatch, call):
"""Regression for #60747: /search, /stats and /{id} ran FTS + SQLite inline on the
loop thread, so a large state.db froze every other dashboard request."""
loop_thread = threading.get_ident()
db_threads: list[int] = []
def _open_db(profile=None, *, read_only):
assert read_only is True
return _ReadDB(db_threads)
monkeypatch.setattr(_web_server_sessions, "_open_session_db_for_profile", _open_db)
monkeypatch.setattr(_rt_sessions, "_resolve_session_id", lambda db, sid: sid)
asyncio.run(call())
assert db_threads, "handler never touched the SessionDB"
assert all(thread_id != loop_thread for thread_id in db_threads)
def test_session_rename_runs_writer_open_and_update_off_event_loop(monkeypatch):
loop_thread = threading.get_ident()
db_threads: list[int] = []
class _DB:
def set_session_title(self, sid, title):
db_threads.append(threading.get_ident())
assert (sid, title) == ("sess-1", "renamed")
def get_session_title(self, sid):
db_threads.append(threading.get_ident())
assert sid == "sess-1"
return "renamed"
def close(self):
db_threads.append(threading.get_ident())
def _open_db(profile=None, *, read_only):
db_threads.append(threading.get_ident())
assert profile is None
assert read_only is False
return _DB()
monkeypatch.setattr(_web_server_sessions, "_open_session_db_for_profile", _open_db)
monkeypatch.setattr(_rt_sessions, "_resolve_session_id", lambda db, sid: sid)
result = asyncio.run(
_rt_sessions.rename_session_endpoint(
"sess-1", _web_models.SessionRename(title="renamed")
)
)
assert result == {"ok": True, "title": "renamed"}
assert db_threads
assert all(thread_id != loop_thread for thread_id in db_threads)