architect(ET): auto-commit from architect run_id=436
All checks were successful
CI / test (push) Successful in 27s

This commit is contained in:
2026-06-09 10:52:01 +03:00
parent e89438dcad
commit c7afb80286
6 changed files with 417 additions and 1 deletions

View File

@@ -92,6 +92,39 @@ Self-hosting зацикливался на `deploy-staging`: `scripts/staging_ch
Подробнее: [adr-0015](adr/adr-0015-task-deps-and-merge-serialization.md), детально — `docs/work-items/ORCH-026/06-adr/ADR-001-merge-serialization-and-task-deps.md`.
### Per-repo serial gate: пакетный автономный режим (ORCH-088 — design)
Эпик «1020 задач за ночь», Этап 1 (serial e2e). Закрывает **stale-анализ**: ветка задачи N+1
срезалась на входе в анализ (`start_pipeline._create_gitea_branch`) от `main`, ещё не содержащего код
предшественника N (физическое код-затирание уже закрыто ORCH-026; ORCH-088 — **логический** разрыв).
Новая задача репо не входит в `analysis` (не режет ветку, не запускает analyst), пока в том же репо
есть незавершённая задача (`stage != 'done'`) или репо заморожен. Аддитивно, под kill-switch, область
репо, never-raise, restart-safe; `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*`**без изменений**.
- **Gate-в-claim** (`db.claim_next_job`) — analyst-job (`jobs.agent='analyst'`) применимого репо не
выбирается, если `EXISTS` другая незавершённая задача репо (`t2.id != jobs.task_id` — rework-analyst
не блокирует себя) ИЛИ активна строка `repo_freeze`. По образцу `task_deps` `NOT EXISTS` (ORCH-026);
только локальная БД (offline hot-path, NFR-2). Job'ы уже активной задачи проходят свободно.
- **Отложенный срез ветки (анти-stale-base, AC-6):** для применимого репо `start_pipeline` создаёт
task-row + enqueue analyst, но **не** создаёт Gitea-ветку/docs; срез релоцируется на момент claim
analyst-job (launcher), когда `origin/main` уже содержит предшественника (`done` ⇔ SHA-в-main,
ORCH-071/073). `ensure_worktree` режет от свежего `origin/main` ⇒ AC-6 структурно. Идемпотентно
(`_create_gitea_branch` 409 = no-op).
- **Durable per-repo freeze** (новая аддитивная таблица `repo_freeze`, `cleared_at IS NULL` = активен) —
post-deploy `DEGRADED`/rollback (ORCH-021) → `set_repo_freeze` + Telegram-алерт; gate закрыт
безусловно до **ручного** снятия (`POST /serial-gate/unfreeze`). Деградировавшая задача уже `done`
(BR-7) ⇒ отдельный сигнал, независимый от `stage`.
- **Согласование NFR-1:** hot-claim тотальный сбой построения gate-фрагмента → **fail-open** (не
заклинить очередь всех проектов, AC-8); freeze в Python-слое (`is_repo_frozen`) → **fail-closed**
(безопасность прода, AC-9).
- Чистая логика — leaf `src/serial_gate.py` (never-raise). Флаги `serial_gate_enabled` (kill-switch),
`serial_gate_repos` (CSV; **пусто ⇒ все репо**, в отличие от self-hosting-only ORCH-35/43/58),
`serial_gate_freeze_enabled`. Наблюдаемость — аддитивный блок `serial_gate` в `GET /queue`
(per-repo `active_task` / `waiting` / `frozen`). Cross-repo параллелизм сохранён (FR-3); при
выключенном флаге — нулевая регрессия (enduro не затронут).
Подробнее: [adr-0017](adr/adr-0017-serial-gate.md), детально —
`docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md`,
`docs/work-items/ORCH-088/08-data-requirements.md`.
### Исполняемый самодеплой стадии `deploy` (ORCH-36)
`deploy` перестаёт быть «бумажной»: для self-hosting (`is_self_hosting_repo`) стадия
РЕАЛЬНО деплоит прод (8500) через хост-хук `scripts/orchestrator-deploy-hook.sh`,

View File

@@ -22,11 +22,12 @@ Per-work-item решения живут в `docs/work-items/<id>/06-adr/ADR-NNN-
| adr-0014 | SHA-в-main — единственный критерий merge-verify + регресс-гард | accepted | 2026-06-08 | ORCH-073 |
| adr-0015 | Зависимости задач (B ждёт A) + сериализация merge внутри репо | accepted | 2026-06-08 | ORCH-026 |
| adr-0016 | ensure_open_pr — гарантированный код-PR перед merge-verify | accepted | 2026-06-09 | ORCH-082 |
| adr-0017 | Per-repo serial gate (пакетный автономный режим, serial e2e) | proposed | 2026-06-09 | ORCH-088 |
> ⚠️ Историческая коллизия: номер `0007` занят двумя файлами —
> `adr-0007-reconciler.md` (ORCH-053) и `adr-0007-executable-self-deploy.md`
> (ORCH-036). Оба accepted; для новых сквозных ADR использовать следующий
> свободный номер (текущий максимум — `0016`).
> свободный номер (текущий максимум — `0017`).
> adr-0014 **amends** adr-0013 (меняет критерий merge-verify на «SHA-в-main»).
> adr-0016 **amends** adr-0013/0014 (гарантирует открытый код-PR перед merge_pr, ORCH-082).

View File

@@ -0,0 +1,59 @@
# adr-0017: Per-repo serial gate (пакетный автономный режим, serial e2e)
Статус: **proposed** · Дата: 2026-06-09 · Источник: **ORCH-088** (Этап 1)
Детально: `docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md`.
## Контекст
Цель эпика ORCH-088 — масштаб автономности: накидать вечером 1020 задач и получить к утру пакет,
последовательно проведённый через весь конвейер (analysis → … → deploy → done). Корневая проблема —
**stale-анализ**: ветка задачи N+1 срезается на входе в анализ (`start_pipeline._create_gitea_branch`)
от `main`, ещё не содержащего код предшественника N. Физическое код-затирание уже закрыто (ORCH-026
auto_rebase + merge-lease); остаётся **логический** разрыв. Plane API v1 не имеет bulk/relations ⇒
очередь/зависимости хранятся у оркестратора (gate по локальной БД).
## Решение
**Per-repo serial gate** — новая задача репо не входит в `analysis` (не режет ветку, не запускает
analyst), пока в том же репо есть незавершённая задача (`stage != 'done'`) или репо заморожен.
Три механизма, аддитивно, под kill-switch, с областью репо, never-raise, restart-safe:
1. **Gate-в-claim** (`db.claim_next_job`) — analyst-job (`jobs.agent='analyst'`) применимого репо не
выбирается, если `EXISTS` другая незавершённая задача репо ИЛИ активна строка `repo_freeze`. По
образцу `task_deps` `NOT EXISTS` (ORCH-026); только локальная БД (offline hot-path). Job'ы уже
активной задачи проходят свободно; rework-analyst не блокирует себя (`t2.id != jobs.task_id`).
2. **Отложенный срез ветки** — для применимого репо `start_pipeline` создаёт task-row + enqueue
analyst, но **не** создаёт Gitea-ветку/docs; срез релоцируется на момент claim analyst-job
(launcher), когда `origin/main` уже содержит предшественника (`done` ⇔ SHA-в-main, ORCH-071/073).
`ensure_worktree` режет от свежего `origin/main` ⇒ AC-6 структурно. Идемпотентно (409 = no-op).
3. **Durable per-repo freeze** (`repo_freeze`) — post-deploy `DEGRADED`/rollback (ORCH-021) →
`set_repo_freeze` + Telegram-алерт; gate закрыт безусловно до **ручного** снятия
(`POST /serial-gate/unfreeze`). Деградировавшая задача уже `done` (BR-7) ⇒ нужен отдельный сигнал.
Чистая логика — leaf `src/serial_gate.py` (never-raise). Флаги `serial_gate_enabled` (kill-switch),
`serial_gate_repos` (CSV; **пусто ⇒ все репо**, в отличие от self-hosting-only ORCH-35/43/58),
`serial_gate_freeze_enabled`. Наблюдаемость — блок `serial_gate` в `GET /queue`.
## Альтернативы
- **Гейт в `start_pipeline` + re-trigger при `done`** — больше состояния/путей, риск зависших задач;
relocation на claim переиспользует restart-safe `jobs`-очередь.
- **Freeze как колонка `tasks`** — неверная семантика (freeze per-repo, задача уже `done`).
- **Self-hosting-only область** — лишает enduro анти-stale-base (FR-3).
- **Отдельная таблица очереди ожидания** — избыточно; `jobs(queued)`+gate достаточно.
- **Снятие freeze Plane-жестом** — перегрузка статусов (анти-паттерн ORCH-059).
## Последствия
- **+** AC-6 закрыт структурно; AC-2/AC-3 «бесплатны» (ожидание = `queued` job без ветки);
переиспользование проверенных паттернов; cross-repo параллелизм сохранён; `STAGE_TRANSITIONS` /
`QG_CHECKS` / `check_*` / merge-gate / merge-verify / image-freshness / post-deploy / deploy-хук /
`max_concurrency`**без изменений**.
- **NFR-1:** hot-claim тотальный сбой → **fail-open** (не заклинить очередь всех проектов); freeze в
Python-слое → **fail-closed** (безопасность прода).
- **** Срез ветки/docs мигрируют из async в sync-путь launcher (обёртка); Blocked-задача держит пакет
(Этап 1, осознанно); freeze снимается только вручную.
- Откат: `serial_gate_enabled=False` ⇒ claim/старт 1:1 как до ORCH-088; таблица `repo_freeze` инертна.
- **Вне скопа** (Этап 1): merge-очередь FIFO, pre-merge rebase как отдельная фича, фазы A/B/C,
любой параллелизм задач внутри одного репо, зависимость от ORCH-83.
## Связи
- Переиспользует: adr-0002 (очередь ORCH-1), adr-0015 (claim-gate/auto_rebase/merge-lease ORCH-026),
adr-0010 (post-deploy monitor — источник DEGRADED), adr-0013/0014 (merge-verify ⇒ `done`⇔SHA-в-main).
- Новая аддитивная таблица `repo_freeze` (`docs/work-items/ORCH-088/08-data-requirements.md`).

View File

@@ -0,0 +1,221 @@
# 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) требует обрабатывать пакет из 1020 задач **строго по одной**
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.

View File

@@ -0,0 +1,73 @@
# 08 — Требования к схеме БД: ORCH-088 (Serial gate, freeze-хранилище)
Work Item: **ORCH-088** · Repo: **orchestrator** · Стадия: architecture
Связь: ADR `06-adr/ADR-001-serial-gate.md` (D2/D3/D4), ТЗ `02-trz.md` §5.
> Общая прод-БД (self-hosting обслуживает enduro-trails из того же инстанса). Все миграции —
> **только аддитивные и идемпотентные** (`CREATE TABLE IF NOT EXISTS` / `_ensure_column`). Изменение
> существующих таблиц-контрактов (`tasks`, `jobs`, `job_deps`, `agent_runs`) запрещено.
---
## 1. Новая таблица `repo_freeze` (FR-5)
Durable per-repo признак заморозки пакета после post-deploy `DEGRADED`/rollback. Append-only журнал:
активная заморозка ⇔ существует строка репо с `cleared_at IS NULL`.
```sql
CREATE TABLE IF NOT EXISTS repo_freeze (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo TEXT NOT NULL, -- ключ области (per-repo)
frozen_at TEXT NOT NULL DEFAULT (datetime('now')),
reason TEXT, -- напр. "post-deploy DEGRADED 3/5"
work_item_id TEXT, -- задача-источник деградации (уже stage='done')
cleared_at TEXT -- NULL = freeze активен; снят оператором → datetime
);
CREATE INDEX IF NOT EXISTS idx_repo_freeze_active ON repo_freeze (repo, cleared_at);
```
### Семантика
- **Активный freeze репо `R`:** `EXISTS (SELECT 1 FROM repo_freeze WHERE repo=R AND cleared_at IS NULL)`.
- **Выставление** (`set_repo_freeze`): INSERT новой строки (`cleared_at=NULL`). Повторный DEGRADED при
уже активном freeze — допускается доп. строка (журнал) либо no-op при существующей активной (выбор
разработчика; на gate не влияет — `EXISTS` идемпотентен).
- **Снятие** (`clear_repo_freeze`): `UPDATE repo_freeze SET cleared_at=datetime('now')
WHERE repo=? AND cleared_at IS NULL` (закрывает все активные строки репо). Идемпотентно (повтор → 0 rows).
- **Read (gate/snapshot):** только `cleared_at IS NULL`-строки; `is_repo_frozen` — **fail-closed**
(ошибка чтения → `True`, AC-9).
### Использование в горячем claim
`db.claim_next_job` читает `repo_freeze` инлайн внутри `serial_gate`-фрагмента (только локальная БД,
offline — NFR-2):
```
OR EXISTS (SELECT 1 FROM repo_freeze f WHERE f.repo = jobs.repo AND f.cleared_at IS NULL)
```
(внутри `AND NOT ( jobs.agent='analyst' AND … )` — см. ADR D1). Тотальный сбой построения фрагмента →
fail-open для claim (AC-8); реально выставленная строка блокирует через сам SQL.
---
## 2. Активная задача репо — без новых колонок
«Репо занят» вычисляется из существующих столбцов `tasks(repo, stage)`:
```sql
EXISTS (SELECT 1 FROM tasks WHERE repo=? AND id != ? AND stage != 'done')
```
Новых колонок/таблиц для «активной задачи» и «очереди ожидания» **не вводится**: ожидание = существующий
`jobs.status='queued'` analyst-job + gate в claim (ТЗ §5).
---
## 3. Идемпотентность и restart-safety
- Миграция `repo_freeze` выполняется в общем init-пути схемы (`db.init_db`/`_ensure_*`), безопасна к
повторному запуску (`IF NOT EXISTS`).
- Всё состояние gate/freeze — в БД (нет in-memory) ⇒ после рестарта поведение идентично (NFR-3/AC-3):
активная задача определяется из `tasks`, freeze — из `repo_freeze`, ожидающая задача — `queued` job.
- При выключенных флагах (`serial_gate_enabled=False` / `serial_gate_freeze_enabled=False`) таблица
инертна; enduro и существующие контракты не затрагиваются (NFR-4/AC-11).
---
## 4. Неизменяемые контракты
`tasks`, `jobs`, `job_deps`, `agent_runs`, `tracker_messages` — схема **без изменений**.
`STAGE_TRANSITIONS` / `QG_CHECKS` — не БД, но также не меняются (NFR-5).

View File

@@ -0,0 +1,29 @@
# 10 — Технические риски: ORCH-088 (Serial gate)
Work Item: **ORCH-088** · Repo: **orchestrator** · Стадия: architecture
Связь: ADR `06-adr/ADR-001-serial-gate.md`, ТЗ `02-trz.md` §11-12, BRD §8.
Оценка: **Вероятность** (Н/С/В) × **Влияние** (Н/С/В). Self-hosting: «Влияние В» = риск для конвейера
ВСЕХ проектов (общий прод/БД/очередь).
| ID | Риск | Вер. | Влия. | Митигейшн |
|----|------|------|-------|-----------|
| **R-1** | **Stale-base сохраняется**, если ветка режется на входе (`_create_gitea_branch` в `start_pipeline`) до завершения предшественника — гейт только claim не лечит (BRD R-1, AC-6 FAIL). | С | В | Relocation среза ветки на claim analyst-job (ADR D1): `start_pipeline` не создаёт ветку для применимого репо; `ensure_worktree` режет от свежего `origin/main` уже после открытия gate. Тест AC-6: `git merge-base --is-ancestor <validated_sha A> <base B>`. |
| **R-2** | Gate, ошибочно **fail-closed на транзиентной ошибке БД** в hot-claim, заклинивает очередь ВСЕХ проектов (enduro встаёт). | С | В | `build_claim_clause` обёрнут в try/except → ошибка построения = пустой фрагмент = claim без gate (**fail-open**, ADR D10/AC-8). Freeze fail-closed применяется только в Python-слое, не в тотальном сбое hot-claim. Unit-тест: исключение в построении clause → claim не падает, выбирает job. |
| **R-3** | **«Вечный freeze» / залипшая Blocked-задача** останавливает пакет незаметно (D6 — Blocked держит gate). | С | С | Наблюдаемость `GET /queue` блок `serial_gate` (`active_task`, `waiting`, `frozen`+reason); Telegram-алерт при выставлении freeze и карточка «⏳ ждёт …»; ручное снятие — простой эндпоинт `POST /serial-gate/unfreeze` (D4). Оператор видит причину застоя. |
| **R-4** | **Async→sync релокация** `_create_gitea_branch`/`_create_initial_docs`: эти вызовы async (httpx) в `start_pipeline`, а launcher `_spawn` — sync. Неверная обёртка → исключение/блок event-loop. | С | С | Developer оборачивает Gitea-API для sync-контекста (httpx sync client / `asyncio.run` в отдельном пути). Контракт launcher never-raise: сбой материализации ветки → лог + job в retry, прод не трогается. Тест: claim analyst-job создаёт ветку и worktree без падения. |
| **R-5** | **Потребитель ожидает материализованную ветку до запуска analyst** (между `start_pipeline` и claim ветки нет): Telegram-карточка / Plane-sync / reconciler могут предполагать существование ветки. | Н | С | Проверено: трекер/Plane-sync используют branch как строку имени, не git-ref. Перед разработкой — аудит читателей `tasks.branch`/Gitea-ветки на стадии до analyst. `start_pipeline` по-прежнему пишет `branch` в task-row (имя), не материализуя ref. |
| **R-6** | **SQL-инъекция / поломка clause** через `serial_gate_repos` CSV при встраивании в `IN (...)`. | Н | С | Санитизация repo-токенов `^[A-Za-z0-9._-]+$` в `build_claim_clause` (ADR D8); невалидный токен дропается. CSV — операторский конфиг (не пользовательский ввод), риск низкий, но гард обязателен. Unit-тест на мусорный CSV. |
| **R-7** | **Rework-analyst блокирует сам себя**: rejection-path `start_pipeline` re-enqueue analyst активной задачи; наивный gate «есть незавершённая задача репо» удержал бы её навсегда. | С | В | Условие `t2.id != jobs.task_id` (ADR D1) — учитываются только **другие** задачи. Unit-тест: rework-analyst задачи A при единственной незавершённой A — claim проходит. |
| **R-8** | **Freeze не учитывает уже-`done` задачу**: деградировавшая задача к моменту DEGRADED уже `stage='done'` (BR-7) ⇒ обычный gate её не удержит, следующая стартует до выставления freeze (гонка). | Н | С | Freeze — **отдельный durable сигнал** (`repo_freeze`), не зависит от `stage` (ADR D2/D3). `set_repo_freeze` вызывается в DEGRADED-ветке монитора; до снятия gate закрыт безусловно. Возможная узкая гонка «`done`→claim next до записи freeze» приемлема Этапом 1 (следующий тик уже видит freeze); при необходимости — выставлять предупредительный freeze в начале окна мониторинга (вне скопа). |
| **R-9** | **Default-all область** неожиданно сериализует enduro (меняет поведение при включении флага). | С | Н | Осознанное решение (ADR D5, ТЗ §8): enduro выигрывает от serial e2e; `max_concurrency=1` и так ограничивает параллелизм. Оператор может сузить `ORCH_SERIAL_GATE_REPOS=orchestrator`. «Нулевая регрессия» гарантирована при **выключенном** kill-switch (NFR-4). |
| **R-10** | **Миграция на общей прод-БД** (`repo_freeze`) роняет init при неудачном порядке/блокировке. | Н | В | `CREATE TABLE IF NOT EXISTS` + idempotent index; выполняется в существующем init-пути схемы; аддитивно, не трогает enduro-строки. Прод-контейнер не рестартится механизмом gate (NFR-6). |
---
## Сводный вывод
Архитектурно безопасных блокеров нет. Критические векторы — **R-1** (закрыт relocation среза ветки),
**R-2/R-7** (закрыты fail-open hot-claim и `t2.id != jobs.task_id`). Все механизмы аддитивны, под
kill-switch, never-raise, не рестартят прод. Главный операционный риск — **R-3** (ручной freeze),
смягчён наблюдаемостью и алертами. Реализация — стандартный путь стадии development без эскалации
`arch:major-change` (нет новой стадии/QG/смены БД-контракта; новая таблица аддитивна).