Files
cleveragents-core/tools/controller/master/merging.py
T
drew 94821d2702 feat(controller): Phase 1d-3c-3 — MERGING handler (6-response-shape table)
The master's per-tick handler for workflows in MERGING. Per plan v6+v9
non-blocking retry: state stays MERGING across ticks, retry counter +
backoff tracked in workflows.merging_retry_count and
merging_retry_next_attempt_at.

tools/controller/master/merging.py:

- MergeResponse: normalized {status_code, pr_state, error_message}
  the callback returns. status_code drives the 6-response table:
    200          → MERGED
    409          → IMPLEMENTING(tier_last_succeeded) with reason=
                    'post-approval-base-conflict'
    403          → STUCK ('branch-protection')
    422          → AWAITING_CI ('ci-required-status-missing')
    5xx (any)    → stay MERGING; bump retry_count + schedule
                    next_attempt_at = now + 2^retry_count seconds
                    (capped at 60s); STUCK at retry_count >= 5
    404 + pr_state='merged'  → MERGED ('externally-merged')
    404 + pr_state='closed'  → ABANDONED ('externally-closed')
    404 + no pr_state        → ABANDONED (conservative default)

- run_merging_tick(engine, merge, owner, repo) — sweeps workflows
  WHERE current_state='MERGING' AND kind='pr' AND owner+repo match
  AND (next_attempt_at IS NULL OR next_attempt_at <= now). Per row:
  call merge → map response → transition (or schedule retry) + emit
  controller_events row.

- Callback failure (callback raises) wrapped as a synthetic 503 so
  the retry logic kicks in cleanly.

- Counter resets on non-retry responses (409 / 422) — keeps backoff
  fresh for future retries.

- MAX_MERGE_RETRIES = 5; MAX_BACKOFF_S = 60.0.

14 new tests across 7 classes:
- 200 happy path + event row
- 409 → IMPLEMENTING(tier_last_succeeded)
- 403 → STUCK
- 422 → AWAITING_CI + retry counter reset
- 404 paths (merged + closed + no-state default)
- 5xx retry (counter bumped + backoff scheduled +
  in-window-skipped + max-retries-STUCK + callback-raises-as-5xx)
- Owner/repo filter (other repo's MERGING untouched)
- kind='pr' filter (issues never processed even if mis-seeded)

Total: 343 controller tests; full auto_agents suite 2705 pass.
2026-05-18 13:56:26 -04:00

357 lines
12 KiB
Python

"""MERGING handler — calls Forgejo's merge endpoint + maps the
6-response-shape table to state machine transitions.
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:
200 success → MERGED
409 conflict → IMPLEMENTING(tier_last_succeeded) 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)
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.
MergeCallback = Callable[[str, str, int], MergeResponse]
@dataclass
class MergingHandlerReport:
"""Per-tick summary of the MERGING handler."""
workflows_processed: int = 0
workflows_merged: int = 0
workflows_back_to_implementing: 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 workflow_id, entity_number, tier_last_succeeded, "
" merging_retry_count, merging_retry_next_attempt_at "
" FROM workflows "
" WHERE current_state = 'MERGING' "
" AND owner = :owner AND repo = :repo "
" AND kind = 'pr' "
" AND (merging_retry_next_attempt_at IS NULL "
" OR 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":
report.workflows_back_to_implementing += 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)."""
try:
resp = merge(owner, repo, row.entity_number)
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:
_transition(
session, row.workflow_id, "MERGING", "IMPLEMENTING", now,
event="merge_base_conflict",
tier=row.tier_last_succeeded,
payload={
"reason": "post-approval-base-conflict",
"http_status": status,
},
)
# Reset merging retry counter for next time.
_reset_merging_retries(session, row.workflow_id)
return {"kind": "implementing", "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,
},
)
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},
)
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,
},
)
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,
},
)
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."""
if tier is not None:
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"
),
{"to": to_state, "tier": tier, "now": now, "wf_id": workflow_id},
)
else:
session.execute(
text(
"UPDATE workflows SET "
" current_state = :to, "
" last_transition_at = :now, entered_state_at = :now "
"WHERE workflow_id = :wf_id"
),
{"to": to_state, "now": now, "wf_id": workflow_id},
)
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",
]