fix(profiles): ship a retry path for the rename identity migration

A rename under a live multiplexer that could not reach the control verb warned and
stopped there, leaving the operator with no way to finish: the rename cannot be
repeated (profiles/<old> is gone) and the CLI deliberately never rewrites the
routing DB a live gateway holds in memory.

- `hermes profile migrate-identity <old> <new>`: retries the migration —
  delegates to the gateway control verb while a multiplexer is live, performs the
  durable rewrite of both state DBs when none is. Idempotent, and exits non-zero
  naming the offending database on a collision, a lock, or a partial failure. Only
  the name format and the existence of the new profile are checked; the old profile
  directory is expected to be gone.
- An older gateway that does not implement the verb is reported as such (`identify`
  answers while the migrate verb does not), not as "no gateway".
- `_migrate_profile_identity` returns an explicit success/failure result so the
  command can set its exit code; the rename warning now names the exact invocation.
- A failed control answer keeps the raw payload when it carries no reason field.
- The offline failure branch called `click.echo` in a module that never imports
  `click`: a failed second database raised NameError instead of printing its warning.
This commit is contained in:
xielevi
2026-09-15 23:25:56 +08:00
committed by Teknium
parent 4ba717df12
commit 81140e4546
7 changed files with 214 additions and 15 deletions
+18 -2
View File
@@ -9,10 +9,10 @@ from __future__ import annotations
from pathlib import Path
import os
import sys
from typing import Optional
from typing import NoReturn, Optional
def _die(msg: str, code: int = 1, *, err: bool = False) -> None:
def _die(msg: str, code: int = 1, *, err: bool = False) -> NoReturn:
print(msg, file=sys.stderr if err else sys.stdout)
sys.exit(code)
@@ -436,6 +436,21 @@ def _profile_rename(args):
_die(f"Error: {e}")
def _profile_migrate_identity(args):
"""Retry the identity migration of a rename that already completed. Exits non-zero when a
live gateway would not migrate (it still owns the routing index in memory), or when a
database rejected the rewrite (collision, lock, partial failure)."""
from hermes_cli.profiles import migrate_profile_identity
try:
migrated = migrate_profile_identity(args.old_name, args.new_name)
except (ValueError, FileNotFoundError) as e:
_die(f"Error: {e}")
if not migrated:
_die(f"Error: session identity was not migrated. Restart or stop the gateway, then run:\n"
f" hermes profile migrate-identity {args.old_name} {args.new_name}", err=True)
print(f"✓ Session/routing identity migrated: {args.old_name} → {args.new_name}")
def _profile_export(args):
from hermes_cli.profiles import export_profile, get_profile_export_path
name = args.profile_name
@@ -575,6 +590,7 @@ PROFILE_ACTIONS = {
'show': _profile_show,
'alias': _profile_alias,
'rename': _profile_rename,
'migrate-identity': _profile_migrate_identity,
'export': _profile_export,
'import': _profile_import,
'install': _profile_install,
+70 -13
View File
@@ -1875,30 +1875,84 @@ def rename_profile(old_name: str, new_name: str) -> Path:
return new_dir
def _migrate_profile_identity(old_canon: str, new_canon: str, live_mux: bool) -> None:
"""Rekey renamed-profile identity without racing a live gateway's in-memory routing index."""
def migrate_profile_identity(old_name: str, new_name: str) -> bool:
"""Retry the session/routing identity migration of a rename that already completed.
``rename_profile`` runs the migration itself; this is the standalone retry behind
``hermes profile migrate-identity <old> <new>`` for when that attempt failed. The rename
cannot simply be repeated — ``profiles/<old>`` is gone — and the identity to migrate is read
from the DB rows that still name *old*, so only the new profile has to exist here.
A live multiplexer holds the routing index in memory and therefore stays the owner of the
migration (the CLI delegates to its control verb); with no live multiplexer the durable
rewrite is safe because nothing else holds the store. Idempotent: re-running a completed
migration succeeds with nothing left to rekey. Returns True when the identity was migrated,
False when a live gateway would not do it — the caller reports that as a failure.
"""
old_canon = _canon_valid(old_name)
new_canon = _canon_valid(new_name)
if "default" in (old_canon, new_canon):
raise ValueError("Identity migration applies to named profiles only.")
if not get_profile_dir(new_canon).is_dir():
raise _unknown_profile_error(new_canon)
return _migrate_profile_identity(old_canon, new_canon, _live_default_multiplexer())
def _control_answer_failure(answer) -> str:
"""Why a control-socket answer is not a success. Keeps the raw answer when the payload carries
no reason field, so a malformed or old-gateway response stays diagnosable instead of
collapsing into a generic warning."""
if isinstance(answer, dict):
failure = answer.get("error") or answer.get("message") or answer.get("detail")
return str(failure) if failure else repr(answer)
if answer is not None:
return repr(answer)
return "no response from gateway control socket"
def _gateway_accepts_profile_identity_verb(root: Path) -> bool:
"""True when the gateway at *root* answers a verb it has always had. Distinguishes a failed
migration verb caused by an older gateway process from one caused by no gateway at all."""
try:
from gateway.control_socket import identify_gateway
return identify_gateway(root) is not None
except Exception:
return False
def _migrate_profile_identity(old_canon: str, new_canon: str, live_mux: bool) -> bool:
"""Rekey renamed-profile identity without racing a live gateway's in-memory routing index.
Returns True when the identity was migrated — by the gateway's control verb, or by this
process's durable rewrite when no gateway holds the store — and False when a live gateway did
not accept it. Never fatal to the rename, which has already happened by this point.
"""
if live_mux:
from hermes_constants import get_default_hermes_root
root = get_default_hermes_root()
try:
from hermes_constants import get_default_hermes_root
from gateway.control_socket import migrate_gateway_profile_identity
answer = migrate_gateway_profile_identity(
get_default_hermes_root(), old_canon, new_canon)
answer = migrate_gateway_profile_identity(root, old_canon, new_canon)
except Exception as exc:
answer, failure = None, f"{type(exc).__name__}: {exc}"
reason = f"{type(exc).__name__}: {exc}"
else:
failure = answer.get("error") if isinstance(answer, dict) else None
if isinstance(answer, dict) and answer.get("ok") is True:
return
detail = f" ({failure})" if failure else ""
return True
reason = _control_answer_failure(answer)
if answer is None and _gateway_accepts_profile_identity_verb(root):
reason += (" — the gateway is running but does not implement "
"'migrate-profile-identity' (an older process than this CLI)")
print(
"⚠ Profile was renamed, but the live gateway could not migrate session identity"
f"{detail}. Restart the gateway, then retry the identity migration.",
f" ({reason}). Restart the gateway, then run:\n"
f" hermes profile migrate-identity {old_canon} {new_canon}",
file=sys.stderr)
return
return False
from hermes_state_registry import acquire, release_or_close
from hermes_constants import get_default_hermes_root
root = get_default_hermes_root()
migrated = True
for db_path in (root / "state.db", get_profile_dir(new_canon) / "state.db"):
if not db_path.exists():
continue
@@ -1907,13 +1961,16 @@ def _migrate_profile_identity(old_canon: str, new_canon: str, live_mux: bool) ->
db = acquire(db_path)
db.rekey_profile_state(old_canon, new_canon)
except Exception as exc:
click.echo(
migrated = False
print(
f"⚠ Profile was renamed, but identity migration failed for {db_path}: "
f"{type(exc).__name__}: {exc}", err=True)
f"{type(exc).__name__}: {exc}",
file=sys.stderr)
finally:
if db is not None:
with contextlib.suppress(Exception):
release_or_close(db)
return migrated
# Profile env resolution (called from _apply_profile_override)
+12
View File
@@ -90,6 +90,18 @@ def build_profile_parser(subparsers, *, cmd_profile: Callable) -> None:
"new_name",
help="New profile name (for 'default': a display name — the canonical id stays 'default')")
profile_migrate = profile_subparsers.add_parser(
"migrate-identity",
help="Retry a renamed profile's session/routing identity migration",
description="Re-run the session/routing identity migration that `hermes profile rename` "
"performs automatically. The rename has already happened when this is needed, so pass "
"the OLD and NEW names: state still keyed by the old profile name (session keys, "
"profile_name, heartbeats, routing/delivery rows) is rekeyed to the new one. Run it "
"after restarting the gateway (which reloads the routing index from the DB) or after "
"stopping it. Idempotent.")
profile_migrate.add_argument("old_name", help="Profile name before the rename")
profile_migrate.add_argument("new_name", help="Profile name after the rename")
profile_export = profile_subparsers.add_parser("export", help="Export a profile to archive")
profile_export.add_argument("profile_name", help="Profile to export")
profile_export.add_argument(
@@ -101,6 +101,7 @@ Webhook payloads/routes: `references/webhooks.md`.
```
hermes profile list|create NAME (--clone|--clone-all|--clone-from)|use|show|delete
hermes profile rename A B | alias NAME | export NAME | import FILE
hermes profile migrate-identity A B Retry a completed rename's session/routing identity migration
```
### Credentials & Pools
+78
View File
@@ -938,6 +938,84 @@ class TestRenameProfile:
acquire.assert_not_called()
assert "Restart the gateway" in capsys.readouterr().err
def test_migrate_identity_command_repairs_a_failed_live_migration(self, profile_env, capsys):
"""The failed-live-migration end state must be recoverable: `hermes profile
migrate-identity <old> <new>` rekeys the durable rows once no gateway holds the store, and
is idempotent (a second run has nothing left to rekey but still succeeds)."""
from hermes_cli.profile_cmd import cmd_profile
from hermes_state import SessionDB
from argparse import Namespace
tmp_path = profile_env
create_profile("oldname", no_alias=True)
old_dir = tmp_path / ".hermes" / "profiles" / "oldname"
pdb = SessionDB(old_dir / "state.db")
pdb.create_session(
"sess1", "feishu", session_key="agent:oldname:feishu:dm:chatA",
profile_name="oldname", chat_id="chatA", chat_type="dm")
pdb.close()
root_db = SessionDB(tmp_path / ".hermes" / "state.db")
root_db.save_gateway_routing_entry(
"agent:oldname:feishu:dm:chatA",
json.dumps({"session_key": "agent:oldname:feishu:dm:chatA", "session_id": "sess1",
"origin": {"platform": "feishu", "chat_id": "chatA", "profile": "oldname"}}),
scope=str(tmp_path / ".hermes" / "sessions"))
root_db.close()
# Rename under a live multiplexer whose control verb answers nothing: the CLI warns and
# leaves the (in-memory-owned) store alone, so the rows still name the old profile.
with patch("hermes_cli.profiles.check_alias_collision", return_value="skip"), \
patch("hermes_cli.profiles._live_default_multiplexer", return_value=True), \
patch("hermes_cli.profiles._notify_multiplexer"), \
patch("gateway.control_socket.migrate_gateway_profile_identity", return_value=None):
rename_profile("oldname", "newname")
assert "hermes profile migrate-identity oldname newname" in capsys.readouterr().err
# Gateway restarted/stopped → the retry command repairs both stores.
with patch("hermes_cli.profiles._live_default_multiplexer", return_value=False):
cmd_profile(Namespace(profile_action="migrate-identity",
old_name="oldname", new_name="newname"))
assert "✓ Session/routing identity migrated" in capsys.readouterr().out
# Idempotent: nothing left to rekey, still a success.
cmd_profile(Namespace(profile_action="migrate-identity",
old_name="oldname", new_name="newname"))
moved_db = SessionDB(tmp_path / ".hermes" / "profiles" / "newname" / "state.db")
row = moved_db._read_one(
"SELECT session_key, profile_name FROM sessions WHERE id = ?", ("sess1",))
assert row is not None
assert row["session_key"] == "agent:newname:feishu:dm:chatA"
assert row["profile_name"] == "newname"
moved_db.close()
root_db2 = SessionDB(tmp_path / ".hermes" / "state.db")
routing = root_db2.load_gateway_routing_entries(
scope=str(tmp_path / ".hermes" / "sessions"))
assert "agent:oldname:feishu:dm:chatA" not in routing
assert "agent:newname:feishu:dm:chatA" in routing
root_db2.close()
def test_migrate_identity_reports_the_raw_answer_and_exits_nonzero(self, profile_env, capsys):
"""A live gateway that answers with something unusable must fail loudly — exit non-zero,
name the retry command, and quote the raw answer (a non-dict payload used to print a
reason-less warning). The live gateway keeps ownership: no direct DB rewrite."""
from hermes_cli.profile_cmd import cmd_profile
from argparse import Namespace
create_profile("oldname", no_alias=True)
create_profile("newname", no_alias=True) # the rename already happened; only <new> must exist
with patch("hermes_cli.profiles._live_default_multiplexer", return_value=True), \
patch("gateway.control_socket.migrate_gateway_profile_identity",
return_value="<html>not a control answer</html>"), \
patch("hermes_state_registry.acquire") as acquire:
with pytest.raises(SystemExit) as excinfo:
cmd_profile(Namespace(profile_action="migrate-identity",
old_name="oldname", new_name="newname"))
assert excinfo.value.code != 0
err = capsys.readouterr().err
assert "not a control answer" in err
assert "hermes profile migrate-identity oldname newname" in err
acquire.assert_not_called()
# ===================================================================
# TestExportImport
@@ -242,6 +242,40 @@ hermes profile rename mybot assistant
# ~/.local/bin/mybot → ~/.local/bin/assistant
```
The rename also migrates the profile's persisted session/routing identity — session keys
(`agent:<old>:*`), `sessions.profile_name`, heartbeats, and routing/delivery rows — to the new
name. A live multiplexed gateway owns that migration (it holds the routing index in memory), so
when it is running the CLI delegates to it.
## `hermes profile migrate-identity`
```bash
hermes profile migrate-identity <old-name> <new-name>
```
Retries the identity migration of a rename that already completed. Run it if `hermes profile
rename` warned that the live gateway could not migrate session identity: restart the gateway
(it reloads the routing index from the database, so the migration lands), or stop it — with no
gateway holding the store the command performs the durable rewrite itself.
The migration is driven by the rows that still name `<old>`, so `profiles/<old>` does not have
to exist; only `<new>` is checked. Idempotent — re-running a completed migration succeeds with
nothing left to rekey. Exits non-zero when a live gateway refuses the migration, when a
database rejects the rewrite (a routing collision, a lock, or one of the two databases failing
while the other succeeds), naming the database and error.
**Example:**
```bash
hermes profile rename mybot assistant
# ⚠ Profile was renamed, but the live gateway could not migrate session identity (…).
# Restart the gateway, then run:
# hermes profile migrate-identity mybot assistant
hermes profile migrate-identity mybot assistant
# ✓ Session/routing identity migrated: mybot → assistant
```
## `hermes profile export`
```bash
+1
View File
@@ -308,6 +308,7 @@ User-modified skills are never overwritten.
hermes profile list # show all profiles with status
hermes profile show coder # detailed info for one profile
hermes profile rename coder dev-bot # rename (updates alias + service)
hermes profile migrate-identity coder dev-bot # retry a rename's identity migration
hermes profile export coder # pack into coder.tar.gz (shareable; keys stripped)
hermes profile import coder.tar.gz # install an archive as a new profile
```