Files
hermes-agent/hermes_cli/update_handoff.py
2026-09-21 21:18:57 -04:00

154 lines
6.4 KiB
Python

"""Frozen compat surface for releases that finish `hermes update` via the post-swap hand-off.
Releases from 2026-09-16 (94ced1a2b2) lazily import ``hermes_cli.update_handoff``
from the NEW tree after the checkout swap. Like every other retired updater
hook, this module keeps that import working by routing into the historical
takeover (``hermes_cli._old_updater`` → ``_update_takeover.py``) instead of
re-executing ``hermes update --post-swap``; the pulled tree is never imported
into the pre-pull interpreter.
Guarded by tests/compat/old_updater_surface.json — do not remove a public name
without regenerating the frozen surface and proving no shipped release loads it.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import time
from pathlib import Path
from typing import Any
# Set on the post-swap child: the receipt header says "continued", the lock is the parent's.
POST_SWAP_ENV = "HERMES_UPDATE_POST_SWAP"
def is_post_swap_child() -> bool:
return os.environ.get(POST_SWAP_ENV) == "1"
def write_handoff(payload: dict[str, Any]) -> Path:
"""Persist the post-swap payload under HERMES_HOME; returns its path."""
from hermes_constants import get_hermes_home
directory = get_hermes_home() / "logs" / "update_receipts"
directory.mkdir(parents=True, exist_ok=True)
path = directory / f"post_swap_{os.getpid()}.json"
path.write_text(json.dumps(payload, indent=2, default=str), encoding="utf-8")
return path
def read_handoff(path: str | Path) -> dict[str, Any]:
payload = json.loads(Path(path).read_text(encoding="utf-8-sig"))
if not isinstance(payload, dict):
raise ValueError(f"post-swap hand-off {path} is not a JSON object")
return payload
def post_swap_python() -> Path:
"""Interpreter for the child: the project venv's python when this process runs from (or
under) the project's Windows console shim — the shim can never be re-executed on Windows
because it holds itself open for the whole process lifetime (#88838, #89599) — else the
running interpreter."""
if sys.platform != "win32":
return Path(sys.executable)
from hermes_cli._launchers import _is_windows
from hermes_constants import project_venv_dir
from pm.environments import venv_python
venv_dir = project_venv_dir(Path(__file__).resolve().parents[1])
if venv_dir is not None and _is_windows():
candidate = venv_python(venv_dir, windows=True)
if candidate.is_file():
return candidate
return Path(sys.executable)
def post_swap_command(handoff_path: Path, argv_tail: list[str]) -> list[str]:
"""``python -m hermes_cli.main update <original flags> --post-swap <file>``.
Historical command shape; kept so a printed manual-continuation line reads
exactly as older releases expect. The takeover child this tree actually
spawns (see :func:`continue_update_in_fresh_interpreter`) ignores the flag.
"""
return [str(post_swap_python()), "-m", "hermes_cli.main", "update", *argv_tail,
"--post-swap", str(handoff_path)]
def post_swap_child_env() -> dict[str, str]:
"""Environment for the child. ``HERMES_UPDATE_REEXEC`` marks it as already off the Windows
shim (no second re-exec at the sync boundary). The lock hand-off pid is only claimed when
nobody upstream (Tauri/Electron updater) already named theirs."""
from hermes_cli.update_lock import HANDOFF_PID_ENV
env = {**os.environ, POST_SWAP_ENV: "1", "HERMES_UPDATE_REEXEC": "1"}
env.setdefault(HANDOFF_PID_ENV, str(os.getpid()))
return env
def _takeover_request(payload: dict[str, Any], argv_tail: list[str] | None) -> dict[str, Any]:
"""Map a historical post-swap payload onto the takeover request schema.
Payloads written before the takeover seam (e.g. release 0.21.3) name the
desktop flag and the Windows resume token differently and carry no
assume_yes at all; `update_finish.py` reads the new names with bare
subscriptions, so an unmapped payload KeyErrors mid-completion with the
checkout already swapped. New-schema payloads pass through unchanged.
"""
request = dict(payload)
request.setdefault("desktop", bool(payload.get("had_desktop_app_before_update", False)))
request.setdefault("windows_resume", payload.get("windows_gateway_resume"))
request.setdefault("assume_yes", "--yes" in (argv_tail or []))
request.setdefault("restart_update", False)
return request
def _continue_legacy_post_swap(handoff_path: str | Path, *, argv_tail: list[str]) -> int:
"""Complete the command shape shipped before PM owned source updates.
Those releases start the replacement checkout as ``hermes update <flags>
--post-swap FILE``. The replacement bootstrap calls this before importing
its PM or CLI graph, then the existing takeover child prepares and finishes
the update entirely on replacement code.
"""
payload = read_handoff(handoff_path)
try:
Path(handoff_path).unlink()
except OSError:
pass
from hermes_cli._old_updater import _run_child
code, _completed = _run_child(_takeover_request(payload, argv_tail))
return int(code)
def continue_update_in_fresh_interpreter(payload: dict[str, Any], *, argv_tail: list[str] | None = None) -> int | None:
"""Run the post-swap tail in a child interpreter on the pulled code.
The old caller's payload carries the same fields the completion request
needs (receipt, plan, windows_resume, gateway_mode, ...); ``argv_tail`` is
accepted and ignored because the takeover tail does not re-parse update
flags. Returns the child's exit code, or ``None`` when no child could be
started (the caller then owns the failure bookkeeping). Ctrl-C semantics
mirror the historical hand-off: wait for the child's cleanup instead of
subprocess.run's kill-on-interrupt.
"""
from hermes_cli._old_updater import _run_child
handoff_path = write_handoff(payload)
request = _takeover_request(payload, argv_tail)
cmd = post_swap_command(handoff_path, argv_tail or [])
print(f"→ Post-swap hand-off: completing the update in a fresh interpreter ({handoff_path})")
sys.stdout.flush()
sys.stderr.flush()
try:
code, _completed = _run_child(request)
return int(code)
except OSError as exc:
print(f" ⚠ Could not start the post-update interpreter: {exc}")
print(" The code update is applied. Finish it with:")
print(f" {subprocess.list2cmdline(cmd)}")
return None