architect(ET): auto-commit from architect run_id=436
This commit is contained in:
221
docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md
Normal file
221
docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md
Normal 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) требует обрабатывать пакет из 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.
|
||||
73
docs/work-items/ORCH-088/08-data-requirements.md
Normal file
73
docs/work-items/ORCH-088/08-data-requirements.md
Normal 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).
|
||||
29
docs/work-items/ORCH-088/10-tech-risks.md
Normal file
29
docs/work-items/ORCH-088/10-tech-risks.md
Normal 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/смены БД-контракта; новая таблица аддитивна).
|
||||
Reference in New Issue
Block a user