mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-26 11:12:03 +00:00
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:
parent
7ff48a6291
commit
0ef86febe2
6 changed files with 101 additions and 4 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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_"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue