Files
hermes-agent/agent/verify/runner.py
T
Teknium c408601937 refactor(agent/review): simplify curator, background_review, verify, insights, title and learning modules (-22% LOC)
Cluster: agent/{curator,curator_backup,background_review,review_engine,
review_idle_queue,insights,learning_graph,learning_graph_render,
learning_mutations,learn_prompt,verification_evidence,verification_stop,
verify_hooks,side_question,title_generator,turn_summary,
manual_compression_feedback,trajectory,moa_trace,trace_upload,verify/*}.
13662 -> 10693 LOC (-2969, -21.7%), behavior-neutral.

- Dead code: 27 private helpers with zero references removed
  (_auto_title_session, _resolve_review_model, _parse_make_targets,
  _filter_verifiable_paths, _find_subsequence, _is_under_root/_temp_dir,
  _merge_runs, learning_graph_render bucket/period/node helpers,
  _memories_dir/_memory_local_index/_node_detail, _cron_jobs_file,
  _retention_cutoff, _scope_for_args, _clean_token, _count_diff_lines,
  _ordered_verbs, _hermes_meta, _iter_skill_files).
- Unified helpers: _read_config_section (curator + curator_backup),
  _write_file/_write_json (4 curator report writers), _msg_text
  (background_review <- side_question), _report_failure/_notify_title
  (title_generator instant/auto paths), _is_under (verification_evidence),
  _scoped SQL pair builder + _query (insights), _optional_lock
  (background_review), verify.recipes table-driven detection.
- if/elif routing -> dict dispatch: side_question role labels,
  curator_backup summary bits, learning_graph_render buckets, insights
  section rendering, verify recipe pickers.
- Redundant defensive layers, single-use wrappers and verbose narrative
  comments collapsed; every non-obvious WHY/invariant kept in compact form.

Verification: parity.py (all REMOVED symbols zero-ref), import smoke for
every module + cli/run_agent/gateway.run/hermes_cli.main/
agent.conversation_loop/tui_gateway.server, old-vs-new fuzz parity on all
shared pure functions, SQL trace parity for insights and
verification_evidence, cluster tests 1354 passed / 0 failed (46 files).
2026-09-02 13:30:25 -07:00

237 lines
7.7 KiB
Python

"""Verification runner: execute a Recipe's phases and smoke-test the app.
Scoped port of grok-cli's verify sub-agent flow (bootstrap -> build -> test ->
start in background -> readiness loop -> teardown) as a plain subprocess runner.
Commands come from the project's own recipe (package.json scripts, Makefile
targets, ...) and run with ``shell=True`` on purpose: this is a developer tool
running the project's own build commands in its own checkout — the same trust
level as the terminal tool.
"""
from __future__ import annotations
import os
import signal
import subprocess
import time
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable
from agent.verify.recipes import Recipe
DEFAULT_PHASE_TIMEOUT = 600.0
DEFAULT_READY_TIMEOUT = 60.0
_TAIL_CHARS = 2000
PHASE_ORDER = ("bootstrap", "build", "test")
# Project-authored shell commands; see module docstring.
_SUBPROCESS_KW: dict[str, Any] = dict(
shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, errors="replace"
)
@dataclass
class PhaseResult:
phase: str
command: str
exit_code: int | None
duration: float
output_tail: str
timed_out: bool = False
@property
def ok(self) -> bool:
return self.exit_code == 0 and not self.timed_out
def to_dict(self) -> dict[str, Any]:
return {
"phase": self.phase, "command": self.command, "exitCode": self.exit_code,
"duration": round(self.duration, 3), "ok": self.ok, "timedOut": self.timed_out,
"outputTail": self.output_tail,
}
@dataclass
class ReadinessResult:
url: str
ready: bool
status_code: int | None
duration: float
error: str | None = None
output_tail: str = ""
def to_dict(self) -> dict[str, Any]:
return {
"url": self.url, "ready": self.ready, "statusCode": self.status_code,
"duration": round(self.duration, 3), "error": self.error, "outputTail": self.output_tail,
}
@dataclass
class VerifyResult:
recipe_name: str
phases: list[PhaseResult] = field(default_factory=list)
readiness: ReadinessResult | None = None
@property
def ok(self) -> bool:
return all(p.ok for p in self.phases) and (self.readiness is None or self.readiness.ready)
def to_dict(self) -> dict[str, Any]:
return {
"recipe": self.recipe_name, "ok": self.ok,
"phases": [p.to_dict() for p in self.phases],
"readiness": self.readiness.to_dict() if self.readiness else None,
}
def _tail(text: str, limit: int = _TAIL_CHARS) -> str:
return text[-limit:] if len(text) > limit else text
def _run_phase_command(
phase: str,
command: str,
root: Path,
timeout: float,
on_output: Callable[[str], None] | None = None,
) -> PhaseResult:
started = time.monotonic()
timed_out = False
try:
proc = subprocess.run(command, cwd=str(root), timeout=timeout, **_SUBPROCESS_KW)
output = proc.stdout or ""
exit_code: int | None = proc.returncode
except subprocess.TimeoutExpired as exc:
raw = exc.output
output = raw.decode("utf-8", errors="replace") if isinstance(raw, bytes) else (raw or "")
exit_code = None
timed_out = True
duration = time.monotonic() - started
if on_output and output:
on_output(output)
return PhaseResult(phase, command, exit_code, duration, _tail(output), timed_out)
def _poll_readiness(url: str, timeout: float, interval: float = 1.0) -> tuple[bool, int | None, str | None]:
deadline = time.monotonic() + timeout
last_error: str | None = None
while time.monotonic() < deadline:
try:
with urllib.request.urlopen(url, timeout=5) as resp:
return True, resp.status, None
except urllib.error.HTTPError as exc:
# The server answered — it is up, even if it returned 4xx/5xx.
return True, exc.code, None
except (urllib.error.URLError, OSError, TimeoutError) as exc:
last_error = str(exc)
time.sleep(interval)
return False, None, last_error
def _terminate_process_group(proc: subprocess.Popen) -> None:
"""Terminate the started app and its whole process group cleanly.
On POSIX the child is spawned with ``start_new_session=True`` so we can
signal the whole group; on Windows (no ``os.killpg``) we fall back to
terminating just the direct child. SIGTERM first, SIGKILL after 10s.
"""
if proc.poll() is not None:
return
killpg = getattr(os, "killpg", None)
getpgid = getattr(os, "getpgid", None)
pgid = None
if killpg is not None and getpgid is not None:
try:
pgid = getpgid(proc.pid)
except (ProcessLookupError, PermissionError):
pgid = None
def stop(sig: int, fallback: Callable[[], None]) -> None:
if pgid is not None and killpg is not None:
killpg(pgid, sig) # windows-footgun: ok — POSIX-only branch (killpg checked above)
else:
fallback()
try:
stop(signal.SIGTERM, proc.terminate)
except (ProcessLookupError, PermissionError):
return
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
try:
stop(signal.SIGKILL, proc.kill)
except (ProcessLookupError, PermissionError):
pass
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
pass
def _run_start_phase(
recipe: Recipe,
root: Path,
ready_timeout: float,
port_override: int | None = None,
) -> ReadinessResult:
assert recipe.start is not None
port = port_override or recipe.port or 8000
url = f"http://127.0.0.1:{port}{recipe.readiness_path}"
started = time.monotonic()
# start_new_session: own process group for clean teardown.
proc = subprocess.Popen(recipe.start, cwd=str(root), start_new_session=True, **_SUBPROCESS_KW)
output = ""
try:
ready, status, error = _poll_readiness(url, ready_timeout)
finally:
_terminate_process_group(proc)
try:
if proc.stdout is not None:
output = proc.stdout.read() or ""
except (OSError, ValueError):
output = ""
return ReadinessResult(url, ready, status, time.monotonic() - started, error, _tail(output))
def run_verify(
root: Path,
recipe: Recipe,
phases: tuple[str, ...] | list[str] | None = None,
phase_timeout: float = DEFAULT_PHASE_TIMEOUT,
ready_timeout: float = DEFAULT_READY_TIMEOUT,
skip_start: bool = False,
port_override: int | None = None,
stop_on_failure: bool = True,
on_output: Callable[[str], None] | None = None,
) -> VerifyResult:
"""Run a verify pass for ``recipe`` at project ``root``.
Executes the selected command phases sequentially, then (unless
``skip_start`` or a phase failed) launches ``recipe.start`` in the
background, polls the readiness URL, and tears the process group down.
"""
root = Path(root)
selected = tuple(phases) if phases else PHASE_ORDER + ("start",)
result = VerifyResult(recipe_name=recipe.name)
for phase in PHASE_ORDER:
if phase not in selected:
continue
for command in getattr(recipe, phase):
phase_result = _run_phase_command(phase, command, root, phase_timeout, on_output)
result.phases.append(phase_result)
if not phase_result.ok and stop_on_failure:
return result
failed = not all(p.ok for p in result.phases)
if skip_start or "start" not in selected or failed or not recipe.start:
return result
result.readiness = _run_start_phase(recipe, root, ready_timeout, port_override)
return result