Context compression session metadata loss and orphan sessions
NousResearch/hermes-agent · cluster 33906 · agent
Data as of
Nothing open on GitHub. Fix merged in #49772. Check it covers your case.
The short answer
- Known?
- Yes. 10 related items grouped here, 4 issues already closed.
- Fix event
- A fix was merged to main in PR #49772. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These issues and fixes concern data loss during context compression rotation, specifically around session metadata (gateway origin, user_id, chat fields, indexing) not being preserved or persisted to state.db in continuation sessions, and orphan sessions being created as a result.
Open issues0
Open PRs0
Fixes on record
-
#49772 fix(compression): preserve goal, platform, and session indexing across rotation
files:
agent/conversation_compression.py,hermes_cli/goals.py,tests/agent/test_compression_rotation_state.py,tests/hermes_cli/test_goals.pyShow the diff
-
agent/conversation_compression.py@@ -592,14 +592,62 @@ def _release_lock() -> None: except Exception: pass agent._session_db_created = False - agent._session_db.create_session( - session_id=agent.session_id, - source=agent.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"), - model=agent.model, - model_config=agent._session_init_model_config, - parent_session_id=old_session_id, - ) + try: + agent._session_db.create_session( + session_id=agent.session_id, + source=agent.platform or os.environ.get("HERMES_SESSION_SOURCE", "cli"), + model=agent.model, + model_config=agent._session_init_model_config, + parent_session_id=old_session_id, + ) + except Exception as _cs_err: + # The child row could not be created (e.g. FK constraint, + # contended write). Previously the outer handler simply + # warned and let the agent continue on the NEW id — which + # has no row in state.db, producing an orphan: the parent + # is ended, the child is never indexed, and every + # subsequent message is attributed to a session that + # doesn't exist (#33906/#33907). Roll the live id back to + # the parent so the conversation stays attached to a real, + # indexed session instead of a phantom. + logger.warning( + "Compression child session create failed (%s) — " + "rolling back to parent session %s to avoid an orphan.", + _cs_err, old_session_id, + ) + agent.session_id = old_session_id + try: + from gateway.session_context import set_current_session_id + set_current_session_id(agent.session_id) + except Exception: + os.environ["HERMES_SESSION_ID"] = agent.session_id + try: + from hermes_logging import set_session_context + set_session_context(agent.session_id) + except Exception: + pass + # Re-open the parent: it was ended above, but we're + # continuing on it, so it must not stay closed. + try: + agent._session_db.reopen_session(old_session_id) + except Exception: + pass + old_session_id = None # no rotation happened + # The parent row already exists in state.db, so mark the + # session as created — _ensure_db_session would otherwise + # retry a (harmless INSERT OR IGNORE) create next turn. + agent._session_db_created = True + raise agent._session_db_created = True + # Carry a persistent /goal onto the continuation session. + # Compression mints a fresh child id; load_goal does a flat + # per-session lookup with no parent walk, so without this an + # active goal silently dies at the boundary (#33618). + try: + from hermes_cli.goals import migrate_goal_to_session + migrate_goal_to_session(old_session_id, agent.session_id, reason="compression") + except Exception as _goal_err: + logger.debug("Could not migrate goal on compression: %s", _goal_err) # Auto-number the title for the continuation session if old_title: try: @@ -615,7 +663,18 @@ def _release_lock() -> None: agent._session_db.update_system_prompt(agent.session_id, new_system_prompt) agent._last_flushed_db_idx = 0 except Exception as e: - logger.warning("Session DB compression split failed — new session will NOT be indexed: %s", e) + # If the rotation rolled back to the parent (orphan-avoidance + # above), agent.session_id is the still-indexed parent and + # old_session_id was cleared — so this is recovery, not an + # un-indexed orphan. Otherwise an earlier step failed before the + # child was created and the warning's original meaning holds. + if locals().get("old_session_id") is None and not in_place: + logger.warning( + "Compression rotation aborted and rolled back to the " + "parent session (%s): %s", agent.session_id or "?", e, + ) + else: + logger.warning("Session DB compression split failed — new session will NOT be indexed: %s", e) # Compaction-boundary bookkeeping, computed once. `old_session_id` is only # bound in the rotation branch; in-place leaves it unset. `_boundary_parent` @@ -637,6 +696,7 @@ def _release_lock() -> None: agent.session_id or "", boundary_reason="compression", old_session_id=_boundary_parent, + platform=getattr(agent, "platform", None) or "cli", conversation_id=getattr(agent, "_gateway_session_key", None), ) except Exception as _ce_err: -
hermes_cli/goals.py@@ -279,6 +279,44 @@ def clear_goal(session_id: str) -> None: save_goal(session_id, state) +def migrate_goal_to_session(old_session_id: str, new_session_id: str, *, reason: str = "") -> bool: + """Carry a persistent /goal from a parent session to its continuation. + + Context compression rotates ``session_id`` to a fresh child session, + but ``load_goal`` does a flat ``goal:<session_id>`` lookup with no + parent-lineage walk — so an active goal silently dies at the + compaction boundary (#33618). Copy the goal onto the new session and + archive the old row as ``cleared`` so exactly one active goal row + exists per logical conversation (avoids the "two active goals" + hazard of a pure copy). + + Returns True when a goal was migrated, False when there was nothing + to migrate or the DB was unavailable. Best-effort and never raises — + a failure here must not block compression. + """ + if not old_session_id or not new_session_id or old_session_id == new_session_id: + return False + try: + state = load_goal(old_session_id) + if state is None or getattr(state, "status", None) == "cleared": + return False + # Don't clobber a goal already set on the child (e.g. a resumed + # lineage that re-established its own goal). + if load_goal(new_session_id) is not None: + return False + save_goal(new_session_id, state) + # Archive the parent's row so it isn't double-counted as active. + clear_goal(old_session_id) + logger.debug( + "GoalManager: migrated goal %s -> %s (%s)", + old_session_id, new_session_id, reason or "rotation", + ) + return True + except Exception as exc: # pragma: no cover - defensive + logger.debug("GoalManager: goal migration failed: %s", exc) + return False + + # ────────────────────────────────────────────────────────────────────── # Judge # ────────────────────────────────────────────────────────────────────── @@ -907,6 +945,7 @@ def _log(msg: str) -> None: "load_goal", "save_goal", "clear_goal", + "migrate_goal_to_session", "judge_goal", "run_kanban_goal_loop", ] -
tests/agent/test_compression_rotation_state.py@@ -0,0 +1,129 @@ +"""Compression rotation hardening — state-loss fixes at the compaction boundary. + +When auto-compression rotates ``agent.session_id`` to a continuation child, +three pieces of state used to be lost or corrupted: + + * #33618 — a persistent ``/goal`` did not follow the rotation (``load_goal`` + is a flat per-session lookup with no lineage walk), so it silently died. + * #33906/#33907 — if the child ``create_session`` raised, the outer handler + only warned and let the agent continue on the NEW (un-indexed) id, + producing an orphan session missing from state.db. + * #27633 — the compaction-boundary ``on_session_start`` notification omitted + the ``platform`` kwarg, so context-engine plugins saw ``source=unknown`` + for every message after the boundary. + +These tests drive the real ``compress_context`` path against a real SessionDB. +""" + +from __future__ import annotations + +import os +from pathlib import Path +from unittest.mock import MagicMock, patch + +from hermes_state import SessionDB + + +def _build_agent_with_db(db: SessionDB, session_id: str, platform: str = "telegram"): + with patch.dict(os.environ, {"OPENROUTER_API_KEY": "test-key"}): + from run_agent import AIAgent + + agent = AIAgent( + api_key="[redacted:secret-assignment]", + base_url="https://openrouter.ai/api/v1", + model="test/model", + platform=platform, + quiet_mode=True, + session_db=db, + session_id=session_id, + skip_context_files=True, + skip_memory=True, + ) + + compressor = MagicMock() + compressor.compress.return_value = [ + {"role": "user", "content": "[CONTEXT COMPACTION] summary"}, + {"role": "user", "content": "tail"}, + ] + compressor.compression_count = 1 + compressor.last_prompt_tokens = 0 + compressor.last_completion_tokens = 0 + compressor._last_summary_error = None + compressor._last_compress_aborted = False + compressor._last_summary_auth_failure = False + compressor._last_aux_model_failure_model = None + compressor._last_aux_model_failure_error = None + agent.context_compressor = compressor + return agent + + +def _msgs(n=20): + return [{"role": "user", "content": f"m{i}"} for i in range(n)] + + +class TestGoalMigratesOnRotation: + def test_goal_follows_compression_rotation(self, tmp_path: Path): + db = SessionDB(db_path=tmp_path / "state.db") + parent = "PARENT_GOAL_ROT" + db.create_session(parent, source="cli") + agent = _build_agent_with_db(db, parent) + + # Set a persistent goal on the parent via the real persistence path. + with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path / ".hermes")}): + (tmp_path / ".hermes").mkdir(exist_ok=True) + import hermes_cli.goals as goals + goals._DB_CACHE.clear() + # Point the goal DB at the same state.db the agent uses. + with patch.object(goals, "_get_session_db", return_value=db): + goals.save_goal(parent, goals.GoalState(goal="finish the migration")) + + agent._compress_context(_msgs(), "sys", approx_tokens=[redacted:secret-assignment] + child = agent.session_id + assert child != parent # rotation happened + + migrated = goals.load_goal(child) + assert migrated is not None + assert migrated.goal == "finish the migration" + goals._DB_CACHE.clear() + + +class TestOrphanRollbackOnCreateFailure: + def test_rolls_back_to_parent_when_child_create_fails(self, tmp_path: Path): + db = SessionDB(db_path=tmp_path / "state.db") + parent = "PARENT_ORPHAN_ROT" + db.create_session(parent, source="cli") + agent = _build_agent_with_db(db, parent) + + # Make the CHILD create_session raise, but let the initial parent + # end_session/reopen work. We patch create_session to blow up. + real_create = db.create_session + + def _boom(*a, **k): + raise RuntimeError("FOREIGN KEY constraint failed") + + with patch.object(db, "create_session", side_effect=_boom): + agent._compress_context(_msgs(), "sys", approx_tokens=[redacted:secret-assignment] + + # The live id must roll back to the still-indexed parent — NOT a + # phantom child id that has no row in state.db. + assert agent.session_id == parent + assert db.get_session(parent) is not None + _ = real_create # silence unused + + +class TestPlatformForwardedAtBoundary: + def test_on_session_start_receives_platform(self, tmp_path: Path): + db = SessionDB(db_path=tmp_path / "state.db") + parent = "PARENT_PLATFORM_ROT" + db.create_session(parent, source="telegram") + agent = _build_agent_with_db(db, parent, platform="telegram") + + agent._compress_context(_msgs(), "sys", approx_tokens=[redacted:secret-assignment] + + # The boundary notify must forward the platform so context-engine + # plugins don't fall back to source=unknown (#27633). + calls = [c for c in agent.context_compressor.on_session_start.call_args_list] + assert calls, "on_session_start was not called at the boundary" + kwargs = calls[-1].kwargs + assert kwargs.get("platform") == "telegram" + assert kwargs.get("boundary_reason") == "compression" -
tests/hermes_cli/test_goals.py@@ -547,6 +547,47 @@ def test_subgoals_round_trip(self): assert rt.subgoals == ["a", "b", "c"] +class TestMigrateGoalToSession: + """migrate_goal_to_session carries a /goal from a parent session to its + compression continuation child (#33618). load_goal does a flat + per-session lookup with no lineage walk, so without migration an active + goal silently dies when compression rotates session_id.""" + + def test_migrates_active_goal_to_child(self, hermes_home): + from hermes_cli.goals import save_goal, load_goal, migrate_goal_to_session, GoalState + save_goal("parent-sid", GoalState(goal="ship the feature")) + assert migrate_goal_to_session("parent-sid", "child-sid", reason="compression") is True + child = load_goal("child-sid") + assert child is not None and child.goal == "ship the feature" + # Parent row archived (cleared) so only the child is active. + parent = load_goal("parent-sid") + assert parent is not None and parent.status == "cleared" + + def test_no_goal_to_migrate_returns_false(self, hermes_home): + from hermes_cli.goals import migrate_goal_to_session, load_goal + assert migrate_goal_to_session("empty-parent", "child2") is False + assert load_goal("child2") is None + + def test_does_not_clobber_existing_child_goal(self, hermes_home): + from hermes_cli.goals import save_goal, load_goal, migrate_goal_to_session, GoalState + save_goal("p3", GoalState(goal="parent goal")) + save_goal("c3", GoalState(goal="child already has one")) + assert migrate_goal_to_session("p3", "c3") is False + assert load_goal("c3").goal == "child already has one" + + def test_same_id_is_noop(self, hermes_home): + from hermes_cli.goals import save_goal, migrate_goal_to_session, GoalState + save_goal("same", GoalState(goal="g")) + assert migrate_goal_to_session("same", "same") is False + + def test_cleared_goal_not_migrated(self, hermes_home): + from hermes_cli.goals import save_goal, clear_goal, migrate_goal_to_session, load_goal, GoalState + save_goal("p4", GoalState(goal="done already")) + clear_goal("p4") + assert migrate_goal_to_session("p4", "c4") is False + assert load_goal("c4") is None + + class TestGoalManagerSubgoals: def test_add_subgoal(self, hermes_home): from hermes_cli.goals import GoalManager
-
-
#58899 refactor: consolidate gateway session metadata into state.db
files:
gateway/channel_directory.py,gateway/mirror.py,gateway/run.py,gateway/session.py,hermes_cli/status.py,hermes_state.py,mcp_serve.py,tests/test_hermes_state.py,tests/test_mcp_serve.pyShow the diff
-
gateway/channel_directory.py@@ -263,7 +263,67 @@ async def _build_slack(adapter) -> List[Dict[str, Any]]: def _build_from_sessions(platform_name: str) -> List[Dict[str, str]]: - """Pull known channels/contacts from sessions.json origin data.""" + """Pull known channels/contacts from gateway session origin data. + + state.db is the primary source (#9006): gateway session rows persist + origin_json. Falls back to sessions.json for pre-migration databases. + """ + entries = _build_from_sessions_db(platform_name) + if entries: + return entries + return _build_from_sessions_json(platform_name) + + +def _build_from_sessions_db(platform_name: str) -> List[Dict[str, str]]: + """Pull channels/contacts from state.db gateway session rows.""" + entries: List[Dict[str, str]] = [] + try: + from hermes_state import SessionDB + db = SessionDB() + try: + lister = getattr(db, "list_gateway_sessions", None) + if not callable(lister): + return [] + rows = lister(platform=platform_name, active_only=False) + finally: + db.close() + + seen_ids = set() + for row in rows: + origin: Dict[str, Any] = {} + if row.get("origin_json"): + try: + parsed = json.loads(row["origin_json"]) + if isinstance(parsed, dict): + origin = parsed + except (TypeError, ValueError): + pass + if not origin: + origin = { + "chat_id": row.get("chat_id"), + "thread_id": row.get("thread_id"), + "chat_name": row.get("display_name"), + } + entry_id = _session_entry_id(origin) + if not entry_id or entry_id in seen_ids: + continue + seen_ids.add(entry_id) + entries.append({ + "id": entry_id, + "name": _session_entry_name(origin), + "type": row.get("chat_type") or "dm", + "thread_id": origin.get("thread_id"), + }) + except Exception as e: + logger.debug( + "Channel directory: state.db session read failed for %s: %s", + platform_name, e, + ) + return entries + + +def _build_from_sessions_json(platform_name: str) -> List[Dict[str, str]]: + """Legacy fallback: pull channels/contacts from sessions.json origin data.""" sessions_path = get_hermes_home() / "sessions" / "sessions.json" if not sessions_path.exists(): return [] -
gateway/mirror.py@@ -102,14 +102,36 @@ def _find_session_id( """ Find the active session_id for a platform + chat_id pair. - Scans sessions.json entries and matches where origin.chat_id == chat_id - on the right platform. DM session keys don't embed the chat_id - (e.g. "agent:main:telegram:dm"), so we check the origin dict. + Queries state.db gateway session rows (primary source since #9006); + falls back to scanning sessions.json for pre-migration databases. + DM session keys don't embed the chat_id (e.g. "agent:main:telegram:dm"), + so we match on the persisted chat origin, not the key. When *user_id* is provided, prefer exact sender matches. If multiple same-chat candidates exist and none matches the user, return None instead of guessing and contaminating another participant's session. """ + # Primary: state.db + try: + from hermes_state import SessionDB + db = SessionDB() + try: + finder = getattr(db, "find_session_by_origin", None) + if callable(finder): + session_id = finder( + platform=platform, + chat_id=chat_id, + thread_id=thread_id, + user_id=user_id, + ) + if session_id: + return str(session_id) + finally: + db.close() + except Exception as e: + logger.debug("Mirror state.db session lookup failed: %s", e) + + # Fallback: sessions.json (pre-migration databases) if not _SESSIONS_INDEX.exists(): return None -
gateway/run.py@@ -7540,14 +7540,11 @@ async def _session_expiry_watcher(self, interval: int = 300): _update_prompt_pending = getattr(self, "_update_prompt_pending", None) if isinstance(_update_prompt_pending, dict): _update_prompt_pending.pop(key, None) - with self.session_store._lock: - entry.expiry_finalized = True - # Session finalization is a conversation boundary — - # drop the persisted /model override too so a later - # message doesn't rehydrate it after the in-memory - # override was popped above. - entry.model_override = None - self.session_store._save() + # Persist the finalized flag to sessions.json AND + # state.db (single write-path, #9006) — also drops + # the persisted /model override, since finalization + # is a conversation boundary. + self.session_store.set_expiry_finalized(entry) logger.debug( "Session expiry finalized for %s", entry.session_id, @@ -7562,9 +7559,9 @@ async def _session_expiry_watcher(self, interval: int = 300): "Marking as finalized to prevent infinite retry loop.", failures, entry.session_id, e, ) - with self.session_store._lock: - entry.expiry_finalized = True - self.session_store._save() + self.session_store.set_expiry_finalized( + entry, clear_model_override=False + ) _finalize_failures.pop(entry.session_id, None) else: logger.debug( -
gateway/session.py@@ -1202,6 +1202,7 @@ def _record_gateway_session_peer( session_id: str, session_key: str, source: Optional[SessionSource], + display_name: Optional[str] = None, ) -> None: """Persist the routing peer for an existing gateway session row.""" if not self._db or not source: @@ -1210,6 +1211,11 @@ def _record_gateway_session_peer( if not callable(recorder): return try: + origin_json = None + try: + origin_json = json.dumps(source.to_dict()) + except Exception: + pass recorder( session_id, source=source.platform.value, @@ -1218,9 +1224,56 @@ def _record_gateway_session_peer( chat_id=source.chat_id, chat_type=source.chat_type, thread_id=source.thread_id, + display_name=display_name or source.chat_name, + origin_json=origin_json, ) + except TypeError: + # Older SessionDB without display_name/origin_json kwargs. + try: + recorder( + session_id, + source=source.platform.value, + user_id=source.user_id, + session_key=session_key, + chat_id=source.chat_id, + chat_type=source.chat_type, + thread_id=source.thread_id, + ) + except Exception as exc: + logger.debug("Gateway session peer record failed for %s: %s", session_key, exc) except Exception as exc: logger.debug("Gateway session peer record failed for %s: %s", session_key, exc) + + def set_expiry_finalized( + self, entry: SessionEntry, *, clear_model_override: bool = True + ) -> None: + """Mark a session entry expiry-finalized in memory, sessions.json, AND state.db. + + Single write-path for the expiry watcher (#9006): keeps the durable + state.db flag in sync with the JSON routing index so the flag + survives sessions.json pruning/loss. + + ``clear_model_override=False`` preserves the give-up path's original + behavior (flag only, no override drop). + """ + with self._lock: + entry.expiry_finalized = True + if clear_model_override: + # Session finalization is a conversation boundary — drop the + # persisted /model override too so a later message doesn't + # rehydrate it after the in-memory override was popped. + entry.model_override = None + self._save() + if self._db: + setter = getattr(self._db, "set_expiry_finalized", None) + if callable(setter): + try: + setter(entry.session_id, True) + except Exception as exc: + logger.debug( + "Session DB expiry_finalized write failed for %s: %s", + entry.session_id, exc, + ) def _is_session_expired(self, entry: SessionEntry) -> bool: """Check if a session has expired based on its reset policy. @@ -1618,6 +1671,7 @@ def get_or_create_session( session_id, session_key, source, + display_name=entry.display_name, ) except Exception as e: print(f"[gateway] Warning: Failed to create SQLite session: {e}") @@ -1643,6 +1697,7 @@ def update_session( entry.session_id, session_key, entry.origin, + display_name=entry.display_name, ) def set_model_override( @@ -1888,6 +1943,7 @@ def reset_session(self, session_key: str, display_name: Optional[str] = None) -> session_id, session_key, old_entry.origin, + display_name=new_entry.display_name if new_entry else None, ) except Exception as e: logger.debug("Session DB operation failed: %s", e) @@ -1950,6 +2006,7 @@ def switch_session(self, session_key: str, target_session_id: str) -> Optional[S target_session_id, session_key, new_entry.origin if new_entry else None, + display_name=new_entry.display_name if new_entry else None, ) return new_entry -
hermes_cli/status.py@@ -543,17 +543,39 @@ def _resolve_env(env_ref) -> str: print() print(color("◆ Sessions", Colors.CYAN, Colors.BOLD)) - sessions_file = get_hermes_home() / "sessions" / "sessions.json" - if sessions_file.exists(): - import json + # Gateway session count: state.db is the source of truth (#9006); + # fall back to sessions.json for pre-migration installs. + _session_count = None + try: + from hermes_state import SessionDB + _db = SessionDB() try: - with open(sessions_file, encoding="utf-8") as f: - data = json.load(f) - print(f" Active: {len(data)} session(s)") - except Exception: - print(" Active: (error reading sessions file)") + _lister = getattr(_db, "list_gateway_sessions", None) + if callable(_lister): + _session_count = len(_lister(active_only=True)) + finally: + _db.close() + except Exception: + _session_count = None + + if _session_count is not None and _session_count > 0: + print(f" Active: {_session_count} session(s)") else: - print(" Active: 0") + sessions_file = get_hermes_home() / "sessions" / "sessions.json" + if sessions_file.exists(): + import json + try: + with open(sessions_file, encoding="utf-8") as f: + data = json.load(f) + _entries = { + k: v for k, v in data.items() + if not str(k).startswith("_") + } if isinstance(data, dict) else {} + print(f" Active: {len(_entries)} session(s)") + except Exception: + print(" Active: (error reading sessions file)") + else: + print(f" Active: {_session_count if _session_count is not None else 0}") # ========================================================================= # Deep checks -
hermes_state.py@@ -122,7 +122,7 @@ def _delete_delegate_children(conn, parent_ids: List[str]) -> List[str]: DEFAULT_DB_PATH = get_hermes_home() / "state.db" -SCHEMA_VERSION = 17 +SCHEMA_VERSION = 18 # Cap on user-controlled FTS5 query input before regex/sanitizer processing. # Search queries do not need to be arbitrarily large, and bounding them keeps @@ -705,6 +705,9 @@ def repair_state_db_schema(db_path: Path, *, backup: bool = True) -> Dict[str, A chat_id TEXT, chat_type TEXT, thread_id TEXT, + display_name TEXT, + origin_json TEXT, + expiry_finalized INTEGER DEFAULT 0, model TEXT, model_config TEXT, system_prompt TEXT, @@ -1522,6 +1525,19 @@ def _init_schema(self): ) except sqlite3.OperationalError: pass + if current_version < 18: + # v18: gateway metadata consolidation (#9006). Backfill + # display_name / origin_json / expiry_finalized from + # sessions.json so pre-migration gateway sessions are + # discoverable from state.db without the JSON index. + try: + self._backfill_gateway_metadata_from_sessions_json(cursor) + except Exception as exc: + # Backfill is best-effort: sessions.json may be absent, + # corrupted, or partially stale. Missing metadata simply + # means consumers fall back to sessions.json for those + # rows until the gateway rewrites them. + logger.debug("v18 gateway metadata backfill skipped: %s", exc) if current_version < SCHEMA_VERSION and fts_migrations_complete: cursor.execute( "UPDATE schema_version SET version = ?", @@ -1647,8 +1663,17 @@ def record_gateway_session_peer( chat_id: str = None, chat_type: str = None, thread_id: str = None, + display_name: str = None, + origin_json: str = None, ) -> None: - """Persist the gateway routing peer for an existing session row.""" + """Persist the gateway routing peer for an existing session row. + + ``display_name`` / ``origin_json`` carry the gateway's presentation + and full origin metadata (#9006) so consumers (mcp_serve, mirror, + channel directory) can read routing data from state.db instead of + sessions.json. They are COALESCE'd only in the sense that ``None`` + leaves the existing value untouched. + """ if not session_id or not session_key: return @@ -1656,7 +1681,9 @@ def _do(conn): conn.execute( """UPDATE sessions SET session_key = ?, source = ?, user_id = ?, chat_id = ?, - chat_type = ?, thread_id = ? + chat_type = ?, thread_id = ?, + display_name = COALESCE(?, display_name), + origin_json = COALESCE(?, origin_json) WHERE id = ?""", ( session_key, @@ -1665,12 +1692,168 @@ def _do(conn): chat_id, chat_type, thread_id, + display_name, + origin_json, session_id, ), ) self._execute_write(_do) + def set_expiry_finalized(self, session_id: str, finalized: bool = True) -> None: + """Mark a gateway session's expiry-finalization flag in state.db. + + Mirrors ``SessionEntry.expiry_finalized`` (sessions.json) so the flag + survives even if the JSON index is pruned or lost (#9006). + """ + if not session_id: + return + + def _do(conn): + conn.execute( + "UPDATE sessions SET expiry_finalized = ? WHERE id = ?", + (1 if finalized else 0, session_id), + ) + + self._execute_write(_do) + + def list_gateway_sessions( + self, + *, + platform: Optional[str] = None, + active_only: bool = True, + ) -> List[Dict[str, Any]]: + """List gateway sessions (rows with a session_key) from state.db. + + Returns the newest row per session_key — the same shape consumers got + from sessions.json: one live mapping per routing key. ``platform`` + filters on ``source``; ``active_only`` restricts to sessions that + have not ended. + """ + query = """ + SELECT sessions.*, + COALESCE( + (SELECT MAX(m.timestamp) FROM messages m + WHERE m.session_id = sessions.id), + sessions.started_at + ) AS last_active + FROM sessions + WHERE session_key IS NOT NULL + AND started_at = ( + SELECT MAX(s2.started_at) FROM sessions s2 + WHERE s2.session_key = sessions.session_key + ) + """ + params: list = [] + if platform: + query += " AND LOWER(source) = LOWER(?)" + params.append(platform) + if active_only: + query += " AND ended_at IS NULL" + query += " ORDER BY last_active DESC" + with self._lock: + rows = self._conn.execute(query, params).fetchall() + return [dict(r) for r in rows] + + def find_session_by_origin( + self, + *, + platform: str, + chat_id: str, + thread_id: Optional[str] = None, + user_id: Optional[str] = None, + ) -> Optional[str]: + """Find the most recent live session_id for a platform + chat origin. + + Equivalent of gateway/mirror's sessions.json scan: matches on + source + chat_id (+ thread_id when provided). When ``user_id`` is + provided, exact sender matches are preferred; if multiple distinct + users share the chat and none matches, returns None rather than + contaminating another participant's session. + """ + if not platform or chat_id in (None, ""): + return None + query = """ + SELECT id, user_id, started_at FROM sessions + WHERE LOWER(source) = LOWER(?) + AND session_key IS NOT NULL + AND chat_id = ? + AND ended_at IS NULL + """ + params: list = [platform, str(chat_id)] + if thread_id is not None: + query += " AND COALESCE(thread_id, '') = ?" + params.append(str(thread_id)) + query += " ORDER BY started_at DESC" + with self._lock: + rows = [dict(r) for r in self._conn.execute(query, params).fetchall()] + if not rows: + return None + if user_id: + exact = [r for r in rows if str(r.get("user_id") or "") == str(user_id)] + if exact: + return str(exact[0]["id"]) + if len(rows) > 1: + return None + elif len(rows) > 1: + distinct_users = { + str(r.get("user_id") or "").strip() + for r in rows + if str(r.get("user_id") or "").strip() + } + if len(distinct_users) > 1: + return None + return str(rows[0]["id"]) + + def _backfill_gateway_metadata_from_sessions_json( + self, cursor: sqlite3.Cursor + ) -> None: + """One-time v18 backfill of gateway metadata from sessions.json. + + Existing gateway sessions predate the display_name / origin_json / + expiry_finalized columns; copy what sessions.json knows so consumers + can switch to state.db without losing pre-migration sessions. + Only fills NULL columns — never overwrites data written by newer code. + """ + sessions_file = get_hermes_home() / "sessions" / "sessions.json" + if not sessions_file.exists(): + return + with open(sessions_file, "r", encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, dict): + return + for key, entry in data.items(): + if str(key).startswith("_") or not isinstance(entry, dict): + continue + session_id = entry.get("session_id") + if not session_id: + continue + origin = entry.get("origin") + cursor.execute( + """UPDATE sessions + SET session_key = COALESCE(session_key, ?), + chat_id = COALESCE(chat_id, ?), + chat_type = COALESCE(chat_type, ?), + thread_id = COALESCE(thread_id, ?), + display_name = COALESCE(display_name, ?), + origin_json = COALESCE(origin_json, ?), + expiry_finalized = CASE + WHEN COALESCE(expiry_finalized, 0) = 0 AND ? = 1 THEN 1 + ELSE expiry_finalized + END + WHERE id = ?""", + ( + entry.get("session_key") or key, + (origin or {}).get("chat_id") if isinstance(origin, dict) else None, + entry.get("chat_type"), + (origin or {}).get("thread_id") if isinstance(origin, dict) else None, + entry.get("display_name"), + json.dumps(origin) if isinstance(origin, dict) else None, + 1 if entry.get("expiry_finalized") or entry.get("memory_flushed") else 0, + str(session_id), + ), + ) + def find_latest_gateway_session_for_peer( self, *, -
mcp_serve.py@@ -37,6 +37,7 @@ import threading import time from dataclasses import dataclass, field +from datetime import datetime from pathlib import Path from typing import Dict, List, Optional @@ -79,10 +80,99 @@ def _get_session_db(): def _load_sessions_index() -> dict: - """Load the gateway sessions.json index directly. + """Load the gateway session routing index. Returns a dict of session_key -> entry_dict with platform routing info. - This avoids importing the full SessionStore which needs GatewayConfig. + + state.db is the primary source (#9006): gateway sessions persist their + routing metadata (session_key, chat/thread ids, display_name, origin) on + the durable session row, so a single database read replaces the old + dual-file sessions.json dependency. Falls back to sessions.json for + pre-migration databases where no gateway rows carry a session_key yet. + """ + entries = _load_sessions_index_from_db() + if entries: + return entries + return _load_sessions_index_from_json() + + +def _row_to_index_entry(row: dict) -> dict: + """Convert a state.db gateway session row to the sessions.json entry shape.""" + origin = {} + origin_json = row.get("origin_json") + if origin_json: + try: + parsed = json.loads(origin_json) + if isinstance(parsed, dict): + origin = parsed + except (TypeError, ValueError): + pass + if not origin: + # Pre-origin_json rows: synthesize the minimal origin from columns. + origin = { + "platform": row.get("source", ""), + "chat_id": row.get("chat_id"), + "chat_type": row.get("chat_type"), + "thread_id": row.get("thread_id"), + "user_id": row.get("user_id"), + } + + def _iso(ts) -> str: + try: + return datetime.fromtimestamp(float(ts)).isoformat() if ts else "" + except (TypeError, ValueError, OSError): + return "" + + input_tokens = [redacted:secret-assignment]"input_tokens") or 0) + output_tokens = [redacted:secret-assignment]"output_tokens") or 0) + return { + "session_id": str(row.get("id", "")), + "session_key": row.get("session_key", ""), + "platform": row.get("source", ""), + "chat_type": row.get("chat_type") or origin.get("chat_type", ""), + "display_name": row.get("display_name") or origin.get("chat_name") or "", + "origin": origin, + "created_at": _iso(row.get("started_at")), + "updated_at": _iso(row.get("last_active") or row.get("started_at")), + "input_tokens": input_tokens, + "output_tokens": output_tokens, + "total_tokens": input_tokens + output_tokens, + } + + +def _load_sessions_index_from_db() -> dict: + """Build the routing index from state.db gateway session rows.""" + db = _get_session_db() + if db is None: + return {} + try: + lister = getattr(db, "list_gateway_sessions", None) + if not callable(lister): + return {} + rows = lister(active_only=True) + entries = {} + for row in rows: + key = row.get("session_key") + if not key: + continue + entries[key] = _row_to_index_entry(row) + return entries + except Exception as e: + logger.debug("Failed to load gateway sessions from state.db: %s", e) + return {} + finally: + try: + db.close() + except Exception: + pass + + +def _load_sessions_index_from_json() -> dict: + """Legacy fallback: load the gateway sessions.json index directly. + + Used only for pre-migration databases whose gateway rows don't carry a + session_key yet. This avoids importing the full SessionStore which + needs GatewayConfig. """ sessions_file = _get_sessions_dir() / "sessions.json" if not sessions_file.exists(): @@ -226,8 +316,7 @@ def __init__(self): self._last_poll_timestamps: Dict[str, float] = {} # session_key -> unix timestamp # In-memory approval tracking (populated from events) self._pending_approvals: Dict[str, dict] = {} - # mtime cache — skip expensive work when files haven't changed - self._sessions_json_mtime: float = 0.0 + # mtime cache — skip expensive work when state.db hasn't changed self._state_db_mtime: float = 0.0 self._cached_sessions_index: dict = {} @@ -353,24 +442,14 @@ def _poll_loop(self): def _poll_once(self, db): """Check for new messages across all sessions. - Uses mtime checks on sessions.json and state.db to skip work - when nothing has changed — makes 200ms polling essentially free. + Uses a single mtime check on state.db to skip work when nothing + has changed — makes 200ms polling essentially free. Since #9006 + the routing index itself lives in state.db (session rows carry + session_key/origin metadata), so a new conversation and its first + message land in the SAME file and one mtime check covers both — + eliminating the old dual-file (sessions.json + state.db) race that + could drop brand-new conversations (#8925). """ - # Check if sessions.json has changed (mtime check is ~1μs). - # Capture the previously-seen mtime *before* refreshing the cache so the - # skip guard below can still tell whether sessions.json changed this tick. - prev_sessions_json_mtime = self._sessions_json_mtime - sessions_file = _get_sessions_dir() / "sessions.json" - try: - sj_mtime = sessions_file.stat().st_mtime if sessions_file.exists() else 0.0 - except OSError: - sj_mtime = 0.0 - - if sj_mtime != self._sessions_json_mtime: - self._sessions_json_mtime = sj_mtime - self._cached_sessions_index = _load_sessions_index() - - # Check if state.db has changed try: from hermes_constants import get_hermes_home db_file = get_hermes_home() / "state.db" @@ -382,19 +461,14 @@ def _poll_once(self, db): except OSError: db_mtime = 0.0 - # Skip only when NEITHER file changed since the last poll. Comparing - # against ``prev_sessions_json_mtime`` (not the freshly-stored - # ``self._sessions_json_mtime``) is essential: the latter was just set to - # ``sj_mtime`` above, so using it would make the sessions.json term - # always true and collapse the guard to a db-only check. That would - # discard a tick where only sessions.json changed — e.g. a brand-new - # conversation registered after its first message already landed in - # state.db on an earlier tick — and the new chat's messages would never - # be emitted until state.db happened to change again. - if db_mtime == self._state_db_mtime and sj_mtime == prev_sessions_json_mtime: + if db_mtime == self._state_db_mtime: return # Nothing changed since last poll — skip entirely self._state_db_mtime = db_mtime + # Refresh the routing index from state.db on every change tick — + # it's a single indexed query and it can never lag the messages + # table (both live in the same database file). + self._cached_sessions_index = _load_sessions_index() entries = self._cached_sessions_index for session_key, entry in entries.items(): -
tests/test_hermes_state.py@@ -2,6 +2,7 @@ import sqlite3 import time +import json import pytest import hermes_state @@ -4831,6 +4832,174 @@ def test_gateway_session_recovery_reopens_legacy_agent_close_rows(db): ) is None +def test_gateway_metadata_display_name_origin_round_trip(db): + """record_gateway_session_peer persists display_name/origin_json (#9006).""" + db.create_session("gw-meta", "telegram", user_id="u1") + origin = {"platform": "telegram", "chat_id": "c1", "chat_name": "Alice", "chat_type": "dm"} + db.record_gateway_session_peer( + "gw-meta", + source="telegram", + user_id="u1", + session_key="agent:main:telegram:dm:c1", + chat_id="c1", + chat_type="dm", + thread_id=None, + display_name="Alice", + origin_json=json.dumps(origin), + ) + row = db.get_session("gw-meta") + assert row["display_name"] == "Alice" + assert json.loads(row["origin_json"])["chat_name"] == "Alice" + + # None values must not clobber existing metadata. + db.record_gateway_session_peer( + "gw-meta", + source="telegram", + user_id="u1", + session_key="agent:main:telegram:dm:c1", + chat_id="c1", + chat_type="dm", + ) + row = db.get_session("gw-meta") + assert row["display_name"] == "Alice" + assert row["origin_json"] is not None + + +def test_set_expiry_finalized_round_trip(db): + db.create_session("gw-exp", "telegram", session_key="agent:main:telegram:dm:x") + row = db.get_session("gw-exp") + assert not row["expiry_finalized"] + db.set_expiry_finalized("gw-exp") + assert db.get_session("gw-exp")["expiry_finalized"] == 1 + db.set_expiry_finalized("gw-exp", False) + assert db.get_session("gw-exp")["expiry_finalized"] == 0 + + +def test_list_gateway_sessions_filters_and_dedupes(db): + # Two rows on the same session_key: only the newest should be returned. + db.create_session( + "gw-old", "telegram", + session_key="agent:main:telegram:dm:c1", chat_id="c1", chat_type="dm", + ) + db._conn.execute( + "UPDATE sessions SET started_at = started_at - 100 WHERE id = 'gw-old'" + ) + db._conn.commit() + db.create_session( + "gw-new", "telegram", + session_key="agent:main:telegram:dm:c1", chat_id="c1", chat_type="dm", + ) + db.create_session( + "gw-discord", "discord", + session_key="agent:main:discord:group:g1:u1", chat_id="g1", chat_type="group", + ) + # Non-gateway session (no session_key) must never appear. + db.create_session("cli-session", "cli") + # Ended gateway session excluded when active_only. + db.create_session( + "gw-ended", "slack", + session_key="agent:main:slack:dm:s1", chat_id="s1", chat_type="dm", + ) + db.end_session("gw-ended", "session_reset") + + rows = db.list_gateway_sessions(active_only=True) + ids = {r["id"] for r in rows} + assert ids == {"gw-new", "gw-discord"} + + tg_rows = db.list_gateway_sessions(platform="telegram", active_only=True) + assert [r["id"] for r in tg_rows] == ["gw-new"] + + all_rows = db.list_gateway_sessions(active_only=False) + assert "gw-ended" in {r["id"] for r in all_rows} + assert "cli-session" not in {r["id"] for r in all_rows} + + +def test_find_session_by_origin_matching_rules(db): + db.create_session( + "gw-o1", "telegram", user_id="u1", + session_key="agent:main:telegram:group:c9:u1", chat_id="c9", chat_type="group", + ) + db.create_session( + "gw-o2", "telegram", user_id="u2", + session_key="agent:main:telegram:group:c9:u2", chat_id="c9", chat_type="group", + ) + + # Exact user match wins. + assert db.find_session_by_origin( + platform="telegram", chat_id="c9", user_id="u2" + ) == "gw-o2" + # Unknown user among multiple distinct users -> None (no contamination). + assert db.find_session_by_origin( + platform="telegram", chat_id="c9", user_id="u3" + ) is None + # No user given + multiple distinct users -> None. + assert db.find_session_by_origin(platform="telegram", chat_id="c9") is None + # Ended sessions are ignored: only gw-o1 remains as a live candidate. + # A single remaining candidate is returned even without an exact user + # match — mirrors the original sessions.json scan semantics. + db.end_session("gw-o2", "session_reset") + assert db.find_session_by_origin( + platform="telegram", chat_id="c9", user_id="u2" + ) == "gw-o1" + # Single remaining candidate resolves without user_id. + assert db.find_session_by_origin(platform="telegram", chat_id="c9") == "gw-o1" + # Thread filter. + db.create_session( + "gw-th", "discord", user_id="u9", + session_key="agent:main:discord:thread:t7", chat_id="ch7", + chat_type="thread", thread_id="t7", + ) + assert db.find_session_by_origin( + platform="discord", chat_id="ch7", thread_id="t7" + ) == "gw-th" + assert db.find_session_by_origin( + platform="discord", chat_id="ch7", thread_id="other" + ) is None + + +def test_v18_backfill_from_sessions_json(tmp_path, monkeypatch): + """Migration backfills display_name/origin_json/expiry_finalized from sessions.json.""" + import hermes_state as hs + + home = tmp_path / ".hermes" + (home / "sessions").mkdir(parents=True) + monkeypatch.setenv("HERMES_HOME", str(home)) + monkeypatch.setattr(hs, "DEFAULT_DB_PATH", home / "state.db") + + # Seed a pre-v18 database: create schema, downgrade version, add a bare row. + db = hs.SessionDB(home / "state.db") + db.create_session("legacy-gw", "telegram", user_id="u1") + db._conn.execute("UPDATE schema_version SET version = 17") + db._conn.execute( + "UPDATE sessions SET session_key = NULL, display_name = NULL, " + "origin_json = NULL WHERE id = 'legacy-gw'" + ) + db._conn.commit() + db.close() + + origin = {"platform": "telegram", "chat_id": "123", "chat_name": "Alice", + "chat_type": "dm", "user_id": "u1"} + (home / "sessions" / "sessions.json").write_text(json.dumps({ + "_README": "sentinel", + "agent:main:telegram:dm:123": { + "session_id": "legacy-gw", + "display_name": "Alice", + "chat_type": "dm", + "expiry_finalized": True, + "origin": origin, + }, + })) + + db = hs.SessionDB(home / "state.db") + row = db.get_session("legacy-gw") + db.close() + assert row["session_key"] == "agent:main:telegram:dm:123" + assert row["display_name"] == "Alice" + assert row["chat_id"] == "123" + assert json.loads(row["origin_json"])["chat_name"] == "Alice" + assert row["expiry_finalized"] == 1 + + def test_compression_failure_cooldown_round_trips_and_clears(db): db.create_session("s1", "cli") -
tests/test_mcp_serve.py@@ -1232,18 +1232,19 @@ def get_messages(self, sid): assert len(r2["events"]) == 1 assert r2["events"][0]["content"] == "New reply!" - def test_poll_picks_up_new_conversation_when_only_sessions_json_changed( + def test_poll_picks_up_new_conversation_on_db_change( self, tmp_path, monkeypatch ): - """A conversation registered in sessions.json *after* its first message - already landed in state.db must still be picked up, even when state.db's - mtime did not move on this tick. - - Regression guard: the skip-when-unchanged check must compare against the - sessions.json mtime seen on the *previous* poll. If it instead compares - against the just-refreshed value, the sessions.json term is always true, - the guard collapses to a db-only check, and a sessions.json-only change - is silently dropped — the new chat's messages never reach MCP clients. + """A brand-new conversation must be picked up on the tick where + state.db changes. + + Since #9006 the routing index lives IN state.db (session rows carry + session_key/origin metadata), so a new conversation's registration and + its first message land in the same file — a single mtime check covers + both and the old dual-file (sessions.json + state.db) race (#8925) is + structurally impossible. This test asserts the index is refreshed on a + db-mtime bump, so a conversation the bridge has never seen before is + emitted on the same tick. """ import mcp_serve @@ -1257,14 +1258,20 @@ def test_poll_picks_up_new_conversation_when_only_sessions_json_changed( db_path.write_text("placeholder") session_id = "20260329_150000_late_register" - sessions_json = sessions_dir / "sessions.json" - sessions_json.write_text(json.dumps({ - "agent:main:telegram:dm:late": { - "session_id": session_id, - "platform": "telegram", - "origin": {"platform": "telegram", "chat_id": "late"}, - } - })) + # The routing index now comes from _load_sessions_index() (state.db + # primary, sessions.json fallback). Stub it to return the new + # conversation, simulating the gateway having just written the + # session row + first message in one state.db transaction. + monkeypatch.setattr( + mcp_serve, "_load_sessions_index", + lambda: { + "agent:main:telegram:dm:late": { + "session_id": session_id, + "platform": "telegram", + "origin": {"platform": "telegram", "chat_id": "late"}, + } + }, + ) class DB: def get_messages(self, sid): @@ -1275,12 +1282,11 @@ def get_messages(self, sid): }] bridge = mcp_serve.EventBridge() - # Simulate the boundary state: state.db has NOT changed since the last - # poll (its message landed on an earlier tick), but sessions.json was - # only updated with this conversation now — the bridge has not yet seen - # the current sessions.json content. - bridge._state_db_mtime = db_path.stat().st_mtime - bridge._sessions_json_mtime = 0.0 + # Bridge has never seen this db state (mtime differs) and has an + # empty cached index — exactly the state after a new conversation's + # first write. + bridge._state_db_mtime = 0.0 + assert bridge._cached_sessions_index == {} bridge._poll_once(DB())
-
4 PRs closed without merging
From the close comments: done elsewhere 3 · merged via another change 1
-
#34048 fix(compression): prevent orphan sessions when state.db write fails (#33906) · merged via another change →
#49772 -
#40061 fix(gateway): persist session origin metadata in state.db · done elsewhere →
747386e,#58899 -
#59581 fix(agent): preserve gateway metadata across compression rotation · done elsewhere →
d5b4879 -
#59870 fix(compression): preserve gateway origin on continuation · done elsewhere →
d5b4879
Closed issues · 4
- Closed#33906Context compression creates orphan sessions missing from state.db
- Closed#33907Context compression creates orphan sessions missing from state.db
- Closed#39260Bug: Gateway does not persist chat metadata (chat_id, chat_type, thread_id, parent_chat_id, chat_name, session_key) to state.db sessions
- Closed#59527Compression continuations lose gateway origin metadata, breaking `/sessions` and `/resume`
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.16: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the Lab