Files
cleveragents-core/tools/_controller_db_bridge.py
T
drew 0bc734c020 style: ruff format the controller-state-machine branch (288 files)
Applies `ruff format` to the accumulated formatting debt on this branch.
Formatting-only — no behavioral changes. Required for CI/lint's format
gate (`nox -s format -- --check`), which the branch was failing on 288
tracked files that drifted from ruff's canonical style.

In-progress WIP files are intentionally excluded so this commit stays a
clean formatting-only diff.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 00:09:17 -04:00

380 lines
14 KiB
Python

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