From c0715ad55b73c8ae1137465505e5f7db5dfdebbc Mon Sep 17 00:00:00 2001 From: Slava Date: Mon, 8 Jun 2026 23:01:46 +0300 Subject: [PATCH 1/7] docs: init ORCH-082 business request --- docs/work-items/ORCH-082/00-business-request.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 docs/work-items/ORCH-082/00-business-request.md diff --git a/docs/work-items/ORCH-082/00-business-request.md b/docs/work-items/ORCH-082/00-business-request.md new file mode 100644 index 0000000..d17b07c --- /dev/null +++ b/docs/work-items/ORCH-082/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: ORCH-81: конвейер не создаёт PR для ветки → деплой стопорится на merge-verify (HOLD) + +Work Item ID: ORCH-082 + +## Description + +TBD From 781f9df26c39ffd101c669cc47b577e3332a7e79 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Tue, 9 Jun 2026 00:29:02 +0300 Subject: [PATCH 2/7] analyst(ET): auto-commit from analyst run_id=407 --- docs/work-items/ORCH-082/01-brd.md | 119 ++++++++++++++++++ docs/work-items/ORCH-082/02-trz.md | 108 ++++++++++++++++ .../ORCH-082/03-acceptance-criteria.md | 69 ++++++++++ docs/work-items/ORCH-082/04-test-plan.yaml | 90 +++++++++++++ 4 files changed, 386 insertions(+) create mode 100644 docs/work-items/ORCH-082/01-brd.md create mode 100644 docs/work-items/ORCH-082/02-trz.md create mode 100644 docs/work-items/ORCH-082/03-acceptance-criteria.md create mode 100644 docs/work-items/ORCH-082/04-test-plan.yaml diff --git a/docs/work-items/ORCH-082/01-brd.md b/docs/work-items/ORCH-082/01-brd.md new file mode 100644 index 0000000..3cf4288 --- /dev/null +++ b/docs/work-items/ORCH-082/01-brd.md @@ -0,0 +1,119 @@ +# 01 — BRD: ORCH-082 (ORCH-81) + +**Конвейер не создаёт PR для ветки → деплой стопорится на merge-verify (HOLD)** + +- Work Item: **ORCH-082** (Plane-заголовок «ORCH-81») +- Repo: `orchestrator` (self-hosting) +- Тип: **Багфикс / надёжность конвейера** +- Приоритет: **HIGH** — блокирует автономный деплой +- Зона: создание PR (reviewer/developer/deployer пути), `src/merge_gate.py`, `src/stage_engine.py` (`_handle_merge_verify`), `src/agents/launcher.py` (`_ensure_pr`) + +--- + +## 1. Контекст и проблема + +При деплое **ORCH-074** (08.06, статус «Confirm Deploy») детерминированный finalizer +(`run_deploy_finalizer` → под-гейт `_handle_merge_verify`) вызвал +`merge_gate.merge_pr(repo, branch)` и получил **`ok=False` («no open PR»)**: в Gitea для +ветки `feature/ORCH-074-…` **не существовало открытого PR** с `head.ref==branch` и +`base.ref=="main"`. + +Защита **ORCH-073** (fail-closed по «SHA-в-main») отработала **корректно**: задача удержана +на стадии `deploy` (НЕ `done`), Plane → Blocked, Telegram-alert, ложно-зелёного `done` не +произошло. Это **правильное** поведение для случая «merge реально невозможен». + +**Дефект не в защите, а в инварианте до неё:** автономный конвейер **не гарантировал**, что к +моменту merge у ветки существует открытый PR. PR на сегодня создаётся ровно в одном месте — +`launcher._ensure_pr`, вызываемом **только** на пути `agent == "developer"` и **только** когда +в этом конкретном run был непустой git-diff, успешный commit и успешный push (см. root-cause +ниже). Любой сценарий, где developer-run не произвёл свежий коммит, оставляет ветку **без PR**, +и задача неминуемо застревает на merge-verify. + +### Workaround, применённый вручную (НЕ фикс) +PR #79 создан вручную через Gitea API (`mergeable=True`) → штатно перезапущен +`run_deploy_finalizer` → `merge_pr` честно влил код в `main` → задача `done`. Это разовое ручное +вмешательство, **не** устранение причины. + +### Почему это системный пробел, а не разовый сбой +Так как создание PR **не гарантировано конвейером**, любая следующая задача с тем же стечением +обстоятельств (developer-run без нового коммита; тихо упавший вызов создания PR; ветка +восстановлена/пересоздана вручную) застрянет на merge-verify тем же образом. Автономность +деплоя (цель ORCH-54) этим заблокирована. + +--- + +## 2. Root cause (предварительный аудит кода — подтвердить логами G1) + +PR создаётся **исключительно** функцией `AgentLauncher._ensure_pr` (`src/agents/launcher.py`), +которая вызывается из `_monitor_agent` по цепочке условий: + +``` +exit_code == 0 + → есть worktree-изменения (git status --porcelain непусто) + → git commit succeeded + → git push succeeded + → agent == "developer" ←── ТОЛЬКО здесь вызывается self._ensure_pr(...) +``` + +Отсюда минимум три структурных способа остаться без PR: + +- **R-A (условное создание).** Если developer-run завершился без изменений (`git status` + пустой) — ветка уже была закоммичена/запушена в прошлый run, бойнс REQUEST_CHANGES без новых + правок, повторный прогон, или ручное восстановление ветки — `_ensure_pr` **не вызывается + вовсе**. PR не появится никогда. (Соответствует гипотезе ТЗ №2.) +- **R-B (тихий сбой создания).** `_ensure_pr` ловит любое исключение + (`except Exception → logger.error → return None`): транзиентная ошибка Gitea на шаге + `POST …/pulls` теряется без ретрая и без эскалации. Конвейер «думает», что developer + отработал, и едет дальше. (Гипотеза ТЗ №1 — silent fail.) +- **R-C (разъехавшееся состояние ветки/PR).** ORCH-074 — первая задача после серии ручных + восстановлений `main` 08.06. PR мог быть закрыт/пересоздан, либо у ветки остался только + авто-docs-PR (`base != main`), который `merge_pr`/`pr_already_merged` корректно НЕ считают + кодовым PR. (Гипотеза ТЗ №4.) + +Идемпотентность (гипотеза №3): сам `_ensure_pr` идемпотентен на чтении (сначала `GET …open&head`, +создаёт только если пусто), но он не запускается вне «свежий developer-коммит», поэтому +идемпотентность не достигает merge-стадии — никакой флаг «PR создан» в БД не хранится. + +**Вывод:** гарантия «к моменту merge у ветки есть открытый код-PR» в конвейере **отсутствует**. + +--- + +## 3. Бизнес-цели + +| ID | Цель | +|----|------| +| **G1** | Установить и задокументировать точную причину отсутствия PR на ORCH-074 (код-аудит + логи run_id 396/398). | +| **G2** | Гарантировать инвариант: к моменту merge-verify у ветки **есть** открытый код-PR; если его нет — finalizer/deployer создаёт его сам, **идемпотентно**, ПЕРЕД `merge_pr`, вместо HOLD на ручное вмешательство. | +| **G3** | Явно логировать факт PR: **PR-created / PR-existed / PR-create-failed** (наблюдаемость). | + +## 4. Не-цели (явные границы) + +- НЕ ослаблять защиту ORCH-073: fail-closed по «SHA-в-main» остаётся. Реальная невозможность + merge → по-прежнему HOLD + alert. +- НЕ авто-мержить без PR (PR — обязательный артефакт ревью/слияния). +- НЕ создавать PR в неподходящий момент — только на ребре `deploy → done`, ПОСЛЕ прохождения + всех гейтов (security/merge-gate/staging/image-freshness уже пройдены). +- НЕ менять `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, схему БД, контракты `check_deploy_status`, + exit-коды хука. + +## 5. Заинтересованные стороны +- **Owner** (homenet542) — автономность деплоя орка. +- Все проекты на инстансе (enduro-trails) — общий прод/очередь: ложный HOLD self-задачи не + должен требовать ручного вмешательства, а реальный дефект merge — обязан удерживаться. + +## 6. Бизнес-риски и допущения +- **Грабли (из ORCH-073):** у ветки может быть несколько PR (код-PR + авто docs-PR). Создание/ + выбор PR обязан фильтровать `head.ref==branch` И `base.ref=="main"`, иначе слияние/верификация + схватят не тот PR. +- **Допущение:** merge-verify исполняется ПОСЛЕ всех гейтов, поэтому создание PR именно здесь не + обходит ревью и безопасно по времени. +- **Контракт надёжности:** весь новый путь — **never-raise**; ошибка создания PR (Gitea + недоступна) → честный HOLD + alert, а не исключение в `advance_stage`. + +## 7. Definition of Done (бизнес-уровень) +1. Root cause задокументирован (`06-adr/` архитектором, ссылка из ADR на этот BRD). +2. После фикса задача с веткой без PR не зависает: конвейер создаёт PR идемпотентно и доводит до + `done` (при честном merge). +3. Защита ORCH-073 цела (регресс-тест на «код не в main» → HOLD). +4. Логи различают created/existed/failed. +5. `pytest` зелёный; never-raise соблюдён. diff --git a/docs/work-items/ORCH-082/02-trz.md b/docs/work-items/ORCH-082/02-trz.md new file mode 100644 index 0000000..cf6c9c9 --- /dev/null +++ b/docs/work-items/ORCH-082/02-trz.md @@ -0,0 +1,108 @@ +# 02 — ТЗ: ORCH-082 (ORCH-81) + +**Гарантированный идемпотентный код-PR перед merge-verify + наблюдаемость** + +> Машина стадий, реестр `QG_CHECKS`, схема БД, exit-коды хука, контракты +> `check_deploy_status`/`_parse_deploy_status`, защита ORCH-073 (SHA-в-main) — **НЕ меняются**. +> Изменение — точечная врезка «ensure PR» в под-гейт merge-verify + новый идемпотентный +> PR-актор в `merge_gate` + структурное логирование. + +--- + +## 1. Задействованные модули `src/` + +| Модуль | Роль в задаче | Характер изменения | +|--------|---------------|--------------------| +| `src/merge_gate.py` | leaf-логика merge-актора (`merge_pr`, `verify_merged_to_main`, `pr_already_merged`) | **+ новый идемпотентный актор** `ensure_open_pr(repo, branch) -> (status, detail)` (never-raise). | +| `src/stage_engine.py` | под-гейт `_handle_merge_verify` на ребре `deploy → done` | **врезка:** вызвать `ensure_open_pr` ПЕРЕД `merge_pr`; на `failed` → честный HOLD+alert; логировать исход. | +| `src/agents/launcher.py` | `_ensure_pr` (текущий единственный создатель PR) | **усилить наблюдаемость** (различать created/existed/failed) — опционально переиспользовать новый актор `merge_gate.ensure_open_pr`, чтобы создание PR было единым кодом. Поведение «создавать только у developer» НЕ ужесточать без необходимости. | +| `src/config.py` | флаги | **+ kill-switch** `merge_verify_autocreate_pr_enabled` (дефолт `True`), область — та же `merge_verify_applies` (self-hosting / `merge_verify_repos`). | +| `docs/architecture/README.md`, `CHANGELOG.md` | golden source | обновить (раздел ORCH-071/073 merge-verify — дописать про авто-создание PR). | + +> Точная сигнатура `ensure_open_pr`, имя/дефолт kill-switch и место врезки — за архитектором +> (ADR). Ниже — функциональные требования к поведению, не финальный дизайн. + +## 2. Функциональные требования + +### FR-1 — Идемпотентный PR-актор `merge_gate.ensure_open_pr(repo, branch)` +Возвращает структурированный исход (например `("existed"|"created"|"failed", detail)`): +1. `GET …/pulls?state=open` → если есть PR с **`head.ref==branch` И `base.ref=="main"`** → + `("existed", )`. **Фильтр идентичен `merge_pr`/ORCH-073 FR-3** — авто-docs-PR + (`base != main`) НЕ считается код-PR. +2. Иначе `POST …/pulls` (`head=branch`, `base=main`, заголовок/тело — авто) → `201` → + `("created", )`. +3. Идемпотентность: если параллельно PR уже создан и Gitea вернёт ошибку «PR exists» — + повторный `GET` подтверждает существующий PR и возвращает `("existed", …)`, **дубль не + плодится** (AC-2). +4. Любая иная ошибка HTTP/parse/сети → `("failed", )`. **Never-raise.** + +### FR-2 — Врезка в `_handle_merge_verify` (ребро `deploy → done`) +Внутри существующего `_handle_merge_verify`, ПОСЛЕ `merge_verify_applies(repo)`-гейта и +резолва `validated_revision`, но **ПЕРЕД** `merge_pr`: +- если `merge_verify_autocreate_pr_enabled` → вызвать `ensure_open_pr(repo, branch)`; +- `status == "created"|"existed"` → продолжить штатно к `merge_pr` → `verify_merged_to_main`; +- `status == "failed"` → **честный HOLD + alert** (как сегодняшний not-merged путь: + `note_not_merged_alert` + `set_issue_blocked` + Plane-коммент + Telegram; задача остаётся на + `deploy`, НЕ `done`, БЕЗ отката на development) с сообщением, отражающим «PR создать не + удалось» (а не «PR не влит»). +- kill-switch off → текущее поведение 1:1 (никакого создания PR). + +### FR-3 — Защита ORCH-073 цела (регресс-инвариант) +Создание PR **не подменяет** проверку слияния. После `ensure_open_pr` + `merge_pr` верификация +остаётся **только** `verify_merged_to_main` (SHA-в-main, ORCH-073 FR-1) + регресс-гард +(`check_main_regression`). Если код реально не оказался в `main` — HOLD сохраняется. Создание PR +лишь устраняет **ложный** HOLD «no open PR», который конвейер обязан был предотвратить. + +### FR-4 — Наблюдаемость (G3) +В лог писать однозначный исход на каждом из мест работы с PR: +- `merge-verify ensure_open_pr -> created PR #N` / +- `… -> existed PR #N` / +- `… -> failed: `. +Сообщение HOLD при `failed` обязано отличаться текстом от HOLD «not merged» (оператор должен +видеть, что причина — невозможность создать PR, а не невозможность слить уже созданный). +Желательно — пометка исхода в `14-deploy-log.md` (best-effort, frontmatter `deploy_status:` +нетронут). + +### FR-5 — Идемпотентность повторного прохода +Повторный заход в merge-verify (reaper / reconciler / повторный approve) при уже существующем +PR → `ensure_open_pr` возвращает `("existed", …)`, `merge_pr` → `already-merged`/штатно — **без +дублей PR и без побочных эффектов** (INV-5/AC-9 ORCH-073 сохранены). + +## 3. Изменения API (HTTP / внутренние) +- **Внешний HTTP API сервиса — без изменений** (новых endpoint нет). +- **Исходящие вызовы Gitea:** новый `POST /api/v1/repos/{owner}/{repo}/pulls` из контекста + merge-verify (тот же вызов, что уже делает `_ensure_pr`); чтение — существующий + `GET …/pulls?state=open`. +- **Внутренний контракт `merge_gate`:** новая публичная функция `ensure_open_pr` (leaf, + never-raise), вызывается из `stage_engine._handle_merge_verify` (и опционально из + `launcher._ensure_pr`). + +## 4. Изменения схемы БД +**Нет.** Состояние идемпотентности выводится из самого Gitea (наличие открытого PR), миграции +не требуются. (Согласуется с restart-safe-моделью merge-verify.) + +## 5. Требования к новым QG checks +**Новых зарегистрированных QG-checks нет.** Это под-гейт-врезка в `advance_stage` +(`_handle_merge_verify`), как и сам ORCH-071 merge-verify — не отдельный `QG_CHECKS`-элемент. +Реестр `QG_CHECKS` не трогается. + +## 6. Конфигурация / kill-switch +- `merge_verify_autocreate_pr_enabled: bool = True` (env `ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED`). + `False` → ровно прежнее поведение (нет авто-создания PR; «no open PR» → HOLD как раньше). +- Область действия — `merge_gate.merge_verify_applies(repo)`: реально только для self-hosting / + `merge_verify_repos`; прочие репо — no-op. + +## 7. Артефакты pipeline (создать/обновить) +- `docs/work-items/ORCH-082/06-adr/ADR-001-*.md` — архитектор (root cause G1 + дизайн ensure-PR). +- `12-review.md`, `13-test-report.md`, `14/15/16-*` — последующие стадии. +- Обновить `docs/architecture/README.md` (блок ORCH-071/073) и `CHANGELOG.md` — в ТОМ ЖЕ PR + (правило агентов №2/№6). + +## 8. Инварианты (не нарушать) +- `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, `check_deploy_status`/`_parse_deploy_status`, + exit-коды хука, terminal-sync, merge-gate (ORCH-043), image-freshness (ORCH-058) — **без + изменений**. +- Контракт **never-raise** на всём пути merge-verify (INV-1 ORCH-073). +- Слияние только через PR (`POST /pulls/{index}/merge`); `main` никогда не push/force-push. +- Защита ORCH-073 (SHA-в-main + регресс-гард) приоритетна: при конфликте «создать PR» проигрывает + «не дать ложно-зелёный done». diff --git a/docs/work-items/ORCH-082/03-acceptance-criteria.md b/docs/work-items/ORCH-082/03-acceptance-criteria.md new file mode 100644 index 0000000..909f50b --- /dev/null +++ b/docs/work-items/ORCH-082/03-acceptance-criteria.md @@ -0,0 +1,69 @@ +# 03 — Критерии приёмки: ORCH-082 (ORCH-81) + +Каждый критерий — однозначное условие PASS/FAIL. Машинные вердикты гейтов — только из +YAML-frontmatter. + +--- + +### AC-1 — Root cause задокументирован +- **PASS:** в `06-adr/ADR-001-*.md` зафиксировано, **почему** PR не создался на ORCH-074 + (со ссылкой на код-путь `launcher._ensure_pr` и/или логи run_id 396/398), и какая из гипотез + R-A/R-B/R-C подтвердилась. +- **FAIL:** причина не названа / только догадка без привязки к коду или логам. + +### AC-2 — Гарантированный идемпотентный код-PR к merge-verify +- **PASS:** к моменту merge-verify у ветки гарантированно существует открытый PR с + `head.ref==branch` И `base.ref=="main"`; повторный вызов авто-создания при уже существующем PR + **не плодит дубль** (возвращает existed). +- **FAIL:** при отсутствии PR задача сразу уходит в HOLD; ИЛИ повторный проход создаёт второй PR. + +### AC-3 — Авто-создание PR ПЕРЕД merge_pr (вместо немедленного HOLD) +- **PASS:** при физическом отсутствии открытого код-PR `_handle_merge_verify` сначала создаёт PR + (`ensure_open_pr → created`), затем выполняет `merge_pr` → `verify_merged_to_main`; ложного + HOLD «no open PR» не возникает. +- **FAIL:** «no open PR» по-прежнему приводит к HOLD без попытки создать PR (при включённом + kill-switch). + +### AC-4 — Защита ORCH-073 цела (регресс) +- **PASS:** при реальном «код не в `main`» (`verify_merged_to_main → False`) — по-прежнему HOLD + + alert + `set_issue_blocked`, задача НЕ `done`, БЕЗ авто-отката на development. Регресс-гард + `check_main_regression` не ослаблен. +- **FAIL:** создание PR маскирует невлитый код и пропускает задачу в `done`; ИЛИ ослаблен + SHA-в-main / регресс-гард. + +### AC-5 — Логи различают исход PR +- **PASS:** в логах присутствует ровно один однозначный исход на проход: **PR-created** / + **PR-existed** / **PR-create-failed**; HOLD по «create-failed» текстуально отличим от HOLD + «not merged». +- **FAIL:** исход не логируется или created/existed/failed неразличимы. + +### AC-6 — Грабли мультиPR: фильтр base==main +- **PASS:** при наличии у ветки авто-docs-PR (`base != main`) актор НЕ принимает его за код-PR и + создаёт/выбирает именно PR на `main`. +- **FAIL:** docs-PR трактуется как код-PR (слияние/верификация работают не с тем PR). + +### AC-7 — Never-raise + честный HOLD при недоступности Gitea +- **PASS:** при ошибке создания PR (Gitea недоступна/HTTP-ошибка) `ensure_open_pr` возвращает + `failed`, путь merge-verify даёт честный HOLD+alert, исключение НЕ всплывает в `advance_stage`. +- **FAIL:** исключение пробрасывается / процесс падает / задача молча уходит в `done`. + +### AC-8 — Kill-switch off → прежнее поведение 1:1 +- **PASS:** при `merge_verify_autocreate_pr_enabled=False` авто-создание не выполняется; «no open + PR» → HOLD как до фикса (поведение ORCH-074 воспроизводится). +- **FAIL:** при выключенном флаге PR всё равно создаётся. + +### AC-9 — Условность (область self-hosting) +- **PASS:** для не-self репозиториев (`merge_verify_applies → False`) врезка — no-op; создание PR + остаётся за прежним механизмом. +- **FAIL:** авто-создание срабатывает для чужих репо. + +### AC-10 — Инварианты не нарушены +- **PASS:** `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, `check_deploy_status`, exit-коды хука, + merge-gate/image-freshness — без изменений; `main` не push/force-push; документация + (`README.md`, `CHANGELOG.md`) обновлена в этом же PR. +- **FAIL:** затронут любой из перечисленных инвариантов / документация не обновлена. + +### AC-11 — pytest зелёный +- **PASS:** `pytest tests/ -q` зелёный, включая новые тесты из `04-test-plan.yaml` и + существующие `test_merge_verify*.py` / `test_orch073_*` / `test_merge_actor.py`. +- **FAIL:** любой тест падает. diff --git a/docs/work-items/ORCH-082/04-test-plan.yaml b/docs/work-items/ORCH-082/04-test-plan.yaml new file mode 100644 index 0000000..8c6e315 --- /dev/null +++ b/docs/work-items/ORCH-082/04-test-plan.yaml @@ -0,0 +1,90 @@ +work_item: ORCH-082 +title: "Гарантированный идемпотентный код-PR перед merge-verify (фикс ложного HOLD)" +strategy: > + Юнит-тесты на новый идемпотентный актор merge_gate.ensure_open_pr (мок Gitea HTTP) + и интеграционные тесты на врезку в stage_engine._handle_merge_verify (мок merge_gate + + verify), включая регресс ORCH-073. Все пути — never-raise. Gitea и git мокаются, + сеть не дёргается. + +tests: + # ---- ensure_open_pr: идемпотентный PR-актор (FR-1) ---- + - id: TC-01 + type: unit + description: "ensure_open_pr: открытого код-PR нет -> POST создаёт PR -> ('created', N); фильтр base==main применён" + module: tests/test_orch082_ensure_pr.py + expected: PASS + + - id: TC-02 + type: unit + description: "ensure_open_pr: открытый PR head==branch И base==main уже есть -> ('existed', N), POST не вызывается (нет дубля)" + module: tests/test_orch082_ensure_pr.py + expected: PASS + + - id: TC-03 + type: unit + description: "Грабли мультиPR: у ветки только docs-PR (base!=main) -> он НЕ считается код-PR -> создаётся PR на main (AC-6)" + module: tests/test_orch082_ensure_pr.py + expected: PASS + + - id: TC-04 + type: unit + description: "ensure_open_pr never-raise: Gitea POST/GET кидает HTTP/timeout -> ('failed', reason), исключение не всплывает (AC-7)" + module: tests/test_orch082_ensure_pr.py + expected: PASS + + - id: TC-05 + type: unit + description: "Идемпотентность гонки: POST вернул 'PR exists' -> повторный GET подтверждает существующий -> ('existed', N), дубль не создан" + module: tests/test_orch082_ensure_pr.py + expected: PASS + + # ---- _handle_merge_verify: врезка ensure-PR (FR-2/FR-3) ---- + - id: TC-06 + type: integration + description: "merge-verify: PR отсутствовал -> ensure_open_pr создаёт -> merge_pr -> verify True -> deploy->done БЕЗ ложного HOLD (AC-3)" + module: tests/test_orch082_merge_verify_autocreate.py + expected: PASS + + - id: TC-07 + type: integration + description: "Регресс ORCH-073: PR создан/влит, но verify_merged_to_main=False (код не в main) -> HOLD + set_issue_blocked, НЕ done, без отката (AC-4)" + module: tests/test_orch082_merge_verify_autocreate.py + expected: PASS + + - id: TC-08 + type: integration + description: "ensure_open_pr -> 'failed' (Gitea down) -> честный HOLD+alert, текст отличается от 'not merged', advance_stage не падает (AC-7)" + module: tests/test_orch082_merge_verify_autocreate.py + expected: PASS + + - id: TC-09 + type: integration + description: "Kill-switch merge_verify_autocreate_pr_enabled=False -> ensure_open_pr не вызывается, 'no open PR' -> прежний HOLD 1:1 (AC-8)" + module: tests/test_orch082_merge_verify_autocreate.py + expected: PASS + + - id: TC-10 + type: integration + description: "Условность: non-self репо (merge_verify_applies=False) -> врезка no-op, авто-создание не выполняется (AC-9)" + module: tests/test_orch082_merge_verify_autocreate.py + expected: PASS + + - id: TC-11 + type: integration + description: "Идемпотентный повторный проход (reaper/reconciler): PR уже existed, merge_pr=already-merged -> verify True -> done, без дублей PR (AC-2/FR-5)" + module: tests/test_orch082_merge_verify_autocreate.py + expected: PASS + + # ---- Наблюдаемость (G3 / AC-5) ---- + - id: TC-12 + type: unit + description: "Логи различают created/existed/failed; HOLD-сообщение create-failed != HOLD-сообщение not-merged (caplog, AC-5)" + module: tests/test_orch082_merge_verify_autocreate.py + expected: PASS + + # ---- Регресс существующего merge-verify контракта ---- + - id: TC-13 + type: integration + description: "Happy-path ORCH-071/073 не изменён: merge_pr ok + verify True + регресс-гард ok -> done, merged_to_main: true во frontmatter" + module: tests/test_merge_verify.py + expected: PASS From 74269b467cc01182531969e8dc45dfde07992189 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Tue, 9 Jun 2026 00:41:31 +0300 Subject: [PATCH 3/7] architect(ET): auto-commit from architect run_id=408 --- docs/architecture/README.md | 33 +++ docs/architecture/adr/README.md | 4 +- ...0016-ensure-open-pr-before-merge-verify.md | 52 +++++ ...-001-ensure-open-pr-before-merge-verify.md | 221 ++++++++++++++++++ docs/work-items/ORCH-082/10-tech-risks.md | 27 +++ 5 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 docs/architecture/adr/adr-0016-ensure-open-pr-before-merge-verify.md create mode 100644 docs/work-items/ORCH-082/06-adr/ADR-001-ensure-open-pr-before-merge-verify.md create mode 100644 docs/work-items/ORCH-082/10-tech-risks.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 7bb26b9..995d13c 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -206,6 +206,39 @@ merge-в-main вообще**. Detached host-деплой лишь retag'ал о `docs/work-items/ORCH-071/06-adr/ADR-001-merge-verify-gate.md`, `docs/work-items/ORCH-073/06-adr/ADR-001-merge-verify-sha-truth-and-regression-guard.md`. +#### Гарантированный код-PR перед merge-verify (ORCH-082 — фикс ложного HOLD «no open PR») +Под-гейт merge-verify (ORCH-071/073) детерминированно мержит **открытый** код-PR ветки в `main` +(`merge_pr`, фильтр `head.ref==branch` И `base.ref=="main"`). Но конвейер **не гарантировал**, что +к моменту merge у ветки этот PR есть: PR создаётся единственной `launcher._ensure_pr` **только** на +developer-пути и **только** при свежем worktree-коммите. На деплое ORCH-074 (08.06, первая задача +после ручных восстановлений `main`) у ветки не оказалось открытого код-PR → `merge_pr` вернул +`("False", "no open PR")` → защита ORCH-073 верно удержала задачу (HOLD, не ложный `done`), но это +лечило следствие. ORCH-082 закрывает **отсутствующий инвариант** «к merge-verify у ветки есть +открытый код-PR» аддитивно, внутри того же под-гейта, не трогая машину стадий: +- **Новый leaf-актор `merge_gate.ensure_open_pr(repo, branch) -> (status, detail)`** (never-raise): + `GET …/pulls?state=open` с фильтром `head.ref==branch` И `base.ref=="main"` (**идентичен** + `merge_pr`/ORCH-073 FR-3 — авто-docs-PR `base != main` НЕ код-PR) → `("existed", N)`; иначе + `POST …/pulls` → `("created", N)`; гонка «PR exists»/409/422 → повторный GET → `existed` (без + дублей); любая иная ошибка → `("failed", reason)`. +- **Врезка в `_handle_merge_verify`** ПОСЛЕ резолва `validated_revision` и **ПЕРЕД** `merge_pr`: + `created|existed` → штатно к `merge_pr` → `verify_merged_to_main`; `failed` → честный HOLD+alert + через новый helper `_hold_pr_create_failed` (текст «PR создать не удалось» — отличим от + not-merged HOLD; `result.note="pr-create-failed-hold"`), задача остаётся на `deploy`, БЕЗ отката + на development. +- **Защита ORCH-073 неприкосновенна и приоритетна:** подтверждение merge остаётся ТОЛЬКО + `verify_merged_to_main` (SHA-в-main) + `check_main_regression`; `ensure_open_pr` устраняет лишь + **ложный** HOLD «no open PR», но не маскирует реально невлитый код (тот → HOLD как прежде). +- **`launcher._ensure_pr`** рекомендуется делегировать в `ensure_open_pr` (единый код создания PR), + сохранив прежний триггер «только developer-путь». +- **Условность как ORCH-35/43/58/71:** kill-switch `merge_verify_autocreate_pr_enabled` (дефолт + `true`); область — `merge_verify_applies(repo)` (self-hosting / `merge_verify_repos`); non-self — + no-op. `False` → поведение ORCH-074 1:1. Идемпотентность из Gitea (наличие открытого PR), **без + миграции БД** (restart-safe). `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, `check_deploy_status`, + exit-коды хука, merge-gate, image-freshness — без изменений; `main` не push/force-push. + +Подробнее: [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`. + ### Post-deploy наблюдение прода + реакция на деградацию (ORCH-021 — реализовано) Конвейер заканчивался на `deploy → done` и **забывал про прод**: «успех» = health-check в момент рестарта (~60с). Класс «зелёный деплой, красный прод» (прецедент ET-8 — diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md index e496903..58bf7c0 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -21,12 +21,14 @@ Per-work-item решения живут в `docs/work-items//06-adr/ADR-NNN- | adr-0013 | Merge-в-main + пост-деплой верификация как условие `done` | accepted | 2026-06-08 | ORCH-071 | | adr-0014 | SHA-в-main — единственный критерий merge-verify + регресс-гард | accepted | 2026-06-08 | ORCH-073 | | adr-0015 | Зависимости задач (B ждёт A) + сериализация merge внутри репо | accepted | 2026-06-08 | ORCH-026 | +| adr-0016 | ensure_open_pr — гарантированный код-PR перед merge-verify | accepted | 2026-06-09 | ORCH-082 | > ⚠️ Историческая коллизия: номер `0007` занят двумя файлами — > `adr-0007-reconciler.md` (ORCH-053) и `adr-0007-executable-self-deploy.md` > (ORCH-036). Оба accepted; для новых сквозных ADR использовать следующий -> свободный номер (текущий максимум — `0015`). +> свободный номер (текущий максимум — `0016`). > adr-0014 **amends** adr-0013 (меняет критерий merge-verify на «SHA-в-main»). +> adr-0016 **amends** adr-0013/0014 (гарантирует открытый код-PR перед merge_pr, ORCH-082). ## Формат **Контекст → Решение → Альтернативы → Последствия → Связи.** Статус: proposed / accepted / superseded. diff --git a/docs/architecture/adr/adr-0016-ensure-open-pr-before-merge-verify.md b/docs/architecture/adr/adr-0016-ensure-open-pr-before-merge-verify.md new file mode 100644 index 0000000..ede581a --- /dev/null +++ b/docs/architecture/adr/adr-0016-ensure-open-pr-before-merge-verify.md @@ -0,0 +1,52 @@ +# ADR-0016: ensure_open_pr — гарантированный код-PR перед merge-verify (ORCH-082) + +## Статус +Accepted — амендмент к [adr-0013](adr-0013-merge-verify-gate.md) и +[adr-0014](adr-0014-merge-verify-sha-source-of-truth.md). Детально: +`docs/work-items/ORCH-082/06-adr/ADR-001-ensure-open-pr-before-merge-verify.md`. + +## Контекст +Merge-verify (ORCH-071/073) — под-гейт ребра `deploy → done`: детерминированно мержит код-PR в +`main` (`merge_pr`) и подтверждает merge **только** по «SHA-в-main» (`verify_merged_to_main`, +ORCH-073). На деплое ORCH-074 (08.06) `merge_pr` вернул `("False", "no open PR")`: у ветки **не +было** открытого PR с `head==branch` И `base=="main"`. Защита ORCH-073 верно удержала задачу +(HOLD, не ложный `done`), но это лечило **следствие**. + +Первопричина (код-аудит): PR создаётся в конвейере **единственной** функцией +`launcher._ensure_pr`, вызываемой **только** на developer-пути и **только** при свежем +worktree-коммите. Любой сценарий без свежего developer-коммита (бойнс без правок, повторный +прогон, **ручное восстановление ветки/`main`** — случай ORCH-074) оставляет ветку без код-PR. +Инвариант «к merge-verify у ветки есть открытый код-PR» в конвейере **отсутствовал** → блокер +автономного деплоя (ORCH-54). + +## Решение +Аддитивно обеспечить инвариант **внутри того же под-гейта**, ПЕРЕД `merge_pr`, не трогая машину +стадий: + +1. **Новый leaf-актор `merge_gate.ensure_open_pr(repo, branch) -> (status, detail)`** (never-raise): + `GET …/pulls?state=open` с фильтром **`head.ref==branch` И `base.ref=="main"`** (идентичен + `merge_pr`/ORCH-073 FR-3 — авто-docs-PR не считается код-PR) → `("existed", N)`; иначе + `POST …/pulls` → `("created", N)`; гонка «PR exists» → повторный GET → `existed` (без дублей); + любая ошибка → `("failed", reason)`. +2. **Врезка в `_handle_merge_verify`** ПОСЛЕ резолва `validated_revision` и ПЕРЕД `merge_pr`: + `created|existed` → штатно к `merge_pr`; `failed` → честный HOLD+alert через новый helper + `_hold_pr_create_failed` (текст «PR создать не удалось» — отличим от not-merged HOLD), задача + остаётся на `deploy`, БЕЗ отката на development. +3. **Kill-switch `merge_verify_autocreate_pr_enabled`** (дефолт `True`); область — + `merge_verify_applies` (self-hosting / `merge_verify_repos`). `False` → поведение ORCH-074 1:1. +4. **`launcher._ensure_pr`** рекомендуется делегировать в `ensure_open_pr` (единый код создания + PR), сохранив прежний триггер «только developer-путь». + +## Последствия +- **Защита ORCH-073 неприкосновенна и приоритетна:** подтверждение merge остаётся ТОЛЬКО + `verify_merged_to_main` (SHA-в-main) + `check_main_regression`. Создание PR устраняет лишь + **ложный** HOLD «no open PR», но не маскирует реально невлитый код (тот → HOLD как прежде). +- **Без миграций:** идемпотентность выводится из Gitea (наличие открытого PR), схема БД не меняется + — restart-safe; повторный заход (reaper/reconciler/re-approve) → `existed`, дублей нет. +- **Инварианты целы:** `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, `check_deploy_status`, + exit-коды хука, merge-gate (ORCH-043), image-freshness (ORCH-058) — без изменений; `main` не + push/force-push; never-raise на всём пути. +- **Наблюдаемость:** один однозначный исход в логах на проход — created / existed / failed; HOLD по + failed текстуально отличим от HOLD not-merged. +- **Минус:** код-PR может создаваться после прохождения гейтов — безопасно, т.к. гейты валидируют + код ветки, а merge-verify идёт ПОСЛЕ всех гейтов; PR — лишь механизм слияния, ревью не обходится. diff --git a/docs/work-items/ORCH-082/06-adr/ADR-001-ensure-open-pr-before-merge-verify.md b/docs/work-items/ORCH-082/06-adr/ADR-001-ensure-open-pr-before-merge-verify.md new file mode 100644 index 0000000..f10cbb7 --- /dev/null +++ b/docs/work-items/ORCH-082/06-adr/ADR-001-ensure-open-pr-before-merge-verify.md @@ -0,0 +1,221 @@ +# ADR-001: Гарантированный идемпотентный код-PR перед merge-verify (ensure_open_pr) + +- Work Item: **ORCH-082** (Plane-заголовок «ORCH-81») +- Repo: `orchestrator` (self-hosting) +- Связь: амендмент к 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) +- BRD/ТЗ/AC: `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md` + +## Статус +Accepted + +## Контекст + +### Что случилось (инцидент ORCH-074, 08.06) +Деплой ORCH-074 встал на под-гейте merge-verify (ребро `deploy → done`): +`run_deploy_finalizer → _handle_merge_verify` вызвал `merge_gate.merge_pr(repo, branch)` и +получил `ok=False, "no open PR"` — в Gitea для ветки `feature/ORCH-074-…` **не было открытого +PR** с `head.ref==branch` И `base.ref=="main"`. Защита ORCH-073 (fail-closed по «SHA-в-main») +**отработала правильно**: задача удержана на `deploy` (НЕ `done`), Plane → Blocked, Telegram-alert, +ложно-зелёного `done` не произошло. Разблокировано вручную — PR #79 создан через Gitea API, +finalizer перезапущен, код честно влит, задача `done`. Это **workaround, не фикс**. + +### Root cause (G1, подтверждён код-аудитом) +PR создаётся в конвейере **ровно в одном месте** — `AgentLauncher._ensure_pr` +(`src/agents/launcher.py:1079`), и вызывается он из `_monitor_agent` **только** по цепочке +условий (`src/agents/launcher.py:751–753`): + +``` +exit_code == 0 + → git status --porcelain непусто (есть worktree-изменения) + → git commit succeeded + → git push succeeded + → agent == "developer" ←── ТОЛЬКО здесь вызывается self._ensure_pr(...) +``` + +Отсюда класс «ветка без PR» структурно неизбежен. Подтверждённые код-аудитом ветви: + +- **R-A (условное создание) — структурный первопричинный дефект.** Если в конкретном + developer-run нет свежих изменений (`git status` пуст: ветка уже была закоммичена/запушена + ранее, бойнс REQUEST_CHANGES без новых правок, повторный прогон, **ручное восстановление + ветки**) — `_ensure_pr` **не вызывается вовсе**. PR не появится никогда. Никакого + персистентного флага «PR создан» в БД нет, поэтому идемпотентность чтения внутри `_ensure_pr` + до merge-стадии не доходит. +- **R-C (разъехавшееся состояние ветки/PR) — проксимальный триггер ORCH-074.** ORCH-074 — первая + задача после серии **ручных восстановлений `main` 08.06**: открытый код-PR был закрыт/не + пересоздан, у ветки мог остаться лишь авто-docs-PR (`base != main`), который `merge_pr` (фильтр + `base=="main"`, ORCH-073 FR-3) корректно НЕ считает кодовым. +- **R-B (тихий сбой создания) — потенциальная, не первопричина здесь.** `_ensure_pr` глотает любое + исключение (`except Exception → logger.error → return None`): транзиентная ошибка Gitea на + `POST …/pulls` теряется без ретрая и эскалации. + +**Вывод:** в конвейере **отсутствует инвариант** «к моменту merge-verify у ветки есть открытый +код-PR». Защита ORCH-073 верно ловит следствие, но причина — выше по потоку. Любая следующая +задача с тем же стечением обстоятельств застрянет тем же образом → автономный деплой (ORCH-54) +заблокирован. + +### Ограничения, которые нельзя нарушать +- Защита ORCH-073 (SHA-в-main + регресс-гард) — приоритетна. Создание PR **не должно** маскировать + реально невлитый код. +- `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, схема БД, `check_deploy_status`/`_parse_deploy_status`, + exit-коды хука, merge-gate (ORCH-043), image-freshness (ORCH-058) — без изменений. +- Весь путь merge-verify — **never-raise**. +- Слияние только через PR; `main` никогда не push/force-push. + +## Решение + +Закрыть пробел инвариантом «обеспечить открытый код-PR» **внутри того же под-гейта merge-verify**, +ПЕРЕД детерминированным `merge_pr`. Три точечные врезки, симметричные существующему дизайну +ORCH-071/073 (leaf-актор в `merge_gate` + врезка в `_handle_merge_verify` + kill-switch). Машина +стадий и реестры не трогаются. + +### Р-1. Новый идемпотентный leaf-актор `merge_gate.ensure_open_pr(repo, branch)` + +Сигнатура (решение архитектора по ТЗ §1): + +```python +def ensure_open_pr(repo: str, branch: str) -> tuple[str, str]: + """Гарантировать открытый код-PR (head==branch, base==main). never-raise. + Возврат: ("existed", "") | ("created", "") | ("failed", ""). + """ +``` + +Алгоритм (FR-1): +1. `GET …/pulls?state=open` → найти PR с **`head.ref==branch` И `base.ref=="main"`**. Фильтр + **идентичен** `merge_pr`/ORCH-073 FR-3 — авто-docs-PR (`base != main`) НЕ считается код-PR + (AC-6). Нашли → `("existed", )`. +2. Иначе `POST …/pulls` (`head=branch`, `base="main"`, авто-заголовок/тело) → `201` → + `("created", )`. +3. **Идемпотентность при гонке:** если на `POST` Gitea вернёт «PR exists»/`409`/`422` — + повторный `GET` (шаг 1) подтверждает существующий PR → `("existed", …)`. Дубль не плодится + (AC-2, FR-5). +4. Любая иная HTTP/parse/сетевая ошибка → `("failed", )`. **Never-raise** (`except + Exception → ("failed", str(e))`). + +Актор — **leaf** (зависит только от `settings` + `httpx`, без импорта `stage_engine`), как +`merge_pr`/`verify_merged_to_main`. Таймауты — переиспользовать `settings.merge_pr_timeout_s` +(тот же класс Gitea-вызовов). + +> **Почему фильтр `base=="main"` критичен** (грабли ORCH-073): у ветки одновременно бывают код-PR +> и авто-docs-PR. Без фильтра актор «увидит» docs-PR как existed и не создаст нужный код-PR, а +> `merge_pr` потом не найдёт что мержить → петля. Один и тот же предикат `head==branch && +> base=="main"` гарантирует, что `ensure_open_pr` и `merge_pr` работают с одним и тем же PR. + +### Р-2. Врезка в `_handle_merge_verify` (ребро `deploy → done`) + +В существующем `_handle_merge_verify` (`src/stage_engine.py:1324`), **ПОСЛЕ** +`merge_verify_applies(repo)`-гейта и резолва `sha = image_freshness.validated_revision(...)`, +но **ПЕРЕД** `merge_pr`: + +```python +sha = image_freshness.validated_revision(repo, branch) + +# ORCH-082: гарантировать открытый код-PR ДО детерминированного merge_pr. +if settings.merge_verify_autocreate_pr_enabled: + pr_status, pr_detail = merge_gate.ensure_open_pr(repo, branch) + logger.info( + f"Task {task_id}: merge-verify ensure_open_pr -> {pr_status} ({pr_detail})" + ) + if pr_status == "failed": + return _hold_pr_create_failed( + task_id, repo, work_item_id, branch, pr_detail, result + ) + # "created" | "existed" -> штатно продолжаем к merge_pr. + +merged_ok, merge_msg = merge_gate.merge_pr(repo, branch) +... +``` + +Семантика (FR-2): +- `created | existed` → продолжаем штатно к `merge_pr` → `verify_merged_to_main` → регресс-гард. +- `failed` → **честный HOLD + alert** через новый helper `_hold_pr_create_failed` (см. Р-3); задача + остаётся на `deploy` (НЕ `done`), БЕЗ отката на development — симметрично текущему not-merged/ + regressed HOLD. +- kill-switch off → блок пропускается целиком → поведение 1:1 как до фикса (AC-8). + +Место выбрано так, что **никакой существующий шаг не сдвигается**: `merge_pr` и +`verify_merged_to_main` остаются на своих местах с теми же контрактами. Создание PR — это только +страховка инварианта ДО них. + +### Р-3. Новый HOLD-helper `_hold_pr_create_failed` (распознаваемость причины, FR-4/AC-5) + +Зеркало существующего `_hold_main_regressed` (`src/stage_engine.py:1280`). Текст HOLD **обязан +отличаться** от not-merged HOLD: оператор должен видеть, что причина — **невозможность создать +PR** (Gitea недоступна), а не **невозможность слить уже созданный**: + +```python +def _hold_pr_create_failed(task_id, repo, work_item_id, branch, reason, result) -> bool: + merge_gate.note_not_merged_alert(work_item_id) # переиспользуем счётчик-нотификатор + msg = (f"PR создать не удалось: {reason} (repo={repo}, branch={branch}, " + f"wi={work_item_id}). Открытый код-PR отсутствует и не создан — задача " + f"удержана на `deploy` (НЕ done). Нужно проверить доступность Gitea / создать PR.") + # set_issue_blocked + plane_add_comment + send_telegram (каждый в try/except, never-break HOLD) + result.alerted = True + result.note = "pr-create-failed-hold" # отличается от "merge-not-verified-hold" + result.advanced = False + return True +``` + +Это сохраняет инвариант «никогда не пробрасываем исключение в `advance_stage`»: `failed` — +структурированный исход, а не throw. + +### Р-4. Единый источник кода создания PR (опционально, рекомендуется) + +`launcher._ensure_pr` рекомендуется **делегировать** в `merge_gate.ensure_open_pr`, чтобы создание +PR жило в одном месте и одинаково логировало created/existed/failed (G3). **Поведенческий +инвариант:** триггер «создавать PR только в developer-пути со свежим коммитом» **НЕ ужесточается** +(BRD/ТЗ §1) — меняется лишь реализация под капотом, не условие вызова. Это снижает риск +рассинхрона двух копий логики «выбрать/создать PR». Если делегирование увеличивает диффу/риск — +допустимо оставить `_ensure_pr` как есть и лишь усилить его логирование (created/existed/failed); +функциональная цель ORCH-082 достигается врезкой Р-2 независимо. + +### Р-5. Kill-switch и область действия + +- `merge_verify_autocreate_pr_enabled: bool = True` + (env `ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED`) в `src/config.py`, рядом с + `merge_verify_enabled`/`regression_guard_enabled`. +- `False` → ровно прежнее поведение: авто-создания нет, «no open PR» → HOLD как в ORCH-074 (AC-8). +- Область — `merge_gate.merge_verify_applies(repo)` (self-hosting / `merge_verify_repos`); прочие + репо — no-op, создание PR остаётся за прежним механизмом (AC-9). Отдельного `*_repos` для + авто-создания НЕ вводим: семантически оно неотделимо от merge-verify, у которого уже есть область. + +## Последствия + +### Плюсы +- Закрыт структурный пробел: к merge-verify ветка гарантированно имеет открытый код-PR; ложный + HOLD «no open PR» больше не требует ручного вмешательства (AC-2/AC-3). +- Защита ORCH-073 цела и приоритетна: верификация остаётся **только** `verify_merged_to_main` + (SHA-в-main) + `check_main_regression`. Реально невлитый код → HOLD как прежде (AC-4/FR-3). +- Идемпотентность по факту Gitea (наличие открытого PR), без новой колонки/таблицы — согласуется с + restart-safe-моделью merge-verify; повторный заход (reaper/reconciler/re-approve) → `existed`, + дублей нет (FR-5/AC-2). +- Распознаваемые исходы в логах и в HOLD-тексте: created / existed / failed (G3/AC-5). +- Инварианты сохранены: `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, `check_deploy_status`, + exit-коды хука, merge-gate, image-freshness — не тронуты (AC-10). `main` не push/force-push. + +### Минусы / ограничения +- Auto-создание PR на ребре `deploy → done` означает, что код-PR может появиться **после** того, + как все гейты (security/merge-gate/staging/image-freshness) уже пройдены по ветке. Это безопасно + по времени (BRD §6 допущение): ревью/гейты валидируют **код ветки**, а PR — лишь механизм + слияния; merge-verify исполняется ПОСЛЕ всех гейтов. PR здесь не обходит ревью. +- При недоступности Gitea задача попадёт в HOLD (как и сегодня) — но теперь с явным текстом + «PR создать не удалось» вместо «PR не влит». Это сознательный fail-closed (AC-7): never-raise, + честный HOLD, не ложно-зелёный `done`. +- Небольшое дублирование Gitea-вызовов между `ensure_open_pr` и `merge_pr` (оба GET список PR). Это + приемлемо: два независимых leaf-актора с одинаковым фильтром важнее микро-оптимизации; объединять + в один вызов — увеличить связность без пользы. + +### Влияние на self-hosting +Изменение строго аддитивно и под kill-switch (`True`). Прод-контейнер не рестартится этой задачей; +выкат — через staging-гейт (8501) как любая ORCH-задача. На ребре `deploy → done` риск-профиль не +растёт: при любом сбое — HOLD, не падение `advance_stage`, конвейер всех проектов не встаёт. + +## Связанные документы +- BRD/ТЗ/AC: `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md` +- Тех-риски: `10-tech-risks.md` +- Глобальный амендмент: [adr-0016](../../../architecture/adr/adr-0016-ensure-open-pr-before-merge-verify.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) +- Постмортем фантомного merge: `docs/history/LESSONS_2026-06-08_phantom-merge.md`, + runbook `docs/operations/PHANTOM_MERGE_RUNBOOK.md` diff --git a/docs/work-items/ORCH-082/10-tech-risks.md b/docs/work-items/ORCH-082/10-tech-risks.md new file mode 100644 index 0000000..9829afc --- /dev/null +++ b/docs/work-items/ORCH-082/10-tech-risks.md @@ -0,0 +1,27 @@ +# 10 — Технические риски: ORCH-082 (ORCH-81) + +Риски точечной врезки «ensure_open_pr перед merge-verify». Все — в зоне ребра `deploy → done` +(self-hosting), под kill-switch `merge_verify_autocreate_pr_enabled`. + +| ID | Риск | Вероятн. | Влияние | Митигация | +|----|------|----------|---------|-----------| +| **R1** | `ensure_open_pr` выбирает/создаёт **не тот** PR (авто-docs-PR `base != main`) → `merge_pr` мержит/верифицирует не тот PR | Сред. | Высокое | Фильтр `head.ref==branch` И `base.ref=="main"`, **идентичный** `merge_pr` (ORCH-073 FR-3). Тест AC-6: ветка с docs-PR (`base!=main`) → актор его игнорирует и создаёт код-PR на `main`. | +| **R2** | Создание PR **маскирует** реально невлитый код → ложно-зелёный `done` (регресс ORCH-073) | Низк. | Критич. | Верификация остаётся ТОЛЬКО `verify_merged_to_main` (SHA-в-main) + `check_main_regression`; `ensure_open_pr` НЕ влияет на вердикт merge. Регресс-тест AC-4: `verify_merged_to_main→False` ⇒ HOLD, не `done`. | +| **R3** | Гонка: параллельно создаётся 2 PR → дубль | Низк. | Сред. | Идемпотентность FR-1.3: на ошибку «PR exists»/409/422 — повторный GET → `existed`; PR создаётся только если GET пуст. Тест AC-2. | +| **R4** | Исключение из `ensure_open_pr` пробрасывается в `advance_stage` → падение перехода | Низк. | Высокое | Контракт never-raise (`except Exception → ("failed", reason)`); врезка обёрнута внешним try/except `_handle_merge_verify`. `failed` → структурированный HOLD, не throw. Тест AC-7. | +| **R5** | Gitea недоступна на ребре `deploy → done` → задача в HOLD | Низк. | Сред. | Сознательный fail-closed: `failed` → честный HOLD+alert (`_hold_pr_create_failed`), НЕ ложный `done`. Текст HOLD отличим от not-merged (AC-5) — оператор видит причину. Reaper/reconciler/re-approve переиграют, когда Gitea вернётся (FR-5). | +| **R6** | Оператор не различит HOLD «PR не создан» и HOLD «PR не влит» | Сред. | Низк. | Отдельный helper `_hold_pr_create_failed` с собственным текстом и `result.note="pr-create-failed-hold"` (≠ `merge-not-verified-hold`); лог-строка `ensure_open_pr -> failed: `. AC-5. | +| **R7** | Расхождение логики выбора/создания PR между `launcher._ensure_pr` и `merge_gate.ensure_open_pr` | Сред. | Сред. | Рекомендованное делегирование `_ensure_pr → ensure_open_pr` (единый код). Если не делегируем — обе копии используют ОДИН фильтр `head==branch && base==main`; тест на согласованность. | +| **R8** | Включение по умолчанию (`True`) меняет прод-поведение скрытно | Низк. | Сред. | Поведение строго аддитивно: при наличии PR → `existed`/no-op; меняется лишь ранее-падавший путь «no open PR». Kill-switch `False` → 1:1 ORCH-074 (AC-8). Выкат через staging-гейт (8501). | +| **R9** | Регресс инвариантов (`STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД/exit-коды) | Низк. | Высокое | Под-гейт-врезка в `advance_stage`, НЕ новый `QG_CHECKS`-элемент и НЕ новая стадия; БД не трогается (идемпотентность из Gitea). Тест AC-10 + полный `pytest`. | + +## Зоны без изменений (подтверждение границ) +- **Инфраструктура/топология** — без изменений → `07-infra-requirements.md` не требуется. +- **Схема БД** — без изменений (идемпотентность выводится из Gitea) → `08-data-requirements.md` + не требуется. +- `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status`, exit-коды хука, + merge-gate (ORCH-043), image-freshness (ORCH-058), terminal-sync — не тронуты. + +## Главный архитектурный приоритет +При любом конфликте «создать PR» **проигрывает** «не дать ложно-зелёный `done`» (защита ORCH-073). +Создание PR — страховка инварианта ДО merge_pr, никогда не подмена верификации merge. From 0ab6a33ef5eb2b3e33957c288bcd060a1ee9f5fb Mon Sep 17 00:00:00 2001 From: claude-bot Date: Tue, 9 Jun 2026 00:48:43 +0300 Subject: [PATCH 4/7] feat(merge-verify): guarantee idempotent open code-PR before merge_pr (ORCH-082) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the missing invariant "by merge-verify time the branch has an open code-PR". The pipeline created a PR only on the developer path with a fresh worktree commit (launcher._ensure_pr), so a branch (e.g. after a manual main restore) could reach the deploy->done merge-verify under-gate PR-less -> merge_pr returned "no open PR" -> a FALSE HOLD (ORCH-074 incident). - merge_gate.ensure_open_pr(repo, branch) -> (status, detail): idempotent leaf-actor (never-raise). GET open PRs filtered head==branch AND base==main (identical to merge_pr/ORCH-073 FR-3 — auto docs-PR is not a code-PR) -> existed; else POST -> created; 409/422 race -> re-GET -> existed (no dup); any other error -> failed. - stage_engine._handle_merge_verify: врезка after validated_revision and BEFORE merge_pr. created|existed -> proceed; failed -> honest HOLD via new _hold_pr_create_failed (note "pr-create-failed-hold", text distinguishable from the not-merged HOLD; task stays on deploy, NO rollback). - launcher._ensure_pr delegated to ensure_open_pr (single PR-creation path, shared head==branch & base==main filter); the developer-only trigger is unchanged. - ORCH-073 protection untouched & authoritative: merge is confirmed ONLY by verify_merged_to_main (SHA-in-main) + check_main_regression. Real un-merged code still HOLDs. - Kill-switch ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED (default true); scope = merge_verify_applies (self-hosting / merge_verify_repos); non-self -> no-op; false -> ORCH-074 behaviour 1:1. No DB migration; main never push/force-push. - Append ORCH-082 marker to MAIN_REGRESSION_MARKERS (append-only convention). - conftest defaults the autocreate flag OFF (mirrors merge_verify_enabled) so unrelated deploy->done tests stay 1:1 (no network). Tests: tests/test_orch082_ensure_pr.py (TC-01..05), tests/test_orch082_merge_verify_autocreate.py (TC-06..12). Docs: README merge-verify block (ORCH-082), CHANGELOG, .env.example. Refs: ORCH-082 Co-Authored-By: Claude Opus 4.8 --- .env.example | 6 + CHANGELOG.md | 1 + src/agents/launcher.py | 51 +++-- src/config.py | 16 ++ src/merge_gate.py | 96 +++++++++ src/stage_engine.py | 64 ++++++ tests/conftest.py | 6 + tests/test_orch082_ensure_pr.py | 163 ++++++++++++++++ tests/test_orch082_merge_verify_autocreate.py | 183 ++++++++++++++++++ 9 files changed, 557 insertions(+), 29 deletions(-) create mode 100644 tests/test_orch082_ensure_pr.py create mode 100644 tests/test_orch082_merge_verify_autocreate.py diff --git a/.env.example b/.env.example index 9fb8d2e..94d3a65 100644 --- a/.env.example +++ b/.env.example @@ -123,11 +123,17 @@ ORCH_TASK_DEPS_SOURCE=db # REGRESSION_GUARD_ENABLED -> kill-switch for the ORCH-073 main-integrity regression # guard (false -> SHA-in-main alone gates done); reuses the # merge-verify scope, so non-self repos are a no-op. +# MERGE_VERIFY_AUTOCREATE_PR_ENABLED -> ORCH-082: guarantee an open code-PR +# (head==branch, base==main) via merge_gate.ensure_open_pr +# BEFORE the deterministic merge_pr (fixes the false HOLD +# "no open PR"). false -> exactly pre-ORCH-082 behaviour. +# Reuses the merge-verify scope; non-self repos -> no-op. ORCH_MERGE_VERIFY_ENABLED=true ORCH_MERGE_VERIFY_REPOS= 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-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/CHANGELOG.md b/CHANGELOG.md index 377131e..c3fd024 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу. ## [Unreleased] +- **Гарантированный идемпотентный код-PR перед merge-verify (фикс ложного HOLD «no open PR»)** (ORCH-082/ORCH-81): закрыт отсутствующий инвариант «к моменту merge-verify у ветки есть открытый код-PR». **Корень (ORCH-074, 08.06):** PR создавался единственной `launcher._ensure_pr` ТОЛЬКО на developer-пути и ТОЛЬКО при свежем worktree-коммите (`exit==0 → git status непуст → commit → push → agent=="developer"`); после ручных восстановлений `main` у ветки ORCH-074 не оказалось открытого код-PR → детерминированный `merge_gate.merge_pr` вернул `("False", "no open PR")` → защита ORCH-073 верно удержала задачу (HOLD, не ложный `done`), но лечила следствие. **Фикс (ADR-001, аддитивно, внутри того же под-гейта merge-verify, машина стадий не тронута):** (1) новый идемпотентный leaf-актор `merge_gate.ensure_open_pr(repo, branch) -> (status, detail)` (never-raise): `GET …/pulls?state=open` с фильтром **`head.ref==branch` И `base.ref=="main"`** (идентичен `merge_pr`/ORCH-073 FR-3 — авто-docs-PR `base!=main` НЕ код-PR) → `("existed", N)`; иначе `POST …/pulls` → `("created", N)`; гонка `409/422` «PR exists» → повторный GET → `existed` (без дублей); любая иная HTTP/parse/сетевая ошибка → `("failed", reason)`. (2) Врезка в `stage_engine._handle_merge_verify` ПОСЛЕ резолва `validated_revision` и ПЕРЕД `merge_pr`: при `merge_verify_autocreate_pr_enabled` → `ensure_open_pr`; `created|existed` → штатно к `merge_pr` → `verify_merged_to_main`; `failed` → честный HOLD через новый helper `_hold_pr_create_failed` (текст «PR создать не удалось», `result.note="pr-create-failed-hold"` — текстуально отличим от not-merged HOLD; задача остаётся на `deploy`, НЕ `done`, БЕЗ отката на development). (3) `launcher._ensure_pr` делегирован в `merge_gate.ensure_open_pr` (единый код создания PR, общий фильтр `head==branch & base==main`); триггер «создавать только на developer-пути со свежим коммитом» НЕ ужесточён — менялась только реализация под капотом. **Защита ORCH-073 неприкосновенна и приоритетна:** подтверждение merge остаётся ТОЛЬКО `verify_merged_to_main` (SHA-в-main) + `check_main_regression`; `ensure_open_pr` устраняет лишь ЛОЖНЫЙ HOLD «no open PR», реально невлитый код → HOLD как прежде. Kill-switch `ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED` (дефолт `true`); область — `merge_verify_applies(repo)` (self-hosting / `merge_verify_repos`), non-self → no-op; `false` → поведение ORCH-074 1:1. Идемпотентность из Gitea (наличие открытого PR), без миграции БД (restart-safe); `main` не push/force-push. Инварианты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS` (под-гейт — врезка в `advance_stage`, не новый QG), схема БД, `check_deploy_status`/`_parse_deploy_status`, exit-коды хука, merge-gate (ORCH-043), image-freshness (ORCH-058), внешние HTTP-эндпоинты. ADR `docs/work-items/ORCH-082/06-adr/ADR-001-ensure-open-pr-before-merge-verify.md` (+ сквозной `adr-0016`). Документация: `docs/architecture/README.md` (блок ORCH-082 в merge-verify). Тесты: `tests/test_orch082_ensure_pr.py` (TC-01..05: идемпотентный актор, фильтр base==main, гонка 409/422, never-raise), `tests/test_orch082_merge_verify_autocreate.py` (TC-06..12: врезка, регресс ORCH-073, kill-switch, условность, наблюдаемость). - **Устойчивость резолва `--effort` к пустому env + developer → `xhigh`** (ORCH-081/ORCH-52h): фикс конфигурационного бага, из-за которого в проде `resolve_agent_effort()` возвращал `''` для всех 6 агентов и `--effort` не передавался в Claude CLI (каждый агент бежал на встроенном CLI-дефолте вместо заявленного уровня — прямой удар по предсказуемости качества всего конвейера, включая enduro-trails из общего инстанса). **Корень:** pydantic Settings трактует ПРИСУТСТВУЮЩУЮ env-переменную, даже пустую (`ORCH_AGENT_EFFORT_*=` без значения), как явное `''` и перебивает class-default; в проде пусты И per-agent, И `agent_effort_default`, поэтому у цепочки резолва (`_resolve_agent_attr`: project-override → per-agent env → default → `''`) не остаётся непустого «пола» для отката. **Фикс (вариант c, ADR-001):** в `resolve_agent_effort` (`src/agents/launcher.py`) добавлен уровень 4 — непустой **per-role floor** ниже `default`: новый чистый helper `_agent_effort_floor(agent)` возвращает декларированный class-default поля `agent_effort_` через `type(settings).model_fields[...].default` (значение, которое пустой env перебить НЕ может). Floor срабатывает ТОЛЬКО когда уровни 1–3 пусты и применяется ДО валидации, поэтому: (а) при пустом прод-`.env` каждая роль получает СВОЙ канонический уровень (developer=`xhigh`, tester/deployer=`medium`, analyst/architect/reviewer=`high`), а не общий default; (б) явная опечатка (`turbo`/`ultra`) непуста → floor НЕ применяется → значение штатно дропается валидацией `VALID_EFFORTS` в `''` (never-break ORCH-41 не регрессирует, floor не маскирует мусор); (в) непустой явный env/project-override/`default` по-прежнему ПОБЕЖДАЕТ floor (приоритет резолва сохранён 1:1). Unknown-agent (имя вне 6 ролей) деградирует на class-default `agent_effort_default` (`high`) — безопасный непустой пол. **`config.py`:** `agent_effort_developer` `high → xhigh` (канон Opus 4.8: coding/agentic роль) — единственное изменение значений; floor подтягивает его автоматически (единый источник правды, ноль риска дрейфа floor-карты). Инварианты НЕ менялись: приоритеты/сигнатуры резолва ORCH-41, `_resolve_agent_attr` (общий с model-резолвом, не тронут), `resolve_agent_model` (ORCH-074), путь проброса `--effort` в `_spawn`, `VALID_EFFORTS`, API, схема БД (без миграций). ADR `docs/work-items/ORCH-081/06-adr/ADR-001-effort-resolution-floor.md`. Документация: `docs/architecture/README.md` (таблица «модель/эффорт по ролям»: developer `xhigh` + ремарка про floor), `.env.example` (`ORCH_AGENT_EFFORT_DEVELOPER=xhigh` + комментарий split/floor). Тесты: `tests/test_resolve_agent_effort.py` (TC-01..08: канон-дефолты, floor при пустом env per-role, floor-не-маскирует-typo, приоритет, `xhigh∈VALID_EFFORTS`, сборка флага `--effort xhigh`/`--effort medium`). - **Убран мёртвый frontmatter `model:` + валидация имени модели (never-break)** (ORCH-074): закрыты два дефекта данных/валидации каркаса выбора модели агентов (ORCH-41), без изменения механизма резолва, API или схемы БД. **G1 — мёртвый frontmatter:** из YAML-frontmatter всех 6 промптов `.openclaw/agents/*.md` удалена строка `model:` (`claude-sonnet-4-6` у analyst/developer/tester/deployer, `claude-opus-4-7` у architect/reviewer). launcher НЕ читал frontmatter `model:` — это была лживая/мёртвая декларация, противоречащая реально используемой модели (config) и принципу «документация = golden source»; мина: если бы кто-то «починил» launcher читать frontmatter, все агенты молча уехали бы на устаревшие модели. config (`agent_model_*`/`agent_model_default`) остаётся единственным источником правды; frontmatter описательный. **G2 — валидация имени модели:** добавлен чистый helper `is_valid_model(name)` + `_MODEL_NAME_RE` (`^claude-[a-z0-9.-]+$`) рядом с `VALID_EFFORTS` в `src/agents/launcher.py`. Резолвенное имя модели валидируется ПЕРЕД попаданием в `--model`: невалидное (опечатка, `gpt-4`, пустое, неверный префикс) → `logger.warning` + откат на следующий валидный уровень каскада ORCH-41 (project-override → env → default), в пределе → `""` (без флага `--model`, CLI-дефолт). Никогда не возвращается мусор и не бросается исключение (never-break, поведенческая аналогия `resolve_agent_effort`/`VALID_EFFORTS`). Выбран **формат-чек, а не allowlist `VALID_MODELS`**: allowlist воссоздаёт ровно ту мину, что убивается в G1 (статичный список врёт при устаревании — молча дропнул бы корректную будущую `claude-opus-4-9`); формат-чек forward-compatible (новые `claude-*` проходят без правки кода), финальный авторитет о существовании модели — сам Claude CLI. Тот же предикат применён к inline-чтению `--fallback-model` (`agent_fallback_model` читается напрямую в `_spawn`, мимо `resolve_agent_model` — TRZ §4), поэтому опечатка в `ORCH_AGENT_FALLBACK_MODEL` тоже дропается с warning; для текущего пустого значения регрессии нет. **G4 (fallback) НЕ включён** (`agent_fallback_model=""`, AC-5 N/A) — ради детерминизма (все агенты на `claude-opus-4-8`); **G3 (routing) НЕ включён** (AC-4 N/A) — осознанное решение стейкхолдера (Слава 08.06). Реализация `resolve_agent_model` рефакторнута на генератор кандидатов `_agent_model_candidates` (тот же приоритет ORCH-41) + валидация-со-скипом. Инварианты НЕ менялись: приоритеты/сигнатуры резолва ORCH-41, структура CLI-команды `_spawn`, `VALID_EFFORTS`-гард эффорта, `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, схема БД (без миграций); enduro per-project override валидные имена проходят без изменения поведения. ADR `docs/work-items/ORCH-074/06-adr/ADR-001-model-name-validation.md`. Документация: `docs/architecture/README.md` (таблица «модель/эффорт по ролям» + валидация), `CLAUDE.md`, `.env.example` (блок `ORCH_AGENT_MODEL_*`/`ORCH_AGENT_EFFORT_*`/`ORCH_AGENT_FALLBACK_MODEL`). Тесты: `tests/test_agent_frontmatter_no_model.py` (G1: TC-01/02), `tests/test_resolve_agent_model.py` (G2 never-break: TC-03..09, TC-11 + is_valid_model). - **Управление зависимостями задач (B ждёт A) + сериализация мержа одного репо** (ORCH-026): два уровня по ADR-001, оба условны (kill-switch + CSV-область, never-raise), без новой стадии и без изменения `STAGE_TRANSITIONS`/реестра `QG_CHECKS`. **Уровень A — сериализация merge/deploy внутри одного репо:** переиспользует существующий merge-lease ORCH-043/065 (никакого нового механизма); единственная новая логика — **безусловный pre-merge rebase**: в `check_branch_mergeable` (`src/qg/checks.py`) под удержанным лизом при флаге `premerge_rebase_always` (дефолт `True`) `auto_rebase_onto_main` вызывается **всегда** (а не только при `branch_is_behind_main`) — детерминированный структурный анти-фантом на ребре планировщика, дополняющий рубежи ORCH-073. На актуальной ветке это no-op (rebase не сдвигает HEAD, `push --force-with-lease` → «Everything up-to-date», CI не триггерится); kill-switch `premerge_rebase_always=False` → прежнее поведение ORCH-043 1:1. Окно сериализации «merge → main-updated» per-repo (для self `done` ⇔ SHA-in-main, ORCH-073): пока A не в `main`, B того же репо получает `merge-lock busy` → defer (не откат); кросс-репо параллелизм сохранён (лиз — per-repo файл). **Уровень B — декларативные зависимости задач:** аддитивная таблица `job_deps(task_id, depends_on_task_id)` (идемпотентный `CREATE TABLE/INDEX IF NOT EXISTS` в `init_db`, без миграции на живой БД); гейт планировщика в `claim_next_job` (`src/db.py`) — `NOT EXISTS (job_deps d JOIN tasks t … WHERE d.task_id=jobs.task_id AND t.stage!='done')` при `task_deps_enabled`: задача с незавершённой зависимостью **не выбирается** и слот `max_concurrency` не занимает; инертно при пустой `job_deps` → нулевая регрессия, kill-switch `task_deps_enabled=False` → запрос 1:1 как ORCH-1. Новый leaf-модуль `src/task_deps.py` (контракт never-raise): `is_task_ready` (fail-open → ready), DFS-детектор циклов (`detect_cycle`/`find_any_cycle`, итеративный WHITE/GREY/BLACK), `handle_cycle` (`set_issue_blocked` по каждой задаче цикла + один Telegram-alert с цепочкой «A → B → A»), `declare_dependency` (вставка + детект цикла), `ingest_plane_relations` (только для `task_deps_source=plane|hybrid`: резолв Plane `blocked-by` UUID → локальный task → запись в `job_deps`; источник истины горячего цикла остаётся БД, дефолт `db` НЕ ходит в сеть на claim), `snapshot` (read-only сводка). Видимость: строка «⏳ ждёт ORCH-NNN» в Telegram-карточке (`src/notifications.py`, never-raise, инвариант «одна карточка на задачу» сохранён); блок `task_deps` в `GET /queue` (`src/main.py`). Совместимость: `reconciler` F-1 пропускает dep-заблокированные задачи (`is_task_ready`, паттерн ORCH-060) + backstop-детект цикла; `job_reaper` сканирует только `running` → dep-блок остаётся `queued`. Зависимости — только intra-repo (v1). Новые настройки: `ORCH_PREMERGE_REBASE_ALWAYS` (true), `ORCH_TASK_DEPS_ENABLED` (true), `ORCH_TASK_DEPS_SOURCE` (db). Инварианты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS` (гейт зависимостей — врезка в `claim_next_job`, НЕ зарегистрированный QG), схема `tasks`/`jobs`/`agent_runs`, внешние HTTP-эндпоинты; non-self (enduro) — no-op при пустых `job_deps`/области. ADR `docs/work-items/ORCH-026/06-adr/ADR-001-merge-serialization-and-task-deps.md`, глобальный `docs/architecture/adr/adr-0015-task-deps-and-merge-serialization.md`. Документация: `docs/architecture/README.md`, `CLAUDE.md`, `.env.example`. Тесты: `tests/test_orch026_premerge_rebase.py`, `tests/test_orch026_merge_serialize.py`, `tests/test_orch026_conditionality.py`, `tests/test_orch026_task_deps.py`, `tests/test_orch026_dep_cycles.py`, `tests/test_orch026_dep_visibility.py`, `tests/test_orch026_migration.py`, `tests/test_orch026_queue_observability.py`, `tests/test_orch026_serialize_integration.py`, `tests/test_orch026_deps_integration.py`. diff --git a/src/agents/launcher.py b/src/agents/launcher.py index 6bc29c9..ee27af4 100644 --- a/src/agents/launcher.py +++ b/src/agents/launcher.py @@ -1077,35 +1077,28 @@ class AgentLauncher: return None def _ensure_pr(self, repo: str, branch: str, run_id: int): - import httpx - owner = settings.gitea_owner - headers = {"Authorization": f"token {settings.gitea_token}"} - base_url = f"{settings.gitea_url}/api/v1" - try: - resp = httpx.get( - f"{base_url}/repos/{owner}/{repo}/pulls", - params={"state": "open", "head": branch}, - headers=headers, timeout=10 - ) - resp.raise_for_status() - prs = resp.json() - if prs: - return prs[0]["number"] - parts = branch.split("/") - title = parts[-1] if parts else branch - resp = httpx.post( - f"{base_url}/repos/{owner}/{repo}/pulls", - json={"title": f"feat: {title}", "head": branch, "base": "main", - "body": f"Auto-created by orchestrator after developer run_id={run_id}"}, - headers=headers, timeout=10 - ) - resp.raise_for_status() - pr_number = resp.json()["number"] - logger.info(f"Created PR #{pr_number} for {branch}") - return pr_number - except Exception as e: - logger.error(f"Failed to create PR for {branch}: {e}") - return None + """Ensure an open code-PR exists for ``branch``; return its number or None. + + ORCH-082 (ADR-001 Р-4): delegated to the single idempotent PR-creation actor + ``merge_gate.ensure_open_pr`` so PR creation lives in ONE place and logs the + same created/existed/failed outcomes (G3). The CALL TRIGGER is unchanged — the + caller (`_monitor_agent`) still invokes this ONLY on the developer path with a + fresh worktree commit; only the implementation under the hood is shared. The + actor uses the same ``head==branch AND base==main`` filter as ``merge_pr``, so + the developer-created PR and the one merge-verify merges are guaranteed to be + the same code-PR. Never raises (the actor is never-raise); ``failed`` -> None, + preserving the previous "best-effort, return None on failure" contract. + """ + from .. import merge_gate + status, detail = merge_gate.ensure_open_pr(repo, branch) + logger.info(f"_ensure_pr({branch}, run_id={run_id}) -> {status} ({detail})") + if status in ("created", "existed"): + try: + return int(detail) + except (TypeError, ValueError): + return None + logger.error(f"Failed to ensure PR for {branch}: {detail}") + return None def _write_task_file(self, repo: str, branch: str, task_file: str, content: str): """Write task file directly into the task's worktree. diff --git a/src/config.py b/src/config.py index 2fc010d..1b3b118 100644 --- a/src/config.py +++ b/src/config.py @@ -442,6 +442,22 @@ class Settings(BaseSettings): # merge_verify_repos), so non-self repos are a no-op. regression_guard_enabled: bool = True + # ORCH-082 (ADR-001 Р-5): guarantee an open code-PR BEFORE the deterministic + # merge_pr inside the merge-verify under-gate. The pipeline never guaranteed the + # branch had an open PR (head==branch, base==main) at merge time — PRs are created + # ONLY on the developer path with a fresh worktree commit (launcher._ensure_pr), + # so a branch (e.g. after a manual main restore / a bounce with no new commits) + # could reach merge-verify PR-less -> merge_pr returns "no open PR" -> a FALSE HOLD + # that ORCH-073 fail-closed correctly catches but should never have to. The + # idempotent leaf-actor merge_gate.ensure_open_pr creates/finds the code-PR ДО + # merge_pr; ORCH-073's SHA-in-main proof is untouched and stays authoritative. + # merge_verify_autocreate_pr_enabled -> kill-switch (env + # ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED). False -> exactly the pre-ORCH-082 + # behaviour (no auto-create; "no open PR" -> HOLD as before). Reuses the + # merge_verify_applies scope (self-hosting / merge_verify_repos) — no separate + # *_repos, since auto-create is semantically inseparable from merge-verify. + merge_verify_autocreate_pr_enabled: bool = True + # Telegram notifications telegram_bot_token: str = "" telegram_chat_id: str = "" diff --git a/src/merge_gate.py b/src/merge_gate.py index b8a51fe..2cb78ff 100644 --- a/src/merge_gate.py +++ b/src/merge_gate.py @@ -587,6 +587,101 @@ def merge_verify_applies(repo: str) -> bool: return False +def ensure_open_pr(repo: str, branch: str) -> tuple[str, str]: + """Guarantee an open **code-PR** (``head==branch`` AND ``base=="main"``) exists. + + ORCH-082 (ADR-001 Р-1 / FR-1): the idempotent leaf-actor that closes the missing + invariant "by merge-verify time the branch has an open code-PR". The pipeline used + to create a PR ONLY on the developer path with a fresh worktree commit + (``launcher._ensure_pr``), so a branch could reach the ``deploy -> done`` merge-verify + under-gate with no open code-PR -> ``merge_pr`` returned ``"no open PR"`` -> a FALSE + HOLD (the ORCH-074 incident). This actor creates/finds the code-PR ДО the + deterministic ``merge_pr``; ORCH-073's SHA-in-main proof stays authoritative. + + Algorithm (FR-1): + 1. ``GET …/pulls?state=open`` -> a PR with **``head.ref==branch`` AND + ``base.ref=="main"``**. The filter is **identical** to ``merge_pr``/ORCH-073 + FR-3 so both actors agree on exactly the same PR — an auto docs-PR + (``base != main``) is NOT a code-PR (AC-6). Found -> ``("existed", "")``. + 2. Otherwise ``POST …/pulls`` (``head=branch``, ``base=main``, auto title/body) -> + ``201`` -> ``("created", "")``. + 3. Idempotency on a race: a ``POST`` that fails because the PR already exists + (Gitea ``409``/``422``) -> a repeat ``GET`` (step 1) confirms the existing PR -> + ``("existed", …)``; no duplicate is created (AC-2 / FR-5). + 4. Any other HTTP/parse/network error -> ``("failed", "")``. + + 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``. + """ + try: + import httpx + owner = settings.gitea_owner + headers = {"Authorization": f"token {settings.gitea_token}"} + base = f"{settings.gitea_url}/api/v1/repos/{owner}/{repo}" + timeout = settings.merge_pr_timeout_s + + def _find_open_code_pr() -> int | None: + """GET open PRs; return the code-PR number (head==branch AND base==main).""" + resp = httpx.get( + f"{base}/pulls", params={"state": "open"}, headers=headers, timeout=timeout + ) + if resp.status_code != 200: + return None + for pr in resp.json() or []: + if ( + pr.get("head", {}).get("ref") == branch + and pr.get("base", {}).get("ref") == "main" + ): + return pr.get("number") + return None + + # Step 1: an open code-PR already exists -> existed (no duplicate POST). + existing = _find_open_code_pr() + if existing is not None: + logger.info("ensure_open_pr: %s/%s already has open code-PR #%s", repo, branch, existing) + return "existed", str(existing) + + # Step 2: create the code-PR onto main. + parts = branch.split("/") + title = parts[-1] if parts else branch + m = httpx.post( + f"{base}/pulls", + json={ + "title": f"feat: {title}", + "head": branch, + "base": "main", + "body": f"Auto-created by orchestrator merge-verify for {branch}", + }, + headers=headers, + timeout=timeout, + ) + if m.status_code in (200, 201): + number = (m.json() or {}).get("number") + logger.info("ensure_open_pr: created PR #%s for %s/%s", number, repo, branch) + return "created", str(number) + + # Step 3: race / already-exists (409 conflict, 422 unprocessable) -> re-GET. + if m.status_code in (409, 422): + again = _find_open_code_pr() + if again is not None: + logger.info( + "ensure_open_pr: %s/%s PR already existed on retry (#%s, HTTP %s)", + repo, branch, again, m.status_code, + ) + return "existed", str(again) + + detail = (m.text or "").strip()[:200] + logger.warning( + "ensure_open_pr: create failed for %s/%s: HTTP %s %s", + repo, branch, m.status_code, detail, + ) + return "failed", f"create PR failed: HTTP {m.status_code}" + except Exception as e: # noqa: BLE001 - never-raise contract (AC-7) + logger.warning("ensure_open_pr unexpected error for %s/%s: %s", repo, branch, e) + return "failed", f"ensure_open_pr error: {e}" + + def merge_pr(repo: str, branch: str) -> tuple[bool, str]: """Deterministically merge the open PR for ``branch`` via the Gitea PR-merge API. @@ -730,6 +825,7 @@ MAIN_REGRESSION_MARKERS: list[tuple[str, str, str]] = [ ("ORCH-069", "qg0_title_max", "src/config.py"), ("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"), ] diff --git a/src/stage_engine.py b/src/stage_engine.py index c8427c8..e76af93 100644 --- a/src/stage_engine.py +++ b/src/stage_engine.py @@ -1321,6 +1321,52 @@ def _hold_main_regressed( return True +def _hold_pr_create_failed( + task_id, repo, work_item_id, branch, reason: str, result: AdvanceResult +) -> bool: + """HOLD the task because the open code-PR could not be ensured (ORCH-082 Р-3). + + FR-2/FR-4 (AC-5/AC-7): ``ensure_open_pr`` returned ``"failed"`` (Gitea unreachable / + HTTP error) — there is no open code-PR and one could not be created. Symmetric to the + not-merged / regressed HOLD: task stays on ``deploy`` (NOT done), NO rollback to + development, ALERT-only (Telegram + Plane ``set_issue_blocked`` + comment). The HOLD + text MUST be distinguishable from the not-merged HOLD so the operator sees the cause is + "could not CREATE the PR" (infra), not "could not MERGE an existing one". Returns + ``True`` (INTERVENED). Never breaks the HOLD on a notify error; ``failed`` is a + structured outcome, not a propagated exception (INV-1). + """ + merge_gate.note_not_merged_alert(work_item_id) # reuse the counter-notifier. + msg = ( + f"PR создать не удалось: {reason} (repo={repo}, branch={branch}, " + f"wi={work_item_id}). Открытый код-PR отсутствует и не создан — задача " + f"удержана на `deploy` (НЕ done). Нужно проверить доступность Gitea / создать PR." + ) + logger.warning(f"Task {task_id}: {msg}") + if work_item_id: + try: + set_issue_blocked(work_item_id) + except Exception as e: # noqa: BLE001 - never break the HOLD + logger.warning(f"Task {task_id}: set_issue_blocked failed: {e}") + try: + plane_add_comment( + work_item_id, + "\U0001f6a8 PR создать не удалось: " + reason + ". Открытый код-PR " + "отсутствует — задача удержана на `deploy` (НЕ done). Проверьте " + "доступность Gitea / создайте PR вручную и повторите approve.", + author="deployer", + ) + except Exception as e: # noqa: BLE001 - never break the HOLD + logger.warning(f"Task {task_id}: plane pr-create-failed comment failed: {e}") + try: + send_telegram(f"\U0001f6a8 {msg}") + except Exception as e: # noqa: BLE001 - never break the HOLD + logger.warning(f"Task {task_id}: pr-create-failed telegram failed: {e}") + result.alerted = True + result.note = "pr-create-failed-hold" + result.advanced = False + return True + + def _handle_merge_verify(task_id, repo, work_item_id, branch, result: AdvanceResult) -> bool: """ORCH-071 merge-verify under-gate on the `deploy -> done` edge. @@ -1353,6 +1399,24 @@ def _handle_merge_verify(task_id, repo, work_item_id, branch, result: AdvanceRes from . import image_freshness sha = image_freshness.validated_revision(repo, branch) + # ORCH-082 (Р-2 / FR-2): guarantee an open code-PR (head==branch, base==main) + # BEFORE the deterministic merge_pr. The pipeline never guaranteed the branch + # had one at merge time (PRs are created only on the developer path with a fresh + # commit) -> a PR-less branch hit merge_pr "no open PR" -> a FALSE HOLD (ORCH-074). + # `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. + if settings.merge_verify_autocreate_pr_enabled: + pr_status, pr_detail = merge_gate.ensure_open_pr(repo, branch) + logger.info( + f"Task {task_id}: merge-verify ensure_open_pr -> {pr_status} ({pr_detail})" + ) + if pr_status == "failed": + return _hold_pr_create_failed( + task_id, repo, work_item_id, branch, pr_detail, result + ) + # "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) logger.info( diff --git a/tests/conftest.py b/tests/conftest.py index 26b8255..216fe0f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -98,4 +98,10 @@ def _disable_merge_verify(monkeypatch): # _handle_merge_verify's confirmed branch. Default it OFF too so unrelated # deploy->done tests stay 1:1; the dedicated ORCH-073 tests re-enable it. monkeypatch.setattr(_cfg.settings, "regression_guard_enabled", False, raising=False) + # ORCH-082: the merge-verify ensure_open_pr врезка makes REAL Gitea calls before + # merge_pr. Default it OFF so unrelated deploy->done / merge-verify tests stay 1:1 + # (no network); the dedicated ORCH-082 tests re-enable it via their own monkeypatch. + monkeypatch.setattr( + _cfg.settings, "merge_verify_autocreate_pr_enabled", False, raising=False + ) yield diff --git a/tests/test_orch082_ensure_pr.py b/tests/test_orch082_ensure_pr.py new file mode 100644 index 0000000..a12644e --- /dev/null +++ b/tests/test_orch082_ensure_pr.py @@ -0,0 +1,163 @@ +"""ORCH-082 FR-1 — merge_gate.ensure_open_pr: idempotent open-code-PR actor. + +Covers TC-01..05 / AC-2 / AC-6 / AC-7. The actor guarantees an open code-PR +(``head==branch`` AND ``base=="main"``) exists before the deterministic ``merge_pr``, +without ever creating a duplicate. Gitea HTTP is mocked; the actor honours the strict +never-raise contract (any error -> ``("failed", reason)``). +""" + +import pytest + +from src import merge_gate + +REPO = "orchestrator" +BRANCH = "feature/ORCH-082-x" + + +class _Resp: + """Minimal httpx.Response stand-in (status_code + json/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(autouse=True) +def _settings(monkeypatch): + monkeypatch.setattr(merge_gate.settings, "merge_pr_timeout_s", 5) + 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") + + +def _install_httpx(monkeypatch, get_resp, post_resp=None, record=None): + """Patch merge_gate's lazily-imported httpx with stub get/post callables.""" + import httpx + + def fake_get(url, *a, **k): + if record is not None: + record.append(("GET", url, k.get("params"))) + return get_resp() if callable(get_resp) else get_resp + + def fake_post(url, *a, **k): + if record is not None: + record.append(("POST", url, k.get("json"))) + if post_resp is None: + raise AssertionError("POST must NOT be called") + return post_resp() if callable(post_resp) else post_resp + + monkeypatch.setattr(httpx, "get", fake_get) + monkeypatch.setattr(httpx, "post", fake_post) + + +# --------------------------------------------------------------------------- +# TC-01: no open code-PR -> POST creates one -> ("created", N); base==main filter. +# --------------------------------------------------------------------------- +def test_tc01_creates_pr_when_absent(monkeypatch): + record = [] + _install_httpx( + monkeypatch, + get_resp=_Resp(200, []), # no open PRs at all + post_resp=_Resp(201, {"number": 42}), + record=record, + ) + status, detail = merge_gate.ensure_open_pr(REPO, BRANCH) + assert (status, detail) == ("created", "42") + # POST body targets head=branch, base=main. + post = [r for r in record if r[0] == "POST"][0] + assert post[2]["head"] == BRANCH + assert post[2]["base"] == "main" + + +# --------------------------------------------------------------------------- +# TC-02: an open code-PR (head==branch AND base==main) already exists -> existed, +# POST is never called (no duplicate). +# --------------------------------------------------------------------------- +def test_tc02_existed_no_duplicate(monkeypatch): + payload = [{"number": 7, "head": {"ref": BRANCH}, "base": {"ref": "main"}}] + _install_httpx(monkeypatch, get_resp=_Resp(200, payload), post_resp=None) + status, detail = merge_gate.ensure_open_pr(REPO, BRANCH) + assert (status, detail) == ("existed", "7") # POST stub would raise if called + + +# --------------------------------------------------------------------------- +# TC-03 (AC-6): only a docs-PR (base != main) exists -> NOT a code-PR -> create on main. +# --------------------------------------------------------------------------- +def test_tc03_docs_pr_not_counted_creates_on_main(monkeypatch): + record = [] + # An open PR exists but onto a docs base, and another onto a different head. + docs_payload = [ + {"number": 9, "head": {"ref": BRANCH}, "base": {"ref": "docs/logs"}}, + {"number": 10, "head": {"ref": "other/branch"}, "base": {"ref": "main"}}, + ] + _install_httpx( + monkeypatch, + get_resp=_Resp(200, docs_payload), + post_resp=_Resp(201, {"number": 11}), + record=record, + ) + status, detail = merge_gate.ensure_open_pr(REPO, BRANCH) + assert (status, detail) == ("created", "11") + assert any(r[0] == "POST" for r in record) + + +# --------------------------------------------------------------------------- +# TC-04 (AC-7): Gitea GET/POST raise -> ("failed", reason), never raises. +# --------------------------------------------------------------------------- +def test_tc04_never_raise_on_get_error(monkeypatch): + import httpx + + def boom(*a, **k): + raise httpx.ConnectError("gitea down") + + monkeypatch.setattr(httpx, "get", boom) + monkeypatch.setattr(httpx, "post", boom) + status, detail = merge_gate.ensure_open_pr(REPO, BRANCH) + assert status == "failed" + assert detail # carries a reason + + +def test_tc04_never_raise_on_post_error(monkeypatch): + import httpx + + def boom_post(*a, **k): + raise httpx.ConnectError("post exploded") + + _install_httpx(monkeypatch, get_resp=_Resp(200, []), post_resp=None) + monkeypatch.setattr(httpx, "post", boom_post) + status, detail = merge_gate.ensure_open_pr(REPO, BRANCH) + assert status == "failed" + + +def test_tc04_failed_when_post_non_2xx(monkeypatch): + # A plain non-2xx, non-conflict POST -> failed (not silently swallowed). + _install_httpx( + monkeypatch, get_resp=_Resp(200, []), post_resp=_Resp(500, text="boom") + ) + status, detail = merge_gate.ensure_open_pr(REPO, BRANCH) + assert status == "failed" + assert "500" in detail + + +# --------------------------------------------------------------------------- +# TC-05 (AC-2 / FR-5): race -> POST returns 409/422 "PR exists" -> re-GET confirms +# the existing PR -> ("existed", N), no duplicate. +# --------------------------------------------------------------------------- +@pytest.mark.parametrize("conflict_code", [409, 422]) +def test_tc05_race_post_conflict_confirms_existing(monkeypatch, conflict_code): + # First GET: no PR (so we attempt POST). POST: conflict. Re-GET: PR now present. + gets = iter([ + _Resp(200, []), # first probe: absent + _Resp(200, [{"number": 99, "head": {"ref": BRANCH}, "base": {"ref": "main"}}]), + ]) + _install_httpx( + monkeypatch, + get_resp=lambda: next(gets), + post_resp=_Resp(conflict_code, text="pull request already exists"), + ) + status, detail = merge_gate.ensure_open_pr(REPO, BRANCH) + assert (status, detail) == ("existed", "99") diff --git a/tests/test_orch082_merge_verify_autocreate.py b/tests/test_orch082_merge_verify_autocreate.py new file mode 100644 index 0000000..2b038e3 --- /dev/null +++ b/tests/test_orch082_merge_verify_autocreate.py @@ -0,0 +1,183 @@ +"""ORCH-082 FR-2/FR-3/FR-4 — ensure_open_pr врезка in _handle_merge_verify. + +Covers TC-06..12 / AC-3 / AC-4 / AC-5 / AC-7 / AC-8 / AC-9 / FR-5. Calls the +``deploy -> done`` under-gate handler directly with mocked merge_gate primitives + +side effects (Plane/Telegram). Asserts the return contract: ``False`` == advance to +``done``, ``True`` == HOLD (alert, NOT done). The ORCH-073 SHA-in-main proof stays +authoritative — auto-creating a PR must NEVER mask un-merged code. +""" + +import os +import tempfile + +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_orch082.db")) + +import logging # noqa: E402 +from unittest.mock import MagicMock # noqa: E402 + +import pytest # noqa: E402 + +from src import stage_engine, image_freshness # noqa: E402 +from src.stage_engine import AdvanceResult, _handle_merge_verify # noqa: E402 + +REPO = "orchestrator" +WI = "ORCH-082" +BRANCH = "feature/ORCH-082-x" + + +@pytest.fixture(autouse=True) +def _wire(monkeypatch): + # Under-gate in scope; autocreate ON; regression guard OFF (its own tests cover it). + 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") + # Silence Plane/Telegram side effects (assert on .called where relevant). + 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-06 (AC-3): PR absent -> ensure_open_pr creates -> merge_pr -> verify True -> +# deploy->done with NO false HOLD. +# --------------------------------------------------------------------------- +def test_tc06_autocreate_then_merge_then_done(monkeypatch): + ensure = MagicMock(return_value=("created", "5")) + merge = MagicMock(return_value=(True, "merged PR #5")) + monkeypatch.setattr(stage_engine.merge_gate, "ensure_open_pr", ensure) + 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, REPO, WI, BRANCH, res) + + assert intervened is False # advance to done + assert res.alerted is False + ensure.assert_called_once_with(REPO, BRANCH) + assert merge.called + assert not stage_engine.set_issue_blocked.called + + +# --------------------------------------------------------------------------- +# TC-07 (AC-4 / FR-3): PR created/merged but verify_merged_to_main=False (code not +# in main) -> HOLD + set_issue_blocked, NOT done, no rollback. ORCH-073 protection +# is untouched by auto-create. +# --------------------------------------------------------------------------- +def test_tc07_verify_false_still_holds(monkeypatch): + monkeypatch.setattr(stage_engine.merge_gate, "ensure_open_pr", lambda r, b: ("created", "5")) + monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", lambda r, b: (True, "merged PR #5")) + monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: False) + + res = AdvanceResult() + intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res) + + assert intervened is True # HOLD + assert res.advanced is False + assert res.note == "merge-not-verified-hold" + assert stage_engine.set_issue_blocked.called + + +# --------------------------------------------------------------------------- +# TC-08 (AC-7 / AC-5): ensure_open_pr -> failed -> honest HOLD with distinguishable +# text/note; merge_pr is NOT reached; advance_stage does not raise. +# --------------------------------------------------------------------------- +def test_tc08_ensure_failed_holds_distinct(monkeypatch): + monkeypatch.setattr( + stage_engine.merge_gate, "ensure_open_pr", lambda r, b: ("failed", "gitea down") + ) + merge = MagicMock() + monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", merge) + + res = AdvanceResult() + intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res) + + assert intervened is True # HOLD + assert res.advanced is False + assert res.note == "pr-create-failed-hold" # distinct from "merge-not-verified-hold" + assert not merge.called # merge_pr never reached + assert stage_engine.set_issue_blocked.called + + +# --------------------------------------------------------------------------- +# TC-09 (AC-8): kill-switch OFF -> ensure_open_pr NOT called; "no open PR" -> prior +# HOLD 1:1 (ORCH-074 behaviour reproduced). +# --------------------------------------------------------------------------- +def test_tc09_killswitch_off_no_autocreate(monkeypatch): + monkeypatch.setattr(stage_engine.settings, "merge_verify_autocreate_pr_enabled", False) + ensure = MagicMock() + monkeypatch.setattr(stage_engine.merge_gate, "ensure_open_pr", ensure) + # merge_pr finds no open PR -> verify False -> prior not-merged HOLD. + monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", lambda r, b: (False, "no open PR")) + monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: False) + + res = AdvanceResult() + intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res) + + assert intervened is True + assert res.note == "merge-not-verified-hold" # exactly the prior HOLD + assert not ensure.called # auto-create skipped entirely + + +# --------------------------------------------------------------------------- +# TC-10 (AC-9): non-self repo (merge_verify_applies=False) -> врезка no-op, neither +# ensure_open_pr nor merge_pr called. +# --------------------------------------------------------------------------- +def test_tc10_non_self_repo_noop(monkeypatch): + monkeypatch.setattr(stage_engine.merge_gate, "merge_verify_applies", lambda r: False) + ensure = MagicMock() + merge = MagicMock() + monkeypatch.setattr(stage_engine.merge_gate, "ensure_open_pr", ensure) + monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", merge) + + res = AdvanceResult() + intervened = _handle_merge_verify(1, "enduro-trails", "ET-1", "feature/x", res) + + assert intervened is False # advance unchanged + assert not ensure.called + assert not merge.called + + +# --------------------------------------------------------------------------- +# TC-11 (AC-2 / FR-5): idempotent re-drive (reaper/reconciler) -> ensure existed, +# merge_pr already-merged -> verify True -> done, no duplicate PR. +# --------------------------------------------------------------------------- +def test_tc11_idempotent_redrive(monkeypatch): + ensure = MagicMock(return_value=("existed", "5")) + monkeypatch.setattr(stage_engine.merge_gate, "ensure_open_pr", ensure) + monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", lambda r, b: (True, "already-merged")) + monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True) + + res = AdvanceResult() + intervened = _handle_merge_verify(1, REPO, WI, BRANCH, res) + + assert intervened is False # advance to done + assert ensure.return_value[0] == "existed" + assert not stage_engine.set_issue_blocked.called + + +# --------------------------------------------------------------------------- +# TC-12 (AC-5): logs distinguish created/existed/failed; the create-failed HOLD text +# differs from the not-merged HOLD text. +# --------------------------------------------------------------------------- +def test_tc12_logs_distinguish_outcomes(monkeypatch, caplog): + monkeypatch.setattr(stage_engine.merge_gate, "ensure_open_pr", lambda r, b: ("created", "5")) + monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", lambda r, b: (True, "merged PR #5")) + monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", lambda r, b, s: True) + + with caplog.at_level(logging.INFO, logger="orchestrator"): + _handle_merge_verify(1, REPO, WI, BRANCH, AdvanceResult()) + assert any("ensure_open_pr -> created" in r.message for r in caplog.records) + + # create-failed note differs from not-merged note (text-distinguishable HOLD). + monkeypatch.setattr( + stage_engine.merge_gate, "ensure_open_pr", lambda r, b: ("failed", "gitea down") + ) + res = AdvanceResult() + _handle_merge_verify(1, REPO, WI, BRANCH, res) + assert res.note == "pr-create-failed-hold" + assert res.note != "merge-not-verified-hold" From 1997376eb56fec12f18f91acb6a0bfdccb8b2ae6 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Tue, 9 Jun 2026 00:51:48 +0300 Subject: [PATCH 5/7] reviewer(ET): auto-commit from reviewer run_id=410 --- docs/work-items/ORCH-082/12-review.md | 65 +++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 docs/work-items/ORCH-082/12-review.md diff --git a/docs/work-items/ORCH-082/12-review.md b/docs/work-items/ORCH-082/12-review.md new file mode 100644 index 0000000..12ec9f7 --- /dev/null +++ b/docs/work-items/ORCH-082/12-review.md @@ -0,0 +1,65 @@ +--- +type: review +work_item_id: ORCH-082 +verdict: APPROVED +version: 1 +--- + +# Review ORCH-082 — Гарантированный идемпотентный код-PR перед merge-verify + +## Summary +Изменение закрывает отсутствующий инвариант «к моменту merge-verify у ветки есть открытый +код-PR» (root cause ложного HOLD «no open PR» на деплое ORCH-074). Реализовано строго аддитивно, +по дизайну ADR-001: новый идемпотентный leaf-актор `merge_gate.ensure_open_pr`, точечная врезка в +`stage_engine._handle_merge_verify` ПЕРЕД `merge_pr`, distinguishable HOLD-helper +`_hold_pr_create_failed`, делегирование `launcher._ensure_pr` в единый актор, kill-switch +`merge_verify_autocreate_pr_enabled`. Защита ORCH-073 (SHA-в-main + регресс-гард) не ослаблена и +остаётся приоритетной. Машина стадий, `QG_CHECKS`, схема БД, контракты деплоя — не тронуты. + +Все 4 оси проверки пройдены: +- **ТЗ (02-trz.md):** FR-1..FR-5 реализованы — идемпотентный актор с фильтром + `head==branch & base=="main"`, врезка после `validated_revision` и до `merge_pr`, честный HOLD + на `failed`, защита ORCH-073 цела, идемпотентность повторного прохода. +- **AC (03-acceptance-criteria.md):** AC-1..AC-11 покрыты. Root cause задокументирован в ADR + (R-A структурный + R-C проксимальный для ORCH-074); идемпотентность/existed (TC-02, TC-05); + autocreate до merge_pr (TC-06); защита ORCH-073 (TC-07); логи различают исход (TC-12); фильтр + base==main (TC-03); never-raise (TC-04, TC-08); kill-switch off (TC-09); условность non-self + (TC-10); инварианты + документация; pytest зелёный. +- **ADR:** реализация 1:1 соответствует Р-1..Р-5 ADR-001; не нарушает глобальные adr-0013/0014 + (амендмент adr-0016 корректно зарегистрирован). +- **Качество кода:** never-raise соблюдён (все внешние вызовы в try/except), docstrings на + публичных функциях, тесты содержательные (мок Gitea HTTP + интеграционные на под-гейт, не + тривиальные). Секреты не хардкодятся (token из settings). `main` не push/force-push. + +`pytest tests/ -q` → **1046 passed**. Целевые наборы (`test_orch082_ensure_pr.py`, +`test_orch082_merge_verify_autocreate.py`, `test_merge_verify.py`) — зелёные. + +## Findings + +### P0 — Blocker +- нет + +### P1 — Must fix +- нет + +### P2 — Should fix +- нет + +### P3 — Nice-to-have +- [ ] Поведенческое уточнение `launcher._ensure_pr`: после делегирования в `ensure_open_pr` + developer-путь теперь требует `base=="main"` (раньше принимался любой открытый PR с + `head==branch`). Это корректное усиление (выравнивание с `merge_pr`) и для штатного потока + PR всегда создаётся на `main` — регресса нет; зафиксировано для истории, действий не требует. + +## Документация +Документация обновлена в том же PR — соответствие правилу №2/№6 CLAUDE.md: +- `docs/architecture/README.md` — добавлен раздел ORCH-082 в блок merge-verify (строки 209-240). +- `CHANGELOG.md` — запись в `## [Unreleased]`. +- `.env.example` — `ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED=true` + комментарий. +- `docs/architecture/adr/adr-0016-ensure-open-pr-before-merge-verify.md` — сквозной ADR (амендмент + adr-0013/0014), зарегистрирован в `docs/architecture/adr/README.md` (макс. номер → 0016). +- `docs/work-items/ORCH-082/06-adr/ADR-001-*.md` — детальный ADR (root cause + дизайн). +- API сервиса не менялось (новых endpoint нет), конфиг-флаг отражён в `.env.example`. Все + изменения `src/` (merge_gate, stage_engine, launcher, config) задокументированы. + +**Вердикт: APPROVED** — P0/P1 отсутствуют, документация обновлена, тесты зелёные. From 039322001aa42aff389b8433f73bfdad22643391 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Tue, 9 Jun 2026 00:53:24 +0300 Subject: [PATCH 6/7] tester(ET): auto-commit from tester run_id=411 --- docs/work-items/ORCH-082/13-test-report.md | 81 ++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 docs/work-items/ORCH-082/13-test-report.md diff --git a/docs/work-items/ORCH-082/13-test-report.md b/docs/work-items/ORCH-082/13-test-report.md new file mode 100644 index 0000000..45983b4 --- /dev/null +++ b/docs/work-items/ORCH-082/13-test-report.md @@ -0,0 +1,81 @@ +--- +type: test-report +work_item_id: ORCH-082 +result: PASS +--- + +# Test Report — ORCH-082 + +Гарантированный идемпотентный код-PR перед merge-verify (фикс ложного HOLD «no open PR»). + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 +- Ветка: feature/ORCH-082-orch-81-pr-merge-verify-hold +- Дата: 2026-06-09 +- Review verdict: APPROVED (12-review.md, P0/P1 отсутствуют) + +## Проверка окружения +- `GET /health` → `{"status":"ok","service":"orchestrator"}` — прод-контейнер 8500 жив. +- Тесты прогнаны в worktree ветки (прод не затронут, деструктивных операций нет). + +## Smoke test API (prod 8500) +| Endpoint | Результат | +|----------|-----------| +| `GET /health` | `{"status":"ok"}` — OK | +| `GET /status` | OK — ORCH-082 (id=61) виден на стадии `testing` | +| `GET /queue` | OK — `running:1, queued:0`, breaker `closed`, reconcile/reaper/post_deploy активны | + +## Результаты (привязка к 04-test-plan.yaml) + +| TC ID | Тип | Описание | Тест | Результат | +|-------|-----|----------|------|-----------| +| TC-01 | unit | ensure_open_pr: PR нет → POST создаёт → ('created', N); фильтр base==main | test_tc01_creates_pr_when_absent | PASS | +| TC-02 | unit | PR head==branch И base==main уже есть → ('existed', N), POST не вызывается | test_tc02_existed_no_duplicate | PASS | +| TC-03 | unit | Мульти-PR: только docs-PR (base!=main) → создаётся PR на main (AC-6) | test_tc03_docs_pr_not_counted_creates_on_main | PASS | +| TC-04 | unit | never-raise: GET/POST кидает ошибку → ('failed', reason), не всплывает (AC-7) | test_tc04_never_raise_on_get_error / _on_post_error / _failed_when_post_non_2xx | PASS (3) | +| TC-05 | unit | Гонка: POST 'PR exists' (409/422) → повторный GET → ('existed', N), без дубля | test_tc05_race_post_conflict_confirms_existing[409,422] | PASS (2) | +| TC-06 | integration | PR отсутствовал → ensure создаёт → merge_pr → verify True → done без HOLD (AC-3) | test_tc06_autocreate_then_merge_then_done | PASS | +| TC-07 | integration | Регресс ORCH-073: verify=False → HOLD + set_issue_blocked, НЕ done, без отката (AC-4) | test_tc07_verify_false_still_holds | PASS | +| TC-08 | integration | ensure → 'failed' (Gitea down) → честный HOLD+alert, текст ≠ 'not merged' (AC-7) | test_tc08_ensure_failed_holds_distinct | PASS | +| TC-09 | integration | Kill-switch off → ensure не вызывается, 'no open PR' → прежний HOLD 1:1 (AC-8) | test_tc09_killswitch_off_no_autocreate | PASS | +| TC-10 | integration | Условность: non-self репо (applies=False) → no-op, авто-создание не выполняется (AC-9) | test_tc10_non_self_repo_noop | PASS | +| TC-11 | integration | Идемпотентный повторный проход: PR existed, already-merged → verify True → done (FR-5) | test_tc11_idempotent_redrive | PASS | +| TC-12 | unit | Логи различают created/existed/failed; HOLD create-failed ≠ HOLD not-merged (AC-5) | test_tc12_logs_distinguish_outcomes | PASS | +| TC-13 | integration | Happy-path ORCH-071/073 не изменён: verify True → done, merged_to_main: true | test_merge_verify.py (verify_true_when_sha_is_ancestor + 7 регресс-тестов) | PASS | + +Все 13 TC из тест-плана покрыты и зелёные. + +## Сопоставление с критериями приёмки (03-acceptance-criteria.md) +- **AC-1** Root cause в ADR-001 (R-A структурный + R-C для ORCH-074) — подтверждено review. +- **AC-2** Идемпотентный код-PR, без дублей — TC-02, TC-05, TC-11 — PASS. +- **AC-3** Авто-создание PR ПЕРЕД merge_pr — TC-06 — PASS. +- **AC-4** Защита ORCH-073 цела (verify=False → HOLD, не done) — TC-07 + test_merge_verify — PASS. +- **AC-5** Логи различают исход PR — TC-12 — PASS. +- **AC-6** Фильтр base==main (docs-PR не код-PR) — TC-03 — PASS. +- **AC-7** Never-raise + честный HOLD при недоступности Gitea — TC-04, TC-08 — PASS. +- **AC-8** Kill-switch off → поведение 1:1 — TC-09 — PASS. +- **AC-9** Условность self-hosting — TC-10 — PASS. +- **AC-10** Инварианты не нарушены, документация обновлена — подтверждено review (README/CHANGELOG/.env.example/ADR). +- **AC-11** pytest зелёный — **1046 passed** — PASS. + +## Вывод pytest + +Полный прогон: +``` +1046 passed, 1 warning in 25.57s +``` +(единственный warning — PydanticDeprecatedSince20 в src/config.py:5, не относится к ORCH-082, предсуществующий.) + +Целевые наборы: +``` +tests/test_orch082_ensure_pr.py ............ (8 passed) +tests/test_orch082_merge_verify_autocreate.py ....... (7 passed) +tests/test_merge_verify.py ........ (8 passed) +======================== 23 passed, 1 warning in 0.42s ========================= +``` + +## Итог +**PASS** — все 1046 тестов зелёные, целевые наборы ORCH-082 + регресс merge-verify зелёные, +smoke API (health/status/queue) OK, все 13 TC и AC-1..AC-11 покрыты. Задача готова к переходу +на стадию `deploy-staging`. From 9834dae1083afc60db873942ca65a827e7295e51 Mon Sep 17 00:00:00 2001 From: deploy-finalizer Date: Tue, 9 Jun 2026 01:01:56 +0300 Subject: [PATCH 7/7] deploy(ORCH-036): finalize SUCCESS for ORCH-082 --- docs/work-items/ORCH-082/14-deploy-log.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 docs/work-items/ORCH-082/14-deploy-log.md diff --git a/docs/work-items/ORCH-082/14-deploy-log.md b/docs/work-items/ORCH-082/14-deploy-log.md new file mode 100644 index 0000000..c1e1ed7 --- /dev/null +++ b/docs/work-items/ORCH-082/14-deploy-log.md @@ -0,0 +1,12 @@ +--- +deploy_status: SUCCESS +work_item: ORCH-082 +hook_exit_code: 0 +deployed_by: deploy-finalizer +--- + +# Deploy log — ORCH-036 executable self-deploy + +Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`. + +Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.