0bc734c020
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>
380 lines
14 KiB
Python
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",
|
|
]
|