docs(sessions): clarify sessions.json is the gateway routing index, not the session list (#51726)

Users who inspect ~/.hermes/sessions/sessions.json see only gateway entries
(e.g. agent:main:whatsapp:dm:...) and mistake it for the session index that
hermes sessions list / /sessions read — which is actually state.db. Issue
#49361 reported CLI sessions as 'invisible' on this premise.

- gateway/session.py: write a self-documenting _README sentinel at the top of
  sessions.json explaining it's the gateway routing index and that ALL sessions
  (CLI/TUI/gateway) live in state.db; skip _-prefixed keys on load so the
  sentinel never round-trips into a SessionEntry.
- Harden every sessions.json reader against the sentinel: mcp_serve loader,
  gateway/mirror.py, gateway/channel_directory.py all skip _-prefixed keys.
- docs/user-guide/sessions.md: warning callout naming the exact symptom.
- tests: assert prune ignores metadata sentinels; add round-trip coverage.
This commit is contained in:
Teknium 2026-06-23 23:56:36 -07:00 committed by GitHub
parent 7ff48a6291
commit 0ef86febe2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 101 additions and 4 deletions

View file

@ -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

View file

@ -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()

View file

@ -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:<platform>:...) 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_"
)

View file

@ -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 {}

View file

@ -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"

View file

@ -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:<platform>:...`) 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