Files
cleveragents-core/tools/controller/master/merging.py
T
drew 615a05b982 feat(controller): rebase-default conflict resolution with merge fallback
PR branches 177-180 commits ahead of base cannot be rebased
commit-by-commit by a single-shot resolver agent (too many conflict
stops for one session). Conflict-prep now defaults to rebase (linear
history) and falls back to a single 3-way merge when the branch is too
divergent (commit count over CONTROLLER_CONFLICT_REBASE_MAX_COMMITS,
default 60). The merge pipeline derives the track from branch shape via
a Do:rebase -> Do:merge ladder in _make_merge_pr — no stored flag.

Adds a git_rebase_continue MCP tool plus status rebase/merge-in-progress
fields so the conflict-resolver agent is fully MCP-driven and dual-mode
(mid-rebase or mid-merge). Also routes a green-CI implementer noop
straight to REVIEWING instead of a deadlock-prone AWAITING_CI round
trip. No state-machine change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 17:17:16 -04:00

551 lines
20 KiB
Python

"""MERGING handler — calls Forgejo's merge endpoint + maps the
6-response-shape table to state machine transitions.
.. deprecated:: 2026-05-19 (T5-7 + T5-10)
The controller-internal MERGING handler is RETIRED in production.
``tools/merge_drive.py`` is now the singleton merge process; it
talks to the controller DB via ``tools/_controller_db_bridge.py``
(APPROVED → MERGING claim + outcome events). The production master
no longer registers this handler (``merging_args=None`` in
``master/__main__.py``).
This module is preserved because:
- ``MergeResponse`` and ``MergeCallback`` types are used by
``master/forgejo_http.py`` and by the bridge's typing layer.
- ``run_merging_tick`` and ``MergingHandlerReport`` are still
exercised by unit tests as a reference implementation of the
response-shape → state-transition mapping. ``merge_drive.py``'s
bridge integration MUST produce the same transitions.
- The 6-response-shape mapping itself (200/409/422/403/404/5xx)
is documentation that future merge-process implementations
should consult.
The handler will be deleted once an end-to-end test of
``merge_drive.py`` covers each response shape, at which point
the reference role this module plays is met by that test.
Per plan v9 non-blocking retry: MERGING stays in MERGING across
ticks. The handler:
1. Checks if workflows.merging_retry_next_attempt_at is in the future
(if so, skip this tick).
2. Calls the merge callback (production: Forgejo POST /pulls/{n}/merge).
3. Maps the response (T5-10 update for 409):
200 success → MERGED
409 conflict → CONFLICT_RESOLVING with
reason='post-approval-base-conflict'
403 forbidden → STUCK (branch protection)
422 missing-checks → AWAITING_CI (rare race)
5xx → stay MERGING, retry_count++, backoff
STUCK at retry_count >= 5
404 → check PR state → MERGED (extern-merged)
OR ABANDONED (extern-closed)
Rebase-default merge ladder
---------------------------
The single ``status_code`` this handler maps is produced by a ladder
that lives entirely in ``master/forgejo_http._make_merge_pr`` (NOT
here — ``MergeCallback``'s signature is unchanged): the callback POSTs
``{"Do":"rebase"}`` first (linear history), and on a 409 OR a 405
(rebase merge style disabled in repo config) retries with
``{"Do":"merge"}``. A merge that ALSO 409s is returned as a 409, so
the handler's 409 branch below fires ``merge_base_conflict`` →
CONFLICT_RESOLVING for the genuinely-conflicting base. From this
module's point of view there is still exactly one ``status_code`` per
``merge`` call — the ladder is invisible.
Forgejo HTTP injected via two callbacks so tests don't need a live
instance. Production wires to existing _claim_runtime helpers.
"""
from __future__ import annotations
import json
import logging
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Any
from sqlalchemy import text
from sqlalchemy.engine import Engine
from ..db.session import session_scope
logger = logging.getLogger(__name__)
# Per plan v9: STUCK after 5 retries on transient errors.
MAX_MERGE_RETRIES = 5
# Backoff: 2^retry_count seconds, capped at 60s.
MAX_BACKOFF_S = 60.0
@dataclass
class MergeResponse:
"""Normalized Forgejo merge response shape.
The callback returns this rather than raw HTTP details so the
handler can route deterministically. Maps to plan v9's 6-response
table.
"""
status_code: int
pr_state: str | None = None # 'merged' | 'open' | 'closed' | None
error_message: str | None = None
# Forgejo merge call callback. Returns a MergeResponse summarizing
# the HTTP attempt + (for 404 paths) the PR's actual state.
#
# Signature: ``(owner, repo, pr_number, approved_at_sha)``. The
# ``approved_at_sha`` arg is the exact SHA the reviewer signed off
# on (from ``ReviewerOutputV1.approved_at_sha``); the callback
# SHOULD pass it to Forgejo's merge endpoint as ``head_commit_id``
# so a race-condition push between approval and merge produces a
# 409 instead of silently merging unapproved code. May be None if
# the controller can't locate a reviewer attempt (defensive
# fallback — Forgejo merges whatever HEAD currently is).
MergeCallback = Callable[[str, str, int, str | None], MergeResponse]
@dataclass
class MergingHandlerReport:
"""Per-tick summary of the MERGING handler."""
workflows_processed: int = 0
workflows_merged: int = 0
# T5-10: 409 (base conflict) now bounces to CONFLICT_RESOLVING
# instead of IMPLEMENTING. The old counter name is kept for back-
# compat but is always zero post-T5-10; readers should use
# ``workflows_to_conflict_resolving`` instead.
workflows_back_to_implementing: int = 0
workflows_to_conflict_resolving: int = 0
workflows_stuck: int = 0
workflows_awaiting_ci: int = 0
workflows_abandoned: int = 0
workflows_retrying: int = 0 # retry scheduled for future tick
transitions: list[dict] = field(default_factory=list)
def run_merging_tick(
engine: Engine,
*,
merge: MergeCallback,
owner: str,
repo: str,
max_retries: int = MAX_MERGE_RETRIES,
) -> MergingHandlerReport:
"""One MERGING-handling sweep.
For each workflow with current_state='MERGING' where
merging_retry_next_attempt_at is null or in the past:
1. Call the merge callback.
2. Apply the response → state transition (via direct
UPDATE because MERGING transitions don't fit the
outcome-mapper protocol).
3. Insert a controller_events row.
"""
report = MergingHandlerReport()
now = datetime.now(timezone.utc)
with session_scope(engine) as session:
rows = session.execute(
text(
"SELECT w.workflow_id, w.entity_number, "
" w.current_tier, w.tier_last_succeeded, "
" w.merging_retry_count, "
" w.merging_retry_next_attempt_at, "
# Pull approved_at_sha from the latest reviewer
# attempt's output_payload. ReviewerOutputV1.verdict='approve'
# carries the exact SHA the reviewer signed off on; the
# merge callback uses it as Forgejo's head_commit_id
# parameter so a race-condition push between approval
# and merge results in a 409 instead of silently merging
# unapproved code.
" (SELECT a.output_payload FROM workflow_attempts a "
" WHERE a.workflow_id = w.workflow_id "
" AND a.role = 'reviewer' "
" AND a.status = 'complete' "
" ORDER BY a.attempt_number DESC LIMIT 1) AS reviewer_payload "
" FROM workflows w "
" WHERE w.current_state = 'MERGING' "
" AND w.owner = :owner AND w.repo = :repo "
" AND w.kind = 'pr' "
" AND (w.merging_retry_next_attempt_at IS NULL "
" OR w.merging_retry_next_attempt_at <= :now)"
),
{"owner": owner, "repo": repo, "now": now},
).all()
for r in rows:
report.workflows_processed += 1
transition = _process_merging_row(
session,
r,
merge=merge,
owner=owner,
repo=repo,
max_retries=max_retries,
now=now,
)
report.transitions.append(transition)
kind = transition.get("kind", "unknown")
if kind == "merged":
report.workflows_merged += 1
elif kind == "implementing":
# Pre-T5-10 path; should no longer fire but kept for
# back-compat with any external callers.
report.workflows_back_to_implementing += 1
elif kind == "conflict_resolving":
report.workflows_to_conflict_resolving += 1
elif kind == "stuck":
report.workflows_stuck += 1
elif kind == "awaiting_ci":
report.workflows_awaiting_ci += 1
elif kind == "abandoned":
report.workflows_abandoned += 1
elif kind == "retry":
report.workflows_retrying += 1
return report
# ─── per-row processing ───────────────────────────────────────────────
def _process_merging_row(
session,
row,
*,
merge: MergeCallback,
owner: str,
repo: str,
max_retries: int,
now: datetime,
) -> dict:
"""Call the merge callback + apply the response. Returns a
transition record (used for telemetry + reporting)."""
# Extract approved_at_sha from the latest reviewer attempt's
# output_payload. None when no reviewer attempt is found OR when
# the payload doesn't carry the field (e.g., verdict was 'abstain'
# rather than 'approve' — that shouldn't reach MERGING, but be
# defensive). When None, the merge callback falls back to merging
# whatever HEAD currently is.
approved_at_sha: str | None = None
raw_payload = row.reviewer_payload if hasattr(row, "reviewer_payload") else None
if raw_payload:
try:
payload = (
json.loads(raw_payload) if isinstance(raw_payload, str) else raw_payload
)
if isinstance(payload, dict):
candidate = payload.get("approved_at_sha")
if isinstance(candidate, str) and candidate:
approved_at_sha = candidate
except (ValueError, TypeError):
logger.warning(
"merging: could not decode reviewer payload for PR #%s",
row.entity_number,
)
if approved_at_sha is None:
logger.warning(
"merging: PR #%s has no approved_at_sha from reviewer; "
"Forgejo will merge whatever HEAD currently is (race risk)",
row.entity_number,
)
try:
resp = merge(owner, repo, row.entity_number, approved_at_sha)
except Exception as exc: # noqa: BLE001 — treat as transient
# Wrap in a synthetic 503 so the retry logic kicks in.
logger.warning(
"merge callback raised for PR #%s: %s; treating as 5xx",
row.entity_number,
exc,
)
resp = MergeResponse(status_code=503, error_message=f"callback raised: {exc}")
status = resp.status_code
# ── 200: merged ──
if status == 200:
_transition(
session,
row.workflow_id,
"MERGING",
"MERGED",
now,
event="merge_ok",
payload={"reason": "merged", "http_status": status},
)
return {"kind": "merged", "workflow_id": row.workflow_id}
# ── 409: conflict (base advanced post-approval) ──
if status == 409:
# T5-10 (2026-05-19): route directly to CONFLICT_RESOLVING so
# the controller's conflict_resolver role (LLM) handles the
# rebase + conflict resolution. Pre-T5-10 this went to
# IMPLEMENTING, which burned a wasted implementer attempt just
# to re-discover the conflict and emit ``implementer_rebase_failed``.
# Merge processes stay 100% deterministic; LLM work bounces
# back to the controller exactly once.
_transition(
session,
row.workflow_id,
"MERGING",
"CONFLICT_RESOLVING",
now,
event="merge_base_conflict",
payload={
"reason": "post-approval-base-conflict",
"http_status": status,
"source": "merging_handler",
},
)
# Reset merging retry counter for next time.
_reset_merging_retries(session, row.workflow_id)
return {"kind": "conflict_resolving", "workflow_id": row.workflow_id}
# ── 403: branch protection ──
if status == 403:
_transition(
session,
row.workflow_id,
"MERGING",
"STUCK",
now,
event="merge_branch_protection_blocked",
payload={
"reason": "branch-protection",
"http_status": status,
"error": resp.error_message,
},
)
# E-8 fix: reset retry counter so an operator_unstick that
# routes the workflow back through MERGING doesn't inherit
# stale retry state.
_reset_merging_retries(session, row.workflow_id)
return {"kind": "stuck", "workflow_id": row.workflow_id}
# ── 422: CI required-status missing (race) ──
if status == 422:
_transition(
session,
row.workflow_id,
"MERGING",
"AWAITING_CI",
now,
event="merge_ci_required_missing",
payload={
"reason": "ci-required-status-missing",
"http_status": status,
},
)
_reset_merging_retries(session, row.workflow_id)
return {"kind": "awaiting_ci", "workflow_id": row.workflow_id}
# ── 404: PR closed externally (merged or abandoned) ──
if status == 404:
# Use the response's pr_state to determine which side.
# If the callback didn't populate it, default to ABANDONED
# (conservative — operator can re-open via controller-cli).
if resp.pr_state == "merged":
_transition(
session,
row.workflow_id,
"MERGING",
"MERGED",
now,
event="merge_external_action",
payload={"reason": "externally-merged", "http_status": status},
)
_reset_merging_retries(session, row.workflow_id)
return {"kind": "merged", "workflow_id": row.workflow_id}
_transition(
session,
row.workflow_id,
"MERGING",
"ABANDONED",
now,
event="merge_external_action",
payload={
"reason": "externally-closed-or-removed",
"http_status": status,
"pr_state": resp.pr_state,
},
)
# E-8 fix: reset retry counter on terminal transition.
_reset_merging_retries(session, row.workflow_id)
return {"kind": "abandoned", "workflow_id": row.workflow_id}
# ── 5xx / anything else: transient. Retry until exhausted ──
next_retry_count = (row.merging_retry_count or 0) + 1
if next_retry_count >= max_retries:
_transition(
session,
row.workflow_id,
"MERGING",
"STUCK",
now,
event="merge_retry_exhausted",
payload={
"reason": "merge-retry-exhausted",
"http_status": status,
"retries": next_retry_count,
"error": resp.error_message,
},
)
# E-8 fix: reset retry counter on terminal transition so
# operator_unstick doesn't inherit stale state.
_reset_merging_retries(session, row.workflow_id)
return {"kind": "stuck", "workflow_id": row.workflow_id}
backoff = min(MAX_BACKOFF_S, float(2**next_retry_count))
next_at = now + timedelta(seconds=backoff)
session.execute(
text(
"UPDATE workflows SET "
" merging_retry_count = :count, "
" merging_retry_next_attempt_at = :next_at "
"WHERE workflow_id = :wf_id"
),
{
"count": next_retry_count,
"next_at": next_at,
"wf_id": row.workflow_id,
},
)
# Record the retry attempt as a controller_event (not a transition).
session.execute(
text(
"INSERT INTO controller_events "
"(workflow_id, ts, event_type, payload, "
" forgejo_write_pending, replay_attempts) "
"VALUES (:wf_id, :ts, 'merge-retry-scheduled', :payload, 0, 0)"
),
{
"wf_id": row.workflow_id,
"ts": now,
"payload": json.dumps(
{
"http_status": status,
"retry_count": next_retry_count,
"next_attempt_at": next_at.isoformat(),
"backoff_s": backoff,
"error": resp.error_message,
}
),
},
)
return {
"kind": "retry",
"workflow_id": row.workflow_id,
"retry_count": next_retry_count,
"next_attempt_at": next_at.isoformat(),
}
# ─── helpers ──────────────────────────────────────────────────────────
def _transition(
session,
workflow_id: int,
from_state: str,
to_state: str,
now: datetime,
*,
event: str,
tier: int | None = None,
payload: dict[str, Any] | None = None,
) -> None:
"""Write a workflow state transition + controller_events row.
R-8 fix (2026-05-19): the UPDATE now filters
``WHERE current_state = :from_state`` matching the TOCTOU defense
already used in ci_status_poll + reconciliation. If another tick
advanced the workflow between the merging-handler SELECT and this
UPDATE, we skip the no-op event row to keep the audit trail
accurate.
"""
if tier is not None:
result = session.execute(
text(
"UPDATE workflows SET "
" current_state = :to, current_tier = :tier, "
" last_transition_at = :now, entered_state_at = :now "
"WHERE workflow_id = :wf_id "
" AND current_state = :from"
),
{
"to": to_state,
"tier": tier,
"now": now,
"wf_id": workflow_id,
"from": from_state,
},
)
else:
result = session.execute(
text(
"UPDATE workflows SET "
" current_state = :to, "
" last_transition_at = :now, entered_state_at = :now "
"WHERE workflow_id = :wf_id "
" AND current_state = :from"
),
{
"to": to_state,
"now": now,
"wf_id": workflow_id,
"from": from_state,
},
)
if (result.rowcount or 0) == 0:
logger.info(
"merging: workflow %s no longer in %s (race lost); skipping event row",
workflow_id,
from_state,
)
return
session.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, 'transition', :from_state, :to_state, "
" :payload, 0, 0)"
),
{
"wf_id": workflow_id,
"ts": now,
"from_state": from_state,
"to_state": to_state,
"payload": json.dumps({**(payload or {}), "event": event}),
},
)
def _reset_merging_retries(session, workflow_id: int) -> None:
"""Clear the merging retry counter + backoff after a non-retry
response (success / conflict / 422)."""
session.execute(
text(
"UPDATE workflows SET "
" merging_retry_count = 0, "
" merging_retry_next_attempt_at = NULL "
"WHERE workflow_id = :wf_id"
),
{"wf_id": workflow_id},
)
__all__ = [
"MAX_BACKOFF_S",
"MAX_MERGE_RETRIES",
"MergeCallback",
"MergeResponse",
"MergingHandlerReport",
"run_merging_tick",
]