2586 lines
111 KiB
Python
2586 lines
111 KiB
Python
"""
|
|
Process Registry -- in-memory registry for background processes spawned via
|
|
terminal(background=true): rolling 200KB output buffer, poll/log/wait/kill,
|
|
crash recovery via a JSON checkpoint, and session-scoped tracking for gateway
|
|
reset protection.
|
|
|
|
Background processes execute THROUGH the environment interface -- nothing runs
|
|
on the host unless TERMINAL_ENV=local; for Docker/Singularity/Modal/Daytona/SSH
|
|
the command runs inside the sandbox.
|
|
"""
|
|
|
|
import codecs
|
|
import json
|
|
import logging
|
|
import os
|
|
import platform
|
|
import shlex
|
|
import signal
|
|
import subprocess
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
|
|
_IS_WINDOWS = platform.system() == "Windows"
|
|
# systemd transient scopes exist only on Linux. Gate every scope-path branch
|
|
# on this constant (not merely "not Windows") so macOS and other POSIX
|
|
# platforms provably never touch systemd code (#70716 cross-platform audit).
|
|
_IS_LINUX = platform.system() == "Linux"
|
|
from tools.environments.local import _find_shell, _resolve_safe_cwd, _sanitize_subprocess_env
|
|
from hermes_cli._subprocess_compat import windows_hide_flags
|
|
from dataclasses import dataclass, field
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from hermes_cli.config import get_hermes_home
|
|
|
|
from agent.redact import redact_sensitive_text
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# Checkpoint file for crash recovery (gateway only)
|
|
CHECKPOINT_PATH = get_hermes_home() / "processes.json"
|
|
|
|
# Limits
|
|
MAX_OUTPUT_CHARS = 200_000 # 200KB rolling output buffer
|
|
FINISHED_TTL_SECONDS = 1800 # Keep finished processes for 30 minutes
|
|
MAX_PROCESSES = 64 # Max concurrent tracked processes (LRU pruning)
|
|
|
|
# Watch pattern rate limiting — PER SESSION. At most ONE watch-match notification
|
|
# every WATCH_MIN_INTERVAL_SECONDS; a match inside the cooldown is dropped and counts
|
|
# as one strike per window. After WATCH_STRIKE_LIMIT consecutive strike windows the
|
|
# session's watch_patterns are permanently disabled and it falls back to
|
|
# notify_on_complete semantics (one notification when the process exits).
|
|
WATCH_MIN_INTERVAL_SECONDS = 15
|
|
WATCH_STRIKE_LIMIT = 3
|
|
|
|
# Lifetime cap — independent of the strike counter. A pattern recurring at a cadence
|
|
# just above the cooldown never trips the strike limit yet forces a full-context agent
|
|
# turn every time; watch_patterns is documented as "ONLY for rare one-shot signals",
|
|
# so after this many delivered matches we disable it and fall back to notify_on_complete.
|
|
WATCH_LIFETIME_MAX_HITS = 8
|
|
|
|
# Global circuit breaker across all sessions — secondary safety net so concurrent
|
|
# siblings can't collectively flood the user even when each is under its own cap.
|
|
WATCH_GLOBAL_MAX_PER_WINDOW = 15
|
|
WATCH_GLOBAL_WINDOW_SECONDS = 10
|
|
WATCH_GLOBAL_COOLDOWN_SECONDS = 30
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# systemd cgroup isolation for gateway-spawned local executors
|
|
# ---------------------------------------------------------------------------
|
|
# Under a systemd gateway with MemoryMax, local background commands inherit the
|
|
# gateway's cgroup, so a memory-heavy executor can get the ENTIRE gateway killed by
|
|
# systemd-oomd. Wrapping the spawn in ``systemd-run --user --scope`` gives the worker
|
|
# its own transient cgroup. Usability is probed once (the binary can exist while the
|
|
# user D-Bus session is absent — system services, containers) and cached.
|
|
|
|
_SYSTEMD_SCOPE_AVAILABLE: Optional[bool] = None
|
|
_SYSTEMD_SCOPE_PROBE_LOCK = threading.Lock()
|
|
_SYSTEMD_SCOPE_PROBED_AT = 0.0
|
|
_SYSTEMD_SCOPE_FAILURE_TTL_SECONDS = 60.0
|
|
_MIN_WORKER_MEMORY_MAX_BYTES = 64 * 1024 * 1024
|
|
_DEFAULT_WORKER_MEMORY_MAX_BYTES = 1024 * 1024 * 1024
|
|
_WORKER_MEMORY_MAX_CAP_BYTES = 4 * 1024 * 1024 * 1024
|
|
|
|
|
|
def _worker_memory_max_bytes() -> int:
|
|
"""Finite per-worker cgroup limit that can never widen host risk.
|
|
|
|
``TERMINAL_LOCAL_MEMORY_MAX_MB`` is honored only when it *tightens* the safe
|
|
bound (min of the gateway's cgroup-v2 ``memory.max`` and half of physical RAM,
|
|
capped at 4 GiB), so an oversized override cannot exceed the enclosing slice.
|
|
"""
|
|
override_bound: Optional[int] = None
|
|
override = os.getenv("TERMINAL_LOCAL_MEMORY_MAX_MB", "").strip()
|
|
if override:
|
|
try:
|
|
parsed = int(override) * 1024 * 1024
|
|
except ValueError:
|
|
parsed = -1
|
|
if parsed >= _MIN_WORKER_MEMORY_MAX_BYTES:
|
|
override_bound = parsed
|
|
else:
|
|
logger.warning(
|
|
"Ignoring invalid TERMINAL_LOCAL_MEMORY_MAX_MB=%r; "
|
|
"expected an integer representing at least %d MiB",
|
|
override,
|
|
_MIN_WORKER_MEMORY_MAX_BYTES // (1024 * 1024),
|
|
)
|
|
|
|
candidates: List[int] = []
|
|
try:
|
|
for line in Path("/proc/self/cgroup").read_text(encoding="utf-8").splitlines():
|
|
if line.startswith("0::"):
|
|
relative = line.partition("::")[2].lstrip("/")
|
|
raw_limit = (
|
|
Path("/sys/fs/cgroup") / relative / "memory.max"
|
|
).read_text(encoding="utf-8").strip()
|
|
if raw_limit.isdigit() and int(raw_limit) >= _MIN_WORKER_MEMORY_MAX_BYTES:
|
|
candidates.append(int(raw_limit))
|
|
break
|
|
except (OSError, ValueError):
|
|
pass
|
|
|
|
try:
|
|
physical_bytes = int(os.sysconf("SC_PHYS_PAGES")) * int(os.sysconf("SC_PAGE_SIZE"))
|
|
candidates.append(min(
|
|
_WORKER_MEMORY_MAX_CAP_BYTES,
|
|
max(_MIN_WORKER_MEMORY_MAX_BYTES, physical_bytes // 2),
|
|
))
|
|
except (OSError, ValueError, TypeError):
|
|
pass
|
|
|
|
safe_bound = min(candidates) if candidates else _DEFAULT_WORKER_MEMORY_MAX_BYTES
|
|
return min(override_bound, safe_bound) if override_bound else safe_bound
|
|
|
|
|
|
def _systemd_scope_argv(binary: str, unit_name: str, *argv: str) -> List[str]:
|
|
"""``systemd-run --user --scope`` command line shared by the probe and real spawns.
|
|
|
|
``--collect`` makes the transient scope self-clean after exit; ``--unit`` gives it
|
|
a recognisable name for ``systemctl --user status`` / journalctl.
|
|
"""
|
|
return [
|
|
binary, "--user", "--scope", "--quiet",
|
|
"--unit", unit_name,
|
|
"--collect",
|
|
"--property", "MemoryAccounting=yes",
|
|
"--property", f"MemoryMax={_worker_memory_max_bytes()}",
|
|
"--property", "OOMPolicy=kill",
|
|
"--",
|
|
*argv,
|
|
]
|
|
|
|
|
|
def _systemd_scope_cached() -> Optional[bool]:
|
|
"""Cached probe verdict, or None when a (re)probe is due.
|
|
|
|
A True verdict is permanent; a False one expires after
|
|
``_SYSTEMD_SCOPE_FAILURE_TTL_SECONDS`` so a transient D-Bus outage isn't sticky.
|
|
"""
|
|
cached = _SYSTEMD_SCOPE_AVAILABLE
|
|
if cached is True:
|
|
return True
|
|
if cached is False and time.monotonic() - _SYSTEMD_SCOPE_PROBED_AT < _SYSTEMD_SCOPE_FAILURE_TTL_SECONDS:
|
|
return False
|
|
return None
|
|
|
|
|
|
def _systemd_run_user_scope_available() -> bool:
|
|
"""Return True if ``systemd-run --user --scope`` can create a cgroup.
|
|
|
|
``shutil.which`` alone is insufficient: system-service deployments and containers
|
|
may lack the user D-Bus session bus even though the binary is on PATH, so every
|
|
spawn would fail with ``Failed to connect to user bus``. We run a cheap no-op
|
|
probe (``systemd-run --user --scope --unit=… -- /bin/true``) and cache the outcome.
|
|
"""
|
|
global _SYSTEMD_SCOPE_AVAILABLE, _SYSTEMD_SCOPE_PROBED_AT
|
|
verdict = _systemd_scope_cached()
|
|
if verdict is not None:
|
|
return verdict
|
|
|
|
# Double-checked locking keeps concurrent first-use spawns from observing a
|
|
# temporary False while the definitive probe is still in flight — such a race
|
|
# would launch the losing workload back inside the gateway cgroup.
|
|
with _SYSTEMD_SCOPE_PROBE_LOCK:
|
|
verdict = _systemd_scope_cached()
|
|
if verdict is not None:
|
|
return verdict
|
|
|
|
available = False
|
|
if _IS_LINUX:
|
|
try:
|
|
import shutil
|
|
|
|
binary = shutil.which("systemd-run")
|
|
if binary:
|
|
# A unique unit avoids collisions; the timeout bounds D-Bus.
|
|
probe_unit = f"hermes-probe-scope-{os.getpid()}-{uuid.uuid4().hex[:8]}"
|
|
result = subprocess.run(
|
|
_systemd_scope_argv(binary, probe_unit, "/bin/true"),
|
|
capture_output=True,
|
|
timeout=3,
|
|
)
|
|
available = result.returncode == 0
|
|
if not available:
|
|
logger.debug(
|
|
"systemd-run --user --scope probe failed (rc=%s): %s",
|
|
result.returncode,
|
|
(result.stderr or b"").decode("utf-8", "replace").strip(),
|
|
)
|
|
except Exception as exc:
|
|
logger.debug("systemd-run --user --scope probe error: %s", exc)
|
|
|
|
_SYSTEMD_SCOPE_AVAILABLE = available
|
|
_SYSTEMD_SCOPE_PROBED_AT = time.monotonic()
|
|
return available
|
|
|
|
|
|
def _is_supervised_gateway_process() -> bool:
|
|
"""Whether this process is the live, supervised Hermes gateway itself.
|
|
|
|
Supervisor markers and ``_HERMES_GATEWAY`` are inherited by every descendant
|
|
(and importing ``gateway.run`` sets the latter), so also require ownership of
|
|
the live gateway PID file — transient scopes are for the gateway, not terminal
|
|
children or unrelated CLIs in the same supervised tree.
|
|
"""
|
|
if os.environ.get("_HERMES_GATEWAY") != "1":
|
|
return False
|
|
|
|
try:
|
|
from gateway.restart import is_gateway_supervisor_process
|
|
from gateway.status import get_running_pid
|
|
|
|
return (
|
|
is_gateway_supervisor_process()
|
|
and get_running_pid(cleanup_stale=False) == os.getpid()
|
|
)
|
|
except Exception as exc:
|
|
logger.debug("Could not verify supervised gateway process identity: %s", exc)
|
|
return False
|
|
|
|
|
|
def _build_systemd_scope_argv(
|
|
shell_argv: List[str],
|
|
unit_suffix: str,
|
|
) -> List[str]:
|
|
"""Wrap *shell_argv* in a ``systemd-run --user --scope`` invocation with its own
|
|
memory accounting, so an OOM in the worker cannot kill the gateway cgroup."""
|
|
import shutil
|
|
|
|
binary = shutil.which("systemd-run")
|
|
if binary is None:
|
|
# Caller should have checked _systemd_run_user_scope_available();
|
|
# guard anyway so we never pass None into Popen.
|
|
return shell_argv
|
|
return _systemd_scope_argv(binary, f"hermes-worker-{unit_suffix}", *shell_argv)
|
|
|
|
|
|
def _stop_systemd_unit(unit_name: str) -> bool:
|
|
"""Stop a transient systemd user scope by unit name.
|
|
|
|
Reaps the *entire* cgroup — catching double-forked descendants that survive a
|
|
plain PID signal because they were reparented to init inside the scope.
|
|
``systemctl --user stop`` SIGTERMs every process in the cgroup and escalates to
|
|
SIGKILL after ``TimeoutStopSec``.
|
|
|
|
Returns True if the unit was stopped (or was already gone), False if
|
|
``systemctl`` is unavailable or the stop command failed.
|
|
"""
|
|
import shutil
|
|
|
|
binary = shutil.which("systemctl")
|
|
if binary is None:
|
|
return False
|
|
try:
|
|
result = subprocess.run(
|
|
[binary, "--user", "stop", unit_name],
|
|
capture_output=True,
|
|
timeout=15,
|
|
)
|
|
if result.returncode != 0:
|
|
stderr = (result.stderr or b"").decode(errors="replace").strip()
|
|
stderr_lower = stderr.lower()
|
|
if any(
|
|
marker in stderr_lower
|
|
for marker in ("not loaded", "not found", "does not exist")
|
|
):
|
|
return True
|
|
logger.debug(
|
|
"systemctl --user stop %s exited %d: %s",
|
|
unit_name, result.returncode,
|
|
stderr,
|
|
)
|
|
return False
|
|
return True
|
|
except Exception as exc:
|
|
logger.debug("systemctl --user stop %s failed: %s", unit_name, exc)
|
|
return False
|
|
|
|
|
|
def format_uptime_short(seconds: int) -> str:
|
|
s = max(0, int(seconds))
|
|
if s < 60:
|
|
return f"{s}s"
|
|
mins, secs = divmod(s, 60)
|
|
if mins < 60:
|
|
return f"{mins}m {secs}s"
|
|
hours, mins = divmod(mins, 60)
|
|
return f"{hours}h {mins}m"
|
|
|
|
|
|
@dataclass
|
|
class ProcessSession:
|
|
"""A tracked background process with output buffering."""
|
|
id: str # Unique session ID ("proc_xxxxxxxxxxxx")
|
|
command: str # Original command string
|
|
task_id: str = "" # Task/sandbox isolation key
|
|
owner_task_id: str = "" # RAW spawning task id (e.g. "sa-..."); task_id is the
|
|
# CONTAINER key (may be collapsed by _resolve_container_task_id)
|
|
# so ownership checks must use this field
|
|
session_key: str = "" # Gateway session key (for reset protection)
|
|
pid: Optional[int] = None # OS process ID
|
|
process: Optional[subprocess.Popen] = None # Popen handle (local only)
|
|
env_ref: Any = None # Reference to the environment object
|
|
cwd: Optional[str] = None # Working directory
|
|
started_at: float = 0.0 # time.time() of spawn (wall clock)
|
|
host_start_time: Optional[int] = None # kernel start ticks (/proc/<pid>/stat f22) — PID-reuse guard
|
|
exited: bool = False # Whether the process has finished
|
|
exit_code: Optional[int] = None # Exit code (None if still running)
|
|
completion_reason: str = "exited" # exited|killed|lost|failed_start|already_exited
|
|
termination_source: str = "" # process.kill|kill_all|backend_lost|failed_start
|
|
output_buffer: str = "" # Rolling output (last MAX_OUTPUT_CHARS)
|
|
max_output_chars: int = MAX_OUTPUT_CHARS
|
|
detached: bool = False # True if recovered from crash (no pipe)
|
|
pid_scope: str = "host" # "host" for local/PTY PIDs, "sandbox" for env-local PIDs
|
|
systemd_unit: str = "" # transient scope unit name when spawned under systemd-run (#70716)
|
|
# Watcher/notification metadata (persisted for crash recovery)
|
|
watcher_platform: str = ""
|
|
watcher_chat_id: str = ""
|
|
watcher_user_id: str = ""
|
|
watcher_user_name: str = ""
|
|
watcher_thread_id: str = ""
|
|
watcher_message_id: str = "" # Triggering message id — reply anchor for topic routing
|
|
watcher_interval: int = 0 # 0 = no watcher configured
|
|
# Session-db id of the spawning conversation; lets the gateway drop completions
|
|
# whose session was closed at a user boundary (/new) instead of injecting them
|
|
# into the chat's NEW session.
|
|
parent_session_id: str = ""
|
|
notify_on_complete: bool = False # Queue agent notification on exit
|
|
watch_patterns: List[str] = field(default_factory=list)
|
|
_watch_hits: int = field(default=0, repr=False) # total matches delivered
|
|
_watch_suppressed: int = field(default=0, repr=False) # matches dropped by rate limit
|
|
_watch_disabled: bool = field(default=False, repr=False) # permanently killed after strike limit
|
|
# Per-session rate-limit state (see WATCH_* constants). A strike is a WINDOW with
|
|
# drops, not a dropped match.
|
|
_watch_last_emit_at: float = field(default=0.0, repr=False)
|
|
_watch_cooldown_until: float = field(default=0.0, repr=False)
|
|
_watch_strike_candidate: bool = field(default=False, repr=False)
|
|
_watch_consecutive_strikes: int = field(default=0, repr=False)
|
|
_completion_event: threading.Event = field(default_factory=threading.Event, repr=False)
|
|
_lock: threading.Lock = field(default_factory=threading.Lock)
|
|
_reader_thread: Optional[threading.Thread] = field(default=None, repr=False)
|
|
_pty: Any = field(default=None, repr=False) # ptyprocess handle (when use_pty=True)
|
|
|
|
def append_output(self, text: str) -> None:
|
|
"""Append to the rolling output buffer under the session lock, keeping the tail."""
|
|
with self._lock:
|
|
self.output_buffer += text
|
|
if len(self.output_buffer) > self.max_output_chars:
|
|
self.output_buffer = self.output_buffer[-self.max_output_chars:]
|
|
|
|
|
|
# Session fields persisted verbatim in the crash-recovery checkpoint (plus
|
|
# ``session_id``; ``command`` is redacted and ``owner_task_id`` defaulted on write).
|
|
_CHECKPOINT_FIELDS = (
|
|
"command", "pid", "pid_scope", "host_start_time", "systemd_unit", "cwd",
|
|
"started_at", "task_id", "owner_task_id", "session_key",
|
|
"watcher_platform", "watcher_chat_id", "watcher_user_id", "watcher_user_name",
|
|
"watcher_thread_id", "watcher_message_id", "watcher_interval",
|
|
"parent_session_id", "notify_on_complete", "watch_patterns",
|
|
)
|
|
_CHECKPOINT_DEFAULTS = {
|
|
f.name: ([] if f.name == "watch_patterns" else f.default)
|
|
for f in ProcessSession.__dataclass_fields__.values()
|
|
if f.name in _CHECKPOINT_FIELDS
|
|
}
|
|
|
|
|
|
class ProcessRegistry:
|
|
"""In-memory registry of running and finished background processes.
|
|
|
|
Thread-safe: accessed from executor threads (terminal_tool, process handlers),
|
|
the gateway asyncio loop (watchers, reset checks) and the cleanup thread.
|
|
"""
|
|
|
|
_SHELL_NOISE_SUBSTRINGS = (
|
|
"bash: cannot set terminal process group",
|
|
"bash: no job control in this shell",
|
|
"no job control in this shell",
|
|
"cannot set terminal process group",
|
|
"tcsetattr: Inappropriate ioctl for device",
|
|
)
|
|
|
|
def __init__(self):
|
|
self._running: Dict[str, ProcessSession] = {}
|
|
self._finished: Dict[str, ProcessSession] = {}
|
|
self._lock = threading.Lock()
|
|
|
|
# Side-channel for check_interval watchers (gateway reads after agent run)
|
|
self.pending_watchers: List[Dict[str, Any]] = []
|
|
|
|
# Unified queue for all background events (completion, watch_match,
|
|
# async_delegation...; distinguished by "type"). CLI process_loop and the
|
|
# gateway drain it after each agent turn to auto-trigger new turns.
|
|
import queue as _queue_mod
|
|
self.completion_queue: _queue_mod.Queue = _queue_mod.Queue()
|
|
# Rehydrate durable delegation completions once, at registry startup.
|
|
try:
|
|
from tools.async_delegation import restore_undelivered_completions
|
|
restore_undelivered_completions(self.completion_queue)
|
|
except Exception as exc:
|
|
logger.warning("Could not restore async delegation completions: %s", exc)
|
|
|
|
# Sessions whose completion the agent already consumed via wait()/read_log()
|
|
# — it has the output in hand, so drain loops AND gateway/tui watchers skip.
|
|
self._completion_consumed: set = set()
|
|
# Sessions merely *observed* exited via poll(). poll() is read-only and must
|
|
# NOT mark consumed (a status check would suppress the watcher's autonomous
|
|
# delivery turn), but on the CLI the poll result is inline in the same turn,
|
|
# so drain_notifications() skips these to avoid a duplicate [SYSTEM: ...];
|
|
# gateway/tui watchers deliberately ignore this set.
|
|
self._poll_observed: set = set()
|
|
|
|
# Global watch-match circuit breaker across all sessions.
|
|
self._global_watch_lock = threading.Lock()
|
|
self._global_watch_window_start: float = 0.0
|
|
self._global_watch_window_hits: int = 0
|
|
self._global_watch_tripped_until: float = 0.0
|
|
self._global_watch_suppressed_during_trip: int = 0
|
|
# Driver-installed sinks (desktop gateway): on_output(session, chunk) streams
|
|
# live output from reader threads; on_close(session_or_none, process_id) drops
|
|
# a read-only terminal tab without killing the process.
|
|
self.on_output = None
|
|
self.on_close = None
|
|
|
|
@staticmethod
|
|
def _clean_shell_noise(text: str) -> str:
|
|
"""Strip shell startup warnings from the beginning of output."""
|
|
lines = text.split("\n")
|
|
while lines and any(noise in lines[0] for noise in ProcessRegistry._SHELL_NOISE_SUBSTRINGS):
|
|
lines.pop(0)
|
|
return "\n".join(lines)
|
|
|
|
def _emit_output(self, session: ProcessSession, chunk: str) -> None:
|
|
"""Forward a freshly-read chunk to the live-output sink, if one is set.
|
|
Called from reader threads; never raise into the read loop."""
|
|
sink = self.on_output
|
|
if sink is None or not chunk:
|
|
return
|
|
try:
|
|
sink(session, chunk)
|
|
except Exception:
|
|
pass
|
|
|
|
def _check_watch_patterns(self, session: ProcessSession, new_text: str) -> None:
|
|
"""Scan a freshly-read chunk for watch patterns and queue notifications.
|
|
|
|
Rate limiting per session (see the WATCH_* constants): one match per cooldown
|
|
window, a match inside the window is one strike, WATCH_STRIKE_LIMIT
|
|
consecutive strikes or WATCH_LIFETIME_MAX_HITS total deliveries disable
|
|
watching and promote the session to notify_on_complete.
|
|
"""
|
|
if not session.watch_patterns or session._watch_disabled:
|
|
return
|
|
# Late chunks after the reader declared exit are post-exit noise; dropping them
|
|
# avoids stale notifications minutes after the process ended.
|
|
if session.exited:
|
|
return
|
|
|
|
# Scan new text line-by-line for pattern matches
|
|
matched_lines = []
|
|
matched_pattern = None
|
|
for line in new_text.splitlines():
|
|
for pat in session.watch_patterns:
|
|
if pat in line:
|
|
matched_lines.append(line.rstrip())
|
|
if matched_pattern is None:
|
|
matched_pattern = pat
|
|
break # one match per line is enough
|
|
|
|
if not matched_lines:
|
|
return
|
|
|
|
now = time.time()
|
|
should_disable = False
|
|
lifetime_exhausted = False
|
|
with session._lock:
|
|
# Case 1: inside the cooldown — drop, count one strike per window, and
|
|
# disable + promote once the strike limit is hit.
|
|
if session._watch_cooldown_until and now < session._watch_cooldown_until:
|
|
session._watch_suppressed += len(matched_lines)
|
|
if not session._watch_strike_candidate:
|
|
# First drop in this window — count one strike.
|
|
session._watch_strike_candidate = True
|
|
session._watch_consecutive_strikes += 1
|
|
if session._watch_consecutive_strikes >= WATCH_STRIKE_LIMIT:
|
|
session._watch_disabled = True
|
|
# Promote to notify_on_complete so the agent still gets
|
|
# exactly one notification when the process actually ends.
|
|
session.notify_on_complete = True
|
|
should_disable = True
|
|
return_early = True
|
|
else:
|
|
# Case 2: cooldown expired. A prior window with no drops resets the
|
|
# consecutive-strike counter (healthy cadence again).
|
|
if session._watch_cooldown_until and not session._watch_strike_candidate:
|
|
session._watch_consecutive_strikes = 0
|
|
session._watch_strike_candidate = False
|
|
|
|
# Emit the notification and start a new cooldown window.
|
|
session._watch_last_emit_at = now
|
|
session._watch_cooldown_until = now + WATCH_MIN_INTERVAL_SECONDS
|
|
session._watch_hits += 1
|
|
suppressed = session._watch_suppressed
|
|
session._watch_suppressed = 0
|
|
return_early = False
|
|
# Lifetime cap: this match is still delivered, but no further ones.
|
|
lifetime_exhausted = session._watch_hits >= WATCH_LIFETIME_MAX_HITS
|
|
if lifetime_exhausted:
|
|
session._watch_disabled = True
|
|
session.notify_on_complete = True
|
|
|
|
if return_early:
|
|
if should_disable:
|
|
# Exactly one summary so the agent/user sees why things went quiet.
|
|
self.completion_queue.put({
|
|
**self._watch_event_base(session),
|
|
"type": "watch_disabled",
|
|
"suppressed": session._watch_suppressed,
|
|
"message": (
|
|
f"Watch patterns disabled for process {session.id} — "
|
|
f"{WATCH_STRIKE_LIMIT} consecutive rate-limit windows triggered "
|
|
f"(min spacing {WATCH_MIN_INTERVAL_SECONDS}s). "
|
|
f"Falling back to notify_on_complete semantics; you'll get "
|
|
f"exactly one notification when the process exits."
|
|
),
|
|
})
|
|
return
|
|
|
|
# Trim matched output to a reasonable size
|
|
output = "\n".join(matched_lines[:20])
|
|
if len(output) > 2000:
|
|
output = output[:2000] + "\n...(truncated)"
|
|
|
|
if not self._global_watch_admit(now):
|
|
# Even when the breaker drops the final match, still explain the silence.
|
|
if lifetime_exhausted:
|
|
self._emit_lifetime_watch_disabled(session)
|
|
return
|
|
|
|
notification = {
|
|
**self._watch_event_base(session),
|
|
"type": "watch_match",
|
|
"pattern": matched_pattern,
|
|
"output": output,
|
|
"suppressed": suppressed,
|
|
}
|
|
_redact_process_result(notification)
|
|
self.completion_queue.put(notification)
|
|
|
|
if lifetime_exhausted:
|
|
self._emit_lifetime_watch_disabled(session)
|
|
|
|
def _emit_lifetime_watch_disabled(self, session: ProcessSession) -> None:
|
|
"""Queue the watch_disabled summary for the lifetime-cap path."""
|
|
self.completion_queue.put({
|
|
**self._watch_event_base(session),
|
|
"type": "watch_disabled",
|
|
"suppressed": 0,
|
|
"message": (
|
|
f"Watch patterns disabled for process {session.id} — "
|
|
f"reached the lifetime cap of {WATCH_LIFETIME_MAX_HITS} delivered "
|
|
f"matches. Falling back to notify_on_complete semantics; you'll get "
|
|
f"exactly one notification when the process exits."
|
|
),
|
|
})
|
|
|
|
@staticmethod
|
|
def _watch_event_base(session: ProcessSession) -> dict:
|
|
"""Session identity + watcher routing fields shared by every watch event."""
|
|
return {
|
|
"session_id": session.id,
|
|
"session_key": session.session_key,
|
|
"task_id": session.task_id,
|
|
"owner_task_id": session.owner_task_id or session.task_id,
|
|
"command": session.command,
|
|
"platform": session.watcher_platform,
|
|
"chat_id": session.watcher_chat_id,
|
|
"user_id": session.watcher_user_id,
|
|
"user_name": session.watcher_user_name,
|
|
"thread_id": session.watcher_thread_id,
|
|
"message_id": session.watcher_message_id,
|
|
}
|
|
|
|
@staticmethod
|
|
def _global_watch_event(type_: str, message: str, **extra) -> dict:
|
|
"""Unaddressed (all-sessions) watch breaker event."""
|
|
return {
|
|
"session_id": "",
|
|
"session_key": "",
|
|
"command": "",
|
|
"type": type_,
|
|
**extra,
|
|
"message": message,
|
|
"platform": "",
|
|
"chat_id": "",
|
|
"user_id": "",
|
|
"user_name": "",
|
|
"thread_id": "",
|
|
}
|
|
|
|
def _global_watch_admit(self, now: float) -> bool:
|
|
"""True if this watch_match may pass the global breaker.
|
|
|
|
In cooldown: drop and count. Otherwise slide the rolling window; exceeding
|
|
the cap trips the breaker for WATCH_GLOBAL_COOLDOWN_SECONDS with ONE
|
|
"tripped" summary, and the cooldown's end emits ONE "released" summary.
|
|
"""
|
|
release_msg = None
|
|
with self._global_watch_lock:
|
|
# Handle cooldown expiry first so we can emit the release summary.
|
|
if self._global_watch_tripped_until and now >= self._global_watch_tripped_until:
|
|
suppressed = self._global_watch_suppressed_during_trip
|
|
self._global_watch_tripped_until = 0.0
|
|
self._global_watch_suppressed_during_trip = 0
|
|
self._global_watch_window_start = now
|
|
self._global_watch_window_hits = 0
|
|
if suppressed > 0:
|
|
# Queued outside the lock (below).
|
|
release_msg = self._global_watch_event(
|
|
"watch_overflow_released",
|
|
f"Watch-pattern notifications resumed. "
|
|
f"{suppressed} match event(s) were suppressed during the flood.",
|
|
suppressed=suppressed,
|
|
)
|
|
|
|
# Still in cooldown — drop and count.
|
|
if self._global_watch_tripped_until and now < self._global_watch_tripped_until:
|
|
self._global_watch_suppressed_during_trip += 1
|
|
admit = False
|
|
trip_now = None
|
|
else:
|
|
# Slide the window.
|
|
if now - self._global_watch_window_start >= WATCH_GLOBAL_WINDOW_SECONDS:
|
|
self._global_watch_window_start = now
|
|
self._global_watch_window_hits = 0
|
|
|
|
if self._global_watch_window_hits >= WATCH_GLOBAL_MAX_PER_WINDOW:
|
|
# Trip the breaker.
|
|
self._global_watch_tripped_until = now + WATCH_GLOBAL_COOLDOWN_SECONDS
|
|
self._global_watch_suppressed_during_trip += 1
|
|
trip_now = now
|
|
admit = False
|
|
else:
|
|
self._global_watch_window_hits += 1
|
|
trip_now = None
|
|
admit = True
|
|
|
|
# Queue summary events outside the lock.
|
|
if release_msg is not None:
|
|
self.completion_queue.put(release_msg)
|
|
if trip_now is not None:
|
|
self.completion_queue.put(self._global_watch_event(
|
|
"watch_overflow_tripped",
|
|
f"Watch-pattern overflow: >{WATCH_GLOBAL_MAX_PER_WINDOW} "
|
|
f"notifications in {WATCH_GLOBAL_WINDOW_SECONDS}s across all processes. "
|
|
f"Suppressing further watch_match events for "
|
|
f"{WATCH_GLOBAL_COOLDOWN_SECONDS}s.",
|
|
))
|
|
return admit
|
|
|
|
@staticmethod
|
|
def _is_host_pid_alive(pid: Optional[int]) -> bool:
|
|
"""Best-effort liveness check for host-visible PIDs."""
|
|
if not pid:
|
|
return False
|
|
# ``os.kill(pid, 0)`` is NOT a no-op on Windows (bpo-14484) — use
|
|
# the cross-platform existence check.
|
|
from gateway.status import _pid_exists
|
|
return _pid_exists(pid)
|
|
|
|
@staticmethod
|
|
def _safe_host_start_time(pid: Optional[int]) -> Optional[int]:
|
|
"""Kernel start ticks for a host PID, or None when unavailable."""
|
|
if not pid:
|
|
return None
|
|
try:
|
|
from gateway.status import get_process_start_time
|
|
return get_process_start_time(pid)
|
|
except Exception:
|
|
return None
|
|
|
|
@classmethod
|
|
def _host_pid_is_ours(cls, pid: Optional[int], expected_start: Optional[int]) -> bool:
|
|
"""True only if ``pid`` is alive AND still the process we spawned.
|
|
|
|
The kernel recycles PIDs, so a stored number can later name an unrelated
|
|
process (seen in the wild: a browser's session leader tree-killed). The kernel
|
|
start time captured at spawn must match the live one; with no baseline
|
|
(legacy checkpoints, no ``/proc``) degrade to a bare liveness check.
|
|
"""
|
|
if not cls._is_host_pid_alive(pid):
|
|
return False
|
|
if expected_start is None:
|
|
return True
|
|
return cls._safe_host_start_time(pid) == expected_start
|
|
|
|
def _refresh_detached_session(self, session: Optional[ProcessSession]) -> Optional[ProcessSession]:
|
|
"""Update recovered host-PID sessions when the underlying process has exited."""
|
|
if session is None or session.exited or not session.detached or session.pid_scope != "host":
|
|
return session
|
|
|
|
# A recycled PID (alive but not ours) counts as "our process exited" so a
|
|
# later kill() can never tree-kill the stranger.
|
|
if self._host_pid_is_ours(session.pid, session.host_start_time):
|
|
return session
|
|
|
|
with session._lock:
|
|
if session.exited:
|
|
return session
|
|
session.exited = True
|
|
# Recovered sessions no longer have a waitable handle, so the real
|
|
# exit code is unavailable once the original process object is gone.
|
|
session.exit_code = None
|
|
|
|
self._move_to_finished(session)
|
|
return session
|
|
|
|
@staticmethod
|
|
def _proc_alive(proc) -> bool:
|
|
"""True if a psutil.Process is running and not a zombie.
|
|
|
|
A zombie is already dead (just unreaped), so there's nothing to SIGKILL.
|
|
"""
|
|
try:
|
|
import psutil
|
|
if not proc.is_running():
|
|
return False
|
|
return proc.status() != psutil.STATUS_ZOMBIE
|
|
except Exception:
|
|
return False
|
|
|
|
@staticmethod
|
|
def _config_value(section: str, key: str, fallback):
|
|
"""``config.yaml`` value for ``section.key``, else the DEFAULT_CONFIG value.
|
|
|
|
Raises if config is unreadable; callers wrap with their own hard fallback so
|
|
registry code paths never crash on a broken config file.
|
|
"""
|
|
from hermes_cli.config import DEFAULT_CONFIG, cfg_get, read_raw_config
|
|
|
|
val = cfg_get(read_raw_config(), section, key)
|
|
return DEFAULT_CONFIG[section][key] if val is None else val
|
|
|
|
@staticmethod
|
|
def _daemon_term_grace_seconds() -> float:
|
|
"""Grace window (s) between SIGTERM and escalated SIGKILL, floored at 0
|
|
(0 disables escalation). ``terminal.daemon_term_grace_seconds``; 2.0 if
|
|
config is unreadable."""
|
|
try:
|
|
return max(float(ProcessRegistry._config_value("terminal", "daemon_term_grace_seconds", 2.0)), 0.0)
|
|
except Exception:
|
|
return 2.0
|
|
|
|
@classmethod
|
|
def _terminate_host_pid(cls, pid: int, expected_start: Optional[int] = None) -> None:
|
|
"""Terminate a host-visible PID and its descendants.
|
|
|
|
``expected_start`` (kernel start time at spawn) is re-validated first; a
|
|
mismatch or dead PID means the number was recycled onto a stranger and we
|
|
refuse to touch it — a leaked orphan beats tree-killing someone's browser.
|
|
|
|
POSIX: psutil walks the tree and SIGTERMs children before the parent so
|
|
subprocess trees (Chromium renderers under an agent-browser daemon) aren't
|
|
reparented to init and survive. After ``terminal.daemon_term_grace_seconds``
|
|
any survivor is SIGKILLed (0 disables escalation).
|
|
|
|
Windows: ``taskkill /PID <pid> /T /F`` (same primitive as
|
|
``gateway.status.terminate_pid``; ``/F`` is already a hard kill). The psutil
|
|
path is unusable there: PPID links go stale so ``children(recursive=True)``
|
|
misses orphans, and ``terminate()`` is ``TerminateProcess()`` on one handle —
|
|
nothing cascades like a SIGTERM to a process group. The bare ``os.kill``
|
|
fallback covers OSError/PermissionError and a missing ``taskkill.exe``.
|
|
"""
|
|
if expected_start is not None and not cls._host_pid_is_ours(pid, expected_start):
|
|
logger.warning(
|
|
"Refusing to terminate host pid %d: start-time mismatch — "
|
|
"PID was recycled onto an unrelated process.", pid,
|
|
)
|
|
return
|
|
def _sigterm_quietly():
|
|
try:
|
|
os.kill(pid, signal.SIGTERM)
|
|
except (OSError, ProcessLookupError, PermissionError):
|
|
pass
|
|
|
|
if _IS_WINDOWS:
|
|
try:
|
|
subprocess.run(
|
|
["taskkill", "/PID", str(pid), "/T", "/F"],
|
|
capture_output=True,
|
|
text=True, encoding='utf-8', errors='replace',
|
|
timeout=10,
|
|
creationflags=windows_hide_flags(),
|
|
stdin=subprocess.DEVNULL,
|
|
)
|
|
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
_sigterm_quietly()
|
|
return
|
|
|
|
import psutil
|
|
try:
|
|
parent = psutil.Process(pid)
|
|
except psutil.NoSuchProcess:
|
|
return
|
|
except (OSError, PermissionError):
|
|
_sigterm_quietly()
|
|
return
|
|
|
|
# Snapshot the whole tree (children before parent) and SIGTERM each.
|
|
try:
|
|
targets = parent.children(recursive=True)
|
|
except (psutil.NoSuchProcess, psutil.AccessDenied, OSError):
|
|
targets = []
|
|
targets.append(parent)
|
|
|
|
for proc in targets:
|
|
try:
|
|
proc.terminate()
|
|
except (psutil.NoSuchProcess, psutil.AccessDenied, OSError):
|
|
pass
|
|
|
|
# Escalate to SIGKILL for anything that ignored SIGTERM within the grace
|
|
# window. We deliberately do NOT trust ``psutil.wait_procs``' gone/alive
|
|
# partition: it reaps via ``Process.wait()`` and mis-partitions across
|
|
# zombie transitions in a parent/child tree, leaving survivors un-killed.
|
|
# A direct liveness re-probe of every target is deterministic.
|
|
grace = cls._daemon_term_grace_seconds()
|
|
if grace <= 0:
|
|
return
|
|
deadline = time.monotonic() + grace
|
|
while time.monotonic() < deadline:
|
|
if not any(cls._proc_alive(_p) for _p in targets):
|
|
break
|
|
time.sleep(0.05)
|
|
for proc in targets:
|
|
try:
|
|
if not cls._proc_alive(proc):
|
|
continue
|
|
proc.kill() # SIGKILL on POSIX
|
|
logger.info(
|
|
"Escalated to SIGKILL for pid %d (ignored SIGTERM within "
|
|
"%.1fs grace)", proc.pid, grace,
|
|
)
|
|
except (psutil.NoSuchProcess, psutil.AccessDenied, OSError):
|
|
pass
|
|
|
|
# ----- Spawn -----
|
|
|
|
@staticmethod
|
|
def _env_temp_dir(env: Any) -> str:
|
|
"""Return the writable sandbox temp dir for env-backed background tasks."""
|
|
get_temp_dir = getattr(env, "get_temp_dir", None)
|
|
if callable(get_temp_dir):
|
|
try:
|
|
temp_dir = get_temp_dir()
|
|
if isinstance(temp_dir, str) and temp_dir.startswith("/"):
|
|
return temp_dir.rstrip("/") or "/"
|
|
except Exception as exc:
|
|
logger.debug("Could not resolve environment temp dir: %s", exc)
|
|
return "/tmp"
|
|
|
|
def _scope_argv(self, session: ProcessSession, argv: List[str], unit_suffix: str, label: str):
|
|
"""Wrap *argv* in a transient systemd scope when we are the supervised gateway.
|
|
|
|
Returns ``(argv, scoped)``. A scoped worker gets its own cgroup so an OOM kills
|
|
only the worker, not the gateway (and its messaging control plane).
|
|
"""
|
|
in_supervised_gateway = _IS_LINUX and _is_supervised_gateway_process()
|
|
if in_supervised_gateway and _systemd_run_user_scope_available():
|
|
session.systemd_unit = f"hermes-worker-{unit_suffix}.scope"
|
|
return _build_systemd_scope_argv(argv, unit_suffix=unit_suffix), True
|
|
if in_supervised_gateway:
|
|
# Under a supervisor but no private cgroup: an OOM in the worker can
|
|
# still take the whole gateway down.
|
|
logger.debug(
|
|
"%s background executor not isolated in a systemd scope "
|
|
"(systemd-run --user unavailable); worker shares the gateway cgroup.",
|
|
label,
|
|
)
|
|
return argv, False
|
|
|
|
def _track_started(self, session: ProcessSession, reader_target, reader_name: str) -> None:
|
|
"""Start the output reader thread, register the session and checkpoint it."""
|
|
reader = threading.Thread(
|
|
target=reader_target, args=(session,), daemon=True, name=reader_name,
|
|
)
|
|
session._reader_thread = reader
|
|
reader.start()
|
|
with self._lock:
|
|
self._prune_if_needed()
|
|
self._running[session.id] = session
|
|
self._write_checkpoint()
|
|
|
|
def _spawn_local_pty(self, session: ProcessSession, safe_command: str, env_vars: dict) -> ProcessSession:
|
|
"""PTY spawn for interactive CLI tools (Codex, Claude Code, REPLs).
|
|
|
|
Raises ImportError when no PTY backend is installed and re-raises any spawn
|
|
failure; ``spawn_local`` falls back to pipe mode in both cases.
|
|
"""
|
|
if _IS_WINDOWS:
|
|
from winpty import PtyProcess as _PtyProcessCls
|
|
else:
|
|
from ptyprocess import PtyProcess as _PtyProcessCls
|
|
user_shell = _find_shell()
|
|
pty_env = _sanitize_subprocess_env(os.environ, env_vars)
|
|
pty_env["PYTHONUNBUFFERED"] = "1"
|
|
# A PTY is a real TTY, so pager-happy tools (git log/diff, man) WILL page and
|
|
# hang waiting for `q` — default them to cat, honoring any pager the user set.
|
|
pty_env.setdefault("GIT_PAGER", "cat")
|
|
pty_env.setdefault("PAGER", "cat")
|
|
pty_argv, _ = self._scope_argv(
|
|
session, [user_shell, "-lic", f"set +m; {safe_command}"], session.id, "PTY",
|
|
)
|
|
pty_proc = _PtyProcessCls.spawn(
|
|
pty_argv,
|
|
cwd=session.cwd,
|
|
env=pty_env,
|
|
dimensions=(30, 120),
|
|
)
|
|
session.pid = pty_proc.pid
|
|
session.host_start_time = self._safe_host_start_time(session.pid)
|
|
session._pty = pty_proc
|
|
self._track_started(session, self._pty_reader_loop, f"proc-pty-reader-{session.id}")
|
|
return session
|
|
|
|
def spawn_local(
|
|
self,
|
|
command: str,
|
|
cwd: str = None,
|
|
task_id: str = "",
|
|
session_key: str = "",
|
|
env_vars: dict = None,
|
|
use_pty: bool = False,
|
|
owner_task_id: str = "",
|
|
) -> ProcessSession:
|
|
"""Spawn a background process locally (TERMINAL_ENV=local only; other
|
|
backends use spawn_via_env()).
|
|
|
|
``use_pty`` requests a pseudo-terminal via ptyprocess/pywinpty for interactive
|
|
CLI tools; it falls back to a plain pipe when that is unavailable or fails.
|
|
"""
|
|
# Bash parses ``A && B &`` as ``(A && B) &`` — a subshell that holds our stdout
|
|
# pipe open forever when B is a long-running server. The rewriter turns it into
|
|
# ``A && { B & }``. Lazy import: terminal_tool imports this module.
|
|
from tools.terminal_tool import _rewrite_compound_background as _rewrite_bg
|
|
|
|
safe_command = _rewrite_bg(command)
|
|
|
|
session = ProcessSession(
|
|
id=f"proc_{uuid.uuid4().hex[:12]}",
|
|
command=command,
|
|
task_id=task_id,
|
|
owner_task_id=owner_task_id or task_id,
|
|
session_key=session_key,
|
|
cwd=_resolve_safe_cwd(cwd or os.getcwd()),
|
|
started_at=time.time(),
|
|
)
|
|
|
|
pty_scope_attempted = False
|
|
if use_pty:
|
|
try:
|
|
return self._spawn_local_pty(session, safe_command, env_vars)
|
|
except ImportError:
|
|
logger.warning("ptyprocess not installed, falling back to pipe mode")
|
|
except Exception as e:
|
|
logger.warning("PTY spawn failed (%s), falling back to pipe mode", e)
|
|
if session.systemd_unit:
|
|
pty_scope_attempted = True
|
|
if not _stop_systemd_unit(session.systemd_unit):
|
|
raise RuntimeError(
|
|
"PTY scope could not be reaped; refusing pipe fallback "
|
|
"to avoid duplicate command execution"
|
|
) from e
|
|
session.systemd_unit = ""
|
|
|
|
# Pipe path (non-PTY or PTY fallback). The user's login shell keeps parity with
|
|
# LocalEnvironment (rc files sourced, user tools on PATH). PYTHONUNBUFFERED so
|
|
# tqdm/datasets-style buffering doesn't hide progress from process(action="poll").
|
|
user_shell = _find_shell()
|
|
bg_env = _sanitize_subprocess_env(os.environ, env_vars)
|
|
bg_env["PYTHONUNBUFFERED"] = "1"
|
|
_popen_kwargs = {"creationflags": windows_hide_flags()} if _IS_WINDOWS else {}
|
|
|
|
unit_suffix = f"{session.id}-pipe-fallback" if pty_scope_attempted else session.id
|
|
spawn_argv, _ = self._scope_argv(
|
|
session, [user_shell, "-lic", f"set +m; {safe_command}"], unit_suffix, "Local",
|
|
)
|
|
|
|
# start_new_session is REQUIRED with systemd-run --scope too: the scope does not
|
|
# give the worker a new session, so from an interactive TUI the worker would
|
|
# share the foreground process group and background spawns would stop the whole
|
|
# session (observed as dead TUIs in state T). Cgroup isolation is unaffected —
|
|
# the scope attaches to the invoked process, not the spawning session.
|
|
proc = subprocess.Popen(
|
|
spawn_argv,
|
|
text=True,
|
|
cwd=session.cwd,
|
|
env=bg_env,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
stdin=subprocess.DEVNULL,
|
|
start_new_session=True,
|
|
**_popen_kwargs,
|
|
)
|
|
|
|
session.process = proc
|
|
session.pid = proc.pid
|
|
session.host_start_time = self._safe_host_start_time(session.pid)
|
|
|
|
try:
|
|
self._track_started(session, self._reader_loop, f"proc-reader-{session.id}")
|
|
except Exception:
|
|
# Post-Popen setup failed — kill the orphaned subprocess (and any setsid
|
|
# descendants) before re-raising so nothing leaks untracked.
|
|
try:
|
|
if session.systemd_unit:
|
|
# Scope teardown is the authoritative cleanup for the worker cgroup
|
|
# (never killpg here); the wrapper PID is terminated as fallback.
|
|
_stop_systemd_unit(session.systemd_unit)
|
|
self._terminate_host_pid(proc.pid, session.host_start_time)
|
|
elif not _IS_WINDOWS:
|
|
try:
|
|
kill_signal = getattr(signal, "SIGKILL", signal.SIGTERM)
|
|
os.killpg(os.getpgid(proc.pid), kill_signal) # windows-footgun: ok - guarded by _IS_WINDOWS above
|
|
except (ProcessLookupError, PermissionError, OSError):
|
|
proc.kill()
|
|
else:
|
|
proc.kill()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
proc.wait(timeout=5)
|
|
except Exception:
|
|
pass
|
|
raise
|
|
|
|
return session
|
|
|
|
def spawn_via_env(
|
|
self,
|
|
env: Any,
|
|
command: str,
|
|
cwd: str = None,
|
|
task_id: str = "",
|
|
session_key: str = "",
|
|
timeout: int = 10,
|
|
owner_task_id: str = "",
|
|
) -> ProcessSession:
|
|
"""Spawn a background process inside a non-local backend's sandbox.
|
|
|
|
The command is wrapped to capture its in-sandbox PID and redirect output to
|
|
a log file, which later execute() calls poll. Less capable than local spawn
|
|
(no live pipe, no stdin) but runs in the correct sandbox context.
|
|
"""
|
|
session = ProcessSession(
|
|
id=f"proc_{uuid.uuid4().hex[:12]}",
|
|
command=command,
|
|
task_id=task_id,
|
|
owner_task_id=owner_task_id or task_id,
|
|
session_key=session_key,
|
|
cwd=cwd,
|
|
started_at=time.time(),
|
|
env_ref=env,
|
|
pid_scope="sandbox",
|
|
)
|
|
|
|
# Run the command in the sandbox with output capture
|
|
temp_dir = self._env_temp_dir(env)
|
|
log_path = f"{temp_dir}/hermes_bg_{session.id}.log"
|
|
pid_path = f"{temp_dir}/hermes_bg_{session.id}.pid"
|
|
exit_path = f"{temp_dir}/hermes_bg_{session.id}.exit"
|
|
quoted_command = shlex.quote(command)
|
|
quoted_temp_dir = shlex.quote(temp_dir)
|
|
quoted_log_path = shlex.quote(log_path)
|
|
quoted_pid_path = shlex.quote(pid_path)
|
|
quoted_exit_path = shlex.quote(exit_path)
|
|
bg_command = (
|
|
f"mkdir -p {quoted_temp_dir} && "
|
|
f"( nohup bash -lc {quoted_command} > {quoted_log_path} 2>&1; "
|
|
f"rc=$?; printf '%s\\n' \"$rc\" > {quoted_exit_path} ) & "
|
|
f"echo $! > {quoted_pid_path} && cat {quoted_pid_path}"
|
|
)
|
|
|
|
try:
|
|
result = env.execute(
|
|
bg_command,
|
|
timeout=timeout,
|
|
rewrite_compound_background=False,
|
|
)
|
|
output = result.get("output", "").strip()
|
|
# Try to extract the PID from the output
|
|
for line in output.splitlines():
|
|
line = line.strip()
|
|
if line.isdigit():
|
|
session.pid = int(line)
|
|
break
|
|
# No PID from the wrapper (syntax error, broken redirect): a failed launch,
|
|
# not a fake running session.
|
|
if session.pid is None:
|
|
session.exited = True
|
|
session.exit_code = int(result.get("returncode", -1))
|
|
if session.exit_code == 0:
|
|
session.exit_code = -1
|
|
session.completion_reason = "failed_start"
|
|
session.termination_source = "failed_start"
|
|
session.output_buffer = result.get("output", "").strip()
|
|
except Exception as e:
|
|
session.exited = True
|
|
session.exit_code = -1
|
|
session.completion_reason = "failed_start"
|
|
session.termination_source = "failed_start"
|
|
session.output_buffer = f"Failed to start: {e}"
|
|
|
|
if not session.exited:
|
|
# Start a poller thread that periodically reads the log file
|
|
reader = threading.Thread(
|
|
target=self._env_poller_loop,
|
|
args=(session, env, log_path, pid_path, exit_path),
|
|
daemon=True,
|
|
name=f"proc-poller-{session.id}",
|
|
)
|
|
session._reader_thread = reader
|
|
reader.start()
|
|
|
|
with self._lock:
|
|
self._prune_if_needed()
|
|
if not session.exited:
|
|
self._running[session.id] = session
|
|
|
|
if not session.exited:
|
|
self._write_checkpoint()
|
|
|
|
return session
|
|
|
|
# ----- Reader / Poller Threads -----
|
|
|
|
def _reader_loop(self, session: ProcessSession):
|
|
"""Background thread: read stdout from a local Popen process.
|
|
|
|
Uses ``buffer.read1(4096)`` not ``TextIOWrapper.read(4096)``: on pipes the
|
|
latter blocks until EOF, landing "live" output in one burst at exit.
|
|
|
|
Orphaned-pipe guard: a backgrounded grandchild (``node server.js &``)
|
|
inherits our pipe's write end, so EOF never arrives while it lives — a
|
|
blocking read would park this thread, ``session.exited`` would never flip
|
|
and ``notify_on_complete`` never fire (``_reconcile_local_exit`` only runs
|
|
lazily from poll()/wait()). On POSIX we ``select()`` with a short interval
|
|
and stop draining shortly after the direct child exits, mirroring
|
|
``tools/environments/base.py::_wait_for_process``. Windows pipes lack
|
|
select(); the blocking path stays and the lazy reconcile is the safety net.
|
|
"""
|
|
first_chunk = True
|
|
# A multibyte UTF-8 char split across read1() chunks would become U+FFFD
|
|
# mojibake with stateless decoding; the incremental decoder holds the partial
|
|
# sequence until the continuation bytes arrive.
|
|
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
|
|
def _append_chunk(chunk: str):
|
|
nonlocal first_chunk
|
|
if first_chunk:
|
|
chunk = self._clean_shell_noise(chunk)
|
|
first_chunk = False
|
|
self._ingest_output(session, chunk)
|
|
|
|
try:
|
|
proc = session.process
|
|
if proc is None or proc.stdout is None:
|
|
return
|
|
stdout = proc.stdout
|
|
|
|
raw_read = getattr(getattr(stdout, "buffer", None), "read1", None)
|
|
|
|
# select() needs a real OS fd; mocked streams (tests, adapters) may lack
|
|
# fileno() and use the blocking loop instead.
|
|
fd = None
|
|
if raw_read is not None and not _IS_WINDOWS:
|
|
fileno = getattr(stdout, "fileno", None)
|
|
try:
|
|
candidate = fileno() if callable(fileno) else None
|
|
except Exception:
|
|
candidate = None
|
|
if isinstance(candidate, int) and candidate >= 0:
|
|
fd = candidate
|
|
|
|
if fd is not None:
|
|
import select as _select
|
|
|
|
idle_after_exit = 0
|
|
while True:
|
|
try:
|
|
ready, _, _ = _select.select([fd], [], [], 0.2)
|
|
except (ValueError, OSError):
|
|
break # fd already closed
|
|
if ready:
|
|
raw = raw_read(4096)
|
|
if not raw:
|
|
break # true EOF — all writers closed
|
|
chunk = decoder.decode(raw)
|
|
if chunk:
|
|
_append_chunk(chunk)
|
|
idle_after_exit = 0
|
|
elif proc.poll() is not None:
|
|
# Direct child gone and pipe idle ~200ms: a few more cycles
|
|
# for a buffered tail, then stop rather than wait forever on
|
|
# an orphaned grandchild's pipe.
|
|
idle_after_exit += 1
|
|
if idle_after_exit >= 3:
|
|
break
|
|
else:
|
|
while True:
|
|
if raw_read is not None:
|
|
raw = raw_read(4096)
|
|
if not raw:
|
|
break
|
|
chunk = decoder.decode(raw)
|
|
if not chunk:
|
|
continue # partial multibyte sequence — wait for more bytes
|
|
else:
|
|
# Mocked/alternate streams without a raw buffer: less "live".
|
|
chunk = stdout.read(4096)
|
|
if not chunk:
|
|
break
|
|
|
|
_append_chunk(chunk)
|
|
except Exception as e:
|
|
logger.debug("Process stdout reader ended: %s", e)
|
|
finally:
|
|
# Flush the decoder: a truncated multibyte sequence at EOF becomes one
|
|
# U+FFFD instead of vanishing.
|
|
try:
|
|
tail = decoder.decode(b"", final=True)
|
|
if tail:
|
|
_append_chunk(tail)
|
|
except Exception:
|
|
pass
|
|
# Always reap the child to prevent zombie processes.
|
|
try:
|
|
session.process.wait(timeout=5)
|
|
except Exception as e:
|
|
logger.debug("Process wait timed out or failed: %s", e)
|
|
self._finish_exited(session, session.process.returncode)
|
|
|
|
def _env_poller_loop(
|
|
self, session: ProcessSession, env: Any, log_path: str, pid_path: str, exit_path: str
|
|
):
|
|
"""Background thread: poll a sandbox log file for non-local backends."""
|
|
quoted_log_path = shlex.quote(log_path)
|
|
quoted_pid_path = shlex.quote(pid_path)
|
|
quoted_exit_path = shlex.quote(exit_path)
|
|
prev_output_len = 0 # track delta for watch pattern scanning
|
|
while not session.exited:
|
|
time.sleep(2) # Poll every 2 seconds
|
|
try:
|
|
# Read new output from the log file
|
|
result = env.execute(f"cat {quoted_log_path} 2>/dev/null", timeout=10)
|
|
new_output = result.get("output", "")
|
|
if new_output:
|
|
# Delta since the previous read feeds watch-pattern scanning.
|
|
delta = new_output[prev_output_len:] if len(new_output) > prev_output_len else ""
|
|
prev_output_len = len(new_output)
|
|
with session._lock:
|
|
session.output_buffer = new_output[-session.max_output_chars:]
|
|
if delta:
|
|
self._check_watch_patterns(session, delta)
|
|
self._emit_output(session, delta)
|
|
|
|
# Check if process is still running
|
|
check = env.execute(
|
|
f"kill -0 \"$(cat {quoted_pid_path} 2>/dev/null)\" 2>/dev/null; echo $?",
|
|
timeout=5,
|
|
)
|
|
check_output = check.get("output", "").strip()
|
|
if check_output and check_output.splitlines()[-1].strip() != "0":
|
|
# Process has exited -- get exit code captured by the wrapper shell.
|
|
exit_result = env.execute(
|
|
f"cat {quoted_exit_path} 2>/dev/null",
|
|
timeout=5,
|
|
)
|
|
exit_str = exit_result.get("output", "").strip()
|
|
try:
|
|
session.exit_code = int(exit_str.splitlines()[-1].strip())
|
|
except (ValueError, IndexError):
|
|
session.exit_code = -1
|
|
session.exited = True
|
|
if session.completion_reason != "killed":
|
|
session.completion_reason = "exited"
|
|
self._move_to_finished(session)
|
|
return
|
|
|
|
except Exception:
|
|
# Environment might be gone (sandbox reaped, etc.)
|
|
session.exited = True
|
|
session.exit_code = -1
|
|
session.completion_reason = "lost"
|
|
session.termination_source = "backend_lost"
|
|
self._move_to_finished(session)
|
|
return
|
|
|
|
def _pty_reader_loop(self, session: ProcessSession):
|
|
"""Background thread: read output from a PTY process."""
|
|
pty = session._pty
|
|
# Same split-multibyte handling as _reader_loop.
|
|
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
|
|
|
|
_append_text = lambda text: self._ingest_output(session, text) # noqa: E731
|
|
|
|
try:
|
|
while pty.isalive():
|
|
try:
|
|
chunk = pty.read(4096)
|
|
if chunk:
|
|
# ptyprocess returns bytes; pywinpty returns str
|
|
text = chunk if isinstance(chunk, str) else decoder.decode(chunk)
|
|
if text:
|
|
_append_text(text)
|
|
except Exception: # EOFError included
|
|
break
|
|
except Exception as e:
|
|
logger.debug("PTY stdout reader ended: %s", e)
|
|
|
|
# Flush any partial multibyte sequence held by the decoder.
|
|
try:
|
|
tail = decoder.decode(b"", final=True)
|
|
if tail:
|
|
_append_text(tail)
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
pty.wait()
|
|
except Exception as e:
|
|
logger.debug("PTY wait timed out or failed: %s", e)
|
|
self._finish_exited(session, pty.exitstatus if hasattr(pty, 'exitstatus') else -1)
|
|
|
|
def _ingest_output(self, session: ProcessSession, text: str) -> None:
|
|
"""Buffer a freshly-read chunk, then scan watch patterns and stream it live."""
|
|
session.append_output(text)
|
|
self._check_watch_patterns(session, text)
|
|
self._emit_output(session, text)
|
|
|
|
def _finish_exited(self, session: ProcessSession, exit_code) -> None:
|
|
"""Mark a reader-observed exit and move the session to finished.
|
|
|
|
A kill that raced the reader already recorded its own exit_code/reason;
|
|
don't overwrite it.
|
|
"""
|
|
session.exited = True
|
|
if session.completion_reason != "killed":
|
|
session.exit_code = exit_code
|
|
session.completion_reason = "exited"
|
|
self._move_to_finished(session)
|
|
|
|
def _move_to_finished(self, session: ProcessSession):
|
|
"""Move a session from running to finished.
|
|
|
|
Idempotent: kill_process() and the reader thread can both call this; only
|
|
the FIRST move enqueues the completion notification, so no duplicates.
|
|
"""
|
|
with self._lock:
|
|
was_running = self._running.pop(session.id, None) is not None
|
|
self._finished[session.id] = session
|
|
session._completion_event.set()
|
|
self._write_checkpoint()
|
|
|
|
if was_running and session.notify_on_complete:
|
|
from tools.ansi_strip import strip_ansi
|
|
output_tail = strip_ansi(session.output_buffer[-2000:]) if session.output_buffer else ""
|
|
notification = {
|
|
"type": "completion",
|
|
"session_id": session.id,
|
|
"session_key": session.session_key,
|
|
"task_id": session.task_id,
|
|
"owner_task_id": session.owner_task_id or session.task_id,
|
|
"command": session.command,
|
|
"exit_code": session.exit_code,
|
|
"completion_reason": session.completion_reason,
|
|
"termination_source": session.termination_source,
|
|
"output": output_tail,
|
|
# Stable producer identity across checkpoint recovery (unlike a
|
|
# consumer-observed completion timestamp).
|
|
"started_at": session.started_at,
|
|
}
|
|
_redact_process_result(notification)
|
|
self.completion_queue.put(notification)
|
|
|
|
# ----- Query Methods -----
|
|
|
|
def is_completion_consumed(self, session_id: str) -> bool:
|
|
"""Check if a completion notification was already consumed via wait/log."""
|
|
return session_id in self._completion_consumed
|
|
|
|
def is_session_waiting(self, session_id: str) -> bool:
|
|
"""Whether a goal loop (``hermes_cli.goals`` wait barrier) should stay parked
|
|
on this session: still running AND, if it has ``watch_patterns``, none has
|
|
matched yet (a long-lived watcher unblocks on its trigger, not on exit).
|
|
Unknown/exited/already-fired sessions return False so a stale barrier can
|
|
never wedge the loop."""
|
|
if not session_id:
|
|
return False
|
|
with self._lock:
|
|
session = self._running.get(session_id) or self._finished.get(session_id)
|
|
if session is None:
|
|
return False
|
|
try:
|
|
self._refresh_detached_session(session)
|
|
except Exception:
|
|
pass
|
|
if session.exited:
|
|
return False
|
|
return not (session.watch_patterns and not session._watch_disabled and session._watch_hits > 0)
|
|
|
|
def wait_for_pending_completions(
|
|
self,
|
|
task_id: Optional[str] = None,
|
|
*,
|
|
timeout: float | None = None,
|
|
poll_interval: float = 1.0,
|
|
) -> dict:
|
|
"""Bounded linger for ``notify_on_complete`` background processes at one-shot exit.
|
|
|
|
A one-shot CLI run (``hermes -q/-Q/-z``) exits when its turn ends; any
|
|
background process it spawned still holds a stdout pipe owned by the dying
|
|
parent and dies of SIGPIPE seconds later (Bot Mode handoff replies sent via
|
|
message_agent/bot_relay were the visible casualty). Only ``notify_on_complete``
|
|
processes carry a completion contract — servers/daemons/watchers aren't the
|
|
parent's to wait for.
|
|
|
|
``task_id=None`` waits on every tracked process (a one-shot process hosts one
|
|
agent). ``timeout=None`` reads ``terminal.oneshot_completion_wait_seconds``;
|
|
``<= 0`` disables. Each ``poll_interval`` pass re-reconciles child state so an
|
|
orphaned-pipe exit can't wedge the linger. Returns
|
|
``{"waited": [...], "completed": [...], "timed_out": [...]}`` of session ids.
|
|
"""
|
|
if timeout is None:
|
|
timeout = self._oneshot_completion_wait_seconds()
|
|
result: dict = {"waited": [], "completed": [], "timed_out": []}
|
|
with self._lock:
|
|
pending = [
|
|
s
|
|
for s in self._running.values()
|
|
if s.notify_on_complete
|
|
and not s.exited
|
|
and (task_id is None or s.task_id == task_id)
|
|
]
|
|
if not pending or timeout <= 0:
|
|
return result
|
|
result["waited"] = [s.id for s in pending]
|
|
logger.info(
|
|
"One-shot exit lingering (bounded %ss) for %d notify_on_complete "
|
|
"background process(es): %s",
|
|
timeout,
|
|
len(pending),
|
|
", ".join(s.id for s in pending),
|
|
)
|
|
deadline = time.monotonic() + max(float(timeout), 0.0)
|
|
interval = max(float(poll_interval), 0.05)
|
|
try:
|
|
from tools.interrupt import is_interrupted as _is_interrupted
|
|
except Exception:
|
|
def _is_interrupted() -> bool:
|
|
return False
|
|
interrupted = False
|
|
for session in pending:
|
|
try:
|
|
while not session.exited:
|
|
if interrupted or _is_interrupted():
|
|
interrupted = True
|
|
break
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
break
|
|
# Reconcile first so orphaned-pipe and detached exits fire the event.
|
|
try:
|
|
self._reconcile_local_exit(session)
|
|
self._refresh_detached_session(session)
|
|
except Exception:
|
|
pass
|
|
if session.exited:
|
|
break
|
|
session._completion_event.wait(min(remaining, interval))
|
|
except KeyboardInterrupt:
|
|
# Stop waiting, but never let the interrupt skip the caller's durable
|
|
# teardown (session flush, end_session) that follows.
|
|
interrupted = True
|
|
if session.exited:
|
|
result["completed"].append(session.id)
|
|
else:
|
|
result["timed_out"].append(session.id)
|
|
if result["timed_out"]:
|
|
logger.warning(
|
|
"One-shot exit linger timed out after %ss with %d background "
|
|
"process(es) still running: %s — they may be killed when this "
|
|
"process exits.",
|
|
timeout,
|
|
len(result["timed_out"]),
|
|
", ".join(result["timed_out"]),
|
|
)
|
|
return result
|
|
|
|
@staticmethod
|
|
def _oneshot_completion_wait_seconds() -> float:
|
|
"""Bounded linger (s) for one-shot exits with pending notify_on_complete
|
|
processes: ``terminal.oneshot_completion_wait_seconds`` (0 disables), 600
|
|
if config is unreadable."""
|
|
try:
|
|
return max(float(ProcessRegistry._config_value("terminal", "oneshot_completion_wait_seconds", 600.0)), 0.0)
|
|
except Exception:
|
|
return 600.0
|
|
|
|
def _drain_should_skip(
|
|
self, session_id: str, *, skip_poll_observed: bool = True
|
|
) -> bool:
|
|
"""Skip a completion the CLI agent already has this turn — consumed via
|
|
wait/log or observed inline via poll(). Gateway/tui watchers check only
|
|
``is_completion_consumed`` so a read-only poll never suppresses their
|
|
autonomous delivery turn."""
|
|
return session_id in self._completion_consumed or (
|
|
skip_poll_observed and session_id in self._poll_observed
|
|
)
|
|
|
|
@staticmethod
|
|
def _surface_child_process_notifications() -> bool:
|
|
"""Whether subagent-owned process notifications surface in the parent
|
|
(``delegation.surface_child_process_notifications``; suppress on any config
|
|
error — never crash the drain loop)."""
|
|
try:
|
|
return bool(ProcessRegistry._config_value("delegation", "surface_child_process_notifications", False))
|
|
except Exception:
|
|
return False
|
|
|
|
def drain_notifications(
|
|
self,
|
|
session_key: str = "",
|
|
owns_event=None,
|
|
*,
|
|
skip_poll_observed: bool = True,
|
|
) -> "list[tuple[dict, str]]":
|
|
"""Pop all pending events and return ``(raw_event, formatted_text)`` pairs.
|
|
|
|
Skips completions per ``_drain_should_skip``; gateway/TUI callers pass
|
|
``skip_poll_observed=False``.
|
|
|
|
Routing: async-delegation events always need ownership proof; ordinary
|
|
events need it once they carry ``session_key`` or ``origin_ui_session_id``.
|
|
``owns_event(evt)`` (strongest; the TUI passes a compression-chain-aware
|
|
check so a post-compression session still claims its pre-compression
|
|
dispatches) consumes ONLY on True; ``session_key`` uses plain equality.
|
|
Non-owned routed events are re-queued for their owner. With no filter every
|
|
event is consumed (legacy single-session), except restored delegation
|
|
payloads, which stay fail-closed.
|
|
"""
|
|
results: "list[tuple[dict, str]]" = []
|
|
requeue: "list[dict]" = []
|
|
# delegation.surface_child_process_notifications, read at most once per drain
|
|
# and only when an sa- event shows up.
|
|
surface_child: "bool | None" = None
|
|
while not self.completion_queue.empty():
|
|
try:
|
|
evt = self.completion_queue.get_nowait()
|
|
except Exception:
|
|
break
|
|
is_async_delegation = evt.get("type") == "async_delegation"
|
|
evt_session_key = str(evt.get("session_key") or "")
|
|
evt_origin_sid = str(evt.get("origin_ui_session_id") or "")
|
|
requires_positive_proof = is_async_delegation or bool(
|
|
evt_session_key or evt_origin_sid
|
|
)
|
|
if owns_event is not None and requires_positive_proof:
|
|
try:
|
|
owned = bool(owns_event(evt))
|
|
except Exception:
|
|
owned = False # fail closed — never leak on a broken check
|
|
if not owned:
|
|
requeue.append(evt)
|
|
continue
|
|
elif session_key and requires_positive_proof:
|
|
if evt_session_key != session_key:
|
|
requeue.append(evt)
|
|
continue
|
|
elif is_async_delegation and evt.get("restored"):
|
|
# Restored payloads from a previous process: an unfiltered drain
|
|
# cannot prove ownership, so leave them for the owner.
|
|
requeue.append(evt)
|
|
continue
|
|
# Routing happened first so a foreign session cannot drop the owner's
|
|
# event via its own consumed/observed state.
|
|
_evt_sid = evt.get("session_id", "")
|
|
if evt.get("type") == "completion" and self._drain_should_skip(
|
|
_evt_sid, skip_poll_observed=skip_poll_observed
|
|
):
|
|
continue
|
|
|
|
# Subagent-owned process notifications are suppressed by default — the
|
|
# child's delegation result is the deliverable. Judge ownership on
|
|
# owner_task_id (RAW spawning id; task_id is the container key, collapsed
|
|
# by _resolve_container_task_id). Dropped, NOT requeued: children never
|
|
# drain, so a requeue would pin the event forever. 'async_delegation'
|
|
# is the result itself and is NEVER suppressed.
|
|
_evt_task_id = str(
|
|
evt.get("owner_task_id") or evt.get("task_id") or ""
|
|
)
|
|
if not is_async_delegation and _evt_task_id.startswith("sa-"):
|
|
if surface_child is None:
|
|
surface_child = self._surface_child_process_notifications()
|
|
if not surface_child:
|
|
logger.debug(
|
|
"Suppressed subagent-owned process notification "
|
|
"(delegation.surface_child_process_notifications=false): "
|
|
"type=%s session_id=%s task_id=%s",
|
|
evt.get("type", "completion"),
|
|
_evt_sid,
|
|
_evt_task_id,
|
|
)
|
|
continue
|
|
|
|
text = format_process_notification(evt)
|
|
if text:
|
|
results.append((evt, text))
|
|
for evt in requeue:
|
|
self.completion_queue.put(evt)
|
|
return results
|
|
|
|
# Minimum characters of the random suffix required for prefix resolution.
|
|
# Short prefixes ("p", "pr", "proc_1") are too collision-prone to act on.
|
|
_MIN_PREFIX_CHARS = 4
|
|
|
|
def get(self, session_id: str) -> Optional[ProcessSession]:
|
|
"""Get a session by full ID or unique prefix (``proc_4dae`` / bare ``4dae``,
|
|
like git short hashes). Ambiguous or too-short prefixes resolve to None,
|
|
never to an arbitrary pick."""
|
|
with self._lock:
|
|
session = self._running.get(session_id) or self._finished.get(session_id)
|
|
if session is None:
|
|
session = self._resolve_prefix(session_id)
|
|
return self._refresh_detached_session(session)
|
|
|
|
def _resolve_prefix(self, session_id: str) -> Optional[ProcessSession]:
|
|
"""Resolve a unique session-ID prefix (prefix-only, unique hit; a bare hex
|
|
tail is normalized to ``proc_<tail>``). :meth:`get` tries exact first."""
|
|
if not session_id or not isinstance(session_id, str):
|
|
return None
|
|
query = session_id.strip()
|
|
if not query:
|
|
return None
|
|
# Allow the bare suffix form: "4dae56" -> "proc_4dae56".
|
|
if not query.startswith("proc_"):
|
|
query = f"proc_{query}"
|
|
suffix = query[len("proc_"):]
|
|
if len(suffix) < self._MIN_PREFIX_CHARS:
|
|
return None
|
|
with self._lock:
|
|
matches = [
|
|
s
|
|
for store in (self._running, self._finished)
|
|
for sid, s in store.items()
|
|
if sid.startswith(query)
|
|
]
|
|
if len(matches) == 1:
|
|
return matches[0]
|
|
return None
|
|
|
|
def _reconcile_local_exit(self, session: "ProcessSession") -> None:
|
|
"""Reconcile ``session.exited`` against the real child state.
|
|
|
|
The reader flips ``exited`` only at EOF; when the direct child has exited but
|
|
a descendant (e.g. a daemon from ``hermes update``) holds the pipe open, poll()
|
|
would report "running" forever. If ``Popen.poll()`` has an exit code, drain
|
|
readable bytes non-blocking and flip ``exited``; the stuck daemon reader thread
|
|
is reaped with the process. No-op for env/PTY, exited and detached sessions.
|
|
"""
|
|
if session is None or session.exited:
|
|
return
|
|
proc = getattr(session, "process", None)
|
|
if proc is None:
|
|
return
|
|
try:
|
|
rc = proc.poll()
|
|
except Exception:
|
|
return
|
|
if rc is None:
|
|
return # Direct child still running — reader block is legitimate.
|
|
|
|
# Best-effort non-blocking drain of whatever the reader hasn't consumed.
|
|
drained = ""
|
|
stdout = getattr(proc, "stdout", None)
|
|
if stdout is not None and not _IS_WINDOWS:
|
|
try:
|
|
import fcntl
|
|
fd = stdout.fileno()
|
|
flags = fcntl.fcntl(fd, fcntl.F_GETFL)
|
|
fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
|
|
try:
|
|
chunk = stdout.read()
|
|
if chunk:
|
|
drained = chunk if isinstance(chunk, str) else chunk.decode("utf-8", errors="replace")
|
|
except (BlockingIOError, OSError, ValueError):
|
|
pass
|
|
finally:
|
|
try:
|
|
fcntl.fcntl(fd, fcntl.F_SETFL, flags)
|
|
except Exception:
|
|
pass
|
|
except Exception as e:
|
|
logger.debug("Non-blocking drain failed for %s: %s", session.id, e)
|
|
|
|
with session._lock:
|
|
if drained:
|
|
session.output_buffer += drained
|
|
if len(session.output_buffer) > session.max_output_chars:
|
|
session.output_buffer = session.output_buffer[-session.max_output_chars:]
|
|
session.exited = True
|
|
if session.completion_reason != "killed":
|
|
session.exit_code = rc
|
|
session.completion_reason = "exited"
|
|
logger.info(
|
|
"Reconciled session %s: direct child exited with code %s but reader "
|
|
"was still blocked (orphaned pipe). Flipped to exited.",
|
|
session.id, rc,
|
|
)
|
|
self._move_to_finished(session)
|
|
|
|
def poll(self, session_id: str) -> dict:
|
|
"""Check status and get new output for a background process."""
|
|
from tools.ansi_strip import strip_ansi
|
|
|
|
session = self.get(session_id)
|
|
if session is None:
|
|
return {"status": "not_found", "error": f"No process with ID {session_id}"}
|
|
|
|
self._reconcile_local_exit(session) # orphaned-pipe reader guard
|
|
|
|
with session._lock:
|
|
output_preview = strip_ansi(session.output_buffer[-1000:]) if session.output_buffer else ""
|
|
|
|
result = {
|
|
"session_id": session.id,
|
|
"command": session.command,
|
|
"status": "exited" if session.exited else "running",
|
|
"pid": session.pid,
|
|
"uptime_seconds": int(time.time() - session.started_at),
|
|
"output_preview": output_preview,
|
|
}
|
|
if session.exited:
|
|
result["exit_code"] = session.exit_code
|
|
result["completion_reason"] = session.completion_reason
|
|
result["termination_source"] = session.termination_source
|
|
# Read-only: record in _poll_observed (CLI inline dedup) but NOT in
|
|
# _completion_consumed, or a status check would suppress the watcher's
|
|
# autonomous delivery turn. See __init__.
|
|
self._poll_observed.add(session_id)
|
|
if session.detached:
|
|
result["detached"] = True
|
|
result["note"] = "Process recovered after restart -- output history unavailable"
|
|
return result
|
|
|
|
def read_log(self, session_id: str, offset: int | None = None, limit: int = 200) -> dict:
|
|
"""Read the full output log with optional pagination by lines."""
|
|
from tools.ansi_strip import strip_ansi
|
|
|
|
session = self.get(session_id)
|
|
if session is None:
|
|
return {"status": "not_found", "error": f"No process with ID {session_id}"}
|
|
|
|
with session._lock:
|
|
full_output = strip_ansi(session.output_buffer)
|
|
|
|
lines = full_output.splitlines()
|
|
total_lines = len(lines)
|
|
|
|
# offset=None -> last N lines; an explicit offset=0 means the HEAD (don't
|
|
# conflate the two via falsiness).
|
|
if offset is None and limit > 0:
|
|
selected = lines[-limit:]
|
|
observed_completion_output = bool(selected) or total_lines == 0
|
|
else:
|
|
offset = offset or 0
|
|
selected = lines[offset:offset + limit]
|
|
stop = slice(offset, offset + limit).indices(total_lines)[1]
|
|
observed_completion_output = (
|
|
total_lines == 0 or (bool(selected) and stop == total_lines)
|
|
)
|
|
|
|
result = {
|
|
"session_id": session.id,
|
|
"command": session.command,
|
|
"status": "exited" if session.exited else "running",
|
|
"output": "\n".join(selected),
|
|
"total_lines": total_lines,
|
|
"showing": f"{len(selected)} lines",
|
|
}
|
|
if session.exited and observed_completion_output:
|
|
self._completion_consumed.add(session_id)
|
|
return result
|
|
|
|
def wait(self, session_id: str, timeout: int = None) -> dict:
|
|
"""Block until the process exits, the timeout elapses, or the user interrupts.
|
|
|
|
``timeout`` defaults to (and is clamped by) TERMINAL_TIMEOUT. Returns a dict
|
|
with status exited|timeout|interrupted|not_found|error and an output snapshot.
|
|
"""
|
|
from tools.ansi_strip import strip_ansi
|
|
from tools.interrupt import is_interrupted as _is_interrupted
|
|
|
|
try:
|
|
default_timeout = int(os.getenv("TERMINAL_TIMEOUT", "180"))
|
|
except (ValueError, TypeError):
|
|
default_timeout = 180
|
|
max_timeout = default_timeout
|
|
requested_timeout = timeout
|
|
timeout_note = None
|
|
|
|
# The schema says minimum=1 but not every caller enforces it; timeout=0 is
|
|
# falsy and would silently fall through to the default wait.
|
|
if requested_timeout is not None and requested_timeout <= 0:
|
|
return {
|
|
"status": "error",
|
|
"error": f"timeout must be positive (got {requested_timeout})",
|
|
}
|
|
|
|
if requested_timeout and requested_timeout > max_timeout:
|
|
effective_timeout = max_timeout
|
|
timeout_note = (
|
|
f"Requested wait of {requested_timeout}s was clamped "
|
|
f"to configured limit of {max_timeout}s"
|
|
)
|
|
else:
|
|
effective_timeout = requested_timeout or max_timeout
|
|
|
|
session = self.get(session_id)
|
|
if session is None:
|
|
return {"status": "not_found", "error": f"No process with ID {session_id}"}
|
|
|
|
deadline = time.monotonic() + effective_timeout
|
|
|
|
while time.monotonic() < deadline:
|
|
session = self._refresh_detached_session(session)
|
|
if session is None:
|
|
return {"status": "not_found", "error": f"No process with ID {session_id}"}
|
|
self._reconcile_local_exit(session) # orphaned-pipe reader guard
|
|
if session.exited:
|
|
self._completion_consumed.add(session_id)
|
|
result = self._exit_snapshot(session, "exited")
|
|
elif _is_interrupted():
|
|
result = {
|
|
"status": "interrupted",
|
|
"command": session.command,
|
|
"output": strip_ansi(session.output_buffer[-1000:]),
|
|
"note": "User sent a new message -- wait interrupted",
|
|
}
|
|
else:
|
|
result = None
|
|
if result is not None:
|
|
if timeout_note:
|
|
result["timeout_note"] = timeout_note
|
|
return result
|
|
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
break
|
|
session._completion_event.wait(timeout=min(1.0, remaining))
|
|
|
|
result = {
|
|
"status": "timeout",
|
|
"command": session.command,
|
|
"output": strip_ansi(session.output_buffer[-1000:]),
|
|
# Not a failure — models re-issued identical waits after misreading
|
|
# this result as an error.
|
|
"process_running": True,
|
|
}
|
|
uptime = time.time() - session.started_at if session.started_at else None
|
|
base_note = (
|
|
f"Wait window of {effective_timeout}s elapsed — the process is "
|
|
"still running. This is not an error."
|
|
)
|
|
if uptime is not None:
|
|
base_note += f" Uptime: {int(uptime)}s."
|
|
if session.notify_on_complete:
|
|
base_note += (
|
|
" notify_on_complete is set: you will be notified on exit — "
|
|
"do more work instead of waiting again."
|
|
)
|
|
else:
|
|
base_note += (
|
|
" Poll again later or use terminal(background=true, "
|
|
"notify_on_complete=true) next time for automatic notification."
|
|
)
|
|
if timeout_note:
|
|
result["timeout_note"] = f"{timeout_note}. {base_note}"
|
|
else:
|
|
result["timeout_note"] = base_note
|
|
return result
|
|
|
|
@staticmethod
|
|
def _exit_snapshot(session: ProcessSession, status: str) -> dict:
|
|
"""Result dict for an exited session: exit metadata + last 2000 chars of output."""
|
|
from tools.ansi_strip import strip_ansi
|
|
|
|
return {
|
|
"status": status,
|
|
"command": session.command,
|
|
"exit_code": session.exit_code,
|
|
"completion_reason": session.completion_reason,
|
|
"termination_source": session.termination_source,
|
|
"output": strip_ansi(session.output_buffer[-2000:]),
|
|
}
|
|
|
|
def kill_process(
|
|
self,
|
|
session_id: str,
|
|
*,
|
|
source: str = "process.kill",
|
|
consume_output: bool = True,
|
|
) -> dict:
|
|
"""Kill a background process and return its output snapshot.
|
|
|
|
``consume_output`` is true for explicit tool/RPC kills (the caller sees the
|
|
output). Bulk cleanup passes false so it doesn't suppress an autonomous
|
|
completion notification — except abandoned-turn reaping
|
|
(``kill_started_since``), which passes true so a killed abandoned process
|
|
can't enqueue a follow-up reviving work the timeout stopped.
|
|
"""
|
|
from tools.ansi_strip import strip_ansi
|
|
|
|
session = self.get(session_id)
|
|
if session is None:
|
|
return {"status": "not_found", "error": f"No process with ID {session_id}"}
|
|
|
|
if session.exited:
|
|
# A double-forked descendant may still be alive in the systemd scope even
|
|
# though the main process exited — stop the scope to reap survivors.
|
|
if session.systemd_unit:
|
|
_stop_systemd_unit(session.systemd_unit)
|
|
with session._lock:
|
|
result = self._exit_snapshot(session, "already_exited")
|
|
# Only suppress the autonomous turn after its output is present in
|
|
# the explicit kill result, matching wait/log consumption.
|
|
if consume_output:
|
|
self._completion_consumed.add(session_id)
|
|
return result
|
|
|
|
try:
|
|
early = self._signal_kill(session, session_id, consume_output)
|
|
if early is not None:
|
|
return early
|
|
|
|
# Additive to the PID kill: stopping the scope reaps double-forked
|
|
# descendants reparented inside the cgroup.
|
|
if session.systemd_unit:
|
|
_stop_systemd_unit(session.systemd_unit)
|
|
# Capture output, mark consumed, THEN expose ``exited`` to watcher tasks —
|
|
# closes the delayed-notification race without losing the transcript.
|
|
with session._lock:
|
|
output = strip_ansi(session.output_buffer[-2000:])
|
|
if consume_output:
|
|
self._completion_consumed.add(session_id)
|
|
session.exited = True
|
|
session.exit_code = -15 # SIGTERM
|
|
session.completion_reason = "killed"
|
|
session.termination_source = source
|
|
self._move_to_finished(session)
|
|
self._write_checkpoint()
|
|
return {
|
|
"status": "killed",
|
|
"session_id": session.id,
|
|
"completion_reason": session.completion_reason,
|
|
"termination_source": session.termination_source,
|
|
"output": output,
|
|
}
|
|
except Exception as e:
|
|
return {"status": "error", "error": str(e)}
|
|
|
|
def _signal_kill(self, session: ProcessSession, session_id: str, consume_output: bool) -> Optional[dict]:
|
|
"""Deliver the kill via PTY, local Popen tree, sandbox exec or recovered host
|
|
PID. Returns a final result dict when the kill cannot proceed (recycled/dead
|
|
recovered PID, or no runtime handle), else None."""
|
|
from tools.ansi_strip import strip_ansi
|
|
|
|
if session._pty:
|
|
try:
|
|
session._pty.terminate(force=True)
|
|
except Exception:
|
|
if session.pid:
|
|
os.kill(session.pid, signal.SIGTERM)
|
|
elif session.process:
|
|
# Tree kill: on Windows Popen.terminate() only kills the shell wrapper and
|
|
# leaves Git Bash descendants behind.
|
|
self._terminate_host_pid(session.process.pid, session.host_start_time)
|
|
elif session.env_ref and session.pid:
|
|
session.env_ref.execute(f"kill {session.pid} 2>/dev/null", timeout=5)
|
|
elif session.detached and session.pid_scope == "host" and session.pid:
|
|
# Identity check, not bare liveness: a gone/recycled PID means our
|
|
# process exited — never tree-kill the stranger. Still stop an owned
|
|
# scope: a daemonized descendant may survive the wrapper PID.
|
|
if not self._host_pid_is_ours(session.pid, session.host_start_time):
|
|
if session.systemd_unit:
|
|
_stop_systemd_unit(session.systemd_unit)
|
|
with session._lock:
|
|
session.exited = True
|
|
session.exit_code = None
|
|
output = strip_ansi(session.output_buffer[-2000:])
|
|
if consume_output:
|
|
self._completion_consumed.add(session_id)
|
|
self._move_to_finished(session)
|
|
return {"status": "already_exited", "exit_code": session.exit_code, "output": output}
|
|
self._terminate_host_pid(session.pid, session.host_start_time)
|
|
else:
|
|
return {
|
|
"status": "error",
|
|
"error": (
|
|
"Recovered process cannot be killed after restart because "
|
|
"its original runtime handle is no longer available"
|
|
),
|
|
}
|
|
return None
|
|
|
|
def _live_session(self, session_id: str):
|
|
"""``(session, None)`` for a running session, else ``(None, error_result)``."""
|
|
session = self.get(session_id)
|
|
if session is None:
|
|
return None, {"status": "not_found", "error": f"No process with ID {session_id}"}
|
|
if session.exited:
|
|
return None, {"status": "already_exited", "error": "Process has already finished"}
|
|
return session, None
|
|
|
|
def write_stdin(self, session_id: str, data: str) -> dict:
|
|
"""Send raw data to a running process's stdin (no newline appended)."""
|
|
session, err = self._live_session(session_id)
|
|
if err:
|
|
return err
|
|
|
|
# PTY mode -- write through pty handle.
|
|
if session._pty:
|
|
try:
|
|
# pywinpty expects str on Windows; ptyprocess expects bytes on POSIX.
|
|
if _IS_WINDOWS:
|
|
pty_data = data.decode("utf-8") if isinstance(data, bytes) else str(data)
|
|
else:
|
|
# surrogateescape: a PTY is a byte stream — round-trip the
|
|
# original bytes instead of crashing on surrogate content.
|
|
pty_data = data.encode("utf-8", "surrogateescape") if isinstance(data, str) else data
|
|
session._pty.write(pty_data)
|
|
return {"status": "ok", "bytes_written": len(data)}
|
|
except Exception as e:
|
|
return {"status": "error", "error": str(e)}
|
|
|
|
# Popen mode -- write through stdin pipe
|
|
if not session.process or not session.process.stdin:
|
|
return {"status": "error", "error": "Process stdin not available (non-local backend or stdin closed)"}
|
|
try:
|
|
session.process.stdin.write(data)
|
|
session.process.stdin.flush()
|
|
return {"status": "ok", "bytes_written": len(data)}
|
|
except Exception as e:
|
|
return {"status": "error", "error": str(e)}
|
|
|
|
def submit_stdin(self, session_id: str, data: str = "") -> dict:
|
|
"""Send data + newline to stdin (like pressing Enter).
|
|
|
|
On a Windows PTY, Enter is a carriage return: ConPTY treats ``\\r`` as
|
|
end-of-line and a bare ``\\n`` through pywinpty is NOT a line terminator —
|
|
the child's blocking line read (``readline()``, Go ``bufio.Scanner`` in
|
|
``gh auth login``) never returns and the process hangs looking healthy.
|
|
``\\r\\n`` gives it both; POSIX PTYs and pipes keep ``\\n``.
|
|
"""
|
|
session = self.get(session_id)
|
|
is_windows_pty = bool(_IS_WINDOWS and session is not None and session._pty)
|
|
return self.write_stdin(session_id, data + ("\r\n" if is_windows_pty else "\n"))
|
|
|
|
def request_close_terminal(self, session_id: str) -> dict:
|
|
"""Ask the desktop GUI to close this process's read-only terminal tab.
|
|
|
|
Does NOT kill the process — output keeps buffering and the tab can be
|
|
reopened from the status stack. Errors when no UI close sink is wired."""
|
|
sink = self.on_close
|
|
if sink is None:
|
|
return {
|
|
"status": "error",
|
|
"error": "close_terminal is only available in the Hermes desktop app.",
|
|
}
|
|
# The session may already be finished (or pruned) — the tab can still
|
|
# linger and be closed, so a missing session is not an error here.
|
|
session = self.get(session_id)
|
|
try:
|
|
sink(session, session_id)
|
|
except Exception as e:
|
|
return {"status": "error", "error": str(e)}
|
|
return {
|
|
"status": "ok",
|
|
"closed": session_id,
|
|
"note": (
|
|
"Closed the read-only terminal tab. The process was not killed; "
|
|
"its output remains available and the user can reopen the tab "
|
|
"from the status stack."
|
|
),
|
|
}
|
|
|
|
def close_stdin(self, session_id: str) -> dict:
|
|
"""Close a running process's stdin / send EOF without killing the process."""
|
|
session, err = self._live_session(session_id)
|
|
if err:
|
|
return err
|
|
|
|
if session._pty:
|
|
try:
|
|
session._pty.sendeof()
|
|
return {"status": "ok", "message": "EOF sent"}
|
|
except Exception as e:
|
|
return {"status": "error", "error": str(e)}
|
|
|
|
if not session.process or not session.process.stdin:
|
|
return {"status": "error", "error": "Process stdin not available (non-local backend or stdin closed)"}
|
|
try:
|
|
session.process.stdin.close()
|
|
return {"status": "ok", "message": "stdin closed"}
|
|
except Exception as e:
|
|
return {"status": "error", "error": str(e)}
|
|
|
|
def count_running(self) -> int:
|
|
"""O(1) count of running processes for status-bar polling; CPython dict
|
|
``len()`` is atomic so no lock is needed."""
|
|
try:
|
|
return len(self._running)
|
|
except Exception:
|
|
return 0
|
|
|
|
def list_sessions(self, task_id: str = None, session_key: str = None) -> list:
|
|
"""List running and recently-finished processes for ``task_id`` and/or
|
|
``session_key``. Cross-task entries that share the gateway session (a
|
|
forgotten preview server blocking session reset) are flagged
|
|
``"session_scoped": true``."""
|
|
with self._lock:
|
|
all_sessions = list(self._running.values()) + list(self._finished.values())
|
|
|
|
all_sessions = [self._refresh_detached_session(s) for s in all_sessions]
|
|
|
|
if task_id or session_key:
|
|
all_sessions = [
|
|
s for s in all_sessions
|
|
if (task_id and s.task_id == task_id)
|
|
or (session_key and s.session_key == session_key)
|
|
]
|
|
|
|
result = []
|
|
for s in all_sessions:
|
|
entry = {
|
|
"session_id": s.id,
|
|
"command": s.command[:200],
|
|
"cwd": s.cwd,
|
|
"pid": s.pid,
|
|
"started_at": time.strftime("%Y-%m-%dT%H:%M:%S", time.localtime(s.started_at)),
|
|
"uptime_seconds": int(time.time() - s.started_at),
|
|
"status": "exited" if s.exited else "running",
|
|
"output_preview": s.output_buffer[-200:] if s.output_buffer else "",
|
|
}
|
|
if task_id and session_key and s.task_id != task_id and s.session_key == session_key:
|
|
entry["session_scoped"] = True
|
|
# Trigger metadata for goal-loop judges (a watcher may never exit).
|
|
if s.watch_patterns and not s._watch_disabled:
|
|
entry["watch_patterns"] = list(s.watch_patterns)
|
|
entry["watch_hit"] = s._watch_hits > 0
|
|
if s.notify_on_complete:
|
|
entry["notify_on_complete"] = True
|
|
if s.exited:
|
|
entry["exit_code"] = s.exit_code
|
|
if s.detached:
|
|
entry["detached"] = True
|
|
result.append(entry)
|
|
return result
|
|
|
|
# ----- Session/Task Queries (for gateway integration) -----
|
|
|
|
def _any_running(self, predicate) -> bool:
|
|
"""True if any still-running session satisfies *predicate*, after refreshing
|
|
detached sessions so a finished-but-unreaped process reads as inactive."""
|
|
with self._lock:
|
|
sessions = list(self._running.values())
|
|
for session in sessions:
|
|
self._refresh_detached_session(session)
|
|
with self._lock:
|
|
return any(not s.exited and predicate(s) for s in self._running.values())
|
|
|
|
def has_active_processes(self, task_id: str) -> bool:
|
|
"""Check if there are active (running) processes for a task_id."""
|
|
return self._any_running(lambda s: s.task_id == task_id)
|
|
|
|
def has_active_for_session(
|
|
self, session_key: str, max_active_age: Optional[float] = None,
|
|
) -> bool:
|
|
"""Active processes for a gateway session key. Processes older than
|
|
``max_active_age`` seconds are ignored as stale so a forgotten
|
|
``http.server`` can't freeze session idle/daily reset forever; ``None``
|
|
keeps legacy behaviour (any running process blocks)."""
|
|
now = time.time()
|
|
return self._any_running(
|
|
lambda s: s.session_key == session_key
|
|
and (max_active_age is None or (now - s.started_at) < max_active_age)
|
|
)
|
|
|
|
def has_any_active(self) -> bool:
|
|
"""Whether ANY background process is running — scale-to-zero must not
|
|
suspend a gateway with live background work or the process is lost."""
|
|
return self._any_running(lambda s: True)
|
|
|
|
def snapshot_running_ids(self, task_id: str) -> frozenset[str]:
|
|
"""Running IDs owned by ``task_id`` — a turn-boundary marker: on timeout
|
|
only processes absent from the starting snapshot belong to the abandoned
|
|
turn; older ones intentionally span turns and must survive."""
|
|
with self._lock:
|
|
return frozenset(
|
|
s.id
|
|
for s in self._running.values()
|
|
if s.task_id == task_id and not s.exited
|
|
)
|
|
|
|
def kill_started_since(
|
|
self,
|
|
task_id: str,
|
|
baseline_ids,
|
|
*,
|
|
source: str,
|
|
) -> int:
|
|
"""Kill ``task_id`` processes created after ``baseline_ids``. Output is
|
|
consumed so an abandoned turn can't enqueue a follow-up reviving work the
|
|
timeout deliberately stopped."""
|
|
return self.kill_all(
|
|
task_id,
|
|
exclude_ids=frozenset(baseline_ids or ()),
|
|
source=source,
|
|
consume_output=True,
|
|
)
|
|
|
|
def kill_all(
|
|
self,
|
|
task_id: Optional[str] = None,
|
|
*,
|
|
exclude_ids: frozenset = frozenset(),
|
|
source: str = "kill_all",
|
|
consume_output: bool = False,
|
|
) -> int:
|
|
"""Kill all running processes, optionally filtered by task_id. Returns count killed."""
|
|
with self._lock:
|
|
targets = [
|
|
s for s in self._running.values()
|
|
if (task_id is None or s.task_id == task_id)
|
|
and s.id not in exclude_ids
|
|
and not s.exited
|
|
]
|
|
|
|
killed = 0
|
|
for session in targets:
|
|
result = self.kill_process(
|
|
session.id,
|
|
source=source,
|
|
consume_output=consume_output,
|
|
)
|
|
if result.get("status") in {"killed", "already_exited"}:
|
|
killed += 1
|
|
return killed
|
|
|
|
# ----- Cleanup / Pruning -----
|
|
|
|
def _prune_if_needed(self):
|
|
"""Remove oldest finished sessions if over MAX_PROCESSES. Must hold _lock."""
|
|
# First prune expired finished sessions
|
|
now = time.time()
|
|
expired = [
|
|
sid for sid, s in self._finished.items()
|
|
if (now - s.started_at) > FINISHED_TTL_SECONDS
|
|
]
|
|
if len(self._running) + len(self._finished) - len(expired) >= MAX_PROCESSES:
|
|
# Still over the limit: also drop the oldest surviving finished session.
|
|
survivors = [sid for sid in self._finished if sid not in expired]
|
|
if survivors:
|
|
expired.append(min(survivors, key=lambda sid: self._finished[sid].started_at))
|
|
for sid in expired:
|
|
del self._finished[sid]
|
|
# Belt-and-suspenders against module-lifetime growth: forget consumed /
|
|
# poll-observed marks for any session no longer tracked at all.
|
|
tracked = self._running.keys() | self._finished.keys()
|
|
self._completion_consumed &= tracked
|
|
self._poll_observed &= tracked
|
|
|
|
# ----- Checkpoint (crash recovery) -----
|
|
|
|
def _write_checkpoint(
|
|
self,
|
|
extra_entries: Optional[List[Dict[str, Any]]] = None,
|
|
):
|
|
"""Write running process metadata to checkpoint file atomically."""
|
|
try:
|
|
with self._lock:
|
|
entries = []
|
|
for s in self._running.values():
|
|
if not s.exited:
|
|
# Backfill the start time so recovery can detect PID recycling
|
|
# even for sessions spawned before this field existed.
|
|
if s.host_start_time is None and s.pid_scope == "host" and s.pid:
|
|
s.host_start_time = self._safe_host_start_time(s.pid)
|
|
entry = {"session_id": s.id, **{f: getattr(s, f) for f in _CHECKPOINT_FIELDS}}
|
|
# Redact inline credentials before persisting: the file lives
|
|
# at ~/.hermes/processes.json. Recovery uses command only for
|
|
# display (adoption re-validates the PID, never re-runs it),
|
|
# so masking is lossless.
|
|
entry["command"] = redact_sensitive_text(s.command, code_file=True)
|
|
entry["owner_task_id"] = s.owner_task_id or s.task_id
|
|
entries.append(entry)
|
|
if extra_entries:
|
|
tracked_ids = {item.get("session_id") for item in entries}
|
|
entries.extend(
|
|
item
|
|
for item in extra_entries
|
|
if item.get("session_id") not in tracked_ids
|
|
)
|
|
|
|
# Atomic write to avoid corruption on crash
|
|
from utils import atomic_json_write
|
|
atomic_json_write(CHECKPOINT_PATH, entries)
|
|
except Exception as e:
|
|
logger.debug("Failed to write checkpoint file: %s", e, exc_info=True)
|
|
|
|
def recover_from_checkpoint(self) -> int:
|
|
"""
|
|
On gateway startup, probe PIDs from checkpoint file.
|
|
|
|
Returns the number of processes recovered as detached.
|
|
"""
|
|
if not CHECKPOINT_PATH.exists():
|
|
return 0
|
|
|
|
try:
|
|
entries = json.loads(CHECKPOINT_PATH.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
return 0
|
|
|
|
recovered = 0
|
|
unresolved_scope_entries: List[Dict[str, Any]] = []
|
|
for entry in entries:
|
|
pid = entry.get("pid")
|
|
if not pid:
|
|
continue
|
|
|
|
pid_scope = entry.get("pid_scope", "host")
|
|
if pid_scope != "host":
|
|
# In-sandbox PIDs mean nothing once the environment handle is gone.
|
|
logger.info(
|
|
"Skipping recovery for non-host process: %s (pid=%s, scope=%s)",
|
|
entry.get("command", "unknown")[:60],
|
|
pid,
|
|
pid_scope,
|
|
)
|
|
continue
|
|
|
|
# Alive AND the same process: across a restart the kernel may have
|
|
# recycled the PID onto a stranger, and adopting it would let a later
|
|
# kill tree-kill e.g. a browser.
|
|
recorded_start = entry.get("host_start_time")
|
|
if not self._host_pid_is_ours(pid, recorded_start):
|
|
if self._is_host_pid_alive(pid):
|
|
logger.info(
|
|
"Not recovering session %s: pid %d is alive but its "
|
|
"start time no longer matches — PID was recycled onto "
|
|
"an unrelated process; refusing to adopt it.",
|
|
entry.get("session_id", "?"), pid,
|
|
)
|
|
systemd_unit = entry.get("systemd_unit", "")
|
|
if systemd_unit and not _stop_systemd_unit(systemd_unit):
|
|
logger.warning(
|
|
"Could not reap persisted scope %s for dead wrapper pid %s; "
|
|
"retaining checkpoint entry for the next startup",
|
|
systemd_unit,
|
|
pid,
|
|
)
|
|
unresolved_scope_entries.append(entry)
|
|
continue
|
|
|
|
fields = {f: entry.get(f, _CHECKPOINT_DEFAULTS[f]) for f in _CHECKPOINT_FIELDS}
|
|
fields.update(
|
|
command=entry.get("command", "unknown"),
|
|
owner_task_id=entry.get("owner_task_id", "") or entry.get("task_id", ""),
|
|
pid=pid,
|
|
host_start_time=recorded_start,
|
|
pid_scope=pid_scope,
|
|
started_at=entry.get("started_at", time.time()),
|
|
)
|
|
session = ProcessSession(
|
|
id=entry["session_id"],
|
|
detached=True, # Can't read output, but can report status + kill
|
|
**fields,
|
|
)
|
|
with self._lock:
|
|
self._running[session.id] = session
|
|
recovered += 1
|
|
logger.info("Recovered detached process: %s (pid=%d)", session.command[:60], pid)
|
|
|
|
# Re-enqueue watcher so gateway can resume notifications
|
|
if session.watcher_interval > 0:
|
|
self.pending_watchers.append({
|
|
"session_id": session.id,
|
|
"check_interval": session.watcher_interval,
|
|
"session_key": session.session_key,
|
|
"platform": session.watcher_platform,
|
|
"chat_id": session.watcher_chat_id,
|
|
"user_id": session.watcher_user_id,
|
|
"user_name": session.watcher_user_name,
|
|
"thread_id": session.watcher_thread_id,
|
|
"message_id": session.watcher_message_id,
|
|
"notify_on_complete": session.notify_on_complete,
|
|
"parent_session_id": session.parent_session_id,
|
|
})
|
|
|
|
self._write_checkpoint(extra_entries=unresolved_scope_entries)
|
|
|
|
return recovered
|
|
|
|
|
|
# Module-level singleton
|
|
process_registry = ProcessRegistry()
|
|
|
|
|
|
# Notification rendering lives in tools.process_registry_notifications; the names are
|
|
# re-exported here so `from tools.process_registry import format_process_notification`
|
|
# and `patch("tools.process_registry._x")` keep resolving.
|
|
from tools.process_registry_notifications import ( # noqa: F401,E402
|
|
_delegation_attribution_line,
|
|
_delegation_config,
|
|
_delegation_model_not_found,
|
|
_delegation_model_not_found_notice,
|
|
_format_age,
|
|
_format_async_delegation,
|
|
_model_not_found_patterns,
|
|
format_process_notification,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Registry -- the "process" tool schema + handler
|
|
# ---------------------------------------------------------------------------
|
|
from tools.registry import registry, tool_error
|
|
|
|
PROCESS_SCHEMA = {
|
|
"name": "process_manage",
|
|
# The enum names the verbs; the description keeps only non-obvious semantics
|
|
# (write-vs-submit is the one real trap: a lone \n on a Windows PTY is not Enter).
|
|
"description": (
|
|
"Poll, wait on, or kill background terminal processes (from "
|
|
"terminal(background=true)). "
|
|
"poll: status + new output. log: full output, paged. wait: block "
|
|
"until exit or timeout (partial output on timeout). write vs "
|
|
"submit: submit appends Enter — use it to answer prompts; write "
|
|
"sends raw bytes, no newline. close: EOF stdin. kill: terminate."
|
|
),
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {
|
|
"action": {
|
|
"type": "string",
|
|
"enum": ["list", "poll", "log", "wait", "kill", "write", "submit", "close"]
|
|
},
|
|
"session_id": {
|
|
"type": "string",
|
|
"description": "From terminal background output; any unique prefix works ('4dae' for proc_4dae56ca81f6). Required except for 'list'."
|
|
},
|
|
"data": {
|
|
"type": "string",
|
|
"description": "Stdin text for write/submit."
|
|
},
|
|
"timeout": {
|
|
"type": "integer",
|
|
"description": "Max seconds for 'wait'.",
|
|
"minimum": 1
|
|
},
|
|
"offset": {
|
|
"type": "integer",
|
|
"description": "Log line offset (default: last 200)."
|
|
},
|
|
"limit": {
|
|
"type": "integer",
|
|
"description": "Max log lines.",
|
|
"minimum": 1
|
|
}
|
|
},
|
|
"required": ["action"]
|
|
}
|
|
}
|
|
|
|
|
|
def _redact_process_result(result: dict) -> dict:
|
|
"""Redact secrets from background-process output before it reaches the model,
|
|
session.db and CLI, mirroring the foreground ``terminal`` redaction so the two
|
|
surfaces can't diverge. Respects ``security.redact_secrets``; ``redact_terminal_output``
|
|
picks ``code_file`` from the recorded command. The command itself is redacted too.
|
|
"""
|
|
if not isinstance(result, dict):
|
|
return result
|
|
from agent.redact import redact_sensitive_text, redact_terminal_output
|
|
|
|
command = result.get("command") or ""
|
|
for field in ("output", "output_preview"):
|
|
value = result.get(field)
|
|
if isinstance(value, str) and value:
|
|
result[field] = redact_terminal_output(value, command)
|
|
if isinstance(result.get("command"), str) and result["command"]:
|
|
result["command"] = redact_sensitive_text(result["command"], code_file=True)
|
|
return result
|
|
|
|
|
|
def _list_processes(task_id) -> dict:
|
|
# Surface session-scoped background processes (e.g. a forgotten preview
|
|
# server) in addition to this task's own — they share the gateway
|
|
# session_key and can block session reset.
|
|
try:
|
|
from tools.approval import get_current_session_key
|
|
session_key = get_current_session_key(default="") or ""
|
|
except Exception:
|
|
session_key = ""
|
|
return {
|
|
"processes": [
|
|
_redact_process_result(p)
|
|
for p in process_registry.list_sessions(task_id=task_id, session_key=session_key or None)
|
|
]
|
|
}
|
|
|
|
|
|
# action -> (handler(session_id, args) -> dict, redact output?). Output-bearing
|
|
# actions are redacted; stdin actions return only status.
|
|
_SESSION_ACTIONS = {
|
|
"poll": (lambda sid, a: process_registry.poll(sid), True),
|
|
"log": (lambda sid, a: process_registry.read_log(sid, offset=a.get("offset"), limit=a.get("limit", 200)), True),
|
|
"wait": (lambda sid, a: process_registry.wait(sid, timeout=a.get("timeout")), True),
|
|
"kill": (lambda sid, a: process_registry.kill_process(sid), True),
|
|
"write": (lambda sid, a: process_registry.write_stdin(sid, str(a.get("data", ""))), False),
|
|
"submit": (lambda sid, a: process_registry.submit_stdin(sid, str(a.get("data", ""))), False),
|
|
"close": (lambda sid, a: process_registry.close_stdin(sid), False),
|
|
}
|
|
|
|
|
|
def _handle_process(args, **kw):
|
|
action = args.get("action", "")
|
|
# Coerce to string — some models send session_id as an integer
|
|
session_id = str(args.get("session_id", "")) if args.get("session_id") is not None else ""
|
|
|
|
if action == "list":
|
|
return json.dumps(_list_processes(kw.get("task_id")), ensure_ascii=False)
|
|
if action in _SESSION_ACTIONS:
|
|
if not session_id:
|
|
return tool_error(f"session_id is required for {action}")
|
|
handler, redact = _SESSION_ACTIONS[action]
|
|
result = handler(session_id, args)
|
|
return json.dumps(_redact_process_result(result) if redact else result, ensure_ascii=False)
|
|
return tool_error(f"Unknown process action: {action}. Use: list, poll, log, wait, kill, write, submit, close")
|
|
|
|
|
|
registry.register(
|
|
name="process_manage",
|
|
toolset="terminal",
|
|
schema=PROCESS_SCHEMA,
|
|
handler=_handle_process,
|
|
emoji="⚙️",
|
|
)
|