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