From 4597a8471ddbabeb5cd96f9e00522c729c9004b1 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Tue, 9 Jun 2026 10:52:01 +0300 Subject: [PATCH] architect(ET): auto-commit from architect run_id=436 --- docs/architecture/README.md | 33 +++ docs/architecture/adr/README.md | 3 +- docs/architecture/adr/adr-0017-serial-gate.md | 59 +++++ .../ORCH-088/06-adr/ADR-001-serial-gate.md | 221 ++++++++++++++++++ .../ORCH-088/08-data-requirements.md | 73 ++++++ docs/work-items/ORCH-088/10-tech-risks.md | 29 +++ 6 files changed, 417 insertions(+), 1 deletion(-) create mode 100644 docs/architecture/adr/adr-0017-serial-gate.md create mode 100644 docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md create mode 100644 docs/work-items/ORCH-088/08-data-requirements.md create mode 100644 docs/work-items/ORCH-088/10-tech-risks.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index fe43914..a8bee3d 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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) +Эпик «10–20 задач за ночь», Этап 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`, diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md index 58bf7c0..8efe640 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -22,11 +22,12 @@ Per-work-item решения живут в `docs/work-items//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). diff --git a/docs/architecture/adr/adr-0017-serial-gate.md b/docs/architecture/adr/adr-0017-serial-gate.md new file mode 100644 index 0000000..0850029 --- /dev/null +++ b/docs/architecture/adr/adr-0017-serial-gate.md @@ -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 — масштаб автономности: накидать вечером 10–20 задач и получить к утру пакет, +последовательно проведённый через весь конвейер (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`). diff --git a/docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md b/docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md new file mode 100644 index 0000000..cb5f199 --- /dev/null +++ b/docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md @@ -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 ` — **присоединяется к 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 () + 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 `. + +**Почему не «гейт в `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=`)** → `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: {: {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-карточка `⏳ ждёт завершения ` (по образцу 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. diff --git a/docs/work-items/ORCH-088/08-data-requirements.md b/docs/work-items/ORCH-088/08-data-requirements.md new file mode 100644 index 0000000..83e9510 --- /dev/null +++ b/docs/work-items/ORCH-088/08-data-requirements.md @@ -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). diff --git a/docs/work-items/ORCH-088/10-tech-risks.md b/docs/work-items/ORCH-088/10-tech-risks.md new file mode 100644 index 0000000..2e4d634 --- /dev/null +++ b/docs/work-items/ORCH-088/10-tech-risks.md @@ -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 `. | +| **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/смены БД-контракта; новая таблица аддитивна).