a10758177c
Lays the foundation for the planned controller + DB-owned PR state machine rewrite (see .drew/controller_state_machine.md). Phase 0a — Pydantic V1 worker I/O contracts (tools/controller/contracts/): - v1.py: ImplementerInputV1/OutputV1, ReviewerInputV1/OutputV1, EstimatorOutputV1, IssueImplementerOutputV1, ConflictResolverInputV1/OutputV1, SummarizerInputV1/OutputV1. Plus CISummary / CIFailure / GateResult / FailedAssertion / FileLocation. All models use extra="forbid" + required output_version Literal discriminator (no defaults). - parse.py: strict_parse() + strict_parse_with_retry() helpers. Corrective prompt template quotes the Pydantic ValidationError + the JSON schema. TTL guard skips retry when remaining time too short. Defense-in-depth around the response-builder MCPs that arrive in Phase 1a. Phase 0b — Static CI parser-coverage discovery: - NOX_SESSION_TO_PARSER map in tools/controller/ci_summary_parsers/. Derived from .forgejo/workflows/*.yml + noxfile.py. Covers ruff, pyright, behave, robot_framework, slipcover, vulture, radon, bandit, semgrep, build. Cron-only sessions (benchmark, benchmark_regression) are in NOX_SESSIONS_SKIP_COVERAGE. - scripts/enumerate-ci-gates.py: reads the workflow YAML, joins against the map, exits non-zero on coverage gaps. Wired into tests so the actual .forgejo/ is regression-guarded. 63 new tests; full auto_agents suite still passes (2424).
507 lines
17 KiB
Python
507 lines
17 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, Literal
|
|
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
# ─── 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 IMPLEMENTER attempts in worker input.
|
|
|
|
Semantics:
|
|
- "Prior attempts" always refers to PRIOR IMPLEMENTER attempts —
|
|
the worker outputs an implementer's previous tries against this
|
|
workflow. Reviewer / conflict-resolver / estimator each see this
|
|
block to know what's been tried.
|
|
- Reviewer's own prior verdicts live in ``ReviewerInputV1.prior_reviews``,
|
|
not here.
|
|
- Conflict-resolver's prior conflict attempts aren't tracked yet
|
|
(Phase 4 work).
|
|
|
|
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)
|
|
|
|
|
|
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)
|
|
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."""
|
|
|
|
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"]
|
|
wallclock_seconds: float = Field(..., ge=0.0)
|
|
|
|
|
|
# ─── 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)
|
|
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
|
|
"""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
output_version: Literal["V1"]
|
|
outcome: Literal[
|
|
"resolved",
|
|
"rebase-failed",
|
|
"noop",
|
|
"blocked",
|
|
"competence-failure",
|
|
]
|
|
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]
|
|
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)
|
|
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."""
|
|
|
|
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)
|
|
|
|
|
|
# ─── 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)
|
|
pr_number: int = Field(..., ge=1)
|
|
head_sha: str
|
|
base_branch: str
|
|
base_sha: str
|
|
conflicted_files: list[ConflictedFile]
|
|
prior_implementer_outputs: PriorAttemptsBlock
|
|
workspace_dir: str
|
|
wallclock_budget_s: int = Field(..., ge=1)
|
|
|
|
|
|
class ConflictResolverOutputV1(BaseModel):
|
|
"""What the conflict-resolver emits."""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
output_version: Literal["V1"]
|
|
outcome: Literal["resolved", "partial", "irreconcilable", "competence-failure"]
|
|
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()
|