fix(checkpoints): safe restore must find the ledger under the walked project key
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.
This commit is contained in:
committed by
kshitij
parent
d6e64c4de5
commit
ce7afd48d4
@@ -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)
|
||||
|
||||
|
||||
@@ -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": []}
|
||||
|
||||
Reference in New Issue
Block a user