Files
hermes-agent/hermes_cli/_old_updater.py
ethernet 1524a30ec9 fix(update): let the Windows takeover child adopt its orchestrator's update marker
A desktop hand-off on Windows stalled at "Another Hermes update is already
running (process <the hand-off itself>)". The hand-off PowerShell claims the
marker and runs the old `hermes update`, whose _old_updater shim spawns
_update_takeover.py as python -I -S -B. HERMES_UPDATE_HANDOFF_PID names the
old updater, not the marker owner, so adoption relies on the ancestry walk;
without psutil (-S) that walk falls back to /proc or ps, and Windows has
neither, so the grandchild refused its own orchestrator and exited 2.

Read the parent pid from a Toolhelp32 snapshot through stdlib ctypes, and
treat a parent created after its child as a recycled pid, as psutil does.
Reproduced and verified on a real Windows venv: the grandchild now adopts
its ancestor's marker and still refuses an unrelated live holder.

The regression tests were unmarked, and the Windows lane imports only files
carrying a platforms marker, so the Windows half never ran: mark both
stdlib-walk tests for every lane.

Also print a line when the old updater hands off to the new (PM) updater,
so logs show where the switch happened.
2026-09-23 12:02:05 -04:00

159 lines
7.1 KiB
Python

"""The newly imported escape hatch from an already-running historical updater.
This module must remain stdlib-only: the parent still has the old interpreter,
modules and native extensions. Only the child imports the updated application.
"""
from __future__ import annotations
import dataclasses
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
from typing import Any, NoReturn
_result: int | None = None
def _historical_context() -> tuple[dict, list[dict], Any]:
"""Carry data already held by old frames, without importing their modules.
Older callers did not pass a continuation object. Read only the historical
updater's known local names; do not serialize frames or arbitrary globals.
"""
names = ("had_desktop_app_before_update", "pre_update_snapshot_id",
"pre_update_version", "gateway_mode", "assume_yes", "_pre_update_plan")
found = {}
resumes = []
restart_update = None
frame = sys._getframe(1)
try:
while frame is not None:
# Capture hooks can be imported BEFORE the old updater has pulled.
# A declared-but-unassigned version in its innermost known frame
# proves early entry; a None value is still a post-capture value.
if (restart_update is None
and frame.f_globals.get("__name__") in ("hermes_cli.update_cmd", "hermes_cli.main")
and frame.f_code.co_name in ("_cmd_update_impl", "cmd_update")
and "pre_update_version" in frame.f_code.co_varnames):
restart_update = "pre_update_version" not in frame.f_locals
for name in names:
if name not in found and name in frame.f_locals:
found[name] = frame.f_locals[name]
token = frame.f_locals.get("_windows_gateway_resume")
if isinstance(token, dict) and not any(token is item for item in resumes):
resumes.append(token)
frame = frame.f_back
finally:
del frame
receipt_module = sys.modules.get("hermes_cli.update_receipt")
receipt_slot = vars(receipt_module).get("_current") if receipt_module else None
get_current = getattr(receipt_slot, "get", None)
current = get_current() if get_current is not None else receipt_slot
receipt = getattr(current, "data", None)
plan = found.get("_pre_update_plan")
if dataclasses.is_dataclass(plan) and not isinstance(plan, type):
plan = dataclasses.asdict(plan)
elif not isinstance(plan, dict):
plan = receipt.get("plan") if isinstance(receipt, dict) else None
return {
"restart_update": restart_update is True,
# Some old updaters reach this hook before checking Desktop at all.
"desktop": found.get("had_desktop_app_before_update"),
"pre_update_snapshot_id": found.get("pre_update_snapshot_id"),
"pre_update_version": found.get("pre_update_version"),
"gateway_mode": bool(found.get("gateway_mode", "--gateway" in sys.argv)),
"assume_yes": bool(found.get("assume_yes", "--yes" in sys.argv)),
"windows_resume": resumes[0] if resumes else None,
"plan": plan,
"receipt": receipt if isinstance(receipt, dict) else None,
}, resumes, receipt_slot
def _run_child(request: dict) -> tuple[int, dict]:
"""One JSON exchange; the old process never imports the updated graph."""
root = Path(__file__).resolve().parents[1]
home = os.environ.get("HERMES_HOME")
constants = sys.modules.get("hermes_constants")
override = vars(constants).get("_HERMES_HOME_OVERRIDE") if constants else None
if override is not None:
value = override.get()
if isinstance(value, (str, Path)) and value:
home = str(value)
request.update(root=str(root), home=home, argv=list(sys.argv))
env = {key: value for key, value in os.environ.items()
if not key.startswith(("PYTHON", "UV_")) and key != "VIRTUAL_ENV"}
if home:
env["HERMES_HOME"] = home
env.setdefault("HERMES_UPDATE_HANDOFF_PID", str(os.getpid()))
# Everything the user saw so far came from the OLD updater; say so before the
# new one (package manager) takes over, so logs show where the switch happened.
print("→ Handing off to the new updater (package manager) for the rest of this update...", flush=True)
with tempfile.TemporaryDirectory(prefix="hermes-update-takeover-") as directory:
context = Path(directory) / "request.json"
result = Path(directory) / "result.json"
context.write_text(json.dumps(request), encoding="utf-8")
child = subprocess.run(
[sys.executable, "-I", "-S", "-B", "-X", "utf8", str(root / "hermes_cli/_update_takeover.py"),
str(context), str(result)], cwd=root, env=env,
)
completed = json.loads(result.read_text(encoding="utf-8-sig")) if result.is_file() else {}
if not isinstance(completed, dict):
raise ValueError("invalid update takeover acknowledgement")
return child.returncode if child.returncode >= 0 else 1, completed
def stop_for_relaunch(*, incomplete: bool = False) -> NoReturn:
"""Finish in a fresh child, then exit rather than resume an old fallback.
The historical name is retained. A finally/atexit path can call another
shim while unwinding; it must receive the same result, not start again.
"""
if incomplete:
# A newly retired completion hook has no complete captured worklist.
# It must not start another update or invent a successful receipt.
print(
"You're updating from an older version of Hermes Agent. "
"To complete this update, run `hermes update` again.",
file=sys.stderr,
)
raise SystemExit(1)
global _result
if _result is not None:
raise SystemExit(_result)
_result = 1
try:
request, resumes, receipt_slot = _historical_context()
_result, completed = _run_child(request)
if completed.get("resume_handled"):
for token in resumes:
token["resume_needed"] = False
if completed.get("receipt_handled"):
if hasattr(receipt_slot, "set"):
receipt_slot.set(None)
elif receipt_slot is not None:
vars(sys.modules["hermes_cli.update_receipt"])["_current"] = None
except (OSError, ValueError, TypeError) as exc:
_result = 1
print(f"Update takeover failed: {exc}", file=sys.stderr, flush=True)
raise SystemExit(_result)
def relaunch_stopped_serves(token: dict) -> None:
"""A historical atexit token is separate work, not another whole update."""
if not token.get("pending"):
return
code = 1
try:
code, completed = _run_child({"stopped_serves": token})
if completed.get("serves_handled") is True:
token["pending"] = False
except (OSError, ValueError, TypeError) as exc:
print(f"Stopped serve recovery failed: {exc}", file=sys.stderr, flush=True)
if code or token.get("pending"):
print("Stopped serve recovery did not complete; restart the affected "
"serve/dashboard backends manually.", file=sys.stderr, flush=True)