Files
hermes-agent/hermes_cli/gateway_profile_lifecycle.py
Teknium 8e5ea80dfb fix(gateway): restart --all stamps the marker after the old host is gone
Written before the stop, the marker was visible to the still-draining old
host: a platform reconnecting in that window replayed "Gateway online"
and unlinked it, so the new host booted silent. restart_all_verb_scope now
yields the stamp and _restart_all_as_host runs it once the old host has
exited and before the start, the same order the Windows restart uses.

The Windows restart test runs on Windows (platforms marker) instead of
patching out the host check.
2026-10-24 16:00:35 -07:00

251 lines
12 KiB
Python

"""Named-profile lifecycle inside the host gateway, never a service-manager action."""
from __future__ import annotations
import logging
from contextlib import contextmanager
from hermes_constants import get_hermes_home, get_default_hermes_root
from hermes_cli.profiles import parked_marker_path, profile_is_parked, profile_is_standalone, profiles_to_serve
logger = logging.getLogger(__name__)
def ack_confirmed(answer, key, name):
return isinstance(answer, dict) and answer.get(key) == name and not answer.get("error")
def ack_failure(answer):
if isinstance(answer, dict):
return answer.get("error") or "host operation is still pending"
return "host control socket did not answer"
def mark_planned_stop(pid: int | None = None) -> None:
"""Best-effort planned-stop marker for ``pid`` (default: the recorded gateway PID)."""
try:
from gateway.status import get_running_pid, write_planned_stop_marker
if pid is None:
pid = get_running_pid(cleanup_stale=False)
if pid is not None:
write_planned_stop_marker(pid)
except Exception:
pass
def host_scope_for_all_verb(owner):
"""Home a ``--all`` verb acts as: the live host owner's, else the default root.
``--all`` names the ONE host gateway, whichever profile invoked the CLI — but the active home
decides the service name, the Windows task/launcher and the detached child's ``--profile``, so
with the host DOWN ``-p X gateway start|restart --all`` re-launched X's own gateway, which the
named-profile guard then refused (exit 78): the host stayed down after a Desktop update.
"""
from hermes_cli.gateway_migrate import _home_env
return _home_env(owner.home if owner is not None else get_default_hermes_root())
@contextmanager
def restart_all_verb_scope(owner):
"""``host_scope_for_all_verb`` plus the owed online notice: ``restart --all`` restarts the host.
The verb stops the host with a service call or a SIGTERM, so the old process never runs the
in-band restart path that stamps the planned-restart marker; without it the next boot reads a
plain stop and every home channel stays silent — #142515's gap, on every OS. Yields the stamp
for the caller to run once the old host is gone and before the start: a marker written while the
old host still drains can be replayed and unlinked by it, and the new host then boots silent.
"""
from gateway.restart_notice import write_planned_restart_marker
with host_scope_for_all_verb(owner):
yield write_planned_restart_marker
def profile_lifecycle(command: str, args) -> bool:
"""True when a named-profile command was handled (including an unconfirmed request)."""
from hermes_cli import gateway as gw
from gateway.control_socket import request_unserve_profile, request_serve_profile_hot
name = gw._current_profile_name()
if not name or name == "default" or getattr(args, "all", False) or getattr(args, "force", False):
return False
home = get_hermes_home()
if profile_is_standalone(home):
# gateway.standalone wins: the host never serves (or parks) an opted-out profile, so its
# verbs keep addressing its own gateway process even while a stale host record lists it.
return False
marker = parked_marker_path(home)
if command == "restart" and profile_is_parked(home) and not gw.find_gateway_pids():
# `stop` parked the profile AND dropped it from the host's served set, so the unserve/serve
# path below finds no host serving it and would fall through to a standalone restart.
# Unpark like `start` does; a live --force gateway keeps its own restart (pids non-empty).
command = "start"
if command == "start":
if not profile_is_parked(home):
return False
marker.unlink()
owner = gw._host_multiplexer_for_all_verb()
if owner is None:
from hermes_cli.gateway_multiplex_served import live_default_gateway_pid
if live_default_gateway_pid() is None:
return False # Unpark even when today's normal start path must start the host first.
host_home = owner.home if owner is not None else get_default_hermes_root()
answer = request_serve_profile_hot(host_home, name)
if ack_confirmed(answer, "served", name):
print(f"Profile '{name}' served by the host gateway.")
else:
print(f"Profile '{name}' unparked, but serving was not confirmed: {ack_failure(answer)}.")
print("The host retries on its next rescan (within 30s). Check gateway status.")
return True
# A separate --force gateway still owns its normal process/service lifecycle.
if gw.find_gateway_pids():
return False
owner = gw._served_by_another_host_gateway()
if owner is None and not gw.named_profile_served_by_running_multiplexer():
return False
host_home = owner.home if owner is not None else get_default_hermes_root()
# Unserving stops this profile's executing turns: the live-work policy applies before the teardown.
from hermes_cli.gateway_lifecycle_guards import refuse_while_sessions_live
refuse_while_sessions_live(command, args, host_home=host_home, profile=name)
if command == "stop":
# Persist intent BEFORE teardown: the periodic rescan must not re-add this profile.
marker.touch()
answer = request_unserve_profile(host_home, name)
if ack_confirmed(answer, "unserved", name):
print(f"Profile '{name}' parked; its bots and cron are stopped. "
f"Start again with: hermes -p {name} gateway start")
else:
print(f"Profile '{name}' parked, but immediate stop was not confirmed: {ack_failure(answer)}.")
print("The host drops it on its next rescan (within 30s).")
return True
answer = request_unserve_profile(host_home, name)
if not ack_confirmed(answer, "unserved", name):
print(f"Profile '{name}' restart was not confirmed: {ack_failure(answer)}.")
return True
answer = request_serve_profile_hot(host_home, name)
if ack_confirmed(answer, "served", name):
print(f"Profile '{name}' restarted by the host gateway.")
else:
print(f"Profile '{name}' stopped, but serving was not confirmed: {ack_failure(answer)}.")
print("The host retries on its next rescan (within 30s). Check gateway status.")
return True
def print_parked_status() -> bool:
"""A parked satellite is still installed, but is not a running gateway."""
from hermes_cli import gateway as gw
name = gw._current_profile_name()
if name and name != "default" and profile_is_parked(get_hermes_home()):
print(f"Profile '{name}': parked (hermes -p {name} gateway start)")
return True
if not name or name == "default":
parked = [profile for profile, home in profiles_to_serve(True, include_parked=True)
if profile != "default" and profile_is_parked(home)]
if parked:
owner = gw.host_multiplexer_serving()
if owner is not None:
print(f"Served profiles: {', '.join(owner.profiles)}")
for profile in parked:
print(f"Profile '{profile}': parked (hermes -p {profile} gateway start)")
return False
def owned_gateway_pid() -> int | None:
"""This profile's own gateway pid, only on positive, fresh ownership evidence; else None.
A served profile's runtime record carries the host multiplexer's pid (#141133), so the recorded
pid alone never authorizes a stop. Every ``find_gateway_pids()`` caller (stop, uninstall's kill,
``service_manager.is_running``, cron readiness, Windows start polls) reads it through here.
"""
from gateway.status import get_running_pid, _get_process_hermes_home
try:
pid = get_running_pid()
if pid is None or not _pid_owned_by_home(pid, _get_process_hermes_home()):
return None
# The runtime may have switched owners while the OS/control probes ran.
return pid if get_running_pid() == pid else None
except Exception:
# An inaccessible rendezvous or process is never permission to kill it.
logger.debug("Could not prove gateway process ownership", exc_info=True)
return None
def _pid_owned_by_home(pid: int, home) -> bool:
"""Ownership ladder, cheapest proof first; the control socket is dialled only for the host's pid.
``gateway status`` asks once per profile and every served profile's record names the host, so the
host record (a file read plus an incarnation check) settles those without a dial.
"""
from gateway import host_attach, host_rendezvous as hr
from gateway.status import _same_hermes_home
from hermes_cli.dashboard_procs import _hermes_home_for_pid
record = hr.read_record(hr.ROLE_GATEWAY)
is_host = record is not None and record.pid == pid and hr.record_names_live_owner(record)
if is_host and not _same_hermes_home(host_attach.record_home(record), home):
return False # the live host, launched from another home
if _launch_record_owns(pid, home):
return True
if is_host:
host_attach.invalidate_host_gateway_cache()
owner = host_attach.host_gateway()
# A verified identity answer from the host's own home needs no argv profile (bare launch).
if owner is not None and owner.pid == pid and owner.served_known:
return _same_hermes_home(owner.home, home)
process_home = _hermes_home_for_pid(pid, require_launch_identity=True)
return process_home is not None and _same_hermes_home(process_home, home)
def _launch_record_owns(pid: int, home) -> bool:
"""``<home>/gateway.pid`` names *pid*'s exact incarnation as launched from *home*.
A gateway writes that file once, about its own launch home (the host never writes one into a
served profile), so it pins the profile resolved at launch: a ``gateway run --force`` that relied
on the sticky ``active_profile`` stays stoppable after the sticky choice changes, which argv and
environment alone cannot prove.
"""
from gateway.status import (
_GATEWAY_KIND, _get_process_start_time, _pid_from_record, _read_pid_record, _same_hermes_home)
record = _read_pid_record() or {}
started, recorded_home = record.get("start_time"), record.get("hermes_home")
return (record.get("kind") == _GATEWAY_KIND and _pid_from_record(record) == pid
and started is not None and started == _get_process_start_time(pid)
and isinstance(recorded_home, str) and _same_hermes_home(recorded_home, home))
def report_profile_stop_failure() -> None:
"""Do not report absence when a live PID was refused or the probe failed."""
from gateway.status import get_running_pid
from hermes_cli import gateway as gw
try:
absent = get_running_pid() is None and not gw.find_gateway_pids()
except Exception:
logger.debug("Could not confirm gateway absence after stop", exc_info=True)
absent = False
if absent:
print("✗ No gateway running for this profile")
return
gw.print_error("Gateway stop was not confirmed; a gateway may still be running. "
"Check gateway status and process ownership.")
_print_host_hint()
raise SystemExit(1)
def _print_host_hint() -> None:
"""Name the refused pid when it is the host gateway serving this profile (#141133)."""
from gateway import host_rendezvous as hr
from gateway.status import get_running_pid
try:
pid, record = get_running_pid(), hr.read_record(hr.ROLE_GATEWAY)
except Exception:
logger.debug("Could not read the host record for the stop hint", exc_info=True)
return
if pid is not None and record is not None and record.pid == pid:
print(f" PID {pid} is the host gateway serving this profile; stopping it would take every "
f"served profile offline. Stop the host instead: hermes gateway stop --all")