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

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

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

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

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

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"]