Files
hermes-agent/hermes_state_profile_repair.py
teknium1 67757285f6 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.
2026-09-18 22:36:41 -07:00

339 lines
19 KiB
Python

"""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 (``<root>/state.db`` → ``default``,
``<root>/profiles/<name>/state.db`` → ``name``) and every gateway session key encodes the profile
that owns the conversation (``agent:main:…`` for the default, ``agent:<name>:…`` 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:<ns>:…`` 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>:…`` (+ ``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}