From 67757285f696a4614b76e928dfd86c9f07ec5704 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 18 Sep 2026 22:17:59 -0700 Subject: [PATCH] feat(sessions): `hermes sessions repair-profiles` settles crossed-profile durable state The per-profile store model (#88734), the parent-inheritance fence (#88381), profile-stamped topic rows (#76423) and profile-prefixed voice keys (#75198) are all forward-only: they put NEW state under the right profile and refuse to widen existing damage, but nothing walks the stores and settles what earlier releases left crossed. #113884 found 246 sessions stranded that way and could only warn. `hermes sessions repair-profiles` scans every profile's state.db plus the gateway's voice-mode and sessions.json files and names six kinds of crossing: 1. `profile_name` disagreeing with the row's own session key -> relabel; 2. rows physically in another profile's store -> move (all message generations, usage rows, system prompt) to the owning store, parents before children so lineage survives, copy-then-delete so a crash leaves a duplicate the next run settles; 3. `parent_session_id` crossing namespaces -> sever (own identity kept); 4. routing rows outside the default store under multiplexing -> move (an existing row wins); routing rows for a profile that no longer exists -> drop; 5. Telegram topic bindings and voice-mode entries missing their bot's profile -> relabel from the sessions that hold the chat (ambiguous chats reported); 6. sessions.json mirror entries for an unclaimed namespace -> drop (the legacy import re-injects them into routing every boot). Report-only by default. `--apply` refuses while a gateway owns any store, takes a quick snapshot of every store first, and is idempotent. Two cases are reported but never guessed: rows keyed to a profile that does not exist, and `agent:main` rows inside a named profile's store (`--legacy-main rekey|move` says which of the two histories they are). Storage side lives in `hermes_state_profile_repair.py` (SessionDB mixin); orchestration across stores in `hermes_cli/sessions_repair_profiles.py`; the CLI face in `hermes_cli/sessions_cmd_repair_profiles.py` (pre-DB handler: it opens every store itself). Part of #88715 (PR-6). Closes the remediation gap #113884 only warns about. --- hermes_cli/sessions_cmd.py | 10 +- hermes_cli/sessions_cmd_repair_profiles.py | 109 +++++ hermes_cli/sessions_repair_profiles.py | 462 ++++++++++++++++++ hermes_cli/subcommands/sessions.py | 20 + hermes_state.py | 3 +- hermes_state_profile_repair.py | 338 +++++++++++++ .../test_sessions_repair_profiles.py | 236 +++++++++ website/docs/reference/cli-commands.md | 1 + website/docs/user-guide/sessions.md | 46 ++ 9 files changed, 1223 insertions(+), 2 deletions(-) create mode 100644 hermes_cli/sessions_cmd_repair_profiles.py create mode 100644 hermes_cli/sessions_repair_profiles.py create mode 100644 hermes_state_profile_repair.py create mode 100644 tests/hermes_cli/test_sessions_repair_profiles.py diff --git a/hermes_cli/sessions_cmd.py b/hermes_cli/sessions_cmd.py index 25e5d4f5db..ff11dcc26e 100644 --- a/hermes_cli/sessions_cmd.py +++ b/hermes_cli/sessions_cmd.py @@ -963,7 +963,15 @@ def _cmd_stats(db, args): # -- dispatch ----------------------------------------------------------------- -_PRE_DB_HANDLERS = {"repair": _cmd_repair, "recover": _cmd_recover, "import": _cmd_import} +def _cmd_repair_profiles(args): + from hermes_cli.sessions_cmd_repair_profiles import cmd_repair_profiles + return cmd_repair_profiles(args) + + +_PRE_DB_HANDLERS = { + "repair": _cmd_repair, "recover": _cmd_recover, "import": _cmd_import, + "repair-profiles": _cmd_repair_profiles, # opens every profile's store itself +} _OBSERVATIONAL_DB_ACTIONS = frozenset({"list", "stats", "pinned"}) _DB_HANDLERS = { "list": _cmd_list, "export": _cmd_export, "delete": _cmd_delete, "rename": _cmd_rename, "pinned": _cmd_pinned, diff --git a/hermes_cli/sessions_cmd_repair_profiles.py b/hermes_cli/sessions_cmd_repair_profiles.py new file mode 100644 index 0000000000..bc026dd13b --- /dev/null +++ b/hermes_cli/sessions_cmd_repair_profiles.py @@ -0,0 +1,109 @@ +"""``hermes sessions repair-profiles`` — the CLI face of :mod:`hermes_cli.sessions_repair_profiles`. + +Runs pre-DB (``_PRE_DB_HANDLERS``): it opens every profile's store itself rather than the one +ambient ``SessionDB()``. Report-only unless ``--apply``; ``--json`` for automation. +""" +from __future__ import annotations + +import json +import sys +from typing import Dict, List + +from hermes_cli.sessions_repair_profiles import ( + Finding, default_snapshot, enumerate_stores, live_gateway_homes, scan_stores, +) + +_KIND_LABELS = { + "mislabelled": "profile_name disagrees with the row's own session key", + "wrong_store": "rows in another profile's store", + "legacy_main": "legacy agent:main rows inside a named profile's store", + "unclaimed_namespace": "rows keyed to a profile that does not exist", + "crossed_parent": "parent_session_id crossing profile namespaces", + "routing_stray": "routing rows outside the default store", + "routing_unclaimed": "routing rows for a profile that does not exist", + "topic_profile_less": "Telegram topic bindings without their bot's profile", + "voice_profile_less": "voice-mode entries without their bot's profile", + "sessions_json_unclaimed": "sessions.json entries for a profile that does not exist", +} + + +def _group(findings: List[Finding]) -> Dict[str, List[Finding]]: + grouped: Dict[str, List[Finding]] = {} + for finding in findings: + grouped.setdefault(finding.kind, []).append(finding) + return grouped + + +def _print_report(findings: List[Finding]) -> None: + for kind, rows in _group(findings).items(): + print(f"\n{_KIND_LABELS.get(kind, kind)} ({len(rows)}):") + for finding in rows: + print(f" [{finding.store}] {finding.subject}\n {finding.detail}") + if finding.action: + print(f" → {finding.action}") + else: + print(f" ✗ not repaired — {finding.reason}") + + +def cmd_repair_profiles(args) -> int: + stores = enumerate_stores() + apply = bool(getattr(args, "apply", False)) + as_json = bool(getattr(args, "json", False)) + legacy_main = getattr(args, "legacy_main", None) or "report" + + if apply: + # Before any store is opened for write: a writable open runs schema init, and a live + # gateway holds the routing index in memory and would write it back over the repair. + live = live_gateway_homes(stores) + if live: + names = ", ".join(f"{profile} (pid {pid})" for profile, pid in live) + print(f"A gateway is running for: {names}. It holds the routing index in memory and would " + "write it back over this repair. Stop it (`hermes gateway stop`), then re-run --apply.", + file=sys.stderr) + return 1 + + plan, session = scan_stores(stores, legacy_main=legacy_main, read_only=not apply) + try: + findings = plan.findings + repairable = plan.repairable + if as_json and not apply: + print(json.dumps({"stores": [s.profile for s in stores], "findings": [f.as_dict() for f in findings], + "repairable": len(repairable)}, indent=2)) + return 0 + if not as_json: + print(f"Scanned {len(stores)} profile store(s): {', '.join(s.profile for s in stores)}") + if not findings: + print("✓ No crossed-profile state found.") + return 0 + _print_report(findings) + if not apply: + print(f"\n{len(repairable)} of {len(findings)} finding(s) can be repaired. " + "Re-run with --apply to perform them.") + return 0 + if not repairable: + if as_json: + print(json.dumps({"findings": [f.as_dict() for f in findings], "applied": {}}, indent=2)) + else: + print("\nNothing to repair.") + return 0 + + if not getattr(args, "yes", False) and not as_json: + from hermes_cli.sessions_cmd import _confirm_prompt + if not _confirm_prompt(f"\nApply {len(repairable)} repair(s)? A snapshot of every store is taken first. [y/N] "): + print("Aborted — nothing was changed.") + return 0 + result = plan.apply(session, snapshot=default_snapshot) + finally: + session.close() + + if as_json: + print(json.dumps({"findings": [f.as_dict() for f in findings], "applied": result}, indent=2)) + else: + for profile, snap in result["snapshots"].items(): + print(f" snapshot [{profile}]: {snap or 'FAILED'}") + applied = ", ".join(f"{k}={v}" for k, v in sorted(result["totals"].items()) if v) + print(f"\nApplied: {applied or 'nothing'}") + for failure in result["failures"]: + print(f" ✗ {failure['kind']} {failure['subject']}: {failure['error']}") + print("Re-run without --apply to confirm the stores are clean.") + return 1 if result["failures"] else 0 diff --git a/hermes_cli/sessions_repair_profiles.py b/hermes_cli/sessions_repair_profiles.py new file mode 100644 index 0000000000..d93df3665c --- /dev/null +++ b/hermes_cli/sessions_repair_profiles.py @@ -0,0 +1,462 @@ +"""``hermes sessions repair-profiles`` — settle crossed-profile durable state across every store. + +The per-profile store model (#88734) and the identity fences that followed are forward-only: they +put NEW rows in the right place and refuse to widen existing damage, but nothing walks the stores +and settles what an earlier release left crossed. This does, for six kinds of crossing: + +1. ``sessions.profile_name`` disagreeing with the profile in the row's own ``session_key``; +2. rows physically in the wrong store (a named profile's keys in the root ``state.db``, another + profile's keys in ``profiles/

/state.db``) — moved to the owning profile's store; +3. ``parent_session_id`` crossing namespaces — severed (the child's own identity is untouched); +4. ``gateway_routing`` rows in a named profile's store (the routing index lives in the gateway's + home; #66887 copied every profile's rows into whichever store was scoped) — moved to the routing + store when absent there, dropped otherwise — and root rows for a namespace no profile claims; +5. profile-less Telegram topic bindings and ``gateway_voice_mode.json`` entries owned by a + non-default bot — relabelled from the evidence of the rows themselves; +6. ``sessions.json`` mirror entries whose key namespace no served profile claims — dropped, since + the legacy import re-injects any key the routing DB lacks on every boot. + +Legacy ``agent:main:`` rows inside a named profile's store are reported but NOT repaired unless +``--legacy-main`` says how: they are either a standalone (pre-multiplex) gateway's own history that +must be rekeyed to ``agent:

:`` (the #113884 incident), or a default-profile chat that leaked in +under a scoped write (#102157) and must move to the root store. The row carries no evidence that +tells the two apart, and guessing wrong hands a conversation to the other bot. + +Report-only by default. ``--apply`` refuses while a gateway owning any touched store is live (it +holds the routing index in memory and writes it back), takes a quick snapshot of every store it +will mutate, and is idempotent: a second run finds nothing. +""" +from __future__ import annotations + +import contextlib +import json +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable, Dict, Iterable, List, Optional, Set, Tuple + +logger = logging.getLogger(__name__) + +_LEGACY_MAIN_CHOICES = ("report", "rekey", "move") +_VOICE_MODE_FILE = "gateway_voice_mode.json" + + +@dataclass(frozen=True) +class Store: + """One profile's ``state.db``; ``routing`` marks the store that owns the multiplexer's index.""" + profile: str + home: Path + routing: bool = False + + @property + def db_path(self) -> Path: + return self.home / "state.db" + + +@dataclass +class Finding: + kind: str + store: str + subject: str + detail: str + action: Optional[str] = None # what --apply does; None = report only + reason: Optional[str] = None # why it is report only + _fix: Optional[Callable[["_Session"], Dict[str, int]]] = field(default=None, repr=False, compare=False) + + @property + def repairable(self) -> bool: + return self._fix is not None + + def as_dict(self) -> Dict[str, Any]: + return {"kind": self.kind, "store": self.store, "subject": self.subject, "detail": self.detail, + "action": self.action, "reason": self.reason} + + +class _Session: + """Open store handles for one run; every store opened at most once, all released at the end.""" + + def __init__(self, *, read_only: bool) -> None: + self.read_only = read_only + self._dbs: Dict[Path, Any] = {} + + def db(self, store: Store): + """The store's SessionDB. Read-only runs never create a file: a live profile that has no + ``state.db`` yet reads as empty; an apply run opens it for real when a move lands there.""" + path = store.db_path + if path not in self._dbs: + if self.read_only: + from hermes_state import SessionDB + self._dbs[path] = SessionDB(path, read_only=True) if path.exists() else _EMPTY_STORE + else: + from hermes_state_registry import acquire + self._dbs[path] = acquire(path) + return self._dbs[path] + + def close(self) -> None: + from hermes_state_registry import release_or_close + for db in self._dbs.values(): + if db is _EMPTY_STORE: + continue + with contextlib.suppress(Exception): + release_or_close(db) if not self.read_only else db.close() + self._dbs.clear() + + +class _EmptyStore: + """Stand-in for a live profile whose ``state.db`` does not exist yet: nothing to find.""" + def find_crossed_profile_sessions(self, owner): + return {"mislabelled": [], "foreign": [], "crossed_parents": []} + + def find_profile_less_telegram_topic_rows(self): + return [] + + def list_gateway_routing_rows(self): + return [] + + def key_profiles_for_chat(self, platform, chat_id): + return set() + + +_EMPTY_STORE = _EmptyStore() + + +# ── enumeration ─────────────────────────────────────────────────────────────── + +def enumerate_stores() -> List[Store]: + """Default root plus every live named profile (a live profile claims its namespace whether or not + it has written a ``state.db`` yet). The root store owns the routing index: the multiplexer's + ``_routing_home`` is its launch home, the root.""" + from hermes_cli.profiles import _get_default_hermes_home, _iter_named_profile_dirs + root = _get_default_hermes_home() + stores = [Store("default", root, routing=True)] + stores.extend(Store(entry.name, entry) for entry in _iter_named_profile_dirs()) + return stores + + +def _gateway_multiplexes(root: Path) -> bool: + """Does the default gateway serve every profile? Same reader as every other CLI surface: the live + record, else the explicit flag, else False — an unset flag is a verdict only the gateway reaches, + and guessing "yes" would uproot a standalone gateway's own routing index.""" + from hermes_cli.gateway_multiplex_mode import default_gateway_multiplexes + try: + return default_gateway_multiplexes(root) + except Exception: + return False + + +def live_gateway_homes(stores: Iterable[Store]) -> List[Tuple[str, int]]: + from gateway.status import live_gateway_pid_for_home + live = [] + for store in stores: + pid = live_gateway_pid_for_home(store.home) + if pid is not None: + live.append((store.profile, pid)) + return live + + +# ── scan ────────────────────────────────────────────────────────────────────── + +class RepairPlan: + def __init__(self, stores: List[Store], *, legacy_main: str = "report") -> None: + if legacy_main not in _LEGACY_MAIN_CHOICES: + raise ValueError(f"legacy_main must be one of {_LEGACY_MAIN_CHOICES}") + self.stores = stores + self.by_profile = {s.profile: s for s in stores} + self.claimed: Set[str] = set(self.by_profile) + self.routing_store = next((s for s in stores if s.routing), None) + self.legacy_main = legacy_main + self.multiplexes = _gateway_multiplexes(self.routing_store.home) if self.routing_store else False + self.findings: List[Finding] = [] + self._moves: Dict[Tuple[Path, Path], "_MoveBatch"] = {} + + # -- helpers -- + def _add(self, finding: Finding) -> None: + self.findings.append(finding) + + @property + def repairable(self) -> List[Finding]: + return [f for f in self.findings if f.repairable] + + # -- scan -- + def scan(self, session: _Session) -> "RepairPlan": + for store in self.stores: + self._scan_sessions(session, store) + self._scan_topics(session, store) + for store in self.stores: + self._scan_routing(session, store) + if self.routing_store is not None: + self._scan_voice_modes(session, self.routing_store) + self._scan_sessions_json(self.routing_store) + return self + + def _scan_sessions(self, session: _Session, store: Store) -> None: + db = session.db(store) + crossed = db.find_crossed_profile_sessions(store.profile) + moving: Set[str] = set() + for row in crossed["foreign"]: + self._plan_foreign_row(store, row, moving) + for row in crossed["mislabelled"]: + if row["id"] in moving: + continue # the move stamps the right label + self._add(Finding( + "mislabelled", store.profile, row["id"], + f"profile_name={row['profile_name']!r} but key {row['session_key']!r} belongs to " + f"{row['key_profile']!r}", action=f"relabel to {row['key_profile']!r}", + _fix=lambda s, st=store, sid=row["id"]: { + "relabelled": s.db(st).relabel_sessions_to_key_profile([sid])})) + for row in crossed["crossed_parents"]: + if row["id"] in moving: + continue # the import drops a parent that is not in the target store + self._add(Finding( + "crossed_parent", store.profile, row["id"], + f"{row['key_profile']!r} row inherits from parent {row['parent_session_id']} keyed under " + f"{row['parent_profile']!r}", action="sever parent_session_id", + _fix=lambda s, st=store, sid=row["id"]: {"severed": s.db(st).sever_crossed_parents([sid])})) + + def _plan_foreign_row(self, store: Store, row: Dict[str, Any], moving: Set[str]) -> None: + key_profile, sid = row["key_profile"], row["id"] + detail = f"key {row['session_key']!r} ({row['message_count']} messages) sits in the {store.profile} store" + if key_profile == "default" and store.profile != "default": + self._plan_legacy_main_row(store, row, moving) + return + if key_profile not in self.claimed: + self._add(Finding( + "unclaimed_namespace", store.profile, sid, detail, + reason=f"no live profile named {key_profile!r}; create it, then rerun, or " + f"`hermes profile migrate-identity {key_profile} `")) + return + moving.add(sid) + self._add(Finding( + "wrong_store", store.profile, sid, detail, action=f"move to the {key_profile} store", + _fix=self._move_batch(store, self.by_profile[key_profile]).fix_for(sid))) + + def _plan_legacy_main_row(self, store: Store, row: Dict[str, Any], moving: Set[str]) -> None: + sid = row["id"] + detail = (f"legacy default-namespace key {row['session_key']!r} ({row['message_count']} messages) " + f"in the {store.profile} store") + if self.legacy_main == "rekey": + self._add(Finding( + "legacy_main", store.profile, sid, detail, action=f"rekey to agent:{store.profile}:", + _fix=lambda s, st=store, i=sid: {"rekeyed": s.db(st).rekey_legacy_main_sessions([i], st.profile)})) + elif self.legacy_main == "move" and self.routing_store is not None: + moving.add(sid) + self._add(Finding( + "legacy_main", store.profile, sid, detail, action="move to the default store", + _fix=self._move_batch(store, self.routing_store).fix_for(sid))) + else: + self._add(Finding( + "legacy_main", store.profile, sid, detail, + reason="either a standalone gateway's own history (--legacy-main rekey) or a default " + "chat that leaked in (--legacy-main move); the row cannot say which")) + + def _move_batch(self, src: Store, dst: Store) -> "_MoveBatch": + return self._moves.setdefault((src.db_path, dst.db_path), _MoveBatch(src, dst)) + + def _scan_topics(self, session: _Session, store: Store) -> None: + rows = session.db(store).find_profile_less_telegram_topic_rows() + for row in rows: + self._add(Finding( + "topic_profile_less", store.profile, f"chat {row['chat_id']} thread {row['thread_id']}", + f"telegram topic binding labelled 'default' but keyed {row['session_key']!r}", + action=f"relabel to {row['key_profile']!r}", + _fix=lambda s, st=store, r=row: s.db(st).relabel_telegram_topic_rows([r]))) + + def _scan_routing(self, session: _Session, store: Store) -> None: + from hermes_state_profile_repair import session_key_profile + rows = session.db(store).list_gateway_routing_rows() + for row in rows: + key_profile = session_key_profile(row["session_key"]) + if key_profile is None: + continue + subject = f"{row['session_key']} (scope {row['scope']!r})" + if store.routing: + if key_profile not in self.claimed: + self._add(Finding( + "routing_unclaimed", store.profile, subject, + f"routing row for a namespace no live profile claims ({key_profile!r}); every " + "inbound event on it logs a missing-profile error", action="delete", + _fix=lambda s, st=store, r=row: { + "routing_deleted": s.db(st).delete_gateway_routing_rows([(r["scope"], r["session_key"])])})) + continue + if key_profile == store.profile and not self.multiplexes: + continue # a standalone gateway's own index lives in its own store + routing_store = self.routing_store + if routing_store is None: + continue + self._add(Finding( + "routing_stray", store.profile, subject, + f"routing row in the {store.profile} store; the index lives in the default store", + action="move to the default store (existing row there wins)", + _fix=lambda s, st=store, r=row, rs=routing_store: self._move_routing_row(s, st, rs, r))) + + def _move_routing_row(self, session: _Session, store: Store, routing_store: Store, + row: Dict[str, Any]) -> Dict[str, int]: + adopted = session.db(routing_store).insert_gateway_routing_rows_if_absent( + [(row["scope"], row["session_key"], row["entry_json"], row["updated_at"])]) + deleted = session.db(store).delete_gateway_routing_rows([(row["scope"], row["session_key"])]) + return {"routing_adopted": adopted, "routing_deleted": deleted} + + def _scan_voice_modes(self, session: _Session, store: Store) -> None: + path = store.home / _VOICE_MODE_FILE + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return + if not isinstance(data, dict): + return + for key in sorted(k for k in data if isinstance(k, str) and k.count(":") == 1): + platform, chat_id = key.split(":", 1) + owners: Set[str] = set() + for st in self.stores: + owners |= session.db(st).key_profiles_for_chat(platform, chat_id) + if not owners or "default" in owners: + continue # the default bot speaks there: the unprefixed key is its own + if len(owners) > 1: + self._add(Finding( + "voice_profile_less", store.profile, key, + f"voice mode entry without a profile; chat is held by {sorted(owners)}", + reason="more than one non-default bot speaks in this chat")) + continue + owner = next(iter(owners)) + self._add(Finding( + "voice_profile_less", store.profile, key, + f"voice mode {data[key]!r} without a profile; chat is held only by {owner!r}", + action=f"rekey to {owner}:{key}", + _fix=lambda s, p=path, k=key, o=owner: _rekey_voice_mode_entry(p, k, o))) + + def _scan_sessions_json(self, store: Store) -> None: + from hermes_state_profile_repair import session_key_profile + path = store.home / "sessions" / "sessions.json" + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (FileNotFoundError, json.JSONDecodeError, OSError): + return + if not isinstance(data, dict): + return + for key in sorted(k for k in data if isinstance(k, str) and not k.startswith("_")): + key_profile = session_key_profile(key) + if key_profile is None or key_profile in self.claimed: + continue + self._add(Finding( + "sessions_json_unclaimed", store.profile, key, + f"sessions.json mirror entry for a namespace no live profile claims ({key_profile!r}); " + "the legacy import re-injects it into routing on every boot", action="delete entry", + _fix=lambda s, p=path, k=key: _drop_sessions_json_entry(p, k))) + + # -- apply -- + def apply(self, session: _Session, *, snapshot: Callable[[Store], Optional[str]]) -> Dict[str, Any]: + """Run every repairable fix; snapshots every store first. Fixes are independent and each + re-checks its precondition, so a partial run leaves a state the next run completes.""" + snapshots = {s.profile: snapshot(s) for s in self.stores if s.db_path.exists()} + totals: Dict[str, int] = {} + failures: List[Dict[str, str]] = [] + for finding in self.repairable: + fix = finding._fix + assert fix is not None + try: + for key, count in (fix(session) or {}).items(): + totals[key] = totals.get(key, 0) + int(count) + except Exception as exc: # one bad row must not abandon the rest of the plan + logger.warning("repair-profiles: %s on %s failed", finding.kind, finding.subject, exc_info=True) + failures.append({"kind": finding.kind, "subject": finding.subject, + "error": f"{type(exc).__name__}: {exc}"}) + return {"snapshots": snapshots, "totals": totals, "failures": failures} + + +class _MoveBatch: + """Every session moving from one store to another, moved together: export all, import all + (parents before children, so a moved child keeps its moved parent), then delete from the source. + Row by row, deleting a parent would detach the children still waiting in the source. Copy + precedes delete, so a crash between the two leaves a duplicate the next run settles + (``present`` on import, then the delete).""" + + def __init__(self, src: Store, dst: Store) -> None: + self.src, self.dst = src, dst + self.ids: List[str] = [] + self._result: Optional[Dict[str, Dict[str, int]]] = None + + def fix_for(self, sid: str) -> Callable[[_Session], Dict[str, int]]: + self.ids.append(sid) + return lambda session: self.run(session).get(sid, {"missing": 1}) + + def run(self, session: _Session) -> Dict[str, Dict[str, int]]: + if self._result is not None: + return self._result + src_db, dst_db = session.db(self.src), session.db(self.dst) + payloads = {sid: src_db.export_session_for_move(sid) for sid in self.ids} + ordered = _parents_first([p for p in payloads.values() if p is not None]) + result: Dict[str, Dict[str, int]] = {sid: {"missing": 1} for sid, p in payloads.items() if p is None} + for payload in ordered: + sid = payload["session"]["id"] + outcome = dst_db.import_moved_session(payload, profile_name=self.dst.profile) + if dst_db.count_messages_all(sid) < len(payload["messages"]): + logger.warning("repair-profiles: %s copied into %s with fewer messages than the source; " + "source row kept", sid, self.dst.profile) + result[sid] = {"copied_incomplete": 1} + continue + result[sid] = {outcome: 1} + for payload in ordered: + sid = payload["session"]["id"] + if "copied_incomplete" not in result[sid] and src_db.delete_moved_session(sid): + result[sid]["moved"] = 1 + self._result = result + return result + + +def _parents_first(payloads: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Order moved sessions so every parent in the batch precedes its children (a cycle, impossible + for a well-formed store, falls back to insertion order).""" + pending = {p["session"]["id"]: p for p in payloads} + ordered: List[Dict[str, Any]] = [] + while pending: + ready = [sid for sid, p in pending.items() if p["session"].get("parent_session_id") not in pending] + if not ready: + ready = list(pending) + for sid in ready: + ordered.append(pending.pop(sid)) + return ordered + + +def _rekey_voice_mode_entry(path: Path, key: str, owner: str) -> Dict[str, int]: + from utils import atomic_json_write + data = json.loads(path.read_text(encoding="utf-8")) + if key not in data: + return {} + data.setdefault(f"{owner}:{key}", data.pop(key)) + data.pop(key, None) + atomic_json_write(path, data) + return {"voice_rekeyed": 1} + + +def _drop_sessions_json_entry(path: Path, key: str) -> Dict[str, int]: + from utils import atomic_json_write + data = json.loads(path.read_text(encoding="utf-8")) + if key not in data: + return {} + del data[key] + atomic_json_write(path, data, mode=0o600) + return {"sessions_json_dropped": 1} + + +# ── entry points ────────────────────────────────────────────────────────────── + +def scan_stores(stores: Optional[List[Store]] = None, *, legacy_main: str = "report", + read_only: bool = True) -> Tuple[RepairPlan, _Session]: + """Open every store and build the plan. The caller owns the returned session (close it).""" + stores = enumerate_stores() if stores is None else stores + session = _Session(read_only=read_only) + try: + plan = RepairPlan(stores, legacy_main=legacy_main).scan(session) + except Exception: + session.close() + raise + return plan, session + + +def default_snapshot(store: Store) -> Optional[str]: + from hermes_cli.backup import create_quick_snapshot + try: + return create_quick_snapshot(label="repair-profiles", hermes_home=store.home) + except Exception as exc: + logger.warning("repair-profiles: snapshot of %s failed: %s", store.home, exc) + return None diff --git a/hermes_cli/subcommands/sessions.py b/hermes_cli/subcommands/sessions.py index e235c20a9e..9e8fc885c1 100644 --- a/hermes_cli/subcommands/sessions.py +++ b/hermes_cli/subcommands/sessions.py @@ -183,6 +183,26 @@ def build_sessions_parser(subparsers, *, cmd_sessions: Callable) -> None: "orphan's start for them to count as the same conversation " "(default: 900)") + sessions_repair_profiles = sessions_subparsers.add_parser( + "repair-profiles", help="Settle session/routing state that landed under the wrong profile", + description="Scan every profile's state.db (and the gateway's voice-mode / sessions.json " + "files) for durable state crossed between profiles: rows whose profile_name " + "disagrees with their session key, rows sitting in another profile's store, " + "parent links crossing namespaces, routing rows outside the default store or for a " + "profile that no longer exists, and Telegram topic / voice-mode entries missing " + "their bot's profile. Reports without touching anything unless --apply is given; " + "--apply refuses while a gateway is running, snapshots every store first, and is " + "safe to re-run.") + _flag(sessions_repair_profiles, "--apply", help="Perform the repairs (default: report only)") + _flag(sessions_repair_profiles, "--json", help="Machine-readable report") + _flag(sessions_repair_profiles, "--yes", "-y", default=False, help="Skip the confirmation prompt") + sessions_repair_profiles.add_argument( + "--legacy-main", choices=("report", "rekey", "move"), default="report", + help="What to do with agent:main rows found inside a named profile's store: 'rekey' them " + "to that profile (a standalone gateway's own history, e.g. after multiplexing was " + "switched on), 'move' them to the default store (a default chat that leaked in), or " + "'report' (default) — the rows themselves cannot tell the two cases apart") + sessions_recover = sessions_subparsers.add_parser( "recover", help="Rebuild canonical session data into a separate clean database", description="Offline, non-destructive recovery for a damaged state.db. The " diff --git a/hermes_state.py b/hermes_state.py index df1da67519..71b91c19f7 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -48,6 +48,7 @@ from hermes_state_sessions import SessionSessionsMixin from hermes_state_fts import SessionFtsSetupMixin, load_fts5_cjk_extension from hermes_state_portability import SessionPortabilityMixin from hermes_state_telegram import SessionTelegramTopicsMixin +from hermes_state_profile_repair import SessionProfileRepairMixin from hermes_state_schema import SessionSchemaMixin import hermes_state_holders as _state_holders import hermes_state_lockguard as _lockguard @@ -442,7 +443,7 @@ class SessionDB( SessionSessionsMixin, SessionFtsSetupMixin, SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin, SessionTelegramTopicsMixin, SessionCompressionMixin, SessionGatewayMixin, SessionMaintenanceMixin, SessionUsageMixin, SessionTitlesMixin, - SessionMessagesMixin, SessionRewindMixin, + SessionMessagesMixin, SessionRewindMixin, SessionProfileRepairMixin, ): """SQLite-backed session storage with FTS5 search; many reader threads, one writer (WAL).""" diff --git a/hermes_state_profile_repair.py b/hermes_state_profile_repair.py new file mode 100644 index 0000000000..34d88cd7be --- /dev/null +++ b/hermes_state_profile_repair.py @@ -0,0 +1,338 @@ +"""Crossed-profile durable state in ONE ``state.db`` — finders and fixers for +``hermes sessions repair-profiles`` (#88715 PR-6). + +Every ``state.db`` belongs to exactly one profile (``/state.db`` → ``default``, +``/profiles//state.db`` → ``name``) and every gateway session key encodes the profile +that owns the conversation (``agent:main:…`` for the default, ``agent::…`` for a named one). +The per-profile store model (#88734) is forward-only: it routes NEW writes to the right store but +never touched rows that had already landed in the wrong one, and the identity fences +(``_INHERIT_PARENT_META_SQL``, ``_recovered_row_allowed_for_active_profile``) only refuse to +*widen* damage that already exists. This module is the backward-looking half: it names each crossed +row and can settle it. + +Store-level only. Which profiles exist, which store owns which home, whether a live gateway holds the +routing index in memory, and the JSON files outside ``state.db`` are the orchestrator's concern +(``hermes_cli/sessions_repair_profiles.py``). + +Moving a session between two stores is two single-store transactions — copy into the target, then +delete from the source — because a crash between them leaves a *duplicate*, which the next run +settles idempotently (:meth:`import_moved_session` reports ``present``; the delete only proceeds when +the target holds at least as many messages). The reverse order would lose the row. +""" +from __future__ import annotations + +import logging +from typing import Any, Dict, Iterable, List, Optional, Set, Tuple + +from hermes_state_common import _id_chunks, _placeholders as _session_ids_placeholders + +logger = logging.getLogger(__name__) + + +def session_key_profile(session_key: Any) -> Optional[str]: + """Profile encoded in an ``agent::…`` gateway key (``default`` for ``main``), or None for a + keyless row (CLI/subagent lineage) or a key another producer minted (hosted rooms, tests).""" + if not isinstance(session_key, str): + return None + parts = session_key.split(":") + if len(parts) < 3 or parts[0] != "agent" or not parts[1]: + return None + from gateway.session import profile_from_session_key_namespace + return profile_from_session_key_namespace(parts[1]) + + +def _stored_profile(value: Any) -> Optional[str]: + """``sessions.profile_name`` as a comparable label; NULL/blank is "unowned", not a crossing.""" + name = str(value or "").strip() + return name or None + + +def _table_columns(conn, table: str) -> List[str]: + return [row[1] for row in conn.execute(f"PRAGMA table_info('{table}')")] + + +# Columns a moved message must NOT carry over verbatim: ``id`` is reassigned by the target's +# AUTOINCREMENT and ``display_order`` points at a message id of the SOURCE store — left NULL, the +# ``messages_display_order_insert`` trigger recomputes it from ``display_identity`` (a content hash, +# store-independent) or the new id. +_MESSAGE_MOVE_SKIP = frozenset({"id", "display_order"}) + + +class SessionProfileRepairMixin: + """Per-store crossed-profile identity: find, relabel, sever, move, and settle routing/topic rows.""" + + # ── finders ──────────────────────────────────────────────────────────────── + + def find_crossed_profile_sessions(self, owner: str) -> Dict[str, List[Dict[str, Any]]]: + """Keyed session rows whose identity disagrees with itself or with this store. + + ``mislabelled``: ``profile_name`` names a profile other than the one in the row's own key + (the key is what routing, the agent cache and ``_db_for_key`` consult; the label is stamped + after the fact). ``foreign``: the key's profile is not *owner* — the row sits in a store that + the routed profile never reads. ``crossed_parents``: child and parent are both keyed and name + different profiles, so the NULL-fill inheritance fence would have been the only thing standing + between them. A row can appear in more than one list. + """ + rows = self._read_all( + "SELECT s.id, s.session_key, s.profile_name, s.parent_session_id, s.message_count, " + " p.session_key AS parent_session_key " + "FROM sessions s LEFT JOIN sessions p ON p.id = s.parent_session_id " + "WHERE s.session_key LIKE 'agent:%' ORDER BY s.started_at, s.id") + found: Dict[str, List[Dict[str, Any]]] = {"mislabelled": [], "foreign": [], "crossed_parents": []} + for row in rows: + key_profile = session_key_profile(row["session_key"]) + if key_profile is None: + continue + label = _stored_profile(row["profile_name"]) + if label is not None and label != key_profile: + found["mislabelled"].append({ + "id": row["id"], "session_key": row["session_key"], + "profile_name": label, "key_profile": key_profile}) + if key_profile != owner: + found["foreign"].append({ + "id": row["id"], "session_key": row["session_key"], "key_profile": key_profile, + "message_count": int(row["message_count"] or 0)}) + parent_profile = session_key_profile(row["parent_session_key"]) + if parent_profile is not None and parent_profile != key_profile: + found["crossed_parents"].append({ + "id": row["id"], "key_profile": key_profile, + "parent_session_id": row["parent_session_id"], "parent_profile": parent_profile}) + return found + + def list_gateway_routing_rows(self) -> List[Dict[str, Any]]: + return [dict(row) for row in self._read_all( + "SELECT scope, session_key, entry_json, updated_at FROM gateway_routing " + "ORDER BY scope, session_key")] + + def find_profile_less_telegram_topic_rows(self) -> List[Dict[str, Any]]: + """``telegram_dm_topic_bindings`` rows labelled ``default`` whose ``session_key`` names a named + profile: written by a multiplexer that had not yet learned to stamp the routed profile + (#76423). Stores whose topic tables predate ``profile_name`` have nothing to relabel.""" + def _read(conn): + existing = {row[0] for row in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name = 'telegram_dm_topic_bindings'")} + if not existing or "profile_name" not in _table_columns(conn, "telegram_dm_topic_bindings"): + return [] + return [dict(row) for row in conn.execute( + "SELECT chat_id, thread_id, session_key FROM telegram_dm_topic_bindings " + "WHERE profile_name = 'default' ORDER BY chat_id, thread_id")] + out = [] + for row in self._read_retrying_ioerr(_read): + key_profile = session_key_profile(row["session_key"]) + if key_profile not in (None, "default"): + out.append({**row, "key_profile": key_profile}) + return out + + def count_messages_all(self, session_id: str) -> int: + row = self._read_one("SELECT COUNT(*) FROM messages WHERE session_id = ?", (session_id,)) + return int(row[0]) if row else 0 + + # ── in-store fixers ─────────────────────────────────────────────────────── + + def relabel_sessions_to_key_profile(self, ids: Iterable[str]) -> int: + """Set ``profile_name`` to the profile in each row's own key. Re-derives the target inside the + transaction (never trusts a stale report).""" + wanted = list(ids) + if not wanted: + return 0 + + def _do(conn) -> int: + changed = 0 + for chunk in _id_chunks(wanted): + rows = conn.execute( + f"SELECT id, session_key FROM sessions WHERE id IN ({_session_ids_placeholders(chunk)})", + chunk).fetchall() + for session_id, session_key in rows: + target = session_key_profile(session_key) + if target is None: + continue + changed += conn.execute( + "UPDATE sessions SET profile_name = ? WHERE id = ? AND profile_name IS NOT ?", + (target, session_id, target)).rowcount + return changed + return self._execute_write(_do) + + def sever_crossed_parents(self, ids: Iterable[str]) -> int: + """Detach children from a parent keyed under another profile. Only ``parent_session_id`` is + cleared — ``profile_name`` stays the row's own (relabelled separately when it is wrong) — and + only while the crossing still holds.""" + wanted = list(ids) + if not wanted: + return 0 + + def _do(conn) -> int: + severed = 0 + for chunk in _id_chunks(wanted): + rows = conn.execute( + "SELECT s.id, s.session_key, p.session_key FROM sessions s " + "JOIN sessions p ON p.id = s.parent_session_id " + f"WHERE s.id IN ({_session_ids_placeholders(chunk)})", chunk).fetchall() + for session_id, key, parent_key in rows: + mine, theirs = session_key_profile(key), session_key_profile(parent_key) + if mine is not None and theirs is not None and mine != theirs: + severed += conn.execute( + "UPDATE sessions SET parent_session_id = NULL WHERE id = ?", (session_id,)).rowcount + return severed + return self._execute_write(_do) + + # ── cross-store move ────────────────────────────────────────────────────── + + def export_session_for_move(self, session_id: str) -> Optional[Dict[str, Any]]: + """Everything the target store needs to hold *session_id* as its own: the row (every column), + the resolved system prompt, EVERY message row (inactive and compacted generations included — + a move is not an export) and its usage rows.""" + def _read(conn): + session = conn.execute("SELECT * FROM sessions WHERE id = ?", (session_id,)).fetchone() + if session is None: + return None + prompt = None + if session["system_prompt_hash"]: + row = conn.execute( + "SELECT prompt FROM system_prompts WHERE hash = ?", (session["system_prompt_hash"],)).fetchone() + prompt = row[0] if row else None + messages = [dict(r) for r in conn.execute( + "SELECT * FROM messages WHERE session_id = ? ORDER BY id", (session_id,))] + usage = [dict(r) for r in conn.execute( + "SELECT * FROM session_model_usage WHERE session_id = ?", (session_id,))] + return {"session": dict(session), "system_prompt": prompt, "messages": messages, "usage": usage} + return self._read_retrying_ioerr(_read) + + def import_moved_session(self, payload: Dict[str, Any], *, profile_name: str) -> str: + """Insert a moved session into THIS store as *profile_name*'s. ``present`` when the id already + exists (an earlier run copied but did not delete), else ``imported``. The parent link survives + only when the parent is already here — a moved row must never point across stores or at a + row of another profile. Columns the target schema lacks are dropped, never invented.""" + session = dict(payload["session"]) + session_id = session["id"] + + def _do(conn) -> str: + if conn.execute("SELECT 1 FROM sessions WHERE id = ?", (session_id,)).fetchone(): + return "present" + session["profile_name"] = profile_name + parent_id = session.get("parent_session_id") + if parent_id and conn.execute("SELECT 1 FROM sessions WHERE id = ?", (parent_id,)).fetchone() is None: + session["parent_session_id"] = None + session["system_prompt_hash"] = self._store_system_prompt(conn, payload.get("system_prompt")) + self._insert_row(conn, "sessions", session, skip=frozenset()) + for message in payload.get("messages") or []: + self._insert_row(conn, "messages", {**message, "session_id": session_id}, skip=_MESSAGE_MOVE_SKIP) + for usage in payload.get("usage") or []: + self._insert_row(conn, "session_model_usage", {**usage, "session_id": session_id}, skip=frozenset()) + return "imported" + return self._execute_write(_do) + + @staticmethod + def _insert_row(conn, table: str, values: Dict[str, Any], *, skip: frozenset) -> None: + columns = [c for c in _table_columns(conn, table) if c in values and c not in skip] + conn.execute( + f"INSERT INTO {table} ({', '.join(columns)}) VALUES ({', '.join('?' for _ in columns)})", + [values[c] for c in columns]) + + def delete_moved_session(self, session_id: str) -> bool: + """Remove a session this store no longer owns after the target confirmed it. Children left + behind are detached (same FK rule as :meth:`delete_session`); topic bindings on the row + cascade with it.""" + def _do(conn) -> bool: + if conn.execute("SELECT 1 FROM sessions WHERE id = ?", (session_id,)).fetchone() is None: + return False + conn.execute("UPDATE sessions SET parent_session_id = NULL WHERE parent_session_id = ?", (session_id,)) + conn.execute("DELETE FROM messages WHERE session_id = ?", (session_id,)) + conn.execute("DELETE FROM session_model_usage WHERE session_id = ?", (session_id,)) + conn.execute("DELETE FROM sessions WHERE id = ?", (session_id,)) + self._delete_unreferenced_system_prompts(conn) + return True + return bool(self._execute_write(_do)) + + # ── routing index ───────────────────────────────────────────────────────── + + def rekey_legacy_main_sessions(self, ids: Iterable[str], profile: str) -> int: + """``agent:main:…`` → ``agent::…`` (+ ``profile_name``) for rows a standalone + gateway wrote before this store's profile was multiplexed (#113884). Only rows still under + the legacy namespace change; a ``main``-named profile is written in its marked form.""" + from gateway.session import _session_key_namespace + wanted = list(ids) + if not wanted: + return 0 + new_ns = _session_key_namespace(profile) + ":" + + def _do(conn) -> int: + changed = 0 + for chunk in _id_chunks(wanted): + changed += conn.execute( + "UPDATE sessions SET session_key = ? || substr(session_key, 12), profile_name = ? " + f"WHERE id IN ({_session_ids_placeholders(chunk)}) AND substr(session_key, 1, 11) = 'agent:main:'", + (new_ns, profile, *chunk)).rowcount + return changed + return self._execute_write(_do) + + def delete_gateway_routing_rows(self, rows: Iterable[Tuple[str, str]]) -> int: + wanted = list(rows) + if not wanted: + return 0 + + def _do(conn) -> int: + return sum(conn.execute( + "DELETE FROM gateway_routing WHERE scope = ? AND session_key = ?", (scope, key)).rowcount + for scope, key in wanted) + return self._execute_write(_do) + + def insert_gateway_routing_rows_if_absent(self, rows: Iterable[Tuple[str, str, str, float]]) -> int: + """Adopt ``(scope, session_key, entry_json, updated_at)`` rows another store held for this + one's routing index. An existing key wins — the row the gateway actually loads stays.""" + wanted = list(rows) + if not wanted: + return 0 + + def _do(conn) -> int: + return sum(conn.execute( + "INSERT OR IGNORE INTO gateway_routing (scope, session_key, entry_json, updated_at) " + "VALUES (?, ?, ?, ?)", row).rowcount for row in wanted) + return self._execute_write(_do) + + # ── telegram topic tables ───────────────────────────────────────────────── + + def relabel_telegram_topic_rows(self, rows: Iterable[Dict[str, Any]]) -> Dict[str, int]: + """Stamp the key's profile onto ``default``-labelled bindings (and the chat's mode row when + only the default one exists). A binding that would collide with one the named profile already + wrote is the stale duplicate and is removed.""" + wanted = list(rows) + counts = {"bindings_relabelled": 0, "bindings_duplicates_removed": 0, "mode_rows_relabelled": 0} + if not wanted: + return counts + + def _do(conn) -> Dict[str, int]: + mode_has_profile = "profile_name" in _table_columns(conn, "telegram_dm_topic_mode") + for row in wanted: + target = session_key_profile(row["session_key"]) + if target in (None, "default"): + continue + chat_id, thread_id = row["chat_id"], row["thread_id"] + collides = conn.execute( + "SELECT 1 FROM telegram_dm_topic_bindings WHERE profile_name = ? AND chat_id = ? " + "AND thread_id = ?", (target, chat_id, thread_id)).fetchone() + if collides: + counts["bindings_duplicates_removed"] += conn.execute( + "DELETE FROM telegram_dm_topic_bindings WHERE profile_name = 'default' " + "AND chat_id = ? AND thread_id = ?", (chat_id, thread_id)).rowcount + else: + counts["bindings_relabelled"] += conn.execute( + "UPDATE telegram_dm_topic_bindings SET profile_name = ? WHERE profile_name = 'default' " + "AND chat_id = ? AND thread_id = ?", (target, chat_id, thread_id)).rowcount + if mode_has_profile and conn.execute( + "SELECT 1 FROM telegram_dm_topic_mode WHERE profile_name = ? AND chat_id = ?", + (target, chat_id)).fetchone() is None: + counts["mode_rows_relabelled"] += conn.execute( + "UPDATE telegram_dm_topic_mode SET profile_name = ? WHERE profile_name = 'default' " + "AND chat_id = ?", (target, chat_id)).rowcount + return counts + return self._execute_write(_do) + + # ── evidence for JSON-file repairs ──────────────────────────────────────── + + def key_profiles_for_chat(self, platform: str, chat_id: str) -> Set[str]: + """Profiles whose keyed sessions hold *(platform, chat_id)* in this store — the evidence for + who owns a profile-less ``gateway_voice_mode.json`` entry.""" + rows = self._read_all( + "SELECT DISTINCT session_key FROM sessions WHERE source = ? AND chat_id = ? " + "AND session_key LIKE 'agent:%'", (platform, str(chat_id))) + return {p for p in (session_key_profile(r["session_key"]) for r in rows) if p is not None} diff --git a/tests/hermes_cli/test_sessions_repair_profiles.py b/tests/hermes_cli/test_sessions_repair_profiles.py new file mode 100644 index 0000000000..c26d7917eb --- /dev/null +++ b/tests/hermes_cli/test_sessions_repair_profiles.py @@ -0,0 +1,236 @@ +"""``hermes sessions repair-profiles``: the backward-looking half of the per-profile store model. + +The forward-only fixes (#88734 store routing, the #88381 inheritance fence, #76423 topic labels, +#75198 voice keys) put NEW state in the right place; nothing settled what earlier releases left +crossed. One fixture carries all six kinds of crossing; the contracts are that a dry run mutates +nothing, that apply settles every repairable finding, and that a second apply finds nothing. +Part of #88715. +""" +from __future__ import annotations + +import json +import sqlite3 +from argparse import Namespace +from pathlib import Path + +import pytest + +from hermes_state import SessionDB + +ROOT_KEY = "agent:main:telegram:dm:100" +ACME_KEY = "agent:acme:telegram:dm:200" +BETA_KEY = "agent:beta:telegram:dm:300" +GHOST_KEY = "agent:ghost:telegram:dm:400" + + +@pytest.fixture +def homes(tmp_path, monkeypatch): + monkeypatch.setattr(Path, "home", lambda: tmp_path) + root = tmp_path / ".hermes" + root.mkdir() + monkeypatch.setenv("HERMES_HOME", str(root)) + from hermes_cli.profiles import create_profile + for name in ("acme", "beta"): + create_profile(name, no_alias=True, no_skills=True) + return {"default": root, "acme": root / "profiles" / "acme", "beta": root / "profiles" / "beta"} + + +def _session(db: SessionDB, sid: str, key: str, *, profile: str, parent=None, messages=2, chat_id=None, + source="telegram"): + db.create_session(sid, source, session_key=key, chat_id=chat_id or key.rsplit(":", 1)[-1], + chat_type="dm", profile_name=profile, parent_session_id=parent, system_prompt="sys") + for i in range(messages): + db.append_message(sid, "user" if i % 2 == 0 else "assistant", f"{sid} m{i}") + + +def _seed_crossings(homes): + """Every defect the command knows, spread over the three stores.""" + root = SessionDB(homes["default"] / "state.db") + acme = SessionDB(homes["acme"] / "state.db") + beta = SessionDB(homes["beta"] / "state.db") + scope = str((homes["default"] / "sessions").resolve()) + + # healthy controls — must survive untouched + _session(root, "ok-root", ROOT_KEY, profile="default") + _session(acme, "ok-acme", ACME_KEY, profile="acme") + root.save_gateway_routing_entry(ACME_KEY, json.dumps({"session_key": ACME_KEY, "session_id": "ok-acme"}), scope=scope) + + # 1. label ≠ key namespace (row in the right store) + _session(acme, "mislabelled", "agent:acme:telegram:dm:201", profile="default") + # 2. wrong store: acme's key in the root store, with a compressed parent it points at (moves whole) + _session(root, "stray-parent", "agent:acme:telegram:dm:202", profile="acme", messages=3) + _session(root, "stray-child", "agent:acme:telegram:dm:202", profile="acme", parent="stray-parent") + # …and a legacy agent:main row inside acme's store (reported, not repaired by default) + _session(acme, "legacy-main", "agent:main:telegram:dm:203", profile="default") + # …and a row keyed to a profile that does not exist + _session(root, "ghost", GHOST_KEY, profile="ghost") + # 3. parent crossing namespaces inside beta's store: beta's own child forked from an acme row + # (which is itself a wrong-store row and moves out — the child must not follow a pointer + # into another profile's store) + _session(beta, "beta-parent", BETA_KEY, profile="beta") + _session(beta, "acme-in-beta", "agent:acme:telegram:dm:204", profile="acme") + _session(beta, "beta-child", "agent:beta:telegram:dm:301", profile="beta", parent="acme-in-beta") + # 4. routing rows: beta's index row copied into beta's store (#66887) + a ghost row in the root + beta.save_gateway_routing_entry(BETA_KEY, json.dumps({"session_key": BETA_KEY, "session_id": "beta-parent"}), scope=scope) + root.save_gateway_routing_entry(GHOST_KEY, json.dumps({"session_key": GHOST_KEY, "session_id": "ghost"}), scope=scope) + # 5. profile-less topic binding + voice-mode entry for a chat only acme's bot holds + acme.bind_telegram_topic(chat_id="200", thread_id="7", user_id="200", session_key=ACME_KEY, + session_id="ok-acme", profile_name="default") + (homes["default"] / "gateway_voice_mode.json").write_text(json.dumps({ + "telegram:200": "all", # acme's chat, unprefixed + "telegram:100": "voice_only", # the default bot's chat — legitimately unprefixed + })) + # 6. sessions.json mirror entry for the ghost namespace + (homes["default"] / "sessions").mkdir(exist_ok=True) + (homes["default"] / "sessions" / "sessions.json").write_text(json.dumps({ + "_README": "x", GHOST_KEY: {"session_key": GHOST_KEY, "session_id": "ghost"}, + ROOT_KEY: {"session_key": ROOT_KEY, "session_id": "ok-root"}, + })) + for db in (root, acme, beta): + db.close() + + +def _dump(path: Path) -> dict: + """Every user table as sorted row tuples — the whole-file mutation oracle for the dry run.""" + conn = sqlite3.connect(path) + try: + tables = [r[0] for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' " + "AND name NOT LIKE '%_fts%' ORDER BY name")] + return {t: sorted(map(repr, conn.execute(f"SELECT * FROM {t}").fetchall())) for t in tables} + finally: + conn.close() + + +def _run(**kw) -> int: + from hermes_cli.sessions_cmd import cmd_sessions + args = Namespace(sessions_action="repair-profiles", apply=False, json=False, yes=True, legacy_main="report") + for k, v in kw.items(): + setattr(args, k, v) + return cmd_sessions(args) or 0 + + +def _report() -> dict: + import io + import contextlib + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + assert _run(json=True) == 0 + return json.loads(buf.getvalue()) + + +def test_dry_run_names_every_crossing_and_changes_nothing(homes, capsys): + _seed_crossings(homes) + before = {name: _dump(home / "state.db") for name, home in homes.items()} + files_before = {p.name: p.read_text() for p in (homes["default"] / "gateway_voice_mode.json", + homes["default"] / "sessions" / "sessions.json")} + + report = _report() + + kinds = {(f["kind"], f["subject"]) for f in report["findings"]} + assert { + ("mislabelled", "mislabelled"), + ("wrong_store", "stray-parent"), ("wrong_store", "stray-child"), + ("legacy_main", "legacy-main"), + ("unclaimed_namespace", "ghost"), + ("wrong_store", "acme-in-beta"), ("crossed_parent", "beta-child"), + ("routing_unclaimed", f"{GHOST_KEY} (scope {str((homes['default'] / 'sessions').resolve())!r})"), + ("topic_profile_less", "chat 200 thread 7"), + ("voice_profile_less", "telegram:200"), + ("sessions_json_unclaimed", GHOST_KEY), + } <= kinds + # beta's own routing row: a standalone gateway's index lives in its own store; nothing recorded a + # multiplexing verdict here, so it is NOT reported as stray. + assert not any(f["kind"] == "routing_stray" for f in report["findings"]) + # the healthy controls and the default bot's own voice key are not findings + assert not any(f["subject"] in {"ok-root", "ok-acme", "telegram:100", ROOT_KEY} for f in report["findings"]) + # the two report-only kinds say why + by_kind = {f["kind"]: f for f in report["findings"]} + assert by_kind["legacy_main"]["action"] is None and "--legacy-main" in by_kind["legacy_main"]["reason"] + assert by_kind["unclaimed_namespace"]["action"] is None and "ghost" in by_kind["unclaimed_namespace"]["reason"] + + assert {name: _dump(home / "state.db") for name, home in homes.items()} == before + assert {p.name: p.read_text() for p in (homes["default"] / "gateway_voice_mode.json", + homes["default"] / "sessions" / "sessions.json")} == files_before + + +def test_apply_settles_every_repairable_crossing_and_is_idempotent(homes, monkeypatch, capsys): + _seed_crossings(homes) + snapshots = [] + monkeypatch.setattr("hermes_cli.sessions_cmd_repair_profiles.default_snapshot", + lambda store: snapshots.append(store.profile) or f"snap-{store.profile}") + + assert _run(apply=True) == 0 + assert set(snapshots) == {"default", "acme", "beta"} + + root = SessionDB(homes["default"] / "state.db") + acme = SessionDB(homes["acme"] / "state.db") + beta = SessionDB(homes["beta"] / "state.db") + try: + # 1. relabelled from the key + assert acme.get_session("mislabelled")["profile_name"] == "acme" + # 2. moved whole: parent link and every message intact in the target, gone from the source + assert root.get_session("stray-parent") is None and root.get_session("stray-child") is None + moved_child = acme.get_session("stray-child") + assert moved_child["profile_name"] == "acme" and moved_child["parent_session_id"] == "stray-parent" + assert len(acme.get_messages("stray-parent")) == 3 and len(acme.get_messages("stray-child")) == 2 + assert acme.get_session("stray-parent")["system_prompt"] == "sys" + # legacy agent:main row and the ghost row are left exactly as they were + assert acme.get_session("legacy-main")["session_key"] == "agent:main:telegram:dm:203" + assert root.get_session("ghost")["session_key"] == GHOST_KEY + # 3. severed, own identity kept; the acme row it pointed at moved to acme's store + child = beta.get_session("beta-child") + assert child["parent_session_id"] is None and child["profile_name"] == "beta" + assert beta.get_session("acme-in-beta") is None and acme.get_session("acme-in-beta") is not None + # 4. ghost routing row dropped, acme's healthy row kept, beta's own row kept (standalone) + scope = str((homes["default"] / "sessions").resolve()) + assert set(root.load_gateway_routing_entries(scope=scope)) == {ACME_KEY} + assert set(beta.load_gateway_routing_entries(scope=scope)) == {BETA_KEY} + # 5. topic binding carries acme; voice key prefixed only for acme's chat + assert acme.get_telegram_topic_binding(chat_id="200", thread_id="7", profile_name="acme") is not None + assert acme.get_telegram_topic_binding(chat_id="200", thread_id="7", profile_name="default") is None + voice = json.loads((homes["default"] / "gateway_voice_mode.json").read_text()) + assert voice == {"acme:telegram:200": "all", "telegram:100": "voice_only"} + # 6. ghost mirror entry dropped, the default's kept + mirror = json.loads((homes["default"] / "sessions" / "sessions.json").read_text()) + assert set(mirror) == {"_README", ROOT_KEY} + # controls untouched + assert root.get_session("ok-root")["profile_name"] == "default" + assert acme.get_session("ok-acme")["profile_name"] == "acme" + finally: + for db in (root, acme, beta): + db.close() + + # idempotent: only the two report-only findings remain, and nothing is repairable + report = _report() + assert {f["kind"] for f in report["findings"]} == {"legacy_main", "unclaimed_namespace"} + assert report["repairable"] == 0 + + +def test_apply_refuses_while_a_gateway_owns_a_store(homes, monkeypatch, capsys): + _seed_crossings(homes) + monkeypatch.setattr("hermes_cli.sessions_cmd_repair_profiles.live_gateway_homes", + lambda stores: [("default", 4242)]) + before = _dump(homes["default"] / "state.db") + + assert _run(apply=True) == 1 + + assert "pid 4242" in capsys.readouterr().err + assert _dump(homes["default"] / "state.db") == before + + +def test_legacy_main_rekey_adopts_a_standalone_gateways_history(homes, monkeypatch): + """The #113884 incident: multiplexing switched on, every existing key of the named profile's own + gateway stopped resolving. ``--legacy-main rekey`` gives them the namespace the multiplexer reads.""" + _seed_crossings(homes) + monkeypatch.setattr("hermes_cli.sessions_cmd_repair_profiles.default_snapshot", lambda store: "snap") + + assert _run(apply=True, legacy_main="rekey") == 0 + + acme = SessionDB(homes["acme"] / "state.db") + try: + row = acme.get_session("legacy-main") + assert row["session_key"] == "agent:acme:telegram:dm:203" and row["profile_name"] == "acme" + finally: + acme.close() + assert not any(f["kind"] == "legacy_main" for f in _report()["findings"]) diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index f9a9089cf8..b4f012322c 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -1684,6 +1684,7 @@ Subcommands: | `optimize-storage` | Migrate the full-text search index to the compact v23 external-content layout; on large databases this reclaims a large fraction of `state.db`. | | `repair` | Repair a malformed `state.db` schema (e.g. `table messages_fts already exists`) so hidden sessions reappear; a backup is made first. | | `repair-routing` | Re-attach gateway conversations stranded in session rows that lost their routing identity (a chat "jumping back in time" after a restart). Dry-run by default; `--apply` performs the adoptions (stop the gateway first); `--max-gap-seconds N` tunes the contiguity window. Only unambiguous cases are repaired. See [Sessions → Repair Stranded Gateway Sessions](../user-guide/sessions.md#repair-stranded-gateway-sessions). | +| `repair-profiles` | Settle session, routing, Telegram-topic and voice-mode state that landed under the wrong profile (rows in another profile's store, labels disagreeing with the session key, parent links crossing profiles, index rows for deleted profiles). Dry-run by default; `--apply` performs the repairs after snapshotting every store (stop the gateway first); `--legacy-main rekey\|move` decides what `agent:main` rows inside a named profile's store are; `--json` for automation. See [Sessions → Repair State Crossed Between Profiles](../user-guide/sessions.md#repair-state-crossed-between-profiles). | | `recover` | Offline, non-destructive recovery of a damaged `state.db` into a separate clean database. | | `retitle-skills` | Regenerate titles for sessions opened with a `/skill`, using what the user actually typed; lists changes unless `--apply` is passed. | diff --git a/website/docs/user-guide/sessions.md b/website/docs/user-guide/sessions.md index d9f89fd0dc..d199aaf7a2 100644 --- a/website/docs/user-guide/sessions.md +++ b/website/docs/user-guide/sessions.md @@ -650,6 +650,52 @@ conversation stays readable via `/resume` and session search either way — routing is the only thing the repair changes. Back up first (`cp ~/.hermes/state.db ~/.hermes/state.db.bak`). +### Repair State Crossed Between Profiles + +Every profile owns one `state.db`, and every gateway session key names the +profile that owns the conversation (`agent:main:…` for the default profile, +`agent::…` for a named one). Older releases could leave the two +disagreeing — a named profile's rows written into the default store, a child +session inheriting from another profile's row, a routing row copied into the +wrong store, a Telegram topic or `/voice` setting saved without the bot's +profile. Current versions put new state in the right place; `hermes sessions +repair-profiles` settles what is already crossed. + +```bash +# Report only — every store is scanned, nothing is written +hermes sessions repair-profiles + +# Perform the repairs (stop the gateway first; a snapshot of every store is taken) +hermes sessions repair-profiles --apply + +# Machine-readable report +hermes sessions repair-profiles --json +``` + +What it finds and does: + +| Finding | Repair | +|---|---| +| `profile_name` disagrees with the row's own session key | relabel from the key | +| rows sitting in another profile's store | move (with all messages) to the owning profile's store | +| `parent_session_id` pointing at another profile's row | sever the link; the row's own identity is kept | +| routing rows outside the default store (under multiplexing) | move to the default store; an existing row there wins | +| routing rows / `sessions.json` entries for a profile that no longer exists | delete | +| Telegram topic bindings and voice-mode entries missing their bot's profile | relabel from the sessions that hold the chat | + +Two cases are reported but never repaired without being told what they are: +rows keyed to a profile that does not exist (create the profile, or +`hermes profile migrate-identity `), and `agent:main:…` rows inside +a named profile's store. The latter are either the history of a gateway that +used to run standalone for that profile (`--legacy-main rekey` gives them the +profile's namespace) or default-profile chats that leaked in under a scoped +write (`--legacy-main move` sends them to the default store) — the rows +themselves cannot tell the two apart. + +`--apply` refuses while a gateway owns any of the stores (it holds the routing +index in memory and would write it back), and is safe to re-run: a second run +finds nothing. + ## Importing Sessions from Claude Code and Codex CLI