Restored (each proven red against a sabotaged production path, green on main): - tests/agent/test_overflow_overhead_aware_tokens.py:: test_overflow_recovery_compresses_with_tool_overhead_counted[413, context_overflow, prompt_too_long, long_context_tier_429] Rewritten from the four estimator-to-compressor tests: the real estimator runs with a ~10k-token tool schema and the compressor must receive an approx_tokens that counts it (LCM issue 441). No helper-name pinning. - tests/hermes_cli/test_web_server_sessiondb_eventloop.py:: test_bulk_delete_sessiondb_work_runs_off_event_loop, test_session_read_handlers_run_sessiondb_work_off_event_loop[search,stats,detail] Real route handlers; every SessionDB open/call lands off the event-loop thread (#60747). AST/source tests stay deleted. - tests/hermes_state/test_hermes_state.py::TestCompressionChainProjection:: test_list_batches_tip_row_fetch_into_one_query list_sessions_rich resolves a page of compression tips in one batched query, not one per root (N+1 guard). - tests/hermes_state/test_tail_rewind_semantics.py::TestCompressTagsCarriedTail:: test_compress_marks_carried_forward_tail_dicts Repaired from a skip-on-exception body that asserted nothing: compress() tags exactly the verbatim carried tail so archive_and_compact gets an accurate tail_count (#86366). - tests/hermes_state/test_wal_checkpoint_strategy.py:: test_failed_checkpoint_never_raises_and_is_not_silent, test_failed_checkpoint_at_close_still_releases_the_handle Periodic PASSIVE checkpoint failure neither raises into the write path nor passes silently; a failing checkpoint at close() still releases the handle (#45383 strategy). Log-wording asserts dropped. - tests/hermes_state/test_hermes_state_ux_messages.py:: test_db_unavailable_details_line_carries_raw_cause_only_below_the_lead, test_db_unavailable_network_drive_cause_does_not_offer_doctor_fix CLI details=True keeps the raw SQLite cause on its own Details line only; a locking-protocol (network FS) cause never offers `doctor --fix`. - tests/tools/test_code_execution.py::TestStubSchemaDrift:: test_generated_module_accepts_all_params Replaces the syntax-only test_generated_module_compiles: executes the generated hermes_tools module (uds and file transports) and asserts every stub accepts all its params as kwargs and forwards each by name and value.
152 lines
4.7 KiB
Python
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):
|
|
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)
|