20 KiB
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 инструмента, обслуживающего ВСЕ проекты из одного
инстанса с общей БД/очередью.
Ключевые факты текущей архитектуры, влияющие на решение (проверено по коду):
- Где происходит слияние в
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. - Как запускается 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'а. - Чем триггерится QG.
advance_stageвызывается ТОЛЬКО при (а) завершении LLM-агента (launcher._try_advance_stage) или (б) приходе вебхука. Стадия без агента не имеет собственного триггера (стадияdeployоценивается, когда заканчивает deployer, исполняющийся ВО ВРЕМЯ неё). Поэтому новая «пустая» стадияmerge-gateмеждуdeploy-stagingиdeployзависла бы без триггера (нужен был бы chaining в движке либо синтетический job — лишняя и не-restart-safe поверхность). - 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:
git fetch origin mainв worktree ветки (ensure_worktree, AC-8 — изоляция).branch_is_behind_main: ветка отстаёт ⇔git merge-base --is-ancestor origin/main <HEAD>вернул ненулевой код. Не удалось определить (git/сеть) → трактуем как «не пропускаем вслепую» (never-raise →(False, reason)), НЕ как «up-to-date».- Не отстаёт →
(True, "branch up-to-date with main"), rebase/push не выполняются (AC-1). - Отстаёт →
git rebase origin/main:- текстовый конфликт →
git rebase --abort, worktree чист →(False, "rebase conflict: <файлы>")(AC-3); - чистый rebase →
git push --force-with-lease origin <branch>(ТОЛЬКО ветка задачи; НИКОГДАmain, AC-7) → далее re-test.
- текстовый конфликт →
- Контракт 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_s→ busy; - файл есть, возраст
>= merge_lock_timeout_s→ stale, перезахват сlogger.warning(crash-recovery: процесс-холдер умер, не освободив lease).
- Release — идемпотентный (
os.remove, ignore-missing). - Restart-safe: lease на диске; зависший lease реклеймится по возрасту.
Поведение check_branch_mergeable(repo, work_item_id, branch) (детерминированно, без LLM):
- Попытка acquire (неблокирующая). Busy →
(False, "merge-lock busy")— сигнальный reason (НЕ провал кода, см. §5: defer, а не rollback). - Double-check под lease: повторно
branch_is_behind_main(пока ждали/между тикамиmainмог уйти — например, другая задача только что влилась). - Не отстаёт →
(True, "branch up-to-date with main"). - Отстаёт →
auto_rebase_onto_main:- конфликт →
(False, "rebase conflict: ..."); - успех →
retest_branch: зелёный →(True, "rebased onto main, re-test green"); красный/тайм-аут →(False, "re-test failed after rebase: ...").
- конфликт →
- При успехе lease НЕ освобождается — он удерживается до фактического merge. При любом провале (конфликт/красный re-test) lease освобождается (откат на development, слияния не будет).
- Регистрация в
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_descdeveloper'а (по образцу 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_*).