From 3d0f51512b6ed367772d0286c4628c077e340514 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Tue, 9 Jun 2026 22:23:22 +0300 Subject: [PATCH] architect(ET): auto-commit from architect run_id=512 --- docs/architecture/README.md | 34 +++ ...tor-transient-retry-and-already-in-main.md | 82 +++++++ ...ansient-retry-and-already-in-main-guard.md | 222 ++++++++++++++++++ docs/work-items/ORCH-093/10-tech-risks.md | 36 +++ 4 files changed, 374 insertions(+) create mode 100644 docs/architecture/adr/adr-0027-merge-actor-transient-retry-and-already-in-main.md create mode 100644 docs/work-items/ORCH-093/06-adr/ADR-001-merge-transient-retry-and-already-in-main-guard.md create mode 100644 docs/work-items/ORCH-093/10-tech-risks.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index ddba666..805f683 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -477,6 +477,40 @@ 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 (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/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` **не требуется**; возврат в анализ **не требуется** — +ТЗ реализуемо без нарушения принципов архитектуры.