# ADR-001 (ORCH-071): Детерминированный merge-в-main + пост-деплой верификация как условие `done`

## Статус
Accepted

## Контекст

### Подтверждённый root cause (постмортем `docs/history/LESSONS_2026-06-08_phantom-merge.md`)
Для self-hosting репо `orchestrator` стадия `deploy` идёт **детерминированным** путём
`stage_engine._handle_self_deploy_phase_b → self_deploy.initiate_deploy →
run_deploy_finalizer`, а LLM-агент `deployer` **не запускается** (так предписывает
`.openclaw/agents/deployer.md`). Фактический merge PR в `main` исторически выполнял
**только** агент `deployer` через Bash/curl. Следствие: на self-hosting пути **нет ни
одного шага, выполняющего git-merge ветки в `main`** (аудит: `grep` по
`pulls/.../merge` в `src/` — 0 совпадений).

Detached host-процесс (Phase B) лишь **retag staging-образа на прод-тег + рестарт 8500**.
`run_deploy_finalizer` маппит exit-code хука `0 → SUCCESS`, пишет `14-deploy-log.md`,
вызывает `advance_stage(..., finished_agent="deployer")`; гейт `check_deploy_status`
читает только `deploy_status:` → `SUCCESS → done`. **Состояние `main` нигде не
верифицируется.** «Зелёный» деплой (прод-образ собран из рабочей ветки) маскирует
отсутствие merge — сигнала нет, пока следующая задача не срежет ветку от устаревшего
`main` и не потеряет код предшественника. Накопительно потеряны ORCH-022/059/066/068.

Вторичный фактор (урок №3): Phase B **рестартит прод-контейнер**, поэтому любой
держатель merge-lease / незавершённый git-шаг ВНУТРИ живого процесса умирает до
завершения merge. Значит наивно «добавить merge в Phase B» (живой старый контейнер,
который вот-вот рестартует) — снова гонка с рестартом.

### Требования (из ТЗ/BRD)
- **G1/FR-2** — пост-деплой верификация: deployed SHA — предок `origin/main` ИЛИ `PR.merged==true`.
- **G2/FR-3** — `done` ТОЛЬКО при подтверждённом merge; `deploy_status: SUCCESS` + post-deploy `HEALTHY` — недостаточно.
- **G3/FR-1** — merge детерминированным кодом (агент не запускается), через Gitea PR-merge API; завершён ДО рестарта ЛИБО вынесен в шаг, переживающий рестарт.
- **INV-1** never-raise; **INV-2** не рестартить/не ронять прод; **INV-3** ручной approve сохранён; **INV-4** только PR-merge API, никогда push/force-push в `main`; **INV-5** идемпотентность (`pr_already_merged`).
- **НЕ менять:** `STAGE_TRANSITIONS`, `check_deploy_status`/`_parse_deploy_status`, схему БД, source-of-truth.

## Решение

Вводим **детерминированный merge-актор + пост-merge верификацию** как **под-гейт ребра
`deploy → done`**, врезанный в `advance_stage`. Это симметрично существующим edge-под-гейтам
(security/merge-gate/image-freshness на ребре `deploy-staging → deploy`): `STAGE_TRANSITIONS`
не меняется, новый под-гейт — условие финализации, а не новая стадия.

### D1. Точка врезки — `advance_stage`, ребро `deploy → done` (единая для ВСЕХ путей)
Врезка `_handle_merge_verify(...)` в `src/stage_engine.py::advance_stage` **после** успешного
прохождения QG (`check_deploy_status == SUCCESS`, т.е. `next_stage == "done"`) и **до**
`update_task_stage(task_id, next_stage)`:

```python
# --- ORCH-071 merge-verify under-gate (deploy -> done edge) ---
if current_stage == "deploy" and next_stage == "done":
    if _handle_merge_verify(task_id, repo, work_item_id, branch, result):
        return result   # HOLD: merge не подтверждён -> alert, НЕ done, НЕ rollback
```

`advance_stage` — **единственная** функция перехода стадий. Её вызывают `run_deploy_finalizer`
(Phase C), reconciler F-1 (`finished_agent=None`), job-reaper (re-drive). Врезка именно здесь
**гейтит ВСЕ пути единообразно**: ни один из них не сможет довести `deploy → done` без
подтверждённого merge. Это закрывает скрытую дыру: reconciler F-1 предоценивает
`check_deploy_status` read-only и при зелёном вызывает `advance_stage` — без врезки он бы
протолкнул `done` в обход merge.

### D2. Когда выполняется merge — в Phase C (после рестарта), а НЕ в Phase B
Merge выполняется внутри `_handle_merge_verify`, т.е. на ребре `deploy → done`, которое
достигается **из `run_deploy_finalizer` уже в НОВОМ контейнере после рестарта прода**. Это
осознанный выбор в пользу второго варианта G3 («шаг, переживающий рестарт»):

- Phase B лишь **диспетчеризует** detached-деплой (`ssh` возвращается мгновенно), рестарт прода
  происходит асинхронно на хосте. Merge в Phase B (живой старый контейнер) **гонялся бы** с
  рестартом и мог быть убит на полушаге — ровно постмортем-урок №3. Поэтому merge в Phase B
  **отвергнут**.
- Phase C finalizer уже **restart-surviving**: это reserved-agent job `deploy-finalizer`,
  переставляемый с defer и **claim'ится воркером нового контейнера** после рестарта; если новый
  контейнер умрёт на полушаге merge — job re-drive'ится (reaper/requeue), а `pr_already_merged`
  делает повтор идемпотентным. Merge физически происходит **строго ПОСЛЕ** рестарта → рестарт
  его не убивает. G3 удовлетворён.

### D3. Merge-актор — `src/merge_gate.py::merge_pr(repo, branch) -> (bool, str)`
Новый детерминированный merge-актор (рядом с `pr_already_merged`/`pid_alive`/`reclaim_stale_lease`):
1. `pr_already_merged(repo, branch)` → `True` → **no-op** `(True, "already-merged")` (INV-5/AC-9).
2. Иначе `GET /repos/{owner}/{repo}/pulls?state=open&head=<branch>` → индекс открытого PR.
3. `POST /repos/{owner}/{repo}/pulls/{index}/merge` (Do: `merge`) через существующий httpx-клиент
   и `settings.gitea_*`. Никогда не push/force-push в `main` (INV-4/AC-8).
4. **never-raise** (INV-1): любая HTTP/parse-ошибка → `(False, reason)`; нет открытого PR при
   `pr_already_merged==False` → `(False, "no open PR")`.

Работает под merge-lease, который уже **удерживается** этой задачей с merge-gate ребра
`deploy-staging → deploy` (Phase A held-across-wait) и освобождается на `done`/откате
(существующий `release_merge_lease`, ORCH-043) либо реклеймится по смерти держателя (ORCH-065).
Сериализация слияний сохранена без новой блокировки.

### D4. Верификатор — `src/merge_gate.py::verify_merged_to_main(repo, branch, sha) -> bool`
Возвращает `True`, если merge подтверждён (FR-2):
- `pr_already_merged(repo, branch) is True`  **ИЛИ**
- `git merge-base --is-ancestor <sha> origin/main` в worktree задачи (после `git fetch origin main`),
  где `<sha>` — validated commit = `git rev-parse HEAD` worktree (тот же якорь, что
  `image_freshness.validated_revision`).

**never-raise** (INV-1/AC-7): любая git/HTTP-ошибка → `False` (= «не подтверждено» → alert + HOLD,
fail-closed для `done`). Исключение НИКОГДА не пробрасывается в `advance_stage`.

### D5. `_handle_merge_verify` (оркестрация под-гейта, `src/stage_engine.py`)
Возвращает `True` (вмешался → HOLD, не advance) / `False` (merge подтверждён → штатный advance в `done`):
1. Условность: `merge_verify_applies(repo)` (см. D7) `False` → вернуть `False` (поведение 1:1 как раньше).
2. `sha = validated_revision(...)`; `merge_gate.merge_pr(repo, branch)` (no-op если уже слит).
3. `ok = merge_gate.verify_merged_to_main(repo, branch, sha)`.
4. `ok==True`:
   - дописать `merged_to_main: true` во frontmatter `14-deploy-log.md` (машиночитаемая
     наблюдаемость; `deploy_status:` НЕ трогаем — контракт парсинга `check_deploy_status`
     неизменен), вернуть `False` → `advance_stage` штатно ведёт `deploy → done`
     (терминал-sync/post-deploy-monitor как сегодня; AC-4).
5. `ok==False`:
   - **alert** «deploy succeeded but not merged» — Telegram + Plane-коммент;
   - `set_issue_blocked(work_item_id)` (Plane не-терминальный; согласовано с ORCH-066
     DEGRADED→Blocked и deploy-finalize-exhausted);
   - дописать `merged_to_main: false`; **НЕ** `update_task_stage` (задача остаётся на `deploy`),
     **НЕ** откат на `development` (not-merged — инфра-дефект, не код; FR-3 → ALERT-only, как
     ORCH-021 self-hosting);
   - вернуть `True`.
   Повтор (re-drive/reaper) переоценит: после ручного устранения merge подтвердится → `done`.

Вся функция обёрнута never-raise: внутренняя ошибка → трактуется как «не подтверждено» (HOLD+alert),
не падение конвейера.

### D6. Идемпотентность (INV-5/AC-9)
- Перед merge — `pr_already_merged` (no-op повтор).
- `verify` зелёный для уже-слитого PR (ветвь `pr_already_merged is True`).
- Повторный прогон ребра `deploy → done` (двойной webhook / reaper / reconciler): merge no-op,
  verify зелёный, нет дубль-merge, нет ложного БАГ-8 отката.

### D7. Условность раската (FR-5/AC-10) — `src/config.py`
Новые флаги (паттерн `merge_gate_*`/`image_freshness_*`):
- `merge_verify_enabled: bool = True` — глобальный kill-switch; `False` → строго прежнее
  поведение (`_handle_merge_verify` сразу `False`, 1:1 до фикса).
- `merge_verify_repos: str = ""` — CSV; пусто → реально ТОЛЬКО для self-hosting
  (`is_self_hosting_repo`); непусто → только перечисленные.
- (опц.) `merge_pr_timeout_s` / `merge_verify_timeout_s` — таймауты Gitea/git.

`merge_verify_applies(repo)` — never-raise, зеркало `self_deploy_applies` / `image_freshness`.
Non-self репо (enduro-trails): под-гейт — **no-op**, merge остаётся за агентом `deployer` (AC-4b).

### D8. Наблюдаемость (опц., FR §2/§3)
Блок `merge_verify` в `GET /queue` (по образцу `reaper`/`post_deploy`): `enabled`,
`merge_verified_total`, `not_merged_alerts_total`, `last_alert_wi`. Каждый alert → `logger.warning`
+ Telegram.

### D9. Диагностический runbook (G4/FR-4)
`docs/operations/PHANTOM_MERGE_RUNBOOK.md` — 4 проверки постмортема с copy-paste командами:
(1) Gitea API список PR + `merged`-флаги; (2) md5 прод-файлов vs `git show origin/main:<file>`;
(3) `git merge-base` ветки vs `main`; (4) таймлайн деплой-логов. + критерий «фантом подтверждён».

## Что НЕ меняется (контракты)
`STAGE_TRANSITIONS`; `check_deploy_status`/`_parse_deploy_status` (читают только `deploy_status:`);
реестр `QG_CHECKS` (под-гейт — врезка в `advance_stage`, НЕ новый зарегистрированный QG, как
`_handle_merge_gate`); схема БД (restart-safe состояние — существующие sentinel'ы
`.deploy-state-<repo>/<wi>/` + очередь `jobs`); БАГ-8; terminal-sync; merge-gate (ORCH-043);
image-freshness (ORCH-058); `Confirm Deploy` (ORCH-059); post-deploy monitor (ORCH-021);
exit-коды хука (0/1/2); ручной approve прод-деплоя (INV-3). Non-self merge — за агентом `deployer`.

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

**Плюсы**
- Невозможно состояние «`done` + прод задеплоен, а PR `open`»: либо merge подтверждён → `done`,
  либо HOLD + alert (G2/критерий успеха BRD §8).
- Единая врезка в `advance_stage` гейтит ВСЕ пути (finalizer/reconciler/reaper) — нет обходных
  дверей к `done`.
- Merge в restart-surviving Phase C структурно не убивается рестартом прода (G3, урок №3).
- Минимальный blast-radius: `STAGE_TRANSITIONS`/`check_deploy_status`/схема БД/реестр QG — нетронуты;
  раскат за kill-switch.

**Минусы / ограничения**
- При недоступной Gitea verify консервативно даёт `False` → возможен ложный not-merged alert и
  HOLD; снимается повтором после восстановления Gitea (приемлемо: fail-closed для `done` важнее).
- HOLD при not-merged требует ручного вмешательства (ALERT-only) — осознанно (not-merged —
  инфра-дефект, авто-откат на `development` запрещён FR-3).
- Появляется реальный исходящий merge-вызов из кода — должно покрываться mock-тестами Gitea
  (AC-2) и smoke рестарта (AC-3).

## Альтернативы (отвергнуто)
- **Merge в Phase B (до рестарта).** Гонка с асинхронным рестартом прода → merge может быть убит
  на полушаге (постмортем-урок №3). Отвергнуто в пользу restart-surviving Phase C.
- **Новый зарегистрированный QG `check_merged_to_main` на стадии `deploy`.** У стадии один QG
  (`check_deploy_status`); второй потребовал бы менять `STAGE_TRANSITIONS`/контракт. Врезка
  под-гейта в `advance_stage` (как merge-gate) даёт тот же охват без изменения реестра.
- **Авто-откат на `development` при not-merged.** Запрещено FR-3: not-merged — инфра-дефект,
  не код; реакция = alert + ручное вмешательство.
