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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user