fix(profiles): purge a deleted profile's session/routing identity on delete

`hermes profile delete` removes the profile directory and tears its runtime down, but the name is
also baked into durable identity the delete path never touches — `agent:<name>:*` routing keys,
`gateway_heartbeats.profile` and `delivery_obligations`. An inbound event on a chat keyed to the
dead name then enters the routing index, resolves a profile whose directory is gone, and logs
`Profile '<name>' does not exist` on every event for the life of the store (the #111926 flood,
reached from a *deleted* rather than a renamed profile). The delete side is now symmetric with the
rename rekey (`rekey_profile_state` / `rekey_profile_routing` / `migrate-profile-identity`), with
the same ownership rule:

- `SessionDB.purge_profile_state(name)` — the mirror of `rekey_profile_state`, in one
  `_execute_write` transaction. Routing keys, heartbeat rows and the telegram topic rows the rekey
  also owns are hard-deleted (a binding is matched by `profile_name` OR its `session_key`
  namespace, because the rename rewrites both); `delivery_obligations` rows are terminalized
  (`state='abandoned'`) rather than dropped, so pending delivery state is not lost silently.
- `SessionStore.purge_profile_routing(name)` — the mirror of `rekey_profile_routing`: drops the
  in-memory entries and persists the drop. Mandatory, not belt-and-braces — the owning process
  writes its in-memory copy back, so a durable delete made elsewhere is undone by its next save.
- A delete-only control verb `purge-profile-identity`, deliberately NOT inside
  `_unserve_profile()`: that hook also unserves a rename's old name, whose identity the rekey still
  has to migrate. `hermes profile delete` requires the owner's `{"ok": true}` answer and reports a
  partial settlement (naming the retry) instead of a clean success.
- The retry is the new `hermes profile purge-identity <name>`. It refuses a name that is a live
  profile again: the purge keys off the name alone, so `delete foo` (settlement pending) →
  `create foo` → `purge-identity foo` would otherwise delete the NEW incarnation's identity. The
  delete path tombstones the directory before it purges, so the guard never blocks the delete.
- `sessions` rows are not deleted by the purge: it settles identity, not history. What a delete
  leaves of a profile's conversation record is `delete_profile`'s business — it removes the
  profile's own home, `state.db` included.

Tests (`scripts/run_tests.sh`, red on base → green): `tests/hermes_state/test_purge_profile_state.py`,
`tests/gateway/test_purge_profile_routing.py`, `tests/gateway/test_profile_identity_purge.py`,
`tests/hermes_cli/test_profile_identity_purge_cmd.py` and `TestDeleteProfile` in
`tests/hermes_cli/test_profiles.py` — 95 passed, 0 failed across those five files.
This commit is contained in:
xielevi
2026-09-16 17:01:13 +08:00
committed by Teknium
parent c89a1cbc11
commit a41552fad4
16 changed files with 842 additions and 6 deletions

View File

@@ -653,6 +653,69 @@ class SessionGatewayMixin:
self._execute_write(_do)
return counts
def purge_profile_state(self, profile: str) -> Dict[str, int]:
"""Delete exact profile identity from this state database (#111926, delete side).
The mirror of :meth:`rekey_profile_state`: a rename must rekey a profile's identity, a
delete must purge it. ``agent:<name>:*`` routing keys, ``gateway_heartbeats.profile``,
``delivery_obligations`` and the telegram topic tables are bookkeeping for a profile that
no longer exists — left behind, every inbound event on a chat keyed to the deleted name
enters the routing index, resolves a profile whose directory is gone, and logs
``Profile '<name>' does not exist`` on each event for the life of the store.
What each store gets, and why:
* Routing keys and heartbeat rows are hard-deleted — pure bookkeeping for a dead name.
* ``delivery_obligations`` rows are **terminalized** (``state='abandoned'``), not deleted:
a pending obligation is delivery state that should not vanish silently, and the ledger's
own retention prunes abandoned rows. Delivered history is left as it was.
* ``sessions`` rows are not deleted here: this helper settles identity, not history, and it
does not decide what a delete leaves of a profile's conversation record — ``delete_profile``
removes the profile's own home, ``state.db`` included, with the directory. Rows in a shared
store keep whatever ownership they had; re-binding or archiving them belongs to the flow
that recreates the name, not to this purge.
Idempotent.
"""
name = (profile or "").strip()
counts: Dict[str, int] = {}
if not name:
return counts
ns, ns_len = f"agent:{name}:", len(f"agent:{name}:")
def _do(conn):
existing = {row[0] for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'").fetchall()}
if "gateway_routing" in existing:
counts["gateway_routing"] = conn.execute(
"DELETE FROM gateway_routing WHERE substr(session_key, 1, ?) = ?",
(ns_len, ns)).rowcount
if "gateway_heartbeats" in existing:
counts["gateway_heartbeats"] = conn.execute(
"DELETE FROM gateway_heartbeats WHERE profile = ?", (name,)).rowcount
if "delivery_obligations" in existing:
# Terminalize, never hard-delete: a pending obligation is delivery state someone may
# still care about, and the ledger's own retention prunes abandoned rows. Only
# non-terminal rows are touched — delivered history is left exactly as it was.
counts["delivery_obligations"] = conn.execute(
"UPDATE delivery_obligations SET state='abandoned', updated_at=? "
"WHERE (adapter_profile = ? OR substr(session_key, 1, ?) = ?) "
"AND state NOT IN ('delivered', 'abandoned')",
(time.time(), name, ns_len, ns)).rowcount
if "telegram_dm_topic_mode" in existing:
counts["telegram_dm_topic_mode"] = conn.execute(
"DELETE FROM telegram_dm_topic_mode WHERE profile_name = ?", (name,)).rowcount
if "telegram_dm_topic_bindings" in existing:
# A rename rewrites a binding's session_key namespace as well as its profile_name
# (:meth:`rekey_profile_state`), so matching on one alone leaves the other behind.
counts["telegram_dm_topic_bindings"] = conn.execute(
"DELETE FROM telegram_dm_topic_bindings "
"WHERE profile_name = ? OR substr(session_key, 1, ?) = ?",
(name, ns_len, ns)).rowcount
self._execute_write(_do)
return counts
@staticmethod
def session_gateway_runtime(session_meta: Optional[Dict[str, Any]]) -> Dict[str, Any]:
"""Read the persisted runtime route off a session row dict (``model_config`` as