Files
hermes-agent/hermes_cli/update_handoff.py
teknium1 bec8924594 fix(update): hand-off carries sibling snapshots + Windows pause token on both paths; child pinned to the parent's home
Independent review of the post-swap hand-off found state that did not cross or was
handled by the wrong side:

- update_cmd_config._LAST_SIBLING_SNAPSHOTS is rebound by the pre-update backup in the
  parent; the child read an empty dict and the sibling-profile cron/model-settings safety
  nets silently did nothing. It now rides in the payload.
- The ZIP path handed off without the Windows pause token, and the parent's finally/atexit
  resumed the paused gateways while the child was reinstalling the venv. The token now
  crosses on both paths; the parent flips resume_needed off only after a child actually ran.
- The child inherits HERMES_HOME but re-read the sticky active_profile, finishing a
  `-p default` update inside another profile's home. _apply_profile_override keeps the
  parent's resolved home for the post-swap child.
- The child env now also sets HERMES_UPDATE_REEXEC so cmd_update's Windows hard-exit tail
  covers it and the shim re-exec can never fire a second time from inside it; the
  hand-off pid is only claimed when no upstream updater (Tauri/Electron) already named
  one.
- Popen + wait instead of subprocess.run: Ctrl-C no longer kills the child (which owns the
  receipt and the gateway resume) 250 ms after the interrupt.
- A child that cannot start: the parent takes the receipt back, records the failed step,
  writes .update-incomplete and the gateway exit code, exits 1.
- The child consumes the hand-off file and builds git_cmd without re-running the checkout
  preflight (no second fork banner).
2026-09-17 00:02:09 -07:00

163 lines
6.9 KiB
Python

"""Post-swap hand-off: finish ``hermes update`` in an interpreter born on the pulled code.
``hermes update`` starts in an interpreter that imported the PRE-pull tree. Once ``git merge``
(or the ZIP swap) has replaced the checkout, every later phase — dependency sync, Node/web/
Desktop builds, maintenance, fleet restart, verification, receipt — used to keep running in
that stale process and lazily import NEW source into an OLD ``sys.modules`` graph. Any rename
between the two commits then surfaced as an ``ImportError``/``AttributeError`` inside the
updater itself, after the code swap had already succeeded (#87134, #112465, #112558, #112604
and their siblings). Module purges and targeted reloads only ever moved the crash to the next
unpurged module.
The permanent shape: the pre-pull process stops at the swap, writes everything the tail needs
into a hand-off file and re-executes ``hermes update --post-swap <file>`` under the venv
interpreter. The child imports exclusively from the pulled tree, resumes the open receipt and
owns the rest of the run; the parent relays its exit code. Nothing in the updater runs pulled
code inside a pre-pull interpreter any more, so there is no stale-symbol class left to isolate.
"""
from __future__ import annotations
import json
import logging
import os
import subprocess
import sys
from pathlib import Path
from typing import Any
from hermes_cli.update_cmd_common import _best_effort
logger = logging.getLogger("hermes_cli.update_cmd")
# 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 _json_default(value: Any):
if isinstance(value, (set, frozenset)):
return sorted(value, key=str)
if isinstance(value, Path):
return str(value)
raise TypeError(f"not JSON serializable: {type(value).__name__}")
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=_json_default), encoding="utf-8")
return path
def read_handoff(path: str | Path) -> dict[str, Any]:
payload = json.loads(Path(path).read_text(encoding="utf-8"))
if not isinstance(payload, dict):
raise ValueError(f"post-swap hand-off {path} is not a JSON object")
return payload
def is_post_swap_child() -> bool:
return os.environ.get(POST_SWAP_ENV) == "1"
def _running_from_windows_shim() -> bool:
from hermes_cli.main_install_repair import _windows_shim_in_process_chain
return _windows_shim_in_process_chain() is not None
def post_swap_python() -> Path:
"""Interpreter for the child: the project venv's python (the console shim can never be
re-executed on Windows — it holds itself open), else the running interpreter."""
from hermes_cli.main_install_repair import _windows_shim_in_process_chain
shim = _windows_shim_in_process_chain()
if shim is not None:
from hermes_constants import venv_python_path
candidate = venv_python_path(shim.parent.parent, 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>``."""
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; ``cmd_update`` hard-exits it when its receipt
is durable instead of waiting on a leftover non-daemon thread). The lock hand-off pid is
only claimed when nobody upstream (Tauri/Electron updater) already named theirs."""
from hermes_cli.main_install_repair import _UPDATE_REEXEC_ENV
from hermes_cli.update_lock import HANDOFF_PID_ENV
env = {**os.environ, POST_SWAP_ENV: "1", _UPDATE_REEXEC_ENV: "1"}
env.setdefault(HANDOFF_PID_ENV, str(os.getpid()))
return env
def _print_manual_continuation(cmd: list[str], exc: OSError) -> None:
logger.warning("Post-swap hand-off could not start: %s", 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)}")
def continue_update_in_fresh_interpreter(payload: dict[str, Any], *, argv_tail: list[str]) -> int | None:
"""Run the post-swap tail in a child interpreter on the pulled code.
Returns the child's exit code, or ``None`` when no child could be started (the caller then
owns the failure bookkeeping). The parent has already detached from the receipt (the child
resumes it) and only relays the exit code. On Windows, when this process runs from
``hermes.exe``, the child cannot be awaited: the shim is one of the files the dependency
sync must replace and it stays open for as long as this process lives (#88838, #89599).
That case spawns detached, prints where the run continues and returns 0 — the child prints
its own result and ``--gateway`` writes the true exit code to ``.update_exit_code``.
Ctrl-C reaches parent and child together; the child owns the receipt and the Windows
gateway resume, so the parent keeps waiting for it instead of ``subprocess.run``'s
kill-on-interrupt, which would cut it off mid-cleanup.
"""
handoff_path = write_handoff(payload)
cmd = post_swap_command(handoff_path, argv_tail)
env = post_swap_child_env()
logger.debug("Post-swap hand-off → %s", subprocess.list2cmdline(cmd))
sys.stdout.flush()
sys.stderr.flush()
if _running_from_windows_shim():
try:
subprocess.Popen(cmd, env=env, stdin=subprocess.DEVNULL)
except OSError as exc:
_print_manual_continuation(cmd, exc)
return None
print("→ Windows: hermes.exe cannot replace itself while it runs; the update")
print(" continues under the venv Python. The code update is already applied and")
print(" this shell returns right away; the install finishes below.")
return 0
try:
child = subprocess.Popen(cmd, env=env, stdin=sys.stdin)
except OSError as exc:
_print_manual_continuation(cmd, exc)
return None
try:
return int(child.wait())
except KeyboardInterrupt:
print("\n Interrupted — waiting for the update child to finish its cleanup...")
try:
return int(child.wait(timeout=60))
except subprocess.TimeoutExpired:
child.terminate()
return 130
except KeyboardInterrupt:
child.terminate()
return 130