Files
cleveragents-core/tools/controller/master/prefetch.py
T
drew 016b348117 feat(controller): grooming gate (Phase 0 + Phase 1 worker-shape dispatch)
Phase 0 (foundation):
- Cause enum (controller_events.cause) for action attribution
- Schema: grooming_decisions audit table; workflows gains
  grooming_evaluated_at + deferred_reason + deferred_at +
  deferred_target_workflow_id; pulls gains touched_files
- audit_comments: CLOSE / DEFER templates + render_comment_template
- forgejo_writes: close_issue + defer_issue 5-step crash-safe protocol
  (fingerprint dedup, error matrix, dry-run)
- patch_pr_state callback in forgejo_http
- grooming_config: 22-env-var frozen-dataclass config + log_effective
- pulls.touched_files cache extension (_pipeline_cache.py schema v8)
- reaper.reap_grooming_decisions audit-retention sweep
- reconciliation RESUME guard (deferred_reason)

Phase 1 (worker-queue shape, 2026-05-25):
- New state: GROOMING. New events: grooming_started, groom_verdict_
  {proceed,defer,close}. 5 new transitions; all invariants still clean
- GroomingInputV1 + GroomingOutputV1 Pydantic contracts
- outcomes._map_grooming_outcome routes verdicts to state-machine events
- prefetch.build_grooming_stage_b_input + list_open_prs callback
- scheduler GROOMING -> grooming_stage_b role
- promote: cfg-gated DISCOVERED -> GROOMING when CONTROLLER_GROOMING_
  ENABLED=true; issues skip grooming
- forgejo_writes decomposed: close_act/defer_act (Forgejo writes only;
  state-machine already transitioned) + close_decide_and_act/
  defer_decide_and_act (Phase 0 callers); _apply_workflow_transition
  is underscore-private
- grooming.py library: tokenization, suspicion scoring (Jaccard +
  weighted overlap), deterministic checks, action -> verdict mapping
- mcp/grooming_builder.py: 14-tool FastMCP server emits GroomingOutputV1
- .opencode/agents/grooming-stage-b.md: duplicate-detection agent
  prompt (claude-haiku-4-5)
- grooming_side_effects.run_grooming_side_effects_tick: per-state tick
  performs Forgejo writes after groom_verdict_{defer,close} fires.
  Filters on event_type='transition' + payload.event (centralizes the
  convention pending Phase 2's latest_transition_event helper)
- GroomingCallbacks frozen dataclass; loop.py + __main__.py wired

Worker role registry (single source of truth):
- worker/roles.py: WORKER_ROLES + WorkerRoleSpec + default_roles_csv
  + output_filename_for. agent_runner.ROLE_TO_MCP_MODULE / ROLE_TO_
  OUTPUT_MODEL derive from it; opencode_session.agent_name_for reads
  it for flat cases; all 6 prompt builders use output_filename_for;
  worker --roles default = default_roles_csv(); launcher script
  derives --roles via shell substitution. Cross-site invariant test
  enforces alignment across 5 sites + opencode.json MCP registry.

Phase 0 silent-bug fix:
- reconciliation.py RESUME guard SELECT now includes deferred_reason
  (was missing since Phase 0; guard was a silent no-op). Tightened
  from getattr to attribute access to fail fast on future omissions.

Tests (1456 total, +91 grooming-specific):
- test_grooming_phase0.py: 34 tests (orchestrator matrix, crash
  recovery, idempotency, dry-run)
- test_grooming_phase1.py: 60 tests (library, contracts, state
  machine, outcomes, scheduler, promote, prefetch, act-variants
  with signature parity, side-effect tick incl. natural-idempotency
  + executed-flag-skip + verdict-mismatch + reconciliation RESUME)
- test_mcp_builders.py TestGroomingBuilder: 29 tests (happy paths
  + 22 validation rules + Pydantic round-trip + master-tick-read-
  path companion)
- test_worker_agent_runner.py TestRoleMaps: cross-role wiring
  alignment + agent-prompt-vs-worker-fallback filename contract +
  inspect.signature equality (close_act/defer_act vs
  close_issue/defer_issue)
- test_state_machine.py: transition count 51 -> 56 +
  events_from_grooming

Live-validated end-to-end on 4 staged sentinel PRs (#55-#58) in
dry_run: agent emits verdicts via MCP, state-machine transitions
fire, side-effect tick writes audit row, deferred_reason gates
reconciliation RESUME correctly.

Deferred refinements + Phase 2 prerequisite (latest_transition_event
helper) tracked in .drew/regressions-plan.md "Phase 1 follow-up
backlog".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 15:05:29 -04:00

1129 lines
41 KiB
Python

"""Prefetch callbacks — assemble V1 worker input from Forgejo + DB.
Plan v9 puts prefetch on the master side: at attempt-enqueue time the
scheduler calls prefetch (a pluggable callback) to build the worker's
input_payload from PR details + comments + reviews + prior attempts.
Workers then dequeue an already-assembled payload with NO extra
Forgejo I/O of their own.
This module ships the production prefetch implementation:
- ``PrefetchDataCallbacks`` — bundle of Forgejo fetchers (DI for tests).
- ``build_implementer_input`` / ``build_reviewer_input`` /
``build_estimator_input`` — per-role assembly into V1-shape dicts.
- ``make_prefetch_callback(engine, callbacks)`` — factory matching the
scheduler's ``PrefetchCallback`` protocol.
What the worker still patches in
--------------------------------
A few V1 input fields aren't known at prefetch time. The worker fills
these in after dequeue and before passing the payload to the agent:
- ``attempt_id`` — autoincrement PK, only known post-INSERT.
- ``attempt_number`` — known by the scheduler but not threaded through
the current ``PrefetchCallback`` signature; placeholder is 1.
- ``workspace_dir`` — per-worker filesystem path.
- ``wallclock_budget_s`` — worker-side config (default 600s).
For now these are placeholders the worker overwrites. The V1 contracts
admit the placeholders (``ge=0`` / ``ge=1`` / non-empty str) so the
schema parses; the worker's responsibility is to swap them for real
values before the agent ever sees the payload.
CI context (P2)
---------------
``ci_summary`` (and the implementer's ``failing_gates``) are populated
via the optional ``get_ci_status`` callback + the deterministic
``summarize_ci_status`` builder. When the callback is not wired (tests
that don't care about CI), ``ci_summary`` stays None — the pre-P2
behaviour.
What Phase 1h DOES NOT yet produce
----------------------------------
- ``conflicted_files`` for the conflict_resolver role — needs a git
rebase + conflict-parse pass that lives on the worker side.
- ``implementer_claim`` for the reviewer role — derived from the most
recent implementer attempt's ``output_payload`` (we set it when the
payload validates as ImplementerOutputV1, else leave None).
"""
from __future__ import annotations
import json
import logging
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import text
from sqlalchemy.engine import Engine
from ..db.session import session_scope
from .ci_run_status import GetActionTasksCallback
from .ci_summarize import summarize_ci_status
logger = logging.getLogger(__name__)
# Default worker-side fields the prefetch fills with placeholders.
_PLACEHOLDER_ATTEMPT_ID = 0
_PLACEHOLDER_ATTEMPT_NUMBER = 1
_PLACEHOLDER_WORKSPACE_DIR = "<worker-injected>"
_DEFAULT_WALLCLOCK_BUDGET_S = 600
# Number of recent IMPLEMENTER outputs to include verbatim in
# PriorAttemptsBlock. Older attempts are summarized (Phase 2+).
_PRIOR_VERBATIM_LIMIT = 3
# T5-13: cap on prehydrated PR comments for the conflict_resolver
# input. Unlike the implementer's ``pr_comments_since_last_attempt``
# (naturally bounded by a time window), the resolver gets all PR
# comments — a hundreds-comment PR would blow the prompt budget.
# Keep the most-recent N.
_CONFLICT_RESOLVER_PR_COMMENT_LIMIT = 20
# ─── callback bundle ─────────────────────────────────────────────────
# Each callback returns Forgejo-shape data, or raises on transport
# failure. Tests inject synthetic callbacks; production wires them via
# ``forgejo_http.build_callbacks``.
#
# Signatures:
# get_pr_details(owner, repo, pr_number) -> dict | None
# Full PR object (head/base/title/body/etc.); None on 404.
# get_pr_diff(owner, repo, pr_number) -> str | None
# Raw unified diff text; None if unavailable.
# list_pr_reviews(owner, repo, pr_number) -> list[dict]
# Forgejo PR reviews (state/body/user/submitted_at/...).
# list_pr_comments(owner, repo, pr_number) -> list[dict]
# Issue-style PR comments (body/user/created_at/...).
# get_ci_status(owner, repo, head_sha) -> dict | None
# Forgejo combined-status (``{"state": ..., "statuses": [...]}``);
# None on fetch failure. Optional — when omitted, ci_summary is
# left None (the pre-P2 behaviour).
GetPRDetailsCallback = Callable[[str, str, int], dict | None]
GetPRDiffCallback = Callable[[str, str, int], str | None]
ListPRReviewsCallback = Callable[[str, str, int], list[dict]]
ListPRCommentsCallback = Callable[[str, str, int], list[dict]]
GetCIStatusCallback = Callable[[str, str, str], dict | None]
# (owner, repo) -> list of currently-open PRs (same shape as the
# discovery list_prs callback). Grooming uses this to fetch the open-PR
# universe at prefetch time so the worker never makes Forgejo calls of
# its own.
ListOpenPRsCallback = Callable[[str, str], list[dict]]
# (owner, repo, head_sha) -> get_ci_logs bundle dict (all jobs, full
# logs). See tools/_ci_logs.get_ci_logs.
GetCILogsCallback = Callable[[str, str, str], dict]
# GetActionTasksCallback (the zombie-CI active-run-check fetcher) is
# imported from ci_run_status — one definition, shared.
@dataclass(frozen=True)
class PrefetchDataCallbacks:
"""Bundle of Forgejo fetchers the prefetch helpers depend on."""
get_pr_details: GetPRDetailsCallback
get_pr_diff: GetPRDiffCallback
list_pr_reviews: ListPRReviewsCallback
list_pr_comments: ListPRCommentsCallback
# P2: CI-status fetcher. When set, prefetch populates ``ci_summary``
# (and the implementer's ``failing_gates``) so workers stop getting
# null CI context. Optional for backward-compat — tests that don't
# care about CI can omit it (defaults to None → ci_summary stays
# None, the pre-P2 behaviour).
get_ci_status: GetCIStatusCallback | None = None
# Unified CI-log fetcher (tools/_ci_logs.get_ci_logs). When set,
# _build_ci_summary fills each failed gate's log text from the
# cache instead of leaving raw_log_excerpt empty. Optional — when
# omitted the summary still carries gate states, just no log text.
get_ci_logs: GetCILogsCallback | None = None
# Actions-task fetcher for the zombie-CI active-run check. When set,
# _build_ci_summary lets ci_summarize ask Forgejo whether a still-
# pending run is actually executing; without it the summary falls
# back to the 90-min staleness heuristic. Optional.
get_action_tasks: GetActionTasksCallback | None = None
# Phase 1 grooming gate's open-PR list fetcher. Required for the
# grooming_stage_b prefetch (so the worker compares the anchor PR
# against the full open universe without any Forgejo I/O of its
# own). Optional for backward-compat — non-grooming roles never
# touch it.
list_open_prs: ListOpenPRsCallback | None = None
# ─── DB-side helpers ─────────────────────────────────────────────────
def _read_workflow(session, workflow_id: int) -> dict:
"""Read the workflow row as a plain dict. Raises if absent."""
row = session.execute(
text(
"SELECT workflow_id, kind, owner, repo, entity_number, "
" current_state, current_tier, tier_last_succeeded "
"FROM workflows WHERE workflow_id = :wf_id"
),
{"wf_id": workflow_id},
).first()
if row is None:
raise ValueError(f"workflow {workflow_id} not found")
return {
"workflow_id": row.workflow_id,
"kind": row.kind,
"owner": row.owner,
"repo": row.repo,
"entity_number": row.entity_number,
"current_state": row.current_state,
"current_tier": row.current_tier,
"tier_last_succeeded": row.tier_last_succeeded,
}
def _coerce_payload(raw: Any) -> dict | None:
"""Deserialize a JSON payload that may come back as text from raw
``text()`` SELECTs against the JSON column (the ORM's JSON type
processor only fires when the column type is known)."""
if isinstance(raw, dict):
return raw
if isinstance(raw, str):
try:
parsed = json.loads(raw)
except (TypeError, ValueError):
return None
return parsed if isinstance(parsed, dict) else None
return None
def _read_prior_implementer_attempts(
session,
workflow_id: int,
) -> tuple[list[dict], int]:
"""Return (verbatim_outputs, total_attempts).
verbatim_outputs is the most-recent ``_PRIOR_VERBATIM_LIMIT``
implementer outputs. total_attempts counts the attempts the next
agent should know about.
Includes ``status='complete'`` attempts AND ``gate-failed`` ones:
a gate-failed attempt is status='failed' (so the scheduler
re-enqueues it), but its payload carries the worker gate's failure
report in ``blockers`` — the next agent needs that to fix the lint/
typecheck error specifically rather than rediscovering it blind.
Other failed outcomes (worker-internal-error, stale-input) carry no
actionable detail and stay excluded.
"""
rows = session.execute(
text(
"SELECT attempt_number, output_payload, output_version "
"FROM workflow_attempts "
"WHERE workflow_id = :wf_id "
" AND role = 'implementer' "
" AND ( status = 'complete' "
" OR (status = 'failed' AND outcome = 'gate-failed') ) "
" AND output_payload IS NOT NULL "
"ORDER BY attempt_number DESC"
),
{"wf_id": workflow_id},
).all()
total = len(rows)
verbatim: list[dict] = []
for r in rows[:_PRIOR_VERBATIM_LIMIT]:
payload = _coerce_payload(r.output_payload)
if payload is not None:
verbatim.append(payload)
# Caller expects oldest-first verbatim block:
verbatim.reverse()
return verbatim, total
def _read_most_recent_conflict_resolver_output(
session,
workflow_id: int,
) -> dict | None:
"""T5-11: return the most-recent completed conflict_resolver
attempt's ``output_payload`` (parsed dict), or ``None``.
Used to populate ``PriorAttemptsBlock.last_resolver_output`` so the
implementer prompt can detect "the prior step was a resolver" and
surface the ``verified-clean`` fast-success guidance. Without this,
the implementer's ``prior_attempts.verbatim`` only contains
role='implementer' rows (per ``_read_prior_implementer_attempts``);
resolver attempts would be invisible and the verified-clean block
would never render. See T5-11 in ``.drew/PENDING_FIXES.md``.
"""
row = session.execute(
text(
"SELECT output_payload FROM workflow_attempts "
"WHERE workflow_id = :wf_id "
" AND role = 'conflict_resolver' "
" AND status = 'complete' "
" AND output_payload IS NOT NULL "
"ORDER BY attempt_number DESC LIMIT 1"
),
{"wf_id": workflow_id},
).first()
if row is None:
return None
return _coerce_payload(row.output_payload)
def _read_most_recent_implementer_output(
session,
workflow_id: int,
) -> dict | None:
"""Return the latest completed implementer output, or None."""
row = session.execute(
text(
"SELECT output_payload FROM workflow_attempts "
"WHERE workflow_id = :wf_id "
" AND role = 'implementer' "
" AND status = 'complete' "
" AND output_payload IS NOT NULL "
"ORDER BY attempt_number DESC LIMIT 1"
),
{"wf_id": workflow_id},
).first()
if row is None:
return None
return _coerce_payload(row.output_payload)
def _read_prior_reviewer_outputs(
session,
workflow_id: int,
) -> list[dict]:
"""Return all completed reviewer outputs, oldest first."""
rows = session.execute(
text(
"SELECT output_payload FROM workflow_attempts "
"WHERE workflow_id = :wf_id "
" AND role = 'reviewer' "
" AND status = 'complete' "
" AND output_payload IS NOT NULL "
"ORDER BY attempt_number ASC"
),
{"wf_id": workflow_id},
).all()
out: list[dict] = []
for r in rows:
payload = _coerce_payload(r.output_payload)
if payload is not None:
out.append(payload)
return out
def _read_last_reviewer_output(
session,
workflow_id: int,
) -> dict | None:
"""Return the most recent completed reviewer attempt's payload + id +
finished_at, or None.
Used to inject the controller-reviewer's verdict + blocking_issues
into the next implementer attempt's ``active_reviews`` so the
implementer can act on what the reviewer actually said. Without
this plumbing the implementer retries blind (trial-5 T5-1b).
"""
row = session.execute(
text(
"SELECT attempt_id, output_payload, finished_at "
"FROM workflow_attempts "
"WHERE workflow_id = :wf_id "
" AND role = 'reviewer' "
" AND status = 'complete' "
" AND output_payload IS NOT NULL "
"ORDER BY attempt_number DESC LIMIT 1"
),
{"wf_id": workflow_id},
).first()
if row is None:
return None
payload = _coerce_payload(row.output_payload)
if payload is None:
return None
return {
"attempt_id": row.attempt_id,
"finished_at": row.finished_at,
"output_payload": payload,
}
def _controller_reviewer_as_active_review(
reviewer_meta: dict | None,
) -> dict | None:
"""Render the last reviewer's ``request-changes`` verdict as a
synthetic ``Review`` dict (V1 ``active_reviews`` shape).
Returns None when the last reviewer approved or no prior reviewer
attempt exists — nothing needs to be surfaced to the implementer
in those cases.
The synthetic review_id is offset by 999_900_000 to avoid colliding
with Forgejo's review IDs. The reviewer_login encodes the attempt
id so the implementer can distinguish the in-controller reviewer
from human / bot reviewers.
"""
if reviewer_meta is None:
return None
out = reviewer_meta.get("output_payload")
if not isinstance(out, dict):
return None
if out.get("verdict") != "request-changes":
return None
blockers = out.get("blocking_issues") or []
lines: list[str] = [
f"## Controller reviewer (attempt {reviewer_meta['attempt_id']}) — "
"verdict=request-changes",
"",
f"Confidence: {out.get('confidence') or 'unspecified'}",
f"Suggested next action: {out.get('suggested_next_action') or 'unspecified'}",
"",
f"### {len(blockers)} blocker(s)",
"",
]
for i, b in enumerate(blockers, 1):
if not isinstance(b, dict):
continue
lines.append(f"**{i}. severity={b.get('severity', '?')}**")
file_path = b.get("file_path")
if file_path:
loc = str(file_path)
if b.get("line_range"):
loc = f"{loc}:{b['line_range']}"
lines.append(f"Location: `{loc}`")
lines.append("")
lines.append(b.get("description") or "(no description)")
if b.get("suggested_fix"):
lines.append("")
lines.append(f"Suggested fix: {b['suggested_fix']}")
lines.append("")
lines.append("---")
lines.append("Posted in-process by the controller reviewer (not on Forgejo).")
fa = reviewer_meta.get("finished_at")
if isinstance(fa, datetime):
submitted_at = fa.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
elif isinstance(fa, str) and fa:
submitted_at = fa
else:
submitted_at = None
return {
"review_id": 999_900_000 + int(reviewer_meta["attempt_id"]),
"reviewer_login": f"controller-reviewer-a{reviewer_meta['attempt_id']}",
"state": "REQUEST_CHANGES",
"body": "\n".join(lines),
"submitted_at": submitted_at,
}
def _read_last_implementer_finished_at(
session,
workflow_id: int,
) -> Any | None:
"""Timestamp of the most recently completed implementer attempt,
or None. Used to filter "comments since last attempt"."""
row = session.execute(
text(
"SELECT finished_at FROM workflow_attempts "
"WHERE workflow_id = :wf_id "
" AND role = 'implementer' "
" AND status = 'complete' "
"ORDER BY attempt_number DESC LIMIT 1"
),
{"wf_id": workflow_id},
).first()
return row.finished_at if row is not None else None
# ─── Forgejo-shape adapters ──────────────────────────────────────────
def _diff_summary_from_pr(pr: dict) -> str:
"""One-line diff summary derived from PR fields (additions /
deletions / changed_files). Falls back gracefully on missing keys."""
parts: list[str] = []
changed = pr.get("changed_files")
if isinstance(changed, int):
parts.append(f"{changed} files")
add = pr.get("additions")
if isinstance(add, int):
parts.append(f"+{add}")
delete = pr.get("deletions")
if isinstance(delete, int):
parts.append(f"-{delete}")
if not parts:
return "diff metadata unavailable"
return ", ".join(parts)
def _active_reviews_from_forgejo(reviews: list[dict]) -> list[dict]:
"""Project Forgejo reviews into the V1 ``Review`` shape.
Skips reviews that don't carry the required fields (review_id,
reviewer_login, state). Forgejo states map directly:
APPROVED / REQUEST_CHANGES / COMMENT / PENDING / DISMISSED.
"""
valid_states = {
"APPROVED",
"REQUEST_CHANGES",
"COMMENT",
"PENDING",
"DISMISSED",
}
out: list[dict] = []
for r in reviews:
if not isinstance(r, dict):
continue
review_id = r.get("id")
if not isinstance(review_id, int):
continue
user = r.get("user") if isinstance(r.get("user"), dict) else {}
login = user.get("login") if isinstance(user, dict) else None
if not isinstance(login, str) or not login:
continue
state = r.get("state")
if state not in valid_states:
continue
out.append(
{
"review_id": review_id,
"reviewer_login": login,
"state": state,
"body": r.get("body") or "",
"submitted_at": r.get("submitted_at"),
}
)
return out
def _comment_bodies_since(comments: list[dict], since: Any | None) -> list[str]:
"""Return comment bodies whose ``created_at`` is strictly after
``since``. If ``since`` is None, returns ALL comment bodies.
Compares as ``datetime`` (not string) so timezone-suffix variants
don't bifurcate the comparison: Forgejo emits
``2026-05-18T12:00:00Z``; Python ``datetime.isoformat()`` emits
``2026-05-18T12:00:00+00:00`` — lexicographic compare is wrong.
"""
since_dt = _to_aware_datetime(since)
out: list[str] = []
for c in comments:
if not isinstance(c, dict):
continue
body = c.get("body")
if not isinstance(body, str) or not body:
continue
if since_dt is None:
out.append(body)
continue
created_at_dt = _to_aware_datetime(c.get("created_at"))
if created_at_dt is None:
# Conservatively include — missing timestamp means we can't
# prove it's older than the cutoff. Better to show the
# worker a comment it might already have seen than to
# silently drop a relevant one.
out.append(body)
continue
if created_at_dt > since_dt:
out.append(body)
return out
def _to_aware_datetime(value: Any) -> datetime | None:
"""Coerce a value (str / datetime / None) to a timezone-aware
datetime. Returns None for None or unparseable values.
Accepts the trailing-Z variant Forgejo emits. Naive datetimes are
interpreted as UTC (the controller's invariant: all timestamps
are UTC at the boundary).
"""
if value is None:
return None
if isinstance(value, datetime):
return value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
if isinstance(value, str):
if not value:
return None
candidate = value.replace("Z", "+00:00") if value.endswith("Z") else value
try:
parsed = datetime.fromisoformat(candidate)
except ValueError:
return None
return (
parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=timezone.utc)
)
return None
# ─── CI summary (P2) ─────────────────────────────────────────────────
def _build_ci_summary(
callbacks: PrefetchDataCallbacks,
*,
owner: str,
repo: str,
head_sha: str | None,
) -> dict | None:
"""Build a CISummary-shape dict for ``head_sha`` via the deterministic
``summarize_ci_status`` builder.
Returns None when:
- no ``get_ci_status`` callback is wired (pre-P2 backward-compat),
- ``head_sha`` is missing,
- the CI-status fetch fails or raises.
The summarizer never raises; per-gate log fetches degrade to a
``log-fetch-failed`` CIFailure. When ``callbacks.get_ci_logs`` is
wired, each failed gate's ``raw_log_excerpt`` is filled from the
unified CI-log cache; without it the summary still carries gate
states (the worker at least sees which gates failed).
"""
if callbacks.get_ci_status is None or not head_sha:
return None
try:
forgejo_status = callbacks.get_ci_status(owner, repo, head_sha)
except Exception as exc: # noqa: BLE001 — transient; degrade to None
logger.warning(
"prefetch: get_ci_status failed for %s/%s @%s: %s",
owner,
repo,
head_sha[:12],
exc,
)
return None
if forgejo_status is None:
return None
# Real log fetcher: pull every gate's full log from the unified
# CI-log cache so the worker's ci_summary carries actual failure
# text, not just gate states. ``dict.get`` IS the LogFetcher
# contract (gate_context -> str | None).
#
# Only fetch when a gate actually failed — a fully-green CI has no
# log a worker would read, and get_ci_logs would otherwise do a
# full all-jobs session-fetch for nothing on every green PR.
statuses = forgejo_status.get("statuses") or []
has_failure = any(
isinstance(s, dict)
and str(s.get("state") or s.get("status") or "").lower()
in {"failure", "failed", "error", "cancelled", "canceled", "timed_out"}
for s in statuses
)
logs_by_gate: dict[str, str] = {}
if has_failure and callbacks.get_ci_logs is not None:
try:
bundle = callbacks.get_ci_logs(owner, repo, head_sha)
for job in bundle.get("jobs") or []:
if (
isinstance(job, dict)
and job.get("context")
and isinstance(job.get("log"), str)
):
logs_by_gate[job["context"]] = job["log"]
except Exception as exc: # noqa: BLE001 — degrade to no logs
logger.warning(
"prefetch: get_ci_logs failed for %s/%s @%s: %s",
owner,
repo,
head_sha[:12],
exc,
)
try:
return summarize_ci_status(
head_sha=head_sha,
forgejo_status=forgejo_status,
log_fetcher=logs_by_gate.get,
owner=owner,
repo=repo,
get_action_tasks=callbacks.get_action_tasks,
)
except Exception as exc: # noqa: BLE001 — never block prefetch
logger.warning(
"prefetch: summarize_ci_status raised for %s/%s @%s: %s",
owner,
repo,
head_sha[:12],
exc,
)
return None
def _failing_gates(ci_summary: dict | None) -> list[dict]:
"""Extract the failed/errored gates from a CISummary dict — the
implementer's ``failing_gates`` convenience filter."""
if not ci_summary:
return []
gates = ci_summary.get("gates") or []
return [
g
for g in gates
if isinstance(g, dict) and g.get("status") in {"failed", "error"}
]
# ─── per-role builders ───────────────────────────────────────────────
def build_implementer_input(
*,
engine: Engine,
workflow_id: int,
tier: int,
callbacks: PrefetchDataCallbacks,
) -> dict:
"""Assemble an ImplementerInputV1-shape dict for a PR workflow.
Worker patches in attempt_id / attempt_number / workspace_dir
before validation.
"""
with session_scope(engine) as session:
wf = _read_workflow(session, workflow_id)
if wf["kind"] != "pr":
raise ValueError(
f"implementer prefetch needs kind='pr', got {wf['kind']!r}"
)
prior_verbatim, prior_total = _read_prior_implementer_attempts(
session,
workflow_id,
)
since = _read_last_implementer_finished_at(session, workflow_id)
reviewer_meta = _read_last_reviewer_output(session, workflow_id)
# T5-11: surface the most-recent conflict_resolver attempt's
# output so the implementer prompt can detect post-resolution
# context and offer the ``verified-clean`` fast-success path.
last_resolver_output = _read_most_recent_conflict_resolver_output(
session,
workflow_id,
)
pr = callbacks.get_pr_details(wf["owner"], wf["repo"], wf["entity_number"])
if pr is None:
raise ValueError(
f"PR {wf['owner']}/{wf['repo']}#{wf['entity_number']} not found"
)
head_sha = (
pr.get("head", {}).get("sha") if isinstance(pr.get("head"), dict) else None
)
head_ref = (
pr.get("head", {}).get("ref") if isinstance(pr.get("head"), dict) else None
)
base_branch = (
pr.get("base", {}).get("ref") if isinstance(pr.get("base"), dict) else None
)
if not head_sha or not head_ref or not base_branch:
raise ValueError(f"PR {wf['entity_number']} missing head/base metadata")
reviews = callbacks.list_pr_reviews(wf["owner"], wf["repo"], wf["entity_number"])
comments = callbacks.list_pr_comments(wf["owner"], wf["repo"], wf["entity_number"])
# P2: populate CI context so the implementer sees the actual
# failing gates instead of null.
ci_summary = _build_ci_summary(
callbacks,
owner=wf["owner"],
repo=wf["repo"],
head_sha=head_sha,
)
forgejo_active = _active_reviews_from_forgejo(reviews)
synthetic_review = _controller_reviewer_as_active_review(reviewer_meta)
active_reviews = (
[synthetic_review, *forgejo_active]
if synthetic_review is not None
else forgejo_active
)
return {
"input_version": "V1",
"workflow_id": workflow_id,
"attempt_id": _PLACEHOLDER_ATTEMPT_ID,
"attempt_number": _PLACEHOLDER_ATTEMPT_NUMBER,
"owner": wf["owner"],
"repo": wf["repo"],
"pr_number": wf["entity_number"],
"tier": tier,
"head_sha": head_sha,
"head_ref": head_ref,
"base_branch": base_branch,
"ci_summary": ci_summary, # P2: wired via get_ci_status.
"failing_gates": _failing_gates(ci_summary), # P2.
"active_reviews": active_reviews,
"pr_comments_since_last_attempt": _comment_bodies_since(comments, since),
"prior_attempts": {
"verbatim": prior_verbatim,
"older_summary": None,
"older_summary_covers_through_attempt": None,
"total_attempts": prior_total,
"last_resolver_output": last_resolver_output,
},
"allowed_files": None,
"diff_summary": _diff_summary_from_pr(pr),
"workspace_dir": _PLACEHOLDER_WORKSPACE_DIR,
"wallclock_budget_s": _DEFAULT_WALLCLOCK_BUDGET_S,
}
def build_reviewer_input(
*,
engine: Engine,
workflow_id: int,
callbacks: PrefetchDataCallbacks,
) -> dict:
"""Assemble a ReviewerInputV1-shape dict for a PR workflow."""
with session_scope(engine) as session:
wf = _read_workflow(session, workflow_id)
if wf["kind"] != "pr":
raise ValueError(f"reviewer prefetch needs kind='pr', got {wf['kind']!r}")
prior_implementer_verbatim, prior_implementer_total = (
_read_prior_implementer_attempts(session, workflow_id)
)
prior_reviews = _read_prior_reviewer_outputs(session, workflow_id)
implementer_claim = _read_most_recent_implementer_output(
session,
workflow_id,
)
pr = callbacks.get_pr_details(wf["owner"], wf["repo"], wf["entity_number"])
if pr is None:
raise ValueError(
f"PR {wf['owner']}/{wf['repo']}#{wf['entity_number']} not found"
)
head_sha = (
pr.get("head", {}).get("sha") if isinstance(pr.get("head"), dict) else None
)
if not head_sha:
raise ValueError(f"PR {wf['entity_number']} missing head.sha")
full_diff = callbacks.get_pr_diff(wf["owner"], wf["repo"], wf["entity_number"])
# P2: populate CI context for the reviewer.
ci_summary = _build_ci_summary(
callbacks,
owner=wf["owner"],
repo=wf["repo"],
head_sha=head_sha,
)
payload: dict[str, Any] = {
"input_version": "V1",
"workflow_id": workflow_id,
"attempt_id": _PLACEHOLDER_ATTEMPT_ID,
"attempt_number": _PLACEHOLDER_ATTEMPT_NUMBER,
"owner": wf["owner"],
"repo": wf["repo"],
"pr_number": wf["entity_number"],
"head_sha": head_sha,
"ci_summary": ci_summary, # P2: wired via get_ci_status.
"diff_summary": _diff_summary_from_pr(pr),
"full_diff": full_diff,
"prior_reviews": prior_reviews,
"prior_implementer_attempts": {
"verbatim": prior_implementer_verbatim,
"older_summary": None,
"older_summary_covers_through_attempt": None,
"total_attempts": prior_implementer_total,
},
"implementer_claim": implementer_claim,
"workspace_dir": _PLACEHOLDER_WORKSPACE_DIR,
"wallclock_budget_s": _DEFAULT_WALLCLOCK_BUDGET_S,
}
return payload
def build_estimator_input(
*,
engine: Engine,
workflow_id: int,
callbacks: PrefetchDataCallbacks,
) -> dict:
"""Assemble an EstimatorInputV1-shape dict.
Works for both PR and issue workflows. For issues, head_sha /
diff_summary stay None (estimator runs pre-PR for issues).
"""
with session_scope(engine) as session:
wf = _read_workflow(session, workflow_id)
pr_number: int | None = None
head_sha: str | None = None
diff_summary: str | None = None
title = ""
body = ""
if wf["kind"] == "pr":
pr = callbacks.get_pr_details(wf["owner"], wf["repo"], wf["entity_number"])
if pr is None:
raise ValueError(
f"PR {wf['owner']}/{wf['repo']}#{wf['entity_number']} not found"
)
pr_number = wf["entity_number"]
head_sha = (
pr.get("head", {}).get("sha") if isinstance(pr.get("head"), dict) else None
)
diff_summary = _diff_summary_from_pr(pr)
title = pr.get("title") or ""
body = pr.get("body") or ""
else:
# Issue workflow: estimator runs pre-PR. We synthesize title /
# body from the issue payload via the same get_pr_details
# callback (Forgejo treats issues and PRs interchangeably at
# the /issues/{n} endpoint, but the controller binds prefetch
# callbacks to PRs; for issues we accept that title/body stay
# empty for now — Phase 1h+ adds a list_issue_details callback).
title = ""
body = ""
# P2: populate CI context for PR-kind estimator inputs. Issue
# workflows run the estimator pre-PR (head_sha is None) — ci_summary
# stays None there, which is correct.
ci_summary = _build_ci_summary(
callbacks,
owner=wf["owner"],
repo=wf["repo"],
head_sha=head_sha,
)
return {
"input_version": "V1",
"workflow_id": workflow_id,
"attempt_id": _PLACEHOLDER_ATTEMPT_ID,
"owner": wf["owner"],
"repo": wf["repo"],
"pr_number": pr_number,
"head_sha": head_sha,
"ci_summary": ci_summary,
"diff_summary": diff_summary,
"pr_title": title,
"pr_body": body,
"workspace_dir": _PLACEHOLDER_WORKSPACE_DIR,
"wallclock_budget_s": _DEFAULT_WALLCLOCK_BUDGET_S,
}
def build_conflict_resolver_input(
*,
engine: Engine,
workflow_id: int,
tier: int,
callbacks: PrefetchDataCallbacks,
) -> dict:
"""Assemble conflict-resolver input.
Conflicted-file extraction needs a git rebase pass that lives on
the worker side — ``conflicted_files`` is returned empty and the
worker fills it.
T5-13: ``pr_title`` / ``pr_body`` / ``pr_comments`` are prehydrated
here so the conflict_resolver can disambiguate merge intent WITHOUT
its own Forgejo I/O. This brings the conflict_resolver in line with
the implementer/reviewer/estimator (all prefetched) and lets the
agent run fully network-isolated (``forgejo*: deny``).
"""
with session_scope(engine) as session:
wf = _read_workflow(session, workflow_id)
if wf["kind"] != "pr":
raise ValueError(
f"conflict_resolver prefetch needs kind='pr', got {wf['kind']!r}"
)
prior_verbatim, prior_total = _read_prior_implementer_attempts(
session,
workflow_id,
)
pr = callbacks.get_pr_details(wf["owner"], wf["repo"], wf["entity_number"])
if pr is None:
raise ValueError(
f"PR {wf['owner']}/{wf['repo']}#{wf['entity_number']} not found"
)
head_sha = (
pr.get("head", {}).get("sha") if isinstance(pr.get("head"), dict) else None
)
head_ref = (
pr.get("head", {}).get("ref") if isinstance(pr.get("head"), dict) else None
)
base_branch = (
pr.get("base", {}).get("ref") if isinstance(pr.get("base"), dict) else None
)
base_sha = (
pr.get("base", {}).get("sha") if isinstance(pr.get("base"), dict) else None
)
if not head_sha or not head_ref or not base_branch or not base_sha:
raise ValueError(
f"PR {wf['entity_number']} missing head/base metadata for conflict resolver"
)
# T5-13: prehydrate PR intent so the resolver never needs forgejo*.
# Cap at the most-recent N comments — the resolver has no "since"
# window (unlike the implementer), so an unbounded list would blow
# the prompt budget on a heavily-commented PR.
comments = callbacks.list_pr_comments(
wf["owner"],
wf["repo"],
wf["entity_number"],
)
pr_comment_bodies = _comment_bodies_since(comments, None)
pr_comment_bodies = pr_comment_bodies[-_CONFLICT_RESOLVER_PR_COMMENT_LIMIT:]
return {
"input_version": "V1",
"workflow_id": workflow_id,
"attempt_id": _PLACEHOLDER_ATTEMPT_ID,
"owner": wf["owner"],
"repo": wf["repo"],
"pr_number": wf["entity_number"],
"head_sha": head_sha,
"head_ref": head_ref,
"base_branch": base_branch,
"base_sha": base_sha,
# The worker starts the rebase (prepare_conflict_rebase) and
# hands the agent a mid-rebase worktree; the agent enumerates
# conflicts itself via git status, so this stays empty.
"conflicted_files": [],
"prior_implementer_outputs": {
"verbatim": prior_verbatim,
"older_summary": None,
"older_summary_covers_through_attempt": None,
"total_attempts": prior_total,
},
"pr_title": pr.get("title") or "",
"pr_body": pr.get("body") or "",
"pr_comments": pr_comment_bodies,
"workspace_dir": _PLACEHOLDER_WORKSPACE_DIR,
"wallclock_budget_s": _DEFAULT_WALLCLOCK_BUDGET_S,
}
def build_grooming_stage_b_input(
*,
engine: Engine,
workflow_id: int,
callbacks: PrefetchDataCallbacks,
wallclock_budget_s: int = _DEFAULT_WALLCLOCK_BUDGET_S,
) -> dict:
"""Assemble a GroomingInputV1-shape dict.
The worker runs deterministic checks + Stage A suspicion scoring +
Stage B LLM judgment internally; this builder just hands it the raw
data: the anchor PR detail dict + the list of currently-open PRs in
(owner, repo). The worker fetches no additional Forgejo data of its
own — anything else it needs (PR titles, bodies, file lists) lives
on the per-PR dicts.
Requires ``callbacks.list_open_prs`` to be wired; raises ValueError
otherwise (grooming can't run without the open-PR universe).
"""
if callbacks.list_open_prs is None:
raise ValueError(
"grooming_stage_b prefetch requires "
"PrefetchDataCallbacks.list_open_prs (the open-PR list "
"fetcher); none was wired"
)
with session_scope(engine) as session:
wf = _read_workflow(session, workflow_id)
if wf["kind"] != "pr":
raise ValueError(
"grooming_stage_b prefetch needs kind='pr' "
f"(grooming gates PR pipelines, not issues); got {wf['kind']!r}"
)
anchor_pr = callbacks.get_pr_details(
wf["owner"], wf["repo"], wf["entity_number"]
)
if anchor_pr is None:
raise ValueError(
f"anchor PR {wf['owner']}/{wf['repo']}#{wf['entity_number']} "
"not found (404 at prefetch time — PR was deleted between "
"discovery and grooming)"
)
open_prs = callbacks.list_open_prs(wf["owner"], wf["repo"])
return {
"input_version": "V1",
"workflow_id": workflow_id,
"attempt_id": _PLACEHOLDER_ATTEMPT_ID,
"owner": wf["owner"],
"repo": wf["repo"],
"pr_number": wf["entity_number"],
"anchor_pr": anchor_pr,
"open_prs": open_prs,
"workspace_dir": _PLACEHOLDER_WORKSPACE_DIR,
"wallclock_budget_s": wallclock_budget_s,
}
# ─── PrefetchCallback factory ────────────────────────────────────────
def make_prefetch_callback(
engine: Engine,
callbacks: PrefetchDataCallbacks,
) -> Callable[[int, str, int | None], tuple[dict, str]]:
"""Build a ``PrefetchCallback`` matching the scheduler protocol.
Routes by role to the per-role builder. Returns ``(payload, "V1")``.
"""
def prefetch(
workflow_id: int,
role: str,
tier: int | None,
) -> tuple[dict, str]:
if role == "implementer":
payload = build_implementer_input(
engine=engine,
workflow_id=workflow_id,
tier=int(tier) if tier is not None else 0,
callbacks=callbacks,
)
elif role == "reviewer":
payload = build_reviewer_input(
engine=engine,
workflow_id=workflow_id,
callbacks=callbacks,
)
elif role == "estimator":
payload = build_estimator_input(
engine=engine,
workflow_id=workflow_id,
callbacks=callbacks,
)
elif role == "conflict_resolver":
payload = build_conflict_resolver_input(
engine=engine,
workflow_id=workflow_id,
tier=int(tier) if tier is not None else 0,
callbacks=callbacks,
)
elif role == "grooming_stage_b":
payload = build_grooming_stage_b_input(
engine=engine,
workflow_id=workflow_id,
callbacks=callbacks,
)
else:
raise ValueError(f"unknown role for prefetch: {role!r}")
return payload, "V1"
return prefetch
__all__ = [
"GetCILogsCallback",
"GetCIStatusCallback",
"GetPRDetailsCallback",
"GetPRDiffCallback",
"ListOpenPRsCallback",
"ListPRReviewsCallback",
"ListPRCommentsCallback",
"PrefetchDataCallbacks",
"build_conflict_resolver_input",
"build_estimator_input",
"build_grooming_stage_b_input",
"build_implementer_input",
"build_reviewer_input",
"make_prefetch_callback",
]