94821d2702
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.
357 lines
12 KiB
Python
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",
|
|
]
|