Files
cleveragents-core/tools/controller/contracts/v1.py
T
drew 4d969eaf2b feat(controller): Phase 3 — Gate 3 reviewer-abandon
When the reviewer finishes a review and judges the work fundamentally
unworkable (implementation surfaced misdiagnosis, obsoleted-by-other-work,
or irreducible complexity), it can now emit
verdict='abstain' + suggested_next_action='abandon' with a Gate-3
abandon_reason_category. The controller routes REVIEWING → ABANDONED
and (when the kill switch is on) performs the Forgejo close via the
reviewer-abandon side-effect tick — no implementer/CI/merge cycles.

Wired with the same defense-in-depth pattern Phase 2 established:
MCP setter validation + outcomes mapper dispatch with confidence
gating + Pydantic atomicity validator + side-effect tick with
audit-trail attribution (cause=REVIEWER_ABANDON,
event_type='reviewer_abandon'). Default-off
CONTROLLER_GATE3_ABANDON_ENABLED kill switch so a fresh deploy is
audit-only until the operator explicitly enables Forgejo writes.

Bundled refactor: hoisted the 9 Gate-2 + 3 Gate-3-exclusive abandon
categories into tools/controller/contracts/abandon_categories.py
(triggered by Phase 3 per the plan's follow-up backlog). Both gates
now consume the shared frozensets; doc-contract tests grep each
agent prompt against the canonical list.

Adversarial review (2 rounds): caught + fixed MCP cross-check
ordering (atomicity FIRST so missing-setter shows actionable error),
confidence=None symmetric downgrade across both gates, dead
blocking-issues extraction in _run_close, idempotency clock-collision
in the test, low-vs-missing reason-string conflation, and several
test-quality gaps. 4064/4071 tests passing (7 pre-existing failures
unrelated to Phase 3).

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

882 lines
36 KiB
Python

"""V1 contracts for the controller's worker I/O.
Conventions:
- ``output_version: Literal["V1"]`` is a required discriminator field
on every worker output model (no default). Missing field → strict
parse failure → retry-once-with-corrective-prompt → STUCK on
second failure.
- ``ConfigDict(extra="forbid")`` on every model. Workers that emit
extra fields fail loud, not silently.
- No ``dict[str, Any]`` escape hatches. Add fields to V2 explicitly
when the need arises.
"""
from __future__ import annotations
from datetime import datetime
from typing import Annotated, Any, Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
# ─── shared building blocks ─────────────────────────────────────────
class FileLocation(BaseModel):
"""Repository-relative file reference with optional line context."""
model_config = ConfigDict(extra="forbid")
file_path: str
line_range: str | None = None
function_or_test: str | None = None
class FailedAssertion(BaseModel):
"""Test-failure-specific structured detail (pytest / behave / robot)."""
model_config = ConfigDict(extra="forbid")
test_name: str
expected: str | None = None
actual: str | None = None
assertion_excerpt: str = Field(
...,
max_length=4096,
description="Raw assertion line + adjacent context (~10 lines).",
)
class CIFailureFinding(BaseModel):
"""One structured finding within a CIFailure.
A simple lint failure has one finding (the line that didn't lint).
A test session has many findings (one per failing test).
Bandit/semgrep findings have one per security issue.
"""
model_config = ConfigDict(extra="forbid")
error_class: str = Field(..., description="Tool-specific error code/class.")
summary: str = Field(..., max_length=512)
location: FileLocation | None = None
severity: Literal["info", "warning", "error", "blocker"] = "error"
class CIFailure(BaseModel):
"""Structured failure for one CI context.
v9/v10: structured fields are ALWAYS populated; ``raw_log_excerpt``
is supplementary context (NOT a fallback). No ``raw_only`` parser
exists — unmatched CI tools raise ``UnknownCIToolError`` →
workflow STUCK → operator writes a parser.
"""
model_config = ConfigDict(extra="forbid")
parser_used: str = Field(
..., description="Parser module name (e.g. 'ruff', 'pyright', 'behave')."
)
parser_version: str = Field(
..., description="From parser module's PARSER_VERSION constant."
)
# Required structured fields:
error_class: str = Field(
...,
max_length=128,
description="Most-specific tool error class (e.g. 'AssertionError', 'LintRuleE501').",
)
summary_line: str = Field(
...,
max_length=200,
description="One-line human-readable summary of the failure.",
)
findings: list[CIFailureFinding] = Field(default_factory=list)
failing_locations: list[FileLocation] = Field(default_factory=list)
failed_assertions: list[FailedAssertion] = Field(default_factory=list)
# Supplementary (always present; smart-excerpt-selected by summarizer):
raw_log_excerpt: str = Field(
..., max_length=16384, description="≤16KB excerpt of the failing job's log."
)
log_excerpt_lines: int = Field(..., ge=0)
# Multi-tool composite (e.g. nox security_scan = bandit+semgrep+vulture):
composite_findings: list["CIFailure"] = Field(
default_factory=list,
description="Nested findings when one gate runs multiple tools.",
)
# Forward-ref resolution for the recursive composite_findings field.
CIFailure.model_rebuild()
class GateResult(BaseModel):
"""One CI context's result."""
model_config = ConfigDict(extra="forbid")
name: str = Field(
..., description="Forgejo context name, e.g. 'CI / Run Unit Tests'."
)
status: Literal["passed", "failed", "error", "skipped", "pending"]
severity: Literal["info", "warning", "error", "blocker"] = "error"
target_url: str | None = Field(
None, description="Forgejo link to logs (operator-visible)."
)
duration_seconds: float | None = Field(None, ge=0.0)
failure: CIFailure | None = Field(
None,
description="Populated iff status in {failed, error}.",
)
class CISummary(BaseModel):
"""Standardized CI state for a head_sha.
Built deterministically by ``_ci_summary.build()`` in the master's
prefetch path. Every worker that needs CI context consumes this
shape (no worker scans raw logs).
"""
model_config = ConfigDict(extra="forbid")
summary_version: Literal["V1"] = "V1"
head_sha: str
observed_at: datetime
overall_state: Literal["success", "failure", "error", "pending", "unknown"]
gates: list[GateResult]
# Convenience aggregates (computed by build()):
gates_total: int = Field(..., ge=0)
gates_passed: int = Field(..., ge=0)
gates_failed: int = Field(..., ge=0)
gates_skipped: int = Field(..., ge=0)
gates_pending: int = Field(..., ge=0)
# Parser-side metadata for debugging + drift detection:
parser_versions: dict[str, str] = Field(
default_factory=dict,
description="Per-parser version (e.g. {'ruff': 'v1', 'pyright': 'v1'}).",
)
# ─── shared worker fields ─────────────────────────────────────────────
class PriorAttemptsBlock(BaseModel):
"""Bounded representation of prior attempts in worker input.
Semantics:
- ``verbatim`` is IMPLEMENTER-only — the most-recent implementer
outputs against this workflow. Reviewer / conflict-resolver /
estimator each see this block to know what implementation has
been tried.
- Reviewer's own prior verdicts live in ``ReviewerInputV1.prior_reviews``,
not here.
- ``last_resolver_output`` (T5-11) carries the most-recent
conflict_resolver output, kept SEPARATE from ``verbatim`` so the
latter retains its implementer-only type. See the field comment
below.
v6: last 3 verbatim + older synthesized via summarizer; never
blocks the workflow on summarization failure (falls back to
verbatim-extra up to 6).
v9: reference-based for older CI summaries (latest carries full
CISummary; older lookup via ``ci_summary_id``).
"""
model_config = ConfigDict(extra="forbid")
verbatim: list["ImplementerOutputV1"] = Field(default_factory=list)
older_summary: str | None = None
older_summary_covers_through_attempt: int | None = Field(None, ge=0)
total_attempts: int = Field(..., ge=0)
# T5-11: the most-recent conflict_resolver output (if any) for this
# workflow. Populated by ``prefetch._read_most_recent_conflict_resolver_output``.
# Lets the implementer prompt detect "the prior step was a resolver"
# and surface the ``verified-clean`` fast-success guidance.
# Pre-T5-11 there was no plumbing for resolver outputs to reach the
# implementer's input — ``_read_prior_implementer_attempts`` filters
# on ``role='implementer'`` so resolver attempts never appeared in
# ``verbatim``. This dedicated field fixes the gap without weakening
# ``verbatim``'s implementer-only type.
last_resolver_output: "ConflictResolverOutputV1 | None" = None
class Review(BaseModel):
"""A blocking or advisory review on a PR.
Used in implementer/reviewer input to convey active reviewer
state. Distinct from ``ReviewerOutputV1`` (which is what the
reviewer agent emits).
"""
model_config = ConfigDict(extra="forbid")
review_id: int
reviewer_login: str
state: Literal["APPROVED", "REQUEST_CHANGES", "COMMENT", "PENDING", "DISMISSED"]
body: str = ""
submitted_at: datetime | None = None
# ─── reviewer ─────────────────────────────────────────────────────────
class ReviewerInputV1(BaseModel):
"""Input to a reviewer worker session.
State machine guarantees ``ci_summary.overall_state == 'success'``
when this is invoked (reviewer never sees failing CI). The
``ci_summary`` field is typed Optional for contract consistency
with issue workflows.
"""
model_config = ConfigDict(extra="forbid")
input_version: Literal["V1"] = "V1"
workflow_id: int = Field(..., ge=0)
attempt_id: int = Field(..., ge=0)
attempt_number: int = Field(..., ge=1)
owner: str = Field(..., min_length=1)
repo: str = Field(..., min_length=1)
pr_number: int = Field(..., ge=1)
head_sha: str
ci_summary: CISummary | None = Field(
None,
description="None only when reviewer is invoked on a workflow with no CI yet (rare).",
)
# Worker-visible context:
diff_summary: str
full_diff: str | None = Field(
None,
description="May be omitted for very large PRs; reviewer falls back to diff_summary + file reads.",
)
prior_reviews: list["ReviewerOutputV1"] = Field(default_factory=list)
prior_implementer_attempts: PriorAttemptsBlock | None = None
implementer_claim: "ImplementerOutputV1 | None" = Field(
None,
description="Most-recent implementer output that triggered this review.",
)
# Worker plumbing:
workspace_dir: str
wallclock_budget_s: int = Field(..., ge=1)
class BlockingIssue(BaseModel):
"""One blocking concern raised by a reviewer."""
model_config = ConfigDict(extra="forbid")
file_path: str | None = None
line_range: str | None = None
description: str = Field(..., max_length=2048)
severity: Literal["error", "blocker"] # info/warning are not "blocking"
suggested_fix: str | None = None
class ReviewerOutputV1(BaseModel):
"""What the reviewer worker emits.
Phase 3 (Gate 3 reviewer abandon, 2026-05-25) extends the schema
additively with two optional fields. Pre-Phase-3 outputs (no
``abandon_reason_*``) still parse — the existing
``verdict='abstain' + suggested_next_action='human-attention'`` path
is preserved unchanged. When ``verdict='abstain' +
suggested_next_action='abandon'``, the ``reviewer_abandon``
state-machine event fires and the workflow routes
``REVIEWING → ABANDONED``; the reviewer-abandon side-effect tick
then performs the Forgejo close via Phase 1's ``close_act``
orchestrator with ``cause=REVIEWER_ABANDON``.
"""
model_config = ConfigDict(extra="forbid")
output_version: Literal["V1"]
verdict: Literal["approve", "request-changes", "comment", "abstain"]
blocking_issues: list[BlockingIssue] = Field(default_factory=list)
approved_at_sha: str | None = Field(
None,
description="Required iff verdict='approve'.",
)
suggested_next_action: Literal[
"merge", "wait-for-ci", "re-implement", "human-attention", "abandon"
]
confidence: Literal["high", "medium", "low"]
# T5-4: set True iff this reviewer attempt was invoked to re-examine
# an implementer's dispute (input_payload contained
# ``prior_implementer_dispute``). When True + verdict=request-changes,
# the state machine routes to OPERATOR_ATTENTION instead of looping
# back to IMPLEMENTING. The reviewer MUST quote the actual bytes at
# the disputed file:line range before standing by the verdict (T5-5).
re_examined_disputed_claim: bool = False
wallclock_seconds: float = Field(..., ge=0.0)
# Phase 3 additive fields (2026-05-25, Gate 3 reviewer abandon).
# Required when ``suggested_next_action='abandon'`` (which is itself
# only compatible with ``verdict='abstain'`` per the MCP's
# ``_VERDICT_ACTION_COMPAT`` cross-field check). MUST be one of the
# 12 Gate-3 categories listed in ``.drew/regressions-plan.md``
# "Abandon-reason categories — by gate > Gate 3" (the 9 Gate-2
# categories plus implementation_revealed_misdiagnosis,
# obsoleted_by_other_merged_work, irreducible_complexity).
# Enforced at the MCP boundary (``reviewer_set_abandon_reason``)
# AND by ``outcomes._map_reviewer_outcome`` before firing the
# state-machine event.
abandon_reason_category: str | None = Field(default=None, max_length=64)
# Optional free-form one-sentence elaboration for the audit
# comment. Kept short — the close-comment template embeds it
# verbatim into the PR.
abandon_reason_detail: str | None = Field(default=None, max_length=512)
@model_validator(mode="after")
def _validate_abandon_atomicity(self) -> "ReviewerOutputV1":
"""Phase 3 cross-field invariant: ``suggested_next_action='abandon'``
REQUIRES ``abandon_reason_category``. The MCP setter enforces
this at the agent boundary AND the outcomes mapper enforces it
at the master-tick boundary — this validator is the third layer,
guarding hand-crafted payloads (salvage / replay / migration /
legacy-import) that bypass both. Without it, a payload that
round-trips strict_parse cleanly could still be unactionable
downstream, surfacing only as a silent skip in the side-effect
tick. Catch it at parse time instead.
Conversely: a non-abandon action carrying abandon fields is
rejected — that combination is semantically contradictory
("re-implement BUT here's why I'd abandon it"). Symmetric with
``EstimatorOutputV1._validate_abandon_atomicity``.
"""
if self.suggested_next_action == "abandon":
if not self.abandon_reason_category:
raise ValueError(
"suggested_next_action='abandon' REQUIRES "
"abandon_reason_category (one of the 12 Gate-3 "
"categories — see .drew/regressions-plan.md "
"\"Abandon-reason categories — by gate > Gate 3\")"
)
else:
if self.abandon_reason_category:
raise ValueError(
f"abandon_reason_category={self.abandon_reason_category!r} "
f"set but suggested_next_action="
f"{self.suggested_next_action!r}; only "
"suggested_next_action='abandon' takes a category"
)
if self.abandon_reason_detail:
raise ValueError(
"abandon_reason_detail set but suggested_next_action="
f"{self.suggested_next_action!r}; only "
"suggested_next_action='abandon' takes a detail"
)
return self
# ─── implementer ──────────────────────────────────────────────────────
class ImplementerInputV1(BaseModel):
"""Input to an implementer worker session."""
model_config = ConfigDict(extra="forbid")
input_version: Literal["V1"] = "V1"
workflow_id: int = Field(..., ge=0)
attempt_id: int = Field(..., ge=0)
attempt_number: int = Field(..., ge=1)
# Phase 1k++ (R-trial-1): owner/repo are required so the agent
# knows which Forgejo repo to clone. Without these the agent
# would have to derive them from worker process env, which
# couples the worker to a single repo.
owner: str = Field(..., min_length=1)
repo: str = Field(..., min_length=1)
pr_number: int = Field(..., ge=1)
tier: Literal[0, 1, 2]
head_sha: str
head_ref: str
base_branch: str
# CI context — Optional for request-changes triggers on green-CI PRs:
ci_summary: CISummary | None = None
# Server-side computed convenience field (failing_gates filter).
# v9 fix: was @property in v8 (broken serialization); now real
# field populated by master prefetch.
failing_gates: list[GateResult] = Field(default_factory=list)
# Review context:
active_reviews: list[Review] = Field(default_factory=list)
pr_comments_since_last_attempt: list[str] = Field(default_factory=list)
prior_attempts: PriorAttemptsBlock
# Optional reviewer-scoping:
allowed_files: list[str] | None = None
# Worker plumbing:
diff_summary: str
workspace_dir: str
wallclock_budget_s: int = Field(..., ge=1)
class ImplementerOutputV1(BaseModel):
"""What an implementer worker emits.
Outcome-specific invariants (enforced by MCP at construction time
AND by ``model_validator`` at parse time, defense in depth):
- ``resolved`` → ≥1 commit and ≥1 file modified
- ``rebase-failed`` → handled by transitioning to CONFLICT_RESOLVING
- ``blocked`` → ≥1 entry in ``blockers``
- ``noop`` → 0 commits, 0 files modified, no blockers
- ``competence-failure`` → free-form; worker tried and gave up
- ``dispute-reviewer`` (T5-4) → top-tier only; all four
``disputed_*`` / ``dispute_evidence`` / ``verified_at_sha``
fields required. The implementer is asserting the controller
reviewer's blocking_issue is factually wrong (e.g., points at
a code location that doesn't contain what the reviewer claims).
Routes to REVIEWING for a re-examination, not to STUCK.
- ``verified-clean`` (T5-11) → fast-success path for the post-
conflict-resolution implementer pass. Use when the resolver's
commits already address everything (no further code changes
needed) and the implementer's only job would be to push a
cosmetic commit. Forbids commits/files/blockers (same invariant
as noop) but routes to AWAITING_CI (CI verifies the resolver's
commits) instead of competence-failure → escalation. Prevents
the workflow from burning a tier when the resolver did clean
work.
- ``ci-not-ready`` → CI for the PR head has no verdict yet (still
pending/queued/running) and there is nothing for the implementer
to act on (no failing gates, no review feedback, clean worktree).
Forbids commits/files/blockers (same invariant as noop) but
routes to AWAITING_CI so the controller waits for the verdict and
re-dispatches the implementer only if CI goes red. Exists for the
RUN_CI_LOCAL path, where CI is kicked on-demand and finishes
minutes after the implementer runs — without it the implementer
can only say ``blocked`` (→ STUCK) on a PR whose CI is simply
not done yet.
- ``ci-infra-failure`` → the CI failure the implementer was sent to
fix is NOT a code failure: the failing job's log carries no
verdict at all (no ``##[error]``, no test-runner summary, no
``Traceback``, no exit code — it just stops mid-run), the
signature of a hard process kill (OOM-killer / pod eviction).
There is nothing in the diff to fix. Forbids commits/files/
blockers (same invariant as noop) but routes IMPLEMENTING →
DISCOVERED so the CI-freshness gate reruns CI under its bounded
rerun budget, instead of the implementer dead-ending the PR at
``blocked`` → STUCK. Use ONLY when the log genuinely shows no
verdict; a real test failure (with an assertion / traceback /
``##[error]``) must be fixed and emitted as ``resolved``.
"""
model_config = ConfigDict(extra="forbid")
output_version: Literal["V1"]
outcome: Literal[
"resolved",
"rebase-failed",
"noop",
"blocked",
"competence-failure",
"dispute-reviewer",
"verified-clean",
"ci-not-ready",
"ci-infra-failure",
# ``gate-failed`` is worker-authored, not agent-emitted: the
# worker's deterministic pre-push lint+typecheck gate failed on
# the agent's commits. Listed here so the authoritative
# finalize output validates against this model.
"gate-failed",
]
files_touched: list[str] = Field(default_factory=list)
commit_shas: list[str] = Field(default_factory=list)
confidence: Literal["high", "medium", "low"]
blockers: list[str] = Field(default_factory=list)
used_tier: Literal[0, 1, 2]
# T5-4: dispute fields. All four are None for non-dispute outcomes;
# all four are required (set by the MCP) when outcome=dispute-reviewer.
# disputed_review_id matches a Review.review_id from input_payload
# active_reviews. disputed_blocker_index is 0-based into that
# review's blocking_issues. dispute_evidence is the rebuttal text
# (min 200 chars enforced at MCP construction time). verified_at_sha
# is the commit the implementer read while preparing the rebuttal
# (7-40 hex chars).
disputed_review_id: int | None = Field(default=None, ge=0)
disputed_blocker_index: int | None = Field(default=None, ge=0)
dispute_evidence: str | None = None
verified_at_sha: str | None = None
wallclock_seconds: float = Field(..., ge=0.0)
class IssueImplementerOutputV1(BaseModel):
"""Implementer output for issue-kind workflows (which create new PRs)."""
model_config = ConfigDict(extra="forbid")
output_version: Literal["V1"]
outcome: Literal["created-pr", "noop", "blocked", "competence-failure"]
created_pr_number: int | None = Field(
None,
description="Required iff outcome='created-pr'.",
)
head_branch: str | None = Field(
None,
description="Required iff outcome='created-pr'; used for orphan-PR adoption.",
)
files_touched: list[str] = Field(default_factory=list)
commit_shas: list[str] = Field(default_factory=list)
confidence: Literal["high", "medium", "low"]
blockers: list[str] = Field(default_factory=list)
wallclock_seconds: float = Field(..., ge=0.0)
# ─── estimator ────────────────────────────────────────────────────────
class EstimatorInputV1(BaseModel):
"""Input to an estimator session (decides starting tier)."""
model_config = ConfigDict(extra="forbid")
input_version: Literal["V1"] = "V1"
workflow_id: int = Field(..., ge=0)
attempt_id: int = Field(..., ge=0)
owner: str = Field(..., min_length=1)
repo: str = Field(..., min_length=1)
pr_number: int | None = Field(None, ge=1)
head_sha: str | None = None
ci_summary: CISummary | None = None # None for issue workflows pre-PR
diff_summary: str | None = None
pr_title: str
pr_body: str = ""
workspace_dir: str
wallclock_budget_s: int = Field(..., ge=1)
class EstimatorOutputV1(BaseModel):
"""What the estimator emits.
Phase 2 (Gate 2 abandon, 2026-05-25) extends the schema additively
with three optional fields. Pre-Phase-2 outputs (no ``verdict`` /
no ``abandon_reason_*``) still parse — the legacy
``is_metadata_only=True`` path is preserved unchanged. When
``verdict='abandon'``, the ``estimator_abandon`` state-machine
event fires and the workflow routes ``ANALYZING → ABANDONED``;
the estimator-abandon side-effect tick then performs the Forgejo
close via Phase 1's ``close_act`` orchestrator with
``cause=ESTIMATOR_ABANDON``.
"""
model_config = ConfigDict(extra="forbid")
output_version: Literal["V1"]
recommended_tier: Literal[0, 1, 2]
is_metadata_only: bool = Field(
False,
description=(
"v9: informational only; routes reviewer to lightweight profile. "
"Does NOT bypass REVIEWING (Hard Rule #1)."
),
)
confidence: Literal["high", "medium", "low"]
reasoning: str = Field(..., max_length=2048)
wallclock_seconds: float = Field(..., ge=0.0)
# Phase 2 additive fields (2026-05-25, Gate 2 estimator abandon).
# ``verdict`` is the top-level routing signal; when None (default,
# pre-Phase-2 shape) the estimator is silent and the legacy
# tier+metadata-only routing applies.
#
# - 'implement' — normal tier-N implementation flow
# - 'metadata-only' — same as is_metadata_only=True (kept distinct
# so the agent can be explicit about either path)
# - 'abandon' — workflow → ABANDONED via estimator_abandon
# event; abandon_reason_category REQUIRED
verdict: Literal["implement", "metadata-only", "abandon"] | None = None
# Required when verdict='abandon'. MUST be one of the 9 Gate-2
# categories listed in ``.drew/regressions-plan.md`` "Abandon-
# reason categories — by gate". Enforced at the MCP boundary
# (``estimator_set_verdict``) AND by ``outcomes._map_estimator_outcome``
# before firing the state-machine event.
abandon_reason_category: str | None = Field(default=None, max_length=64)
# Optional free-form one-sentence elaboration for the audit
# comment. Kept short — the close-comment template embeds it
# verbatim into the PR.
abandon_reason_detail: str | None = Field(default=None, max_length=512)
@model_validator(mode="after")
def _validate_abandon_atomicity(self) -> "EstimatorOutputV1":
"""Phase 2 cross-field invariant: ``verdict='abandon'`` REQUIRES
``abandon_reason_category``. The MCP setter enforces this at
the agent boundary AND the outcomes mapper enforces it at the
master-tick boundary — this validator is the third layer,
guarding hand-crafted payloads (salvage / replay / migration /
legacy-import) that bypass both. Without it, a payload that
round-trips strict_parse cleanly could still be unactionable
downstream, surfacing only as a silent skip in the side-effect
tick. Catch it at parse time instead.
Conversely: a non-abandon verdict carrying abandon fields is
rejected — that combination is semantically contradictory
("I'm implementing it BUT here's why I'd abandon it"). Symmetric
with the MCP setter's cross-field check at
``estimator_builder.py``.
"""
if self.verdict == "abandon":
if not self.abandon_reason_category:
raise ValueError(
"verdict='abandon' REQUIRES abandon_reason_category "
"(one of the 9 Gate-2 categories — see "
".drew/regressions-plan.md \"Abandon-reason "
"categories — by gate > Gate 2\")"
)
else:
# Defensive: abandon fields populated without verdict='abandon'
# is contradictory. The mapper would ignore them, but
# rejecting at parse time prevents the misleading payload
# from ever being stored.
if self.abandon_reason_category:
raise ValueError(
f"abandon_reason_category={self.abandon_reason_category!r} "
f"set but verdict={self.verdict!r}; only "
"verdict='abandon' takes a category"
)
if self.abandon_reason_detail:
raise ValueError(
"abandon_reason_detail set but "
f"verdict={self.verdict!r}; only verdict='abandon' "
"takes a detail"
)
return self
# ─── grooming Stage B (Phase 1 corrected dispatch, 2026-05-25) ────────
class GroomingInputV1(BaseModel):
"""Input to a grooming_stage_b worker session.
The worker runs the entire grooming runtime (deterministic checks +
Stage A suspicion scoring + Stage B LLM judgment) internally; this
input just hands it the raw data: the anchor PR (the workflow under
evaluation) + the full list of currently-open PRs (the worker
decides which are suspect candidates). The worker fetches no
additional Forgejo data of its own.
"""
model_config = ConfigDict(extra="forbid")
input_version: Literal["V1"] = "V1"
workflow_id: int = Field(..., ge=0)
attempt_id: int = Field(..., ge=0)
owner: str = Field(..., min_length=1)
repo: str = Field(..., min_length=1)
pr_number: int = Field(..., ge=1)
# The anchor PR's full Forgejo detail dict (title, body, head, base,
# additions, deletions, changed_files, labels, etc.). The worker
# parses title/body for Closes-keywords + uses
# additions/deletions/changed_files in the Stage B LLM prompt's
# quality signals.
anchor_pr: dict[str, Any]
# All currently-open PRs in the same (owner, repo). The worker runs
# the Stage A suspicion-score pre-filter against this list, picks
# candidates that score above CONTROLLER_GROOMING_SUSPECT_THRESHOLD,
# then sends those to the Stage B LLM. Empty list is fine — the
# worker just runs deterministic checks + returns 'proceed'.
open_prs: list[dict[str, Any]] = Field(default_factory=list)
workspace_dir: str
wallclock_budget_s: int = Field(..., ge=1)
class GroomingOutputV1(BaseModel):
"""What the grooming_stage_b worker emits.
The top-level ``verdict`` drives the state-machine event selection
in ``outcomes.py::_map_grooming_outcome``:
- ``'proceed'`` → ``groom_verdict_proceed`` → GROOMING → ANALYZING
- ``'defer'`` → ``groom_verdict_defer`` → GROOMING → PAUSED
- ``'close'`` → ``groom_verdict_close`` → GROOMING → ABANDONED
The audit fields (check_name, stage, reason_category,
target_workflow_id, confidence, llm_reasoning, preserved_value,
suspicion_score, forced_proceed_reason, loser_head_sha_at_decision)
flow into the ``grooming_decisions`` row written by the
side-effect tick (see ``run_grooming_side_effects_tick`` in
``master/grooming_side_effects.py``).
"""
model_config = ConfigDict(extra="forbid")
output_version: Literal["V1"]
# Top-level verdict — drives state-machine event selection.
verdict: Literal["proceed", "defer", "close"]
# Audit / dispatch detail.
check_name: str = Field(..., max_length=64)
stage: Literal["deterministic_conclusive", "stage_b_llm"]
reason_category: str = Field(..., max_length=64)
target_workflow_id: int | None = None
confidence: Literal["high", "medium", "low"] | None = None
llm_reasoning: str | None = Field(default=None, max_length=4000)
preserved_value: str | None = Field(default=None, max_length=2000)
suspicion_score: float | None = Field(default=None, ge=0.0, le=1.0)
# Non-NULL when verdict='proceed' was FORCED rather than chosen:
# 'semantic_contradiction' — Stage B LLM emitted a contradictory verdict
# 'low_confidence' — Stage B LLM confidence below MIN_CONFIDENCE
# NULL when verdict='proceed' was the legitimate Stage A/B verdict.
forced_proceed_reason: Literal[
"semantic_contradiction", "low_confidence"
] | None = None
loser_head_sha_at_decision: str | None = Field(default=None, max_length=64)
wallclock_seconds: float = Field(..., ge=0.0)
# ─── conflict resolver ────────────────────────────────────────────────
class ConflictedFile(BaseModel):
"""Per-file conflict descriptor."""
model_config = ConfigDict(extra="forbid")
file_path: str
ours_excerpt: str = Field(..., max_length=8192)
theirs_excerpt: str = Field(..., max_length=8192)
base_excerpt: str | None = Field(None, max_length=8192)
class ConflictResolverInputV1(BaseModel):
"""Input to a conflict-resolver session."""
model_config = ConfigDict(extra="forbid")
input_version: Literal["V1"] = "V1"
workflow_id: int = Field(..., ge=0)
attempt_id: int = Field(..., ge=0)
owner: str = Field(..., min_length=1)
repo: str = Field(..., min_length=1)
pr_number: int = Field(..., ge=1)
head_sha: str
# The PR's head branch ref. The worker needs this to land the
# resolved/rebased branch back on the PR (run-2 #40: the resolver
# was never told the head branch, guessed `main`, and the
# resolution was lost). Mirrors ImplementerInputV1.head_ref.
head_ref: str
base_branch: str
base_sha: str
conflicted_files: list[ConflictedFile]
prior_implementer_outputs: PriorAttemptsBlock
# T5-13: PR intent context, prehydrated by the controller's
# ``prefetch.build_conflict_resolver_input``. The conflict_resolver
# needs to know WHY a PR was made to pick the correct side of a
# conflict. Pre-T5-13 the agent fetched this itself via
# ``forgejo_fetch_pr`` / ``forgejo_fetch_comments`` (``forgejo*``
# was allow-listed) — the only worker that did its own Forgejo I/O.
# Per the prefetch design ("workers dequeue an already-assembled
# payload with NO extra Forgejo I/O of their own") these fields
# carry the intent so the agent can be fully network-isolated
# (``forgejo*: deny`` in conflict-resolver-worker.md).
pr_title: str = ""
pr_body: str = ""
pr_comments: list[str] = Field(default_factory=list)
# The conflict-prep track the worker put the worktree in. The
# deterministic worker (conflict_rebase.prepare_conflict_track)
# picks "rebase" by default and "merge" when the PR branch is too
# divergent for a sane commit-by-commit rebase. The agent reads it
# to know whether it is mid-rebase (resolve → git_rebase_continue,
# loop) or mid-merge (resolve all → git_commit, one merge commit).
# Defaults to "rebase" for back-compat — a payload built before
# this field existed parses unchanged and the agent behaves exactly
# as it did pre-merge-track.
mode: Literal["rebase", "merge"] = "rebase"
workspace_dir: str
wallclock_budget_s: int = Field(..., ge=1)
class ConflictResolverOutputV1(BaseModel):
"""What the conflict-resolver emits.
Outcome taxonomy (T5-13 added ``blocked``):
- ``resolved`` — every conflict fixed, rebase complete, pushed.
Requires ``new_head_sha`` + ≥1 commit + ≥1 file.
- ``partial`` — some conflicts resolved but stopped early. The
controller treats this as a competence-failure (re-enqueue).
- ``irreconcilable`` — conflicts span genuinely-divergent intents
the agent cannot safely merge. Routes to STUCK for human review.
- ``competence-failure`` — the task exceeds the agent's reasoning
capability despite sufficient evidence. NOT for ambiguity
(ambiguity → ``irreconcilable``).
- ``blocked`` — an ENVIRONMENT failure unrelated to the agent's
reasoning: a git tool error, repository corruption, or a push
that failed because the remote moved concurrently. Distinct
from ``competence-failure`` (agent's fault) and ``irreconcilable``
(intent ambiguity). The controller re-enqueues (a transient
push race usually clears on retry); persistent ``blocked``
exhausts the pickup budget → STUCK.
"""
model_config = ConfigDict(extra="forbid")
output_version: Literal["V1"]
outcome: Literal[
"resolved",
"partial",
"irreconcilable",
"competence-failure",
"blocked",
]
files_modified: list[str] = Field(default_factory=list)
commit_shas: list[str] = Field(default_factory=list)
new_head_sha: str | None = Field(
None,
description="Required iff outcome='resolved'.",
)
reasoning: str = Field(..., max_length=2048)
confidence: Literal["high", "medium", "low"]
wallclock_seconds: float = Field(..., ge=0.0)
# ─── summarizer ───────────────────────────────────────────────────────
class SummarizerInputV1(BaseModel):
"""Input to the summarizer (synthesizes older prior_attempts)."""
model_config = ConfigDict(extra="forbid")
input_version: Literal["V1"] = "V1"
workflow_id: int = Field(..., ge=0)
attempt_id: int = Field(..., ge=0)
prior_summary: str | None = Field(
None,
description=(
"Latest cached summary (covers attempts 1..K). None on "
"first summarization pass."
),
)
newly_aged_out_attempt: ImplementerOutputV1
workspace_dir: str
wallclock_budget_s: int = Field(..., ge=1)
class SummarizerOutputV1(BaseModel):
"""What the summarizer emits.
Length constraints are MCP-enforced too (set_summary refuses <50
or >2000 chars).
"""
model_config = ConfigDict(extra="forbid")
output_version: Literal["V1"]
summary: str = Field(..., min_length=50, max_length=2000)
covers_through_attempt: int = Field(..., ge=1)
wallclock_seconds: float = Field(..., ge=0.0)
# Resolve all forward refs.
PriorAttemptsBlock.model_rebuild()
ReviewerInputV1.model_rebuild()