19 KiB
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 закрывает логический разрыв.
Корень проблемы (проверено в коде):
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", ...).- Позже
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_* — без изменений.
- Gate-в-claim (
db.claim_next_job): analyst-job новой задачи не выбирается, пока в том же репо есть другая незавершённая задача ИЛИ репо заморожен. Job уже активной задачи (architect/developer/…/deployer) проходят свободно. - Отложенный срез ветки: для применимого репо
start_pipelineне создаёт Gitea-ветку и docs; создание ветки+docs релоцируется в момент claim analyst-job (launcher), когдаorigin/mainуже содержит предшественника. Это и даёт AC-6 структурно. - Durable per-repo freeze (
repo_freeze): при post-deployDEGRADEDрепо замораживается; 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_pipelinerejection-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_pipeline1: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) -> boolrepo_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 «бесплатны»: ожидание =
queuedanalyst-job без ветки в restart-safejobs-очереди; открытие 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.