diff --git a/gateway/channel_directory.py b/gateway/channel_directory.py index fba3d58d51a..4a1bc151c26 100644 --- a/gateway/channel_directory.py +++ b/gateway/channel_directory.py @@ -275,6 +275,10 @@ def _build_from_sessions(platform_name: str) -> List[Dict[str, str]]: seen_ids = set() for _key, session in data.items(): + # Skip documentation/metadata sentinels (keys starting with "_", + # e.g. the gateway's "_README" note) — not session entries. + if str(_key).startswith("_") or not isinstance(session, dict): + continue origin = session.get("origin") or {} if origin.get("platform") != platform_name: continue diff --git a/gateway/mirror.py b/gateway/mirror.py index 71a3d313d32..b90a421b9c3 100644 --- a/gateway/mirror.py +++ b/gateway/mirror.py @@ -111,6 +111,10 @@ def _find_session_id( candidates = [] for _key, entry in data.items(): + # Skip documentation/metadata sentinels (keys starting with "_", e.g. + # the gateway's "_README" note) — they are not session entries. + if str(_key).startswith("_") or not isinstance(entry, dict): + continue origin = entry.get("origin") or {} entry_platform = (origin.get("platform") or entry.get("platform", "")).lower() diff --git a/gateway/session.py b/gateway/session.py index 68df8f2955d..beabc295cee 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -809,6 +809,11 @@ class SessionStore: with open(sessions_file, "r", encoding="utf-8") as f: data = json.load(f) for key, entry_data in data.items(): + # Keys starting with "_" are documentation/metadata sentinels + # (e.g. the "_README" note written by _save), not session + # entries. Skip them so they never reach SessionEntry.from_dict. + if key.startswith("_"): + continue try: self._entries[key] = SessionEntry.from_dict(entry_data) except (ValueError, KeyError) as e: @@ -825,6 +830,22 @@ class SessionStore: sessions_file = self.sessions_dir / "sessions.json" data = {key: entry.to_dict() for key, entry in self._entries.items()} + # Self-documenting sentinel so anyone who inspects this file directly + # understands what it is and where CLI/TUI sessions actually live. Keys + # starting with "_" are skipped on load (see _ensure_loaded_locked), so + # this never round-trips into a SessionEntry. Ordered first via a fresh + # dict so it renders at the top of the pretty-printed JSON. + data = { + "_README": ( + "Gateway routing index ONLY: maps messaging session keys " + "(agent:main::...) to active session IDs. This is NOT " + "the session list. ALL sessions (CLI, TUI, and gateway) live in " + "~/.hermes/state.db and are shown by `hermes sessions list` and " + "`/sessions`. Seeing only gateway entries here is expected and " + "does not mean CLI sessions are missing." + ), + **data, + } fd, tmp_path = tempfile.mkstemp( dir=str(self.sessions_dir), suffix=".tmp", prefix=".sessions_" ) diff --git a/mcp_serve.py b/mcp_serve.py index 5ae0261d9af..fdbe6d7b571 100644 --- a/mcp_serve.py +++ b/mcp_serve.py @@ -89,7 +89,14 @@ def _load_sessions_index() -> dict: return {} try: with open(sessions_file, "r", encoding="utf-8") as f: - return json.load(f) + data = json.load(f) + # Drop documentation/metadata sentinels (keys starting with "_", e.g. + # the "_README" note the gateway writes into the index). They are not + # session entries and would break consumers that treat every value as + # an entry dict. + if isinstance(data, dict): + return {k: v for k, v in data.items() if not str(k).startswith("_")} + return {} except Exception as e: logger.debug("Failed to load sessions.json: %s", e) return {} diff --git a/tests/gateway/test_session_store_prune.py b/tests/gateway/test_session_store_prune.py index d6af52edf45..b331d305e37 100644 --- a/tests/gateway/test_session_store_prune.py +++ b/tests/gateway/test_session_store_prune.py @@ -236,14 +236,15 @@ class TestPrunePersistsToDisk: store._loaded = True store._save() - # Verify pre-prune state on disk. + # Verify pre-prune state on disk. Filter out metadata sentinels + # (e.g. the "_README" note) so we assert on session keys only. saved_pre = json.loads((tmp_path / "sessions.json").read_text()) - assert set(saved_pre.keys()) == {"stale", "fresh"} + assert {k for k in saved_pre if not k.startswith("_")} == {"stale", "fresh"} # Prune and check disk. store.prune_old_entries(max_age_days=90) saved_post = json.loads((tmp_path / "sessions.json").read_text()) - assert set(saved_post.keys()) == {"fresh"} + assert {k for k in saved_post if not k.startswith("_")} == {"fresh"} class TestGatewayConfigSerialization: @@ -296,3 +297,46 @@ class TestGatewayWatcherCallsPrune: should_prune = (now - last_ts) > prune_interval assert should_prune is False + + +class TestReadmeSentinel: + """The gateway writes a self-documenting ``_README`` key into sessions.json + so users who inspect the file directly understand it's the gateway routing + index (not the session list). It must never round-trip into a SessionEntry, + and real entries must survive a save/load cycle alongside it (#49361).""" + + def test_save_writes_readme_sentinel_first(self, tmp_path): + store = _make_store(tmp_path) + store._entries["agent:main:whatsapp:dm:99"] = _entry( + "agent:main:whatsapp:dm:99", age_days=1 + ) + store._save() + + raw = json.loads((tmp_path / "sessions.json").read_text()) + assert "_README" in raw + # Sentinel renders first so it's the first thing a user sees on `cat`. + assert next(iter(raw)) == "_README" + # The note points users at the real store and command. + assert "state.db" in raw["_README"] + assert "hermes sessions list" in raw["_README"] + + def test_readme_sentinel_skipped_on_load(self, tmp_path): + # Write an index containing both the sentinel and a real entry. + store = _make_store(tmp_path) + store._entries["agent:main:whatsapp:dm:99"] = _entry( + "agent:main:whatsapp:dm:99", age_days=1, session_id="sid_wa" + ) + store._save() + + # Fresh store loads from disk for real (no _ensure_loaded patch). + config = GatewayConfig( + default_reset_policy=SessionResetPolicy(mode="none"), + session_store_max_age_days=90, + ) + reloaded = SessionStore(sessions_dir=tmp_path, config=config) + reloaded._ensure_loaded() + + # Sentinel never becomes a SessionEntry; the real entry survives intact. + assert not any(k.startswith("_") for k in reloaded._entries) + assert "agent:main:whatsapp:dm:99" in reloaded._entries + assert reloaded._entries["agent:main:whatsapp:dm:99"].session_id == "sid_wa" diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index fa55080c2e3..b1d6c903c4a 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -494,6 +494,23 @@ Sessions with **active background processes** are never auto-reset, regardless o The SQLite database uses WAL mode for concurrent readers and a single writer, which suits the gateway's multi-platform architecture well. +:::warning `sessions.json` is not the session list +`~/.hermes/sessions/sessions.json` is the **gateway routing index** — it maps +messaging session keys (`agent:main::...`) to active session IDs. +It only ever contains gateway/messaging entries, so if you run a messaging +platform you'll see only those (e.g. `agent:main:whatsapp:dm:...`). + +This is **expected** and does **not** mean your CLI sessions are missing. +`hermes sessions list`, `/sessions`, and the dashboard all read `state.db`, +which holds **every** session (CLI, TUI, and gateway). The `/save` snapshots +under `~/.hermes/sessions/saved/*.json` are convenience exports, not the index. + +If CLI sessions genuinely don't appear in `hermes sessions list`, the cause is +`state.db` not receiving them — run `hermes sessions repair` and watch for a +`⚠ Session store unavailable` warning at CLI startup, which means SQLite +persistence failed for that run. +::: + :::note Legacy JSONL transcripts Sessions created before state.db became canonical may have leftover `*.jsonl` files in `~/.hermes/sessions/`. They are no longer written or