mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
Rebasing onto main brought three new detached spawns the ratchet flags: - gateway exec quick commands: awaited, and the timeout/cancel path kills the group (_stop_exec_tree); - managed session workers: covered, the worker kills its own group when its owner dies (_die_with_owner); - runtime gateway start: a gateway daemon launch that must outlive its launcher.
158 lines
9.2 KiB
Python
158 lines
9.2 KiB
Python
"""One detached-process primitive; callers must first prove owner/service absence."""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
|
|
from hermes_cli._subprocess_compat import windows_detach_popen_kwargs, _WINDOWS_GATEWAY_BREAKAWAY_ENV
|
|
from hermes_cli.gateway_launchd_runtime import GatewayJob, start_gateway_job
|
|
from hermes_cli.gateway_runtime_service import RuntimeStartError, remaining
|
|
|
|
|
|
# One launch's policy and pins, never the shared daemon's: hook consent would become daemon-lifetime
|
|
# auto-approval for messaging/cron, and a Kanban worker's or terminal's pins would retarget every later
|
|
# session's board, cwd and source. The behaviour values main's CLI read per process (ephemeral prompt,
|
|
# prefill file, provider request, project plugins) ride each session's ``client_env`` instead
|
|
# (agent.session_client_env); an operator-started gateway keeps its own environment.
|
|
DAEMON_STRIPPED_ENV = (
|
|
"HERMES_PROFILE", "HERMES_YOLO_MODE", "HERMES_IGNORE_RULES", "HERMES_SAFE_MODE",
|
|
"HERMES_IGNORE_USER_CONFIG", "HERMES_ACCEPT_HOOKS", "HERMES_SESSION_SOURCE", "TERMINAL_CWD",
|
|
"HERMES_KANBAN_TASK", "HERMES_KANBAN_WORKSPACE", "HERMES_KANBAN_BRANCH", "HERMES_KANBAN_RUN_ID",
|
|
"HERMES_KANBAN_CLAIM_LOCK", "HERMES_KANBAN_CLAIM_TTL_SECONDS", "HERMES_KANBAN_GOAL_MODE",
|
|
"HERMES_KANBAN_GOAL_MAX_TURNS", "HERMES_KANBAN_DB", "HERMES_KANBAN_WORKSPACES_ROOT",
|
|
"HERMES_KANBAN_BOARD", "HERMES_TENANT", "HERMES_SESSION_SOURCE_EXPLICIT", "HERMES_TURN_AUTHOR",
|
|
# Human-presence/one-shot markers: inherited, every messaging/cron turn would read as attended or -q.
|
|
"HERMES_INTERACTIVE", "HERMES_GATEWAY_SESSION", "HERMES_SINGLE_QUERY_SESSION",
|
|
"HERMES_EPHEMERAL_SYSTEM_PROMPT", "HERMES_PREFILL_MESSAGES_FILE", "HERMES_INFERENCE_PROVIDER",
|
|
"HERMES_ENABLE_PROJECT_PLUGINS", "HERMES_TUI_CLIENT_ENV",
|
|
# A terminal's shell under a systemd service (gnome-terminal-server, a user unit) carries these; the
|
|
# unmanaged daemon would then identify as "systemd" and maintenance would refuse it as supervised.
|
|
"INVOCATION_ID", "JOURNAL_STREAM",
|
|
# Same for any process below a launchd/--external-supervisor gateway (its agents' tools, scripts they
|
|
# run): the daemon would identify as "launchd", never arm --idle-exit, and outlive every client.
|
|
# HERMES_S6_SUPERVISED_CHILD stays: without it `gateway run` in an s6 container redirects to s6.
|
|
"XPC_SERVICE_NAME", "LAUNCHD_SOCKET", "HERMES_LAUNCHD_LABEL",
|
|
"HERMES_GATEWAY_EXTERNAL_SUPERVISOR", "HERMES_SUPERVISED_CHILD",
|
|
# systemd's notify socket and watchdog name the unit's main process: the daemon would feed them.
|
|
"NOTIFY_SOCKET", "WATCHDOG_USEC", "WATCHDOG_PID",
|
|
)
|
|
|
|
|
|
def spawn_unmanaged_gateway(profile_home: Path, *, deadline: float, idle_exit: bool = False,
|
|
force: bool = False) -> subprocess.Popen | GatewayJob:
|
|
"""Request a daemon, not readiness. Refuse Windows no-breakaway fallback.
|
|
|
|
No --replace, persistence, login changes, elevation, shell, or inherited stdio.
|
|
The gateway runtime's own exclusive ownership fence arbitrates racing starts.
|
|
``idle_exit``: the daemon ends itself once idle (a chat/TUI/cron client's own start; never
|
|
Desktop's ``gateway ensure``, which keeps its attached gateway for the app's lifetime).
|
|
``force``: a ``gateway restart --force --detach`` replacement keeps the ``--force`` its old owner
|
|
needed (a named profile under a multiplexer), or it exits on the run-side guard.
|
|
"""
|
|
home = profile_home.resolve()
|
|
root = Path(__file__).resolve().parent.parent
|
|
# A root home is only pinned by an explicit selector: without one the child's
|
|
# _apply_profile_override follows the sticky active_profile and boots the wrong
|
|
# profile's daemon. A <root>/profiles/<name> home is already trusted as-is.
|
|
selector = [] if home.parent.name == "profiles" else ["--profile", "default"]
|
|
command = [sys.executable, "-m", "hermes_cli.main", *selector, "gateway", "run", "--quiet",
|
|
*(["--idle-exit"] if idle_exit else []), *(["--force"] if force else [])]
|
|
# The target profile's own secrets, never the launching client's: a `hermes -p work` client's
|
|
# dotenv would otherwise become the default daemon's frozen launch env (host_gateway_child_env).
|
|
from tools.environments.local import served_profile_child_env
|
|
env = served_profile_child_env(base=dict(os.environ), target_home=home, inherit_credentials=True)
|
|
if sys.platform == "win32":
|
|
from hermes_cli.gateway_windows import windowless_gateway_restart_spec
|
|
command, _, overlay = windowless_gateway_restart_spec(command)
|
|
if not overlay:
|
|
raise RuntimeStartError("windows_interpreter_unavailable")
|
|
env.update(overlay)
|
|
env[_WINDOWS_GATEWAY_BREAKAWAY_ENV] = "1"
|
|
env.update(HERMES_HOME=str(home), HERMES_GATEWAY_DETACHED="1", PYTHONIOENCODING="utf-8")
|
|
# Profile selection is the explicit home, not the invoking client's display
|
|
# name. Launch-only approval/context/config flags ride the launching session's frozen
|
|
# policy, not the shared daemon or every later session.
|
|
from gateway.session_context import _VAR_MAP # the launching turn's session identity, too
|
|
from tools.environments.local_env_policy import carry_gateway_profile_pin
|
|
carry_gateway_profile_pin(env) # the supervisor markers go; the daemon's tools keep their pin
|
|
for key in (*DAEMON_STRIPPED_ENV, *_VAR_MAP):
|
|
env.pop(key, None)
|
|
remaining(deadline)
|
|
logs = home / "logs"
|
|
logs.mkdir(parents=True, exist_ok=True)
|
|
if sys.platform == "darwin": # LAN access must outlive the app that asked for the gateway
|
|
if job := start_gateway_job(command, env=env, cwd=root, log_path=logs / "gateway-stdio.log",
|
|
deadline=deadline):
|
|
return job
|
|
try:
|
|
with (logs / "gateway-stdio.log").open("ab", buffering=0) as output:
|
|
remaining(deadline)
|
|
return subprocess.Popen(command, cwd=root, env=env, close_fds=True,
|
|
stdin=subprocess.DEVNULL, stdout=output, stderr=output,
|
|
# detached-spawn: ok — a gateway daemon launch must outlive its launcher
|
|
**windows_detach_popen_kwargs())
|
|
except OSError as exc:
|
|
reason = "windows_breakaway_unavailable" if sys.platform == "win32" else "spawn_failed"
|
|
raise RuntimeStartError(reason) from exc
|
|
|
|
|
|
def start_detached_replacement(profile_home: Path, *, force: bool = False) -> int:
|
|
"""``gateway restart --detach`` with no service: start the stopped owner's replacement the way
|
|
``gateway ensure`` starts an absent one. In the foreground, the restarting client (Desktop's
|
|
bounded, piped call) would itself become the long-lived gateway it only meant to restart."""
|
|
import time
|
|
try:
|
|
spawn_unmanaged_gateway(profile_home, deadline=time.monotonic() + 30, force=force)
|
|
except RuntimeStartError as exc:
|
|
print(f"✗ Could not start the gateway in the background: {exc}", file=sys.stderr)
|
|
return 1
|
|
print("✓ Started the gateway in the background")
|
|
return 0
|
|
|
|
|
|
def stdio_log_path(profile_home: Path) -> Path:
|
|
return profile_home / "logs" / "gateway-stdio.log"
|
|
|
|
|
|
def stdio_log_size(profile_home: Path) -> int:
|
|
"""Where this launch's output will start in the append-only stdio log."""
|
|
try:
|
|
return stdio_log_path(profile_home).stat().st_size
|
|
except OSError:
|
|
return 0
|
|
|
|
|
|
_BANNER_PREFIXES = ("┌", "│", "├", "└")
|
|
_TAIL_LINES = 15
|
|
_TAIL_BYTES = 64 * 1024
|
|
|
|
|
|
def startup_failure_report(profile_home: Path, offset: int, status: int, pid: int | None = None) -> str:
|
|
"""What a client prints when the gateway it started died before serving: the exit status, the
|
|
redacted tail of THIS launch's stdio (the traceback lives there, written before logging is up and
|
|
before the detached-stdio redactor is installed) and the command that shows the whole log."""
|
|
path = stdio_log_path(profile_home)
|
|
try:
|
|
with path.open("rb") as log:
|
|
log.seek(max(offset, path.stat().st_size - _TAIL_BYTES))
|
|
text = log.read().decode("utf-8", errors="replace")
|
|
except OSError:
|
|
text = ""
|
|
lines = [line.rstrip() for line in text.splitlines()
|
|
if line.strip() and not line.lstrip().startswith(_BANNER_PREFIXES)][-_TAIL_LINES:]
|
|
if not lines:
|
|
# A clean refusal (config/credential verdict, exit 78) is logged, not printed: the runtime
|
|
# status file names it.
|
|
from gateway.status import read_runtime_status
|
|
status_file = read_runtime_status(profile_home / "gateway_state.json") or {}
|
|
if status_file.get("exit_reason") and pid is not None and status_file.get("pid") == pid:
|
|
lines = [str(status_file["exit_reason"])[:2000]]
|
|
path = profile_home / "logs" / "gateway.log"
|
|
from agent.redact import redact_sensitive_text
|
|
tail = redact_sensitive_text("\n".join(lines), force=True, redact_url_credentials=True)
|
|
see = f'type "{path}"' if sys.platform == "win32" else f"tail -n 200 '{path}'"
|
|
body = "\n".join(" " + line for line in tail.splitlines()) if tail else " (it wrote no output)"
|
|
return f"the gateway exited with status {status} before it could serve:\n{body}\nFull log: {see}"
|