"""Controller state machine — TRANSITIONS table + invariants. Plan v9 simplified scope: ship the 6 LOAD-BEARING property invariants for v1; add the other 15 as bugs surface. States (per plan v6+v9, with v8 metadata-only-no-bypass + v6 CONFLICT_RESOLVING + v6 MERGING + v6 CREATED_PR): DISCOVERED → ANALYZING (estimator picks tier OR routes to metadata_only) → STUCK (estimator failed twice) ANALYZING → IMPLEMENTING(tier) (normal path) → REVIEWING (metadata-only path; per v8 Hard Rule #1 still goes through reviewer with lightweight profile) → STUCK (estimator output schema violation) IMPLEMENTING → AWAITING_CI (worker pushed; head_sha advanced) → ESCALATING (worker emitted competence-failure; no push) → CONFLICT_RESOLVING (worker emitted rebase-failed) → STUCK (worker emitted blocked) AWAITING_CI → REVIEWING (CI green) → IMPLEMENTING(tier) (CI red, attempts-per-tier remaining) → ESCALATING (CI red, attempts-per-tier exhausted) → AWAITING_CI (flake retry; one retry per flake-classifier per gate; same state) CONFLICT_RESOLVING → IMPLEMENTING(same tier) (1st conflict resolved) → ESCALATING (2nd conflict at same tier) → STUCK (3rd conflict; or resolver outcome=irreconcilable) ESCALATING → IMPLEMENTING(tier+1) (next tier available) → ABANDONED (max tier exhausted) REVIEWING → MERGING (verdict=approve) → IMPLEMENTING(tier) (verdict=request-changes, attempts-per-tier remaining) → ESCALATING (verdict=request-changes, attempts-per-tier exhausted) → STUCK (verdict=abstain; retry-once-then-stuck handled in v9 retry policy) MERGING → MERGED (Forgejo 200) → IMPLEMENTING(tier_last_succeeded) (Forgejo 409; post- approval base conflict) → AWAITING_CI (Forgejo 422; CI status expired race) → STUCK (Forgejo 403 branch protection; or retry_count>=5) → MERGED OR ABANDONED (Forgejo 404; check external state) Terminal states: MERGED, ABANDONED, STUCK, CREATED_PR CREATED_PR is reachable only from issue-kind workflows (IMPLEMENTING on an issue successfully creates a PR + spawns a new pr-kind workflow with parent_workflow_id; the issue workflow terminates at CREATED_PR). For v1 simplicity, this module models only the PR-kind state machine; issue-kind handling lands in Phase 4. """ from __future__ import annotations from dataclasses import dataclass, field from typing import Iterable # ─── states ────────────────────────────────────────────────────────── KNOWN_STATES: frozenset[str] = frozenset({ "DISCOVERED", "ANALYZING", "IMPLEMENTING", "AWAITING_CI", "CONFLICT_RESOLVING", "ESCALATING", "REVIEWING", "MERGING", "MERGED", "ABANDONED", "STUCK", "CREATED_PR", }) TERMINAL_STATES: frozenset[str] = frozenset({ "MERGED", "ABANDONED", "STUCK", "CREATED_PR", }) NON_TERMINAL_STATES: frozenset[str] = KNOWN_STATES - TERMINAL_STATES # Events that drive transitions. The event name captures "what the # controller observed" so the transition table reads as data, not # code. @dataclass(frozen=True) class Event: name: str description: str = "" def __repr__(self) -> str: return f"Event({self.name!r})" # ─── canonical events ──────────────────────────────────────────────── EVENTS = { # ANALYZING "estimator_done": Event("estimator_done", "Estimator returned tier"), "estimator_metadata_only": Event( "estimator_metadata_only", "Estimator flagged is_metadata_only=true", ), "estimator_failed_twice": Event( "estimator_failed_twice", "Estimator strict-parse failed twice (v6 retry policy)", ), # IMPLEMENTING "implementer_pushed": Event( "implementer_pushed", "Worker emitted outcome=resolved; head_sha advanced", ), "implementer_competence_failure": Event( "implementer_competence_failure", "Worker emitted outcome=competence-failure", ), "implementer_rebase_failed": Event( "implementer_rebase_failed", "Worker emitted outcome=rebase-failed", ), "implementer_blocked": Event( "implementer_blocked", "Worker emitted outcome=blocked", ), # AWAITING_CI "ci_green": Event("ci_green", "CI overall_state=success"), "ci_red_retry_same_tier": Event( "ci_red_retry_same_tier", "CI failed; attempts-per-tier remaining", ), "ci_red_escalate": Event( "ci_red_escalate", "CI failed; attempts-per-tier exhausted", ), "ci_flake_retry": Event( "ci_flake_retry", "CI failed but classified as flake; retry once", ), # CONFLICT_RESOLVING "conflict_resolved_first": Event( "conflict_resolved_first", "Resolver returned outcome=resolved (1st conflict at tier)", ), "conflict_resolved_second_same_tier": Event( "conflict_resolved_second_same_tier", "Resolver returned outcome=resolved (2nd conflict at tier)", ), "conflict_repeated_three_plus": Event( "conflict_repeated_three_plus", "3rd or later conflict — likely structural; STUCK", ), "conflict_irreconcilable": Event( "conflict_irreconcilable", "Resolver returned outcome=irreconcilable", ), # ESCALATING "escalate_next_tier_available": Event( "escalate_next_tier_available", "Tier+1 ≤ MAX_TIER; transition to IMPLEMENTING(tier+1)", ), "escalate_max_tier_exhausted": Event( "escalate_max_tier_exhausted", "Tier was MAX_TIER; ABANDONED", ), # REVIEWING "reviewer_approve": Event( "reviewer_approve", "Reviewer verdict=approve", ), "reviewer_request_changes_retry": Event( "reviewer_request_changes_retry", "Reviewer verdict=request-changes; attempts-per-tier remaining", ), "reviewer_request_changes_escalate": Event( "reviewer_request_changes_escalate", "Reviewer verdict=request-changes; attempts-per-tier exhausted", ), "reviewer_abstain": Event( "reviewer_abstain", "Reviewer verdict=abstain (rare); per v6 retry-once-then-STUCK", ), # MERGING "merge_ok": Event("merge_ok", "Forgejo merge returned 200"), "merge_base_conflict": Event( "merge_base_conflict", "Forgejo merge returned 409; base advanced post-approval", ), "merge_ci_required_missing": Event( "merge_ci_required_missing", "Forgejo merge returned 422; race with CI status", ), "merge_branch_protection_blocked": Event( "merge_branch_protection_blocked", "Forgejo merge returned 403; operator must intervene", ), "merge_retry_exhausted": Event( "merge_retry_exhausted", "merging_retry_count >= 5; STUCK", ), "merge_external_action": Event( "merge_external_action", "Forgejo merge returned 404; reconcile via PR state", ), # DISCOVERED → ANALYZING "discovery_picked_up": Event( "discovery_picked_up", "Master saw DISCOVERED workflow; queue estimator attempt", ), # Operator escape hatch (reachable from every non-terminal state) "operator_unstick": Event( "operator_unstick", "Operator manually unstucking; back to DISCOVERED", ), "pickup_exhausted": Event( "pickup_exhausted", "Attempt picked up MAX_PICKUPS times w/o success; STUCK", ), } # ─── transitions ───────────────────────────────────────────────────── # Map of (from_state, event_name) → to_state. Pure data; tests # enumerate every key to verify invariants. TRANSITIONS: dict[tuple[str, str], str] = { ("DISCOVERED", "discovery_picked_up"): "ANALYZING", ("ANALYZING", "estimator_done"): "IMPLEMENTING", ("ANALYZING", "estimator_metadata_only"): "REVIEWING", ("ANALYZING", "estimator_failed_twice"): "STUCK", ("IMPLEMENTING", "implementer_pushed"): "AWAITING_CI", ("IMPLEMENTING", "implementer_competence_failure"): "ESCALATING", ("IMPLEMENTING", "implementer_rebase_failed"): "CONFLICT_RESOLVING", ("IMPLEMENTING", "implementer_blocked"): "STUCK", ("IMPLEMENTING", "pickup_exhausted"): "STUCK", ("AWAITING_CI", "ci_green"): "REVIEWING", ("AWAITING_CI", "ci_red_retry_same_tier"): "IMPLEMENTING", ("AWAITING_CI", "ci_red_escalate"): "ESCALATING", ("AWAITING_CI", "ci_flake_retry"): "AWAITING_CI", ("CONFLICT_RESOLVING", "conflict_resolved_first"): "IMPLEMENTING", ("CONFLICT_RESOLVING", "conflict_resolved_second_same_tier"): "ESCALATING", ("CONFLICT_RESOLVING", "conflict_repeated_three_plus"): "STUCK", ("CONFLICT_RESOLVING", "conflict_irreconcilable"): "STUCK", ("CONFLICT_RESOLVING", "pickup_exhausted"): "STUCK", ("ESCALATING", "escalate_next_tier_available"): "IMPLEMENTING", ("ESCALATING", "escalate_max_tier_exhausted"): "ABANDONED", ("REVIEWING", "reviewer_approve"): "MERGING", ("REVIEWING", "reviewer_request_changes_retry"): "IMPLEMENTING", ("REVIEWING", "reviewer_request_changes_escalate"): "ESCALATING", ("REVIEWING", "reviewer_abstain"): "STUCK", ("REVIEWING", "pickup_exhausted"): "STUCK", ("MERGING", "merge_ok"): "MERGED", ("MERGING", "merge_base_conflict"): "IMPLEMENTING", ("MERGING", "merge_ci_required_missing"): "AWAITING_CI", ("MERGING", "merge_branch_protection_blocked"): "STUCK", ("MERGING", "merge_retry_exhausted"): "STUCK", ("MERGING", "merge_external_action"): "MERGED", # reconciliation refines # Operator escape hatch from STUCK. ("STUCK", "operator_unstick"): "DISCOVERED", } # ─── transition lookup ──────────────────────────────────────────────── class IllegalTransitionError(Exception): """Raised when ``apply_event`` is called with an event that has no mapped transition from the current state. The master catches this and transitions the workflow to STUCK with reason='illegal-event'.""" def apply_event(current_state: str, event_name: str) -> str: """Return the next state for ``event_name`` from ``current_state``. Raises ``ValueError`` if ``current_state`` isn't in ``KNOWN_STATES`` (callers should validate state at load time per v6 unknown-state guard). Raises ``IllegalTransitionError`` if no transition is mapped. """ if current_state not in KNOWN_STATES: raise ValueError( f"unknown state {current_state!r}; not in KNOWN_STATES" ) try: return TRANSITIONS[(current_state, event_name)] except KeyError: raise IllegalTransitionError( f"no transition from {current_state!r} on event " f"{event_name!r}; known events from this state: " f"{sorted(e for (s, e) in TRANSITIONS if s == current_state)}" ) from None def events_from(state: str) -> list[str]: """Return the list of event names that have a transition mapped from ``state``. Useful for operator CLI 'what can I do from here?' queries + property tests.""" return sorted(e for (s, e) in TRANSITIONS if s == state) def reachable_from(state: str) -> set[str]: """Set of states reachable from ``state`` in one or more transitions. Includes the starting state IFF it's part of a cycle (e.g., AWAITING_CI has a self-loop via ``ci_flake_retry``). """ seen: set[str] = set() stack = [state] cycles_back = False while stack: s = stack.pop() for (frm, _evt), to in TRANSITIONS.items(): if frm != s: continue if to == state and s != state: # A transition leads back to the start — that's a cycle. cycles_back = True if to == state and s == state: # Self-loop on the start state. cycles_back = True if to in seen: continue seen.add(to) stack.append(to) if not cycles_back: seen.discard(state) return seen # ─── property invariants (v9: ship 6 for v1) ────────────────────────── @dataclass(frozen=True) class InvariantViolation: """Returned by ``check_*`` helpers when an invariant fails. The property tests assert ``check_*() == []``.""" invariant: str message: str def _violation(invariant: str, message: str) -> InvariantViolation: return InvariantViolation(invariant=invariant, message=message) def check_no_path_implementing_to_reviewing_skips_ci( ) -> list[InvariantViolation]: """Invariant #1 (v9 load-bearing): REVIEWING is reachable from IMPLEMENTING only through AWAITING_CI. Closes the no-mans-land race by construction. """ name = "no_path_implementing_to_reviewing_skips_ci" # Direct edge IMPLEMENTING → REVIEWING would violate this. for (frm, evt), to in TRANSITIONS.items(): if frm == "IMPLEMENTING" and to == "REVIEWING": return [_violation( name, f"direct IMPLEMENTING → REVIEWING via {evt!r}; must " f"route through AWAITING_CI" )] # AWAITING_CI is the only path; check it's reachable from # IMPLEMENTING. if "AWAITING_CI" not in reachable_from("IMPLEMENTING"): return [_violation( name, "IMPLEMENTING cannot reach AWAITING_CI" )] return [] def check_terminal_states_have_no_exits() -> list[InvariantViolation]: """Invariant #2: terminal states are sinks — no outgoing transitions. Note: ``STUCK`` has the ``operator_unstick`` exit, which is the OPERATOR-driven escape hatch. Treated as "outgoing for operator-driven events, but not for controller-driven events" — we permit it. """ name = "terminal_states_have_no_exits" out: list[InvariantViolation] = [] for (frm, evt), to in TRANSITIONS.items(): if frm in {"MERGED", "ABANDONED", "CREATED_PR"}: out.append(_violation( name, f"terminal {frm!r} has transition via {evt!r} → {to!r}" )) # STUCK's only allowed exit is operator_unstick. if frm == "STUCK" and evt != "operator_unstick": out.append(_violation( name, f"STUCK has unauthorized exit via {evt!r} → {to!r}; " f"only 'operator_unstick' is allowed" )) return out def check_tier_monotonic_non_decreasing() -> list[InvariantViolation]: """Invariant #3: when ESCALATING fires, the next tier is strictly greater than the previous. Encoded structurally: ESCALATING has only two events — escalate_next_tier_available (→ IMPLEMENTING, interpreted by master as tier+1) and escalate_max_tier_exhausted (→ ABANDONED). """ name = "tier_monotonic_non_decreasing" out: list[InvariantViolation] = [] escalating_events = events_from("ESCALATING") if set(escalating_events) != { "escalate_next_tier_available", "escalate_max_tier_exhausted", }: out.append(_violation( name, f"ESCALATING has unexpected events {escalating_events}; " f"expected {{'escalate_next_tier_available', " f"'escalate_max_tier_exhausted'}}" )) return out def check_every_pr_workflow_includes_reviewing() -> list[InvariantViolation]: """Invariant #4: every path from DISCOVERED to MERGED must pass through REVIEWING. Per Hard Rule #1: every PR receives a fresh LLM review. """ name = "every_pr_workflow_includes_reviewing" out: list[InvariantViolation] = [] # Check: no transition lands in MERGED from anything but MERGING. for (frm, evt), to in TRANSITIONS.items(): if to == "MERGED" and frm != "MERGING": out.append(_violation( name, f"MERGED reachable from {frm!r} via {evt!r} (not MERGING)" )) # Check: MERGING is reachable only via REVIEWING. for (frm, evt), to in TRANSITIONS.items(): if to == "MERGING" and frm != "REVIEWING": out.append(_violation( name, f"MERGING reachable from {frm!r} via {evt!r} (not REVIEWING)" )) return out def check_conflict_resolving_bounded() -> list[InvariantViolation]: """Invariant #5: CONFLICT_RESOLVING transitions for 1st-time resolution go to IMPLEMENTING; 2nd same-tier resolution goes to ESCALATING; 3rd-or-later goes to STUCK. Structurally enforced via exactly these named events. """ name = "conflict_resolving_bounded" out: list[InvariantViolation] = [] expected_events = { "conflict_resolved_first": "IMPLEMENTING", "conflict_resolved_second_same_tier": "ESCALATING", "conflict_repeated_three_plus": "STUCK", "conflict_irreconcilable": "STUCK", "pickup_exhausted": "STUCK", } actual = { evt: to for (frm, evt), to in TRANSITIONS.items() if frm == "CONFLICT_RESOLVING" } for evt, expected_to in expected_events.items(): got_to = actual.get(evt) if got_to != expected_to: out.append(_violation( name, f"CONFLICT_RESOLVING + {evt!r}: expected → {expected_to!r}; " f"got → {got_to!r}" )) unexpected = set(actual) - set(expected_events) if unexpected: out.append(_violation( name, f"CONFLICT_RESOLVING has unexpected event(s) {sorted(unexpected)}" )) return out def check_escalation_deterministic() -> list[InvariantViolation]: """Invariant #6: ESCALATING has exactly two transitions, both deterministic. Combined with tier-monotonic-non-decreasing, this means the escalation policy is static (min(tier+1, MAX_TIER)). """ name = "escalation_deterministic" out: list[InvariantViolation] = [] actual = { evt: to for (frm, evt), to in TRANSITIONS.items() if frm == "ESCALATING" } if actual != { "escalate_next_tier_available": "IMPLEMENTING", "escalate_max_tier_exhausted": "ABANDONED", }: out.append(_violation( name, f"ESCALATING transitions are non-deterministic: {actual}" )) return out # Registry of all v1 load-bearing invariants. Tests iterate this. LOAD_BEARING_INVARIANTS: dict[str, callable] = { # type: ignore[type-arg] "no_path_implementing_to_reviewing_skips_ci": check_no_path_implementing_to_reviewing_skips_ci, "terminal_states_have_no_exits": check_terminal_states_have_no_exits, "tier_monotonic_non_decreasing": check_tier_monotonic_non_decreasing, "every_pr_workflow_includes_reviewing": check_every_pr_workflow_includes_reviewing, "conflict_resolving_bounded": check_conflict_resolving_bounded, "escalation_deterministic": check_escalation_deterministic, } def check_all_invariants() -> list[InvariantViolation]: """Run every load-bearing invariant; return concatenated violations.""" out: list[InvariantViolation] = [] for check in LOAD_BEARING_INVARIANTS.values(): out.extend(check()) return out __all__ = [ "EVENTS", "Event", "IllegalTransitionError", "InvariantViolation", "KNOWN_STATES", "LOAD_BEARING_INVARIANTS", "NON_TERMINAL_STATES", "TERMINAL_STATES", "TRANSITIONS", "apply_event", "check_all_invariants", "events_from", "reachable_from", ]