Files
cleveragents-core/tools/controller/master/outcomes.py
T
drew b519ebd98f feat(controller): Phase 1d-2 — outcome mapper + master tick handler
The master-side state machine driver. When a worker writes a complete
(or failed) attempt, the tick handler picks it up next pass, maps the
outcome to a state machine event, and applies the transition.

tools/controller/master/:

- outcomes.py: map_outcome_to_event() — pure function. Inputs:
  role, current_state, output_payload, status, head_sha_advanced,
  attempts_remaining_at_tier, conflict_count_at_current_tier.
  Returns EventMapResult(event_name|None, reason).
  - Implementer: resolved+pushed → implementer_pushed;
    resolved+NO push → implementer_competence_failure (worker lied);
    rebase-failed / competence-failure / blocked / noop all routed.
  - Reviewer: approve → reviewer_approve; request-changes → retry vs
    escalate based on attempts_remaining_at_tier; abstain →
    reviewer_abstain; comment → no transition (operator review needed).
  - Estimator: is_metadata_only → estimator_metadata_only; else
    estimator_done.
  - Conflict resolver: counts at current tier — 1st → first; 2nd →
    second_same_tier (escalate); 3rd+ → three_plus (STUCK).
  - Summarizer: doesn't drive state transitions.
  - status='failed' policy: contract-violation → pickup_exhausted;
    other failed outcomes (worker-internal-error / stale-input /
    ttl-insufficient-for-retry / git-clone-failed / lost-lock / etc.)
    → no event (master re-enqueues per the v9 table).
  - status='reaped' → no event (pickup guard handled).

- tick.py: run_tick() — one master tick. Queries
  workflow_attempts WHERE status IN ('complete', 'failed') AND
  finished_at > workflow.last_transition_at (heuristic for "not yet
  processed"). Per row:
  1. Validate current_state ∈ KNOWN_STATES (per v6 unknown-state
     guard); if not, transition workflow → STUCK with reason='unknown-state'.
  2. Map outcome → event via outcomes.map_outcome_to_event.
  3. If no event, bump workflow.last_transition_at so we don't
     re-process forever.
  4. Apply event via state_machine.apply_event; IllegalTransition →
     STUCK with reason='illegal-event'.
  5. Commit transition (workflows.current_state + entered_state_at +
     last_transition_at) AND insert controller_events row.

  Returns TickReport with attempts_processed + transitions_applied +
  transitions_log.

42 new tests:
- outcomes (22): status='failed' policy (3 paths) + each role's
  happy paths + edge cases (unknown outcome / missing field /
  None payload / unknown role).
- tick (20): implementer transitions (resolved+push → AWAITING_CI,
  resolved-no-push → ESCALATING, rebase → CONFLICT_RESOLVING,
  blocked → STUCK), reviewer transitions (approve → MERGING,
  request-changes → IMPLEMENTING), event row content, no
  reprocessing on second tick, unmapped attempts bump
  last_transition_at, terminal workflows skipped (4 parametrized),
  unknown current_state → STUCK.

Total: 271 controller tests; full auto_agents suite 2633 pass.
2026-05-18 13:38:28 -04:00

254 lines
10 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,
) -> 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 CONFLICT_RESOLVING
attempts have already happened at the current tier (per v6
bounded retry policy).
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"
return _map_failed_outcome(outcome)
# status='complete' → use the output_payload.outcome
outcome = (output_payload or {}).get("outcome")
if outcome is None:
return EventMapResult(None, reason="no outcome in output_payload")
# Dispatch by role.
if role == "estimator":
return _map_estimator_outcome(output_payload)
if role == "implementer":
return _map_implementer_outcome(
outcome, current_state, head_sha_advanced,
attempts_remaining_at_tier,
)
if role == "reviewer":
return _map_reviewer_outcome(outcome, attempts_remaining_at_tier)
if role == "conflict_resolver":
return _map_conflict_resolver_outcome(
outcome, conflict_count_at_current_tier,
)
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")
return EventMapResult(None, reason=f"unknown role {role!r}")
# ─── per-role mappers ─────────────────────────────────────────────────
def _map_failed_outcome(outcome: str) -> 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 and
absolute-wallclock-exceeded transition the workflow to STUCK.
"""
if outcome == "contract-violation":
# Per the v9 contract: workflow → STUCK with reason. We model
# this via the pickup_exhausted event (terminal-failure path).
return EventMapResult("pickup_exhausted", reason="contract-violation")
# Other failed outcomes (worker-internal-error / stale-input /
# 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) OR REVIEWING(metadata-only)."""
if 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")
def _map_implementer_outcome(
outcome: str,
current_state: str,
head_sha_advanced: bool,
attempts_remaining_at_tier: int,
) -> 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).
"""
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":
return EventMapResult("implementer_blocked", reason="outcome=blocked")
if outcome == "noop":
# No-op: worker decided no work needed. Treat as
# competence-failure for v1 (workflow escalates; if it's
# truly nothing to do, max-tier eventually ABANDONs).
# Future: dedicated event + state transition.
return EventMapResult(
"implementer_competence_failure",
reason="outcome=noop treated as competence-failure for v1",
)
return EventMapResult(None, reason=f"unknown implementer outcome {outcome!r}")
def _map_reviewer_outcome(
outcome: str, attempts_remaining_at_tier: int,
) -> EventMapResult:
"""REVIEWING-state transitions."""
if outcome == "approve":
return EventMapResult("reviewer_approve", reason="verdict=approve")
if outcome == "request-changes":
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":
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"):
# Treat partial as competence-failure for v1 (resolver
# shouldn't half-finish; if it does, route through escalation).
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"]