# ADR-001: Merge-gate + auto-rebase + re-test (безопасная параллель в одном репо)

## Статус
Proposed

> Решение архитектора по ТЗ ORCH-043 (`02-trz.md`). Реализует BR-1..BR-8, удовлетворяет
> AC-1..AC-15. Глобальный сквозной аналог — `docs/architecture/adr/adr-0006-merge-gate.md`.

---

## Контекст

Конвейер валидирует ветку относительно того `main`, из которого она была создана, а не
относительно `main` на момент слияния. Между «ветка проверена» и «ветка влита» `main` мог
уйти вперёд из-за слияния другой параллельной задачи → **семантический конфликт слияния**:
git сливает без текстового конфликта, но объединённый код `main` сломан. Для self-hosting
(`orchestrator`) это = красный `main` инструмента, обслуживающего ВСЕ проекты из одного
инстанса с общей БД/очередью.

Ключевые факты текущей архитектуры, влияющие на решение (проверено по коду):

1. **Где происходит слияние в `main`.** Ветку в `main` вливает **deployer-агент в начале
   своего запуска на стадии `deploy`** (см. `src/webhooks/gitea.py:336-353` — комментарий
   «deployer merges the PR at the START of its run»). Замена самого механизма слияния PR
   в Gitea — **вне объёма** (BRD §4). Значит, merge остаётся PR-merge через deployer.
2. **Как запускается deployer стадии `deploy`.** При прохождении `check_staging_status`
   на стадии `deploy-staging` движок (`stage_engine.advance_stage`) переводит задачу
   `deploy-staging → deploy` и запускает `get_agent_for_stage("deploy-staging") = deployer`.
   Этот deployer и делает merge. Значит **merge-gate обязан отработать на ребре
   `deploy-staging → deploy`, ДО запуска этого deployer'а**.
3. **Чем триггерится QG.** `advance_stage` вызывается ТОЛЬКО при (а) завершении
   LLM-агента (`launcher._try_advance_stage`) или (б) приходе вебхука. **Стадия без агента
   не имеет собственного триггера** (стадия `deploy` оценивается, когда заканчивает
   deployer, исполняющийся ВО ВРЕМЯ неё). Поэтому новая «пустая» стадия `merge-gate`
   между `deploy-staging` и `deploy` зависла бы без триггера (нужен был бы chaining в
   движке либо синтетический job — лишняя и не-restart-safe поверхность).
4. **Concurrency.** `max_concurrency` по умолчанию `1`; QG исполняется в monitor-thread
   агента. Блокирующее ожидание lock внутри `advance_stage` при одном worker-слоте даёт
   **дедлок** (задача B держит слот, ожидая merge задачи A, которой нужен тот же слот).
   Сериализация обязана быть **неблокирующей**.

---

## Решение

### 1. Место встройки — ребро `deploy-staging → deploy` (кандидат A ТЗ §6), без новой стадии

Merge-gate — детерминированный шаг в `advance_stage`, исполняемый **после** прохождения
`check_staging_status` и **до** `update_task_stage(deploy)` / запуска deployer'а, который
мержит. `STAGE_TRANSITIONS` **не меняется** (минимальный blast-radius; `get_previous_stage`
не затрагивается; snapshot `_EXPECTED_TRANSITIONS` без изменений). В реестр `QG_CHECKS`
добавляется один ключ `check_branch_mergeable` (snapshot `_EXPECTED_QGS` обновляется
осознанно, AC-10).

Отвергнутые варианты:
- **(B) Новая стадия `merge-gate`** — концептуально честнее, но «пустая» стадия без агента
  не имеет триггера (см. Контекст §3). Потребовала бы chaining в `advance_stage`
  (не restart-safe для безагентного перехода) или синтетический job-тип в очереди
  (поверхность в `launcher`/`queue_worker`, который сейчас умеет только LLM-агентов).
- **(C) Перенос merge в детерминированный шаг оркестратора** — прямо запрещён объёмом
  (BRD §4: «Замена механизма слияния PR в Gitea — вне объёма»).

Триггер гейта — **существующее** событие «staging-deployer завершился» → отдельного
механизма триггера не вводим.

### 2. Догон ветки — `rebase` onto `origin/main` + `push --force-with-lease`

Выбор `rebase` (а не merge-commit) обусловлен критериями приёмки AC-2/AC-7, которые прямо
требуют `push --force-with-lease` догнанной ветки. Алгоритм `auto_rebase_onto_main`:

1. `git fetch origin main` в worktree ветки (`ensure_worktree`, AC-8 — изоляция).
2. `branch_is_behind_main`: ветка отстаёт ⇔ `git merge-base --is-ancestor origin/main <HEAD>`
   вернул ненулевой код. Не удалось определить (git/сеть) → трактуем как «не пропускаем
   вслепую» (never-raise → `(False, reason)`), НЕ как «up-to-date».
3. Не отстаёт → `(True, "branch up-to-date with main")`, rebase/push **не выполняются** (AC-1).
4. Отстаёт → `git rebase origin/main`:
   - **текстовый конфликт** → `git rebase --abort`, worktree чист → `(False, "rebase conflict: <файлы>")` (AC-3);
   - **чистый rebase** → `git push --force-with-lease origin <branch>` (**ТОЛЬКО ветка задачи; НИКОГДА `main`**, AC-7) → далее re-test.
5. Контракт **never-raise**: любая git/OS-ошибка → `(False, "<reason>")` (AC-9).

`main` гейтом не пушится и не форс-пушится никогда. Единственная force-операция —
`--force-with-lease` по ветке задачи.

### 3. Re-test — `python -m pytest` в worktree догнанной ветки

`retest_branch(repo, branch)`:
- Команда `python -m pytest <merge_retest_target>` (`merge_retest_target` по умолчанию
  `tests/`) из корня worktree ветки — согласовано с CI orchestrator
  (`pytest tests/ -q`, CLAUDE.md) и паттерном `check_tests_local`.
- Тайм-аут `settings.merge_retest_timeout_s` (дефолт 600); превышение →
  `(False, "re-test timeout (<T>s)")` (AC-6), процесс убивается, задача не виснет.
- `returncode == 0` → `(True, "re-test green")`; иначе `(False, "re-test failed after rebase: <tail>")` (AC-4).

> Гейт по умолчанию реален для self-hosting репо `orchestrator` (BR-7). Для других репо
> применять только при совпадающей тест-команде/раскладке — через `merge_gate_repos`
> (см. §6). Команда re-test параметризуется `merge_retest_target` для портируемости.

### 4. Сериализация слияний — файловый merge-lease на репозиторий (BR-5, AC-5)

Цель: «догон + re-test + **слияние**» одного репо выполняет одновременно только одна
задача. Слияние делает deployer ПОЗЖЕ и в ОТДЕЛЬНОМ запуске, поэтому простой
context-manager-lock внутри гейта окно гонки не закрывает — нужен **lease, живущий от
гейта до фактического merge**.

**Механизм — файловый lease**, БЕЗ изменения схемы БД (ТЗ §4 предпочитает no-schema-change):
- Файл `<repos_dir>/.merge-lease-<repo>.json`, содержимое `{task_id, work_item_id, branch,
  acquired_at, pid}`.
- **Acquire — атомарный, НЕблокирующий** (`open(..., O_CREAT|O_EXCL)`):
  - файла нет → захват, запись метаданных;
  - файл есть, holder == self → идемпотентно «уже наш» (restart/повтор);
  - файл есть, holder != self, возраст `< merge_lock_timeout_s` → **busy**;
  - файл есть, возраст `>= merge_lock_timeout_s` → **stale, перезахват** с `logger.warning`
    (crash-recovery: процесс-холдер умер, не освободив lease).
- **Release — идемпотентный** (`os.remove`, ignore-missing).
- **Restart-safe**: lease на диске; зависший lease реклеймится по возрасту.

**Поведение `check_branch_mergeable(repo, work_item_id, branch)`** (детерминированно, без LLM):
1. Попытка acquire (неблокирующая). Busy → `(False, "merge-lock busy")` — **сигнальный
   reason** (НЕ провал кода, см. §5: defer, а не rollback).
2. **Double-check под lease**: повторно `branch_is_behind_main` (пока ждали/между тиками
   `main` мог уйти — например, другая задача только что влилась).
3. Не отстаёт → `(True, "branch up-to-date with main")`.
4. Отстаёт → `auto_rebase_onto_main`:
   - конфликт → `(False, "rebase conflict: ...")`;
   - успех → `retest_branch`: зелёный → `(True, "rebased onto main, re-test green")`;
     красный/тайм-аут → `(False, "re-test failed after rebase: ...")`.
5. **При успехе lease НЕ освобождается** — он удерживается до фактического merge.
   **При любом провале (конфликт/красный re-test) lease освобождается** (откат на
   development, слияния не будет).
6. Регистрация в `QG_CHECKS["check_branch_mergeable"]`; сигнатура `(repo, work_item_id,
   branch)` совпадает с дефолтной артефактной → `_run_qg` диспетчеризует без спец-кейса.

**Жизненный цикл lease (точки release):**
- **PR-merged вебхук** ветки (`gitea.handle_pr`, `action=closed & merged`) → release;
- **`deploy → done`** в `advance_stage` (страховочный release);
- **любой откат на development** из merge-gate / `check_deploy_status` → release;
- **возраст `>= merge_lock_timeout_s`** → авто-реклейм (backstop при краше).

### 5. Откаты и defer (интеграция в `stage_engine`, BR-4/BR-8, AC-11)

`check_branch_mergeable` различает два негативных исхода:

- **`reason == "merge-lock busy"` → DEFER, не rollback.** Код задачи исправен — нельзя
  слать на development и нельзя тратить `MAX_DEVELOPER_RETRIES`. Движок **повторно
  ставит deployer на `deploy-staging` с задержкой** `settings.merge_defer_delay_s`
  (через `available_at`-гейт очереди, ORCH-1; задача остаётся на `deploy-staging`).
  Неблокирующий defer освобождает worker-слот → задача-холдер успевает влиться (нет
  дедлока при `max_concurrency=1`). Повторов defer — ограниченное число
  (`merge_defer_max_attempts`), исчерпание → Telegram-алерт + блокировка.
- **`reason` = конфликт rebase ИЛИ красный re-test → rollback на `development`** по образцу
  `check_staging_status`/`check_deploy_status` в `_handle_qg_failure_rollbacks`:
  `update_task_stage(development)`, `set_issue_blocked`, дословный `reason` в Plane
  (`plane_add_comment`, author="deployer"), `send_telegram`, учёт `MAX_DEVELOPER_RETRIES`,
  **release lease**. Дословный `reason` встраивается в `task_desc` developer'а (по образцу
  ORCH-046), чтобы агент видел суть.

### 6. Конфигурация (`src/config.py`, env-префикс `ORCH_`)

| Setting | Назначение | Дефолт |
|---------|-----------|--------|
| `merge_gate_enabled: bool` | Глобальный вкл/выкл (no-op `(True, "merge-gate disabled")` при False, AC-12) | `True` |
| `merge_gate_repos: str` | CSV-список репо, где гейт реален; пусто = только self-hosting (`orchestrator`) | `""` |
| `merge_retest_timeout_s: int` | Тайм-аут re-test | `600` |
| `merge_retest_target: str` | pytest-цель для re-test (портируемость) | `tests/` |
| `merge_lock_timeout_s: int` | Макс. возраст lease (ожидание/реклейм) | `300` |
| `merge_defer_delay_s: int` | Задержка перед повтором гейта при busy | `60` |
| `merge_defer_max_attempts: int` | Лимит defer-повторов до эскалации | `5` |

Семантика `merge_gate_repos`: пусто → гейт реален ТОЛЬКО для `orchestrator`
(`is_self_hosting_repo`), для прочих — no-op `(True, "merge-gate N/A for <repo>")`
(по образцу условного staging-гейта ORCH-35). Это безопасный поэтапный раскат.

### 7. API
Новых HTTP-эндпоинтов нет. Допустимо (необязательно) добавить в `GET /status`/`GET /queue`
индикатор состояния merge-lease для наблюдаемости — без изменения существующих контрактов.

---

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

### Плюсы
- Закрывает воспроизводимый сценарий «две зелёные ветки ломают `main`»: перед слиянием
  ветка догоняется до актуального `origin/main` и повторно тестируется; слияния
  сериализуются lease'ом.
- Минимальный blast-radius: `STAGE_TRANSITIONS` не тронут, snapshot-переходы не меняются,
  +1 ключ в `QG_CHECKS`. Триггер — существующее событие, без chaining/новых job-типов.
- Restart-safe и deadlock-safe: файловый lease с реклеймом по возрасту; неблокирующий
  acquire + defer вместо блокирующего ожидания.
- Соответствует self-hosting-инвариантам: никогда не пуш/форс-пуш `main`; force только
  `--force-with-lease` по ветке задачи; прод-контейнер не рестартится; страховка
  `deploy-staging` сохранена.
- Поэтапный раскат через `merge_gate_enabled` / `merge_gate_repos`.

### Минусы / ограничения
- **Merge-gate как «скрытый» под-гейт** ребра `deploy-staging → deploy` не отражён в
  `STAGE_TRANSITIONS` (плата за отказ от новой стадии). Смягчение: явно описан в
  `docs/architecture/README.md` и этом ADR.
- **Сериализация зависит от вебхука PR-merged** для своевременного release. Деградация
  предусмотрена (реклейм по возрасту `merge_lock_timeout_s`), но при «потерянном»
  вебхуке возможна задержка следующей задачи до тайм-аута lease.
- **Defer перезапускает staging-deployer** (повторно прогоняет staging-проверку и
  перезаписывает `15-staging-log.md`) — переиспользует существующий механизм очереди
  ценой лишнего прогона staging. Допустимо; альтернатива (отдельный «retry-gate» job-тип)
  дороже по поверхности.
- **Длинный re-test (до 600s)** исполняется синхронно в monitor-thread staging-deployer'а
  и удерживает worker-слот на это время (при `max_concurrency=1` приостанавливает прочие
  задачи). Это неотъемлемая стоимость «re-test перед слиянием».
- **`rebase --force-with-lease`** переписывает историю ветки и обновляет head открытого PR;
  прежний approve ревьюера может пометиться stale в Gitea. На стадии `deploy` ревью
  повторно не проверяется — функционально безопасно.

### Влияние на масштаб изменения
Вводится новый модуль (`src/merge_gate.py`), новый QG, lease-подсистема и изменение
поведения ребра `deploy-staging → deploy` + откаты/вебхук. Это **сквозное изменение
конвейера** → рекомендуется лейбл `arch:major-change` и обязательная страховка стадией
`deploy-staging` (8501) перед прод-деплоем самого ORCH-043. Глобальный ADR —
`docs/architecture/adr/adr-0006-merge-gate.md`.

---

## Точки изменения кода (для developer; имена функций — финальные)
- `src/merge_gate.py` (**новый**): `branch_is_behind_main`, `auto_rebase_onto_main`,
  `retest_branch`, lease (`acquire_merge_lease`/`release_merge_lease`/реклейм).
- `src/qg/checks.py`: `check_branch_mergeable(repo, work_item_id, branch)` + регистрация в `QG_CHECKS`.
- `src/stage_engine.py`: вызов merge-gate на ребре `deploy-staging → deploy` (после
  `check_staging_status`, до advance); ветка rollback merge-gate в
  `_handle_qg_failure_rollbacks`; defer-ветка для `"merge-lock busy"`; release lease в
  `deploy → done` и в откатах.
- `src/webhooks/gitea.py`: release lease в `handle_pr` (closed & merged).
- `src/db.py` (опц.): `enqueue_job(..., available_at_delay_s=...)` для defer, либо переиспользовать `available_at`.
- `src/config.py`: настройки §6.
- `tests/`: тесты по `04-test-plan.yaml` + обновить `tests/test_qg_registry_snapshot.py`
  (`_EXPECTED_QGS` += `check_branch_mergeable`; `_EXPECTED_TRANSITIONS` — **без изменений**).
- Документация: `docs/architecture/README.md` (обновлена в этом PR), `CHANGELOG.md`,
  `.env.example` (новые `ORCH_*`).
