# ADR-001: Per-repo serial gate + deferred branch cut + durable rollback-freeze (ORCH-088, Этап 1)

Work Item: **ORCH-088** · Repo: **orchestrator** (self-hosting) · Стадия: architecture
Связь: BRD `01-brd.md`, ТЗ `02-trz.md`, AC `03-acceptance-criteria.md`, данные `08-data-requirements.md`, риски `10-tech-risks.md`.
Сквозная регистрация: `docs/architecture/adr/adr-0017-serial-gate.md`.

## Статус
Proposed

---

## Контекст
Эпик ORCH-088 (Этап 1, serial e2e) требует обрабатывать пакет из 10–20 задач **строго по одной**
end-to-end и устранить **stale-анализ**: ветка задачи N+1 не должна срезаться от `main`, ещё не
содержащего код предшественника N (BRD §1.2). Физическое код-затирание при параллельном merge уже
закрыто (ORCH-026 auto_rebase + merge-lease); ORCH-088 закрывает **логический** разрыв.

Корень проблемы (проверено в коде):
1. `webhooks/plane.py::start_pipeline` при переводе issue в анализ:
   `create_task_atomic(stage='analysis')` → **`_create_gitea_branch(repo, branch)`** (срез Gitea-ветки
   от `main` в момент T0) → `_create_initial_docs(...)` → `enqueue_job("analyst", ...)`.
2. Позже `agents/launcher._spawn` зовёт `git_worktree.ensure_worktree(repo, branch)`, который при
   **существующей** Gitea-ветке делает `fetch + checkout <branch>` — **присоединяется к stale-ветке**,
   а не режет свежую. `ensure_worktree` режет от `origin/main` (`git worktree add -b … origin/main`)
   **только если ветки ещё нет** (git_worktree.py L84-86).

⇒ Ключ к AC-6: **ветка не должна быть создана раньше, чем предшественник `done`.** Гейтить только
claim недостаточно (R-1) — к этому моменту ветка уже срезана.

Существующий каркас для переиспользования: persistent-очередь ORCH-1 (`jobs`, atomic claim,
restart-safe), gate-в-claim ORCH-026 (`task_deps` `NOT EXISTS`), leaf-паттерн `src/task_deps.py` /
`src/post_deploy.py` (never-raise), наблюдаемость `GET /queue`, `max_concurrency=1`.

---

## Решение

### Сводка
Вводится **per-repo serial gate** двумя согласованными механизмами, аддитивно, под kill-switch,
с областью репо, never-raise, restart-safe. `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` — без изменений.

1. **Gate-в-claim** (`db.claim_next_job`): analyst-job новой задачи **не выбирается**, пока в том же
   репо есть другая незавершённая задача ИЛИ репо заморожен. Job уже активной задачи
   (architect/developer/…/deployer) проходят свободно.
2. **Отложенный срез ветки**: для применимого репо `start_pipeline` **не** создаёт Gitea-ветку и docs;
   создание ветки+docs **релоцируется** в момент claim analyst-job (launcher), когда `origin/main`
   уже содержит предшественника. Это и даёт AC-6 структурно.
3. **Durable per-repo freeze** (`repo_freeze`): при post-deploy `DEGRADED` репо замораживается; gate
   закрыт безусловно до **ручного** снятия.

Чистая логика — **новый leaf-модуль `src/serial_gate.py`** (never-raise, по образцу
`src/task_deps.py` / `src/post_deploy.py`).

### D1 — Где гейтить вход в анализ: claim + relocation среза ветки (OQ-1)
**Решение: релоцировать срез ветки на claim analyst-job, гейтить в `claim_next_job`.**

- `claim_next_job` получает дополнительный SQL-фрагмент `serial_gate` (строится как существующий
  `dep_gate`, конкатенацией; только локальная БД — NFR-2 offline). Условие «**не** выбирать job,
  если это analyst-job применимого репо, у которого есть конфликт»:

  ```
  AND NOT (
      jobs.agent = 'analyst'
      {repo_scope}                       -- "" при пустом CSV (все репо); иначе AND jobs.repo IN (<sanitized>)
      AND (
          EXISTS (SELECT 1 FROM tasks t2
                   WHERE t2.repo = jobs.repo
                     AND t2.id  != jobs.task_id        -- «другая» задача (rework-analyst своей же задачи не блокирует себя)
                     AND t2.stage != 'done')
          OR EXISTS (SELECT 1 FROM repo_freeze f
                      WHERE f.repo = jobs.repo AND f.cleared_at IS NULL)
      )
  )
  ```
- Гейт **только для `jobs.agent='analyst'`** — вход в анализ. Прочие роли активной задачи проходят
  (иначе единственная активная задача не сдвинется). Rework-analyst (`start_pipeline` rejection-path,
  re-enqueue analyst той же задачи) не блокируется собой за счёт `t2.id != jobs.task_id`.
- **Relocation среза ветки**: для применимого репо `start_pipeline` создаёт task-row
  (`stage='analysis'`) и enqueue analyst-job, но **НЕ** зовёт `_create_gitea_branch` /
  `_create_initial_docs`. Эти два вызова переносятся в путь spawn analyst-job (launcher), выполняясь
  **в момент claim** — когда `origin/main` уже включает предшественника (gate открылся ⇒ предшественник
  `done` ⇒ merge-verify ORCH-071 гарантировал SHA-в-main). Последовательность на claim сохраняется
  идентичной нынешней: `_create_gitea_branch` (от свежего `main`) → `_create_initial_docs` →
  `ensure_worktree` (fetch+checkout только что созданной ветки) ⇒ база = свежий `origin/main`.
- **Идемпотентность**: `_create_gitea_branch` уже обрабатывает 409 «branch exists» как no-op ⇒ повтор
  claim (рестарт/реклейм) безопасен без флага в БД. AC-6 проверяемо:
  `git merge-base --is-ancestor <validated_sha A> <base B>`.

**Почему не «гейт в `start_pipeline` + отложенный re-trigger»** (альтернатива OQ-1.A): webhook Plane —
one-shot; отложенная задача потребовала бы отдельного re-trigger (reconciler/done-hook) и псевдо-стадии
ожидания → больше состояния, больше путей, выше риск «зависшей» задачи. Relocation на claim
переиспользует уже-restart-safe `jobs`-очередь: ожидающая задача = `queued` analyst-job без ветки;
открытие gate = обычный claim на ближайшем тике планировщика (AC-2, AC-3 «бесплатно»).

### D2 — Хранилище freeze: отдельная таблица `repo_freeze` (OQ-2)
**Решение: новая аддитивная таблица** `repo_freeze(repo, frozen_at, reason, work_item_id, cleared_at)`
(детали — `08-data-requirements.md`). Активный freeze ⇔ `cleared_at IS NULL`. Колонка на `tasks`
отвергнута: freeze — **per-repo** сигнал, а деградировавшая задача к этому моменту уже `stage='done'`
(BR-7) — привязка к задаче семантически неверна. Таблица — append-only журнал (история заморозок,
наблюдаемость), идемпотентная миграция `CREATE TABLE IF NOT EXISTS`.

### D3 — Выставление freeze (FR-5)
В `stage_engine.run_post_deploy_monitor` в ветке вердикта `DEGRADED` (после реакции
`ALERT_ONLY`/`ROLLBACK*`, рядом с `set_issue_blocked`, L1702-1715) — вызов
`serial_gate.set_repo_freeze(repo, reason, work_item_id)` (never-raise) + Telegram-алерт
«пакет заморожен, следующая задача не стартует до ручного снятия» (reuse `send_telegram`/`_notify_post_deploy`).
Freeze **durable** (БД), self-hosting прод **не** рестартится/не роняется (NFR-6) — freeze есть
пассивная остановка стартов, не действие над прод.

### D4 — Снятие freeze: явный админ-эндпоинт (OQ-3)
**Решение: `POST /serial-gate/unfreeze` (body/query `repo=<repo>`)** → `serial_gate.clear_repo_freeze(repo)`
(ставит `cleared_at=now` всем активным строкам репо) + лог + Telegram-подтверждение. Аутентификация —
по существующему админ-механизму сервиса (тот же секрет/доступ, что у управляющих ручек; developer
согласует с текущей поверхностью). Альтернативой допускается ручная правка БД
(`UPDATE repo_freeze SET cleared_at=…`) — задокументировать в README. Снятие — простое, явное,
наблюдаемое (`GET /queue`). Plane-жест как триггер снятия **отвергнут** (перегрузка статусов —
анти-паттерн ORCH-059).

### D5 — Область по умолчанию: все зарегистрированные репо (OQ-5)
**Решение: пустой `serial_gate_repos` ⇒ применять ко ВСЕМ репо** (а не self-hosting-only как
ORCH-35/43/58). Обоснование: serial e2e и анти-stale-base полезны и enduro-trails (FR-3), у каждого
репо свой `main`. Cross-repo независимость сохраняется самим условием (`t2.repo = jobs.repo`). Оператор
может сузить область CSV (`ORCH_SERIAL_GATE_REPOS=orchestrator`), если хочет оставить enduro
без serial. «Нулевая регрессия для enduro» (BR-8/NFR-4) относится к **выключенному** kill-switch.

### D6 — Blocked/Needs-Input держит gate закрытым (OQ-4)
**Решение (Этап 1): осознанно держит.** Задача в Blocked/Needs-Input имеет `stage != 'done'` ⇒
участвует в `EXISTS` ⇒ gate закрыт. Пакет не движется, пока оператор не доведёт задачу до прод или не
закроет. Отдельный «вывод из учёта активных» — вне скопа (зафиксировано в AC, BRD §6). Наблюдаемость
(`GET /queue` + Telegram-карточка «⏳ ждёт …») делает залипание видимым (R-3).

### D7 — Конфигурация (`src/config.py`)
По образцу `task_deps_*` / `post_deploy_*`:
- `serial_gate_enabled: bool = True` (`ORCH_SERIAL_GATE_ENABLED`) — kill-switch. `False` ⇒ `claim` и
  `start_pipeline` 1:1 как сейчас (ветка режется в `start_pipeline`, gate-фрагмент опущен) — NFR-4/AC-7.
- `serial_gate_repos: str = ""` (`ORCH_SERIAL_GATE_REPOS`, CSV) — область; пусто ⇒ все репо (D5).
- `serial_gate_freeze_enabled: bool = True` (`ORCH_SERIAL_GATE_FREEZE_ENABLED`) — независимый тумблер
  freeze-слоя (FR-5) для поэтапного раската; `False` ⇒ freeze не выставляется/не учитывается.
- Helper `serial_gate.serial_gate_applies(repo) -> bool` (never-raise): `enabled` + (CSV непуст →
  членство; иначе True).

### D8 — Leaf-модуль `src/serial_gate.py` (never-raise)
Публичный контракт (вся логика без сети, только БД/config):
- `serial_gate_applies(repo) -> bool`
- `repo_has_active_task(repo, exclude_task_id=None) -> bool` — `EXISTS tasks stage!='done'`
- `is_repo_frozen(repo) -> bool` — **fail-CLOSED** (ошибка/сомнение → `True`, AC-9)
- `set_repo_freeze(repo, reason, work_item_id)` / `clear_repo_freeze(repo)`
- `build_claim_clause() -> str` — SQL-фрагмент для `claim_next_job` (санитизация repo-токенов
  `^[A-Za-z0-9._-]+$` перед встраиванием в `IN (...)`; невалидный токен дропается)
- `snapshot() -> dict` — per-repo `{active_task, waiting, frozen, frozen_reason, frozen_at}` для `/queue`

### D9 — Наблюдаемость `GET /queue` (BR-9, AC-10)
Аддитивный блок `serial_gate`: `{enabled, repos, per_repo: {<repo>: {active_task:{work_item_id,stage}|null,
waiting:[…], frozen:bool, frozen_reason, frozen_at}}}`. never-raise: при ошибке — минимальный словарь
с флагами и пустыми данными. Существующие ключи `/queue` не меняются.

### D10 — Согласование fail-open (claim) ↔ fail-closed (freeze) — NFR-1
Два требования действуют на разных слоях, без противоречия:
- **Hot-claim, тотальный сбой gate-запроса** ⇒ **fail-OPEN**: весь `serial_gate`-фрагмент строится
  через `try/except` в `build_claim_clause`; любая ошибка построения → пустой фрагмент → claim как без
  gate. Заклинивание очереди ВСЕХ проектов (включая enduro) хуже, чем разовый риск stale-base (AC-8).
- **Freeze-решение в Python-слое** (`is_repo_frozen`, deferral-решение, snapshot) ⇒ **fail-CLOSED**:
  невозможность подтвердить отсутствие freeze → считать замороженным, не стартовать (AC-9, безопасность
  прода). Когда freeze реально выставлен, строка `repo_freeze` существует и блокирует в самом SQL —
  fail-open в claim касается лишь тотального сбоя запроса (транзиент), что приемлемо.

---

## Точки врезки (для разработчика)
| Файл | Изменение |
|------|-----------|
| `src/serial_gate.py` | **новый** leaf-модуль (D8) |
| `src/db.py` | миграция `repo_freeze` (idempotent); `serial_gate` фрагмент в `claim_next_job` (D1); read-only helper'ы выборки активной задачи/freeze |
| `src/config.py` | `serial_gate_enabled` / `serial_gate_repos` / `serial_gate_freeze_enabled` (D7) |
| `src/webhooks/plane.py` | `start_pipeline`: для применимого репо **не** звать `_create_gitea_branch`/`_create_initial_docs`; оставить task-row + enqueue analyst (D1) |
| `src/agents/launcher.py` | `_spawn` для `agent=='analyst'` применимого репо: материализовать ветку+docs (relocated `_create_gitea_branch`+`_create_initial_docs`) перед `ensure_worktree` (D1) |
| `src/stage_engine.py` | `run_post_deploy_monitor` DEGRADED-ветка: `serial_gate.set_repo_freeze(...)` + алерт (D3) |
| `src/main.py` | `GET /queue` блок `serial_gate` (D9); `POST /serial-gate/unfreeze` (D4) |
| `src/notifications.py` | Telegram-карточка `⏳ ждёт завершения <wi>` (по образцу task_deps), best-effort |

---

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

### Плюсы
- AC-6 закрыт **структурно** (ветка не существует до открытия gate) — не «лечение следствия».
- AC-2/AC-3 «бесплатны»: ожидание = `queued` analyst-job без ветки в restart-safe `jobs`-очереди;
  открытие gate = обычный claim. In-memory состояния нет (NFR-3).
- Переиспользует проверенные паттерны (claim-gate ORCH-026, leaf never-raise, `/queue`-снимок).
- `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/merge-gate/merge-verify/image-freshness/post-deploy/
  deploy-хук/`max_concurrency` — без изменений (NFR-5/AC-11).
- Cross-repo параллелизм сохранён (FR-3/AC-4); enduro при выключенном флаге не затронут (NFR-4).

### Минусы / ограничения
- Срез ветки и `_create_initial_docs` мигрируют из async-`start_pipeline` в sync-путь launcher —
  developer обязан обернуть Gitea-API вызовы для sync-контекста (httpx sync / `asyncio.run`); риск
  R-4 (см. `10-tech-risks.md`).
- Между `start_pipeline` и claim analyst-job у задачи нет материализованной ветки — потребители,
  ожидающие ветку до запуска analyst, должны быть проверены (R-5).
- Blocked-задача держит пакет (D6) — осознанный размен Этапа 1; требует операторского внимания.
- Freeze снимается только вручную — «вечный freeze» при невнимании оператора (R-3, mitigation —
  наблюдаемость + алерт).

### Откат
Полный откат — `serial_gate_enabled=False` (claim/старт 1:1 как сейчас) и/или
`serial_gate_freeze_enabled=False`. Таблица `repo_freeze` инертна при выключенных флагах.

---

## Альтернативы (отклонены)
- **Гейт в `start_pipeline` + re-trigger при `done`** — больше состояния/путей, выше риск зависания (D1).
- **Freeze как колонка `tasks`** — неверная семантика (freeze per-repo, задача уже `done`) (D2).
- **Self-hosting-only область** (как ORCH-35/43/58) — лишает enduro анти-stale-base (D5).
- **Снятие freeze Plane-жестом** — перегрузка статусов, анти-паттерн ORCH-059 (D4).
- **Отдельная таблица очереди ожидания** — избыточно, `jobs`(queued)+gate достаточно (ТЗ §5).

## Связи
- Переиспользует: ORCH-1 (очередь), ORCH-026 (claim-gate, auto_rebase/merge-lease), ORCH-021
  (post-deploy monitor — источник DEGRADED), ORCH-071/073 (merge-verify ⇒ `done` ⇔ SHA-в-main).
- Сквозной ADR: `docs/architecture/adr/adr-0017-serial-gate.md`.
- Не пересекается с merge-очередью/pre-merge rebase/фазами A/B/C — **вне скопа** Этапа 1.
