4ba717df12
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.
78 lines
3.1 KiB
Python
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
|