Files
hermes-agent/tests/hermes_cli/test_web_server_idle_proof.py
ethernet 59624b7ace tests: replace every bare host skipif with platforms()
The OS lanes are marker-driven: list_os_marked_tests.py picks the files
a lane imports from their platforms() specs and the lane selects with
-m platforms. A test gated with skipif(sys.platform != "win32") is
therefore never imported on the Windows lane and skipped everywhere else
— it runs on no host. skipif(sys.platform == "win32") tests were merely
invisible to the lane bookkeeping, but the rule the tree now follows is
one host marker, never a bare skipif.

Mechanical mapping, semantics preserved: skip-on-Windows → "posix",
skip-off-Windows → "windows", skip-off-Linux → "linux", skip-on-macOS →
"not macos". The former skip reasons stay as trailing comments. A
non-host condition (os.geteuid() == 0) stays a separate skipif beside
the marker, spelled getattr(os, "geteuid", ...) so the decorator still
imports on Windows.

Where the conversion would stack two platforms() marks on one test (the
conftest rejects that at collection) the narrower mark wins:
- test_update_wedged_gateway: the class is already platforms("linux");
  its per-test "needs UNIX sockets" marks were redundant and are gone.
- test_process_registry.TestSystemdCgroupIsolation: the class-level
  skip-on-Windows moves onto the 11 methods that had no host mark; the
  11 platforms("linux") methods keep theirs.
- test_file_ops_single_roundtrip: the two fifo tests drop their
  platforms("linux") in favour of the module's "posix" (mkfifo exists on
  macOS; both tests already skip when it does not).
- test_linux_desktop_entry / test_gateway_job_teardown_live: duplicate
  or wider marks removed.
2026-09-21 19:18:15 -04:00

241 lines
10 KiB
Python

"""A Desktop-pooled ``hermes serve`` child must PROVE it is idle before the Desktop may retire it
for a foreground open (supersedes #104871, whose idle signal was renderer bookkeeping).
Occupied is not busy: the renderer keeps every pinned tile's backend keepalive-fresh forever, so
the Desktop needs the backend's own ledgers — running sessions, running cron jobs (the desktop
child runs the in-process ticker), and prompts waiting on a human. Anything unreadable is
``None`` and the caller treats it as busy.
"""
import os
import socket
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.request
import json
from pathlib import Path
import pytest
from hermes_cli.web_server_idle_proof import idle_proof, pending_human_input
REPO_ROOT = Path(__file__).resolve().parents[2]
def test_idle_proof_is_true_only_when_every_ledger_is_provably_empty():
assert idle_proof(turn_probe=lambda: False, input_probe=lambda: 0) == {"idle": True, "reason": None}
assert idle_proof(turn_probe=lambda: True, input_probe=lambda: 0) == {
"idle": False, "reason": "turn_in_flight", "detail": None}
assert idle_proof(turn_probe=lambda: "session:abc", input_probe=lambda: 0)["detail"] == "session:abc"
assert idle_proof(turn_probe=lambda: False, input_probe=lambda: 1) == {
"idle": False, "reason": "awaiting_human_input"}
# Fail closed: an indeterminate probe is never reported as idle.
assert idle_proof(turn_probe=lambda: None, input_probe=lambda: 0)["idle"] is None
assert idle_proof(turn_probe=lambda: False, input_probe=lambda: None)["idle"] is None
def test_idle_proof_reads_the_real_cron_and_human_input_ledgers():
"""The probe must see a running cron job (invisible to the renderer) and a pending approval
or open clarify request in the live process ledgers, not a renderer-published flag."""
import cron.scheduler as scheduler
from tools import approval
from tui_gateway import server_requests
assert idle_proof()["idle"] is True
with scheduler._running_lock:
scheduler._running_job_ids.add(scheduler._inflight_key("idle-proof-live-job"))
try:
# The busy verdict names the ledger and the job, so a backend that will not retire is diagnosable.
assert idle_proof() == {"idle": False, "reason": "turn_in_flight", "detail": "cron:idle-proof-live-job"}
finally:
with scheduler._running_lock:
scheduler._running_job_ids.discard(scheduler._inflight_key("idle-proof-live-job"))
from tools.approval_gateway_wait import _ApprovalEntry
entry = _ApprovalEntry({"request_id": "idle-proof-approval"})
with approval._lock:
approval._gateway_queues.setdefault("idle-proof-session", []).append(entry)
try:
assert pending_human_input() == 1
assert idle_proof() == {"idle": False, "reason": "awaiting_human_input"}
finally:
with approval._lock:
approval._gateway_queues.pop("idle-proof-session", None)
req = server_requests.ServerRequest("sid-idle-proof", "clarify", {})
with server_requests._lock:
server_requests._open[req.id] = req
try:
assert idle_proof()["idle"] is False
finally:
with server_requests._lock:
server_requests._open.pop(req.id, None)
assert idle_proof()["idle"] is True
def test_queued_prompts_and_unwinding_workers_are_not_idle(monkeypatch):
from tui_gateway import server
session = {"running": False, "history_lock": threading.RLock()}
monkeypatch.setitem(server._sessions, "queued-retirement", session)
server._enqueue_prompt(session, "accepted follow-up", None)
assert idle_proof()["idle"] is False
session.pop("queued_prompt")
release = threading.Event()
thread = threading.Thread(target=lambda: release.wait(10), daemon=True)
session["_run_thread"] = thread
thread.start()
try:
assert idle_proof()["idle"] is False
finally:
release.set()
thread.join(10)
assert idle_proof()["idle"] is True
# ---------------------------------------------------------------------------
# Live children: three real desktop-shaped ``serve`` processes, one held busy
# ---------------------------------------------------------------------------
pytestmark_live = pytest.mark.platforms("posix") # POSIX serve-runner path under test
TOKEN = "idle-proof-live-token"
def _spawn_desktop_child(tmp_path: Path, name: str, *, busy: bool) -> subprocess.Popen:
"""Mirror the Desktop pool spawn: ``HERMES_DESKTOP=1`` (in-process cron ticker), a per-child
HERMES_HOME, an ephemeral port, the session token the Desktop probes with. The busy child
registers a cron run in the scheduler's running-job ledger before it serves — the same
ledger a real fire uses and the one the probe reads."""
home = tmp_path / name
home.mkdir()
env = dict(os.environ)
env.pop("PYTHONPATH", None)
for k in ("HERMES_PARENT_PID", "HERMES_PARENT_START_MARKER", "HERMES_PARENT_NONCE"):
env.pop(k, None)
env.update(
HERMES_HOME=str(home),
HERMES_SERVE_HEADLESS="1",
HERMES_DESKTOP="1",
HERMES_DASHBOARD_SESSION_TOKEN=TOKEN,
PYTHONUNBUFFERED="1",
)
hold = (
"import cron.scheduler as s\n"
"with s._running_lock:\n"
" s._running_job_ids.add(s._inflight_key('live-idle-proof-job'))\n"
) if busy else ""
code = (
hold
+ "from hermes_cli.web_server import start_server\n"
"start_server(host='127.0.0.1', port=0, open_browser=False, headless=True)\n"
)
return subprocess.Popen(
[sys.executable, "-c", code], cwd=str(REPO_ROOT), env=env,
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True,
)
def _read_until(proc: subprocess.Popen, token: str, timeout: float = 120.0):
lines: list[str] = []
hit = threading.Event()
def _pump():
assert proc.stdout is not None
for line in proc.stdout:
lines.append(line)
if token in line:
hit.set()
return
threading.Thread(target=_pump, daemon=True).start()
hit.wait(timeout)
return hit.is_set(), lines
def _probe_settled(port: int, settled, timeout: float = 20.0) -> tuple[int, dict]:
"""The probe is stable BETWEEN ticks, not at every instant: the in-process cron ticker holds
retirement admission for its whole scan (#98745), so a single sample can say
``retirement_admission`` for an idle resident, or name the admission instead of the cron ledger
for a busy child. Poll until *settled(body)* or the window closes; the last verdict is returned
either way, so a genuinely wrong state still fails the assertion."""
deadline = time.monotonic() + timeout
while True:
verdict = _probe(port)
if settled(verdict[1]) or time.monotonic() >= deadline:
return verdict
time.sleep(0.25)
def _probe(port: int, token: str | None = TOKEN) -> tuple[int, dict]:
req = urllib.request.Request(f"http://127.0.0.1:{port}/api/health/idle",
headers={"X-Hermes-Session-Token": token} if token else {})
try:
with urllib.request.urlopen(req, timeout=10) as resp:
return resp.status, json.loads(resp.read().decode())
except urllib.error.HTTPError as exc:
return exc.code, {}
def _await_verdict(port: int, accept, timeout: float = 60.0) -> tuple[tuple[int, dict], list]:
"""Probe until ``accept(verdict)`` or the deadline; returns the last verdict and every one seen."""
deadline = time.monotonic() + timeout
seen: list[tuple[int, dict]] = []
while True:
verdict = _probe(port)
seen.append(verdict)
if accept(verdict) or time.monotonic() >= deadline:
return verdict, seen
time.sleep(0.2)
@pytestmark_live
def test_live_pooled_children_prove_idle_or_busy_over_the_desktop_probe(tmp_path):
"""Three real children at the Desktop's pool cap; exactly one is busy. The Desktop's probe
must get ``idle: false`` from the busy one and ``idle: true`` from the two residents that are
merely occupied. The probe is token-gated: an unauthenticated caller learns nothing."""
children = {
"resident-a": _spawn_desktop_child(tmp_path, "resident-a", busy=False),
"resident-b": _spawn_desktop_child(tmp_path, "resident-b", busy=False),
"cron-busy": _spawn_desktop_child(tmp_path, "cron-busy", busy=True),
}
try:
ports = {}
for name, proc in children.items():
ready, lines = _read_until(proc, "HERMES_BACKEND_READY")
assert ready, f"{name}: no READY sentinel; output:\n{''.join(lines)}"
ready_line = next(l for l in lines if "HERMES_BACKEND_READY" in l)
ports[name] = int(ready_line.strip().rsplit("port=", 1)[1])
# READY precedes quiescence: the desktop child's cron ticker runs its first tick right at
# start and holds retirement admission through the whole scan (``retirement_admission``),
# which on a loaded runner outlasts the first probe. A resident is "provably idle" once that
# startup work drains, so poll until the verdict settles instead of sampling once.
verdicts = {name: _probe_settled(port, lambda b: b.get("idle") is True)
for name, port in ports.items() if name != "cron-busy"}
verdicts["cron-busy"], busy_seen = _await_verdict(
ports["cron-busy"], lambda v: str(v[1].get("detail", "")).startswith("cron:"))
assert verdicts["resident-a"] == (200, {"ok": True, "idle": True, "reason": None}), verdicts
assert verdicts["resident-b"] == (200, {"ok": True, "idle": True, "reason": None}), verdicts
assert verdicts["cron-busy"] == (200, {
"ok": True, "idle": False, "reason": "turn_in_flight", "detail": "cron:live-idle-proof-job"}), verdicts
# Whatever startup work shadowed the cron ledger, the busy child never once claimed idle.
assert all(body.get("idle") is False for _status, body in busy_seen), busy_seen
status, body = _probe(ports["resident-a"], token=None)
assert status == 401 and "idle" not in body
finally:
for proc in children.values():
proc.terminate()
for proc in children.values():
try:
proc.wait(timeout=30)
except subprocess.TimeoutExpired:
proc.kill()