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.
This commit is contained in:
xielevi
2026-09-15 22:39:04 +08:00
committed by Teknium
parent 2e320e6d1a
commit 4ba717df12
9 changed files with 580 additions and 8 deletions
+107
View File
@@ -546,6 +546,113 @@ class SessionGatewayMixin:
return
self._write_sql("DELETE FROM gateway_hygiene_state WHERE session_key = ?", (session_key,))
def rekey_profile_state(self, old_name: str, new_name: str) -> Dict[str, int]:
"""Atomically rewrite exact profile identity in this state database."""
old, new = (old_name or "").strip(), (new_name or "").strip()
counts: Dict[str, int] = {}
if not old or not new or old == new:
return counts
old_ns, new_ns = f"agent:{old}:", f"agent:{new}:"
ns_len = len(old_ns)
def _do(conn):
existing = {row[0] for row in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'").fetchall()}
collision = conn.execute(
"SELECT old.scope, ? || substr(old.session_key, ?) "
"FROM gateway_routing AS old JOIN gateway_routing AS target "
"ON target.scope = old.scope "
"AND target.session_key = ? || substr(old.session_key, ?) "
"WHERE substr(old.session_key, 1, ?) = ? LIMIT 1",
(new_ns, ns_len + 1, new_ns, ns_len + 1, ns_len, old_ns),
).fetchone()
if collision is not None:
raise ValueError(
f"profile routing collision in scope {collision[0]!r}: {collision[1]!r}")
for table, columns in (
("telegram_dm_topic_mode", ("chat_id",)),
("telegram_dm_topic_bindings", ("chat_id", "thread_id")),
):
if table not in existing:
continue
equality = " AND ".join(
f"target.{column} = old.{column}" for column in columns)
collision = conn.execute(
f"SELECT 1 FROM {table} AS old JOIN {table} AS target "
f"ON target.profile_name = ? AND {equality} "
"WHERE old.profile_name = ? LIMIT 1", (new, old)).fetchone()
if collision is not None:
raise ValueError(f"profile identity collision in {table}")
counts["sessions_profile_name"] = conn.execute(
"UPDATE sessions SET profile_name = ? WHERE profile_name = ?", (new, old)).rowcount
counts["gateway_heartbeats_profile"] = conn.execute(
"UPDATE gateway_heartbeats SET profile = ? WHERE profile = ?", (new, old)).rowcount
counts["sessions_session_key"] = conn.execute(
"UPDATE sessions SET session_key = ? || substr(session_key, ?) "
"WHERE substr(session_key, 1, ?) = ?",
(new_ns, ns_len + 1, ns_len, old_ns)).rowcount
origin_count = 0
for session_id, origin_json in conn.execute(
"SELECT id, origin_json FROM sessions WHERE origin_json IS NOT NULL").fetchall():
try:
payload = json.loads(origin_json)
except (ValueError, TypeError):
continue
if isinstance(payload, dict) and payload.get("profile") == old:
payload["profile"] = new
conn.execute("UPDATE sessions SET origin_json = ? WHERE id = ?",
(json.dumps(payload, ensure_ascii=False), session_id))
origin_count += 1
counts["sessions_origin_json"] = origin_count
if "delivery_obligations" in existing:
counts["delivery_obligations_adapter_profile"] = conn.execute(
"UPDATE delivery_obligations SET adapter_profile = ? WHERE adapter_profile = ?",
(new, old)).rowcount
counts["delivery_obligations_session_key"] = conn.execute(
"UPDATE delivery_obligations SET session_key = ? || substr(session_key, ?) "
"WHERE substr(session_key, 1, ?) = ?",
(new_ns, ns_len + 1, ns_len, old_ns)).rowcount
for table in ("telegram_dm_topic_mode", "telegram_dm_topic_bindings"):
if table in existing:
counts[f"{table}_profile_name"] = conn.execute(
f"UPDATE {table} SET profile_name = ? WHERE profile_name = ?",
(new, old)).rowcount
if "telegram_dm_topic_bindings" in existing:
counts["telegram_dm_topic_bindings_session_key"] = conn.execute(
"UPDATE telegram_dm_topic_bindings "
"SET session_key = ? || substr(session_key, ?) "
"WHERE substr(session_key, 1, ?) = ?",
(new_ns, ns_len + 1, ns_len, old_ns)).rowcount
routing = conn.execute(
"SELECT rowid, session_key, entry_json FROM gateway_routing "
"WHERE substr(session_key, 1, ?) = ?", (ns_len, old_ns)).fetchall()
for rowid, session_key, entry_json in routing:
new_session_key = new_ns + session_key[ns_len:]
new_json = entry_json
if entry_json:
try:
payload = json.loads(entry_json)
except (ValueError, TypeError):
payload = None
if isinstance(payload, dict):
if isinstance(payload.get("session_key"), str) and payload["session_key"].startswith(old_ns):
payload["session_key"] = new_ns + payload["session_key"][ns_len:]
origin = payload.get("origin")
if isinstance(origin, dict) and origin.get("profile") == old:
origin["profile"] = new
new_json = json.dumps(payload, ensure_ascii=False)
conn.execute(
"UPDATE gateway_routing SET session_key = ?, entry_json = ? WHERE rowid = ?",
(new_session_key, new_json, rowid))
counts["gateway_routing"] = len(routing)
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