Files
hermes-agent/hermes_cli/update_cmd_posix_pause.py
Teknium 92189e2d0a fix: POSIX update pause drains turns first and survives a killed update cleanly
Review fixes for the pre-swap gateway pause (independent review of a029ec2):

- Every gateway is drained before the code swap: the control socket's
  pause-for-update runs the request_restart drain (the post-update restart's
  SIGUSR1 path) and the update waits within the fleet's canonical budget
  (_gateway_drain_budget, 1905 s by default) with progress lines. systemd
  units and launchd jobs are stopped through their supervisor only after
  their drained gateway has exited, which also keeps Restart=/KeepAlive from
  relaunching it mid-update. A gateway that does not take the drain gets the
  supervisor stop / SIGTERM as before. The request is recorded (stop_sent)
  before it is sent and carries no planned-stop marker: the marker watcher
  takes the immediate signal-stop path and could fire first. Before: a
  supervised gateway got systemctl stop / bootout at once
  (restart_drain_timeout=0 cuts an in-flight turn), a bare one was SIGKILLed
  after 70 s.
- The pause record stores canonical identities (pause_record.identity), so
  recovery recognises a still-live gateway; the native birth stamp stays on
  the entry for _alive.
- Supervised posix_units go through drop_never_stopped / split_draining /
  backoff / hand-back like every other entry: a killed update no longer owes a
  live unit it never stopped, nor relaunches one still draining.
- Unknown cgroup membership (either side unreadable) never authorises
  stopping the unit; cgroup v1 name=systemd is read when v2 is absent.
- A record that cannot be written restores the token exactly (fresh or
  adopted), so the checkout's tree gate is not armed by a pause that never
  happened.
- A replayed bare gateway's environment follows the restart watcher it
  replaces: the launch profile's own named home keeps the launcher env (an
  exported bot token can be its only credential); every other home and the
  multiplexing root get the served-profile factory.
- Resuming a paused unit runs the definition repair the fleet restart runs
  first (_repair_unit_without_fatal_exit_park; launchd plist refresh).
- Shared POSIX tests use platforms("posix") instead of a module skipif, so
  the macOS lane imports them.

e2e cell (e) now reads the recorded stop request (record stop_sent) instead of
the planned-stop marker, which the drain path no longer writes.
2026-10-07 04:39:00 -07:00

676 lines
34 KiB
Python

"""Pre-swap gateway pause for ``hermes update`` on Linux and macOS.
Windows pauses its gateways before the fetch (``update_cmd_windows``) because they lock venv
files. On POSIX nothing is locked, but a gateway that keeps running while git rewrites the tree
loads new-tree modules into an old process on its next lazy import. So every gateway running
from this install is stopped at the commit point, immediately before the first checkout move
(``update_cmd_commit.arm_commit_point``), and the same set is restarted once the dependencies
are synced and the launchers published, before the product builds
(``source_completion.complete_source_checkout``'s ``before_build``).
The set rides the Windows pause contract: one token in ``update_pause_record`` (identity = pid
+ start time; supervisor unit, or argv + home for bare processes), written before the first stop,
resumed by ``_resume_windows_gateways_after_update`` (which dispatches here on
``platform == "posix"``), adopted by the next ``hermes update`` and resumed by the next launch
when this process dies.
* every gateway is drained first: the control socket's ``pause-for-update`` runs the same
``request_restart`` drain as the post-update restart's SIGUSR1 (refuse new turns, let the
in-flight ones finish within ``agent.restart_after_turn_timeout``, then exit), waited for within
that restart's budget. The request is recorded before it is sent, and carries no planned-stop
marker (the gateway's marker watcher would take the immediate stop path instead).
* systemd ``hermes-gateway*`` units and launchd ``ai.hermes.gateway*`` jobs are then stopped and
later started through their supervisor, so it cannot respawn them on old code mid-update; one
that does not take the drain gets its supervisor's stop at once.
* bare ``gateway run`` processes that do not answer get marker + SIGTERM, and are replayed from
their recorded argv + home.
* the updater never kills itself: a unit whose cgroup holds this process (``/update`` from
chat: the update runs in the gateway unit's cgroup, which ``systemctl stop`` kills) is
stopped only after this process moved into a transient scope of its own. A launchd job
whose coalition holds it, or any gateway whose stop would take the updater down, is left
running for the post-commit restart.
"""
from __future__ import annotations
import copy
import os
import signal
import subprocess
import sys
import time
from contextlib import suppress
from pathlib import Path
#: This run's token: armed in ``_cmd_update_impl``, filled at the commit point. Same dict object
#: the completion request carries, so the paused set reaches the completion child.
_RUN: dict | None = None
#: Runtimes the early resume already restarted on the new code: the late fleet restart in the
#: same completion process must not restart them a second time.
_RESTARTED: dict = {"units": set(), "labels": set(), "pids": set()}
_STOP_TIMEOUT_S = 120.0
_READY_TIMEOUT_S = 30.0
class PauseRefused(RuntimeError):
"""The pause could not be completed; everything it stopped was started again."""
def _empty_token() -> dict:
return {"platform": "posix", "resume_needed": False, "profiles": {}, "unmapped_pids": [],
"unmapped": [], "posix_units": []}
def arm_pause(*, no_gateway_restart: bool) -> dict | None:
"""Arm this run's pause (no stop yet) and adopt any set a killed update left paused.
``None`` on Windows (its own pause) and with ``--no-gateway-restart`` (the user manages
gateways, nothing is stopped). An adopted orphan set is recorded under this process now, so
even an update that never moves the checkout restarts it at exit."""
global _RUN
if sys.platform == "win32" or no_gateway_restart:
return None
from hermes_cli import update_pause_record as pause_record
token = _empty_token()
try:
adopted, claims = pause_record.adopt_orphans()
if adopted is not None:
token = pause_record.record_pause({**token, "resume_needed": True}, adopted, claims)
token["platform"] = "posix"
except Exception as exc: # health: allow BLE001 -- an unreadable record must not block the update; recovery retries it
print(f" ⚠ Could not adopt gateways an earlier update paused: {exc}")
_RUN = token
return token
def gateways_paused() -> bool:
"""True while this run holds gateways stopped (no gateway can answer a chat prompt)."""
return bool(_RUN and _RUN.get("posix_stopped") and _RUN.get("resume_needed"))
# --------------------------------------------------------------------------- discovery
def _unit_cgroup(scope_cmd: list, unit: str) -> str | None:
from hermes_cli.update_cmd_fleet import _systemctl
with suppress(OSError, subprocess.TimeoutExpired):
result = _systemctl(scope_cmd + ["show", unit, "--property=ControlGroup", "--value"], timeout=10)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
return None
def _pid_cgroup(pid: int | str = "self") -> str | None:
"""The systemd cgroup path of *pid*: the unified (v2) line, else the v1 ``name=systemd``
hierarchy, which systemd names units in identically. ``None``: not Linux or unreadable, which
callers must treat as unknown, never as outside a unit."""
with suppress(OSError):
lines = Path(f"/proc/{pid}/cgroup").read_text(encoding="utf-8").splitlines()
unified = next((line[3:].strip() for line in lines if line.startswith("0::")), "")
legacy = next((line.split(":", 2)[2].strip() for line in lines if ":name=systemd:" in line), "")
return unified or legacy or None
return None
def _inside(cgroup: str | None, unit_cgroup: str | None) -> bool:
if not cgroup or not unit_cgroup:
return False
return cgroup == unit_cgroup or cgroup.startswith(unit_cgroup.rstrip("/") + "/")
def _discover_systemd() -> list[dict]:
"""Active ``hermes-gateway*`` units of this install, one per live MainPID."""
from hermes_cli.gateway import _ensure_user_systemd_env, supports_systemd_services
from hermes_cli.update_cmd_fleet import (
_for_each_systemd_gateway_unit, _systemd_gateway_unit_listings, _systemd_unit_owned_by_update,
_unit_main_pid,
)
if not supports_systemd_services():
return []
with suppress(Exception):
_ensure_user_systemd_env()
units: list[dict] = []
seen_pids: set[int] = set()
for scope, scope_cmd, listing in _systemd_gateway_unit_listings():
names: list[str] = []
_for_each_systemd_gateway_unit(listing.stdout, process_unit=names.append,
on_unit_timeout=lambda name, exc: None)
for name in names:
if not name.startswith("hermes-gateway"):
continue # serve/dashboard units are not gateways: the post-commit restart owns them
pid = _unit_main_pid(scope_cmd, name)
if pid <= 0 or pid in seen_pids or not _systemd_unit_owned_by_update(scope_cmd, name):
continue
seen_pids.add(pid) # a legacy per-profile unit sharing the multiplexer's PID is one process
units.append({"kind": "systemd", "scope": scope, "unit": name, "pid": pid,
"cgroup": _unit_cgroup(scope_cmd, name), "home": _home_of(pid)})
return units
def _discover_launchd() -> list[dict]:
"""Loaded ``ai.hermes.gateway*`` jobs of this install with a live process (macOS)."""
if sys.platform != "darwin":
return []
from hermes_cli.gateway import (
_locate_launchd_gateway_service, get_launchd_plist_path, launchd_gateway_labels_for_install,
legacy_launchd_labels_for_install,
)
from hermes_cli.update_fleet_scope import launchd_label_foreign_home
derived = launchd_gateway_labels_for_install()
jobs = []
for label in derived + legacy_launchd_labels_for_install(exclude=set(derived)):
if launchd_label_foreign_home(label) is not None:
continue
domain, pid = _locate_launchd_gateway_service(label)
if domain is None or not pid or pid <= 0:
continue
plist = get_launchd_plist_path().with_name(f"{label}.plist")
jobs.append({"kind": "launchd", "label": label, "domain": domain, "plist": str(plist), "pid": int(pid),
"home": _home_of(int(pid))})
return jobs
def _home_of(pid: int) -> str | None:
"""The Hermes home *pid* serves (its control socket lives there), or ``None``."""
from hermes_cli.update_fleet_scope import gateway_pid_home
with suppress(Exception):
if home := gateway_pid_home(pid):
return str(home)
return None
def _supervisor_markers(pid: int) -> str | None:
"""Why *pid* answers to a supervisor other than a hermes unit/job (it would respawn it on old
code), or ``None``."""
from hermes_cli.gateway import _capture_gateway_argv
if "--external-supervisor" in (_capture_gateway_argv(pid) or []):
return "--external-supervisor"
try:
import psutil # type: ignore
env = psutil.Process(pid).environ()
except Exception: # health: allow BLE001 -- unreadable environment: no marker proven
return None
if env.get("HERMES_S6_SUPERVISED_CHILD"):
return "s6"
if unit := _respawning_service(pid):
return f"systemd unit {unit}"
# launchd names terminal apps' shells application.<bundle id>; those are not KeepAlive jobs.
xpc = env.get("XPC_SERVICE_NAME", "0")
if xpc not in ("", "0") and not xpc.startswith("application."):
return f"launchd job {xpc}"
return None
def _respawning_service(pid: int) -> str | None:
"""The systemd service that would respawn *pid* when it exits, or ``None``.
systemd restarts a unit only when its main process exits, so the gateway must BE the
MainPID or its direct child (a non-exec wrapper that exits with it). The inherited
INVOCATION_ID is no evidence: a terminal tab's shell carries one (its .scope is never
respawned), and so does anything deep under a CI runner's service."""
from hermes_cli.update_cmd_fleet import _systemctl, _unit_main_pid
cgroup = _pid_cgroup(pid) or ""
leaf = cgroup.rsplit("/", 1)[-1]
if not leaf.endswith(".service"):
return None
scope_cmd = ["systemctl", "--user"] if "/user@" in cgroup else ["systemctl"]
with suppress(OSError, subprocess.SubprocessError):
main = _unit_main_pid(scope_cmd, leaf)
if main > 0 and main in (pid, _ppid(pid)):
restart = _systemctl(scope_cmd + ["show", leaf, "--property=Restart", "--value"], timeout=10)
return leaf if (restart.stdout or "").strip() not in ("", "no") else None
return None
def _discover_bare(service_pids: set[int]) -> tuple[list[dict], list[str]]:
"""``(bare gateways, notices)``: this install's ``gateway run`` processes outside any unit."""
from gateway.status import get_process_start_time
from hermes_cli.gateway import _capture_gateway_argv, _get_service_pids, find_gateway_pids
from hermes_cli.update_cmd_fleet import _scoped_manual_gateway_pids
from hermes_cli.update_fleet_scope import gateway_pid_home
exclude = set(service_pids) | set(_get_service_pids(all_profiles=True))
pids = _scoped_manual_gateway_pids(find_gateway_pids(exclude_pids=exclude, all_profiles=True))
bare, notices = [], []
for pid in pids:
pid = int(pid)
marker = _supervisor_markers(pid)
argv, home = _capture_gateway_argv(pid), gateway_pid_home(pid)
if marker or not argv or not home:
why = f"supervised by {marker}" if marker else "its command line could not be read" if not argv \
else "its Hermes home could not be proved"
notices.append(f" ↷ gateway PID {pid} keeps running until after the update ({why})")
continue
bare.append({"pid": pid, "argv": list(argv), "home": str(home), "ct": get_process_start_time(pid)})
return bare, notices
# --------------------------------------------------------------------------- self-preservation
def _ppid(pid: int) -> int:
with suppress(OSError, ValueError, IndexError):
stat = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
return int(stat.rsplit(")", 1)[1].split()[1])
return 0
def _escape_cgroup(unit: dict) -> bool:
"""Move this process (and its ancestors in the same unit, e.g. ``/update``'s bash wrapper)
into a transient scope so stopping *unit* cannot kill it. True only when proven out: a
membership that cannot be read (either side) keeps the unit running, since its stop would take
this update down with it."""
own, unit_cgroup = _pid_cgroup(), unit.get("cgroup")
if own is None or not unit_cgroup:
return False
if not _inside(own, unit_cgroup):
return True
from gateway.status import looks_like_gateway_command_line
pids, pid = [], os.getpid()
while pid > 1 and pid != unit["pid"] and _inside(_pid_cgroup(pid), unit["cgroup"]):
with suppress(OSError):
cmdline = Path(f"/proc/{pid}/cmdline").read_bytes().replace(b"\0", b" ").decode("utf-8", "replace")
if pid != os.getpid() and looks_like_gateway_command_line(cmdline):
break
pids.append(pid)
pid = _ppid(pid)
bus = "--user" if unit["scope"] == "user" else "--system"
cmd = ["busctl", bus, "call", "org.freedesktop.systemd1", "/org/freedesktop/systemd1",
"org.freedesktop.systemd1.Manager", "StartTransientUnit", "ssa(sv)a(sa(sv))",
f"hermes-update-{os.getpid()}.scope", "fail", "2", "PIDs", "au", str(len(pids)), *map(str, pids),
"Description", "s", f"hermes update (outside {unit['unit']} while it is paused)", "0"]
with suppress(OSError, subprocess.SubprocessError):
subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace",
stdin=subprocess.DEVNULL, timeout=15, check=False)
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
if (now := _pid_cgroup()) is not None and not _inside(now, unit_cgroup):
return True
time.sleep(0.1)
return False
def _left_running(units: list[dict], jobs: list[dict]) -> tuple[list[dict], list[dict], list[str]]:
"""Drop the runtimes whose stop would kill this updater; name each one."""
from hermes_cli.gateway import _is_pid_ancestor_of_current_process
keep_units, keep_jobs, notices = [], [], []
for unit in units:
if _manage_cmd(unit["scope"]) is None:
notices.append(f" ↷ {unit['unit']} keeps running until after the update (stopping a system "
"unit needs root or passwordless sudo)")
elif _escape_cgroup(unit):
keep_units.append(unit)
else:
notices.append(f" ↷ {unit['unit']} keeps running until after the update (this update could "
"not prove it runs outside that unit's cgroup)")
for job in jobs:
if (_is_pid_ancestor_of_current_process(job["pid"])
or os.environ.get("XPC_SERVICE_NAME") == job["label"]):
notices.append(f" ↷ {job['label']} keeps running until after the update (this update runs "
"inside its launchd job)")
else:
keep_jobs.append(job)
return keep_units, keep_jobs, notices
# --------------------------------------------------------------------------- stop / start
def _manage_cmd(scope: str) -> list | None:
from hermes_cli.update_cmd_fleet import _needs_sudo, _sudo_noninteractive_ok
cmd = ["systemctl", "--user"] if scope == "user" else ["systemctl"]
cmd = cmd + ["--no-ask-password"]
if scope == "system" and _needs_sudo(scope):
sudo = ["sudo", "-n", *cmd]
return sudo if _sudo_noninteractive_ok(cmd + ["show", "-p", "Id", "hermes-gateway"]) else None
return cmd
def _stop_unit(unit: dict) -> None:
from hermes_cli.update_cmd_fleet import _systemctl
cmd = _manage_cmd(unit["scope"])
if cmd is None:
raise PauseRefused(f"no privilege to stop system unit {unit['unit']}")
result = _systemctl(cmd + ["stop", unit["unit"]], timeout=_STOP_TIMEOUT_S)
if result.returncode != 0:
raise PauseRefused(f"systemctl stop {unit['unit']} failed: {(result.stderr or '').strip()}")
def _start_unit(unit: dict) -> int:
"""Start *unit* through systemd; the new MainPID (raises when it does not come up). The unit
gets the definition repair the restart it replaces ran first (``_restart_one_systemd_gateway_unit``)."""
from hermes_cli.update_cmd_fleet import (
_repair_unit_without_fatal_exit_park, _systemctl, _unit_main_pid, _wait_for_service_active,
)
cmd = _manage_cmd(unit["scope"])
if cmd is None:
raise RuntimeError(f"no privilege to start system unit {unit['unit']}")
_repair_unit_without_fatal_exit_park(unit["unit"], unit["scope"])
_systemctl(cmd + ["reset-failed", unit["unit"]], timeout=10)
result = _systemctl(cmd + ["start", unit["unit"]], timeout=_STOP_TIMEOUT_S)
scope_cmd = ["systemctl", "--user"] if unit["scope"] == "user" else ["systemctl"]
if result.returncode != 0 or not _wait_for_service_active(scope_cmd, unit["unit"], timeout=15.0):
raise RuntimeError(f"{unit['unit']} did not start: {(result.stderr or '').strip() or 'not active'}")
return _unit_main_pid(scope_cmd, unit["unit"])
def _stop_job(job: dict, *, drained: bool = False) -> None:
"""Boot *job* out of launchd. *drained*: its gateway already exited through the drain; the
bootout only keeps KeepAlive from serving the update."""
from hermes_cli.gateway import _locate_launchd_gateway_service
from hermes_cli.update_cmd_windows import _write_update_planned_stop_marker
if not drained and job.get("home"):
_write_update_planned_stop_marker(Path(job["home"]), job["pid"])
result = subprocess.run(["launchctl", "bootout", f"{job['domain']}/{job['label']}"],
capture_output=True, text=True, encoding="utf-8", errors="replace",
stdin=subprocess.DEVNULL, timeout=90, check=False)
_wait_gone([job["pid"]], 30.0)
# KeepAlive may have respawned the drained gateway already: the job, not the old PID, decides.
if result.returncode != 0 and (_locate_launchd_gateway_service(job["label"])[1] or 0) > 0:
raise PauseRefused(f"launchctl bootout {job['label']} failed: {(result.stderr or '').strip()}")
def _start_job(job: dict) -> None:
"""Load and start *job*. The invoking profile's plist is regenerated first, as the restart it
replaces does (``launchd_restart``); that refresh also loads it."""
from hermes_cli.gateway import (
_launchctl_bootstrap, _wait_for_launchd_service_pid, get_launchd_label, refresh_launchd_plist_if_needed,
)
if not (job["label"] == get_launchd_label() and refresh_launchd_plist_if_needed()):
_launchctl_bootstrap(job["domain"], job["plist"], job["label"])
subprocess.run(["launchctl", "kickstart", f"{job['domain']}/{job['label']}"],
capture_output=True, text=True, encoding="utf-8", errors="replace",
stdin=subprocess.DEVNULL, timeout=30, check=False)
if not _wait_for_launchd_service_pid(job["label"], old_pid=job["pid"], timeout=15.0, domain=job["domain"]):
raise RuntimeError(f"{job['label']} did not come back under launchd")
def _alive(pid: int, ct) -> bool:
from gateway.status import _pid_exists, get_process_start_time
try:
if not _pid_exists(int(pid)):
return False
except Exception: # health: allow BLE001 -- unknown liveness counts as alive (never relaunch a twin)
return True
return ct is None or get_process_start_time(int(pid)) in (None, ct)
def _wait_gone(pids, timeout: float, born: dict | None = None) -> set[int]:
deadline = time.monotonic() + timeout
live = set(pids)
while live and time.monotonic() < deadline:
live = {pid for pid in live if _alive(pid, (born or {}).get(pid))}
if live:
time.sleep(0.2)
return live
def _ask_to_drain(entry: dict) -> bool:
"""Ask the gateway over its control socket to finish its in-flight turns and exit; True on its
ACK. That is the ``request_restart`` drain the post-update restart uses (SIGUSR1); SIGTERM,
``systemctl stop`` and ``launchctl bootout`` enter ``stop()`` at once and cut a turn off. No
planned-stop marker on this path: the gateway's marker watcher takes that same immediate
signal-stop path, and could fire before the request lands. The caller records the request
(``mark_stop_sent``) BEFORE sending it, so a request that reaches the gateway after this update
died still leaves the evidence that keeps the drained gateway's restart debt."""
from gateway.control_socket import pause_gateway_for_update
if not entry.get("home"):
return False
try:
ack = pause_gateway_for_update(Path(entry["home"]))
except Exception: # health: allow BLE001 -- no answer is the pre-verb gateway: the fallback stop handles it
return False
return bool(ack and (ack.get("pausing") or ack.get("already_stopping")))
def _stop_at_once(entry: dict) -> None:
"""The stop for a gateway that did not take the drain: its supervisor's, else marker + SIGTERM."""
from hermes_cli.update_cmd_windows import _write_update_planned_stop_marker
if entry.get("kind") == "systemd":
_stop_unit(entry)
elif entry.get("kind") == "launchd":
_stop_job(entry)
else:
_write_update_planned_stop_marker(Path(entry["home"]), entry["pid"])
with suppress(ProcessLookupError, PermissionError):
os.kill(entry["pid"], signal.SIGTERM)
def _drain_budget() -> float:
"""The post-update restart's wait for a drained gateway (after-turn wait + stop drain + headroom)."""
from hermes_cli.update_cmd_fleet import _gateway_drain_budget
return _gateway_drain_budget()
def _stop_gateways(token: dict, entries: list[dict]) -> None:
"""Drain every gateway (bare ones SIGTERMed when they do not answer), then wait for all of them
within one drain budget. A supervised gateway that exited through the drain is stopped through
its supervisor at once: its exit 75 asks for a restart, which systemd honours after
``RestartSec=5`` and launchd immediately, still on the old tree. Past the budget a supervised
survivor gets its supervisor's stop and a bare one SIGKILL, as the restart path escalates."""
from hermes_cli import update_pause_record as pause_record
from hermes_cli.update_cmd_drain_report import drain_progress_reporter
budget, waiting = _drain_budget(), []
for entry in entries:
pause_record.mark_stop_sent(token, entry["pid"]) # before the request: see _ask_to_drain
if _ask_to_drain(entry):
waiting.append(entry)
else:
_stop_at_once(entry)
if not entry.get("kind"):
waiting.append(entry) # a SIGTERMed bare gateway is waited for like a drained one
if not waiting:
return
print(f" ⏳ Waiting up to {budget:.0f}s for in-flight turns on {_names(waiting)} to finish before the update...")
ticks = [drain_progress_reporter(Path(e["home"]) if e.get("home") else None, budget_s=budget) for e in waiting]
deadline = time.monotonic() + budget
while waiting and time.monotonic() < deadline:
for entry in [e for e in waiting if not _alive(e["pid"], e.get("ct"))]:
waiting.remove(entry)
_disarm(entry)
for tick in ticks if waiting else ():
tick()
time.sleep(0.2)
for entry in waiting:
if entry.get("kind"):
_stop_at_once(entry)
else:
with suppress(ProcessLookupError, PermissionError):
os.kill(entry["pid"], getattr(signal, "SIGKILL", signal.SIGTERM))
bare = [e for e in waiting if not e.get("kind")]
if left := _wait_gone([e["pid"] for e in bare], 10.0, {e["pid"]: e.get("ct") for e in bare}):
raise PauseRefused("gateway PID(s) " + ", ".join(map(str, sorted(left))) + " did not stop")
def _disarm(entry: dict) -> None:
"""A drained gateway exited: keep its supervisor from bringing it back during the update."""
if entry.get("kind") == "systemd":
_stop_unit(entry)
elif entry.get("kind") == "launchd":
_stop_job(entry, drained=True)
def _names(entries: list[dict]) -> str:
return ", ".join(e.get("unit") or e.get("label") or f"gateway PID {e['pid']}" for e in entries)
def _gateway_on_home(home: str, exclude: set[int]) -> int | None:
"""The gateway serving *home*, by the canonical identity reader (PID file + runtime lock,
then the runtime status record), never a process scan: a replayed ``gateway run`` matches the
process matcher for the moment before it finds the home already served and exits."""
from gateway.status import live_gateway_pid_for_home
try:
pid = live_gateway_pid_for_home(Path(home))
except Exception: # health: allow BLE001 -- unreadable identity files: nothing proven to adopt
return None
return int(pid) if pid and int(pid) not in exclude else None
def _replay_env(home: str) -> dict:
"""The environment a replayed gateway starts with, as the restart watcher it replaces chose it
(``_spawn_gateway_restart_watcher``): a named profile's gateway, replayed by an update launched
under that same profile, inherits the launcher's environment (an exported bot token may be its
only credential). The multiplexing root, and any home other than the launcher's, get that home's
own secrets and none of the launcher's (``served_profile_child_env``)."""
from hermes_constants import get_default_hermes_root, get_routing_process_hermes_home
from tools.environments.local import build_subprocess_env, served_profile_child_env
target = Path(home).resolve()
if target != get_default_hermes_root().resolve() and target == get_routing_process_hermes_home().resolve():
return build_subprocess_env(scrub_secrets=False, inherit_profile_home=False, extra={"HERMES_HOME": home})
return served_profile_child_env(target_home=home, inherit_credentials=True)
def _relaunch_bare(entry: dict) -> int:
"""Replay a bare gateway from its recorded argv under its recorded home; the new PID once it
is serving. A gateway already serving that home (an earlier attempt's) is adopted instead."""
if (existing := _gateway_on_home(entry["home"], {int(entry["pid"])})) is not None:
return existing
logs = Path(entry["home"]) / "logs"
logs.mkdir(parents=True, exist_ok=True)
env = _replay_env(entry["home"])
for leaked in ("INVOCATION_ID", "JOURNAL_STREAM", "HERMES_UPDATE_PAUSED", "_HERMES_GATEWAY"):
env.pop(leaked, None)
with open(logs / "gateway-update-resume.log", "ab") as log:
proc = subprocess.Popen(list(entry["argv"]), env=env, stdin=subprocess.DEVNULL, stdout=log,
stderr=subprocess.STDOUT, start_new_session=True, close_fds=True)
deadline = time.monotonic() + _READY_TIMEOUT_S
while time.monotonic() < deadline:
if (pid := _gateway_on_home(entry["home"], {int(entry["pid"])})) is not None:
return pid # the replay, or a gateway someone started meanwhile (the replay then exits 0)
if proc.poll() is not None:
raise RuntimeError(f"replayed gateway for {entry['home']} exited {proc.returncode} "
f"(see {logs / 'gateway-update-resume.log'})")
time.sleep(0.5)
raise RuntimeError(f"replayed gateway for {entry['home']} did not come up within {int(_READY_TIMEOUT_S)}s")
def _describe(token: dict) -> str:
names = [u.get("unit") or u.get("label") for u in token.get("posix_units") or []]
names += [f"gateway PID {u['pid']}" for u in token.get("unmapped") or [] if u.get("argv")]
return ", ".join(names)
# --------------------------------------------------------------------------- the commit point
def pause_at_commit_point() -> str | None:
"""Stop this install's gateways before the first checkout move; once per run.
Never refuses the update: the pause is protection, not a gate. When it cannot complete
(discovery or the durable record fails, a stop fails), the gateways run through the swap and
the post-commit restart refreshes them, which is how POSIX updated before the pause existed.
Always ``None``; the caller's refusal branch stays for the contract it shares with Windows."""
token = _RUN
if token is None:
return None
if "posix_pause_verdict" not in token:
token["posix_pause_verdict"] = _pause(token)
return token["posix_pause_verdict"]
def _pause(token: dict) -> str | None:
from hermes_cli import update_pause_record as pause_record
try:
units, jobs = _discover_systemd(), _discover_launchd()
units, jobs, notices = _left_running(units, jobs)
bare, more = _discover_bare({u["pid"] for u in units} | {j["pid"] for j in jobs})
except Exception as exc: # health: allow BLE001 -- a discovery that cannot finish stops nothing
print(f" ⚠ Could not list the running gateways ({exc}); they keep running and are restarted after the update")
return None
for line in notices + more:
print(line)
supervised = units + jobs
if not supervised and not bare:
return None
from gateway.status import get_process_start_time
from hermes_cli.update_cmd_windows import _planned_stop_marker_path
before = copy.deepcopy(token) # what this attempt may add; a failed record puts it back
for entry in supervised + bare:
entry["ct"] = get_process_start_time(entry["pid"]) # native birth stamp: what _alive compares
token["posix_units"] = [*token.get("posix_units", []), *supervised]
token["unmapped"] = [*token.get("unmapped", []), *bare]
token["unmapped_pids"] = [*token.get("unmapped_pids", []), *(e["pid"] for e in bare)]
# Recovery judges liveness by the canonical identity (``ct:<unix seconds>``), never the native stamp.
token["identities"] = {**token.get("identities", {}),
**{str(e["pid"]): pause_record.identity(e["pid"])["ct"] for e in supervised + bare}}
token["resume_needed"] = True
try:
pause_record.record_pause(token, None, [])
pause_record.mark_stop_requested(
token, [e["pid"] for e in supervised + bare],
markers={e["pid"]: _planned_stop_marker_path(Path(e["home"])) for e in supervised + bare if e.get("home")})
except Exception as exc: # health: allow BLE001 -- no durable record, no stop
# Nothing was stopped: this attempt's id must not arm the checkout's tree gate, whose
# required record write would then refuse the update. An adopted obligation stays as it was.
if token.get("pause_id") != before.get("pause_id"):
with suppress(Exception):
pause_record.discharge(token)
token.clear()
token.update(before)
print(f" ⚠ Could not record the gateways to pause ({exc}); they keep running and are restarted after the update")
return None
# From here a refusal or a crash leaves the set owed: the command's own exit resume (registered
# in ``_cmd_update_impl`` for this token), else the next launch's recovery, restarts it.
token["posix_stopped"] = True
try:
_stop_gateways(token, supervised + bare)
except Exception as exc: # health: allow BLE001 -- roll back: restart the stopped set, update unpaused
print(f" ⚠ Could not pause every gateway ({exc}); restarting them, the update continues without the pause")
try:
resume_paused_set(token)
except Exception as again: # health: allow BLE001 -- still owed: completion restarts them after the deps
print(f" ⚠ {again}; they restart once the dependencies are synced")
for restarted in _RESTARTED.values():
restarted.clear() # back on the OLD code: the post-commit fleet restart must refresh them
pause_record.sync(token)
return None
pause_record.sync(token)
print(f" ⏸ Paused for the update: {_describe(token)} (restarted once the dependencies are synced)")
return None
# --------------------------------------------------------------------------- resume
def resume_paused_set(token: dict) -> None:
"""Start every paused runtime again; each leaves *token* only on its own verified start."""
failures: list[str] = []
restarted: list[str] = []
kept_units = []
for entry in token.get("posix_units") or []:
try:
if entry.get("kind") == "launchd":
_start_job(entry)
_RESTARTED["labels"].add(entry["label"])
name = entry["label"]
else:
_RESTARTED["pids"].add(_start_unit(entry))
_RESTARTED["units"].add(f"{entry['scope']}/{entry['unit']}")
name = entry["unit"]
restarted.append(name)
token.setdefault("restarted_services", []).append(name)
except Exception as exc: # health: allow BLE001 -- one failed unit never keeps the others stopped
failures.append(str(exc))
kept_units.append(entry)
token["posix_units"] = kept_units
kept_bare = []
for entry in token.get("unmapped") or []:
if not entry.get("argv"):
continue
try:
new_pid = _relaunch_bare(entry)
_RESTARTED["pids"].add(new_pid)
restarted.append(f"gateway PID {entry['pid']} → {new_pid}")
except Exception as exc: # health: allow BLE001 -- one failed replay never keeps the others stopped
failures.append(str(exc))
kept_bare.append(entry)
token["unmapped"] = kept_bare
token["unmapped_pids"] = [e["pid"] for e in kept_bare]
token["profiles"] = {}
if restarted:
print(f" ▶ Restarted paused gateway(s): {', '.join(restarted)}")
if failures:
raise RuntimeError("Could not restart every paused gateway: " + "; ".join(failures))
token["resume_needed"] = False
token["posix_stopped"] = False
def already_restarted() -> dict:
"""What the early resume restarted on the new code in this process (for the fleet restart)."""
return _RESTARTED