Files
cleveragents-core/tools/controller/master/tick.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

300 lines
10 KiB
Python

"""Master tick handler — advances state machine for completed attempts.
One tick:
1. Query workflow_attempts where status='complete' AND workflow has
NOT yet processed this attempt (we track via a "consumed_at"
column? No — for v1 we use a simpler heuristic: an attempt is
"unprocessed" if its workflow_attempts.finished_at is more recent
than the workflow's last_transition_at).
2. For each, look up the workflow + use ``map_outcome_to_event`` to
pick a state machine event.
3. Apply via ``apply_event``; update workflow.current_state;
insert controller_events row.
What's deliberately deferred to Phase 1d-3:
- Discovery (Forgejo poll for new PRs/issues)
- Forgejo writes (status comments, labels, merges)
- Per-workflow "what to enqueue next" logic (after a state
transition, the master schedules the next attempt; for v1
this commit only handles the transition itself)
- Reconciliation tick (DB↔Forgejo sync)
- MERGING state's Forgejo merge call
This tick is composable: the master's main loop will call this tick,
then the reaper, then the pickup guard, then sleep + repeat.
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass, field
from datetime import datetime, timezone
from sqlalchemy import text
from sqlalchemy.engine import Engine
from ..db.session import session_scope
from ..state_machine import (
IllegalTransitionError,
KNOWN_STATES,
TERMINAL_STATES,
apply_event,
)
from .outcomes import EventMapResult, map_outcome_to_event
logger = logging.getLogger(__name__)
@dataclass
class TickReport:
"""Per-tick summary."""
attempts_processed: int = 0
transitions_applied: int = 0
unmapped_attempts: list[tuple[int, str]] = field(default_factory=list)
# (attempt_id, reason) — operator-visible via tail-events
transitions_log: list[dict] = field(default_factory=list)
# Each entry: {workflow_id, from_state, to_state, event, attempt_id}
def run_tick(engine: Engine) -> TickReport:
"""One master tick: advance state for any complete attempts not
yet processed."""
report = TickReport()
now = datetime.now(timezone.utc)
with session_scope(engine) as session:
rows = session.execute(
text(
"SELECT a.attempt_id, a.workflow_id, a.role, a.tier, "
" a.output_payload, a.status, "
" a.head_sha_before, a.head_sha_after, "
" a.pickup_count, "
" w.current_state, w.current_tier, w.last_transition_at "
" FROM workflow_attempts a "
" JOIN workflows w ON w.workflow_id = a.workflow_id "
" WHERE a.status IN ('complete', 'failed') "
" AND a.finished_at IS NOT NULL "
" AND a.finished_at > w.last_transition_at "
" AND w.current_state NOT IN ('MERGED', 'ABANDONED', 'STUCK', 'CREATED_PR') "
" ORDER BY a.finished_at ASC"
)
).all()
for r in rows:
report.attempts_processed += 1
transitioned = _process_attempt(session, r, now)
if transitioned is None:
# No mappable event; log + skip.
continue
report.transitions_applied += 1
report.transitions_log.append(transitioned)
return report
# ─── per-row processing ───────────────────────────────────────────────
def _process_attempt(session, row, now: datetime) -> dict | None:
"""Apply one attempt's outcome to its workflow's state machine.
Returns the transition record on success, or None if no
transition was applied (unmapped outcome, illegal event, etc.).
"""
# 1. Validate the workflow's current state.
if row.current_state not in KNOWN_STATES:
logger.error(
"workflow %s has unknown current_state %r; transitioning to STUCK",
row.workflow_id, row.current_state,
)
_transition_to_stuck(
session, row.workflow_id, row.current_state, now,
reason="unknown-state",
attempt_id=row.attempt_id,
)
return {
"workflow_id": row.workflow_id,
"from_state": row.current_state,
"to_state": "STUCK",
"event": "(synthetic) unknown-state",
"attempt_id": row.attempt_id,
}
# 2. Decode output_payload + compute auxiliary context.
output_payload = _decode_output_payload(row.output_payload)
head_sha_advanced = (
bool(row.head_sha_after)
and bool(row.head_sha_before)
and row.head_sha_after != row.head_sha_before
)
# 3. Map outcome → event.
# attempts_remaining_at_tier + conflict_count_at_current_tier:
# for v1 we use a simple computation (count of attempts at this
# role+tier vs max_attempts on the workflow). The full policy
# lives in Phase 1d-3; v1 ships with a "≥1 remaining always"
# default which keeps the state machine in a simple loop until
# max_attempts kicks in via the pickup guard.
attempts_remaining_at_tier = 1
conflict_count_at_current_tier = (
_count_conflict_resolver_attempts(
session, row.workflow_id, row.current_tier,
)
)
mapped: EventMapResult = map_outcome_to_event(
role=row.role,
current_state=row.current_state,
output_payload=output_payload,
status=row.status,
head_sha_advanced=head_sha_advanced,
attempts_remaining_at_tier=attempts_remaining_at_tier,
conflict_count_at_current_tier=conflict_count_at_current_tier,
)
if mapped.event_name is None:
# Bump the workflow's last_transition_at so we don't re-process
# the same attempt every tick.
session.execute(
text(
"UPDATE workflows SET last_transition_at = :now "
"WHERE workflow_id = :wf_id"
),
{"now": now, "wf_id": row.workflow_id},
)
logger.debug(
"attempt_id=%s no mappable event: %s",
row.attempt_id, mapped.reason,
)
return None
# 4. Apply the event.
try:
to_state = apply_event(row.current_state, mapped.event_name)
except IllegalTransitionError as exc:
logger.warning(
"illegal transition for workflow %s: %s (event=%s); "
"transitioning to STUCK",
row.workflow_id, exc, mapped.event_name,
)
_transition_to_stuck(
session, row.workflow_id, row.current_state, now,
reason=f"illegal-event: {mapped.event_name}",
attempt_id=row.attempt_id,
)
return {
"workflow_id": row.workflow_id,
"from_state": row.current_state,
"to_state": "STUCK",
"event": mapped.event_name,
"attempt_id": row.attempt_id,
}
# 5. Commit the transition.
session.execute(
text(
"UPDATE workflows SET "
" current_state = :to_state, "
" last_transition_at = :now, "
" entered_state_at = :now "
"WHERE workflow_id = :wf_id"
),
{"to_state": to_state, "now": now, "wf_id": row.workflow_id},
)
session.execute(
text(
"INSERT INTO controller_events "
"(workflow_id, ts, event_type, from_state, to_state, "
" attempt_id, payload, forgejo_write_pending, replay_attempts) "
"VALUES (:wf_id, :ts, 'transition', :from_state, :to_state, "
" :aid, :payload, 0, 0)"
),
{
"wf_id": row.workflow_id,
"ts": now,
"from_state": row.current_state,
"to_state": to_state,
"aid": row.attempt_id,
"payload": json.dumps({
"event": mapped.event_name,
"reason": mapped.reason,
"role": row.role,
}),
},
)
return {
"workflow_id": row.workflow_id,
"from_state": row.current_state,
"to_state": to_state,
"event": mapped.event_name,
"attempt_id": row.attempt_id,
}
# ─── helpers ──────────────────────────────────────────────────────────
def _decode_output_payload(raw) -> dict | None:
"""SQLite returns JSON columns as strings (when bound via text());
Postgres returns dicts. Normalize."""
if raw is None:
return None
if isinstance(raw, str):
try:
return json.loads(raw)
except json.JSONDecodeError:
return None
return raw
def _count_conflict_resolver_attempts(
session, workflow_id: int, current_tier: int | None,
) -> int:
"""Count CONFLICT_RESOLVING attempts at the current tier."""
if current_tier is None:
return 0
row = session.execute(
text(
"SELECT COUNT(*) AS n FROM workflow_attempts "
"WHERE workflow_id = :wf_id AND role = 'conflict_resolver' "
" AND tier = :tier"
),
{"wf_id": workflow_id, "tier": current_tier},
).first()
return row.n if row else 0
def _transition_to_stuck(
session, workflow_id: int, from_state: str, now: datetime, *,
reason: str, attempt_id: int | None,
) -> None:
session.execute(
text(
"UPDATE workflows SET "
" current_state = 'STUCK', "
" last_transition_at = :now, "
" entered_state_at = :now "
"WHERE workflow_id = :wf_id"
),
{"now": now, "wf_id": workflow_id},
)
session.execute(
text(
"INSERT INTO controller_events "
"(workflow_id, ts, event_type, from_state, to_state, "
" attempt_id, payload, forgejo_write_pending, replay_attempts) "
"VALUES (:wf_id, :ts, 'transition', :from_state, 'STUCK', "
" :aid, :payload, 0, 0)"
),
{
"wf_id": workflow_id,
"ts": now,
"from_state": from_state,
"aid": attempt_id,
"payload": json.dumps({"reason": reason}),
},
)
__all__ = ["TickReport", "run_tick"]