Files
hermes-agent/tests/gateway/test_session_recovery.py
kshitijk4poor c39f5424ef fix(gateway): the flush-time fence compares whole seconds
The flush payload's ``ts`` is ``int(time.time())`` while ``sessions.started_at`` is a REAL, so
``started_at > not_after`` rejected a row minted later in the same second as the flush — the
common "message arrives, session minted, SIGTERM" shape lost recoverability (file preserved, not
misrouted). Compare ``floor(started_at)`` against the whole-second ``ts`` so same-second rows
are adopted and only rows from a later second are refused.

Spotted by the round-2 gate on the salvage stack.
2026-09-17 21:19:05 +05:30

65 lines
3.2 KiB
Python

"""Tests for SessionRecoveryMixin.resolve_session_id_for_key — flush-recovery
session_key → session_id resolution (profile aware)."""
from unittest.mock import patch
from gateway.config import GatewayConfig
from gateway.session import SessionStore
class _FakeGatewayDB:
"""Minimal SessionDB stand-in exposing only the peer finder the resolver uses."""
def __init__(self, rows_by_key):
self.rows_by_key = rows_by_key
self.queries = []
def find_latest_gateway_session_for_peer(self, *, source, session_key=None, **kwargs):
self.queries.append(session_key)
return self.rows_by_key.get(session_key)
def _store(tmp_path, db):
config = GatewayConfig(multiplex_profiles=True)
with patch("gateway.session.SessionStore._ensure_loaded"):
store = SessionStore(sessions_dir=tmp_path, config=config)
store._loaded = True
store._db = db # pin: _db_for_key returns this db for every key (no profile I/O)
return store
def test_resolve_profile_namespaced_key_does_not_adopt_main_row(tmp_path, monkeypatch):
"""A profile-namespaced key must never resolve into the root store's
``agent:main`` row: the exact-key finder only matches the namespaced key, and an
unresolvable profile store (``_db_for_key`` → None) yields None, never the root store."""
db = _FakeGatewayDB({"agent:main:whatsapp:dm:15551234567": {"id": "main-row"}})
store = _store(tmp_path, db)
assert (
store.resolve_session_id_for_key("agent:test-bot:whatsapp:dm:15551234567")
is None
)
assert db.queries and all("agent:test-bot" in q for q in db.queries)
assert not any(q.startswith("agent:main") for q in db.queries)
# Fail-closed store: when the profile's home is unresolvable, _db_for_key returns None and the
# resolver must answer None too — even with a routing-map hit — so the flush file is preserved
# instead of being appended to the ambient root store (#66887/#102157 split-identity class).
key = "agent:test-bot:whatsapp:dm:15551234567"
monkeypatch.setattr(store, "peek_session_id", lambda session_key: "routed-sid")
monkeypatch.setattr(store, "_db_for_key", lambda session_key: None)
assert store.resolve_session_id_for_key(key) is None
assert store.resolve_session_id_for_key(key, not_after=1700000000) is None
def test_resolve_db_fallback_rejects_row_started_after_flush(tmp_path):
"""A row created after the flush timestamp cannot be the message's origin; only a row that
already existed at flush time is adopted, and it comes back with its owning db."""
key = "agent:main:telegram:dm:42"
db = _FakeGatewayDB({key: {"id": "late-row", "started_at": 1700000100.0}})
store = _store(tmp_path, db)
assert store.resolve_session_id_for_key(key, not_after=1700000000) is None
assert store.resolve_session_id_for_key(key, not_after=1700000100) == ("late-row", db)
# The flush ``ts`` is a whole second while ``started_at`` is a REAL: a row minted later in the
# SAME second as the flush is still a valid origin, not a post-flush row.
same_second = _FakeGatewayDB({key: {"id": "same-sec", "started_at": 1700000000.818}})
assert _store(tmp_path / "b", same_second).resolve_session_id_for_key(key, not_after=1700000000) == ("same-sec", same_second)