refactor(checkpoints): slim the profile-rename rekey and name it as a checkpoint_manager sibling

Follow-up to the cherry-picked fix from #112724 (@poijygfdyy):

- tools/checkpoint_profile_migration.py -> tools/checkpoint_manager_profile_rename.py, the
  repo's `<stem>_<topic>.py` sibling convention for code that extends checkpoint_manager.
- Replace the fail-closed target-collision check plus temp-file/rollback choreography with an
  idempotent rekey: every step overwrites and the old project metadata is removed last, so a
  mid-way failure is repaired by `hermes profile migrate-identity` redoing the same writes.
  A genuine collision cannot occur — `profiles/<new>` must not exist for the rename to run.
  192 -> 98 lines.
- Metadata/ledger writes go through the same idiom as checkpoint_manager itself
  (`_register_project` plain write, `_save_ledger`), dropping the private temp-file helpers.
- Keep the git-present precondition as a single early check: without git the ref cannot move
  and rekeying only the metadata would orphan the history.
- Test: `create_profile` now seeds `workspace/`, so the fixture uses `project/`; add the control
  assertion that a workdir outside the profile dir keeps its history unchanged.
- Docs: the profile rename / migrate-identity reference notes that checkpoint history is preserved.

Fixes #112973
This commit is contained in:
teknium1
2026-09-16 09:57:11 -07:00
committed by Teknium
parent 8a2c9edac6
commit c055eee1fe
5 changed files with 112 additions and 198 deletions

View File

@@ -164,7 +164,7 @@ def _gateway_accepts_profile_identity_verb(root: Path) -> bool:
def _migrate_checkpoint_identity(old_canon: str, new_canon: str) -> bool:
"""Rekey checkpoint projects whose absolute workdirs moved with the profile directory."""
from hermes_cli.profiles import get_profile_dir
from tools.checkpoint_profile_migration import migrate_profile_checkpoint_projects
from tools.checkpoint_manager_profile_rename import migrate_profile_checkpoint_projects
old_dir = get_profile_dir(old_canon)
new_dir = get_profile_dir(new_canon)

View File

@@ -1,4 +1,4 @@
"""Profile rename must preserve profile-local checkpoint history."""
"""Profile rename must preserve profile-local checkpoint history (#112973)."""
from pathlib import Path
from unittest.mock import patch
@@ -20,7 +20,7 @@ def profile_env(tmp_path, monkeypatch):
return default_home
def test_rename_preserves_profile_local_checkpoint_history(profile_env):
def test_rename_preserves_profile_local_checkpoint_history(profile_env, tmp_path):
"""A moved profile keeps rollback history under the moved workspace path.
Checkpoint refs, project metadata, and the safe-restore ledger are keyed by the
@@ -28,8 +28,11 @@ def test_rename_preserves_profile_local_checkpoint_history(profile_env):
checkpoint store itself, but it also changes every profile-local workdir key.
"""
old_dir = create_profile("oldname", no_alias=True)
workdir = old_dir / "workspace"
workdir = old_dir / "project"
workdir.mkdir()
outside = tmp_path / "outside-project" # control: not under the profile dir, same store
outside.mkdir()
(outside / "keep.txt").write_text("v1\n", encoding="utf-8")
(workdir / "pyproject.toml").write_text("[project]\nname = 'rename-checkpoint'\n", encoding="utf-8")
tracked = workdir / "note.txt"
tracked.write_text("before\n", encoding="utf-8")
@@ -39,6 +42,8 @@ def test_rename_preserves_profile_local_checkpoint_history(profile_env):
manager = CheckpointManager(enabled=True, max_snapshots=5)
assert manager.ensure_checkpoint(str(workdir), "before profile rename") is True
checkpoint_hash = manager.list_checkpoints(str(workdir))[0]["hash"]
assert manager.ensure_checkpoint(str(outside), "outside profile") is True
outside_hash = manager.list_checkpoints(str(outside))[0]["hash"]
tracked.write_text("after\n", encoding="utf-8")
manager.record_agent_write(str(tracked))
finally:
@@ -48,13 +53,14 @@ def test_rename_preserves_profile_local_checkpoint_history(profile_env):
patch("hermes_cli.profiles._live_default_multiplexer", return_value=False):
new_dir = rename_profile("oldname", "newname")
new_workdir = new_dir / "workspace"
new_workdir = new_dir / "project"
new_tracked = new_workdir / "note.txt"
token = set_hermes_home_override(new_dir)
try:
manager = CheckpointManager(enabled=True, max_snapshots=5)
checkpoints = manager.list_checkpoints(str(new_workdir))
assert [entry["hash"] for entry in checkpoints] == [checkpoint_hash]
assert [entry["hash"] for entry in manager.list_checkpoints(str(outside))] == [outside_hash]
project_paths = {entry["workdir"] for entry in manager.list_all_checkpoints()}
assert str(new_workdir.resolve()) in project_paths

View File

@@ -0,0 +1,97 @@
"""Rekey profile-local checkpoint projects after a named profile directory moves (#112973).
The checkpoint store lives under ``HERMES_HOME/checkpoints`` and therefore moves with a renamed
profile, but project identity inside it (ref, ``projects/<hash>.json``, agent-write ledger) is a
hash of the absolute workdir. Every workdir beneath the profile home gets a new hash after the
rename, so its history stays on disk yet unreachable from the new path until it is rekeyed here.
"""
from __future__ import annotations
import json
import logging
import shutil
from pathlib import Path
from typing import Dict
from tools import checkpoint_manager as cm
logger = logging.getLogger(__name__)
def _rebase_ledger_paths(ledger: Dict, old_workdir: Path, new_workdir: Path) -> Dict:
"""Move absolute ledger keys under ``old_workdir`` to the corresponding new path."""
rebased = {}
for raw_path, entry in ledger.items():
try:
relative = Path(raw_path).relative_to(old_workdir)
except (TypeError, ValueError):
rebased[raw_path] = entry
else:
rebased[str(new_workdir / relative)] = entry
return rebased
def _rekey_project(store: Path, meta: Dict, old_workdir: Path, new_workdir: Path) -> None:
"""Install the project under its new hash, then drop the old identity.
Every step overwrites, so a retry (``hermes profile migrate-identity``) after a mid-way failure
simply redoes the same writes; the old metadata goes last because it is what keeps the project
visible to that retry. The per-project git index is a rebuildable cache and is discarded.
"""
old_hash, new_hash = meta["_hash"], cm._project_hash(str(new_workdir))
new_meta = {k: v for k, v in meta.items() if k != "_hash"}
new_meta.update({"workdir": str(new_workdir), **cm._volume_evidence(new_workdir)})
meta_path = cm._project_meta_path(store, new_hash)
meta_path.parent.mkdir(parents=True, exist_ok=True)
meta_path.write_text(json.dumps(new_meta), encoding="utf-8")
old_ledger_path = cm._ledger_path(store, old_hash)
if old_ledger_path.exists():
cm._save_ledger(store, new_hash, _rebase_ledger_paths(cm._load_ledger(store, old_hash), old_workdir, new_workdir))
old_ref, new_ref = cm._ref_name(old_hash), cm._ref_name(new_hash)
old_tip = cm._ref_tip(store, str(new_workdir), old_ref)
if old_tip:
ok, _, err = cm._run_git(["update-ref", new_ref, old_tip], store, str(new_workdir))
if not ok:
raise OSError(f"could not create {new_ref}: {err}")
if not cm._delete_ref(store, old_ref):
raise OSError(f"could not delete {old_ref}")
cm._unlink_quiet(cm._project_meta_path(store, old_hash))
cm._unlink_quiet(old_ledger_path)
cm._unlink_quiet(cm._index_path(store, old_hash))
def migrate_profile_checkpoint_projects(old_profile_dir: Path, new_profile_dir: Path) -> Dict[str, int]:
"""Rekey checkpoint projects whose workdirs moved with a profile rename.
Only workdirs beneath ``old_profile_dir`` are affected: an external workdir keeps its absolute
path (and hash) even though the store holding its history moved. A workdir that no longer
exists under the new profile dir did not move and keeps its (orphaned) identity.
"""
old_root = cm._normalize_path(str(old_profile_dir))
new_root = cm._normalize_path(str(new_profile_dir))
store = cm._store_path(new_root / "checkpoints")
result = {"scanned": 0, "migrated": 0, "errors": 0}
if not cm._store_has_head(store):
return result
if shutil.which("git") is None:
# Without git the ref cannot move; rekeying only the metadata would orphan the history.
logger.warning("Cannot migrate checkpoint projects after profile rename: git not found")
result["errors"] = 1
return result
for meta in cm._list_projects(store):
result["scanned"] += 1
old_workdir = cm._normalize_path(str(meta.get("workdir") or ""))
if not meta.get("workdir") or not old_workdir.is_relative_to(old_root):
continue
new_workdir = new_root / old_workdir.relative_to(old_root)
if not new_workdir.is_dir():
continue
try:
_rekey_project(store, meta, old_workdir, new_workdir)
result["migrated"] += 1
except OSError as exc:
result["errors"] += 1
logger.warning("Cannot migrate checkpoint project %s -> %s after profile rename: %s",
old_workdir, new_workdir, exc)
return result

View File

@@ -1,192 +0,0 @@
"""Rekey profile-local checkpoint projects after a named profile directory moves.
The checkpoint store itself lives under ``HERMES_HOME/checkpoints`` and therefore moves with a
renamed profile. Project identity inside that store is different: refs, metadata and the
agent-write ledger are keyed by a hash of the absolute workdir. A profile rename changes that
absolute path for every project beneath the profile home, so those entries must follow the move.
"""
from __future__ import annotations
import json
import logging
import os
import shutil
from pathlib import Path
from typing import Dict
from tools import checkpoint_manager as cm
logger = logging.getLogger(__name__)
def _rebase_ledger_paths(ledger: Dict, old_workdir: Path, new_workdir: Path) -> Dict:
"""Move absolute ledger keys under ``old_workdir`` to the corresponding new path."""
rebased = {}
for raw_path, entry in ledger.items():
try:
relative = Path(raw_path).relative_to(old_workdir)
except (TypeError, ValueError):
rebased[raw_path] = entry
else:
rebased[str(new_workdir / relative)] = entry
return rebased
def _temp_json_path(path: Path) -> Path:
return path.with_name(f".{path.name}.profile-rename-{os.getpid()}.tmp")
def _write_json_temp(path: Path, data: Dict) -> Path:
"""Write a same-directory temporary JSON file ready for atomic ``replace``."""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = _temp_json_path(path)
tmp.write_text(json.dumps(data), encoding="utf-8")
return tmp
def migrate_profile_checkpoint_projects(old_profile_dir: Path, new_profile_dir: Path) -> Dict[str, int]:
"""Rekey live checkpoint projects whose workdirs moved with a profile rename.
Only workdirs beneath ``old_profile_dir`` are affected. External workdirs keep the same
absolute path even though their profile-owned checkpoint store moved, so their hash/ref stays
valid. Target collisions fail closed rather than merging two checkpoint histories.
The per-project git index is a rebuildable cache: after the new ref and durable metadata are
installed, the old index is discarded and the next checkpoint seeds a fresh index from the
migrated ref. The agent-write ledger is durable behavior state and is rebased to the moved
absolute file paths so safe restore continues to recognize Hermes-authored writes.
"""
old_root = cm._normalize_path(str(old_profile_dir))
new_root = cm._normalize_path(str(new_profile_dir))
base = new_root / "checkpoints"
store = cm._store_path(base)
result = {"scanned": 0, "migrated": 0, "errors": 0}
if not cm._store_has_head(store):
return result
git_available = shutil.which("git") is not None
for meta in cm._list_projects(store):
result["scanned"] += 1
raw_workdir = meta.get("workdir")
old_hash = meta.get("_hash")
if not raw_workdir or not old_hash:
continue
old_workdir = cm._normalize_path(str(raw_workdir))
try:
relative = old_workdir.relative_to(old_root)
except ValueError:
continue
new_workdir = cm._normalize_path(str(new_root / relative))
# A project that was already absent before the profile rename stays stale/orphaned; the
# rename should not retarget its retention evidence to a path that was never moved.
if not new_workdir.is_dir():
continue
if not git_available:
result["errors"] += 1
logger.warning(
"Cannot migrate checkpoint project %s after profile rename: git not found",
old_workdir,
)
continue
new_hash = cm._project_hash(str(new_workdir))
if new_hash == old_hash: # defensive (cryptographic/path identity coincidence)
continue
old_ref, new_ref = cm._ref_name(old_hash), cm._ref_name(new_hash)
old_meta = cm._project_meta_path(store, old_hash)
new_meta = cm._project_meta_path(store, new_hash)
old_ledger = cm._ledger_path(store, old_hash)
new_ledger = cm._ledger_path(store, new_hash)
old_index = cm._index_path(store, old_hash)
old_tip = cm._ref_tip(store, str(new_workdir), old_ref)
new_tip = cm._ref_tip(store, str(new_workdir), new_ref)
if new_tip or new_meta.exists() or new_ledger.exists():
result["errors"] += 1
logger.warning(
"Cannot migrate checkpoint project %s -> %s after profile rename: "
"target identity %s already exists",
old_workdir,
new_workdir,
new_hash,
)
continue
serialized_meta = {key: value for key, value in meta.items() if key != "_hash"}
serialized_meta["workdir"] = str(new_workdir)
evidence = cm._volume_evidence(new_workdir)
if evidence:
serialized_meta.update(evidence)
else:
serialized_meta.pop("workdir_parent_dev", None)
serialized_meta.pop("workdir_parent_ino", None)
ledger = cm._read_json_dict(old_ledger) if old_ledger.exists() else None
rebased_ledger = (
_rebase_ledger_paths(ledger, old_workdir, new_workdir)
if ledger is not None
else None
)
meta_tmp = ledger_tmp = None
new_ref_created = False
installed_meta = installed_ledger = False
try:
meta_tmp = _write_json_temp(new_meta, serialized_meta)
if rebased_ledger is not None:
ledger_tmp = _write_json_temp(new_ledger, rebased_ledger)
if old_tip:
ok, _, err = cm._run_git(["update-ref", new_ref, old_tip], store, str(new_workdir))
if not ok:
raise OSError(f"could not create target checkpoint ref: {err}")
new_ref_created = True
meta_tmp.replace(new_meta)
meta_tmp = None
installed_meta = True
if ledger_tmp is not None:
ledger_tmp.replace(new_ledger)
ledger_tmp = None
installed_ledger = True
except Exception as exc:
result["errors"] += 1
logger.warning(
"Cannot migrate checkpoint project %s -> %s after profile rename: %s",
old_workdir,
new_workdir,
exc,
)
if meta_tmp is not None:
cm._unlink_quiet(meta_tmp)
if ledger_tmp is not None:
cm._unlink_quiet(ledger_tmp)
if installed_meta:
cm._unlink_quiet(new_meta)
if installed_ledger:
cm._unlink_quiet(new_ledger)
if new_ref_created:
cm._delete_ref(store, new_ref)
continue
# Delete the source ref only after the complete target identity exists. If that destructive
# step fails, roll the target back and leave the source metadata/ledger/index untouched so
# the standalone migrate-identity command can retry without merging two histories.
if old_tip and not cm._delete_ref(store, old_ref):
result["errors"] += 1
logger.warning("Could not delete source checkpoint ref %s after creating %s", old_ref, new_ref)
cm._unlink_quiet(new_meta)
cm._unlink_quiet(new_ledger)
if new_ref_created:
cm._delete_ref(store, new_ref)
continue
cm._unlink_quiet(old_meta)
cm._unlink_quiet(old_ledger)
cm._unlink_quiet(old_index)
result["migrated"] += 1
return result

View File

@@ -245,7 +245,10 @@ hermes profile rename mybot 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.
when it is running the CLI delegates to it. Checkpoint (`/rollback`) history of workspaces that
live inside the profile directory is rekeyed to their new path as well, so it stays reachable
after the rename; `hermes profile migrate-identity` retries that step too if it was reported as
failed.
## `hermes profile migrate-identity`