mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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.
251 lines
12 KiB
Python
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")
|