hermes-agent/tests/gateway/test_session_store_stale_prune.py
Teknium 39975613b1
test: prune wave 2 + speed fixes — 28,106 → 19,757 test functions, suite wall 315s → 294s
Second, deeper pass over tools/gateway/hermes_cli plus first pass over
the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker,
dashboard, conformance, monitoring, secret_sources, hermes_state,
providers). Same rubric as wave 1 (AGENTS.md test policy); security,
alternation/caching invariants, issue-number regressions, and E2E kept.

Real test-quality fixes found and rooted out along the way:
- tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls
  (DEFAULT_CONFIG smart-approval leaked in) — pinned approval
  mode=manual via autouse fixture: 17.4s → 0.4s.
- test_model_switch_custom_providers.py / test_user_providers_model_switch.py
  silently probed live provider catalogs (~2s/test) — stubbed
  cached_provider_model_ids/provider_model_ids/fetch_api_models.
- test_telegram_noise_filter.py: 15-platform copy-paste matrix over
  shared gateway.run logic → 3 representative platforms (55s → 3.9s).
- test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on
  MagicMock agents — interrupt.side_effect now clears _running_agents
  (22s → 1.0s).
- test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x
  (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps
  patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait
  5s → 0.5s.
- test_telegram_init_deadline.py: loop-block margin restored to 1.0s
  with rationale comment — the watchdog-dump assertion needs the loop
  blocked well past deadline+grace under parallel load (flaked once in
  the 40-worker verification run at a 0.2s margin).

Verification: full hermetic suite via scripts/run_tests.sh —
2,438 files, 21,718 tests passed, 0 failed, 293.9s wall.
Suite totals vs original baseline: 46,820 → 19,757 test functions
(−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
2026-07-29 13:39:40 -07:00

148 lines
5.7 KiB
Python

"""Tests for SessionStore._prune_stale_sessions_locked — crash self-healing.
When a gateway crashes (exit code 1) the graceful shutdown path is skipped and
sessions.json is left pointing at sessions already ended in state.db. On the
next startup _ensure_loaded_locked calls _prune_stale_sessions_locked to detect
and remove those stale routing entries before get_or_create_session() can reuse
them and silently route incoming messages into a closed session (#52804).
"""
import json
from datetime import datetime, timedelta
from unittest.mock import MagicMock, patch
from gateway.config import GatewayConfig, Platform, SessionResetPolicy
from gateway.session import SessionEntry, SessionSource, SessionStore
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_entry(key: str, session_id: str) -> SessionEntry:
now = datetime.now()
return SessionEntry(
session_key=key,
session_id=session_id,
created_at=now - timedelta(hours=2),
updated_at=now - timedelta(hours=1),
platform=Platform.TELEGRAM,
chat_type="dm",
)
def _make_entry_with_origin(key: str, session_id: str) -> SessionEntry:
entry = _make_entry(key, session_id)
entry.origin = SessionSource(
platform=Platform.TELEGRAM,
chat_id="5140768830",
chat_type="dm",
user_id="5140768830",
user_name="João",
)
return entry
def _make_store_with_db(tmp_path, db_mock) -> SessionStore:
"""Build a SessionStore with a mock SessionDB, bypassing disk load."""
config = GatewayConfig(default_reset_policy=SessionResetPolicy(mode="none"))
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 _db_returning(rows: dict) -> MagicMock:
"""SessionDB mock where get_session maps session_id -> row dict."""
db = MagicMock()
db.get_session.side_effect = lambda sid: rows.get(sid)
return db
# ---------------------------------------------------------------------------
# Core behaviour
# ---------------------------------------------------------------------------
class TestPruneStaleSessionsLocked:
def test_prunes_multiple_stale_entries(self, tmp_path):
db = _db_returning({
"sid_a": {"end_reason": "agent_close", "id": "sid_a"},
"sid_b": {"end_reason": "session_reset", "id": "sid_b"},
"sid_c": {"end_reason": None, "id": "sid_c"}, # alive — keep
})
store = _make_store_with_db(tmp_path, db)
store._entries["key_a"] = _make_entry("key_a", "sid_a")
store._entries["key_b"] = _make_entry("key_b", "sid_b")
store._entries["key_c"] = _make_entry("key_c", "sid_c")
store._prune_stale_sessions_locked()
assert "key_a" not in store._entries
assert "key_b" not in store._entries
assert "key_c" in store._entries
def test_keeps_stale_entry_when_recovery_lookup_raises(self, tmp_path):
"""Indeterminate recovery must not delete the only routing handle.
Startup pruning sees an ended parent and tries to repoint it to the
latest live gateway child. If that recovery query raises, deleting the
sessions.json entry loses the routing key entirely; keeping it lets the
runtime stale guard retry recovery on the next message.
"""
key = "agent:main:telegram:dm:5140768830"
db = _db_returning({"sid_parent": {"end_reason": "compression", "id": "sid_parent"}})
db.find_latest_gateway_session_for_peer.side_effect = RuntimeError("db busy")
store = _make_store_with_db(tmp_path, db)
store._entries[key] = _make_entry_with_origin(key, "sid_parent")
store._prune_stale_sessions_locked()
assert key in store._entries
assert store._entries[key].session_id == "sid_parent"
def test_noop_when_db_is_none(self, tmp_path):
config = GatewayConfig(default_reset_policy=SessionResetPolicy(mode="none"))
with patch("gateway.session.SessionStore._ensure_loaded"):
store = SessionStore(sessions_dir=tmp_path, config=config)
store._db = None
store._loaded = True
store._entries["key"] = _make_entry("key", "sid_x")
store._prune_stale_sessions_locked() # must not raise
assert "key" in store._entries
def test_sessions_json_rewritten_after_pruning(self, tmp_path):
db = _db_returning({"sid_stale": {"end_reason": "agent_close", "id": "sid_stale"}})
store = _make_store_with_db(tmp_path, db)
store._entries["stale_key"] = _make_entry("stale_key", "sid_stale")
with patch.object(store, "_save") as mock_save:
store._prune_stale_sessions_locked()
mock_save.assert_called_once()
# ---------------------------------------------------------------------------
# Integration: _ensure_loaded_locked calls _prune_stale_sessions_locked
# ---------------------------------------------------------------------------
class TestEnsureLoadedCallsPrune:
def test_stale_entry_pruned_during_load(self, tmp_path):
entry = _make_entry("dm_key", "sid_stale")
(tmp_path / "sessions.json").write_text(
json.dumps({"dm_key": entry.to_dict()}, indent=2), encoding="utf-8"
)
db = _db_returning({"sid_stale": {"end_reason": "agent_close", "id": "sid_stale"}})
config = GatewayConfig(default_reset_policy=SessionResetPolicy(mode="none"))
store = SessionStore(sessions_dir=tmp_path, config=config)
store._db = db
store._ensure_loaded()
assert "dm_key" not in store._entries