Files
orchestrator/docs/work-items/ORCH-088/06-adr/ADR-001-serial-gate.md

19 KiB
Raw Blame History

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_docsensure_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. Falseclaim и 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) -> boolEXISTS tasks stage!='done'
  • is_repo_frozen(repo) -> boolfail-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.