---
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` должен оставаться зелёным.
