Files
hermes-agent/tests/gateway/test_purge_profile_routing.py
xielevi a41552fad4 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.
2026-09-16 14:21:14 -07:00

70 lines
2.7 KiB
Python

"""In-memory routing purge for `hermes profile delete`.
The routing index lives in ``SessionStore._entries`` and is written back periodically, so a durable
DB delete made anywhere else is undone by this process's next save — the store has to drop its own
copy, and the drop has to be persisted, or a deleted profile's chats keep resolving to it. This is
the delete-side sibling of the rename rekey in #111926.
"""
from __future__ import annotations
def _make_store(tmp_path):
from gateway.config import GatewayConfig
from gateway.session import SessionStore
sessions_dir = tmp_path / "sessions"
sessions_dir.mkdir(exist_ok=True)
store = SessionStore(
sessions_dir,
GatewayConfig(sessions_dir=sessions_dir, write_sessions_json=False,
multiplex_profiles=True),
)
store._ensure_loaded()
return store
def _entry(session_key, chat_id, profile):
from gateway.session import SessionEntry, SessionSource, Platform
from gateway.session_lifecycle import _now
now = _now()
return SessionEntry(
session_key=session_key, session_id=f"sid-{chat_id}",
platform=Platform.FEISHU, chat_type="dm", created_at=now, updated_at=now,
origin=SessionSource(platform=Platform.FEISHU, chat_id=chat_id, profile=profile),
)
def test_purges_deleted_namespace_and_persists_the_drop(tmp_path):
store = _make_store(tmp_path)
with store._lock:
store._entries["agent:gone:feishu:dm:chatA"] = _entry(
"agent:gone:feishu:dm:chatA", "chatA", "gone")
store._entries["agent:keepme:feishu:dm:chatB"] = _entry(
"agent:keepme:feishu:dm:chatB", "chatB", "keepme")
store._save()
dropped = store.purge_profile_routing("gone")
assert dropped == 1
assert "agent:gone:feishu:dm:chatA" not in store._entries
assert "agent:keepme:feishu:dm:chatB" in store._entries
# The drop is durable: a store reloading the same home must not resurrect the deleted profile.
reloaded = _make_store(tmp_path)
assert "agent:gone:feishu:dm:chatA" not in reloaded._entries
assert "agent:keepme:feishu:dm:chatB" in reloaded._entries
def test_namespace_scoped_and_idempotent(tmp_path):
store = _make_store(tmp_path)
with store._lock:
store._entries["agent:foo_bar:feishu:dm:chatA"] = _entry(
"agent:foo_bar:feishu:dm:chatA", "chatA", "foo_bar")
store._entries["agent:fooXbar:feishu:dm:chatB"] = _entry(
"agent:fooXbar:feishu:dm:chatB", "chatB", "fooXbar")
assert store.purge_profile_routing("foo_bar") == 1
assert store.purge_profile_routing("foo_bar") == 0
assert "agent:foo_bar:feishu:dm:chatA" not in store._entries
assert "agent:fooXbar:feishu:dm:chatB" in store._entries