c408601937
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).
237 lines
7.7 KiB
Python
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
|