fix(gateway): make session identity durable so chat continuity survives crashes and restarts
Root cause of #82616: gateway session identity (session_key/chat_id/ origin_json) was written best-effort in a separate UPDATE after row creation, both reset-path DB writes swallowed failures silently (logger.debug / bare print), transcript reads ignored the reroute map that writes follow, and restart recovery ranked candidate rows by started_at while hard-rejecting empty rows. A single failed write could therefore strand the live conversation in an unroutable orphan row while a days-old zombie kept the routing key — after any gateway restart the chat silently resumed the zombie (user-visible context loss, 5 confirmed incidents on one install since June). Four class fixes: 1. Identity lands atomically in the session INSERT: origin_json and display_name join _insert_session_row's column list + COALESCE backfill; both gateway creation paths (get_or_create + reset) pass full identity including parent_session_id lineage (fixes #12857). 2. record_gateway_session_peer self-heals: when the target row is missing (failed/deferred create, crash window) it INSERTs the row with full identity instead of silently no-opping — every per-turn peer refresh is now a repair opportunity, and an identity-less lazy writer (update_token_counts/record_auxiliary_usage) can never leave a gateway session permanently unroutable. 3. load_transcript follows the write-side reroute chain and the durable compression tip before querying, so reads can no longer return 0 rows for a session whose messages live under its compression child; read exceptions are WARNING, distinguishable from an empty result. 4. find_latest_gateway_session_for_peer ranks by COALESCE(last_activity_at, started_at) (message-bearing rows first) and returns an empty-but-keyed row instead of None — a zombie predecessor can no longer beat the live conversation, and recovery never mints a fresh id when a keyed row exists. Reset-path DB write failures now log at WARNING with the routing consequence spelled out. Tests: tests/gateway/test_session_continuity_82616.py (11 tests) — sabotage-verified: 6/11 fail without the fixes. E2E incident replay (real SessionDB, temp HERMES_HOME) confirms the production shape now resolves to the live session. Fixes #82616. Related: #12857, #78182 (read-path half), #79576.
This commit is contained in:
+83
-5
@@ -2678,6 +2678,10 @@ class SessionStore:
|
||||
entry = published
|
||||
_needs_save = True
|
||||
if entry is candidate:
|
||||
try:
|
||||
_origin_json = json.dumps(source.to_dict())
|
||||
except Exception:
|
||||
_origin_json = None
|
||||
db_create_kwargs = {
|
||||
"session_id": session_id,
|
||||
"source": source.platform.value,
|
||||
@@ -2687,6 +2691,12 @@ class SessionStore:
|
||||
"chat_type": source.chat_type,
|
||||
"thread_id": source.thread_id,
|
||||
"profile_name": source.profile,
|
||||
# Identity lands atomically in the INSERT (#82616): a
|
||||
# crash after this write can no longer strand the row
|
||||
# unroutable, and lineage survives resets (#12857).
|
||||
"origin_json": _origin_json,
|
||||
"display_name": source.chat_name,
|
||||
"parent_session_id": prev_session_id,
|
||||
}
|
||||
|
||||
if _needs_save:
|
||||
@@ -2713,7 +2723,16 @@ class SessionStore:
|
||||
else:
|
||||
self._db.end_session(db_end_session_id, _db_end_reason)
|
||||
except Exception as e:
|
||||
logger.debug("Session DB operation failed: %s", e)
|
||||
# A failed end-write leaves a zombie open row still holding
|
||||
# this chat's session_key: restart recovery will resolve the
|
||||
# chat to it and time-travel the conversation (#82616). Say
|
||||
# so loudly — this was a silent logger.debug for months.
|
||||
logger.warning(
|
||||
"Failed to end predecessor session row %s for %s: %s — "
|
||||
"the old row remains open and may win restart recovery "
|
||||
"until the next successful peer refresh",
|
||||
db_end_session_id, session_key, e,
|
||||
)
|
||||
|
||||
if self._db and db_create_kwargs:
|
||||
try:
|
||||
@@ -2725,7 +2744,15 @@ class SessionStore:
|
||||
display_name=entry.display_name,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[gateway] Warning: Failed to create SQLite session: {e}")
|
||||
# The row will be self-healed with full identity by the next
|
||||
# per-turn peer refresh (record_gateway_session_peer now
|
||||
# INSERTs on missing row, #82616) — but the failure itself is
|
||||
# a routing hazard and must be visible, not a bare print.
|
||||
logger.warning(
|
||||
"Failed to create session row %s for %s: %s — deferring "
|
||||
"to the self-healing peer refresh on the next turn",
|
||||
db_create_kwargs.get("session_id"), session_key, e,
|
||||
)
|
||||
|
||||
return entry
|
||||
|
||||
@@ -3145,6 +3172,12 @@ class SessionStore:
|
||||
|
||||
self._entries[session_key] = new_entry
|
||||
self._save()
|
||||
_reset_origin_json = None
|
||||
if old_entry.origin is not None:
|
||||
try:
|
||||
_reset_origin_json = json.dumps(old_entry.origin.to_dict())
|
||||
except Exception:
|
||||
_reset_origin_json = None
|
||||
db_create_kwargs = {
|
||||
"session_id": session_id,
|
||||
"source": old_entry.platform.value if old_entry.platform else "unknown",
|
||||
@@ -3154,6 +3187,11 @@ class SessionStore:
|
||||
"chat_type": old_entry.origin.chat_type if old_entry.origin else None,
|
||||
"thread_id": old_entry.origin.thread_id if old_entry.origin else None,
|
||||
"profile_name": old_entry.origin.profile if old_entry.origin else None,
|
||||
# Identity + lineage land atomically in the INSERT (#82616,
|
||||
# #12857) — see the get_or_create twin path.
|
||||
"origin_json": _reset_origin_json,
|
||||
"display_name": old_entry.display_name,
|
||||
"parent_session_id": db_end_session_id,
|
||||
}
|
||||
|
||||
if self._db and db_end_session_id:
|
||||
@@ -3168,7 +3206,13 @@ class SessionStore:
|
||||
else:
|
||||
self._db.end_session(db_end_session_id, "session_reset")
|
||||
except Exception as e:
|
||||
logger.debug("Session DB operation failed: %s", e)
|
||||
# Zombie hazard — see the get_or_create twin path (#82616).
|
||||
logger.warning(
|
||||
"Failed to end predecessor session row %s for %s during "
|
||||
"reset: %s — the old row remains open and may win restart "
|
||||
"recovery until the next successful peer refresh",
|
||||
db_end_session_id, session_key, e,
|
||||
)
|
||||
|
||||
if self._db and db_create_kwargs:
|
||||
try:
|
||||
@@ -3180,7 +3224,12 @@ class SessionStore:
|
||||
display_name=new_entry.display_name if new_entry else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Session DB operation failed: %s", e)
|
||||
logger.warning(
|
||||
"Failed to create session row %s for %s during reset: %s "
|
||||
"— deferring to the self-healing peer refresh on the next "
|
||||
"turn",
|
||||
session_id, session_key, e,
|
||||
)
|
||||
|
||||
return new_entry
|
||||
|
||||
@@ -3626,9 +3675,31 @@ class SessionStore:
|
||||
state.db is the canonical store. The legacy JSONL fallback was removed
|
||||
in spec 002 — pre-DB sessions on existing disks have already been
|
||||
migrated (their DB row holds the full message history).
|
||||
|
||||
Reads follow the same routing writes use (#82616): the in-memory
|
||||
reroute map installed after a compression rotation, then the durable
|
||||
compression tip in state.db. Before this, writes followed the reroute
|
||||
chain while reads queried the stale id directly — the transcript
|
||||
"vanished" (disk=0) even though every message sat healthy under the
|
||||
child session.
|
||||
"""
|
||||
if not self._db:
|
||||
return []
|
||||
# Follow the write-side reroute chain (cycle-guarded, same shape as
|
||||
# append_to_transcript).
|
||||
reroutes = getattr(self, "_transcript_reroutes", None) or {}
|
||||
seen = set()
|
||||
while session_id in reroutes and session_id not in seen:
|
||||
seen.add(session_id)
|
||||
session_id = reroutes[session_id]
|
||||
try:
|
||||
# Durable successor: a compression child published to state.db
|
||||
# survives restart even though the in-memory reroute map doesn't.
|
||||
tip = self._db.get_compression_tip(session_id)
|
||||
if tip:
|
||||
session_id = tip
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
# repair_alternation: this load feeds LIVE REPLAY. A durable
|
||||
# user;user wedge (e.g. a turn that persisted no assistant row)
|
||||
@@ -3638,7 +3709,14 @@ class SessionStore:
|
||||
session_id, repair_alternation=True
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug("Could not load messages from DB: %s", e)
|
||||
# A failed read must be distinguishable from an empty transcript:
|
||||
# downstream guards treat [] as "nothing persisted" and may make
|
||||
# routing decisions on it (#82616). WARNING, not DEBUG.
|
||||
logger.warning(
|
||||
"Transcript read failed for session %s (returning empty; "
|
||||
"downstream must not treat this as data loss): %s",
|
||||
session_id, e,
|
||||
)
|
||||
return []
|
||||
|
||||
def rewind_session(self, session_id: str, n: int = 1) -> Optional[Dict[str, Any]]:
|
||||
|
||||
+76
-10
@@ -3453,6 +3453,8 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
|
||||
cwd: str = None,
|
||||
profile_name: str = None,
|
||||
git_repo_root: str = None,
|
||||
origin_json: str = None,
|
||||
display_name: str = None,
|
||||
) -> None:
|
||||
"""Insert a session row, enriching NULL metadata on conflict.
|
||||
|
||||
@@ -3495,9 +3497,10 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
|
||||
"""INSERT INTO sessions (
|
||||
id, source, user_id, session_key, chat_id, chat_type, thread_id,
|
||||
model, model_config, system_prompt, system_prompt_hash,
|
||||
parent_session_id, cwd, profile_name, git_repo_root, started_at
|
||||
parent_session_id, cwd, profile_name, git_repo_root,
|
||||
origin_json, display_name, started_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
model = COALESCE(sessions.model, excluded.model),
|
||||
model_config = COALESCE(sessions.model_config, excluded.model_config),
|
||||
@@ -3518,7 +3521,9 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
|
||||
parent_session_id = COALESCE(sessions.parent_session_id, excluded.parent_session_id),
|
||||
cwd = COALESCE(sessions.cwd, excluded.cwd),
|
||||
profile_name = COALESCE(sessions.profile_name, excluded.profile_name),
|
||||
git_repo_root = COALESCE(sessions.git_repo_root, excluded.git_repo_root)""",
|
||||
git_repo_root = COALESCE(sessions.git_repo_root, excluded.git_repo_root),
|
||||
origin_json = COALESCE(sessions.origin_json, excluded.origin_json),
|
||||
display_name = COALESCE(sessions.display_name, excluded.display_name)""",
|
||||
(
|
||||
session_id,
|
||||
source,
|
||||
@@ -3534,6 +3539,8 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
|
||||
cwd,
|
||||
profile_name,
|
||||
git_repo_root,
|
||||
origin_json,
|
||||
display_name,
|
||||
time.time(),
|
||||
),
|
||||
)
|
||||
@@ -3635,6 +3642,15 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
|
||||
on one routing peer when an explicit gateway resume moves its tip to a
|
||||
different lane. Normal per-turn metadata refreshes update only the
|
||||
supplied row.
|
||||
|
||||
Self-healing (#82616): when the target row does not exist yet — the
|
||||
gateway's ``create_session`` write failed and was deferred, or a
|
||||
crash landed between routing publication and row creation — this
|
||||
recorder INSERTs the row with the full identity instead of silently
|
||||
no-opping. Every per-turn peer refresh is therefore a repair
|
||||
opportunity: a gateway session row can no longer be first-created by
|
||||
an identity-less lazy writer (``update_token_counts`` /
|
||||
``record_auxiliary_usage``) and stay unroutable forever.
|
||||
"""
|
||||
if not session_id or not session_key:
|
||||
return
|
||||
@@ -3690,6 +3706,43 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
|
||||
{target_clause}""",
|
||||
query_params,
|
||||
)
|
||||
# Self-heal (#82616): the UPDATE is a silent no-op when the row
|
||||
# is missing (create_session failed earlier, or a crash landed
|
||||
# between routing publication and row creation). Insert it with
|
||||
# the full identity so the session is durably routable — never
|
||||
# leave first-creation to an identity-less lazy writer.
|
||||
if not include_compression_ancestors:
|
||||
cur = conn.execute(
|
||||
"SELECT 1 FROM sessions WHERE id = ? LIMIT 1", (session_id,)
|
||||
)
|
||||
if cur.fetchone() is None:
|
||||
conn.execute(
|
||||
"""INSERT INTO sessions (
|
||||
id, source, user_id, session_key, chat_id,
|
||||
chat_type, thread_id, display_name, origin_json,
|
||||
started_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
session_key = COALESCE(sessions.session_key, excluded.session_key),
|
||||
chat_id = COALESCE(sessions.chat_id, excluded.chat_id),
|
||||
chat_type = COALESCE(sessions.chat_type, excluded.chat_type),
|
||||
thread_id = COALESCE(sessions.thread_id, excluded.thread_id),
|
||||
display_name = COALESCE(sessions.display_name, excluded.display_name),
|
||||
origin_json = COALESCE(sessions.origin_json, excluded.origin_json)""",
|
||||
(
|
||||
session_id,
|
||||
source,
|
||||
user_id,
|
||||
session_key,
|
||||
chat_id,
|
||||
chat_type,
|
||||
thread_id,
|
||||
display_name,
|
||||
origin_json,
|
||||
time.time(),
|
||||
),
|
||||
)
|
||||
|
||||
self._execute_write(_do)
|
||||
|
||||
@@ -3898,6 +3951,15 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
|
||||
(dashboard viewer disconnect before #60609) are treated as recoverable;
|
||||
explicit conversation boundaries such as /new, /resume switches, and
|
||||
compression splits are not.
|
||||
|
||||
Ordering and emptiness (#82616): candidates are ranked by actual
|
||||
conversation recency (``last_activity_at``, falling back to
|
||||
``started_at``) — ``started_at`` alone resurrected days-old zombie
|
||||
rows over the live conversation. Rows with messages are preferred,
|
||||
but an empty keyed row is still returned rather than ``None``:
|
||||
returning ``None`` mints a brand-new session id, which is a worse
|
||||
outcome than resuming an empty-but-correctly-keyed row (and "empty"
|
||||
may just mean the transcript lives under a compression child).
|
||||
"""
|
||||
if not session_key:
|
||||
return None
|
||||
@@ -3906,16 +3968,17 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
|
||||
"""
|
||||
SELECT s.*,
|
||||
COALESCE(sp.prompt, s.system_prompt)
|
||||
AS _system_prompt_resolved
|
||||
AS _system_prompt_resolved,
|
||||
(COALESCE(s.message_count, 0) > 0 OR EXISTS (
|
||||
SELECT 1 FROM messages WHERE messages.session_id = s.id LIMIT 1
|
||||
)) AS _has_messages
|
||||
FROM sessions s
|
||||
LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash
|
||||
WHERE s.session_key = ?
|
||||
AND s.source = ?
|
||||
AND (s.ended_at IS NULL OR s.end_reason IN ('agent_close', 'ws_orphan_reap'))
|
||||
AND (COALESCE(s.message_count, 0) > 0 OR EXISTS (
|
||||
SELECT 1 FROM messages WHERE messages.session_id = s.id LIMIT 1
|
||||
))
|
||||
ORDER BY s.started_at DESC
|
||||
ORDER BY _has_messages DESC,
|
||||
COALESCE(s.last_activity_at, s.started_at) DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(session_key, source),
|
||||
@@ -3932,7 +3995,10 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
|
||||
"""
|
||||
SELECT s.*,
|
||||
COALESCE(sp.prompt, s.system_prompt)
|
||||
AS _system_prompt_resolved
|
||||
AS _system_prompt_resolved,
|
||||
(COALESCE(s.message_count, 0) > 0 OR EXISTS (
|
||||
SELECT 1 FROM messages WHERE messages.session_id = s.id LIMIT 1
|
||||
)) AS _has_messages
|
||||
FROM sessions s
|
||||
LEFT JOIN system_prompts sp ON sp.hash = s.system_prompt_hash
|
||||
WHERE s.source = ?
|
||||
@@ -3944,7 +4010,7 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin)
|
||||
AND (COALESCE(s.message_count, 0) > 0 OR EXISTS (
|
||||
SELECT 1 FROM messages WHERE messages.session_id = s.id LIMIT 1
|
||||
))
|
||||
ORDER BY s.started_at DESC
|
||||
ORDER BY COALESCE(s.last_activity_at, s.started_at) DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(source, user_id, chat_id, chat_type, thread_id),
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
"""Regression tests for gateway session continuity (#82616).
|
||||
|
||||
Incident shape (Aug 2026, production): a /new reset's DB writes both failed
|
||||
silently → the routing index moved to a new session id that had NO row →
|
||||
the row was later lazily materialized with no identity columns → a gateway
|
||||
crash pruned sessions.json → restart recovery resolved the chat to the
|
||||
days-old zombie predecessor (still open, still keyed) → the user's DM
|
||||
time-traveled three days back.
|
||||
|
||||
Four fixes under test:
|
||||
1. Identity lands atomically in the session INSERT (origin_json,
|
||||
display_name, parent_session_id in db_create_kwargs).
|
||||
2. record_gateway_session_peer self-heals a missing row (INSERT on
|
||||
absent id) so identity-less lazy writers can never be first.
|
||||
3. load_transcript follows the write-side reroute chain + durable
|
||||
compression tip; read exceptions are WARNING not silent [].
|
||||
4. find_latest_gateway_session_for_peer ranks by last_activity_at and
|
||||
returns an empty-but-keyed row instead of None.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(tmp_path):
|
||||
d = SessionDB(db_path=tmp_path / "state.db")
|
||||
yield d
|
||||
try:
|
||||
d.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
PEER = dict(
|
||||
source="telegram",
|
||||
user_id="6308981865",
|
||||
session_key="agent:main:telegram:dm:6308981865",
|
||||
chat_id="6308981865",
|
||||
chat_type="dm",
|
||||
thread_id=None,
|
||||
)
|
||||
|
||||
|
||||
def _mk_session(db, session_id, *, key=True, started_at=None, last_activity_at=None,
|
||||
ended_at=None, end_reason=None, msgs=0):
|
||||
kwargs = {"user_id": PEER["user_id"]}
|
||||
if key:
|
||||
kwargs.update(
|
||||
session_key=PEER["session_key"], chat_id=PEER["chat_id"],
|
||||
chat_type=PEER["chat_type"],
|
||||
)
|
||||
db.create_session(session_id, "telegram", **kwargs)
|
||||
for i in range(msgs):
|
||||
db.append_message(session_id, "user" if i % 2 == 0 else "assistant", f"m{i}")
|
||||
with db._lock:
|
||||
if started_at is not None:
|
||||
db._conn.execute("UPDATE sessions SET started_at=? WHERE id=?", (started_at, session_id))
|
||||
if last_activity_at is not None:
|
||||
db._conn.execute("UPDATE sessions SET last_activity_at=? WHERE id=?", (last_activity_at, session_id))
|
||||
if ended_at is not None:
|
||||
db._conn.execute(
|
||||
"UPDATE sessions SET ended_at=?, end_reason=? WHERE id=?",
|
||||
(ended_at, end_reason, session_id),
|
||||
)
|
||||
db._conn.commit()
|
||||
|
||||
|
||||
class TestIdentityAtInsert:
|
||||
def test_insert_session_row_persists_origin_and_display_name(self, db):
|
||||
origin = json.dumps({"platform": "telegram", "chat_id": "6308981865"})
|
||||
db.create_session(
|
||||
"s1", "telegram",
|
||||
session_key=PEER["session_key"], chat_id=PEER["chat_id"],
|
||||
chat_type="dm", origin_json=origin, display_name="Teknium",
|
||||
)
|
||||
row = db.get_session("s1")
|
||||
assert row["origin_json"] == origin
|
||||
assert row["display_name"] == "Teknium"
|
||||
assert row["session_key"] == PEER["session_key"]
|
||||
|
||||
def test_conflict_backfills_origin_without_overwriting(self, db):
|
||||
db.create_session("s2", "telegram", session_key=PEER["session_key"])
|
||||
# Second insert (agent-side upsert) backfills origin_json on the
|
||||
# existing row...
|
||||
db.create_session("s2", "telegram", origin_json='{"a":1}', display_name="X")
|
||||
row = db.get_session("s2")
|
||||
assert row["origin_json"] == '{"a":1}'
|
||||
# ...but never overwrites a value already set.
|
||||
db.create_session("s2", "telegram", origin_json='{"b":2}', display_name="Y")
|
||||
row = db.get_session("s2")
|
||||
assert row["origin_json"] == '{"a":1}'
|
||||
assert row["display_name"] == "X"
|
||||
|
||||
|
||||
class TestPeerRecorderSelfHeal:
|
||||
def test_recorder_inserts_missing_row_with_full_identity(self, db):
|
||||
"""The incident's core gap: routing points at a session id whose
|
||||
create_session write failed. The peer refresh must create the row
|
||||
with identity, not silently no-op."""
|
||||
sid = "20260806_161836_" + uuid.uuid4().hex[:8]
|
||||
assert db.get_session(sid) is None
|
||||
db.record_gateway_session_peer(
|
||||
sid,
|
||||
source="telegram",
|
||||
user_id=PEER["user_id"],
|
||||
session_key=PEER["session_key"],
|
||||
chat_id=PEER["chat_id"],
|
||||
chat_type="dm",
|
||||
display_name="Teknium",
|
||||
origin_json='{"platform": "telegram"}',
|
||||
)
|
||||
row = db.get_session(sid)
|
||||
assert row is not None, "peer refresh must self-heal the missing row"
|
||||
assert row["session_key"] == PEER["session_key"]
|
||||
assert row["chat_id"] == PEER["chat_id"]
|
||||
assert row["origin_json"] == '{"platform": "telegram"}'
|
||||
|
||||
def test_lazy_writer_then_peer_refresh_repairs_identity(self, db):
|
||||
"""Orphan-factory shape: update_token_counts materializes the row
|
||||
identity-less first; the next peer refresh must stamp it."""
|
||||
sid = "lazy_" + uuid.uuid4().hex[:8]
|
||||
db.update_token_counts(sid, input_tokens=10, output_tokens=5)
|
||||
row = db.get_session(sid)
|
||||
assert row is not None and row["session_key"] is None # the orphan
|
||||
db.record_gateway_session_peer(
|
||||
sid,
|
||||
source="telegram",
|
||||
user_id=PEER["user_id"],
|
||||
session_key=PEER["session_key"],
|
||||
chat_id=PEER["chat_id"],
|
||||
chat_type="dm",
|
||||
)
|
||||
row = db.get_session(sid)
|
||||
assert row["session_key"] == PEER["session_key"]
|
||||
assert row["chat_id"] == PEER["chat_id"]
|
||||
|
||||
|
||||
class TestPeerResolutionRecency:
|
||||
def test_prefers_recent_activity_over_started_at(self, db):
|
||||
"""The Aug 9 misroute: zombie predecessor (older start, no recent
|
||||
activity, still open+keyed) must lose to the keyed row with newer
|
||||
activity."""
|
||||
now = time.time()
|
||||
_mk_session(db, "zombie", started_at=now - 6 * 86400,
|
||||
last_activity_at=now - 3 * 86400, msgs=4)
|
||||
_mk_session(db, "live", started_at=now - 3 * 86400,
|
||||
last_activity_at=now - 600, msgs=6)
|
||||
found = db.find_latest_gateway_session_for_peer(**PEER)
|
||||
assert found is not None
|
||||
assert found["id"] == "live"
|
||||
|
||||
def test_empty_keyed_row_returned_not_none(self, db):
|
||||
"""Returning None mints a fresh id (worse than an empty resume);
|
||||
an empty keyed row must be returned."""
|
||||
_mk_session(db, "emptyrow", msgs=0)
|
||||
found = db.find_latest_gateway_session_for_peer(**PEER)
|
||||
assert found is not None
|
||||
assert found["id"] == "emptyrow"
|
||||
|
||||
def test_rows_with_messages_beat_empty_rows(self, db):
|
||||
now = time.time()
|
||||
_mk_session(db, "hascontent", last_activity_at=now - 86400, msgs=4)
|
||||
_mk_session(db, "emptynewer", last_activity_at=now - 60, msgs=0)
|
||||
found = db.find_latest_gateway_session_for_peer(**PEER)
|
||||
assert found["id"] == "hascontent"
|
||||
|
||||
def test_explicit_reset_rows_stay_unrecoverable(self, db):
|
||||
now = time.time()
|
||||
_mk_session(db, "resetold", last_activity_at=now - 60,
|
||||
ended_at=now - 30, end_reason="session_reset", msgs=4)
|
||||
found = db.find_latest_gateway_session_for_peer(**PEER)
|
||||
assert found is None, "/new boundaries must never be resurrected"
|
||||
|
||||
def test_incident_shape_still_resolves_to_best_available(self, db):
|
||||
"""Pre-fix production shape: zombie keyed+open, real session
|
||||
UNSTAMPED. The resolver can only see the zombie — proving stamping
|
||||
(fixes 1-2) is load-bearing; but with the real session stamped by
|
||||
the self-heal, the resolver must pick it."""
|
||||
now = time.time()
|
||||
_mk_session(db, "zombie2", started_at=now - 6 * 86400,
|
||||
last_activity_at=now - 3 * 86400, msgs=4)
|
||||
_mk_session(db, "real", key=False, started_at=now - 3 * 86400,
|
||||
last_activity_at=now - 600, msgs=8)
|
||||
found = db.find_latest_gateway_session_for_peer(**PEER)
|
||||
assert found["id"] == "zombie2" # unstamped rows are invisible
|
||||
# Self-heal stamps the real row (any later peer refresh):
|
||||
db.record_gateway_session_peer(
|
||||
"real", source="telegram", user_id=PEER["user_id"],
|
||||
session_key=PEER["session_key"], chat_id=PEER["chat_id"],
|
||||
chat_type="dm",
|
||||
)
|
||||
found = db.find_latest_gateway_session_for_peer(**PEER)
|
||||
assert found["id"] == "real"
|
||||
|
||||
|
||||
class TestLoadTranscriptReroutes:
|
||||
def test_load_transcript_follows_reroute_chain(self, tmp_path):
|
||||
from gateway.session import SessionStore
|
||||
|
||||
from gateway.config import GatewayConfig
|
||||
|
||||
store = SessionStore(sessions_dir=tmp_path / "gw", config=GatewayConfig())
|
||||
db = store._db
|
||||
assert db is not None
|
||||
db.create_session("parent", "telegram", session_key=PEER["session_key"])
|
||||
db.create_session("child", "telegram", session_key=PEER["session_key"])
|
||||
db.append_message("child", "user", "hello from the child")
|
||||
# Write-side reroute installed after a compression rotation:
|
||||
store._transcript_reroutes["parent"] = "child"
|
||||
msgs = store.load_transcript("parent")
|
||||
assert any("hello from the child" in str(m.get("content", "")) for m in msgs), (
|
||||
"reads must follow the same reroute chain writes use"
|
||||
)
|
||||
|
||||
def test_load_transcript_follows_durable_compression_tip(self, tmp_path):
|
||||
from gateway.session import SessionStore
|
||||
|
||||
from gateway.config import GatewayConfig
|
||||
|
||||
store = SessionStore(sessions_dir=tmp_path / "gw2", config=GatewayConfig())
|
||||
db = store._db
|
||||
assert db is not None
|
||||
db.create_session("p2", "telegram", session_key=PEER["session_key"])
|
||||
db.append_message("p2", "user", "old")
|
||||
db.publish_compression_child(
|
||||
parent_session_id="p2",
|
||||
child_session_id="c2",
|
||||
source="telegram",
|
||||
messages=[{"role": "user", "content": "compressed history"}],
|
||||
require_compression_lease=False,
|
||||
)
|
||||
# No in-memory reroute (fresh store after restart) — durable tip only.
|
||||
store._transcript_reroutes.clear()
|
||||
msgs = store.load_transcript("p2")
|
||||
assert any("compressed history" in str(m.get("content", "")) for m in msgs)
|
||||
Reference in New Issue
Block a user