Files
hermes-agent/gateway/restart_loop_guard.py
T
Teknium aed6720dab refactor(gateway/run, slash_commands): dispatch tables, helper unification and hand-reviewed comment compaction
run.py:
- built-in adapter creation: 9-branch if/elif -> _BUILTIN_ADAPTERS table
- idle slash-command routing: 35 `if canonical == ...` branches -> _gateway_idle_command_handlers()
- shared helpers: _send_command_ack (4 sites), _command_origin_for_source (2), _session_entry_for_manager
  (goal/heartbeat), _toggle_adapter_auto_tts_set (2), _load_env_or_agent_cfg_timeout (2), _float_env reuse (2),
  _resolve_session_key_or_none (3), _running_agent_ids (4), _schedule_rename_from_title_thread (2),
  _write_runtime_status_quiet (5), _AUTO_RESET_CONTEXT_NOTES/_auto_reset_reason_text
- ruff SIM102/SIM103/SIM105/SIM108/SIM118 + F401 across gateway/ (semantics re-reviewed; sqlite Row
  `.keys()` and side-effecting assignments kept)
- two hand-reviewed comment/docstring compaction passes (AST-identical, rationale kept)

slash_commands.py:
- /model: typed path and picker callback shared one 200-line commit block -> _perform_model_switch +
  _commit_model_switch
- comment/docstring compaction (AST-identical)
2026-09-02 13:31:53 -07:00

157 lines
5.8 KiB
Python

"""Auto-resume restart-loop breaker (defense-3).
Defenses 1 and 2 (the ``_HERMES_GATEWAY`` guard on ``hermes gateway
stop|restart`` + ``terminal_tool``, and the cron-creation lifecycle filter)
stop the agent scheduling its own restart via cron/CLI. They do NOT cover
every SIGTERM source (raw ``launchctl kickstart``, a bad external monitor,
any repeated crash): the supervisor respawns, the gateway auto-resumes the
restart-interrupted session, whose next turn re-runs the offending logic.
This module is the last-resort circuit breaker. Each boot with
restart-interrupted sessions pending is timestamped and persisted (each boot
is a fresh process, so in-memory state is useless). Boots CHAIN while
consecutive gaps stay within ``max_gap_seconds``, so slow crash cycles (a
wedged loop killed by the liveness watchdog every ~150s) trip exactly like the
fast ~10s respawn loop. When tripped, the caller SKIPS auto-resume for that
boot — the gateway still serves real inbound messages, it just stops replaying
the session that keeps killing it.
State lives in ``<HERMES_HOME>/gateway/restart_loop.json`` (profile-scoped).
Best-effort: any read/write failure fails OPEN (no false trip) because a
broken breaker must never wedge a healthy gateway.
"""
from __future__ import annotations
import json
import logging
import time
from typing import List, Optional
from hermes_constants import get_hermes_home
import contextlib
logger = logging.getLogger("gateway.run")
# A legitimate operator restart (or two) never trips; a ~10s respawn loop does
# within a few cycles.
DEFAULT_MAX_RESTARTS = 3
DEFAULT_WINDOW_SECONDS = 60
# Longest gap between consecutive restart-interrupted boots that still counts
# them as the SAME loop. A fixed-window prune only sees cycles faster than the
# window (a slower loop drops its own history every boot and never trips);
# chaining on the inter-boot gap makes the breaker period-agnostic, and a
# single boot followed by real quiet resets the chain.
DEFAULT_MAX_GAP_SECONDS = 300
# Cap the persisted chain; only the newest ``max_restarts`` entries can change
# a verdict, the rest are forensics.
_MAX_STORED_BOOTS = 50
def _state_path():
return get_hermes_home() / "gateway" / "restart_loop.json"
def _load_boots() -> List[float]:
try:
data = json.loads(_state_path().read_text(encoding="utf-8"))
return [float(t) for t in data.get("boots", []) if isinstance(t, (int, float))]
except (OSError, ValueError, TypeError):
return []
def _save_boots(boots: List[float]) -> None:
try:
path = _state_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps({"boots": boots}), encoding="utf-8")
except OSError:
pass
def _chain_gap(window_seconds: int, max_gap_seconds: int) -> float:
"""Inter-boot gap that still links two boots. Floored by ``window_seconds`` so
widening the window never makes the breaker *less* sensitive."""
return float(max(1, window_seconds, max_gap_seconds))
def _chain_ending_at(boots: List[float], ts: float, gap: float) -> List[float]:
"""Unbroken chain of boots leading up to ``ts`` (oldest first).
Walks backwards keeping boots while each successive gap stays within
``gap``; the first wider gap ends the chain (older boots belong to an
already-resolved episode). Nothing recent enough -> empty list, which is how
a healthy gateway forgets an old loop.
"""
chain: List[float] = []
prev = ts
for t in sorted(boots, reverse=True):
if t > ts:
# Clock moved backwards (NTP step, restored state file): treat the
# future entry as adjacent rather than dropping the whole chain.
chain.append(t)
continue
if prev - t > gap:
break
chain.append(t)
prev = t
chain.reverse()
return chain
def record_restart_interrupted_boot(
window_seconds: int = DEFAULT_WINDOW_SECONDS,
*,
now: Optional[float] = None,
max_gap_seconds: int = DEFAULT_MAX_GAP_SECONDS,
) -> List[float]:
"""Record a restart-interrupted boot; return the pruned chain + now (most recent last).
Best-effort — a persistence failure returns the in-memory list without raising.
"""
ts = time.time() if now is None else now
boots = _chain_ending_at(_load_boots(), ts, _chain_gap(window_seconds, max_gap_seconds))
boots.append(ts)
_save_boots(boots[-_MAX_STORED_BOOTS:])
return boots
def clear() -> None:
"""Remove the persisted boot log (used on clean shutdown / by tests)."""
with contextlib.suppress(OSError):
_state_path().unlink(missing_ok=True)
def check_and_record(
max_restarts: int = DEFAULT_MAX_RESTARTS,
window_seconds: int = DEFAULT_WINDOW_SECONDS,
*,
now: Optional[float] = None,
max_gap_seconds: int = DEFAULT_MAX_GAP_SECONDS,
) -> bool:
"""Record this boot and return True when auto-resume should be SKIPPED.
The single entry point the gateway calls: appends the current boot, then
checks whether the updated chain has reached ``max_restarts``.
"""
boots = record_restart_interrupted_boot(
window_seconds, now=now, max_gap_seconds=max_gap_seconds
)
tripped = len(boots) >= max_restarts if max_restarts > 0 else False
if tripped:
logger.warning(
"Restart-loop breaker TRIPPED: %d chained restart-interrupted "
"gateway boots (no gap wider than %ds; threshold %d). Skipping "
"auto-resume to break a suspected SIGTERM-respawn loop (#30719, "
"#81642). Restart-interrupted sessions stay resume-pending and "
"will continue on the next real user message. If this is a false "
"positive, delete %s.",
len(boots),
int(_chain_gap(window_seconds, max_gap_seconds)),
max_restarts,
_state_path(),
)
return tripped