Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 51 additions & 30 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,40 @@ def _restart_initiated_ttl_secs() -> float:
return max(60.0, min(value, 86400.0))


# Map of agent.* config keys → the HERMES_* env var the gateway reads them
# through. config.yaml is the authoritative, documented surface and
# UNCONDITIONALLY wins over a pre-set env var (PR #18413 — a stale .env must
# never shadow current config). Bridged at gateway startup (the module-level
# block below) via _bridge_agent_config_to_env so the mapping is single-sourced
# and unit-testable. New agent timeout/threshold knobs go here, not inline.
_AGENT_CONFIG_ENV_BRIDGE: dict[str, str] = {
"max_turns": "HERMES_MAX_ITERATIONS",
"gateway_timeout": "HERMES_AGENT_TIMEOUT",
"gateway_timeout_warning": "HERMES_AGENT_TIMEOUT_WARNING",
"gateway_notify_interval": "HERMES_AGENT_NOTIFY_INTERVAL",
"restart_drain_timeout": "HERMES_RESTART_DRAIN_TIMEOUT",
"gateway_auto_continue_freshness": "HERMES_AUTO_CONTINUE_FRESHNESS",
"restart_loop_threshold": "HERMES_RESTART_LOOP_THRESHOLD",
"restart_loop_window_secs": "HERMES_RESTART_LOOP_WINDOW_SECS",
"restart_initiated_ttl_secs": "HERMES_RESTART_INITIATED_TTL_SECS",
}


def _bridge_agent_config_to_env(agent_cfg: Any) -> None:
"""Bridge agent.* config values into their HERMES_* env vars (config wins).

config.yaml is authoritative: a present config key OVERWRITES any pre-set
env var (PR #18413). A key absent from config leaves the env var untouched,
so an operator env survives until config is set. Single-sourced via
_AGENT_CONFIG_ENV_BRIDGE; no-op on a non-dict.
"""
if not isinstance(agent_cfg, dict):
return
for _cfg_key, _env_var in _AGENT_CONFIG_ENV_BRIDGE.items():
if _cfg_key in agent_cfg:
os.environ[_env_var] = str(agent_cfg[_cfg_key])


# Inspection verbs that READ the safe-restart script without EXECUTING it — a
# `terminal` command starting with one of these mentions the path but does not
# initiate a restart, so it must NOT trip the F2 self-loop detector (C1).
Expand Down Expand Up @@ -593,12 +627,22 @@ def _command_invokes_safe_restart(cmd: str) -> bool:
return False


# F2 restart-initiator breadcrumb (per-session, per-boot files) — see spec
# 2026-06-22_f2-initiator-detection-authoritative-breadcrumb.md. The
# safe-restart skill's safe-restart.py drops one file per restart it initiates;
# the gateway's clean-turn gate consumes it to know "this turn initiated a
# restart" independent of the command string (robust to alias/wrapper/rename
# that the C1 heuristic misses). Shared contract with the script — keep in sync.
# ─── F2 BREADCRUMB CONTRACT (shared with safe-restart.py — keep byte-identical) ───
# The restart-initiator breadcrumb is a cross-repo file contract between this
# gateway (consumer) and the safe-gateway-restart skill's safe-restart.py
# (producer, in the hermes-home repo). The two cannot share a Python module (the
# skill is pure-stdlib standalone), so this block is the SINGLE documented source
# of truth and each repo has its own always-on conformance test against these
# frozen facts (gateway: test_gateway_breadcrumb_contract_matches_frozen; skill:
# the mirror in test_watcher.py):
# • directory: $HERMES_HOME/.restart_initiated/ (_RESTART_INITIATED_DIRNAME)
# • filename: sha256(session_key.encode("utf-8")).hexdigest()[:8] (per-session)
# • file JSON: {"session_key": str, "ts": float, "boot_id": str}
# • boot_id: "{pid}:{psutil_create_time}", produced by the gateway ONLY
# (gateway/status.py:_compute_boot_id, persisted in gateway_state.json);
# the script copies the string verbatim, never recomputes it.
# A change to any of these MUST land in both repos together or a conformance test
# reddens. Full mechanism: spec 2026-06-22_f2-initiator-detection-authoritative-breadcrumb.md.
_RESTART_INITIATED_DIRNAME = ".restart_initiated"


Expand Down Expand Up @@ -1334,30 +1378,7 @@ def _reload_runtime_env_preserving_config_authority() -> None:
# .env entries (e.g. HERMES_MAX_ITERATIONS=60 written by an old
# `hermes setup` run) silently shadow the user's current config.
# See PR #18413 / the 60-vs-500 max_turns incident.
_agent_cfg = _cfg.get("agent", {})
if _agent_cfg and isinstance(_agent_cfg, dict):
if "max_turns" in _agent_cfg:
os.environ["HERMES_MAX_ITERATIONS"] = str(_agent_cfg["max_turns"])
if "gateway_timeout" in _agent_cfg:
os.environ["HERMES_AGENT_TIMEOUT"] = str(_agent_cfg["gateway_timeout"])
if "gateway_timeout_warning" in _agent_cfg:
os.environ["HERMES_AGENT_TIMEOUT_WARNING"] = str(_agent_cfg["gateway_timeout_warning"])
if "gateway_notify_interval" in _agent_cfg:
os.environ["HERMES_AGENT_NOTIFY_INTERVAL"] = str(_agent_cfg["gateway_notify_interval"])
if "restart_drain_timeout" in _agent_cfg:
os.environ["HERMES_RESTART_DRAIN_TIMEOUT"] = str(_agent_cfg["restart_drain_timeout"])
if "gateway_auto_continue_freshness" in _agent_cfg:
os.environ["HERMES_AUTO_CONTINUE_FRESHNESS"] = str(
_agent_cfg["gateway_auto_continue_freshness"]
)
if "restart_loop_threshold" in _agent_cfg:
os.environ["HERMES_RESTART_LOOP_THRESHOLD"] = str(
_agent_cfg["restart_loop_threshold"]
)
if "restart_loop_window_secs" in _agent_cfg:
os.environ["HERMES_RESTART_LOOP_WINDOW_SECS"] = str(
_agent_cfg["restart_loop_window_secs"]
)
_bridge_agent_config_to_env(_cfg.get("agent", {}))
_display_cfg = _cfg.get("display", {})
if _display_cfg and isinstance(_display_cfg, dict):
if "busy_input_mode" in _display_cfg:
Expand Down
20 changes: 20 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -831,6 +831,26 @@ def _ensure_hermes_home_managed(home: Path):
# budget routinely interrupted legitimate work on /restart. Raise
# further in config.yaml if you run very-long-reasoning models.
"restart_drain_timeout": 180,
# F2 restart-loop circuit-breaker tuning (gateway). These bridge to the
# HERMES_RESTART_* env vars at gateway startup (gateway/run.py); set them
# here in config.yaml — config is authoritative and wins over a pre-set
# env var (same precedence as every agent.* knob; see PR #18413). All
# three are clamped at read time, so an out-of-range value is corrected
# rather than rejected.
#
# How many auto-resume RELAPSES (a session that resumed this boot and
# got drain-marked again) within restart_loop_window_secs before the
# breaker suspends the session and alerts once. Clamped to [1, 100].
"restart_loop_threshold": 3,
# Sliding window (seconds) over which restart_loop_threshold relapses
# are counted. Clamped to [1, 86400].
"restart_loop_window_secs": 300,
# Freshness backstop (seconds) for an F2 restart-initiator breadcrumb:
# a breadcrumb older than this is ignored. This is only a janitor — the
# boot_id check is the primary cross-boot guard — so the floor is
# generous (clamped to [60, 86400]) to never self-discard an in-turn
# write→gate latency.
"restart_initiated_ttl_secs": 600,
# Max app-level retry attempts for API errors (connection drops,
# provider timeouts, 5xx, etc.) before the agent surfaces the
# failure. The OpenAI SDK already does its own low-level retries
Expand Down
157 changes: 157 additions & 0 deletions tests/gateway/test_restart_cascade.py
Original file line number Diff line number Diff line change
Expand Up @@ -1019,3 +1019,160 @@ def test_sweep_degraded_boot_reaps_all(tmp_path, monkeypatch):
_write_breadcrumb(runner, sk, boot_id=f"{_os.getpid()}:")
removed = runner._sweep_restart_initiated_breadcrumbs()
assert removed == 1


# ───────── F2 BACKLOG CLEANUP (spec 2026-06-22_f2-breadcrumb-backlog-cleanup) ─────────
#
# Item 3 (config consistency), Item 2 (contract conformance), Item 1 covered in
# the skill's test_watcher.py. Phase 4 = no-regression source-guard.

from gateway.run import (
_AGENT_CONFIG_ENV_BRIDGE,
_bridge_agent_config_to_env,
)
Comment thread
Kyzcreig marked this conversation as resolved.
Comment thread
Kyzcreig marked this conversation as resolved.


# ---- Phase 1: Item 3 — restart_* config family consistency ----

def test_restart_initiated_ttl_in_bridge_map():
"""The new knob is wired into the single-sourced bridge map (so the startup
block bridges it without a bespoke if-branch)."""
assert _AGENT_CONFIG_ENV_BRIDGE["restart_initiated_ttl_secs"] == "HERMES_RESTART_INITIATED_TTL_SECS"
# the already-shipped siblings stay mapped (no accidental drop in the refactor)
assert _AGENT_CONFIG_ENV_BRIDGE["restart_loop_threshold"] == "HERMES_RESTART_LOOP_THRESHOLD"
assert _AGENT_CONFIG_ENV_BRIDGE["restart_loop_window_secs"] == "HERMES_RESTART_LOOP_WINDOW_SECS"


def test_restart_initiated_ttl_bridged_from_config(monkeypatch):
"""The new agent.restart_initiated_ttl_secs config key is bridged to its env
var by _bridge_agent_config_to_env (the single-sourced startup bridge)."""
monkeypatch.delenv("HERMES_RESTART_INITIATED_TTL_SECS", raising=False)
_bridge_agent_config_to_env({"restart_initiated_ttl_secs": 240})
assert _os.environ["HERMES_RESTART_INITIATED_TTL_SECS"] == "240"


def test_restart_family_defaults_match_live_helper_fallbacks(monkeypatch):
"""I-2 (pinned to the LIVE helper, not a literal): the DEFAULT_CONFIG agent
values equal what each helper returns with NO env set — so surfacing them in
config changed no effective value, and a future helper-fallback edit can't
drift silently past a test comparing two stale literals."""
from hermes_cli.config import DEFAULT_CONFIG

for var in ("HERMES_RESTART_LOOP_THRESHOLD", "HERMES_RESTART_LOOP_WINDOW_SECS",
"HERMES_RESTART_INITIATED_TTL_SECS"):
monkeypatch.delenv(var, raising=False)
agent = DEFAULT_CONFIG["agent"]
assert agent["restart_loop_threshold"] == _restart_loop_threshold()
assert agent["restart_loop_window_secs"] == _restart_loop_window_secs()
assert agent["restart_initiated_ttl_secs"] == _restart_initiated_ttl_secs()


def test_config_wins_over_preset_env_for_ttl(monkeypatch):
"""I-3 (precedence, corrected from ground-truth): config UNCONDITIONALLY wins
over a pre-set env var (PR #18413). Proven for the new TTL knob AND a shipped
sibling, so the new one matches established semantics."""
monkeypatch.setenv("HERMES_RESTART_INITIATED_TTL_SECS", "900")
monkeypatch.setenv("HERMES_RESTART_LOOP_THRESHOLD", "9")
_bridge_agent_config_to_env(
{"restart_initiated_ttl_secs": 120, "restart_loop_threshold": 3}
)
assert _os.environ["HERMES_RESTART_INITIATED_TTL_SECS"] == "120" # config wins
assert _os.environ["HERMES_RESTART_LOOP_THRESHOLD"] == "3" # sibling-consistent


def test_bridge_absent_key_leaves_env_untouched(monkeypatch):
"""A key absent from config does NOT clobber a pre-set env var (config-when-
present, else env) — the documented precedence."""
monkeypatch.setenv("HERMES_RESTART_INITIATED_TTL_SECS", "777")
_bridge_agent_config_to_env({"max_turns": 50}) # no ttl key
assert _os.environ["HERMES_RESTART_INITIATED_TTL_SECS"] == "777"


def test_ttl_config_clamps_both_bounds_and_malformed(monkeypatch):
"""Pass-1 R-3: exercise BOTH clamp bounds + malformed, not just the floor."""
# floor
monkeypatch.setenv("HERMES_RESTART_INITIATED_TTL_SECS", "5")
assert _restart_initiated_ttl_secs() == 60.0
# ceiling
monkeypatch.setenv("HERMES_RESTART_INITIATED_TTL_SECS", "999999")
assert _restart_initiated_ttl_secs() == 86400.0
# malformed → default
monkeypatch.setenv("HERMES_RESTART_INITIATED_TTL_SECS", "abc")
assert _restart_initiated_ttl_secs() == 600.0


def test_bridge_noop_on_non_dict():
"""Defensive: a non-dict agent config (malformed yaml) is a no-op, not a crash."""
_bridge_agent_config_to_env(None)
_bridge_agent_config_to_env("not a dict")
_bridge_agent_config_to_env(["list"])


# ---- Phase 2: Item 2 — frozen-contract conformance (gateway side, always-on) ----

# The single source of truth for the cross-repo F2 breadcrumb contract. The skill
# (safe-restart.py, hermes-home) has a mirror test asserting the same literals.
_FROZEN_BREADCRUMB_DIRNAME = ".restart_initiated"
_FROZEN_BREADCRUMB_JSON_KEYS = {"session_key", "ts", "boot_id"}
# Representative key vector (Pass-1 C-2: one sample != byte-equality).
_CONTRACT_KEY_VECTOR = [
"",
"agent:main:discord:group:123",
"x" * 4096,
"agent:main:test:weird/slash:1",
"ünïçödé:キー:1",
]


def _frozen_filename(session_key: str) -> str:
import hashlib
return hashlib.sha256((session_key or "").encode("utf-8")).hexdigest()[:8]


def test_gateway_breadcrumb_contract_matches_frozen():
"""Always-on (fork CI, never skips): the gateway's breadcrumb constants match
the frozen contract literals over a key vector. The skill's mirror test asserts
the same literals in hermes-home CI, so drift in EITHER repo reddens its own
suite without needing the other checked out."""
from gateway.run import _RESTART_INITIATED_DIRNAME, _restart_initiated_filename

assert _RESTART_INITIATED_DIRNAME == _FROZEN_BREADCRUMB_DIRNAME
for key in _CONTRACT_KEY_VECTOR:
assert _restart_initiated_filename(key) == _frozen_filename(key), key
# the consumer reads exactly these JSON keys (from the gate's data.get calls)
import inspect
from gateway.run import GatewayRunner
consume_src = inspect.getsource(GatewayRunner._consume_restart_initiated_breadcrumb)
for jk in _FROZEN_BREADCRUMB_JSON_KEYS:
assert f'"{jk}"' in consume_src or f"'{jk}'" in consume_src, jk


def test_breadcrumb_contract_block_documented_in_source():
"""The # F2 BREADCRUMB CONTRACT block is the single documented source of truth;
its presence is grep-guarded so it can't be silently deleted."""
import inspect
import gateway.run as gr
assert "F2 BREADCRUMB CONTRACT" in inspect.getsource(gr)


# ---- Phase 4: I-1 — no-regression source guard for the shipped #80 path ----

def test_shipped_breadcrumb_bodies_unchanged():
"""I-1 teeth: the load-bearing lines of the four shipped #80 functions are
present in source, so this hygiene PR provably didn't edit detection logic."""
import inspect
import gateway.run as gr
import gateway.status as gs

run_src = inspect.getsource(gr)
# gate: always-consume (no short-circuit) + OR
assert "breadcrumb = self._consume_restart_initiated_breadcrumb(session_key)" in run_src
assert "initiated_restart = flag or breadcrumb" in run_src
# consume: boot gate + degraded fail-safe + always-unlink
assert "current_boot.endswith(\":\")" in run_src
assert "path.unlink()" in run_src
# sweep: current-boot keep
assert "_sweep_restart_initiated_breadcrumbs" in run_src
# producer: boot_id refreshed every write
status_src = inspect.getsource(gs)
assert 'payload["boot_id"] = current_record["boot_id"]' in status_src
Loading