Files
pragent/pilot/review/opencode_lenses.py

139 lines
4.7 KiB
Python

"""Parallel execution and selection of opencode review lenses."""
import concurrent.futures as _cf
from .opencode_synthesis import _normalize_lens_finding
LENS_TIMEOUT_S = 540
def _run_one_lens(workdir, spec, model, factory_root, budget=None, budget_state=None):
"""Run one lens through the compatibility module's runtime seam."""
from . import opencode as oc
bin_ = oc._opencode_bin()
home = oc._shared_home()
oc._warm_opencode(home, model)
env = oc._build_env(home)
agent_path = spec.agent_path(factory_root)
prompt = (
f"You are the {spec.id} lens. Read .pragent/brief.md, load the "
f"lens-orchestration skill (mandatory), and return STRICT JSON "
f"findings per that skill. Cap at {spec.max_findings} findings, "
f"severity >= {spec.severity_floor}. The agent markdown you should "
f"load is at {agent_path} (it sets your role + permissions)."
)
cmd = [
bin_, "run", "--pure", "--format", "json",
"--agent", spec.id, "--dir", workdir, "--model", model, prompt,
]
try:
proc = oc._run_process(
cmd, cwd=workdir, env=env,
timeout=min(LENS_TIMEOUT_S, budget.max_duration_seconds)
if budget else LENS_TIMEOUT_S,
parse_events=oc.parse_opencode_events, budget=budget,
budget_state=budget_state, model=model,
)
except oc.subprocess.TimeoutExpired:
print(f"pragent: lens {spec.id} timed out after {LENS_TIMEOUT_S}s", flush=True)
return [], None, spec.id
except Exception as e:
print(f"pragent: lens {spec.id} crashed: {e}", flush=True)
return [], None, spec.id
text, usage = oc.parse_opencode_events(proc.stdout or "")
if not text.strip():
print(
f"pragent: lens {spec.id} empty text (rc={proc.returncode}); "
f"stderr tail: {(proc.stderr or '')[-500:]}",
flush=True,
)
return [], usage, spec.id
obj = oc._extract_json_object(text)
if obj is None:
print(f"pragent: lens {spec.id} produced no parseable JSON", flush=True)
return [], usage, spec.id
raw_findings = obj.get("findings") or []
if not isinstance(raw_findings, list):
return [], usage, spec.id
normalized = [
finding
for raw in raw_findings
if (finding := _normalize_lens_finding(raw, spec, model)) is not None
]
print(
f"pragent: lens {spec.id} findings={len(normalized)} "
f"raw={len(raw_findings)} ok=1",
flush=True,
)
return normalized, usage, spec.id
def run_lenses(workdir, reviewers, default_model, factory_root, budget=None, budget_state=None):
"""Run configured lenses in parallel and return results by lens id."""
if not reviewers:
return {}
from . import opencode as oc
pool_size = min(len(reviewers), oc.MAX_PARALLEL_LENSES)
out = {}
with _cf.ThreadPoolExecutor(max_workers=pool_size) as ex:
futures = {
ex.submit(
_run_one_lens, workdir, spec,
spec.model or default_model, factory_root, budget, budget_state,
): spec
for spec in reviewers
}
for fut in _cf.as_completed(futures):
spec = futures[fut]
try:
findings, usage, _ = fut.result()
except Exception as e:
print(f"pragent: lens {spec.id} worker crashed: {e}", flush=True)
findings, usage = [], None
out[spec.id] = (findings, usage)
return out
def intersect_with_triage(reviewers, selected_ids):
"""Preserve reviewer order while applying the triage verdict."""
if selected_ids is None:
return list(reviewers)
selected = set(selected_ids)
return [reviewer for reviewer in reviewers if reviewer.id in selected]
def filter_by_skip_if(reviewers, changed_paths):
"""Drop lenses whose configured glob matches every changed path."""
import fnmatch
out = []
for reviewer in reviewers:
pattern = reviewer.skip_if_all_changed_paths.strip()
if pattern and changed_paths and all(
fnmatch.fnmatch(path, pattern) for path in changed_paths
):
continue
out.append(reviewer)
return out
def merge_usage(parts):
"""Sum per-lens usage, retaining the existing usage dictionary shape."""
from . import opencode as oc
base = oc._new_usage()
base["duration_s"] = 0.0
for usage in parts:
if not usage:
continue
for key in base:
if isinstance(base[key], (int, float)):
base[key] += usage.get(key, 0) or 0
base["iterations"].extend(usage.get("iterations", []) or [])
return base