Merge branch 'simp/r3-19-A1' into simp/integration3
# Conflicts: # hermes_cli/gateway.py
This commit is contained in:
+62
-131
@@ -1115,7 +1115,6 @@ def _systemd_main_pid(system: bool = False) -> int | None:
|
||||
def _read_gateway_runtime_status() -> dict | None:
|
||||
try:
|
||||
from gateway.status import read_runtime_status
|
||||
|
||||
state = read_runtime_status()
|
||||
except Exception:
|
||||
return None
|
||||
@@ -1149,7 +1148,6 @@ def _wait_for_systemd_service_restart(
|
||||
sub_state = props.get("SubState", "")
|
||||
try:
|
||||
from gateway.status import get_running_pid
|
||||
|
||||
new_pid = get_running_pid()
|
||||
except Exception:
|
||||
new_pid = None
|
||||
@@ -1208,7 +1206,6 @@ def _wait_for_systemd_service_restart(
|
||||
def _systemd_restart_wait_timeout(system: bool = False) -> float:
|
||||
"""Cover systemd's relaunch delays before applying the runtime wait floor."""
|
||||
from gateway.shutdown_forensics import parse_systemd_duration_to_us
|
||||
|
||||
props = _read_systemd_unit_properties(system=system, properties=("RestartUSec", "TimeoutStartUSec"))
|
||||
supervisor_budget = 0.0
|
||||
for name in ("RestartUSec", "TimeoutStartUSec"):
|
||||
@@ -1354,6 +1351,34 @@ def _probe_launchd_service_running() -> bool:
|
||||
return get_launchd_plist_path().exists() and _launchctl_label_supervising_process(get_launchd_label())
|
||||
|
||||
|
||||
def _s6_gateway_snapshot(gateway_pids: tuple[int, ...]) -> GatewayRuntimeSnapshot | None:
|
||||
"""Snapshot for an s6-supervised container gateway, or None when s6 isn't the service manager."""
|
||||
from hermes_cli.service_manager import detect_service_manager, get_service_manager
|
||||
if detect_service_manager() != "s6":
|
||||
return None
|
||||
service_name = f"gateway-{_profile_suffix() or 'default'}"
|
||||
mgr = get_service_manager()
|
||||
service_installed = service_running = False
|
||||
try:
|
||||
service_dir = getattr(mgr, "scandir", None)
|
||||
if service_dir is not None:
|
||||
service_installed = (service_dir / service_name).is_dir()
|
||||
except Exception:
|
||||
service_installed = False
|
||||
if service_installed:
|
||||
try:
|
||||
service_running = bool(mgr.is_running(service_name))
|
||||
except Exception:
|
||||
service_running = False
|
||||
return GatewayRuntimeSnapshot(
|
||||
manager="s6 (container supervisor)",
|
||||
service_installed=service_installed,
|
||||
service_running=service_running,
|
||||
gateway_pids=gateway_pids,
|
||||
service_scope="s6",
|
||||
)
|
||||
|
||||
|
||||
def get_gateway_runtime_snapshot(system: bool = False) -> GatewayRuntimeSnapshot:
|
||||
"""Return a unified view of gateway liveness for the current profile."""
|
||||
gateway_pids = tuple(find_gateway_pids())
|
||||
@@ -1361,33 +1386,12 @@ def get_gateway_runtime_snapshot(system: bool = False) -> GatewayRuntimeSnapshot
|
||||
return GatewayRuntimeSnapshot(manager="Termux / manual process", gateway_pids=gateway_pids)
|
||||
|
||||
from hermes_constants import is_container
|
||||
|
||||
if is_linux() and is_container():
|
||||
# Report s6 supervision under our /init; other container runtimes keep "docker (foreground)".
|
||||
try:
|
||||
from hermes_cli.service_manager import detect_service_manager, get_service_manager
|
||||
if detect_service_manager() == "s6":
|
||||
service_name = f"gateway-{_profile_suffix() or 'default'}"
|
||||
mgr = get_service_manager()
|
||||
service_installed = service_running = False
|
||||
try:
|
||||
service_dir = getattr(mgr, "scandir", None)
|
||||
if service_dir is not None:
|
||||
service_installed = (service_dir / service_name).is_dir()
|
||||
except Exception:
|
||||
service_installed = False
|
||||
if service_installed:
|
||||
try:
|
||||
service_running = bool(mgr.is_running(service_name))
|
||||
except Exception:
|
||||
service_running = False
|
||||
return GatewayRuntimeSnapshot(
|
||||
manager="s6 (container supervisor)",
|
||||
service_installed=service_installed,
|
||||
service_running=service_running,
|
||||
gateway_pids=gateway_pids,
|
||||
service_scope="s6",
|
||||
)
|
||||
snapshot = _s6_gateway_snapshot(gateway_pids)
|
||||
if snapshot is not None:
|
||||
return snapshot
|
||||
except Exception:
|
||||
pass # Fall through to the legacy label on any detection error.
|
||||
return GatewayRuntimeSnapshot(manager="docker (foreground)", gateway_pids=gateway_pids)
|
||||
@@ -1449,7 +1453,6 @@ def _print_other_profiles_gateway_status() -> None:
|
||||
"""Print other profiles' running gateways at the bottom of ``hermes gateway status``."""
|
||||
try:
|
||||
from hermes_cli.profiles import get_active_profile_name
|
||||
|
||||
current = get_active_profile_name()
|
||||
other_processes = [p for p in find_profile_gateway_processes() if p.profile != current]
|
||||
if not other_processes:
|
||||
@@ -1486,7 +1489,6 @@ def _gateway_list() -> None:
|
||||
pid = None
|
||||
try:
|
||||
from gateway.status import get_running_pid
|
||||
|
||||
pid = get_running_pid(prof.path / "gateway.pid", cleanup_stale=False)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1511,7 +1513,6 @@ def kill_gateway_processes(force: bool = False, exclude_pids: set | None = None,
|
||||
if _capture_gateway_argv(pid) is None:
|
||||
continue
|
||||
from gateway.status import get_process_start_time
|
||||
|
||||
expected_start_time = get_process_start_time(pid)
|
||||
terminate_pid(pid, force=force, expected_start_time=expected_start_time)
|
||||
killed += 1
|
||||
@@ -1539,7 +1540,6 @@ def _reaper_candidate_is_supervisor_owned(pid: int) -> bool:
|
||||
return False
|
||||
try:
|
||||
import psutil # type: ignore
|
||||
|
||||
parent = psutil.Process(pid).parent()
|
||||
for _ in range(_REAPER_SUPERVISOR_WALK_LIMIT):
|
||||
if parent is None:
|
||||
@@ -1573,7 +1573,6 @@ def _reap_unsupervised_gateway_orphans(extra_exclude: set | None = None) -> bool
|
||||
if is_windows():
|
||||
try:
|
||||
from hermes_cli.gateway_windows import get_task_name # profile-aware task name
|
||||
|
||||
_task_name = get_task_name()
|
||||
except Exception:
|
||||
_task_name = "Hermes_Gateway"
|
||||
@@ -1581,7 +1580,6 @@ def _reap_unsupervised_gateway_orphans(extra_exclude: set | None = None) -> bool
|
||||
return False
|
||||
|
||||
from gateway.status import _pid_exists, get_process_start_time, write_planned_stop_marker
|
||||
|
||||
own = _reaper_exclusion_pids(extra_exclude)
|
||||
try:
|
||||
# On Windows also drop Task Scheduler-owned candidates (the pidfile-less gap).
|
||||
@@ -1625,12 +1623,10 @@ def _reap_unsupervised_gateway_orphans(extra_exclude: set | None = None) -> bool
|
||||
|
||||
def _reaper_exclusion_pids(extra_exclude: set | None) -> set[int]:
|
||||
"""PIDs the orphan reaper must never kill: self, caller extras, service-managed, recorded."""
|
||||
own = {os.getpid()}
|
||||
if extra_exclude:
|
||||
own |= extra_exclude
|
||||
# Service-managed gateways are never orphans: on macOS supports_systemd_services() is False, so
|
||||
# a launchd gateway would otherwise be SIGTERM'd (and left down under KeepAlive.SuccessfulExit=
|
||||
# false). all_profiles=True: the scan sees every profile; a sibling's launchd gateway must survive.
|
||||
own = {os.getpid()} | (extra_exclude or set())
|
||||
# Service-managed gateways are never orphans (on macOS supports_systemd_services() is False, so
|
||||
# a launchd gateway would otherwise be SIGTERM'd). all_profiles=True: the scan sees every
|
||||
# profile's gateway and a sibling's launchd gateway must not be reaped.
|
||||
with contextlib.suppress(Exception):
|
||||
own |= _get_service_pids(all_profiles=True)
|
||||
# Exempt the recorded gateway PID and its parent chain (on Windows the Scheduled-Task bootstrap's
|
||||
@@ -1641,20 +1637,14 @@ def _reaper_exclusion_pids(extra_exclude: set | None) -> set[int]:
|
||||
# a false negative kills a live gateway. The probe still supplies the runtime-status fallback PID.
|
||||
try:
|
||||
from gateway.status import _pid_from_record, _read_gateway_lock_record, _read_pid_record, get_running_pid
|
||||
|
||||
recorded_pids = set()
|
||||
for _record in (_read_pid_record(), _read_gateway_lock_record()):
|
||||
_raw_pid = _pid_from_record(_record)
|
||||
if _raw_pid and _raw_pid > 0:
|
||||
recorded_pids.add(_raw_pid)
|
||||
_probed = get_running_pid(cleanup_stale=False)
|
||||
if _probed and _probed > 0:
|
||||
recorded_pids.add(_probed)
|
||||
recorded_pids = {_pid_from_record(rec) for rec in (_read_pid_record(), _read_gateway_lock_record())}
|
||||
recorded_pids.add(get_running_pid(cleanup_stale=False))
|
||||
for recorded in recorded_pids:
|
||||
if not recorded or recorded <= 0:
|
||||
continue
|
||||
own.add(recorded)
|
||||
try:
|
||||
import psutil # type: ignore
|
||||
|
||||
parent = psutil.Process(recorded).parent()
|
||||
while parent is not None:
|
||||
own.add(parent.pid)
|
||||
@@ -1710,7 +1700,6 @@ def _mark_planned_stop(pid: int | None = None) -> None:
|
||||
"""Best-effort planned-stop marker for ``pid`` (default: the recorded gateway PID)."""
|
||||
try:
|
||||
from gateway.status import get_running_pid, write_planned_stop_marker
|
||||
|
||||
if pid is None:
|
||||
pid = get_running_pid(cleanup_stale=False)
|
||||
if pid is not None:
|
||||
@@ -1735,7 +1724,6 @@ def stop_profile_gateway() -> bool:
|
||||
return _reap_unsupervised_gateway_orphans()
|
||||
|
||||
_mark_planned_stop(pid)
|
||||
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
@@ -1746,7 +1734,6 @@ def stop_profile_gateway() -> bool:
|
||||
|
||||
# ``_pid_exists``, NOT ``os.kill(pid, 0)`` (TerminateProcess on Windows).
|
||||
from gateway.status import _pid_exists
|
||||
|
||||
for _ in range(20):
|
||||
if not _pid_exists(pid):
|
||||
break
|
||||
@@ -1760,7 +1747,6 @@ def stop_profile_gateway() -> bool:
|
||||
_reap_unsupervised_gateway_orphans(extra_exclude={pid} if pid else None)
|
||||
except Exception as exc:
|
||||
logger.debug("orphan reap after stop_profile_gateway failed: %s", exc)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -1808,7 +1794,6 @@ def is_windows() -> bool:
|
||||
def _gw_windows():
|
||||
"""Lazily import :mod:`hermes_cli.gateway_windows` (Windows-only service backend)."""
|
||||
from hermes_cli import gateway_windows
|
||||
|
||||
return gateway_windows
|
||||
|
||||
|
||||
@@ -1880,7 +1865,6 @@ def _windows_console_window_attached() -> bool | None:
|
||||
return None
|
||||
try:
|
||||
import ctypes
|
||||
|
||||
return bool(ctypes.windll.kernel32.GetConsoleWindow()) # type: ignore[attr-defined]
|
||||
except (OSError, AttributeError):
|
||||
return None
|
||||
@@ -1891,7 +1875,6 @@ def _windows_gateway_breakaway_state() -> bool | None:
|
||||
if not is_windows():
|
||||
return None
|
||||
from hermes_cli._subprocess_compat import _WINDOWS_GATEWAY_BREAKAWAY_ENV
|
||||
|
||||
return {"1": True, "0": False}.get(os.environ.pop(_WINDOWS_GATEWAY_BREAKAWAY_ENV, None))
|
||||
|
||||
|
||||
@@ -1906,7 +1889,6 @@ SERVICE_DESCRIPTION = "Hermes Agent Gateway - Messaging Platform Integration"
|
||||
def _profile_name_from_home(home: Path, default: Path) -> str | None:
|
||||
"""Profile name when ``home`` is ``<default>/profiles/<name>`` with a service-safe name, else None."""
|
||||
import re
|
||||
|
||||
try:
|
||||
parts = home.relative_to((default / "profiles").resolve()).parts
|
||||
except ValueError:
|
||||
@@ -1921,7 +1903,6 @@ def _profile_suffix() -> str:
|
||||
``<root>/profiles/<name>``, else a short hash of the path."""
|
||||
import hashlib
|
||||
from hermes_constants import get_default_hermes_root
|
||||
|
||||
home = get_hermes_home().resolve()
|
||||
default = get_default_hermes_root().resolve()
|
||||
if home == default:
|
||||
@@ -1937,7 +1918,6 @@ def _profile_arg(hermes_home: str | None = None, default_root: str | Path | None
|
||||
``get_hermes_home()``/``get_default_hermes_root()`` would otherwise refer to root.
|
||||
"""
|
||||
from hermes_constants import get_default_hermes_root
|
||||
|
||||
home = Path(hermes_home or str(get_hermes_home())).resolve()
|
||||
default = Path(default_root).resolve() if default_root else get_default_hermes_root().resolve()
|
||||
if home == default:
|
||||
@@ -2029,14 +2009,11 @@ def _ensure_user_systemd_env() -> None:
|
||||
"""
|
||||
uid = os.getuid() # windows-footgun: ok — POSIX systemd helper, never invoked on Windows
|
||||
xdg = os.environ.get("XDG_RUNTIME_DIR")
|
||||
if not xdg or not _runtime_dir_is_ours(xdg):
|
||||
runtime_dir = f"/run/user/{uid}"
|
||||
if _runtime_dir_is_ours(runtime_dir):
|
||||
os.environ["XDG_RUNTIME_DIR"] = runtime_dir
|
||||
if (not xdg or not _runtime_dir_is_ours(xdg)) and _runtime_dir_is_ours(f"/run/user/{uid}"):
|
||||
os.environ["XDG_RUNTIME_DIR"] = f"/run/user/{uid}"
|
||||
|
||||
if "DBUS_SESSION_BUS_ADDRESS" not in os.environ:
|
||||
xdg_runtime = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{uid}")
|
||||
bus_path = Path(xdg_runtime) / "bus"
|
||||
bus_path = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{uid}")) / "bus"
|
||||
if _path_exists_safe(bus_path):
|
||||
os.environ["DBUS_SESSION_BUS_ADDRESS"] = f"unix:path={bus_path}"
|
||||
|
||||
@@ -2074,7 +2051,6 @@ def _preflight_user_systemd(*, auto_enable_linger: bool = True) -> None:
|
||||
return
|
||||
|
||||
import getpass
|
||||
|
||||
username = getpass.getuser()
|
||||
linger_enabled, linger_detail = get_systemd_linger_status()
|
||||
sudo_hint = f" sudo loginctl enable-linger {username}"
|
||||
@@ -2162,7 +2138,7 @@ def _service_scope_label(system: bool = False) -> str:
|
||||
|
||||
|
||||
def get_installed_systemd_scopes() -> list[str]:
|
||||
scopes = []
|
||||
scopes: list[str] = []
|
||||
seen_paths: set[Path] = set()
|
||||
for system, label in ((False, "user"), (True, "system")):
|
||||
unit_path = get_systemd_unit_path(system=system)
|
||||
@@ -2328,7 +2304,6 @@ def _system_service_identity(run_as_user: str | None = None) -> tuple[str, str,
|
||||
import getpass
|
||||
import grp
|
||||
import pwd
|
||||
|
||||
username = (
|
||||
run_as_user or os.getenv("SUDO_USER") or os.getenv("USER") or os.getenv("LOGNAME") or getpass.getuser()
|
||||
).strip()
|
||||
@@ -2369,30 +2344,18 @@ def _default_system_service_user() -> str | None:
|
||||
def prompt_linux_gateway_install_scope() -> str | None:
|
||||
# Only root can create a boot-time system service, so that scope is offered only to root
|
||||
# sessions — a non-root user is never handed a "re-run under sudo" recipe.
|
||||
user_option = "User service (no sudo; best for laptops/dev boxes; may need linger after logout)"
|
||||
if os.geteuid() != 0: # windows-footgun: ok — Linux systemd install wizard, never invoked on Windows
|
||||
choice = prompt_choice(
|
||||
" Choose how the gateway should run in the background:",
|
||||
[user_option, "Skip service install for now"],
|
||||
default=0,
|
||||
)
|
||||
if choice == 0:
|
||||
print_info(
|
||||
" Tip: for a boot-time system service, re-run setup as root "
|
||||
"(e.g. from a root shell or `sudo -i`)."
|
||||
)
|
||||
return {0: "user", 1: None}[choice]
|
||||
|
||||
choice = prompt_choice(
|
||||
" Choose how the gateway should run in the background:",
|
||||
[
|
||||
user_option,
|
||||
"System service (starts on boot; runs as your chosen user)",
|
||||
"Skip service install for now",
|
||||
],
|
||||
default=0,
|
||||
)
|
||||
return {0: "user", 1: "system", 2: None}[choice]
|
||||
is_root = os.geteuid() == 0 # windows-footgun: ok — Linux systemd install wizard, never invoked on Windows
|
||||
options = ["User service (no sudo; best for laptops/dev boxes; may need linger after logout)"]
|
||||
values: list[str | None] = ["user"]
|
||||
if is_root:
|
||||
options.append("System service (starts on boot; runs as your chosen user)")
|
||||
values.append("system")
|
||||
options.append("Skip service install for now")
|
||||
values.append(None)
|
||||
choice = prompt_choice(" Choose how the gateway should run in the background:", options, default=0)
|
||||
if not is_root and choice == 0:
|
||||
print_info(" Tip: for a boot-time system service, re-run setup as root (e.g. from a root shell or `sudo -i`).")
|
||||
return values[choice]
|
||||
|
||||
|
||||
def install_linux_gateway_from_setup(force: bool = False, enable_on_startup: bool = True) -> tuple[str | None, bool]:
|
||||
@@ -2430,7 +2393,6 @@ def ensure_gateway_service(context: str = "setup") -> bool:
|
||||
True when a service is installed and running.
|
||||
"""
|
||||
from hermes_constants import is_container
|
||||
|
||||
if is_container():
|
||||
# Containers use restart policies, not service managers.
|
||||
print_info("Start the gateway to bring your bots online:")
|
||||
@@ -2449,11 +2411,9 @@ def ensure_gateway_service(context: str = "setup") -> bool:
|
||||
try:
|
||||
if _is_service_running():
|
||||
return True
|
||||
|
||||
if not _is_service_installed():
|
||||
if supports_systemd and has_conflicting_systemd_units():
|
||||
# Both user and system units would fight over bot tokens.
|
||||
# Don't pile a fresh install onto a conflicted state.
|
||||
# Both units would fight over bot tokens; don't pile a fresh install onto a conflicted state.
|
||||
print_systemd_scope_conflict_warning()
|
||||
return False
|
||||
print_info(" Installing the gateway background service ...")
|
||||
@@ -2462,11 +2422,9 @@ def ensure_gateway_service(context: str = "setup") -> bool:
|
||||
elif is_macos():
|
||||
launchd_install(force=False)
|
||||
else:
|
||||
# Registers the Scheduled Task AND starts it.
|
||||
_gw_windows().install(force=False)
|
||||
_gw_windows().install(force=False) # Registers the Scheduled Task AND starts it.
|
||||
print_success(" Gateway service installed and started.")
|
||||
return True
|
||||
|
||||
if supports_systemd:
|
||||
systemd_start()
|
||||
elif is_macos():
|
||||
@@ -2482,8 +2440,7 @@ def ensure_gateway_service(context: str = "setup") -> bool:
|
||||
print_warning(f" Gateway service needs root for this scope: {e}")
|
||||
_print_system_scope_remediation("start")
|
||||
except SystemExit:
|
||||
# Some install/start paths sys.exit() on hard failures (e.g. temp-HOME
|
||||
# guard). A background-service failure must never abort setup/import.
|
||||
# Some install/start paths sys.exit() on hard failures (temp-HOME guard); never abort setup/import.
|
||||
print_warning(" Gateway service install did not complete.")
|
||||
print_info(" You can retry manually: hermes gateway install")
|
||||
except Exception as e:
|
||||
@@ -2505,7 +2462,6 @@ def get_systemd_linger_status() -> tuple[bool | None, str]:
|
||||
if not username:
|
||||
try:
|
||||
import pwd
|
||||
|
||||
username = pwd.getpwuid(os.getuid()).pw_name # windows-footgun: ok — POSIX loginctl helper, never invoked on Windows
|
||||
except Exception:
|
||||
return None, "could not determine current user"
|
||||
@@ -2532,7 +2488,6 @@ def get_systemd_linger_status() -> tuple[bool | None, str]:
|
||||
def get_launchd_plist_path() -> Path:
|
||||
"""``~/Library/LaunchAgents/ai.hermes.gateway[-<profile>].plist`` under the real account home."""
|
||||
import pwd
|
||||
|
||||
suffix = _profile_suffix()
|
||||
name = f"ai.hermes.gateway-{suffix}" if suffix else "ai.hermes.gateway"
|
||||
# Real account home: profile mode may point HOME at a profile dir.
|
||||
@@ -2550,7 +2505,6 @@ def launchd_gateway_labels_for_install() -> list[str]:
|
||||
import re as _re
|
||||
|
||||
from hermes_cli.profiles import list_profiles
|
||||
|
||||
root_label: list[str] = []
|
||||
profile_labels: list[str] = []
|
||||
for profile in list_profiles():
|
||||
@@ -2582,7 +2536,6 @@ def get_python_path() -> str:
|
||||
# Update-boundary: a gateway restarted mid-update can hold a stale hermes_constants
|
||||
# without this symbol; see _reload_hermes_constants() in hermes_cli/managed_uv.py.
|
||||
from hermes_cli.managed_uv import _reload_hermes_constants
|
||||
|
||||
venv_python_path = _reload_hermes_constants().venv_python_path
|
||||
|
||||
venv_python = venv_python_path(venv, windows=is_windows())
|
||||
@@ -2714,7 +2667,6 @@ def _systemd_watchdog_seconds(hermes_home: str | Path | None = None) -> int:
|
||||
override_token = reset_home_override = None
|
||||
if hermes_home is not None:
|
||||
from hermes_constants import (reset_hermes_home_override, set_hermes_home_override)
|
||||
|
||||
override_token = set_hermes_home_override(hermes_home)
|
||||
reset_home_override = reset_hermes_home_override
|
||||
try:
|
||||
@@ -2736,7 +2688,6 @@ def _append_node_dir_for_service(path_entries: list[str], hermes_root: Path | No
|
||||
Managed dirs are profile-scoped. PATH lookup is only the fallback when no managed Node exists.
|
||||
"""
|
||||
from hermes_constants import (hermes_managed_node_tree_present, iter_hermes_node_dirs)
|
||||
|
||||
managed_node_present = hermes_managed_node_tree_present(hermes_root)
|
||||
for directory in iter_hermes_node_dirs(hermes_root) if managed_node_present else ():
|
||||
entry = str(directory)
|
||||
@@ -2883,7 +2834,6 @@ def _normalize_launchd_plist_for_comparison(text: str) -> str:
|
||||
"""Normalize plist text for staleness checks, ignoring the PATH payload: the generated PATH is
|
||||
captured from the invoking shell and varies across shells."""
|
||||
import re
|
||||
|
||||
return re.sub(
|
||||
r"(<key>PATH</key>\s*<string>)(.*?)(</string>)", r"\1__HERMES_PATH__\3",
|
||||
_normalize_service_definition(text), flags=re.S,
|
||||
@@ -2917,7 +2867,6 @@ def _temp_home_in_service_definition(definition: str) -> str | None:
|
||||
"""
|
||||
import re
|
||||
import tempfile
|
||||
|
||||
candidates = re.findall(r'HERMES_HOME=([^"\n]+)', definition)
|
||||
candidates += re.findall(r"<key>HERMES_HOME</key>\s*<string>(.*?)</string>", definition, flags=re.S)
|
||||
temp_roots = {
|
||||
@@ -2993,7 +2942,6 @@ def _ensure_linger_enabled() -> None:
|
||||
return
|
||||
|
||||
import getpass
|
||||
|
||||
username = getpass.getuser()
|
||||
if Path(f"/var/lib/systemd/linger/{username}").exists():
|
||||
print("✓ Systemd linger is enabled (service survives logout)")
|
||||
@@ -3218,7 +3166,6 @@ def systemd_restart(system: bool = False):
|
||||
# persists for the get_running_pid / drain-timeout reads below.
|
||||
refresh_systemd_unit_if_needed(system=system)
|
||||
from gateway.status import get_running_pid
|
||||
|
||||
pid = get_running_pid() or _systemd_main_pid(system=system)
|
||||
if pid is not None and probe_gateway_loop_liveness(pid) == GATEWAY_LOOP_WEDGED:
|
||||
# Event loop provably dead: SIGUSR1 can never drain it, so escalate (SIGTERM grace →
|
||||
@@ -3486,7 +3433,6 @@ def _append_launchd_reload_log(message: str) -> None:
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
from datetime import datetime as _dt
|
||||
|
||||
stamp = _dt.now().astimezone().strftime("%Y-%m-%d %H:%M:%S %z")
|
||||
with path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(f"[{stamp}] {message}\n")
|
||||
@@ -3545,7 +3491,6 @@ def _launchd_unsupported_marker_path() -> Path:
|
||||
def _write_launchd_unsupported_marker() -> None:
|
||||
"""Persist that launchd cannot supervise the gateway on this host."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
payload = {
|
||||
"written_at": datetime.now(timezone.utc).isoformat(),
|
||||
"reason": "launchd domain unsupported (exit 5/125)",
|
||||
@@ -3589,7 +3534,6 @@ def _spawn_detached_gateway() -> bool:
|
||||
"""Launch the gateway detached (launchd fallback for macOS 26+). CLI-managed nohup equivalent:
|
||||
stdout → gateway.log, timestamped stderr → gateway.error.log, PID via gateway.pid so stop/status work."""
|
||||
from hermes_cli._subprocess_compat import windows_detach_popen_kwargs
|
||||
|
||||
log_dir = get_hermes_home() / "logs"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
@@ -3608,7 +3552,6 @@ def _launchd_fallback_to_detached(reason: str, *, exit_on_failure: bool = True)
|
||||
"""Start the gateway detached when launchd can't manage it; on failure print the manual workaround
|
||||
and (by default) exit 1."""
|
||||
from hermes_constants import display_hermes_home as _dhh
|
||||
|
||||
_write_launchd_unsupported_marker()
|
||||
print(f"⚠ launchd cannot manage the gateway on this macOS version ({reason}).")
|
||||
if _spawn_detached_gateway():
|
||||
@@ -3656,7 +3599,6 @@ def generate_launchd_plist() -> str:
|
||||
nofile_block = ""
|
||||
try:
|
||||
from hermes_cli.resource_limits import configured_nofile_soft_limit
|
||||
|
||||
nofile_target = configured_nofile_soft_limit()
|
||||
except Exception:
|
||||
nofile_target = None
|
||||
@@ -3766,23 +3708,15 @@ def _spawn_deferred_launchd_reload(
|
||||
f"launchctl bootout {q_target} 2>/dev/null; "
|
||||
# Wait for the OLD gateway to exit: bootout only SIGTERMs and every bootstrap during the drain fails EIO.
|
||||
f"_wait_deadline=$(($(date +%s) + {_reload_budget})); "
|
||||
f"while kill -0 {gateway_pid} 2>/dev/null; do "
|
||||
f" if [ $(date +%s) -ge $_wait_deadline ]; then "
|
||||
f"while kill -0 {gateway_pid} 2>/dev/null; do if [ $(date +%s) -ge $_wait_deadline ]; then "
|
||||
f" echo \"[{stamp}] old gateway pid {gateway_pid} still alive after {_reload_budget}s drain wait — bootstrapping anyway\" >> {q_log}; "
|
||||
f" break; "
|
||||
f" fi; "
|
||||
f" sleep 1; "
|
||||
f"done; "
|
||||
f" break; fi; sleep 1; done; "
|
||||
# Let launchd finish unregistering the label after the process exits.
|
||||
f"sleep 1; "
|
||||
f"_deadline=$(($(date +%s) + {_reload_budget})); "
|
||||
f"while :; do "
|
||||
f"sleep 1; _deadline=$(($(date +%s) + {_reload_budget})); while :; do "
|
||||
f" launchctl bootstrap {shlex.quote(domain)} {shlex.quote(str(plist_path))} 2>/dev/null; "
|
||||
f" if {listed}; then break; fi; "
|
||||
f" echo \"[{stamp}] bootstrap not yet registered for {q_target} — retrying\" >> {q_log}; "
|
||||
f" if [ $(date +%s) -ge $_deadline ]; then break; fi; "
|
||||
f" sleep 2; "
|
||||
f"done; "
|
||||
f" if [ $(date +%s) -ge $_deadline ]; then break; fi; sleep 2; done; "
|
||||
f"if ! {listed}; then "
|
||||
f" echo \"[{stamp}] FAILED launchd reload for {q_target} — service NOT registered after {_reload_budget}s of retries\" >> {q_log}; "
|
||||
f"fi; "
|
||||
@@ -3906,7 +3840,6 @@ def launchd_install(force: bool = False):
|
||||
print("Next steps:")
|
||||
print(" hermes gateway status # Check status")
|
||||
from hermes_constants import display_hermes_home as _dhh
|
||||
|
||||
print(f" tail -f {_dhh()}/logs/gateway.log # View logs")
|
||||
|
||||
|
||||
@@ -3986,7 +3919,6 @@ def _wait_for_gateway_exit(timeout: float = 10.0, force_after: float | None = 5.
|
||||
"""Wait up to ``timeout`` s for the gateway (by gateway.pid, not launchd labels, so multiple
|
||||
HERMES_HOMEs work) to exit; SIGKILL it after ``force_after`` s of graceful waiting."""
|
||||
from gateway.status import get_process_start_time, get_running_pid
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
force_deadline = (time.monotonic() + force_after) if force_after is not None else None
|
||||
force_sent = False
|
||||
@@ -4043,7 +3975,6 @@ def launchd_restart():
|
||||
domain = _launchd_domain()
|
||||
target = f"{domain}/{label}"
|
||||
from gateway.status import get_running_pid
|
||||
|
||||
try:
|
||||
pid = get_running_pid()
|
||||
if pid is not None and _request_gateway_self_restart(pid):
|
||||
|
||||
Reference in New Issue
Block a user