Independent review of #120171 found checks that could not fail. Each is now proven red by a mutation that the old version reported as XFAIL or pass. - chaos/test_tui_gateway_turn_liveness: the orphaned-tool xfail used raises=AssertionError and RpcError subclasses it, so a gateway crash counted as the expected failure. Every invariant is now asserted normally; only the known leftovers (surviving tool tree and the tool_call it leaves without a result, both fixed by #120306) raise ToolOutlivedGateway, the only exception the xfail accepts. The DB check used to sit behind the orphan assert and never ran; running it exposed the dangling tool_call half of the same bug. - history/test_prefix_stability: surface_switch's strict xfail tripped at the first prefix break, before usage and integrity. Messages/system prompt, usage and integrity are asserted first; the tools-array drift is checked last and raises ToolsArrayDrift, the only exception the xfail accepts. - history/test_transcript_ledger: scripted steer/interrupt callables run on the fake provider's handler thread, where an assert only dropped the connection. Script records those failures and the test re-raises them after every turn; steer must land and the interrupted turn must report interrupted=True within 30 s. - fakes/fake_llm_provider: Hang drops the connection at its deadline instead of leaving a kept-alive client waiting past it. - parity: the API server port was picked, released, then bound by the child. Readiness now requires our child's pid from authenticated /health/detailed and retries on a fresh port when the child reports it in use. The fixture guard refused any HERMES_HOME under ~/.hermes, failing all parity tests whenever TMPDIR is Hermes's scratch dir; it now refuses only the live root or a real profile. - chaos/_gateway_harness: the gateway stays in pytest's process group, so the runner's kill of a timed-out file reaches it. - sqlite: a DELETE-mode open can fail with SQLITE_BUSY reported as "vtable constructor failed: messages_fts"; the delete arm's busy tolerance keys on the result code. A failed episode's roles are stopped so the shared chamber and rig no longer fail every later episode. - chaos, compaction, parity homes: updates.check=false (history already had it). The passive update check made a GitHub round-trip from every test surface, and on a blobless clone whose objects lag upstream its `git merge-base --is-ancestor <upstream tip> HEAD` starts a lazy fetch that the 5 s timeout orphans; the orphan scans then failed on git processes. - chaos/test_agent_turn_liveness: a PROBE failure now carries the provider call counts and the agent's stale-kill log, so a cross-turn breaker trip can be told apart from a slow probe. - tests.yml e2e: HERMES_TEST_FILE_RETRIES=0 so a race detector's red is never retried into green; own uv cache entry (cache-suffix: e2e).
307 lines
11 KiB
Python
307 lines
11 KiB
Python
"""Shared oracle helpers for the chaos (agent-turn liveness) E2E lane.
|
|
|
|
The chaos suites drive a REAL Hermes surface (AIAgent child process, GatewayRunner,
|
|
tui_gateway JSON-RPC subprocess) against ``tests/fakes/fake_llm_provider`` in a fault
|
|
mode and then check the same liveness invariants everywhere:
|
|
|
|
* bounded termination — the turn ends (answer or surfaced error) within the
|
|
configured deadline plus a generous epsilon, never "whenever the fault clears";
|
|
* bounded provider calls — no retry storm;
|
|
* reusability — the same session accepts and answers a new message afterwards;
|
|
* history integrity — every assistant ``tool_call`` has a matching ``tool`` row,
|
|
both in state.db and in what is sent back to the model;
|
|
* process hygiene — no process carrying the scenario's tag survives the session.
|
|
|
|
Everything here is surface-agnostic; surface drivers live next to their test file.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import signal
|
|
import sqlite3
|
|
import sys
|
|
import time
|
|
import uuid
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Iterable
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[4]
|
|
|
|
# One scenario's liveness budget. The fake faults last an HOUR; the configured
|
|
# deadlines below are seconds, so a turn that is still running at DEADLINE is
|
|
# waiting on the fault, not merely slow — margins are >10x the healthy runtime.
|
|
STALE_TIMEOUT_S = 3
|
|
REQUEST_TIMEOUT_S = 5
|
|
API_MAX_RETRIES = 2
|
|
MAX_TURNS = 8
|
|
TOOL_TIMEOUT_S = 3
|
|
TURN_DEADLINE_S = 90.0
|
|
# A retry storm is dozens-to-thousands of calls (#92450: ~64/s). Healthy fault
|
|
# handling with API_MAX_RETRIES=2 stays in single digits (stream retries x API retries).
|
|
MAX_PROVIDER_CALLS_PER_FAILED_TURN = 16
|
|
INTERRUPT_DEADLINE_S = 15.0
|
|
# Interrupt scenarios run with deadlines far beyond INTERRUPT_DEADLINE_S so only a
|
|
# working interrupt can end the turn in time.
|
|
LONG_TIMEOUT_S = 600
|
|
PROCESS_REAP_S = 15.0
|
|
|
|
PLAIN_MODEL = "fake-model"
|
|
# A slug with a reasoning stale-timeout floor: an explicit
|
|
# providers.<id>.stale_timeout_seconds must still win over the floor (#115024).
|
|
REASONING_MODEL = "deepseek-r1"
|
|
|
|
|
|
def new_tag() -> str:
|
|
return f"chaos{uuid.uuid4().hex[:12]}"
|
|
|
|
|
|
def chaos_config(
|
|
base_url: str,
|
|
*,
|
|
model: str = PLAIN_MODEL,
|
|
stale_timeout: float = STALE_TIMEOUT_S,
|
|
request_timeout: float = REQUEST_TIMEOUT_S,
|
|
api_max_retries: int = API_MAX_RETRIES,
|
|
max_turns: int = MAX_TURNS,
|
|
extra: str = "",
|
|
) -> str:
|
|
"""config.yaml text with every liveness knob short and explicit (real keys:
|
|
``agent.api_max_retries``, ``agent.auto_recovery_cycles``, ``agent.max_turns``,
|
|
``providers.<id>.request_timeout_seconds`` / ``stale_timeout_seconds``)."""
|
|
return (
|
|
"model:\n"
|
|
" provider: custom\n"
|
|
f" base_url: {base_url}\n"
|
|
f" default: {model}\n"
|
|
" context_length: 128000\n"
|
|
"agent:\n"
|
|
f" api_max_retries: {api_max_retries}\n"
|
|
" auto_recovery_cycles: 0\n"
|
|
f" max_turns: {max_turns}\n"
|
|
"providers:\n"
|
|
" custom:\n"
|
|
f" request_timeout_seconds: {request_timeout}\n"
|
|
f" stale_timeout_seconds: {stale_timeout}\n"
|
|
"compression:\n"
|
|
" enabled: false\n"
|
|
"memory:\n"
|
|
" memory_enabled: false\n"
|
|
" user_profile_enabled: false\n"
|
|
# Offline: the passive update check does a GitHub round-trip and, on a partial clone
|
|
# whose objects lag upstream, spawns a git lazy fetch that outlives the gateway.
|
|
"updates:\n"
|
|
" check: false\n"
|
|
+ extra
|
|
)
|
|
|
|
|
|
def write_chaos_home(root: Path, base_url: str, **cfg: Any) -> tuple[Path, Path]:
|
|
"""Create ``root/home`` (fake $HOME) and ``root/home/.hermes`` (HERMES_HOME)."""
|
|
home = root / "home"
|
|
hermes_home = home / ".hermes"
|
|
hermes_home.mkdir(parents=True, exist_ok=True)
|
|
(hermes_home / "config.yaml").write_text(chaos_config(base_url, **cfg), encoding="utf-8")
|
|
(hermes_home / ".env").write_text("OPENAI_API_KEY=sk-fake-chaos\n", encoding="utf-8")
|
|
return home, hermes_home
|
|
|
|
|
|
def hermetic_env(home: Path, hermes_home: Path, tag: str) -> dict[str, str]:
|
|
"""Child env: fake HOME/HERMES_HOME, no real credentials, repo importable, and a
|
|
tag every descendant inherits so the orphan scan can find it after reparenting."""
|
|
env = {
|
|
k: v for k, v in os.environ.items()
|
|
if not (
|
|
k.endswith(("_API_KEY", "_TOKEN", "_SECRET"))
|
|
or k.startswith(("HERMES_", "OPENROUTER", "ANTHROPIC", "OPENAI", "NOUS_"))
|
|
or k in {"PYTEST_CURRENT_TEST"}
|
|
)
|
|
}
|
|
env.update({
|
|
"HOME": str(home),
|
|
"HERMES_HOME": str(hermes_home),
|
|
"OPENAI_API_KEY": "sk-fake-chaos",
|
|
"PYTHONPATH": str(REPO_ROOT),
|
|
"PYTHONUNBUFFERED": "1",
|
|
"PYTHONFAULTHANDLER": "1",
|
|
"CHAOS_TAG": tag,
|
|
# HOME *is* the tmp root, so the live-DB guard (pytest ancestry) would read the
|
|
# tmp state.db as "production"; the documented child opt-out is safe here.
|
|
"HERMES_STATE_DB_GUARD_BYPASS": "1",
|
|
"TZ": "UTC",
|
|
"NO_COLOR": "1",
|
|
})
|
|
return env
|
|
|
|
|
|
# ── process hygiene ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def tagged_pids(tag: str, *, exclude: Iterable[int] = ()) -> list[int]:
|
|
"""PIDs whose environment carries ``CHAOS_TAG=<tag>`` (survives reparenting to init)."""
|
|
needle = f"CHAOS_TAG={tag}".encode()
|
|
skip = {os.getpid(), *exclude}
|
|
found: list[int] = []
|
|
for entry in os.listdir("/proc"):
|
|
if not entry.isdigit() or int(entry) in skip:
|
|
continue
|
|
try:
|
|
with open(f"/proc/{entry}/environ", "rb") as fh:
|
|
data = fh.read()
|
|
with open(f"/proc/{entry}/stat", "rb") as fh:
|
|
state = fh.read().rsplit(b")", 1)[1].split()[0]
|
|
except OSError:
|
|
continue
|
|
if state == b"Z":
|
|
continue # zombie: already dead, only waiting on its parent's reap
|
|
if needle in data.split(b"\0"):
|
|
found.append(int(entry))
|
|
return found
|
|
|
|
|
|
def describe_pids(pids: Iterable[int]) -> list[str]:
|
|
out = []
|
|
for pid in pids:
|
|
try:
|
|
cmd = Path(f"/proc/{pid}/cmdline").read_bytes().replace(b"\0", b" ").decode(errors="replace")
|
|
except OSError:
|
|
cmd = "?"
|
|
out.append(f"{pid}: {cmd[:160]}")
|
|
return out
|
|
|
|
|
|
def wait_no_tagged(tag: str, timeout: float = PROCESS_REAP_S) -> list[int]:
|
|
"""Poll until no tagged process remains; return survivors at the deadline."""
|
|
deadline = time.monotonic() + timeout
|
|
while True:
|
|
left = tagged_pids(tag)
|
|
if not left or time.monotonic() >= deadline:
|
|
return left
|
|
time.sleep(0.1)
|
|
|
|
|
|
def kill_tagged(tag: str) -> None:
|
|
"""Test cleanup only: SIGKILL anything still carrying our tag (never pkill -f).
|
|
|
|
The conftest live-system guard refuses os.kill on PIDs reparented out of the test's
|
|
subtree — exactly the orphans a red run leaves behind. The tag proves they are ours,
|
|
so fall back to a pidfd signal (also immune to PID reuse) instead of leaking them."""
|
|
for pid in tagged_pids(tag):
|
|
try:
|
|
os.kill(pid, signal.SIGKILL)
|
|
except OSError:
|
|
pass
|
|
except RuntimeError:
|
|
pidfd_open = getattr(os, "pidfd_open", None) # absent on some Python builds
|
|
if pidfd_open is None:
|
|
continue
|
|
try:
|
|
fd = pidfd_open(pid)
|
|
except OSError:
|
|
continue
|
|
try:
|
|
signal.pidfd_send_signal(fd, signal.SIGKILL)
|
|
except OSError:
|
|
pass
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
# ── history integrity ───────────────────────────────────────────────────────
|
|
|
|
|
|
def _call_ids(msg: dict[str, Any]) -> list[str]:
|
|
calls = msg.get("tool_calls") or []
|
|
if isinstance(calls, str):
|
|
try:
|
|
calls = json.loads(calls)
|
|
except json.JSONDecodeError:
|
|
return ["<unparseable tool_calls>"]
|
|
return [c.get("id") or c.get("call_id") or "<missing id>" for c in calls if isinstance(c, dict)]
|
|
|
|
|
|
def unanswered_tool_calls(messages: list[dict[str, Any]]) -> list[str]:
|
|
"""tool_call ids with no ``tool`` message in the block right after their assistant.
|
|
|
|
Wire rule (every provider): an assistant ``tool_calls`` message must be followed by
|
|
one ``tool`` result per id before the next assistant/user message.
|
|
"""
|
|
missing: list[str] = []
|
|
for i, msg in enumerate(messages):
|
|
if msg.get("role") != "assistant":
|
|
continue
|
|
ids = _call_ids(msg)
|
|
if not ids:
|
|
continue
|
|
answered = set()
|
|
for follow in messages[i + 1:]:
|
|
if follow.get("role") != "tool":
|
|
break
|
|
answered.add(follow.get("tool_call_id"))
|
|
missing.extend(tid for tid in ids if tid not in answered)
|
|
return missing
|
|
|
|
|
|
def tool_results_by_id(messages: list[dict[str, Any]]) -> dict[str, str]:
|
|
out: dict[str, str] = {}
|
|
for msg in messages:
|
|
if msg.get("role") == "tool":
|
|
content = msg.get("content")
|
|
if not isinstance(content, str):
|
|
content = json.dumps(content)
|
|
out[msg.get("tool_call_id") or ""] = content
|
|
return out
|
|
|
|
|
|
def persisted_messages(state_db: Path, session_id: str | None = None) -> list[dict[str, Any]]:
|
|
"""Active message rows from a real state.db, oldest first, OpenAI-shaped.
|
|
|
|
With ``session_id=None`` the newest session that has messages is used."""
|
|
conn = sqlite3.connect(f"file:{state_db}?mode=ro", uri=True, timeout=10)
|
|
try:
|
|
conn.row_factory = sqlite3.Row
|
|
if session_id is None:
|
|
row = conn.execute(
|
|
"SELECT session_id FROM messages ORDER BY id DESC LIMIT 1").fetchone()
|
|
if row is None:
|
|
return []
|
|
session_id = row["session_id"]
|
|
rows = conn.execute(
|
|
"SELECT role, content, tool_call_id, tool_calls FROM messages "
|
|
"WHERE session_id = ? AND active = 1 ORDER BY id", (session_id,)).fetchall()
|
|
finally:
|
|
conn.close()
|
|
msgs = []
|
|
for r in rows:
|
|
m: dict[str, Any] = {"role": r["role"], "content": r["content"]}
|
|
if r["tool_call_id"]:
|
|
m["tool_call_id"] = r["tool_call_id"]
|
|
if r["tool_calls"]:
|
|
m["tool_calls"] = r["tool_calls"]
|
|
msgs.append(m)
|
|
return msgs
|
|
|
|
|
|
def integrity_ok(state_db: Path) -> str:
|
|
conn = sqlite3.connect(f"file:{state_db}?mode=ro", uri=True, timeout=10)
|
|
try:
|
|
return conn.execute("PRAGMA integrity_check").fetchone()[0]
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
@dataclass
|
|
class Timing:
|
|
started: float
|
|
ended: float | None = None
|
|
|
|
@property
|
|
def elapsed(self) -> float:
|
|
return (self.ended or time.monotonic()) - self.started
|
|
|
|
|
|
def python_exe() -> str:
|
|
return sys.executable
|