Merge pull request 'fix(merge_gate): retry transient Gitea merge errors (405/5xx) + already-in-main guard (ORCH-093)' (#104) from feature/ORCH-093-bug-merge-gitea-405-5xx-hold-p into main
Some checks failed
CI / test (push) Has been cancelled

This commit was merged in pull request #104.
This commit is contained in:
2026-06-09 22:51:43 +03:00
23 changed files with 1733 additions and 21 deletions

View File

@@ -166,6 +166,22 @@ ORCH_MERGE_PR_TIMEOUT_S=60
ORCH_MERGE_VERIFY_TIMEOUT_S=60
ORCH_REGRESSION_GUARD_ENABLED=true
ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED=true
# ORCH-093: deterministic merge-actor retry of TRANSIENT Gitea merge errors. merge_pr
# wraps ONLY the mutating POST /pulls/{n}/merge in a bounded exponential-backoff
# retry-loop on transient outcomes (405 "try again later" / 408 / 5xx / network /
# timeout, and 409|422 while the PR is still mergeable); terminal outcomes
# (403/404/real conflict) -> fast honest False (the ORCH-071/081 HOLD backstop is
# unchanged). Fixes the ORCH-063 false HOLD + manual re-merge. The already-in-main
# guard (no commits beyond origin/main -> no garbage PR) is always-on under
# MERGE_VERIFY_AUTOCREATE_PR_ENABLED (no separate flag).
# MERGE_RETRY_ENABLED -> kill-switch; false -> exactly one POST (one-shot, prior behaviour).
# MERGE_RETRY_MAX_ATTEMPTS -> max POST attempts on a transient outcome.
# MERGE_RETRY_BACKOFF_BASE_S -> exponential backoff base seconds (sleep = base*2^(i-1)).
# MERGE_RETRY_BACKOFF_MAX_S -> per-sleep backoff ceiling seconds (bounds total wait).
ORCH_MERGE_RETRY_ENABLED=true
ORCH_MERGE_RETRY_MAX_ATTEMPTS=3
ORCH_MERGE_RETRY_BACKOFF_BASE_S=2
ORCH_MERGE_RETRY_BACKOFF_MAX_S=5
# ORCH-036: executable self-deploy of the `deploy` stage. For the self-hosting repo
# (orchestrator) the stage REALLY restarts prod (8500) via a detached host hook;
# deploy_status: SUCCESS means proven health-ok, not an LLM declaration. Three

View File

@@ -1,4 +1,4 @@
Work item: ORCH-091
Work item: ORCH-093
Repo: orchestrator
Branch: feature/ORCH-091-bug-to-analyse-stage-deploy-st
Branch: feature/ORCH-093-bug-merge-gitea-405-5xx-hold-p
Stage: development

View File

@@ -3,6 +3,12 @@
Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу.
## [Unreleased]
- **Merge-актор ретраит транзиентные ошибки Gitea (405/5xx) + гард «ветка уже в `main`»** (ORCH-093, `fix`): две точечные доработки детерминированного merge-актора `src/merge_gate.py`, чинящие инцидент **ORCH-063**: self-deploy прошёл, staging OK, PR был `open`+`mergeable`, но `POST /pulls/{n}/merge` вернул `HTTP 405 "Please try again later"` (Gitea пересчитывал `mergeable` сразу после пуша) → one-shot `merge_pr` мгновенно вернул `False` → корректная защита ORCH-071/081 удержала задачу на `deploy` + потребовала ручной домерж; повторный прогон финализатора плодил мусорный пустой PR. **Аддитивно, never-raise, под существующими kill-switch'ами:** `STAGE_TRANSITIONS` / `QG_CHECKS` / схема БД — **не тронуты**; INV-4 (мерж только через Gitea PR-merge API, никогда `push`/`force-push` в `main`) сохранён 1:1.
- **Retry-loop транзиента (FR-1/FR-2, AC-1/AC-2/AC-3, D1/D2):** `merge_pr` оборачивает **только** мутирующий `POST …/merge` в ограниченный retry-loop с экспоненциальным backoff (`min(base*2^(i-1), max)`, дефолты 2/5 с → суммарный сон `(N-1)*max ≤ 10 с`, monitor-поток не подвешивается). Классификатор `_classify_merge_response`: **транзиент** (ретрай) — `405`/`408`/любой `5xx`/`httpx`-таймаут/сетевая ошибка, **и** `409`/`422` когда PR всё ещё mergeable; **терминал** (быстрый честный `False`, защита ORCH-071/081 как прежде) — `403`/`404`/реальный конфликт (`409`/`422` при `mergeable==False`). Неоднозначный `409`/`422` разрешается доп. `GET /pulls/{index}``mergeable`; дефолт-политика `mergeable==None`/недоступно → **транзиент** (fail-OPEN-в-ретрай: икота Gitea — наблюдаемый кейс, бюджет конечен, backstop сохранён). Каждая попытка логируется `attempt i/N` (образец `check_ci_green`).
- **Гард already-in-main (FR-3/FR-4, AC-4, D3/D4):** новый leaf `_branch_fully_in_main` (`git merge-base --is-ancestor HEAD origin/main` в per-branch worktree) вызывается в `ensure_open_pr` **между** «открытый code-PR не найден» и `POST …/pulls`: ветка целиком в `main` (нет коммитов `origin/main..HEAD`) → новый исход `"already-in-main"` **без создания PR** (нет мусорного пустого PR на уже влитой ветке). git-ошибка/ambiguous (`None`) → **fail-OPEN** (деградация на create-путь, НЕ ложный no-op). В `stage_engine._handle_merge_verify` исход `already-in-main` **пропускает** `merge_pr` (мержить нечего) и отдаёт авторитетному SHA-in-main (`verify_merged_to_main`) довести до `done`; это НЕ HOLD. SHA-in-main остаётся единственным доказательством мержа (ADR-0014).
- **Конфиг/откат (FR-5, AC-5/AC-7, D5):** новые поля `src/config.py` `merge_retry_enabled` (kill-switch; `False` → ровно один POST = байт-в-байт прежнее one-shot, нулевая регрессия) / `merge_retry_max_attempts` (3) / `merge_retry_backoff_base_s` (2) / `merge_retry_backoff_max_s` (5), env `ORCH_MERGE_RETRY_*`, дескрипторы в `.env.example`. Гард already-in-main — без отдельного флага (накрыт `merge_verify_autocreate_pr_enabled`). Откат: `ORCH_MERGE_RETRY_ENABLED=false` (мгновенный runtime) или revert PR.
- **Трассировка:** перед правкой `merge_pr`/`ensure_open_pr`/`_handle_merge_verify` прочитаны ADR ORCH-071/073/082 — инварианты (SHA-in-main authoritative, never-raise, idempotency-guard `pr_already_merged`, base==main фильтр code-PR) сохранены; в `MAIN_REGRESSION_MARKERS` добавлена строка `("ORCH-093", "_classify_merge_response", "src/merge_gate.py")` (append-only).
- Тесты: `tests/test_merge_gate.py` (TC-01..TC-12: 405×2→200, 5xx→200, network→200, реальный конфликт/403 терминал, ambiguous-mergeable, исчерпание ретраев, kill-switch one-shot, already-in-main без POST, create при коммитах сверх main, fail-OPEN на git-ошибке гарда, never-raise; `httpx` мокается, `time.sleep` → no-op), `tests/test_config.py` (TC-13: дефолты + env-override `ORCH_MERGE_RETRY_*`), `tests/test_merge_verify.py` (TC-14..TC-16: already-in-main пропускает `merge_pr`→done; исчерпание+SHA-not-in-main→HOLD; транзиент-успех→done). Обновлён `tests/test_orch082_ensure_pr.py` (гард запинён на create-путь — у гарда своё покрытие). Полный регресс `tests/ -q` зелёный (1389). ADR: `docs/work-items/ORCH-093/06-adr/ADR-001-merge-transient-retry-and-already-in-main-guard.md`, сквозной `docs/architecture/adr/adr-0027-merge-actor-transient-retry-and-already-in-main.md`.
- **Live-карточка трекера: полнота карты статусов, отражение откатов, суммирование метрик стадии по попыткам** (ORCH-091, `fix`): три верифицированных дефекта рендера Telegram-карточки (`src/notifications.py`, ORCH-067/087). **Аддитивно, never-raise, без нового поведения конвейера:** `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / транспорт нотификаций / схема БД — **не тронуты** (затронут ровно один модуль индикативного слоя); kill-switch не требуется (рендер деградирует безопасно, откат = `git revert`).
- **Деф.1 — застрявший заголовок «To Analyse» (FR-1/2/3, AC-1/2/3):** `_STAGE_STATUS_LABEL` покрывал 8 из 10 ключей `STAGE_TRANSITIONS``deploy-staging` и `cancelled` (ORCH-090) выпадали в дефолт-«To Analyse» (ложный «первый статус» на стадии staging-деплоя). Карта расширена: `deploy-staging → "Deploying (staging)"` (plain-стиль активной стадии, суффикс «(staging)» снимает коллизию с prod-overlay `_LIVE_BRANCH_LABELS['deploying']` и с pause-лейблом `deploy`), `cancelled → "Cancelled"` (offline-база ORCH-090, совпадает с overlay-лейблом → нет конфликта precedence). Runtime-фолбэк `plane_status_label` для **немаппленной** (будущей/неизвестной) стадии заменён с «To Analyse» на **нейтральный** капитализированный лейбл (`_neutral_stage_label`, `"deploy-staging" → "Deploy Staging"`); `created` остаётся явным ключом → честная «To Analyse»; битый/None-вход → безопасный дефолт. Полнота карты гарантируется **программно** тестом, итерирующим `STAGE_TRANSITIONS.keys()` (единый источник истины) — новая стадия без курируемого лейбла даёт красный тест; автогенерация лейблов в самом модуле запрещена (карта остаётся курируемой/человекочитаемой).
- **Деф.2 — ложная картина при откате (FR-4, AC-4):** цикл рендера выводил `✅`-строку для каждой стадии с завершённым прогоном её агента **без учёта позиции** относительно текущей — после отката (`deploy-staging → development` ORCH-043, `review → development` REQUEST_CHANGES) карточка показывала абсурд «✅ Внедрение … + 🔄 Разработка». Введён лёгкий read-only хелпер `_pipeline_pos` от **порядка `STAGE_TRANSITIONS`** (не от `_TRACKER_STAGES`, который не содержит `deploy-staging`/`cancelled` и не авторитетен по порядку); гейт подавления: `✅`-строка рисуется только если `current_pos >= _pipeline_pos(stage_key)`. Нормализация `deploy-staging → deploy` применяется **только** к вычислению текущей позиции (схлопнутая строка «Внедрение» несёт `stage_key="deploy"`); `is_active_stage`**без изменений** (нулевой регресс активного рендера). Подавлённые откатом прогоны по-прежнему входят в тоталы задачи (намеренная семантика отката).

View File

@@ -7,7 +7,7 @@
- Backend: FastAPI + uvicorn (Python 3.12)
- БД: SQLite (`src/db.py`)
- Агенты: Claude CLI (`ORCH_CLAUDE_BIN`), по одному промпту на роль в `.openclaw/agents/`. **ORCH-74:** модель/эффорт агента берутся ТОЛЬКО из config (`resolve_agent_model`/`resolve_agent_effort`, ORCH-41) — frontmatter `model:` удалён как мёртвый, frontmatter описательный; имя модели валидируется форматом `^claude-…$` перед `--model` (never-break). **ORCH-077 (52d, замыкает эпик 52):** тело всех 6 промптов переписано в едином **каноне Anthropic** (5 обязательных XML-секций в нормативном порядке `<context>``<task>``<deliverables>``<constraints>``<output_format>`, запреты в формате «❌ X → ✅ Y», `<thinking>` у решающих ролей), и каждый промпт **добровольно** эмитит 6-польную frontmatter-схему 52c (`work_item`/`stage`/`author_agent`/`status`/`created_at`/`model_used`) **аддитивно** — рядом с machine-verdict ключом, НЕ меняя его имя/регистр/значения (`verdict:`/`result:`/`staging_status:`/`deploy_status:`/`security_status:` — байт-в-байт). Это **docs/prompts-only** изменение: `src/**`/`STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД не тронуты; `frontmatter_validation_strict` остаётся `False` (enforcement НЕ включён). Промпт `cat`-ается из worktree в момент запуска → новые промпты вступают в силу на следующем worktree от `main` без прод-рестарта. Анти-регресс — структурные тесты `tests/test_agent_prompts_canon.py` + зелёный `test_agent_frontmatter_no_model.py`. **Норматив на будущее:** новые/изменённые агент-промпты следуют этому канону. Детали — `docs/architecture/adr/adr-0021-prompt-canon-anthropic.md`. **ORCH-092 (эпилог эпика 52, docs/prompts-only):** аудит 6 промптов поверх канона — копируемые frontmatter-примеры расхардкожены (`created_at: <YYYY-MM-DD>`/`model_used: <resolve ORCH-41>` + врезка «подставь `date +%F`/модель из конфига, не копируй буквально»; литерал `claude-opus-4-8` — только справка в таблице полей); добавлена секция `<escalation>` developer/reviewer/tester (после `</success_criteria>`, порядок 5 секций цел); developer лишён ручного `git rebase origin/main` (свежесть базы — инвариант движка serial-gate ORCH-088 + `auto_rebase_onto_main` под merge-lease; ручной rebase конфликтовал с запретом force-push — ADR-001 D1); tester обогащён worktree-путём + smoke `serial_gate` + покрытием каждого TC; из reviewer удалена мёртвая строка «тот же экземпляр Developer». **Языковое исключение (нормативно, ADR-001 D2):** `deployer.md` сознательно остаётся на **английском** (5 ru + 1 en) как самый safety-critical промпт — НЕ «чинить» язык вслепую; критичные self-hosting-запреты подняты в видную рамку. Verdict-ключи и канон 52d — байт-в-байт; анти-регресс`tests/test_agent_prompts_canon.py` (ORCH-092 TC-01…TC-08). Детали — `docs/work-items/ORCH-092/06-adr/ADR-001-developer-rebase-and-deployer-language.md`.
- Очередь задач: собственная (SQLite `jobs`, `src/queue_worker.py`, ORCH-1). **ORCH-026:** `claim_next_job` гейтит задачи с незавершёнными зависимостями (`job_deps`, `NOT EXISTS`) без занятия слота `max_concurrency`; декларации/детект циклов — leaf `src/task_deps.py` (kill-switch `ORCH_TASK_DEPS_ENABLED`). Сериализация мержа одного репо — безусловный pre-merge rebase под merge-lease (`ORCH_PREMERGE_REBASE_ALWAYS`). **ORCH-088 (serial gate, Этап 1):** новая задача репо не входит в `analysis` (analyst-job не выбирается, ветка не режется), пока в репо есть **более ранняя** незавершённая задача (`t2.id < jobs.task_id`, FIFO) ИЛИ репо заморожен (`repo_freeze`). Срез ветки **отложен** со `start_pipeline` на момент claim analyst-job (`launcher._materialize_deferred_branch`) — база = свежий `origin/main` с кодом предшественника (анти-stale-base). Post-deploy `DEGRADED` → durable per-repo freeze (`repo_freeze`, `cleared_at IS NULL` = активен) + Telegram; снятие — вручную `POST /serial-gate/unfreeze?repo=…`. Leaf `src/serial_gate.py` (claim — fail-OPEN, freeze — fail-CLOSED); флаги `ORCH_SERIAL_GATE_ENABLED` (kill-switch), `ORCH_SERIAL_GATE_REPOS` (CSV; пусто = все репо), `ORCH_SERIAL_GATE_FREEZE_ENABLED`. Блок `serial_gate` в `GET /queue`. `STAGE_TRANSITIONS`/`QG_CHECKS` не тронуты.
- Очередь задач: собственная (SQLite `jobs`, `src/queue_worker.py`, ORCH-1). **ORCH-026:** `claim_next_job` гейтит задачи с незавершёнными зависимостями (`job_deps`, `NOT EXISTS`) без занятия слота `max_concurrency`; декларации/детект циклов — leaf `src/task_deps.py` (kill-switch `ORCH_TASK_DEPS_ENABLED`). Сериализация мержа одного репо — безусловный pre-merge rebase под merge-lease (`ORCH_PREMERGE_REBASE_ALWAYS`). **ORCH-088 (serial gate, Этап 1):** новая задача репо не входит в `analysis` (analyst-job не выбирается, ветка не режется), пока в репо есть **более ранняя** незавершённая задача (`t2.id < jobs.task_id`, FIFO) ИЛИ репо заморожен (`repo_freeze`). Срез ветки **отложен** со `start_pipeline` на момент claim analyst-job (`launcher._materialize_deferred_branch`) — база = свежий `origin/main` с кодом предшественника (анти-stale-base). Post-deploy `DEGRADED` → durable per-repo freeze (`repo_freeze`, `cleared_at IS NULL` = активен) + Telegram; снятие — вручную `POST /serial-gate/unfreeze?repo=…`. Leaf `src/serial_gate.py` (claim — fail-OPEN, freeze — fail-CLOSED); флаги `ORCH_SERIAL_GATE_ENABLED` (kill-switch), `ORCH_SERIAL_GATE_REPOS` (CSV; пусто = все репо), `ORCH_SERIAL_GATE_FREEZE_ENABLED`. Блок `serial_gate` в `GET /queue`. `STAGE_TRANSITIONS`/`QG_CHECKS` не тронуты. **ORCH-093 (merge-актор устойчив к икоте Gitea):** детерминированный merge-актор под-гейта `deploy → done` (`src/merge_gate.py`) ретраит **транзиентные** ошибки Gitea вместо ложного HOLD (инцидент ORCH-063: `POST …/merge``405 "try again later"` сразу после пуша). `merge_pr` оборачивает **только** мутирующий `POST …/merge` в ограниченный retry-loop с экспоненциальным backoff (`min(base*2^(i-1), max)`, потолок суммарного сна `(N-1)*max ≤ 10 с`); классификатор `_classify_merge_response`: транзиент (ретрай) — `405`/`408`/`5xx`/таймаут/сетевая + `409`/`422` при `mergeable==True` (доп. `GET /pulls/{index}`; `mergeable==None` → дефолт-транзиент, fail-OPEN-в-ретрай), терминал (быстрый честный `False`, защита ORCH-071/073 как прежде) — `403`/`404`/реальный конфликт (`mergeable==False`). Kill-switch `merge_retry_enabled=false` → ровно один POST (байт-в-байт прежнее one-shot); флаги `ORCH_MERGE_RETRY_*` (`max_attempts=3`, `backoff_base_s=2`, `backoff_max_s=5`). Гард **already-in-main** в `ensure_open_pr` (leaf `_branch_fully_in_main`, `git merge-base --is-ancestor HEAD origin/main`): ветка целиком в `main` → исход `"already-in-main"` без создания мусорного пустого PR; `_handle_merge_verify` пропускает `merge_pr` и отдаёт авторитетному SHA-в-main довести до `done` (НЕ HOLD); git-ошибка → fail-OPEN на create-путь. Без отдельного флага (накрыт `merge_verify_autocreate_pr_enabled`). INV-4 (мерж только через Gitea PR-merge API, никогда push/force-push в `main`), never-raise, `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД — сохранены. Детали — `docs/work-items/ORCH-093/06-adr/ADR-001-merge-transient-retry-and-already-in-main-guard.md`, сквозной `docs/architecture/adr/adr-0027-merge-actor-transient-retry-and-already-in-main.md`.
- Контейнеризация: Docker + Compose
- CI/CD: Gitea Actions (`.gitea/workflows/`)
- Деплой: docker compose на mva154

View File

@@ -477,6 +477,78 @@ developer-пути и **только** при свежем worktree-коммит
Подробнее: [adr-0016](adr/adr-0016-ensure-open-pr-before-merge-verify.md) (amends 0013/0014);
детально — `docs/work-items/ORCH-082/06-adr/ADR-001-ensure-open-pr-before-merge-verify.md`.
#### Ретрай транзиентных merge-ошибок Gitea + гард already-in-main (ORCH-093 — фикс ложного HOLD на 405/5xx)
Инцидент **ORCH-063**: self-deploy прошёл, staging OK, PR был `open`+`mergeable`, конфликтов не было,
но `POST /pulls/{n}/merge` вернул `HTTP 405 {"message":"Please try again later"}` (Gitea пересчитывал
`mergeable` сразу после пуша). One-shot `merge_pr` мгновенно вернул `False` → корректная защита
ORCH-071/073 удержала задачу на `deploy` (HOLD+alert) + потребовала **ручной домерж** (повтор влился с
первого раза); повторный прогон финализатора плодил **мусорный пустой PR** на уже влитой ветке. У
Claude-агентов есть transient-breaker, у CI-гейта — `check_ci_green`, а у детерминированного
merge-актора аналога не было. ORCH-093 закрывает это аддитивно, внутри того же под-гейта, не трогая
машину стадий:
- **Retry-loop в `merge_pr` (ORCH-093 D1/D2):** ретраится **только** мутирующий `POST …/merge`
(идемпотентные шаги до него — без изменений). Классификатор `_classify_merge_response →
transient|terminal`: **транзиент** (ретрай с backoff) — `405`/`408`/любой `5xx`/`httpx`-таймаут/
сетевая ошибка, **и** `409`/`422` когда PR всё ещё `mergeable` (доп. `GET /pulls/{index}`);
**терминал** (быстрый честный `False`, защита ORCH-071/073 как прежде) — `403`/`404`/реальный
конфликт (`409`/`422` при `mergeable==False`). Дефолт-политика `mergeable==None`/недоступно →
транзиент (fail-OPEN-в-ретрай: икота Gitea наблюдаема, бюджет конечен, backstop сохранён).
Backoff экспоненциальный с потолком `min(base*2^(i-1), max)` (дефолты 2/5 с → суммарный сон
`(N-1)*max ≤ 10 с`, monitor-поток merge-verify не подвешивается). Лог `attempt i/N` (образец
`check_ci_green`).
- **Гард already-in-main в `ensure_open_pr` (ORCH-093 D3):** leaf `_branch_fully_in_main`
(`git merge-base --is-ancestor HEAD origin/main` в per-branch worktree) вызывается **между** «код-PR
не найден» и `POST …/pulls`: ветка целиком в `main` (нет коммитов `origin/main..HEAD`) → новый исход
`("already-in-main", …)` **без создания PR** (нет мусорного пустого PR). git-ошибка/ambiguous
(`None`) → **fail-OPEN** (деградация на create-путь, НЕ ложный no-op). Без отдельного флага —
накрыт `merge_verify_autocreate_pr_enabled`.
- **Врезка в `_handle_merge_verify` (ORCH-093 D4):** `pr_status == "already-in-main"` → лог,
**пропуск** `merge_pr` (мержить нечего), сразу к `verify_merged_to_main` (SHA-в-main подтвердит →
`done`). Это НЕ HOLD; SHA-в-main остаётся авторитетным (если SHA не в `main` — прежний HOLD,
fail-closed).
- **Конфиг/откат:** `merge_retry_enabled` (kill-switch; `False` → ровно один POST = байт-в-байт
прежнее one-shot) / `merge_retry_max_attempts` (3) / `merge_retry_backoff_base_s` (2) /
`merge_retry_backoff_max_s` (5), env `ORCH_MERGE_RETRY_*`. `STAGE_TRANSITIONS`, `QG_CHECKS`, схема
БД, exit-коды хука, merge-gate, image-freshness — без изменений; `main` не push/force-push.
Подробнее: [adr-0027](adr/adr-0027-merge-actor-transient-retry-and-already-in-main.md)
(amends 0013/0014/0016); детально —
`docs/work-items/ORCH-093/06-adr/ADR-001-merge-transient-retry-and-already-in-main-guard.md`.
#### Ретрай транзиентных merge-ошибок Gitea + гард already-in-main (ORCH-093 — фикс ложного HOLD на 405/5xx)
Инцидент ORCH-063 (09.06): self-deploy прошёл, PR `open`+`mergeable=True`, конфликтов нет — но
`POST …/merge` вернул `HTTP 405 {"message":"Please try again later"}` (Gitea пересчитывал
`mergeable` сразу после пуша). `merge_pr` был **one-shot** → мгновенный `False` → ложный HOLD
ORCH-071/073 + ручной домерж; повторный прогон финализатора после ручного мержа создавал **пустой
PR** на уже влитой ветке. ORCH-093 аддитивно закрывает оба дефекта, не трогая машину стадий:
- **Ретрай-loop в `merge_pr`** оборачивает **только** `POST /pulls/{index}/merge` до
`merge_retry_max_attempts` (дефолт 3) с экспон. backoff и потолком (`merge_retry_backoff_base_s` 2 /
`merge_retry_backoff_max_s` 5; суммарно ≤10 с, не подвешивает monitor-поток). Шаги до POST
(idempotency `pr_already_merged`, поиск код-PR) — без изменений. Лог `attempt i/N` (образец
`check_ci_green`).
- **Классификатор транзиент/терминал** по коду ответа **и** полю `mergeable`: **транзиент** (ретрай)
`405`/`408`/`5xx`/таймаут/сетевое, `409`/`422` при `mergeable==True`; **терминал** (быстрый
честный `False`) — `403`/`404`, `409`/`422` при `mergeable==False`. Неоднозначный `409/422`
разрешается доп. `GET /pulls/{index}`; `mergeable==None`/недоступен → транзиент-по-дефолту в рамках
бюджета (цель — не давать ложного HOLD на икоте; backstop ORCH-071/073 сохранён).
- **Гард already-in-main в `ensure_open_pr`**: перед созданием PR — `git merge-base --is-ancestor
<branch> origin/main` (rc==0 → ветка целиком в `main`) → новый исход `("already-in-main", …)`, PR
**не создаётся**; git-ошибка/ambiguous → **fail-OPEN** на текущий create-путь (икота git не должна
стать ложным no-op мержа). `_handle_merge_verify` трактует `already-in-main` как «мержить нечего» →
пропуск `merge_pr` → авторитетный SHA-в-main (`verify_merged_to_main`) доводит до `done` без мусорного
PR. Это НЕ `failed`-ветка.
- **Защита ORCH-071/073 неприкосновенна:** реальный конфликт → быстрый честный HOLD; подтверждение
merge остаётся ТОЛЬКО SHA-в-main. Терминал/исчерпание ретраев → `(False, …)` → прежний HOLD+alert.
- **Условность / откат:** kill-switch `merge_retry_enabled` (дефолт `true`; `False` → one-shot 1:1,
env `ORCH_MERGE_RETRY_*`); гард already-in-main — без отдельного флага (накрыт
`merge_verify_autocreate_pr_enabled`). Область — `merge_verify_applies` (self-hosting; на прочих
репо мерж за `deployer` — изменение нейтрально). `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД,
exit-коды хука, merge-gate, image-freshness — без изменений; `main` не push/force-push (INV-4).
Подробнее: [adr-0027](adr/adr-0027-merge-actor-transient-retry-and-already-in-main.md) (amends
0013/0014/0016); детально —
`docs/work-items/ORCH-093/06-adr/ADR-001-merge-transient-retry-and-already-in-main-guard.md`.
### Post-deploy наблюдение прода + реакция на деградацию (ORCH-021 — реализовано)
Конвейер заканчивался на `deploy → done` и **забывал про прод**: «успех» = health-check
в момент рестарта (~60с). Класс «зелёный деплой, красный прод» (прецедент ET-8 —

View File

@@ -0,0 +1,82 @@
---
work_item: ORCH-093
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# adr-0027: Merge-актор — ретрай транзиентных ошибок Gitea + гард «ветка уже в `main`»
Сквозной (cross-cutting) ADR. **Амендмент** к [adr-0013](adr-0013-merge-verify-gate.md) (merge-verify
под-гейт), [adr-0014](adr-0014-merge-verify-sha-source-of-truth.md) (SHA-в-main как источник истины)
и [adr-0016](adr-0016-ensure-open-pr-before-merge-verify.md) (гарантированный код-PR). Детальное
решение задачи — `docs/work-items/ORCH-093/06-adr/ADR-001-merge-transient-retry-and-already-in-main-guard.md`.
> Регистрируется как сквозной, т.к. правит блок merge-актора с **3+ маркерами** (`ORCH-071`,
> `ORCH-073`, `ORCH-082`) — анти-археология маркеров (`docs/_standards/TRACEABILITY.md`): сводный
> ADR агрегирует эволюцию вместо перечисления work item в коде.
## Статус
Proposed
## Контекст
Детерминированный merge-актор merge-verify под-гейта (`deploy → done`, self-hosting) состоит из
`ensure_open_pr``merge_pr``verify_merged_to_main` (`src/merge_gate.py`). Инцидент **ORCH-063
(09.06)** вскрыл два дефекта, оба сверены по коду прода:
1. `merge_pr`**one-shot**: `POST /pulls/{index}/merge`, любой не-`200/201` → мгновенный `False`.
Транзиентная икота Gitea (`405 "Please try again later"` при пересчёте `mergeable` сразу после
пуша; `5xx`; таймаут) → ложный HOLD защиты ORCH-071/073 → ручной домерж.
2. `ensure_open_pr` — после ручного мержа код-PR `closed`, открытый не найден → создаёт **новый
пустой PR** на ветке, уже целиком в `main`.
Защита ORCH-071/073 («deploy succeeded but not merged») корректна и сохраняется; задача снижает
лишь **ложные** срабатывания на транзиентах и устраняет мусорные PR. Это блокер автономного прогона
(эпик ORCH-088).
## Решение
Аддитивно, без правки `STAGE_TRANSITIONS` / `QG_CHECKS` / схемы БД; INV-4 (мерж только через Gitea
PR-merge API; никогда `push`/`force-push` в `main`) и never-raise сохранены.
- **Ретрай-loop вокруг `POST …/merge`** (только мутирующий вызов) до `merge_retry_max_attempts`
(дефолт 3) с экспоненциальным backoff и потолком (`base 2`, `max 5`; суммарно ≤10 с). Классификатор
**транзиент** (`405`/`408`/`5xx`/таймаут/сетевое; `409`/`422` при `mergeable==True`; `mergeable==None`
→ транзиент-по-дефолту в рамках бюджета) vs **терминал** (`403`/`404`; `409`/`422` при
`mergeable==False`) — по коду ответа **и** полю `mergeable` (`GET /pulls/{index}`). Терминал →
быстрый честный `False` (защита ORCH-071/073 — как прежде). Образец — `check_ci_green`
(`attempt i/N`) + transient-breaker агентов.
- **Гард already-in-main в `ensure_open_pr`**: перед созданием PR — `git merge-base --is-ancestor
<branch> origin/main` (rc==0 → ветка целиком в `main`) → новый исход `"already-in-main"`, PR не
создаётся; git-ошибка/ambiguous → **fail-OPEN** на текущий create-путь (гард не должен превратить
икоту git в ложный no-op мержа). `_handle_merge_verify` трактует `"already-in-main"` как «мержить
нечего» → пропуск `merge_pr` → авторитетный SHA-в-main (`verify_merged_to_main`, ADR-0014) доводит
до `done` без мусорного PR.
- **Конфиг**: `merge_retry_enabled` (kill-switch; `False` → one-shot, нулевая регрессия),
`merge_retry_max_attempts`, `merge_retry_backoff_base_s`, `merge_retry_backoff_max_s`
(env `ORCH_MERGE_RETRY_*`). Гард already-in-main — без отдельного флага (накрыт существующим
`merge_verify_autocreate_pr_enabled`).
Объём раската — реально только self-hosting (`merge_verify_applies`); на прочих репо мерж делает
LLM-deployer → изменение нейтрально.
## Последствия
- **+** Транзиент Gitea переживается автоматически → нет ложного HOLD / ручного домержа в автономном
конвейере; нет мусорных пустых PR; повтор финализатора идемпотентен.
- **+** Реальный конфликт → быстрый честный HOLD; защита ORCH-071/073 и SHA-в-main (ADR-0014) —
авторитетны и неизменны.
- **** Дефолт `mergeable==None → transient` может добавить ≤10 с до HOLD на реальном конфликте
(бюджет жёстко ограничен); один лишний `GET /pulls/{index}` в редком ambiguous-кейсе.
- **Откат:** `ORCH_MERGE_RETRY_ENABLED=false` → one-shot; `ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED=false`
→ отключает врезку `ensure_open_pr` с гардом. Полный откат — revert PR.
## Ссылки
- Детальный ADR: `docs/work-items/ORCH-093/06-adr/ADR-001-merge-transient-retry-and-already-in-main-guard.md`
- Лехатая: [adr-0006](adr-0006-merge-gate.md), [adr-0013](adr-0013-merge-verify-gate.md),
[adr-0014](adr-0014-merge-verify-sha-source-of-truth.md),
[adr-0016](adr-0016-ensure-open-pr-before-merge-verify.md)
- Код: `src/merge_gate.py`, `src/stage_engine.py::_handle_merge_verify`, `src/config.py`

View File

@@ -0,0 +1,7 @@
# Business Request: BUG: merge-актор не ретраит транзиентные ошибки Gitea (405/5xx) → ложный HOLD + мусорные PR
Work Item ID: ORCH-093
## Description
TBD

View File

@@ -0,0 +1,145 @@
---
work_item: ORCH-093
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# 01 — BRD (бизнес-требования): ORCH-093 — merge-актор не ретраит транзиентные ошибки Gitea (405/5xx) → ложный HOLD + мусорные PR
Work Item: **ORCH-093** · Repo: **orchestrator** · Стадия: analysis
## 1. Бизнес-контекст и проблема
Тип: **BUG** (надёжность self-deploy merge-фазы). Найдено по инциденту **ORCH-063 (09.06)**.
**Инцидент-первоисточник (ORCH-063, 09.06).** Прод-деплой self-hosting прошёл, staging OK, но при
мерже PR в `main` Gitea вернул `HTTP 405 {"message":"Please try again later"}` — транзиентная икота
(Gitea пересчитывал `mergeable` сразу после пуша). PR #98 был `open` + `mergeable=True`, конфликтов
**не было**. Однако merge-актор `merge_gate.merge_pr()`**one-shot**: на любой не-200/201 он сразу
вернул `(False, "merge failed: HTTP 405")`. Сработала корректная защита ORCH-071/081 «deploy
succeeded but not merged» → задача удержана на `deploy` (НЕ `done`), алерт, потребовался **ручной
домерж** (повтор `merge_pr` вручную → смержилось с первого раза). Защита отработала верно, но
**транзиент не должен был требовать человека**.
**Два дефекта, оба верифицированы по коду прода `src/merge_gate.py`:**
- **ДЕФЕКТ 1 — `merge_pr` не ретраит транзиентные HTTP-ошибки.** `merge_gate.merge_pr()`
(`src/merge_gate.py` ~700) делает **один** `POST /pulls/{index}/merge`; на любой не-200/201
(включая `405 "try again later"`, `5xx`, `409/422` «ещё считается mergeable») сразу
`return False, "merge failed: HTTP {code}"` — без ретрая. Сравни: у Claude-агентов есть
transient-breaker (`429/overload` ретраится), у merge-актора такого механизма нет → инфра-икота
Gitea = ложный HOLD.
- **ДЕФЕКТ 2 — `ensure_open_pr` плодит мусорные PR на уже влитой ветке.** При повторном прогоне
финализатора **после** ручного мержа: PR #98 уже `merged+closed``ensure_open_pr`
(`src/merge_gate.py` ~605) не находит открытого code-PR → **создаёт новый пустой PR #99** (ветка
уже в `main`, diff пустой). Пришлось закрывать вручную.
**Боль:** ложные HOLD при инфра-икоте Gitea требуют ручного вмешательства в автономный конвейер
(эпик ORCH-088 — пакетный автономный прогон) и оставляют мусорные пустые PR.
## 2. Объём (scope)
### В объёме
- `merge_pr` ретраит **транзиентные** ошибки мержа (405/«try again», 408, `5xx`, таймаут/сетевые, а
также `409/422` когда PR **всё ещё mergeable**) с ограниченным числом попыток и backoff — **перед**
тем как вернуть `False`.
- Различение «mergeable, но Gitea временно отказал» (ретраить) vs «реальный конфликт / не-mergeable»
(НЕ ретраить, честный быстрый HOLD).
- `ensure_open_pr` / merge-verify **не создаёт** новый PR, если ветка уже полностью в `main` (нет
коммитов `origin/main..branch`) — возвращает исход «already-in-main»; финализатор сразу доводит до
`done` без мусорного PR.
- Конфигурируемость (число ретраев, backoff, kill-switch на ретрай-поведение); разумные дефолты.
- Обновление `.env.example`, `CHANGELOG.md`, merge-gate-раздела документации.
### Вне объёма
- ❌ Снятие/ослабление защиты ORCH-071/081 «deploy succeeded but not merged» — она корректна; задача
лишь снижает **ложные** срабатывания на транзиентах.
- ❌ Ретрай **реального** конфликта / не-mergeable — это законный HOLD, нужен человек.
- ❌ Любые прямые `push`/`force-push` в `main` (инвариант INV-4 ORCH-071/073 — мерж только через
Gitea PR-merge API).
- ❌ Изменение `STAGE_TRANSITIONS`, состава `QG_CHECKS`, схемы БД.
- ❌ Изменение SHA-in-main-доказательства мержа (`verify_merged_to_main`) как источника истины.
## 3. Заинтересованные стороны
- **Заказчик / оператор автономного конвейера (Owner, Стрим)** — меньше ручных домержей, чище список
PR в Gitea.
- **Self-hosting репо `orchestrator`** — основной потребитель merge-verify under-gate (ORCH-071);
изменение в первую очередь касается self-deploy merge-фазы.
- **Все проекты на общем инстансе** — косвенно: меньше зависших на `deploy` задач, держащих
merge-lease и клинящих serial-gate репо (ORCH-088).
- **Reviewer / tester** — принимают результат по AC и зелёному `pytest`.
## 4. Бизнес-требования (BR)
- **BR-1** — При транзиентной ошибке мержа (`405`/«Please try again later», `408`, `5xx`,
таймаут/сетевая ошибка) `merge_pr` повторяет `POST …/merge` до `N` раз с backoff, прежде чем
вернуть `(False, …)`; успешный повтор внутри бюджета → `(True, …)`, мерж выполнен.
- **BR-2** — `merge_pr` различает «PR mergeable, Gitea временно отказал» (ретраить) и «реальный
конфликт / PR не mergeable» (НЕ ретраить). Различение опирается на код ответа **и** поле
`mergeable` PR (`GET /pulls/{n}`). Неоднозначный `409/422` классифицируется по `mergeable`.
- **BR-3** — Терминальные ошибки (`404` нет PR / реальный конфликт / `403`) НЕ ретраятся — `merge_pr`
возвращает `(False, …)` быстро; честный HOLD (защита ORCH-071/081) сохраняется.
- **BR-4** — При исчерпании ретраев `merge_pr` возвращает `(False, …)` с понятным reason; защита
«deploy succeeded but not merged» срабатывает как прежде (HOLD + алерт).
- **BR-5** — Если ветка уже полностью в `main` (нет коммитов `origin/main..branch`), `ensure_open_pr`
НЕ создаёт PR — возвращает исход «already-in-main»; merge-verify доводит задачу до `done` без
мусорного пустого PR.
- **BR-6** — Поведение ретрая конфигурируемо: число попыток, backoff и kill-switch; дефолты разумны
(≈3 попытки, backoff 25 с) и задокументированы в `.env.example`.
- **BR-7** — При выключенном ретрай-kill-switch поведение `merge_pr` идентично текущему (one-shot) —
нулевая регрессия.
## 5. Нефункциональные требования (NFR)
- **NFR-1 (never-raise)** — Контракт never-raise `merge_pr` / `ensure_open_pr` сохранён: любая
HTTP/parse/сетевая ошибка → `(False, …)` / `("failed"|"already-in-main", …)`, исключение никогда не
пробрасывается в `_handle_merge_verify` / `advance_stage`.
- **NFR-2 (self-hosting safety / INV-4)** — Никаких прямых `push`/`force-push` в `main`; мерж только
через Gitea PR-merge API. Прод-контейнер `orchestrator` не перезапускается этой задачей.
- **NFR-3 (обратимость / kill-switch)** — Ретрай-поведение полностью отключаемо одним флагом →
откат к нынешнему one-shot без изменения кода.
- **NFR-4 (ограниченность)** — Суммарное время ретраев ограничено (`N` × backoff_max) и не может
«подвесить» monitor-поток, исполняющий merge-verify; backoff с верхним потолком.
- **NFR-5 (идемпотентность)** — Повторный прогон финализатора на уже влитой ветке безопасен и
бесследен (нет дублей PR, нет дублей мержа — переиспользуется `pr_already_merged`).
- **NFR-6 (наблюдаемость)** — Каждый ретрай и его причина логируются (по образцу `check_ci_green`:
`attempt i/N`); исход (успех/исчерпание/терминал) различим в логе.
## 6. Допущения и ограничения
- Gitea-код `405 {"message":"Please try again later"}`**транзиент** (Gitea пересчитывает
`mergeable` сразу после пуша); `5xx`/таймаут/сетевая — транзиент.
- `409` (conflict) и `422` (unprocessable) **двойственны**: либо реальный конфликт, либо «ещё не
пересчитан mergeable». Источник различения — поле `mergeable` из `GET /pulls/{n}` (а не только
код): `mergeable==True` → транзиент (ретраить), `mergeable==False` → реальный конфликт (НЕ
ретраить).
- `404` (нет PR) обрабатывается раньше шагом «no open PR» и/или трактуется как терминал.
- Образец паттерна ретрая уже есть в репо: `check_ci_green` (`src/qg/checks.py`, attempts + interval
+ backoff) и transient-breaker агентов (`backoff_base_seconds`/`backoff_max_seconds`/
`transient_max_attempts` в `config.py`).
- Merge-verify under-gate (ORCH-071) реален только для self-hosting (`merge_verify_applies`); на
прочих репо мерж делает LLM-deployer — там изменение `merge_pr` не задействуется.
- Изменение **точечное** в `src/merge_gate.py` + флаги в `src/config.py`; `STAGE_TRANSITIONS`,
`QG_CHECKS`, схема БД не трогаются.
## 7. Критерии успеха
`merge_pr` переживает транзиентную икоту Gitea (405/5xx/таймаут/«not mergeable yet») за счёт
ограниченного ретрая с backoff и больше не даёт ложного HOLD; реальный конфликт по-прежнему даёт
быстрый честный HOLD; `ensure_open_pr` не создаёт мусорных PR на уже влитой ветке; поведение
конфигурируемо и отключаемо; never-raise сохранён; `pytest tests/ -q` зелёный; доки и `.env.example`
обновлены. Детальные PASS/FAIL — `03-acceptance-criteria.md`.
## 8. Риски
- Слишком агрессивный ретрай реального конфликта → задержка честного HOLD (митигируется BR-2/BR-3:
классификация по `mergeable`).
- Ошибочная классификация транзиента как терминала (или наоборот) при неполном ответе Gitea
(`mergeable=None`) — нужна осторожная дефолт-политика.
- Гонка `ensure_open_pr` already-in-main vs параллельный мерж.
Детали и оценка — `10-tech-risks.md` (заполняет архитектор).

View File

@@ -0,0 +1,142 @@
---
work_item: ORCH-093
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# 02 — ТЗ (TRZ): ORCH-093 — merge-актор ретраит транзиентные ошибки Gitea + гард «ветка уже в main»
Work Item: **ORCH-093** · Repo: **orchestrator** · Стадия: analysis
> ТЗ описывает **конкретные изменения к реализации**, выведенные из BRD и фактического кода
> (`src/merge_gate.py`, `src/config.py`, `src/stage_engine.py`). Архитектурное обоснование (точный
> алгоритм классификации, формат хелпера, выбор дефолтов) — задача архитектора (`06-adr`).
## 1. Сводка изменения
Две точечные доработки `src/merge_gate.py`:
1. **`merge_pr` (~700)** — обернуть `POST /pulls/{index}/merge` в **retry-loop** на транзиентных
кодах (`405`/«try again», `408`, `5xx`, таймаут/сетевые, плюс `409/422` при `mergeable==True`) с
ограниченным числом попыток и backoff; **терминальные** исходы (`404` нет PR, реальный конфликт /
`mergeable==False`, `403`) → быстрый `(False, …)` без ретрая. По образцу `check_ci_green`
(attempts + interval) и transient-breaker агентов.
2. **`ensure_open_pr` (~605)** — добавить гард «ветка уже полностью в `main`» (нет коммитов
`origin/main..branch`) → новый исход `"already-in-main"` **до** создания PR; в
`_handle_merge_verify` этот исход трактуется как «мерж уже состоялся» → SHA-in-main подтверждает →
`done` без мусорного PR.
Новые флаги ретрая в `src/config.py` (`ORCH_MERGE_RETRY_*`) + дескрипторы в `.env.example`. Контракт
never-raise и INV-4 (никогда не `push`/`force-push` `main`) — сохраняются. `STAGE_TRANSITIONS`,
`QG_CHECKS`, схема БД — **не трогаются**.
## 2. Задействованные модули / пути
| Путь | Действие |
|------|----------|
| `src/merge_gate.py` | изменить — `merge_pr` (retry-loop + классификатор транзиент/терминал); `ensure_open_pr` (гард already-in-main); при необходимости leaf-хелперы `_is_transient_merge_error()` / `_branch_fully_in_main()` |
| `src/config.py` | изменить — добавить флаги ретрая мержа (`merge_retry_enabled`, `merge_retry_max_attempts`, `merge_retry_backoff_base_s`, `merge_retry_backoff_max_s`) по образцу `ci_poll_*` / `merge_pr_timeout_s` |
| `src/stage_engine.py` | изменить (точечно) — `_handle_merge_verify` (~1447): обработать новый исход `ensure_open_pr == "already-in-main"` как «мерж уже состоялся» (пропустить `merge_pr`, дать `verify_merged_to_main` подтвердить → `done`) |
| `.env.example` | изменить — новые дескрипторы `ORCH_MERGE_RETRY_*` |
| `tests/test_merge_gate.py` | изменить — мок httpx-последовательностей (405×2→200; конфликт; already-in-main; исчерпание; kill-switch off) |
| `CHANGELOG.md` | изменить — запись ORCH-093 |
| `docs/architecture/README.md` (merge-gate раздел) / `CLAUDE.md` | изменить — описать ретрай и гард already-in-main |
## 3. Функциональные требования
### FR-1 — retry-loop транзиентных ошибок мержа в `merge_pr` (BR-1, BR-4, BR-6, BR-7)
- Шаги `merge_pr` до `POST` (idempotency-guard `pr_already_merged`; `GET …/pulls?state=open` поиск
code-PR `head==branch AND base==main`; `index is None → (False, "no open PR")`) — **без изменений**.
- `POST /pulls/{index}/merge` выполняется в цикле до `merge_retry_max_attempts` попыток (дефолт `3`):
- `200/201``(True, "merged PR #<n>")` (немедленный выход).
- **транзиентный** исход (см. FR-2) И остались попытки → `sleep(backoff)` и повтор `POST`;
`backoff` экспоненциальный от `merge_retry_backoff_base_s` (дефолт `2`) с потолком
`merge_retry_backoff_max_s` (дефолт `5`).
- **терминальный** исход (см. FR-2) → немедленно `(False, "merge failed: HTTP <code>")` без
дальнейших попыток.
- исчерпание попыток на транзиенте → `(False, "merge failed after <N> attempts: HTTP <code>")`.
- **Kill-switch** `merge_retry_enabled=False` → ровно одна попытка `POST` (текущее one-shot
поведение, BR-7).
- Каждая попытка логируется (`attempt i/N`, код, transient/terminal) — образец `check_ci_green`.
### FR-2 — классификация транзиент vs терминал (BR-2, BR-3)
- **Транзиентные** (ретраить): `405` («Please try again later»), `408` (timeout), любой `5xx`,
`httpx`-таймаут / сетевая ошибка, **и** `409`/`422` когда PR **всё ещё mergeable**.
- **Терминальные** (НЕ ретраить, быстрый `False`): `403` (нет прав), `404` (PR исчез), и `409`/`422`
при **реальном конфликте** (`mergeable==False`).
- Различение неоднозначного `409`/`422`: дополнительный `GET /pulls/{index}` → поле `mergeable`:
- `mergeable==True` → транзиент (Gitea ещё не пересчитал) → ретрай.
- `mergeable==False` → реальный конфликт → терминал.
- `mergeable` отсутствует/`None` → консервативная дефолт-политика (рекомендация аналитика:
трактовать как транзиент с тем же ограниченным бюджетом ретраев, т.к. сетевая икота Gitea —
наблюдаемый кейс; финальное решение — архитектор в `06-adr`).
- Сетевые/таймаут-исключения `httpx` внутри попытки ловятся (never-raise) и классифицируются как
транзиент в рамках того же бюджета.
### FR-3 — гард «ветка уже полностью в main» в `ensure_open_pr` (BR-5)
- Перед шагом «создать PR» (после того как открытый code-PR не найден) `ensure_open_pr` проверяет,
что в ветке нет коммитов сверх `origin/main`: в per-branch worktree `git fetch origin main` +
`git rev-list --count origin/main..<branch>` (или `git merge-base --is-ancestor <branch> origin/main`).
- count `== 0` (ветка целиком в `main`) → `("already-in-main", "<reason>")`**PR не создаётся**.
- count `> 0` (есть невлитые коммиты) → текущий путь `POST …/pulls` (создать code-PR).
- git/OS ошибка проверки → **не** блокировать (never-raise); деградировать на текущее поведение
(попытаться создать PR) ИЛИ вернуть `failed` — точную fail-политику фиксирует архитектор. Гард
не должен превратить инфра-икоту git в ложный no-op мержа.
- Сигнатура возврата `ensure_open_pr` расширяется новым статусом `"already-in-main"` дополнительно к
`"existed"|"created"|"failed"` (обратносовместимо для существующих веток вызова).
### FR-4 — обработка `already-in-main` в `_handle_merge_verify` (BR-5)
- В `stage_engine._handle_merge_verify` (~1487): при `pr_status == "already-in-main"`
логировать, **пропустить** `merge_gate.merge_pr` (мержить нечего) и перейти сразу к
`verify_merged_to_main` (SHA-in-main подтвердит факт мержа → `done`). Это НЕ `failed`-ветка (не
HOLD): ветка уже в `main`, цель достигнута.
- SHA-in-main (`verify_merged_to_main`) остаётся **авторитетным** доказательством мержа; гард только
избегает мусорного PR и лишнего `merge_pr`.
### FR-5 — конфигурация и обратная совместимость (BR-6, BR-7)
- Новые поля `settings` (см. §2) с дефолтами; читаются из env (`ORCH_MERGE_RETRY_*`).
- При `merge_retry_enabled=False` — поведение `merge_pr` байт-в-байт как сейчас (one-shot).
- Гард already-in-main также под флагом ИЛИ всегда-вкл (рекомендация: всегда-вкл, т.к. он лишь
предотвращает создание заведомо пустого PR; решение — архитектор).
## 4. Изменения API
Нет (внешних HTTP-эндпоинтов оркестратора не добавляется/не меняется). Меняется только клиентское
обращение к Gitea API внутри `merge_gate` (дополнительный `GET /pulls/{index}` для чтения
`mergeable` при неоднозначном `409/422`; ретрай `POST …/merge`). Read-only блок merge-verify в
`GET /queue` (`merge_verify_status()`) опционально может получить счётчик ретраев (необязательно).
## 5. Изменения схемы БД
Нет. (Merge-lease — файловый, не БД; счётчики `_MERGE_VERIFY_COUNTERS` — in-process. Новые поля —
только в `config.Settings`, не в схеме.)
## 6. Требования к новым/изменённым QG checks
Нет. `STAGE_TRANSITIONS`, состав `QG_CHECKS`, exit-гейты рёбер и под-гейты ребра
`deploy-staging → deploy`**не трогаются**. Изменение целиком внутри детерминированного
merge-актора `merge_pr`/`ensure_open_pr` (под-гейт-врезка `_handle_merge_verify` ребра
`deploy → done`), который НЕ зарегистрирован в `QG_CHECKS`.
## 7. Совместимость / регресс
- **Kill-switch `merge_retry_enabled=False`** → one-shot `merge_pr` (текущее поведение) — нулевая
регрессия.
- **Защита ORCH-071/081** «deploy succeeded but not merged» сохраняется 1:1: после исчерпания
ретраев / на терминальном конфликте `merge_pr` возвращает `False`, и при неподтверждённом
SHA-in-main срабатывает прежний HOLD + алерт.
- **INV-4 / self-hosting safety**: никаких `push`/`force-push` в `main`; мерж только через Gitea
PR-merge API; прод-контейнер не перезапускается.
- **never-raise**: `merge_pr` / `ensure_open_pr` ловят все исключения и возвращают безопасный
кортеж — контракт сохранён (тесты на never-raise остаются зелёными).
- **Идемпотентность**: `pr_already_merged` (idempotency-guard) и гард already-in-main делают
повторный прогон финализатора бесследным (нет дублей PR/мержей).
- **Область раската**: реально задействуется на merge-verify under-gate (self-hosting,
`merge_verify_applies`); на прочих репо merge делает LLM-deployer — изменение нейтрально.
- **Артефакты pipeline**: создаётся/обновляется только аналитический пакет (`01``04`); в
development-стадии обновятся `CHANGELOG.md`, `.env.example`, merge-gate-раздел доки. ADR
(`06-adr/`) — пишет архитектор.
- Полный регресс `pytest tests/ -q` должен оставаться зелёным.

View File

@@ -0,0 +1,114 @@
---
work_item: ORCH-093
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# 03 — Критерии приёмки (Acceptance Criteria): ORCH-093 — ретрай транзиентных merge-ошибок Gitea + гард already-in-main
Work Item: **ORCH-093** · Repo: **orchestrator** · Стадия: analysis
Формат: каждый критерий имеет **PASS** (что должно быть истинно для приёмки) и **FAIL** (что
считается провалом). Reviewer/tester проверяет их буквально по файлам репозитория и тестам.
---
## AC-1 — ретрай транзиента 405/5xx/таймаут → успешный мерж
**Условие:** `merge_pr` при транзиентной ошибке мержа повторяет `POST …/merge` с backoff и
доводит мерж до успеха в пределах бюджета.
- **PASS:** мок httpx даёт на `POST …/merge` `405` дважды, затем `200``merge_pr` возвращает
`(True, …)`, выполнено ровно 3 `POST`, ложного `False` нет. Аналогично для `5xx` и
таймаута/сетевой ошибки в первых попытках.
- **FAIL:** `merge_pr` возвращает `False` на первом `405`/`5xx`/таймауте (one-shot), не делая
повторных `POST`.
---
## AC-2 — реальный конфликт / не-mergeable НЕ ретраится (быстрый честный HOLD)
**Условие:** `merge_pr` при реальном конфликте (`409`/`422` с `mergeable==False`) или `403` не
зацикливается, а возвращает `(False, …)` быстро.
- **PASS:** мок httpx даёт `409` на `POST …/merge` и `GET /pulls/{n}` с `mergeable=False`
`merge_pr` возвращает `(False, …)` без дополнительных `POST` (не более одной попытки мержа);
reason различим как терминальный. `403` → немедленный `(False, …)`.
- **FAIL:** `merge_pr` ретраит реальный конфликт до исчерпания бюджета (вечный/долгий цикл),
задерживая честный HOLD.
---
## AC-3 — исчерпание ретраев → (False, …) + защита ORCH-071/081 как прежде
**Условие:** если транзиент не проходит за `N` попыток, `merge_pr` возвращает `(False, …)` с
понятным reason; защита «deploy succeeded but not merged» срабатывает как раньше.
- **PASS:** мок даёт `405` на всех `N` попытках → `merge_pr` возвращает
`(False, "merge failed after <N> attempts: HTTP 405")` (или эквивалент); в `_handle_merge_verify`
неподтверждённый SHA-in-main → HOLD + алерт (поведение ORCH-071/081 неизменно). Тест на
не-merged HOLD остаётся зелёным.
- **FAIL:** при исчерпании ретраев reason неинформативен; или защита HOLD не срабатывает / задача
ошибочно уходит в `done`.
---
## AC-4 — гард «ветка уже в main» → нет мусорного PR, задача доходит до done
**Условие:** если ветка уже полностью в `main` (нет коммитов `origin/main..branch`),
`ensure_open_pr` не создаёт PR и возвращает `already-in-main`; финализатор доводит до `done`.
- **PASS:** мок: открытого code-PR нет, `git rev-list --count origin/main..branch == 0`
`ensure_open_pr` возвращает `("already-in-main", …)` и **не делает** `POST …/pulls`; в
`_handle_merge_verify` этот статус пропускает `merge_pr` и `verify_merged_to_main` (SHA-in-main)
подтверждает мерж → задача доходит до `done` без создания пустого PR.
- **FAIL:** `ensure_open_pr` создаёт новый пустой PR на уже влитой ветке, либо статус
`already-in-main` ошибочно трактуется как `failed` (ложный HOLD).
---
## AC-5 — kill-switch / конфиг ретраев; дефолты задокументированы
**Условие:** ретрай-поведение конфигурируемо (число попыток, backoff, kill-switch); при выключении —
one-shot как сейчас; дефолты в `.env.example`.
- **PASS:** в `src/config.py` есть поля `merge_retry_enabled` / `merge_retry_max_attempts` /
`merge_retry_backoff_base_s` / `merge_retry_backoff_max_s` с разумными дефолтами (≈3 / 2 / 5);
`.env.example` содержит дескрипторы `ORCH_MERGE_RETRY_*`; при `merge_retry_enabled=False` тест
подтверждает ровно одну попытку `POST` (one-shot).
- **FAIL:** ретрай захардкожен (нет флагов/kill-switch), или `.env.example` не обновлён, или при
выключенном флаге поведение отличается от текущего one-shot.
---
## AC-6 — never-raise сохранён; регресс зелёный; доки обновлены
**Условие:** контракт never-raise `merge_pr`/`ensure_open_pr` цел; полный регресс зелёный;
документация и `CHANGELOG` обновлены.
- **PASS:** при любой HTTP/parse/сетевой ошибке (в т.ч. внутри ретрай-цикла и git-проверки гарда)
функции возвращают безопасный кортеж, исключение не пробрасывается; `pytest tests/ -q` зелёный;
merge-gate-раздел доки (`docs/architecture/README.md` / `CLAUDE.md`) и `CHANGELOG.md` описывают
ретрай и гард already-in-main.
- **FAIL:** исключение пробрасывается в `advance_stage`; падает любой тест в `tests/`; доки/CHANGELOG
не отражают изменение.
---
## AC-7 — инварианты self-hosting / INV-4 не нарушены
**Условие:** изменение не вводит прямых `push`/`force-push` в `main` и не трогает
`STAGE_TRANSITIONS`/`QG_CHECKS`/схему БД.
- **PASS:** мерж по-прежнему идёт только через Gitea PR-merge API; `git diff` не содержит правок
`STAGE_TRANSITIONS` / состава `QG_CHECKS` / схемы БД; никаких новых вызовов `git push … main`.
- **FAIL:** появился прямой push в `main`, либо изменены `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД.
---
## Сводная матрица AC ↔ FR/BR
| AC | Покрывает |
|----|-----------|
| AC-1 | BR-1 / FR-1, FR-2 |
| AC-2 | BR-2, BR-3 / FR-2 |
| AC-3 | BR-4 / FR-1 |
| AC-4 | BR-5 / FR-3, FR-4 |
| AC-5 | BR-6, BR-7 / FR-5 |
| AC-6 | NFR-1, NFR-6 / FR-1…FR-5 |
| AC-7 | NFR-2 / §6, §7 ТЗ |

View File

@@ -0,0 +1,116 @@
work_item: ORCH-093
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-09
model_used: claude-opus-4-8
title: "Ретрай транзиентных merge-ошибок Gitea (405/5xx) + гард already-in-main"
framework: pytest
scope: >
Покрывает src/merge_gate.py::merge_pr (retry-loop + классификация транзиент/терминал) и
ensure_open_pr (гард «ветка уже в main»), новые флаги src/config.py (ORCH_MERGE_RETRY_*) и
обработку already-in-main в stage_engine._handle_merge_verify. Вне покрытия: реальная сеть Gitea,
STAGE_TRANSITIONS/QG_CHECKS, схема БД.
notes: >
httpx мокается monkeypatch'ем (по образцу tests/test_merge_gate.py / test_orch073_merge_pr.py):
последовательности ответов на POST /pulls/{n}/merge и GET /pulls/{n}. time.sleep патчится в no-op,
чтобы backoff не замедлял тесты. git-операции гарда (rev-list/merge-base) мокаются через
monkeypatch subprocess.run. Полный регресс tests/ должен оставаться зелёным; считается регрессом
любое падение существующих test_merge_gate*/test_merge_verify*/test_orch073*.
tests:
- id: TC-01
type: unit
description: "merge_pr: POST даёт 405,405,200 -> возвращает (True, merged PR #n); ровно 3 POST; ложного False нет (AC-1)"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-02
type: unit
description: "merge_pr: POST даёт 503 (5xx), затем 200 -> ретрай -> (True, ...) (AC-1)"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-03
type: unit
description: "merge_pr: POST бросает httpx Timeout/сетевую ошибку в 1-й попытке, затем 200 -> ретрай -> (True, ...); never-raise (AC-1, AC-6)"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-04
type: unit
description: "merge_pr: реальный конфликт 409 + GET /pulls/{n} mergeable=False -> (False, ...) без доп. POST (терминал, быстрый HOLD) (AC-2)"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-05
type: unit
description: "merge_pr: неоднозначный 409 + GET /pulls/{n} mergeable=True -> классифицирован как транзиент -> ретрай -> 200 -> (True, ...) (AC-2)"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-06
type: unit
description: "merge_pr: 403 (нет прав) -> немедленно (False, ...) без ретрая (терминал) (AC-2)"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-07
type: unit
description: "merge_pr: 405 на всех N попытках -> (False, 'merge failed after N attempts: HTTP 405') с понятным reason (AC-3)"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-08
type: unit
description: "merge_pr: kill-switch merge_retry_enabled=False -> ровно один POST (one-shot, как сейчас) при 405 -> (False, ...) (AC-5, AC-3)"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-09
type: unit
description: "ensure_open_pr: открытого code-PR нет, rev-list --count origin/main..branch == 0 -> ('already-in-main', ...); POST /pulls НЕ вызывается (AC-4)"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-10
type: unit
description: "ensure_open_pr: открытого PR нет, есть невлитые коммиты (count>0) -> создаёт PR ('created', ...) (регресс прежнего поведения) (AC-4)"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-11
type: unit
description: "ensure_open_pr: git-ошибка проверки гарда -> never-raise, безопасный кортеж, без падения (AC-6)"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-12
type: unit
description: "merge_pr/ensure_open_pr: любая непойманная httpx/parse ошибка -> (False/failed, ...) кортеж, исключение не пробрасывается (never-raise) (AC-6)"
module: tests/test_merge_gate.py
expected: PASS
- id: TC-13
type: unit
description: "config: дефолты merge_retry_enabled/merge_retry_max_attempts/backoff_base/backoff_max присутствуют и читаются из ORCH_MERGE_RETRY_* env (AC-5)"
module: tests/test_config.py
expected: PASS
- id: TC-14
type: integration
description: "_handle_merge_verify: ensure_open_pr -> 'already-in-main' пропускает merge_pr, verify_merged_to_main (SHA-in-main) подтверждает -> задача доходит до done без мусорного PR (AC-4)"
module: tests/test_merge_verify.py
expected: PASS
- id: TC-15
type: integration
description: "_handle_merge_verify: merge_pr исчерпал ретраи (False) и SHA-in-main не подтверждён -> HOLD + alert (ORCH-071/081 как прежде), задача удержана на deploy, не done (AC-3)"
module: tests/test_merge_verify.py
expected: PASS
- id: TC-16
type: integration
description: "_handle_merge_verify happy-path: транзиент 405x2->200 в merge_pr -> SHA-in-main подтверждён -> done без ложного HOLD (end-to-end под-гейта deploy->done) (AC-1)"
module: tests/test_merge_verify.py
expected: PASS

View File

@@ -0,0 +1,222 @@
---
work_item: ORCH-093
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# ADR-001: Ретрай транзиентных merge-ошибок Gitea + гард «ветка уже в `main`» (ORCH-093)
Work Item: **ORCH-093** — merge-актор не ретраит транзиентные ошибки Gitea (405/5xx) → ложный HOLD + мусорные PR
Стадия: **architecture**
Сквозная регистрация: **`docs/architecture/adr/adr-0027-merge-actor-transient-retry-and-already-in-main.md`**
(амендмент к [adr-0013](../../../architecture/adr/adr-0013-merge-verify-gate.md) /
[adr-0014](../../../architecture/adr/adr-0014-merge-verify-sha-source-of-truth.md) /
[adr-0016](../../../architecture/adr/adr-0016-ensure-open-pr-before-merge-verify.md) — лехатая merge-verify под-гейта).
## Статус
Proposed
## Контекст
Инцидент **ORCH-063 (09.06)**: self-deploy прошёл, staging OK, PR #98 был `open` + `mergeable=True`,
конфликтов не было — но `POST /pulls/98/merge` вернул `HTTP 405 {"message":"Please try again later"}`
(Gitea пересчитывал `mergeable` сразу после пуша). Сверено по коду прода `src/merge_gate.py`:
- **`merge_pr` (`src/merge_gate.py:700`) — one-shot.** Тело цикла отсутствует: единственный
`POST /pulls/{index}/merge` (стр. 747-752); любой не-`200/201` → немедленно
`return False, "merge failed: HTTP {code}"` (стр. 761). Транзиентная икота Gitea = мгновенный
`False`. Сработала корректная защита ORCH-071/073 «deploy succeeded but not merged»
(`_handle_merge_verify`, `src/stage_engine.py:1527`) → задача удержана на `deploy`, алерт,
**потребовался ручной домерж** (повтор `merge_pr` вручную → влилось с первого раза).
- **`ensure_open_pr` (`src/merge_gate.py:605`) — плодит мусорный PR.** При повторном прогоне
финализатора **после** ручного мержа: код-PR уже `merged+closed``_find_open_code_pr()`
(стр. 639) → `None` → шаг 2 `POST …/pulls` (стр. 663) создаёт **новый пустой PR** на ветке,
которая уже целиком в `main` (diff пустой). Пришлось закрывать вручную.
Контраст: у Claude-агентов есть transient-breaker (`429/overload` ретраится,
`config.transient_max_attempts`/`backoff_*`), у CI-гейта — `check_ci_green`
(`src/qg/checks.py:82`, `ci_poll_max_attempts` × `ci_poll_interval_s` с логом `attempt i/N`).
У детерминированного merge-актора аналога нет. Защита ORCH-071/073 отработала верно, но
**транзиент не должен был требовать человека** — это блокер автономного прогона (эпик ORCH-088) и
оставляет мусор в списке PR Gitea.
«Как есть» не годится: инфра-икота Gitea = ложный HOLD + ручное вмешательство в автономный конвейер.
## Решение
### Сводка
Две точечные доработки `src/merge_gate.py`, обе **аддитивны**, never-raise, под существующими
kill-switch'ами; `STAGE_TRANSITIONS` / `QG_CHECKS` / схема БД — не трогаются; INV-4 (мерж только
через Gitea PR-merge API, никогда `push`/`force-push` в `main`) сохранён.
1. **`merge_pr`** — обернуть **только** `POST …/merge` в ограниченный retry-loop на транзиентных
исходах; терминальные → быстрый честный `False` (защита ORCH-071/073 — как прежде).
2. **`ensure_open_pr`** — гард «ветка уже полностью в `main`» **до** создания PR → новый исход
`"already-in-main"`; `_handle_merge_verify` трактует его как «мержить нечего» и даёт
авторитетному SHA-in-main (`verify_merged_to_main`) довести до `done` без мусорного PR.
### D1 — retry-loop вокруг `POST …/merge` в `merge_pr` (BR-1, BR-4, BR-6, BR-7 / FR-1)
Шаги `merge_pr` **до** POST — без изменений (идемпотентность `pr_already_merged`; `GET …/pulls?state=open`
поиск код-PR `head==branch AND base==main`; `index is None → (False, "no open PR")`). Ретраится
**исключительно** мутирующий `POST /pulls/{index}/merge`:
- Цикл `for attempt in range(1, N+1)`, `N = settings.merge_retry_max_attempts` (дефолт `3`).
- `200/201` → немедленный `(True, "merged PR #<n>")`.
- **транзиентный** исход (D2) И `attempt < N` → лог `attempt i/N` (образец `check_ci_green`) →
`time.sleep(backoff(attempt))` → повтор POST.
- **терминальный** исход (D2) → немедленно `(False, "merge failed: HTTP <code>")`, без ретрая.
- исчерпание на транзиенте → `(False, "merge failed after <N> attempts: HTTP <code>")`.
**Backoff** — экспоненциальный c потолком (идиома transient-breaker агентов, ограничен NFR-4):
`backoff(i) = min(merge_retry_backoff_base_s * 2**(i-1), merge_retry_backoff_max_s)`
(дефолты base `2`, max `5`). Суммарный сон ограничен `(N-1) × backoff_max ≤ 10 с`; плюс
`merge_pr_timeout_s` на POST → верхняя граница задержки детерминирована и **не подвешивает**
monitor-поток, исполняющий merge-verify (NFR-4).
**Kill-switch** `merge_retry_enabled=False` → ровно одна попытка POST = байт-в-байт текущее one-shot
поведение (BR-7, нулевая регрессия). Реализуется как `N_eff = N if merge_retry_enabled else 1` без
ветвления тела цикла.
Привязка: AC-1 (405×2→200 = 3 POST, `True`), AC-3 (405×N → `False` + понятный reason), AC-5
(kill-switch → 1 POST).
### D2 — классификация транзиент vs терминал (BR-2, BR-3 / FR-2)
Leaf-хелпер `_classify_merge_response(repo, branch, index, status_code) -> "transient" | "terminal"`
(never-raise). Дерево решений:
| Исход POST | Класс | Действие |
|------------|-------|----------|
| `405` («try again later»), `408`, любой `5xx` | **transient** | ретрай |
| `httpx`-таймаут / сетевое исключение | **transient** | ретрай (ловится внутри попытки, never-raise) |
| `403` (нет прав), `404` (PR исчез) | **terminal** | быстрый `False` |
| `409` / `422` | **ambiguous** → доп. `GET /pulls/{index}` → поле `mergeable` | см. ниже |
Разрешение неоднозначного `409/422` по `GET /pulls/{index}``mergeable`:
- `mergeable == True`**transient** (Gitea ещё не пересчитал — корневой кейс ORCH-063) → ретрай.
- `mergeable == False`**terminal** (реальный конфликт) → быстрый честный HOLD.
- `mergeable` отсутствует / `None` / сам `GET` упал → **transient** в рамках того же ограниченного
бюджета (см. дефолт-политику ниже).
**Дефолт-политика для `mergeable == None`/недоступного — транзиент** (принято от рекомендации
аналитика, FR-2). Обоснование: (а) цель задачи — не давать ложного HOLD на икоте Gitea, а икота —
именно наблюдаемый кейс с неполным/запаздывающим `mergeable`; (б) цена ошибки ограничена — даже
если за `None` скрывается реальный конфликт, бюджет ретраев конечен (`≤10 с`), после чего
`merge_pr` всё равно вернёт `False` → срабатывает **та же** защита ORCH-071/073 (HOLD + алерт);
(в) обратный выбор (терминал по `None`) воспроизводит ровно тот ложный HOLD, что чинит задача.
Таким образом дефолт fail-OPEN-в-ретрай безопасен: автономность выигрывает, корректность
backstop'а сохранена.
Привязка: AC-1 (транзиент → ретрай), AC-2 (`409`+`mergeable=False`/`403` → терминал, ≤1 POST).
### D3 — гард «ветка уже полностью в `main`» в `ensure_open_pr` (BR-5 / FR-3)
Новый leaf-хелпер `_branch_fully_in_main(repo, branch) -> bool | None` (never-raise), вызывается в
`ensure_open_pr` **после** того как `_find_open_code_pr()` вернул `None` и **до** `POST …/pulls`:
- В per-branch worktree (`ensure_worktree`, изоляция ORCH-2): `git fetch origin main`
`git merge-base --is-ancestor <branch-HEAD> origin/main` (идиома уже используется в
`branch_is_behind_main` / `verify_merged_to_main`; эквивалент `git rev-list --count origin/main..HEAD == 0`).
- `rc == 0` → ветка целиком в `main``True`.
- `rc == 1` → есть невлитые коммиты → `False`.
- git/OS-ошибка / ambiguous rc → `None`.
Маппинг в `ensure_open_pr`:
- `True` → новый исход `("already-in-main", "<reason>")`**PR не создаётся**.
- `False` → текущий путь шага 2 (`POST …/pulls` создать код-PR) — без изменений.
- `None` (**fail-OPEN**) → деградировать на текущее поведение (попытаться создать PR), **НЕ**
блокировать. Обоснование: единственная цель гарда — избежать заведомо пустого PR; вернуть
`"failed"` на git-икоте значило бы превратить инфра-икоту git в ложный no-op/HOLD мержа — ровно
анти-паттерн, против которого предостерегает BRD. SHA-in-main downstream остаётся авторитетным:
даже если на git-ошибке гард ошибётся и создаст пустой PR, это лишь косметика, не ложный `done`.
Сигнатура `ensure_open_pr` расширяется исходом `"already-in-main"` дополнительно к
`"existed"|"created"|"failed"` (обратносовместимо для существующих веток вызова).
**Без отдельного флага:** гард — чистый fail-OPEN correctness-guard, уже целиком накрыт
существующим kill-switch'ем `merge_verify_autocreate_pr_enabled` (вся врезка `ensure_open_pr` в
`_handle_merge_verify` под ним — `src/stage_engine.py:1486`). Отдельный флаг был бы избыточной
конфиг-поверхностью (принято от рекомендации FR-5: «всегда-вкл»).
Привязка: AC-4 (count==0 → `already-in-main`, нет POST …/pulls).
### D4 — обработка `already-in-main` в `_handle_merge_verify` (BR-5 / FR-4)
В `stage_engine._handle_merge_verify` (`src/stage_engine.py:1486-1495`): при
`pr_status == "already-in-main"` — лог, **пропустить** `merge_gate.merge_pr` (мержить нечего) и
сразу к `verify_merged_to_main` (SHA-in-main подтвердит факт мержа → `done`). Это **НЕ** `failed`-ветка
(не HOLD): цель уже достигнута, ветка в `main`. Реализуется флагом `skip_merge`, обнуляющим вызов
`merge_pr` на строке 1498; ветка `verify_merged_to_main` (стр. 1503) и весь нижестоящий код —
без изменений. SHA-in-main остаётся **авторитетным** доказательством мержа (ADR-0014); гард только
избегает мусорного PR и лишнего `merge_pr`.
Деградация safety: если по какой-то причине SHA не в `main` при `already-in-main` (не должно случаться,
т.к. `sha = validated_revision = worktree HEAD`, а ветка целиком в `main`), срабатывает прежний
HOLD (стр. 1527) — fail-closed, безопасно.
Привязка: AC-4 (`already-in-main` → пропуск `merge_pr`, SHA-in-main → `done`).
### D5 — конфигурация (BR-6, BR-7 / FR-5)
Новые поля `src/config.Settings` (по образцу `ci_poll_*` / `merge_pr_timeout_s`), читаются из env:
| Поле | env | Дефолт |
|------|-----|--------|
| `merge_retry_enabled` | `ORCH_MERGE_RETRY_ENABLED` | `True` (kill-switch; `False` → one-shot) |
| `merge_retry_max_attempts` | `ORCH_MERGE_RETRY_MAX_ATTEMPTS` | `3` |
| `merge_retry_backoff_base_s` | `ORCH_MERGE_RETRY_BACKOFF_BASE_S` | `2` |
| `merge_retry_backoff_max_s` | `ORCH_MERGE_RETRY_BACKOFF_MAX_S` | `5` |
Дескрипторы добавляются в `.env.example`. Гард already-in-main — без отдельного флага (D3).
## Альтернативы
- **Ретрай всех steps `merge_pr` (включая `GET …/pulls?state=open`)** — отвергнуто: ретраить нужно
только мутирующий POST; список PR — дешёвый идемпотентный GET, его транзиент-ретрай усложняет
логику без выгоды (повторный POST сам перечитает при необходимости через `pr_already_merged`).
- **Терминал по `mergeable == None`** — отвергнуто: воспроизводит ложный HOLD, который чинит задача
(см. D2); бюджет ретраев конечен, backstop ORCH-071/073 сохранён.
- **Фиксированный interval-backoff (как `check_ci_green`)** — отвергнуто в пользу экспоненциального
с потолком: merge-икота короткая, экспонента с малым потолком (`5 с`) быстрее проходит первую
попытку и жёстко ограничена сверху (NFR-4).
- **`"failed"` на git-ошибке гарда already-in-main** — отвергнуто: превращает икоту git в ложный
no-op/HOLD мержа (анти-паттерн BRD); выбран fail-OPEN-в-create (D3).
- **Отдельный kill-switch для гарда already-in-main** — отвергнуто: уже накрыт
`merge_verify_autocreate_pr_enabled`; лишняя конфиг-поверхность.
- **Снять/ослабить защиту ORCH-071/081** — вне объёма и неверно: защита корректна, задача лишь
снижает **ложные** срабатывания.
## Последствия
- **+** Транзиентная икота Gitea (405/5xx/таймаут/«not mergeable yet») переживается автоматически →
нет ложного HOLD, нет ручного домержа в автономном прогоне (ORCH-088).
- **+** Нет мусорных пустых PR на уже влитой ветке; повторный прогон финализатора идемпотентен (NFR-5).
- **+** Реальный конфликт по-прежнему даёт быстрый честный HOLD (≤1 POST); защита ORCH-071/073 — 1:1.
- **+** Наблюдаемость: каждый ретрай логируется `attempt i/N` + класс (transient/terminal) (NFR-6).
- **** Доп. `GET /pulls/{index}` на неоднозначном `409/422` (один лишний дешёвый запрос только в
редком ambiguous-кейсе) — приемлемо.
- **** Дефолт-политика `mergeable==None → transient` может на реальном конфликте добавить ≤10 с
до HOLD. Митигейшн: бюджет жёстко ограничен; HOLD всё равно срабатывает.
- **** Расширение возврата `ensure_open_pr` новым исходом — все вызовы перечислены, BC сохранён.
- **Откат:** `ORCH_MERGE_RETRY_ENABLED=false` → one-shot `merge_pr` (нынешнее поведение);
`ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED=false` отключает врезку `ensure_open_pr` целиком (вместе
с гардом). Полный откат кода — revert PR; флаги дают мгновенный runtime-откат без деплоя кода.
## Ссылки
- BRD: `docs/work-items/ORCH-093/01-brd.md`
- TRZ: `docs/work-items/ORCH-093/02-trz.md`
- Acceptance: `docs/work-items/ORCH-093/03-acceptance-criteria.md`
- Tech-risks: `docs/work-items/ORCH-093/10-tech-risks.md`
- Сквозной ADR: `docs/architecture/adr/adr-0027-merge-actor-transient-retry-and-already-in-main.md`
- Лехатая merge-verify: [adr-0013](../../../architecture/adr/adr-0013-merge-verify-gate.md),
[adr-0014](../../../architecture/adr/adr-0014-merge-verify-sha-source-of-truth.md),
[adr-0016](../../../architecture/adr/adr-0016-ensure-open-pr-before-merge-verify.md)
- Сверено по коду: `src/merge_gate.py` (`merge_pr:700`, `ensure_open_pr:605`,
`branch_is_behind_main:53`, `verify_merged_to_main:767`), `src/stage_engine.py`
(`_handle_merge_verify:1447`), `src/qg/checks.py` (`check_ci_green:82`), `src/config.py`
(`ci_poll_*:140`, `merge_pr_timeout_s:549`, `transient_max_attempts:77`)

View File

@@ -0,0 +1,36 @@
---
work_item: ORCH-093
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-09
model_used: claude-opus-4-8
---
# 10 — Технические риски: ORCH-093 — ретрай транзиентных merge-ошибок Gitea + гард already-in-main
Work Item: **ORCH-093** · Repo: **orchestrator** · Стадия: architecture
> Информационный (гейтом не парсится). Перечисляет риски реализации и их митигейшн.
## Реестр рисков
| ID | Риск | Вер. | Влия. | Митигейшн |
|----|------|------|-------|-----------|
| TR-1 | Ошибочная классификация реального конфликта как транзиента (`mergeable==None`/неполный ответ) → лишние ретраи перед HOLD | Сред. | Низ. | D2: бюджет ретраев жёстко ограничен (`(N-1)×backoff_max ≤ 10 с`); после исчерпания — тот же HOLD ORCH-071/073. Цена ≤10 с задержки, не ложный `done`. |
| TR-2 | Слишком агрессивный/долгий ретрай подвешивает monitor-поток, исполняющий merge-verify | Низ. | Сред. | D1/NFR-4: экспон. backoff с потолком `merge_retry_backoff_max_s`; суммарный сон детерминирован; `merge_pr_timeout_s` ограничивает каждый POST. |
| TR-3 | Гонка гарда already-in-main vs параллельный мерж (ветка влита между `_find_open_code_pr` и `_branch_fully_in_main`) | Низ. | Низ. | SHA-в-main (`verify_merged_to_main`, ADR-0014) остаётся авторитетным; гард лишь избегает пустого PR. Ложный `done` невозможен — решает SHA, не гард. |
| TR-4 | git-икота гарда (`fetch`/`merge-base` падает) → ложный `already-in-main` → пропуск реального мержа | Низ. | Выс. | D3: fail-OPEN — `None` деградирует на create-PR, НЕ на `already-in-main`; ложный пропуск мержа структурно невозможен (для `already-in-main` нужен rc==0, не ошибка). |
| TR-5 | Регрессия one-shot поведения при `merge_retry_enabled=False` | Низ. | Сред. | BR-7: `N_eff = 1` без ветвления тела цикла; тест AC-5 подтверждает ровно один POST. |
| TR-6 | Расширение возврата `ensure_open_pr` (`already-in-main`) ломает необработанную ветку вызова | Низ. | Сред. | Все вызовы перечислены (`_handle_merge_verify`, `launcher._ensure_pr`); BC: новый исход обрабатывается явно, прочие пути 1:1. Покрытие — тест AC-4. |
| TR-7 | Лишний `GET /pulls/{index}` на ambiguous `409/422` сам транзиентно падает → неверный класс | Низ. | Низ. | never-raise: сбой `GET` → дефолт transient в рамках бюджета (D2); никогда не исключение в `advance_stage`. |
## Сводный вывод
Доминирующий класс — **корректность классификации транзиент/терминал** (TR-1, TR-4): обе ветки
спроектированы fail-safe в сторону, противоположную багу (ретрай-с-бюджетом и fail-OPEN-в-create),
с авторитетным backstop'ом SHA-в-main + защитой ORCH-071/073, которые не трогаются. Остаточный риск
для прод-конвейера (self-hosting) **низкий**: изменение точечное, аддитивное, полностью отключаемо
двумя существующими/новыми kill-switch'ами без деплоя кода; `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД
не затронуты. Эскалация `arch:major-change` **не требуется**; возврат в анализ **не требуется**
ТЗ реализуемо без нарушения принципов архитектуры.

View File

@@ -0,0 +1,89 @@
---
verdict: APPROVED
work_item: ORCH-093
stage: review
author_agent: reviewer
status: approved
created_at: 2026-06-09
model_used: claude-opus-4-8
type: review
work_item_id: ORCH-093
version: 1
---
# Review ORCH-093
## Summary
Две точечные доработки детерминированного merge-актора (`src/merge_gate.py`), чинящие инцидент
**ORCH-063** (ложный HOLD на транзиентном `HTTP 405` от Gitea + мусорный пустой PR на уже влитой
ветке): (1) retry-loop вокруг мутирующего `POST …/merge` с классификатором транзиент/терминал;
(2) гард `already-in-main` в `ensure_open_pr` + врезка в `_handle_merge_verify`.
Реализация **полностью соответствует** ТЗ (FR-1…FR-5), критериям приёмки (AC-1…AC-7) и ADR-001
(D1…D5). Контракты сохранены: never-raise, INV-4 (мерж только через Gitea PR-merge API, никогда
`push`/`force-push` в `main`), `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД — байт-в-байт не тронуты
(проверено `git diff`: затронуты только `src/merge_gate.py`, `src/config.py` и точечно
`src/stage_engine.py`). Защита ORCH-071/073/081 («deploy succeeded but not merged») сохранена 1:1:
терминал/исчерпание ретраев → `(False, …)` → прежний HOLD+alert.
**Тесты содержательные и зелёные:** `tests/test_merge_gate.py` (TC-01…TC-12), `tests/test_config.py`
(TC-13), `tests/test_merge_verify.py` (TC-14…TC-16), обновлён `tests/test_orch082_ensure_pr.py`.
Локальный прогон затронутых сьютов — **72 passed**. Каждый AC покрыт буквально (405×2→200=3 POST;
5xx→200; network→200; реальный конфликт/403 терминал без ретрая; ambiguous-409+mergeable=True ретрай;
исчерпание; kill-switch one-shot; already-in-main без POST; fail-OPEN на git-ошибке гарда;
never-raise).
**Трассировка (TRACEABILITY.md):** правки в блоках с маркерами ORCH-071/073/082 сверены с их
инвариантами — SHA-in-main остаётся единственным авторитетным доказательством мержа (ADR-0014),
idempotency-guard `pr_already_merged`, фильтр `base==main` для code-PR, never-raise — сохранены.
В append-only `MAIN_REGRESSION_MARKERS` корректно добавлена строка
`("ORCH-093", "_classify_merge_response", "src/merge_gate.py")` — без слома существующих маркеров.
Документация обновлена (CHANGELOG, `.env.example`, `CLAUDE.md`, локальный ADR-001 + сквозной
adr-0027, `docs/architecture/README.md`). Один P2 по гигиене документации (дубль секции в README) —
не блокирует приёмку.
## Findings
### P0 — Blocker
- Нет.
### P1 — Must fix
- Нет.
### P2 — Should fix
- [ ] **Дубль секции ORCH-093 в `docs/architecture/README.md`.** Один и тот же заголовок
`#### Ретрай транзиентных merge-ошибок Gitea + гард already-in-main (ORCH-093 — фикс ложного HOLD
на 405/5xx)` встречается **дважды** — строки **480516** и **518550**с почти идентичным,
перекрывающимся содержимым и совпадающим markdown-anchor'ом. Подтверждено `git diff` (на `origin/main`
— 0 вхождений, на ветке — 2), т.е. обе секции добавлены этим PR (вероятно случайная вставка/дубль
блока при правке golden-source). README — обзорная витрина архитектуры; дублирующий блок с
коллизией заголовков следует схлопнуть в одну секцию (оставить вариант 480516 или 518550, не оба).
Правило: `CLAUDE.md` §2 «документация = golden source», стандарт обзорных доков (ORCH-079).
### P3 — Nice to have
- [ ] **`tests/test_merge_gate.py::_PostSeq`** обращается к `self._items_last` до его первой
инициализации, если конструктору передать пустой список (атрибут ставится только после первого
`pop`). Сейчас не срабатывает (все вызовы передают непустую последовательность), но защититься
дефолтом `self._items_last = None` в `__init__` дешевле, чем потенциальный `AttributeError` при
будущем редактировании теста.
## Документация
Проверка обязательна (изменён `src/`). Статус — **обновлена** (golden source синхронизирован с кодом):
| Артефакт | Статус |
|----------|--------|
| `CHANGELOG.md` | ✅ запись ORCH-093 (`[Unreleased]`) с детализацией retry/guard/конфиг/тесты |
| `.env.example` | ✅ дескрипторы `ORCH_MERGE_RETRY_*` (4 поля) + пояснительный блок |
| `CLAUDE.md` | ✅ абзац ORCH-093 в секции «Очередь задач» |
| `docs/architecture/README.md` | ⚠️ обновлена, но **секция продублирована** (P2 — схлопнуть) |
| `docs/work-items/ORCH-093/06-adr/ADR-001-…md` | ✅ локальный ADR (proposed) |
| `docs/architecture/adr/adr-0027-…md` | ✅ сквозной ADR (amends 0013/0014/0016) |
API / `STAGE_TRANSITIONS` / QG / схема БД не менялись → доп. обновлений не требуется. Пункт
`README.md` «Известные ограничения» данным PR не закрывается (ORCH-079 не применим).
**Вывод:** P0/P1 нет; единственный P2 — косметический дубль секции README (не блокирует). Verdict —
`APPROVED`. Рекомендую попутно схлопнуть дубль перед мержем.

View File

@@ -0,0 +1,83 @@
---
result: PASS
work_item: ORCH-093
stage: testing
author_agent: tester
status: pass
created_at: 2026-06-09
model_used: claude-opus-4-8
type: test-report
work_item_id: ORCH-093
---
# Test Report — ORCH-093
merge-актор ретраит транзиентные ошибки Gitea (405/5xx/таймаут) + гард «ветка уже в main».
## Окружение
- Python: 3.12.13
- pytest: 8.3.3
- Worktree: `/repos/_wt/orchestrator/feature_ORCH-093-bug-merge-gitea-405-5xx-hold-p`
- Branch: `feature/ORCH-093-bug-merge-gitea-405-5xx-hold-p`
- Дата: 2026-06-09
## Предусловия
- Review verdict: **APPROVED** (`12-review.md`, P0/P1 нет; единственный P2 — косметический дубль секции README, не блокирует).
## Smoke API (read-only, prod 8500)
| Эндпоинт | Результат |
|----------|-----------|
| `GET /health` | `{"status":"ok","service":"orchestrator"}` — OK |
| `GET /status` | OK (active_tasks отдаётся; ORCH-093 task#78 в `testing`) |
| `GET /queue` | OK — блок `serial_gate` **присутствует** (ORCH-088), `auto_labels` **присутствует** (ORCH-089), `stop` присутствует (ORCH-090). Регресса смока нет. |
## Результаты (покрытие ТЗ — каждый TC из 04-test-plan.yaml)
| TC ID | Описание (AC) | Тест | Результат |
|-------|---------------|------|-----------|
| TC-01 | merge_pr: 405,405,200 → (True, …); ровно 3 POST; ложного False нет (AC-1) | `test_merge_gate.py::test_tc01_merge_retries_405_then_succeeds` | PASS |
| TC-02 | merge_pr: 503 (5xx)→200 → ретрай → (True, …) (AC-1) | `test_merge_gate.py::test_tc02_merge_retries_5xx_then_succeeds` | PASS |
| TC-03 | merge_pr: httpx Timeout/сетевая→200 → ретрай; never-raise (AC-1, AC-6) | `test_merge_gate.py::test_tc03_merge_retries_network_error_then_succeeds` | PASS |
| TC-04 | merge_pr: 409 + GET mergeable=False → (False, …) без доп. POST (терминал) (AC-2) | `test_merge_gate.py::test_tc04_real_conflict_terminal_no_retry` | PASS |
| TC-05 | merge_pr: ambiguous 409 + GET mergeable=True → транзиент → ретрай → 200 (AC-2) | `test_merge_gate.py::test_tc05_ambiguous_409_mergeable_true_retries` | PASS |
| TC-06 | merge_pr: 403 → немедленно (False, …) без ретрая (терминал) (AC-2) | `test_merge_gate.py::test_tc06_403_terminal_no_retry` | PASS |
| TC-07 | merge_pr: 405 на всех N → (False, 'merge failed after N attempts…') понятный reason (AC-3) | `test_merge_gate.py::test_tc07_exhausts_retries_clear_reason` | PASS |
| TC-08 | merge_pr: kill-switch off → ровно один POST (one-shot) при 405 (AC-5, AC-3) | `test_merge_gate.py::test_tc08_killswitch_off_one_shot` | PASS |
| TC-09 | ensure_open_pr: count==0 → ('already-in-main', …); POST /pulls НЕ вызван (AC-4) | `test_merge_gate.py::test_tc09_ensure_already_in_main_no_post` | PASS |
| TC-10 | ensure_open_pr: count>0 → создаёт PR (регресс прежнего поведения) (AC-4) | `test_merge_gate.py::test_tc10_ensure_creates_when_commits_beyond_main` | PASS |
| TC-11 | ensure_open_pr: git-ошибка гарда → never-raise, fail-open (AC-6) | `test_merge_gate.py::test_tc11_ensure_guard_git_error_fail_open`, `::test_tc11_branch_fully_in_main_never_raises` | PASS |
| TC-12 | merge_pr/ensure_open_pr: любая httpx/parse ошибка → безопасный кортеж, never-raise (AC-6) | `test_merge_gate.py::test_tc12_merge_pr_never_raises`, `::test_tc12_ensure_open_pr_never_raises` | PASS |
| TC-13 | config: дефолты merge_retry_* + чтение ORCH_MERGE_RETRY_* env (AC-5) | `test_config.py::test_merge_retry_settings_defaults`, `::test_merge_retry_settings_env_override` | PASS |
| TC-14 | _handle_merge_verify: 'already-in-main' пропускает merge_pr, SHA-in-main → done (AC-4) | `test_merge_verify.py::test_tc14_already_in_main_skips_merge_pr_then_done` | PASS |
| TC-15 | _handle_merge_verify: merge_pr исчерпал ретраи + SHA не подтверждён → HOLD+alert (ORCH-071/081) (AC-3) | `test_merge_verify.py::test_tc15_merge_failed_and_not_in_main_holds` | PASS |
| TC-16 | _handle_merge_verify happy-path: 405x2→200 → SHA-in-main → done без ложного HOLD (AC-1) | `test_merge_verify.py::test_tc16_transient_retry_success_then_done` | PASS |
**Сопоставление с `03-acceptance-criteria.md`:** AC-1 (TC-01/02/03/16), AC-2 (TC-04/05/06),
AC-3 (TC-07/15), AC-4 (TC-09/10/14), AC-5 (TC-08/13), AC-6 (TC-11/12 + зелёный регресс),
AC-7 (`STAGE_TRANSITIONS`/`QG_CHECKS`/сигнатуры неизменны — `test_config.py::test_tc19_*` зелёные).
Все 16 TC выполнены и сопоставлены.
## Вывод pytest
Полный регресс из worktree ветки задачи:
```
$ cd /repos/_wt/orchestrator/feature_ORCH-093-bug-merge-gitea-405-5xx-hold-p && pytest tests/ -v --tb=short
...
======================= 1389 passed, 1 warning in 44.62s =======================
```
Целевые сьюты ORCH-093 (`test_merge_gate.py`, `test_config.py`, `test_merge_verify.py`,
`test_orch082_ensure_pr.py`):
```
======================== 72 passed, 1 warning in 1.84s =========================
```
Единственный warning — `PydanticDeprecatedSince20` (class-based config, существующий, не связан с ORCH-093).
Падений и регрессов `test_merge_gate*/test_merge_verify*/test_orch08*/test_config*` нет.
## Итог
PASS — все 1389 тестов зелёные, целевые TC-01…TC-16 PASS и сопоставлены с AC-1…AC-7,
smoke read-only OK (`serial_gate`/`auto_labels` присутствуют в `/queue`). Задача переходит на `deploy-staging`.

View File

@@ -0,0 +1,12 @@
---
deploy_status: SUCCESS
work_item: ORCH-093
hook_exit_code: 0
deployed_by: deploy-finalizer
---
# Deploy log — ORCH-036 executable self-deploy
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.

View File

@@ -549,6 +549,31 @@ class Settings(BaseSettings):
merge_pr_timeout_s: int = 60
merge_verify_timeout_s: int = 60
# ORCH-093: deterministic merge-actor retry of TRANSIENT Gitea merge errors.
# The incident ORCH-063 had a green self-deploy + an open, mergeable PR, yet
# POST /pulls/{n}/merge returned HTTP 405 ("Please try again later") because
# Gitea was still recomputing `mergeable` right after the push — the one-shot
# merge_pr returned False, the ORCH-071/081 backstop HELD the task on `deploy`,
# and a human had to re-merge by hand. merge_pr now wraps ONLY the mutating
# POST in a bounded exponential-backoff retry-loop on TRANSIENT outcomes
# (405/408/5xx/network-timeout, and 409|422 while the PR is still mergeable);
# TERMINAL outcomes (403/404/real conflict) -> fast honest False (the HOLD
# protection is unchanged). Mirrors the ci_poll_* idiom of check_ci_green.
# merge_retry_enabled -> kill-switch; False -> exactly one POST
# (byte-for-byte the prior one-shot behaviour,
# env ORCH_MERGE_RETRY_ENABLED).
# merge_retry_max_attempts -> max POST attempts on a transient outcome
# (env ORCH_MERGE_RETRY_MAX_ATTEMPTS).
# merge_retry_backoff_base_s -> exponential backoff base seconds
# (env ORCH_MERGE_RETRY_BACKOFF_BASE_S).
# merge_retry_backoff_max_s -> per-sleep backoff ceiling seconds; total sleep
# is bounded by (N-1) * max so the monitor-thread
# is never wedged (env ORCH_MERGE_RETRY_BACKOFF_MAX_S).
merge_retry_enabled: bool = True
merge_retry_max_attempts: int = 3
merge_retry_backoff_base_s: int = 2
merge_retry_backoff_max_s: int = 5
# ORCH-026: intra-repo merge serialisation (Level A) + declarative task
# dependencies (Level B). Level A reuses the ORCH-043/065 merge-lease window
# (no new mechanism) — the merge-lease already serialises "merge -> main-updated"

View File

@@ -602,6 +602,51 @@ def merge_verify_applies(repo: str) -> bool:
return False
def _branch_fully_in_main(repo: str, branch: str) -> bool | None:
"""Return True iff ``branch`` has NO commits beyond ``origin/main`` (ORCH-093 D3).
Used by ``ensure_open_pr`` to avoid creating an empty PR on a branch that is
already fully merged into ``main`` (the ORCH-063 garbage-PR symptom on a
re-driven finalizer after a manual merge). In the per-branch worktree:
``git fetch origin main`` then ``git merge-base --is-ancestor HEAD origin/main``
(equivalent to ``git rev-list --count origin/main..HEAD == 0``; same idiom as
``branch_is_behind_main`` / ``verify_merged_to_main``).
* ``rc == 0`` -> HEAD is an ancestor of origin/main -> fully in main -> ``True``.
* ``rc == 1`` -> there are commits beyond main -> ``False``.
* git/OS error / ambiguous rc -> ``None`` (caller fail-OPENs: degrade to the
create path; an infra hiccup must NOT become a false no-op merge).
Never-raise: any error -> ``None``.
"""
try:
wt = ensure_worktree(repo, branch)
except Exception as e: # noqa: BLE001 - never-raise contract -> fail-OPEN
logger.warning("_branch_fully_in_main: worktree error for %s/%s: %s", repo, branch, e)
return None
try:
subprocess.run(
["git", "-C", wt, "fetch", "origin", "main"],
capture_output=True, timeout=_FETCH_TIMEOUT,
)
r = subprocess.run(
["git", "-C", wt, "merge-base", "--is-ancestor", "HEAD", "origin/main"],
capture_output=True, timeout=_SHORT_TIMEOUT,
)
except (subprocess.SubprocessError, OSError) as e:
logger.warning("_branch_fully_in_main: git error for %s/%s: %s", repo, branch, e)
return None
if r.returncode == 0:
return True
if r.returncode == 1:
return False
logger.warning(
"_branch_fully_in_main: ambiguous merge-base rc=%s for %s/%s (fail-open)",
r.returncode, repo, branch,
)
return None
def ensure_open_pr(repo: str, branch: str) -> tuple[str, str]:
"""Guarantee an open **code-PR** (``head==branch`` AND ``base=="main"``) exists.
@@ -625,6 +670,12 @@ def ensure_open_pr(repo: str, branch: str) -> tuple[str, str]:
``("existed", …)``; no duplicate is created (AC-2 / FR-5).
4. Any other HTTP/parse/network error -> ``("failed", "<reason>")``.
ORCH-093 (D3) adds a guard BETWEEN steps 1 and 2: if the branch is already fully
in ``main`` (no commits beyond ``origin/main``) there is nothing to PR -> the new
outcome ``("already-in-main", "<reason>")`` is returned WITHOUT a ``POST`` (avoids
an empty garbage PR on a re-driven finalizer). A git error of the guard fails OPEN
(degrade to the create path) so an infra hiccup never becomes a false no-op.
Reuses ``settings.merge_pr_timeout_s`` (same class of Gitea calls as ``merge_pr``).
Never-raise (AC-7): any unexpected error -> ``("failed", str(e))``; the exception is
NEVER propagated into ``_handle_merge_verify`` / ``advance_stage``.
@@ -657,6 +708,21 @@ def ensure_open_pr(repo: str, branch: str) -> tuple[str, str]:
logger.info("ensure_open_pr: %s/%s already has open code-PR #%s", repo, branch, existing)
return "existed", str(existing)
# Step 1b (ORCH-093 D3): guard "branch already fully in main". If the branch
# has no commits beyond origin/main there is nothing to PR — creating one
# would yield an empty garbage PR (the ORCH-063 symptom on a re-driven
# finalizer after a manual merge). Return the new "already-in-main" outcome
# so _handle_merge_verify skips merge_pr and lets the authoritative
# SHA-in-main check confirm -> done. fail-OPEN on git error / ambiguous
# (None): degrade to the create path below, NEVER block — an infra hiccup
# must not become a false no-op merge (SHA-in-main downstream stays the proof).
if _branch_fully_in_main(repo, branch) is True:
logger.info(
"ensure_open_pr: %s/%s already fully in main -> already-in-main (no PR created)",
repo, branch,
)
return "already-in-main", "branch already in main (no commits beyond origin/main)"
# Step 2: create the code-PR onto main.
parts = branch.split("/")
title = parts[-1] if parts else branch
@@ -697,6 +763,89 @@ def ensure_open_pr(repo: str, branch: str) -> tuple[str, str]:
return "failed", f"ensure_open_pr error: {e}"
# ---------------------------------------------------------------------------
# ORCH-093: transient-error retry of the merge POST + classification helpers.
# ---------------------------------------------------------------------------
def _merge_backoff(attempt: int) -> float:
"""Exponential backoff (s) with a ceiling for the merge-POST retry (ORCH-093 D1).
``backoff(i) = min(base * 2**(i-1), max)`` — the transient-breaker idiom of the
Claude agents, bounded so the total sleep ``(N-1) * max`` can never wedge the
monitor-thread running merge-verify (NFR-4). Defaults base=2, max=5 -> the
sequence is 2, 4, 5, 5, … seconds.
"""
base = settings.merge_retry_backoff_base_s
cap = settings.merge_retry_backoff_max_s
try:
return float(min(base * (2 ** (max(attempt, 1) - 1)), cap))
except Exception: # noqa: BLE001 - never-raise; degrade to the ceiling
return float(cap)
def _pr_mergeable(repo: str, index) -> bool | None:
"""Read the ``mergeable`` field of PR ``index`` via ``GET /pulls/{index}`` (ORCH-093 D2).
Used ONLY to disambiguate a ``409``/``422`` merge POST: Gitea may still be
recomputing mergeability right after a push (the ORCH-063 root cause). Returns
the boolean ``mergeable`` flag, or ``None`` when it is absent / non-boolean / the
GET fails (never-raise) — the caller treats ``None`` as the default-policy
transient (D2).
"""
try:
import httpx
owner = settings.gitea_owner
headers = {"Authorization": f"token {settings.gitea_token}"}
resp = httpx.get(
f"{settings.gitea_url}/api/v1/repos/{owner}/{repo}/pulls/{index}",
headers=headers, timeout=settings.merge_pr_timeout_s,
)
if resp.status_code != 200:
return None
val = (resp.json() or {}).get("mergeable")
return val if isinstance(val, bool) else None
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("_pr_mergeable check failed for %s PR #%s: %s", repo, index, e)
return None
def _classify_merge_response(repo: str, branch: str, index, status_code: int) -> str:
"""Classify a non-2xx ``POST /pulls/{index}/merge`` outcome (ORCH-093 D2).
Returns ``"transient"`` (retry within budget) or ``"terminal"`` (fast honest
``False``; the ORCH-071/081 HOLD backstop takes over). Decision tree:
* ``405`` ("try again later"), ``408``, any ``5xx`` -> **transient**.
* ``403`` (no rights), ``404`` (PR gone) -> **terminal**.
* ``409`` / ``422`` (ambiguous) -> ``GET /pulls/{index}`` -> ``mergeable``:
- ``False`` -> **terminal** (real conflict, fast HOLD).
- ``True`` / ``None`` / GET failed -> **transient** (default-policy
fail-OPEN-in-retry: Gitea has not recomputed yet — the ORCH-063 case;
the retry budget is finite, so a real conflict still HOLDs after it).
* any other unexpected code -> **terminal** (do not loop on unknowns).
Never-raise: any error -> ``"transient"`` (conservative, within the bounded
retry budget).
"""
try:
if status_code in (405, 408) or 500 <= status_code <= 599:
return "transient"
if status_code in (403, 404):
return "terminal"
if status_code in (409, 422):
mergeable = _pr_mergeable(repo, index)
if mergeable is False:
return "terminal"
# True OR None/unavailable -> transient (default-policy, D2).
return "transient"
return "terminal"
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning(
"_classify_merge_response error for %s/%s PR #%s: %s (transient)",
repo, branch, index, e,
)
return "transient"
def merge_pr(repo: str, branch: str) -> tuple[bool, str]:
"""Deterministically merge the open PR for ``branch`` via the Gitea PR-merge API.
@@ -712,8 +861,16 @@ def merge_pr(repo: str, branch: str) -> tuple[bool, str]:
(FR-3) adds the ``base == main`` filter so the actor merges exactly the
feature code-PR and never an auto docs-PR / a PR onto a foreign base. No
such open PR -> ``(False, "no open PR")``.
3. ``POST /repos/{owner}/{repo}/pulls/{index}/merge`` (Do: ``merge``) ->
200/201 -> ``(True, "merged PR #<n>")``; otherwise ``(False, "<reason>")``.
3. ``POST /repos/{owner}/{repo}/pulls/{index}/merge`` (Do: ``merge``) in a
bounded retry-loop (ORCH-093 D1): ``200/201`` -> ``(True, "merged PR #<n>")``;
a TRANSIENT outcome (405/408/5xx/network/timeout, or 409|422 while still
mergeable) is retried with exponential backoff up to
``merge_retry_max_attempts``; a TERMINAL outcome (403/404/real conflict) ->
immediate ``(False, "merge failed: HTTP <code>")``; exhausting the budget on
a transient -> ``(False, "merge failed after <N> attempts: HTTP <code>")``.
The kill-switch ``merge_retry_enabled=False`` forces exactly one POST
(the prior one-shot behaviour). Only the mutating POST is retried — the
idempotent steps above are not.
Never-raise (INV-1/AC-9 / TC-09): any HTTP/parse error -> ``(False, reason)``.
"""
@@ -744,21 +901,59 @@ def merge_pr(repo: str, branch: str) -> tuple[bool, str]:
if index is None:
return False, "no open PR"
m = httpx.post(
f"{base}/pulls/{index}/merge",
json={"Do": "merge"},
headers=headers,
timeout=timeout,
)
if m.status_code in (200, 201):
logger.info("merge_pr: merged PR #%s for %s/%s", index, repo, branch)
return True, f"merged PR #{index}"
detail = (m.text or "").strip()[:200]
logger.warning(
"merge_pr: merge failed for %s/%s PR #%s: HTTP %s %s",
repo, branch, index, m.status_code, detail,
)
return False, f"merge failed: HTTP {m.status_code}"
# ORCH-093 D1: retry ONLY the mutating POST on transient outcomes. The
# kill-switch collapses the budget to one attempt = the prior one-shot path
# (no branching of the loop body, ADR D1).
n_eff = settings.merge_retry_max_attempts if settings.merge_retry_enabled else 1
if n_eff < 1:
n_eff = 1
for attempt in range(1, n_eff + 1):
try:
m = httpx.post(
f"{base}/pulls/{index}/merge",
json={"Do": "merge"},
headers=headers,
timeout=timeout,
)
except (httpx.HTTPError, OSError) as e:
# Network/timeout -> transient within the bounded budget (never-raise).
logger.warning(
"merge_pr: attempt %s/%s network error for %s/%s PR #%s: %s (transient)",
attempt, n_eff, repo, branch, index, e,
)
if attempt < n_eff:
time.sleep(_merge_backoff(attempt))
continue
return False, f"merge failed after {n_eff} attempts: network error"
if m.status_code in (200, 201):
logger.info(
"merge_pr: merged PR #%s for %s/%s (attempt %s/%s)",
index, repo, branch, attempt, n_eff,
)
return True, f"merged PR #{index}"
detail = (m.text or "").strip()[:200]
cls = _classify_merge_response(repo, branch, index, m.status_code)
if cls == "terminal":
logger.warning(
"merge_pr: merge failed for %s/%s PR #%s: HTTP %s %s (terminal)",
repo, branch, index, m.status_code, detail,
)
return False, f"merge failed: HTTP {m.status_code}"
# Transient: log attempt i/N (check_ci_green idiom) and retry if budget left.
logger.warning(
"merge_pr: attempt %s/%s transient HTTP %s for %s/%s PR #%s %s",
attempt, n_eff, m.status_code, repo, branch, index, detail,
)
if attempt < n_eff:
time.sleep(_merge_backoff(attempt))
continue
return False, f"merge failed after {n_eff} attempts: HTTP {m.status_code}"
# Unreachable (loop always returns), defensive only.
return False, f"merge failed after {n_eff} attempts"
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("merge_pr unexpected error for %s/%s: %s", repo, branch, e)
return False, f"merge error: {e}"
@@ -841,6 +1036,7 @@ MAIN_REGRESSION_MARKERS: list[tuple[str, str, str]] = [
("ORCH-071", "verify_merged_to_main", "src/merge_gate.py"),
("ORCH-073", "check_main_regression", "src/merge_gate.py"),
("ORCH-082", "ensure_open_pr", "src/merge_gate.py"),
("ORCH-093", "_classify_merge_response", "src/merge_gate.py"),
]

View File

@@ -1483,6 +1483,7 @@ def _handle_merge_verify(task_id, repo, work_item_id, branch, result: AdvanceRes
# `created`/`existed` -> proceed unchanged; `failed` -> honest HOLD with a
# distinguishable text (NOT the not-merged HOLD). ORCH-073's SHA-in-main proof
# below is untouched and stays authoritative. Kill-switch off -> 1:1 prior path.
skip_merge = False
if settings.merge_verify_autocreate_pr_enabled:
pr_status, pr_detail = merge_gate.ensure_open_pr(repo, branch)
logger.info(
@@ -1492,10 +1493,25 @@ def _handle_merge_verify(task_id, repo, work_item_id, branch, result: AdvanceRes
return _hold_pr_create_failed(
task_id, repo, work_item_id, branch, pr_detail, result
)
if pr_status == "already-in-main":
# ORCH-093 (D4): the branch is already fully in `main` -> nothing to
# merge and no PR was created. Skip the deterministic merge_pr; the
# authoritative SHA-in-main check below confirms the merge -> done.
# This is NOT a HOLD (the goal is already achieved); if for some
# reason the SHA is not in main the prior not-merged HOLD still fires
# (fail-closed, safe).
logger.info(
f"Task {task_id}: merge-verify already-in-main -> skip merge_pr "
"(SHA-in-main authoritative)"
)
skip_merge = True
# "created" | "existed" -> proceed normally to merge_pr.
# Deterministic merge-actor (no-op if the PR is already merged, INV-5/AC-9).
merged_ok, merge_msg = merge_gate.merge_pr(repo, branch)
if skip_merge:
merged_ok, merge_msg = True, "already-in-main (skipped merge_pr)"
else:
merged_ok, merge_msg = merge_gate.merge_pr(repo, branch)
logger.info(
f"Task {task_id}: merge-verify merge_pr -> ok={merged_ok} ({merge_msg})"
)

View File

@@ -257,3 +257,38 @@ def test_tc19_check_branch_mergeable_signature_intact():
from src.qg.checks import check_branch_mergeable
params = list(inspect.signature(check_branch_mergeable).parameters)
assert params == ["repo", "work_item_id", "branch"]
# ---------------------------------------------------------------------------
# ORCH-093 / TC-13: merge_retry_* settings defaults + env override (AC-5).
# ---------------------------------------------------------------------------
_MERGE_RETRY_ENV = (
"ORCH_MERGE_RETRY_ENABLED",
"ORCH_MERGE_RETRY_MAX_ATTEMPTS",
"ORCH_MERGE_RETRY_BACKOFF_BASE_S",
"ORCH_MERGE_RETRY_BACKOFF_MAX_S",
)
def test_merge_retry_settings_defaults(monkeypatch):
"""Documented defaults when no ORCH_MERGE_RETRY_* env is set."""
for name in _MERGE_RETRY_ENV:
monkeypatch.delenv(name, raising=False)
s = Settings()
assert s.merge_retry_enabled is True
assert s.merge_retry_max_attempts == 3
assert s.merge_retry_backoff_base_s == 2
assert s.merge_retry_backoff_max_s == 5
def test_merge_retry_settings_env_override(monkeypatch):
"""Each field is read from its ORCH_MERGE_RETRY_* env var."""
monkeypatch.setenv("ORCH_MERGE_RETRY_ENABLED", "false")
monkeypatch.setenv("ORCH_MERGE_RETRY_MAX_ATTEMPTS", "5")
monkeypatch.setenv("ORCH_MERGE_RETRY_BACKOFF_BASE_S", "1")
monkeypatch.setenv("ORCH_MERGE_RETRY_BACKOFF_MAX_S", "8")
s = Settings()
assert s.merge_retry_enabled is False
assert s.merge_retry_max_attempts == 5
assert s.merge_retry_backoff_base_s == 1
assert s.merge_retry_backoff_max_s == 8

View File

@@ -389,3 +389,207 @@ def test_tc16_deployer_prompt_consults_guard():
assert "no second merge" in lowered, (
"deployer prompt must document the already-merged no-op (AC-11)"
)
# ===========================================================================
# ORCH-093: merge_pr transient-retry + ensure_open_pr already-in-main guard.
# TC-01..TC-12 — httpx mocked; time.sleep no-op so backoff never slows tests.
# ===========================================================================
ORCH093_BRANCH = "feature/ORCH-093-x"
class _Resp093:
"""Response stand-in with status_code / json() / text (merge_pr reads .text)."""
def __init__(self, status_code, payload=None, text=""):
self.status_code = status_code
self._payload = payload if payload is not None else []
self.text = text
def json(self):
return self._payload
@pytest.fixture
def merge093(monkeypatch):
"""Wire Gitea settings + retry defaults; no-op backoff; PR not-already-merged."""
monkeypatch.setattr(merge_gate.settings, "gitea_url", "http://gitea.test")
monkeypatch.setattr(merge_gate.settings, "gitea_owner", "admin")
monkeypatch.setattr(merge_gate.settings, "gitea_token", "tok")
monkeypatch.setattr(merge_gate.settings, "merge_pr_timeout_s", 5)
monkeypatch.setattr(merge_gate.settings, "merge_retry_enabled", True)
monkeypatch.setattr(merge_gate.settings, "merge_retry_max_attempts", 3)
monkeypatch.setattr(merge_gate.settings, "merge_retry_backoff_base_s", 2)
monkeypatch.setattr(merge_gate.settings, "merge_retry_backoff_max_s", 5)
monkeypatch.setattr(merge_gate.time, "sleep", lambda *a, **k: None)
monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False)
def _open_code_pr_get(number=7):
"""A list-PRs GET returning exactly one open code-PR (head==branch, base==main)."""
return lambda *a, **k: _Resp093(
200, [{"head": {"ref": ORCH093_BRANCH}, "base": {"ref": "main"}, "number": number}]
)
class _PostSeq:
"""Returns queued responses (or raises queued exceptions) on each POST call."""
def __init__(self, items):
self._items = list(items)
self.calls = 0
def __call__(self, *a, **k):
self.calls += 1
item = self._items.pop(0) if self._items else self._items_last
self._items_last = item
if isinstance(item, Exception):
raise item
return item
# --- TC-01: 405, 405, 200 -> (True, ...); exactly 3 POST; no false False (AC-1) ---
def test_tc01_merge_retries_405_then_succeeds(merge093, monkeypatch):
monkeypatch.setattr(httpx, "get", _open_code_pr_get(7))
seq = _PostSeq([_Resp093(405, text="try again later"),
_Resp093(405, text="try again later"),
_Resp093(200)])
monkeypatch.setattr(httpx, "post", seq)
ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH)
assert ok is True and "PR #7" in msg
assert seq.calls == 3
# --- TC-02: 503 (5xx) then 200 -> retry -> (True, ...) (AC-1) ---
def test_tc02_merge_retries_5xx_then_succeeds(merge093, monkeypatch):
monkeypatch.setattr(httpx, "get", _open_code_pr_get(7))
seq = _PostSeq([_Resp093(503, text="bad gateway"), _Resp093(200)])
monkeypatch.setattr(httpx, "post", seq)
ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH)
assert ok is True and seq.calls == 2
# --- TC-03: httpx Timeout in attempt 1, then 200 -> retry; never-raise (AC-1/AC-6) ---
def test_tc03_merge_retries_network_error_then_succeeds(merge093, monkeypatch):
monkeypatch.setattr(httpx, "get", _open_code_pr_get(7))
seq = _PostSeq([httpx.ConnectTimeout("timed out"), _Resp093(200)])
monkeypatch.setattr(httpx, "post", seq)
ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH)
assert ok is True and seq.calls == 2
# --- TC-04: real conflict 409 + mergeable=False -> (False, ...), no extra POST (AC-2) ---
def test_tc04_real_conflict_terminal_no_retry(merge093, monkeypatch):
monkeypatch.setattr(httpx, "get", _open_code_pr_get(7))
monkeypatch.setattr(merge_gate, "_pr_mergeable", lambda r, i: False)
seq = _PostSeq([_Resp093(409, text="conflict")])
monkeypatch.setattr(httpx, "post", seq)
ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH)
assert ok is False and "HTTP 409" in msg
assert seq.calls == 1 # terminal -> no retry
# --- TC-05: ambiguous 409 + mergeable=True -> transient -> retry -> 200 (AC-2) ---
def test_tc05_ambiguous_409_mergeable_true_retries(merge093, monkeypatch):
monkeypatch.setattr(httpx, "get", _open_code_pr_get(7))
monkeypatch.setattr(merge_gate, "_pr_mergeable", lambda r, i: True)
seq = _PostSeq([_Resp093(409, text="recomputing"), _Resp093(200)])
monkeypatch.setattr(httpx, "post", seq)
ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH)
assert ok is True and seq.calls == 2
# --- TC-06: 403 (no rights) -> immediate (False, ...) without retry (AC-2) ---
def test_tc06_403_terminal_no_retry(merge093, monkeypatch):
monkeypatch.setattr(httpx, "get", _open_code_pr_get(7))
seq = _PostSeq([_Resp093(403, text="forbidden")])
monkeypatch.setattr(httpx, "post", seq)
ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH)
assert ok is False and "HTTP 403" in msg and seq.calls == 1
# --- TC-07: 405 on all N attempts -> (False, "merge failed after N attempts: HTTP 405") (AC-3) ---
def test_tc07_exhausts_retries_clear_reason(merge093, monkeypatch):
monkeypatch.setattr(httpx, "get", _open_code_pr_get(7))
seq = _PostSeq([_Resp093(405), _Resp093(405), _Resp093(405)])
monkeypatch.setattr(httpx, "post", seq)
ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH)
assert ok is False
assert "after 3 attempts" in msg and "HTTP 405" in msg
assert seq.calls == 3
# --- TC-08: kill-switch off -> exactly one POST (one-shot) at 405 -> (False, ...) (AC-5/AC-3) ---
def test_tc08_killswitch_off_one_shot(merge093, monkeypatch):
monkeypatch.setattr(merge_gate.settings, "merge_retry_enabled", False)
monkeypatch.setattr(httpx, "get", _open_code_pr_get(7))
seq = _PostSeq([_Resp093(405), _Resp093(200)]) # 2nd would succeed if retried
monkeypatch.setattr(httpx, "post", seq)
ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH)
assert ok is False and seq.calls == 1 # one-shot: never retried
# --- TC-09: ensure_open_pr — no open PR, branch fully in main -> already-in-main, no POST (AC-4) ---
def test_tc09_ensure_already_in_main_no_post(merge093, monkeypatch):
monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp093(200, [])) # no open PR
monkeypatch.setattr(merge_gate, "_branch_fully_in_main", lambda r, b: True)
monkeypatch.setattr(httpx, "post", lambda *a, **k: (_ for _ in ()).throw(
AssertionError("must NOT POST /pulls for an already-in-main branch")))
status, detail = merge_gate.ensure_open_pr("orchestrator", ORCH093_BRANCH)
assert status == "already-in-main"
# --- TC-10: ensure_open_pr — no open PR, commits beyond main -> creates PR (regress) (AC-4) ---
def test_tc10_ensure_creates_when_commits_beyond_main(merge093, monkeypatch):
monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp093(200, []))
monkeypatch.setattr(merge_gate, "_branch_fully_in_main", lambda r, b: False)
post_calls = []
def fake_post(url, json=None, headers=None, timeout=None):
post_calls.append(url)
return _Resp093(201, {"number": 12})
monkeypatch.setattr(httpx, "post", fake_post)
status, detail = merge_gate.ensure_open_pr("orchestrator", ORCH093_BRANCH)
assert status == "created" and detail == "12"
assert len(post_calls) == 1
# --- TC-11: ensure_open_pr — git error in guard (None) -> fail-OPEN -> create path (AC-6) ---
def test_tc11_ensure_guard_git_error_fail_open(merge093, monkeypatch):
monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp093(200, []))
# None == git/OS error / ambiguous -> must NOT block; degrade to create.
monkeypatch.setattr(merge_gate, "_branch_fully_in_main", lambda r, b: None)
monkeypatch.setattr(httpx, "post", lambda *a, **k: _Resp093(201, {"number": 13}))
status, detail = merge_gate.ensure_open_pr("orchestrator", ORCH093_BRANCH)
assert status == "created" # fail-open: did not become a false no-op
def test_tc11_branch_fully_in_main_never_raises(monkeypatch):
"""_branch_fully_in_main: any git/OS error -> None (never-raise) (AC-6)."""
monkeypatch.setattr(merge_gate, "ensure_worktree", lambda r, b: "/wt")
def boom(*a, **k):
raise OSError("git exploded")
monkeypatch.setattr(merge_gate.subprocess, "run", boom)
assert merge_gate._branch_fully_in_main("orchestrator", ORCH093_BRANCH) is None
# --- TC-12: merge_pr / ensure_open_pr — uncaught httpx error -> safe tuple (never-raise) (AC-6) ---
def test_tc12_merge_pr_never_raises(merge093, monkeypatch):
def boom(*a, **k):
raise httpx.HTTPError("kaboom")
monkeypatch.setattr(httpx, "get", boom)
ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH)
assert ok is False and isinstance(msg, str)
def test_tc12_ensure_open_pr_never_raises(merge093, monkeypatch):
def boom(*a, **k):
raise httpx.HTTPError("kaboom")
monkeypatch.setattr(httpx, "get", boom)
status, detail = merge_gate.ensure_open_pr("orchestrator", ORCH093_BRANCH)
assert status == "failed" and isinstance(detail, str)

View File

@@ -131,3 +131,92 @@ def test_tc12_kill_switch_disables_under_gate(monkeypatch):
monkeypatch.setattr(merge_gate.settings, "merge_verify_enabled", False)
assert merge_gate.merge_verify_applies("orchestrator") is False
assert merge_gate.merge_verify_applies("enduro-trails") is False
# ===========================================================================
# ORCH-093 / TC-14..16: _handle_merge_verify integration (deploy->done under-gate).
# already-in-main skips merge_pr; transient-retry success -> done; exhausted -> HOLD.
# ===========================================================================
import os # noqa: E402
import tempfile # noqa: E402
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_orch093.db"))
from unittest.mock import MagicMock # noqa: E402
from src import stage_engine, image_freshness # noqa: E402
from src.stage_engine import AdvanceResult, _handle_merge_verify # noqa: E402
_O93_REPO = "orchestrator"
_O93_WI = "ORCH-093"
_O93_BRANCH = "feature/ORCH-093-x"
@pytest.fixture
def _o93_wire(monkeypatch):
monkeypatch.setattr(stage_engine.merge_gate, "merge_verify_applies", lambda r: True)
monkeypatch.setattr(stage_engine.settings, "merge_verify_autocreate_pr_enabled", True)
monkeypatch.setattr(stage_engine.settings, "regression_guard_enabled", False)
monkeypatch.setattr(image_freshness, "validated_revision", lambda r, b: "deadbeef")
for name in ("set_issue_blocked", "plane_add_comment", "send_telegram", "link_for"):
monkeypatch.setattr(stage_engine, name, MagicMock())
monkeypatch.setattr(
stage_engine.self_deploy, "record_merged_to_main", MagicMock(return_value=True)
)
# --- TC-14: ensure_open_pr -> already-in-main -> skip merge_pr; SHA-in-main -> done (AC-4) ---
def test_tc14_already_in_main_skips_merge_pr_then_done(_o93_wire, monkeypatch):
monkeypatch.setattr(
stage_engine.merge_gate, "ensure_open_pr", lambda r, b: ("already-in-main", "x")
)
merge = MagicMock()
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", merge)
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True)
res = AdvanceResult()
intervened = _handle_merge_verify(1, _O93_REPO, _O93_WI, _O93_BRANCH, res)
assert intervened is False # advance to done
assert res.alerted is False
assert not merge.called # merge_pr SKIPPED (nothing to merge)
assert not stage_engine.set_issue_blocked.called
# --- TC-15: merge_pr exhausted (False) + SHA not in main -> HOLD + alert (ORCH-071/081) (AC-3) ---
def test_tc15_merge_failed_and_not_in_main_holds(_o93_wire, monkeypatch):
monkeypatch.setattr(
stage_engine.merge_gate, "ensure_open_pr", lambda r, b: ("existed", "9")
)
monkeypatch.setattr(
stage_engine.merge_gate, "merge_pr",
lambda r, b: (False, "merge failed after 3 attempts: HTTP 405"),
)
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: False)
res = AdvanceResult()
intervened = _handle_merge_verify(1, _O93_REPO, _O93_WI, _O93_BRANCH, res)
assert intervened is True # HOLD, NOT done
assert res.advanced is False
assert res.note == "merge-not-verified-hold"
assert stage_engine.set_issue_blocked.called
# --- TC-16: happy path — transient retry success in merge_pr -> SHA-in-main -> done (AC-1) ---
def test_tc16_transient_retry_success_then_done(_o93_wire, monkeypatch):
monkeypatch.setattr(
stage_engine.merge_gate, "ensure_open_pr", lambda r, b: ("existed", "9")
)
# merge_pr already rode out the 405x2->200 transient internally -> (True, ...).
monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", lambda r, b: (True, "merged PR #9"))
monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True)
res = AdvanceResult()
intervened = _handle_merge_verify(1, _O93_REPO, _O93_WI, _O93_BRANCH, res)
assert intervened is False # done, no false HOLD
assert res.alerted is False
assert not stage_engine.set_issue_blocked.called

View File

@@ -32,6 +32,11 @@ def _settings(monkeypatch):
monkeypatch.setattr(merge_gate.settings, "gitea_owner", "owner")
monkeypatch.setattr(merge_gate.settings, "gitea_token", "tok")
monkeypatch.setattr(merge_gate.settings, "gitea_url", "http://gitea.test")
# ORCH-093: these tests target the HTTP create/race logic of ensure_open_pr.
# The new already-in-main guard (_branch_fully_in_main) runs real git; pin it
# to "commits beyond main" (False) so the create path is exercised as intended.
# The guard itself has dedicated coverage (test_merge_gate.py TC-09/10/11).
monkeypatch.setattr(merge_gate, "_branch_fully_in_main", lambda r, b: False)
def _install_httpx(monkeypatch, get_resp, post_resp=None, record=None):