Files
hermes-agent/tests/e2e/core/kanban/_helpers.py
teknium1 4573438877 test(kanban-e2e): review fixes: live-worker TTL extension, visible artifact refusal, structural reviewer detection
- dispatcher_restart: new scenario — a live worker hung on its provider call with a 2 s claim
  TTL must get claim_extended on every expired tick, keep its pid, never be reclaimed and never
  get a duplicate spawn (one first-call bill); then the same worker finishes the card.
  Sabotage `if False and (host_local ...)` in release_stale_claims -> RED ("live worker's claim
  reclaimed").
- worker_contract[outside]: when the card is not done, require a visible refusal naming the
  artifact (kanban_complete tool error, a block/refuse event, or task_runs.error) and no crash
  loop (one run, not crashed, no gave_up). #119619 KnownGap now also needs the worker's log to
  name the missing skill or a nonzero exit trailer.
- rate_limit_review: the review role is detected by a marker in a scratch-home sdlc-review
  skill body (the review lane preloads it), not by prompt wording.
- billing bounds: a finishing attempt's first answer is the terminal board call plus at most
  one closing turn (dropping the closing call is allowed).
- footgun hygiene: encoding="utf-8" on read_text, Linux/POSIX-gated marks on SIGKILL/killpg/kill(0).
2026-09-24 04:26:19 -07:00

186 lines
7.3 KiB
Python

"""Real-process Kanban board harness: a scratch HOME/HERMES_HOME, the recording fake provider, and
the real ``hermes kanban`` CLI (dispatcher ticks spawn real ``hermes chat -q`` workers).
Nothing below imports the dispatcher: every board mutation goes through a child process and every
verdict is read back from ``kanban.db`` (tasks / task_runs / task_events rows) or the files on disk.
"""
from __future__ import annotations
import json
import os
import signal
import sqlite3
import subprocess
import sys
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable, Optional
REPO = Path(__file__).resolve().parents[4]
PY = sys.executable
# Clocks shrunk through the documented env knobs, never by patching code.
FAST_ENV = {
"HERMES_KANBAN_CRASH_GRACE_SECONDS": "0",
"HERMES_KANBAN_RATE_LIMIT_COOLDOWN_SECONDS": "0",
}
def wait_until(pred: Callable[[], Any], timeout: float, what: str, interval: float = 0.1) -> Any:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
val = pred()
if val:
return val
time.sleep(interval)
raise AssertionError(f"timed out after {timeout:.0f}s waiting for {what}")
def pid_alive(pid: Optional[int]) -> bool:
if not pid:
return False
try:
os.kill(int(pid), 0) # windows-footgun: ok — Linux-gated (module skips off Linux)
except (ProcessLookupError, PermissionError):
return False
# A zombie child of ours still answers kill(0); /proc tells the truth.
try:
stat = Path(f"/proc/{int(pid)}/stat").read_text(encoding="utf-8")
return stat.rsplit(")", 1)[1].split()[0] != "Z"
except OSError:
return False
@dataclass
class Board:
"""One isolated Kanban install driven only through real child processes."""
root: Path
base_url: str
extra_config: str = ""
env_extra: dict[str, str] = field(default_factory=dict)
spawned_pids: set[int] = field(default_factory=set)
def __post_init__(self) -> None:
self.home = self.root / "home"
self.hermes_home = self.home / ".hermes"
self.hermes_home.mkdir(parents=True, exist_ok=True)
(self.hermes_home / "config.yaml").write_text(
"model:\n"
" provider: custom\n"
f" base_url: {self.base_url}\n"
" default: fake-model\n"
" context_length: 128000\n"
"agent:\n"
" api_max_retries: 1\n"
"updates:\n"
" check: false\n"
+ self.extra_config,
encoding="utf-8",
)
(self.hermes_home / ".env").write_text("OPENAI_API_KEY=sk-fake-e2e\n", encoding="utf-8")
# Workers run with cwd=<task workspace>; the wrapper pins THIS checkout on the path.
self.hermes_bin = self.root / "hermes-bin"
self.hermes_bin.write_text(
"#!/bin/sh\n"
f"PYTHONPATH={REPO} exec {PY} -m hermes_cli.main \"$@\"\n", encoding="utf-8")
self.hermes_bin.chmod(0o755)
# env / processes -------------------------------------------------------
def env(self) -> dict[str, str]:
env = {k: v for k, v in os.environ.items()
if not k.startswith("HERMES_") and not k.endswith(("_API_KEY", "_TOKEN"))}
env.pop("PYTEST_CURRENT_TEST", None)
env.update({
"HOME": str(self.home), "HERMES_HOME": str(self.hermes_home),
"HERMES_BIN": str(self.hermes_bin), "PYTHONPATH": str(REPO),
"NO_COLOR": "1", "TERM": "dumb",
# Children keep pytest's PYTEST_VERSION, which arms the live-DB guard against the scratch
# HOME's own state.db; the whole tree is under ``root`` (asserted below), so let workers
# open their real session store.
"HERMES_STATE_DB_GUARD_BYPASS": "1",
**FAST_ENV, **self.env_extra,
})
assert env["HERMES_HOME"].startswith(str(self.root))
return env
def cli(self, *args: str, timeout: float = 90.0, check: bool = True) -> subprocess.CompletedProcess:
proc = subprocess.run(
[PY, "-m", "hermes_cli.main", "kanban", *args], cwd=str(self.root), env=self.env(),
capture_output=True, text=True, timeout=timeout, stdin=subprocess.DEVNULL,
)
if check and proc.returncode != 0:
raise AssertionError(f"kanban {' '.join(args)} rc={proc.returncode}\n{proc.stdout}\n{proc.stderr}")
return proc
def cli_json(self, *args: str, **kw: Any) -> Any:
out = self.cli(*args, "--json", **kw).stdout
return json.loads(out[out.index("{"):] if "{" in out else out)
def create(self, title: str, *extra: str) -> str:
return self.cli_json("create", title, "--assignee", "default", *extra)["id"]
def dispatch(self, *extra: str) -> dict:
res = self.cli_json("dispatch", *extra)
for tid in [s["task_id"] for s in res.get("spawned", [])]:
pid = self.task(tid)["worker_pid"]
if pid:
self.spawned_pids.add(int(pid))
return res
def kill_workers(self) -> None:
for pid in list(self.spawned_pids):
try:
os.kill(pid, signal.SIGKILL) # windows-footgun: ok — Linux-gated (module skips off Linux)
except (ProcessLookupError, PermissionError):
pass
# state readback --------------------------------------------------------
@property
def db_path(self) -> Path:
return self.hermes_home / "kanban.db"
def _q(self, sql: str, args: tuple = ()) -> list[dict]:
conn = sqlite3.connect(f"file:{self.db_path}?mode=ro", uri=True, timeout=30)
conn.row_factory = sqlite3.Row
try:
return [dict(r) for r in conn.execute(sql, args).fetchall()]
finally:
conn.close()
def task(self, tid: str) -> dict:
rows = self._q("SELECT * FROM tasks WHERE id = ?", (tid,))
assert rows, f"task {tid} row is gone"
return rows[0]
def tasks(self) -> list[dict]:
return self._q("SELECT * FROM tasks ORDER BY created_at, id")
def runs(self, tid: str) -> list[dict]:
return self._q("SELECT * FROM task_runs WHERE task_id = ? ORDER BY id", (tid,))
def events(self, tid: str, kind: Optional[str] = None) -> list[dict]:
rows = self._q("SELECT * FROM task_events WHERE task_id = ? ORDER BY id", (tid,))
for r in rows:
try:
r["payload"] = json.loads(r.get("payload") or "null")
except (TypeError, ValueError):
pass
return [r for r in rows if kind is None or r["kind"] == kind]
def worker_log(self, tid: str) -> str:
hits = list(self.hermes_home.rglob(f"{tid}.log"))
return hits[0].read_text(encoding="utf-8", errors="replace") if hits else ""
def wait_worker_exit(self, tid: str, pid: int, timeout: float = 90.0) -> None:
wait_until(lambda: not pid_alive(pid), timeout,
f"worker {pid} of {tid} to exit; log tail:\n{self.worker_log(tid)[-2000:]}")
def diag(self, tid: str) -> str:
return (f"task={json.dumps(self.task(tid), default=str)[:1500]}\n"
f"runs={json.dumps(self.runs(tid), default=str)[:2500]}\n"
f"events={[(e['kind'], e['payload']) for e in self.events(tid)][-15:]}\n"
f"log tail:\n{self.worker_log(tid)[-2500:]}")