diff --git a/.env.example b/.env.example index 9119703..dfba7c6 100644 --- a/.env.example +++ b/.env.example @@ -166,6 +166,22 @@ ORCH_MERGE_PR_TIMEOUT_S=60 ORCH_MERGE_VERIFY_TIMEOUT_S=60 ORCH_REGRESSION_GUARD_ENABLED=true ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED=true +# ORCH-093: deterministic merge-actor retry of TRANSIENT Gitea merge errors. merge_pr +# wraps ONLY the mutating POST /pulls/{n}/merge in a bounded exponential-backoff +# retry-loop on transient outcomes (405 "try again later" / 408 / 5xx / network / +# timeout, and 409|422 while the PR is still mergeable); terminal outcomes +# (403/404/real conflict) -> fast honest False (the ORCH-071/081 HOLD backstop is +# unchanged). Fixes the ORCH-063 false HOLD + manual re-merge. The already-in-main +# guard (no commits beyond origin/main -> no garbage PR) is always-on under +# MERGE_VERIFY_AUTOCREATE_PR_ENABLED (no separate flag). +# MERGE_RETRY_ENABLED -> kill-switch; false -> exactly one POST (one-shot, prior behaviour). +# MERGE_RETRY_MAX_ATTEMPTS -> max POST attempts on a transient outcome. +# MERGE_RETRY_BACKOFF_BASE_S -> exponential backoff base seconds (sleep = base*2^(i-1)). +# MERGE_RETRY_BACKOFF_MAX_S -> per-sleep backoff ceiling seconds (bounds total wait). +ORCH_MERGE_RETRY_ENABLED=true +ORCH_MERGE_RETRY_MAX_ATTEMPTS=3 +ORCH_MERGE_RETRY_BACKOFF_BASE_S=2 +ORCH_MERGE_RETRY_BACKOFF_MAX_S=5 # ORCH-036: executable self-deploy of the `deploy` stage. For the self-hosting repo # (orchestrator) the stage REALLY restarts prod (8500) via a detached host hook; # deploy_status: SUCCESS means proven health-ok, not an LLM declaration. Three diff --git a/.task-dev.md b/.task-dev.md index 4922723..32c813d 100644 --- a/.task-dev.md +++ b/.task-dev.md @@ -1,4 +1,4 @@ -Work item: ORCH-091 +Work item: ORCH-093 Repo: orchestrator -Branch: feature/ORCH-091-bug-to-analyse-stage-deploy-st +Branch: feature/ORCH-093-bug-merge-gitea-405-5xx-hold-p Stage: development \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dee4f5..e0721df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,12 @@ Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу. ## [Unreleased] +- **Merge-актор ретраит транзиентные ошибки Gitea (405/5xx) + гард «ветка уже в `main`»** (ORCH-093, `fix`): две точечные доработки детерминированного merge-актора `src/merge_gate.py`, чинящие инцидент **ORCH-063**: self-deploy прошёл, staging OK, PR был `open`+`mergeable`, но `POST /pulls/{n}/merge` вернул `HTTP 405 "Please try again later"` (Gitea пересчитывал `mergeable` сразу после пуша) → one-shot `merge_pr` мгновенно вернул `False` → корректная защита ORCH-071/081 удержала задачу на `deploy` + потребовала ручной домерж; повторный прогон финализатора плодил мусорный пустой PR. **Аддитивно, never-raise, под существующими kill-switch'ами:** `STAGE_TRANSITIONS` / `QG_CHECKS` / схема БД — **не тронуты**; INV-4 (мерж только через Gitea PR-merge API, никогда `push`/`force-push` в `main`) сохранён 1:1. + - **Retry-loop транзиента (FR-1/FR-2, AC-1/AC-2/AC-3, D1/D2):** `merge_pr` оборачивает **только** мутирующий `POST …/merge` в ограниченный retry-loop с экспоненциальным backoff (`min(base*2^(i-1), max)`, дефолты 2/5 с → суммарный сон `(N-1)*max ≤ 10 с`, monitor-поток не подвешивается). Классификатор `_classify_merge_response`: **транзиент** (ретрай) — `405`/`408`/любой `5xx`/`httpx`-таймаут/сетевая ошибка, **и** `409`/`422` когда PR всё ещё mergeable; **терминал** (быстрый честный `False`, защита ORCH-071/081 как прежде) — `403`/`404`/реальный конфликт (`409`/`422` при `mergeable==False`). Неоднозначный `409`/`422` разрешается доп. `GET /pulls/{index}` → `mergeable`; дефолт-политика `mergeable==None`/недоступно → **транзиент** (fail-OPEN-в-ретрай: икота Gitea — наблюдаемый кейс, бюджет конечен, backstop сохранён). Каждая попытка логируется `attempt i/N` (образец `check_ci_green`). + - **Гард already-in-main (FR-3/FR-4, AC-4, D3/D4):** новый leaf `_branch_fully_in_main` (`git merge-base --is-ancestor HEAD origin/main` в per-branch worktree) вызывается в `ensure_open_pr` **между** «открытый code-PR не найден» и `POST …/pulls`: ветка целиком в `main` (нет коммитов `origin/main..HEAD`) → новый исход `"already-in-main"` **без создания PR** (нет мусорного пустого PR на уже влитой ветке). git-ошибка/ambiguous (`None`) → **fail-OPEN** (деградация на create-путь, НЕ ложный no-op). В `stage_engine._handle_merge_verify` исход `already-in-main` **пропускает** `merge_pr` (мержить нечего) и отдаёт авторитетному SHA-in-main (`verify_merged_to_main`) довести до `done`; это НЕ HOLD. SHA-in-main остаётся единственным доказательством мержа (ADR-0014). + - **Конфиг/откат (FR-5, AC-5/AC-7, D5):** новые поля `src/config.py` `merge_retry_enabled` (kill-switch; `False` → ровно один POST = байт-в-байт прежнее one-shot, нулевая регрессия) / `merge_retry_max_attempts` (3) / `merge_retry_backoff_base_s` (2) / `merge_retry_backoff_max_s` (5), env `ORCH_MERGE_RETRY_*`, дескрипторы в `.env.example`. Гард already-in-main — без отдельного флага (накрыт `merge_verify_autocreate_pr_enabled`). Откат: `ORCH_MERGE_RETRY_ENABLED=false` (мгновенный runtime) или revert PR. + - **Трассировка:** перед правкой `merge_pr`/`ensure_open_pr`/`_handle_merge_verify` прочитаны ADR ORCH-071/073/082 — инварианты (SHA-in-main authoritative, never-raise, idempotency-guard `pr_already_merged`, base==main фильтр code-PR) сохранены; в `MAIN_REGRESSION_MARKERS` добавлена строка `("ORCH-093", "_classify_merge_response", "src/merge_gate.py")` (append-only). + - Тесты: `tests/test_merge_gate.py` (TC-01..TC-12: 405×2→200, 5xx→200, network→200, реальный конфликт/403 терминал, ambiguous-mergeable, исчерпание ретраев, kill-switch one-shot, already-in-main без POST, create при коммитах сверх main, fail-OPEN на git-ошибке гарда, never-raise; `httpx` мокается, `time.sleep` → no-op), `tests/test_config.py` (TC-13: дефолты + env-override `ORCH_MERGE_RETRY_*`), `tests/test_merge_verify.py` (TC-14..TC-16: already-in-main пропускает `merge_pr`→done; исчерпание+SHA-not-in-main→HOLD; транзиент-успех→done). Обновлён `tests/test_orch082_ensure_pr.py` (гард запинён на create-путь — у гарда своё покрытие). Полный регресс `tests/ -q` зелёный (1389). ADR: `docs/work-items/ORCH-093/06-adr/ADR-001-merge-transient-retry-and-already-in-main-guard.md`, сквозной `docs/architecture/adr/adr-0027-merge-actor-transient-retry-and-already-in-main.md`. - **Live-карточка трекера: полнота карты статусов, отражение откатов, суммирование метрик стадии по попыткам** (ORCH-091, `fix`): три верифицированных дефекта рендера Telegram-карточки (`src/notifications.py`, ORCH-067/087). **Аддитивно, never-raise, без нового поведения конвейера:** `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / транспорт нотификаций / схема БД — **не тронуты** (затронут ровно один модуль индикативного слоя); kill-switch не требуется (рендер деградирует безопасно, откат = `git revert`). - **Деф.1 — застрявший заголовок «To Analyse» (FR-1/2/3, AC-1/2/3):** `_STAGE_STATUS_LABEL` покрывал 8 из 10 ключей `STAGE_TRANSITIONS` — `deploy-staging` и `cancelled` (ORCH-090) выпадали в дефолт-«To Analyse» (ложный «первый статус» на стадии staging-деплоя). Карта расширена: `deploy-staging → "Deploying (staging)"` (plain-стиль активной стадии, суффикс «(staging)» снимает коллизию с prod-overlay `_LIVE_BRANCH_LABELS['deploying']` и с pause-лейблом `deploy`), `cancelled → "Cancelled"` (offline-база ORCH-090, совпадает с overlay-лейблом → нет конфликта precedence). Runtime-фолбэк `plane_status_label` для **немаппленной** (будущей/неизвестной) стадии заменён с «To Analyse» на **нейтральный** капитализированный лейбл (`_neutral_stage_label`, `"deploy-staging" → "Deploy Staging"`); `created` остаётся явным ключом → честная «To Analyse»; битый/None-вход → безопасный дефолт. Полнота карты гарантируется **программно** тестом, итерирующим `STAGE_TRANSITIONS.keys()` (единый источник истины) — новая стадия без курируемого лейбла даёт красный тест; автогенерация лейблов в самом модуле запрещена (карта остаётся курируемой/человекочитаемой). - **Деф.2 — ложная картина при откате (FR-4, AC-4):** цикл рендера выводил `✅`-строку для каждой стадии с завершённым прогоном её агента **без учёта позиции** относительно текущей — после отката (`deploy-staging → development` ORCH-043, `review → development` REQUEST_CHANGES) карточка показывала абсурд «✅ Внедрение … + 🔄 Разработка». Введён лёгкий read-only хелпер `_pipeline_pos` от **порядка `STAGE_TRANSITIONS`** (не от `_TRACKER_STAGES`, который не содержит `deploy-staging`/`cancelled` и не авторитетен по порядку); гейт подавления: `✅`-строка рисуется только если `current_pos >= _pipeline_pos(stage_key)`. Нормализация `deploy-staging → deploy` применяется **только** к вычислению текущей позиции (схлопнутая строка «Внедрение» несёт `stage_key="deploy"`); `is_active_stage` — **без изменений** (нулевой регресс активного рендера). Подавлённые откатом прогоны по-прежнему входят в тоталы задачи (намеренная семантика отката). diff --git a/CLAUDE.md b/CLAUDE.md index 6368e7d..dfb0be4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -7,7 +7,7 @@ - Backend: FastAPI + uvicorn (Python 3.12) - БД: SQLite (`src/db.py`) - Агенты: Claude CLI (`ORCH_CLAUDE_BIN`), по одному промпту на роль в `.openclaw/agents/`. **ORCH-74:** модель/эффорт агента берутся ТОЛЬКО из config (`resolve_agent_model`/`resolve_agent_effort`, ORCH-41) — frontmatter `model:` удалён как мёртвый, frontmatter описательный; имя модели валидируется форматом `^claude-…$` перед `--model` (never-break). **ORCH-077 (52d, замыкает эпик 52):** тело всех 6 промптов переписано в едином **каноне Anthropic** (5 обязательных XML-секций в нормативном порядке ``→``→``→``→``, запреты в формате «❌ X → ✅ Y», `` у решающих ролей), и каждый промпт **добровольно** эмитит 6-польную frontmatter-схему 52c (`work_item`/`stage`/`author_agent`/`status`/`created_at`/`model_used`) **аддитивно** — рядом с machine-verdict ключом, НЕ меняя его имя/регистр/значения (`verdict:`/`result:`/`staging_status:`/`deploy_status:`/`security_status:` — байт-в-байт). Это **docs/prompts-only** изменение: `src/**`/`STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД не тронуты; `frontmatter_validation_strict` остаётся `False` (enforcement НЕ включён). Промпт `cat`-ается из worktree в момент запуска → новые промпты вступают в силу на следующем worktree от `main` без прод-рестарта. Анти-регресс — структурные тесты `tests/test_agent_prompts_canon.py` + зелёный `test_agent_frontmatter_no_model.py`. **Норматив на будущее:** новые/изменённые агент-промпты следуют этому канону. Детали — `docs/architecture/adr/adr-0021-prompt-canon-anthropic.md`. **ORCH-092 (эпилог эпика 52, docs/prompts-only):** аудит 6 промптов поверх канона — копируемые frontmatter-примеры расхардкожены (`created_at: `/`model_used: ` + врезка «подставь `date +%F`/модель из конфига, не копируй буквально»; литерал `claude-opus-4-8` — только справка в таблице полей); добавлена секция `` developer/reviewer/tester (после ``, порядок 5 секций цел); developer лишён ручного `git rebase origin/main` (свежесть базы — инвариант движка serial-gate ORCH-088 + `auto_rebase_onto_main` под merge-lease; ручной rebase конфликтовал с запретом force-push — ADR-001 D1); tester обогащён worktree-путём + smoke `serial_gate` + покрытием каждого TC; из reviewer удалена мёртвая строка «тот же экземпляр Developer». **Языковое исключение (нормативно, ADR-001 D2):** `deployer.md` сознательно остаётся на **английском** (5 ru + 1 en) как самый safety-critical промпт — НЕ «чинить» язык вслепую; критичные self-hosting-запреты подняты в видную рамку. Verdict-ключи и канон 52d — байт-в-байт; анти-регресс — `tests/test_agent_prompts_canon.py` (ORCH-092 TC-01…TC-08). Детали — `docs/work-items/ORCH-092/06-adr/ADR-001-developer-rebase-and-deployer-language.md`. -- Очередь задач: собственная (SQLite `jobs`, `src/queue_worker.py`, ORCH-1). **ORCH-026:** `claim_next_job` гейтит задачи с незавершёнными зависимостями (`job_deps`, `NOT EXISTS`) без занятия слота `max_concurrency`; декларации/детект циклов — leaf `src/task_deps.py` (kill-switch `ORCH_TASK_DEPS_ENABLED`). Сериализация мержа одного репо — безусловный pre-merge rebase под merge-lease (`ORCH_PREMERGE_REBASE_ALWAYS`). **ORCH-088 (serial gate, Этап 1):** новая задача репо не входит в `analysis` (analyst-job не выбирается, ветка не режется), пока в репо есть **более ранняя** незавершённая задача (`t2.id < jobs.task_id`, FIFO) ИЛИ репо заморожен (`repo_freeze`). Срез ветки **отложен** со `start_pipeline` на момент claim analyst-job (`launcher._materialize_deferred_branch`) — база = свежий `origin/main` с кодом предшественника (анти-stale-base). Post-deploy `DEGRADED` → durable per-repo freeze (`repo_freeze`, `cleared_at IS NULL` = активен) + Telegram; снятие — вручную `POST /serial-gate/unfreeze?repo=…`. Leaf `src/serial_gate.py` (claim — fail-OPEN, freeze — fail-CLOSED); флаги `ORCH_SERIAL_GATE_ENABLED` (kill-switch), `ORCH_SERIAL_GATE_REPOS` (CSV; пусто = все репо), `ORCH_SERIAL_GATE_FREEZE_ENABLED`. Блок `serial_gate` в `GET /queue`. `STAGE_TRANSITIONS`/`QG_CHECKS` не тронуты. +- Очередь задач: собственная (SQLite `jobs`, `src/queue_worker.py`, ORCH-1). **ORCH-026:** `claim_next_job` гейтит задачи с незавершёнными зависимостями (`job_deps`, `NOT EXISTS`) без занятия слота `max_concurrency`; декларации/детект циклов — leaf `src/task_deps.py` (kill-switch `ORCH_TASK_DEPS_ENABLED`). Сериализация мержа одного репо — безусловный pre-merge rebase под merge-lease (`ORCH_PREMERGE_REBASE_ALWAYS`). **ORCH-088 (serial gate, Этап 1):** новая задача репо не входит в `analysis` (analyst-job не выбирается, ветка не режется), пока в репо есть **более ранняя** незавершённая задача (`t2.id < jobs.task_id`, FIFO) ИЛИ репо заморожен (`repo_freeze`). Срез ветки **отложен** со `start_pipeline` на момент claim analyst-job (`launcher._materialize_deferred_branch`) — база = свежий `origin/main` с кодом предшественника (анти-stale-base). Post-deploy `DEGRADED` → durable per-repo freeze (`repo_freeze`, `cleared_at IS NULL` = активен) + Telegram; снятие — вручную `POST /serial-gate/unfreeze?repo=…`. Leaf `src/serial_gate.py` (claim — fail-OPEN, freeze — fail-CLOSED); флаги `ORCH_SERIAL_GATE_ENABLED` (kill-switch), `ORCH_SERIAL_GATE_REPOS` (CSV; пусто = все репо), `ORCH_SERIAL_GATE_FREEZE_ENABLED`. Блок `serial_gate` в `GET /queue`. `STAGE_TRANSITIONS`/`QG_CHECKS` не тронуты. **ORCH-093 (merge-актор устойчив к икоте Gitea):** детерминированный merge-актор под-гейта `deploy → done` (`src/merge_gate.py`) ретраит **транзиентные** ошибки Gitea вместо ложного HOLD (инцидент ORCH-063: `POST …/merge` → `405 "try again later"` сразу после пуша). `merge_pr` оборачивает **только** мутирующий `POST …/merge` в ограниченный retry-loop с экспоненциальным backoff (`min(base*2^(i-1), max)`, потолок суммарного сна `(N-1)*max ≤ 10 с`); классификатор `_classify_merge_response`: транзиент (ретрай) — `405`/`408`/`5xx`/таймаут/сетевая + `409`/`422` при `mergeable==True` (доп. `GET /pulls/{index}`; `mergeable==None` → дефолт-транзиент, fail-OPEN-в-ретрай), терминал (быстрый честный `False`, защита ORCH-071/073 как прежде) — `403`/`404`/реальный конфликт (`mergeable==False`). Kill-switch `merge_retry_enabled=false` → ровно один POST (байт-в-байт прежнее one-shot); флаги `ORCH_MERGE_RETRY_*` (`max_attempts=3`, `backoff_base_s=2`, `backoff_max_s=5`). Гард **already-in-main** в `ensure_open_pr` (leaf `_branch_fully_in_main`, `git merge-base --is-ancestor HEAD origin/main`): ветка целиком в `main` → исход `"already-in-main"` без создания мусорного пустого PR; `_handle_merge_verify` пропускает `merge_pr` и отдаёт авторитетному SHA-в-main довести до `done` (НЕ HOLD); git-ошибка → fail-OPEN на create-путь. Без отдельного флага (накрыт `merge_verify_autocreate_pr_enabled`). INV-4 (мерж только через Gitea PR-merge API, никогда push/force-push в `main`), never-raise, `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД — сохранены. Детали — `docs/work-items/ORCH-093/06-adr/ADR-001-merge-transient-retry-and-already-in-main-guard.md`, сквозной `docs/architecture/adr/adr-0027-merge-actor-transient-retry-and-already-in-main.md`. - Контейнеризация: Docker + Compose - CI/CD: Gitea Actions (`.gitea/workflows/`) - Деплой: docker compose на mva154 diff --git a/docs/architecture/README.md b/docs/architecture/README.md index ddba666..9d953f8 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -477,6 +477,78 @@ developer-пути и **только** при свежем worktree-коммит Подробнее: [adr-0016](adr/adr-0016-ensure-open-pr-before-merge-verify.md) (amends 0013/0014); детально — `docs/work-items/ORCH-082/06-adr/ADR-001-ensure-open-pr-before-merge-verify.md`. +#### Ретрай транзиентных merge-ошибок Gitea + гард already-in-main (ORCH-093 — фикс ложного HOLD на 405/5xx) +Инцидент **ORCH-063**: self-deploy прошёл, staging OK, PR был `open`+`mergeable`, конфликтов не было, +но `POST /pulls/{n}/merge` вернул `HTTP 405 {"message":"Please try again later"}` (Gitea пересчитывал +`mergeable` сразу после пуша). One-shot `merge_pr` мгновенно вернул `False` → корректная защита +ORCH-071/073 удержала задачу на `deploy` (HOLD+alert) + потребовала **ручной домерж** (повтор влился с +первого раза); повторный прогон финализатора плодил **мусорный пустой PR** на уже влитой ветке. У +Claude-агентов есть transient-breaker, у CI-гейта — `check_ci_green`, а у детерминированного +merge-актора аналога не было. ORCH-093 закрывает это аддитивно, внутри того же под-гейта, не трогая +машину стадий: +- **Retry-loop в `merge_pr` (ORCH-093 D1/D2):** ретраится **только** мутирующий `POST …/merge` + (идемпотентные шаги до него — без изменений). Классификатор `_classify_merge_response → + transient|terminal`: **транзиент** (ретрай с backoff) — `405`/`408`/любой `5xx`/`httpx`-таймаут/ + сетевая ошибка, **и** `409`/`422` когда PR всё ещё `mergeable` (доп. `GET /pulls/{index}`); + **терминал** (быстрый честный `False`, защита ORCH-071/073 как прежде) — `403`/`404`/реальный + конфликт (`409`/`422` при `mergeable==False`). Дефолт-политика `mergeable==None`/недоступно → + транзиент (fail-OPEN-в-ретрай: икота Gitea наблюдаема, бюджет конечен, backstop сохранён). + Backoff экспоненциальный с потолком `min(base*2^(i-1), max)` (дефолты 2/5 с → суммарный сон + `(N-1)*max ≤ 10 с`, monitor-поток merge-verify не подвешивается). Лог `attempt i/N` (образец + `check_ci_green`). +- **Гард already-in-main в `ensure_open_pr` (ORCH-093 D3):** leaf `_branch_fully_in_main` + (`git merge-base --is-ancestor HEAD origin/main` в per-branch worktree) вызывается **между** «код-PR + не найден» и `POST …/pulls`: ветка целиком в `main` (нет коммитов `origin/main..HEAD`) → новый исход + `("already-in-main", …)` **без создания PR** (нет мусорного пустого PR). git-ошибка/ambiguous + (`None`) → **fail-OPEN** (деградация на create-путь, НЕ ложный no-op). Без отдельного флага — + накрыт `merge_verify_autocreate_pr_enabled`. +- **Врезка в `_handle_merge_verify` (ORCH-093 D4):** `pr_status == "already-in-main"` → лог, + **пропуск** `merge_pr` (мержить нечего), сразу к `verify_merged_to_main` (SHA-в-main подтвердит → + `done`). Это НЕ HOLD; SHA-в-main остаётся авторитетным (если SHA не в `main` — прежний HOLD, + fail-closed). +- **Конфиг/откат:** `merge_retry_enabled` (kill-switch; `False` → ровно один POST = байт-в-байт + прежнее one-shot) / `merge_retry_max_attempts` (3) / `merge_retry_backoff_base_s` (2) / + `merge_retry_backoff_max_s` (5), env `ORCH_MERGE_RETRY_*`. `STAGE_TRANSITIONS`, `QG_CHECKS`, схема + БД, exit-коды хука, merge-gate, image-freshness — без изменений; `main` не push/force-push. + +Подробнее: [adr-0027](adr/adr-0027-merge-actor-transient-retry-and-already-in-main.md) +(amends 0013/0014/0016); детально — +`docs/work-items/ORCH-093/06-adr/ADR-001-merge-transient-retry-and-already-in-main-guard.md`. + +#### Ретрай транзиентных merge-ошибок Gitea + гард already-in-main (ORCH-093 — фикс ложного HOLD на 405/5xx) +Инцидент ORCH-063 (09.06): self-deploy прошёл, PR `open`+`mergeable=True`, конфликтов нет — но +`POST …/merge` вернул `HTTP 405 {"message":"Please try again later"}` (Gitea пересчитывал +`mergeable` сразу после пуша). `merge_pr` был **one-shot** → мгновенный `False` → ложный HOLD +ORCH-071/073 + ручной домерж; повторный прогон финализатора после ручного мержа создавал **пустой +PR** на уже влитой ветке. ORCH-093 аддитивно закрывает оба дефекта, не трогая машину стадий: +- **Ретрай-loop в `merge_pr`** оборачивает **только** `POST /pulls/{index}/merge` до + `merge_retry_max_attempts` (дефолт 3) с экспон. backoff и потолком (`merge_retry_backoff_base_s` 2 / + `merge_retry_backoff_max_s` 5; суммарно ≤10 с, не подвешивает monitor-поток). Шаги до POST + (idempotency `pr_already_merged`, поиск код-PR) — без изменений. Лог `attempt i/N` (образец + `check_ci_green`). +- **Классификатор транзиент/терминал** по коду ответа **и** полю `mergeable`: **транзиент** (ретрай) + — `405`/`408`/`5xx`/таймаут/сетевое, `409`/`422` при `mergeable==True`; **терминал** (быстрый + честный `False`) — `403`/`404`, `409`/`422` при `mergeable==False`. Неоднозначный `409/422` + разрешается доп. `GET /pulls/{index}`; `mergeable==None`/недоступен → транзиент-по-дефолту в рамках + бюджета (цель — не давать ложного HOLD на икоте; backstop ORCH-071/073 сохранён). +- **Гард already-in-main в `ensure_open_pr`**: перед созданием PR — `git merge-base --is-ancestor + origin/main` (rc==0 → ветка целиком в `main`) → новый исход `("already-in-main", …)`, PR + **не создаётся**; git-ошибка/ambiguous → **fail-OPEN** на текущий create-путь (икота git не должна + стать ложным no-op мержа). `_handle_merge_verify` трактует `already-in-main` как «мержить нечего» → + пропуск `merge_pr` → авторитетный SHA-в-main (`verify_merged_to_main`) доводит до `done` без мусорного + PR. Это НЕ `failed`-ветка. +- **Защита ORCH-071/073 неприкосновенна:** реальный конфликт → быстрый честный HOLD; подтверждение + merge остаётся ТОЛЬКО SHA-в-main. Терминал/исчерпание ретраев → `(False, …)` → прежний HOLD+alert. +- **Условность / откат:** kill-switch `merge_retry_enabled` (дефолт `true`; `False` → one-shot 1:1, + env `ORCH_MERGE_RETRY_*`); гард already-in-main — без отдельного флага (накрыт + `merge_verify_autocreate_pr_enabled`). Область — `merge_verify_applies` (self-hosting; на прочих + репо мерж за `deployer` — изменение нейтрально). `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, + exit-коды хука, merge-gate, image-freshness — без изменений; `main` не push/force-push (INV-4). + +Подробнее: [adr-0027](adr/adr-0027-merge-actor-transient-retry-and-already-in-main.md) (amends +0013/0014/0016); детально — +`docs/work-items/ORCH-093/06-adr/ADR-001-merge-transient-retry-and-already-in-main-guard.md`. + ### Post-deploy наблюдение прода + реакция на деградацию (ORCH-021 — реализовано) Конвейер заканчивался на `deploy → done` и **забывал про прод**: «успех» = health-check в момент рестарта (~60с). Класс «зелёный деплой, красный прод» (прецедент ET-8 — diff --git a/docs/architecture/adr/adr-0027-merge-actor-transient-retry-and-already-in-main.md b/docs/architecture/adr/adr-0027-merge-actor-transient-retry-and-already-in-main.md new file mode 100644 index 0000000..8bb59ac --- /dev/null +++ b/docs/architecture/adr/adr-0027-merge-actor-transient-retry-and-already-in-main.md @@ -0,0 +1,82 @@ +--- +work_item: ORCH-093 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-09 +model_used: claude-opus-4-8 +--- + +# adr-0027: Merge-актор — ретрай транзиентных ошибок Gitea + гард «ветка уже в `main`» + +Сквозной (cross-cutting) ADR. **Амендмент** к [adr-0013](adr-0013-merge-verify-gate.md) (merge-verify +под-гейт), [adr-0014](adr-0014-merge-verify-sha-source-of-truth.md) (SHA-в-main как источник истины) +и [adr-0016](adr-0016-ensure-open-pr-before-merge-verify.md) (гарантированный код-PR). Детальное +решение задачи — `docs/work-items/ORCH-093/06-adr/ADR-001-merge-transient-retry-and-already-in-main-guard.md`. + +> Регистрируется как сквозной, т.к. правит блок merge-актора с **3+ маркерами** (`ORCH-071`, +> `ORCH-073`, `ORCH-082`) — анти-археология маркеров (`docs/_standards/TRACEABILITY.md`): сводный +> ADR агрегирует эволюцию вместо перечисления work item в коде. + +## Статус +Proposed + +## Контекст + +Детерминированный merge-актор merge-verify под-гейта (`deploy → done`, self-hosting) состоит из +`ensure_open_pr` → `merge_pr` → `verify_merged_to_main` (`src/merge_gate.py`). Инцидент **ORCH-063 +(09.06)** вскрыл два дефекта, оба сверены по коду прода: + +1. `merge_pr` — **one-shot**: `POST /pulls/{index}/merge`, любой не-`200/201` → мгновенный `False`. + Транзиентная икота Gitea (`405 "Please try again later"` при пересчёте `mergeable` сразу после + пуша; `5xx`; таймаут) → ложный HOLD защиты ORCH-071/073 → ручной домерж. +2. `ensure_open_pr` — после ручного мержа код-PR `closed`, открытый не найден → создаёт **новый + пустой PR** на ветке, уже целиком в `main`. + +Защита ORCH-071/073 («deploy succeeded but not merged») корректна и сохраняется; задача снижает +лишь **ложные** срабатывания на транзиентах и устраняет мусорные PR. Это блокер автономного прогона +(эпик ORCH-088). + +## Решение + +Аддитивно, без правки `STAGE_TRANSITIONS` / `QG_CHECKS` / схемы БД; INV-4 (мерж только через Gitea +PR-merge API; никогда `push`/`force-push` в `main`) и never-raise сохранены. + +- **Ретрай-loop вокруг `POST …/merge`** (только мутирующий вызов) до `merge_retry_max_attempts` + (дефолт 3) с экспоненциальным backoff и потолком (`base 2`, `max 5`; суммарно ≤10 с). Классификатор + **транзиент** (`405`/`408`/`5xx`/таймаут/сетевое; `409`/`422` при `mergeable==True`; `mergeable==None` + → транзиент-по-дефолту в рамках бюджета) vs **терминал** (`403`/`404`; `409`/`422` при + `mergeable==False`) — по коду ответа **и** полю `mergeable` (`GET /pulls/{index}`). Терминал → + быстрый честный `False` (защита ORCH-071/073 — как прежде). Образец — `check_ci_green` + (`attempt i/N`) + transient-breaker агентов. +- **Гард already-in-main в `ensure_open_pr`**: перед созданием PR — `git merge-base --is-ancestor + origin/main` (rc==0 → ветка целиком в `main`) → новый исход `"already-in-main"`, PR не + создаётся; git-ошибка/ambiguous → **fail-OPEN** на текущий create-путь (гард не должен превратить + икоту git в ложный no-op мержа). `_handle_merge_verify` трактует `"already-in-main"` как «мержить + нечего» → пропуск `merge_pr` → авторитетный SHA-в-main (`verify_merged_to_main`, ADR-0014) доводит + до `done` без мусорного PR. +- **Конфиг**: `merge_retry_enabled` (kill-switch; `False` → one-shot, нулевая регрессия), + `merge_retry_max_attempts`, `merge_retry_backoff_base_s`, `merge_retry_backoff_max_s` + (env `ORCH_MERGE_RETRY_*`). Гард already-in-main — без отдельного флага (накрыт существующим + `merge_verify_autocreate_pr_enabled`). + +Объём раската — реально только self-hosting (`merge_verify_applies`); на прочих репо мерж делает +LLM-deployer → изменение нейтрально. + +## Последствия + +- **+** Транзиент Gitea переживается автоматически → нет ложного HOLD / ручного домержа в автономном + конвейере; нет мусорных пустых PR; повтор финализатора идемпотентен. +- **+** Реальный конфликт → быстрый честный HOLD; защита ORCH-071/073 и SHA-в-main (ADR-0014) — + авторитетны и неизменны. +- **−** Дефолт `mergeable==None → transient` может добавить ≤10 с до HOLD на реальном конфликте + (бюджет жёстко ограничен); один лишний `GET /pulls/{index}` в редком ambiguous-кейсе. +- **Откат:** `ORCH_MERGE_RETRY_ENABLED=false` → one-shot; `ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED=false` + → отключает врезку `ensure_open_pr` с гардом. Полный откат — revert PR. + +## Ссылки +- Детальный ADR: `docs/work-items/ORCH-093/06-adr/ADR-001-merge-transient-retry-and-already-in-main-guard.md` +- Лехатая: [adr-0006](adr-0006-merge-gate.md), [adr-0013](adr-0013-merge-verify-gate.md), + [adr-0014](adr-0014-merge-verify-sha-source-of-truth.md), + [adr-0016](adr-0016-ensure-open-pr-before-merge-verify.md) +- Код: `src/merge_gate.py`, `src/stage_engine.py::_handle_merge_verify`, `src/config.py` diff --git a/docs/work-items/ORCH-093/00-business-request.md b/docs/work-items/ORCH-093/00-business-request.md new file mode 100644 index 0000000..16708f1 --- /dev/null +++ b/docs/work-items/ORCH-093/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: BUG: merge-актор не ретраит транзиентные ошибки Gitea (405/5xx) → ложный HOLD + мусорные PR + +Work Item ID: ORCH-093 + +## Description + +TBD diff --git a/docs/work-items/ORCH-093/01-brd.md b/docs/work-items/ORCH-093/01-brd.md new file mode 100644 index 0000000..c1a62b5 --- /dev/null +++ b/docs/work-items/ORCH-093/01-brd.md @@ -0,0 +1,145 @@ +--- +work_item: ORCH-093 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-09 +model_used: claude-opus-4-8 +--- + +# 01 — BRD (бизнес-требования): ORCH-093 — merge-актор не ретраит транзиентные ошибки Gitea (405/5xx) → ложный HOLD + мусорные PR + +Work Item: **ORCH-093** · Repo: **orchestrator** · Стадия: analysis + +## 1. Бизнес-контекст и проблема + +Тип: **BUG** (надёжность self-deploy merge-фазы). Найдено по инциденту **ORCH-063 (09.06)**. + +**Инцидент-первоисточник (ORCH-063, 09.06).** Прод-деплой self-hosting прошёл, staging OK, но при +мерже PR в `main` Gitea вернул `HTTP 405 {"message":"Please try again later"}` — транзиентная икота +(Gitea пересчитывал `mergeable` сразу после пуша). PR #98 был `open` + `mergeable=True`, конфликтов +**не было**. Однако merge-актор `merge_gate.merge_pr()` — **one-shot**: на любой не-200/201 он сразу +вернул `(False, "merge failed: HTTP 405")`. Сработала корректная защита ORCH-071/081 «deploy +succeeded but not merged» → задача удержана на `deploy` (НЕ `done`), алерт, потребовался **ручной +домерж** (повтор `merge_pr` вручную → смержилось с первого раза). Защита отработала верно, но +**транзиент не должен был требовать человека**. + +**Два дефекта, оба верифицированы по коду прода `src/merge_gate.py`:** + +- **ДЕФЕКТ 1 — `merge_pr` не ретраит транзиентные HTTP-ошибки.** `merge_gate.merge_pr()` + (`src/merge_gate.py` ~700) делает **один** `POST /pulls/{index}/merge`; на любой не-200/201 + (включая `405 "try again later"`, `5xx`, `409/422` «ещё считается mergeable») сразу + `return False, "merge failed: HTTP {code}"` — без ретрая. Сравни: у Claude-агентов есть + transient-breaker (`429/overload` ретраится), у merge-актора такого механизма нет → инфра-икота + Gitea = ложный HOLD. +- **ДЕФЕКТ 2 — `ensure_open_pr` плодит мусорные PR на уже влитой ветке.** При повторном прогоне + финализатора **после** ручного мержа: PR #98 уже `merged+closed` → `ensure_open_pr` + (`src/merge_gate.py` ~605) не находит открытого code-PR → **создаёт новый пустой PR #99** (ветка + уже в `main`, diff пустой). Пришлось закрывать вручную. + +**Боль:** ложные HOLD при инфра-икоте Gitea требуют ручного вмешательства в автономный конвейер +(эпик ORCH-088 — пакетный автономный прогон) и оставляют мусорные пустые PR. + +## 2. Объём (scope) + +### В объёме +- `merge_pr` ретраит **транзиентные** ошибки мержа (405/«try again», 408, `5xx`, таймаут/сетевые, а + также `409/422` когда PR **всё ещё mergeable**) с ограниченным числом попыток и backoff — **перед** + тем как вернуть `False`. +- Различение «mergeable, но Gitea временно отказал» (ретраить) vs «реальный конфликт / не-mergeable» + (НЕ ретраить, честный быстрый HOLD). +- `ensure_open_pr` / merge-verify **не создаёт** новый PR, если ветка уже полностью в `main` (нет + коммитов `origin/main..branch`) — возвращает исход «already-in-main»; финализатор сразу доводит до + `done` без мусорного PR. +- Конфигурируемость (число ретраев, backoff, kill-switch на ретрай-поведение); разумные дефолты. +- Обновление `.env.example`, `CHANGELOG.md`, merge-gate-раздела документации. + +### Вне объёма +- ❌ Снятие/ослабление защиты ORCH-071/081 «deploy succeeded but not merged» — она корректна; задача + лишь снижает **ложные** срабатывания на транзиентах. +- ❌ Ретрай **реального** конфликта / не-mergeable — это законный HOLD, нужен человек. +- ❌ Любые прямые `push`/`force-push` в `main` (инвариант INV-4 ORCH-071/073 — мерж только через + Gitea PR-merge API). +- ❌ Изменение `STAGE_TRANSITIONS`, состава `QG_CHECKS`, схемы БД. +- ❌ Изменение SHA-in-main-доказательства мержа (`verify_merged_to_main`) как источника истины. + +## 3. Заинтересованные стороны + +- **Заказчик / оператор автономного конвейера (Owner, Стрим)** — меньше ручных домержей, чище список + PR в Gitea. +- **Self-hosting репо `orchestrator`** — основной потребитель merge-verify under-gate (ORCH-071); + изменение в первую очередь касается self-deploy merge-фазы. +- **Все проекты на общем инстансе** — косвенно: меньше зависших на `deploy` задач, держащих + merge-lease и клинящих serial-gate репо (ORCH-088). +- **Reviewer / tester** — принимают результат по AC и зелёному `pytest`. + +## 4. Бизнес-требования (BR) + +- **BR-1** — При транзиентной ошибке мержа (`405`/«Please try again later», `408`, `5xx`, + таймаут/сетевая ошибка) `merge_pr` повторяет `POST …/merge` до `N` раз с backoff, прежде чем + вернуть `(False, …)`; успешный повтор внутри бюджета → `(True, …)`, мерж выполнен. +- **BR-2** — `merge_pr` различает «PR mergeable, Gitea временно отказал» (ретраить) и «реальный + конфликт / PR не mergeable» (НЕ ретраить). Различение опирается на код ответа **и** поле + `mergeable` PR (`GET /pulls/{n}`). Неоднозначный `409/422` классифицируется по `mergeable`. +- **BR-3** — Терминальные ошибки (`404` нет PR / реальный конфликт / `403`) НЕ ретраятся — `merge_pr` + возвращает `(False, …)` быстро; честный HOLD (защита ORCH-071/081) сохраняется. +- **BR-4** — При исчерпании ретраев `merge_pr` возвращает `(False, …)` с понятным reason; защита + «deploy succeeded but not merged» срабатывает как прежде (HOLD + алерт). +- **BR-5** — Если ветка уже полностью в `main` (нет коммитов `origin/main..branch`), `ensure_open_pr` + НЕ создаёт PR — возвращает исход «already-in-main»; merge-verify доводит задачу до `done` без + мусорного пустого PR. +- **BR-6** — Поведение ретрая конфигурируемо: число попыток, backoff и kill-switch; дефолты разумны + (≈3 попытки, backoff 2–5 с) и задокументированы в `.env.example`. +- **BR-7** — При выключенном ретрай-kill-switch поведение `merge_pr` идентично текущему (one-shot) — + нулевая регрессия. + +## 5. Нефункциональные требования (NFR) + +- **NFR-1 (never-raise)** — Контракт never-raise `merge_pr` / `ensure_open_pr` сохранён: любая + HTTP/parse/сетевая ошибка → `(False, …)` / `("failed"|"already-in-main", …)`, исключение никогда не + пробрасывается в `_handle_merge_verify` / `advance_stage`. +- **NFR-2 (self-hosting safety / INV-4)** — Никаких прямых `push`/`force-push` в `main`; мерж только + через Gitea PR-merge API. Прод-контейнер `orchestrator` не перезапускается этой задачей. +- **NFR-3 (обратимость / kill-switch)** — Ретрай-поведение полностью отключаемо одним флагом → + откат к нынешнему one-shot без изменения кода. +- **NFR-4 (ограниченность)** — Суммарное время ретраев ограничено (`N` × backoff_max) и не может + «подвесить» monitor-поток, исполняющий merge-verify; backoff с верхним потолком. +- **NFR-5 (идемпотентность)** — Повторный прогон финализатора на уже влитой ветке безопасен и + бесследен (нет дублей PR, нет дублей мержа — переиспользуется `pr_already_merged`). +- **NFR-6 (наблюдаемость)** — Каждый ретрай и его причина логируются (по образцу `check_ci_green`: + `attempt i/N`); исход (успех/исчерпание/терминал) различим в логе. + +## 6. Допущения и ограничения + +- Gitea-код `405 {"message":"Please try again later"}` — **транзиент** (Gitea пересчитывает + `mergeable` сразу после пуша); `5xx`/таймаут/сетевая — транзиент. +- `409` (conflict) и `422` (unprocessable) **двойственны**: либо реальный конфликт, либо «ещё не + пересчитан mergeable». Источник различения — поле `mergeable` из `GET /pulls/{n}` (а не только + код): `mergeable==True` → транзиент (ретраить), `mergeable==False` → реальный конфликт (НЕ + ретраить). +- `404` (нет PR) обрабатывается раньше шагом «no open PR» и/или трактуется как терминал. +- Образец паттерна ретрая уже есть в репо: `check_ci_green` (`src/qg/checks.py`, attempts + interval + + backoff) и transient-breaker агентов (`backoff_base_seconds`/`backoff_max_seconds`/ + `transient_max_attempts` в `config.py`). +- Merge-verify under-gate (ORCH-071) реален только для self-hosting (`merge_verify_applies`); на + прочих репо мерж делает LLM-deployer — там изменение `merge_pr` не задействуется. +- Изменение **точечное** в `src/merge_gate.py` + флаги в `src/config.py`; `STAGE_TRANSITIONS`, + `QG_CHECKS`, схема БД не трогаются. + +## 7. Критерии успеха + +`merge_pr` переживает транзиентную икоту Gitea (405/5xx/таймаут/«not mergeable yet») за счёт +ограниченного ретрая с backoff и больше не даёт ложного HOLD; реальный конфликт по-прежнему даёт +быстрый честный HOLD; `ensure_open_pr` не создаёт мусорных PR на уже влитой ветке; поведение +конфигурируемо и отключаемо; never-raise сохранён; `pytest tests/ -q` зелёный; доки и `.env.example` +обновлены. Детальные PASS/FAIL — `03-acceptance-criteria.md`. + +## 8. Риски + +- Слишком агрессивный ретрай реального конфликта → задержка честного HOLD (митигируется BR-2/BR-3: + классификация по `mergeable`). +- Ошибочная классификация транзиента как терминала (или наоборот) при неполном ответе Gitea + (`mergeable=None`) — нужна осторожная дефолт-политика. +- Гонка `ensure_open_pr` already-in-main vs параллельный мерж. + +Детали и оценка — `10-tech-risks.md` (заполняет архитектор). diff --git a/docs/work-items/ORCH-093/02-trz.md b/docs/work-items/ORCH-093/02-trz.md new file mode 100644 index 0000000..beec2a8 --- /dev/null +++ b/docs/work-items/ORCH-093/02-trz.md @@ -0,0 +1,142 @@ +--- +work_item: ORCH-093 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-09 +model_used: claude-opus-4-8 +--- + +# 02 — ТЗ (TRZ): ORCH-093 — merge-актор ретраит транзиентные ошибки Gitea + гард «ветка уже в main» + +Work Item: **ORCH-093** · Repo: **orchestrator** · Стадия: analysis + +> ТЗ описывает **конкретные изменения к реализации**, выведенные из BRD и фактического кода +> (`src/merge_gate.py`, `src/config.py`, `src/stage_engine.py`). Архитектурное обоснование (точный +> алгоритм классификации, формат хелпера, выбор дефолтов) — задача архитектора (`06-adr`). + +## 1. Сводка изменения + +Две точечные доработки `src/merge_gate.py`: + +1. **`merge_pr` (~700)** — обернуть `POST /pulls/{index}/merge` в **retry-loop** на транзиентных + кодах (`405`/«try again», `408`, `5xx`, таймаут/сетевые, плюс `409/422` при `mergeable==True`) с + ограниченным числом попыток и backoff; **терминальные** исходы (`404` нет PR, реальный конфликт / + `mergeable==False`, `403`) → быстрый `(False, …)` без ретрая. По образцу `check_ci_green` + (attempts + interval) и transient-breaker агентов. +2. **`ensure_open_pr` (~605)** — добавить гард «ветка уже полностью в `main`» (нет коммитов + `origin/main..branch`) → новый исход `"already-in-main"` **до** создания PR; в + `_handle_merge_verify` этот исход трактуется как «мерж уже состоялся» → SHA-in-main подтверждает → + `done` без мусорного PR. + +Новые флаги ретрая в `src/config.py` (`ORCH_MERGE_RETRY_*`) + дескрипторы в `.env.example`. Контракт +never-raise и INV-4 (никогда не `push`/`force-push` `main`) — сохраняются. `STAGE_TRANSITIONS`, +`QG_CHECKS`, схема БД — **не трогаются**. + +## 2. Задействованные модули / пути + +| Путь | Действие | +|------|----------| +| `src/merge_gate.py` | изменить — `merge_pr` (retry-loop + классификатор транзиент/терминал); `ensure_open_pr` (гард already-in-main); при необходимости leaf-хелперы `_is_transient_merge_error()` / `_branch_fully_in_main()` | +| `src/config.py` | изменить — добавить флаги ретрая мержа (`merge_retry_enabled`, `merge_retry_max_attempts`, `merge_retry_backoff_base_s`, `merge_retry_backoff_max_s`) по образцу `ci_poll_*` / `merge_pr_timeout_s` | +| `src/stage_engine.py` | изменить (точечно) — `_handle_merge_verify` (~1447): обработать новый исход `ensure_open_pr == "already-in-main"` как «мерж уже состоялся» (пропустить `merge_pr`, дать `verify_merged_to_main` подтвердить → `done`) | +| `.env.example` | изменить — новые дескрипторы `ORCH_MERGE_RETRY_*` | +| `tests/test_merge_gate.py` | изменить — мок httpx-последовательностей (405×2→200; конфликт; already-in-main; исчерпание; kill-switch off) | +| `CHANGELOG.md` | изменить — запись ORCH-093 | +| `docs/architecture/README.md` (merge-gate раздел) / `CLAUDE.md` | изменить — описать ретрай и гард already-in-main | + +## 3. Функциональные требования + +### FR-1 — retry-loop транзиентных ошибок мержа в `merge_pr` (BR-1, BR-4, BR-6, BR-7) +- Шаги `merge_pr` до `POST` (idempotency-guard `pr_already_merged`; `GET …/pulls?state=open` поиск + code-PR `head==branch AND base==main`; `index is None → (False, "no open PR")`) — **без изменений**. +- `POST /pulls/{index}/merge` выполняется в цикле до `merge_retry_max_attempts` попыток (дефолт `3`): + - `200/201` → `(True, "merged PR #")` (немедленный выход). + - **транзиентный** исход (см. FR-2) И остались попытки → `sleep(backoff)` и повтор `POST`; + `backoff` экспоненциальный от `merge_retry_backoff_base_s` (дефолт `2`) с потолком + `merge_retry_backoff_max_s` (дефолт `5`). + - **терминальный** исход (см. FR-2) → немедленно `(False, "merge failed: HTTP ")` без + дальнейших попыток. + - исчерпание попыток на транзиенте → `(False, "merge failed after attempts: HTTP ")`. +- **Kill-switch** `merge_retry_enabled=False` → ровно одна попытка `POST` (текущее one-shot + поведение, BR-7). +- Каждая попытка логируется (`attempt i/N`, код, transient/terminal) — образец `check_ci_green`. + +### FR-2 — классификация транзиент vs терминал (BR-2, BR-3) +- **Транзиентные** (ретраить): `405` («Please try again later»), `408` (timeout), любой `5xx`, + `httpx`-таймаут / сетевая ошибка, **и** `409`/`422` когда PR **всё ещё mergeable**. +- **Терминальные** (НЕ ретраить, быстрый `False`): `403` (нет прав), `404` (PR исчез), и `409`/`422` + при **реальном конфликте** (`mergeable==False`). +- Различение неоднозначного `409`/`422`: дополнительный `GET /pulls/{index}` → поле `mergeable`: + - `mergeable==True` → транзиент (Gitea ещё не пересчитал) → ретрай. + - `mergeable==False` → реальный конфликт → терминал. + - `mergeable` отсутствует/`None` → консервативная дефолт-политика (рекомендация аналитика: + трактовать как транзиент с тем же ограниченным бюджетом ретраев, т.к. сетевая икота Gitea — + наблюдаемый кейс; финальное решение — архитектор в `06-adr`). +- Сетевые/таймаут-исключения `httpx` внутри попытки ловятся (never-raise) и классифицируются как + транзиент в рамках того же бюджета. + +### FR-3 — гард «ветка уже полностью в main» в `ensure_open_pr` (BR-5) +- Перед шагом «создать PR» (после того как открытый code-PR не найден) `ensure_open_pr` проверяет, + что в ветке нет коммитов сверх `origin/main`: в per-branch worktree `git fetch origin main` + + `git rev-list --count origin/main..` (или `git merge-base --is-ancestor origin/main`). + - count `== 0` (ветка целиком в `main`) → `("already-in-main", "")` — **PR не создаётся**. + - count `> 0` (есть невлитые коммиты) → текущий путь `POST …/pulls` (создать code-PR). + - git/OS ошибка проверки → **не** блокировать (never-raise); деградировать на текущее поведение + (попытаться создать PR) ИЛИ вернуть `failed` — точную fail-политику фиксирует архитектор. Гард + не должен превратить инфра-икоту git в ложный no-op мержа. +- Сигнатура возврата `ensure_open_pr` расширяется новым статусом `"already-in-main"` дополнительно к + `"existed"|"created"|"failed"` (обратносовместимо для существующих веток вызова). + +### FR-4 — обработка `already-in-main` в `_handle_merge_verify` (BR-5) +- В `stage_engine._handle_merge_verify` (~1487): при `pr_status == "already-in-main"` — + логировать, **пропустить** `merge_gate.merge_pr` (мержить нечего) и перейти сразу к + `verify_merged_to_main` (SHA-in-main подтвердит факт мержа → `done`). Это НЕ `failed`-ветка (не + HOLD): ветка уже в `main`, цель достигнута. +- SHA-in-main (`verify_merged_to_main`) остаётся **авторитетным** доказательством мержа; гард только + избегает мусорного PR и лишнего `merge_pr`. + +### FR-5 — конфигурация и обратная совместимость (BR-6, BR-7) +- Новые поля `settings` (см. §2) с дефолтами; читаются из env (`ORCH_MERGE_RETRY_*`). +- При `merge_retry_enabled=False` — поведение `merge_pr` байт-в-байт как сейчас (one-shot). +- Гард already-in-main также под флагом ИЛИ всегда-вкл (рекомендация: всегда-вкл, т.к. он лишь + предотвращает создание заведомо пустого PR; решение — архитектор). + +## 4. Изменения API + +Нет (внешних HTTP-эндпоинтов оркестратора не добавляется/не меняется). Меняется только клиентское +обращение к Gitea API внутри `merge_gate` (дополнительный `GET /pulls/{index}` для чтения +`mergeable` при неоднозначном `409/422`; ретрай `POST …/merge`). Read-only блок merge-verify в +`GET /queue` (`merge_verify_status()`) опционально может получить счётчик ретраев (необязательно). + +## 5. Изменения схемы БД + +Нет. (Merge-lease — файловый, не БД; счётчики `_MERGE_VERIFY_COUNTERS` — in-process. Новые поля — +только в `config.Settings`, не в схеме.) + +## 6. Требования к новым/изменённым QG checks + +Нет. `STAGE_TRANSITIONS`, состав `QG_CHECKS`, exit-гейты рёбер и под-гейты ребра +`deploy-staging → deploy` — **не трогаются**. Изменение целиком внутри детерминированного +merge-актора `merge_pr`/`ensure_open_pr` (под-гейт-врезка `_handle_merge_verify` ребра +`deploy → done`), который НЕ зарегистрирован в `QG_CHECKS`. + +## 7. Совместимость / регресс + +- **Kill-switch `merge_retry_enabled=False`** → one-shot `merge_pr` (текущее поведение) — нулевая + регрессия. +- **Защита ORCH-071/081** «deploy succeeded but not merged» сохраняется 1:1: после исчерпания + ретраев / на терминальном конфликте `merge_pr` возвращает `False`, и при неподтверждённом + SHA-in-main срабатывает прежний HOLD + алерт. +- **INV-4 / self-hosting safety**: никаких `push`/`force-push` в `main`; мерж только через Gitea + PR-merge API; прод-контейнер не перезапускается. +- **never-raise**: `merge_pr` / `ensure_open_pr` ловят все исключения и возвращают безопасный + кортеж — контракт сохранён (тесты на never-raise остаются зелёными). +- **Идемпотентность**: `pr_already_merged` (idempotency-guard) и гард already-in-main делают + повторный прогон финализатора бесследным (нет дублей PR/мержей). +- **Область раската**: реально задействуется на merge-verify under-gate (self-hosting, + `merge_verify_applies`); на прочих репо merge делает LLM-deployer — изменение нейтрально. +- **Артефакты pipeline**: создаётся/обновляется только аналитический пакет (`01`–`04`); в + development-стадии обновятся `CHANGELOG.md`, `.env.example`, merge-gate-раздел доки. ADR + (`06-adr/`) — пишет архитектор. +- Полный регресс `pytest tests/ -q` должен оставаться зелёным. diff --git a/docs/work-items/ORCH-093/03-acceptance-criteria.md b/docs/work-items/ORCH-093/03-acceptance-criteria.md new file mode 100644 index 0000000..24f9136 --- /dev/null +++ b/docs/work-items/ORCH-093/03-acceptance-criteria.md @@ -0,0 +1,114 @@ +--- +work_item: ORCH-093 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-09 +model_used: claude-opus-4-8 +--- + +# 03 — Критерии приёмки (Acceptance Criteria): ORCH-093 — ретрай транзиентных merge-ошибок Gitea + гард already-in-main + +Work Item: **ORCH-093** · Repo: **orchestrator** · Стадия: analysis + +Формат: каждый критерий имеет **PASS** (что должно быть истинно для приёмки) и **FAIL** (что +считается провалом). Reviewer/tester проверяет их буквально по файлам репозитория и тестам. + +--- + +## AC-1 — ретрай транзиента 405/5xx/таймаут → успешный мерж + +**Условие:** `merge_pr` при транзиентной ошибке мержа повторяет `POST …/merge` с backoff и +доводит мерж до успеха в пределах бюджета. +- **PASS:** мок httpx даёт на `POST …/merge` `405` дважды, затем `200` → `merge_pr` возвращает + `(True, …)`, выполнено ровно 3 `POST`, ложного `False` нет. Аналогично для `5xx` и + таймаута/сетевой ошибки в первых попытках. +- **FAIL:** `merge_pr` возвращает `False` на первом `405`/`5xx`/таймауте (one-shot), не делая + повторных `POST`. + +--- + +## AC-2 — реальный конфликт / не-mergeable НЕ ретраится (быстрый честный HOLD) + +**Условие:** `merge_pr` при реальном конфликте (`409`/`422` с `mergeable==False`) или `403` не +зацикливается, а возвращает `(False, …)` быстро. +- **PASS:** мок httpx даёт `409` на `POST …/merge` и `GET /pulls/{n}` с `mergeable=False` → + `merge_pr` возвращает `(False, …)` без дополнительных `POST` (не более одной попытки мержа); + reason различим как терминальный. `403` → немедленный `(False, …)`. +- **FAIL:** `merge_pr` ретраит реальный конфликт до исчерпания бюджета (вечный/долгий цикл), + задерживая честный HOLD. + +--- + +## AC-3 — исчерпание ретраев → (False, …) + защита ORCH-071/081 как прежде + +**Условие:** если транзиент не проходит за `N` попыток, `merge_pr` возвращает `(False, …)` с +понятным reason; защита «deploy succeeded but not merged» срабатывает как раньше. +- **PASS:** мок даёт `405` на всех `N` попытках → `merge_pr` возвращает + `(False, "merge failed after attempts: HTTP 405")` (или эквивалент); в `_handle_merge_verify` + неподтверждённый SHA-in-main → HOLD + алерт (поведение ORCH-071/081 неизменно). Тест на + не-merged HOLD остаётся зелёным. +- **FAIL:** при исчерпании ретраев reason неинформативен; или защита HOLD не срабатывает / задача + ошибочно уходит в `done`. + +--- + +## AC-4 — гард «ветка уже в main» → нет мусорного PR, задача доходит до done + +**Условие:** если ветка уже полностью в `main` (нет коммитов `origin/main..branch`), +`ensure_open_pr` не создаёт PR и возвращает `already-in-main`; финализатор доводит до `done`. +- **PASS:** мок: открытого code-PR нет, `git rev-list --count origin/main..branch == 0` → + `ensure_open_pr` возвращает `("already-in-main", …)` и **не делает** `POST …/pulls`; в + `_handle_merge_verify` этот статус пропускает `merge_pr` и `verify_merged_to_main` (SHA-in-main) + подтверждает мерж → задача доходит до `done` без создания пустого PR. +- **FAIL:** `ensure_open_pr` создаёт новый пустой PR на уже влитой ветке, либо статус + `already-in-main` ошибочно трактуется как `failed` (ложный HOLD). + +--- + +## AC-5 — kill-switch / конфиг ретраев; дефолты задокументированы + +**Условие:** ретрай-поведение конфигурируемо (число попыток, backoff, kill-switch); при выключении — +one-shot как сейчас; дефолты в `.env.example`. +- **PASS:** в `src/config.py` есть поля `merge_retry_enabled` / `merge_retry_max_attempts` / + `merge_retry_backoff_base_s` / `merge_retry_backoff_max_s` с разумными дефолтами (≈3 / 2 / 5); + `.env.example` содержит дескрипторы `ORCH_MERGE_RETRY_*`; при `merge_retry_enabled=False` тест + подтверждает ровно одну попытку `POST` (one-shot). +- **FAIL:** ретрай захардкожен (нет флагов/kill-switch), или `.env.example` не обновлён, или при + выключенном флаге поведение отличается от текущего one-shot. + +--- + +## AC-6 — never-raise сохранён; регресс зелёный; доки обновлены + +**Условие:** контракт never-raise `merge_pr`/`ensure_open_pr` цел; полный регресс зелёный; +документация и `CHANGELOG` обновлены. +- **PASS:** при любой HTTP/parse/сетевой ошибке (в т.ч. внутри ретрай-цикла и git-проверки гарда) + функции возвращают безопасный кортеж, исключение не пробрасывается; `pytest tests/ -q` зелёный; + merge-gate-раздел доки (`docs/architecture/README.md` / `CLAUDE.md`) и `CHANGELOG.md` описывают + ретрай и гард already-in-main. +- **FAIL:** исключение пробрасывается в `advance_stage`; падает любой тест в `tests/`; доки/CHANGELOG + не отражают изменение. + +--- + +## AC-7 — инварианты self-hosting / INV-4 не нарушены + +**Условие:** изменение не вводит прямых `push`/`force-push` в `main` и не трогает +`STAGE_TRANSITIONS`/`QG_CHECKS`/схему БД. +- **PASS:** мерж по-прежнему идёт только через Gitea PR-merge API; `git diff` не содержит правок + `STAGE_TRANSITIONS` / состава `QG_CHECKS` / схемы БД; никаких новых вызовов `git push … main`. +- **FAIL:** появился прямой push в `main`, либо изменены `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД. + +--- + +## Сводная матрица AC ↔ FR/BR +| AC | Покрывает | +|----|-----------| +| AC-1 | BR-1 / FR-1, FR-2 | +| AC-2 | BR-2, BR-3 / FR-2 | +| AC-3 | BR-4 / FR-1 | +| AC-4 | BR-5 / FR-3, FR-4 | +| AC-5 | BR-6, BR-7 / FR-5 | +| AC-6 | NFR-1, NFR-6 / FR-1…FR-5 | +| AC-7 | NFR-2 / §6, §7 ТЗ | diff --git a/docs/work-items/ORCH-093/04-test-plan.yaml b/docs/work-items/ORCH-093/04-test-plan.yaml new file mode 100644 index 0000000..8d68244 --- /dev/null +++ b/docs/work-items/ORCH-093/04-test-plan.yaml @@ -0,0 +1,116 @@ +work_item: ORCH-093 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-09 +model_used: claude-opus-4-8 +title: "Ретрай транзиентных merge-ошибок Gitea (405/5xx) + гард already-in-main" +framework: pytest +scope: > + Покрывает src/merge_gate.py::merge_pr (retry-loop + классификация транзиент/терминал) и + ensure_open_pr (гард «ветка уже в main»), новые флаги src/config.py (ORCH_MERGE_RETRY_*) и + обработку already-in-main в stage_engine._handle_merge_verify. Вне покрытия: реальная сеть Gitea, + STAGE_TRANSITIONS/QG_CHECKS, схема БД. +notes: > + httpx мокается monkeypatch'ем (по образцу tests/test_merge_gate.py / test_orch073_merge_pr.py): + последовательности ответов на POST /pulls/{n}/merge и GET /pulls/{n}. time.sleep патчится в no-op, + чтобы backoff не замедлял тесты. git-операции гарда (rev-list/merge-base) мокаются через + monkeypatch subprocess.run. Полный регресс tests/ должен оставаться зелёным; считается регрессом + любое падение существующих test_merge_gate*/test_merge_verify*/test_orch073*. + +tests: + - id: TC-01 + type: unit + description: "merge_pr: POST даёт 405,405,200 -> возвращает (True, merged PR #n); ровно 3 POST; ложного False нет (AC-1)" + module: tests/test_merge_gate.py + expected: PASS + + - id: TC-02 + type: unit + description: "merge_pr: POST даёт 503 (5xx), затем 200 -> ретрай -> (True, ...) (AC-1)" + module: tests/test_merge_gate.py + expected: PASS + + - id: TC-03 + type: unit + description: "merge_pr: POST бросает httpx Timeout/сетевую ошибку в 1-й попытке, затем 200 -> ретрай -> (True, ...); never-raise (AC-1, AC-6)" + module: tests/test_merge_gate.py + expected: PASS + + - id: TC-04 + type: unit + description: "merge_pr: реальный конфликт 409 + GET /pulls/{n} mergeable=False -> (False, ...) без доп. POST (терминал, быстрый HOLD) (AC-2)" + module: tests/test_merge_gate.py + expected: PASS + + - id: TC-05 + type: unit + description: "merge_pr: неоднозначный 409 + GET /pulls/{n} mergeable=True -> классифицирован как транзиент -> ретрай -> 200 -> (True, ...) (AC-2)" + module: tests/test_merge_gate.py + expected: PASS + + - id: TC-06 + type: unit + description: "merge_pr: 403 (нет прав) -> немедленно (False, ...) без ретрая (терминал) (AC-2)" + module: tests/test_merge_gate.py + expected: PASS + + - id: TC-07 + type: unit + description: "merge_pr: 405 на всех N попытках -> (False, 'merge failed after N attempts: HTTP 405') с понятным reason (AC-3)" + module: tests/test_merge_gate.py + expected: PASS + + - id: TC-08 + type: unit + description: "merge_pr: kill-switch merge_retry_enabled=False -> ровно один POST (one-shot, как сейчас) при 405 -> (False, ...) (AC-5, AC-3)" + module: tests/test_merge_gate.py + expected: PASS + + - id: TC-09 + type: unit + description: "ensure_open_pr: открытого code-PR нет, rev-list --count origin/main..branch == 0 -> ('already-in-main', ...); POST /pulls НЕ вызывается (AC-4)" + module: tests/test_merge_gate.py + expected: PASS + + - id: TC-10 + type: unit + description: "ensure_open_pr: открытого PR нет, есть невлитые коммиты (count>0) -> создаёт PR ('created', ...) (регресс прежнего поведения) (AC-4)" + module: tests/test_merge_gate.py + expected: PASS + + - id: TC-11 + type: unit + description: "ensure_open_pr: git-ошибка проверки гарда -> never-raise, безопасный кортеж, без падения (AC-6)" + module: tests/test_merge_gate.py + expected: PASS + + - id: TC-12 + type: unit + description: "merge_pr/ensure_open_pr: любая непойманная httpx/parse ошибка -> (False/failed, ...) кортеж, исключение не пробрасывается (never-raise) (AC-6)" + module: tests/test_merge_gate.py + expected: PASS + + - id: TC-13 + type: unit + description: "config: дефолты merge_retry_enabled/merge_retry_max_attempts/backoff_base/backoff_max присутствуют и читаются из ORCH_MERGE_RETRY_* env (AC-5)" + module: tests/test_config.py + expected: PASS + + - id: TC-14 + type: integration + description: "_handle_merge_verify: ensure_open_pr -> 'already-in-main' пропускает merge_pr, verify_merged_to_main (SHA-in-main) подтверждает -> задача доходит до done без мусорного PR (AC-4)" + module: tests/test_merge_verify.py + expected: PASS + + - id: TC-15 + type: integration + description: "_handle_merge_verify: merge_pr исчерпал ретраи (False) и SHA-in-main не подтверждён -> HOLD + alert (ORCH-071/081 как прежде), задача удержана на deploy, не done (AC-3)" + module: tests/test_merge_verify.py + expected: PASS + + - id: TC-16 + type: integration + description: "_handle_merge_verify happy-path: транзиент 405x2->200 в merge_pr -> SHA-in-main подтверждён -> done без ложного HOLD (end-to-end под-гейта deploy->done) (AC-1)" + module: tests/test_merge_verify.py + expected: PASS diff --git a/docs/work-items/ORCH-093/06-adr/ADR-001-merge-transient-retry-and-already-in-main-guard.md b/docs/work-items/ORCH-093/06-adr/ADR-001-merge-transient-retry-and-already-in-main-guard.md new file mode 100644 index 0000000..21009eb --- /dev/null +++ b/docs/work-items/ORCH-093/06-adr/ADR-001-merge-transient-retry-and-already-in-main-guard.md @@ -0,0 +1,222 @@ +--- +work_item: ORCH-093 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-09 +model_used: claude-opus-4-8 +--- + +# ADR-001: Ретрай транзиентных merge-ошибок Gitea + гард «ветка уже в `main`» (ORCH-093) + +Work Item: **ORCH-093** — merge-актор не ретраит транзиентные ошибки Gitea (405/5xx) → ложный HOLD + мусорные PR +Стадия: **architecture** +Сквозная регистрация: **`docs/architecture/adr/adr-0027-merge-actor-transient-retry-and-already-in-main.md`** +(амендмент к [adr-0013](../../../architecture/adr/adr-0013-merge-verify-gate.md) / +[adr-0014](../../../architecture/adr/adr-0014-merge-verify-sha-source-of-truth.md) / +[adr-0016](../../../architecture/adr/adr-0016-ensure-open-pr-before-merge-verify.md) — лехатая merge-verify под-гейта). + +## Статус +Proposed + +## Контекст + +Инцидент **ORCH-063 (09.06)**: self-deploy прошёл, staging OK, PR #98 был `open` + `mergeable=True`, +конфликтов не было — но `POST /pulls/98/merge` вернул `HTTP 405 {"message":"Please try again later"}` +(Gitea пересчитывал `mergeable` сразу после пуша). Сверено по коду прода `src/merge_gate.py`: + +- **`merge_pr` (`src/merge_gate.py:700`) — one-shot.** Тело цикла отсутствует: единственный + `POST /pulls/{index}/merge` (стр. 747-752); любой не-`200/201` → немедленно + `return False, "merge failed: HTTP {code}"` (стр. 761). Транзиентная икота Gitea = мгновенный + `False`. Сработала корректная защита ORCH-071/073 «deploy succeeded but not merged» + (`_handle_merge_verify`, `src/stage_engine.py:1527`) → задача удержана на `deploy`, алерт, + **потребовался ручной домерж** (повтор `merge_pr` вручную → влилось с первого раза). +- **`ensure_open_pr` (`src/merge_gate.py:605`) — плодит мусорный PR.** При повторном прогоне + финализатора **после** ручного мержа: код-PR уже `merged+closed` → `_find_open_code_pr()` + (стр. 639) → `None` → шаг 2 `POST …/pulls` (стр. 663) создаёт **новый пустой PR** на ветке, + которая уже целиком в `main` (diff пустой). Пришлось закрывать вручную. + +Контраст: у Claude-агентов есть transient-breaker (`429/overload` ретраится, +`config.transient_max_attempts`/`backoff_*`), у CI-гейта — `check_ci_green` +(`src/qg/checks.py:82`, `ci_poll_max_attempts` × `ci_poll_interval_s` с логом `attempt i/N`). +У детерминированного merge-актора аналога нет. Защита ORCH-071/073 отработала верно, но +**транзиент не должен был требовать человека** — это блокер автономного прогона (эпик ORCH-088) и +оставляет мусор в списке PR Gitea. + +«Как есть» не годится: инфра-икота Gitea = ложный HOLD + ручное вмешательство в автономный конвейер. + +## Решение + +### Сводка + +Две точечные доработки `src/merge_gate.py`, обе **аддитивны**, never-raise, под существующими +kill-switch'ами; `STAGE_TRANSITIONS` / `QG_CHECKS` / схема БД — не трогаются; INV-4 (мерж только +через Gitea PR-merge API, никогда `push`/`force-push` в `main`) сохранён. + +1. **`merge_pr`** — обернуть **только** `POST …/merge` в ограниченный retry-loop на транзиентных + исходах; терминальные → быстрый честный `False` (защита ORCH-071/073 — как прежде). +2. **`ensure_open_pr`** — гард «ветка уже полностью в `main`» **до** создания PR → новый исход + `"already-in-main"`; `_handle_merge_verify` трактует его как «мержить нечего» и даёт + авторитетному SHA-in-main (`verify_merged_to_main`) довести до `done` без мусорного PR. + +### D1 — retry-loop вокруг `POST …/merge` в `merge_pr` (BR-1, BR-4, BR-6, BR-7 / FR-1) + +Шаги `merge_pr` **до** POST — без изменений (идемпотентность `pr_already_merged`; `GET …/pulls?state=open` +поиск код-PR `head==branch AND base==main`; `index is None → (False, "no open PR")`). Ретраится +**исключительно** мутирующий `POST /pulls/{index}/merge`: + +- Цикл `for attempt in range(1, N+1)`, `N = settings.merge_retry_max_attempts` (дефолт `3`). +- `200/201` → немедленный `(True, "merged PR #")`. +- **транзиентный** исход (D2) И `attempt < N` → лог `attempt i/N` (образец `check_ci_green`) → + `time.sleep(backoff(attempt))` → повтор POST. +- **терминальный** исход (D2) → немедленно `(False, "merge failed: HTTP ")`, без ретрая. +- исчерпание на транзиенте → `(False, "merge failed after attempts: HTTP ")`. + +**Backoff** — экспоненциальный c потолком (идиома transient-breaker агентов, ограничен NFR-4): +`backoff(i) = min(merge_retry_backoff_base_s * 2**(i-1), merge_retry_backoff_max_s)` +(дефолты base `2`, max `5`). Суммарный сон ограничен `(N-1) × backoff_max ≤ 10 с`; плюс +`merge_pr_timeout_s` на POST → верхняя граница задержки детерминирована и **не подвешивает** +monitor-поток, исполняющий merge-verify (NFR-4). + +**Kill-switch** `merge_retry_enabled=False` → ровно одна попытка POST = байт-в-байт текущее one-shot +поведение (BR-7, нулевая регрессия). Реализуется как `N_eff = N if merge_retry_enabled else 1` без +ветвления тела цикла. + +Привязка: AC-1 (405×2→200 = 3 POST, `True`), AC-3 (405×N → `False` + понятный reason), AC-5 +(kill-switch → 1 POST). + +### D2 — классификация транзиент vs терминал (BR-2, BR-3 / FR-2) + +Leaf-хелпер `_classify_merge_response(repo, branch, index, status_code) -> "transient" | "terminal"` +(never-raise). Дерево решений: + +| Исход POST | Класс | Действие | +|------------|-------|----------| +| `405` («try again later»), `408`, любой `5xx` | **transient** | ретрай | +| `httpx`-таймаут / сетевое исключение | **transient** | ретрай (ловится внутри попытки, never-raise) | +| `403` (нет прав), `404` (PR исчез) | **terminal** | быстрый `False` | +| `409` / `422` | **ambiguous** → доп. `GET /pulls/{index}` → поле `mergeable` | см. ниже | + +Разрешение неоднозначного `409/422` по `GET /pulls/{index}` → `mergeable`: +- `mergeable == True` → **transient** (Gitea ещё не пересчитал — корневой кейс ORCH-063) → ретрай. +- `mergeable == False` → **terminal** (реальный конфликт) → быстрый честный HOLD. +- `mergeable` отсутствует / `None` / сам `GET` упал → **transient** в рамках того же ограниченного + бюджета (см. дефолт-политику ниже). + +**Дефолт-политика для `mergeable == None`/недоступного — транзиент** (принято от рекомендации +аналитика, FR-2). Обоснование: (а) цель задачи — не давать ложного HOLD на икоте Gitea, а икота — +именно наблюдаемый кейс с неполным/запаздывающим `mergeable`; (б) цена ошибки ограничена — даже +если за `None` скрывается реальный конфликт, бюджет ретраев конечен (`≤10 с`), после чего +`merge_pr` всё равно вернёт `False` → срабатывает **та же** защита ORCH-071/073 (HOLD + алерт); +(в) обратный выбор (терминал по `None`) воспроизводит ровно тот ложный HOLD, что чинит задача. +Таким образом дефолт fail-OPEN-в-ретрай безопасен: автономность выигрывает, корректность +backstop'а сохранена. + +Привязка: AC-1 (транзиент → ретрай), AC-2 (`409`+`mergeable=False`/`403` → терминал, ≤1 POST). + +### D3 — гард «ветка уже полностью в `main`» в `ensure_open_pr` (BR-5 / FR-3) + +Новый leaf-хелпер `_branch_fully_in_main(repo, branch) -> bool | None` (never-raise), вызывается в +`ensure_open_pr` **после** того как `_find_open_code_pr()` вернул `None` и **до** `POST …/pulls`: + +- В per-branch worktree (`ensure_worktree`, изоляция ORCH-2): `git fetch origin main` → + `git merge-base --is-ancestor origin/main` (идиома уже используется в + `branch_is_behind_main` / `verify_merged_to_main`; эквивалент `git rev-list --count origin/main..HEAD == 0`). + - `rc == 0` → ветка целиком в `main` → `True`. + - `rc == 1` → есть невлитые коммиты → `False`. + - git/OS-ошибка / ambiguous rc → `None`. + +Маппинг в `ensure_open_pr`: +- `True` → новый исход `("already-in-main", "")` — **PR не создаётся**. +- `False` → текущий путь шага 2 (`POST …/pulls` создать код-PR) — без изменений. +- `None` (**fail-OPEN**) → деградировать на текущее поведение (попытаться создать PR), **НЕ** + блокировать. Обоснование: единственная цель гарда — избежать заведомо пустого PR; вернуть + `"failed"` на git-икоте значило бы превратить инфра-икоту git в ложный no-op/HOLD мержа — ровно + анти-паттерн, против которого предостерегает BRD. SHA-in-main downstream остаётся авторитетным: + даже если на git-ошибке гард ошибётся и создаст пустой PR, это лишь косметика, не ложный `done`. + +Сигнатура `ensure_open_pr` расширяется исходом `"already-in-main"` дополнительно к +`"existed"|"created"|"failed"` (обратносовместимо для существующих веток вызова). + +**Без отдельного флага:** гард — чистый fail-OPEN correctness-guard, уже целиком накрыт +существующим kill-switch'ем `merge_verify_autocreate_pr_enabled` (вся врезка `ensure_open_pr` в +`_handle_merge_verify` под ним — `src/stage_engine.py:1486`). Отдельный флаг был бы избыточной +конфиг-поверхностью (принято от рекомендации FR-5: «всегда-вкл»). + +Привязка: AC-4 (count==0 → `already-in-main`, нет POST …/pulls). + +### D4 — обработка `already-in-main` в `_handle_merge_verify` (BR-5 / FR-4) + +В `stage_engine._handle_merge_verify` (`src/stage_engine.py:1486-1495`): при +`pr_status == "already-in-main"` — лог, **пропустить** `merge_gate.merge_pr` (мержить нечего) и +сразу к `verify_merged_to_main` (SHA-in-main подтвердит факт мержа → `done`). Это **НЕ** `failed`-ветка +(не HOLD): цель уже достигнута, ветка в `main`. Реализуется флагом `skip_merge`, обнуляющим вызов +`merge_pr` на строке 1498; ветка `verify_merged_to_main` (стр. 1503) и весь нижестоящий код — +без изменений. SHA-in-main остаётся **авторитетным** доказательством мержа (ADR-0014); гард только +избегает мусорного PR и лишнего `merge_pr`. + +Деградация safety: если по какой-то причине SHA не в `main` при `already-in-main` (не должно случаться, +т.к. `sha = validated_revision = worktree HEAD`, а ветка целиком в `main`), срабатывает прежний +HOLD (стр. 1527) — fail-closed, безопасно. + +Привязка: AC-4 (`already-in-main` → пропуск `merge_pr`, SHA-in-main → `done`). + +### D5 — конфигурация (BR-6, BR-7 / FR-5) + +Новые поля `src/config.Settings` (по образцу `ci_poll_*` / `merge_pr_timeout_s`), читаются из env: + +| Поле | env | Дефолт | +|------|-----|--------| +| `merge_retry_enabled` | `ORCH_MERGE_RETRY_ENABLED` | `True` (kill-switch; `False` → one-shot) | +| `merge_retry_max_attempts` | `ORCH_MERGE_RETRY_MAX_ATTEMPTS` | `3` | +| `merge_retry_backoff_base_s` | `ORCH_MERGE_RETRY_BACKOFF_BASE_S` | `2` | +| `merge_retry_backoff_max_s` | `ORCH_MERGE_RETRY_BACKOFF_MAX_S` | `5` | + +Дескрипторы добавляются в `.env.example`. Гард already-in-main — без отдельного флага (D3). + +## Альтернативы + +- **Ретрай всех steps `merge_pr` (включая `GET …/pulls?state=open`)** — отвергнуто: ретраить нужно + только мутирующий POST; список PR — дешёвый идемпотентный GET, его транзиент-ретрай усложняет + логику без выгоды (повторный POST сам перечитает при необходимости через `pr_already_merged`). +- **Терминал по `mergeable == None`** — отвергнуто: воспроизводит ложный HOLD, который чинит задача + (см. D2); бюджет ретраев конечен, backstop ORCH-071/073 сохранён. +- **Фиксированный interval-backoff (как `check_ci_green`)** — отвергнуто в пользу экспоненциального + с потолком: merge-икота короткая, экспонента с малым потолком (`5 с`) быстрее проходит первую + попытку и жёстко ограничена сверху (NFR-4). +- **`"failed"` на git-ошибке гарда already-in-main** — отвергнуто: превращает икоту git в ложный + no-op/HOLD мержа (анти-паттерн BRD); выбран fail-OPEN-в-create (D3). +- **Отдельный kill-switch для гарда already-in-main** — отвергнуто: уже накрыт + `merge_verify_autocreate_pr_enabled`; лишняя конфиг-поверхность. +- **Снять/ослабить защиту ORCH-071/081** — вне объёма и неверно: защита корректна, задача лишь + снижает **ложные** срабатывания. + +## Последствия + +- **+** Транзиентная икота Gitea (405/5xx/таймаут/«not mergeable yet») переживается автоматически → + нет ложного HOLD, нет ручного домержа в автономном прогоне (ORCH-088). +- **+** Нет мусорных пустых PR на уже влитой ветке; повторный прогон финализатора идемпотентен (NFR-5). +- **+** Реальный конфликт по-прежнему даёт быстрый честный HOLD (≤1 POST); защита ORCH-071/073 — 1:1. +- **+** Наблюдаемость: каждый ретрай логируется `attempt i/N` + класс (transient/terminal) (NFR-6). +- **−** Доп. `GET /pulls/{index}` на неоднозначном `409/422` (один лишний дешёвый запрос только в + редком ambiguous-кейсе) — приемлемо. +- **−** Дефолт-политика `mergeable==None → transient` может на реальном конфликте добавить ≤10 с + до HOLD. Митигейшн: бюджет жёстко ограничен; HOLD всё равно срабатывает. +- **−** Расширение возврата `ensure_open_pr` новым исходом — все вызовы перечислены, BC сохранён. +- **Откат:** `ORCH_MERGE_RETRY_ENABLED=false` → one-shot `merge_pr` (нынешнее поведение); + `ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED=false` отключает врезку `ensure_open_pr` целиком (вместе + с гардом). Полный откат кода — revert PR; флаги дают мгновенный runtime-откат без деплоя кода. + +## Ссылки +- BRD: `docs/work-items/ORCH-093/01-brd.md` +- TRZ: `docs/work-items/ORCH-093/02-trz.md` +- Acceptance: `docs/work-items/ORCH-093/03-acceptance-criteria.md` +- Tech-risks: `docs/work-items/ORCH-093/10-tech-risks.md` +- Сквозной ADR: `docs/architecture/adr/adr-0027-merge-actor-transient-retry-and-already-in-main.md` +- Лехатая merge-verify: [adr-0013](../../../architecture/adr/adr-0013-merge-verify-gate.md), + [adr-0014](../../../architecture/adr/adr-0014-merge-verify-sha-source-of-truth.md), + [adr-0016](../../../architecture/adr/adr-0016-ensure-open-pr-before-merge-verify.md) +- Сверено по коду: `src/merge_gate.py` (`merge_pr:700`, `ensure_open_pr:605`, + `branch_is_behind_main:53`, `verify_merged_to_main:767`), `src/stage_engine.py` + (`_handle_merge_verify:1447`), `src/qg/checks.py` (`check_ci_green:82`), `src/config.py` + (`ci_poll_*:140`, `merge_pr_timeout_s:549`, `transient_max_attempts:77`) diff --git a/docs/work-items/ORCH-093/10-tech-risks.md b/docs/work-items/ORCH-093/10-tech-risks.md new file mode 100644 index 0000000..e63ad84 --- /dev/null +++ b/docs/work-items/ORCH-093/10-tech-risks.md @@ -0,0 +1,36 @@ +--- +work_item: ORCH-093 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-09 +model_used: claude-opus-4-8 +--- + +# 10 — Технические риски: ORCH-093 — ретрай транзиентных merge-ошибок Gitea + гард already-in-main + +Work Item: **ORCH-093** · Repo: **orchestrator** · Стадия: architecture + +> Информационный (гейтом не парсится). Перечисляет риски реализации и их митигейшн. + +## Реестр рисков + +| ID | Риск | Вер. | Влия. | Митигейшн | +|----|------|------|-------|-----------| +| TR-1 | Ошибочная классификация реального конфликта как транзиента (`mergeable==None`/неполный ответ) → лишние ретраи перед HOLD | Сред. | Низ. | D2: бюджет ретраев жёстко ограничен (`(N-1)×backoff_max ≤ 10 с`); после исчерпания — тот же HOLD ORCH-071/073. Цена ≤10 с задержки, не ложный `done`. | +| TR-2 | Слишком агрессивный/долгий ретрай подвешивает monitor-поток, исполняющий merge-verify | Низ. | Сред. | D1/NFR-4: экспон. backoff с потолком `merge_retry_backoff_max_s`; суммарный сон детерминирован; `merge_pr_timeout_s` ограничивает каждый POST. | +| TR-3 | Гонка гарда already-in-main vs параллельный мерж (ветка влита между `_find_open_code_pr` и `_branch_fully_in_main`) | Низ. | Низ. | SHA-в-main (`verify_merged_to_main`, ADR-0014) остаётся авторитетным; гард лишь избегает пустого PR. Ложный `done` невозможен — решает SHA, не гард. | +| TR-4 | git-икота гарда (`fetch`/`merge-base` падает) → ложный `already-in-main` → пропуск реального мержа | Низ. | Выс. | D3: fail-OPEN — `None` деградирует на create-PR, НЕ на `already-in-main`; ложный пропуск мержа структурно невозможен (для `already-in-main` нужен rc==0, не ошибка). | +| TR-5 | Регрессия one-shot поведения при `merge_retry_enabled=False` | Низ. | Сред. | BR-7: `N_eff = 1` без ветвления тела цикла; тест AC-5 подтверждает ровно один POST. | +| TR-6 | Расширение возврата `ensure_open_pr` (`already-in-main`) ломает необработанную ветку вызова | Низ. | Сред. | Все вызовы перечислены (`_handle_merge_verify`, `launcher._ensure_pr`); BC: новый исход обрабатывается явно, прочие пути 1:1. Покрытие — тест AC-4. | +| TR-7 | Лишний `GET /pulls/{index}` на ambiguous `409/422` сам транзиентно падает → неверный класс | Низ. | Низ. | never-raise: сбой `GET` → дефолт transient в рамках бюджета (D2); никогда не исключение в `advance_stage`. | + +## Сводный вывод + +Доминирующий класс — **корректность классификации транзиент/терминал** (TR-1, TR-4): обе ветки +спроектированы fail-safe в сторону, противоположную багу (ретрай-с-бюджетом и fail-OPEN-в-create), +с авторитетным backstop'ом SHA-в-main + защитой ORCH-071/073, которые не трогаются. Остаточный риск +для прод-конвейера (self-hosting) **низкий**: изменение точечное, аддитивное, полностью отключаемо +двумя существующими/новыми kill-switch'ами без деплоя кода; `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД +не затронуты. Эскалация `arch:major-change` **не требуется**; возврат в анализ **не требуется** — +ТЗ реализуемо без нарушения принципов архитектуры. diff --git a/docs/work-items/ORCH-093/12-review.md b/docs/work-items/ORCH-093/12-review.md new file mode 100644 index 0000000..3b043b9 --- /dev/null +++ b/docs/work-items/ORCH-093/12-review.md @@ -0,0 +1,89 @@ +--- +verdict: APPROVED +work_item: ORCH-093 +stage: review +author_agent: reviewer +status: approved +created_at: 2026-06-09 +model_used: claude-opus-4-8 +type: review +work_item_id: ORCH-093 +version: 1 +--- + +# Review ORCH-093 + +## Summary + +Две точечные доработки детерминированного merge-актора (`src/merge_gate.py`), чинящие инцидент +**ORCH-063** (ложный HOLD на транзиентном `HTTP 405` от Gitea + мусорный пустой PR на уже влитой +ветке): (1) retry-loop вокруг мутирующего `POST …/merge` с классификатором транзиент/терминал; +(2) гард `already-in-main` в `ensure_open_pr` + врезка в `_handle_merge_verify`. + +Реализация **полностью соответствует** ТЗ (FR-1…FR-5), критериям приёмки (AC-1…AC-7) и ADR-001 +(D1…D5). Контракты сохранены: never-raise, INV-4 (мерж только через Gitea PR-merge API, никогда +`push`/`force-push` в `main`), `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД — байт-в-байт не тронуты +(проверено `git diff`: затронуты только `src/merge_gate.py`, `src/config.py` и точечно +`src/stage_engine.py`). Защита ORCH-071/073/081 («deploy succeeded but not merged») сохранена 1:1: +терминал/исчерпание ретраев → `(False, …)` → прежний HOLD+alert. + +**Тесты содержательные и зелёные:** `tests/test_merge_gate.py` (TC-01…TC-12), `tests/test_config.py` +(TC-13), `tests/test_merge_verify.py` (TC-14…TC-16), обновлён `tests/test_orch082_ensure_pr.py`. +Локальный прогон затронутых сьютов — **72 passed**. Каждый AC покрыт буквально (405×2→200=3 POST; +5xx→200; network→200; реальный конфликт/403 терминал без ретрая; ambiguous-409+mergeable=True ретрай; +исчерпание; kill-switch one-shot; already-in-main без POST; fail-OPEN на git-ошибке гарда; +never-raise). + +**Трассировка (TRACEABILITY.md):** правки в блоках с маркерами ORCH-071/073/082 сверены с их +инвариантами — SHA-in-main остаётся единственным авторитетным доказательством мержа (ADR-0014), +idempotency-guard `pr_already_merged`, фильтр `base==main` для code-PR, never-raise — сохранены. +В append-only `MAIN_REGRESSION_MARKERS` корректно добавлена строка +`("ORCH-093", "_classify_merge_response", "src/merge_gate.py")` — без слома существующих маркеров. + +Документация обновлена (CHANGELOG, `.env.example`, `CLAUDE.md`, локальный ADR-001 + сквозной +adr-0027, `docs/architecture/README.md`). Один P2 по гигиене документации (дубль секции в README) — +не блокирует приёмку. + +## Findings + +### P0 — Blocker +- Нет. + +### P1 — Must fix +- Нет. + +### P2 — Should fix +- [ ] **Дубль секции ORCH-093 в `docs/architecture/README.md`.** Один и тот же заголовок + `#### Ретрай транзиентных merge-ошибок Gitea + гард already-in-main (ORCH-093 — фикс ложного HOLD + на 405/5xx)` встречается **дважды** — строки **480–516** и **518–550** — с почти идентичным, + перекрывающимся содержимым и совпадающим markdown-anchor'ом. Подтверждено `git diff` (на `origin/main` + — 0 вхождений, на ветке — 2), т.е. обе секции добавлены этим PR (вероятно случайная вставка/дубль + блока при правке golden-source). README — обзорная витрина архитектуры; дублирующий блок с + коллизией заголовков следует схлопнуть в одну секцию (оставить вариант 480–516 или 518–550, не оба). + Правило: `CLAUDE.md` §2 «документация = golden source», стандарт обзорных доков (ORCH-079). + +### P3 — Nice to have +- [ ] **`tests/test_merge_gate.py::_PostSeq`** обращается к `self._items_last` до его первой + инициализации, если конструктору передать пустой список (атрибут ставится только после первого + `pop`). Сейчас не срабатывает (все вызовы передают непустую последовательность), но защититься + дефолтом `self._items_last = None` в `__init__` дешевле, чем потенциальный `AttributeError` при + будущем редактировании теста. + +## Документация + +Проверка обязательна (изменён `src/`). Статус — **обновлена** (golden source синхронизирован с кодом): + +| Артефакт | Статус | +|----------|--------| +| `CHANGELOG.md` | ✅ запись ORCH-093 (`[Unreleased]`) с детализацией retry/guard/конфиг/тесты | +| `.env.example` | ✅ дескрипторы `ORCH_MERGE_RETRY_*` (4 поля) + пояснительный блок | +| `CLAUDE.md` | ✅ абзац ORCH-093 в секции «Очередь задач» | +| `docs/architecture/README.md` | ⚠️ обновлена, но **секция продублирована** (P2 — схлопнуть) | +| `docs/work-items/ORCH-093/06-adr/ADR-001-…md` | ✅ локальный ADR (proposed) | +| `docs/architecture/adr/adr-0027-…md` | ✅ сквозной ADR (amends 0013/0014/0016) | + +API / `STAGE_TRANSITIONS` / QG / схема БД не менялись → доп. обновлений не требуется. Пункт +`README.md` «Известные ограничения» данным PR не закрывается (ORCH-079 не применим). + +**Вывод:** P0/P1 нет; единственный P2 — косметический дубль секции README (не блокирует). Verdict — +`APPROVED`. Рекомендую попутно схлопнуть дубль перед мержем. diff --git a/docs/work-items/ORCH-093/13-test-report.md b/docs/work-items/ORCH-093/13-test-report.md new file mode 100644 index 0000000..8a3a81d --- /dev/null +++ b/docs/work-items/ORCH-093/13-test-report.md @@ -0,0 +1,83 @@ +--- +result: PASS +work_item: ORCH-093 +stage: testing +author_agent: tester +status: pass +created_at: 2026-06-09 +model_used: claude-opus-4-8 +type: test-report +work_item_id: ORCH-093 +--- + +# Test Report — ORCH-093 + +merge-актор ретраит транзиентные ошибки Gitea (405/5xx/таймаут) + гард «ветка уже в main». + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 +- Worktree: `/repos/_wt/orchestrator/feature_ORCH-093-bug-merge-gitea-405-5xx-hold-p` +- Branch: `feature/ORCH-093-bug-merge-gitea-405-5xx-hold-p` +- Дата: 2026-06-09 + +## Предусловия +- Review verdict: **APPROVED** (`12-review.md`, P0/P1 нет; единственный P2 — косметический дубль секции README, не блокирует). + +## Smoke API (read-only, prod 8500) +| Эндпоинт | Результат | +|----------|-----------| +| `GET /health` | `{"status":"ok","service":"orchestrator"}` — OK | +| `GET /status` | OK (active_tasks отдаётся; ORCH-093 task#78 в `testing`) | +| `GET /queue` | OK — блок `serial_gate` **присутствует** (ORCH-088), `auto_labels` **присутствует** (ORCH-089), `stop` присутствует (ORCH-090). Регресса смока нет. | + +## Результаты (покрытие ТЗ — каждый TC из 04-test-plan.yaml) + +| TC ID | Описание (AC) | Тест | Результат | +|-------|---------------|------|-----------| +| TC-01 | merge_pr: 405,405,200 → (True, …); ровно 3 POST; ложного False нет (AC-1) | `test_merge_gate.py::test_tc01_merge_retries_405_then_succeeds` | PASS | +| TC-02 | merge_pr: 503 (5xx)→200 → ретрай → (True, …) (AC-1) | `test_merge_gate.py::test_tc02_merge_retries_5xx_then_succeeds` | PASS | +| TC-03 | merge_pr: httpx Timeout/сетевая→200 → ретрай; never-raise (AC-1, AC-6) | `test_merge_gate.py::test_tc03_merge_retries_network_error_then_succeeds` | PASS | +| TC-04 | merge_pr: 409 + GET mergeable=False → (False, …) без доп. POST (терминал) (AC-2) | `test_merge_gate.py::test_tc04_real_conflict_terminal_no_retry` | PASS | +| TC-05 | merge_pr: ambiguous 409 + GET mergeable=True → транзиент → ретрай → 200 (AC-2) | `test_merge_gate.py::test_tc05_ambiguous_409_mergeable_true_retries` | PASS | +| TC-06 | merge_pr: 403 → немедленно (False, …) без ретрая (терминал) (AC-2) | `test_merge_gate.py::test_tc06_403_terminal_no_retry` | PASS | +| TC-07 | merge_pr: 405 на всех N → (False, 'merge failed after N attempts…') понятный reason (AC-3) | `test_merge_gate.py::test_tc07_exhausts_retries_clear_reason` | PASS | +| TC-08 | merge_pr: kill-switch off → ровно один POST (one-shot) при 405 (AC-5, AC-3) | `test_merge_gate.py::test_tc08_killswitch_off_one_shot` | PASS | +| TC-09 | ensure_open_pr: count==0 → ('already-in-main', …); POST /pulls НЕ вызван (AC-4) | `test_merge_gate.py::test_tc09_ensure_already_in_main_no_post` | PASS | +| TC-10 | ensure_open_pr: count>0 → создаёт PR (регресс прежнего поведения) (AC-4) | `test_merge_gate.py::test_tc10_ensure_creates_when_commits_beyond_main` | PASS | +| TC-11 | ensure_open_pr: git-ошибка гарда → never-raise, fail-open (AC-6) | `test_merge_gate.py::test_tc11_ensure_guard_git_error_fail_open`, `::test_tc11_branch_fully_in_main_never_raises` | PASS | +| TC-12 | merge_pr/ensure_open_pr: любая httpx/parse ошибка → безопасный кортеж, never-raise (AC-6) | `test_merge_gate.py::test_tc12_merge_pr_never_raises`, `::test_tc12_ensure_open_pr_never_raises` | PASS | +| TC-13 | config: дефолты merge_retry_* + чтение ORCH_MERGE_RETRY_* env (AC-5) | `test_config.py::test_merge_retry_settings_defaults`, `::test_merge_retry_settings_env_override` | PASS | +| TC-14 | _handle_merge_verify: 'already-in-main' пропускает merge_pr, SHA-in-main → done (AC-4) | `test_merge_verify.py::test_tc14_already_in_main_skips_merge_pr_then_done` | PASS | +| TC-15 | _handle_merge_verify: merge_pr исчерпал ретраи + SHA не подтверждён → HOLD+alert (ORCH-071/081) (AC-3) | `test_merge_verify.py::test_tc15_merge_failed_and_not_in_main_holds` | PASS | +| TC-16 | _handle_merge_verify happy-path: 405x2→200 → SHA-in-main → done без ложного HOLD (AC-1) | `test_merge_verify.py::test_tc16_transient_retry_success_then_done` | PASS | + +**Сопоставление с `03-acceptance-criteria.md`:** AC-1 (TC-01/02/03/16), AC-2 (TC-04/05/06), +AC-3 (TC-07/15), AC-4 (TC-09/10/14), AC-5 (TC-08/13), AC-6 (TC-11/12 + зелёный регресс), +AC-7 (`STAGE_TRANSITIONS`/`QG_CHECKS`/сигнатуры неизменны — `test_config.py::test_tc19_*` зелёные). +Все 16 TC выполнены и сопоставлены. + +## Вывод pytest + +Полный регресс из worktree ветки задачи: + +``` +$ cd /repos/_wt/orchestrator/feature_ORCH-093-bug-merge-gitea-405-5xx-hold-p && pytest tests/ -v --tb=short +... +======================= 1389 passed, 1 warning in 44.62s ======================= +``` + +Целевые сьюты ORCH-093 (`test_merge_gate.py`, `test_config.py`, `test_merge_verify.py`, +`test_orch082_ensure_pr.py`): + +``` +======================== 72 passed, 1 warning in 1.84s ========================= +``` + +Единственный warning — `PydanticDeprecatedSince20` (class-based config, существующий, не связан с ORCH-093). +Падений и регрессов `test_merge_gate*/test_merge_verify*/test_orch08*/test_config*` нет. + +## Итог + +PASS — все 1389 тестов зелёные, целевые TC-01…TC-16 PASS и сопоставлены с AC-1…AC-7, +smoke read-only OK (`serial_gate`/`auto_labels` присутствуют в `/queue`). Задача переходит на `deploy-staging`. diff --git a/docs/work-items/ORCH-093/14-deploy-log.md b/docs/work-items/ORCH-093/14-deploy-log.md new file mode 100644 index 0000000..e22e0d7 --- /dev/null +++ b/docs/work-items/ORCH-093/14-deploy-log.md @@ -0,0 +1,12 @@ +--- +deploy_status: SUCCESS +work_item: ORCH-093 +hook_exit_code: 0 +deployed_by: deploy-finalizer +--- + +# Deploy log — ORCH-036 executable self-deploy + +Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`. + +Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM. diff --git a/src/config.py b/src/config.py index bca46f8..f1313ea 100644 --- a/src/config.py +++ b/src/config.py @@ -549,6 +549,31 @@ class Settings(BaseSettings): merge_pr_timeout_s: int = 60 merge_verify_timeout_s: int = 60 + # ORCH-093: deterministic merge-actor retry of TRANSIENT Gitea merge errors. + # The incident ORCH-063 had a green self-deploy + an open, mergeable PR, yet + # POST /pulls/{n}/merge returned HTTP 405 ("Please try again later") because + # Gitea was still recomputing `mergeable` right after the push — the one-shot + # merge_pr returned False, the ORCH-071/081 backstop HELD the task on `deploy`, + # and a human had to re-merge by hand. merge_pr now wraps ONLY the mutating + # POST in a bounded exponential-backoff retry-loop on TRANSIENT outcomes + # (405/408/5xx/network-timeout, and 409|422 while the PR is still mergeable); + # TERMINAL outcomes (403/404/real conflict) -> fast honest False (the HOLD + # protection is unchanged). Mirrors the ci_poll_* idiom of check_ci_green. + # merge_retry_enabled -> kill-switch; False -> exactly one POST + # (byte-for-byte the prior one-shot behaviour, + # env ORCH_MERGE_RETRY_ENABLED). + # merge_retry_max_attempts -> max POST attempts on a transient outcome + # (env ORCH_MERGE_RETRY_MAX_ATTEMPTS). + # merge_retry_backoff_base_s -> exponential backoff base seconds + # (env ORCH_MERGE_RETRY_BACKOFF_BASE_S). + # merge_retry_backoff_max_s -> per-sleep backoff ceiling seconds; total sleep + # is bounded by (N-1) * max so the monitor-thread + # is never wedged (env ORCH_MERGE_RETRY_BACKOFF_MAX_S). + merge_retry_enabled: bool = True + merge_retry_max_attempts: int = 3 + merge_retry_backoff_base_s: int = 2 + merge_retry_backoff_max_s: int = 5 + # ORCH-026: intra-repo merge serialisation (Level A) + declarative task # dependencies (Level B). Level A reuses the ORCH-043/065 merge-lease window # (no new mechanism) — the merge-lease already serialises "merge -> main-updated" diff --git a/src/merge_gate.py b/src/merge_gate.py index 1341016..7629561 100644 --- a/src/merge_gate.py +++ b/src/merge_gate.py @@ -602,6 +602,51 @@ def merge_verify_applies(repo: str) -> bool: return False +def _branch_fully_in_main(repo: str, branch: str) -> bool | None: + """Return True iff ``branch`` has NO commits beyond ``origin/main`` (ORCH-093 D3). + + Used by ``ensure_open_pr`` to avoid creating an empty PR on a branch that is + already fully merged into ``main`` (the ORCH-063 garbage-PR symptom on a + re-driven finalizer after a manual merge). In the per-branch worktree: + ``git fetch origin main`` then ``git merge-base --is-ancestor HEAD origin/main`` + (equivalent to ``git rev-list --count origin/main..HEAD == 0``; same idiom as + ``branch_is_behind_main`` / ``verify_merged_to_main``). + + * ``rc == 0`` -> HEAD is an ancestor of origin/main -> fully in main -> ``True``. + * ``rc == 1`` -> there are commits beyond main -> ``False``. + * git/OS error / ambiguous rc -> ``None`` (caller fail-OPENs: degrade to the + create path; an infra hiccup must NOT become a false no-op merge). + + Never-raise: any error -> ``None``. + """ + try: + wt = ensure_worktree(repo, branch) + except Exception as e: # noqa: BLE001 - never-raise contract -> fail-OPEN + logger.warning("_branch_fully_in_main: worktree error for %s/%s: %s", repo, branch, e) + return None + 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", "HEAD", "origin/main"], + capture_output=True, timeout=_SHORT_TIMEOUT, + ) + except (subprocess.SubprocessError, OSError) as e: + logger.warning("_branch_fully_in_main: git error for %s/%s: %s", repo, branch, e) + return None + if r.returncode == 0: + return True + if r.returncode == 1: + return False + logger.warning( + "_branch_fully_in_main: ambiguous merge-base rc=%s for %s/%s (fail-open)", + r.returncode, repo, branch, + ) + return None + + def ensure_open_pr(repo: str, branch: str) -> tuple[str, str]: """Guarantee an open **code-PR** (``head==branch`` AND ``base=="main"``) exists. @@ -625,6 +670,12 @@ def ensure_open_pr(repo: str, branch: str) -> tuple[str, str]: ``("existed", …)``; no duplicate is created (AC-2 / FR-5). 4. Any other HTTP/parse/network error -> ``("failed", "")``. + ORCH-093 (D3) adds a guard BETWEEN steps 1 and 2: if the branch is already fully + in ``main`` (no commits beyond ``origin/main``) there is nothing to PR -> the new + outcome ``("already-in-main", "")`` is returned WITHOUT a ``POST`` (avoids + an empty garbage PR on a re-driven finalizer). A git error of the guard fails OPEN + (degrade to the create path) so an infra hiccup never becomes a false no-op. + Reuses ``settings.merge_pr_timeout_s`` (same class of Gitea calls as ``merge_pr``). Never-raise (AC-7): any unexpected error -> ``("failed", str(e))``; the exception is NEVER propagated into ``_handle_merge_verify`` / ``advance_stage``. @@ -657,6 +708,21 @@ def ensure_open_pr(repo: str, branch: str) -> tuple[str, str]: logger.info("ensure_open_pr: %s/%s already has open code-PR #%s", repo, branch, existing) return "existed", str(existing) + # Step 1b (ORCH-093 D3): guard "branch already fully in main". If the branch + # has no commits beyond origin/main there is nothing to PR — creating one + # would yield an empty garbage PR (the ORCH-063 symptom on a re-driven + # finalizer after a manual merge). Return the new "already-in-main" outcome + # so _handle_merge_verify skips merge_pr and lets the authoritative + # SHA-in-main check confirm -> done. fail-OPEN on git error / ambiguous + # (None): degrade to the create path below, NEVER block — an infra hiccup + # must not become a false no-op merge (SHA-in-main downstream stays the proof). + if _branch_fully_in_main(repo, branch) is True: + logger.info( + "ensure_open_pr: %s/%s already fully in main -> already-in-main (no PR created)", + repo, branch, + ) + return "already-in-main", "branch already in main (no commits beyond origin/main)" + # Step 2: create the code-PR onto main. parts = branch.split("/") title = parts[-1] if parts else branch @@ -697,6 +763,89 @@ def ensure_open_pr(repo: str, branch: str) -> tuple[str, str]: return "failed", f"ensure_open_pr error: {e}" +# --------------------------------------------------------------------------- +# ORCH-093: transient-error retry of the merge POST + classification helpers. +# --------------------------------------------------------------------------- +def _merge_backoff(attempt: int) -> float: + """Exponential backoff (s) with a ceiling for the merge-POST retry (ORCH-093 D1). + + ``backoff(i) = min(base * 2**(i-1), max)`` — the transient-breaker idiom of the + Claude agents, bounded so the total sleep ``(N-1) * max`` can never wedge the + monitor-thread running merge-verify (NFR-4). Defaults base=2, max=5 -> the + sequence is 2, 4, 5, 5, … seconds. + """ + base = settings.merge_retry_backoff_base_s + cap = settings.merge_retry_backoff_max_s + try: + return float(min(base * (2 ** (max(attempt, 1) - 1)), cap)) + except Exception: # noqa: BLE001 - never-raise; degrade to the ceiling + return float(cap) + + +def _pr_mergeable(repo: str, index) -> bool | None: + """Read the ``mergeable`` field of PR ``index`` via ``GET /pulls/{index}`` (ORCH-093 D2). + + Used ONLY to disambiguate a ``409``/``422`` merge POST: Gitea may still be + recomputing mergeability right after a push (the ORCH-063 root cause). Returns + the boolean ``mergeable`` flag, or ``None`` when it is absent / non-boolean / the + GET fails (never-raise) — the caller treats ``None`` as the default-policy + transient (D2). + """ + try: + import httpx + owner = settings.gitea_owner + headers = {"Authorization": f"token {settings.gitea_token}"} + resp = httpx.get( + f"{settings.gitea_url}/api/v1/repos/{owner}/{repo}/pulls/{index}", + headers=headers, timeout=settings.merge_pr_timeout_s, + ) + if resp.status_code != 200: + return None + val = (resp.json() or {}).get("mergeable") + return val if isinstance(val, bool) else None + except Exception as e: # noqa: BLE001 - never-raise contract + logger.warning("_pr_mergeable check failed for %s PR #%s: %s", repo, index, e) + return None + + +def _classify_merge_response(repo: str, branch: str, index, status_code: int) -> str: + """Classify a non-2xx ``POST /pulls/{index}/merge`` outcome (ORCH-093 D2). + + Returns ``"transient"`` (retry within budget) or ``"terminal"`` (fast honest + ``False``; the ORCH-071/081 HOLD backstop takes over). Decision tree: + + * ``405`` ("try again later"), ``408``, any ``5xx`` -> **transient**. + * ``403`` (no rights), ``404`` (PR gone) -> **terminal**. + * ``409`` / ``422`` (ambiguous) -> ``GET /pulls/{index}`` -> ``mergeable``: + - ``False`` -> **terminal** (real conflict, fast HOLD). + - ``True`` / ``None`` / GET failed -> **transient** (default-policy + fail-OPEN-in-retry: Gitea has not recomputed yet — the ORCH-063 case; + the retry budget is finite, so a real conflict still HOLDs after it). + * any other unexpected code -> **terminal** (do not loop on unknowns). + + Never-raise: any error -> ``"transient"`` (conservative, within the bounded + retry budget). + """ + try: + if status_code in (405, 408) or 500 <= status_code <= 599: + return "transient" + if status_code in (403, 404): + return "terminal" + if status_code in (409, 422): + mergeable = _pr_mergeable(repo, index) + if mergeable is False: + return "terminal" + # True OR None/unavailable -> transient (default-policy, D2). + return "transient" + return "terminal" + except Exception as e: # noqa: BLE001 - never-raise contract + logger.warning( + "_classify_merge_response error for %s/%s PR #%s: %s (transient)", + repo, branch, index, e, + ) + return "transient" + + def merge_pr(repo: str, branch: str) -> tuple[bool, str]: """Deterministically merge the open PR for ``branch`` via the Gitea PR-merge API. @@ -712,8 +861,16 @@ def merge_pr(repo: str, branch: str) -> tuple[bool, str]: (FR-3) adds the ``base == main`` filter so the actor merges exactly the feature code-PR and never an auto docs-PR / a PR onto a foreign base. No such open PR -> ``(False, "no open PR")``. - 3. ``POST /repos/{owner}/{repo}/pulls/{index}/merge`` (Do: ``merge``) -> - 200/201 -> ``(True, "merged PR #")``; otherwise ``(False, "")``. + 3. ``POST /repos/{owner}/{repo}/pulls/{index}/merge`` (Do: ``merge``) in a + bounded retry-loop (ORCH-093 D1): ``200/201`` -> ``(True, "merged PR #")``; + a TRANSIENT outcome (405/408/5xx/network/timeout, or 409|422 while still + mergeable) is retried with exponential backoff up to + ``merge_retry_max_attempts``; a TERMINAL outcome (403/404/real conflict) -> + immediate ``(False, "merge failed: HTTP ")``; exhausting the budget on + a transient -> ``(False, "merge failed after attempts: HTTP ")``. + The kill-switch ``merge_retry_enabled=False`` forces exactly one POST + (the prior one-shot behaviour). Only the mutating POST is retried — the + idempotent steps above are not. Never-raise (INV-1/AC-9 / TC-09): any HTTP/parse error -> ``(False, reason)``. """ @@ -744,21 +901,59 @@ def merge_pr(repo: str, branch: str) -> tuple[bool, str]: if index is None: return False, "no open PR" - m = httpx.post( - f"{base}/pulls/{index}/merge", - json={"Do": "merge"}, - headers=headers, - timeout=timeout, - ) - if m.status_code in (200, 201): - logger.info("merge_pr: merged PR #%s for %s/%s", index, repo, branch) - return True, f"merged PR #{index}" - detail = (m.text or "").strip()[:200] - logger.warning( - "merge_pr: merge failed for %s/%s PR #%s: HTTP %s %s", - repo, branch, index, m.status_code, detail, - ) - return False, f"merge failed: HTTP {m.status_code}" + # ORCH-093 D1: retry ONLY the mutating POST on transient outcomes. The + # kill-switch collapses the budget to one attempt = the prior one-shot path + # (no branching of the loop body, ADR D1). + n_eff = settings.merge_retry_max_attempts if settings.merge_retry_enabled else 1 + if n_eff < 1: + n_eff = 1 + for attempt in range(1, n_eff + 1): + try: + m = httpx.post( + f"{base}/pulls/{index}/merge", + json={"Do": "merge"}, + headers=headers, + timeout=timeout, + ) + except (httpx.HTTPError, OSError) as e: + # Network/timeout -> transient within the bounded budget (never-raise). + logger.warning( + "merge_pr: attempt %s/%s network error for %s/%s PR #%s: %s (transient)", + attempt, n_eff, repo, branch, index, e, + ) + if attempt < n_eff: + time.sleep(_merge_backoff(attempt)) + continue + return False, f"merge failed after {n_eff} attempts: network error" + + if m.status_code in (200, 201): + logger.info( + "merge_pr: merged PR #%s for %s/%s (attempt %s/%s)", + index, repo, branch, attempt, n_eff, + ) + return True, f"merged PR #{index}" + + detail = (m.text or "").strip()[:200] + cls = _classify_merge_response(repo, branch, index, m.status_code) + if cls == "terminal": + logger.warning( + "merge_pr: merge failed for %s/%s PR #%s: HTTP %s %s (terminal)", + repo, branch, index, m.status_code, detail, + ) + return False, f"merge failed: HTTP {m.status_code}" + + # Transient: log attempt i/N (check_ci_green idiom) and retry if budget left. + logger.warning( + "merge_pr: attempt %s/%s transient HTTP %s for %s/%s PR #%s %s", + attempt, n_eff, m.status_code, repo, branch, index, detail, + ) + if attempt < n_eff: + time.sleep(_merge_backoff(attempt)) + continue + return False, f"merge failed after {n_eff} attempts: HTTP {m.status_code}" + + # Unreachable (loop always returns), defensive only. + return False, f"merge failed after {n_eff} attempts" except Exception as e: # noqa: BLE001 - never-raise contract logger.warning("merge_pr unexpected error for %s/%s: %s", repo, branch, e) return False, f"merge error: {e}" @@ -841,6 +1036,7 @@ MAIN_REGRESSION_MARKERS: list[tuple[str, str, str]] = [ ("ORCH-071", "verify_merged_to_main", "src/merge_gate.py"), ("ORCH-073", "check_main_regression", "src/merge_gate.py"), ("ORCH-082", "ensure_open_pr", "src/merge_gate.py"), + ("ORCH-093", "_classify_merge_response", "src/merge_gate.py"), ] diff --git a/src/stage_engine.py b/src/stage_engine.py index 267d009..36b169d 100644 --- a/src/stage_engine.py +++ b/src/stage_engine.py @@ -1483,6 +1483,7 @@ def _handle_merge_verify(task_id, repo, work_item_id, branch, result: AdvanceRes # `created`/`existed` -> proceed unchanged; `failed` -> honest HOLD with a # distinguishable text (NOT the not-merged HOLD). ORCH-073's SHA-in-main proof # below is untouched and stays authoritative. Kill-switch off -> 1:1 prior path. + skip_merge = False if settings.merge_verify_autocreate_pr_enabled: pr_status, pr_detail = merge_gate.ensure_open_pr(repo, branch) logger.info( @@ -1492,10 +1493,25 @@ def _handle_merge_verify(task_id, repo, work_item_id, branch, result: AdvanceRes return _hold_pr_create_failed( task_id, repo, work_item_id, branch, pr_detail, result ) + if pr_status == "already-in-main": + # ORCH-093 (D4): the branch is already fully in `main` -> nothing to + # merge and no PR was created. Skip the deterministic merge_pr; the + # authoritative SHA-in-main check below confirms the merge -> done. + # This is NOT a HOLD (the goal is already achieved); if for some + # reason the SHA is not in main the prior not-merged HOLD still fires + # (fail-closed, safe). + logger.info( + f"Task {task_id}: merge-verify already-in-main -> skip merge_pr " + "(SHA-in-main authoritative)" + ) + skip_merge = True # "created" | "existed" -> proceed normally to merge_pr. # Deterministic merge-actor (no-op if the PR is already merged, INV-5/AC-9). - merged_ok, merge_msg = merge_gate.merge_pr(repo, branch) + if skip_merge: + merged_ok, merge_msg = True, "already-in-main (skipped merge_pr)" + else: + merged_ok, merge_msg = merge_gate.merge_pr(repo, branch) logger.info( f"Task {task_id}: merge-verify merge_pr -> ok={merged_ok} ({merge_msg})" ) diff --git a/tests/test_config.py b/tests/test_config.py index 697864b..4e6da8d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -257,3 +257,38 @@ def test_tc19_check_branch_mergeable_signature_intact(): from src.qg.checks import check_branch_mergeable params = list(inspect.signature(check_branch_mergeable).parameters) assert params == ["repo", "work_item_id", "branch"] + + +# --------------------------------------------------------------------------- +# ORCH-093 / TC-13: merge_retry_* settings defaults + env override (AC-5). +# --------------------------------------------------------------------------- +_MERGE_RETRY_ENV = ( + "ORCH_MERGE_RETRY_ENABLED", + "ORCH_MERGE_RETRY_MAX_ATTEMPTS", + "ORCH_MERGE_RETRY_BACKOFF_BASE_S", + "ORCH_MERGE_RETRY_BACKOFF_MAX_S", +) + + +def test_merge_retry_settings_defaults(monkeypatch): + """Documented defaults when no ORCH_MERGE_RETRY_* env is set.""" + for name in _MERGE_RETRY_ENV: + monkeypatch.delenv(name, raising=False) + s = Settings() + assert s.merge_retry_enabled is True + assert s.merge_retry_max_attempts == 3 + assert s.merge_retry_backoff_base_s == 2 + assert s.merge_retry_backoff_max_s == 5 + + +def test_merge_retry_settings_env_override(monkeypatch): + """Each field is read from its ORCH_MERGE_RETRY_* env var.""" + monkeypatch.setenv("ORCH_MERGE_RETRY_ENABLED", "false") + monkeypatch.setenv("ORCH_MERGE_RETRY_MAX_ATTEMPTS", "5") + monkeypatch.setenv("ORCH_MERGE_RETRY_BACKOFF_BASE_S", "1") + monkeypatch.setenv("ORCH_MERGE_RETRY_BACKOFF_MAX_S", "8") + s = Settings() + assert s.merge_retry_enabled is False + assert s.merge_retry_max_attempts == 5 + assert s.merge_retry_backoff_base_s == 1 + assert s.merge_retry_backoff_max_s == 8 diff --git a/tests/test_merge_gate.py b/tests/test_merge_gate.py index 0a12c4c..463b9ad 100644 --- a/tests/test_merge_gate.py +++ b/tests/test_merge_gate.py @@ -389,3 +389,207 @@ def test_tc16_deployer_prompt_consults_guard(): assert "no second merge" in lowered, ( "deployer prompt must document the already-merged no-op (AC-11)" ) + + +# =========================================================================== +# ORCH-093: merge_pr transient-retry + ensure_open_pr already-in-main guard. +# TC-01..TC-12 — httpx mocked; time.sleep no-op so backoff never slows tests. +# =========================================================================== +ORCH093_BRANCH = "feature/ORCH-093-x" + + +class _Resp093: + """Response stand-in with status_code / json() / text (merge_pr reads .text).""" + + def __init__(self, status_code, payload=None, text=""): + self.status_code = status_code + self._payload = payload if payload is not None else [] + self.text = text + + def json(self): + return self._payload + + +@pytest.fixture +def merge093(monkeypatch): + """Wire Gitea settings + retry defaults; no-op backoff; PR not-already-merged.""" + monkeypatch.setattr(merge_gate.settings, "gitea_url", "http://gitea.test") + monkeypatch.setattr(merge_gate.settings, "gitea_owner", "admin") + monkeypatch.setattr(merge_gate.settings, "gitea_token", "tok") + monkeypatch.setattr(merge_gate.settings, "merge_pr_timeout_s", 5) + monkeypatch.setattr(merge_gate.settings, "merge_retry_enabled", True) + monkeypatch.setattr(merge_gate.settings, "merge_retry_max_attempts", 3) + monkeypatch.setattr(merge_gate.settings, "merge_retry_backoff_base_s", 2) + monkeypatch.setattr(merge_gate.settings, "merge_retry_backoff_max_s", 5) + monkeypatch.setattr(merge_gate.time, "sleep", lambda *a, **k: None) + monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False) + + +def _open_code_pr_get(number=7): + """A list-PRs GET returning exactly one open code-PR (head==branch, base==main).""" + return lambda *a, **k: _Resp093( + 200, [{"head": {"ref": ORCH093_BRANCH}, "base": {"ref": "main"}, "number": number}] + ) + + +class _PostSeq: + """Returns queued responses (or raises queued exceptions) on each POST call.""" + + def __init__(self, items): + self._items = list(items) + self.calls = 0 + + def __call__(self, *a, **k): + self.calls += 1 + item = self._items.pop(0) if self._items else self._items_last + self._items_last = item + if isinstance(item, Exception): + raise item + return item + + +# --- TC-01: 405, 405, 200 -> (True, ...); exactly 3 POST; no false False (AC-1) --- +def test_tc01_merge_retries_405_then_succeeds(merge093, monkeypatch): + monkeypatch.setattr(httpx, "get", _open_code_pr_get(7)) + seq = _PostSeq([_Resp093(405, text="try again later"), + _Resp093(405, text="try again later"), + _Resp093(200)]) + monkeypatch.setattr(httpx, "post", seq) + ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH) + assert ok is True and "PR #7" in msg + assert seq.calls == 3 + + +# --- TC-02: 503 (5xx) then 200 -> retry -> (True, ...) (AC-1) --- +def test_tc02_merge_retries_5xx_then_succeeds(merge093, monkeypatch): + monkeypatch.setattr(httpx, "get", _open_code_pr_get(7)) + seq = _PostSeq([_Resp093(503, text="bad gateway"), _Resp093(200)]) + monkeypatch.setattr(httpx, "post", seq) + ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH) + assert ok is True and seq.calls == 2 + + +# --- TC-03: httpx Timeout in attempt 1, then 200 -> retry; never-raise (AC-1/AC-6) --- +def test_tc03_merge_retries_network_error_then_succeeds(merge093, monkeypatch): + monkeypatch.setattr(httpx, "get", _open_code_pr_get(7)) + seq = _PostSeq([httpx.ConnectTimeout("timed out"), _Resp093(200)]) + monkeypatch.setattr(httpx, "post", seq) + ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH) + assert ok is True and seq.calls == 2 + + +# --- TC-04: real conflict 409 + mergeable=False -> (False, ...), no extra POST (AC-2) --- +def test_tc04_real_conflict_terminal_no_retry(merge093, monkeypatch): + monkeypatch.setattr(httpx, "get", _open_code_pr_get(7)) + monkeypatch.setattr(merge_gate, "_pr_mergeable", lambda r, i: False) + seq = _PostSeq([_Resp093(409, text="conflict")]) + monkeypatch.setattr(httpx, "post", seq) + ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH) + assert ok is False and "HTTP 409" in msg + assert seq.calls == 1 # terminal -> no retry + + +# --- TC-05: ambiguous 409 + mergeable=True -> transient -> retry -> 200 (AC-2) --- +def test_tc05_ambiguous_409_mergeable_true_retries(merge093, monkeypatch): + monkeypatch.setattr(httpx, "get", _open_code_pr_get(7)) + monkeypatch.setattr(merge_gate, "_pr_mergeable", lambda r, i: True) + seq = _PostSeq([_Resp093(409, text="recomputing"), _Resp093(200)]) + monkeypatch.setattr(httpx, "post", seq) + ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH) + assert ok is True and seq.calls == 2 + + +# --- TC-06: 403 (no rights) -> immediate (False, ...) without retry (AC-2) --- +def test_tc06_403_terminal_no_retry(merge093, monkeypatch): + monkeypatch.setattr(httpx, "get", _open_code_pr_get(7)) + seq = _PostSeq([_Resp093(403, text="forbidden")]) + monkeypatch.setattr(httpx, "post", seq) + ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH) + assert ok is False and "HTTP 403" in msg and seq.calls == 1 + + +# --- TC-07: 405 on all N attempts -> (False, "merge failed after N attempts: HTTP 405") (AC-3) --- +def test_tc07_exhausts_retries_clear_reason(merge093, monkeypatch): + monkeypatch.setattr(httpx, "get", _open_code_pr_get(7)) + seq = _PostSeq([_Resp093(405), _Resp093(405), _Resp093(405)]) + monkeypatch.setattr(httpx, "post", seq) + ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH) + assert ok is False + assert "after 3 attempts" in msg and "HTTP 405" in msg + assert seq.calls == 3 + + +# --- TC-08: kill-switch off -> exactly one POST (one-shot) at 405 -> (False, ...) (AC-5/AC-3) --- +def test_tc08_killswitch_off_one_shot(merge093, monkeypatch): + monkeypatch.setattr(merge_gate.settings, "merge_retry_enabled", False) + monkeypatch.setattr(httpx, "get", _open_code_pr_get(7)) + seq = _PostSeq([_Resp093(405), _Resp093(200)]) # 2nd would succeed if retried + monkeypatch.setattr(httpx, "post", seq) + ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH) + assert ok is False and seq.calls == 1 # one-shot: never retried + + +# --- TC-09: ensure_open_pr — no open PR, branch fully in main -> already-in-main, no POST (AC-4) --- +def test_tc09_ensure_already_in_main_no_post(merge093, monkeypatch): + monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp093(200, [])) # no open PR + monkeypatch.setattr(merge_gate, "_branch_fully_in_main", lambda r, b: True) + monkeypatch.setattr(httpx, "post", lambda *a, **k: (_ for _ in ()).throw( + AssertionError("must NOT POST /pulls for an already-in-main branch"))) + status, detail = merge_gate.ensure_open_pr("orchestrator", ORCH093_BRANCH) + assert status == "already-in-main" + + +# --- TC-10: ensure_open_pr — no open PR, commits beyond main -> creates PR (regress) (AC-4) --- +def test_tc10_ensure_creates_when_commits_beyond_main(merge093, monkeypatch): + monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp093(200, [])) + monkeypatch.setattr(merge_gate, "_branch_fully_in_main", lambda r, b: False) + post_calls = [] + + def fake_post(url, json=None, headers=None, timeout=None): + post_calls.append(url) + return _Resp093(201, {"number": 12}) + + monkeypatch.setattr(httpx, "post", fake_post) + status, detail = merge_gate.ensure_open_pr("orchestrator", ORCH093_BRANCH) + assert status == "created" and detail == "12" + assert len(post_calls) == 1 + + +# --- TC-11: ensure_open_pr — git error in guard (None) -> fail-OPEN -> create path (AC-6) --- +def test_tc11_ensure_guard_git_error_fail_open(merge093, monkeypatch): + monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp093(200, [])) + # None == git/OS error / ambiguous -> must NOT block; degrade to create. + monkeypatch.setattr(merge_gate, "_branch_fully_in_main", lambda r, b: None) + monkeypatch.setattr(httpx, "post", lambda *a, **k: _Resp093(201, {"number": 13})) + status, detail = merge_gate.ensure_open_pr("orchestrator", ORCH093_BRANCH) + assert status == "created" # fail-open: did not become a false no-op + + +def test_tc11_branch_fully_in_main_never_raises(monkeypatch): + """_branch_fully_in_main: any git/OS error -> None (never-raise) (AC-6).""" + monkeypatch.setattr(merge_gate, "ensure_worktree", lambda r, b: "/wt") + + def boom(*a, **k): + raise OSError("git exploded") + + monkeypatch.setattr(merge_gate.subprocess, "run", boom) + assert merge_gate._branch_fully_in_main("orchestrator", ORCH093_BRANCH) is None + + +# --- TC-12: merge_pr / ensure_open_pr — uncaught httpx error -> safe tuple (never-raise) (AC-6) --- +def test_tc12_merge_pr_never_raises(merge093, monkeypatch): + def boom(*a, **k): + raise httpx.HTTPError("kaboom") + + monkeypatch.setattr(httpx, "get", boom) + ok, msg = merge_gate.merge_pr("orchestrator", ORCH093_BRANCH) + assert ok is False and isinstance(msg, str) + + +def test_tc12_ensure_open_pr_never_raises(merge093, monkeypatch): + def boom(*a, **k): + raise httpx.HTTPError("kaboom") + + monkeypatch.setattr(httpx, "get", boom) + status, detail = merge_gate.ensure_open_pr("orchestrator", ORCH093_BRANCH) + assert status == "failed" and isinstance(detail, str) diff --git a/tests/test_merge_verify.py b/tests/test_merge_verify.py index 4d29b14..42ba1ee 100644 --- a/tests/test_merge_verify.py +++ b/tests/test_merge_verify.py @@ -131,3 +131,92 @@ def test_tc12_kill_switch_disables_under_gate(monkeypatch): monkeypatch.setattr(merge_gate.settings, "merge_verify_enabled", False) assert merge_gate.merge_verify_applies("orchestrator") is False assert merge_gate.merge_verify_applies("enduro-trails") is False + + +# =========================================================================== +# ORCH-093 / TC-14..16: _handle_merge_verify integration (deploy->done under-gate). +# already-in-main skips merge_pr; transient-retry success -> done; exhausted -> HOLD. +# =========================================================================== +import os # noqa: E402 +import tempfile # noqa: E402 + +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") +os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_orch093.db")) + +from unittest.mock import MagicMock # noqa: E402 + +from src import stage_engine, image_freshness # noqa: E402 +from src.stage_engine import AdvanceResult, _handle_merge_verify # noqa: E402 + +_O93_REPO = "orchestrator" +_O93_WI = "ORCH-093" +_O93_BRANCH = "feature/ORCH-093-x" + + +@pytest.fixture +def _o93_wire(monkeypatch): + monkeypatch.setattr(stage_engine.merge_gate, "merge_verify_applies", lambda r: True) + monkeypatch.setattr(stage_engine.settings, "merge_verify_autocreate_pr_enabled", True) + monkeypatch.setattr(stage_engine.settings, "regression_guard_enabled", False) + monkeypatch.setattr(image_freshness, "validated_revision", lambda r, b: "deadbeef") + for name in ("set_issue_blocked", "plane_add_comment", "send_telegram", "link_for"): + monkeypatch.setattr(stage_engine, name, MagicMock()) + monkeypatch.setattr( + stage_engine.self_deploy, "record_merged_to_main", MagicMock(return_value=True) + ) + + +# --- TC-14: ensure_open_pr -> already-in-main -> skip merge_pr; SHA-in-main -> done (AC-4) --- +def test_tc14_already_in_main_skips_merge_pr_then_done(_o93_wire, monkeypatch): + monkeypatch.setattr( + stage_engine.merge_gate, "ensure_open_pr", lambda r, b: ("already-in-main", "x") + ) + merge = MagicMock() + monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", merge) + monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True) + + res = AdvanceResult() + intervened = _handle_merge_verify(1, _O93_REPO, _O93_WI, _O93_BRANCH, res) + + assert intervened is False # advance to done + assert res.alerted is False + assert not merge.called # merge_pr SKIPPED (nothing to merge) + assert not stage_engine.set_issue_blocked.called + + +# --- TC-15: merge_pr exhausted (False) + SHA not in main -> HOLD + alert (ORCH-071/081) (AC-3) --- +def test_tc15_merge_failed_and_not_in_main_holds(_o93_wire, monkeypatch): + monkeypatch.setattr( + stage_engine.merge_gate, "ensure_open_pr", lambda r, b: ("existed", "9") + ) + monkeypatch.setattr( + stage_engine.merge_gate, "merge_pr", + lambda r, b: (False, "merge failed after 3 attempts: HTTP 405"), + ) + monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: False) + + res = AdvanceResult() + intervened = _handle_merge_verify(1, _O93_REPO, _O93_WI, _O93_BRANCH, res) + + assert intervened is True # HOLD, NOT done + assert res.advanced is False + assert res.note == "merge-not-verified-hold" + assert stage_engine.set_issue_blocked.called + + +# --- TC-16: happy path — transient retry success in merge_pr -> SHA-in-main -> done (AC-1) --- +def test_tc16_transient_retry_success_then_done(_o93_wire, monkeypatch): + monkeypatch.setattr( + stage_engine.merge_gate, "ensure_open_pr", lambda r, b: ("existed", "9") + ) + # merge_pr already rode out the 405x2->200 transient internally -> (True, ...). + monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", lambda r, b: (True, "merged PR #9")) + monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True) + + res = AdvanceResult() + intervened = _handle_merge_verify(1, _O93_REPO, _O93_WI, _O93_BRANCH, res) + + assert intervened is False # done, no false HOLD + assert res.alerted is False + assert not stage_engine.set_issue_blocked.called diff --git a/tests/test_orch082_ensure_pr.py b/tests/test_orch082_ensure_pr.py index a12644e..c42dd09 100644 --- a/tests/test_orch082_ensure_pr.py +++ b/tests/test_orch082_ensure_pr.py @@ -32,6 +32,11 @@ def _settings(monkeypatch): monkeypatch.setattr(merge_gate.settings, "gitea_owner", "owner") monkeypatch.setattr(merge_gate.settings, "gitea_token", "tok") monkeypatch.setattr(merge_gate.settings, "gitea_url", "http://gitea.test") + # ORCH-093: these tests target the HTTP create/race logic of ensure_open_pr. + # The new already-in-main guard (_branch_fully_in_main) runs real git; pin it + # to "commits beyond main" (False) so the create path is exercised as intended. + # The guard itself has dedicated coverage (test_merge_gate.py TC-09/10/11). + monkeypatch.setattr(merge_gate, "_branch_fully_in_main", lambda r, b: False) def _install_httpx(monkeypatch, get_resp, post_resp=None, record=None):