Files
hermes-agent/hermes_cli/gateway_supervisor.py
T
Teknium c1e5fa433c fix(gateway,windows): reliability — supervisor task, JOB breakaway, status --deep
Three coordinated fixes for the Windows gateway reliability story:

1. CREATE_BREAKAWAY_FROM_JOB on every detached spawn

   The 'hermes update' triggered from the Electron Desktop GUI ran inside
   Electron's job object. Without breakaway, the post-update gateway
   watcher spawned by update — already DETACHED_PROCESS — was still
   reaped when Electron's job tore down, so the gateway never came back
   after a GUI-initiated update. Adds CREATE_BREAKAWAY_FROM_JOB (0x01000000)
   to:
     - hermes_cli/_subprocess_compat.py::windows_detach_flags() — used by
       every helper that calls windows_detach_popen_kwargs(), including
       launch_detached_profile_gateway_restart()
     - The watcher subprocess's own respawn snippet in
       hermes_cli/gateway.py (inlined flags so the watcher's child
       respawn also breaks away)

   _spawn_detached() in gateway_windows.py already had the flag; this
   change brings the rest of the codebase to parity.

2. Per-minute supervisor Scheduled Task — Windows equivalent of
   systemd Restart=always

   Introduces hermes_cli/gateway_supervisor.py and registers it as a
   second Scheduled Task ('Hermes_Gateway_Supervisor', SC MINUTE /MO 1,
   LIMITED rights) alongside the existing ONLOGON task. Every minute,
   the supervisor uses the same gateway.status.get_running_pid() probe
   as 'hermes gateway status' and, if no gateway is alive, calls
   gateway_windows._spawn_detached() (which now includes BREAKAWAY) to
   bring one back.

   Covers every crash mode, not just 'machine rebooted': taskkill,
   OOM, GUI update SIGTERM, parent job teardown. Cheap — one pythonw
   startup per minute when down, one PID-existence check per minute
   when up.

   Wired into both the schtasks-success and Startup-folder-fallback
   install paths via _install_supervisor_best_effort(), and removed in
   uninstall(). Best-effort: a failing supervisor install logs a
   warning but doesn't roll back the primary install.

3. 'hermes gateway status --deep' shows per-probe PASS/FAIL

   Replaces the existing terse '--deep' output (which only printed
   paths) with an actual diagnostic table:
     [1] PID file present
     [2] Lock file held by a live process
     [3] get_running_pid() result
     [4] _pid_exists(pid) — OS-level liveness
     [5] gateway_state.json (state + age)
     [6] Last lifecycle event from gateway-exit-diag.log

   When the high-level summary disagrees with reality, the user can
   see exactly which signal is lying.

Test-leak fix
-------------

tests/hermes_cli/test_gateway_wsl.py::TestGatewayCommandWSLMessages
monkey-patched is_linux/is_wsl/supports_systemd_services to simulate
WSL but did NOT stub is_windows(). On a Windows host, the dispatcher
in _gateway_command_inner takes the is_windows() branch BEFORE the
WSL guidance branch, so the test invoked gateway_windows.install()
for real. install() writes to %APPDATA%\...\Startup\Hermes_Gateway.cmd
— the REAL user Startup folder, never sandboxed by tmp_path — pointing
at the test's pytest-of-<user>/pytest-<N>/.../gateway-service/ wrapper.
When pytest tore down the tmp_path, every subsequent Windows login
flashed a cmd.exe window that failed to find the missing target.

Stubs is_windows=False on all four affected tests:
  test_install_wsl_no_systemd
  test_start_wsl_no_systemd
  test_status_wsl_running_manual
  test_status_wsl_not_running

Defense-in-depth: _build_startup_launcher() now prefixes the launcher
with 'if not exist <target> exit /b 0', so any future stale Startup
entry silently no-ops instead of flashing a console window.

Status enhancements
-------------------

- status() now reports supervisor task presence alongside the existing
  schtasks/Startup info, and nudges the user to reinstall if the
  supervisor isn't registered.
- Deep mode dumps both the supervisor task name + script path.
2026-06-06 08:10:19 -07:00

171 lines
5.4 KiB
Python

"""Windows gateway supervisor — poll-and-respawn for crash recovery.
Invoked once per minute by the ``Hermes_Gateway_Supervisor`` Scheduled Task
(see :func:`hermes_cli.gateway_windows._install_supervisor_task`). Checks
whether the per-profile gateway is alive and, if not, spawns a detached
replacement using the same ``_spawn_detached()`` helper that
``hermes gateway start`` uses.
This is the Windows analogue of systemd's ``Restart=always``. It runs as the
logged-in user (LIMITED rights), needs no admin context, and exits silently
when the gateway is already up.
CLI:
pythonw -m hermes_cli.gateway_supervisor [--profile NAME]
Exit codes:
0 always (the supervisor is best-effort; never crash the schtasks parent)
Logging:
All actions are appended to ``$HERMES_HOME/logs/gateway-supervisor.log``.
The file is truncated to the last 2000 lines on each invocation to keep
it bounded.
"""
from __future__ import annotations
import argparse
import datetime
import os
import sys
import traceback
from pathlib import Path
_LOG_MAX_LINES = 2000
def _resolve_log_path() -> Path:
"""Return the supervisor log path under the current HERMES_HOME.
Imported lazily so a missing/broken hermes_constants import doesn't
crash the supervisor — we fall back to a sensible default.
"""
try:
from hermes_constants import get_hermes_home
home = get_hermes_home()
except Exception:
# Best-effort fallback. The supervisor must never crash on import.
home = Path(os.environ.get("HERMES_HOME") or Path.home() / ".hermes")
log_dir = Path(home) / "logs"
return log_dir / "gateway-supervisor.log"
def _log(msg: str) -> None:
"""Append a timestamped line to the supervisor log.
Best-effort: a logging failure must never crash the supervisor.
"""
try:
log_path = _resolve_log_path()
log_path.parent.mkdir(parents=True, exist_ok=True)
ts = datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")
with open(log_path, "a", encoding="utf-8") as fh:
fh.write(f"[{ts}] {msg}\n")
except Exception:
pass
def _truncate_log() -> None:
"""Keep the supervisor log bounded (last ``_LOG_MAX_LINES`` lines).
Cheap to do once per invocation — file is small and reads sequentially.
"""
try:
log_path = _resolve_log_path()
if not log_path.exists():
return
with open(log_path, "r", encoding="utf-8", errors="replace") as fh:
lines = fh.readlines()
if len(lines) <= _LOG_MAX_LINES:
return
tail = lines[-_LOG_MAX_LINES:]
tmp = log_path.with_suffix(log_path.suffix + ".tmp")
with open(tmp, "w", encoding="utf-8") as fh:
fh.writelines(tail)
tmp.replace(log_path)
except Exception:
pass
def _parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
prog="hermes_cli.gateway_supervisor",
description="Windows poll-and-respawn supervisor for the Hermes gateway.",
)
# ``--profile`` is accepted for parity with the main CLI but the
# supervisor relies on HERMES_HOME being correctly set in the
# scheduled-task environment; the .cmd wrapper sets it explicitly.
parser.add_argument("--profile", default=None)
return parser.parse_args(argv)
def _gateway_is_alive() -> tuple[bool, int | None]:
"""Probe whether a gateway is running for the current HERMES_HOME.
Returns ``(alive, pid)``. ``pid`` is the running gateway PID when
``alive`` is True, else None.
"""
try:
from gateway.status import get_running_pid
except Exception as exc:
_log(f"probe import failure: {exc!r}")
return (True, None) # Conservative: don't try to respawn if we can't probe
try:
pid = get_running_pid(cleanup_stale=False)
except Exception as exc:
_log(f"get_running_pid raised: {exc!r}")
return (True, None)
return (pid is not None, pid)
def _respawn() -> int | None:
"""Spawn a fresh detached gateway. Return the PID, or None on failure."""
try:
from hermes_cli import gateway_windows
except Exception as exc:
_log(f"gateway_windows import failure: {exc!r}")
return None
try:
return gateway_windows._spawn_detached()
except Exception as exc:
_log(f"_spawn_detached raised: {exc!r}\n{traceback.format_exc()}")
return None
def main(argv: list[str] | None = None) -> int:
"""Entrypoint for ``pythonw -m hermes_cli.gateway_supervisor``."""
try:
_parse_args(argv)
except SystemExit:
# argparse exits with 2 on --help / bad args. The schtasks parent
# should still see 0 so the task doesn't get marked "last run failed".
return 0
except Exception as exc:
_log(f"argparse raised: {exc!r}")
return 0
_truncate_log()
try:
alive, pid = _gateway_is_alive()
if alive:
# Stay quiet on healthy ticks to keep the log small.
return 0
_log("gateway is down; respawning")
new_pid = _respawn()
if new_pid is None:
_log("respawn failed; will retry on next tick")
else:
_log(f"respawned gateway pid={new_pid}")
except Exception as exc:
_log(f"supervisor uncaught: {exc!r}\n{traceback.format_exc()}")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))