---
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 2–5 с) и задокументированы в `.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` (заполняет архитектор).
