teleo-infrastructure/lib/evaluate.py
2026-05-29 14:16:12 +02:00

1179 lines
47 KiB
Python

"""Evaluate stage — PR lifecycle orchestration.
Tier-based review routing. Model diversity: GPT-4o (domain) + Sonnet (Leo STANDARD)
+ Opus (Leo DEEP) = two model families, no correlated blind spots.
Flow per PR:
1. Triage → Haiku (OpenRouter) → DEEP / STANDARD / LIGHT
2. Tier overrides:
a. Claim-shape detector: type: claim in YAML → STANDARD min (Theseus)
b. Random pre-merge promotion: 15% of LIGHT → STANDARD (Rio)
3. Domain review → GPT-4o (OpenRouter) — skipped for LIGHT when LIGHT_SKIP_LLM=True
4. Leo review → Opus DEEP / Sonnet STANDARD (OpenRouter) — skipped for LIGHT
5. Post reviews, submit formal Forgejo approvals, update SQLite
6. If both approve → status = 'approved' (merge module picks it up)
7. Retry budget: 3 attempts max, disposition on attempt 2+
Design reviewed by Ganymede, Rio, Theseus, Rhea, Leo.
LLM transport and prompts extracted to lib/llm.py (Phase 3c).
"""
import json
import logging
import random
from datetime import datetime, timezone
from . import config, db
from .agent_routing import AgentRoute, classify_pr_route
from .domains import agent_for_domain, detect_domain_from_branch, detect_domain_from_diff
from .eval_actions import dispose_rejected_pr, post_formal_approvals, terminate_pr
from .eval_parse import (
deterministic_tier,
diff_contains_claim_type,
extract_changed_files,
filter_diff,
is_musings_only,
parse_batch_response,
parse_issues,
parse_verdict,
validate_batch_fanout,
)
from .forgejo import api as forgejo_api
from .forgejo import get_agent_token, get_pr_diff, repo_path
from .github_feedback import on_eval_complete
from .llm import run_agent_review, run_batch_domain_review, run_domain_review, run_leo_review, triage_pr
from .merge import PIPELINE_OWNED_PREFIXES
from .pr_state import approve_pr, close_pr, reopen_pr, start_review
logger = logging.getLogger("pipeline.evaluate")
# ─── NOTE: Tier 0.5 mechanical pre-check moved to validate.py ────────────
# Tier 0.5 now runs as part of the validate stage (before eval), not inside
# evaluate_pr(). This prevents wasting eval_attempts on mechanically fixable
# PRs. Eval trusts that tier0_pass=1 means all mechanical checks passed.
# ─── Single PR evaluation ─────────────────────────────────────────────────
def _phase1b_domain_for_route(route: AgentRoute) -> str:
if route.route_kind in ("multi", "escalated"):
return "multi"
if route.touched_domains:
return route.touched_domains[0]
return "general"
def _phase1b_review_model(agent: str, tier: str) -> str:
if agent == "Leo":
return config.EVAL_LEO_STANDARD_MODEL
return config.EVAL_DOMAIN_MODEL
def _phase1b_compat_verdicts(agent_verdicts: dict[str, str]) -> tuple[str, str]:
"""Project arbitrary routed verdicts into legacy leo/domain columns."""
leo_verdict = agent_verdicts.get("Leo", "skipped")
non_leo = [verdict for agent, verdict in agent_verdicts.items() if agent != "Leo"]
aggregate = "request_changes" if "request_changes" in agent_verdicts.values() else "approve"
domain_verdict = aggregate if non_leo else "skipped"
return leo_verdict, domain_verdict
def _phase1b_review_marker(pr_number: int, agent: str) -> str:
return f"<!-- PHASE1B_REVIEW:PR={pr_number}:AGENT={agent.upper()} -->"
async def _post_phase1b_review_comment(pr_number: int, agent: str, review_text: str) -> bool:
"""Post a routed review comment once per PR/agent marker."""
marker = _phase1b_review_marker(pr_number, agent)
comments = await forgejo_api("GET", repo_path(f"issues/{pr_number}/comments"))
if isinstance(comments, list):
for comment in comments:
body = comment.get("body", "") if isinstance(comment, dict) else ""
if marker in body:
logger.info("PR #%d: Phase 1b %s review comment already posted", pr_number, agent)
return False
body = review_text if marker in review_text else f"{marker}\n{review_text}"
result = await forgejo_api(
"POST",
repo_path(f"issues/{pr_number}/comments"),
{"body": body},
)
return result is not None
async def _evaluate_pr_phase1b(
conn,
pr_number: int,
*,
tier: str,
diff: str,
review_diff: str,
files: str,
branch_name: str,
eval_attempts: int,
pr_cost: float,
) -> dict:
"""Evaluate a PR using the Phase 1b identity router."""
from . import costs
route = classify_pr_route(diff, branch=branch_name)
domain = _phase1b_domain_for_route(route)
route_context = json.dumps(route.to_audit_dict(), sort_keys=True)
conn.execute(
"UPDATE prs SET domain = ?, domain_agent = ? WHERE number = ?",
(domain, route.primary_agent, pr_number),
)
db.audit(
conn,
"evaluate",
"phase1b_route",
json.dumps({"pr": pr_number, "tier": tier, "route": route.to_audit_dict()}),
)
reviews: dict[str, str] = {}
agent_verdicts: dict[str, str] = {}
usage_by_agent: dict[str, dict] = {}
for agent in route.required_agents:
logger.info("PR #%d: Phase 1b %s review (tier=%s, route=%s)", pr_number, agent, tier, route.route_kind)
review_text, usage = await run_agent_review(review_diff, files, agent, route_context, tier=tier)
if review_text is None:
reopen_pr(conn, pr_number)
if pr_cost > 0:
conn.execute("UPDATE prs SET cost_usd = cost_usd + ? WHERE number = ?", (pr_cost, pr_number))
return {
"pr": pr_number,
"skipped": True,
"reason": "phase1b_agent_review_failed",
"agent": agent,
}
verdict = parse_verdict(review_text, agent)
reviews[agent] = review_text
agent_verdicts[agent] = verdict
usage_by_agent[agent] = usage
await _post_phase1b_review_comment(pr_number, agent, review_text)
db.record_review(
conn,
pr_number,
"approved" if verdict == "approve" else "rejected",
domain=domain,
agent=agent,
reviewer=agent,
reviewer_model=_phase1b_review_model(agent, tier),
rejection_reason=",".join(parse_issues(review_text)) if verdict == "request_changes" else None,
notes=review_text,
)
aggregate_approve = all(verdict == "approve" for verdict in agent_verdicts.values())
leo_verdict, domain_verdict = _phase1b_compat_verdicts(agent_verdicts)
conn.execute(
"UPDATE prs SET leo_verdict = ?, domain_verdict = ?, domain_model = ? WHERE number = ?",
(leo_verdict, domain_verdict, "phase1b-agent-routing", pr_number),
)
for agent, usage in usage_by_agent.items():
model = _phase1b_review_model(agent, tier)
pr_cost += costs.record_usage(
conn,
model,
"eval_agent",
input_tokens=usage.get("prompt_tokens", 0),
output_tokens=usage.get("completion_tokens", 0),
backend="openrouter",
)
if aggregate_approve:
pr_info = await forgejo_api("GET", repo_path(f"pulls/{pr_number}"))
pr_author = pr_info.get("user", {}).get("login", "") if pr_info else ""
await post_formal_approvals(pr_number, pr_author)
is_agent_pr = not branch_name.startswith(PIPELINE_OWNED_PREFIXES)
approve_pr(
conn,
pr_number,
domain=domain,
auto_merge=1 if is_agent_pr else 0,
leo_verdict=leo_verdict,
domain_verdict=domain_verdict,
)
db.audit(
conn,
"evaluate",
"phase1b_approved",
json.dumps(
{
"pr": pr_number,
"tier": tier,
"route": route.to_audit_dict(),
"agent_verdicts": agent_verdicts,
"auto_merge": is_agent_pr,
}
),
)
try:
await on_eval_complete(conn, pr_number, outcome="approved", review_text="\n\n".join(reviews.values()))
except Exception:
logger.exception("PR #%d: GitHub eval feedback failed (non-fatal)", pr_number)
else:
all_issues: list[str] = []
for agent, verdict in agent_verdicts.items():
if verdict == "request_changes":
all_issues.extend(parse_issues(reviews[agent]))
reopen_pr(
conn,
pr_number,
leo_verdict=leo_verdict,
domain_verdict=domain_verdict,
last_error="phase1b agent review requested changes",
eval_issues=json.dumps(all_issues),
)
feedback = {
"route": route.to_audit_dict(),
"agent_verdicts": agent_verdicts,
"tier": tier,
"issues": all_issues,
}
conn.execute(
"UPDATE sources SET feedback = ? WHERE path = (SELECT source_path FROM prs WHERE number = ?)",
(json.dumps(feedback), pr_number),
)
db.audit(
conn,
"evaluate",
"phase1b_changes_requested",
json.dumps(
{
"pr": pr_number,
"tier": tier,
"route": route.to_audit_dict(),
"agent_verdicts": agent_verdicts,
"issues": all_issues,
}
),
)
await dispose_rejected_pr(conn, pr_number, eval_attempts, all_issues)
try:
await on_eval_complete(
conn,
pr_number,
outcome="rejected",
review_text="\n\n".join(reviews.values()),
issues=all_issues,
)
except Exception:
logger.exception("PR #%d: GitHub eval feedback failed (non-fatal)", pr_number)
if pr_cost > 0:
conn.execute("UPDATE prs SET cost_usd = cost_usd + ? WHERE number = ?", (pr_cost, pr_number))
return {
"pr": pr_number,
"tier": tier,
"domain": domain,
"phase1b": True,
"route": route.to_audit_dict(),
"agent_verdicts": agent_verdicts,
"approved": aggregate_approve,
"leo_verdict": leo_verdict,
"domain_verdict": domain_verdict,
}
async def evaluate_pr(conn, pr_number: int, tier: str = None) -> dict:
"""Evaluate a single PR. Returns result dict."""
from . import costs
pr_cost = 0.0
# Check eval attempt budget before claiming
row = conn.execute("SELECT eval_attempts FROM prs WHERE number = ?", (pr_number,)).fetchone()
eval_attempts = (row["eval_attempts"] or 0) if row else 0
if eval_attempts >= config.MAX_EVAL_ATTEMPTS:
# Terminal — hard cap reached. Close PR, tag source.
logger.warning("PR #%d: eval_attempts=%d >= %d, terminal", pr_number, eval_attempts, config.MAX_EVAL_ATTEMPTS)
await terminate_pr(conn, pr_number, "eval budget exhausted")
return {"pr": pr_number, "terminal": True, "reason": "eval_budget_exhausted"}
# Atomic claim — prevent concurrent workers from evaluating the same PR (Ganymede #11)
if not start_review(conn, pr_number):
logger.debug("PR #%d already claimed by another worker, skipping", pr_number)
return {"pr": pr_number, "skipped": True, "reason": "already_claimed"}
# Increment eval_attempts — but not if this is a merge-failure re-entry (Ganymede+Rhea)
merge_cycled = conn.execute(
"SELECT merge_cycled FROM prs WHERE number = ?", (pr_number,)
).fetchone()
if merge_cycled and merge_cycled["merge_cycled"]:
# Merge cycling — don't burn eval budget, clear flag
conn.execute("UPDATE prs SET merge_cycled = 0 WHERE number = ?", (pr_number,))
logger.info("PR #%d: merge-cycled re-eval, not incrementing eval_attempts", pr_number)
else:
conn.execute(
"UPDATE prs SET eval_attempts = COALESCE(eval_attempts, 0) + 1 WHERE number = ?",
(pr_number,),
)
eval_attempts += 1
# Fetch diff
diff = await get_pr_diff(pr_number)
if not diff:
# Close PRs with no diff — stale branch, nothing to evaluate
await close_pr(conn, pr_number, last_error='closed: no diff against main (stale branch)')
return {"pr": pr_number, "skipped": True, "reason": "no_diff_closed"}
# Musings bypass
if is_musings_only(diff):
logger.info("PR #%d is musings-only — auto-approving", pr_number)
await forgejo_api(
"POST",
repo_path(f"issues/{pr_number}/comments"),
{"body": "Auto-approved: musings bypass eval per collective policy."},
)
approve_pr(conn, pr_number, domain='cross-domain', auto_merge=1,
leo_verdict='skipped', domain_verdict='skipped')
return {"pr": pr_number, "auto_approved": True, "reason": "musings_only"}
# Reweave bypass — reweave PRs only add frontmatter edges (supports/challenges/
# related/depends_on/challenged_by). The eval LLM has no context for judging
# edge correctness and consistently flags factual_discrepancy on valid edges.
# Leo's manual PR review is the real quality gate for reweave.
branch_row = conn.execute("SELECT branch FROM prs WHERE number = ?", (pr_number,)).fetchone()
branch_name = branch_row["branch"] if branch_row else ""
if branch_name.startswith("reweave/"):
logger.info("PR #%d is reweave (branch=%s) — auto-approving, Leo reviews manually", pr_number, branch_name)
await forgejo_api(
"POST",
repo_path(f"issues/{pr_number}/comments"),
{"body": "Auto-approved: reweave structural update (frontmatter edges only). Leo reviews manually."},
)
approve_pr(conn, pr_number, domain='cross-domain', auto_merge=1,
leo_verdict='skipped', domain_verdict='skipped')
db.audit(
conn, "evaluate", "reweave_bypass",
json.dumps({"pr": pr_number, "branch": branch_name}),
)
return {"pr": pr_number, "auto_approved": True, "reason": "reweave_bypass"}
# NOTE: Tier 0.5 mechanical checks now run in validate stage (before eval).
# tier0_pass=1 guarantees all mechanical checks passed. No Tier 0.5 here.
# Filter diff
review_diff, _entity_diff = filter_diff(diff)
if not review_diff:
review_diff = diff
files = extract_changed_files(diff)
# Detect domain — try diff paths first, then branch prefix, then 'general'
domain = detect_domain_from_diff(diff)
if domain is None:
pr_row = conn.execute("SELECT branch FROM prs WHERE number = ?", (pr_number,)).fetchone()
if pr_row and pr_row["branch"]:
domain = detect_domain_from_branch(pr_row["branch"])
if domain is None:
domain = "general"
agent = agent_for_domain(domain)
# Update PR domain if not set
conn.execute(
"UPDATE prs SET domain = COALESCE(domain, ?), domain_agent = ? WHERE number = ?",
(domain, agent, pr_number),
)
# Step 1: Triage (if not already triaged)
# Try deterministic routing first ($0), fall back to Haiku triage ($0.001)
if tier is None:
tier = deterministic_tier(diff)
if tier is not None:
db.audit(
conn, "evaluate", "deterministic_tier",
json.dumps({"pr": pr_number, "tier": tier}),
)
else:
tier, triage_usage, _triage_reason = await triage_pr(diff)
pr_cost += costs.record_usage(
conn, config.TRIAGE_MODEL, "eval_triage",
input_tokens=triage_usage.get("prompt_tokens", 0),
output_tokens=triage_usage.get("completion_tokens", 0),
backend="openrouter",
)
# Tier overrides (claim-shape detector + random promotion)
# Order matters: claim-shape catches obvious cases, random promotion catches the rest.
# Claim-shape detector: type: claim in YAML → STANDARD minimum (Theseus)
if tier == "LIGHT" and diff_contains_claim_type(diff):
tier = "STANDARD"
logger.info("PR #%d: claim-shape detector upgraded LIGHT → STANDARD (type: claim found)", pr_number)
db.audit(
conn, "evaluate", "claim_shape_upgrade", json.dumps({"pr": pr_number, "from": "LIGHT", "to": "STANDARD"})
)
# Random pre-merge promotion: 15% of LIGHT → STANDARD (Rio)
if tier == "LIGHT" and random.random() < config.LIGHT_PROMOTION_RATE:
tier = "STANDARD"
logger.info(
"PR #%d: random promotion LIGHT → STANDARD (%.0f%% rate)", pr_number, config.LIGHT_PROMOTION_RATE * 100
)
db.audit(conn, "evaluate", "random_promotion", json.dumps({"pr": pr_number, "from": "LIGHT", "to": "STANDARD"}))
conn.execute("UPDATE prs SET tier = ? WHERE number = ?", (tier, pr_number))
# Update last_attempt timestamp (status already set to 'reviewing' by atomic claim above)
conn.execute(
"UPDATE prs SET last_attempt = datetime('now') WHERE number = ?",
(pr_number,),
)
if config.PHASE1B_AGENT_ROUTING_ENABLED:
return await _evaluate_pr_phase1b(
conn,
pr_number,
tier=tier,
diff=diff,
review_diff=review_diff,
files=files,
branch_name=branch_name,
eval_attempts=eval_attempts,
pr_cost=pr_cost,
)
# Check if domain review already completed (resuming after Leo rate limit)
existing = conn.execute("SELECT domain_verdict, leo_verdict FROM prs WHERE number = ?", (pr_number,)).fetchone()
existing_domain_verdict = existing["domain_verdict"] if existing else "pending"
_existing_leo_verdict = existing["leo_verdict"] if existing else "pending"
# Step 2: Domain review (GPT-4o via OpenRouter)
# LIGHT tier: skip entirely when LIGHT_SKIP_LLM enabled (Rhea: config flag rollback)
# Skip if already completed from a previous attempt
domain_review = None # Initialize — used later for feedback extraction (Ganymede #12)
domain_usage = {"prompt_tokens": 0, "completion_tokens": 0}
leo_usage = {"prompt_tokens": 0, "completion_tokens": 0}
if tier == "LIGHT" and config.LIGHT_SKIP_LLM:
domain_verdict = "skipped"
logger.info("PR #%d: LIGHT tier — skipping domain review (LIGHT_SKIP_LLM=True)", pr_number)
conn.execute(
"UPDATE prs SET domain_verdict = 'skipped', domain_model = 'none' WHERE number = ?",
(pr_number,),
)
elif existing_domain_verdict not in ("pending", None):
domain_verdict = existing_domain_verdict
logger.info("PR #%d: domain review already done (%s), skipping to Leo", pr_number, domain_verdict)
else:
logger.info("PR #%d: domain review (%s/%s, tier=%s)", pr_number, agent, domain, tier)
domain_review, domain_usage = await run_domain_review(review_diff, files, domain or "general", agent)
if domain_review is None:
# OpenRouter failure (timeout, error) — revert to open for retry.
# NOT a rate limit — don't trigger 15-min backoff, just skip this PR.
reopen_pr(conn, pr_number)
if pr_cost > 0:
conn.execute("UPDATE prs SET cost_usd = cost_usd + ? WHERE number = ?", (pr_cost, pr_number))
return {"pr": pr_number, "skipped": True, "reason": "openrouter_failed"}
domain_verdict = parse_verdict(domain_review, agent)
conn.execute(
"UPDATE prs SET domain_verdict = ?, domain_model = ? WHERE number = ?",
(domain_verdict, config.EVAL_DOMAIN_MODEL, pr_number),
)
# Post domain review as comment (from agent's Forgejo account)
agent_tok = get_agent_token(agent)
await forgejo_api(
"POST",
repo_path(f"issues/{pr_number}/comments"),
{"body": domain_review},
token=agent_tok,
)
# If domain review rejects, skip Leo review (save Opus)
if domain_verdict == "request_changes":
logger.info("PR #%d: domain rejected, skipping Leo review", pr_number)
domain_issues = parse_issues(domain_review) if domain_review else []
reopen_pr(conn, pr_number, leo_verdict='skipped',
last_error='domain review requested changes',
eval_issues=json.dumps(domain_issues))
db.audit(
conn, "evaluate", "domain_rejected", json.dumps({"pr": pr_number, "agent": agent, "issues": domain_issues})
)
db.record_review(
conn, pr_number, "rejected",
domain=domain, agent=agent, reviewer=agent, reviewer_model=config.EVAL_DOMAIN_MODEL,
rejection_reason=",".join(domain_issues) if domain_issues else None,
notes=(domain_review or "")[:4000],
)
# Disposition: check if this PR should be terminated or kept open
await dispose_rejected_pr(conn, pr_number, eval_attempts, domain_issues)
try:
await on_eval_complete(conn, pr_number, outcome="rejected",
review_text=domain_review, issues=domain_issues)
except Exception:
logger.exception("PR #%d: GitHub eval feedback failed (non-fatal)", pr_number)
if domain_verdict != "skipped":
pr_cost += costs.record_usage(
conn, config.EVAL_DOMAIN_MODEL, "eval_domain",
input_tokens=domain_usage.get("prompt_tokens", 0),
output_tokens=domain_usage.get("completion_tokens", 0),
backend="openrouter",
)
if pr_cost > 0:
conn.execute("UPDATE prs SET cost_usd = cost_usd + ? WHERE number = ?", (pr_cost, pr_number))
return {
"pr": pr_number,
"domain_verdict": domain_verdict,
"leo_verdict": "skipped",
"eval_attempts": eval_attempts,
}
# Step 3: Leo review (Opus — only if domain passes, skipped for LIGHT)
leo_verdict = "skipped"
leo_review = None # Initialize — used later for issue extraction
if tier != "LIGHT":
logger.info("PR #%d: Leo review (tier=%s)", pr_number, tier)
leo_review, leo_usage = await run_leo_review(review_diff, files, tier)
if leo_review is None:
# DEEP: Opus rate limited (queue for later). STANDARD: OpenRouter failed (skip, retry next cycle).
reopen_pr(conn, pr_number)
if domain_verdict != "skipped":
pr_cost += costs.record_usage(
conn, config.EVAL_DOMAIN_MODEL, "eval_domain",
input_tokens=domain_usage.get("prompt_tokens", 0),
output_tokens=domain_usage.get("completion_tokens", 0),
backend="openrouter",
)
if pr_cost > 0:
conn.execute("UPDATE prs SET cost_usd = cost_usd + ? WHERE number = ?", (pr_cost, pr_number))
reason = "opus_rate_limited" if tier == "DEEP" else "openrouter_failed"
return {"pr": pr_number, "skipped": True, "reason": reason}
leo_verdict = parse_verdict(leo_review, "LEO")
conn.execute("UPDATE prs SET leo_verdict = ? WHERE number = ?", (leo_verdict, pr_number))
# Post Leo review as comment (from Leo's Forgejo account)
leo_tok = get_agent_token("Leo")
await forgejo_api(
"POST",
repo_path(f"issues/{pr_number}/comments"),
{"body": leo_review},
token=leo_tok,
)
else:
# LIGHT tier: Leo is auto-skipped, domain verdict is the only gate
conn.execute("UPDATE prs SET leo_verdict = 'skipped' WHERE number = ?", (pr_number,))
# Step 4: Determine final verdict
# "skipped" counts as approve (LIGHT skips both reviews deliberately)
both_approve = leo_verdict in ("approve", "skipped") and domain_verdict in ("approve", "skipped")
if both_approve:
# Get PR author for formal approvals
pr_info = await forgejo_api(
"GET",
repo_path(f"pulls/{pr_number}"),
)
pr_author = pr_info.get("user", {}).get("login", "") if pr_info else ""
# Submit formal Forgejo reviews (required for merge)
await post_formal_approvals(pr_number, pr_author)
# Auto-merge agent PRs: if branch is NOT pipeline-owned, set auto_merge=1
# so the merge cycle picks it up without manual intervention.
branch_row = conn.execute("SELECT branch FROM prs WHERE number = ?", (pr_number,)).fetchone()
branch_name = branch_row["branch"] if branch_row else ""
is_agent_pr = not branch_name.startswith(PIPELINE_OWNED_PREFIXES)
approve_pr(conn, pr_number, domain=domain, auto_merge=1 if is_agent_pr else 0)
db.audit(
conn,
"evaluate",
"approved",
json.dumps({"pr": pr_number, "tier": tier, "domain": domain, "leo": leo_verdict, "domain_agent": agent,
"auto_merge": is_agent_pr}),
)
db.record_review(
conn, pr_number, "approved",
domain=domain, agent=agent, reviewer="leo", reviewer_model="sonnet" if tier == "STANDARD" else "opus",
notes=(leo_review or "")[:4000] if leo_review else None,
)
if is_agent_pr:
logger.info("PR #%d: APPROVED + auto_merge (agent branch %s)", pr_number, branch_name)
else:
logger.info("PR #%d: APPROVED (tier=%s, leo=%s, domain=%s)", pr_number, tier, leo_verdict, domain_verdict)
try:
await on_eval_complete(conn, pr_number, outcome="approved",
review_text=leo_review or domain_review)
except Exception:
logger.exception("PR #%d: GitHub eval feedback failed (non-fatal)", pr_number)
else:
# Collect all issue tags from both reviews
all_issues = []
if domain_verdict == "request_changes" and domain_review is not None:
all_issues.extend(parse_issues(domain_review))
if leo_verdict == "request_changes" and leo_review is not None:
all_issues.extend(parse_issues(leo_review))
reopen_pr(conn, pr_number, eval_issues=json.dumps(all_issues))
# Store feedback for re-extraction path
feedback = {"leo": leo_verdict, "domain": domain_verdict, "tier": tier, "issues": all_issues}
conn.execute(
"UPDATE sources SET feedback = ? WHERE path = (SELECT source_path FROM prs WHERE number = ?)",
(json.dumps(feedback), pr_number),
)
db.audit(
conn,
"evaluate",
"changes_requested",
json.dumps(
{"pr": pr_number, "tier": tier, "leo": leo_verdict, "domain": domain_verdict, "issues": all_issues}
),
)
db.record_review(
conn, pr_number, "approved-with-changes",
domain=domain, agent=agent, reviewer="leo",
reviewer_model="sonnet" if tier == "STANDARD" else "opus",
rejection_reason=",".join(all_issues) if all_issues else None,
notes=(leo_review or domain_review or "")[:4000],
)
logger.info(
"PR #%d: CHANGES REQUESTED (leo=%s, domain=%s, issues=%s)",
pr_number,
leo_verdict,
domain_verdict,
all_issues,
)
# Disposition: check if this PR should be terminated or kept open
await dispose_rejected_pr(conn, pr_number, eval_attempts, all_issues)
try:
await on_eval_complete(conn, pr_number, outcome="rejected",
review_text=leo_review or domain_review, issues=all_issues)
except Exception:
logger.exception("PR #%d: GitHub eval feedback failed (non-fatal)", pr_number)
# Record cost (only for reviews that actually ran)
if domain_verdict != "skipped":
pr_cost += costs.record_usage(
conn, config.EVAL_DOMAIN_MODEL, "eval_domain",
input_tokens=domain_usage.get("prompt_tokens", 0),
output_tokens=domain_usage.get("completion_tokens", 0),
backend="openrouter",
)
if leo_verdict not in ("skipped",):
if tier == "DEEP":
pr_cost += costs.record_usage(
conn, config.EVAL_LEO_MODEL, "eval_leo",
input_tokens=leo_usage.get("prompt_tokens", 0),
output_tokens=leo_usage.get("completion_tokens", 0),
backend="max",
)
else:
pr_cost += costs.record_usage(
conn, config.EVAL_LEO_STANDARD_MODEL, "eval_leo",
input_tokens=leo_usage.get("prompt_tokens", 0),
output_tokens=leo_usage.get("completion_tokens", 0),
backend="openrouter",
)
if pr_cost > 0:
conn.execute("UPDATE prs SET cost_usd = cost_usd + ? WHERE number = ?", (pr_cost, pr_number))
return {
"pr": pr_number,
"tier": tier,
"domain": domain,
"leo_verdict": leo_verdict,
"domain_verdict": domain_verdict,
"approved": both_approve,
}
# ─── Rate limit backoff ───────────────────────────────────────────────────
# When rate limited, don't retry for 15 minutes. Prevents ~2700 wasted
# CLI calls overnight when Opus is exhausted.
_rate_limit_backoff_until: datetime | None = None
_RATE_LIMIT_BACKOFF_MINUTES = 15
# ─── Batch domain review ─────────────────────────────────────────────────
async def _run_batch_domain_eval(
conn, batch_prs: list[dict], domain: str, agent: str,
) -> tuple[int, int]:
"""Execute batch domain review for a group of same-domain STANDARD PRs.
1. Claim all PRs atomically
2. Run single batch domain review
3. Parse + validate fan-out
4. Post per-PR comments
5. Continue to individual Leo review for each
6. Fall back to individual review for any validation failures
Returns (succeeded, failed).
"""
from .forgejo import get_pr_diff as _get_pr_diff
succeeded = 0
failed = 0
# Step 1: Fetch diffs and build batch
pr_diffs = []
claimed_prs = []
for pr_row in batch_prs:
pr_num = pr_row["number"]
# Atomic claim
if not start_review(conn, pr_num):
continue
# Increment eval_attempts — skip if merge-cycled (Ganymede+Rhea)
mc_row = conn.execute("SELECT merge_cycled FROM prs WHERE number = ?", (pr_num,)).fetchone()
if mc_row and mc_row["merge_cycled"]:
conn.execute(
"UPDATE prs SET merge_cycled = 0, last_attempt = datetime('now') WHERE number = ?",
(pr_num,),
)
logger.info("PR #%d: merge-cycled re-eval, not incrementing eval_attempts", pr_num)
else:
conn.execute(
"UPDATE prs SET eval_attempts = COALESCE(eval_attempts, 0) + 1, "
"last_attempt = datetime('now') WHERE number = ?",
(pr_num,),
)
diff = await _get_pr_diff(pr_num)
if not diff:
reopen_pr(conn, pr_num)
continue
# Musings bypass
if is_musings_only(diff):
await forgejo_api(
"POST",
repo_path(f"issues/{pr_num}/comments"),
{"body": "Auto-approved: musings bypass eval per collective policy."},
)
approve_pr(conn, pr_num, domain='cross-domain', auto_merge=1,
leo_verdict='skipped', domain_verdict='skipped')
succeeded += 1
continue
review_diff, _ = filter_diff(diff)
if not review_diff:
review_diff = diff
files = extract_changed_files(diff)
# Build label from branch name or first claim filename
branch = pr_row.get("branch", "")
label = branch.split("/")[-1][:60] if branch else f"pr-{pr_num}"
pr_diffs.append({
"number": pr_num,
"label": label,
"diff": review_diff,
"files": files,
"full_diff": diff, # kept for Leo review
"file_count": len([line for line in files.split("\n") if line.strip()]),
})
claimed_prs.append(pr_num)
if not pr_diffs:
return 0, 0
# Enforce BATCH_EVAL_MAX_DIFF_BYTES — split if total diff is too large.
# We only know diff sizes after fetching, so enforce here not in _build_domain_batches.
total_bytes = sum(len(p["diff"].encode()) for p in pr_diffs)
if total_bytes > config.BATCH_EVAL_MAX_DIFF_BYTES and len(pr_diffs) > 1:
# Keep PRs up to the byte cap, revert the rest to open for next cycle
kept = []
running_bytes = 0
for p in pr_diffs:
p_bytes = len(p["diff"].encode())
if running_bytes + p_bytes > config.BATCH_EVAL_MAX_DIFF_BYTES and kept:
break
kept.append(p)
running_bytes += p_bytes
overflow = [p for p in pr_diffs if p not in kept]
for p in overflow:
reopen_pr(conn, p["number"], dec_eval_attempts=True)
claimed_prs.remove(p["number"])
logger.info(
"PR #%d: diff too large for batch (%d bytes total), deferring to next cycle",
p["number"], total_bytes,
)
pr_diffs = kept
if not pr_diffs:
return 0, 0
# Detect domain for all PRs (should be same domain)
conn.execute(
"UPDATE prs SET domain = COALESCE(domain, ?), domain_agent = ? WHERE number IN ({})".format(
",".join("?" * len(claimed_prs))
),
[domain, agent, *claimed_prs],
)
# Step 2: Run batch domain review
logger.info(
"Batch domain review: %d PRs in %s domain (PRs: %s)",
len(pr_diffs),
domain,
", ".join(f"#{p['number']}" for p in pr_diffs),
)
batch_response, batch_domain_usage = await run_batch_domain_review(pr_diffs, domain, agent)
if batch_response is None:
# Complete failure — revert all to open
logger.warning("Batch domain review failed — reverting all PRs to open")
for pr_num in claimed_prs:
reopen_pr(conn, pr_num)
return 0, len(claimed_prs)
# Step 3: Parse + validate fan-out
parsed = parse_batch_response(batch_response, claimed_prs, agent)
valid_reviews, fallback_prs = validate_batch_fanout(parsed, pr_diffs, agent)
db.audit(
conn, "evaluate", "batch_domain_review",
json.dumps({
"domain": domain,
"batch_size": len(pr_diffs),
"valid": len(valid_reviews),
"fallback": fallback_prs,
}),
)
# Record batch domain review cost ONCE for the whole batch (not per-PR)
from . import costs
costs.record_usage(
conn, config.EVAL_DOMAIN_MODEL, "eval_domain",
input_tokens=batch_domain_usage.get("prompt_tokens", 0),
output_tokens=batch_domain_usage.get("completion_tokens", 0),
backend="openrouter",
)
# Step 4: Process valid reviews — post comments + continue to Leo
for pr_data in pr_diffs:
pr_num = pr_data["number"]
if pr_num in fallback_prs:
# Revert — will be picked up by individual eval next cycle
reopen_pr(conn, pr_num, dec_eval_attempts=True)
logger.info("PR #%d: batch fallback — will retry individually", pr_num)
continue
if pr_num not in valid_reviews:
# Should not happen, but safety
reopen_pr(conn, pr_num)
continue
review_text = valid_reviews[pr_num]
domain_verdict = parse_verdict(review_text, agent)
# Post domain review comment
agent_tok = get_agent_token(agent)
await forgejo_api(
"POST",
repo_path(f"issues/{pr_num}/comments"),
{"body": review_text},
token=agent_tok,
)
conn.execute(
"UPDATE prs SET domain_verdict = ?, domain_model = ? WHERE number = ?",
(domain_verdict, config.EVAL_DOMAIN_MODEL, pr_num),
)
# If domain rejects, handle disposition (same as individual path)
if domain_verdict == "request_changes":
domain_issues = parse_issues(review_text)
eval_attempts = (conn.execute(
"SELECT eval_attempts FROM prs WHERE number = ?", (pr_num,)
).fetchone()["eval_attempts"] or 0)
reopen_pr(conn, pr_num, leo_verdict='skipped',
last_error='domain review requested changes',
eval_issues=json.dumps(domain_issues))
db.audit(
conn, "evaluate", "domain_rejected",
json.dumps({"pr": pr_num, "agent": agent, "issues": domain_issues, "batch": True}),
)
await dispose_rejected_pr(conn, pr_num, eval_attempts, domain_issues)
succeeded += 1
continue
# Domain approved — continue to individual Leo review
logger.info("PR #%d: batch domain approved, proceeding to individual Leo review", pr_num)
review_diff = pr_data["diff"]
files = pr_data["files"]
leo_review, leo_usage = await run_leo_review(review_diff, files, "STANDARD")
if leo_review is None:
reopen_pr(conn, pr_num)
logger.debug("PR #%d: Leo review failed, will retry next cycle", pr_num)
continue
if leo_review == "RATE_LIMITED":
reopen_pr(conn, pr_num)
logger.info("PR #%d: Leo rate limited, will retry next cycle", pr_num)
continue
leo_verdict = parse_verdict(leo_review, "LEO")
conn.execute("UPDATE prs SET leo_verdict = ? WHERE number = ?", (leo_verdict, pr_num))
# Post Leo review
leo_tok = get_agent_token("Leo")
await forgejo_api(
"POST",
repo_path(f"issues/{pr_num}/comments"),
{"body": leo_review},
token=leo_tok,
)
costs.record_usage(
conn, config.EVAL_LEO_STANDARD_MODEL, "eval_leo",
input_tokens=leo_usage.get("prompt_tokens", 0),
output_tokens=leo_usage.get("completion_tokens", 0),
backend="openrouter",
)
# Final verdict
both_approve = leo_verdict in ("approve", "skipped") and domain_verdict in ("approve", "skipped")
if both_approve:
pr_info = await forgejo_api("GET", repo_path(f"pulls/{pr_num}"))
pr_author = pr_info.get("user", {}).get("login", "") if pr_info else ""
await post_formal_approvals(pr_num, pr_author)
approve_pr(conn, pr_num, domain=domain or 'cross-domain', auto_merge=1)
db.audit(
conn, "evaluate", "approved",
json.dumps({"pr": pr_num, "tier": "STANDARD", "domain": domain,
"leo": leo_verdict, "domain_agent": agent, "batch": True}),
)
logger.info("PR #%d: APPROVED (batch domain + individual Leo)", pr_num)
else:
all_issues = []
if leo_verdict == "request_changes":
all_issues.extend(parse_issues(leo_review))
reopen_pr(conn, pr_num, eval_issues=json.dumps(all_issues))
feedback = {"leo": leo_verdict, "domain": domain_verdict,
"tier": "STANDARD", "issues": all_issues}
conn.execute(
"UPDATE sources SET feedback = ? WHERE path = (SELECT source_path FROM prs WHERE number = ?)",
(json.dumps(feedback), pr_num),
)
db.audit(
conn, "evaluate", "changes_requested",
json.dumps({"pr": pr_num, "tier": "STANDARD", "leo": leo_verdict,
"domain": domain_verdict, "issues": all_issues, "batch": True}),
)
eval_attempts = (conn.execute(
"SELECT eval_attempts FROM prs WHERE number = ?", (pr_num,)
).fetchone()["eval_attempts"] or 0)
await dispose_rejected_pr(conn, pr_num, eval_attempts, all_issues)
succeeded += 1
return succeeded, failed
def _build_domain_batches(
rows: list, conn,
) -> tuple[dict[str, list[dict]], list[dict]]:
"""Group STANDARD PRs by domain for batch eval. DEEP and LIGHT stay individual.
Returns (batches_by_domain, individual_prs).
Respects BATCH_EVAL_MAX_PRS and BATCH_EVAL_MAX_DIFF_BYTES.
"""
domain_candidates: dict[str, list[dict]] = {}
individual: list[dict] = []
for row in rows:
pr_num = row["number"]
tier = row["tier"]
# Only batch STANDARD PRs with pending domain review
if tier != "STANDARD":
individual.append(row)
continue
# Check if domain review already done (resuming after Leo rate limit)
existing = conn.execute(
"SELECT domain_verdict, domain FROM prs WHERE number = ?", (pr_num,)
).fetchone()
if existing and existing["domain_verdict"] not in ("pending", None):
individual.append(row)
continue
domain = existing["domain"] if existing and existing["domain"] and existing["domain"] != "general" else "general"
domain_candidates.setdefault(domain, []).append(row)
# Build sized batches per domain
batches: dict[str, list[dict]] = {}
for domain, prs in domain_candidates.items():
if len(prs) == 1:
# Single PR — no batching benefit, process individually
individual.extend(prs)
continue
# Cap at BATCH_EVAL_MAX_PRS
batch = prs[: config.BATCH_EVAL_MAX_PRS]
batches[domain] = batch
# Overflow goes individual
individual.extend(prs[config.BATCH_EVAL_MAX_PRS :])
return batches, individual
# ─── Main entry point ──────────────────────────────────────────────────────
async def evaluate_cycle(conn, max_workers=None) -> tuple[int, int]:
"""Run one evaluation cycle.
Groups eligible STANDARD PRs by domain for batch domain review.
DEEP PRs get individual eval. LIGHT PRs get auto-approved.
Leo review always individual (safety net for batch cross-contamination).
"""
global _rate_limit_backoff_until
# Check if we're in Opus rate-limit backoff
opus_backoff = False
if _rate_limit_backoff_until is not None:
now = datetime.now(timezone.utc)
if now < _rate_limit_backoff_until:
remaining = int((_rate_limit_backoff_until - now).total_seconds())
logger.debug("Opus rate limit backoff: %d seconds remaining — triage + domain review continue", remaining)
opus_backoff = True
else:
logger.info("Rate limit backoff expired, resuming full eval cycles")
_rate_limit_backoff_until = None
# Find PRs ready for evaluation
if opus_backoff:
verdict_filter = "AND (p.domain_verdict = 'pending' OR (p.leo_verdict = 'pending' AND p.tier != 'DEEP'))"
else:
verdict_filter = "AND (p.leo_verdict = 'pending' OR p.domain_verdict = 'pending')"
# Stagger removed — migration protection no longer needed. Merge is domain-serialized
# and entity conflicts auto-resolve. Safe to let all eligible PRs enter eval. (Cory, Mar 14)
rows = conn.execute(
f"""SELECT p.number, p.tier, p.branch, p.domain FROM prs p
LEFT JOIN sources s ON p.source_path = s.path
WHERE p.status = 'open'
AND p.tier0_pass = 1
AND COALESCE(p.eval_attempts, 0) < {config.MAX_EVAL_ATTEMPTS}
{verdict_filter}
AND (p.last_attempt IS NULL
OR p.last_attempt < datetime('now', '-10 minutes'))
ORDER BY
CASE WHEN COALESCE(p.eval_attempts, 0) = 0 THEN 0 ELSE 1 END,
CASE COALESCE(p.priority, s.priority, 'medium')
WHEN 'critical' THEN 0
WHEN 'high' THEN 1
WHEN 'medium' THEN 2
WHEN 'low' THEN 3
ELSE 4
END,
p.created_at ASC
LIMIT ?""",
(max_workers or config.MAX_EVAL_WORKERS,),
).fetchall()
if not rows:
return 0, 0
succeeded = 0
failed = 0
# Phase 1b routes per PR by identity and supports cross-domain top-2 review,
# so stale DB-domain batching is disabled while the feature flag is on.
if config.PHASE1B_AGENT_ROUTING_ENABLED:
domain_batches, individual_prs = {}, list(rows)
else:
domain_batches, individual_prs = _build_domain_batches(rows, conn)
# Process batch domain reviews first
for domain, batch_prs in domain_batches.items():
try:
agent = agent_for_domain(domain)
b_succeeded, b_failed = await _run_batch_domain_eval(
conn, batch_prs, domain, agent,
)
succeeded += b_succeeded
failed += b_failed
except Exception:
logger.exception("Batch eval failed for domain %s", domain)
# Revert all to open
for pr_row in batch_prs:
reopen_pr(conn, pr_row["number"])
failed += len(batch_prs)
# Process individual PRs (DEEP, LIGHT, single-domain, fallback)
for row in individual_prs:
try:
if opus_backoff and row["tier"] == "DEEP":
existing = conn.execute(
"SELECT domain_verdict FROM prs WHERE number = ?",
(row["number"],),
).fetchone()
if existing and existing["domain_verdict"] not in ("pending", None):
logger.debug(
"PR #%d: skipping DEEP during Opus backoff (domain already %s)",
row["number"],
existing["domain_verdict"],
)
continue
result = await evaluate_pr(conn, row["number"], tier=row["tier"])
if result.get("skipped"):
reason = result.get("reason", "")
logger.debug("PR #%d skipped: %s", row["number"], reason)
if "rate_limited" in reason:
from datetime import timedelta
if reason == "opus_rate_limited":
_rate_limit_backoff_until = datetime.now(timezone.utc) + timedelta(
minutes=_RATE_LIMIT_BACKOFF_MINUTES
)
opus_backoff = True
logger.info(
"Opus rate limited — backing off Opus for %d min, continuing triage+domain",
_RATE_LIMIT_BACKOFF_MINUTES,
)
continue
else:
_rate_limit_backoff_until = datetime.now(timezone.utc) + timedelta(
minutes=_RATE_LIMIT_BACKOFF_MINUTES
)
logger.info(
"Rate limited (%s) — backing off for %d minutes", reason, _RATE_LIMIT_BACKOFF_MINUTES
)
break
else:
succeeded += 1
except Exception:
logger.exception("Failed to evaluate PR #%d", row["number"])
failed += 1
reopen_pr(conn, row["number"])
if succeeded or failed:
logger.info("Evaluate cycle: %d evaluated, %d errors", succeeded, failed)
return succeeded, failed