# ADR-001: Сериализация merge/деплоя внутри репо (A) + декларативные зависимости задач (B)

**Work Item:** ORCH-026 · **Repo:** orchestrator (self-hosting) · **Стадия:** architecture
**Статус:** Accepted
**Связи:** дополняет ORCH-043 (merge-gate), ORCH-065 (merge-lease + reclaim), ORCH-073/071
(merge-verify, SHA-in-main), ORCH-1 (очередь). Глобальный ADR — `adr/adr-0015`.

---

## Контекст

ORCH-026 закрывает **первопричину** эрозии `main` 08.06 (некоординированный параллелизм
задач одного репо: ветки от устаревшего `main`, фантом-merge затирает соседа) и попутно вводит
исходный скоуп — декларативные зависимости задач (B ждёт A). Требования — `01-brd.md`,
`02-trz.md`; PASS/FAIL — `03-acceptance-criteria.md`.

Ключевое наблюдение архитектора: **бо́льшая часть инфраструктуры для Уровня A уже существует** и
её НЕ нужно переписывать:

- **merge-lease** (ORCH-065, `src/merge_gate.py`): per-repo файловый лиз
  `.merge-lease-<repo>.json`, неблокирующий acquire, holder-aware release, проактивный реклейм
  мёртвого/устаревшего держателя (`reclaim_stale_lease`, `pid_alive`). Restart-safe, per-repo.
- **merge-gate** (ORCH-043, `check_branch_mergeable`): на ребре `deploy-staging → deploy`
  захватывает лиз, при необходимости ребейзит, держит лиз до фактического merge.
- **defer-механизм** (`_handle_merge_gate_defer`): `merge-lock busy` → повторная постановка
  deployer'а через `available_at`, bounded `merge_defer_max_attempts` → эскалация (Blocked+alert).
- **окно лиза** уже простирается от `deploy-staging → deploy` до release на одном из событий:
  PR-merged webhook (`gitea.py`), `deploy→done` (`stage_engine.py`), откат, проактивный реклейм.
  Для self-hosting `done` достигается ТОЛЬКО после `verify_merged_to_main` (SHA-in-main, ORCH-073).

Таким образом окно сериализации A-1 («merge → main-updated») **структурно уже реализовано**:
пока A не подтверждена в `main` (для self — SHA-in-main → `done`), лиз держится, и B того же
репо на своём merge-gate получает `merge-lock busy` → defer. Открытый вопрос BRD §4.3 (граница
окна для self) решается так: **окно = от acquire до release; release-события не меняем**. Для
non-self репо граница — PR-merged webhook; для self — `deploy→done` (= SHA-in-main подтверждён).

Что реально **отсутствует** для Уровня A:

- **A-2: безусловный proactive pre-merge rebase.** Сейчас `check_branch_mergeable` ребейзит
  ТОЛЬКО если `branch_is_behind_main` (⇔ `origin/main` не предок HEAD). AC-A2 требует, чтобы
  rebase вызывался **всегда** перед merge — детерминированный структурный анти-фантом на уровне
  планировщика, не зависящий от точности ancestor-проверки.

Для Уровня B инфраструктуры нет вовсе: очередь `jobs` (ORCH-1) плоская (FIFO по `id` +
`available_at` + `max_concurrency`), выразить «B ждёт A» нельзя.

---

## Решение

### Уровень A — сериализация merge/деплоя (минимально-инвазивно, переиспользуя ORCH-043/065)

**A-1/A-3/A-4 (окно сериализации) — без изменений механизма.** Окно сериализации обеспечивается
существующим merge-lease: захват в `check_branch_mergeable`, удержание до release. Подтверждаем и
фиксируем в доке, что release-события (`PR-merged` / `deploy→done` / откат / `reclaim_stale_lease`)
формируют окно «merge → main-updated». Кросс-репо параллелизм сохранён автоматически (лиз —
per-repo файл). Restart-safe и анти-залипание — за счёт ORCH-065 reclaim. **Кода-изменений нет.**

**A-2 (безусловный pre-merge rebase) — новое поведение, флаг `premerge_rebase_always`.**

- В `check_branch_mergeable` (`src/qg/checks.py`), ПОД захваченным merge-lease: когда
  `settings.premerge_rebase_always` истинно (и merge-gate применим к репо), **пропустить
  short-circuit `branch_is_behind_main`** и **всегда** вызвать `merge_gate.auto_rebase_onto_main`.
- `auto_rebase_onto_main` уже идемпотентен и дёшев на актуальной ветке: `git rebase origin/main`
  на не-отстающей ветке — no-op (rc 0, HEAD не меняется), последующий `push --force-with-lease`
  → «Everything up-to-date» (тот же SHA, **CI не перезапускается, лишних коммитов нет**). На
  отстающей ветке — реальный догон. Текстовый конфликт → существующий контракт: `rebase --abort`
  → откат на `development` (как ORCH-043). **Инвариант: никаких push/force-push в `main`** —
  единственная force-операция остаётся `--force-with-lease` на ветку задачи.
- Когда флаг выключен → прежнее поведение (ребейз только при `branch_is_behind_main`),
  обратная совместимость 1:1 (AC-A7/AC-G2).
- **Скоуп — общий с merge-gate:** реально только для `merge_gate_repos` (пусто → self-hosting
  `orchestrator`). Никакого нового scope-флага.

**A-5/A-6 (safety, anti-livelock) — без изменений.** `STAGE_TRANSITIONS`, `QG_CHECKS`,
`Confirm Deploy` (ORCH-059), exit-коды хука, terminal-sync не трогаются. defer-бюджет —
существующий `merge_defer_max_attempts` → Blocked+alert при исчерпании. Прод-контейнер не
рестартится вне штатного `Confirm Deploy`.

### Уровень B — декларативные зависимости (новая инфраструктура)

**B-источник: гибрид с БД как источником истины для планировщика; флаг `task_deps_source`.**

Планировщик `claim_next_job` — горячий цикл, обслуживающий очередь ВСЕХ проектов из ОДНОГО
инстанса. Он **обязан** быть offline-устойчивым и быстрым: сетевой запрос в Plane на каждый claim
= при недоступности Plane встанет конвейер всех проектов (нарушение self-hosting safety). Поэтому:

- **Авторитетный для планировщика стор — локальная БД**, новая аддитивная таблица
  `job_deps(task_id, depends_on_task_id, created_at)` (детали — `08-data-requirements.md`).
  Связь хранится по `tasks.id` (стабильный локальный ключ). Зависимости — **только внутри одного
  репо** (v1; кросс-репо — non-goal, BRD §5).
- **`task_deps_source = db | plane | hybrid`** (дефолт **`db`**): `db` — связи пишутся напрямую в
  `job_deps` (потребитель — декомпозиция эпиков ORCH-025); `plane` — связи читаются из Plane
  relations в `handle_work_item_created` и **кэшируются** в `job_deps`; `hybrid` — Plane как
  декларация + БД-кэш. Plane-ingestion — тонкий add-on за флагом; планировщик ВСЕГДА читает БД.

**B-2 (гейт планировщика) — SQL `NOT EXISTS`, без занятия слота `max_concurrency`.**

Гейт готовности выражается декларативно в `claim_next_job` (`src/db.py`): задача claimable, если
у неё нет ни одной незавершённой зависимости. Когда `settings.task_deps_enabled` — к существующему
SELECT добавляется условие:

```sql
AND NOT EXISTS (
    SELECT 1 FROM job_deps d
    JOIN tasks t ON t.id = d.depends_on_task_id
    WHERE d.task_id = j.task_id AND t.stage != 'done'
)
```

Это: (1) **не занимает слот** — job просто не выбирается, агент не запускается (AC-B2);
(2) restart-safe (чистая БД); (3) never-raise (это SQL); (4) при пустой `job_deps` —
инертно (нулевая регрессия, AC-G2); (5) при выключенном `task_deps_enabled` условие НЕ
добавляется → запрос 1:1 как в ORCH-1. Как только все зависимости достигают `stage='done'`,
задача автоматически становится claimable.

Чистая leaf-логика «готова ли задача» выносится в новый модуль `src/task_deps.py`:
`is_task_ready(task_id) -> (bool, waiting_on: list[str])` (never-raise) — для реконсилятора,
карточки и `/queue` (SQL в `claim_next_job` — горячий путь, дублирует ту же семантику).

**B-3 (детект дедлоков) — DFS, чистая функция.**

`task_deps.detect_cycle(task_id) -> list[int] | None` — обход графа `job_deps` (внутри репо),
детерминированный, юнит-тестируемый, never-raise. Запускается: (1) при вставке связи
(`add_dependency`) — цикл отклоняется/алертится сразу (лучший UX); (2) backstop-проход в тике
`reconciler` (на случай связей, добавленных в обход). Цикл → `set_issue_blocked(work_item_id)` +
Telegram/Plane alert с перечислением цикла. SQL-гейт B-2 сам по себе никогда не выберет задачу в
цикле (её зависимости не достигнут `done`) — детектор делает это **видимым**, а не молчаливым
вечным ожиданием (AC-B3). Поток остальных задач не блокируется.

**B-4 (видимость).**

- Нормальное ожидание (B ждёт A, A в работе — транзиентно и ожидаемо): строка в Telegram-карточке
  «⏳ ждёт ORCH-NNN» через `notifications.update_task_tracker`, never-raise/silent. **Plane Blocked
  при нормальном ожидании НЕ ставим** — иначе флаппинг Blocked на каждом коротком ожидании.
- Дедлок/цикл (B-3): `set_issue_blocked` (Plane `Blocked`) + alert. Это «и/или» из AC-B4.
- Инвариант «одна карточка на задачу» сохранён (ORCH-042/067).

**B-5 (совместимость reconciler/reaper).**

- `reconciler` F-1 не должен «разблокировать» dep-заблокированную задачу мимо её зависимостей.
  В фильтр пригодности reconciler добавляется проверка `task_deps.is_task_ready` (по образцу
  `reconcile_skip_blocked_enabled`, ORCH-060): не готова → skip.
- `reaper` сканирует **`running`** jobs; dep-заблокированный job остаётся `queued` (его не
  клеймят) → reaper его не трогает по построению. Фиксируем в доке.

**Наблюдаемость (TRZ §4):** блок `task_deps` в снимке `GET /queue` (read-only, по образцу
`reconcile`/`reaper`): кол-во заблокированных задач, держатель merge-lease, defer-счётчики,
обнаруженные циклы. Никогда не источник решений.

### Конфигурация (`src/config.py`)

| Флаг | Дефолт | Назначение |
|------|--------|-----------|
| `premerge_rebase_always` | `True` | Уровень A: безусловный pre-merge rebase под лизом. Скоуп — `merge_gate_repos`. Kill-switch (`False` → ребейз только при behind, как ORCH-043). |
| `task_deps_enabled` | `True` | Уровень B: глобальный kill-switch гейта зависимостей. `False` → `claim_next_job` 1:1 как ORCH-1. Инертно при пустой `job_deps`. |
| `task_deps_source` | `"db"` | Источник деклараций: `db`\|`plane`\|`hybrid`. Планировщик всегда читает БД-кэш. |

Дефолты следуют конвенции репо (`*_enabled=True` + kill-switch), при этом обе фичи инертны без
данных (нет деклараций / нет применимых репо) → нулевая регрессия для enduro-trails.

---

## Альтернативы (и почему отвергнуты)

1. **Уровень A — отдельный глобальный per-repo merge-lock или FIFO merge-queue.** Дублировал бы
   уже существующий merge-lease (ORCH-065), вводил второй механизм сериализации с риском
   рассинхрона. Отвергнуто: BRD §4.2 требует минимально-инвазивного решения, переиспользующего
   ORCH-065/043. Окно лиза уже даёт сериализацию.

2. **Уровень A — расширять release-точки лиза (держать до отдельного `main-updated`-события).**
   Не требуется: для self `done` ⇔ SHA-in-main (ORCH-073), для non-self — PR-merged webhook;
   окно уже корректно. Доп. событие усложнило бы reclaim без выигрыша.

3. **Уровень B — Plane relations как источник истины планировщика.** Сетевой запрос в горячем
   цикле claim; при недоступности Plane встаёт очередь всех проектов (self-hosting risk).
   Отвергнуто; Plane оставлен опциональным источником **декларации** (`task_deps_source=plane`),
   но планировщик читает только БД-кэш.

4. **Уровень B — гейт зависимостей в воркере (`_drain_once`) поверх `claim_next_job`.** Пришлось
   бы клеймить job, обнаруживать незавершённую зависимость и re-queue’ить — churn, расход attempts,
   гонки. SQL `NOT EXISTS` в самом `claim_next_job` чище: job просто не выбирается, слот свободен.

5. **Уровень B — поле/JSON в `tasks` вместо таблицы.** Таблица `job_deps` нормальна (M:N),
   индексируема, проще для DFS и `NOT EXISTS`. Поле в `tasks` потребовало бы парсинг-логики.

---

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

**Плюсы.**
- Минимально-инвазивно: Уровень A — один флаг + снятие short-circuit; окно сериализации не
  переписывается. Переиспользует ORCH-043/065 целиком.
- Уровень B — одно `NOT EXISTS` в `claim_next_job` + аддитивная таблица + leaf-модуль
  `task_deps.py`; `STAGE_TRANSITIONS`/`QG_CHECKS` не тронуты (паттерн врезки ORCH-071/058).
- Обе фичи инертны без данных → нулевая регрессия для enduro-trails (AC-A7/AC-G2).
- restart-safe (БД + файловый лиз), never-raise, kill-switch на каждую фичу.

**Минусы / ограничения.**
- `premerge_rebase_always=True` добавляет (дешёвый, no-op на актуальной ветке) `rebase`+`push`
  на каждый self-merge. Цена — лишний git-вызов; компенсируется детерминизмом анти-фантома.
- Уровень B v1 — только intra-repo зависимости; кросс-репо — follow-up (non-goal).
- Гейт B-2 в `claim_next_job` слегка усложняет горячий SQL (один `NOT EXISTS`); защищён
  kill-switch и инертностью при пустой таблице.
- `task_deps.py` цикл-детектор — новая поверхность; покрывается юнит-тестами (`04-test-plan.yaml`).

**Инварианты (не нарушать).**
1. `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_deploy_status`/`check_staging_status`,
   `Confirm Deploy` (ORCH-059), БАГ-8, terminal-sync — без изменений.
2. Никаких push/force-push в `main`; force только `--force-with-lease` на ветку задачи.
3. Сериализация — строго per-repo; кросс-репо параллелизм сохранён.
4. never-raise во всех новых функциях; restart-safe состояние; миграция БД только аддитивная.
5. ORCH-026 **дополняет** рубежи ORCH-073, не заменяет.
6. Прод-контейнер orchestrator не рестартится вне штатного `Confirm Deploy`.

**Места реализации (для developer).**
- `src/qg/checks.py::check_branch_mergeable` — ветка `premerge_rebase_always`.
- `src/db.py::claim_next_job` — условный `NOT EXISTS`-гейт; новые helpers `add_dependency`,
  `get_dependencies`, `job_deps` миграция в `init_db` (`CREATE TABLE IF NOT EXISTS`).
- `src/task_deps.py` (новый, leaf) — `is_task_ready`, `detect_cycle`, snapshot для `/queue`.
- `src/webhooks/plane.py::handle_work_item_created` — ingestion Plane relations (за `task_deps_source`).
- `src/reconciler.py` — skip dep-заблокированных + backstop цикл-детект.
- `src/notifications.py` — строка ожидания в карточке.
- `src/config.py` — `premerge_rebase_always`, `task_deps_enabled`, `task_deps_source`.
- Документация: `docs/architecture/README.md`, `CLAUDE.md` (если меняется поведение очереди),
  `CHANGELOG.md`, глобальный `adr/adr-0015`.
