---
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`)
