From 81140e454623ea8a4ddbbbbc110525de5ea5c34d Mon Sep 17 00:00:00 2001 From: xielevi <212198284+xielevi@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:25:56 +0800 Subject: [PATCH] fix(profiles): ship a retry path for the rename identity migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/ is gone) and the CLI deliberately never rewrites the routing DB a live gateway holds in memory. - `hermes profile migrate-identity `: 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. --- hermes_cli/profile_cmd.py | 20 ++++- hermes_cli/profiles.py | 83 ++++++++++++++++--- hermes_cli/subcommands/profile.py | 12 +++ .../hermes-agent/references/cli-reference.md | 1 + tests/hermes_cli/test_profiles.py | 78 +++++++++++++++++ website/docs/reference/profile-commands.md | 34 ++++++++ website/docs/user-guide/profiles.md | 1 + 7 files changed, 214 insertions(+), 15 deletions(-) diff --git a/hermes_cli/profile_cmd.py b/hermes_cli/profile_cmd.py index c4d0d8d071..2d355fe721 100644 --- a/hermes_cli/profile_cmd.py +++ b/hermes_cli/profile_cmd.py @@ -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, diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index 3cf198d06e..48fbe6ae2a 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -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 `` for when that attempt failed. The rename + cannot simply be repeated — ``profiles/`` 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) diff --git a/hermes_cli/subcommands/profile.py b/hermes_cli/subcommands/profile.py index 3a11dc838a..e07405a0e2 100644 --- a/hermes_cli/subcommands/profile.py +++ b/hermes_cli/subcommands/profile.py @@ -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( diff --git a/skills/autonomous-ai-agents/hermes-agent/references/cli-reference.md b/skills/autonomous-ai-agents/hermes-agent/references/cli-reference.md index 10229f5bd5..855a5bf767 100644 --- a/skills/autonomous-ai-agents/hermes-agent/references/cli-reference.md +++ b/skills/autonomous-ai-agents/hermes-agent/references/cli-reference.md @@ -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 diff --git a/tests/hermes_cli/test_profiles.py b/tests/hermes_cli/test_profiles.py index b8d8203228..4a5282a506 100644 --- a/tests/hermes_cli/test_profiles.py +++ b/tests/hermes_cli/test_profiles.py @@ -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 ` 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 must exist + + with patch("hermes_cli.profiles._live_default_multiplexer", return_value=True), \ + patch("gateway.control_socket.migrate_gateway_profile_identity", + return_value="not a control answer"), \ + 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 diff --git a/website/docs/reference/profile-commands.md b/website/docs/reference/profile-commands.md index 3fa4e8c261..390af56449 100644 --- a/website/docs/reference/profile-commands.md +++ b/website/docs/reference/profile-commands.md @@ -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::*`), `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 +``` + +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 ``, so `profiles/` does not have +to exist; only `` 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 diff --git a/website/docs/user-guide/profiles.md b/website/docs/user-guide/profiles.md index cd6da6ab01..3e0580c628 100644 --- a/website/docs/user-guide/profiles.md +++ b/website/docs/user-guide/profiles.md @@ -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 ```