139 lines
4.7 KiB
Python
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
|