Files
hermes-agent/tests/gateway/test_rekey_profile_routing.py
T
xielevi 4ba717df12 fix(profiles): migrate session/routing identity on profile rename
Renaming a profile moved profiles/<old>/ to profiles/<new>/, so the row DATA
travelled with the directory, but the profile name is also baked into
keys/values the move left untouched: session keys (agent:<old>:* namespace),
sessions.profile_name (fail-closed owner ladder / Desktop sidebar scope /
@session: deep links), sessions.origin_json.profile,
gateway_heartbeats.profile, delivery_obligations (session_key +
adapter_profile), telegram_dm_topic_* profile_name bindings, and the
gateway_routing index. Left stale, every inbound event on a chat keyed to the
old name resolved to a profile that no longer exists — flooding errors.log
with "Profile <old> does not exist ... falling back to global HERMES_HOME"
every few seconds — and renamed sessions dropped out of the sidebar / broke
their deep links.

The routing index is held in memory by a live multiplexer and written back
periodically, so a CLI-side DB rewrite alone is clobbered. Fix in layers:

- SessionDB.rekey_profile_state: atomic durable rewrite of the state.db
  tables, matching the agent:<name>: namespace by exact prefix (substr, not
  LIKE — '_' is a legal profile-name character and a LIKE wildcard), rewriting
  the profile inside routing/origin JSON, and REFUSING on a target collision
  (routing rows or telegram bindings) instead of silently merging.
- SessionStore.rekey_profile_routing: rekey the in-memory routing index
  (keys + origin.profile) then persist — the half a DB write cannot reach.
  Raises on a target-key collision before mutating.
- Control verb migrate-profile-identity (params-carrying; the socket passes
  params only to handlers that declare them, bare handlers unchanged) so a
  live gateway rekeys its in-memory copy AND both durable stores (routing home
  + the renamed profile's own state.db).
- rename_profile calls the verb when a multiplexer is live and, if it fails,
  does NOT fall back to a racing CLI-side write: it prints a warning telling
  the operator to restart the gateway and retry. With no live gateway it
  performs the durable rewrite itself (safe: nothing else holds the store
  open).

Checkpoints keyed by the profile's workdir path are a known related gap,
tracked separately, not addressed here.

Tests: rekey_profile_state (all tables, routing/origin JSON, collisions,
idempotent, no-op), rekey_profile_routing (namespace + origin, no-op, no
overwrite), control verb param passing, and rename end-to-end for both the
live-gateway (delegates, refuses unsafe fallback) and no-gateway (durable
rewrite) paths.
2026-09-16 00:32:15 -07:00

78 lines
3.1 KiB
Python

"""In-memory routing rekey for `hermes profile rename`.
The routing index lives in ``SessionStore._entries`` and is written back periodically, so a durable
DB rewrite alone is clobbered — the live store must rekey its in-memory copy too. This is why a
renamed profile's old namespace kept resurfacing until the gateway restarted.
"""
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()
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_rekeys_old_namespace_and_origin_profile(tmp_path):
store = _make_store(tmp_path)
with store._lock:
store._entries["agent:oldname:feishu:dm:chatA"] = _entry(
"agent:oldname:feishu:dm:chatA", "chatA", "oldname")
store._entries["agent:keepme:feishu:dm:chatB"] = _entry(
"agent:keepme:feishu:dm:chatB", "chatB", "keepme")
moved = store.rekey_profile_routing("oldname", "newname")
assert moved == 1
assert "agent:oldname:feishu:dm:chatA" not in store._entries
new_entry = store._entries["agent:newname:feishu:dm:chatA"]
assert new_entry.session_key == "agent:newname:feishu:dm:chatA"
assert new_entry.origin.profile == "newname"
# Bystander namespace untouched.
assert store._entries["agent:keepme:feishu:dm:chatB"].origin.profile == "keepme"
def test_noop_for_equal_or_empty_names(tmp_path):
store = _make_store(tmp_path)
with store._lock:
store._entries["agent:oldname:feishu:dm:chatA"] = _entry(
"agent:oldname:feishu:dm:chatA", "chatA", "oldname")
assert store.rekey_profile_routing("x", "x") == 0
assert store.rekey_profile_routing("", "y") == 0
assert "agent:oldname:feishu:dm:chatA" in store._entries
def test_does_not_overwrite_existing_new_namespace_key(tmp_path):
store = _make_store(tmp_path)
with store._lock:
store._entries["agent:oldname:feishu:dm:chatA"] = _entry(
"agent:oldname:feishu:dm:chatA", "chatA", "oldname")
# A collision on the target key (should not happen in practice) is left alone.
store._entries["agent:newname:feishu:dm:chatA"] = _entry(
"agent:newname:feishu:dm:chatA", "chatA", "newname")
import pytest
with pytest.raises(ValueError, match="routing collision"):
store.rekey_profile_routing("oldname", "newname")
assert "agent:oldname:feishu:dm:chatA" in store._entries
assert "agent:newname:feishu:dm:chatA" in store._entries