hermes-agent/tests/hermes_cli/test_boot_bootstrap.py

326 lines
11 KiB
Python

"""Boot-time post-update bootstrap: identity, records, locks, single-flight.
The record files are an optimization layer over idempotent steps; these
tests assert the contracts that keep that safe: identity resolution from
real git trees and stamps, record scoping (per-install AND per-home vs
per-machine), and the lock protocol including the double-check under lock.
"""
import json
import os
import subprocess
import time
from pathlib import Path
import pytest
from hermes_cli import boot_bootstrap
from hermes_cli.boot_bootstrap import (
_RecordLock,
current_install_identity,
needs_bootstrap,
read_git_head,
read_last_known,
record_path,
run_boot_bootstrap,
write_record,
)
def _git(args, cwd):
env = dict(os.environ)
env.update({
"GIT_AUTHOR_NAME": "t", "GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t", "GIT_COMMITTER_EMAIL": "t@t",
"GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_SYSTEM": os.devnull,
})
return subprocess.run(
["git", *args], cwd=cwd, env=env, capture_output=True, text=True, check=True
)
@pytest.fixture
def repo(tmp_path):
root = tmp_path / "repo"
root.mkdir()
_git(["init", "-b", "main"], root)
(root / "f.txt").write_text("1", encoding="utf-8")
_git(["add", "."], root)
_git(["commit", "-m", "one"], root)
return root
def _head_sha(root):
return _git(["rev-parse", "HEAD"], root).stdout.strip()
# ── read_git_head ────────────────────────────────────────────────────
def test_read_git_head_branch_ref(repo):
assert read_git_head(repo) == _head_sha(repo)
def test_read_git_head_detached(repo):
sha = _head_sha(repo)
_git(["checkout", "--detach", sha], repo)
assert read_git_head(repo) == sha
def test_read_git_head_packed_refs(repo):
sha = _head_sha(repo)
_git(["pack-refs", "--all"], repo)
# Loose ref is gone; only packed-refs carries the branch now.
assert not (repo / ".git" / "refs" / "heads" / "main").exists()
assert read_git_head(repo) == sha
def test_read_git_head_worktree_gitfile(repo, tmp_path):
wt = tmp_path / "wt"
_git(["worktree", "add", str(wt)], repo)
assert (wt / ".git").is_file() # gitfile pointer, not a directory
assert read_git_head(wt) == _head_sha(wt)
def test_read_git_head_missing_and_garbage(tmp_path):
assert read_git_head(tmp_path) is None
(tmp_path / ".git").write_text("not a gitdir pointer", encoding="utf-8")
assert read_git_head(tmp_path) is None
# ── current_install_identity ─────────────────────────────────────────
def test_identity_prefers_git(repo):
assert current_install_identity(repo) == _head_sha(repo)
def test_identity_sealed_stamp(tmp_path):
(tmp_path / "install-stamp.json").write_text(
json.dumps({"commit": "a" * 40, "distribution": "desktop-app"}),
encoding="utf-8",
)
assert current_install_identity(tmp_path) == "a" * 40
def test_identity_broken_tree_is_none(tmp_path):
assert current_install_identity(tmp_path) is None
(tmp_path / "install-stamp.json").write_text("garbage", encoding="utf-8")
assert current_install_identity(tmp_path) is None
# ── record paths ─────────────────────────────────────────────────────
def test_record_paths_key_on_install_root(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
a = record_path(tmp_path / "install-a", "home")
b = record_path(tmp_path / "install-b", "home")
assert a != b
assert a.parent == b.parent # same dir, different keys
def test_home_records_differ_per_profile_machine_record_shared(tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
base = tmp_path / ".hermes"
profile = base / "profiles" / "coder"
install = tmp_path / "install"
monkeypatch.setenv("HERMES_HOME", str(base))
home_default = record_path(install, "home")
machine_default = record_path(install, "machine")
monkeypatch.setenv("HERMES_HOME", str(profile))
home_profile = record_path(install, "home")
machine_profile = record_path(install, "machine")
assert home_default != home_profile # each profile bootstraps its own home
assert machine_default == machine_profile # machine record is shared
def test_record_path_rejects_unknown_scope(tmp_path):
with pytest.raises(ValueError):
record_path(tmp_path, "galaxy")
def test_symlinked_root_canonicalizes(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
real = tmp_path / "real-install"
real.mkdir()
link = tmp_path / "link-install"
link.symlink_to(real)
assert record_path(real, "home") == record_path(link, "home")
# ── needs_bootstrap ──────────────────────────────────────────────────
def test_needs_bootstrap_lifecycle(repo, tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
sha = _head_sha(repo)
# No record yet → identity returned.
assert needs_bootstrap(repo, "home") == sha
write_record(repo, "home", sha)
assert needs_bootstrap(repo, "home") is None
# New commit → mismatch again.
(repo / "f.txt").write_text("2", encoding="utf-8")
_git(["add", "."], repo)
_git(["commit", "-m", "two"], repo)
assert needs_bootstrap(repo, "home") == _head_sha(repo)
def test_needs_bootstrap_broken_tree_never_fires(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
assert needs_bootstrap(tmp_path / "nope", "home") is None
# ── lock protocol ────────────────────────────────────────────────────
def test_lock_loser_skips(tmp_path):
record = tmp_path / "r.json"
first = _RecordLock(record)
second = _RecordLock(record)
assert first.acquire()
assert not second.acquire()
first.release()
assert second.acquire()
second.release()
def test_stale_lock_is_broken(tmp_path):
record = tmp_path / "r.json"
lock_path = record.with_name(record.name + ".lock")
lock_path.parent.mkdir(parents=True, exist_ok=True)
lock_path.write_text(
json.dumps({"pid": 1, "startedAt": time.time() - 3600}), encoding="utf-8"
)
lock = _RecordLock(record)
assert lock.acquire()
lock.release()
def test_fresh_lock_is_respected(tmp_path):
record = tmp_path / "r.json"
lock_path = record.with_name(record.name + ".lock")
lock_path.parent.mkdir(parents=True, exist_ok=True)
lock_path.write_text(
json.dumps({"pid": os.getpid(), "startedAt": time.time()}), encoding="utf-8"
)
assert not _RecordLock(record).acquire()
# ── run_boot_bootstrap ───────────────────────────────────────────────
@pytest.fixture
def fake_steps(monkeypatch):
calls = {"home": 0, "machine": 0}
def home_step():
calls["home"] += 1
return {"ok": True}
def machine_step():
calls["machine"] += 1
return {"ok": True}
from hermes_cli import post_update
monkeypatch.setattr(post_update, "HOME_STEPS", (("h", home_step),))
monkeypatch.setattr(post_update, "MACHINE_STEPS", (("m", machine_step),))
# Machine steps run on a thread; make them synchronous for the test.
# boot_bootstrap does `import threading` inside the function, so patching
# the stdlib module's Thread attribute is what reaches it.
import threading
class _SyncThread:
def __init__(self, target=None, args=(), **kw):
self._target, self._args = target, args
def start(self):
if self._target is not None:
self._target(*self._args)
monkeypatch.setattr(threading, "Thread", _SyncThread)
return calls
def test_run_boot_bootstrap_runs_then_noops(repo, tmp_path, monkeypatch, fake_steps):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
first = run_boot_bootstrap(repo)
assert fake_steps == {"home": 1, "machine": 1}
assert first["home"] == {"h": {"ok": True}}
assert first["machine"] == "deferred"
second = run_boot_bootstrap(repo)
assert fake_steps == {"home": 1, "machine": 1} # no re-run
assert second == {"home": "skipped", "machine": "skipped"}
def test_machine_step_runs_once_across_profiles(repo, tmp_path, monkeypatch, fake_steps):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
base = tmp_path / ".hermes"
monkeypatch.setenv("HERMES_HOME", str(base))
run_boot_bootstrap(repo)
monkeypatch.setenv("HERMES_HOME", str(base / "profiles" / "coder"))
run_boot_bootstrap(repo)
# Each home bootstraps itself; the machine step fires once.
assert fake_steps == {"home": 2, "machine": 1}
def test_step_failure_still_writes_record(repo, tmp_path, monkeypatch):
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
from hermes_cli import post_update
def boom():
raise RuntimeError("step exploded")
monkeypatch.setattr(post_update, "HOME_STEPS", (("boom", boom),))
monkeypatch.setattr(post_update, "MACHINE_STEPS", ())
run_boot_bootstrap(repo)
record = read_last_known(record_path(repo, "home"))
assert record["identity"] == _head_sha(repo)
assert record["results"]["boom"]["ok"] is False
# A broken step must not retrigger the slow path every boot.
assert needs_bootstrap(repo, "home") is None
def test_double_check_under_lock(repo, tmp_path, monkeypatch, fake_steps):
"""A racer that finished between our read and our acquire wins."""
monkeypatch.setattr(Path, "home", lambda: tmp_path)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
sha = _head_sha(repo)
real_acquire = _RecordLock.acquire
def acquire_after_racer_finished(self):
got = real_acquire(self)
if got and self.path.name.endswith(".json.lock"):
# Simulate the previous holder completing just before us.
write_record(repo, "home", sha)
return got
monkeypatch.setattr(_RecordLock, "acquire", acquire_after_racer_finished)
result = run_boot_bootstrap(repo)
assert result["home"] == "done-by-other"
assert fake_steps["home"] == 0
def test_maybe_run_never_raises(monkeypatch, tmp_path):
monkeypatch.setattr(
boot_bootstrap, "run_boot_bootstrap",
lambda root: (_ for _ in ()).throw(RuntimeError("boom")),
)
boot_bootstrap.maybe_run_boot_bootstrap(tmp_path) # must not raise