"""Bridge between the legacy ``merge_drive.py`` orchestrator and the new state-machine controller's database. Per T5-7 the controller now writes workflows to the ``APPROVED`` state on reviewer-approve and stops touching them. A singleton merge process (``merge_drive.py``) is responsible for picking those workflows up and calling the Forgejo merge endpoint. This module is the contract between the two: - :func:`list_approved_workflows` — what to merge - :func:`transition_approved_to_merging` — claim a workflow before the Forgejo merge POST so a concurrent run-13-style ghost can't double-merge it - :func:`transition_merge_outcome` — emit the matching state-machine event (``merge_ok`` / ``merge_base_conflict`` / ``merge_retry_exhausted`` / etc.) based on the Forgejo response. The controller's state machine picks back up from there: per T5-10, a ``merge_base_conflict`` bounces the workflow directly to CONFLICT_RESOLVING, where impl/review masters run the ``conflict_resolver`` role (LLM) and eventually return the workflow to APPROVED for another merge attempt. Connection is optional. If ``CLEVERAGENTS_DB_URL`` isn't set, ``build_optional_engine`` returns ``None`` and ``merge_drive`` falls back to its legacy Forgejo-label-based discovery — i.e., the bridge is *additive*, never *replacing*. """ from __future__ import annotations import json import logging import os from dataclasses import dataclass from datetime import datetime, timezone from typing import Any from sqlalchemy import create_engine, text from sqlalchemy.engine import Engine from sqlalchemy.orm import Session, sessionmaker logger = logging.getLogger(__name__) # ─── connection ──────────────────────────────────────────────────────── def build_optional_engine( db_url: str | None = None, ) -> Engine | None: """Build a SQLAlchemy engine from ``db_url`` or the ``CLEVERAGENTS_DB_URL`` env var. Returns ``None`` when no URL is configured — callers fall back to label-based candidate discovery in that case (the bridge is additive). """ url = db_url or os.environ.get("CLEVERAGENTS_DB_URL") if not url: return None try: engine = create_engine(url, future=True) except Exception as exc: logger.warning( "controller-db-bridge: failed to build engine for %s: %s; " "merge_drive will run in legacy (label-only) mode", url, exc, ) return None return engine def _session_maker(engine: Engine) -> sessionmaker: return sessionmaker(bind=engine, expire_on_commit=False, future=True) # ─── candidate discovery ─────────────────────────────────────────────── @dataclass(frozen=True) class ApprovedWorkflow: """One workflow that's ready for the merge master to process. The merge master uses ``entity_number`` as the Forgejo PR number to drive its existing logic (fetch PR, rebase, call merge endpoint). ``workflow_id`` is used to emit state-machine events back to the controller DB once the merge resolves. """ workflow_id: int entity_number: int # Forgejo PR number owner: str repo: str current_tier: int | None entered_state_at: str # ISO datetime; useful for FIFO ranking last_transition_at: str def _list_workflows_in_state( engine: Engine, owner: str, repo: str, state: str, ) -> list[ApprovedWorkflow]: """List ``kind='pr'`` workflows in ``state`` for the given repo, FIFO-ordered by ``entered_state_at``.""" Maker = _session_maker(engine) with Maker() as s: # type: Session rows = s.execute( text( "SELECT workflow_id, entity_number, owner, repo, " " current_tier, entered_state_at, last_transition_at " " FROM workflows " " WHERE owner = :owner " " AND repo = :repo " " AND kind = 'pr' " " AND current_state = :state " " ORDER BY entered_state_at ASC" # FIFO ), {"owner": owner, "repo": repo, "state": state}, ).all() return [ ApprovedWorkflow( workflow_id=r.workflow_id, entity_number=r.entity_number, owner=r.owner, repo=r.repo, current_tier=r.current_tier, entered_state_at=str(r.entered_state_at) if r.entered_state_at else "", last_transition_at=str(r.last_transition_at) if r.last_transition_at else "", ) for r in rows ] def list_approved_workflows( engine: Engine, owner: str, repo: str, ) -> list[ApprovedWorkflow]: """List workflows in ``APPROVED`` state for the given Forgejo repo. Only ``kind='pr'`` workflows are returned — issue workflows that have spawned a PR transition through CREATED_PR, not APPROVED. """ return _list_workflows_in_state(engine, owner, repo, "APPROVED") def list_merging_workflows( engine: Engine, owner: str, repo: str, ) -> list[ApprovedWorkflow]: """List workflows stuck in ``MERGING`` for the given Forgejo repo. merge_drive is a singleton and ``run_one_cycle`` is serial, so at the start of a fresh cycle there is no in-flight merge — every MERGING row is the residue of a previous cycle that died between the APPROVED→MERGING claim and the outcome-event emit. The controller has no MERGING handler to reap them, so merge_drive reclaims them via :data:`EVENT_MERGE_INTERRUPTED` (MERGING→APPROVED). """ return _list_workflows_in_state(engine, owner, repo, "MERGING") # ─── state-machine transitions ───────────────────────────────────────── # Event names map directly to the state machine's TRANSITIONS table # in ``tools/controller/state_machine.py``. Keep this in sync if # the events change there. EVENT_MERGE_START = "merge_start" # APPROVED → MERGING EVENT_MERGE_OK = "merge_ok" # MERGING → MERGED EVENT_MERGE_BASE_CONFLICT = "merge_base_conflict" # MERGING → CONFLICT_RESOLVING EVENT_MERGE_CI_REQUIRED_MISSING = "merge_ci_required_missing" # MERGING → AWAITING_CI EVENT_MERGE_BRANCH_PROTECTION_BLOCKED = ( "merge_branch_protection_blocked" # MERGING → STUCK ) EVENT_MERGE_RETRY_EXHAUSTED = "merge_retry_exhausted" # MERGING → STUCK EVENT_MERGE_EXTERNAL_ACTION = ( "merge_external_action" # MERGING → MERGED (reconciliation refines) ) EVENT_MERGE_INTERRUPTED = ( "merge_interrupted" # MERGING → APPROVED (graceful-shutdown re-pickup) ) # Map event → expected (from_state, to_state). Used to keep the # bridge's writes aligned with the canonical state machine without # importing it (we deliberately don't depend on tools.controller.* # at runtime; merge_drive should still work even if controller code # isn't on the path). _EVENT_TRANSITION_MAP: dict[str, tuple[str, str]] = { EVENT_MERGE_START: ("APPROVED", "MERGING"), EVENT_MERGE_OK: ("MERGING", "MERGED"), # T5-10: base conflict routes directly to CONFLICT_RESOLVING so # the controller's conflict_resolver role (LLM) handles it. Pre- # T5-10 this went to IMPLEMENTING, which burned a wasted impl # attempt just to re-discover the conflict. EVENT_MERGE_BASE_CONFLICT: ("MERGING", "CONFLICT_RESOLVING"), EVENT_MERGE_CI_REQUIRED_MISSING: ("MERGING", "AWAITING_CI"), EVENT_MERGE_BRANCH_PROTECTION_BLOCKED: ("MERGING", "STUCK"), EVENT_MERGE_RETRY_EXHAUSTED: ("MERGING", "STUCK"), EVENT_MERGE_EXTERNAL_ACTION: ("MERGING", "MERGED"), # T5-7 follow-up: a graceful shutdown that caught the workflow # mid-merge returns it to APPROVED so the next merge run re-picks # it up cleanly, rather than leaving it stranded in MERGING (the # controller no longer has a MERGING handler — merge_drive owns it). EVENT_MERGE_INTERRUPTED: ("MERGING", "APPROVED"), } def _now_iso() -> str: return datetime.now(timezone.utc).isoformat(sep=" ") def transition_approved_to_merging( engine: Engine, workflow_id: int, *, head_sha: str | None = None, ) -> bool: """Atomically transition a workflow from APPROVED → MERGING. Returns ``True`` iff the transition was applied. ``False`` means the workflow was no longer in APPROVED state (race with another merge master, operator unstick, etc.) — caller should drop this candidate and pick a different one. Writes a ``transition`` event with event_type=``merge_start`` so the controller's existing tick code path stays consistent with reviewer / implementer transitions. """ return _apply_transition_guarded( engine, workflow_id, EVENT_MERGE_START, extra_payload={"head_sha": head_sha} if head_sha else None, ) def transition_merge_outcome( engine: Engine, workflow_id: int, event_name: str, *, extra_payload: dict[str, Any] | None = None, ) -> bool: """Emit a merge-outcome event after the Forgejo POST resolved. ``event_name`` must be one of the ``EVENT_MERGE_*`` constants. Returns ``True`` iff the workflow was actually in the expected ``from_state`` (MERGING in every current case). False means a concurrent process or reconciliation moved the workflow out from under us; caller should log + move on. """ if event_name == EVENT_MERGE_START: raise ValueError( "transition_merge_outcome should not be called with " "EVENT_MERGE_START; use transition_approved_to_merging" ) if event_name not in _EVENT_TRANSITION_MAP: raise ValueError(f"unknown merge event {event_name!r}") return _apply_transition_guarded( engine, workflow_id, event_name, extra_payload=extra_payload, ) _RESERVED_PAYLOAD_KEYS: frozenset[str] = frozenset({"source", "reason"}) def _apply_transition_guarded( engine: Engine, workflow_id: int, event_name: str, *, extra_payload: dict[str, Any] | None = None, ) -> bool: """Atomic UPDATE workflows + INSERT controller_events. The UPDATE filters on the expected ``from_state`` so we never overwrite a state another process already advanced. Returns rowcount == 1. ``extra_payload`` keys collide-protected against :data:`_RESERVED_PAYLOAD_KEYS` (``source``, ``reason``) so a caller can't accidentally overwrite the audit markers the bridge writes. Reserved keys in ``extra_payload`` are silently prefixed with ``caller_`` (e.g., ``source`` → ``caller_source``) to preserve the caller's intent without losing the bridge-written marker. """ from_state, to_state = _EVENT_TRANSITION_MAP[event_name] now = _now_iso() payload = {"reason": event_name, "source": "merge_drive"} if extra_payload: for k, v in extra_payload.items(): if k in _RESERVED_PAYLOAD_KEYS: payload[f"caller_{k}"] = v else: payload[k] = v Maker = _session_maker(engine) with Maker() as s: # Guarded UPDATE: rowcount=1 iff workflow was in the expected # from_state. Anything else (someone moved it, deleted, etc.) # gives rowcount=0 and we return False without writing an event. res = s.execute( text( "UPDATE workflows " " SET current_state = :to_state, " " last_transition_at = :now, " " entered_state_at = :now " " WHERE workflow_id = :wf_id " " AND current_state = :from_state" ), { "wf_id": workflow_id, "from_state": from_state, "to_state": to_state, "now": now, }, ) if res.rowcount != 1: s.rollback() logger.info( "controller-db-bridge: workflow %d not in %s " "(expected for event %s); skipping transition", workflow_id, from_state, event_name, ) return False # Audit row. controller_events.forgejo_write_pending is bool # NOT NULL with no default in the SQLite schema; ditto # replay_attempts. Provide both explicitly. s.execute( text( "INSERT INTO controller_events (" " workflow_id, ts, event_type, from_state, to_state, " " payload, forgejo_write_pending, replay_attempts" ") VALUES (" " :wf_id, :ts, :event_type, :from_state, :to_state, " " :payload, 0, 0" ")" ), { "wf_id": workflow_id, "ts": now, "event_type": event_name, "from_state": from_state, "to_state": to_state, "payload": json.dumps(payload), }, ) s.commit() return True __all__ = [ "ApprovedWorkflow", "EVENT_MERGE_BASE_CONFLICT", "EVENT_MERGE_BRANCH_PROTECTION_BLOCKED", "EVENT_MERGE_CI_REQUIRED_MISSING", "EVENT_MERGE_EXTERNAL_ACTION", "EVENT_MERGE_INTERRUPTED", "EVENT_MERGE_OK", "EVENT_MERGE_RETRY_EXHAUSTED", "EVENT_MERGE_START", "build_optional_engine", "list_approved_workflows", "list_merging_workflows", "transition_approved_to_merging", "transition_merge_outcome", ]