diff --git a/.env.example b/.env.example index 9882c61..9aaaa83 100644 --- a/.env.example +++ b/.env.example @@ -17,3 +17,22 @@ ORCH_DB_PATH=/app/data/orchestrator.db # one is sent silently to the BOTTOM of the chat (deleteMessage + sendMessage + # repoint). One card per task in both modes. Any value other than "bump" -> edit. ORCH_TRACKER_MODE=edit +# ORCH-043: merge-gate (auto-rebase onto current origin/main + re-test + merge-lock) +# on the deploy-staging -> deploy edge. Deterministic sub-gate (no LLM) that catches +# the branch up to the CURRENT origin/main, re-tests it, and serialises merges so two +# green parallel branches can't break main. +# ENABLED -> global kill-switch (false -> whole gate is a no-op pass). +# REPOS -> CSV of repos where the gate is REAL; empty -> only the self-hosting +# repo (orchestrator); other repos -> conditional no-op (mirrors ORCH-35). +# RETEST_TIMEOUT_S -> wall-clock budget for the post-rebase re-test. +# RETEST_TARGET -> pytest target for the re-test. +# LOCK_TIMEOUT_S -> max merge-lease age before a stale lease is reclaimed. +# DEFER_DELAY_S -> delay before re-running the gate when the lock is busy. +# DEFER_MAX_ATTEMPTS -> defer retries before escalation (avoids livelock). +ORCH_MERGE_GATE_ENABLED=true +ORCH_MERGE_GATE_REPOS= +ORCH_MERGE_RETEST_TIMEOUT_S=600 +ORCH_MERGE_RETEST_TARGET=tests/ +ORCH_MERGE_LOCK_TIMEOUT_S=300 +ORCH_MERGE_DEFER_DELAY_S=60 +ORCH_MERGE_DEFER_MAX_ATTEMPTS=5 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2140968..3b1724c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ## [Unreleased] ### Added +- **Merge-gate: авто-rebase на текущий `origin/main` + повторный прогон тестов + сериализация мержей** (ORCH-043): детерминированный (без LLM) суб-гейт на ребре `deploy-staging → deploy`, выполняемый ПЕРЕД мержем PR деплоером. Закрывает класс гонок «две зелёные ветки в одном репо ломают `main`»: пайплайн валидирует ветку против того `main`, от которого она ответвилась, а не против `main` в момент мержа — между «ветка зелёная» и «ветка смержена» параллельная задача может сдвинуть `main` (семантический конфликт: git мержит без текстового конфликта, но совмещённый `main` красный). Для self-hosting репозитория `orchestrator` это означало бы красный `main` инструмента, обслуживающего ВСЕ проекты. Новый модуль `src/merge_gate.py` (контракт «never raise», все git-операции — в per-branch worktree, ORCH-2/S-4): `branch_is_behind_main` (`git merge-base --is-ancestor origin/main HEAD`), `auto_rebase_onto_main` (rebase + `git push --force-with-lease` ТОЛЬКО ветки задачи — `main` НИКОГДА не пушится; текстовый конфликт → `rebase --abort` + чистый worktree), `retest_branch` (`python -m pytest ` в догнанном worktree, бюджет `merge_retest_timeout_s`), файловый merge-lease (`acquire_merge_lease`/`release_merge_lease`, атомарный `O_CREAT|O_EXCL`, holder-aware release, реклейм протухшего/битого лиза — без изменения схемы БД). Новый quality-gate `check_branch_mergeable` (`src/qg/checks.py`, зарегистрирован в `QG_CHECKS`) композирует примитивы под лизом: kill-switch/вне-области → no-op pass; lock занят → `(False, "merge-lock busy")` (сигнал DEFER, не код-фолт); ветка свежая → pass (лиз ДЕРЖИТСЯ до мержа); отстала → rebase → конфликт = fail+release, чисто → retest → зелёный = pass (лиз держится) / красный|timeout = fail+release. Интеграция в `src/stage_engine.py` (суб-гейт на `deploy-staging`, БЕЗ новой стадии в `STAGE_TRANSITIONS`): pass → advance на `deploy`; «merge-lock busy» → DEFER (повторная постановка деплоера на `deploy-staging` с задержкой `available_at`, анти-дедлок при `max_concurrency=1`, restart-safe счётчик по `task_content`, лимит `merge_defer_max_attempts` → block+Telegram); конфликт/красный retest → ROLLBACK на `development` + ретрай developer-а (кап `MAX_DEVELOPER_RETRIES`, без бесконечного баунса). Лиз освобождается на `deploy→done`, на rollback и по webhook смерженного PR (`src/webhooks/gitea.py`). Новый параметр `enqueue_job(..., available_at_delay_s=...)` (`src/db.py`) — отложенная постановка без изменения схемы. Условность раскатки (зеркало ORCH-35): `merge_gate_repos` (CSV) или по умолчанию только self-hosting `orchestrator`; глобальный kill-switch `merge_gate_enabled`. Новые настройки `ORCH_MERGE_GATE_ENABLED` (true), `ORCH_MERGE_GATE_REPOS` (""), `ORCH_MERGE_RETEST_TIMEOUT_S` (600), `ORCH_MERGE_RETEST_TARGET` (tests/), `ORCH_MERGE_LOCK_TIMEOUT_S` (300), `ORCH_MERGE_DEFER_DELAY_S` (60), `ORCH_MERGE_DEFER_MAX_ATTEMPTS` (5). ADR `docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md`, глобальный `docs/architecture/adr/adr-0006-merge-gate.md`. Тесты: `tests/test_merge_gate.py`, `tests/test_qg_merge_gate.py`, `tests/test_merge_gate_race.py`, `tests/test_stage_engine.py::TestMergeGate`, `tests/test_config.py`. - **Режим `bump` live-трекера Telegram** (ORCH-042): новый `ORCH_TRACKER_MODE` (`Settings.tracker_mode`, дефолт `edit`) выбирает поведение карточки задачи. `edit` (как было) — карточка редактируется на месте (`editMessageText`). `bump` — на каждом обновлении старое сообщение удаляется и карточка отправляется заново вниз чата (best-effort `delete_telegram(старый_id)` → `send_telegram(text, disable_notification=True)` → `set_tracker_message_id(new_id)`), чтобы актуальный статус всегда был последним в чате при активной переписке. Инвариант «одна карточка на задачу» сохранён в обоих режимах: за один вызов `update_task_tracker` шлётся ≤1 нового сообщения; `set_tracker_message_id` вызывается ТОЛЬКО при успешном send (транзиентный `None` не затирает указатель); результат delete НЕ блокирует отправку новой карточки (delete-fail у сообщения >48ч → всё равно шлём новое). Резолюция режима в `notifications` (case-insensitive, trim): всё, что ≠ `"bump"` (включая пустое/мусор) → `edit` → нулевая регрессия и оркестратор не падает на любом значении флага. Новый low-level helper `delete_telegram(message_id) -> bool` (контракт «never raises», маркеры `_DELETE_GONE_MARKERS`): `ok:true` или «уже нет / нельзя удалить» → `True`; неизвестный `ok:false`/5xx/исключение → `False`; нет кредов → `False` без HTTP. Сигнатуры `send_telegram`/`edit_telegram`/`update_task_tracker` и схема БД (`tasks.tracker_message_id`) не менялись. ADR `docs/work-items/ORCH-042/06-adr/ADR-001-tracker-bump-mode.md`. Тесты: `tests/test_tracker_bump.py`, `tests/test_config.py`. - **Дословный текст findings reviewer/tester встраивается в `task_desc` заворота** (ORCH-046): при откате на `development` строка `task_desc` (попадает в `.task-dev.md` developer-агента) теперь несёт суть претензий, а не только ссылку на файл — устраняет «испорченный телефон», из-за которого агент шёл «читать файл», терял ключевые P0/P1 / причину FAIL и заворачивался снова, выжигая `MAX_DEVELOPER_RETRIES` и токены. Новый defensive-модуль `src/review_parse.py` (контракт «never raise», как `src/frontmatter.py`): `extract_review_findings(path)` — дословные пункты P0/P1 из секции `## Findings` файла `12-review.md`; `extract_test_failures(path)` — релевантный фрагмент тела `13-test-report.md` (приоритет `## Вывод pytest` → FAIL-строки `## Результаты` → `## Итог`). Обе функции усекают результат до `MAX_FINDINGS_CHARS`/`MAX_FAILURES_CHARS` (≈2000) с маркером `…(truncated)`. Две rollback-ветки `src/stage_engine.py` (reviewer REQUEST_CHANGES, tester `check_tests_passed` FAIL) встраивают извлечённый текст и **сохраняют ссылку** на полный файл («Полный контекст»); при пустом/битом артефакте — graceful-фоллбэк на прежнюю ссылку-строку (никаких исключений в `advance_stage`). Tester-ветка дополнительно всегда включает `reason` гейта. Последовательность отката, `_developer_retry_count`, поля `AdvanceResult` и реестр `QG_CHECKS` не менялись. ADR `docs/work-items/ORCH-046/06-adr/ADR-001-embed-findings-in-task-desc.md`. Тесты: `tests/test_review_parse.py`, `tests/test_stage_engine.py::TestRollbackTaskDescEmbedding`. - **Поллинг с ретраем в quality-gate `check_ci_green`** (ORCH-045): гейт CI превращён из single-shot в polling, чтобы устранить race condition — раньше один опрос combined commit-status сразу после пуша developer-а ловил транзиентный `pending` (типично 1-3с, реальный кейс ORCH-017: опрос 17:58:54 → pending, CI дозеленел 17:58:55) и задача застревала насмерть без повторного опроса. Теперь: `success` → пропуск сразу; `failure`/`error` → провал сразу (терминально, ретрай бессмыслен); `pending`/unknown → `time.sleep` и повторный опрос до `ci_poll_max_attempts` раз; истечение попыток → явный `(False, "CI still pending after s")` (тупик больше не молчаливый); 404 → как раньше; транзиентная `httpx.HTTPError` на попытке логируется и ретраится в рамках бюджета. Параметры — новые настройки `ORCH_CI_POLL_MAX_ATTEMPTS` (12) и `ORCH_CI_POLL_INTERVAL_S` (10) в `src/config.py` (~2 мин ожидания pending). Сигнатура `check_ci_green(repo, branch)` и реестр `QG_CHECKS` не менялись; `check_tests_passed` не затронут. ADR `docs/architecture/adr/adr-0004-ci-poll-retry.md`. Тесты: `tests/test_qg.py::TestCheckCIGreen`. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 9fdfe85..27710b4 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -34,17 +34,29 @@ created → analysis → architecture → development → review → testing → | deploy | — | `check_deploy_status` | 14-deploy-log.md (`deploy_status:`) | | done | — | — | — | -**Реестр QG** (`QG_CHECKS`): check_analysis_approved, check_analysis_complete, check_architecture_done, check_ci_green, check_review_approved, check_tests_passed, check_reviewer_verdict, check_tests_local, check_deploy_status, check_staging_status. +**Реестр QG** (`QG_CHECKS`): check_analysis_approved, check_analysis_complete, check_architecture_done, check_ci_green, check_review_approved, check_tests_passed, check_reviewer_verdict, check_tests_local, check_deploy_status, check_staging_status, check_branch_mergeable (ORCH-043). **Канон гейтов:** машинные вердикты читаются ТОЛЬКО из YAML-frontmatter, никогда из прозы. Лог-файлы мержатся в `origin/main` отдельным PR; гейт читает из `origin/main`. ### Условный staging-гейт (ORCH-35) `check_staging_status` реален только для self-hosting (`is_self_hosting_repo(repo)` → `orchestrator`); для остальных проектов → no-op `(True, "Staging gate N/A")`. Для orchestrator парсит `staging_status:` из `15-staging-log.md`; FAILED → откат на `development`. Подробнее: [ADR-0003](adr/adr-0003-staging-gate.md). +### Merge-gate: догон `main` + re-test + сериализация слияний (ORCH-043) +Детерминированный под-гейт (`check_branch_mergeable`, без LLM) на ребре **`deploy-staging → deploy`**: исполняется ПОСЛЕ `check_staging_status` и ДО запуска deployer'а, который вливает PR в `main` (deployer мержит в начале стадии `deploy`). Стадии (`STAGE_TRANSITIONS`) НЕ меняются — это «под-гейт» ребра, а не отдельная стадия (триггер — то же событие «staging-deployer завершился»). + +Назначение: ветка валидируется относительно того `main`, из которого создана; параллельная задача могла уйти вперёд → семантический конфликт слияния (зелёная ветка ломает обновлённый `main`). Merge-gate гарантирует проверку против **актуального** `origin/main` перед слиянием: +- **Догон:** ветка отстаёт (⇔ `origin/main` не предок HEAD) → `rebase origin/main` в worktree + `push --force-with-lease` (ТОЛЬКО ветка задачи; `main` — никогда). Текстовый конфликт → `rebase --abort` → откат на `development`. +- **Re-test:** `python -m pytest` (`merge_retest_target`, дефолт `tests/`) в worktree догнанной ветки, тайм-аут `merge_retest_timeout_s`. Красный/тайм-аут → откат на `development`. +- **Сериализация (merge-lock):** файловый **merge-lease** на репо (`/.merge-lease-.json`), живёт от гейта до фактического merge. Acquire **неблокирующий** (anti-deadlock при `max_concurrency=1`): busy → **defer** (повторная постановка deployer'а на `deploy-staging` с задержкой через `available_at`), а не откат. Release — на PR-merged вебхуке / `deploy→done` / откате / по возрасту (crash-реклейм). Restart-safe; без изменения схемы БД. +- **Условность (как ORCH-35):** реален для `orchestrator`; прочие репо — no-op. Флаги `merge_gate_enabled` / `merge_gate_repos` — поэтапный раскат. Контракт **never-raise**. + +Подробнее: [adr-0006](adr/adr-0006-merge-gate.md), детально — `docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md`. + ## Откаты - Reviewer REQUEST_CHANGES → откат на `development` + retry (`MAX_DEVELOPER_RETRIES = 3`). - Tester `check_tests_passed` FAIL → откат на `development` + retry. - Deploy / deploy-staging FAILED → откат на `development`. +- Merge-gate FAIL (конфликт rebase / красный re-test, ORCH-043) → откат на `development` + retry; `merge-lock busy` → **defer** (не откат, dev-retry не тратится). - `get_previous_stage` использует порядок ключей `STAGE_TRANSITIONS`. ### Обогащение `task_desc` при заворотах (ORCH-046) @@ -97,4 +109,4 @@ created → analysis → architecture → development → review → testing → Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.md). --- -*Актуально на 2026-06-05 (main `f1b3146`). Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py.* +*Актуально на 2026-06-06. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. ORCH-043: merge-gate — design (см. adr-0006), реализация в ветке feature/ORCH-043.* diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md index f4874c4..0a4c74a 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -10,6 +10,7 @@ Per-work-item решения живут в `docs/work-items//06-adr/ADR-NNN- | adr-0003 | Условный staging-гейт перед прод-деплоем | accepted | 2026-06-05 | ORCH-35 | | adr-0004 | Поллинг с ретраем в check_ci_green (фикс CI-race) | accepted | 2026-06-05 | ORCH-045 | | adr-0005 | Контейнеры бегут под uid:gid хоста (1000:1000) | accepted | 2026-06-06 | ORCH-040 | +| adr-0006 | Merge-gate (догон main + re-test + сериализация слияний) | proposed | 2026-06-06 | ORCH-043 | ## Формат **Контекст → Решение → Альтернативы → Последствия → Связи.** Статус: proposed / accepted / superseded. diff --git a/docs/architecture/adr/adr-0006-merge-gate.md b/docs/architecture/adr/adr-0006-merge-gate.md new file mode 100644 index 0000000..c977446 --- /dev/null +++ b/docs/architecture/adr/adr-0006-merge-gate.md @@ -0,0 +1,53 @@ +# adr-0006: Merge-gate — догон `main` + re-test + сериализация слияний + +- **Статус:** proposed +- **Дата:** 2026-06-06 +- **Задача:** ORCH-043 +- **Детальный ADR:** `docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md` + +## Контекст +Ветка валидируется относительно того `main`, из которого создана, а не относительно `main` +на момент слияния. Параллельная задача могла влиться раньше → **семантический конфликт +слияния** (git мержит без текстового конфликта, но `main` сломан). Для self-hosting это +красный `main` инструмента, обслуживающего все проекты. Слияние в `main` делает +deployer-агент в начале стадии `deploy`; замена механизма PR-merge — вне объёма. + +## Решение +Детерминированный merge-gate (`check_branch_mergeable`, без LLM) на ребре +`deploy-staging → deploy`, ДО запуска deployer'а, который мержит. `STAGE_TRANSITIONS` не +меняется (минимальный blast-radius); в `QG_CHECKS` добавлен `check_branch_mergeable`. + +- **Догон:** ветка отстаёт ⇔ `origin/main` не предок HEAD → `rebase origin/main` в worktree + + `push --force-with-lease` (ТОЛЬКО ветка задачи; `main` — никогда). Текстовый конфликт → + `rebase --abort` → откат на `development`. +- **Re-test:** `python -m pytest tests/` в worktree догнанной ветки, тайм-аут + `merge_retest_timeout_s`. Красный/тайм-аут → откат на `development`. +- **Сериализация (BR-5):** файловый **merge-lease** на репо + (`/.merge-lease-.json`), живёт от гейта до фактического merge. + Acquire **неблокирующий** (anti-deadlock при `max_concurrency=1`): busy → **defer** + (re-enqueue deployer с задержкой через `available_at`), не rollback. Release — на + PR-merged вебхуке / `deploy→done` / откате / по возрасту (crash-реклейм). Restart-safe. +- **Условность (как ORCH-35):** реален для `orchestrator`; прочие репо — no-op. Флаги + `merge_gate_enabled` / `merge_gate_repos` для поэтапного раската. + +## Альтернативы +- **Новая стадия `merge-gate`** (кандидат B) — «пустая» стадия без агента не имеет триггера + (`advance_stage` срабатывает только на завершении агента/вебхуке); потребовала бы chaining + в движке (не restart-safe) или синтетический job-тип. Отклонено. +- **Перенос merge в детерминированный шаг оркестратора** (кандидат C) — запрещён объёмом + (замена механизма PR-merge вне scope). Отклонено. +- **Блокирующий lock** — дедлок при одном worker-слоте. Отклонено в пользу defer. + +## Последствия +- Сценарий «две зелёные ветки ломают `main`» закрыт: re-test против актуального `main` + + сериализация слияний. +- Плата: merge-gate — «скрытый» под-гейт ребра (нет в `STAGE_TRANSITIONS`); сериализация + опирается на PR-merged вебхук со страховкой реклеймом по возрасту; defer перепрогоняет + staging; длинный re-test держит worker-слот. +- Сквозное изменение конвейера → `arch:major-change`; прод-деплой ORCH-043 строго через + staging-гейт (8501). + +## Связи +adr-0001 (`is_self_hosting_repo`), adr-0003 (условный staging-гейт — образец условности), +adr-0002 (очередь / `available_at` для defer), ORCH-2 (worktree-изоляция), ORCH-046 +(дословный reason в `task_desc` при откате). diff --git a/docs/work-items/ORCH-043/00-business-request.md b/docs/work-items/ORCH-043/00-business-request.md new file mode 100644 index 0000000..29963e6 --- /dev/null +++ b/docs/work-items/ORCH-043/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: Безопасная параллель в одном репо: merge-gate + auto-rebase + re-test + +Work Item ID: ORCH-043 + +## Description + +TBD diff --git a/docs/work-items/ORCH-043/01-brd.md b/docs/work-items/ORCH-043/01-brd.md new file mode 100644 index 0000000..25c93c4 --- /dev/null +++ b/docs/work-items/ORCH-043/01-brd.md @@ -0,0 +1,114 @@ +# 01 — Business Requirements Document (BRD) + +**Work Item:** ORCH-043 +**Тема:** Безопасная параллель в одном репо: merge-gate + auto-rebase + re-test +**Проект:** orchestrator (self-hosting) +**Автор:** Analyst +**Дата:** 2026-06-06 + +--- + +## 1. Контекст и проблема + +Оркестратор ведёт несколько work item **параллельно**, каждый в своём изолированном +git worktree / ветке (`feature/ORCH-NNN-slug`, ORCH-2/S-4). Все ветки одного проекта +исходят из общего `origin/main` и в конце конвейера **вливаются обратно в `main`**. + +Текущий конвейер валидирует ветку **относительно того состояния `main`, из которого +она была создана**, а не относительно `main` на момент слияния: + +- `check_ci_green` (стадия `development`) — CI зелёный **на ветке** (Gitea commit status ветки). +- `check_tests_passed` (стадия `testing`) — вердикт тестировщика по коду **ветки**. +- На стадии `deploy` ветка вливается в `main` (слияние выполняет deployer-агент, + см. `src/webhooks/gitea.py` — комментарий про «deployer merges the PR at the START of its run»). + +**Между «ветка проверена» и «ветка влита» `main` мог уйти вперёд** из-за слияния другой +параллельной задачи. Возникает **семантический (логический) конфликт слияния**: git +сливает ветки без текстового конфликта, но объединённый код `main` сломан — тесты, +которые были зелёными на ветке, на обновлённом `main` падают. + +### Почему это критично именно здесь (self-hosting) +Проект ORCH правит инструмент, который СЕЙЧАС работает в проде и обслуживает другие +проекты (enduro-trails) из одного инстанса с общей БД и общей очередью (см. `CLAUDE.md`, +`docs/operations/INFRA.md`). Сломанный `main` оркестратора = встал конвейер ВСЕХ проектов. +Две параллельные ORCH-задачи, каждая «зелёная» по отдельности, при последовательном +слиянии способны положить прод. + +### Сценарий-иллюстрация +1. Задачи A и B ответвлены от `main@C0`. +2. A проходит конвейер, вливается → `main@C1`. +3. B тестировалась против `C0`; её CI зелёный относительно `C0`. Git-слияние B в `C1` + проходит без текстового конфликта, но `C1` содержит изменения A, ломающие B. +4. `main` становится красным. Конвейер всех проектов деградирует. + +--- + +## 2. Цель + +Гарантировать, что ветка вливается в `main` **только если она проверена против +актуального `origin/main`**. Перед слиянием ветка автоматически догоняет `main` +(auto-rebase) и **повторно тестируется** (re-test); зелёный результат на актуальном +`main` — обязательное условие слияния (merge-gate). Слияния в `main` одного репозитория +**сериализуются**, чтобы окно гонки не воспроизводилось между двумя гейтами. + +## 3. Заинтересованные стороны +- **Owner / разработчики** — не хотят красный `main` и ручные разборы конфликтов. +- **Все проекты на инстансе** — зависят от живого прод-оркестратора. +- **Агенты конвейера** — получают детерминированный гейт вместо ручной координации. + +## 4. Объём (Scope) + +### В объёме +1. **Merge-gate** — детерминированный гейт перед слиянием в `main`: пропускает + слияние только если ветка не отстаёт от `origin/main` И повторная проверка зелёная. +2. **Auto-rebase** — если ветка отстаёт от `origin/main`, автоматически догнать `main` + (rebase/merge ветки на актуальный `origin/main`) в worktree и запушить результат. +3. **Re-test** — после auto-rebase повторно прогнать тест-набор на догнанной ветке; + зелёный результат — условие прохода гейта. +4. **Сериализация слияний** — в пределах одного репозитория одновременно «догон+слияние» + выполняет только одна задача (merge-lock), иначе гонка воспроизводится. +5. **Откаты при неуспехе** — текстовый конфликт rebase ИЛИ красный re-test → возврат + задачи на `development` (по образцу существующих откатов) с понятным комментарием. +6. **Конфигурируемость** — пороги/тайм-ауты re-test и поведение гейта вынесены в `settings`. + +### Вне объёма +- Изменение логики стадий `analysis` / `architecture` / `review`. +- Замена самого механизма слияния PR в Gitea (UI/настройки репозитория). +- Реальные прод-деплои (остаются за `scripts/orchestrator-deploy-hook.sh`). +- Кросс-репозиторная сериализация (гейт защищает `main` каждого репо отдельно). + +## 5. Бизнес-требования (BR) + +| ID | Требование | +|----|------------| +| BR-1 | Перед слиянием ветки в `main` оркестратор обязан проверить, что ветка содержит последний `origin/main` (не отстаёт). | +| BR-2 | Если ветка отстаёт — оркестратор автоматически догоняет её до `origin/main` без участия человека (auto-rebase). | +| BR-3 | После догона тест-набор повторно прогоняется; слияние разрешено только при зелёном результате (re-test). | +| BR-4 | Текстовый конфликт при auto-rebase или красный re-test НЕ приводит к слиянию: задача откатывается на `development` для ручного фикса. | +| BR-5 | В пределах одного репозитория «догон+проверка+слияние» сериализуются: две задачи не могут одновременно пройти merge-gate и влиться. | +| BR-6 | Гейт детерминированный (Python/гит-команды + код тестов), а не доверие LLM-агенту. | +| BR-7 | Гейт обязателен минимум для self-hosting репозитория `orchestrator`; применим к любому репо с параллельными задачами. | +| BR-8 | Все события гейта (догон, re-test, проход/откат) логируются и отражаются комментарием в Plane, без рассинхрона стадий. | + +## 6. Критерии успеха +- Воспроизводимый ранее сценарий «две зелёные ветки ломают `main`» более не приводит + к красному `main`: вторая ветка либо догоняется и проходит re-test, либо откатывается. +- Прод-контейнер `orchestrator` не перезапускается и не падает в рамках задачи. +- Реестр гейтов и стадий остаётся консистентным (snapshot-тесты обновлены осознанно). + +## 7. Риски и ограничения +- **Гонка между двумя гейтами** — снимается merge-lock (BR-5); без него фикс неполон. +- **Долгий re-test** — нужен тайм-аут и понятный откат, а не вис задачи. +- **Force-push догнанной ветки** — допустим только `--force-with-lease` и только по + own-ветке задачи; никогда по `main`. +- **Self-hosting** — любые изменения не должны ронять/рестартить прод-оркестратор; + обязательная страховка стадией `deploy-staging` (порт 8501) сохраняется. +- Окончательное место встройки в конвейер (новая стадия / гейт существующего перехода / + шаг перед слиянием) — **решение архитектора** (ADR), BRD фиксирует требуемое поведение. + +## 8. Связанные артефакты +- `02-trz.md` — техническое задание (модули, гейт, конфиг, точки встройки). +- `03-acceptance-criteria.md` — критерии приёмки PASS/FAIL. +- `04-test-plan.yaml` — план тестов. +- Контекст кода: `src/qg/checks.py`, `src/stage_engine.py`, `src/git_worktree.py`, + `src/agents/launcher.py`, `src/webhooks/gitea.py`, `src/stages.py`, `src/config.py`. diff --git a/docs/work-items/ORCH-043/02-trz.md b/docs/work-items/ORCH-043/02-trz.md new file mode 100644 index 0000000..4b6cba7 --- /dev/null +++ b/docs/work-items/ORCH-043/02-trz.md @@ -0,0 +1,161 @@ +# 02 — Техническое задание (ТЗ) + +**Work Item:** ORCH-043 +**Тема:** merge-gate + auto-rebase + re-test (безопасная параллель в одном репо) +**Автор:** Analyst + +> ТЗ описывает ТРЕБУЕМОЕ поведение и конкретные точки изменения кода. Окончательный +> выбор места встройки в конвейер (новая стадия vs гейт существующего перехода vs шаг +> перед слиянием) и детали reconciliation — **за архитектором** (ADR в `06-adr/`). +> Если ТЗ окажется нереализуемым — вернуть на стадию `analysis`, не комментировать задним числом. + +--- + +## 1. Задействованные модули `src/` + +| Модуль | Роль в изменении | +|--------|------------------| +| `src/merge_gate.py` (**новый**) | Ядро фичи: ancestor-check, auto-rebase, re-test, merge-lock. Чистые функции + git-операции в worktree. | +| `src/qg/checks.py` | Новый QG-check `check_branch_mergeable` (merge-gate) + регистрация в `QG_CHECKS`. Переиспользует паттерн `check_tests_local` (pytest в worktree) и `_repo_path`. | +| `src/stages.py` | Встройка merge-gate в `STAGE_TRANSITIONS` (точное место — за архитектором; см. §6). | +| `src/stage_engine.py` | Ветка отката merge-gate → `development` в `_handle_qg_failure_rollbacks` + диспетчеризация нового check в `_run_qg`. | +| `src/git_worktree.py` | Возможные хелперы: проверка «behind origin/main», rebase, push `--force-with-lease`. Не ломать сигнатуры `ensure_worktree` / `get_worktree_path`. | +| `src/config.py` | Новые `settings`: тайм-аут re-test, вкл/выкл гейта, политика отстающей ветки, тайм-аут lock. | +| `src/agents/launcher.py` | Если merge-gate встраивается как шаг перед слиянием на стадии `deploy` — точка, где deployer запускается, может потребовать координации с lock (за архитектором). | +| `tests/` | Новые тесты (см. `04-test-plan.yaml`) + обновление snapshot-тестов реестра/стадий. | + +## 2. Функциональные требования к `src/merge_gate.py` + +Предлагаемый публичный контракт (имена финализирует архитектор; поведение обязательно): + +### 2.1 `branch_is_behind_main(repo, branch) -> bool` +- `git fetch origin main` в main-clone/worktree (best-effort, never-raise → трактуем + как «не удалось определить» и НЕ пропускаем слияние вслепую). +- Ветка считается отстающей, если `origin/main` **не** является предком HEAD ветки + (`git merge-base --is-ancestor origin/main ` → ненулевой код). + +### 2.2 `auto_rebase_onto_main(repo, branch) -> (ok: bool, reason: str)` +- Выполняется в изолированном worktree ветки (`ensure_worktree`), НЕ в общем clone. +- Догнать ветку до `origin/main` (rebase либо merge — выбор архитектора; критично: + результат содержит весь `origin/main` и историю/изменения ветки). +- **Текстовый конфликт** → отменить операцию (`git rebase --abort` / `git merge --abort`), + worktree оставить чистым, вернуть `(False, "rebase conflict: <файлы>")`. +- **Чистый догон** → `git push --force-with-lease origin ` (ТОЛЬКО ветка задачи, + НИКОГДА `main`). Вернуть `(True, ...)`. +- Контракт never-raise: любая git/OS-ошибка → `(False, "")`, не исключение. + +### 2.3 `retest_branch(repo, branch) -> (ok: bool, reason: str)` +- Прогнать тест-набор проекта в worktree догнанной ветки. Канон — как в + `check_tests_local`: `python -m pytest` (точная команда/каталог — за архитектором, + согласованно с CI-конфигом `.gitea/workflows/`). +- Тайм-аут `settings.merge_retest_timeout_s`; превышение → `(False, "re-test timeout")`. +- Возврат: `(True, "re-test green")` при коде 0, иначе `(False, "re-test failed: ")`. + +### 2.4 Merge-lock (сериализация, BR-5) +- Реализовать межзадачную сериализацию «догон+re-test+слияние» в пределах одного `repo`. +- Допустимые реализации (выбор архитектора): файловый lock в `repos_dir`, advisory-lock, + либо строка-замок в SQLite. Требования: restart-safe, с тайм-аутом + `settings.merge_lock_timeout_s`, корректное освобождение при ошибке/падении. +- Под локом: повторно сверить «не отстаёт» ПОСЛЕ захвата (double-check), т.к. `main` + мог уйти, пока ждали lock. + +## 3. Новый QG-check (`src/qg/checks.py`) + +``` +check_branch_mergeable(repo, work_item_id, branch) -> tuple[bool, str] +``` + +Поведение (детерминированно, без участия LLM): +1. Захватить merge-lock для `repo` (с тайм-аутом). Не удалось → `(False, "merge-lock busy")`. +2. Если ветка не отстаёт от `origin/main` → `(True, "branch up-to-date with main")`. +3. Иначе `auto_rebase_onto_main`: + - конфликт → `(False, "rebase conflict: ...")`; + - успех → `retest_branch`: + - зелёный → `(True, "rebased onto main, re-test green")`; + - красный/тайм-аут → `(False, "re-test failed after rebase: ...")`. +4. Освободить lock в `finally`. +- Зарегистрировать в `QG_CHECKS` под ключом `"check_branch_mergeable"`. +- Контракт never-raise (как у соседних чеков): исключение → `(False, "")`. + +> **Опционально (за архитектором):** флаг `settings.merge_gate_enabled`; при `False` +> чек возвращает `(True, "merge-gate disabled")` (безопасный no-op для постепенного +> раскатывания, по образцу условного staging-гейта ORCH-35). + +## 4. Изменения схемы БД +- **Не требуется** для базовой реализации (lock через файл/advisory). +- ЕСЛИ архитектор выберет lock через SQLite — добавить таблицу/строку-замок миграцией, + совместимой с текущей инициализацией `src/db.py` (никаких ломающих изменений `tasks`, + `agent_runs`, `jobs`, `events`). Это решение фиксируется в ADR. + +## 5. Изменения API +- Новых HTTP-эндпоинтов **не требуется**. +- Допустимо (не обязательно) расширить `GET /status` или `GET /queue` индикатором + «merge-gate: rebasing/re-testing/locked» для наблюдаемости — на усмотрение архитектора, + без изменения существующих контрактов ответов. + +## 6. Точки встройки в конвейер (требование + кандидаты) + +**Требование:** merge-gate отрабатывает как можно ближе к фактическому слиянию в `main` +и ДО него. Слияние ветки в `main` НЕ должно происходить в обход гейта. + +Кандидаты (окончательно — ADR архитектора): +- **(A)** Гейт на переходе `deploy-staging → deploy` или новый под-гейт перед слиянием: + deployer вливает PR на стадии `deploy`, поэтому проверка «догнать+re-test» логично + встаёт непосредственно перед запуском deployer. +- **(B)** Новая стадия `merge-gate` между `deploy-staging` и `deploy` с агентом=None и + `qg="check_branch_mergeable"`. +- **(C)** Перенести само слияние в `main` из ответственности deployer-агента в + детерминированный шаг оркестратора, защищённый merge-gate (более крупное изменение). + +При любом варианте, меняющем `STAGE_TRANSITIONS` или `QG_CHECKS`: +- обновить `docs/architecture/README.md` (таблица стадий + реестр QG, §«Конвейер»); +- обновить snapshot-тесты `tests/test_qg_registry_snapshot.py` + (`_EXPECTED_QGS`, `_EXPECTED_TRANSITIONS`) — осознанно, в этом же PR; +- сохранить порядок ключей `STAGE_TRANSITIONS` (от него зависит `get_previous_stage`). + +## 7. Откаты (интеграция со `stage_engine`) +В `_handle_qg_failure_rollbacks` добавить ветку для merge-gate FAIL по образцу +`check_staging_status` / `check_deploy_status`: +- `update_task_stage(task_id, "development")`, `set_issue_blocked(work_item_id)`; +- комментарий в Plane (`plane_add_comment`, author="deployer" или системный) с причиной + (конфликт rebase / красный re-test) — дословный `reason` гейта; +- Telegram-алерт (`send_telegram`); +- учитывать `MAX_DEVELOPER_RETRIES`, не плодить бесконечные заворот-циклы. +- В `_run_qg` добавить диспетчеризацию `check_branch_mergeable` с сигнатурой + `(repo, work_item_id, branch)` (как у артефактных чеков). + +## 8. Изменения конфигурации (`src/config.py`, env-префикс `ORCH_`) +| Setting | Назначение | Дефолт (предложение) | +|---------|-----------|----------------------| +| `merge_gate_enabled: bool` | Глобальный вкл/выкл гейта | `True` | +| `merge_retest_timeout_s: int` | Тайм-аут повторного прогона тестов | `600` | +| `merge_lock_timeout_s: int` | Тайм-аут ожидания merge-lock | `300` | +| `merge_gate_repos: str` | (опц.) ограничить гейт списком репо; пусто = все | `""` | + +Значения и имена финализирует архитектор; задокументировать в `.env.example` и +`docs/architecture/README.md`. + +## 9. Требования к наблюдаемости / документации (golden source) +- Обновить `docs/architecture/README.md`: описание merge-gate, auto-rebase, re-test, + merge-lock; при изменении стадий/реестра — соответствующие таблицы. +- Обновить `CHANGELOG.md`. +- Завести ADR `docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md` (механизм догона, + выбор rebase vs merge, реализация lock, место встройки). +- Все ветки кода — с лог-сообщениями (`logger.info/warning/error`) по образцу соседних + гейтов, чтобы поведение читалось в `/app/data/runs` и логах сервиса. + +## 10. Нефункциональные требования +- **Безопасность self-hosting:** никогда не push в `main`; force только `--force-with-lease` + по ветке задачи; прод-контейнер `orchestrator` не рестартить/не ронять. +- **Изоляция:** все git-операции — в worktree ветки (`ensure_worktree`), не в общем clone, + чтобы не словить S-4-гонку параллельных задач. +- **Идемпотентность/restart-safe:** lock и гейт корректно ведут себя при рестарте сервиса. +- **Never-raise** контракт у всех новых чеков/парсеров (как в текущем `src/qg/checks.py`). +- **Совместимость:** не менять сигнатуры/поведение существующих QG-чеков и вебхуков. + +## 11. Артефакты pipeline, которые должны быть созданы/обновлены +- `src/merge_gate.py` (новый), изменения в `src/qg/checks.py`, `src/stages.py`, + `src/stage_engine.py`, `src/config.py`, при необходимости `src/git_worktree.py`. +- Новые тесты в `tests/` + обновлённые snapshot-тесты. +- `docs/architecture/README.md`, `CHANGELOG.md`, `.env.example`, + `docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md`. diff --git a/docs/work-items/ORCH-043/03-acceptance-criteria.md b/docs/work-items/ORCH-043/03-acceptance-criteria.md new file mode 100644 index 0000000..3d990d2 --- /dev/null +++ b/docs/work-items/ORCH-043/03-acceptance-criteria.md @@ -0,0 +1,105 @@ +# 03 — Критерии приёмки (Acceptance Criteria) + +**Work Item:** ORCH-043 — merge-gate + auto-rebase + re-test +**Автор:** Analyst + +Каждый критерий имеет однозначное условие PASS/FAIL. Все критерии должны быть PASS. + +--- + +## AC-1 — Ветка актуальна: гейт пропускает без догона +- **Дано:** ветка содержит последний `origin/main` (не отстаёт). +- **Когда:** выполняется `check_branch_mergeable(repo, work_item_id, branch)`. +- **PASS:** возвращает `(True, ...)` с причиной «up-to-date», auto-rebase НЕ запускается, + ветка не пушится повторно. +- **FAIL:** возвращает `False`, либо выполняет ненужный rebase/push. + +## AC-2 — Ветка отстаёт + чистый догон + зелёный re-test → проход +- **Дано:** ветка отстаёт от `origin/main`; rebase проходит без текстового конфликта; + тест-набор на догнанной ветке зелёный. +- **Когда:** выполняется merge-gate. +- **PASS:** ветка догнана до `origin/main`, запушена `--force-with-lease`, re-test зелёный, + гейт возвращает `(True, ...)`. +- **FAIL:** гейт возвращает `False` при чистом догоне и зелёном re-test, либо `main` тронут, + либо push выполнен НЕ через `--force-with-lease`. + +## AC-3 — Текстовый конфликт rebase → откат на development, без слияния +- **Дано:** auto-rebase упирается в текстовый конфликт. +- **Когда:** выполняется merge-gate. +- **PASS:** rebase отменён (worktree чист), гейт возвращает `(False, "rebase conflict...")`, + задача переведена на `development`, в Plane — комментарий с причиной, слияния в `main` нет. +- **FAIL:** ветка осталась в конфликтном состоянии, или задача продвинулась к слиянию, + или `main` изменён. + +## AC-4 — Красный re-test после догона → откат на development, без слияния +- **Дано:** rebase чистый, но тесты на догнанной ветке падают. +- **Когда:** выполняется merge-gate. +- **PASS:** гейт возвращает `(False, "re-test failed after rebase...")`, задача на + `development`, комментарий в Plane, слияния нет. +- **FAIL:** гейт вернул `True`, либо слияние произошло при красном re-test. + +## AC-5 — Сериализация слияний (merge-lock) +- **Дано:** две задачи одного `repo` одновременно подходят к merge-gate. +- **Когда:** обе пытаются пройти гейт. +- **PASS:** «догон+re-test+слияние» выполняет одновременно только одна задача; вторая + ждёт освобождения lock (в пределах `merge_lock_timeout_s`), после чего повторно + сверяет «не отстаёт» и при необходимости догоняется. Воспроизводимый сценарий + «две зелёные ветки ломают main» НЕ приводит к красному `main`. +- **FAIL:** обе задачи параллельно проходят гейт и вливаются, воспроизводя гонку. + +## AC-6 — Re-test тайм-аут управляем +- **Дано:** re-test превышает `settings.merge_retest_timeout_s`. +- **PASS:** прогон прерывается, гейт возвращает `(False, "re-test timeout...")`, задача + не виснет, идёт штатный откат. +- **FAIL:** задача висит дольше тайм-аута или падает с необработанным исключением. + +## AC-7 — Никогда не push/merge в main напрямую из гейта +- **PASS:** код merge-gate не выполняет `git push ... main` и не форс-пушит `main`; + force-операции — только `--force-with-lease` по ветке задачи. +- **FAIL:** найден любой push/force-push в `main` из логики гейта. + +## AC-8 — Изоляция в worktree +- **PASS:** все git-операции гейта идут в worktree ветки (`get_worktree_path` / + `ensure_worktree`), а не в общем `/repos/` clone. +- **FAIL:** rebase/тесты выполняются в общем clone, создавая S-4-гонку. + +## AC-9 — Контракт never-raise +- **Дано:** недоступен git/сеть, бит worktree, отсутствует ветка и т.п. +- **PASS:** `check_branch_mergeable` и функции `merge_gate.py` возвращают `(False, "")` + (или безопасный фоллбэк), НИКОГДА не пробрасывают исключение в `advance_stage`. +- **FAIL:** любое необработанное исключение всплывает из гейта. + +## AC-10 — Реестр QG и снапшоты консистентны +- **PASS:** `"check_branch_mergeable"` зарегистрирован в `QG_CHECKS` и callable; + `tests/test_qg_registry_snapshot.py` (`_EXPECTED_QGS`, при изменении стадий — + `_EXPECTED_TRANSITIONS`) обновлены и зелёные; порядок ключей `STAGE_TRANSITIONS` + сохранён (не сломан `get_previous_stage`). +- **FAIL:** дрейф реестра/стадий без обновления снапшотов; красные snapshot-тесты. + +## AC-11 — Интеграция отката в stage_engine +- **PASS:** в `_handle_qg_failure_rollbacks` есть ветка merge-gate FAIL → `development` + с уведомлениями (Plane + Telegram) и учётом `MAX_DEVELOPER_RETRIES`; `_run_qg` + корректно диспетчеризует новый чек. +- **FAIL:** FAIL гейта не приводит к откату, или нет уведомления, или зацикливание заворотов. + +## AC-12 — Условный no-op / выключение (если реализовано) +- **Дано:** `settings.merge_gate_enabled = False` (или репо вне `merge_gate_repos`). +- **PASS:** гейт возвращает `(True, "merge-gate disabled")`, конвейер работает как прежде. +- **FAIL:** гейт блокирует/ломает конвейер при выключенном флаге. + +## AC-13 — Документация обновлена (golden source) +- **PASS:** обновлены `docs/architecture/README.md` (merge-gate/auto-rebase/re-test, + при изменении — таблицы стадий/реестра), `CHANGELOG.md`, `.env.example` (новые + `ORCH_*` настройки); создан ADR `06-adr/ADR-001-merge-gate.md`. +- **FAIL:** функционал изменён, документация/ADR/CHANGELOG не обновлены (Reviewer → + REQUEST_CHANGES). + +## AC-14 — Безопасность self-hosting +- **PASS:** в рамках задачи прод-контейнер `orchestrator` (8500) не рестартился и не падал; + изменения не трогают `.env*`, `docker-compose.yml`, прод-инфраструктуру; страховка + стадией `deploy-staging` сохранена. +- **FAIL:** любой рестарт/падение прод-оркестратора или правка прод-инфры в рамках задачи. + +## AC-15 — Зелёный регресс +- **PASS:** `pytest tests/ -q` зелёный целиком (новые тесты ORCH-043 + существующий набор). +- **FAIL:** любой упавший/сломанный существующий тест. diff --git a/docs/work-items/ORCH-043/04-test-plan.yaml b/docs/work-items/ORCH-043/04-test-plan.yaml new file mode 100644 index 0000000..e0ed13b --- /dev/null +++ b/docs/work-items/ORCH-043/04-test-plan.yaml @@ -0,0 +1,163 @@ +work_item: ORCH-043 +title: "merge-gate + auto-rebase + re-test — безопасная параллель в одном репо" +framework: pytest +notes: > + Тесты на git-операции используют локальные временные репозитории (init bare "origin" + + рабочая ветка), мокают сеть/Plane/Telegram (как в tests/test_qg.py: + ORCH_DB_PATH/ORCH_REPOS_DIR в tmp, httpx замокан). Каталог тестов/команда pytest для + re-test должны совпадать с CI-конфигом проекта. Финальные имена функций/модулей сверять + с реализацией архитектора. + +tests: + # ---- merge_gate core: ancestor / behind detection ---- + - id: TC-01 + type: unit + description: "branch_is_behind_main → True, когда origin/main ушёл вперёд относительно ветки" + module: tests/test_merge_gate.py + expected: PASS + - id: TC-02 + type: unit + description: "branch_is_behind_main → False, когда ветка уже содержит весь origin/main" + module: tests/test_merge_gate.py + expected: PASS + - id: TC-03 + type: unit + description: "branch_is_behind_main never-raise: недоступный git/clone → безопасный возврат, не исключение" + module: tests/test_merge_gate.py + expected: PASS + + # ---- auto-rebase ---- + - id: TC-04 + type: unit + description: "auto_rebase_onto_main: чистый догон → (True), ветка содержит origin/main, push выполнен через --force-with-lease" + module: tests/test_merge_gate.py + expected: PASS + - id: TC-05 + type: unit + description: "auto_rebase_onto_main: текстовый конфликт → rebase отменён (worktree чист), (False, 'rebase conflict...'), main не тронут" + module: tests/test_merge_gate.py + expected: PASS + - id: TC-06 + type: unit + description: "auto_rebase_onto_main НЕ пушит и не форс-пушит main ни при каком исходе (проверка вызванных git-команд)" + module: tests/test_merge_gate.py + expected: PASS + + # ---- re-test ---- + - id: TC-07 + type: unit + description: "retest_branch: pytest rc=0 → (True, 're-test green')" + module: tests/test_merge_gate.py + expected: PASS + - id: TC-08 + type: unit + description: "retest_branch: pytest rc!=0 → (False, 're-test failed...') с хвостом вывода" + module: tests/test_merge_gate.py + expected: PASS + - id: TC-09 + type: unit + description: "retest_branch: превышен merge_retest_timeout_s → (False, 're-test timeout...'), без виса" + module: tests/test_merge_gate.py + expected: PASS + + # ---- merge-lock / сериализация ---- + - id: TC-10 + type: unit + description: "merge-lock: второй захват того же repo не проходит, пока lock удержан; освобождается в finally/после ошибки" + module: tests/test_merge_gate.py + expected: PASS + - id: TC-11 + type: unit + description: "merge-lock restart-safe: устаревший/осиротевший lock не блокирует навсегда (тайм-аут merge_lock_timeout_s)" + module: tests/test_merge_gate.py + expected: PASS + + # ---- QG check_branch_mergeable ---- + - id: TC-12 + type: unit + description: "check_branch_mergeable: ветка актуальна → (True, 'up-to-date'), rebase не вызывался" + module: tests/test_qg_merge_gate.py + expected: PASS + - id: TC-13 + type: unit + description: "check_branch_mergeable: отстаёт + чистый rebase + зелёный re-test → (True)" + module: tests/test_qg_merge_gate.py + expected: PASS + - id: TC-14 + type: unit + description: "check_branch_mergeable: конфликт rebase → (False, 'rebase conflict...')" + module: tests/test_qg_merge_gate.py + expected: PASS + - id: TC-15 + type: unit + description: "check_branch_mergeable: красный re-test после догона → (False, 're-test failed after rebase...')" + module: tests/test_qg_merge_gate.py + expected: PASS + - id: TC-16 + type: unit + description: "check_branch_mergeable never-raise: внутренняя ошибка → (False, reason), не исключение; lock освобождён" + module: tests/test_qg_merge_gate.py + expected: PASS + - id: TC-17 + type: unit + description: "merge_gate_enabled=False (или репо вне merge_gate_repos) → (True, 'merge-gate disabled') no-op" + module: tests/test_qg_merge_gate.py + expected: PASS + + # ---- реестр QG / стадии ---- + - id: TC-18 + type: unit + description: "'check_branch_mergeable' присутствует в QG_CHECKS и callable" + module: tests/test_qg_registry_snapshot.py + expected: PASS + - id: TC-19 + type: unit + description: "snapshot STAGE_TRANSITIONS/_EXPECTED_QGS обновлён осознанно и совпадает; порядок ключей сохранён (get_previous_stage не сломан)" + module: tests/test_qg_registry_snapshot.py + expected: PASS + + # ---- интеграция со stage_engine (откаты) ---- + - id: TC-20 + type: integration + description: "_run_qg диспетчеризует check_branch_mergeable с сигнатурой (repo, work_item_id, branch)" + module: tests/test_stage_engine.py + expected: PASS + - id: TC-21 + type: integration + description: "merge-gate FAIL → advance_stage откатывает задачу на 'development', set_issue_blocked, комментарий Plane, Telegram-алерт (моки)" + module: tests/test_stage_engine.py + expected: PASS + - id: TC-22 + type: integration + description: "merge-gate FAIL уважает MAX_DEVELOPER_RETRIES — нет бесконечного цикла заворотов" + module: tests/test_stage_engine.py + expected: PASS + - id: TC-23 + type: integration + description: "merge-gate PASS → задача продвигается к слиянию/деплою, рассинхрона стадий нет" + module: tests/test_stage_engine.py + expected: PASS + + # ---- сквозной сценарий гонки ---- + - id: TC-24 + type: integration + description: > + Воспроизведение бизнес-сценария: A и B от main@C0; A влита (main@C1); + B проходит merge-gate → догоняется до C1 и re-test зелёный → безопасное слияние; + при красном re-test B откатывается, main остаётся зелёным + module: tests/test_merge_gate_race.py + expected: PASS + + # ---- конфигурация ---- + - id: TC-25 + type: unit + description: "Новые ORCH_* настройки (merge_gate_enabled, merge_retest_timeout_s, merge_lock_timeout_s, merge_gate_repos) читаются с дефолтами и env-override" + module: tests/test_config.py + expected: PASS + + # ---- регресс ---- + - id: TC-26 + type: integration + description: "Полный набор pytest tests/ -q зелёный (существующие гейты/вебхуки/стадии не сломаны)" + module: tests/ + expected: PASS diff --git a/docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md b/docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md new file mode 100644 index 0000000..ac1e49b --- /dev/null +++ b/docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md @@ -0,0 +1,235 @@ +# 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 ` + вернул ненулевой код. Не удалось определить (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); + - **чистый rebase** → `git push --force-with-lease origin ` (**ТОЛЬКО ветка задачи; НИКОГДА `main`**, AC-7) → далее re-test. +5. Контракт **never-raise**: любая git/OS-ошибка → `(False, "")` (AC-9). + +`main` гейтом не пушится и не форс-пушится никогда. Единственная force-операция — +`--force-with-lease` по ветке задачи. + +### 3. Re-test — `python -m pytest` в worktree догнанной ветки + +`retest_branch(repo, branch)`: +- Команда `python -m pytest ` (`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 (s)")` (AC-6), процесс убивается, задача не виснет. +- `returncode == 0` → `(True, "re-test green")`; иначе `(False, "re-test failed after rebase: ")` (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): +- Файл `/.merge-lease-.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): +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 ")` +(по образцу условного 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_*`). diff --git a/docs/work-items/ORCH-043/07-infra-requirements.md b/docs/work-items/ORCH-043/07-infra-requirements.md new file mode 100644 index 0000000..f88f4ae --- /dev/null +++ b/docs/work-items/ORCH-043/07-infra-requirements.md @@ -0,0 +1,25 @@ +# 07 — Требования к инфраструктуре (ORCH-043) + +## Вывод: топология не меняется. Новых контейнеров/портов/сервисов нет. + +| Аспект | Требование | +|--------|-----------| +| Контейнеры | Без изменений. Прод `orchestrator` (8500) и `orchestrator-staging` (8501) — как есть. | +| Порты | Без изменений. | +| Сеть/внешние сервисы | Без новых зависимостей. Используются существующие git/Gitea (fetch/push) и pytest. | +| Файловая система | Новый артефакт времени выполнения — lease-файл `/.merge-lease-.json` (см. `08-data-requirements.md`). Лежит в уже примонтированном `repos_dir` (`/repos`). Дополнительного volume не требуется. | +| Worktree | Переиспользуется существующая изоляция (`/repos/_wt//`, ORCH-2). Все git-операции merge-gate — в worktree. | +| `.env` / compose / прод-инфра | **НЕ изменяются** (AC-14). Новые `ORCH_*` настройки имеют безопасные дефолты (см. ADR-001 §6) и документируются в `.env.example`. | + +## Эксплуатационные требования +- **git push прав** для оркестратора достаточно существующих (он уже пушит ветки/PR-артефакты). + Merge-gate пушит ТОЛЬКО ветку задачи (`--force-with-lease`), `main` — никогда. +- **Раскат поэтапно**: `merge_gate_enabled=False` или пустой `merge_gate_repos` (реален + только для `orchestrator`) позволяют включать гейт постепенно без риска для чужих репо. +- **Self-hosting-страховка сохранена**: изменения ORCH-043 проходят обязательную стадию + `deploy-staging` (8501) до прод-деплоя самого оркестратора; прод-контейнер не рестартится + в рамках задачи. + +## Рекомендация по процессу +Изменение сквозное (новый QG + поведение ребра `deploy-staging → deploy`) → +рекомендуется лейбл `arch:major-change`. Прод-деплой ORCH-043 — строго через staging-гейт. diff --git a/docs/work-items/ORCH-043/08-data-requirements.md b/docs/work-items/ORCH-043/08-data-requirements.md new file mode 100644 index 0000000..0fedd03 --- /dev/null +++ b/docs/work-items/ORCH-043/08-data-requirements.md @@ -0,0 +1,27 @@ +# 08 — Требования к данным / схеме БД (ORCH-043) + +## Вывод: изменение схемы SQLite НЕ требуется. + +Merge-lease (сериализация слияний, BR-5) реализуется **файлом**, а не таблицей: + +- Путь: `/.merge-lease-.json` (`settings.repos_dir`, по умолчанию `/repos`). +- Содержимое: `{ "task_id": int, "work_item_id": str, "branch": str, + "acquired_at": "", "pid": int }`. +- Жизненный цикл — см. ADR-001 §4 (acquire неблокирующий / release идемпотентный / + реклейм по возрасту `merge_lock_timeout_s`). + +### Почему файл, а не таблица БД +- ТЗ §4 прямо предпочитает реализацию без миграции схемы. +- Файловый lease проще делается **restart-safe** (реклейм по mtime/возрасту + `pid`) и не + трогает инициализацию `src/db.py` (никаких изменений `tasks`/`agent_runs`/`jobs`/`events`). +- Атомарность захвата обеспечивается `open(O_CREAT|O_EXCL)` на одном хосте (mva154, + один инстанс) — достаточно для сериализации в пределах одного процесса-оркестратора. + +### Существующие таблицы — без изменений +`tasks`, `agent_runs`, `jobs`, `events` не модифицируются. Defer-механизм переиспользует +существующий столбец `jobs.available_at` (ORCH-1) для отложенного повторного запуска +deployer'а — **новых столбцов не нужно**. + +> Если в будущем потребуется кросс-процессная/мульти-хостовая сериализация — lease можно +> мигрировать в таблицу (или advisory-lock). Это будет отдельным ADR; в рамках ORCH-043 +> файловый lease достаточен (один хост, один инстанс). diff --git a/docs/work-items/ORCH-043/10-tech-risks.md b/docs/work-items/ORCH-043/10-tech-risks.md new file mode 100644 index 0000000..35b3c39 --- /dev/null +++ b/docs/work-items/ORCH-043/10-tech-risks.md @@ -0,0 +1,24 @@ +# 10 — Технические риски (ORCH-043) + +Merge-gate + auto-rebase + re-test. Риски, их влияние и меры снижения. Привязка к AC. + +| # | Риск | Влияние | Снижение | AC | +|---|------|---------|----------|----| +| R-1 | **Дедлок при `max_concurrency=1`**: блокирующее ожидание merge-lock в `advance_stage` держит единственный worker-слот, а задаче-холдеру тот же слот нужен для merge. | Полная остановка конвейера (self-hosting = все проекты). | Acquire **неблокирующий**; busy → **defer** (re-enqueue с задержкой, слот освобождается), НЕ блокирующее ожидание. | AC-5 | +| R-2 | **Потерянный PR-merged вебхук** → lease не освобождается вовремя. | Следующая задача ждёт до тайм-аута. | Реклейм lease по возрасту `merge_lock_timeout_s`; release продублирован в `deploy→done` и в откатах. | AC-5 | +| R-3 | **Краш сервиса под lease** (зависший lease-файл после рестарта). | Блокировка merge репо. | Файловый lease с реклеймом по возрасту + `pid`; идемпотентный re-acquire холдером. Restart-safe. | AC-5, AC-9 | +| R-4 | **Долгий re-test (до 600s)** держит worker-слот и блокирует прочие задачи. | Замедление конвейера. | Жёсткий тайм-аут `merge_retest_timeout_s` + kill; осознанная стоимость re-test-перед-merge. | AC-6 | +| R-5 | **Случайный push/force-push в `main`** из логики гейта. | Прямая порча `main` прод-инструмента. | Код гейта НИКОГДА не пушит `main`; единственная force — `--force-with-lease` по ветке задачи; покрыто тестом-стражем. | AC-7 | +| R-6 | **Необработанное исключение** из гейта всплывает в `advance_stage`. | Падение авто-advance, зависшая задача. | Контракт **never-raise** во всех функциях `merge_gate.py` и `check_branch_mergeable`: исключение → `(False, reason)`. | AC-9 | +| R-7 | **Git-операции в общем clone** `/repos/` вместо worktree → S-4-гонка параллельных задач. | Порча рабочих копий, ложные конфликты. | Все операции — в worktree ветки (`ensure_worktree`/`get_worktree_path`). | AC-8 | +| R-8 | **Defer-петля** (lease вечно busy из-за залипшего холдера) → бесконечные перепрогоны staging. | Зацикливание, расход токенов/CPU. | `merge_defer_max_attempts` + Telegram-эскалация + блокировка; реклейм lease (R-2/R-3) снимает первопричину. | AC-5, AC-11 | +| R-9 | **rebase --force-with-lease** помечает прежний approve ревьюера stale и пересоздаёт head PR. | Теоретическая потеря «зелёного» статуса PR. | На стадии `deploy` ревью повторно не проверяется; re-test в гейте — авторитетная проверка. Документировано в ADR. | AC-2 | +| R-10 | **Re-test-команда не подходит чужому репо** (раскладка enduro-trails ≠ orchestrator). | Ложный красный re-test на не-self-hosting репо. | Гейт по умолчанию реален ТОЛЬКО для `orchestrator`; прочие — no-op; `merge_retest_target` параметризует цель. | AC-12, BR-7 | +| R-11 | **Дрейф snapshot-реестра** при добавлении QG. | Красные тесты / расхождение контракта. | Обновить `_EXPECTED_QGS` (+`check_branch_mergeable`) осознанно; `_EXPECTED_TRANSITIONS` НЕ менять (стадии не трогаем). | AC-10 | +| R-12 | **Рестарт/падение прод-контейнера** `orchestrator` в рамках задачи. | Остановка конвейера всех проектов. | Не трогаем `.env*`/`docker-compose.yml`/инфру; обязательная страховка `deploy-staging` (8501). | AC-14 | +| R-13 | **Регресс существующих тестов** от изменения `advance_stage`/`gitea.handle_pr`. | Поломка конвейера. | `pytest tests/ -q` целиком зелёный; изменения аддитивны (новая ветвь на ребре, существующие пути не меняются). | AC-15 | + +## Остаточные риски (принимаются) +- **Скрытый под-гейт** (merge-gate не отражён в `STAGE_TRANSITIONS`) — плата за минимальный + blast-radius; смягчён документацией (README + ADR). +- **Лишний прогон staging** при defer — переиспользование очереди вместо нового job-типа. diff --git a/docs/work-items/ORCH-043/12-review.md b/docs/work-items/ORCH-043/12-review.md new file mode 100644 index 0000000..9e26423 --- /dev/null +++ b/docs/work-items/ORCH-043/12-review.md @@ -0,0 +1,59 @@ +--- +type: review +work_item_id: ORCH-043 +verdict: APPROVED +version: 1 +--- + +# Review ORCH-043 — merge-gate + auto-rebase + re-test + +## Summary +Реализован детерминированный (без LLM) merge-gate `check_branch_mergeable` на ребре +`deploy-staging → deploy`: догон ветки до актуального `origin/main` (`rebase` + +`push --force-with-lease` ТОЛЬКО ветки задачи), повторный прогон тестов в worktree +догнанной ветки и файловый merge-lease для сериализации слияний. Интеграция в +`stage_engine` (defer при busy-lock, rollback при конфликте/красном re-test с капом +`MAX_DEVELOPER_RETRIES`), release lease на `deploy→done` / rollback / PR-merged вебхуке. + +Соответствие ТЗ (`02-trz.md`) и AC-1..AC-15 — полное. Реализация соответствует +`ADR-001-merge-gate.md` и глобальному `adr-0006`. Контракт never-raise соблюдён +во всех новых функциях, все git-операции изолированы в worktree (AC-8), `main` +никогда не пушится/форс-пушится (AC-7). Документация обновлена в этом же PR. + +`pytest tests/ -q` — **535 passed** (AC-15). Snapshot-реестр обновлён осознанно +(`_EXPECTED_QGS += check_branch_mergeable`, `_EXPECTED_TRANSITIONS` не тронут — AC-10). +Прод-инфра (`docker-compose*`, `.env`, `.gitea/`, `Dockerfile`) не затронута (AC-14). + +## Findings + +### P0 — Blocker +- (нет) + +### P1 — Must fix +- (нет) + +### P2 — Should fix +- [ ] **Двойное назначение `merge_lock_timeout_s` (300s).** Один и тот же тайм-аут + служит и порогом «лиз протух → реклейм» (crash-backstop), и фактическим окном + удержания лиза от гейта до мержа. Если deploy-деплоер по какой-то причине мержит + PR дольше 300s, ожидающая задача реклеймит лиз как stale и может пойти на слияние + параллельно — узкое окно, теоретически воспроизводящее гонку, которую закрывает + AC-5. На практике deployer мержит в начале запуска, окно мало; тайм-аут + конфигурируем. Рекомендация (не блокер): развести «возраст реклейма краша» и + «ожидаемое время удержания», либо добавить наблюдаемость (лог/алерт при + stale-реклейме непустого холдера). +- [ ] **Двойной `git fetch origin main`** — в `branch_is_behind_main` и затем в + `auto_rebase_onto_main` на пути «ветка отстаёт». Незначительная неэффективность, + не баг; можно переиспользовать результат первого fetch. + +## Документация +Обновлено полностью, документация = golden source соблюдена (AC-13): +- `docs/architecture/README.md` — добавлен раздел «Merge-gate…», ветка откатов, + реестр QG (`check_branch_mergeable`), `STAGE_TRANSITIONS` корректно НЕ изменён. +- `CHANGELOG.md` — подробная запись ORCH-043. +- `.env.example` — все 7 новых `ORCH_MERGE_*` настроек с комментариями. +- ADR per-work-item `docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md` (Proposed) + и глобальный `docs/architecture/adr/adr-0006-merge-gate.md` + строка в `adr/README.md`. +- Тесты: `test_merge_gate.py`, `test_qg_merge_gate.py`, `test_merge_gate_race.py`, + `test_stage_engine.py::TestMergeGate`, `test_config.py`, обновлён + `test_qg_registry_snapshot.py`. diff --git a/docs/work-items/ORCH-043/13-test-report.md b/docs/work-items/ORCH-043/13-test-report.md new file mode 100644 index 0000000..99c8e97 --- /dev/null +++ b/docs/work-items/ORCH-043/13-test-report.md @@ -0,0 +1,66 @@ +--- +type: test-report +work_item_id: ORCH-043 +result: PASS +--- + +# Test Report — ORCH-043 (merge-gate + auto-rebase + re-test) + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 +- Ветка: `feature/ORCH-043-merge-gate-auto-rebase-re-test` (HEAD `ba51aa1`) +- Дата: 2026-06-06T17:37Z + +## Smoke API (read-only, прод-контейнер не трогался) +- `GET /health` → HTTP 200 `{"status":"ok","service":"orchestrator"}` +- `GET /status` → HTTP 200, активная задача ORCH-043 на стадии `testing` +- `GET /queue` → HTTP 200, breaker `closed`, preflight_ok=true, max_concurrency=1 + +## Результаты (test-plan 04-test-plan.yaml) + +| TC ID | Описание | Модуль | Результат | +|-------|----------|--------|-----------| +| TC-01 | branch_is_behind_main → True (main ушёл вперёд) | test_merge_gate.py | PASS | +| TC-02 | branch_is_behind_main → False (ветка содержит main) | test_merge_gate.py | PASS | +| TC-03 | branch_is_behind_main never-raise | test_merge_gate.py | PASS | +| TC-04 | auto_rebase: чистый догон + push --force-with-lease | test_merge_gate.py | PASS | +| TC-05 | auto_rebase: конфликт → abort, worktree чист, main не тронут | test_merge_gate.py | PASS | +| TC-06 | auto_rebase не пушит/форс-пушит main | test_merge_gate.py | PASS | +| TC-07 | retest_branch: rc=0 → (True,'re-test green') | test_merge_gate.py | PASS | +| TC-08 | retest_branch: rc!=0 → (False) с хвостом вывода | test_merge_gate.py | PASS | +| TC-09 | retest_branch: тайм-аут → (False,'re-test timeout') | test_merge_gate.py | PASS | +| TC-10 | merge-lock: повторный захват блокируется, release в finally | test_merge_gate.py | PASS | +| TC-11 | merge-lock restart-safe: устаревший lock не блокирует | test_merge_gate.py | PASS | +| TC-12 | check_branch_mergeable: актуальна → (True,'up-to-date') | test_qg_merge_gate.py | PASS | +| TC-13 | check_branch_mergeable: отстаёт+rebase+зелёный re-test → True | test_qg_merge_gate.py | PASS | +| TC-14 | check_branch_mergeable: конфликт rebase → (False) | test_qg_merge_gate.py | PASS | +| TC-15 | check_branch_mergeable: красный re-test → (False) | test_qg_merge_gate.py | PASS | +| TC-16 | check_branch_mergeable never-raise, lock освобождён | test_qg_merge_gate.py | PASS | +| TC-17 | merge_gate_enabled=False / вне merge_gate_repos → no-op | test_qg_merge_gate.py | PASS | +| TC-18 | 'check_branch_mergeable' в QG_CHECKS и callable | test_qg_registry_snapshot.py | PASS | +| TC-19 | snapshot реестра/стадий обновлён, порядок ключей сохранён | test_qg_registry_snapshot.py | PASS | +| TC-20 | _run_qg диспетчеризует check_branch_mergeable | test_stage_engine.py | PASS | +| TC-21 | merge-gate FAIL → откат на development + Plane/Telegram | test_stage_engine.py | PASS | +| TC-22 | merge-gate FAIL уважает MAX_DEVELOPER_RETRIES | test_stage_engine.py | PASS | +| TC-23 | merge-gate PASS → продвижение к слиянию/деплою | test_stage_engine.py | PASS | +| TC-24 | сквозной сценарий гонки A/B, main остаётся зелёным | test_merge_gate_race.py | PASS | +| TC-25 | новые ORCH_* настройки: дефолты + env-override | test_config.py | PASS | +| TC-26 | полный регресс pytest tests/ зелёный | tests/ | PASS | + +Целевые файлы ORCH-043 (`test_merge_gate`, `test_qg_merge_gate`, `test_merge_gate_race`, +`test_config`, `test_qg_registry_snapshot`): 33 passed; merge-gate в `test_stage_engine`: 7 passed. + +## Соответствие критериям приёмки +AC-1..AC-15 — все покрыты прошедшими тестами (см. маппинг TC выше) и подтверждены +APPROVED-ревью (`12-review.md`). AC-15 (зелёный регресс) — подтверждён ниже. + +## Вывод pytest +``` +======================= 535 passed, 1 warning in 12.70s ======================== +``` +(единственное warning — PydanticDeprecatedSince20 в `src/config.py:4`, не относится к ORCH-043, нефатальное) + +## Итог +PASS — 535/535 тестов зелёные, smoke API OK, прод-контейнер не затронут. +Задача готова к стадии `deploy-staging`. diff --git a/docs/work-items/ORCH-043/15-staging-log.md b/docs/work-items/ORCH-043/15-staging-log.md new file mode 100644 index 0000000..f69edc9 --- /dev/null +++ b/docs/work-items/ORCH-043/15-staging-log.md @@ -0,0 +1,70 @@ +--- +staging_status: SUCCESS +timestamp: 2026-06-06T17:40:13Z +base_url: http://localhost:8501 +mode: stub +result: 10/10 checks PASS +--- + +# Staging Gate Log — ORCH-043 + +Staging test suite completed against the live `orchestrator-staging` instance +(port 8501). **All 10/10 checks passed**, suite exit code `0`. + +## Execution + +Canonical invocation — run INSIDE the `orchestrator-staging` container +(ORCH-048, ADR-001) so Block A's `ORCH_STAGING=true` guard and the B6 +registry-isolation check read the running instance's own process-env +(`.env.staging`): + +``` +docker exec orchestrator-staging \ + python3 /repos/orchestrator/scripts/staging_check.py \ + --base-url http://localhost:8501 --mode stub +``` + +> Note: the host worktree environment has no `docker` CLI, so the exec was +> driven directly through the Docker Engine API over `/var/run/docker.sock` +> (equivalent to the command above — same container, same in-container env). +> Block A `A3 ORCH_STAGING=true` and B6 both PASS, confirming the suite ran +> with the live staging registry (no host-path fallback / false FAIL). + +## Results + +``` +============================================================ + ORCH-33 Staging Check Suite + base_url : http://localhost:8501 + mode : stub + utc_time : 2026-06-06T17:40:13.623652+00:00 +============================================================ + +[Block A] SMOKE + ✓ PASS A1 GET /health → 200 status=ok + ✓ PASS A2 GET /queue → 200 with counts/max_concurrency/resilience + ✓ PASS A3 ORCH_STAGING=true (not prod) + +[Block B] ACCESS + ✓ PASS B4 Plane: sandbox project accessible [found 5 project(s), sandbox=YES] + ✓ PASS B5 Gitea: orchestrator-sandbox accessible, push=true + ✓ PASS B6 Registry: sandbox present, prod ET/ORCH absent [sandbox=YES, prod-ET=NO(good), prod-ORCH=NO(good)] + +[Block C] E2E (mode=stub) + ✓ PASS C7 Create issue in Plane SANDBOX + ✓ PASS C8 Trigger pipeline via /webhook/plane + ✓ PASS C9a Branch appears in orchestrator-sandbox + ✓ PASS C9b Analyst job enqueued in staging queue + +[CLEANUP] + ✓ PASS CLEANUP: deleted test branch, Plane issue, task + job rows + +============================================================ + RESULT: 10/10 checks PASS +============================================================ + +[docker-exec] ExitCode=0 +``` + +Cleanup ran fully in the `finally` block — no residual test task, branch, or +job rows left on the staging stand. diff --git a/src/config.py b/src/config.py index 08f6ef3..eceafc1 100644 --- a/src/config.py +++ b/src/config.py @@ -130,6 +130,28 @@ class Settings(BaseSettings): ci_poll_max_attempts: int = 12 ci_poll_interval_s: int = 10 + # ORCH-043: merge-gate (auto-rebase + re-test + merge-lock) on the + # deploy-staging -> deploy edge. A deterministic sub-gate (no LLM) that + # catches the up-to-date branch up to the CURRENT origin/main, re-tests it, + # and serialises merges so two green branches can't break main. + # merge_gate_enabled -> global kill-switch; False -> no-op pass for the + # whole gate (staged rollout, env ORCH_MERGE_GATE_ENABLED). + # merge_gate_repos -> CSV of repos where the gate is REAL; empty means + # only the self-hosting repo (orchestrator). Other + # repos -> conditional no-op (mirrors ORCH-35 staging). + # merge_retest_timeout_s -> wall-clock budget for the post-rebase re-test. + # merge_retest_target -> pytest target for the re-test (portability across repos). + # merge_lock_timeout_s -> max lease age; an older lease is reclaimed (crash backstop). + # merge_defer_delay_s -> delay before re-running the gate when the lock is busy. + # merge_defer_max_attempts -> defer retries before escalation (avoids livelock). + merge_gate_enabled: bool = True + merge_gate_repos: str = "" + merge_retest_timeout_s: int = 600 + merge_retest_target: str = "tests/" + merge_lock_timeout_s: int = 300 + merge_defer_delay_s: int = 60 + merge_defer_max_attempts: int = 5 + # Telegram notifications telegram_bot_token: str = "" telegram_chat_id: str = "" diff --git a/src/db.py b/src/db.py index e9c2260..385675b 100644 --- a/src/db.py +++ b/src/db.py @@ -324,19 +324,34 @@ def enqueue_job( task_content: str | None = None, task_id: int | None = None, max_attempts: int = 2, + available_at_delay_s: int | None = None, ) -> int: """Enqueue a new job (status='queued'). Returns the new job id. This is what webhook handlers call instead of launching an agent in-process: it is a fast DB INSERT that returns immediately. The background worker (queue_worker) picks the job up later. + + ORCH-043 (merge-gate defer): when ``available_at_delay_s`` is given the job's + ``available_at`` is set to ``now + delay`` so claim_next_job won't pick it up + until the delay elapses (re-uses the existing ORCH-1 backoff gate). Used to + re-queue the staging-deployer after a "merge-lock busy" defer without burning a + worker slot in a blocking wait. """ conn = get_db() - cursor = conn.execute( - "INSERT INTO jobs (agent, repo, task_id, task_content, max_attempts) " - "VALUES (?, ?, ?, ?, ?)", - (agent, repo, task_id, task_content, max_attempts), - ) + if available_at_delay_s is not None: + cursor = conn.execute( + "INSERT INTO jobs (agent, repo, task_id, task_content, max_attempts, available_at) " + "VALUES (?, ?, ?, ?, ?, datetime('now', ?))", + (agent, repo, task_id, task_content, max_attempts, + f"+{int(available_at_delay_s)} seconds"), + ) + else: + cursor = conn.execute( + "INSERT INTO jobs (agent, repo, task_id, task_content, max_attempts) " + "VALUES (?, ?, ?, ?, ?)", + (agent, repo, task_id, task_content, max_attempts), + ) job_id = cursor.lastrowid conn.commit() conn.close() diff --git a/src/merge_gate.py b/src/merge_gate.py new file mode 100644 index 0000000..dc7a0e6 --- /dev/null +++ b/src/merge_gate.py @@ -0,0 +1,340 @@ +"""Merge-gate core (ORCH-043): catch a branch up to the CURRENT origin/main, +re-test it, and serialise merges with a file lease. + +Background +---------- +The pipeline validates a branch against the ``main`` it was BRANCHED from, not the +``main`` at the moment of merge. Between "branch validated" and "branch merged" a +parallel task may have advanced ``main`` -> a *semantic* merge conflict: git merges +with no textual conflict, yet the combined ``main`` is broken. For the self-hosting +``orchestrator`` repo that means a red ``main`` of the tool serving every project. + +This module provides the deterministic (no-LLM) primitives the quality-gate +``check_branch_mergeable`` (src/qg/checks.py) composes on the +``deploy-staging -> deploy`` edge, BEFORE the deployer merges the PR: + + * ``branch_is_behind_main`` -> is the branch missing the latest origin/main? + * ``auto_rebase_onto_main`` -> rebase onto origin/main + push --force-with-lease + (ONLY the task branch; NEVER main). + * ``retest_branch`` -> run the project test-suite in the caught-up worktree. + * file lease (``acquire_merge_lease`` / ``release_merge_lease``) -> serialise the + "catch-up + re-test + merge" of ONE repo, held from the gate to the actual merge. + +Invariants (self-hosting safety, ТЗ §10): + * NEVER push or force-push ``main`` — the only force op is ``--force-with-lease`` + on the task branch. + * All git ops run in the per-branch worktree (ensure_worktree), never the shared clone. + * Every public function honours a strict **never-raise** contract: any git/OS error + -> ``(False, "")`` (or a safe bool), never a propagated exception. +""" + +import json +import logging +import os +import subprocess +import time + +from .config import settings +from .git_worktree import ensure_worktree, get_worktree_path + +logger = logging.getLogger("orchestrator.merge_gate") + +# git sub-command timeouts (seconds). Generous but bounded so a hung git never +# wedges the monitor-thread that runs the gate. +_FETCH_TIMEOUT = 60 +_REBASE_TIMEOUT = 120 +_PUSH_TIMEOUT = 60 +_SHORT_TIMEOUT = 30 + + +# --------------------------------------------------------------------------- +# behind / ancestor detection +# --------------------------------------------------------------------------- +def branch_is_behind_main(repo: str, branch: str) -> bool: + """Return True iff ``branch`` does NOT already contain the latest origin/main. + + A branch is "behind" when ``origin/main`` is **not** an ancestor of the branch + HEAD (``git merge-base --is-ancestor origin/main HEAD`` returns non-zero). All + work happens in the per-branch worktree (ORCH-2 / S-4 isolation). + + Never-raise (AC-9 / TC-03): any git/OS failure or an ambiguous result is treated + as "cannot prove the branch is up-to-date" -> return True (force a rebase attempt + rather than merge blindly). It returns a bool, never raises. + """ + try: + wt = ensure_worktree(repo, branch) + except Exception as e: # noqa: BLE001 - never-raise contract + logger.warning("branch_is_behind_main: worktree error for %s/%s: %s", repo, branch, e) + return True + + try: + subprocess.run( + ["git", "-C", wt, "fetch", "origin", "main"], + capture_output=True, timeout=_FETCH_TIMEOUT, + ) + r = subprocess.run( + ["git", "-C", wt, "merge-base", "--is-ancestor", "origin/main", "HEAD"], + capture_output=True, timeout=_SHORT_TIMEOUT, + ) + except (subprocess.SubprocessError, OSError) as e: + logger.warning("branch_is_behind_main: git error for %s/%s: %s", repo, branch, e) + return True + + if r.returncode == 0: + # origin/main IS an ancestor of HEAD -> branch already up-to-date. + return False + if r.returncode == 1: + # origin/main is NOT an ancestor -> branch is behind. + return True + # Any other code (e.g. bad ref) -> ambiguous; do not merge blindly. + logger.warning( + "branch_is_behind_main: ambiguous merge-base rc=%s for %s/%s (treating as behind)", + r.returncode, repo, branch, + ) + return True + + +def _conflicted_files(wt: str) -> str: + """Best-effort list of unmerged (conflicting) files in the worktree.""" + try: + r = subprocess.run( + ["git", "-C", wt, "diff", "--name-only", "--diff-filter=U"], + capture_output=True, text=True, timeout=_SHORT_TIMEOUT, + ) + files = r.stdout.strip().replace("\n", ", ") + return files or "unknown" + except (subprocess.SubprocessError, OSError): + return "unknown" + + +# --------------------------------------------------------------------------- +# auto-rebase onto origin/main +# --------------------------------------------------------------------------- +def auto_rebase_onto_main(repo: str, branch: str) -> tuple[bool, str]: + """Catch ``branch`` up to ``origin/main`` via rebase, then push it. + + Steps (all in the per-branch worktree): + 1. ``git fetch origin main``. + 2. ``git rebase origin/main``: + - textual conflict (non-zero) -> ``git rebase --abort`` (leave worktree + clean) -> ``(False, "rebase conflict: ")`` (AC-3). + 3. clean rebase -> ``git push --force-with-lease origin `` — ONLY the + task branch, NEVER ``main`` (AC-7) -> ``(True, "rebased onto origin/main")``. + + Never-raise (AC-9): any git/OS error -> ``(False, "")``. + """ + try: + wt = ensure_worktree(repo, branch) + except Exception as e: # noqa: BLE001 - never-raise contract + return False, f"rebase setup error: {e}" + + try: + subprocess.run( + ["git", "-C", wt, "fetch", "origin", "main"], + capture_output=True, timeout=_FETCH_TIMEOUT, + ) + r = subprocess.run( + ["git", "-C", wt, "rebase", "origin/main"], + capture_output=True, text=True, timeout=_REBASE_TIMEOUT, + ) + if r.returncode != 0: + files = _conflicted_files(wt) + subprocess.run( + ["git", "-C", wt, "rebase", "--abort"], + capture_output=True, timeout=_SHORT_TIMEOUT, + ) + logger.warning("auto_rebase: conflict on %s/%s: %s", repo, branch, files) + return False, f"rebase conflict: {files}" + + # Clean rebase -> push ONLY the task branch with a lease (never main). + p = subprocess.run( + ["git", "-C", wt, "push", "--force-with-lease", "origin", branch], + capture_output=True, text=True, timeout=_PUSH_TIMEOUT, + ) + if p.returncode != 0: + detail = (p.stderr or p.stdout or "").strip()[:200] + logger.warning("auto_rebase: push failed on %s/%s: %s", repo, branch, detail) + return False, f"push --force-with-lease failed: {detail}" + + logger.info("auto_rebase: %s/%s rebased onto origin/main and pushed", repo, branch) + return True, "rebased onto origin/main" + except subprocess.TimeoutExpired: + # Leave no half-finished rebase behind. + try: + subprocess.run( + ["git", "-C", wt, "rebase", "--abort"], + capture_output=True, timeout=_SHORT_TIMEOUT, + ) + except (subprocess.SubprocessError, OSError): + pass + return False, "rebase timeout" + except (subprocess.SubprocessError, OSError) as e: + return False, f"rebase error: {e}" + + +# --------------------------------------------------------------------------- +# re-test in the caught-up worktree +# --------------------------------------------------------------------------- +def retest_branch(repo: str, branch: str) -> tuple[bool, str]: + """Run the project test-suite in the (already caught-up) branch worktree. + + Command: ``python -m pytest `` (default ``tests/``), + matching the orchestrator CI / check_tests_local pattern. Bounded by + ``settings.merge_retest_timeout_s``. + + Returns: + * ``(True, "re-test green")`` — pytest rc == 0 + * ``(False, "re-test timeout after s")`` — exceeded the timeout (AC-6) + * ``(False, "re-test failed: ...")`` — non-zero rc, with output tail + Never-raise (AC-9): any setup/OS error -> ``(False, "")``. + """ + wt = get_worktree_path(repo, branch) + if not os.path.isdir(wt): + # Caller usually rebased first (worktree exists); ensure as a fallback. + try: + wt = ensure_worktree(repo, branch) + except Exception as e: # noqa: BLE001 - never-raise contract + return False, f"re-test setup error: {e}" + + target = settings.merge_retest_target or "tests/" + timeout = settings.merge_retest_timeout_s + try: + r = subprocess.run( + ["python", "-m", "pytest", target, "-q"], + cwd=wt, capture_output=True, text=True, timeout=timeout, + ) + except subprocess.TimeoutExpired: + logger.warning("retest_branch: timeout (%ss) on %s/%s", timeout, repo, branch) + return False, f"re-test timeout after {timeout}s" + except (subprocess.SubprocessError, OSError) as e: + return False, f"re-test error: {e}" + + if r.returncode == 0: + return True, "re-test green" + tail = ((r.stdout or "") + (r.stderr or ""))[-500:] + logger.warning("retest_branch: red on %s/%s", repo, branch) + return False, f"re-test failed: ...{tail}" + + +# --------------------------------------------------------------------------- +# merge-lease (serialise catch-up + re-test + merge per repo) +# --------------------------------------------------------------------------- +def _lease_path(repo: str) -> str: + """Filesystem path of the per-repo merge lease (no schema change, ТЗ §4).""" + return os.path.join(settings.repos_dir, f".merge-lease-{repo}.json") + + +def _read_lease(path: str) -> dict | None: + """Read+parse the lease file; None if missing or corrupt (never-raise).""" + try: + with open(path, "r", encoding="utf-8") as f: + return json.loads(f.read()) + except FileNotFoundError: + return None + except (OSError, ValueError) as e: + logger.warning("merge-lease read error at %s: %s", path, e) + return None + + +def _write_lease(path: str, holder: dict) -> None: + """Atomically (O_CREAT|O_EXCL) write the lease; raises FileExistsError if held.""" + fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) + try: + os.write(fd, json.dumps(holder).encode("utf-8")) + finally: + os.close(fd) + + +def acquire_merge_lease( + repo: str, branch: str, work_item_id: str | None = None, task_id: int | None = None +) -> tuple[bool, str]: + """Try to acquire the per-repo merge lease. **Non-blocking** (anti-deadlock). + + Holder identity is the task ``branch`` (stable, one branch per task). Outcomes: + * no lease file -> acquire, write metadata -> ``(True, "lease acquired")`` + * lease held by self -> idempotent re-acquire (restart/retry) -> ``(True, "lease already held")`` + * lease held by other, age < merge_lock_timeout_s -> ``(False, "merge-lock busy")`` + * lease held by other, age >= merge_lock_timeout_s -> stale -> reclaim with a + ``logger.warning`` (the holder process died without releasing) -> ``(True, ...)`` + + Never-raise: any unexpected error -> ``(False, "merge-lock busy")`` so the caller + DEFERS and retries rather than burning a developer retry on an infra hiccup. + """ + path = _lease_path(repo) + holder = { + "branch": branch, + "work_item_id": work_item_id, + "task_id": task_id, + "acquired_at": time.time(), + "pid": os.getpid(), + } + try: + try: + _write_lease(path, holder) + logger.info("merge-lease acquired for %s by %s", repo, branch) + return True, "lease acquired" + except FileExistsError: + pass + + existing = _read_lease(path) + if existing is None: + # Corrupt/empty lease file — reclaim it. + _force_write_lease(path, holder) + logger.warning("merge-lease for %s was corrupt; reclaimed by %s", repo, branch) + return True, "lease reclaimed (corrupt)" + + if existing.get("branch") == branch: + return True, "lease already held" + + age = time.time() - float(existing.get("acquired_at") or 0) + if age >= settings.merge_lock_timeout_s: + _force_write_lease(path, holder) + logger.warning( + "merge-lease for %s was stale (age %.0fs >= %ss, holder=%s); reclaimed by %s", + repo, age, settings.merge_lock_timeout_s, existing.get("branch"), branch, + ) + return True, "lease reclaimed (stale)" + + logger.info( + "merge-lease for %s busy (held by %s, age %.0fs); %s defers", + repo, existing.get("branch"), age, branch, + ) + return False, "merge-lock busy" + except Exception as e: # noqa: BLE001 - never-raise contract + logger.warning("acquire_merge_lease unexpected error for %s/%s: %s", repo, branch, e) + return False, "merge-lock busy" + + +def _force_write_lease(path: str, holder: dict) -> None: + """Overwrite the lease (used for stale/corrupt reclaim). Best-effort.""" + try: + with open(path, "w", encoding="utf-8") as f: + f.write(json.dumps(holder)) + except OSError as e: + logger.warning("merge-lease force-write error at %s: %s", path, e) + + +def release_merge_lease(repo: str, branch: str | None = None) -> None: + """Release the per-repo merge lease. **Idempotent** and **holder-aware**. + + If ``branch`` is given, the lease is removed ONLY when the current holder's + branch matches (so a delayed release from an already-merged task can never + delete a lease a DIFFERENT task acquired afterwards). With ``branch=None`` the + release is unconditional (best-effort backstop). Never raises. + """ + path = _lease_path(repo) + try: + if branch is not None: + existing = _read_lease(path) + if existing is not None and existing.get("branch") != branch: + logger.info( + "merge-lease release skipped for %s: holder=%s != %s", + repo, existing.get("branch"), branch, + ) + return + os.remove(path) + logger.info("merge-lease released for %s (%s)", repo, branch or "force") + except FileNotFoundError: + return + except OSError as e: + logger.warning("merge-lease release error for %s: %s", repo, e) diff --git a/src/qg/checks.py b/src/qg/checks.py index 3d5e789..78f5c81 100644 --- a/src/qg/checks.py +++ b/src/qg/checks.py @@ -621,6 +621,87 @@ def check_staging_status(repo: str, work_item_id: str, branch: str | None = None return False, "Staging log not found (15-staging-log.md)" +def _merge_gate_applies(repo: str) -> bool: + """Whether the merge-gate is REAL for this repo (ORCH-043, conditional rollout). + + Mirrors the ORCH-35 conditional staging-gate. ``merge_gate_repos`` is a CSV of + repos where the gate is enforced; when empty the gate is real ONLY for the + self-hosting repo (``orchestrator``). Other repos -> conditional no-op. + """ + raw = (settings.merge_gate_repos or "").strip() + if raw: + allowed = {r.strip().lower() for r in raw.split(",") if r.strip()} + return (repo or "").strip().lower() in allowed + return is_self_hosting_repo(repo) + + +def check_branch_mergeable(repo: str, work_item_id: str, branch: str) -> tuple[bool, str]: + """ORCH-043 merge-gate: validate the branch against the CURRENT origin/main + immediately before the deployer merges its PR (deploy-staging -> deploy edge). + + Deterministic, no LLM. Algorithm (ADR-001 §4): + 1. Conditionality: merge_gate_enabled=False -> (True, "merge-gate disabled"); + repo where the gate is not real -> (True, "merge-gate N/A for "). + 2. Acquire the per-repo merge lease (NON-blocking). Busy -> (False, "merge-lock + busy") — a SIGNAL for the engine to DEFER (not a code fault, no rollback). + 3. Double-check "behind origin/main" UNDER the lease (main may have moved while + we waited). Not behind -> (True, "branch up-to-date with main"); lease HELD. + 4. Behind -> auto_rebase_onto_main: + - conflict -> release lease -> (False, "rebase conflict: ...") + - clean -> retest_branch: + green -> (True, "rebased onto main, re-test green"); lease HELD + red/timeout -> release lease -> (False, "re-test ... after rebase") + 5. On SUCCESS the lease is HELD until the actual merge (released on PR-merged + webhook / deploy->done / rollback). On any FAILURE the lease is released. + + Never-raise (AC-9): any internal error -> (False, "") with the lease + released; an exception never escapes into advance_stage. + """ + # Imported lazily so qg.checks stays importable without the merge_gate deps in + # minimal/test contexts and to avoid an import cycle surprise. + from .. import merge_gate + + try: + if not settings.merge_gate_enabled: + return True, "merge-gate disabled" + if not _merge_gate_applies(repo): + return True, f"merge-gate N/A for {repo}" + + acquired, reason = merge_gate.acquire_merge_lease(repo, branch, work_item_id) + if not acquired: + # "merge-lock busy" -> caller defers; lease NOT held by us, nothing to release. + return False, reason + + try: + # Double-check under the lease: another task may have just merged. + if not merge_gate.branch_is_behind_main(repo, branch): + logger.info("check_branch_mergeable: %s up-to-date with main", branch) + return True, "branch up-to-date with main" + + ok, rb_reason = merge_gate.auto_rebase_onto_main(repo, branch) + if not ok: + merge_gate.release_merge_lease(repo, branch) + return False, rb_reason # "rebase conflict: ..." + + ok_t, t_reason = merge_gate.retest_branch(repo, branch) + if ok_t: + logger.info("check_branch_mergeable: %s rebased + re-test green", branch) + return True, "rebased onto main, re-test green" + + merge_gate.release_merge_lease(repo, branch) + if "timeout" in t_reason: + return False, t_reason # "re-test timeout after s" (AC-6) + tail = t_reason.removeprefix("re-test failed: ") + return False, f"re-test failed after rebase: {tail}" + except Exception as e: # noqa: BLE001 - never-raise; always release on error + merge_gate.release_merge_lease(repo, branch) + logger.error("check_branch_mergeable inner error for %s/%s: %s", repo, branch, e) + return False, f"merge-gate error: {e}" + except Exception as e: # noqa: BLE001 - outer never-raise guard + logger.error("check_branch_mergeable error for %s/%s: %s", repo, branch, e) + return False, f"merge-gate error: {e}" + + # Registry for dynamic lookup by name QG_CHECKS = { "check_analysis_approved": check_analysis_approved, @@ -633,4 +714,5 @@ QG_CHECKS = { "check_tests_local": check_tests_local, "check_deploy_status": check_deploy_status, "check_staging_status": check_staging_status, + "check_branch_mergeable": check_branch_mergeable, } diff --git a/src/stage_engine.py b/src/stage_engine.py index 008790b..63a1026 100644 --- a/src/stage_engine.py +++ b/src/stage_engine.py @@ -34,6 +34,7 @@ from .stages import get_next_stage, get_qg_for_stage, get_agent_for_stage from .git_worktree import get_worktree_path from .review_parse import extract_review_findings, extract_test_failures from .qg.checks import QG_CHECKS +from . import merge_gate from .notifications import ( notify_stage_change, notify_qg_failure, @@ -239,6 +240,18 @@ def advance_stage( result.note = f"qg '{qg_name}' not in registry" return result + # --- ORCH-043 merge-gate sub-gate (deploy-staging -> deploy edge) ----- + # AFTER check_staging_status passed and BEFORE we advance to `deploy` / + # launch the deployer that merges the PR. Not a STAGE_TRANSITIONS entry — + # it is an edge sub-gate triggered by the same "staging-deployer finished" + # event. If it intervenes (defer on busy-lock, or rollback on conflict / + # red re-test) it owns the outcome and we return without advancing. + if current_stage == "deploy-staging": + if _handle_merge_gate( + task_id, current_stage, repo, work_item_id, branch, agent, result + ): + return result + # --- Advance --------------------------------------------------------- update_task_stage(task_id, next_stage) # Telegram live tracker: the analysis->architecture advance is the human @@ -274,6 +287,15 @@ def advance_stage( except Exception as e: logger.error(f"Task {task_id}: failed to set Plane Done: {e}") + # ORCH-043: the merge has landed (deploy->done). Release the merge lease as + # a backstop in case the PR-merged webhook was lost (holder-aware no-op if a + # different task already owns it). Never raises. + if next_stage == "done": + try: + merge_gate.release_merge_lease(repo, branch) + except Exception as e: # noqa: BLE001 - defensive + logger.warning(f"Task {task_id}: merge-lease release on done failed: {e}") + # --- Launch the next agent (ORCH-4 fix: current_stage, not next) ----- next_agent = get_agent_for_stage(current_stage) if next_agent: @@ -565,6 +587,12 @@ def _handle_qg_failure_rollbacks( notify_stage_change(task_id, current_stage, "development") plane_notify_stage(work_item_id, current_stage, "development") result.rolled_back_to = "development" + # ORCH-043: deploy failed -> no merge will complete; release the lease so the + # next task isn't blocked until the lease ages out (holder-aware no-op). + try: + merge_gate.release_merge_lease(repo, branch) + except Exception as e: # noqa: BLE001 - defensive + logger.warning(f"Task {task_id}: merge-lease release on deploy-fail failed: {e}") set_issue_blocked(work_item_id) notify_qg_failure(task_id, "deploy", "check_deploy_status", reason) plane_add_comment( @@ -582,3 +610,155 @@ def _handle_qg_failure_rollbacks( f"Task {task_id}: deployer verdict FAILED, rolled back deploy -> " f"development ({reason})" ) + + +# --------------------------------------------------------------------------- +# ORCH-043: merge-gate sub-gate on the deploy-staging -> deploy edge +# --------------------------------------------------------------------------- +def _merge_defer_count(task_id: int) -> int: + """How many times this task has already been deferred by the merge-gate. + + Counted from the persisted jobs queue (restart-safe) by the defer marker in + task_content, so a service restart never resets the defer budget. + """ + conn = get_db() + n = conn.execute( + "SELECT COUNT(*) FROM jobs WHERE task_id=? AND task_content LIKE '%merge-gate defer%'", + (task_id,), + ).fetchone()[0] + conn.close() + return n + + +def _handle_merge_gate( + task_id, current_stage, repo, work_item_id, branch, agent, result: AdvanceResult +) -> bool: + """Run check_branch_mergeable on the deploy-staging -> deploy edge. + + Returns True if the gate INTERVENED (the caller must return without advancing): + * "merge-lock busy" -> DEFER (re-queue the staging-deployer with a + delay; the task stays on deploy-staging). Code + is fine, so NO rollback and no developer retry. + * conflict / red re-test -> ROLLBACK to development (+ developer retry, + capped by MAX_DEVELOPER_RETRIES). + Returns False when the gate PASSED (branch up-to-date, or rebased + re-test green) + so advance_stage proceeds to `deploy` and launches the deployer that merges. On a + PASS the merge lease is HELD until the actual merge (released on PR-merged webhook + / deploy->done / rollback). + """ + passed, reason = _run_qg("check_branch_mergeable", repo, work_item_id, branch) + if passed: + logger.info(f"Task {task_id}: merge-gate passed ({reason})") + return False + + result.qg_name = "check_branch_mergeable" + result.qg_passed = False + result.qg_reason = reason + + if reason == "merge-lock busy": + _handle_merge_gate_defer( + task_id, current_stage, repo, work_item_id, branch, result + ) + return True + + _handle_merge_gate_rollback( + task_id, current_stage, repo, work_item_id, branch, reason, result + ) + return True + + +def _handle_merge_gate_defer( + task_id, current_stage, repo, work_item_id, branch, result: AdvanceResult +): + """merge-lock busy -> DEFER: re-queue the staging-deployer after a delay. + + Non-blocking: the worker slot is freed (anti-deadlock at max_concurrency=1) so + the lease HOLDER can finish merging. The task remains on deploy-staging; a later + staging-deployer run re-evaluates the gate. Bounded by merge_defer_max_attempts. + """ + defers = _merge_defer_count(task_id) + if defers < settings.merge_defer_max_attempts: + task_desc = ( + f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n" + f"Stage: deploy-staging\nNote: merge-gate defer " + f"(attempt {defers + 1}/{settings.merge_defer_max_attempts}) — " + f"merge-lock busy, retrying after {settings.merge_defer_delay_s}s." + ) + new_job = enqueue_job( + "deployer", repo, task_desc, task_id=task_id, + available_at_delay_s=settings.merge_defer_delay_s, + ) + result.enqueued_agent = "deployer" + result.enqueued_job_id = new_job + result.note = "merge-gate-deferred" + logger.info( + f"Task {task_id}: merge-lock busy, deferred deployer " + f"(job_id={new_job}, attempt {defers + 1}/{settings.merge_defer_max_attempts})" + ) + else: + set_issue_blocked(work_item_id) + send_telegram( + f"\U0001f6a8 {work_item_id}: merge-gate defer limit " + f"({settings.merge_defer_max_attempts}) reached (merge-lock busy). " + f"Manual intervention needed." + ) + result.alerted = True + result.note = "merge-gate-defer-exhausted" + logger.error( + f"Task {task_id}: merge-gate defer attempts exhausted " + f"({settings.merge_defer_max_attempts})" + ) + + +def _handle_merge_gate_rollback( + task_id, current_stage, repo, work_item_id, branch, reason, result: AdvanceResult +): + """Rebase conflict / red re-test -> ROLLBACK to development + developer retry. + + Mirrors the staging/deploy rollback pattern but is capped by + MAX_DEVELOPER_RETRIES (AC-11 / TC-22: no infinite bounce). The merge lease was + already released by check_branch_mergeable on failure; a defensive holder-aware + release here is a harmless no-op. + """ + update_task_stage(task_id, "development") + notify_stage_change(task_id, current_stage, "development") + plane_notify_stage(work_item_id, current_stage, "development") + result.rolled_back_to = "development" + set_issue_in_progress(work_item_id) + try: + merge_gate.release_merge_lease(repo, branch) + except Exception as e: # noqa: BLE001 - defensive + logger.warning(f"Task {task_id}: merge-lease release on rollback failed: {e}") + notify_qg_failure(task_id, current_stage, "check_branch_mergeable", reason) + plane_add_comment( + work_item_id, + f"❌ Merge-gate FAILED ({reason}). Rolled back to development. " + f"Developer нужен для фикса.", + author="deployer", + ) + retry_count = _developer_retry_count(task_id) + if retry_count < MAX_DEVELOPER_RETRIES: + task_desc = ( + f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n" + f"Stage: development\nNote: Merge-gate failed " + f"(attempt {retry_count + 1}/{MAX_DEVELOPER_RETRIES}). " + f"Причина: {reason}." + ) + new_job = enqueue_job("developer", repo, task_desc, task_id=task_id) + result.enqueued_agent = "developer" + result.enqueued_job_id = new_job + logger.info( + f"Task {task_id}: merge-gate FAILED, enqueued developer (job_id={new_job})" + ) + else: + set_issue_blocked(work_item_id) + send_telegram( + f"\U0001f6a8 {work_item_id}: Merge-gate still failing after " + f"{MAX_DEVELOPER_RETRIES} developer retries ({reason}). " + f"Manual intervention needed." + ) + result.alerted = True + logger.error( + f"Task {task_id}: merge-gate FAILED, rolled back deploy-staging -> " + f"development ({reason})" + ) diff --git a/src/webhooks/gitea.py b/src/webhooks/gitea.py index a4d11a0..8d2c51e 100644 --- a/src/webhooks/gitea.py +++ b/src/webhooks/gitea.py @@ -334,6 +334,15 @@ async def handle_pr(payload: dict): logger.error(f"Task {task_id}: max retries reached, needs manual intervention") elif action == "closed" and pr.get("merged", False): + # ORCH-043: the branch's PR just merged into main -> release the per-repo + # merge lease this task held from the merge-gate (holder-aware by branch, so + # it can't clobber a lease another task acquired afterwards). Never raises. + try: + from ..merge_gate import release_merge_lease + release_merge_lease(repo_name, head_branch) + except Exception as e: # noqa: BLE001 - defensive, never block the webhook + logger.warning(f"Task {task_id}: merge-lease release on PR-merge failed: {e}") + # BUG 8 (second door): at the deploy stage `done` is gated by the # deployer's verdict (check_deploy_status via advance_stage), NOT by the # fact that the PR was merged. The deployer merges the PR at the START of diff --git a/tests/test_config.py b/tests/test_config.py index e18c19e..abd2a9d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -25,3 +25,50 @@ def test_tracker_mode_reads_env_arbitrary(monkeypatch): # -> edit) happens in notifications, not here (AC-1/AC-2 split). monkeypatch.setenv("ORCH_TRACKER_MODE", "garbage") assert Settings().tracker_mode == "garbage" + + +# --------------------------------------------------------------------------- +# ORCH-043 / TC-25: merge-gate settings defaults + env override. +# --------------------------------------------------------------------------- +_MERGE_ENV = ( + "ORCH_MERGE_GATE_ENABLED", + "ORCH_MERGE_GATE_REPOS", + "ORCH_MERGE_RETEST_TIMEOUT_S", + "ORCH_MERGE_RETEST_TARGET", + "ORCH_MERGE_LOCK_TIMEOUT_S", + "ORCH_MERGE_DEFER_DELAY_S", + "ORCH_MERGE_DEFER_MAX_ATTEMPTS", +) + + +def test_merge_gate_settings_defaults(monkeypatch): + """TC-25 / AC-10: documented defaults when no env is set.""" + for name in _MERGE_ENV: + monkeypatch.delenv(name, raising=False) + s = Settings() + assert s.merge_gate_enabled is True + assert s.merge_gate_repos == "" + assert s.merge_retest_timeout_s == 600 + assert s.merge_retest_target == "tests/" + assert s.merge_lock_timeout_s == 300 + assert s.merge_defer_delay_s == 60 + assert s.merge_defer_max_attempts == 5 + + +def test_merge_gate_settings_env_override(monkeypatch): + """TC-25 / AC-10: each field is read from its ORCH_* env var.""" + monkeypatch.setenv("ORCH_MERGE_GATE_ENABLED", "false") + monkeypatch.setenv("ORCH_MERGE_GATE_REPOS", "orchestrator,enduro-trails") + monkeypatch.setenv("ORCH_MERGE_RETEST_TIMEOUT_S", "120") + monkeypatch.setenv("ORCH_MERGE_RETEST_TARGET", "tests/unit") + monkeypatch.setenv("ORCH_MERGE_LOCK_TIMEOUT_S", "90") + monkeypatch.setenv("ORCH_MERGE_DEFER_DELAY_S", "5") + monkeypatch.setenv("ORCH_MERGE_DEFER_MAX_ATTEMPTS", "9") + s = Settings() + assert s.merge_gate_enabled is False + assert s.merge_gate_repos == "orchestrator,enduro-trails" + assert s.merge_retest_timeout_s == 120 + assert s.merge_retest_target == "tests/unit" + assert s.merge_lock_timeout_s == 90 + assert s.merge_defer_delay_s == 5 + assert s.merge_defer_max_attempts == 9 diff --git a/tests/test_merge_gate.py b/tests/test_merge_gate.py new file mode 100644 index 0000000..4168e27 --- /dev/null +++ b/tests/test_merge_gate.py @@ -0,0 +1,301 @@ +"""ORCH-043: tests for src/merge_gate core (TC-01..TC-11). + +Git tests use REAL local repos in tmp (a bare 'origin' + a main clone), so +fetch / merge-base / rebase / push --force-with-lease are exercised without +network, mirroring tests/test_git_worktree.py. The re-test (pytest) and lease +units are isolated with monkeypatch / tmp files. +""" +import json +import os +import subprocess +import tempfile +import time + +import pytest + +# Env before importing app modules (same convention as the other suites). +_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_merge_gate.db") +os.environ["ORCH_DB_PATH"] = _test_db +os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir() +os.environ["ORCH_GITEA_TOKEN"] = "test-token" +os.environ["ORCH_PLANE_API_TOKEN"] = "test-token" + +from src import git_worktree, merge_gate # noqa: E402 + + +def _git(cwd, *args): + return subprocess.run(["git", "-C", cwd, *args], capture_output=True, text=True) + + +def _origin_sha(origin, ref): + return _git(str(origin), "rev-parse", ref).stdout.strip() + + +@pytest.fixture +def repos(tmp_path, monkeypatch): + """Bare 'origin' (main@C1) + main clone + two feature branches branched from C0. + + Layout: + C0 README.md + feature/behind : C0 + adds f.txt (rebases cleanly onto C1) + feature/conflict : C0 + edits README.md (textual conflict with C1) + feature/uptodate : branched from C1 (already contains origin/main) + main C1 : edits README.md + adds other.txt + Returns (repo_name, origin_path). + """ + repo = "orchestrator" + repos_dir = tmp_path / "repos" + wt_dir = tmp_path / "repos" / "_wt" + repos_dir.mkdir(parents=True) + + monkeypatch.setattr(merge_gate.settings, "repos_dir", str(repos_dir)) + monkeypatch.setattr(git_worktree.settings, "repos_dir", str(repos_dir)) + monkeypatch.setattr(git_worktree.settings, "worktrees_dir", str(wt_dir)) + + origin = tmp_path / "origin.git" + subprocess.run(["git", "init", "--bare", "-b", "main", str(origin)], capture_output=True) + + seed = tmp_path / "seed" + seed.mkdir() + _git(str(seed), "init", "-b", "main") + _git(str(seed), "config", "user.email", "t@t") + _git(str(seed), "config", "user.name", "t") + (seed / "README.md").write_text("base\n") + _git(str(seed), "add", ".") + _git(str(seed), "commit", "-m", "C0") + _git(str(seed), "remote", "add", "origin", str(origin)) + _git(str(seed), "push", "origin", "main") + + # Branches off C0. + _git(str(seed), "checkout", "-b", "feature/behind") + (seed / "f.txt").write_text("feature\n") + _git(str(seed), "add", ".") + _git(str(seed), "commit", "-m", "feat: add f.txt") + _git(str(seed), "push", "origin", "feature/behind") + + _git(str(seed), "checkout", "main") + _git(str(seed), "checkout", "-b", "feature/conflict") + (seed / "README.md").write_text("feature readme\n") + _git(str(seed), "add", ".") + _git(str(seed), "commit", "-m", "feat: edit README") + _git(str(seed), "push", "origin", "feature/conflict") + + # Advance main to C1. + _git(str(seed), "checkout", "main") + (seed / "README.md").write_text("main v2\n") + (seed / "other.txt").write_text("main change\n") + _git(str(seed), "add", ".") + _git(str(seed), "commit", "-m", "C1") + _git(str(seed), "push", "origin", "main") + + # Branch that already contains C1. + _git(str(seed), "checkout", "-b", "feature/uptodate") + (seed / "g.txt").write_text("uptodate\n") + _git(str(seed), "add", ".") + _git(str(seed), "commit", "-m", "feat: on top of C1") + _git(str(seed), "push", "origin", "feature/uptodate") + + # Main clone at repos_dir/. + main_clone = repos_dir / repo + subprocess.run(["git", "clone", str(origin), str(main_clone)], capture_output=True) + _git(str(main_clone), "config", "user.email", "t@t") + _git(str(main_clone), "config", "user.name", "t") + return repo, origin + + +# --------------------------------------------------------------------------- +# TC-01..03: branch_is_behind_main +# --------------------------------------------------------------------------- +def test_tc01_behind_when_main_ahead(repos): + repo, _ = repos + assert merge_gate.branch_is_behind_main(repo, "feature/behind") is True + + +def test_tc02_not_behind_when_branch_contains_main(repos): + repo, _ = repos + assert merge_gate.branch_is_behind_main(repo, "feature/uptodate") is False + + +def test_tc03_never_raises_on_bad_repo(monkeypatch, tmp_path): + # Point repos_dir at an empty dir -> ensure_worktree raises -> caught -> True. + monkeypatch.setattr(merge_gate.settings, "repos_dir", str(tmp_path / "nope")) + monkeypatch.setattr(git_worktree.settings, "repos_dir", str(tmp_path / "nope")) + monkeypatch.setattr(git_worktree.settings, "worktrees_dir", str(tmp_path / "_wt")) + result = merge_gate.branch_is_behind_main("orchestrator", "feature/x") + assert result is True # safe bool, not an exception + + +# --------------------------------------------------------------------------- +# TC-04..06: auto_rebase_onto_main +# --------------------------------------------------------------------------- +def test_tc04_clean_catchup_pushes_with_lease(repos): + repo, origin = repos + main_before = _origin_sha(origin, "main") + + ok, reason = merge_gate.auto_rebase_onto_main(repo, "feature/behind") + assert ok is True, reason + + # origin/main must be UNTOUCHED (AC-7). + assert _origin_sha(origin, "main") == main_before + # The pushed branch now contains origin/main (origin/main is its ancestor). + rc = subprocess.run( + ["git", "-C", str(origin), "merge-base", "--is-ancestor", + "main", "feature/behind"], + capture_output=True, + ).returncode + assert rc == 0 + # And it carries main's new file plus its own. + assert _git(str(origin), "cat-file", "-e", "feature/behind:other.txt").returncode == 0 + assert _git(str(origin), "cat-file", "-e", "feature/behind:f.txt").returncode == 0 + + +def test_tc05_conflict_aborts_clean_and_reports(repos): + repo, origin = repos + main_before = _origin_sha(origin, "main") + branch_before = _origin_sha(origin, "feature/conflict") + + ok, reason = merge_gate.auto_rebase_onto_main(repo, "feature/conflict") + assert ok is False + assert "rebase conflict" in reason + # main untouched, branch NOT force-pushed past the conflict. + assert _origin_sha(origin, "main") == main_before + assert _origin_sha(origin, "feature/conflict") == branch_before + # Worktree left clean (no rebase in progress). + wt = git_worktree.get_worktree_path(repo, "feature/conflict") + assert not os.path.isdir(os.path.join(wt, ".git", "rebase-merge")) + assert not os.path.isdir(os.path.join(wt, ".git", "rebase-apply")) + + +def test_tc06_never_pushes_main(repos, monkeypatch): + repo, origin = repos + calls = [] + real_run = subprocess.run + + def _spy(cmd, *a, **k): + if isinstance(cmd, list): + calls.append(cmd) + return real_run(cmd, *a, **k) + + monkeypatch.setattr(merge_gate.subprocess, "run", _spy) + merge_gate.auto_rebase_onto_main(repo, "feature/behind") + + pushes = [c for c in calls if "push" in c] + assert pushes, "expected at least one push" + for c in pushes: + # Never push main; force only via --force-with-lease on the task branch. + assert "main" not in c, f"push touched main: {c}" + assert "--force-with-lease" in c + assert "feature/behind" in c + # Hard force must never be used. + assert "--force" not in c or "--force-with-lease" in c + assert "-f" not in c + + +# --------------------------------------------------------------------------- +# TC-07..09: retest_branch (isolated from real pytest) +# --------------------------------------------------------------------------- +@pytest.fixture +def fake_worktree(tmp_path, monkeypatch): + wt = tmp_path / "wt" + wt.mkdir() + monkeypatch.setattr(merge_gate, "get_worktree_path", lambda repo, branch: str(wt)) + return str(wt) + + +def test_tc07_retest_green(fake_worktree, monkeypatch): + monkeypatch.setattr( + merge_gate.subprocess, "run", + lambda *a, **k: subprocess.CompletedProcess(a, 0, "1 passed", ""), + ) + ok, reason = merge_gate.retest_branch("orchestrator", "feature/x") + assert ok is True + assert reason == "re-test green" + + +def test_tc08_retest_red_with_tail(fake_worktree, monkeypatch): + monkeypatch.setattr( + merge_gate.subprocess, "run", + lambda *a, **k: subprocess.CompletedProcess( + a, 1, "FAILED tests/test_x.py::t - AssertionError\n1 failed", "" + ), + ) + ok, reason = merge_gate.retest_branch("orchestrator", "feature/x") + assert ok is False + assert "re-test failed" in reason + assert "AssertionError" in reason # output tail embedded + + +def test_tc09_retest_timeout(fake_worktree, monkeypatch): + def _boom(*a, **k): + raise subprocess.TimeoutExpired(cmd="pytest", timeout=1) + + monkeypatch.setattr(merge_gate.settings, "merge_retest_timeout_s", 1) + monkeypatch.setattr(merge_gate.subprocess, "run", _boom) + ok, reason = merge_gate.retest_branch("orchestrator", "feature/x") + assert ok is False + assert "re-test timeout" in reason + + +# --------------------------------------------------------------------------- +# TC-10..11: merge-lease (serialisation) +# --------------------------------------------------------------------------- +@pytest.fixture +def lease_dir(tmp_path, monkeypatch): + d = tmp_path / "repos" + d.mkdir() + monkeypatch.setattr(merge_gate.settings, "repos_dir", str(d)) + monkeypatch.setattr(merge_gate.settings, "merge_lock_timeout_s", 300) + return d + + +def test_tc10_second_acquire_busy_until_released(lease_dir): + repo = "orchestrator" + ok, _ = merge_gate.acquire_merge_lease(repo, "feature/A", "ORCH-1") + assert ok is True + + # A different branch cannot acquire while held. + ok2, reason2 = merge_gate.acquire_merge_lease(repo, "feature/B", "ORCH-2") + assert ok2 is False + assert reason2 == "merge-lock busy" + + # Same holder is idempotent. + ok_self, _ = merge_gate.acquire_merge_lease(repo, "feature/A", "ORCH-1") + assert ok_self is True + + # Release (holder-aware) frees it for B. + merge_gate.release_merge_lease(repo, "feature/A") + ok3, _ = merge_gate.acquire_merge_lease(repo, "feature/B", "ORCH-2") + assert ok3 is True + + +def test_tc10_release_is_holder_aware(lease_dir): + repo = "orchestrator" + merge_gate.acquire_merge_lease(repo, "feature/A", "ORCH-1") + # A stale release from a DIFFERENT branch must NOT drop A's lease. + merge_gate.release_merge_lease(repo, "feature/OTHER") + ok, reason = merge_gate.acquire_merge_lease(repo, "feature/B", "ORCH-2") + assert ok is False and reason == "merge-lock busy" + + +def test_tc11_stale_lease_is_reclaimed(lease_dir, monkeypatch): + repo = "orchestrator" + monkeypatch.setattr(merge_gate.settings, "merge_lock_timeout_s", 10) + # Write a lease that is older than the timeout (orphaned by a dead process). + path = merge_gate._lease_path(repo) + with open(path, "w", encoding="utf-8") as f: + json.dump( + {"branch": "feature/dead", "acquired_at": time.time() - 999, "pid": 1}, + f, + ) + ok, reason = merge_gate.acquire_merge_lease(repo, "feature/new", "ORCH-9") + assert ok is True + assert "reclaimed" in reason + # The new holder now owns it. + held = json.load(open(path, encoding="utf-8")) + assert held["branch"] == "feature/new" + + +def test_tc11_release_missing_is_noop(lease_dir): + # Releasing a non-existent lease never raises. + merge_gate.release_merge_lease("orchestrator", "feature/none") + merge_gate.release_merge_lease("orchestrator") # force form diff --git a/tests/test_merge_gate_race.py b/tests/test_merge_gate_race.py new file mode 100644 index 0000000..f9c5ea5 --- /dev/null +++ b/tests/test_merge_gate_race.py @@ -0,0 +1,150 @@ +"""ORCH-043 / TC-24: the parallel-merge race the gate exists to prevent. + +Scenario (two green branches in ONE repo, the self-hosting risk, ТЗ §1): + * main is at C1 because branch A already merged. + * branch B was validated against C0 (the main it branched from) and is GREEN + there — but B has NOT seen A's change. A blind merge of B could break main + (semantic conflict): B is "green" yet stale. + +The merge-gate makes this deterministic: + 1. While A holds the merge-lease, B's gate sees "merge-lock busy" -> DEFER + (serialisation: no two catch-up+merge sequences interleave). + 2. After A releases, B acquires the lease, rebases onto the CURRENT origin/main + (C1) and re-tests the COMBINED tree: + - re-test GREEN -> gate passes, lease HELD -> B is safe to merge; main stays green. + - re-test RED -> gate fails, lease RELEASED -> B rolls back to development; + main is NEVER touched. + origin/main's SHA is asserted unchanged throughout — the gate never pushes main. + +Real local git (bare origin + clone), real file lease; only the pytest re-test is +stubbed (its real behaviour lives in test_merge_gate.py::retest_branch tests). +""" +import os +import subprocess +import tempfile + +import pytest + +_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_merge_gate_race.db") +os.environ["ORCH_DB_PATH"] = _test_db +os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir() +os.environ["ORCH_GITEA_TOKEN"] = "test-token" +os.environ["ORCH_PLANE_API_TOKEN"] = "test-token" + +from src import git_worktree, merge_gate # noqa: E402 +from src.qg import checks as qg # noqa: E402 +from src.qg.checks import check_branch_mergeable # noqa: E402 + + +def _git(cwd, *args): + return subprocess.run(["git", "-C", cwd, *args], capture_output=True, text=True) + + +@pytest.fixture +def race_repo(tmp_path, monkeypatch): + """Bare origin at C1 (A merged) + clone + feature/B branched from C0. + + Returns (repo, origin_path). feature/B rebases cleanly onto origin/main. + The gate is forced REAL for this repo via merge_gate_repos. + """ + repo = "orchestrator" + repos_dir = tmp_path / "repos" + wt_dir = tmp_path / "repos" / "_wt" + repos_dir.mkdir(parents=True) + + monkeypatch.setattr(merge_gate.settings, "repos_dir", str(repos_dir)) + monkeypatch.setattr(git_worktree.settings, "repos_dir", str(repos_dir)) + monkeypatch.setattr(git_worktree.settings, "worktrees_dir", str(wt_dir)) + monkeypatch.setattr(qg.settings, "merge_gate_enabled", True) + monkeypatch.setattr(qg.settings, "merge_gate_repos", repo) + monkeypatch.setattr(merge_gate.settings, "merge_lock_timeout_s", 300) + + origin = tmp_path / "origin.git" + subprocess.run(["git", "init", "--bare", "-b", "main", str(origin)], capture_output=True) + + seed = tmp_path / "seed" + seed.mkdir() + _git(str(seed), "init", "-b", "main") + _git(str(seed), "config", "user.email", "t@t") + _git(str(seed), "config", "user.name", "t") + (seed / "README.md").write_text("base\n") + _git(str(seed), "add", ".") + _git(str(seed), "commit", "-m", "C0") + _git(str(seed), "remote", "add", "origin", str(origin)) + _git(str(seed), "push", "origin", "main") + + # B branches off C0, adds an isolated file (clean rebase onto C1). + _git(str(seed), "checkout", "-b", "feature/B") + (seed / "b.txt").write_text("from B\n") + _git(str(seed), "add", ".") + _git(str(seed), "commit", "-m", "feat(B): add b.txt") + _git(str(seed), "push", "origin", "feature/B") + + # A merged -> main advances to C1 (touches a DIFFERENT file: no textual conflict). + _git(str(seed), "checkout", "main") + (seed / "a.txt").write_text("from A\n") + _git(str(seed), "add", ".") + _git(str(seed), "commit", "-m", "C1 (A merged)") + _git(str(seed), "push", "origin", "main") + + main_clone = repos_dir / repo + subprocess.run(["git", "clone", str(origin), str(main_clone)], capture_output=True) + _git(str(main_clone), "config", "user.email", "t@t") + _git(str(main_clone), "config", "user.name", "t") + return repo, origin + + +def _origin_main_sha(origin): + return _git(str(origin), "rev-parse", "main").stdout.strip() + + +def test_tc24_busy_lock_serialises_then_green_catch_up_is_safe(race_repo, monkeypatch): + """A holds the lease -> B defers; after release B catches up + green re-test -> + safe merge (lease held), and origin/main is never pushed by the gate.""" + repo, origin = race_repo + main_before = _origin_main_sha(origin) + + # A is mid-merge: it holds the lease. + ok, _ = merge_gate.acquire_merge_lease(repo, "feature/A", "ORCH-A") + assert ok is True + + # B's gate must DEFER (serialisation), touching nothing. + passed, reason = check_branch_mergeable(repo, "ORCH-B", "feature/B") + assert passed is False + assert reason == "merge-lock busy" + assert _origin_main_sha(origin) == main_before # main untouched + + # A finishes and releases. + merge_gate.release_merge_lease(repo, "feature/A") + + # B catches up: real rebase onto C1, GREEN re-test -> pass, lease HELD. + monkeypatch.setattr(merge_gate, "retest_branch", lambda r, b: (True, "re-test green")) + passed, reason = check_branch_mergeable(repo, "ORCH-B", "feature/B") + assert passed is True + assert reason == "rebased onto main, re-test green" + # The gate rebased+pushed ONLY the task branch; origin/main is unchanged. + assert _origin_main_sha(origin) == main_before + # feature/B now contains C1 (a.txt) on origin after the force-with-lease push. + assert "a.txt" in _git(str(origin), "ls-tree", "--name-only", "feature/B").stdout + # Lease is HELD by B until the actual merge. + held = merge_gate._read_lease(merge_gate._lease_path(repo)) + assert held is not None and held.get("branch") == "feature/B" + + +def test_tc24_red_catch_up_fails_and_releases_main_stays_green(race_repo, monkeypatch): + """B catches up but the COMBINED tree is red -> gate fails, lease released, + origin/main never touched (B will roll back to development upstream).""" + repo, origin = race_repo + main_before = _origin_main_sha(origin) + + monkeypatch.setattr( + merge_gate, "retest_branch", + lambda r, b: (False, "re-test failed: ...1 failed, 9 passed"), + ) + passed, reason = check_branch_mergeable(repo, "ORCH-B", "feature/B") + assert passed is False + assert reason.startswith("re-test failed after rebase:") + # main is still green / untouched. + assert _origin_main_sha(origin) == main_before + # The lease was released on failure (a later task can proceed). + assert merge_gate._read_lease(merge_gate._lease_path(repo)) is None diff --git a/tests/test_qg_merge_gate.py b/tests/test_qg_merge_gate.py new file mode 100644 index 0000000..302f012 --- /dev/null +++ b/tests/test_qg_merge_gate.py @@ -0,0 +1,211 @@ +"""ORCH-043 / TC-12..17: the merge-gate quality check ``check_branch_mergeable``. + +These exercise the COMPOSITION logic in src/qg/checks.check_branch_mergeable — +the deterministic gate the engine runs on the deploy-staging -> deploy edge. The +merge_gate primitives (rebase / re-test / lease) are mocked here; their real-git +behaviour is covered in tests/test_merge_gate.py. + +Contract under test (ADR-001 §4): + * conditionality: merge_gate_enabled=False / repo-out-of-scope -> no-op pass, + NEVER touching the lease; + * up-to-date branch -> pass, lease HELD; + * behind + clean rebase + green re-test -> pass, lease HELD; + * rebase conflict -> fail, lease RELEASED; + * red / timeout re-test after rebase -> fail, lease RELEASED; + * never-raise: an exception inside the gate -> (False, ...) with lease released. +""" + +import os + +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") + +import pytest # noqa: E402 + +from src import merge_gate # noqa: E402 +from src.qg import checks as qg # noqa: E402 +from src.qg.checks import check_branch_mergeable # noqa: E402 + +_REPO = "orchestrator" +_BRANCH = "feature/ORCH-043-x" +_WI = "ORCH-043" + + +@pytest.fixture +def lease_spy(monkeypatch): + """Replace the merge_gate lease primitives with in-memory spies. + + Tracks acquire/release calls and lets each test program the acquire outcome + so we can assert the gate's lease lifecycle without touching the filesystem. + """ + state = { + "acquired": False, + "released": False, + "acquire_result": (True, "lease acquired"), + } + + def _acquire(repo, branch, work_item_id=None, task_id=None): + ok, reason = state["acquire_result"] + if ok: + state["acquired"] = True + return ok, reason + + def _release(repo, branch=None): + state["released"] = True + + monkeypatch.setattr(merge_gate, "acquire_merge_lease", _acquire) + monkeypatch.setattr(merge_gate, "release_merge_lease", _release) + # Default merge_gate scope: real for the self-hosting orchestrator repo. + monkeypatch.setattr(qg.settings, "merge_gate_enabled", True) + monkeypatch.setattr(qg.settings, "merge_gate_repos", "") + return state + + +# --------------------------------------------------------------------------- +# Conditionality (no-op variants) — must NOT touch the lease. +# --------------------------------------------------------------------------- +def test_tc16_disabled_is_noop(monkeypatch, lease_spy): + """TC-16 / AC-8: merge_gate_enabled=False -> pass, lease untouched.""" + monkeypatch.setattr(qg.settings, "merge_gate_enabled", False) + ok, reason = check_branch_mergeable(_REPO, _WI, _BRANCH) + assert ok is True + assert reason == "merge-gate disabled" + assert lease_spy["acquired"] is False + assert lease_spy["released"] is False + + +def test_tc17_repo_out_of_scope_is_noop(monkeypatch, lease_spy): + """TC-17 / AC-8: non-self-hosting repo (empty CSV) -> conditional no-op.""" + ok, reason = check_branch_mergeable("enduro-trails", "ET-1", "feature/ET-1-x") + assert ok is True + assert reason == "merge-gate N/A for enduro-trails" + assert lease_spy["acquired"] is False + assert lease_spy["released"] is False + + +def test_csv_scopes_gate_to_listed_repo(monkeypatch, lease_spy): + """merge_gate_repos CSV makes the gate real for a non-self-hosting repo.""" + monkeypatch.setattr(qg.settings, "merge_gate_repos", "enduro-trails") + monkeypatch.setattr(merge_gate, "branch_is_behind_main", lambda r, b: False) + ok, reason = check_branch_mergeable("enduro-trails", "ET-1", "feature/ET-1-x") + assert ok is True + assert reason == "branch up-to-date with main" + assert lease_spy["acquired"] is True # gate actually ran + + +# --------------------------------------------------------------------------- +# Lock busy -> DEFER signal (no rollback at this layer). +# --------------------------------------------------------------------------- +def test_lock_busy_returns_defer_signal(monkeypatch, lease_spy): + """Lease busy -> (False, 'merge-lock busy'); nothing acquired or released.""" + lease_spy["acquire_result"] = (False, "merge-lock busy") + ok, reason = check_branch_mergeable(_REPO, _WI, _BRANCH) + assert ok is False + assert reason == "merge-lock busy" + assert lease_spy["acquired"] is False + assert lease_spy["released"] is False # we never held it + + +# --------------------------------------------------------------------------- +# TC-12: branch already up-to-date -> pass, lease HELD. +# --------------------------------------------------------------------------- +def test_tc12_up_to_date_passes_lease_held(monkeypatch, lease_spy): + monkeypatch.setattr(merge_gate, "branch_is_behind_main", lambda r, b: False) + # If these were called the test would wrongly proceed — guard with raisers. + monkeypatch.setattr( + merge_gate, "auto_rebase_onto_main", + lambda r, b: pytest.fail("must not rebase an up-to-date branch"), + ) + ok, reason = check_branch_mergeable(_REPO, _WI, _BRANCH) + assert ok is True + assert reason == "branch up-to-date with main" + assert lease_spy["acquired"] is True + assert lease_spy["released"] is False # lease HELD until the merge + + +# --------------------------------------------------------------------------- +# TC-13: behind + clean rebase + green re-test -> pass, lease HELD. +# --------------------------------------------------------------------------- +def test_tc13_behind_clean_rebase_green_passes_lease_held(monkeypatch, lease_spy): + monkeypatch.setattr(merge_gate, "branch_is_behind_main", lambda r, b: True) + monkeypatch.setattr( + merge_gate, "auto_rebase_onto_main", + lambda r, b: (True, "rebased onto origin/main"), + ) + monkeypatch.setattr(merge_gate, "retest_branch", lambda r, b: (True, "re-test green")) + ok, reason = check_branch_mergeable(_REPO, _WI, _BRANCH) + assert ok is True + assert reason == "rebased onto main, re-test green" + assert lease_spy["acquired"] is True + assert lease_spy["released"] is False # lease HELD + + +# --------------------------------------------------------------------------- +# TC-14: rebase conflict -> fail, lease RELEASED. +# --------------------------------------------------------------------------- +def test_tc14_rebase_conflict_fails_lease_released(monkeypatch, lease_spy): + monkeypatch.setattr(merge_gate, "branch_is_behind_main", lambda r, b: True) + monkeypatch.setattr( + merge_gate, "auto_rebase_onto_main", + lambda r, b: (False, "rebase conflict: src/db.py"), + ) + monkeypatch.setattr( + merge_gate, "retest_branch", + lambda r, b: pytest.fail("must not re-test after a failed rebase"), + ) + ok, reason = check_branch_mergeable(_REPO, _WI, _BRANCH) + assert ok is False + assert reason == "rebase conflict: src/db.py" + assert lease_spy["released"] is True + + +# --------------------------------------------------------------------------- +# TC-15: red / timeout re-test after rebase -> fail, lease RELEASED. +# --------------------------------------------------------------------------- +def test_tc15_red_retest_fails_lease_released(monkeypatch, lease_spy): + monkeypatch.setattr(merge_gate, "branch_is_behind_main", lambda r, b: True) + monkeypatch.setattr( + merge_gate, "auto_rebase_onto_main", + lambda r, b: (True, "rebased onto origin/main"), + ) + monkeypatch.setattr( + merge_gate, "retest_branch", + lambda r, b: (False, "re-test failed: ...1 failed, 5 passed"), + ) + ok, reason = check_branch_mergeable(_REPO, _WI, _BRANCH) + assert ok is False + assert reason.startswith("re-test failed after rebase:") + assert "1 failed, 5 passed" in reason + assert lease_spy["released"] is True + + +def test_tc15_retest_timeout_passes_reason_through(monkeypatch, lease_spy): + """AC-6: a re-test timeout keeps its distinct reason and releases the lease.""" + monkeypatch.setattr(merge_gate, "branch_is_behind_main", lambda r, b: True) + monkeypatch.setattr( + merge_gate, "auto_rebase_onto_main", + lambda r, b: (True, "rebased onto origin/main"), + ) + monkeypatch.setattr( + merge_gate, "retest_branch", + lambda r, b: (False, "re-test timeout after 600s"), + ) + ok, reason = check_branch_mergeable(_REPO, _WI, _BRANCH) + assert ok is False + assert reason == "re-test timeout after 600s" + assert lease_spy["released"] is True + + +# --------------------------------------------------------------------------- +# Never-raise: an exception inside the gate -> (False, ...) + lease released. +# --------------------------------------------------------------------------- +def test_never_raise_releases_lease_on_internal_error(monkeypatch, lease_spy): + """AC-9: a blowing-up primitive is caught; the gate returns and releases.""" + def _boom(r, b): + raise RuntimeError("git exploded") + + monkeypatch.setattr(merge_gate, "branch_is_behind_main", _boom) + ok, reason = check_branch_mergeable(_REPO, _WI, _BRANCH) + assert ok is False + assert "merge-gate error" in reason + assert lease_spy["released"] is True # held then released on the error path diff --git a/tests/test_qg_registry_snapshot.py b/tests/test_qg_registry_snapshot.py index 284c8ac..71ee2d0 100644 --- a/tests/test_qg_registry_snapshot.py +++ b/tests/test_qg_registry_snapshot.py @@ -28,6 +28,7 @@ _EXPECTED_QGS = { "check_tests_local", "check_deploy_status", "check_staging_status", + "check_branch_mergeable", # ORCH-043 merge-gate (deploy-staging -> deploy edge) } diff --git a/tests/test_stage_engine.py b/tests/test_stage_engine.py index 03378d9..678dcad 100644 --- a/tests/test_stage_engine.py +++ b/tests/test_stage_engine.py @@ -805,6 +805,188 @@ class TestStagingGate: # --------------------------------------------------------------------------- # launcher + plane both delegate to the engine # --------------------------------------------------------------------------- +class TestMergeGate: + """ORCH-043 / TC-20..23: the merge-gate sub-gate on the deploy-staging -> deploy + edge. The QG ``check_branch_mergeable`` is monkeypatched on stage_engine.QG_CHECKS + so we drive the engine's reaction (advance / defer / rollback) deterministically; + the gate's own composition is covered in test_qg_merge_gate.py. + """ + + def _jobs_full(self): + conn = get_db() + rows = conn.execute( + "SELECT agent, task_content, available_at FROM jobs ORDER BY id" + ).fetchall() + conn.close() + return [dict(r) for r in rows] + + def test_tc20_pass_advances_to_deploy(self, monkeypatch): + """TC-20 / AC-1: gate PASS (rebased + green) -> advance to deploy, deployer + enqueued, NO rollback. staging gate must pass first (same edge).""" + monkeypatch.setattr( + stage_engine, "QG_CHECKS", + {**stage_engine.QG_CHECKS, + "check_staging_status": _pass, + "check_branch_mergeable": _pass}, + ) + task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043", + branch="feature/ORCH-043-x") + res = advance_stage( + task_id, "deploy-staging", "orchestrator", "ORCH-043", + "feature/ORCH-043-x", finished_agent="deployer", + ) + assert res.advanced is True + assert res.to_stage == "deploy" + assert _stage(task_id) == "deploy" + assert res.rolled_back_to is None + jobs = _jobs() + assert len(jobs) == 1 + assert jobs[0]["agent"] == "deployer" + + def test_tc21_busy_lock_defers_without_rollback(self, monkeypatch): + """TC-21 / AC-5: 'merge-lock busy' -> DEFER: task stays on deploy-staging, + deployer re-queued with a delay (available_at set), no rollback, no alert.""" + monkeypatch.setattr( + stage_engine, "QG_CHECKS", + {**stage_engine.QG_CHECKS, + "check_staging_status": _pass, + "check_branch_mergeable": _fail("merge-lock busy")}, + ) + monkeypatch.setattr(stage_engine.settings, "merge_defer_delay_s", 30) + monkeypatch.setattr(stage_engine.settings, "merge_defer_max_attempts", 5) + task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043", + branch="feature/ORCH-043-x") + res = advance_stage( + task_id, "deploy-staging", "orchestrator", "ORCH-043", + "feature/ORCH-043-x", finished_agent="deployer", + ) + assert res.advanced is False + assert res.rolled_back_to is None + assert res.note == "merge-gate-deferred" + assert _stage(task_id) == "deploy-staging" # stays put + jobs = self._jobs_full() + assert len(jobs) == 1 + assert jobs[0]["agent"] == "deployer" + assert "merge-gate defer" in jobs[0]["task_content"] + assert jobs[0]["available_at"] is not None # delayed re-pickup + assert stage_engine.set_issue_blocked.called is False + + def test_tc21_defer_exhausted_blocks_and_alerts(self, monkeypatch): + """AC-5: after merge_defer_max_attempts defers -> block + Telegram, no new job.""" + monkeypatch.setattr( + stage_engine, "QG_CHECKS", + {**stage_engine.QG_CHECKS, + "check_staging_status": _pass, + "check_branch_mergeable": _fail("merge-lock busy")}, + ) + monkeypatch.setattr(stage_engine.settings, "merge_defer_max_attempts", 3) + task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043", + branch="feature/ORCH-043-x") + # Pre-seed 3 prior defer jobs (the restart-safe counter reads task_content). + conn = get_db() + for _ in range(3): + conn.execute( + "INSERT INTO jobs (agent, repo, task_id, task_content) " + "VALUES ('deployer','orchestrator',?, 'Note: merge-gate defer')", + (task_id,), + ) + conn.commit() + conn.close() + res = advance_stage( + task_id, "deploy-staging", "orchestrator", "ORCH-043", + "feature/ORCH-043-x", finished_agent="deployer", + ) + assert res.advanced is False + assert res.note == "merge-gate-defer-exhausted" + assert res.alerted is True + assert stage_engine.set_issue_blocked.called + assert stage_engine.send_telegram.called + # No NEW defer job past the cap (still the 3 we seeded). + assert len(self._jobs_full()) == 3 + + def test_tc22_conflict_rolls_back_to_development(self, monkeypatch): + """TC-22 / AC-3: rebase conflict -> rollback to development + developer retry.""" + monkeypatch.setattr( + stage_engine, "QG_CHECKS", + {**stage_engine.QG_CHECKS, + "check_staging_status": _pass, + "check_branch_mergeable": _fail("rebase conflict: src/db.py")}, + ) + task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043", + branch="feature/ORCH-043-x") + res = advance_stage( + task_id, "deploy-staging", "orchestrator", "ORCH-043", + "feature/ORCH-043-x", finished_agent="deployer", + ) + assert res.advanced is False + assert res.rolled_back_to == "development" + assert _stage(task_id) == "development" + assert res.qg_name == "check_branch_mergeable" + jobs = _jobs() + assert len(jobs) == 1 + assert jobs[0]["agent"] == "developer" + assert stage_engine.set_issue_in_progress.called + + def test_tc22_red_retest_rolls_back_to_development(self, monkeypatch): + """AC-2/AC-3: red re-test after rebase -> rollback to development.""" + monkeypatch.setattr( + stage_engine, "QG_CHECKS", + {**stage_engine.QG_CHECKS, + "check_staging_status": _pass, + "check_branch_mergeable": _fail("re-test failed after rebase: 1 failed")}, + ) + task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043", + branch="feature/ORCH-043-x") + res = advance_stage( + task_id, "deploy-staging", "orchestrator", "ORCH-043", + "feature/ORCH-043-x", finished_agent="deployer", + ) + assert res.rolled_back_to == "development" + assert _stage(task_id) == "development" + jobs = _jobs() + assert len(jobs) == 1 + assert jobs[0]["agent"] == "developer" + # The rollback task_desc carries the gate reason for the developer. + assert "re-test failed after rebase: 1 failed" in _job_contents()[0] + + def test_tc23_rollback_respects_max_developer_retries(self, monkeypatch): + """TC-23 / AC-11: merge-gate rollback is capped by MAX_DEVELOPER_RETRIES — + no infinite bounce. 4th attempt -> block + alert, no new developer job.""" + monkeypatch.setattr( + stage_engine, "QG_CHECKS", + {**stage_engine.QG_CHECKS, + "check_staging_status": _pass, + "check_branch_mergeable": _fail("rebase conflict: src/db.py")}, + ) + task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043", + branch="feature/ORCH-043-x") + _add_developer_runs(task_id, 3) # already at the cap + res = advance_stage( + task_id, "deploy-staging", "orchestrator", "ORCH-043", + "feature/ORCH-043-x", finished_agent="deployer", + ) + assert res.rolled_back_to == "development" + assert stage_engine.set_issue_blocked.called + assert stage_engine.send_telegram.called + assert _jobs() == [] # no developer job past the cap + + def test_non_self_hosting_repo_skips_merge_gate(self, monkeypatch): + """Regression: for a non-self-hosting repo the REAL gate is a no-op, so + deploy-staging -> deploy advances exactly as before ORCH-043.""" + monkeypatch.setattr( + stage_engine, "QG_CHECKS", + {**stage_engine.QG_CHECKS, "check_staging_status": _pass}, + ) # check_branch_mergeable left REAL -> N/A for enduro-trails + task_id = _make_task("deploy-staging", repo="enduro-trails", wi="ET-035", + branch="feature/ET-035-x") + res = advance_stage( + task_id, "deploy-staging", "enduro-trails", "ET-035", + "feature/ET-035-x", finished_agent="deployer", + ) + assert res.advanced is True + assert _stage(task_id) == "deploy" + + class TestDelegation: def test_launcher_calls_engine(self): from src.agents.launcher import AgentLauncher