--- 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 #")` (немедленный выход). - **транзиентный** исход (см. FR-2) И остались попытки → `sleep(backoff)` и повтор `POST`; `backoff` экспоненциальный от `merge_retry_backoff_base_s` (дефолт `2`) с потолком `merge_retry_backoff_max_s` (дефолт `5`). - **терминальный** исход (см. FR-2) → немедленно `(False, "merge failed: HTTP ")` без дальнейших попыток. - исчерпание попыток на транзиенте → `(False, "merge failed after attempts: HTTP ")`. - **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..` (или `git merge-base --is-ancestor origin/main`). - count `== 0` (ветка целиком в `main`) → `("already-in-main", "")` — **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` должен оставаться зелёным.