hermes-agent/hermes_cli/boot_bootstrap.py

335 lines
12 KiB
Python

"""Boot-time post-update bootstrap.
Every install kind (git checkout, desktop bundled payload, docker, nix)
compares two per-install facts at boot:
* current identity — the commit this install IS: ``install-stamp.json``
for sealed trees, ``.git/HEAD`` for checkouts. Reading it is a couple of
file reads, no subprocess.
* last-known identity — the commit this install last bootstrapped, recorded
under ``install-bootstrap/`` keyed by the canonical install root.
Equal → nothing happens (the fast path, ~2 ms). Different → run the
idempotent post-update steps from ``hermes_cli.post_update`` under a
single-flight lock, then record the new identity.
Two records, one per step scope:
* home record — ``get_hermes_home()/install-bootstrap/<key>.json``.
Gates home-scoped steps. HERMES_HOME moves per profile, so each profile
bootstraps its own state once per code change.
* machine record — ``<base home>/install-bootstrap/<key>.machine.json``,
anchored to the DEFAULT home (HOME-anchored, not HERMES_HOME-anchored —
the ``_get_profiles_root()`` convention). Every profile resolves the same
file, so machine-global steps run once per machine per code change and
the record's lock serializes concurrent profile boots.
The records are an optimization, never the correctness layer: every step is
idempotent and self-gating, so a deleted record costs one redundant slow
path, nothing more.
Design: .hermes/plans/2026-08-10_163500-boot-time-post-update-bootstrap.md.
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import time
from pathlib import Path
logger = logging.getLogger(__name__)
RECORD_SCHEMA_VERSION = 1
RECORD_DIR_NAME = "install-bootstrap"
LOCK_STALE_SECONDS = 600
# ---------------------------------------------------------------------------
# current identity
# ---------------------------------------------------------------------------
def read_git_head(root: Path) -> str | None:
"""The commit SHA of the checkout at ``root``, from files alone.
Worktree-aware: ``.git`` may be a FILE containing ``gitdir: <path>``
(linked worktrees). Symbolic HEAD is dereferenced through the loose ref,
then ``packed-refs``. Returns None when anything is missing or garbled.
"""
try:
git_path = Path(root) / ".git"
if git_path.is_file():
pointer = git_path.read_text(encoding="utf-8", errors="replace").strip()
if not pointer.startswith("gitdir:"):
return None
git_dir = Path(pointer[len("gitdir:"):].strip())
if not git_dir.is_absolute():
git_dir = (Path(root) / git_dir).resolve()
elif git_path.is_dir():
git_dir = git_path
else:
return None
head = (git_dir / "HEAD").read_text(encoding="utf-8", errors="replace").strip()
if not head.startswith("ref:"):
# Detached HEAD: the line is the SHA itself.
return head if len(head) >= 7 else None
ref = head[len("ref:"):].strip()
# Worktree gitdirs delegate shared refs to the parent repo via
# ``commondir`` (usually "../.."). HEAD itself stays per-worktree,
# but branch refs and packed-refs live in the common dir.
common = git_dir
commondir_file = git_dir / "commondir"
if commondir_file.is_file():
common_pointer = commondir_file.read_text(encoding="utf-8", errors="replace").strip()
common = (git_dir / common_pointer).resolve()
for base in (git_dir, common):
loose = base / ref
if loose.is_file():
sha = loose.read_text(encoding="utf-8", errors="replace").strip()
return sha if len(sha) >= 7 else None
for base in (common, git_dir):
packed = base / "packed-refs"
if not packed.is_file():
continue
for line in packed.read_text(encoding="utf-8", errors="replace").splitlines():
line = line.strip()
if not line or line.startswith(("#", "^")):
continue
parts = line.split(" ", 1)
if len(parts) == 2 and parts[1] == ref:
return parts[0]
return None
except OSError:
return None
def current_install_identity(project_root: Path) -> str | None:
"""What code this install is: stamp commit for sealed trees, git HEAD
for checkouts, None for broken trees (never bootstrap, never write)."""
from hermes_cli.runtime_tree import read_build_info
root = Path(project_root)
if (root / ".git").exists():
return read_git_head(root)
commit = read_build_info(root).get("commit")
if isinstance(commit, str) and len(commit) >= 7:
return commit
# A tagless/commitless stamp is a broken artifact; the tag alone is
# accepted as a weaker identity (bundled artifacts always carry one).
tag = read_build_info(root).get("tag")
return tag if isinstance(tag, str) and tag else None
# ---------------------------------------------------------------------------
# last-known records
# ---------------------------------------------------------------------------
def _install_key(project_root: Path) -> str:
try:
canonical = str(Path(project_root).resolve())
except OSError:
canonical = str(project_root)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:16]
def record_path(project_root: Path, scope: str) -> Path:
"""Where the last-known record for ``project_root`` lives.
``home`` scope follows the active HERMES_HOME (per profile). ``machine``
scope anchors to the default home so every profile shares one record.
"""
if scope == "home":
from hermes_constants import get_hermes_home
base = get_hermes_home()
suffix = ".json"
elif scope == "machine":
from hermes_cli.profiles import _get_default_hermes_home
base = _get_default_hermes_home()
suffix = ".machine.json"
else:
raise ValueError(f"unknown record scope: {scope!r}")
return base / RECORD_DIR_NAME / f"{_install_key(project_root)}{suffix}"
def read_last_known(path: Path) -> dict:
try:
data = json.loads(Path(path).read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return data if isinstance(data, dict) else {}
def _write_record(path: Path, identity: str, results: dict) -> None:
payload = {
"schemaVersion": RECORD_SCHEMA_VERSION,
"identity": identity,
"bootstrappedAt": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
"results": results,
}
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_name(path.name + ".tmp")
tmp.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
os.replace(tmp, path)
def write_record(project_root: Path, scope: str, identity: str, results: dict | None = None) -> None:
"""Record ``identity`` as bootstrapped. Also used by ``hermes update``
after it runs the steps itself, so the next boot skips."""
_write_record(record_path(project_root, scope), identity, results or {})
def needs_bootstrap(project_root: Path, scope: str) -> str | None:
"""The new identity when this install changed since its last bootstrap,
else None. None identity (broken tree) never bootstraps."""
identity = current_install_identity(project_root)
if not identity:
return None
known = read_last_known(record_path(project_root, scope))
if known.get("identity") == identity:
return None
return identity
# ---------------------------------------------------------------------------
# single-flight lock
# ---------------------------------------------------------------------------
class _RecordLock:
"""O_CREAT|O_EXCL existence-as-mutex next to a record file.
Losers skip (boot never waits on another process's bootstrap; the steps
are idempotent, so a botched winner only costs redundant work later).
A stale lock — older than LOCK_STALE_SECONDS — is broken and re-tried
once: a crashed winner died before its record write, so re-running is
correct.
"""
def __init__(self, record: Path):
self.path = record.with_name(record.name + ".lock")
self.acquired = False
def _try_create(self) -> bool:
try:
self.path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
return False
except OSError:
return False
try:
os.write(fd, json.dumps({"pid": os.getpid(), "startedAt": time.time()}).encode("utf-8"))
finally:
os.close(fd)
return True
def _is_stale(self) -> bool:
try:
body = json.loads(self.path.read_text(encoding="utf-8"))
started = float(body.get("startedAt", 0))
except (OSError, ValueError):
# Unreadable lock: age it by mtime instead.
try:
started = self.path.stat().st_mtime
except OSError:
return False
return (time.time() - started) > LOCK_STALE_SECONDS
def acquire(self) -> bool:
if self._try_create():
self.acquired = True
return True
if self._is_stale():
try:
self.path.unlink()
except OSError:
return False
if self._try_create():
self.acquired = True
return True
return False
def release(self) -> None:
if not self.acquired:
return
try:
self.path.unlink()
except OSError:
pass
self.acquired = False
# ---------------------------------------------------------------------------
# the boot entry point
# ---------------------------------------------------------------------------
def run_boot_bootstrap(project_root: Path) -> dict:
"""Run due home- and machine-scoped steps for this install. Returns a
summary dict (for tests/logs); use maybe_run_boot_bootstrap at call
sites."""
from hermes_cli import post_update
summary: dict = {"home": "skipped", "machine": "skipped"}
for scope, steps, deferred in (
("home", post_update.HOME_STEPS, False),
("machine", post_update.MACHINE_STEPS, True),
):
identity = needs_bootstrap(project_root, scope)
if not identity:
continue
record = record_path(project_root, scope)
lock = _RecordLock(record)
if not lock.acquire():
summary[scope] = "lost-race"
continue
try:
# Double-check under the lock: the previous holder may have
# finished between our read and our acquire.
if read_last_known(record).get("identity") == identity:
summary[scope] = "done-by-other"
continue
logger.info(
"post-update bootstrap (%s scope): code changed to %s, running steps",
scope, identity[:12],
)
if deferred:
# Slow machine steps (network installers) must not block
# boot readiness: record first, then run detached. A crash
# mid-step leaves the record written — intended: the record
# gates "did we trigger for this identity", and the steps
# re-gate themselves (confirmed-update checks) next change.
_write_record(record, identity, {"deferred": True})
import threading
threading.Thread(
target=post_update.run_steps,
args=(steps,),
name=f"hermes-bootstrap-{scope}",
daemon=True,
).start()
summary[scope] = "deferred"
else:
results = post_update.run_steps(steps)
_write_record(record, identity, results)
summary[scope] = results
finally:
lock.release()
return summary
def maybe_run_boot_bootstrap(project_root: Path) -> None:
"""The one call boot paths use. Never raises: a bootstrap problem must
not stop the gateway/serve/CLI from starting."""
try:
run_boot_bootstrap(Path(project_root))
except Exception as exc:
logger.warning("boot bootstrap failed (continuing boot): %s", exc)