From ce7afd48d4e645b76d7d4195e1d868051081de36 Mon Sep 17 00:00:00 2001 From: Diamond Hands Dig <98722191+33hodl@users.noreply.github.com> Date: Tue, 15 Sep 2026 11:48:10 +0000 Subject: [PATCH] fix(checkpoints): safe restore must find the ledger under the walked project key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit record_agent_write keys the agent-write ledger by the marker-walk result of get_working_dir_for_path(), while safe restore reads the ledger by the exact hash of the directory named in the restore. The two keys diverge whenever the walk lands somewhere other than that directory: - a markerless project dir under an ancestor that carries a generic project marker (e.g. /tmp with a stray package.json) -> the ledger is filed under the ancestor's hash and restore reads an empty ledger; - restoring a repo subdir while the write was recorded at the repo root. An empty ledger makes restore() silently degrade to a full restore, which overwrites files the user hand-edited after Hermes' last write — the exact outcome safe restore exists to prevent. safe_restore_plan now falls back to the walked key when the exact-key ledger is empty, so the recorded protection is honored regardless of where the walk landed. Verified: 9 previously failing TestSafeRestore tests now pass, plus a new regression test that seeds an ancestor marker and asserts the user hand-edit survives the restore. --- tests/tools/test_checkpoint_manager.py | 21 +++++++++++++++++++++ tools/checkpoint_manager.py | 13 +++++++++++++ 2 files changed, 34 insertions(+) diff --git a/tests/tools/test_checkpoint_manager.py b/tests/tools/test_checkpoint_manager.py index f36f97e112..b87bbcf970 100644 --- a/tests/tools/test_checkpoint_manager.py +++ b/tests/tools/test_checkpoint_manager.py @@ -338,6 +338,27 @@ class TestSafeRestore: assert "README.md" in result["skipped_user_edits"] assert "main.py" in result["restored_files"] + def test_safe_restore_finds_ledger_under_walked_key(self, mgr, work_dir, tmp_path): + base = self._checkpoint(mgr, work_dir) + + # A generic project marker in an ancestor dir (e.g. a stray + # package.json in /tmp) makes record_agent_write's marker walk key + # the ledger to the ancestor, while restore reads the exact dir's + # hash. Safe restore must still find the ledger, or it silently + # degrades to a full restore and overwrites user edits. + (tmp_path / "package.json").write_text("{}\n") + + (work_dir / "main.py").write_text("agent version\n") + (work_dir / "README.md").write_text("user hand edit\n") + mgr.record_agent_write(str(work_dir / "main.py")) + + result = mgr.restore(str(work_dir), base, safe=True) + assert result["success"] is True + assert (work_dir / "main.py").read_text() == "print('hello')\n" + assert (work_dir / "README.md").read_text() == "user hand edit\n" + assert result["restored_files"] == ["main.py"] + assert "README.md" in result["skipped_user_edits"] + def test_safe_restore_skips_file_user_edited_after_agent(self, mgr, work_dir): base = self._checkpoint(mgr, work_dir) diff --git a/tools/checkpoint_manager.py b/tools/checkpoint_manager.py index 790e5124b8..bee7ab758d 100644 --- a/tools/checkpoint_manager.py +++ b/tools/checkpoint_manager.py @@ -638,6 +638,19 @@ class CheckpointManager: return {"success": False, "error": f"Could not compute changed files: {err}"} ledger = _load_ledger(p.store, p.dir_hash) + if not ledger: + # record_agent_write keys the ledger by the marker-walk result + # (get_working_dir_for_path), which can diverge from the dir this + # restore names — e.g. a markerless project dir under an ancestor + # that carries a generic project marker, or a restore of a repo + # subdir while the write was recorded at the repo root. Fall back + # to the walked key so safe restore never silently degrades to a + # full restore (which overwrites user edits) just because the + # ledger landed under a sibling key. + walked = self.get_working_dir_for_path(working_dir) + walk_key = _project_hash(walked) + if walk_key != p.dir_hash: + ledger = _load_ledger(p.store, walk_key) if not ledger: return {"success": True, "restore": [], "skipped": [], "ledger_empty": True} out: Dict[str, List[str]] = {"restore": [], "skipped": []}