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>
945 lines
43 KiB
Python
945 lines
43 KiB
Python
"""Map worker output_payload outcomes → state machine events.
|
|
|
|
Each worker role emits a Pydantic-validated output (per the V1
|
|
contracts). The master inspects the outcome + the workflow's current
|
|
state + auxiliary context (head_sha_advanced, pickup counts, etc.) to
|
|
choose which state machine event to apply.
|
|
|
|
This module is the boundary between "what the worker said" and "what
|
|
the state machine does next." Pure function; takes a dict + context
|
|
and returns an event name (or a tagged "not-mappable" reason).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
|
|
@dataclass
|
|
class EventMapResult:
|
|
"""What ``map_outcome_to_event`` returns.
|
|
|
|
Two outcomes:
|
|
- ``event_name`` set: the master applies the event via
|
|
``state_machine.apply_event``.
|
|
- ``event_name=None``: no mappable transition for this combination.
|
|
The master should:
|
|
- On status='complete' but unknown outcome: log + workflow → STUCK
|
|
with reason='unknown-outcome'
|
|
- On status='failed' outcomes that the contract defines: per the
|
|
status='failed' policy table (re-enqueue / STUCK).
|
|
"""
|
|
|
|
event_name: str | None
|
|
reason: str = ""
|
|
|
|
|
|
# ─── pure mapping ─────────────────────────────────────────────────────
|
|
|
|
|
|
def map_outcome_to_event(
|
|
*,
|
|
role: str,
|
|
current_state: str,
|
|
output_payload: dict[str, Any] | None,
|
|
status: str, # 'complete' | 'failed' | 'reaped'
|
|
head_sha_advanced: bool,
|
|
attempts_remaining_at_tier: int,
|
|
conflict_count_at_current_tier: int = 0,
|
|
prior_contract_violations: int = 0,
|
|
attempt_tier: int | None = None,
|
|
workflow_current_tier: int | None = None,
|
|
prior_disputes_at_current_tier: int = 0,
|
|
prior_worker_errors_at_tier: int = 0,
|
|
prior_ci_not_ready: int = 0,
|
|
prior_ci_infra_failure: int = 0,
|
|
prior_estimator_worker_errors: int = 0,
|
|
prior_gate_failed: int = 0,
|
|
prior_stale_input: int = 0,
|
|
attempt_saw_green_ci: bool = False,
|
|
prior_reviews: int = 0,
|
|
) -> EventMapResult:
|
|
"""Decide which state machine event to apply.
|
|
|
|
Inputs:
|
|
- role: the role that produced this output (estimator /
|
|
implementer / reviewer / conflict_resolver / summarizer).
|
|
- current_state: the workflow's current_state (per the state
|
|
machine).
|
|
- output_payload: dict from the worker's output_payload column
|
|
(None if worker failed before emitting).
|
|
- status: workflow_attempts.status — 'complete' | 'failed' |
|
|
'reaped'.
|
|
- head_sha_advanced: True iff the worker pushed at least one
|
|
commit (master computes this from head_sha_before vs the live
|
|
head). Used by IMPLEMENTING outcomes.
|
|
- attempts_remaining_at_tier: how many more IMPLEMENTING attempts
|
|
this workflow has at the current tier before ESCALATING fires.
|
|
- conflict_count_at_current_tier: how many conflicts the resolver
|
|
has actually *resolved* at the current tier (per v6 bounded
|
|
retry policy). Failed/blocked attempts do NOT count — they are
|
|
retries of the same unresolved conflict, bounded by the pickup
|
|
guard, not distinct "structurally hard" cycles.
|
|
- attempt_saw_green_ci: True iff the CI summary in THIS attempt's
|
|
input_payload was a fully-green run (all gates passed, none
|
|
failed/pending). Lets a ``noop`` against a green CI route
|
|
forward instead of escalating to ABANDONED.
|
|
- prior_reviews: count of completed reviewer attempts on this
|
|
workflow (epoch-scoped). Gates ``dispute-reviewer`` — a dispute
|
|
against a workflow no reviewer has ever judged has nothing to
|
|
dispute.
|
|
|
|
Returns:
|
|
EventMapResult(event_name='...') on a clean mapping;
|
|
EventMapResult(event_name=None, reason='...') otherwise.
|
|
"""
|
|
# status='reaped' = a previous attempt was retired by the pickup
|
|
# guard. Workflow is already in STUCK; nothing to map.
|
|
if status == "reaped":
|
|
return EventMapResult(None, reason="status=reaped; pickup guard handled")
|
|
|
|
# status='failed' is the status='failed' policy table. v9 contract
|
|
# outcomes:
|
|
if status == "failed":
|
|
outcome = (
|
|
(output_payload or {}).get("worker_outcome")
|
|
or (output_payload or {}).get("outcome")
|
|
or "unknown"
|
|
)
|
|
# A (RUN_CI_LOCAL trial finding): an implementer that keeps
|
|
# failing with worker-internal-error retries the SAME tier
|
|
# until pickup-exhaustion → STUCK — a too-big-for-this-tier PR
|
|
# never escalates. After _WORKER_ERROR_ESCALATE_LIMIT such
|
|
# failures at a tier, route to ESCALATING (scheduler →
|
|
# IMPLEMENTING(tier+1), or ABANDONED at MAX_TIER) so a bigger
|
|
# tier + longer wallclock budget gets a shot.
|
|
#
|
|
# Scope note: this fires for EVERY worker-internal-error, not
|
|
# only the OpenCode session timeouts that dominate it in
|
|
# practice. For a non-timeout cause (transport bug, OOM) a
|
|
# bigger tier won't help — but escalate-then-ABANDONED is no
|
|
# worse than retry-to-STUCK, and far better for the common
|
|
# timeout case, so the broad rule is the right backstop.
|
|
if (
|
|
outcome == "worker-internal-error"
|
|
and role == "implementer"
|
|
and current_state == "IMPLEMENTING"
|
|
and prior_worker_errors_at_tier + 1 >= _WORKER_ERROR_ESCALATE_LIMIT
|
|
):
|
|
return EventMapResult(
|
|
"implementer_competence_failure",
|
|
reason=(
|
|
"implementer hit worker-internal-error "
|
|
f"{prior_worker_errors_at_tier + 1}x at this tier "
|
|
"(likely OpenCode session timeouts) -> escalate tier"
|
|
),
|
|
)
|
|
# Pre-push gate-failure escalation. The worker's deterministic
|
|
# lint+typecheck gate failed on the agent's commits (a
|
|
# status='failed' attempt, outcome='gate-failed'). Below the
|
|
# per-tier cap it falls through to _map_failed_outcome → None →
|
|
# the master re-enqueues a fresh implementer attempt at the same
|
|
# tier (the gate report rides in the recorded output_payload).
|
|
# At the cap, the tier keeps producing gate-dirty code →
|
|
# escalate to a stronger tier.
|
|
if (
|
|
outcome == "gate-failed"
|
|
and role == "implementer"
|
|
and current_state == "IMPLEMENTING"
|
|
and prior_gate_failed + 1 >= _MAX_GATE_RETRIES_PER_TIER
|
|
):
|
|
return EventMapResult(
|
|
"implementer_competence_failure",
|
|
reason=(
|
|
f"gate-failed x {prior_gate_failed + 1} at this tier "
|
|
f"(cap {_MAX_GATE_RETRIES_PER_TIER}) — escalate tier"
|
|
),
|
|
)
|
|
# Push-time stale-input contention cap. Bounded ONLY for the
|
|
# IMPLEMENTING push-time stale-input — each cycle there costs a
|
|
# whole ~attempt. Setup-time stale-input in other states is
|
|
# cheap (fails fast at workspace setup, self-clears on the next
|
|
# re-prefetch) and stays uncapped; and ``pickup_exhausted`` is
|
|
# not even a legal transition from e.g. ANALYZING.
|
|
if (
|
|
outcome == "stale-input"
|
|
and current_state == "IMPLEMENTING"
|
|
and prior_stale_input >= _MAX_STALE_INPUT
|
|
):
|
|
return EventMapResult(
|
|
"pickup_exhausted",
|
|
reason=(
|
|
f"stale-input x {prior_stale_input + 1} — PR branch "
|
|
f"contended (cap {_MAX_STALE_INPUT}); STUCK"
|
|
),
|
|
)
|
|
# Estimator counterpart of the rule above. The estimator can't
|
|
# escalate (it IS the pre-tier step) and can't be salvaged (no
|
|
# git artifact), so a flaky estimator session re-enqueues
|
|
# unbounded — run-2 burned 174 estimator worker-internal-error
|
|
# attempts on one PR. After _ESTIMATOR_WORKER_ERROR_LIMIT, STUCK
|
|
# the workflow (operator attention) rather than loop forever.
|
|
if (
|
|
outcome == "worker-internal-error"
|
|
and role == "estimator"
|
|
and current_state == "ANALYZING"
|
|
and prior_estimator_worker_errors + 1 >= _ESTIMATOR_WORKER_ERROR_LIMIT
|
|
):
|
|
return EventMapResult(
|
|
"estimator_failed_twice",
|
|
reason=(
|
|
"estimator hit worker-internal-error "
|
|
f"{prior_estimator_worker_errors + 1}x on this workflow "
|
|
f"(cap {_ESTIMATOR_WORKER_ERROR_LIMIT}) — no escalation or "
|
|
"salvage path for a flaky estimator session; STUCK"
|
|
),
|
|
)
|
|
return _map_failed_outcome(outcome, prior_contract_violations)
|
|
|
|
# status='complete' → dispatch by role. Each role-specific mapper
|
|
# pulls the field IT cares about from output_payload:
|
|
# - estimator: no `outcome`; uses recommended_tier + is_metadata_only
|
|
# - reviewer: has `verdict` (NOT `outcome`)
|
|
# - summarizer: drives no transition
|
|
# - implementer: has `outcome` ∈ {resolved, blocked, ...}
|
|
# - conflict_resolver: has `outcome` ∈ {resolved, irreconcilable, ...}
|
|
#
|
|
# The pre-fix code had a single "outcome is None → return early"
|
|
# guard BEFORE the dispatch, which silently discarded every
|
|
# estimator + reviewer + summarizer attempt (their payloads have
|
|
# no `outcome` field). Trial run-3 (2026-05-19) observed all 6
|
|
# workflows stuck in ANALYZING because of this. Fix: dispatch
|
|
# first, let each mapper decide what to look at.
|
|
payload = output_payload or {}
|
|
if role == "estimator":
|
|
return _map_estimator_outcome(payload)
|
|
if role == "grooming_stage_b":
|
|
return _map_grooming_outcome(payload)
|
|
if role == "summarizer":
|
|
# Summarizer outputs feed prior_attempts.older_summary
|
|
# in subsequent payload assembly; no state-machine event.
|
|
return EventMapResult(None, reason="summarizer doesn't drive transitions")
|
|
if role == "reviewer":
|
|
verdict = payload.get("verdict")
|
|
if verdict is None:
|
|
return EventMapResult(
|
|
None,
|
|
reason="no verdict in reviewer output_payload",
|
|
)
|
|
re_examined = bool(payload.get("re_examined_disputed_claim"))
|
|
return _map_reviewer_outcome(
|
|
verdict,
|
|
attempts_remaining_at_tier,
|
|
re_examined_disputed_claim=re_examined,
|
|
workflow_current_tier=workflow_current_tier,
|
|
suggested_next_action=payload.get("suggested_next_action"),
|
|
abandon_reason_category=payload.get("abandon_reason_category"),
|
|
confidence=payload.get("confidence"),
|
|
)
|
|
|
|
# Roles that DO carry `outcome`.
|
|
outcome = payload.get("outcome")
|
|
if outcome is None:
|
|
return EventMapResult(
|
|
None,
|
|
reason=f"no outcome in {role!r} output_payload",
|
|
)
|
|
if role == "implementer":
|
|
return _map_implementer_outcome(
|
|
outcome,
|
|
current_state,
|
|
head_sha_advanced,
|
|
attempts_remaining_at_tier,
|
|
attempt_tier=attempt_tier,
|
|
prior_disputes_at_current_tier=prior_disputes_at_current_tier,
|
|
prior_ci_not_ready=prior_ci_not_ready,
|
|
prior_ci_infra_failure=prior_ci_infra_failure,
|
|
attempt_saw_green_ci=attempt_saw_green_ci,
|
|
prior_reviews=prior_reviews,
|
|
)
|
|
if role == "conflict_resolver":
|
|
return _map_conflict_resolver_outcome(
|
|
outcome,
|
|
conflict_count_at_current_tier,
|
|
)
|
|
|
|
return EventMapResult(None, reason=f"unknown role {role!r}")
|
|
|
|
|
|
# ─── per-role mappers ─────────────────────────────────────────────────
|
|
|
|
|
|
_CONTRACT_VIOLATION_RETRY_LIMIT = 2
|
|
|
|
|
|
# A-escalation threshold: after this many worker-internal-error
|
|
# implementer failures at one tier, escalate the workflow (→ next tier,
|
|
# or ABANDONED at MAX_TIER) instead of retrying the same tier until
|
|
# pickup-exhaustion. 2 = the 2nd worker-internal-error at a tier
|
|
# escalates.
|
|
_WORKER_ERROR_ESCALATE_LIMIT = 2
|
|
|
|
# Estimator worker-internal-error cap. Unlike the implementer, the
|
|
# estimator has NO escalation path (it runs pre-tier) and NO salvage
|
|
# (it produces no git artifact) — so a flaky estimator session that
|
|
# never emits canonical output just re-enqueues, with nothing to stop
|
|
# it. Run-2 observed 174 consecutive estimator worker-internal-error
|
|
# attempts on one PR. After this many, the workflow STUCKs for
|
|
# operator attention (3 = the 3rd such failure STUCKs).
|
|
_ESTIMATOR_WORKER_ERROR_LIMIT = 3
|
|
|
|
# Pre-push gate-failure cap. The worker's deterministic lint+typecheck
|
|
# gate can fail repeatedly if the agent keeps producing gate-dirty code
|
|
# at this tier. After this many gate-failed outcomes at one tier,
|
|
# escalate (a stronger tier) rather than retrying the same tier
|
|
# forever. 3 = the 3rd gate-failed at a tier escalates.
|
|
_MAX_GATE_RETRIES_PER_TIER = 3
|
|
|
|
# Push-time stale-input cap. A stale-input means the PR branch moved
|
|
# during the attempt; the master re-prefetches (no pickup penalty) and
|
|
# re-dispatches on the fresh head. But each cycle now costs a whole
|
|
# attempt (the push-time leased check), so a genuinely contended branch
|
|
# is bounded: at the cap, STUCK for operator attention. 3 = the 3rd
|
|
# stale-input STUCKs.
|
|
_MAX_STALE_INPUT = 3
|
|
|
|
|
|
def _map_failed_outcome(
|
|
outcome: str,
|
|
prior_contract_violations: int = 0,
|
|
) -> EventMapResult:
|
|
"""Per the v9 status='failed' retry policy table.
|
|
|
|
Most failed outcomes do NOT advance the state machine (the master
|
|
re-enqueues or no-ops); only contract-violation (after retries
|
|
exhausted) and absolute-wallclock-exceeded transition the workflow
|
|
to STUCK.
|
|
|
|
E-1 fix (2026-05-19): contract-violation now gets a retry-once-
|
|
with-corrective-prompt path per the v9 spec promise. Pre-fix the
|
|
FIRST contract-violation went directly to STUCK, making the spec
|
|
promise hollow. The retry-budget gating uses
|
|
``prior_contract_violations`` (count of prior 'contract-violation'
|
|
outcomes for this workflow+role).
|
|
"""
|
|
if outcome == "contract-violation":
|
|
if prior_contract_violations >= _CONTRACT_VIOLATION_RETRY_LIMIT:
|
|
# Retry budget exhausted → STUCK via terminal pickup_exhausted.
|
|
return EventMapResult(
|
|
"pickup_exhausted",
|
|
reason=f"contract-violation x {prior_contract_violations + 1}",
|
|
)
|
|
# Within retry budget: return None so tick bumps
|
|
# last_transition_at; the scheduler enqueues a fresh attempt
|
|
# next iteration (T4-1's "complete-unprocessed" filter only
|
|
# blocks complete attempts, not failed ones).
|
|
return EventMapResult(
|
|
None,
|
|
reason=(
|
|
f"contract-violation (retry {prior_contract_violations + 1}/"
|
|
f"{_CONTRACT_VIOLATION_RETRY_LIMIT + 1}); master re-enqueues"
|
|
),
|
|
)
|
|
if outcome == "stale-input":
|
|
# The PR branch moved during the attempt; the master
|
|
# re-prefetches and re-dispatches on the fresh head. The
|
|
# contention cap (→ STUCK) is applied inline in
|
|
# ``map_outcome_to_event`` where ``current_state`` is known:
|
|
# ``pickup_exhausted`` is only a legal transition from some
|
|
# states (NOT ANALYZING), and only the IMPLEMENTING push-time
|
|
# stale-input is expensive enough to need the cap.
|
|
return EventMapResult(
|
|
None, reason="stale-input; master re-prefetches + re-enqueues"
|
|
)
|
|
# Other failed outcomes (worker-internal-error /
|
|
# ttl-insufficient-for-retry / git-clone-failed /
|
|
# worker-shutting-down / lost-lock) → no state-machine event;
|
|
# master re-enqueues per the policy table.
|
|
return EventMapResult(
|
|
None, reason=f"failed outcome {outcome!r}: master re-enqueues"
|
|
)
|
|
|
|
|
|
def _map_estimator_outcome(output_payload: dict[str, Any]) -> EventMapResult:
|
|
"""ANALYZING → IMPLEMENTING(tier) | REVIEWING(metadata-only) | ABANDONED(abandon).
|
|
|
|
Phase 2 (Gate 2 abandon, 2026-05-25): when the estimator emits
|
|
``verdict='abandon'`` with a Gate-2 ``abandon_reason_category``,
|
|
fire ``estimator_abandon`` → ABANDONED. The estimator-abandon
|
|
side-effect tick then performs the Forgejo close via
|
|
``forgejo_writes.close_act`` with ``cause=ESTIMATOR_ABANDON``.
|
|
|
|
Dispatch precedence (highest wins):
|
|
1. ``verdict='abandon'`` → ``estimator_abandon``
|
|
2. ``verdict='metadata-only'`` OR legacy ``is_metadata_only=True``
|
|
→ ``estimator_metadata_only``
|
|
3. anything else → ``estimator_done`` (normal tier-N flow)
|
|
|
|
The verdict field is OPTIONAL — pre-Phase-2 outputs (no
|
|
``verdict``) still route through the legacy
|
|
``is_metadata_only`` → ``estimator_done`` path. Backward-compatible.
|
|
"""
|
|
verdict = output_payload.get("verdict")
|
|
if verdict == "abandon":
|
|
# Mapper-side defense-in-depth: the MCP setter already
|
|
# validates the category, but a hand-crafted output_payload
|
|
# (e.g. legacy migration, test fixture, salvage path) could
|
|
# bypass the MCP. Refuse to fire the abandon event without
|
|
# a category — the side-effect tick relies on it for the
|
|
# close-comment template and the audit row.
|
|
category = output_payload.get("abandon_reason_category")
|
|
if not category:
|
|
return EventMapResult(
|
|
None,
|
|
reason=(
|
|
"estimator verdict='abandon' missing required "
|
|
"abandon_reason_category; refusing to fire event "
|
|
"(workflow stays in ANALYZING; next tick will "
|
|
"re-evaluate via contract-violation path if the "
|
|
"shape persists)"
|
|
),
|
|
)
|
|
# Confidence gating — agent prompt
|
|
# (`.opencode/agents/estimator-implementation.md` "GATE 2
|
|
# ABANDON > Abandon requires confidence") tells the agent:
|
|
# "Abandon requires confidence='high' or 'medium' for the
|
|
# controller to act. Low-confidence abandon is treated as
|
|
# proceed-with-warning." Mapper enforces it so the prompt's
|
|
# promise matches reality. Low-confidence abandon routes to
|
|
# estimator_done (normal tier-N flow) so the downstream
|
|
# implementer/reviewer can catch whatever the estimator was
|
|
# uncertain about, instead of autonomously closing on a
|
|
# low-conviction signal.
|
|
confidence = output_payload.get("confidence")
|
|
# ``None`` (missing field) is treated as low — a payload that
|
|
# omits confidence has provided no evidence the estimator
|
|
# cleared the high/medium bar, so the safe default is the same
|
|
# downgrade. Phase-3 round-1 symmetry fix — Phase 2 originally
|
|
# only checked for the literal 'low' string; a missing field
|
|
# could autonomously close on no-evidence.
|
|
if confidence in ("low", None):
|
|
missing_or_low = "missing" if confidence is None else "low"
|
|
return EventMapResult(
|
|
"estimator_done",
|
|
reason=(
|
|
f"estimator verdict='abandon' category={category!r} "
|
|
f"confidence={confidence!r} ({missing_or_low}) → "
|
|
f"downgraded to estimator_done (prompt contract: "
|
|
f"abandon requires high or medium)"
|
|
),
|
|
)
|
|
return EventMapResult(
|
|
"estimator_abandon",
|
|
reason=(
|
|
f"estimator verdict=abandon category={category!r} "
|
|
f"confidence={confidence!r}"
|
|
),
|
|
)
|
|
if verdict == "metadata-only" or output_payload.get("is_metadata_only") is True:
|
|
return EventMapResult(
|
|
"estimator_metadata_only", reason="estimator flagged metadata-only"
|
|
)
|
|
return EventMapResult(
|
|
"estimator_done", reason="estimator returned recommended_tier"
|
|
)
|
|
|
|
|
|
_GROOMING_VERDICT_TO_EVENT = {
|
|
"proceed": "groom_verdict_proceed",
|
|
"defer": "groom_verdict_defer",
|
|
"close": "groom_verdict_close",
|
|
}
|
|
|
|
|
|
def _map_grooming_outcome(output_payload: dict[str, Any]) -> EventMapResult:
|
|
"""GROOMING → {ANALYZING, PAUSED, ABANDONED} via the worker's verdict.
|
|
|
|
The grooming_stage_b worker runs deterministic checks + Stage A
|
|
suspicion scoring + Stage B LLM judgment internally and emits a
|
|
single top-level verdict; this mapper just routes verdict → event.
|
|
The action-mapping policy (close vs defer, low-confidence forced
|
|
proceed, semantic-contradiction forced proceed) is the worker's
|
|
responsibility — by the time output_payload reaches us, the verdict
|
|
already reflects those rules.
|
|
|
|
The Forgejo side-effects for ``defer`` / ``close`` (label swap +
|
|
audit comment + PATCH state:closed) happen AFTER the transition in
|
|
``run_grooming_side_effects_tick``; this mapper only drives the
|
|
state-machine event.
|
|
"""
|
|
verdict = output_payload.get("verdict")
|
|
if verdict not in _GROOMING_VERDICT_TO_EVENT:
|
|
return EventMapResult(
|
|
None,
|
|
reason=(
|
|
f"grooming output_payload has invalid verdict {verdict!r}; "
|
|
f"expected one of {sorted(_GROOMING_VERDICT_TO_EVENT)}"
|
|
),
|
|
)
|
|
return EventMapResult(
|
|
_GROOMING_VERDICT_TO_EVENT[verdict],
|
|
reason=(
|
|
f"grooming verdict={verdict!r} "
|
|
f"check={output_payload.get('check_name')!r} "
|
|
f"stage={output_payload.get('stage')!r}"
|
|
),
|
|
)
|
|
|
|
|
|
# T5-9 (2026-05-19): dispute relaxation. Pre-T5-9 we restricted disputes
|
|
# to tier=2 only. Data from trial-5 showed haiku (default tier-0) was
|
|
# already producing credible structured disputes when given the right
|
|
# prompt + MCP contract, so the conservative tier-2 gate was throwing
|
|
# away cheap recovery. Now dispute is allowed at any tier; reviewer
|
|
# stand-down at tier<MAX_TIER escalates the implementer (next tier
|
|
# takes a fresh shot), and only stand-down at MAX_TIER routes to
|
|
# OPERATOR_ATTENTION. Bounded by ``MAX_DISPUTES_PER_TIER``: an
|
|
# implementer can dispute exactly once per tier — a second dispute
|
|
# at the same tier is downgraded to competence-failure so the workflow
|
|
# escalates rather than ping-ponging.
|
|
_MAX_TIER = 2
|
|
_MAX_DISPUTES_PER_TIER = 1
|
|
# Backstop cap on the ci-not-ready outcome. After this many on one
|
|
# workflow, route to STUCK rather than letting ci-not-ready <-> ci_red
|
|
# ping-pong forever. Set high so transient CI-pending blips never
|
|
# trip it — only a genuine runaway loop reaches it.
|
|
_MAX_CI_NOT_READY = 5
|
|
|
|
# Backstop cap on the ci-infra-failure outcome. The IMPLEMENTING →
|
|
# DISCOVERED → CI-freshness-gate loop is already bounded by the gate's
|
|
# RERUN_BUDGET (3), so this is a defence-in-depth cap for the case the
|
|
# implementer keeps mis-classifying a real failure as infra: after this
|
|
# many ci-infra-failure outcomes on one workflow, route to STUCK for
|
|
# operator attention instead of looping the gate forever.
|
|
_MAX_CI_INFRA_FAILURE = 4
|
|
|
|
|
|
def _map_implementer_outcome(
|
|
outcome: str,
|
|
current_state: str,
|
|
head_sha_advanced: bool,
|
|
attempts_remaining_at_tier: int,
|
|
attempt_tier: int | None = None,
|
|
prior_disputes_at_current_tier: int = 0,
|
|
prior_ci_not_ready: int = 0,
|
|
prior_ci_infra_failure: int = 0,
|
|
attempt_saw_green_ci: bool = False,
|
|
prior_reviews: int = 0,
|
|
) -> EventMapResult:
|
|
"""IMPLEMENTING-state transitions.
|
|
|
|
Note: 'resolved' + !head_sha_advanced is a contract-violation:
|
|
the worker claimed to push but didn't. The master sees this and
|
|
routes to ESCALATING (treats as competence-failure).
|
|
|
|
The worker-authored ``gate-failed`` outcome is NOT handled here —
|
|
it is a status='failed' attempt, mapped in the status='failed'
|
|
block of ``map_outcome_to_event`` (a 'complete' attempt with no
|
|
event would not be re-dispatched by the scheduler).
|
|
"""
|
|
if outcome == "resolved":
|
|
if head_sha_advanced:
|
|
return EventMapResult(
|
|
"implementer_pushed", reason="outcome=resolved + head_sha advanced"
|
|
)
|
|
# Worker lied: claimed resolved but no push.
|
|
return EventMapResult(
|
|
"implementer_competence_failure",
|
|
reason="outcome=resolved but head_sha NOT advanced; treat as competence failure",
|
|
)
|
|
if outcome == "rebase-failed":
|
|
return EventMapResult(
|
|
"implementer_rebase_failed", reason="outcome=rebase-failed"
|
|
)
|
|
if outcome == "competence-failure":
|
|
return EventMapResult(
|
|
"implementer_competence_failure", reason="outcome=competence-failure"
|
|
)
|
|
if outcome == "blocked":
|
|
# A blocked implementer below MAX_TIER is often just under-tiered
|
|
# (the task is too big for this model) OR hit a transient infra
|
|
# snag — a push race, a flaky credential helper. Both recover
|
|
# from a fresh, stronger attempt: escalate to the next tier
|
|
# instead of dead-ending at STUCK. The re-attempt also re-runs
|
|
# fetch_and_validate, which adopts any commits a prior attempt
|
|
# already landed on the remote (closes the push-race dead-end).
|
|
# Only ``blocked`` AT MAX_TIER is a genuine give-up — the agent
|
|
# tried the strongest tier and listed real blockers → STUCK so
|
|
# an operator reads them.
|
|
if attempt_tier is not None and attempt_tier < _MAX_TIER:
|
|
return EventMapResult(
|
|
"implementer_competence_failure",
|
|
reason=(
|
|
f"outcome=blocked at tier={attempt_tier} < MAX_TIER "
|
|
f"({_MAX_TIER}); escalate for a stronger/fresh attempt "
|
|
"instead of STUCK"
|
|
),
|
|
)
|
|
return EventMapResult(
|
|
"implementer_blocked",
|
|
reason=f"outcome=blocked at tier={attempt_tier} (MAX_TIER); STUCK",
|
|
)
|
|
if outcome == "noop":
|
|
# Green-CI fast-forward. When the attempt was dispatched
|
|
# against a fully-green CI (every gate passed, none failed or
|
|
# pending), a ``noop`` is the implementer *correctly* reporting
|
|
# "nothing to fix" — the HEAD is already CI-verified. Route it
|
|
# straight to REVIEWING.
|
|
#
|
|
# Pre-fix EVERY ``noop`` mapped to competence-failure →
|
|
# ESCALATING → ABANDONED at MAX_TIER. That dead-ended healthy
|
|
# PRs whose implementer happened to say ``noop`` instead of
|
|
# ``verified-clean`` — the PR-39 / PR-40 incident: 12/12 gates
|
|
# green, workflow ABANDONED. An LLM worker's word choice
|
|
# between two synonyms must not decide a PR's fate when the
|
|
# controller can see the CI is green.
|
|
#
|
|
# This routes to REVIEWING directly, NOT AWAITING_CI. A noop
|
|
# pushes nothing, so no fresh CI run is ever triggered;
|
|
# ci_status_poll resolves a SHA only from a 'resolved' /
|
|
# 'ci-not-ready' attempt, so a 'noop' attempt parked in
|
|
# AWAITING_CI is skipped every sweep and never resolves (the
|
|
# PR-41 deadlock). The HEAD is already green — go review it.
|
|
if attempt_saw_green_ci:
|
|
return EventMapResult(
|
|
"implementer_noop_ci_green",
|
|
reason=(
|
|
"outcome=noop and the attempt saw a fully-green CI "
|
|
"(all gates passed) on the un-advanced HEAD → route "
|
|
"directly to REVIEWING; the HEAD is already "
|
|
"CI-verified, and an AWAITING_CI round-trip would "
|
|
"deadlock (no push → no fresh run to poll)"
|
|
),
|
|
)
|
|
# No-op against a non-green CI (red / pending / unknown / no
|
|
# CI): the worker decided no work was needed but the
|
|
# controller cannot vouch for the PR. Treat as
|
|
# competence-failure for v1 (workflow escalates; if it's
|
|
# truly nothing to do, max-tier eventually ABANDONs).
|
|
# Note (T5-11): for post-conflict-resolution implementer
|
|
# attempts where the resolver's commits already cover
|
|
# everything, ``verified-clean`` is the correct outcome
|
|
# (fast-success path → AWAITING_CI). ``noop`` should only fire
|
|
# when the implementer genuinely couldn't find work AND can't
|
|
# vouch for the existing commits being correct.
|
|
return EventMapResult(
|
|
"implementer_competence_failure",
|
|
reason="outcome=noop (CI not green) treated as competence-failure for v1",
|
|
)
|
|
if outcome == "verified-clean":
|
|
# T5-11 fast-success path: post-conflict-resolution implementer
|
|
# ran, found the resolver's commits already cover everything,
|
|
# and confirmed no further code changes are needed. Route to
|
|
# AWAITING_CI so CI verifies the resolver's commits. Pre-T5-11
|
|
# the only way to express this was ``noop`` which escalated;
|
|
# the new outcome short-circuits the escalation wart.
|
|
return EventMapResult(
|
|
"implementer_verified",
|
|
reason=(
|
|
"outcome=verified-clean; resolver's commits already cover "
|
|
"everything → AWAITING_CI for CI verification (T5-11)"
|
|
),
|
|
)
|
|
if outcome == "ci-not-ready":
|
|
# The implementer ran before CI produced a verdict (CI still
|
|
# pending, nothing to fix). Route to AWAITING_CI to wait;
|
|
# ci_status_poll re-dispatches the implementer only if CI goes
|
|
# red.
|
|
#
|
|
# Backstop: ci-not-ready <-> ci_red_retry can ping-pong if the
|
|
# implementer keeps reading CI as pending. After
|
|
# _MAX_CI_NOT_READY of them on one workflow, route to STUCK for
|
|
# operator attention — a persistent CI-visibility problem, not
|
|
# a tier-capability one, so escalating wouldn't help.
|
|
if prior_ci_not_ready >= _MAX_CI_NOT_READY:
|
|
return EventMapResult(
|
|
"implementer_blocked",
|
|
reason=(
|
|
f"outcome=ci-not-ready emitted {prior_ci_not_ready + 1}x "
|
|
f"on this workflow (cap {_MAX_CI_NOT_READY}) — stuck in a "
|
|
"CI-wait loop; routing to STUCK for operator attention"
|
|
),
|
|
)
|
|
return EventMapResult(
|
|
"implementer_ci_not_ready",
|
|
reason=(
|
|
"outcome=ci-not-ready; CI has no verdict yet → AWAITING_CI "
|
|
"to wait for the CI run"
|
|
),
|
|
)
|
|
if outcome == "ci-infra-failure":
|
|
# The CI failure the implementer was sent to fix carries no
|
|
# verdict in its log (a hard-kill / OOM) — nothing to fix.
|
|
# Route IMPLEMENTING → DISCOVERED so the CI-freshness gate
|
|
# reruns CI under its bounded RERUN_BUDGET, instead of
|
|
# dead-ending the PR at blocked → STUCK (the PR-39 incident).
|
|
#
|
|
# Backstop: the gate loop is already RERUN_BUDGET-bounded, but
|
|
# an implementer that keeps mis-reading a real failure as infra
|
|
# would loop DISCOVERED → ANALYZING → IMPLEMENTING. After
|
|
# _MAX_CI_INFRA_FAILURE of them on one workflow, route to STUCK
|
|
# for operator attention.
|
|
if prior_ci_infra_failure >= _MAX_CI_INFRA_FAILURE:
|
|
return EventMapResult(
|
|
"implementer_blocked",
|
|
reason=(
|
|
f"outcome=ci-infra-failure emitted "
|
|
f"{prior_ci_infra_failure + 1}x on this workflow "
|
|
f"(cap {_MAX_CI_INFRA_FAILURE}) — CI never yields a "
|
|
"real verdict; routing to STUCK for operator attention"
|
|
),
|
|
)
|
|
return EventMapResult(
|
|
"implementer_ci_infra_failure",
|
|
reason=(
|
|
"outcome=ci-infra-failure; CI failure has no verdict "
|
|
"(hard-kill / OOM) → DISCOVERED for a bounded CI rerun"
|
|
),
|
|
)
|
|
if outcome == "dispute-reviewer":
|
|
# ``dispute-reviewer`` routes IMPLEMENTING → REVIEWING — the ONE
|
|
# edge into REVIEWING that bypasses AWAITING_CI, the only state
|
|
# that enforces CI greenness. Two preconditions gate it so a weak
|
|
# model cannot emit ``dispute-reviewer`` as an escape hatch from a
|
|
# red-CI retry loop (the PR-44 incident: a tier-0 implementer,
|
|
# bounced twice by red CI, emitted a fabricated dispute and
|
|
# shortcut a red PR into REVIEWING → APPROVED → MERGING).
|
|
#
|
|
# Guard 1 — CI must be green. A dispute jumps the CI gate, so the
|
|
# HEAD it carries must already be CI-verified. A dispute on a
|
|
# red / pending / unknown HEAD is invalid; treat it as a
|
|
# competence-failure so the workflow escalates and a stronger
|
|
# tier fixes the real CI failure.
|
|
if not attempt_saw_green_ci:
|
|
return EventMapResult(
|
|
"implementer_competence_failure",
|
|
reason=(
|
|
"outcome=dispute-reviewer but the attempt did not see a "
|
|
"green CI — a dispute cannot jump the CI gate on a "
|
|
"red/pending HEAD; escalate instead of disputing"
|
|
),
|
|
)
|
|
# Guard 2 — there must be a reviewer verdict to dispute. If no
|
|
# reviewer has ever judged this workflow, the dispute references
|
|
# a review that does not exist (a hallucinated outcome). Escalate.
|
|
if prior_reviews < 1:
|
|
return EventMapResult(
|
|
"implementer_competence_failure",
|
|
reason=(
|
|
"outcome=dispute-reviewer but no reviewer has judged "
|
|
"this workflow — nothing to dispute; escalate instead"
|
|
),
|
|
)
|
|
# T5-9: dispute available at any tier. Per-tier cap enforced:
|
|
# a second dispute at the SAME tier is downgraded to
|
|
# competence-failure so the workflow escalates (one shot per
|
|
# tier; subsequent disagreement is the next tier's problem).
|
|
if prior_disputes_at_current_tier >= _MAX_DISPUTES_PER_TIER:
|
|
return EventMapResult(
|
|
"implementer_competence_failure",
|
|
reason=(
|
|
f"outcome=dispute-reviewer at tier={attempt_tier!r} "
|
|
f"but workflow already disputed {prior_disputes_at_current_tier} "
|
|
f"time(s) at this tier (cap={_MAX_DISPUTES_PER_TIER}); "
|
|
"escalate instead of re-disputing"
|
|
),
|
|
)
|
|
return EventMapResult(
|
|
"implementer_dispute_reviewer",
|
|
reason=(
|
|
f"outcome=dispute-reviewer at tier={attempt_tier!r}; "
|
|
"route to REVIEWING for re-examination"
|
|
),
|
|
)
|
|
return EventMapResult(None, reason=f"unknown implementer outcome {outcome!r}")
|
|
|
|
|
|
def _map_reviewer_outcome(
|
|
outcome: str,
|
|
attempts_remaining_at_tier: int,
|
|
*,
|
|
re_examined_disputed_claim: bool = False,
|
|
workflow_current_tier: int | None = None,
|
|
suggested_next_action: str | None = None,
|
|
abandon_reason_category: str | None = None,
|
|
confidence: str | None = None,
|
|
) -> EventMapResult:
|
|
"""REVIEWING-state transitions.
|
|
|
|
T5-4 + T5-9: when ``re_examined_disputed_claim=True`` AND
|
|
``verdict=request-changes``, the reviewer was re-invoked to look at
|
|
an implementer's dispute and still stood by the verdict.
|
|
- If the disputer was below MAX_TIER, escalate the implementer to
|
|
next tier (fresh more-capable shot at the same problem).
|
|
- If the disputer was already at MAX_TIER, that's a true
|
|
stalemate → OPERATOR_ATTENTION (human tiebreaker).
|
|
|
|
``verdict=approve`` ignores the re_examined flag (concede path
|
|
goes through APPROVED via the normal approve event).
|
|
|
|
Phase 3 (Gate 3 reviewer abandon, 2026-05-25): when
|
|
``verdict=abstain`` AND ``suggested_next_action='abandon'``, fire
|
|
``reviewer_abandon`` → ABANDONED. Same defense-in-depth pattern as
|
|
Phase 2's estimator-abandon mapper — category required, low
|
|
confidence downgrades.
|
|
"""
|
|
if outcome == "approve":
|
|
return EventMapResult("reviewer_approve", reason="verdict=approve")
|
|
if outcome == "request-changes":
|
|
if re_examined_disputed_claim:
|
|
if workflow_current_tier is not None and workflow_current_tier < _MAX_TIER:
|
|
return EventMapResult(
|
|
"reviewer_re_examined_request_changes_escalate",
|
|
reason=(
|
|
f"verdict=request-changes + re_examined=True at "
|
|
f"tier={workflow_current_tier}; not yet at "
|
|
f"MAX_TIER={_MAX_TIER} → escalate implementer "
|
|
"to next tier (T5-9)"
|
|
),
|
|
)
|
|
return EventMapResult(
|
|
"reviewer_re_examined_request_changes",
|
|
reason=(
|
|
f"verdict=request-changes + re_examined=True at "
|
|
f"tier={workflow_current_tier}=MAX_TIER; stalemate "
|
|
"→ OPERATOR_ATTENTION"
|
|
),
|
|
)
|
|
if attempts_remaining_at_tier > 0:
|
|
return EventMapResult(
|
|
"reviewer_request_changes_retry",
|
|
reason="verdict=request-changes; attempts at tier remaining",
|
|
)
|
|
return EventMapResult(
|
|
"reviewer_request_changes_escalate",
|
|
reason="verdict=request-changes; attempts at tier exhausted",
|
|
)
|
|
if outcome == "comment":
|
|
# Comment-only verdict: no actionable transition. v1 treats
|
|
# this as request-changes with attempts available (operator
|
|
# can re-prompt the reviewer for a final verdict).
|
|
return EventMapResult(
|
|
None,
|
|
reason="verdict=comment; no state-machine transition (operator review needed)",
|
|
)
|
|
if outcome == "abstain":
|
|
# Phase 3 (Gate 3 reviewer abandon, 2026-05-25). The reviewer
|
|
# uses verdict=abstain + suggested_next_action=abandon to signal
|
|
# "implementation revealed this is fundamentally unworkable —
|
|
# close it, don't hand it to a human." Anything ELSE paired
|
|
# with abstain (human-attention is the legacy default) falls
|
|
# through to the legacy STUCK path.
|
|
if suggested_next_action == "abandon":
|
|
# Mapper-side defense-in-depth: the MCP setter already
|
|
# validates the category, but a hand-crafted output_payload
|
|
# (e.g. legacy migration, test fixture, salvage path) could
|
|
# bypass the MCP. Refuse to fire the abandon event without
|
|
# a category — the side-effect tick relies on it for the
|
|
# close-comment template and the audit row.
|
|
if not abandon_reason_category:
|
|
return EventMapResult(
|
|
None,
|
|
reason=(
|
|
"reviewer verdict='abstain' + "
|
|
"suggested_next_action='abandon' missing required "
|
|
"abandon_reason_category; refusing to fire event "
|
|
"(workflow stays in REVIEWING; next tick will "
|
|
"re-evaluate via contract-violation path if the "
|
|
"shape persists)"
|
|
),
|
|
)
|
|
# Confidence gating — same contract as Phase 2's estimator
|
|
# mapper. The agent prompt
|
|
# (.opencode/agents/pr-review-worker.md GATE 3 ABANDON
|
|
# section) tells the reviewer: "Abandon requires
|
|
# confidence='high' or 'medium' for the controller to act.
|
|
# Low-confidence abandon is treated as human-attention."
|
|
# Mapper enforces it so the prompt's promise matches reality.
|
|
# Low-confidence abandon downgrades to ``reviewer_abstain``
|
|
# (legacy human-attention STUCK path) so an operator looks
|
|
# at it instead of the controller autonomously closing on
|
|
# a low-conviction signal.
|
|
#
|
|
# ``None`` (missing field) is treated as low — a payload
|
|
# that omits confidence has provided no evidence the
|
|
# reviewer cleared the high/medium bar, so the safe
|
|
# default is the same downgrade. Round-1 fix from the
|
|
# test-engineer adversarial review.
|
|
if confidence in ("low", None):
|
|
missing_or_low = "missing" if confidence is None else "low"
|
|
return EventMapResult(
|
|
"reviewer_abstain",
|
|
reason=(
|
|
f"reviewer verdict='abstain' + "
|
|
f"suggested_next_action='abandon' category="
|
|
f"{abandon_reason_category!r} confidence="
|
|
f"{confidence!r} ({missing_or_low}) → downgraded "
|
|
f"to reviewer_abstain (prompt contract: abandon "
|
|
f"requires high or medium)"
|
|
),
|
|
)
|
|
return EventMapResult(
|
|
"reviewer_abandon",
|
|
reason=(
|
|
f"verdict=abstain + suggested_next_action=abandon "
|
|
f"category={abandon_reason_category!r} "
|
|
f"confidence={confidence!r}"
|
|
),
|
|
)
|
|
return EventMapResult("reviewer_abstain", reason="verdict=abstain")
|
|
return EventMapResult(None, reason=f"unknown reviewer outcome {outcome!r}")
|
|
|
|
|
|
def _map_conflict_resolver_outcome(
|
|
outcome: str,
|
|
conflict_count_at_current_tier: int,
|
|
) -> EventMapResult:
|
|
"""CONFLICT_RESOLVING-state transitions.
|
|
|
|
Conflict counting policy (v6):
|
|
- 1st conflict at this tier → IMPLEMENTING(same tier)
|
|
- 2nd conflict at this tier → ESCALATING (next tier)
|
|
- 3rd+ conflict (any tier) → STUCK
|
|
"""
|
|
if outcome == "resolved":
|
|
# conflict_count_at_current_tier already includes THIS attempt.
|
|
if conflict_count_at_current_tier >= 3:
|
|
return EventMapResult(
|
|
"conflict_repeated_three_plus",
|
|
reason="resolved but 3+ conflicts seen; structurally hard",
|
|
)
|
|
if conflict_count_at_current_tier >= 2:
|
|
return EventMapResult(
|
|
"conflict_resolved_second_same_tier",
|
|
reason="2nd conflict at tier; escalate",
|
|
)
|
|
return EventMapResult(
|
|
"conflict_resolved_first",
|
|
reason="1st conflict at tier; retry same tier",
|
|
)
|
|
if outcome == "irreconcilable":
|
|
return EventMapResult(
|
|
"conflict_irreconcilable",
|
|
reason="resolver gave up on conflict",
|
|
)
|
|
if outcome in ("partial", "competence-failure", "blocked"):
|
|
# No state-machine event — the master re-enqueues per the
|
|
# status=failed policy; the pickup guard bounds retries and
|
|
# STUCKs the workflow once MAX_PICKUPS is hit.
|
|
# - ``partial``: resolver half-finished; re-enqueue to retry.
|
|
# - ``competence-failure``: exceeded the agent's capability.
|
|
# - ``blocked`` (T5-13): an environment failure (git tool
|
|
# error, repo corruption, push race). Re-enqueue because a
|
|
# transient push race usually clears on the next attempt; a
|
|
# persistent failure exhausts the pickup budget → STUCK,
|
|
# which is the right operator-investigation signal.
|
|
return EventMapResult(
|
|
None,
|
|
reason=f"outcome={outcome!r}; treat per status=failed policy",
|
|
)
|
|
return EventMapResult(None, reason=f"unknown resolver outcome {outcome!r}")
|
|
|
|
|
|
__all__ = ["EventMapResult", "map_outcome_to_event"]
|