feat(cli): global emergency stop — `hermes pause` / `hermes resume`

Resumable ESTOP sentinel at $HERMES_HOME/ESTOP that halts NEW work only:

- agent/estop.py: sentinel engage/disengage/is_engaged (single stat, no
  caching), optional reason + timestamp stored as JSON, paused_reply()
  notice, check_paused() log-once-per-engagement helper. Corrupt/empty
  sentinel still pauses (fail safe); a `touch ~/.hermes/ESTOP` works.
- cron/scheduler.py: tick() skips dispatch while engaged (logged once per
  engagement, not per tick). Due jobs simply wait for the next tick after
  resume — in-flight runs are never touched.
- gateway/kanban_watchers.py: dispatcher skips auto-decompose and worker
  spawning while engaged; zombie reaping still runs and running workers
  finish naturally.
- gateway/run.py: new gateway turns (post-auth, non-internal) get a brief
  "Hermes is paused" reply instead of an agent run. Internal events
  (in-flight background completions) bypass the gate.
- hermes_cli/subcommands/pause.py: `hermes pause [--reason]` and
  `hermes resume`, wired into main() and _BUILTIN_SUBCOMMANDS.
- hermes_cli/status.py: `hermes status` shows a PAUSED banner (one stat).
- tests/test_estop.py: 20 tests — sentinel lifecycle, reason surfacing,
  log-once, cron skip + resume, kanban gate, gateway paused reply +
  internal bypass, CLI idempotence, builtin-set parity, status line.

Never kills in-flight work; resumable with no restart. Footprint ladder:
CLI command only, no new model tool, no new env vars.

Ported from: gastownhall/gastown estop.go (MIT); related prior art:
#26778 (/panic — kill/exit semantics, deliberately different: ours is a
resumable pause), #44617 (interrupt in-flight cron — out of scope here).
This commit is contained in:
Teknium 2026-08-07 08:09:12 -07:00
parent 5396da844a
commit 5db1b72b1f
8 changed files with 638 additions and 31 deletions

167
agent/estop.py Normal file
View File

@ -0,0 +1,167 @@
"""Global emergency stop (ESTOP) — a resumable pause for NEW work only.
``hermes pause`` writes a sentinel file at ``$HERMES_HOME/ESTOP``;
``hermes resume`` removes it. While the sentinel exists:
* the cron scheduler skips dispatching due jobs (``cron/scheduler.py:tick``),
* the embedded kanban dispatcher skips spawning workers
(``gateway/kanban_watchers.py``),
* new gateway turns get a brief "Hermes is paused" reply instead of an
agent run (``gateway/run.py:_handle_message``).
In-flight work is NEVER killed this is pause-new-work, not panic/exit.
The check is a single ``os.stat`` so callers may run it every tick; no
caching beyond the OS is performed, so engaging/disengaging takes effect on
the very next check.
The sentinel body is optional JSON ``{"reason": ..., "engaged_at": ...}``.
A corrupt or empty file still counts as engaged (fail safe): the pause must
hold even if the file was created by ``touch ~/.hermes/ESTOP``.
Ported from: gastownhall/gastown estop.go (MIT). Related prior art:
#26778 (/panic — kill/exit semantics; deliberately different, ours is
resumable) and #44617 (interrupting in-flight cron; deliberately out of
scope here).
"""
from __future__ import annotations
import json
import logging
import os
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
SENTINEL_NAME = "ESTOP"
# Per-component "logged already for this engagement" flags so a paused
# dispatch loop logs once per engagement instead of once per tick.
_log_lock = threading.Lock()
_logged_components: set[str] = set()
def _hermes_home() -> Path:
"""Resolve the active HERMES_HOME (profile-aware) at call time."""
try:
from hermes_constants import get_hermes_home
return get_hermes_home()
except Exception:
return Path(os.path.expanduser("~/.hermes"))
def sentinel_path() -> Path:
"""Path of the ESTOP sentinel under the active HERMES_HOME."""
return _hermes_home() / SENTINEL_NAME
def is_engaged() -> bool:
"""Cheap check (one stat): is the global emergency stop engaged?"""
try:
return sentinel_path().exists()
except OSError:
return False
def engage(reason: Optional[str] = None) -> Path:
"""Create the ESTOP sentinel. Idempotent; re-engaging updates the file."""
path = sentinel_path()
payload = {
"engaged_at": datetime.now(timezone.utc).isoformat(),
"reason": reason or None,
}
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
except OSError:
# Best effort: an empty/partial sentinel still pauses (fail safe).
try:
path.touch(exist_ok=True)
except OSError:
pass
return path
def disengage() -> bool:
"""Remove the ESTOP sentinel. Returns True if a pause was lifted."""
try:
sentinel_path().unlink()
return True
except FileNotFoundError:
return False
except OSError:
return False
def get_state() -> Optional[dict]:
"""Return ``{"reason": ..., "engaged_at": ...}`` or None when not engaged.
A sentinel with an unreadable/corrupt body still reports engaged, with
both fields None the pause is authoritative, the metadata is not.
"""
path = sentinel_path()
if not path.exists():
return None
reason = None
engaged_at = None
try:
raw = json.loads(path.read_text(encoding="utf-8"))
if isinstance(raw, dict):
reason = raw.get("reason") or None
engaged_at = raw.get("engaged_at") or None
except (OSError, ValueError):
pass
return {"reason": reason, "engaged_at": engaged_at}
def paused_reply() -> Optional[str]:
"""Short user-facing notice for new gateway turns, or None if not paused."""
state = get_state()
if state is None:
return None
reason = state.get("reason")
if reason:
return (
f"⏸️ Hermes is paused ({reason}). New work is on hold; "
"run `hermes resume` to pick things back up."
)
return (
"⏸️ Hermes is paused. New work is on hold; "
"run `hermes resume` to pick things back up."
)
def check_paused(component: str, logger: logging.Logger) -> bool:
"""Return True when engaged, logging once per engagement per component.
Dispatch loops call this every tick; the log fires on the disengaged
engaged transition for that component and re-arms after a resume, so a
long pause doesn't spam one line per tick.
"""
if not is_engaged():
with _log_lock:
_logged_components.discard(component)
return False
with _log_lock:
first = component not in _logged_components
if first:
_logged_components.add(component)
if first:
state = get_state() or {}
reason = state.get("reason")
suffix = f" (reason: {reason})" if reason else ""
logger.info(
"%s dispatch paused by global emergency stop%s — remove with "
"`hermes resume` (%s)",
component,
suffix,
sentinel_path(),
)
return True
def _reset_log_state_for_tests() -> None:
"""Clear the log-once bookkeeping (test isolation helper)."""
with _log_lock:
_logged_components.clear()

View File

@ -4759,6 +4759,17 @@ def tick(
return 0
try:
# Global emergency stop (`hermes pause`): skip dispatch entirely while
# the ESTOP sentinel exists. Never touches in-flight runs — due jobs
# simply wait for the next tick after `hermes resume`. Logged once per
# engagement (not every tick) by check_paused.
try:
from agent.estop import check_paused as _estop_check_paused
if _estop_check_paused("cron", logger):
return 0
except ImportError:
pass
if can_dispatch is not None and not can_dispatch():
logger.debug("Cron dispatch paused while gateway drains existing work")
return 0

View File

@ -57,6 +57,22 @@ def _resolve_auto_decompose_settings(
return enabled, per_tick
def _kanban_dispatch_allowed() -> bool:
"""Return False while the global emergency stop (`hermes pause`) is engaged.
Checked every dispatcher tick BEFORE spawning new workers so a pause takes
effect on the next tick without a gateway restart. In-flight workers are
never touched this only stops NEW spawns. Fails open: if the estop
module is unimportable, dispatch proceeds (the sentinel gate must not
become a new crash surface for the dispatcher).
"""
try:
from agent.estop import check_paused
except ImportError:
return True
return not check_paused("kanban", logger)
def _acquire_singleton_lock(lock_path) -> "tuple[Optional[object], str]":
"""Take an exclusive, non-blocking advisory lock for the sole dispatcher.
@ -1435,36 +1451,43 @@ class GatewayKanbanWatchersMixin:
logger.exception("kanban dispatcher: zombie reaper failed")
try:
# Re-read the auto-decompose toggle live each tick so a user
# flipping kanban.auto_decompose=false to STOP runaway fan-out
# takes effect on the next tick, not on gateway restart (#49638).
_ad_enabled, _ad_per_tick = _read_auto_decompose_settings()
if _ad_enabled:
await asyncio.to_thread(_auto_decompose_tick, _ad_per_tick)
results = await asyncio.to_thread(_tick_once)
any_spawned = False
for slug, res in (results or []):
if res is not None and getattr(res, "spawned", None):
any_spawned = True
# Quiet by default — only log when something actually
# happened, so an idle gateway stays silent.
logger.info(
"kanban dispatcher [%s]: spawned=%d reclaimed=%d "
"crashed=%d timed_out=%d promoted=%d auto_blocked=%d",
slug,
len(res.spawned),
res.reclaimed,
len(res.crashed) if hasattr(res.crashed, "__len__") else 0,
len(res.timed_out) if hasattr(res.timed_out, "__len__") else 0,
res.promoted,
len(res.auto_blocked) if hasattr(res.auto_blocked, "__len__") else 0,
)
# Health telemetry (aggregate across boards)
ready_pending = await asyncio.to_thread(_ready_nonempty)
if ready_pending and not any_spawned:
bad_ticks += 1
else:
# Global emergency stop (`hermes pause`): skip auto-decompose
# and dispatch entirely — no new workers while paused. Running
# workers finish naturally; zombie reaping above still runs.
if not _kanban_dispatch_allowed():
ready_pending = False
bad_ticks = 0
else:
# Re-read the auto-decompose toggle live each tick so a user
# flipping kanban.auto_decompose=false to STOP runaway fan-out
# takes effect on the next tick, not on gateway restart (#49638).
_ad_enabled, _ad_per_tick = _read_auto_decompose_settings()
if _ad_enabled:
await asyncio.to_thread(_auto_decompose_tick, _ad_per_tick)
results = await asyncio.to_thread(_tick_once)
any_spawned = False
for slug, res in (results or []):
if res is not None and getattr(res, "spawned", None):
any_spawned = True
# Quiet by default — only log when something actually
# happened, so an idle gateway stays silent.
logger.info(
"kanban dispatcher [%s]: spawned=%d reclaimed=%d "
"crashed=%d timed_out=%d promoted=%d auto_blocked=%d",
slug,
len(res.spawned),
res.reclaimed,
len(res.crashed) if hasattr(res.crashed, "__len__") else 0,
len(res.timed_out) if hasattr(res.timed_out, "__len__") else 0,
res.promoted,
len(res.auto_blocked) if hasattr(res.auto_blocked, "__len__") else 0,
)
# Health telemetry (aggregate across boards)
ready_pending = await asyncio.to_thread(_ready_nonempty)
if ready_pending and not any_spawned:
bad_ticks += 1
else:
bad_ticks = 0
if bad_ticks >= HEALTH_WINDOW:
now = int(time.time())
if now - last_warn_at >= 300:

View File

@ -14663,7 +14663,27 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# Record rate limit so subsequent messages are silently ignored
pairing_store._record_rate_limit(platform_name, source.user_id)
return None
# Global emergency stop (`hermes pause`): give new turns a brief
# paused notice instead of starting an agent run. Internal events
# (background-process completions from IN-FLIGHT work) bypass the
# gate — pause stops NEW work, it never kills or orphans running
# work. Placed after auth so unauthorized senders keep the normal
# silent/pairing behavior and can't probe pause state.
if not is_internal:
try:
from agent.estop import paused_reply as _estop_paused_reply
_paused_notice = _estop_paused_reply()
except ImportError:
_paused_notice = None
if _paused_notice is not None:
logger.info(
"Gateway turn paused by global emergency stop (platform=%s chat=%s)",
getattr(getattr(source, "platform", None), "value", "unknown"),
getattr(source, "chat_id", None) or "unknown",
)
return _paused_notice
# Intercept messages that are responses to a pending /update prompt.
# The update process (detached) wrote .update_prompt.json; the watcher
# forwarded it to the user; now the user's reply goes back via

View File

@ -451,6 +451,7 @@ from hermes_cli.subcommands.login import build_login_parser
from hermes_cli.subcommands.logout import build_logout_parser
from hermes_cli.subcommands.auth import build_auth_parser
from hermes_cli.subcommands.status import build_status_parser
from hermes_cli.subcommands.pause import build_pause_parser
from hermes_cli.subcommands.webhook import build_webhook_parser
from hermes_cli.subcommands.hooks import build_hooks_parser
from hermes_cli.subcommands.doctor import build_doctor_parser
@ -10609,9 +10610,10 @@ _BUILTIN_SUBCOMMANDS = frozenset(
"dump", "egress", "fallback", "gateway", "hooks", "import", "import-agent", "insights",
"gui", "desktop", "kanban", "login", "logout", "logs", "lsp", "mcp", "memory", "migrate", "moa",
"journey", "memory-graph", "learning",
"model", "monitoring", "pairing", "pets", "plugins", "portal", "profile",
"model", "monitoring", "pairing", "pause", "pets", "plugins", "portal", "profile",
"project", "proxy",
"prompt-size",
"resume",
"send", "sessions", "setup",
"skin", "skills", "slack", "status", "sync", "tools", "uninstall", "update",
"version", "webhook", "whatsapp", "whatsapp-cloud", "chat", "secrets", "security",
@ -11480,6 +11482,11 @@ def main():
# =========================================================================
build_status_parser(subparsers, cmd_status=cmd_status)
# =========================================================================
# pause / resume commands (parser built in hermes_cli/subcommands/pause.py)
# =========================================================================
build_pause_parser(subparsers)
# =========================================================================
# cron command (parser built in hermes_cli/subcommands/cron.py)
# =========================================================================

View File

@ -112,6 +112,23 @@ def _effective_provider_label() -> str:
from hermes_constants import is_termux as _is_termux
def _estop_status_line():
"""One-line pause banner for `hermes status`, or None when not paused.
Cheap: a single stat on $HERMES_HOME/ESTOP via agent.estop.
"""
try:
from agent.estop import get_state
except ImportError:
return None
state = get_state()
if state is None:
return None
reason = state.get("reason")
suffix = f" — reason: {reason}" if reason else ""
return f"⏸️ PAUSED (global emergency stop{suffix}; `hermes resume` to lift)"
def show_status(args):
"""Show status of all Hermes Agent components."""
deep = getattr(args, 'deep', False)
@ -121,6 +138,11 @@ def show_status(args):
print(color("│ ⚕ Hermes Agent Status │", Colors.CYAN))
print(color("└─────────────────────────────────────────────────────────┘", Colors.CYAN))
_paused_line = _estop_status_line()
if _paused_line:
print()
print(color(_paused_line, Colors.YELLOW, Colors.BOLD))
# =========================================================================
# Environment
# =========================================================================

View File

@ -0,0 +1,70 @@
"""``hermes pause`` / ``hermes resume`` — the global emergency stop.
``hermes pause`` writes the ESTOP sentinel at ``$HERMES_HOME/ESTOP``, which
halts cron dispatch, kanban dispatch, and new gateway turns on their next
check. In-flight work is never killed. ``hermes resume`` removes the
sentinel and normal operation resumes on the next tick no restart needed.
Ported from: gastownhall/gastown estop.go (MIT); related prior art:
#26778 (/panic — kill/exit semantics, different), #44617.
"""
from __future__ import annotations
import argparse
def cmd_pause(args: argparse.Namespace) -> int:
"""Engage the global emergency stop."""
from agent.estop import engage, get_state, is_engaged
reason = getattr(args, "reason", None)
already = is_engaged()
path = engage(reason=reason)
state = get_state() or {}
verb = "Still paused" if already else "Hermes paused"
detail = f" — reason: {state['reason']}" if state.get("reason") else ""
print(f"⏸️ {verb}{detail}")
print(f" sentinel: {path}")
print(
" Cron dispatch, kanban dispatch, and new gateway turns are on hold.\n"
" In-flight work keeps running. Run `hermes resume` to lift the pause."
)
return 0
def cmd_resume(args: argparse.Namespace) -> int:
"""Disengage the global emergency stop."""
from agent.estop import disengage, sentinel_path
if disengage():
print("▶️ Hermes resumed — dispatch picks up on the next tick.")
else:
print(f"Hermes is not paused (no sentinel at {sentinel_path()}).")
return 0
def build_pause_parser(subparsers) -> None:
"""Attach the ``pause`` and ``resume`` subcommands to ``subparsers``."""
pause_parser = subparsers.add_parser(
"pause",
help="Emergency stop: pause cron/kanban dispatch and new gateway turns",
description=(
"Engage the global emergency stop. Halts NEW work only — cron "
"dispatch, kanban dispatch, and new gateway turns — until "
"`hermes resume`. In-flight work is never killed."
),
)
pause_parser.add_argument(
"--reason",
default=None,
help="Optional reason stored in the sentinel and shown to users",
)
pause_parser.set_defaults(func=cmd_pause)
resume_parser = subparsers.add_parser(
"resume",
help="Lift the emergency stop set by `hermes pause`",
description="Remove the ESTOP sentinel; dispatch resumes on the next tick.",
)
resume_parser.set_defaults(func=cmd_resume)

287
tests/test_estop.py Normal file
View File

@ -0,0 +1,287 @@
"""Global emergency stop (`hermes pause` / `hermes resume`) — agent/estop.py.
The ESTOP sentinel is a resumable pause for NEW work only: cron dispatch,
kanban dispatch, and new gateway turns are halted while it is engaged; work
already in flight is never touched. Removing the sentinel (`hermes resume`)
restores normal operation with no restart.
Ported from: gastownhall/gastown estop.go (MIT); related prior art: #26778
(/panic kill/exit semantics, deliberately different) and #44617
(interrupt in-flight cron deliberately NOT done here).
"""
from __future__ import annotations
import argparse
import json
import logging
import pytest
from agent import estop
@pytest.fixture
def hermes_home(tmp_path, monkeypatch):
"""Point HERMES_HOME at a temp dir and reset estop module log state."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
estop._reset_log_state_for_tests()
return tmp_path
# ── sentinel create / remove ────────────────────────────────────────────────
def test_engage_creates_sentinel_and_is_engaged(hermes_home):
assert estop.is_engaged() is False
estop.engage()
assert (hermes_home / "ESTOP").exists()
assert estop.is_engaged() is True
def test_disengage_removes_sentinel(hermes_home):
estop.engage()
assert estop.disengage() is True
assert not (hermes_home / "ESTOP").exists()
assert estop.is_engaged() is False
# Disengaging when not engaged is a no-op that reports False.
assert estop.disengage() is False
def test_reason_and_timestamp_stored(hermes_home):
estop.engage(reason="runaway cron fan-out")
state = estop.get_state()
assert state is not None
assert state["reason"] == "runaway cron fan-out"
assert state["engaged_at"] # ISO timestamp string
raw = json.loads((hermes_home / "ESTOP").read_text(encoding="utf-8"))
assert raw["reason"] == "runaway cron fan-out"
def test_get_state_none_when_disengaged(hermes_home):
assert estop.get_state() is None
def test_corrupt_sentinel_still_engages(hermes_home):
"""A hand-touched/corrupt ESTOP file must still pause (fail safe)."""
(hermes_home / "ESTOP").write_text("not json", encoding="utf-8")
assert estop.is_engaged() is True
state = estop.get_state()
assert state is not None
assert state.get("reason") is None
# ── paused notice for new gateway turns ─────────────────────────────────────
def test_paused_reply_none_when_disengaged(hermes_home):
assert estop.paused_reply() is None
def test_paused_reply_surfaces_reason_and_resume_hint(hermes_home):
estop.engage(reason="deploy window")
notice = estop.paused_reply()
assert notice is not None
assert "paused" in notice.lower()
assert "deploy window" in notice
assert "hermes resume" in notice
def test_paused_reply_without_reason(hermes_home):
estop.engage()
notice = estop.paused_reply()
assert notice is not None
assert "paused" in notice.lower()
assert "hermes resume" in notice
# ── check_paused: cheap gate + log-once ─────────────────────────────────────
def test_check_paused_logs_once_per_engagement(hermes_home, caplog):
logger = logging.getLogger("test.estop.component")
estop.engage()
with caplog.at_level(logging.INFO, logger=logger.name):
assert estop.check_paused("cron", logger) is True
assert estop.check_paused("cron", logger) is True
assert estop.check_paused("cron", logger) is True
paused_logs = [r for r in caplog.records if "paused" in r.getMessage().lower()]
assert len(paused_logs) == 1
# Resume then re-engage → logs once more (transition-based, not forever).
caplog.clear()
estop.disengage()
with caplog.at_level(logging.INFO, logger=logger.name):
assert estop.check_paused("cron", logger) is False
estop.engage()
assert estop.check_paused("cron", logger) is True
assert estop.check_paused("cron", logger) is True
paused_logs = [r for r in caplog.records if "paused" in r.getMessage().lower()]
assert len(paused_logs) == 1
# ── cron scheduler integration ──────────────────────────────────────────────
def test_cron_tick_skips_dispatch_when_engaged(hermes_home, monkeypatch):
from cron import scheduler
calls = []
def _fake_get_due_jobs():
calls.append(1)
return []
monkeypatch.setattr(scheduler, "get_due_jobs", _fake_get_due_jobs)
estop.engage(reason="test")
assert scheduler.tick(verbose=False) == 0
assert calls == [], "engaged ESTOP must skip the due-job scan entirely"
def test_cron_tick_resumes_after_disengage(hermes_home, monkeypatch):
from cron import scheduler
calls = []
def _fake_get_due_jobs():
calls.append(1)
return []
monkeypatch.setattr(scheduler, "get_due_jobs", _fake_get_due_jobs)
estop.engage()
scheduler.tick(verbose=False)
assert calls == []
estop.disengage()
scheduler.tick(verbose=False)
assert calls == [1], "resume must restore normal cron dispatch"
# ── kanban dispatcher integration ───────────────────────────────────────────
def test_kanban_dispatch_blocked_when_engaged(hermes_home):
from gateway.kanban_watchers import _kanban_dispatch_allowed
assert _kanban_dispatch_allowed() is True
estop.engage(reason="test")
assert _kanban_dispatch_allowed() is False
estop.disengage()
assert _kanban_dispatch_allowed() is True
# ── gateway turn-start integration ──────────────────────────────────────────
class _FakeSource:
platform = None
chat_id = "c1"
user_id = "u1"
user_name = "user"
chat_type = "dm"
profile = None
class _FakeEvent:
internal = False
text = "hello"
def __init__(self):
self.source = _FakeSource()
@pytest.mark.asyncio
async def test_gateway_new_turn_gets_paused_reply(hermes_home):
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner._is_user_authorized = lambda source: True # bare-instance stub
estop.engage(reason="maintenance")
reply = await runner._handle_message(_FakeEvent())
assert reply is not None
assert "paused" in reply.lower()
assert "maintenance" in reply
@pytest.mark.asyncio
async def test_gateway_internal_events_bypass_estop(hermes_home):
"""Internal events (in-flight work completions) must NOT be paused."""
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
estop.engage()
event = _FakeEvent()
event.internal = True
# An internal event proceeds past the estop gate; the bare runner then
# blows up further down the pipeline on missing attributes — that error
# (anything but a paused reply) proves the gate let it through.
try:
reply = await runner._handle_message(event)
except Exception:
return
assert reply is None or "paused" not in (reply or "").lower()
# ── CLI: hermes pause / hermes resume ───────────────────────────────────────
def test_cli_pause_engages_with_reason(hermes_home, capsys):
from hermes_cli.subcommands.pause import cmd_pause
rc = cmd_pause(argparse.Namespace(reason="ops incident"))
assert rc == 0
assert estop.is_engaged() is True
assert estop.get_state()["reason"] == "ops incident"
assert "paused" in capsys.readouterr().out.lower()
def test_cli_pause_idempotent(hermes_home, capsys):
from hermes_cli.subcommands.pause import cmd_pause
assert cmd_pause(argparse.Namespace(reason=None)) == 0
assert cmd_pause(argparse.Namespace(reason=None)) == 0
assert estop.is_engaged() is True
def test_cli_resume_disengages(hermes_home, capsys):
from hermes_cli.subcommands.pause import cmd_pause, cmd_resume
cmd_pause(argparse.Namespace(reason=None))
rc = cmd_resume(argparse.Namespace())
assert rc == 0
assert estop.is_engaged() is False
assert "resumed" in capsys.readouterr().out.lower()
def test_cli_resume_when_not_paused(hermes_home, capsys):
from hermes_cli.subcommands.pause import cmd_resume
rc = cmd_resume(argparse.Namespace())
assert rc == 0
assert "not paused" in capsys.readouterr().out.lower()
def test_builtin_subcommands_include_pause_resume():
from hermes_cli.main import _BUILTIN_SUBCOMMANDS
assert "pause" in _BUILTIN_SUBCOMMANDS
assert "resume" in _BUILTIN_SUBCOMMANDS
# ── hermes status surfacing ─────────────────────────────────────────────────
def test_status_line_when_paused(hermes_home):
from hermes_cli.status import _estop_status_line
assert _estop_status_line() is None
estop.engage(reason="ops")
line = _estop_status_line()
assert line is not None
assert "paused" in line.lower()
assert "ops" in line
estop.disengage()
assert _estop_status_line() is None