feat(merge-verify): guarantee idempotent open code-PR before merge_pr (ORCH-082)
Close the missing invariant "by merge-verify time the branch has an open code-PR". The pipeline created a PR only on the developer path with a fresh worktree commit (launcher._ensure_pr), so a branch (e.g. after a manual main restore) could reach the deploy->done merge-verify under-gate PR-less -> merge_pr returned "no open PR" -> a FALSE HOLD (ORCH-074 incident). - merge_gate.ensure_open_pr(repo, branch) -> (status, detail): idempotent leaf-actor (never-raise). GET open PRs filtered head==branch AND base==main (identical to merge_pr/ORCH-073 FR-3 — auto docs-PR is not a code-PR) -> existed; else POST -> created; 409/422 race -> re-GET -> existed (no dup); any other error -> failed. - stage_engine._handle_merge_verify: врезка after validated_revision and BEFORE merge_pr. created|existed -> proceed; failed -> honest HOLD via new _hold_pr_create_failed (note "pr-create-failed-hold", text distinguishable from the not-merged HOLD; task stays on deploy, NO rollback). - launcher._ensure_pr delegated to ensure_open_pr (single PR-creation path, shared head==branch & base==main filter); the developer-only trigger is unchanged. - ORCH-073 protection untouched & authoritative: merge is confirmed ONLY by verify_merged_to_main (SHA-in-main) + check_main_regression. Real un-merged code still HOLDs. - Kill-switch ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED (default true); scope = merge_verify_applies (self-hosting / merge_verify_repos); non-self -> no-op; false -> ORCH-074 behaviour 1:1. No DB migration; main never push/force-push. - Append ORCH-082 marker to MAIN_REGRESSION_MARKERS (append-only convention). - conftest defaults the autocreate flag OFF (mirrors merge_verify_enabled) so unrelated deploy->done tests stay 1:1 (no network). Tests: tests/test_orch082_ensure_pr.py (TC-01..05), tests/test_orch082_merge_verify_autocreate.py (TC-06..12). Docs: README merge-verify block (ORCH-082), CHANGELOG, .env.example. Refs: ORCH-082 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1077,35 +1077,28 @@ class AgentLauncher:
|
||||
return None
|
||||
|
||||
def _ensure_pr(self, repo: str, branch: str, run_id: int):
|
||||
import httpx
|
||||
owner = settings.gitea_owner
|
||||
headers = {"Authorization": f"token {settings.gitea_token}"}
|
||||
base_url = f"{settings.gitea_url}/api/v1"
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{base_url}/repos/{owner}/{repo}/pulls",
|
||||
params={"state": "open", "head": branch},
|
||||
headers=headers, timeout=10
|
||||
)
|
||||
resp.raise_for_status()
|
||||
prs = resp.json()
|
||||
if prs:
|
||||
return prs[0]["number"]
|
||||
parts = branch.split("/")
|
||||
title = parts[-1] if parts else branch
|
||||
resp = httpx.post(
|
||||
f"{base_url}/repos/{owner}/{repo}/pulls",
|
||||
json={"title": f"feat: {title}", "head": branch, "base": "main",
|
||||
"body": f"Auto-created by orchestrator after developer run_id={run_id}"},
|
||||
headers=headers, timeout=10
|
||||
)
|
||||
resp.raise_for_status()
|
||||
pr_number = resp.json()["number"]
|
||||
logger.info(f"Created PR #{pr_number} for {branch}")
|
||||
return pr_number
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create PR for {branch}: {e}")
|
||||
return None
|
||||
"""Ensure an open code-PR exists for ``branch``; return its number or None.
|
||||
|
||||
ORCH-082 (ADR-001 Р-4): delegated to the single idempotent PR-creation actor
|
||||
``merge_gate.ensure_open_pr`` so PR creation lives in ONE place and logs the
|
||||
same created/existed/failed outcomes (G3). The CALL TRIGGER is unchanged — the
|
||||
caller (`_monitor_agent`) still invokes this ONLY on the developer path with a
|
||||
fresh worktree commit; only the implementation under the hood is shared. The
|
||||
actor uses the same ``head==branch AND base==main`` filter as ``merge_pr``, so
|
||||
the developer-created PR and the one merge-verify merges are guaranteed to be
|
||||
the same code-PR. Never raises (the actor is never-raise); ``failed`` -> None,
|
||||
preserving the previous "best-effort, return None on failure" contract.
|
||||
"""
|
||||
from .. import merge_gate
|
||||
status, detail = merge_gate.ensure_open_pr(repo, branch)
|
||||
logger.info(f"_ensure_pr({branch}, run_id={run_id}) -> {status} ({detail})")
|
||||
if status in ("created", "existed"):
|
||||
try:
|
||||
return int(detail)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
logger.error(f"Failed to ensure PR for {branch}: {detail}")
|
||||
return None
|
||||
|
||||
def _write_task_file(self, repo: str, branch: str, task_file: str, content: str):
|
||||
"""Write task file directly into the task's worktree.
|
||||
|
||||
Reference in New Issue
Block a user