016b348117
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>
1129 lines
41 KiB
Python
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",
|
|
]
|