# ADR-001: Гарантированный идемпотентный код-PR перед merge-verify (ensure_open_pr)

- Work Item: **ORCH-082** (Plane-заголовок «ORCH-81»)
- Repo: `orchestrator` (self-hosting)
- Связь: амендмент к 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)
- BRD/ТЗ/AC: `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`

## Статус
Accepted

## Контекст

### Что случилось (инцидент ORCH-074, 08.06)
Деплой ORCH-074 встал на под-гейте merge-verify (ребро `deploy → done`):
`run_deploy_finalizer → _handle_merge_verify` вызвал `merge_gate.merge_pr(repo, branch)` и
получил `ok=False, "no open PR"` — в Gitea для ветки `feature/ORCH-074-…` **не было открытого
PR** с `head.ref==branch` И `base.ref=="main"`. Защита ORCH-073 (fail-closed по «SHA-в-main»)
**отработала правильно**: задача удержана на `deploy` (НЕ `done`), Plane → Blocked, Telegram-alert,
ложно-зелёного `done` не произошло. Разблокировано вручную — PR #79 создан через Gitea API,
finalizer перезапущен, код честно влит, задача `done`. Это **workaround, не фикс**.

### Root cause (G1, подтверждён код-аудитом)
PR создаётся в конвейере **ровно в одном месте** — `AgentLauncher._ensure_pr`
(`src/agents/launcher.py:1079`), и вызывается он из `_monitor_agent` **только** по цепочке
условий (`src/agents/launcher.py:751–753`):

```
exit_code == 0
  → git status --porcelain непусто (есть worktree-изменения)
    → git commit succeeded
      → git push succeeded
        → agent == "developer"   ←── ТОЛЬКО здесь вызывается self._ensure_pr(...)
```

Отсюда класс «ветка без PR» структурно неизбежен. Подтверждённые код-аудитом ветви:

- **R-A (условное создание) — структурный первопричинный дефект.** Если в конкретном
  developer-run нет свежих изменений (`git status` пуст: ветка уже была закоммичена/запушена
  ранее, бойнс REQUEST_CHANGES без новых правок, повторный прогон, **ручное восстановление
  ветки**) — `_ensure_pr` **не вызывается вовсе**. PR не появится никогда. Никакого
  персистентного флага «PR создан» в БД нет, поэтому идемпотентность чтения внутри `_ensure_pr`
  до merge-стадии не доходит.
- **R-C (разъехавшееся состояние ветки/PR) — проксимальный триггер ORCH-074.** ORCH-074 — первая
  задача после серии **ручных восстановлений `main` 08.06**: открытый код-PR был закрыт/не
  пересоздан, у ветки мог остаться лишь авто-docs-PR (`base != main`), который `merge_pr` (фильтр
  `base=="main"`, ORCH-073 FR-3) корректно НЕ считает кодовым.
- **R-B (тихий сбой создания) — потенциальная, не первопричина здесь.** `_ensure_pr` глотает любое
  исключение (`except Exception → logger.error → return None`): транзиентная ошибка Gitea на
  `POST …/pulls` теряется без ретрая и эскалации.

**Вывод:** в конвейере **отсутствует инвариант** «к моменту merge-verify у ветки есть открытый
код-PR». Защита ORCH-073 верно ловит следствие, но причина — выше по потоку. Любая следующая
задача с тем же стечением обстоятельств застрянет тем же образом → автономный деплой (ORCH-54)
заблокирован.

### Ограничения, которые нельзя нарушать
- Защита ORCH-073 (SHA-в-main + регресс-гард) — приоритетна. Создание PR **не должно** маскировать
  реально невлитый код.
- `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, схема БД, `check_deploy_status`/`_parse_deploy_status`,
  exit-коды хука, merge-gate (ORCH-043), image-freshness (ORCH-058) — без изменений.
- Весь путь merge-verify — **never-raise**.
- Слияние только через PR; `main` никогда не push/force-push.

## Решение

Закрыть пробел инвариантом «обеспечить открытый код-PR» **внутри того же под-гейта merge-verify**,
ПЕРЕД детерминированным `merge_pr`. Три точечные врезки, симметричные существующему дизайну
ORCH-071/073 (leaf-актор в `merge_gate` + врезка в `_handle_merge_verify` + kill-switch). Машина
стадий и реестры не трогаются.

### Р-1. Новый идемпотентный leaf-актор `merge_gate.ensure_open_pr(repo, branch)`

Сигнатура (решение архитектора по ТЗ §1):

```python
def ensure_open_pr(repo: str, branch: str) -> tuple[str, str]:
    """Гарантировать открытый код-PR (head==branch, base==main). never-raise.
    Возврат: ("existed", "<number>") | ("created", "<number>") | ("failed", "<reason>").
    """
```

Алгоритм (FR-1):
1. `GET …/pulls?state=open` → найти PR с **`head.ref==branch` И `base.ref=="main"`**. Фильтр
   **идентичен** `merge_pr`/ORCH-073 FR-3 — авто-docs-PR (`base != main`) НЕ считается код-PR
   (AC-6). Нашли → `("existed", <number>)`.
2. Иначе `POST …/pulls` (`head=branch`, `base="main"`, авто-заголовок/тело) → `201` →
   `("created", <number>)`.
3. **Идемпотентность при гонке:** если на `POST` Gitea вернёт «PR exists»/`409`/`422` —
   повторный `GET` (шаг 1) подтверждает существующий PR → `("existed", …)`. Дубль не плодится
   (AC-2, FR-5).
4. Любая иная HTTP/parse/сетевая ошибка → `("failed", <reason>)`. **Never-raise** (`except
   Exception → ("failed", str(e))`).

Актор — **leaf** (зависит только от `settings` + `httpx`, без импорта `stage_engine`), как
`merge_pr`/`verify_merged_to_main`. Таймауты — переиспользовать `settings.merge_pr_timeout_s`
(тот же класс Gitea-вызовов).

> **Почему фильтр `base=="main"` критичен** (грабли ORCH-073): у ветки одновременно бывают код-PR
> и авто-docs-PR. Без фильтра актор «увидит» docs-PR как existed и не создаст нужный код-PR, а
> `merge_pr` потом не найдёт что мержить → петля. Один и тот же предикат `head==branch &&
> base=="main"` гарантирует, что `ensure_open_pr` и `merge_pr` работают с одним и тем же PR.

### Р-2. Врезка в `_handle_merge_verify` (ребро `deploy → done`)

В существующем `_handle_merge_verify` (`src/stage_engine.py:1324`), **ПОСЛЕ**
`merge_verify_applies(repo)`-гейта и резолва `sha = image_freshness.validated_revision(...)`,
но **ПЕРЕД** `merge_pr`:

```python
sha = image_freshness.validated_revision(repo, branch)

# ORCH-082: гарантировать открытый код-PR ДО детерминированного merge_pr.
if settings.merge_verify_autocreate_pr_enabled:
    pr_status, pr_detail = merge_gate.ensure_open_pr(repo, branch)
    logger.info(
        f"Task {task_id}: merge-verify ensure_open_pr -> {pr_status} ({pr_detail})"
    )
    if pr_status == "failed":
        return _hold_pr_create_failed(
            task_id, repo, work_item_id, branch, pr_detail, result
        )
    # "created" | "existed" -> штатно продолжаем к merge_pr.

merged_ok, merge_msg = merge_gate.merge_pr(repo, branch)
...
```

Семантика (FR-2):
- `created | existed` → продолжаем штатно к `merge_pr` → `verify_merged_to_main` → регресс-гард.
- `failed` → **честный HOLD + alert** через новый helper `_hold_pr_create_failed` (см. Р-3); задача
  остаётся на `deploy` (НЕ `done`), БЕЗ отката на development — симметрично текущему not-merged/
  regressed HOLD.
- kill-switch off → блок пропускается целиком → поведение 1:1 как до фикса (AC-8).

Место выбрано так, что **никакой существующий шаг не сдвигается**: `merge_pr` и
`verify_merged_to_main` остаются на своих местах с теми же контрактами. Создание PR — это только
страховка инварианта ДО них.

### Р-3. Новый HOLD-helper `_hold_pr_create_failed` (распознаваемость причины, FR-4/AC-5)

Зеркало существующего `_hold_main_regressed` (`src/stage_engine.py:1280`). Текст HOLD **обязан
отличаться** от not-merged HOLD: оператор должен видеть, что причина — **невозможность создать
PR** (Gitea недоступна), а не **невозможность слить уже созданный**:

```python
def _hold_pr_create_failed(task_id, repo, work_item_id, branch, reason, result) -> bool:
    merge_gate.note_not_merged_alert(work_item_id)  # переиспользуем счётчик-нотификатор
    msg = (f"PR создать не удалось: {reason} (repo={repo}, branch={branch}, "
           f"wi={work_item_id}). Открытый код-PR отсутствует и не создан — задача "
           f"удержана на `deploy` (НЕ done). Нужно проверить доступность Gitea / создать PR.")
    # set_issue_blocked + plane_add_comment + send_telegram (каждый в try/except, never-break HOLD)
    result.alerted = True
    result.note = "pr-create-failed-hold"   # отличается от "merge-not-verified-hold"
    result.advanced = False
    return True
```

Это сохраняет инвариант «никогда не пробрасываем исключение в `advance_stage`»: `failed` —
структурированный исход, а не throw.

### Р-4. Единый источник кода создания PR (опционально, рекомендуется)

`launcher._ensure_pr` рекомендуется **делегировать** в `merge_gate.ensure_open_pr`, чтобы создание
PR жило в одном месте и одинаково логировало created/existed/failed (G3). **Поведенческий
инвариант:** триггер «создавать PR только в developer-пути со свежим коммитом» **НЕ ужесточается**
(BRD/ТЗ §1) — меняется лишь реализация под капотом, не условие вызова. Это снижает риск
рассинхрона двух копий логики «выбрать/создать PR». Если делегирование увеличивает диффу/риск —
допустимо оставить `_ensure_pr` как есть и лишь усилить его логирование (created/existed/failed);
функциональная цель ORCH-082 достигается врезкой Р-2 независимо.

### Р-5. Kill-switch и область действия

- `merge_verify_autocreate_pr_enabled: bool = True`
  (env `ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED`) в `src/config.py`, рядом с
  `merge_verify_enabled`/`regression_guard_enabled`.
- `False` → ровно прежнее поведение: авто-создания нет, «no open PR» → HOLD как в ORCH-074 (AC-8).
- Область — `merge_gate.merge_verify_applies(repo)` (self-hosting / `merge_verify_repos`); прочие
  репо — no-op, создание PR остаётся за прежним механизмом (AC-9). Отдельного `*_repos` для
  авто-создания НЕ вводим: семантически оно неотделимо от merge-verify, у которого уже есть область.

## Последствия

### Плюсы
- Закрыт структурный пробел: к merge-verify ветка гарантированно имеет открытый код-PR; ложный
  HOLD «no open PR» больше не требует ручного вмешательства (AC-2/AC-3).
- Защита ORCH-073 цела и приоритетна: верификация остаётся **только** `verify_merged_to_main`
  (SHA-в-main) + `check_main_regression`. Реально невлитый код → HOLD как прежде (AC-4/FR-3).
- Идемпотентность по факту Gitea (наличие открытого PR), без новой колонки/таблицы — согласуется с
  restart-safe-моделью merge-verify; повторный заход (reaper/reconciler/re-approve) → `existed`,
  дублей нет (FR-5/AC-2).
- Распознаваемые исходы в логах и в HOLD-тексте: created / existed / failed (G3/AC-5).
- Инварианты сохранены: `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, `check_deploy_status`,
  exit-коды хука, merge-gate, image-freshness — не тронуты (AC-10). `main` не push/force-push.

### Минусы / ограничения
- Auto-создание PR на ребре `deploy → done` означает, что код-PR может появиться **после** того,
  как все гейты (security/merge-gate/staging/image-freshness) уже пройдены по ветке. Это безопасно
  по времени (BRD §6 допущение): ревью/гейты валидируют **код ветки**, а PR — лишь механизм
  слияния; merge-verify исполняется ПОСЛЕ всех гейтов. PR здесь не обходит ревью.
- При недоступности Gitea задача попадёт в HOLD (как и сегодня) — но теперь с явным текстом
  «PR создать не удалось» вместо «PR не влит». Это сознательный fail-closed (AC-7): never-raise,
  честный HOLD, не ложно-зелёный `done`.
- Небольшое дублирование Gitea-вызовов между `ensure_open_pr` и `merge_pr` (оба GET список PR). Это
  приемлемо: два независимых leaf-актора с одинаковым фильтром важнее микро-оптимизации; объединять
  в один вызов — увеличить связность без пользы.

### Влияние на self-hosting
Изменение строго аддитивно и под kill-switch (`True`). Прод-контейнер не рестартится этой задачей;
выкат — через staging-гейт (8501) как любая ORCH-задача. На ребре `deploy → done` риск-профиль не
растёт: при любом сбое — HOLD, не падение `advance_stage`, конвейер всех проектов не встаёт.

## Связанные документы
- BRD/ТЗ/AC: `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`
- Тех-риски: `10-tech-risks.md`
- Глобальный амендмент: [adr-0016](../../../architecture/adr/adr-0016-ensure-open-pr-before-merge-verify.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)
- Постмортем фантомного merge: `docs/history/LESSONS_2026-06-08_phantom-merge.md`,
  runbook `docs/operations/PHANTOM_MERGE_RUNBOOK.md`
