Files
orchestrator/docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md
claude-bot ad1589084b
All checks were successful
CI / test (push) Successful in 14s
architect(ET): auto-commit from architect run_id=183
2026-06-06 17:16:00 +00:00

20 KiB
Raw Blame History

ADR-001: Merge-gate + auto-rebase + re-test (безопасная параллель в одном репо)

Статус

Proposed

Решение архитектора по ТЗ ORCH-043 (02-trz.md). Реализует BR-1..BR-8, удовлетворяет AC-1..AC-15. Глобальный сквозной аналог — docs/architecture/adr/adr-0006-merge-gate.md.


Контекст

Конвейер валидирует ветку относительно того main, из которого она была создана, а не относительно main на момент слияния. Между «ветка проверена» и «ветка влита» main мог уйти вперёд из-за слияния другой параллельной задачи → семантический конфликт слияния: git сливает без текстового конфликта, но объединённый код main сломан. Для self-hosting (orchestrator) это = красный main инструмента, обслуживающего ВСЕ проекты из одного инстанса с общей БД/очередью.

Ключевые факты текущей архитектуры, влияющие на решение (проверено по коду):

  1. Где происходит слияние в main. Ветку в main вливает deployer-агент в начале своего запуска на стадии deploy (см. src/webhooks/gitea.py:336-353 — комментарий «deployer merges the PR at the START of its run»). Замена самого механизма слияния PR в Gitea — вне объёма (BRD §4). Значит, merge остаётся PR-merge через deployer.
  2. Как запускается deployer стадии deploy. При прохождении check_staging_status на стадии deploy-staging движок (stage_engine.advance_stage) переводит задачу deploy-staging → deploy и запускает get_agent_for_stage("deploy-staging") = deployer. Этот deployer и делает merge. Значит merge-gate обязан отработать на ребре deploy-staging → deploy, ДО запуска этого deployer'а.
  3. Чем триггерится QG. advance_stage вызывается ТОЛЬКО при (а) завершении LLM-агента (launcher._try_advance_stage) или (б) приходе вебхука. Стадия без агента не имеет собственного триггера (стадия deploy оценивается, когда заканчивает deployer, исполняющийся ВО ВРЕМЯ неё). Поэтому новая «пустая» стадия merge-gate между deploy-staging и deploy зависла бы без триггера (нужен был бы chaining в движке либо синтетический job — лишняя и не-restart-safe поверхность).
  4. Concurrency. max_concurrency по умолчанию 1; QG исполняется в monitor-thread агента. Блокирующее ожидание lock внутри advance_stage при одном worker-слоте даёт дедлок (задача B держит слот, ожидая merge задачи A, которой нужен тот же слот). Сериализация обязана быть неблокирующей.

Решение

1. Место встройки — ребро deploy-staging → deploy (кандидат A ТЗ §6), без новой стадии

Merge-gate — детерминированный шаг в advance_stage, исполняемый после прохождения check_staging_status и до update_task_stage(deploy) / запуска deployer'а, который мержит. STAGE_TRANSITIONS не меняется (минимальный blast-radius; get_previous_stage не затрагивается; snapshot _EXPECTED_TRANSITIONS без изменений). В реестр QG_CHECKS добавляется один ключ check_branch_mergeable (snapshot _EXPECTED_QGS обновляется осознанно, AC-10).

Отвергнутые варианты:

  • (B) Новая стадия merge-gate — концептуально честнее, но «пустая» стадия без агента не имеет триггера (см. Контекст §3). Потребовала бы chaining в advance_stage (не restart-safe для безагентного перехода) или синтетический job-тип в очереди (поверхность в launcher/queue_worker, который сейчас умеет только LLM-агентов).
  • (C) Перенос merge в детерминированный шаг оркестратора — прямо запрещён объёмом (BRD §4: «Замена механизма слияния PR в Gitea — вне объёма»).

Триггер гейта — существующее событие «staging-deployer завершился» → отдельного механизма триггера не вводим.

2. Догон ветки — rebase onto origin/main + push --force-with-lease

Выбор rebase (а не merge-commit) обусловлен критериями приёмки AC-2/AC-7, которые прямо требуют push --force-with-lease догнанной ветки. Алгоритм auto_rebase_onto_main:

  1. git fetch origin main в worktree ветки (ensure_worktree, AC-8 — изоляция).
  2. branch_is_behind_main: ветка отстаёт ⇔ git merge-base --is-ancestor origin/main <HEAD> вернул ненулевой код. Не удалось определить (git/сеть) → трактуем как «не пропускаем вслепую» (never-raise → (False, reason)), НЕ как «up-to-date».
  3. Не отстаёт → (True, "branch up-to-date with main"), rebase/push не выполняются (AC-1).
  4. Отстаёт → git rebase origin/main:
    • текстовый конфликтgit rebase --abort, worktree чист → (False, "rebase conflict: <файлы>") (AC-3);
    • чистый rebasegit push --force-with-lease origin <branch> (ТОЛЬКО ветка задачи; НИКОГДА main, AC-7) → далее re-test.
  5. Контракт never-raise: любая git/OS-ошибка → (False, "<reason>") (AC-9).

main гейтом не пушится и не форс-пушится никогда. Единственная force-операция — --force-with-lease по ветке задачи.

3. Re-test — python -m pytest в worktree догнанной ветки

retest_branch(repo, branch):

  • Команда python -m pytest <merge_retest_target> (merge_retest_target по умолчанию tests/) из корня worktree ветки — согласовано с CI orchestrator (pytest tests/ -q, CLAUDE.md) и паттерном check_tests_local.
  • Тайм-аут settings.merge_retest_timeout_s (дефолт 600); превышение → (False, "re-test timeout (<T>s)") (AC-6), процесс убивается, задача не виснет.
  • returncode == 0(True, "re-test green"); иначе (False, "re-test failed after rebase: <tail>") (AC-4).

Гейт по умолчанию реален для self-hosting репо orchestrator (BR-7). Для других репо применять только при совпадающей тест-команде/раскладке — через merge_gate_repos (см. §6). Команда re-test параметризуется merge_retest_target для портируемости.

4. Сериализация слияний — файловый merge-lease на репозиторий (BR-5, AC-5)

Цель: «догон + re-test + слияние» одного репо выполняет одновременно только одна задача. Слияние делает deployer ПОЗЖЕ и в ОТДЕЛЬНОМ запуске, поэтому простой context-manager-lock внутри гейта окно гонки не закрывает — нужен lease, живущий от гейта до фактического merge.

Механизм — файловый lease, БЕЗ изменения схемы БД (ТЗ §4 предпочитает no-schema-change):

  • Файл <repos_dir>/.merge-lease-<repo>.json, содержимое {task_id, work_item_id, branch, acquired_at, pid}.
  • Acquire — атомарный, НЕблокирующий (open(..., O_CREAT|O_EXCL)):
    • файла нет → захват, запись метаданных;
    • файл есть, holder == self → идемпотентно «уже наш» (restart/повтор);
    • файл есть, holder != self, возраст < merge_lock_timeout_sbusy;
    • файл есть, возраст >= merge_lock_timeout_sstale, перезахват с logger.warning (crash-recovery: процесс-холдер умер, не освободив lease).
  • Release — идемпотентный (os.remove, ignore-missing).
  • Restart-safe: lease на диске; зависший lease реклеймится по возрасту.

Поведение check_branch_mergeable(repo, work_item_id, branch) (детерминированно, без LLM):

  1. Попытка acquire (неблокирующая). Busy → (False, "merge-lock busy")сигнальный reason (НЕ провал кода, см. §5: defer, а не rollback).
  2. Double-check под lease: повторно branch_is_behind_main (пока ждали/между тиками main мог уйти — например, другая задача только что влилась).
  3. Не отстаёт → (True, "branch up-to-date with main").
  4. Отстаёт → auto_rebase_onto_main:
    • конфликт → (False, "rebase conflict: ...");
    • успех → retest_branch: зелёный → (True, "rebased onto main, re-test green"); красный/тайм-аут → (False, "re-test failed after rebase: ...").
  5. При успехе lease НЕ освобождается — он удерживается до фактического merge. При любом провале (конфликт/красный re-test) lease освобождается (откат на development, слияния не будет).
  6. Регистрация в QG_CHECKS["check_branch_mergeable"]; сигнатура (repo, work_item_id, branch) совпадает с дефолтной артефактной → _run_qg диспетчеризует без спец-кейса.

Жизненный цикл lease (точки release):

  • PR-merged вебхук ветки (gitea.handle_pr, action=closed & merged) → release;
  • deploy → done в advance_stage (страховочный release);
  • любой откат на development из merge-gate / check_deploy_status → release;
  • возраст >= merge_lock_timeout_s → авто-реклейм (backstop при краше).

5. Откаты и defer (интеграция в stage_engine, BR-4/BR-8, AC-11)

check_branch_mergeable различает два негативных исхода:

  • reason == "merge-lock busy" → DEFER, не rollback. Код задачи исправен — нельзя слать на development и нельзя тратить MAX_DEVELOPER_RETRIES. Движок повторно ставит deployer на deploy-staging с задержкой settings.merge_defer_delay_s (через available_at-гейт очереди, ORCH-1; задача остаётся на deploy-staging). Неблокирующий defer освобождает worker-слот → задача-холдер успевает влиться (нет дедлока при max_concurrency=1). Повторов defer — ограниченное число (merge_defer_max_attempts), исчерпание → Telegram-алерт + блокировка.
  • reason = конфликт rebase ИЛИ красный re-test → rollback на development по образцу check_staging_status/check_deploy_status в _handle_qg_failure_rollbacks: update_task_stage(development), set_issue_blocked, дословный reason в Plane (plane_add_comment, author="deployer"), send_telegram, учёт MAX_DEVELOPER_RETRIES, release lease. Дословный reason встраивается в task_desc developer'а (по образцу ORCH-046), чтобы агент видел суть.

6. Конфигурация (src/config.py, env-префикс ORCH_)

Setting Назначение Дефолт
merge_gate_enabled: bool Глобальный вкл/выкл (no-op (True, "merge-gate disabled") при False, AC-12) True
merge_gate_repos: str CSV-список репо, где гейт реален; пусто = только self-hosting (orchestrator) ""
merge_retest_timeout_s: int Тайм-аут re-test 600
merge_retest_target: str pytest-цель для re-test (портируемость) tests/
merge_lock_timeout_s: int Макс. возраст lease (ожидание/реклейм) 300
merge_defer_delay_s: int Задержка перед повтором гейта при busy 60
merge_defer_max_attempts: int Лимит defer-повторов до эскалации 5

Семантика merge_gate_repos: пусто → гейт реален ТОЛЬКО для orchestrator (is_self_hosting_repo), для прочих — no-op (True, "merge-gate N/A for <repo>") (по образцу условного staging-гейта ORCH-35). Это безопасный поэтапный раскат.

7. API

Новых HTTP-эндпоинтов нет. Допустимо (необязательно) добавить в GET /status/GET /queue индикатор состояния merge-lease для наблюдаемости — без изменения существующих контрактов.


Последствия

Плюсы

  • Закрывает воспроизводимый сценарий «две зелёные ветки ломают main»: перед слиянием ветка догоняется до актуального origin/main и повторно тестируется; слияния сериализуются lease'ом.
  • Минимальный blast-radius: STAGE_TRANSITIONS не тронут, snapshot-переходы не меняются, +1 ключ в QG_CHECKS. Триггер — существующее событие, без chaining/новых job-типов.
  • Restart-safe и deadlock-safe: файловый lease с реклеймом по возрасту; неблокирующий acquire + defer вместо блокирующего ожидания.
  • Соответствует self-hosting-инвариантам: никогда не пуш/форс-пуш main; force только --force-with-lease по ветке задачи; прод-контейнер не рестартится; страховка deploy-staging сохранена.
  • Поэтапный раскат через merge_gate_enabled / merge_gate_repos.

Минусы / ограничения

  • Merge-gate как «скрытый» под-гейт ребра deploy-staging → deploy не отражён в STAGE_TRANSITIONS (плата за отказ от новой стадии). Смягчение: явно описан в docs/architecture/README.md и этом ADR.
  • Сериализация зависит от вебхука PR-merged для своевременного release. Деградация предусмотрена (реклейм по возрасту merge_lock_timeout_s), но при «потерянном» вебхуке возможна задержка следующей задачи до тайм-аута lease.
  • Defer перезапускает staging-deployer (повторно прогоняет staging-проверку и перезаписывает 15-staging-log.md) — переиспользует существующий механизм очереди ценой лишнего прогона staging. Допустимо; альтернатива (отдельный «retry-gate» job-тип) дороже по поверхности.
  • Длинный re-test (до 600s) исполняется синхронно в monitor-thread staging-deployer'а и удерживает worker-слот на это время (при max_concurrency=1 приостанавливает прочие задачи). Это неотъемлемая стоимость «re-test перед слиянием».
  • rebase --force-with-lease переписывает историю ветки и обновляет head открытого PR; прежний approve ревьюера может пометиться stale в Gitea. На стадии deploy ревью повторно не проверяется — функционально безопасно.

Влияние на масштаб изменения

Вводится новый модуль (src/merge_gate.py), новый QG, lease-подсистема и изменение поведения ребра deploy-staging → deploy + откаты/вебхук. Это сквозное изменение конвейера → рекомендуется лейбл arch:major-change и обязательная страховка стадией deploy-staging (8501) перед прод-деплоем самого ORCH-043. Глобальный ADR — docs/architecture/adr/adr-0006-merge-gate.md.


Точки изменения кода (для developer; имена функций — финальные)

  • src/merge_gate.py (новый): branch_is_behind_main, auto_rebase_onto_main, retest_branch, lease (acquire_merge_lease/release_merge_lease/реклейм).
  • src/qg/checks.py: check_branch_mergeable(repo, work_item_id, branch) + регистрация в QG_CHECKS.
  • src/stage_engine.py: вызов merge-gate на ребре deploy-staging → deploy (после check_staging_status, до advance); ветка rollback merge-gate в _handle_qg_failure_rollbacks; defer-ветка для "merge-lock busy"; release lease в deploy → done и в откатах.
  • src/webhooks/gitea.py: release lease в handle_pr (closed & merged).
  • src/db.py (опц.): enqueue_job(..., available_at_delay_s=...) для defer, либо переиспользовать available_at.
  • src/config.py: настройки §6.
  • tests/: тесты по 04-test-plan.yaml + обновить tests/test_qg_registry_snapshot.py (_EXPECTED_QGS += check_branch_mergeable; _EXPECTED_TRANSITIONSбез изменений).
  • Документация: docs/architecture/README.md (обновлена в этом PR), CHANGELOG.md, .env.example (новые ORCH_*).