`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.
70 lines
2.7 KiB
Python
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
|