Rework of the #124676 salvage onto the canonical fleet scope instead of a second path heuristic: - the pause, the cold-start guard and the post-relaunch readiness poll all filter the host-wide find_gateway_pids(all_profiles=True) scan through update_cmd_fleet._scoped_manual_gateway_pids, the same home scope the POSIX fleet restart uses (#93349). A PM install's venv lives under installs/, so exe-under-PROJECT_ROOT rarely proved an own gateway; its live HERMES_HOME (or the LOCALAPPDATA default) always does. - a foreign gateway with no HERMES_HOME resolves to its own default home and no longer blocks this install's cold start (#124659, second bullet). - a gateway whose home cannot be read is named in the pause output instead of skipped silently; the spawn ledger's verified (pid, create_time) entry proves an own gateway whose environment is unreadable. - tests trimmed to two invariants on real processes; a real-Windows journey (two installs, one updates while the other serves) replaces the harness comment that documented the bug.
162 lines
7.0 KiB
Python
162 lines
7.0 KiB
Python
"""Home scoping for ``hermes update``'s fleet restart (#93349).
|
|
|
|
The restart phase enumerates ``hermes-gateway*``/``hermes-serve*`` units, ``ai.hermes.gateway*``
|
|
LaunchAgents and every ``gateway run`` process on the host. Those are HOST-wide namespaces: a
|
|
second Hermes install (another ``HERMES_HOME`` root under the same account, its own checkout and
|
|
venv) shares them, and the update used to restart that install's gateway too — including the
|
|
account's real ``hermes-gateway.service`` when a scratch home ran ``hermes update``.
|
|
|
|
The fleet an update owns is the set of homes its plan inventories: the updating root plus every
|
|
``<root>/profiles/<name>``. Ownership is judged from what a runtime actually runs on — the live
|
|
process environment (``HERMES_HOME``/``HOME``, via ``_hermes_home_for_pid``), the unit's declared
|
|
``Environment=`` or the plist's pinned ``HERMES_HOME`` — never from a unit label or an argv
|
|
substring. Unknown ownership is left alone: a restart we cannot prove is ours is somebody else's
|
|
outage.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import shlex
|
|
from contextlib import suppress
|
|
from pathlib import Path
|
|
|
|
|
|
def _resolved(path) -> Path | None:
|
|
try:
|
|
return Path(str(path)).expanduser().resolve()
|
|
except (OSError, RuntimeError, ValueError):
|
|
return None
|
|
|
|
|
|
def update_scope_homes() -> set[Path]:
|
|
"""Resolved homes the running update owns: the invoking home, the install root and its profiles."""
|
|
homes: set[Path] = set()
|
|
with suppress(Exception):
|
|
from hermes_constants import get_hermes_home
|
|
if (home := _resolved(get_hermes_home())) is not None:
|
|
homes.add(home)
|
|
with suppress(Exception):
|
|
from hermes_cli.update_receipt import _profile_homes
|
|
for _profile, home in _profile_homes():
|
|
if (resolved := _resolved(home)) is not None:
|
|
homes.add(resolved)
|
|
return homes
|
|
|
|
|
|
def home_in_update_scope(home, scope: set[Path] | None = None) -> bool:
|
|
"""True when *home* (a path or string) is one of the homes this update owns."""
|
|
if not home:
|
|
return False
|
|
resolved = _resolved(home)
|
|
if resolved is None:
|
|
return False
|
|
return resolved in (update_scope_homes() if scope is None else scope)
|
|
|
|
|
|
def _ledger_gateway_home(pid: int) -> str | None:
|
|
"""Home this install's spawn ledger records for gateway *pid* (``register_self`` at gateway boot),
|
|
only for an exact live ``(pid, create_time)`` match. Proof for a gateway whose environment is
|
|
unreadable (elevated, or another account's service) while its create time still is."""
|
|
with suppress(Exception):
|
|
from hermes_cli.process_identity import ledger_entries
|
|
for entry in ledger_entries(verified_only=True):
|
|
if entry.get("pid") == pid and entry.get("purpose") == "gateway":
|
|
return entry.get("hermes_home") or None
|
|
return None
|
|
|
|
|
|
def gateway_pid_in_update_scope(pid: int, scope: set[Path] | None = None) -> bool | None:
|
|
"""Does gateway *pid* run on a home this update owns? ``None`` when its home cannot be read."""
|
|
from hermes_cli.dashboard_procs import _hermes_home_for_pid
|
|
try:
|
|
home = _hermes_home_for_pid(pid)
|
|
except Exception:
|
|
home = None
|
|
home = home or _ledger_gateway_home(pid)
|
|
if home is None:
|
|
return None
|
|
return home_in_update_scope(home, scope)
|
|
|
|
|
|
def partition_gateway_pids_by_scope(pids, scope: set[Path] | None = None) -> tuple[list[int], list[tuple[int, str | None]]]:
|
|
"""``(owned, foreign)`` split of *pids*; ``foreign`` pairs each PID with its home (None = unreadable)."""
|
|
scope = update_scope_homes() if scope is None else scope
|
|
owned: list[int] = []
|
|
foreign: list[tuple[int, str | None]] = []
|
|
for pid in pids:
|
|
verdict = gateway_pid_in_update_scope(pid, scope)
|
|
if verdict:
|
|
owned.append(pid)
|
|
else:
|
|
home = None
|
|
if verdict is False:
|
|
with suppress(Exception):
|
|
from hermes_cli.dashboard_procs import _hermes_home_for_pid
|
|
home = _hermes_home_for_pid(pid)
|
|
foreign.append((pid, home))
|
|
return owned, foreign
|
|
|
|
|
|
def systemd_unit_hermes_home(scope_cmd: list, svc_name: str) -> str | None:
|
|
"""Home the systemd unit *svc_name* runs on: its live MainPID's environment first, then the
|
|
unit's declared ``Environment=HERMES_HOME``; for a user-scope unit that declares none, the
|
|
user's own default home. ``None`` when nothing readable names a home."""
|
|
from hermes_cli.update_cmd_fleet import _systemctl, _unit_main_pid
|
|
|
|
pid = _unit_main_pid(scope_cmd, svc_name)
|
|
if pid > 0:
|
|
with suppress(Exception):
|
|
from hermes_cli.dashboard_procs import _hermes_home_for_pid
|
|
if (home := _hermes_home_for_pid(pid)) is not None:
|
|
return home
|
|
try:
|
|
shown = _systemctl(list(scope_cmd) + ["show", svc_name, "--property=Environment", "--value"], timeout=10)
|
|
except Exception:
|
|
return None
|
|
if getattr(shown, "returncode", 1) != 0:
|
|
return None
|
|
env_line = (getattr(shown, "stdout", "") or "").strip()
|
|
try:
|
|
tokens = shlex.split(env_line)
|
|
except ValueError:
|
|
tokens = env_line.split()
|
|
for token in tokens:
|
|
key, sep, value = token.partition("=")
|
|
if sep and key == "HERMES_HOME" and value.strip():
|
|
return value.strip()
|
|
if "--user" in scope_cmd:
|
|
return str(Path.home() / ".hermes")
|
|
return None
|
|
|
|
|
|
def systemd_unit_in_update_scope(scope_cmd: list, svc_name: str, scope: set[Path] | None = None) -> bool | None:
|
|
"""Does unit *svc_name* belong to this update? ``None`` = ownership unreadable (leave it alone)."""
|
|
home = systemd_unit_hermes_home(scope_cmd, svc_name)
|
|
if home is None:
|
|
return None
|
|
return home_in_update_scope(home, scope)
|
|
|
|
|
|
def launchd_label_foreign_home(label: str, scope: set[Path] | None = None) -> str | None:
|
|
"""The HERMES_HOME a derived launchd label's installed plist pins when that home is NOT one of
|
|
this update's — labels are account-global, so root B's default profile derives the same bare
|
|
``ai.hermes.gateway`` root A installed. ``None`` = ours, or no/unreadable plist (the locate step
|
|
decides whether a job exists; only a proven foreign home is refused)."""
|
|
import plistlib
|
|
with suppress(Exception):
|
|
from hermes_cli.gateway import get_launchd_plist_path
|
|
plist_path = get_launchd_plist_path().with_name(f"{label}.plist")
|
|
if not plist_path.exists():
|
|
return None
|
|
data = plistlib.loads(plist_path.read_bytes())
|
|
pinned = str(data["EnvironmentVariables"]["HERMES_HOME"])
|
|
return None if home_in_update_scope(pinned, scope) else pinned
|
|
return None
|
|
|
|
|
|
def describe_skipped_runtime(kind: str, name: str, home: str | None) -> str:
|
|
"""One notice line for a runtime the update leaves alone (foreign home or unreadable ownership)."""
|
|
if home is None:
|
|
return f" ↷ {name}: {kind} whose Hermes home could not be read — left alone (not restarted)"
|
|
return f" ↷ {name}: {kind} of another Hermes home ({home}) — left alone (not restarted)"
|