b519ebd98f
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.
254 lines
10 KiB
Python
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"]
|