Files
teknium1 23366b44f3 test(e2e): close the core-suite review findings (narrow xfails, surfaced handler errors, no-retry CI)
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).
2026-09-23 14:54:36 -07:00

387 lines
16 KiB
Python

"""Child-process roles for the SQLite torture chamber.
Run as ``python _roles.py <role> <json-args>``. Every role is a real OS process that opens the shared
``state.db`` through the production ``SessionDB`` (or, for the non-hermes opener, a bare ``sqlite3``
connection) and reports through append-only files, so a ``kill -9`` loses nothing it already reported:
* ``<name>.journal`` — writers: ``I <tok> <sid> <nrows>`` before an append, ``A <tok>`` once it returned.
* ``<name>.report`` — JSON lines: ``ready`` / ``stats`` / ``error`` / ``closed`` events.
Tokens are single FTS words (``TK`` + alnum) so the test can look every acknowledged append up by content,
through ``messages`` and through the FTS indexes.
Journal-mode seam: with ``HERMES_E2E_SQLITE_VERSION_PIN`` set, the production version probe
``hermes_state_wal.is_sqlite_wal_reset_vulnerable()`` reports that SQLite version instead of the linked one;
the real range predicate and ``apply_wal_with_fallback`` then decide the journal mode as they would there.
"""
from __future__ import annotations
import json
import os
import signal
import sqlite3
import sys
import time
import traceback
from pathlib import Path
REPO = Path(__file__).resolve().parents[4]
sys.path.insert(0, str(REPO))
TOOL_FILLER = "y" * 3000 # longer than the FTS tool-content prefix, so the projection is exercised
_stop_requested = False
def _on_sigterm(_signum, _frame):
# Gateway/TUI shape: SIGTERM is a graceful shutdown that ends in SessionDB.close() (checkpoint on close).
global _stop_requested
_stop_requested = True
class Out:
"""O_APPEND line writers: every line reaches the page cache before the next action, so it survives
a SIGKILL of this process (only a kernel crash could lose it)."""
def __init__(self, workdir: Path, name: str):
self.journal_fd = os.open(workdir / f"{name}.journal", os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
self.report_fd = os.open(workdir / f"{name}.report", os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
def journal(self, line: str) -> None:
os.write(self.journal_fd, (line + "\n").encode())
def report(self, **event) -> None:
event.setdefault("pid", os.getpid())
event.setdefault("t", time.time())
os.write(self.report_fd, (json.dumps(event) + "\n").encode())
def _apply_sqlite_version_pin() -> None:
pin = os.environ.get("HERMES_E2E_SQLITE_VERSION_PIN")
if not pin:
return
import hermes_state_wal
pinned = tuple(int(p) for p in pin.split("."))
probe = hermes_state_wal.is_sqlite_wal_reset_vulnerable
def is_sqlite_wal_reset_vulnerable(version_info=None):
return probe(pinned if version_info is None else version_info)
hermes_state_wal.is_sqlite_wal_reset_vulnerable = is_sqlite_wal_reset_vulnerable
def _patient(a: dict, out: Out, op: str, fn, *, deadline: float = 90.0):
"""Run ``fn()``; in the DELETE arm (``busy_ok``) a SQLITE_BUSY refusal is reported as a ``busy`` event and
retried. DELETE mode is documented to block readers on writes (hermes_state_wal), so a busy read/open is an
availability event there, never an integrity one. In the WAL arm it propagates and fails the role."""
end = time.monotonic() + deadline
while True:
try:
return fn()
except sqlite3.OperationalError as exc:
# By result code, not text: SQLITE_BUSY also surfaces as "vtable constructor failed:
# messages_fts" when the FTS5 table's config read hits the lock during an open.
busy = getattr(exc, "sqlite_errorcode", None) in (sqlite3.SQLITE_BUSY, sqlite3.SQLITE_LOCKED) or any(
m in str(exc).lower() for m in ("database is locked", "database is busy"))
if not (busy and a.get("busy_ok")) or time.monotonic() > end:
raise
out.report(event="busy", op=op, error=repr(exc))
time.sleep(0.05)
def _fd_count() -> int:
return len(os.listdir("/proc/self/fd")) if os.path.isdir("/proc/self/fd") else -1
def _stray_close(db_path: Path) -> None:
"""What a plugin, a tool read of ~/.hermes or a header probe does: open + close the live files in THIS
process. POSIX drops every lock this process holds on the inode (sqlite.org/howtocorrupt.html §2.2)."""
for suffix in ("", "-shm", "-wal"):
try:
os.close(os.open(str(db_path) + suffix, os.O_RDONLY))
except OSError:
pass
def _stopping(stop_file: Path) -> bool:
return _stop_requested or stop_file.exists()
def role_writer(a: dict, out: Out) -> int:
"""Long-lived writer (gateway- or TUI-like): appends until told to stop, journaling intent + ack."""
from hermes_state import SessionDB
db_path, stop_file = Path(a["db"]), Path(a["stop"])
tag = a["tag"]
db = SessionDB(db_path=db_path)
for sid in a["sessions"]:
if db.get_session(sid) is None:
db.create_session(sid, a.get("source", "cli"))
out.report(event="ready", wal=bool(getattr(db, "_wal_active", False)), fds=_fd_count())
i = 0
max_appends = int(a.get("max_appends", 10**9))
try:
while not _stopping(stop_file) and i < max_appends:
sid = a["sessions"][i % len(a["sessions"])]
tok = f"TK{tag}x{os.getpid()}x{i}"
if a.get("batch_every") and i % a["batch_every"] == 0:
msgs = [
{"role": "user", "content": f"{tok}u please run it"},
{"role": "tool", "content": f"{tok}t {TOOL_FILLER}", "tool_name": "terminal",
"tool_call_id": f"c{i}"},
]
out.journal(f"I {tok}u {sid} 1")
out.journal(f"I {tok}t {sid} 1")
db.append_messages_batch(sid, msgs)
out.journal(f"A {tok}u")
out.journal(f"A {tok}t")
else:
role = ("user", "assistant")[i % 2]
out.journal(f"I {tok} {sid} 1")
db.append_message(sid, role=role, content=f"{tok} turn {i} of {tag}")
out.journal(f"A {tok}")
i += 1
if a.get("stray_every") and i % a["stray_every"] == 0:
_stray_close(db_path)
if a.get("pace"):
time.sleep(a["pace"])
except BaseException as exc: # a refused/failed append is exactly what the suite exists to catch
out.report(event="error", error=repr(exc), tb=traceback.format_exc()[-3000:], appends=i)
return 2
out.report(event="stats", appends=i, fds=_fd_count())
try:
db.close()
except BaseException as exc:
out.report(event="error", error=f"close: {exc!r}", tb=traceback.format_exc()[-3000:])
return 2
out.report(event="closed", appends=i)
return 0
def role_reader(a: dict, out: Out) -> int:
"""Dashboard-like reader: opens a writable SessionDB at startup and polls until stopped. Reports any
per-session count that went DOWN (no compaction runs in this chamber) and its fd count per pass."""
from hermes_state import SessionDB
db_path, stop_file = Path(a["db"]), Path(a["stop"])
db = _patient(a, out, "open", lambda: SessionDB(db_path=db_path))
out.report(event="ready", wal=bool(getattr(db, "_wal_active", False)), fds=_fd_count())
seen: dict[str, int] = {}
passes = 0
def _pass() -> None:
for row in db.list_sessions_rich(limit=200):
sid = row["id"]
n = db.message_count(sid)
if n < seen.get(sid, 0):
out.report(event="error", error=f"count went down for {sid}: {seen[sid]} -> {n}")
seen[sid] = max(n, seen.get(sid, 0))
try:
while not _stopping(stop_file):
_patient(a, out, "read", _pass)
passes += 1
if passes % 5 == 0:
out.report(event="stats", passes=passes, fds=_fd_count(), total=sum(seen.values()))
time.sleep(a.get("pace", 0.05))
except BaseException as exc:
out.report(event="error", error=repr(exc), tb=traceback.format_exc()[-3000:])
return 2
out.report(event="stats", passes=passes, fds=_fd_count(), total=sum(seen.values()))
db.close()
out.report(event="closed")
return 0
def role_churn(a: dict, out: Out) -> int:
"""`hermes sessions list` / doctor / cron-guard shape: open, read, close — ``iterations`` times in one
process, alternating the production SessionDB with a bare sqlite3 opener (sqlite3 shell, backup tool).
The fd count must not grow with the number of cycles."""
from hermes_state import SessionDB
db_path = Path(a["db"])
start_fds = _fd_count()
fds_after_warmup = None
def _hermes_cycle() -> None:
db = SessionDB(db_path=db_path)
try:
db.message_count()
finally:
db.close()
def _raw_cycle() -> None:
conn = sqlite3.connect(str(db_path), timeout=30.0)
try:
conn.execute("SELECT count(*) FROM messages").fetchone()
finally:
conn.close()
try:
for i in range(int(a["iterations"])):
_patient(a, out, "churn", _raw_cycle if i % 2 else _hermes_cycle)
if i == 3:
fds_after_warmup = _fd_count()
except BaseException as exc:
out.report(event="error", error=repr(exc), tb=traceback.format_exc()[-3000:])
return 2
out.report(event="stats", start_fds=start_fds, warm_fds=fds_after_warmup, end_fds=_fd_count(),
iterations=a["iterations"])
return 0
def role_opener(a: dict, out: Out) -> int:
"""One short-lived process: open, count, close, exit (the last-close checkpoint path)."""
db_path = Path(a["db"])
def _open_count_close() -> int:
if a.get("raw"):
conn = sqlite3.connect(str(db_path), timeout=30.0)
try:
return conn.execute("SELECT count(*) FROM messages").fetchone()[0]
finally:
conn.close()
from hermes_state import SessionDB
db = SessionDB(db_path=db_path)
try:
return db.message_count()
finally:
db.close()
try:
n = _patient(a, out, "open", _open_count_close)
except BaseException as exc:
# may_fail: the chmod episode opens a read-only file; a clean refusal is correct, damage is not.
if a.get("may_fail"):
out.report(event="refused", error=repr(exc))
return 0
out.report(event="error", error=repr(exc), tb=traceback.format_exc()[-3000:])
return 2
out.report(event="stats", count=n)
return 0
def _raw_count(db_path: Path) -> int:
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=30.0)
try:
return conn.execute("SELECT count(*) FROM messages").fetchone()[0]
finally:
conn.close()
def role_fts(a: dict, out: Out) -> int:
"""Maintenance pass: full FTS rebuild + optimize through SessionDB (cross-process admission)."""
from hermes_state import SessionDB
db = _patient(a, out, "open", lambda: SessionDB(db_path=Path(a["db"])))
out.report(event="ready")
try:
rebuilt = _patient(a, out, "fts", db.rebuild_fts)
optimized = _patient(a, out, "fts", db.optimize_fts)
except BaseException as exc:
out.report(event="error", error=repr(exc), tb=traceback.format_exc()[-3000:])
return 2
db.close()
out.report(event="stats", rebuilt=rebuilt, optimized=optimized)
return 0
def role_repair(a: dict, out: Out) -> int:
"""`repair_state_db_schema` as the CLI/doctor/startup recovery calls it."""
from hermes_state_repair import repair_state_db_schema
db_path = Path(a["db"])
try:
before = _raw_count(db_path)
result = repair_state_db_schema(db_path, backup=bool(a.get("backup", True)))
after = _raw_count(db_path)
except BaseException as exc:
out.report(event="error", error=repr(exc), tb=traceback.format_exc()[-3000:])
return 2
out.report(event="stats", before=before, after=after,
result={k: (str(v) if v is not None else None) for k, v in result.items()})
return 0
def role_agent(a: dict, out: Out) -> int:
"""A real AIAgent turn loop on the shared state.db, the LLM behind the loopback fake provider.
Every turn is ``user(<base>Q) -> tool call (terminal echo <base>T) -> answer(<base>A)``; the journal
records ``I <base>`` before ``run_conversation`` and ``A <base>`` once it returned (the turn is durable).
With ``compress_at`` the agent runs ``/compress here <keep>`` after that turn, exactly as the CLI/TUI
slash command does, and journals ``C <kept bases…>``. ``resume`` reloads history from state.db first
(a fresh process resuming the session)."""
from agent.conversation_compression_manual import compress_now, parse_compress_args
from hermes_state import SessionDB
from run_agent import AIAgent
stop_file = Path(a["stop"])
db = SessionDB(db_path=Path(a["db"]))
agent = AIAgent(base_url=a["base_url"], api_key="sk-fake-e2e", model="fake-model", quiet_mode=True,
session_db=db, session_id=a["session_id"], skip_context_files=True, skip_memory=True)
history = db.get_messages_as_conversation(a["session_id"]) if a.get("resume") else None
out.report(event="ready", micro=bool(getattr(agent.context_compressor, "_micro_compact_enabled", False)),
resumed=len(history or []), wal=bool(getattr(db, "_wal_active", False)))
bases: list[str] = []
try:
for i in range(int(a["turns"])):
if _stopping(stop_file):
break
base = f"TA{a['tag']}{os.getpid()}N{i}" # TA: agent turns; TK: plain writer rows
out.journal(f"I {base} {a['session_id']} 3")
result = agent.run_conversation(f"{base}Q question {i} " + "filler " * 120,
conversation_history=history)
history = result["messages"]
if not any(base + "A" in str(m.get("content") or "") for m in history):
raise AssertionError(f"turn {i} ended without its answer: {result.get('final_response')!r}")
out.journal(f"A {base}")
bases.append(base)
if a.get("compress_at") is not None and i == int(a["compress_at"]):
keep = int(a.get("keep", 2))
res = compress_now(agent, history, parse_compress_args(f"here {keep}"),
skip_without_window=True)
if res.status != "compressed":
raise AssertionError(f"/compress here {keep} did not compress: {res.status}")
history = res.after_messages
out.journal("C " + " ".join(bases[-keep:]))
except BaseException as exc:
out.report(event="error", error=repr(exc), tb=traceback.format_exc()[-4000:])
return 2
out.report(event="stats", turns=len(bases))
db.close()
out.report(event="closed")
return 0
def role_cli(a: dict, out: Out) -> int:
"""``hermes <argv>``: ``hermes_cli.main`` run as ``__main__``, i.e. ``python -m hermes_cli.main <argv>``."""
import runpy
sys.argv = ["hermes", *a["argv"]]
try:
runpy.run_module("hermes_cli.main", run_name="__main__", alter_sys=True)
except SystemExit as exc:
return exc.code if isinstance(exc.code, int) else (0 if exc.code is None else 1)
return 0
ROLES = {
"agent": role_agent, "cli": role_cli,
"writer": role_writer, "reader": role_reader, "churn": role_churn, "opener": role_opener,
"fts": role_fts, "repair": role_repair,
}
def main() -> int:
role, args = sys.argv[1], json.loads(sys.argv[2])
_apply_sqlite_version_pin()
if role != "cli": # the CLI keeps its own SIGTERM handling
signal.signal(signal.SIGTERM, _on_sigterm)
out = Out(Path(args["workdir"]), args["name"])
return ROLES[role](args, out)
if __name__ == "__main__":
sys.exit(main())