4d969eaf2b
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>
882 lines
36 KiB
Python
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()
|