Files
hermes-agent/hermes_cli/gateway_profile_lifecycle.py
teknium1 659423a95c fix(gateway): explain why parked restart unparks and status reads runtime first (#124553, salvage #124669)
Follow-up trim to the salvaged fix from #124669 (@lorenzozanee): add the why-comments on the
two one-line branch changes so the next reader does not "simplify" them back.

- profile_lifecycle: `stop` both parks the profile and drops it from the host's served set, so a
  later `restart` finds no host serving it and fell through to the standalone restart path.
  Treating a parked restart as `start` unparks and hot-serves via the host; the
  `find_gateway_pids()` guard keeps a live --force gateway's own restart ownership.
- _cmd_status: gateway.parked records intent, not runtime. Read the runtime snapshot before
  the parked short-circuit so a --force-started live gateway is reported beside the marker;
  a parked profile with nothing running still stops at the parked line.

Sibling surfaces checked: dashboard POST /api/gateway/restart?profile=X spawns
`hermes -p X gateway restart` (same profile_lifecycle, fixed by the same change);
/api/status lists live gateways independently of parked_profiles; tui_gateway has no
parked-marker consumer.
2026-09-28 13:30:57 -07:00

116 lines
5.7 KiB
Python

"""Named-profile lifecycle inside the host gateway, never a service-manager action."""
from __future__ import annotations
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
def _confirmed(answer, key, name):
return isinstance(answer, dict) and answer.get(key) == name and not answer.get("error")
def _failure(answer):
if isinstance(answer, dict):
return answer.get("error") or "host operation is still pending"
return "host control socket did not answer"
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())
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 _confirmed(answer, "served", name):
print(f"Profile '{name}' served by the host gateway.")
else:
print(f"Profile '{name}' unparked, but serving was not confirmed: {_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()
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 _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: {_failure(answer)}.")
print("The host drops it on its next rescan (within 30s).")
return True
answer = request_unserve_profile(host_home, name)
if not _confirmed(answer, "unserved", name):
print(f"Profile '{name}' restart was not confirmed: {_failure(answer)}.")
return True
answer = request_serve_profile_hot(host_home, name)
if _confirmed(answer, "served", name):
print(f"Profile '{name}' restarted by the host gateway.")
else:
print(f"Profile '{name}' stopped, but serving was not confirmed: {_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