architect(ET): auto-commit from architect run_id=436

This commit is contained in:
2026-06-09 10:52:01 +03:00
committed by stream
parent b478b38df5
commit 4597a8471d
6 changed files with 417 additions and 1 deletions

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/смены БД-контракта; новая таблица аддитивна).