On a custom branch the updater runs `git merge --no-edit origin/<branch>` inside the marker window. Its files are the merge of both sides, a blob that is neither pre nor target, so the restore took them for user edits: it put the upstream-only files back to pre, kept the merged ones and spent the marker, leaving a mixed tree (a real random-kill of that merge: 104 of 500 trials broken). - _early_recovery: when pre and target diverge, `git merge-tree --write-tree pre target` gives the tree the merge was writing; its blobs (and prefixes of them, for a file cut short) count as git's like the target's. Conflicted paths, and on git < 2.38 every path both sides changed, count as git's whatever their content. Paths with a newline are hashed one by one (`--stdin-paths` is newline-delimited). The docstring lists the by-design limits. - run_agent (`hermes-agent`) and acp_adapter.entry (`hermes-acp`) never import hermes_cli.main, so they now run the same restore right after hermes_bootstrap (run_agent only when hermes_cli.main is not loaded, since it is also a library module). - Tests: the second test kills inside a clean custom-branch merge (merged file, upstream-only file, a cut-short file, a user edit); the first pins that each console script's entry module imports no other checkout module before the restore runs. Both red on the previous head. - evals/update_pipeline/interrupted_pull_ab.sh gains scenario E: a kill inside the custom-branch merge, then the `hermes-agent` import.
197 lines
10 KiB
Python
197 lines
10 KiB
Python
"""A `hermes update` killed while git writes the new tree must leave a recoverable install.
|
|
|
|
Git rewrites the checkout file by file and moves HEAD last, so a kill in between leaves HEAD on the
|
|
old commit with some files already new — a mix that fails at import in every entry point. The
|
|
updater brackets the move with a marker; the next launch (``_early_recovery``, before any other
|
|
checkout import) puts the old tree back so ``hermes update`` can simply run again.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from hermes_cli import _early_recovery as er
|
|
from hermes_cli import update_cmd
|
|
|
|
|
|
def _git(root: Path, *args: str) -> str:
|
|
return subprocess.run(["git", "-C", str(root), *args], check=True, capture_output=True,
|
|
text=True, encoding="utf-8").stdout.strip()
|
|
|
|
|
|
_MULTI = "top = 1\nx = 0\ny = 0\nz = 0\nend = 1\n"
|
|
|
|
|
|
# Runs an entry module with the repair replaced by a probe that lists the checkout modules imported so
|
|
# far (the entry module, its packages and what hermes_bootstrap needs excluded), then stops.
|
|
_ENTRY_SPY = """
|
|
import importlib, json, os, sys
|
|
import hermes_bootstrap
|
|
from hermes_cli import _early_recovery as er
|
|
|
|
before, venv, entry = set(sys.modules), os.path.realpath(sys.prefix), sys.argv[1]
|
|
|
|
def probe():
|
|
loaded = (n for n in set(sys.modules) - before if not f"{entry}.".startswith(n + "."))
|
|
files = {n: os.path.realpath(str(getattr(sys.modules[n], "__file__", None))) for n in loaded}
|
|
print(json.dumps(sorted(n for n, f in files.items() if f.startswith(os.getcwd()) and not f.startswith(venv))))
|
|
raise SystemExit(0)
|
|
|
|
er.restore_interrupted_pull = probe
|
|
importlib.import_module(entry)
|
|
"""
|
|
|
|
|
|
@pytest.fixture
|
|
def checkout(tmp_path, monkeypatch):
|
|
"""An install at commit A whose fetched ``origin/main`` is B (modifies, deletes, adds, flips a mode)."""
|
|
origin = tmp_path / "origin"
|
|
origin.mkdir()
|
|
_git(origin, "init", "-q", "-b", "main")
|
|
_git(origin, "config", "user.email", "t@example.invalid")
|
|
_git(origin, "config", "user.name", "t")
|
|
files = {"utils.py": "OLD = 1\n", "other.py": "a = 1\n", "gone.py": "x = 1\n", "cut.py": "c = 1\n",
|
|
"blank.py": "b = 1\n", "half.py": "h = 1\n", "tool.sh": "echo\n", "multi.py": _MULTI}
|
|
for name, body in files.items():
|
|
(origin / name).write_text(body, encoding="utf-8", newline="")
|
|
_git(origin, "add", "-A")
|
|
_git(origin, "commit", "-qm", "A")
|
|
for name, body in {"utils.py": "NEW = 1\n", "other.py": "a = 2\n", "cut.py": "c = 2\n", "multi.py": "top = 2\n" + _MULTI[8:],
|
|
"blank.py": "b = 2\n", "half.py": "h = 2 # long enough to span pages\n"}.items():
|
|
(origin / name).write_text(body, encoding="utf-8", newline="")
|
|
(origin / "gone.py").unlink()
|
|
(origin / "newpkg").mkdir()
|
|
(origin / "newpkg" / "__init__.py").write_text("from utils import NEW\n", encoding="utf-8", newline="")
|
|
_git(origin, "add", "-A")
|
|
_git(origin, "update-index", "--chmod=+x", "tool.sh")
|
|
_git(origin, "commit", "-qm", "B")
|
|
root = tmp_path / "install"
|
|
_git(tmp_path, "clone", "-q", str(origin), str(root))
|
|
_git(root, "reset", "-q", "--hard", "HEAD~1")
|
|
monkeypatch.setattr("hermes_cli.main.PROJECT_ROOT", root)
|
|
return root, _git(root, "rev-parse", "HEAD"), _git(root, "rev-parse", "origin/main")
|
|
|
|
|
|
def _pull(root: Path) -> None:
|
|
update_cmd._pull_updates(["git"], "main", None, prompt_for_restore=False, gw_input_fn=None,
|
|
discard_local_changes=False, keep_stash=False)
|
|
|
|
|
|
def test_killed_pull_is_restored_on_next_launch_and_update_reruns(checkout, monkeypatch):
|
|
root, a, b = checkout
|
|
real = update_cmd._git_run
|
|
|
|
def dying_git_run(git_cmd, args, *rest, **kw):
|
|
if args[:1] == ["merge"]:
|
|
# Git rewrites a file as unlink, create, write: the kill lands inside one of those.
|
|
(root / "utils.py").write_text("NEW = 1\n", encoding="utf-8", newline="")
|
|
(root / "cut.py").unlink()
|
|
(root / "blank.py").write_bytes(b"")
|
|
(root / "half.py").write_bytes(b"h = 2 # long") # a multi-page write cut short
|
|
(root / "newpkg").mkdir()
|
|
(root / "newpkg" / "__init__.py").write_text("from utils import NEW\n", encoding="utf-8", newline="")
|
|
(root / ".git" / "index.lock").touch()
|
|
raise KeyboardInterrupt # SIGKILL: nothing after this line of the updater runs
|
|
return real(git_cmd, args, *rest, **kw)
|
|
|
|
monkeypatch.setattr(update_cmd, "_git_run", dying_git_run)
|
|
with pytest.raises(KeyboardInterrupt):
|
|
_pull(root)
|
|
monkeypatch.setattr(update_cmd, "_git_run", real)
|
|
assert _git(root, "rev-parse", "HEAD") == a # the torn state: HEAD old, some files already new
|
|
marker = er.interrupted_pull_marker(root)
|
|
recorded = marker.read_text(encoding="utf-8")
|
|
assert f"pid={os.getpid()}" in recorded and f"target={b}" in recorded # the commit, not the ref name
|
|
# The user re-applies their stash to a file the update also changes (git had not written it yet).
|
|
(root / "other.py").write_text("a = 1 # my edit\n", encoding="utf-8", newline="")
|
|
|
|
# Another `hermes` launched while an update is mid-pull must not race its git.
|
|
updater = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)"])
|
|
try:
|
|
marker.write_text(recorded.replace(f"pid={os.getpid()}", f"pid={updater.pid}"), encoding="utf-8",
|
|
newline="")
|
|
assert er.restore_interrupted_pull(root) is False
|
|
assert marker.exists() and (root / ".git" / "index.lock").exists()
|
|
finally:
|
|
updater.kill()
|
|
updater.wait()
|
|
|
|
# A retry in a container gets the killed updater's pid: our own pid is never a live owner.
|
|
marker.write_text(recorded, encoding="utf-8", newline="")
|
|
assert er.restore_interrupted_pull(root) is True, "restored files mean the caller must relaunch"
|
|
|
|
assert _git(root, "rev-parse", "HEAD") == a
|
|
assert _git(root, "status", "--porcelain", "--untracked-files=all") == "M other.py"
|
|
assert (root / "other.py").read_text(encoding="utf-8") == "a = 1 # my edit\n", "the user's edit survives"
|
|
assert not (root / "newpkg").exists() and not (root / ".git" / "index.lock").exists()
|
|
assert not marker.exists()
|
|
(root / "other.py").write_text("a = 1\n", encoding="utf-8", newline="")
|
|
_pull(root) # `hermes update` again: a normal fast-forward
|
|
assert _git(root, "rev-parse", "HEAD") == b and not marker.exists()
|
|
|
|
# Every console script (`hermes`, `hermes-agent`, `hermes-acp`) repairs before its entry module imports
|
|
# any other checkout module past hermes_bootstrap: any of them may be a half-written file.
|
|
repo = os.path.realpath(Path(er.__file__).parent.parent)
|
|
for entry in ("hermes_cli.main", "run_agent", "acp_adapter.entry"):
|
|
run = subprocess.run([sys.executable, "-c", _ENTRY_SPY, entry], cwd=repo, capture_output=True, text=True,
|
|
encoding="utf-8", env={**os.environ, "PYTHONPATH": repo}, timeout=120)
|
|
assert run.stdout.strip().splitlines()[-1:] == ["[]"], (entry, run.stdout[-500:], run.stderr[-2000:])
|
|
|
|
|
|
def test_restore_never_touches_user_work_when_git_wrote_nothing(checkout):
|
|
"""sys.exit on a merge conflict is not a kill, and a marker git never acted on restores nothing."""
|
|
root, a, b = checkout
|
|
_git(root, "config", "user.email", "t@example.invalid")
|
|
_git(root, "config", "user.name", "t")
|
|
_git(root, "checkout", "-q", "-b", "mywork")
|
|
(root / "other.py").write_text("a = 'mine'\n", encoding="utf-8", newline="")
|
|
_git(root, "commit", "-qam", "local work that conflicts upstream")
|
|
with pytest.raises(SystemExit):
|
|
_pull(root)
|
|
marker = er.interrupted_pull_marker(root)
|
|
assert not marker.exists()
|
|
|
|
# Even a leftover marker (an older updater, or a kill mid-reconcile) stays out of the user's way:
|
|
# following the printed advice leaves a merge in progress, and edits git never wrote are theirs.
|
|
stale = f"pid=0\npre={_git(root, 'rev-parse', 'HEAD')}\ntarget={b}\nstash=\n"
|
|
marker.write_text(stale, encoding="utf-8", newline="")
|
|
merge = subprocess.run(["git", "-C", str(root), "merge", "origin/main"],
|
|
capture_output=True, text=True, encoding="utf-8")
|
|
assert (root / ".git" / "MERGE_HEAD").exists(), merge.stdout + merge.stderr
|
|
(root / "utils.py").write_text("OLD = 1 # resolved by hand\n", encoding="utf-8", newline="")
|
|
before = _git(root, "status", "--porcelain", "--untracked-files=all")
|
|
assert er.restore_interrupted_pull(root) is False
|
|
assert _git(root, "status", "--porcelain", "--untracked-files=all") == before
|
|
_git(root, "reset", "-q", "--hard") # the user gives up on the merge
|
|
(root / "utils.py").write_text("OLD = 1 # my stash, re-applied\n", encoding="utf-8", newline="")
|
|
# tool.sh only changes mode upstream and git never reached it: nothing to restore, no relaunch.
|
|
assert er.restore_interrupted_pull(root) is False
|
|
assert (root / "utils.py").read_text(encoding="utf-8") == "OLD = 1 # my stash, re-applied\n"
|
|
assert not marker.exists(), "git wrote nothing: the marker is spent"
|
|
# A target git no longer knows (gc, re-clone) can never be compared against: drop the marker.
|
|
marker.write_text(stale.replace(b, "0" * 40), encoding="utf-8", newline="")
|
|
assert er.restore_interrupted_pull(root) is False and not marker.exists()
|
|
|
|
# Killed inside the custom-branch `git merge`: its files are the merge of both sides, not origin's
|
|
# blob, and still git's (torn ones too), while the user's own edit survives.
|
|
_git(root, "reset", "-q", "--hard", a)
|
|
(root / "multi.py").write_text(_MULTI.replace("end = 1", "end = 'mine'"), encoding="utf-8", newline="")
|
|
_git(root, "commit", "-qam", "local work that merges cleanly")
|
|
pre = _git(root, "rev-parse", "HEAD")
|
|
merged = _git(root, "merge-tree", "--write-tree", pre, b)
|
|
merged_multi = _git(root, "show", f"{merged}:multi.py") + "\n"
|
|
assert merged_multi == "top = 2\nx = 0\ny = 0\nz = 0\nend = 'mine'\n" # neither side's blob
|
|
(root / "multi.py").write_text(merged_multi, encoding="utf-8", newline="")
|
|
(root / "utils.py").write_text("NEW = 1\n", encoding="utf-8", newline="")
|
|
(root / "half.py").write_bytes(b"h = 2 # long")
|
|
(root / "other.py").write_text("a = 1 # my edit\n", encoding="utf-8", newline="")
|
|
marker.write_text(f"pid=0\npre={pre}\ntarget={b}\nstash=\n", encoding="utf-8", newline="")
|
|
assert er.restore_interrupted_pull(root) is True
|
|
assert _git(root, "rev-parse", "HEAD") == pre and not marker.exists()
|
|
assert _git(root, "status", "--porcelain", "--untracked-files=all") == "M other.py"
|