Files
cleveragents-core/tools/controller/state_machine.py
T
drew 976817fa22 feat(controller): Phase 1d-1 — state machine + reaper + pickup guard
The deterministic spine of the master controller. State machine is
pure data with 6 load-bearing invariants enforced via property tests.
Reaper resets stale-heartbeat workflow_attempts to pending. Pickup
guard transitions workflows to STUCK when an attempt has been
re-pended too many times without success.

tools/controller/state_machine.py:

- KNOWN_STATES = 12; TERMINAL_STATES = {MERGED, ABANDONED, STUCK,
  CREATED_PR}. STUCK's only allowed exit is the operator-driven
  operator_unstick event (back to DISCOVERED).
- 32 TRANSITIONS entries covering DISCOVERED → ANALYZING →
  IMPLEMENTING ↔ AWAITING_CI / CONFLICT_RESOLVING / ESCALATING →
  REVIEWING → MERGING → MERGED. Plus pickup_exhausted exits from
  IMPLEMENTING/CONFLICT_RESOLVING/REVIEWING.
- 27 named events with descriptions. apply_event() lookup raises
  IllegalTransitionError (lists legal events from current state)
  or ValueError on unknown state (per v6 unknown-state guard).
- 6 LOAD-BEARING invariants for v1 (per v9 simplification):
  1. no_path_implementing_to_reviewing_skips_ci (Hard Rule #1
     constructional fix for the no-mans-land race)
  2. terminal_states_have_no_exits (only STUCK→operator_unstick OK)
  3. tier_monotonic_non_decreasing
  4. every_pr_workflow_includes_reviewing
  5. conflict_resolving_bounded (1st→IMPLEMENTING, 2nd→ESCALATING,
     3rd→STUCK; structurally encoded)
  6. escalation_deterministic
- reachable_from() honors cycles (DISCOVERED ∈ reachable(DISCOVERED)
  via STUCK→operator_unstick path; AWAITING_CI self-loops via
  ci_flake_retry).

tools/controller/reaper.py:

- reap_stale_attempts(): SELECT in_progress attempts whose
  lock_heartbeat_at + lock_ttl_seconds < NOW (per-row TTL respects
  per-role differences — estimator 180s, reviewer 720s, tier-2
  implementer 2160s). UPDATEs status='pending', clears lock columns,
  preserves pickup_count (the pickup guard handles that). Inserts
  controller_events row with reason='lock-ttl-expired' per reap.
- Dialect-portable: Postgres uses interval arithmetic; SQLite uses
  julianday(). Same logic either way.

tools/controller/pickup_guard.py:

- transition_exhausted_to_stuck(): finds attempts with status='pending'
  AND pickup_count >= MAX_PICKUPS (default 3 per v6 blocker fix)
  AND workflow not already terminal. Transitions workflow → STUCK,
  marks attempt as 'reaped', inserts controller_events with
  reason='attempt-pickup-exhausted' + pickup_count + max_pickups.

45 new tests:
- state_machine: basic shape (states partition, every transition uses
  known states + defined events), apply_event success/error paths,
  events_from + reachable_from helpers (including cycle awareness),
  per-invariant zero-violations against the live table, per-invariant
  monkeypatch-violations to prove the checks catch the bug class they
  claim to, parametrised sanity check "every non-terminal can reach
  some terminal".
- reaper: empty DB / fresh heartbeat / stale heartbeat reaped /
  per-row TTL respected / event row created / only-in-progress
  reaped / multiple stale attempts.
- pickup guard: empty DB / below limit / at limit / in-progress not
  checked / terminal workflow skipped / event payload content /
  default max_pickups matches v6.

Total: 229 controller tests; full auto_agents suite 2591 pass.
2026-05-18 13:34:28 -04:00

566 lines
20 KiB
Python

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