Files
hermes-agent/gateway/status.py
teknium1 5a0225dfff fix(gateway): restart watcher names its checkout instead of inheriting the cwd
The detached watcher runs as `<python> -c <program>`, so `hermes_cli` resolved only
because update_completion happened to spawn it with cwd=<checkout>. The program now puts
the checkout on sys.path itself, and the bare-Python test runs it from an unrelated cwd
(red without the sys.path line).

Also drops the two unused re-export aliases in gateway.status (`_posix_is_zombie`,
`_pid_exists_win32_ctypes`): nothing imports them and neither is in the old-updater
compat surface.
2026-09-27 03:37:09 -07:00

2194 lines
104 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Gateway runtime status helpers: PID/lock/marker files under ``{HERMES_HOME}`` (one set per
home/profile) that tell whether the gateway daemon is running."""
import asyncio
import contextlib
import copy
import hashlib
import json
import logging
import math
import os
import re
import shlex
import signal
import subprocess
import sys
import threading
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, NamedTuple, Optional
from hermes_constants import _get_platform_default_hermes_home, get_hermes_home, get_process_hermes_home
from hermes_cli._subprocess_compat import pid_exists_stdlib
from utils import atomic_json_write
if sys.platform == "win32":
import msvcrt
else:
import fcntl
_GATEWAY_KIND = "hermes-gateway"
_RUNTIME_STATUS_FILE = "gateway_state.json"
_LOCKS_DIRNAME = "gateway-locks"
_IS_WINDOWS = sys.platform == "win32"
_UNSET = object()
_GATEWAY_LOCK_FILENAME = "gateway.lock"
_gateway_lock_handle = None
# Windows byte-range locks are mandatory for other readers: lock a byte well past
# the JSON payload so status/PID readers can read while another process holds it.
_WINDOWS_LOCK_OFFSET = 1024 * 1024
_GATEWAY_RUNNING_PID_CACHE_TTL_SECONDS = 1.0
_gateway_running_pid_cache_lock = threading.Lock()
# key: (pid_path, cleanup_stale, include_runtime_status) -> (cached_at, file signature, pid)
_gateway_running_pid_cache: dict[tuple[str, bool, bool], tuple[float, tuple, Optional[int]]] = {}
logger = logging.getLogger(__name__)
class _RuntimeStatusWriter:
"""Persist the latest complete status snapshot on one daemon thread.
Runtime status is diagnostic state, not an event log. While one write is
blocked in filesystem I/O, newer submissions replace the single pending
snapshot. This bounds memory and keeps every status producer off asyncio.
"""
def __init__(self, write_fn: Optional[Callable[[Path, dict[str, Any]], None]] = None):
self._write_fn = write_fn
self._condition = threading.Condition()
self._pending: Optional[tuple[int, Path, dict[str, Any]]] = None
self._writing_generation = 0
self._submitted_generation = 0
self._completed_generation = 0
self._successful_generation = 0
self._last_error: Optional[BaseException] = None
self._failure_logged = False
self._thread: Optional[threading.Thread] = None
def submit(self, path: Path, payload: dict[str, Any]) -> int:
with self._condition:
self._submitted_generation += 1
generation = self._submitted_generation
self._pending = (generation, path, copy.deepcopy(payload))
if self._thread is None or not self._thread.is_alive():
self._thread = threading.Thread(
target=self._run, daemon=True, name="gateway-runtime-status-writer")
self._thread.start()
self._condition.notify_all()
return generation
def wait(self, generation: int, timeout: Optional[float] = None) -> bool:
"""Block until ``generation`` (or a later snapshot) is persisted; ``False`` on timeout/failure."""
deadline = None if timeout is None else time.monotonic() + max(timeout, 0.0)
with self._condition:
while (state := self.settled(generation)) is None:
if deadline is None:
self._condition.wait()
continue
remaining = deadline - time.monotonic()
if remaining <= 0:
return False
self._condition.wait(timeout=remaining)
if not state and timeout is None and self._last_error is not None:
raise self._last_error
return state
def flush(self, timeout: float = 2.0) -> bool:
with self._condition:
generation = self._submitted_generation
return generation == 0 or self.wait(generation, timeout=timeout)
def settled(self, generation: int) -> Optional[bool]:
"""``True`` once ``generation`` persisted, ``False`` once it can no longer, else ``None``."""
with self._condition:
if self._successful_generation >= generation:
return True
no_more_work = (
self._completed_generation >= generation
and self._writing_generation == 0
and self._pending is None
)
return False if no_more_work else None
def _run(self) -> None:
while True:
with self._condition:
while self._pending is None:
self._condition.wait()
generation, path, payload = self._pending
self._pending = None
self._writing_generation = generation
error: Optional[BaseException] = None
try:
(self._write_fn or _write_json_file)(path, _merge_over_on_disk(path, payload))
except BaseException as exc:
error = exc
with self._condition:
self._writing_generation = 0
self._completed_generation = max(self._completed_generation, generation)
if error is None:
self._successful_generation = max(self._successful_generation, generation)
self._last_error = None
else:
self._last_error = error
self._condition.notify_all()
if error is None:
if self._failure_logged:
logger.info("Gateway runtime-status persistence recovered")
self._failure_logged = False
elif not self._failure_logged:
logger.warning(
"Failed to persist gateway runtime status; later updates will retry: %s", error)
self._failure_logged = True
else:
logger.debug("Failed to persist gateway runtime status: %s", error)
_runtime_status_state_lock = threading.RLock()
_runtime_status_state_path: Optional[Path] = None
_runtime_status_state: Optional[dict[str, Any]] = None
def _merge_over_on_disk(path: Path, payload: dict[str, Any]) -> dict[str, Any]:
"""Lay the canonical snapshot over whatever is on disk right before writing. Out-of-process
writers (the migration's compensator clearing multiplex-owned status, container_boot
seeding ``desired_state``) stamp this file directly; the gateway's fields win, theirs survive."""
existing = _read_json_file(path)
return {**existing, **payload} if isinstance(existing, dict) else payload
_runtime_status_writer: Optional[_RuntimeStatusWriter] = None
def _get_runtime_status_writer() -> _RuntimeStatusWriter:
"""Lazily create the single writer; callers serialise on ``_runtime_status_state_lock``."""
global _runtime_status_writer
with _runtime_status_state_lock:
if _runtime_status_writer is None:
_runtime_status_writer = _RuntimeStatusWriter()
return _runtime_status_writer
def flush_runtime_status(timeout: float = 2.0) -> bool:
"""Wait boundedly for all runtime-status updates submitted so far."""
writer = _runtime_status_writer
return True if writer is None else writer.flush(timeout=timeout)
async def flush_runtime_status_async(timeout: float = 2.0) -> bool:
"""Await the current writer generation without blocking the event loop."""
writer = _runtime_status_writer
if writer is None:
return True
# ``flush()`` returns at its deadline, so the worker thread is bounded.
return await asyncio.to_thread(writer.flush, timeout=max(float(timeout), 0.0))
class StormInfo(NamedTuple):
"""Respawn-storm check result: start count, window, and backoff to sleep."""
count: int
window_s: float
backoff_s: float
def record_start_and_check_storm(
max_starts: int = 5, window_s: float = 120.0, *, backoff_cap_s: float = 300.0
) -> Optional[StormInfo]:
"""Record this start; :class:`StormInfo` when > ``max_starts`` landed in ``window_s``.
Best-effort: a broken ``gateway-starts.log`` ledger is logged and swallowed, never fatal."""
try:
path = get_hermes_home() / "gateway-starts.log"
path.parent.mkdir(parents=True, exist_ok=True)
now = datetime.now(timezone.utc).timestamp()
existing: list[float] = []
if path.exists():
for line in path.read_text(encoding="utf-8-sig").splitlines():
line = line.strip()
if not line:
continue
try:
existing.append(float(line))
except ValueError:
pass # corrupt/truncated ledger line — skip, never fatal
existing.append(now)
recent = [ts for ts in existing if now - ts <= window_s]
# Ring-buffer the persisted file so it stays bounded.
to_write = existing[-max(max_starts * 4, 40):]
tmp = path.with_suffix(".tmp")
tmp.write_text("\n".join(repr(ts) for ts in to_write) + "\n", encoding="utf-8")
os.replace(tmp, path)
if len(recent) <= max_starts:
return None
backoff = min(backoff_cap_s, 5.0 * (2 ** min(len(recent) - max_starts, 6)))
return StormInfo(count=len(recent), window_s=window_s, backoff_s=backoff)
except Exception as _e:
logger.debug("respawn-storm breaker bookkeeping failed (non-fatal): %s", _e)
return None
def _get_process_hermes_home() -> Path:
"""Launch-home HERMES_HOME for identity files (PID, lock, status, markers):
``get_hermes_home()`` honors the per-session ``_HERMES_HOME_OVERRIDE`` and would misroute
them."""
return get_process_hermes_home()
def _canonical_hermes_home(path: Path | str) -> Path:
"""Stable absolute HERMES_HOME path for persisted identity data."""
return Path(path).expanduser().resolve(strict=False)
def _same_hermes_home(left: Path | str, right: Path | str) -> bool:
"""Compare HERMES_HOME paths with the host platform's case semantics."""
left_c = os.path.normcase(str(_canonical_hermes_home(left)))
return left_c == os.path.normcase(str(_canonical_hermes_home(right)))
def recorded_gateway_home_conflicts(
record: Optional[dict[str, Any]], *, expected_home: Optional[Path | str] = None
) -> bool:
"""True when a persisted gateway record names a DIFFERENT HERMES_HOME (cross-profile kill guard:
profile B's stop must never SIGTERM profile A). ``expected_home`` overrides the comparison base.
Legacy records without ``hermes_home`` prove nothing -> False; a comparison failure fails
closed -> True."""
recorded_home = record.get("hermes_home") if isinstance(record, dict) else None
if not isinstance(recorded_home, str) or not recorded_home.strip():
return False
try:
base = expected_home if expected_home is not None else _get_process_hermes_home()
return not _same_hermes_home(recorded_home, base)
except Exception:
return True
# Mirrors hermes_cli.profiles._PROFILE_ID_RE -- duplicated so gateway identity code
# stays import-light (hermes_constants + stdlib only).
_PROFILE_LABEL_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,63}$")
def _profile_label_for_home(home: Path | str) -> Optional[str]:
"""Best-effort label: ``<root>/profiles/<name>`` -> name, root home -> "default", else None."""
try:
canonical = _canonical_hermes_home(home)
except Exception:
return None
if canonical.parent.name == "profiles" and _PROFILE_LABEL_RE.match(canonical.name):
return canonical.name
import hermes_constants
default_homes = (hermes_constants.get_default_hermes_root, _get_platform_default_hermes_home)
for default_home in default_homes:
with contextlib.suppress(Exception):
if _same_hermes_home(canonical, default_home()):
return "default"
return None
def scoped_lock_owner_label(record: Optional[dict[str, Any]]) -> Optional[str]:
"""Profile label of a scoped-lock owner (None: PID-only wording): the validated ``profile``
field stamped by :func:`acquire_scoped_lock`, else inferred from ``hermes_home`` (old locks)."""
if not isinstance(record, dict):
return None
profile = record.get("profile")
if isinstance(profile, str) and _PROFILE_LABEL_RE.match(profile.strip()):
return profile.strip()
home = record.get("hermes_home")
return _profile_label_for_home(home) if isinstance(home, str) and home.strip() else None
def _get_pid_path() -> Path:
return _get_process_hermes_home() / "gateway.pid"
def _get_gateway_lock_path(pid_path: Optional[Path] = None) -> Path:
return (pid_path or _get_pid_path()).with_name(_GATEWAY_LOCK_FILENAME)
def _get_runtime_status_path() -> Path:
return _get_process_hermes_home() / _RUNTIME_STATUS_FILE
def _get_lock_dir() -> Path:
"""Cross-profile rendezvous dir for machine-local locks; ``HERMES_GATEWAY_LOCK_DIR`` overrides.
Scope is the **OS user**, not the kernel host: separate users have separate ``$HOME``s,
separate ``~/.hermes`` profile roots and separate credentials, so "one gateway per host"
means "one per host per OS user". Holds the token-scoped locks (:func:`acquire_scoped_lock`)
and the host-role lock + rendezvous record (``gateway/host_rendezvous.py``); the per-home
``gateway.pid``/``gateway.lock`` above deliberately stay under each profile's HERMES_HOME.
"""
override = os.getenv("HERMES_GATEWAY_LOCK_DIR")
if override:
return Path(override)
# XDG spec: a relative $XDG_STATE_HOME is INVALID and must be ignored. Honouring one made the
# lock dir CWD-relative, so two serves started from different directories shared no singleton.
state_home_env = os.getenv("XDG_STATE_HOME") or ""
state_home = Path(state_home_env) if os.path.isabs(state_home_env) else Path.home() / ".local" / "state"
return state_home / "hermes" / _LOCKS_DIRNAME
def _utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
# Epochs before 2000-01-01 are corrupt/hand-edited state (e.g. an accidental 0).
_EPOCH_MIN_PLAUSIBLE = 946684800.0 # 2000-01-01T00:00:00Z
def normalize_updated_at(value: Any) -> Optional[str]:
"""Coerce a persisted ``updated_at`` (ISO string, legacy epoch, hand edit, garbage) to the
RFC3339 ``string | null`` that ``/api/status`` promises. ``str``: iff fromisoformat parses
(trailing ``Z`` tolerated; naive -> UTC). Epoch: before 2000-01-01, > 1 day ahead or
non-finite -> None. ``bool``/other -> None."""
if isinstance(value, str):
raw = value.strip()
# Python < 3.11 fromisoformat rejects a trailing 'Z'; tolerate it.
if raw.endswith(("Z", "z")):
raw = raw[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(raw)
except ValueError:
return None
return (parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)).isoformat()
if isinstance(value, (int, float)) and not isinstance(value, bool):
seconds = float(value)
now = datetime.now(timezone.utc).timestamp()
if not math.isfinite(seconds) or seconds < _EPOCH_MIN_PLAUSIBLE or seconds > now + 86400:
return None
try:
return datetime.fromtimestamp(seconds, tz=timezone.utc).isoformat()
except (OverflowError, OSError, ValueError):
return None
return None
# ``exit_reason`` values the out-of-loop watchdogs (gateway/shutdown_watchdog.py) stamp together with
# ``gateway_state: degraded`` right before they hard-exit a wedged process (#113372).
WATCHDOG_EXIT_REASONS = frozenset({"loop_liveness_watchdog", "shutdown_watchdog"})
def retained_gateway_state(runtime: Any) -> str:
"""What a NOT-running gateway's retained ``gateway_state.json`` says about it now:
``"startup_failed"`` (or a watchdog-stamped ``"degraded"``) only while the operator still
wants it running, else ``"stopped"``.
``hermes gateway stop`` keeps the last ``startup_failed`` + ``exit_reason`` on disk for
diagnostics and records the durable stop intent as ``desired_state``; a profile the operator
stopped is "stopped", not a current failure. A watchdog exit (``degraded`` + an exit_reason in
``WATCHDOG_EXIT_REASONS``) is the same kind of current failure as ``startup_failed`` and is kept
under the same rule, so the dashboard agrees with ``hermes gateway status``. Any other retained
state of a dead process (``running``, ``starting``, missing) is just "stopped". Shared by
``/api/status`` and ``/api/messaging/platforms`` so the sidebar strip and the Channels page
cannot disagree."""
rt = runtime if isinstance(runtime, dict) else {}
if rt.get("desired_state") != "stopped":
if rt.get("gateway_state") == "startup_failed":
return "startup_failed"
if rt.get("gateway_state") == "degraded" and rt.get("exit_reason") in WATCHDOG_EXIT_REASONS:
return "degraded"
return "stopped"
def terminate_pid(
pid: int, *, force: bool = False, expected_start_time: Optional[float] = None
) -> None:
"""Terminate a PID; POSIX SIGTERM/SIGKILL, Windows taskkill /T /F for force. Identity guard:
Windows ``force`` REQUIRES a matching ``expected_start_time`` (taskkill on a recycled PID has
killed svchost.exe); POSIX optional, but a provided mismatch refuses the kill everywhere.
On POSIX an expectation is optional, but when the caller provides one and it no longer matches the live
process, the kill is refused on every platform — a mismatched fingerprint always means the PID was
recycled. See #89614.
"""
if force and (_IS_WINDOWS or expected_start_time is not None):
if expected_start_time is None:
raise OSError(f"refusing to force-kill PID {pid} without a process start-time guard")
current_start_time = _get_process_start_time(pid)
if current_start_time is None:
raise OSError(f"refusing to force-kill PID {pid}; process start time is unavailable")
try:
if not _start_times_agree(current_start_time, expected_start_time):
raise OSError(f"refusing to force-kill PID {pid}; process identity changed")
except (TypeError, ValueError) as exc:
raise OSError(f"refusing to force-kill PID {pid}; malformed start time") from exc
if not (force and _IS_WINDOWS):
os.kill(pid, signal.SIGTERM if not force else getattr(signal, "SIGKILL", signal.SIGTERM))
return
# Hide flags: a bare taskkill spawn from windowless pythonw.exe would flash a conhost window.
from hermes_cli._subprocess_compat import windows_hide_flags
try:
result = subprocess.run(
["taskkill", "/PID", str(pid), "/T", "/F"], capture_output=True, text=True,
encoding="utf-8", errors="replace", timeout=10, creationflags=windows_hide_flags(),
)
except FileNotFoundError:
os.kill(pid, signal.SIGTERM)
return
if result.returncode != 0:
details = (result.stderr or result.stdout or "").strip()
raise OSError(details or f"taskkill failed for PID {pid}")
def _start_times_agree(current: Any, *recorded: Any) -> bool:
"""Same process object: all fingerprints > 0 and within 1ms of ``current``; raises on junk."""
cur = float(current)
return cur > 0 and all(r > 0 and abs(r - cur) <= 0.001 for r in map(float, recorded))
# Same-host start-time readings can drift by ~1 s between the claim-time and a later liveness read
# (macOS ``kern.boottime`` adjustment, #117505). Both fingerprint scales are ×100 (Linux /proc ticks,
# psutil centiseconds), so 200 means 2 s on either platform — a recycled PID is essentially never
# that close to the original's start time.
START_TIME_DRIFT_TOLERANCE = 200
def start_time_fingerprints_match(recorded: Any, current: Any, tolerance: int = START_TIME_DRIFT_TOLERANCE) -> bool:
"""Liveness-reconciliation comparator for :func:`get_process_start_time` fingerprints: the
recorded owner and the current reading are the same incarnation when they agree within
``tolerance``. Raises on junk; callers decide what an unreadable (``None``) side means."""
return abs(int(current) - int(recorded)) <= tolerance
def _scope_hash(identity: str) -> str:
return hashlib.sha256(identity.encode("utf-8")).hexdigest()[:16]
def _get_scope_lock_path(scope: str, identity: str) -> Path:
return _get_lock_dir() / f"{scope}-{_scope_hash(identity)}.lock"
def _get_process_start_time(pid: int) -> Optional[int]:
"""Return a stable per-process start-time fingerprint, or None.
Used as a PID-reuse guard: a ``(pid, start_time)`` pair uniquely identifies
a process, so a recycled PID (same number, different process) yields a
different value and is never mistaken for the original.
On Linux this is field 22 of ``/proc/<pid>/stat`` (start time in clock
ticks since boot, an int). On platforms without ``/proc`` (macOS, Windows)
we fall back to ``psutil.Process(pid).create_time()`` — a float epoch
timestamp — quantized to an int (centiseconds) for stable equality.
The two sources are never mixed on a single platform: ``/proc`` always
succeeds first on Linux, and always fails on macOS/Windows so psutil is
always used there. Because the guard only compares the value recorded at
spawn against the live value *on the same host*, the differing units across
platforms are irrelevant — only same-source equality matters.
"""
stat_path = Path(f"/proc/{pid}/stat")
try:
# Field 22 in /proc/<pid>/stat is process start time (clock ticks).
return int(stat_path.read_text(encoding="utf-8").split()[21]) # windows-footgun: ok (/proc is BOM-free)
except (FileNotFoundError, IndexError, PermissionError, ValueError, OSError):
pass
# No /proc (macOS / Windows): psutil is a hard dependency and exposes a
# cross-platform creation time. Quantize to centiseconds so repeated reads
# of the same process compare equal without float-precision fragility.
try:
import psutil # type: ignore
return int(round(psutil.Process(pid).create_time() * 100))
except Exception:
return None
def get_process_start_time(pid: int) -> Optional[int]:
"""Public wrapper for retrieving a process start time when available."""
return _get_process_start_time(pid)
def _read_process_cmdline(pid: int) -> Optional[str]:
"""Process command line as one string: /proc, then psutil, then ``ps``.
Order is by cost, and this runs per live gateway on every roster/status poll. ``psutil`` reads
the process table in-process (a ``sysctl`` on macOS) where ``ps`` costs a fork+exec — measured
0.02ms against 4.2ms on macOS for the same string. It cannot always answer: on macOS it raises
``AccessDenied`` for a process owned by another user, which ``ps`` still reports, so ``ps``
stays as the fallback rather than being replaced."""
with contextlib.suppress(OSError):
raw = Path(f"/proc/{pid}/cmdline").read_bytes()
if raw:
return raw.replace(b"\x00", b" ").decode("utf-8", errors="ignore").strip()
with contextlib.suppress(Exception):
import psutil # type: ignore
cmdline_parts = psutil.Process(pid).cmdline()
if cmdline_parts:
return " ".join(cmdline_parts)
if not _IS_WINDOWS:
with contextlib.suppress(OSError, subprocess.TimeoutExpired):
result = subprocess.run(
["ps", "-p", str(pid), "-o", "command="],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
return None
def inline_source_flag_index(tokens: list[str]) -> int | None:
"""Index of the ``-c`` token when *tokens* is an interpreter running INLINE SOURCE, else None.
Everything after ``-c`` is data the inline program receives, not this process's own identity.
The detached gateway restart watcher (``gateway._spawn_gateway_restart_watcher``) is spawned as
``python -c <watcher source> <old_pid> <python> -m hermes_cli.main gateway run``: its trailing
argv is the command the watcher will LATER spawn, so every argv matcher used to read it as a
live gateway. See #107002 and the "never infer process identity from argv substrings" rule.
Only interpreter options may precede ``-c``; the first non-option token ends the option block
(``python -m hermes_cli.main …`` therefore never matches).
The walk is VALUE-AWARE: ``-X``/``-W``/``-Q`` and ``--check-hash-based-pycs``/``--jit`` take a
SEPARATE operand, so a naive "first non-option token ends the block" walk mistakes that operand
for the end of the block and never reaches the ``-c`` behind it (``python -X utf8 -c <src> …``
was still read as a live gateway). The operand sets are the canonical ones in
``hermes_state_holders``, not a second hand-rolled copy.
*tokens* must be CASE-PRESERVING: the operand-taking ``-Q``/``-W``/``-X`` differ from the
operand-less ``-q``/``-b``, so a lowercased argv would skip the token after a plain ``-q``.
"""
from hermes_state_holders import (
_PYTHON_LONG_OPTIONS_WITH_OPERANDS,
_PYTHON_SHORT_OPTIONS_WITH_OPERANDS,
)
index = 1
while index < len(tokens):
token = tokens[index]
if token == "--":
return None
if token in _PYTHON_LONG_OPTIONS_WITH_OPERANDS:
index += 2 # the next token is this option's operand, not the end of the option block
continue
if token.startswith("--"):
index += 1 # ``--opt=value`` and operand-less long options
continue
if not token.startswith("-") or token == "-":
return None
# Clustered short options (``-uc``, ``-IsB``). An operand-taking letter consumes the rest of
# the cluster as its attached value, or the following token when the cluster ends there --
# so ``-Xc`` is ``-X c``, NOT an inline-source ``-c``.
cluster = token[1:]
for position, letter in enumerate(cluster):
if letter == "c":
return index
if letter in _PYTHON_SHORT_OPTIONS_WITH_OPERANDS:
index += 1 if cluster[position + 1 :] else 2
break
else:
index += 1
return None
def command_line_runs_inline_source(tokens: list[str]) -> bool:
"""True when *tokens* is an interpreter running INLINE SOURCE (``python -c <src> [args]``)."""
return inline_source_flag_index(tokens) is not None
# Hermes' own inline bootstraps hand control to a Hermes entry point IN this process, so the argv
# they run with is this process's own identity; every other ``-c`` program keeps its trailing argv
# as data (#107002). Each pattern is one emitted source shape, anchored at both ends so a program
# merely CARRYING a bootstrap command line (the restart watcher's respawn argv) never matches.
_Q = r"""['"]?"""
_MAIN = rf"{_Q}__main__{_Q}"
_RUN_MODULE = rf"runpy\.run_module\(\s*{_Q}(?P<target>[\w.]+){_Q}\s*,\s*run_name\s*=\s*{_MAIN}\s*,\s*alter_sys\s*=\s*True\s*\)"
_BOOTSTRAPS = (
# hermes_cli._launchers.runtime_command (store launcher, the Windows updater's relaunch)
("module", re.compile(rf"import os, sys, runpy;.*\b{_RUN_MODULE}", re.S)),
# hermes_cli.venv_sync.relaunch_command: argv is assigned inside the source
("module", re.compile(rf"import sys, runpy; sys\.path\.insert\(.*\b{_RUN_MODULE}", re.S)),
("path", re.compile(
rf"import sys, runpy; sys\.path\.insert\(.*\brunpy\.run_path\(\s*{_Q}(?P<target>[^'\"]+?){_Q}\s*,\s*run_name\s*=\s*{_MAIN}\s*\)",
re.S)),
# hermes_cli._launchers._launcher_script (the published POSIX shell / Windows .cmd launcher)
("entry", re.compile(r"import os, re, sys\s.*\bfrom\s+(?P<target>[\w.]+)\s+import\s+(?P<func>\w+)\b.*\bsys\.exit\(\s*(?P=func)\(\)\s*\)", re.S)),
# hermes_cli._launchers._write_cmd_launcher: the launcher script, base64-encoded
("base64", re.compile(rf"import base64; exec\(base64\.b64decode\({_Q}(?P<target>[A-Za-z0-9+/=]+){_Q}\)\)")),
)
_ASSIGNED_ARGV = re.compile(r"\bsys\.argv\s*=\s*\[(.*?)\]\s*;")
def _bootstrap_entry(source: str, argv: list[str]) -> list[str] | None:
"""``[-m, <module>, *argv]`` (or ``[<path>, *argv]``) the inline *source* runs in-process, else None."""
source = source.strip()
kind, match = next(((k, m) for k, p in _BOOTSTRAPS if (m := p.fullmatch(source))), (None, None))
if match is None:
return None
target = match["target"]
if kind == "base64":
import base64
import binascii
try:
return _bootstrap_entry(base64.b64decode(target, validate=True).decode("utf-8"), argv)
except (binascii.Error, UnicodeDecodeError):
return None
if kind == "entry": # the launcher script's own ``--run-module <module>`` switch
return ["-m", argv[1], *argv[2:]] if argv[:1] == ["--run-module"] and len(argv) > 1 else ["-m", target, *argv]
if assigned := _ASSIGNED_ARGV.search(source):
argv = [item.strip().strip("'\"") for item in assigned.group(1).split(",")][1:]
return [target, *argv] if kind == "path" else ["-m", target, *argv]
def inline_bootstrap_argv(tokens: list[str]) -> list[str] | None:
"""*tokens* as the equivalent ``python -m <module> <argv…>`` when this interpreter's ``-c`` source
is a Hermes bootstrap running an entry point in-process; None for any other inline source.
Command lines usually arrive space-joined (``/proc``, psutil, ``ps``), which splits the source
across tokens; the shortest token run that ends in a recognised tail is the source, whatever
joined it, and the tokens after it are the entry point's argv.
"""
index = inline_source_flag_index(tokens)
if index is None:
return None
for end in range(index + 1, len(tokens)):
if tokens[end].rstrip().endswith(")"):
entry = _bootstrap_entry(" ".join(tokens[index + 1 : end + 1]), tokens[end + 1 :])
if entry is not None:
return [tokens[0], *entry]
return None
def _gateway_command_subcommand(command: str | None) -> str | None:
"""Hermes gateway lifecycle subcommand from a command line, or None. No loose substring matches
(``"gateway" in cmdline`` also matched ``gateway status`` / ``python -m tui_gateway``): needs a
Hermes entrypoint plus the ``gateway`` subcommand, or a gateway-dedicated entrypoint. Tokenizes
quote-aware (Windows paths with spaces); ``--profile``/``-p`` selectors are stripped anywhere in
argv since ``_apply_profile_override`` removes them before argparse."""
if not command:
return None
try:
raw_tokens = shlex.split(command, posix=False)
except ValueError:
raw_tokens = command.split()
# Strip surrounding quotes, normalize slashes + case per token.
cased_tokens = [t.strip("\"'").replace("\\", "/") for t in raw_tokens]
tokens = [t.lower() for t in cased_tokens]
if not tokens:
return None
basenames = [t.rsplit("/", 1)[-1] for t in tokens]
# ``python -c <src> … -m hermes_cli.main gateway run``: the trailing argv belongs to the program
# the inline source will spawn later, not to this process (#107002). Case-preserving tokens:
# the operand-taking ``-X``/``-W``/``-Q`` must not be conflated with ``-q``/``-b``.
if command_line_runs_inline_source(cased_tokens):
# …unless the source is a Hermes bootstrap running the entry point in THIS process (store
# launcher, launcher script, venv_sync re-entry): then its argv is this process's (#124318).
cased_tokens = inline_bootstrap_argv(cased_tokens)
if cased_tokens is None:
return None
tokens = [t.lower() for t in cased_tokens]
basenames = [t.rsplit("/", 1)[-1] for t in tokens]
# The launchd job's osascript wrapper (gateway_launchd.launchd_program_arguments) carries the gateway argv
# inside one JXA script string; the gateway itself is its child and is matched on its own command line.
if basenames[0] == "osascript":
return None
# Gateway-dedicated entrypoints carry no subcommand to inspect.
if any(t == "gateway/run.py" or t.endswith("/gateway/run.py") for t in tokens):
return "run"
# Atomic Hermes' bundled desktop runner shares HERMES_HOME with the CLI; without this,
# `gateway run --replace` does not recognise it as a running gateway, skips the
# terminate-and-scoped-lock-handoff path, and collides with its still-held scoped locks
# (e.g. the Discord bot-token lock). See #22418.
if any(b == "desktop-gateway.py" for b in basenames):
return "run"
if any(b in ("hermes-gateway", "hermes-gateway.exe") for b in basenames):
return "run"
joined = " ".join(tokens)
if "hermes_cli.main" not in joined and "hermes_cli/main.py" not in joined and not any(
b in ("hermes", "hermes.exe") for b in basenames
):
return None
# Drop --profile X / -p X / --profile=X / -p=X (consumes a VALUE of "gateway" too).
filtered: list[str] = []
skip_next = False
for token in tokens:
if skip_next:
skip_next = False
elif token in ("--profile", "-p"):
skip_next = True
elif not token.startswith(("--profile=", "-p=")):
filtered.append(token)
for i, token in enumerate(filtered):
if token == "gateway":
# Bare `hermes gateway` defaults to `run`.
return filtered[i + 1] if i + 1 < len(filtered) else "run"
return None
def gateway_spawn_intent_subcommand(command: str | None) -> str | None:
"""Gateway lifecycle subcommand a command line would EVENTUALLY launch, or None.
The identity matcher (``_gateway_command_subcommand``) deliberately refuses ``python -c <src>
…``: the trailing argv is the inline program's data, not that process's own identity (#107002).
Callers that inspect a command line as SPAWN INTENT — "if I launch this, does a gateway runtime
eventually appear?" — need the opposite answer, because
``gateway._spawn_gateway_restart_watcher`` hides a real ``… -m hermes_cli.main gateway run``
behind exactly that wrapper. ``tests/_fixtures/live_system_guard.py`` is the canonical caller.
Still no substring matching: the wrapper is peeled token-wise and each remaining suffix is
handed to the same canonical matcher.
"""
direct = _gateway_command_subcommand(command)
if direct is not None or not command:
return direct
try:
raw_tokens = shlex.split(command, posix=False)
except ValueError:
raw_tokens = command.split()
cased_tokens = [t.strip("\"'").replace("\\", "/") for t in raw_tokens]
flag_index = inline_source_flag_index(cased_tokens)
if flag_index is None:
return None
# Skip the interpreter, its options, ``-c`` and the source literal; then try every suffix —
# the embedded argv starts at an unknown offset (the watcher prefixes it with the old PID).
start = flag_index + 2
for i in range(start, len(cased_tokens)):
nested = _gateway_command_subcommand(" ".join(raw_tokens[i:]))
if nested is not None:
return nested
return None
def looks_like_gateway_command_line(command: str | None) -> bool:
"""True only for a real ``gateway run`` process command line."""
return _gateway_command_subcommand(command) == "run"
def looks_like_gateway_runtime_command_line(command: str | None) -> bool:
"""True for command lines that can host the runtime (``run`` or ``restart``: without a service
manager the manual restart fallback runs ``run_gateway()`` in-process). For validating
Hermes-owned records / cleanup scans only; ``looks_like_gateway_command_line`` stays strict."""
return _gateway_command_subcommand(command) in {"run", "restart"}
def _looks_like_gateway_process(pid: int) -> bool:
"""True when the live PID still looks like the Hermes gateway."""
cmdline = _read_process_cmdline(pid)
return bool(cmdline) and looks_like_gateway_command_line(cmdline)
def _record_looks_like_gateway(record: dict[str, Any]) -> bool:
"""Validate gateway identity from PID-file metadata when cmdline is unavailable."""
argv = record.get("argv")
if record.get("kind") != _GATEWAY_KIND or not isinstance(argv, list) or not argv:
return False
return looks_like_gateway_runtime_command_line(" ".join(str(part) for part in argv))
def _profile_name_for_home(profile_home: Path) -> Optional[str]:
"""Profile id for ``<root>/profiles/<name>``; None for the root/default home (bare gateway)."""
return profile_home.name if profile_home.parent.name == "profiles" else None
def profile_flag_value(command: str) -> Optional[str]:
"""The ``-p``/``--profile`` argument of a command line, or None. Token equality is the only safe
profile match: a substring test lets ``-p ops`` claim (and ``gateway stop`` SIGTERM) ``-p ops-2``."""
tokens = command.split()
for i, tok in enumerate(tokens):
if tok.startswith("--profile="):
return tok.partition("=")[2]
if tok in ("-p", "--profile") and i + 1 < len(tokens):
return tokens[i + 1]
return None
_HERMES_HOME_ASSIGNMENT_RE = re.compile(r"(?:^|\s)hermes_home=(?:\"([^\"]*)\"|'([^']*)'|(\S+))")
def hermes_home_assignments(command: str) -> list[str]:
"""Values of every ``HERMES_HOME=<value>`` assignment in ``command`` (the caller lowercases
and normalizes separators). Values are token-bounded, quotes stripped: the substring test
this replaces let ``HERMES_HOME=/root/profiles/ops`` claim a ``/root/profiles/ops2`` gateway.
The name is token-bounded too (``FOO=hermes_home=/x`` is not an assignment), and a trailing
separator on the value is stripped -- ``HERMES_HOME=/root/.hermes/`` (systemd ``Environment=``
or a shell wrapper spelling) is the same home as ``/root/.hermes``; callers strip the profile
home the same way."""
return [
next(g for g in m.groups() if g is not None).rstrip("/")
for m in _HERMES_HOME_ASSIGNMENT_RE.finditer(command)
]
def command_line_names_hermes_home(command_lc: str, home_lc: str) -> bool:
"""True when ``command_lc`` carries ``HERMES_HOME=<home_lc>`` (both lowercased, ``/``-separated,
no trailing separator). Argv reaches us space-joined, so an unquoted value with a space in it
(``HERMES_HOME=C:/Users/John Doe/.hermes``) is cut at the space by the token parser; a
token-bounded literal match of the whole home recovers that spelling."""
if home_lc in hermes_home_assignments(command_lc):
return True
return re.search(rf"(?:^|\s)hermes_home={re.escape(home_lc)}/?(?=\s|$)", command_lc) is not None
def _command_line_belongs_to_profile(command: str, profile_home: Path) -> bool:
"""True when a gateway command line belongs to ``profile_home`` (mirrors
``hermes_cli.gateway._matches_current_profile``): a stale state file can record a PID recycled
onto ANOTHER profile's live gateway. Named profiles carry ``-p``/``--profile <name>`` or
``HERMES_HOME=`` on argv; the default gateway runs bare. Separators normalized."""
command_lc = command.lower().replace("\\", "/")
profile_name = _profile_name_for_home(profile_home)
home_lc = str(profile_home).lower().replace("\\", "/").rstrip("/")
if profile_name is not None and profile_name != "default":
if profile_flag_value(command_lc) == profile_name.lower():
return True
return command_line_names_hermes_home(command_lc, home_lc)
# Default profile: accept unless argv names another profile (any spelling the CLI pre-parser
# accepts, ``--profile=ops`` included -- a substring test let that gateway pass as the default's)
# or a conflicting explicit HERMES_HOME= (its absence is not disqualifying -- HERMES_HOME usually
# arrives via the env).
if profile_flag_value(command_lc) is not None:
return False
return not hermes_home_assignments(command_lc) or command_line_names_hermes_home(command_lc, home_lc)
def _host_gateway_serves_home(pid: int, profile_home: Path) -> bool:
"""Does the ONE host gateway — PID ``pid`` — serve ``profile_home``'s profile?
Argv cannot answer this: the host singleton runs ONE home's (usually bare/default) command line
while multiplexing every profile, so :func:`_command_line_belongs_to_profile` rejects every
secondary and the profile reads as "not running" while its messages are being served. The live
served set is the only proof; the argv rule stays as the fallback when no record exists.
"""
try:
from gateway.host_attach import host_gateway, profile_name_for_home
owner = host_gateway()
except Exception:
return False
return owner is not None and owner.pid == pid and owner.serves(profile_name_for_home(profile_home))
def _record_matches_live_gateway_pid(
record: dict[str, Any], pid: int, *, expected_home: Optional[Path] = None
) -> bool:
"""True when a live PID still identifies as this gateway record. The live command line wins (a
stale record's argv must not make a recycled PID count as a gateway; with ``expected_home`` it
must also belong to that profile — or serve it as the host multiplexer); unreadable cmdline
(Windows/EACCES) -> persisted record."""
live_cmdline = _read_process_cmdline(pid)
if not live_cmdline:
return _record_looks_like_gateway(record)
if not looks_like_gateway_runtime_command_line(live_cmdline):
return False
if expected_home is not None and _host_gateway_serves_home(pid, expected_home):
return True
return expected_home is None or _command_line_belongs_to_profile(live_cmdline, expected_home)
def _build_pid_record() -> dict:
return {
"pid": os.getpid(), "kind": _GATEWAY_KIND, "argv": list(sys.argv),
"start_time": _get_process_start_time(os.getpid()),
# Scoped locks are machine-global; the owner's home lets a cross-profile
# --replace place its takeover marker where the target will read it.
"hermes_home": str(_canonical_hermes_home(_get_process_hermes_home())),
}
def _get_code_identity_fields() -> dict[str, Any]:
"""Code identity of THIS process for ``gateway_state.json`` (restart picked up new code?).
Lazy import keeps ``gateway.status`` free of ``hermes_cli`` at import time. Never raises.
A gateway keeps serving the module versions it imported at startup, so stamping the identity into
``gateway_state.json`` lets `hermes update` (and the dashboard) prove whether a running gateway actually
picked up new code after the restart phase — instead of assuming it did (#88654, #69754). Never raises;
degrades to absent fields.
"""
try:
from hermes_cli.version_info import get_code_identity
identity = get_code_identity()
return {"code_sha": identity.get("sha"), "code_version": identity.get("version")}
except Exception:
return {}
def _pid_record_belongs_to_current_profile(record: Optional[dict[str, Any]]) -> bool:
"""True when the record's ``hermes_home`` matches the current process (legacy records: True);
another HERMES_HOME's record must be ignored or the default gateway assumes its identity."""
if not isinstance(record, dict):
return False
record_home = record.get("hermes_home")
return not record_home or _same_hermes_home(record_home, _get_process_hermes_home())
def _build_runtime_status_record() -> dict[str, Any]:
return {
**_build_pid_record(), "gateway_state": "starting", "exit_reason": None,
"restart_requested": False, "active_agents": 0, "platforms": {},
"session_store": {"status": "unknown"}, "updated_at": _utc_now_iso(),
**_get_code_identity_fields(),
}
def _read_json_file(path: Path, *, bare_pid_ok: bool = False) -> Optional[dict[str, Any]]:
"""JSON object at ``path``, or None when absent/empty/unreadable/invalid. ``bare_pid_ok`` also
accepts legacy bare-integer PID files as ``{"pid": N}``."""
try:
raw = path.read_text(encoding="utf-8-sig").strip()
except (OSError, UnicodeDecodeError):
# OSError: file vanished or permission flipped between exists() and
# read. UnicodeDecodeError: file holds non-UTF-8 / binary garbage
# (a truncated or clobbered status file). Either way it's unusable.
return None
if not raw:
return None
try:
payload = json.loads(raw)
except json.JSONDecodeError:
payload = None
if bare_pid_ok:
with contextlib.suppress(ValueError):
payload = int(raw)
if bare_pid_ok and isinstance(payload, int):
return {"pid": payload}
return payload if isinstance(payload, dict) else None
def _write_json_file(path: Path, payload: dict[str, Any]) -> None:
atomic_json_write(path, payload, indent=None, separators=(",", ":"))
def _unlink_quietly(path: Path) -> None:
with contextlib.suppress(OSError):
path.unlink(missing_ok=True)
def _read_pid_record(pid_path: Optional[Path] = None) -> Optional[dict]:
return _read_json_file(pid_path or _get_pid_path(), bare_pid_ok=True)
def _read_gateway_lock_record(lock_path: Optional[Path] = None) -> Optional[dict[str, Any]]:
return _read_json_file(lock_path or _get_gateway_lock_path(), bare_pid_ok=True)
def _pid_from_record(record: Optional[dict[str, Any]], key: str = "pid") -> Optional[int]:
try:
return int(record[key])
except (KeyError, TypeError, ValueError):
return None
def _start_times_conflict(recorded_start: Any, current_start: Any) -> bool:
"""PID-reuse guard: True only when BOTH start times are known and differ."""
return None not in (recorded_start, current_start) and current_start != recorded_start
def _live_pid_from_record(record: Optional[dict[str, Any]]) -> Optional[int]:
"""Record's PID when it is alive and passes the start-time PID-reuse guard, else None."""
pid = _pid_from_record(record)
if pid is None or not _pid_exists(pid):
return None
if _start_times_conflict(record.get("start_time"), _get_process_start_time(pid)):
return None
return pid
def _clear_running_pid_cache() -> None:
with _gateway_running_pid_cache_lock:
_gateway_running_pid_cache.clear()
def _file_cache_signature(path: Path) -> tuple[bool, Optional[int], Optional[int]]:
try:
st = path.stat()
except OSError:
return (False, None, None)
return (True, st.st_mtime_ns, st.st_size)
def _cleanup_invalid_pid_path(pid_path: Path, *, cleanup_stale: bool) -> None:
"""Force-unlink a stale PID file + sibling lock (lock confirmed inactive, so no pid check)."""
if not cleanup_stale:
return
_clear_running_pid_cache()
for path in (pid_path, _get_gateway_lock_path(pid_path)):
with contextlib.suppress(Exception):
path.unlink(missing_ok=True)
def _try_acquire_file_lock(handle) -> bool:
try:
if _IS_WINDOWS:
handle.seek(0, os.SEEK_END)
if handle.tell() == 0:
handle.write("\n")
handle.flush()
handle.seek(_WINDOWS_LOCK_OFFSET)
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
else:
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
return True
except (BlockingIOError, OSError):
return False
def _pid_exists(pid: int) -> bool:
"""Cross-platform "is this PID alive" check that does NOT kill the target. CRITICAL on Windows:
``os.kill(pid, 0)`` sends ``CTRL_C_EVENT`` to the whole console group (bpo-14484), so prefer
psutil, then ctypes ``OpenProcess`` (Windows) / ``os.kill(pid, 0)`` (POSIX). Zombies report
dead: treating one as alive makes --replace wait forever under systemd Restart=always."""
pid = int(pid)
try:
import psutil # type: ignore
# Best-effort zombie check: status-read failures fall through to pid_exists().
# Windows has no POSIX zombies, and this probe costs ~7 ms per call — once per
# registry entry inside the session file lock (#115578). Skip it on Windows and
# let pid_exists() below (or the ctypes fallback) decide.
probe_zombie = os.name != "nt"
try:
# A zombie (defunct) process is still in the process table, so ``psutil.pid_exists()`` returns
# True for it — but it is already dead: SIGKILL has no effect and it cannot be a running
# gateway. Treating a zombie as alive makes ``--replace`` wait for the old PID to die (it never
# does, until its parent reaps it), then abort with exit 1 — a silent crash loop under systemd
# ``Restart=always``, which respawns the gateway before reaping the previous process (issue
# #42126). Report zombies as dead so the takeover proceeds. Best-effort: any failure to read
# status (partial/stub psutil, access denied, transient race) falls through to the authoritative
# ``pid_exists()`` below rather than raising.
if probe_zombie and psutil.Process(pid).status() == psutil.STATUS_ZOMBIE:
return False
except getattr(psutil, "NoSuchProcess", ()):
return False
except Exception:
pass
return bool(psutil.pid_exists(pid))
except ImportError:
pass # Fall through to stdlib fallback.
return pid_exists_stdlib(pid)
def _release_file_lock(handle) -> None:
with contextlib.suppress(OSError):
if _IS_WINDOWS:
handle.seek(_WINDOWS_LOCK_OFFSET)
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
else:
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
def acquire_gateway_runtime_lock() -> bool:
"""Claim the cross-process runtime lock; the OS releases it if the process dies."""
global _gateway_lock_handle
if _gateway_lock_handle is not None:
return True
path = _get_gateway_lock_path()
path.parent.mkdir(parents=True, exist_ok=True)
try:
handle = open(path, "a+", encoding="utf-8")
except PermissionError:
# Stale root-owned lock (launchd session that ran as root): the directory owner can
# unlink it; retry once with a fresh file.
try:
path.unlink()
handle = open(path, "a+", encoding="utf-8")
except OSError:
return False
if not _try_acquire_file_lock(handle):
handle.close()
return False
handle.seek(0)
handle.truncate()
json.dump(_build_pid_record(), handle)
handle.flush()
with contextlib.suppress(OSError):
os.fsync(handle.fileno())
_gateway_lock_handle = handle
_clear_running_pid_cache()
return True
def release_gateway_runtime_lock() -> None:
"""Release the gateway runtime lock when owned by this process."""
global _gateway_lock_handle
handle, _gateway_lock_handle = _gateway_lock_handle, None
if handle is None:
return
_release_file_lock(handle)
with contextlib.suppress(OSError):
handle.close()
_clear_running_pid_cache()
def owns_gateway_runtime_lock() -> bool:
"""True when THIS process holds the runtime lock. ``is_gateway_runtime_lock_active`` answers
"does anyone?"; re-probing our own flock succeeds on POSIX, so only the handle discriminates."""
return _gateway_lock_handle is not None
def _probe_lock_file(handle) -> bool:
"""True when another process holds the lock (a won probe is released); closes ``handle``."""
try:
held = not _try_acquire_file_lock(handle)
if not held:
_release_file_lock(handle)
return held
finally:
with contextlib.suppress(OSError):
handle.close()
def is_gateway_runtime_lock_active(lock_path: Optional[Path] = None) -> bool:
"""True when some process currently owns the gateway runtime lock."""
resolved_lock_path = lock_path or _get_gateway_lock_path()
if _gateway_lock_handle is not None and resolved_lock_path == _get_gateway_lock_path():
return True
if not resolved_lock_path.exists():
return False
try:
handle = open(resolved_lock_path, "a+", encoding="utf-8")
except PermissionError:
# Stale root-owned lock (see acquire_gateway_runtime_lock): report inactive.
_unlink_quietly(resolved_lock_path)
return False
return _probe_lock_file(handle)
def _strict_path_exists(path: Path, label: str) -> bool:
"""Like ``path.exists()`` but raises RuntimeError instead of False on EACCES-style errors."""
try:
path.stat()
return True
except FileNotFoundError:
return False
except OSError as exc:
raise RuntimeError(f"{label} metadata is not inspectable: {exc}") from exc
def _is_gateway_runtime_lock_active_strict(lock_path: Path) -> bool:
"""Probe ownership without treating access failures as absence."""
try:
handle = open(lock_path, "r+", encoding="utf-8")
except FileNotFoundError:
return False
except OSError as exc:
raise RuntimeError(f"gateway runtime lock is not inspectable: {exc}") from exc
try:
return _probe_lock_file(handle)
except OSError as exc:
raise RuntimeError(f"gateway runtime lock probe failed: {exc}") from exc
def write_pid_file() -> None:
"""Write this process's PID record via O_CREAT|O_EXCL; a racing gateway's FileExistsError
propagates for the caller to decide."""
path = _get_pid_path()
path.parent.mkdir(parents=True, exist_ok=True)
_write_json_excl(path, _build_pid_record())
_clear_running_pid_cache()
def _write_json_excl(path: Path, record: dict[str, Any]) -> None:
"""Create ``path`` with O_CREAT|O_EXCL and dump ``record``; unlinks on a failed write."""
fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(record, handle)
except Exception:
_unlink_quietly(path)
raise
def _apply_set_fields(target: dict[str, Any], fields) -> None:
"""Assign each ``(key, value, coerce)`` whose value was explicitly passed (not ``_UNSET``)."""
for key, value, coerce in fields:
if value is not _UNSET:
target[key] = coerce(value) if coerce is not None else value
def _coerce_session_store(session_store: Any) -> dict[str, str]:
state = str(session_store.get("status") or "") if isinstance(session_store, dict) else ""
return {"status": state if state in {"ok", "unavailable", "retrying"} else "unknown"}
def _prepare_runtime_status_update(
*, gateway_state: Any = _UNSET, exit_reason: Any = _UNSET, restart_requested: Any = _UNSET,
active_agents: Any = _UNSET, active_work: Any = _UNSET, platform: Any = _UNSET, platform_state: Any = _UNSET,
error_code: Any = _UNSET, error_message: Any = _UNSET, needs_attention: Any = _UNSET,
retrying_since: Any = _UNSET, served_profiles: Any = _UNSET, session_store: Any = _UNSET,
multiplex_standalone_reason: Any = _UNSET,
platform_metrics: Any = _UNSET,
ingress_url: Any = _UNSET, listener_base: Any = _UNSET, clear_profile_platforms: bool = False,
drop_profile_platforms: Optional[str] = None,
load_existing: bool = True, reload_existing: bool = False,
) -> tuple[Path, dict[str, Any], dict[str, Any]]:
"""Merge one update into the process-wide canonical status snapshot."""
global _runtime_status_state_path, _runtime_status_state
path = _get_runtime_status_path()
with _runtime_status_state_lock:
if reload_existing or _runtime_status_state_path != path or _runtime_status_state is None:
_runtime_status_state_path = path
_runtime_status_state = (
(_read_json_file(path) if load_existing else None) or _build_runtime_status_record())
# The module snapshot is only ever reassigned (never mutated in place) and
# submit() copies again, so the previous snapshot can be handed out as-is.
previous_payload = _runtime_status_state
payload = copy.deepcopy(previous_payload)
current_record = _build_pid_record()
payload.setdefault("platforms", {})
if not isinstance(payload["platforms"], dict):
payload["platforms"] = {}
if clear_profile_platforms or drop_profile_platforms:
drop_prefix = f"{drop_profile_platforms}:" if drop_profile_platforms else None
payload["platforms"] = {
k: v for k, v in payload["platforms"].items()
if not isinstance(k, str) or ":" not in k
or (drop_prefix is not None and not k.startswith(drop_prefix))
}
payload.update({key: current_record[key] for key in ("kind", "pid", "argv", "start_time")})
payload["updated_at"] = _utc_now_iso()
payload.update(_get_code_identity_fields())
_apply_set_fields(payload, (
("gateway_state", gateway_state, None), ("exit_reason", exit_reason, None),
("restart_requested", restart_requested, bool),
("active_agents", active_agents, parse_active_agents),
("active_work", active_work, lambda v: list(v) if v else None),
("served_profiles", served_profiles, lambda v: list(v or [])),
("multiplex_standalone_reason", multiplex_standalone_reason, lambda v: str(v) if v else None),
("session_store", session_store, _coerce_session_store),
))
if platform is not _UNSET:
platform_payload = copy.deepcopy(payload["platforms"].get(platform, {}))
if not isinstance(platform_payload, dict):
platform_payload = {}
if platform_state == "connected":
needs_attention = False if needs_attention is _UNSET else needs_attention
retrying_since = None if retrying_since is _UNSET else retrying_since
_apply_set_fields(platform_payload, (
("state", platform_state, None), ("error_code", error_code, None),
("error_message", error_message, None),
("needs_attention", needs_attention, bool),
("retrying_since", retrying_since, None),
("metrics", platform_metrics, None),
("ingress_url", ingress_url, None),
("listener_base", listener_base, None),
))
platform_payload.update(
updated_at=_utc_now_iso(), writer_pid=current_record["pid"],
writer_start_time=current_record["start_time"])
payload["platforms"][platform] = platform_payload
_runtime_status_state = payload
return path, payload, previous_payload
def _emit_runtime_status_transition(
previous_payload: dict[str, Any], payload: dict[str, Any]
) -> None:
with contextlib.suppress(Exception):
from agent.monitoring.gateway_health import emit_runtime_status_transition
emit_runtime_status_transition(previous_payload, payload)
def write_runtime_status(
*, reload_existing: bool = False, wait_timeout: Optional[float] = None, **fields: Any,
) -> bool:
"""Synchronously persist status for CLI callers and off-loop startup.
``wait_timeout`` bounds how long the caller waits for durable persistence.
A timed-out update remains queued for the single background writer.
Keyword ``fields`` are those of ``_prepare_runtime_status_update``.
"""
with _runtime_status_state_lock:
path, payload, previous_payload = _prepare_runtime_status_update(
reload_existing=reload_existing, **fields)
writer = _get_runtime_status_writer()
generation = writer.submit(path, payload)
# Report the transition once it is queued (matching ``publish_runtime_status``): a
# timed-out update is still written by the background writer, so its transition happened.
_emit_runtime_status_transition(previous_payload, payload)
return writer.wait(generation, timeout=wait_timeout)
def publish_runtime_status(**fields: Any) -> int:
"""Merge and enqueue status without waiting for filesystem persistence.
Keyword ``fields`` are those of ``_prepare_runtime_status_update``.
"""
with _runtime_status_state_lock:
path, payload, previous_payload = _prepare_runtime_status_update(
load_existing=False, **fields)
generation = _get_runtime_status_writer().submit(path, payload)
_emit_runtime_status_transition(previous_payload, payload)
return generation
def read_runtime_status(path: Optional[Path] = None) -> Optional[dict[str, Any]]:
"""Read ``gateway_state.json``; ``path`` lets callers inspect another profile's file."""
return _read_json_file(path or _get_runtime_status_path())
# Max age of a ``gateway_state.json`` snapshot before its liveness claim is suspect: an older record
# outlived an ungracefully-killed writer (taskkill /F, OOM, power loss) — or, with the PID alive, the
# housekeeping thread that re-stamps ``updated_at`` every tick has wedged (#113372). 2x the 60 s
# housekeeping interval.
_RUNTIME_STATUS_STALE_TTL_S = 120
def runtime_status_is_stale(
record: Optional[dict[str, Any]], ttl_s: int = _RUNTIME_STATUS_STALE_TTL_S
) -> bool:
"""True when the snapshot's ``updated_at`` is older than ``ttl_s`` (or missing/unparseable)."""
return not isinstance(record, dict) or _marker_is_stale(record.get("updated_at") or "", ttl_s)
def runtime_status_heartbeat_age_s(record: Optional[dict[str, Any]]) -> Optional[int]:
"""Whole seconds since the snapshot's ``updated_at``; None when missing/unparseable (an
unparseable stamp is a stale *file*, not a wedged heartbeat)."""
updated_at = normalize_updated_at(record.get("updated_at")) if isinstance(record, dict) else None
if not updated_at:
return None
return max(0, int((datetime.now(timezone.utc) - datetime.fromisoformat(updated_at)).total_seconds()))
def runtime_status_pid_is_live(record: Optional[dict[str, Any]]) -> bool:
"""True when the snapshot's PID is alive and passes the start-time PID-reuse guard."""
return _live_pid_from_record(record) is not None
def parse_active_agents(raw: Any) -> int:
"""Coerce ``active_agents`` to a non-negative int; shared by writer and both HTTP readers."""
try:
return max(0, int(raw))
except (TypeError, ValueError):
return 0
# Live, serving states: a valid begin-drain target. ``degraded`` is a serving gateway with a parked
# platform (a dead watchdog-stamped ``degraded`` is already excluded by ``gateway_running=False``).
_DRAINABLE_GATEWAY_STATES = frozenset({"running", "degraded"})
def derive_gateway_busy(*, gateway_running: bool, gateway_state: Any, active_agents: Any) -> bool:
"""Busy iff live, serving (``running``/``degraded``), and ``active_agents > 0`` -- the contract NAS gates on. Liveness
keys off ``gateway_running``, NEVER ``updated_at`` (a stale heartbeat is a health warning, not death)."""
if not derive_gateway_drainable(gateway_running=gateway_running, gateway_state=gateway_state):
return False
return parse_active_agents(active_agents) > 0
def derive_gateway_drainable(*, gateway_running: bool, gateway_state: Any) -> bool:
"""Drainable iff live and serving; independent of ``active_agents`` (idle drains finish)."""
return bool(gateway_running) and gateway_state in _DRAINABLE_GATEWAY_STATES
@dataclass(frozen=True)
class GatewayLiveness:
"""Resolved gateway liveness for one dashboard surface. ``source``: which ladder rung answered
(logging/tests only -- never branch product behavior on it). ``probe_error``: a rung raised;
lets fail-open callers tell "down" from "unknown"."""
running: bool
pid: Optional[int]
source: str
health_body: Optional[dict[str, Any]] = None
probe_error: bool = False
# The multiplexer's own ``gateway_state.json`` when the ``multiplexer`` rung answered: a served
# profile writes no runtime record of its own, so its platform states live there under
# ``<profile>:<platform>`` keys.
runtime: Optional[dict[str, Any]] = None
def profile_name_for_home(profile_home: Path) -> Optional[str]:
"""Profile id of any Hermes home: ``<root>/profiles/<name>`` → ``<name>``, the default root →
``"default"``, anything else → None. Multiplex-only makes ``default`` an ordinary served
profile, so reporting surfaces need a name for it too."""
home = Path(profile_home)
named = _profile_name_for_home(home)
if named:
return named
try:
from hermes_constants import get_default_hermes_root
if home.resolve() == Path(get_default_hermes_root()).resolve():
return "default"
except Exception:
return None
return None
def multiplexer_liveness_for_profile(profile_dir: Path) -> Optional[tuple[int, dict[str, Any]]]:
"""``(pid, host gateway_state.json)`` when the ONE live host gateway serves the profile whose home
is ``profile_dir``; None for a home it does not serve or when no gateway owns the host role.
Multiplex-only: ``default`` is just another served profile, not the owner of a private topology —
resolving from the host rendezvous record (``gateway/host_topology.py``) is what lets it be
reported as SERVED rather than only as owner. A served profile owns no
``gateway.pid``/``gateway_state.json`` (#97120), so every PID-file rung of the dashboard ladder
reports it stopped while ``hermes -p X status`` says running — the two must agree.
"""
name = profile_name_for_home(Path(profile_dir))
if not name:
return None
from gateway.host_topology import host_gateway_topology
from hermes_cli.gateway import named_profile_served_by_running_multiplexer
from hermes_cli.gateway_multiplex_served import live_default_gateway_pid
from hermes_constants import get_default_hermes_root
# The roster is matched by NAME, and the multiplexer only serves ``<default root>/profiles/<name>``:
# a profile directory copied to another root (sandbox, restore-from-backup) keeps the name but is
# not the home being served, so it must not borrow the multiplexer's PID.
if name != "default" and not _same_hermes_home(profile_dir, get_default_hermes_root() / "profiles" / name):
return None
topology = host_gateway_topology()
if topology is not None and topology.serves(name):
pid: Optional[int] = topology.pid
elif name != "default" and named_profile_served_by_running_multiplexer(name):
# Config-derived fallback for a record that predates ``served_profiles``.
pid = live_default_gateway_pid()
else:
return None
if pid is None:
return None
return pid, read_runtime_status(get_default_hermes_root() / "gateway_state.json") or {}
def shared_listener_mirror_platforms(runtime: Optional[dict[str, Any]], profile: str) -> dict[str, Any]:
"""Entries for the api_server/webhook mirrors a served ``profile`` gets from the DEFAULT's
listener. The multiplexer never builds those adapters for a secondary (``gateway.run_adapters``
skips them: ``SHARED_LISTENER_MIRROR_PLATFORMS``), so the record has no ``<profile>:api_server``
entry and every reader fell through to ``pending_restart`` — "Restart needed" forever while
``/p/<profile>/v1/...`` answered. Only a live default entry is mirrored; its state is the profile's
state, plus the ``/p/<profile>`` URL the client must actually call.
"""
from gateway.config import SHARED_LISTENER_MIRROR_PATHS, SHARED_LISTENER_MIRROR_PLATFORMS
plats = (runtime or {}).get("platforms")
if not profile or profile == "default" or not isinstance(plats, dict):
return {}
mirrored: dict[str, Any] = {}
for name in sorted(SHARED_LISTENER_MIRROR_PLATFORMS):
entry = plats.get(name)
if not isinstance(entry, dict) or entry.get("state") not in {"connected", "connecting", "retrying"}:
continue
# api_server and webhook bind separate ports; each mirror hangs off its own listener. A record
# from an older gateway carries no ``listener_base``: connected, URL unknown.
base = entry.get("listener_base")
url = f"{base}/p/{profile}{SHARED_LISTENER_MIRROR_PATHS.get(name, '')}" if isinstance(base, str) and base else None
mirrored[name] = {k: v for k, v in entry.items() if k != "listener_base"}
mirrored[name].update(ingress_url=url, mirrored_from="default")
return mirrored
def profile_platforms_from_multiplexer(runtime: Optional[dict[str, Any]], profile: str) -> dict[str, Any]:
"""The ``<profile>:<platform>`` entries of a multiplexer record, re-keyed to bare platform names — the
same shape a standalone gateway for ``profile`` writes into its own ``gateway_state.json`` — plus the
default listener's api_server/webhook mirrors the profile is served through (``ingress_url`` set)."""
plats = (runtime or {}).get("platforms")
if not isinstance(plats, dict):
return {}
prefix = f"{profile}:"
own = {key[len(prefix):]: value for key, value in plats.items()
if isinstance(key, str) and key.startswith(prefix) and isinstance(value, dict)}
return {**shared_listener_mirror_platforms(runtime, profile), **own}
def resolve_gateway_liveness(
*, profile_dir: Optional[Path] = None, runtime: Any = _UNSET,
health_probe: Optional[Callable[[], tuple[bool, Optional[dict[str, Any]]]]] = None,
use_cache: bool = True, pid_probe: Optional[Callable[..., Optional[int]]] = None,
runtime_reader: Optional[Callable[..., Optional[dict[str, Any]]]] = None,
runtime_pid_probe: Optional[Callable[..., Optional[int]]] = None,
) -> GatewayLiveness:
"""Single source of truth for "is the gateway up?" across dashboard surfaces. Ladder, most to
least authoritative: (1) PID file + runtime lock (scoped to ``profile_dir``; cached by default
so polling does not re-flock ``gateway.lock``); (2) caller-supplied HTTP health probe (gateway
in another container); (3) LOCAL runtime status PID validated against the live process table
with ``expected_home`` (a recycled PID of another profile never counts; pass ``runtime`` if
already read); (4) for a named ``profile_dir`` only, the live default multiplexer that records the
profile in ``served_profiles`` (a served profile writes no identity files of its own). ``*_probe``/
``runtime_reader`` are the dashboard's injection/test seam. A rung
that raises degrades to the next (never 500 a status endpoint) and sets ``probe_error``.
Before this existed, ``/api/status`` and ``/api/messaging/platforms`` each open-coded their own ladder
and disagreed on the same page load — the sidebar read "running" while the Channels page rendered "The
gateway is not running." Three deployments hit it: a cross-container gateway (only ``/api/status`` ran
the HTTP health probe), a profile-scoped dashboard (only ``/api/status`` passed the profile's paths, so
messaging borrowed another profile's runtime state — issue #71211), and a launch-service-managed gateway
with no PID file (only some callers used the runtime-status fallback).
"""
_pid_probe = pid_probe or (get_running_pid_cached if use_cache else get_running_pid)
_runtime_reader = runtime_reader or read_runtime_status
_runtime_pid_probe = runtime_pid_probe or get_runtime_status_running_pid
probe_error = False
scoped = profile_dir is not None
def guarded(fn, *args, fallback=None, **kwargs):
nonlocal probe_error
try:
return fn(*args, **kwargs)
except Exception:
probe_error = True
return fallback
# Zero-arg call when unscoped: callers monkeypatch with zero-arg lambdas and
# /api/status's cache signature is keyed on the call shape.
pid = guarded(_pid_probe, profile_dir / "gateway.pid") if scoped else guarded(_pid_probe)
if pid is not None:
return GatewayLiveness(running=True, pid=pid, source="pid")
health_body: Optional[dict[str, Any]] = None
if health_probe is not None:
alive, health_body = guarded(health_probe, fallback=(False, None))
if alive:
# Display-only PID: it belongs to the remote container.
remote_pid = health_body.get("pid") if health_body else None
return GatewayLiveness(
running=True, pid=remote_pid, source="health", health_body=health_body
)
if runtime is _UNSET:
reader_kwargs = {"path": profile_dir / "gateway_state.json"} if scoped else {}
runtime = guarded(_runtime_reader, **reader_kwargs)
# A scoped home with no ``gateway_state.json`` gets an EMPTY record, never ``None``: ``None`` makes
# the probe re-read the PROCESS home's record and lend that gateway's PID to a home it does not
# own (a profile directory copied out of another root).
probe_kwargs = {"expected_home": profile_dir} if scoped else {}
runtime_pid = guarded(_runtime_pid_probe, {} if (scoped and runtime is None) else runtime, **probe_kwargs)
if runtime_pid is not None:
return GatewayLiveness(
running=True, pid=runtime_pid, source="runtime_status", health_body=health_body
)
# (4) A named profile served by the live default multiplexer: no identity files of its own, but
# the multiplexer IS its gateway (mirrors `hermes -p X status` / `gateway list`). Unscoped, the
# question is about the process's OWN home — which is a named profile inside a pooled
# `hermes --profile X serve` (the Desktop's per-profile backend answers its REST without
# `?profile=`), so it takes the same rung instead of reporting the served profile stopped.
own_home = profile_dir if scoped else _get_process_hermes_home()
served = guarded(multiplexer_liveness_for_profile, own_home)
if served is not None:
mux_pid, mux_runtime = served
return GatewayLiveness(
running=True, pid=mux_pid, source="multiplexer", health_body=health_body, runtime=mux_runtime
)
return GatewayLiveness(
running=False, pid=None, source="none", health_body=health_body, probe_error=probe_error
)
def get_runtime_status_running_pid(
runtime: Optional[dict[str, Any]] = None, *, expected_home: Optional[Path] = None
) -> Optional[int]:
"""Live gateway PID from the runtime status record, or None: the ``get_running_pid()`` fallback
for launch-service-managed gateways with a fresh ``gateway_state.json`` but no ``gateway.pid``.
``expected_home`` scopes the OS-identity check to another profile's home so a PID recycled onto
a different profile's gateway is not reported running for the dead one."""
payload = runtime if runtime is not None else read_runtime_status()
if not isinstance(payload, dict):
return None
if payload.get("gateway_state") in {None, "stopped", "startup_failed"}:
return None
pid = _live_pid_from_record(payload)
if pid is None:
return None
# The record's hermes_home must match the home asked about (this process unscoped) so a stale
# or copied record cannot lend another home's gateway identity; legacy records without the
# stamp prove nothing either way and fall through to the live command-line check.
if expected_home is None and not _pid_record_belongs_to_current_profile(payload):
return None
if expected_home is not None and recorded_gateway_home_conflicts(payload, expected_home=expected_home):
return None
if not _record_matches_live_gateway_pid(payload, pid, expected_home=expected_home):
return None
return pid
def live_gateway_pid_for_home(home: Path) -> Optional[int]:
"""Verified PID of the gateway owned by ``home`` (pid file + runtime lock first, then the runtime
status record), or None. Every reader of another home's gateway identity goes through this so
they all prove the same thing: the PID passes the start-time reuse guard, its live command line is
a gateway's belonging to ``home``, and the record is not ``stopped``. Bare PID existence is not
identity -- a stale record whose PID was recycled by an unrelated process lent it ``served_profiles``
and put phantom gateways into the update inventory (#109680) -- while a launch-service gateway whose
``gateway.pid`` was unlinked is still live (#110166). Never unlinks ``home``'s identity files."""
home = Path(home)
# Cached: dashboard surfaces poll this for every served profile; the cache invalidates on any
# pid/lock file change, so a stopped or replaced gateway is seen at once.
pid = get_running_pid_cached(home / "gateway.pid", cleanup_stale=False)
if pid is not None:
return pid
return get_runtime_status_running_pid(read_runtime_status(home / "gateway_state.json"), expected_home=home)
def remove_pid_file() -> None:
"""Remove the PID file only if it belongs to this process: during --replace the old process's
atexit can fire AFTER the new process wrote its own record."""
with contextlib.suppress(Exception):
path = _get_pid_path()
file_pid = _pid_from_record(_read_json_file(path))
if file_pid is not None and file_pid != os.getpid():
return # Belongs to a different process — leave it alone.
path.unlink(missing_ok=True)
_clear_running_pid_cache()
def _scoped_lock_record_is_stale(existing: dict[str, Any], existing_pid: Optional[int]) -> bool:
"""True when a foreign scoped-lock record no longer names a live gateway: PID missing/dead,
start time changed (PID reuse), or the live process is not a gateway -- a readable cmdline says
so (also catches boot-time PID+start_time collisions; systemd spawns deterministically);
cmdline unreadable AND start_time unknown on either side => the lock record's own argv is the
only signal left. Stopped (SIGTSTP) processes look alive to _pid_exists; stale so --replace
works."""
if existing_pid is None or not _pid_exists(existing_pid):
return True
recorded_start = existing.get("start_time")
current_start = _get_process_start_time(existing_pid)
if _start_times_conflict(recorded_start, current_start):
return True
if not _looks_like_gateway_process(existing_pid):
if _read_process_cmdline(existing_pid) is not None:
return True
if None in (recorded_start, current_start) and not _record_looks_like_gateway(existing):
return True
return _process_is_stopped(existing_pid)
def _process_is_stopped(pid: int) -> bool:
"""True for a stopped / tracing-stop state (T/t) in ``/proc/<pid>/status``."""
with contextlib.suppress(OSError):
for line in Path(f"/proc/{pid}/status").read_text(encoding="utf-8").splitlines():
if line.startswith("State:"):
return line.split()[1] in {"T", "t"}
return False
def acquire_scoped_lock(
scope: str, identity: str, metadata: Optional[dict[str, Any]] = None
) -> tuple[bool, Optional[dict[str, Any]]]:
"""Acquire a machine-local lock keyed by scope + identity (one Telegram token across homes)."""
lock_path = _get_scope_lock_path(scope, identity)
lock_path.parent.mkdir(parents=True, exist_ok=True)
record = {
**_build_pid_record(), "scope": scope, "identity_hash": _scope_hash(identity),
"metadata": metadata or {}, "updated_at": _utc_now_iso(),
}
# Profile label for cross-profile conflict diagnostics ("token already in use (PID 559)" alone
# does not say WHICH profile). Omitted when not inferable; readers fall back to hermes_home.
profile = _profile_label_for_home(_get_process_hermes_home())
if profile:
record["profile"] = profile
existing = _read_json_file(lock_path)
if existing is None and lock_path.exists():
# Empty/invalid JSON: previous process died between O_EXCL create and json.dump().
_unlink_quietly(lock_path)
if existing:
existing_pid = _pid_from_record(existing)
# Our own PID: always self-reacquire. start_time guards reuse of OTHER PIDs; requiring
# equality here rejects reconnects when the on-disk record has start_time null.
# Same live PID as this process: always self-reacquire. ``start_time`` is a PID-reuse guard for
# *other* PIDs; it cannot distinguish two processes that share the caller's own PID (impossible
# while we are alive). Requiring start_time equality here falsely rejects reconnects when the
# on-disk record has ``start_time: null`` (older writers / psutil failure at first write) while the
# freshly built record has a real value — the gateway then reports itself as the foreign squatter of
# its own token (#81468).
if existing_pid == os.getpid():
_write_json_file(lock_path, record)
return True, existing
if not _scoped_lock_record_is_stale(existing, existing_pid):
return False, existing
# Rename to a tombstone instead of unlink(): with unlink()+O_EXCL two racing starters
# could both win. os.replace() lets exactly one claim it; a failed replace means another
# racer claimed it and O_EXCL below decides.
with contextlib.suppress(OSError):
tombstone = lock_path.with_name(lock_path.name + ".stale")
os.replace(lock_path, tombstone)
_unlink_quietly(tombstone)
try:
_write_json_excl(lock_path, record)
except FileExistsError:
return False, _read_json_file(lock_path)
return True, None
def release_scoped_lock(scope: str, identity: str) -> None:
"""Release a scope lock owned by this PID. No start_time equality check: on-disk null vs a live
fingerprint would wedge reconnects."""
lock_path = _get_scope_lock_path(scope, identity)
if (_read_json_file(lock_path) or {}).get("pid") == os.getpid():
_unlink_quietly(lock_path)
def release_all_scoped_locks(
*, owner_pid: Optional[int] = None, owner_start_time: Optional[int] = None
) -> int:
"""Remove scoped lock files (--replace cleanup); returns the count removed. With ``owner_pid``
only that gateway's records go (``owner_start_time`` narrows against PID reuse)."""
lock_dir = _get_lock_dir()
if not lock_dir.exists():
return 0
removed = 0
for lock_file in lock_dir.glob("*.lock"):
if owner_pid is not None:
record = _read_json_file(lock_file) or {}
if _pid_from_record(record) != owner_pid or (
owner_start_time is not None and record.get("start_time") != owner_start_time
):
continue
with contextlib.suppress(OSError):
lock_file.unlink(missing_ok=True)
removed += 1
return removed
# ── --replace takeover marker ─────────────────────────────────────────
# SIGTERM exits the gateway with code 1 so Restart=on-failure revives it after unexpected
# kills -- which would also revive a --replace target (flap loop against the replacer). The
# replacer therefore writes a short-lived marker naming the target PID + start_time BEFORE
# SIGTERM; the target's shutdown handler treats a matching marker as a planned takeover and
# exits 0. Unlinked once consumed, so a stale one can grief at most one future shutdown on
# the same PID, within _TAKEOVER_MARKER_TTL_S.
# When a new gateway starts with ``--replace``, it SIGTERMs the existing gateway so it can take over the bot
# token. ``hermes.service`` + ``hermes- gateway.service``). See #5646.
_TAKEOVER_MARKER_FILENAME = ".gateway-takeover.json"
_TAKEOVER_MARKER_TTL_S = 60 # Marker older than this is treated as stale
_PLANNED_STOP_MARKER_FILENAME = ".gateway-planned-stop.json"
_PLANNED_STOP_MARKER_TTL_S = 60
def _get_takeover_marker_path(hermes_home: Optional[Path] = None) -> Path:
"""Takeover marker path; ``hermes_home`` is given only for a verified cross-home handoff."""
home = _canonical_hermes_home(hermes_home or _get_process_hermes_home())
return home / _TAKEOVER_MARKER_FILENAME
def _get_planned_stop_marker_path() -> Path:
return _get_process_hermes_home() / _PLANNED_STOP_MARKER_FILENAME
def _marker_is_stale(written_at: str, ttl_s: int) -> bool:
try:
age = datetime.now(timezone.utc) - datetime.fromisoformat(written_at)
return age.total_seconds() > ttl_s
except (TypeError, ValueError):
return True
def _read_live_pid_marker(path: Path, ttl_s: int) -> Optional[tuple[dict[str, Any], int, Any]]:
"""``(record, target_pid, target_start_time)`` for a usable marker, else None. Malformed/expired
markers can never match anyone, so they are unlinked here (must not wedge a new instance)."""
record = _read_json_file(path)
if not record:
return None
target_pid = _pid_from_record(record, "target_pid")
if target_pid is None or _marker_is_stale(record.get("written_at") or "", ttl_s):
_unlink_quietly(path)
return None
return record, target_pid, record.get("target_start_time")
def _pid_marker_names_self(target_pid: int, target_start_time: Any) -> bool:
"""PID match with an optional start-time PID-reuse guard (watcher probe + consume). Both start
times known -> must match; either unknown -> PID equality decides (bounded by the marker TTL):
``_get_process_start_time`` is None without /proc (macOS, native Windows -- where the
planned-stop watcher matters most) and requiring a match there would misclassify a legitimate
``hermes gateway stop`` as an unexpected exit revived by the service manager."""
if target_pid != os.getpid():
return False
our_start_time = _get_process_start_time(target_pid)
return None in (target_start_time, our_start_time) or target_start_time == our_start_time
def _consume_pid_marker_for_self(path: Path, *, ttl_s: int) -> bool:
parsed = _read_live_pid_marker(path, ttl_s)
if parsed is None:
return False
record, target_pid, target_start_time = parsed
# Cross-profile guard: new markers name the verified TARGET home, which permits a deliberate
# cross-HERMES_HOME --replace while ignoring a marker accidentally written into another
# profile's directory. Legacy markers have no target field: keep the same-replacer-home rule.
# See #29092.
our_home = _get_process_hermes_home()
target_home = record.get("target_hermes_home")
if target_home is not None:
if not isinstance(target_home, str) or not _same_hermes_home(target_home, our_home):
return False
else:
replacer_home = record.get("replacer_hermes_home")
if replacer_home is not None and not _same_hermes_home(replacer_home, our_home):
return False
matches = _pid_marker_names_self(target_pid, target_start_time)
_unlink_quietly(path)
return matches
def write_takeover_marker(
target_pid: int, *, target_home: Optional[Path] = None, target_start_time: Any = _UNSET
) -> bool:
"""Record that ``target_pid`` is being replaced by this process; True on success. Captures the
target's ``start_time`` (PID-reuse guard) + a timestamp for TTL. A verified cross-home handoff
passes ``target_home`` + validated ``target_start_time`` so the marker lands in the target's
home; such callers must fail closed on False (the target's supervisor could revive it)."""
try:
marker_home = _canonical_hermes_home(target_home or _get_process_hermes_home())
if target_start_time is _UNSET:
target_start_time = _get_process_start_time(target_pid)
return _write_marker(_get_takeover_marker_path(marker_home), {
"target_pid": target_pid, "target_start_time": target_start_time,
"target_hermes_home": str(marker_home), "replacer_pid": os.getpid(),
"replacer_hermes_home": str(_canonical_hermes_home(_get_process_hermes_home())),
"written_at": _utc_now_iso(),
})
except OSError:
return False
def _write_marker(path: Path, record: dict[str, Any]) -> bool:
"""Atomically write a marker record; False (never raise) on OS failure."""
try:
_write_json_file(path, record)
return True
except OSError:
return False
def consume_takeover_marker_for_self() -> bool:
"""Consume the takeover marker; True => planned takeover (exit 0); unlinked on match/stale."""
return _consume_pid_marker_for_self(_get_takeover_marker_path(), ttl_s=_TAKEOVER_MARKER_TTL_S)
def clear_takeover_marker(target_home: Optional[Path] = None) -> None:
"""Remove the takeover marker unconditionally. Safe to call repeatedly."""
_unlink_quietly(_get_takeover_marker_path(target_home))
def _validated_scoped_lock_gateway_owner(record: dict[str, Any]) -> Optional[tuple[int, int, Path]]:
"""Resolve a live scoped-lock owner to a verified ``(pid, start_time, home)``. A lock file is
only a claim: the record, the target home's PID record, and the live process must agree on
PID, start-time, gateway identity, and home. Missing legacy metadata fails closed."""
if not isinstance(record, dict) or not _record_looks_like_gateway(record):
return None
owner_pid = _pid_from_record(record)
owner_start_time = record.get("start_time")
raw_home = record.get("hermes_home")
if (
owner_pid is None or owner_pid <= 0 or owner_pid == os.getpid()
or not isinstance(owner_start_time, int) or isinstance(owner_start_time, bool)
or not isinstance(raw_home, str) or not raw_home.strip()
or not Path(raw_home).expanduser().is_absolute()
):
return None
target_home = _canonical_hermes_home(raw_home)
if _scoped_lock_owner_state(owner_pid, owner_start_time) != "same":
return None
live_cmdline = _read_process_cmdline(owner_pid)
if live_cmdline is not None and not looks_like_gateway_runtime_command_line(live_cmdline):
return None
# The target home's own PID record must corroborate the claim.
pid_record = _read_json_file(target_home / "gateway.pid") or {}
pid_record_home = pid_record.get("hermes_home")
if (
not _record_looks_like_gateway(pid_record)
or _pid_from_record(pid_record) != owner_pid
or pid_record.get("start_time") != owner_start_time
or not isinstance(pid_record_home, str)
or not _same_hermes_home(pid_record_home, target_home)
):
return None
return owner_pid, owner_start_time, target_home
def _scoped_lock_owner_state(owner_pid: int, owner_start_time: int) -> str:
"""Return ``same``, ``exited``, or ``unknown`` for a validated owner."""
if not _pid_exists(owner_pid):
return "exited"
live_start_time = _get_process_start_time(owner_pid)
# A different start time means the PID was recycled; never signal the replacement.
if live_start_time is None:
return "unknown"
return "same" if live_start_time == owner_start_time else "exited"
def _wait_for_scoped_lock_owner_exit(
owner_pid: int, owner_start_time: int, *, attempts: int, delay: float
) -> tuple[bool, bool]:
"""Return ``(exited, safe_to_force)`` after bounded identity-aware waits."""
for _ in range(max(0, attempts)):
state = _scoped_lock_owner_state(owner_pid, owner_start_time)
if state == "exited":
return True, False
if state == "unknown":
return False, False
time.sleep(max(0.0, delay))
return False, _scoped_lock_owner_state(owner_pid, owner_start_time) == "same"
def _snapshot_gateway_children(pid: int) -> list:
"""Best-effort snapshot of ``pid``'s live descendants (POSIX only; never raises). Take it while
the parent is alive -- once it exits the children are reparented and undiscoverable. ``[]`` on
Windows (taskkill /T tree-kills)."""
if _IS_WINDOWS:
return []
try:
import psutil # type: ignore
return psutil.Process(int(pid)).children(recursive=True)
except Exception:
logger.debug("Could not snapshot children of gateway PID %d", pid, exc_info=True)
return []
def reap_gateway_children(children: list, *, parent_pid: int, timeout: float = 5.0) -> int:
"""Best-effort reap of a dead gateway's orphaned descendants (POSIX; surviving adapter
subprocesses keep holding token locks); returns count signalled. Call only AFTER the parent is
confirmed dead, with a :func:`_snapshot_gateway_children` snapshot. ``is_running()`` is
identity-aware so a recycled child PID is never signalled; a child whose ppid still equals
``parent_pid`` is skipped (parent alive => not an orphan). SIGTERM, bounded wait, SIGKILL
survivors. Never raises."""
if _IS_WINDOWS or not children:
return 0
reaped = 0
try:
import psutil # type: ignore
live = []
for child in children:
try:
if not child.is_running() or child.status() == psutil.STATUS_ZOMBIE:
continue
if child.ppid() == parent_pid:
logger.debug("Skipping child PID %d of old gateway %d: parent still appears "
"alive", child.pid, parent_pid)
continue
child.terminate()
live.append(child)
except psutil.NoSuchProcess:
continue
except Exception:
logger.debug("Could not terminate child PID %s of old gateway %d",
getattr(child, "pid", "?"), parent_pid, exc_info=True)
if not live:
return 0
gone, alive = psutil.wait_procs(live, timeout=max(0.0, timeout))
reaped = len(gone)
for child in alive:
try:
child.kill()
reaped += 1
except Exception:
logger.debug("Could not force-kill child PID %s of old gateway %d",
getattr(child, "pid", "?"), parent_pid, exc_info=True)
if reaped:
logger.info("Reaped %d orphaned child process(es) of replaced gateway PID %d.",
reaped, parent_pid)
except Exception:
logger.debug("Child reap for replaced gateway PID %d failed", parent_pid, exc_info=True)
return reaped
def take_over_scoped_lock_holder(
record: dict[str, Any], *, graceful_attempts: int = 20, force_attempts: int = 20
) -> Optional[int]:
"""Terminate one verified scoped-lock holder for explicit ``--replace``. Returns the owner PID
only after that exact PID/start-time identity exited; validation or marker-write failure returns
None without signalling (a cross-home handoff must place a consumable marker in the target's
home or its supervisor revives it: flap loop). On POSIX the snapshotted children are reaped."""
owner = _validated_scoped_lock_gateway_owner(record)
if owner is None:
return None
owner_pid, owner_start_time, target_home = owner
# Snapshot while the owner is alive; afterwards children are reparented.
owner_children = _snapshot_gateway_children(owner_pid)
if not write_takeover_marker(
owner_pid, target_home=target_home, target_start_time=owner_start_time
):
return None
try:
replaced = _terminate_verified_owner(
owner_pid, owner_start_time, graceful_attempts=graceful_attempts,
force_attempts=force_attempts,
)
finally:
# The target normally consumes the marker; clean up any remainder.
clear_takeover_marker(target_home)
if replaced is not None:
reap_gateway_children(owner_children, parent_pid=owner_pid)
return replaced
def _terminate_verified_owner(
owner_pid: int, owner_start_time: int, *, graceful_attempts: int, force_attempts: int
) -> Optional[int]:
"""Bounded identity-aware SIGTERM-then-SIGKILL of a verified owner; the PID once it exited, else
None. Per signal step: ``ProcessLookupError`` => already gone; other ``OSError`` => refuse."""
state = _scoped_lock_owner_state(owner_pid, owner_start_time)
if state == "exited":
return owner_pid
if state != "same":
return None
for attempts, delay, kwargs in (
(graceful_attempts, 0.5, {"force": False}),
(force_attempts, 0.25, {"force": True, "expected_start_time": owner_start_time}),
):
try:
terminate_pid(owner_pid, **kwargs)
except ProcessLookupError:
return owner_pid
except OSError:
return None
exited, safe_to_force = _wait_for_scoped_lock_owner_exit(
owner_pid, owner_start_time, attempts=attempts, delay=delay
)
if exited:
return owner_pid
if not safe_to_force:
return None
return None
def write_planned_stop_marker(target_pid: int) -> bool:
"""Record that ``target_pid`` is being stopped intentionally: unexpected SIGTERM exits non-zero
so service managers revive the gateway; the CLI writes this first so a deliberate stop exits
cleanly."""
return _write_marker(_get_planned_stop_marker_path(), {
"target_pid": target_pid, "target_start_time": _get_process_start_time(target_pid),
"stopper_pid": os.getpid(), "written_at": _utc_now_iso(),
})
def consume_planned_stop_marker_for_self() -> bool:
"""Return True when the current process is being intentionally stopped."""
return _consume_pid_marker_for_self(
_get_planned_stop_marker_path(), ttl_s=_PLANNED_STOP_MARKER_TTL_S
)
def planned_stop_marker_targets_self() -> bool:
"""Non-destructive watcher probe: True when a live planned-stop marker names us. Never unlinks a
matching marker (the shutdown handler does the authoritative consume); malformed/expired ones
are still cleaned up; markers naming another PID are left alone."""
parsed = _read_live_pid_marker(_get_planned_stop_marker_path(), _PLANNED_STOP_MARKER_TTL_S)
return parsed is not None and _pid_marker_names_self(parsed[1], parsed[2])
def get_running_pid(
pid_path: Optional[Path] = None, *, cleanup_stale: bool = True
) -> Optional[int]:
"""PID of a running gateway (lock + PID file verified against the live process), or None.
An explicit ``pid_path`` is a scoped query into that home's identity files: records are
validated against the probed home (not the serve process's), and a live record is never
cleanup-unlinked, so polling another profile must not delete its gateway.pid/gateway.lock
(#106406). The unscoped path keeps main's poison-file housekeeping: a live record owned by
another home inside this home's gateway.pid is unlinked on refusal (#89315)."""
resolved_pid_path = pid_path or _get_pid_path()
resolved_lock_path = _get_gateway_lock_path(resolved_pid_path)
if is_gateway_runtime_lock_active(resolved_lock_path):
records = (
_read_pid_record(resolved_pid_path), _read_gateway_lock_record(resolved_lock_path),
)
expected_home = pid_path.parent if pid_path is not None else None
saw_live_pid = False
for record in records:
pid = _live_pid_from_record(record)
if pid is None:
continue
home_ok = (
_pid_record_belongs_to_current_profile(record) if expected_home is None
else not recorded_gateway_home_conflicts(record, expected_home=expected_home)
)
if home_ok and _record_matches_live_gateway_pid(
record, pid, expected_home=expected_home
):
return pid
# Scoped only: a live record we could not adopt may still be a real gateway;
# unlinking its identity files would break that home's double-run protection
# while the PID is alive. Unscoped keeps the #89315 poison-file cleanup.
saw_live_pid = True
if expected_home is None or not saw_live_pid:
_cleanup_invalid_pid_path(resolved_pid_path, cleanup_stale=cleanup_stale)
return get_runtime_status_running_pid() if pid_path is None else None
# Lock inactive: the runtime-status fallback runs BEFORE cleanup here.
runtime_pid = get_runtime_status_running_pid() if pid_path is None else None
if runtime_pid is None:
_cleanup_invalid_pid_path(resolved_pid_path, cleanup_stale=cleanup_stale)
return runtime_pid
def get_running_pid_identity_strict(pid_path: Path) -> Optional[tuple[int, float]]:
"""Return a verified process identity or fail on ambiguous runtime state."""
resolved_pid_path = Path(pid_path)
resolved_lock_path = _get_gateway_lock_path(resolved_pid_path)
pid_exists = _strict_path_exists(resolved_pid_path, "gateway PID")
# A stale PID file without a lock is not a live gateway; the lock probe is authoritative
# for absence.
if not _strict_path_exists(resolved_lock_path, "gateway lock"):
return None
if not _is_gateway_runtime_lock_active_strict(resolved_lock_path):
return None
if not pid_exists:
raise RuntimeError("active gateway lock has no PID metadata")
records = (_read_pid_record(resolved_pid_path), _read_gateway_lock_record(resolved_lock_path))
if not all(records):
raise RuntimeError("gateway PID or lock metadata is malformed")
pid = _pid_from_record(records[0])
if pid is None or pid <= 0 or _pid_from_record(records[1]) != pid:
raise RuntimeError("gateway PID and lock identities disagree")
if not _pid_exists(pid):
raise RuntimeError("gateway identity is not live")
current_start = _get_process_start_time(pid)
starts = tuple(record.get("start_time") for record in records)
if current_start is None or any(start is None for start in starts):
raise RuntimeError("gateway creation time is unavailable")
try:
if not _start_times_agree(current_start, *starts):
raise RuntimeError("gateway process identity changed")
except (TypeError, ValueError) as exc:
raise RuntimeError("gateway creation time is malformed") from exc
if not all(_record_matches_live_gateway_pid(record, pid) for record in records):
raise RuntimeError("runtime metadata does not identify a live gateway")
current = float(current_start)
if not _IS_WINDOWS:
return pid, current
# Windows persists a centisecond fingerprint; SCM checks need the exact psutil epoch.
# Re-read only after validation and prove it rounds to the same value.
try:
import psutil # type: ignore
exact_create_time = float(psutil.Process(pid).create_time())
except Exception as exc:
raise RuntimeError("exact gateway creation time is unavailable") from exc
if int(round(exact_create_time * 100)) != int(current):
raise RuntimeError("gateway process identity changed")
return pid, exact_create_time
def get_running_pid_cached(
pid_path: Optional[Path] = None, *, cleanup_stale: bool = True,
ttl_seconds: float = _GATEWAY_RUNNING_PID_CACHE_TTL_SECONDS,
) -> Optional[int]:
"""Cached ``get_running_pid()`` for dashboard polling: short TTL, invalidated on PID/lock/
runtime-status file changes, so status endpoints do not re-flock ``gateway.lock`` constantly."""
if ttl_seconds <= 0:
return get_running_pid(pid_path, cleanup_stale=cleanup_stale)
resolved_pid_path = pid_path or _get_pid_path()
include_runtime_status = pid_path is None
# The signature covers the PID file, its sibling lock and (unscoped) the runtime status file.
watched = [resolved_pid_path, _get_gateway_lock_path(resolved_pid_path)]
if include_runtime_status:
watched.append(_get_runtime_status_path())
signature = tuple(_file_cache_signature(p) for p in watched)
key = (str(resolved_pid_path), bool(cleanup_stale), include_runtime_status)
now = time.monotonic()
with _gateway_running_pid_cache_lock:
cached = _gateway_running_pid_cache.get(key)
if cached is not None and now - cached[0] <= ttl_seconds and cached[1] == signature:
return cached[2]
pid = get_running_pid(pid_path, cleanup_stale=cleanup_stale)
refreshed_signature = tuple(_file_cache_signature(p) for p in watched)
with _gateway_running_pid_cache_lock:
_gateway_running_pid_cache[key] = (time.monotonic(), refreshed_signature, pid)
return pid
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
def clear_planned_stop_marker() -> None:
"""Remove the planned-stop marker unconditionally."""
try:
_get_planned_stop_marker_path().unlink(missing_ok=True)
except OSError:
pass
def is_gateway_running(
pid_path: Optional[Path] = None,
*,
cleanup_stale: bool = True,
) -> bool:
"""Check if the gateway daemon is currently running."""
return get_running_pid(pid_path, cleanup_stale=cleanup_stale) is not None
# ---- END PLUGIN-COMPAT ----