From dd5fe619d55286fd72f74470fe35d882ffd628f6 Mon Sep 17 00:00:00 2001 From: Slava Date: Mon, 8 Jun 2026 10:56:13 +0300 Subject: [PATCH 1/8] docs: init ORCH-071 business request --- docs/work-items/ORCH-071/00-business-request.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 docs/work-items/ORCH-071/00-business-request.md diff --git a/docs/work-items/ORCH-071/00-business-request.md b/docs/work-items/ORCH-071/00-business-request.md new file mode 100644 index 0000000..ca9ee75 --- /dev/null +++ b/docs/work-items/ORCH-071/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: CRIT BUG: фантомный merge — деплой без слияния в main + +Work Item ID: ORCH-071 + +## Description + +TBD From c26a6b637c2f74b1d0fa9c5be4a9e949cec88efe Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 8 Jun 2026 07:59:49 +0000 Subject: [PATCH 2/8] analyst(ET): auto-commit from analyst run_id=353 --- docs/work-items/ORCH-071/01-brd.md | 53 +++++++++ docs/work-items/ORCH-071/02-trz.md | 78 +++++++++++++ .../ORCH-071/03-acceptance-criteria.md | 61 +++++++++++ docs/work-items/ORCH-071/04-test-plan.yaml | 103 ++++++++++++++++++ 4 files changed, 295 insertions(+) create mode 100644 docs/work-items/ORCH-071/01-brd.md create mode 100644 docs/work-items/ORCH-071/02-trz.md create mode 100644 docs/work-items/ORCH-071/03-acceptance-criteria.md create mode 100644 docs/work-items/ORCH-071/04-test-plan.yaml diff --git a/docs/work-items/ORCH-071/01-brd.md b/docs/work-items/ORCH-071/01-brd.md new file mode 100644 index 0000000..767f81b --- /dev/null +++ b/docs/work-items/ORCH-071/01-brd.md @@ -0,0 +1,53 @@ +# BRD — ORCH-071: Фантомный merge — деплой без слияния в main + +## 1. Контекст и тип +- **Тип:** BUG CRITICAL (целостность `main` / надёжность деплоя, self-hosting). +- **Обнаружено:** Слава + Стрим, 2026-06-08, при разборе «ORCH-067 не подхватился». +- **Постмортем:** `docs/history/LESSONS_2026-06-08_phantom-merge.md`. +- **Подозрение на регресс:** ORCH-065 (idempotent merge / lease-reclaim) — последний честный merge (PR#66). +- **Связано:** восстановление текущего `main` ведётся ОТДЕЛЬНО (ветка `integ/restore-main-2026-06-08`); эта задача — ROOT-FIX, чтобы фантом не повторялся. + +## 2. Проблема (бизнес-формулировка) +Self-deploy (Phase B) для self-hosting репо `orchestrator` собирает прод-образ из ВЕТКИ задачи и рапортует `finalize SUCCESS` + post-deploy `HEALTHY`, **но git-merge ветки в `main` НЕ происходит**. PR остаётся `open`. Следующая задача срезает свою ветку от устаревшего `main` → теряет код незалитых предшественников. + +Накопительно потеряны в `main`: **ORCH-022, 059, 066, 068** (PR#67/68/69/70 — open). Последний реально слитый — ORCH-065 (PR#66). + +## 3. Подтверждённый root cause (по результатам код-аудита) +Гипотеза A постмортема подтверждена аудитом кода ветки: + +1. **В `src/` НЕТ кода, выполняющего merge PR в `main`** (`grep` по `pulls/.../merge`, `/merge`, `merge_pr` — 0 совпадений). Фактический merge выполняет ТОЛЬКО LLM-агент `deployer` через Bash в начале стадии `deploy` (см. `.openclaw/agents/deployer.md`). +2. Для self-hosting (`orchestrator`) стадия `deploy` оркеструется **детерминированным кодом** (`stage_engine._handle_self_deploy_phase_b` → `self_deploy.initiate_deploy` → finalizer `run_deploy_finalizer`), и агент `deployer` **НЕ запускается** (так предписывает `deployer.md`). Detached host-процесс делает retag staging-образа на прод-тег + рестарт 8500. **Ни одна фаза A/B/C не вызывает merge ветки в `main`.** +3. `run_deploy_finalizer` маппит exit-code хука `0→SUCCESS`, пишет `14-deploy-log.md` и вызывает `advance_stage(..., finished_agent="deployer")`. Гейт `check_deploy_status` читает только `deploy_status:` из артефакта → `SUCCESS → done`. **Состояние `main` нигде не верифицируется.** + +Итог: для self-hosting путь `deploy` структурно НЕ содержит шага merge-в-main, а `done` достигается исключительно по deploy-маркеру. «Зелёный» деплой + здоровый прод (образ из рабочей ветки) маскируют отсутствие merge — сигнала о проблеме нет, пока следующая задача не потеряет код предшественника. + +Вторичный фактор (усиливает риск даже если merge добавить наивно): Phase B **рестартит прод-контейнер**, поэтому любой держатель merge-lease / незавершённый git-шаг внутри процесса умирает до завершения merge (урок №3 постмортема). + +## 4. Бизнес-цели +| ID | Цель | +|----|------| +| **G1** | Деплой ВЕРИФИЦИРУЕТ, что задеплоенный commit реально влит в `main` ПОСЛЕ деплоя (deployed SHA — предок `origin/main` ИЛИ `PR.merged==true`). Иначе — alert, задача НЕ `done`. | +| **G2** | Задача → `done` ТОЛЬКО при подтверждённом merge (`PR.merged==true`); маркеров `finalize`/`post-deploy` недостаточно. | +| **G3** | Merge в `main` завершается и подтверждается ДО рестарта прод-контейнера, ЛИБО merge вынесен в шаг, переживающий рестарт (паттерн `requeue_running_jobs` для merge-в-main). | +| **G4** | Диагностический runbook (4 проверки из постмортема) — в `docs/operations`. | + +## 5. Не-цели +- Не менять source-of-truth (Plane), схему БД. +- Не отменять self-hosting safety (no auto-rollback / no-restart-others) — наоборот, усилить верификацией. +- Восстановление текущего `main` (долив 022/059/066/068) — ОТДЕЛЬНАЯ ветка `integ/restore-main-2026-06-08`, вне scope. + +## 6. Инварианты (обязательны к соблюдению) +| ID | Инвариант | +|----|-----------| +| **INV-1** | **never-raise** на шаге верификации — при ошибке шлётся alert, не падение процесса/конвейера. | +| **INV-2** | self-hosting safety: верификация НЕ рестартит и НЕ роняет прод-контейнер `orchestrator` (8500), не трогает другие проекты. | +| **INV-3** | Ручной approve прод-деплоя (триггер «Confirm Deploy», ORCH-059) сохранён — новая логика не вводит авто-деплой. | +| **INV-4** | Никогда не делать force-push / прямой push в `main`; merge только через PR-merge API Gitea (как у deployer-агента сегодня). | +| **INV-5** | Идемпотентность: повторный прогон (re-drive/reaper/двойной webhook) не делает второй merge и не ломает контракты (опора на `pr_already_merged`, ORCH-065). | + +## 7. Заинтересованные стороны +- **Owner** — одобряет прод-деплой («Confirm Deploy»), получает alert при «deployed but not merged». +- **Все проекты на инстансе** (enduro-trails) — косвенно: целостность `main` орка влияет на инструмент, обслуживающий их из общей БД/очереди. + +## 8. Критерий успеха (бизнес-уровень) +После доработки невозможно состояние «задача `done` + прод задеплоен, а PR `open` / commit не в `main`»: либо merge подтверждён и задача `done`, либо задача НЕ `done` и поднят alert «deploy succeeded but not merged». Воспроизведение исходного сценария на staging показывает, что `main` реально получает commit. diff --git a/docs/work-items/ORCH-071/02-trz.md b/docs/work-items/ORCH-071/02-trz.md new file mode 100644 index 0000000..c6b26c0 --- /dev/null +++ b/docs/work-items/ORCH-071/02-trz.md @@ -0,0 +1,78 @@ +# ТЗ — ORCH-071: Верификация merge-в-main как условие done + +> Документ фиксирует ТРЕБОВАНИЯ к изменениям (WHAT). Конкретный дизайн (HOW: новый +> leaf-модуль vs расширение существующего, где разместить шаг merge, формат +> sentinel'ов) — за архитектором (ADR `06-adr/`). ТЗ задаёт инварианты, точки +> врезки и контракты, которые дизайн обязан удовлетворить. + +## 0. Резюме root cause (вход для дизайна) +Для self-hosting (`orchestrator`) стадия `deploy` идёт детерминированным путём +`_handle_self_deploy_phase_b → initiate_deploy → run_deploy_finalizer`, который +**не содержит шага merge PR в `main`** (merge делает только LLM-`deployer`, не +запускаемый на self-hosting). `done` достигается по `deploy_status: SUCCESS` без +верификации `main`. Требуется: (A) выполнить/докатить merge в `main` детерминированно +до перехода в `done`; (B) верифицировать факт merge ПОСЛЕ деплоя; (C) запретить +`done` без подтверждённого merge. + +## 1. Задействованные модули `src/` +| Модуль | Роль в фиксе | Характер изменения | +|--------|--------------|--------------------| +| `src/stage_engine.py` | `run_deploy_finalizer` (Phase C), терминал-блок `next_stage == "done"`, `_handle_self_deploy_phase_b` | Врезка шага merge-в-main + пост-merge верификация; блокировка перехода в `done` при неподтверждённом merge. | +| `src/merge_gate.py` | Уже содержит `pr_already_merged` (ORCH-065, read-only guard) | Добавить детерминированный **merge-актор** для self-hosting (выполнить merge PR через Gitea API) + helper верификации «SHA предок `origin/main`». Опора на существующие `pid_alive`/`reclaim_stale_lease`. | +| `src/self_deploy.py` | Sentinel-state Phase A/B/C | Возможный новый sentinel-маркер `merged` (restart-safe), если дизайн выносит merge в отдельный переживающий рестарт шаг (G3). | +| `src/qg/checks.py` | Реестр `QG_CHECKS`, `check_deploy_status` | Возможный новый под-чек верификации merge (например `check_merged_to_main`) ЛИБО усиление условия перехода `deploy→done`. `check_deploy_status` НЕ менять по контракту парсинга. | +| `src/config.py` | Флаги | Новый kill-switch (напр. `merge_verify_enabled` / `merge_verify_repos`), таймауты merge/verify. Дефолт — область self-hosting (как ORCH-35/43/58). | +| `.openclaw/agents/deployer.md` | Промпт deployer'а (non-self merge) | Уточнить: для self-hosting merge выполняет детерминированный код; non-self путь без изменений. | +| `src/main.py` (`/queue`) | Наблюдаемость | Опционально: блок/счётчики верификации merge (`merge_verified_total`, `not_merged_alerts_total`). | + +## 2. Функциональные требования + +### FR-1 (G3) — Детерминированный merge-в-main для self-hosting +- Для self-hosting репо merge PR ветки в `main` ДОЛЖЕН выполняться **детерминированным кодом** (не LLM-агентом), т.к. `deployer`-агент на self-hosting `deploy` не запускается. +- Merge выполняется через **Gitea PR-merge API** (как сегодня делает агент), НИКОГДА не force-push / не прямой push в `main` (INV-4). +- ПЕРЕД merge консультироваться `merge_gate.pr_already_merged(repo, branch)` — уже слит → no-op (INV-5, переиспользовать ORCH-065). +- **G3 — порядок относительно рестарта:** merge ДОЛЖЕН быть завершён и подтверждён ДО рестарта прод-контейнера, ЛИБО вынесен в шаг, переживающий рестарт (паттерн `requeue_running_jobs`/finalizer-defer): если процесс умер во время Phase B, шаг merge докатывается после рестарта (re-drive finalizer'а или отдельный merge-job). Дизайн выбирает один из двух вариантов; выбранный обязан быть restart-safe (sentinel/jobs, без миграции БД — §4). + +### FR-2 (G1) — Пост-деплой верификация merge +- ПОСЛЕ деплоя (в Phase C / финализации, ДО фиксации `done`) выполнить детерминированную верификацию: задеплоенный commit (validated SHA) — **предок `origin/main`** (`git merge-base --is-ancestor origin/main`) **ИЛИ** `PR.merged == true` (Gitea API). +- Верификация **never-raise** (INV-1): любая ошибка git/HTTP → трактуется как «не подтверждено» → alert, НЕ падение. +- При неподтверждённой верификации — **alert** «deploy succeeded but not merged» (Telegram + Plane-коммент) и задача **НЕ переходит в `done`** (FR-3). + +### FR-3 (G2) — `done` только при подтверждённом merge +- Переход `deploy → done` для self-hosting ДОЛЖЕН быть обусловлен подтверждённым merge (verify из FR-2 зелёный). Наличие `deploy_status: SUCCESS` + post-deploy `HEALTHY` — **недостаточно**. +- При `SUCCESS`-маркере деплоя, но неподтверждённом merge: задача удерживается (не `done`), Plane-статус — не терминальный (например текущий `Deploying`/`Awaiting` или `Blocked` по решению дизайна), шлётся alert. Конвейер НЕ откатывается на `development` автоматически из-за not-merged (это инфраструктурный, не код-дефект) — реакция = alert + ручное вмешательство (согласовать с дизайном; по умолчанию ALERT-only, как ORCH-021 self-hosting). + +### FR-4 (G4) — Диагностический runbook +- В `docs/operations/` добавить runbook с 4 проверками из постмортема (метод однозначной локализации фантома): + 1. Gitea API: список PR + флаги `merged`. + 2. md5 прод-файлов vs `git show origin/main:`. + 3. `git merge-base` ветки vs `main`. + 4. Таймлайн деплой-логов. +- Включить готовые команды (copy-paste) и критерий «фантом подтверждён». + +### FR-5 — Условность раската (как ORCH-35/43/58) +- Новая логика merge+verify реальна для self-hosting (`is_self_hosting_repo` / `merge_verify_repos`); прочие репо — поведение БЕЗ изменений (non-self merge остаётся за агентом `deployer`). +- Kill-switch (env, дефолт `true`) → `false` восстанавливает строго прежнее поведение. + +## 3. Изменения API +- **Внешний HTTP API сервиса (`/health`, `/status`, `/queue`, `/webhook/*`) — без новых endpoint'ов.** Допустимо обогащение ответа `GET /queue` блоком наблюдаемости merge-verify (счётчики), по образцу блоков `reaper`/`post_deploy`. +- **Gitea API (исходящие вызовы):** новый детерминированный вызов `POST /repos/{owner}/{repo}/pulls/{index}/merge` (merge-актор, FR-1) + чтение `GET /repos/{owner}/{repo}/pulls?...` (уже используется в `pr_already_merged`). Через существующий httpx-клиент и `settings.gitea_*`. + +## 4. Изменения схемы БД +- **НЕТ.** Schema-changes запрещены (не-цель). Restart-safe состояние нового шага merge — через sentinel-файлы (`.deploy-state-//`, как ORCH-036) и/или существующую очередь `jobs` (finalizer-defer). Колонка `jobs.pid` (ORCH-065) уже есть, при необходимости переиспользуется. + +## 5. Требования к новым QG checks +- Допускается ввести детерминированный под-чек верификации merge (напр. `check_merged_to_main`), регистрируемый в `QG_CHECKS`, ЛИБО встроить верификацию как условие в логику перехода `deploy→done` без нового чека — на усмотрение дизайна. В любом случае: + - Контракт `check_deploy_status` / `_parse_deploy_status` (читает только `deploy_status:` frontmatter) **НЕ меняется**. + - `STAGE_TRANSITIONS` **НЕ меняется** (verify — это условие/под-гейт ребра/финализации, не новая стадия). + - Вердикт (если артефакт) — строго YAML-frontmatter (канон гейтов), never проза. + +## 6. Артефакты, создаваемые/обновляемые по pipeline +- `14-deploy-log.md` — существующий; дизайн может добавить поле статуса merge (напр. `merged_to_main: true|false`) во frontmatter (машиночитаемо), не ломая `deploy_status:`. +- Новый runbook в `docs/operations/` (FR-4). +- **Обязательно (CLAUDE.md §2):** обновить `docs/architecture/README.md` (раздел Phase B / merge-gate / executable self-deploy — описать новый merge+verify шаг), `CHANGELOG.md`, при сквозном решении — ADR (`docs/work-items/ORCH-071/06-adr/ADR-001-*.md` и/или global `docs/architecture/adr/`). + +## 7. Совместимость / регресс +- Happy-path не-self репо (enduro-trails): merge остаётся за агентом `deployer` → поведение без изменений. +- Happy-path self-hosting: при штатном merge задача `done` ставится как раньше (после добавления verify, который зелёный). +- Все существующие контракты неизменны: `STAGE_TRANSITIONS`, реестр `QG_CHECKS` (кроме возможного нового under-чека), `check_deploy_status`, БАГ-8, terminal-sync, merge-gate (ORCH-043), `Confirm Deploy` (ORCH-059), exit-коды хука (0/1/2), схема БД. diff --git a/docs/work-items/ORCH-071/03-acceptance-criteria.md b/docs/work-items/ORCH-071/03-acceptance-criteria.md new file mode 100644 index 0000000..1de080c --- /dev/null +++ b/docs/work-items/ORCH-071/03-acceptance-criteria.md @@ -0,0 +1,61 @@ +# Критерии приёмки — ORCH-071 + +Формат: каждый критерий имеет явное условие PASS/FAIL. Машинные вердикты — из артефактов/состояния, не из прозы. + +## AC-1 (G1) — Пост-деплой верификация: not-merged ⇒ не done + alert +- **Условие:** после Phase B/финализации, если задеплоенный commit НЕ влит в `origin/main` (не предок `origin/main` И `PR.merged != true`). +- **PASS:** задача НЕ переходит в `done`; шлётся alert «deploy succeeded but not merged» (Telegram + Plane-коммент). +- **FAIL:** задача стала `done` при неслитом PR ИЛИ alert не отправлен. + +## AC-2 (G2) — done только при PR.merged==true (mock-тест) +- **Условие:** SUCCESS-маркеры деплоя присутствуют (`deploy_status: SUCCESS`), но PR `open` (`merged=false`). +- **PASS:** переход в `done` НЕ выполняется (тест на mock Gitea: PR open → done не ставится). +- **FAIL:** задача переведена в `done`. + +## AC-3 (G3) — Merge подтверждён до/независимо от рестарта (smoke) +- **Условие:** симулирован рестарт контейнера во время Phase B (процесс/держатель merge умер до завершения merge). +- **PASS:** после рестарта merge докатывается (re-drive finalizer / merge-job, как `requeue_running_jobs`), `main` получает commit, верификация зелёная → задача `done`. +- **FAIL:** после рестарта merge не докатился, задача `done` без merge ИЛИ навсегда зависла без alert. + +## AC-4 (регресс) — Happy-path +- **Условие:** merge прошёл штатно, `PR.merged==true`, deploy `SUCCESS`, верификация зелёная. +- **PASS:** `done` ставится как раньше (терминал-sync/Plane-статус как сегодня для self-hosting), без лишних alert. +- **FAIL:** регрессия — happy-path не доходит до `done` или шлёт ложный not-merged alert. + +## AC-4b (регресс) — non-self репо без изменений +- **Условие:** деплой репо enduro-trails (не self-hosting). +- **PASS:** merge выполняет агент `deployer` (прежний путь), новая детерминированная merge/verify-логика — no-op для не-self. +- **FAIL:** изменилось поведение non-self деплоя. + +## AC-5 — Зелёный pytest + документация +- **PASS:** `pytest tests/ -q` зелёный; обновлены `CHANGELOG.md`, `docs/architecture/README.md` (раздел Phase B / merge-verify) и runbook (`docs/operations/`). +- **FAIL:** красные тесты ИЛИ документация/CHANGELOG/runbook не обновлены (reviewer → REQUEST_CHANGES, CLAUDE.md §6). + +## AC-6 — Воспроизведение исходного сценария на staging +- **Условие:** на staging провести задачу до деплоя. +- **PASS:** проверить (методом runbook), что `main` реально получил commit задачи (PR merged / SHA предок `origin/main`). +- **FAIL:** прод/«done» достигнуты, а `main` не получил commit. + +## AC-7 (INV-1) — never-raise на верификации +- **Условие:** verify сталкивается с ошибкой git/HTTP (Gitea недоступна, битый ref). +- **PASS:** функция возвращает «не подтверждено» → alert, процесс/конвейер НЕ падает (исключение не пробрасывается). +- **FAIL:** исключение из verify валит finalizer/advance_stage. + +## AC-8 (INV-2) — self-hosting safety +- **Условие:** шаг верификации/merge исполняется для `orchestrator`. +- **PASS:** verify/merge НЕ рестартят и НЕ роняют прод-контейнер 8500, не трогают другие проекты; merge — только PR-merge API, без push в `main`. +- **FAIL:** verify/merge перезапускает прод ИЛИ делает прямой/force push в `main`. + +## AC-9 (INV-5) — идемпотентность повторного прогона +- **Условие:** re-drive стадии `deploy` / повторный webhook / reaper-requeue при уже слитом PR. +- **PASS:** `pr_already_merged` → merge не повторяется (no-op), верификация зелёная, нет дубль-merge/ошибки Gitea, нет ложного БАГ-8 отката. +- **FAIL:** второй merge / merge-error / ложный откат. + +## AC-10 (FR-5) — kill-switch +- **Условие:** kill-switch новой merge/verify-логики выключен (`false`). +- **PASS:** строго прежнее поведение (1:1 до фикса). +- **FAIL:** при выключенном флаге логика всё равно срабатывает. + +## AC-11 (INV-3) — ручной approve сохранён +- **PASS:** прод-деплой по-прежнему запускается только статусом «Confirm Deploy» (ORCH-059); merge/verify не вводят авто-деплой. +- **FAIL:** деплой/merge запускается без человеческого триггера. diff --git a/docs/work-items/ORCH-071/04-test-plan.yaml b/docs/work-items/ORCH-071/04-test-plan.yaml new file mode 100644 index 0000000..af5de7f --- /dev/null +++ b/docs/work-items/ORCH-071/04-test-plan.yaml @@ -0,0 +1,103 @@ +work_item: ORCH-071 +title: "Верификация merge-в-main как условие done (фантомный merge)" +notes: > + Тесты детерминированные, без LLM. Gitea/PR-состояние и git-операции мокаются + (monkeypatch httpx / subprocess / merge_gate helpers). Цель — закрыть AC-1..AC-11. + Все новые функции верификации/merge соблюдают never-raise. + +tests: + # --- FR-2 / G1 / AC-1: пост-деплой верификация merge --- + - id: TC-01 + type: unit + description: "verify_merged_to_main возвращает True, когда deployed SHA — предок origin/main (git merge-base --is-ancestor rc=0)" + module: tests/test_merge_verify.py + expected: PASS + - id: TC-02 + type: unit + description: "verify_merged_to_main возвращает True, когда PR.merged==true (Gitea mock), даже если git-проверка недоступна" + module: tests/test_merge_verify.py + expected: PASS + - id: TC-03 + type: unit + description: "verify_merged_to_main возвращает False, когда SHA не предок origin/main И PR.merged==false (фантом)" + module: tests/test_merge_verify.py + expected: PASS + - id: TC-04 + type: unit + description: "never-raise (AC-7): ошибка git/HTTP в verify -> False (не подтверждено), исключение не пробрасывается" + module: tests/test_merge_verify.py + expected: PASS + + # --- FR-3 / G2 / AC-2: done только при подтверждённом merge --- + - id: TC-05 + type: integration + description: "Phase C finalizer: deploy_status=SUCCESS но PR open -> задача НЕ переходит в done, шлётся alert 'deploy succeeded but not merged'" + module: tests/test_deploy_finalizer_merge_gate.py + expected: PASS + - id: TC-06 + type: integration + description: "Phase C finalizer: deploy_status=SUCCESS и merge подтверждён -> задача переходит в done (happy-path, AC-4)" + module: tests/test_deploy_finalizer_merge_gate.py + expected: PASS + + # --- FR-1 / AC-9: детерминированный merge-актор + идемпотентность --- + - id: TC-07 + type: unit + description: "merge-актор self-hosting вызывает Gitea POST /pulls/{index}/merge, когда PR не слит; никакого push/force-push в main" + module: tests/test_merge_actor.py + expected: PASS + - id: TC-08 + type: unit + description: "идемпотентность (AC-9): pr_already_merged==True -> merge-актор no-op (нет второго merge, нет ошибки Gitea)" + module: tests/test_merge_actor.py + expected: PASS + - id: TC-09 + type: unit + description: "merge-актор never-raise: ошибка Gitea API -> (False, reason), исключение не пробрасывается" + module: tests/test_merge_actor.py + expected: PASS + + # --- FR-1 G3 / AC-3: merge переживает рестарт --- + - id: TC-10 + type: integration + description: "smoke (AC-3): симуляция смерти процесса во время Phase B -> re-drive finalizer/merge-job докатывает merge после 'рестарта', main получает commit, verify зелёная -> done" + module: tests/test_deploy_restart_merge_recovery.py + expected: PASS + + # --- FR-5 / AC-10: условность раската --- + - id: TC-11 + type: unit + description: "AC-4b: для non-self репо (enduro-trails) новая merge/verify-логика = no-op (merge остаётся за агентом deployer)" + module: tests/test_merge_verify.py + expected: PASS + - id: TC-12 + type: unit + description: "AC-10: kill-switch выключен -> строго прежнее поведение (verify/merge не выполняются)" + module: tests/test_merge_verify.py + expected: PASS + + # --- INV-2 / AC-8: self-hosting safety --- + - id: TC-13 + type: unit + description: "AC-8: путь merge/verify не вызывает рестарт прод-контейнера и не делает прямой/force push в main (проверка отсутствия соответствующих вызовов)" + module: tests/test_merge_actor.py + expected: PASS + + # --- INV-3 / AC-11: ручной approve сохранён --- + - id: TC-14 + type: integration + description: "AC-11: Phase B запускается только при confirm_deploy=True ('Confirm Deploy'); merge/verify не вводят авто-деплой (обычный Approved -> no-op)" + module: tests/test_deploy_finalizer_merge_gate.py + expected: PASS + + # --- Регресс существующих контрактов --- + - id: TC-15 + type: unit + description: "регресс: check_deploy_status / _parse_deploy_status неизменны (читают только deploy_status: frontmatter)" + module: tests/test_qg_checks.py + expected: PASS + - id: TC-16 + type: unit + description: "регресс: STAGE_TRANSITIONS и реестр QG_CHECKS не сломаны (deploy->done ребро на месте)" + module: tests/test_stages.py + expected: PASS From 2824fd85434856fa7d0bf8b42737dcf11e9de1d8 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 8 Jun 2026 08:13:44 +0000 Subject: [PATCH 3/8] architect(ET): auto-commit from architect run_id=354 --- docs/architecture/README.md | 38 ++++ .../adr/adr-0013-merge-verify-gate.md | 63 ++++++ .../06-adr/ADR-001-merge-verify-gate.md | 186 ++++++++++++++++++ .../ORCH-071/07-infra-requirements.md | 47 +++++ docs/work-items/ORCH-071/10-tech-risks.md | 23 +++ 5 files changed, 357 insertions(+) create mode 100644 docs/architecture/adr/adr-0013-merge-verify-gate.md create mode 100644 docs/work-items/ORCH-071/06-adr/ADR-001-merge-verify-gate.md create mode 100644 docs/work-items/ORCH-071/07-infra-requirements.md create mode 100644 docs/work-items/ORCH-071/10-tech-risks.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 149199a..abd7160 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -121,6 +121,44 @@ sentinel-файлы (`/.deploy-state-//`), без мигр Детально — `docs/work-items/ORCH-059/06-adr/ADR-001-confirm-deploy-status.md` (уточняет/триггер Фазы B относительно adr-0007). +#### Merge-в-main + пост-деплой верификация как условие `done` (ORCH-071 — фикс фантомного merge) +**Фантомный merge** (CRITICAL, постмортем `docs/history/LESSONS_2026-06-08_phantom-merge.md`): +на self-hosting пути `deploy` агент `deployer` НЕ запускается, а фактический merge PR в `main` +исторически делал ТОЛЬКО он → детерминированный путь +(`_handle_self_deploy_phase_b → initiate_deploy → run_deploy_finalizer`) **не содержал шага +merge-в-main вообще**. Detached host-деплой лишь retag'ал образ + рестартил 8500; `done` +достигался по `deploy_status: SUCCESS` без верификации `main`. Зелёный деплой (образ из рабочей +ветки) маскировал отсутствие merge → следующая задача срезала ветку от устаревшего `main` и +теряла код предшественника (накопительно потеряны ORCH-022/059/066/068). ORCH-071 вводит +**детерминированный merge-актор + пост-merge верификацию** как **под-гейт ребра `deploy → done`** +(симметрично edge-под-гейтам `deploy-staging → deploy`), только для self-hosting: +- **Врезка `_handle_merge_verify` в `advance_stage`** (`current_stage=="deploy"` и + `next_stage=="done"`, ПОСЛЕ зелёного `check_deploy_status`, ДО `update_task_stage`). Гейтит + **ВСЕ** пути к `done` единообразно (`run_deploy_finalizer` Phase C, reconciler F-1, job-reaper — + все идут через `advance_stage`), закрывая дыру обхода merge. +- **Merge в Phase C (после рестарта), НЕ в Phase B** — finalizer restart-surviving (claim воркером + нового контейнера, re-drive reaper'ом), merge физически строго ПОСЛЕ рестарта прода → рестарт его + не убивает (G3 «шаг, переживающий рестарт»; постмортем-урок №3). +- **Merge-актор `merge_gate.merge_pr`** — `pr_already_merged` (no-op повтор, ORCH-065) → иначе + Gitea `POST /repos/{owner}/{repo}/pulls/{index}/merge`. Никогда push/force-push в `main`. +- **Верификатор `merge_gate.verify_merged_to_main`** — `PR.merged==true` ИЛИ + `git merge-base --is-ancestor origin/main` (`validated_revision` — тот же якорь, + что у ORCH-058). never-raise → `False`. +- **Не подтверждено → alert «deploy succeeded but not merged» (Telegram+Plane) + HOLD** + (`set_issue_blocked`, задача НЕ `done`, БЕЗ авто-отката на `development` — not-merged есть + инфра-дефект, реакция ALERT-only как ORCH-021 self-hosting). Подтверждено → штатный `deploy → + done` + `merged_to_main: true` во frontmatter `14-deploy-log.md` (`deploy_status:` нетронут). +- **Условность как ORCH-35/43/58:** `merge_verify_enabled` (kill-switch, дефолт `true`) + + `merge_verify_repos` (пусто → только self-hosting); non-self — no-op, merge остаётся за `deployer`. + never-raise; идемпотентность (`pr_already_merged`, INV-5); ручной approve сохранён (`Confirm Deploy`). +- **Инварианты:** `STAGE_TRANSITIONS`, `check_deploy_status`/`_parse_deploy_status`, реестр + `QG_CHECKS` (под-гейт — врезка в `advance_stage`, НЕ новый зарегистрированный QG), схема БД, + БАГ-8, terminal-sync, merge-gate, image-freshness, exit-коды хука — **без изменений**. + Диагностика фантома — runbook `docs/operations/PHANTOM_MERGE_RUNBOOK.md` (4 проверки постмортема). + +Подробнее: [adr-0013](adr/adr-0013-merge-verify-gate.md), детально — +`docs/work-items/ORCH-071/06-adr/ADR-001-merge-verify-gate.md`. + ### Post-deploy наблюдение прода + реакция на деградацию (ORCH-021 — реализовано) Конвейер заканчивался на `deploy → done` и **забывал про прод**: «успех» = health-check в момент рестарта (~60с). Класс «зелёный деплой, красный прод» (прецедент ET-8 — diff --git a/docs/architecture/adr/adr-0013-merge-verify-gate.md b/docs/architecture/adr/adr-0013-merge-verify-gate.md new file mode 100644 index 0000000..d03fe87 --- /dev/null +++ b/docs/architecture/adr/adr-0013-merge-verify-gate.md @@ -0,0 +1,63 @@ +# adr-0013: Merge-в-main + пост-деплой верификация как условие `done` (фикс фантомного merge) + +- **Статус:** accepted +- **Дата:** 2026-06-08 +- **Задача:** ORCH-071 (CRITICAL bug) +- **Детальный ADR:** `docs/work-items/ORCH-071/06-adr/ADR-001-merge-verify-gate.md` +- **Постмортем:** `docs/history/LESSONS_2026-06-08_phantom-merge.md` + +## Контекст +Для self-hosting репо `orchestrator` стадия `deploy` идёт детерминированным путём +(`_handle_self_deploy_phase_b → initiate_deploy → run_deploy_finalizer`), а LLM-агент +`deployer` НЕ запускается. Фактический merge PR в `main` исторически делал **только** +агент `deployer` → на self-hosting пути **нет шага merge-в-main вообще**. Detached +host-деплой лишь retag'ает образ + рестартит 8500; `done` достигается по +`deploy_status: SUCCESS` без верификации `main`. «Зелёный» деплой (образ из рабочей +ветки) маскирует отсутствие merge → следующая задача срезает ветку от устаревшего `main` +и теряет код предшественника. Накопительно потеряны ORCH-022/059/066/068. Вторичный +фактор: Phase B рестартит прод → merge внутри живого процесса гонялся бы с рестартом +(урок №3). + +## Решение +Детерминированный **merge-актор + пост-merge верификация** как **под-гейт ребра +`deploy → done`**, врезанный в единственную функцию перехода `advance_stage` (симметрично +edge-под-гейтам security/merge-gate/image-freshness). `STAGE_TRANSITIONS`, +`check_deploy_status`/`_parse_deploy_status`, реестр `QG_CHECKS`, схема БД — **не меняются**. + +- **Врезка `_handle_merge_verify` в `advance_stage`** (`current_stage=="deploy"` и + `next_stage=="done"`, ПОСЛЕ зелёного `check_deploy_status`, ДО `update_task_stage`). + Гейтит **ВСЕ** пути к `done` единообразно: `run_deploy_finalizer` (Phase C), reconciler + F-1, job-reaper — все идут через `advance_stage`. Закрывает дыру: reconciler F-1 иначе + протолкнул бы `done` в обход merge. +- **Merge в Phase C (после рестарта), НЕ в Phase B.** Phase C finalizer — + restart-surviving (reserved-job `deploy-finalizer`, claim воркером нового контейнера, + re-drive reaper'ом). Merge физически строго ПОСЛЕ рестарта → рестарт его не убивает + (G3 вторым вариантом — «шаг, переживающий рестарт»). +- **Merge-актор `merge_gate.merge_pr`** — `pr_already_merged` (no-op повтор, ORCH-065) → + иначе Gitea `POST /repos/{owner}/{repo}/pulls/{index}/merge`. Никогда push/force-push в + `main`. never-raise. +- **Верификатор `merge_gate.verify_merged_to_main`** — `PR.merged==true` ИЛИ + `git merge-base --is-ancestor origin/main`. never-raise → `False` + («не подтверждено»). +- **Не подтверждено → alert «deploy succeeded but not merged» (Telegram+Plane) + HOLD** + (`set_issue_blocked`, задача НЕ `done`, БЕЗ авто-отката на `development` — not-merged + есть инфра-дефект, реакция ALERT-only как ORCH-021 self-hosting). Подтверждено → + штатный `deploy → done` (терминал-sync / post-deploy monitor как сегодня) + + `merged_to_main: true` во frontmatter `14-deploy-log.md` (наблюдаемость, `deploy_status:` + нетронут). +- **Идемпотентность (INV-5):** `pr_already_merged` перед merge; verify зелёный для + уже-слитого PR; повтор без дубль-merge/ложного отката. +- **Условность (как ORCH-35/43/58):** `merge_verify_enabled` (kill-switch, дефолт `true`) + + `merge_verify_repos` (пусто → только self-hosting). Non-self репо — no-op, merge остаётся + за агентом `deployer`. + +## Инварианты +never-raise на verify/merge (ошибка → alert, не падение конвейера); не рестартить/не ронять +прод 8500; ручной approve прод-деплоя сохранён (`Confirm Deploy`, ORCH-059); только PR-merge +API Gitea; restart-safe (sentinel + jobs, без миграции БД). + +## Последствия +Невозможно «`done` + прод задеплоен, а PR `open`». Минусы: при недоступной Gitea verify +консервативно `False` → возможен ложный HOLD+alert (снимается повтором; fail-closed для +`done` приоритетен); HOLD требует ручного вмешательства. Диагностика фантома — runbook +`docs/operations/PHANTOM_MERGE_RUNBOOK.md` (G4). diff --git a/docs/work-items/ORCH-071/06-adr/ADR-001-merge-verify-gate.md b/docs/work-items/ORCH-071/06-adr/ADR-001-merge-verify-gate.md new file mode 100644 index 0000000..2c4968d --- /dev/null +++ b/docs/work-items/ORCH-071/06-adr/ADR-001-merge-verify-gate.md @@ -0,0 +1,186 @@ +# ADR-001 (ORCH-071): Детерминированный merge-в-main + пост-деплой верификация как условие `done` + +## Статус +Accepted + +## Контекст + +### Подтверждённый root cause (постмортем `docs/history/LESSONS_2026-06-08_phantom-merge.md`) +Для self-hosting репо `orchestrator` стадия `deploy` идёт **детерминированным** путём +`stage_engine._handle_self_deploy_phase_b → self_deploy.initiate_deploy → +run_deploy_finalizer`, а LLM-агент `deployer` **не запускается** (так предписывает +`.openclaw/agents/deployer.md`). Фактический merge PR в `main` исторически выполнял +**только** агент `deployer` через Bash/curl. Следствие: на self-hosting пути **нет ни +одного шага, выполняющего git-merge ветки в `main`** (аудит: `grep` по +`pulls/.../merge` в `src/` — 0 совпадений). + +Detached host-процесс (Phase B) лишь **retag staging-образа на прод-тег + рестарт 8500**. +`run_deploy_finalizer` маппит exit-code хука `0 → SUCCESS`, пишет `14-deploy-log.md`, +вызывает `advance_stage(..., finished_agent="deployer")`; гейт `check_deploy_status` +читает только `deploy_status:` → `SUCCESS → done`. **Состояние `main` нигде не +верифицируется.** «Зелёный» деплой (прод-образ собран из рабочей ветки) маскирует +отсутствие merge — сигнала нет, пока следующая задача не срежет ветку от устаревшего +`main` и не потеряет код предшественника. Накопительно потеряны ORCH-022/059/066/068. + +Вторичный фактор (урок №3): Phase B **рестартит прод-контейнер**, поэтому любой +держатель merge-lease / незавершённый git-шаг ВНУТРИ живого процесса умирает до +завершения merge. Значит наивно «добавить merge в Phase B» (живой старый контейнер, +который вот-вот рестартует) — снова гонка с рестартом. + +### Требования (из ТЗ/BRD) +- **G1/FR-2** — пост-деплой верификация: deployed SHA — предок `origin/main` ИЛИ `PR.merged==true`. +- **G2/FR-3** — `done` ТОЛЬКО при подтверждённом merge; `deploy_status: SUCCESS` + post-deploy `HEALTHY` — недостаточно. +- **G3/FR-1** — merge детерминированным кодом (агент не запускается), через Gitea PR-merge API; завершён ДО рестарта ЛИБО вынесен в шаг, переживающий рестарт. +- **INV-1** never-raise; **INV-2** не рестартить/не ронять прод; **INV-3** ручной approve сохранён; **INV-4** только PR-merge API, никогда push/force-push в `main`; **INV-5** идемпотентность (`pr_already_merged`). +- **НЕ менять:** `STAGE_TRANSITIONS`, `check_deploy_status`/`_parse_deploy_status`, схему БД, source-of-truth. + +## Решение + +Вводим **детерминированный merge-актор + пост-merge верификацию** как **под-гейт ребра +`deploy → done`**, врезанный в `advance_stage`. Это симметрично существующим edge-под-гейтам +(security/merge-gate/image-freshness на ребре `deploy-staging → deploy`): `STAGE_TRANSITIONS` +не меняется, новый под-гейт — условие финализации, а не новая стадия. + +### D1. Точка врезки — `advance_stage`, ребро `deploy → done` (единая для ВСЕХ путей) +Врезка `_handle_merge_verify(...)` в `src/stage_engine.py::advance_stage` **после** успешного +прохождения QG (`check_deploy_status == SUCCESS`, т.е. `next_stage == "done"`) и **до** +`update_task_stage(task_id, next_stage)`: + +```python +# --- ORCH-071 merge-verify under-gate (deploy -> done edge) --- +if current_stage == "deploy" and next_stage == "done": + if _handle_merge_verify(task_id, repo, work_item_id, branch, result): + return result # HOLD: merge не подтверждён -> alert, НЕ done, НЕ rollback +``` + +`advance_stage` — **единственная** функция перехода стадий. Её вызывают `run_deploy_finalizer` +(Phase C), reconciler F-1 (`finished_agent=None`), job-reaper (re-drive). Врезка именно здесь +**гейтит ВСЕ пути единообразно**: ни один из них не сможет довести `deploy → done` без +подтверждённого merge. Это закрывает скрытую дыру: reconciler F-1 предоценивает +`check_deploy_status` read-only и при зелёном вызывает `advance_stage` — без врезки он бы +протолкнул `done` в обход merge. + +### D2. Когда выполняется merge — в Phase C (после рестарта), а НЕ в Phase B +Merge выполняется внутри `_handle_merge_verify`, т.е. на ребре `deploy → done`, которое +достигается **из `run_deploy_finalizer` уже в НОВОМ контейнере после рестарта прода**. Это +осознанный выбор в пользу второго варианта G3 («шаг, переживающий рестарт»): + +- Phase B лишь **диспетчеризует** detached-деплой (`ssh` возвращается мгновенно), рестарт прода + происходит асинхронно на хосте. Merge в Phase B (живой старый контейнер) **гонялся бы** с + рестартом и мог быть убит на полушаге — ровно постмортем-урок №3. Поэтому merge в Phase B + **отвергнут**. +- Phase C finalizer уже **restart-surviving**: это reserved-agent job `deploy-finalizer`, + переставляемый с defer и **claim'ится воркером нового контейнера** после рестарта; если новый + контейнер умрёт на полушаге merge — job re-drive'ится (reaper/requeue), а `pr_already_merged` + делает повтор идемпотентным. Merge физически происходит **строго ПОСЛЕ** рестарта → рестарт + его не убивает. G3 удовлетворён. + +### D3. Merge-актор — `src/merge_gate.py::merge_pr(repo, branch) -> (bool, str)` +Новый детерминированный merge-актор (рядом с `pr_already_merged`/`pid_alive`/`reclaim_stale_lease`): +1. `pr_already_merged(repo, branch)` → `True` → **no-op** `(True, "already-merged")` (INV-5/AC-9). +2. Иначе `GET /repos/{owner}/{repo}/pulls?state=open&head=` → индекс открытого PR. +3. `POST /repos/{owner}/{repo}/pulls/{index}/merge` (Do: `merge`) через существующий httpx-клиент + и `settings.gitea_*`. Никогда не push/force-push в `main` (INV-4/AC-8). +4. **never-raise** (INV-1): любая HTTP/parse-ошибка → `(False, reason)`; нет открытого PR при + `pr_already_merged==False` → `(False, "no open PR")`. + +Работает под merge-lease, который уже **удерживается** этой задачей с merge-gate ребра +`deploy-staging → deploy` (Phase A held-across-wait) и освобождается на `done`/откате +(существующий `release_merge_lease`, ORCH-043) либо реклеймится по смерти держателя (ORCH-065). +Сериализация слияний сохранена без новой блокировки. + +### D4. Верификатор — `src/merge_gate.py::verify_merged_to_main(repo, branch, sha) -> bool` +Возвращает `True`, если merge подтверждён (FR-2): +- `pr_already_merged(repo, branch) is True` **ИЛИ** +- `git merge-base --is-ancestor origin/main` в worktree задачи (после `git fetch origin main`), + где `` — validated commit = `git rev-parse HEAD` worktree (тот же якорь, что + `image_freshness.validated_revision`). + +**never-raise** (INV-1/AC-7): любая git/HTTP-ошибка → `False` (= «не подтверждено» → alert + HOLD, +fail-closed для `done`). Исключение НИКОГДА не пробрасывается в `advance_stage`. + +### D5. `_handle_merge_verify` (оркестрация под-гейта, `src/stage_engine.py`) +Возвращает `True` (вмешался → HOLD, не advance) / `False` (merge подтверждён → штатный advance в `done`): +1. Условность: `merge_verify_applies(repo)` (см. D7) `False` → вернуть `False` (поведение 1:1 как раньше). +2. `sha = validated_revision(...)`; `merge_gate.merge_pr(repo, branch)` (no-op если уже слит). +3. `ok = merge_gate.verify_merged_to_main(repo, branch, sha)`. +4. `ok==True`: + - дописать `merged_to_main: true` во frontmatter `14-deploy-log.md` (машиночитаемая + наблюдаемость; `deploy_status:` НЕ трогаем — контракт парсинга `check_deploy_status` + неизменен), вернуть `False` → `advance_stage` штатно ведёт `deploy → done` + (терминал-sync/post-deploy-monitor как сегодня; AC-4). +5. `ok==False`: + - **alert** «deploy succeeded but not merged» — Telegram + Plane-коммент; + - `set_issue_blocked(work_item_id)` (Plane не-терминальный; согласовано с ORCH-066 + DEGRADED→Blocked и deploy-finalize-exhausted); + - дописать `merged_to_main: false`; **НЕ** `update_task_stage` (задача остаётся на `deploy`), + **НЕ** откат на `development` (not-merged — инфра-дефект, не код; FR-3 → ALERT-only, как + ORCH-021 self-hosting); + - вернуть `True`. + Повтор (re-drive/reaper) переоценит: после ручного устранения merge подтвердится → `done`. + +Вся функция обёрнута never-raise: внутренняя ошибка → трактуется как «не подтверждено» (HOLD+alert), +не падение конвейера. + +### D6. Идемпотентность (INV-5/AC-9) +- Перед merge — `pr_already_merged` (no-op повтор). +- `verify` зелёный для уже-слитого PR (ветвь `pr_already_merged is True`). +- Повторный прогон ребра `deploy → done` (двойной webhook / reaper / reconciler): merge no-op, + verify зелёный, нет дубль-merge, нет ложного БАГ-8 отката. + +### D7. Условность раската (FR-5/AC-10) — `src/config.py` +Новые флаги (паттерн `merge_gate_*`/`image_freshness_*`): +- `merge_verify_enabled: bool = True` — глобальный kill-switch; `False` → строго прежнее + поведение (`_handle_merge_verify` сразу `False`, 1:1 до фикса). +- `merge_verify_repos: str = ""` — CSV; пусто → реально ТОЛЬКО для self-hosting + (`is_self_hosting_repo`); непусто → только перечисленные. +- (опц.) `merge_pr_timeout_s` / `merge_verify_timeout_s` — таймауты Gitea/git. + +`merge_verify_applies(repo)` — never-raise, зеркало `self_deploy_applies` / `image_freshness`. +Non-self репо (enduro-trails): под-гейт — **no-op**, merge остаётся за агентом `deployer` (AC-4b). + +### D8. Наблюдаемость (опц., FR §2/§3) +Блок `merge_verify` в `GET /queue` (по образцу `reaper`/`post_deploy`): `enabled`, +`merge_verified_total`, `not_merged_alerts_total`, `last_alert_wi`. Каждый alert → `logger.warning` ++ Telegram. + +### D9. Диагностический runbook (G4/FR-4) +`docs/operations/PHANTOM_MERGE_RUNBOOK.md` — 4 проверки постмортема с copy-paste командами: +(1) Gitea API список PR + `merged`-флаги; (2) md5 прод-файлов vs `git show origin/main:`; +(3) `git merge-base` ветки vs `main`; (4) таймлайн деплой-логов. + критерий «фантом подтверждён». + +## Что НЕ меняется (контракты) +`STAGE_TRANSITIONS`; `check_deploy_status`/`_parse_deploy_status` (читают только `deploy_status:`); +реестр `QG_CHECKS` (под-гейт — врезка в `advance_stage`, НЕ новый зарегистрированный QG, как +`_handle_merge_gate`); схема БД (restart-safe состояние — существующие sentinel'ы +`.deploy-state-//` + очередь `jobs`); БАГ-8; terminal-sync; merge-gate (ORCH-043); +image-freshness (ORCH-058); `Confirm Deploy` (ORCH-059); post-deploy monitor (ORCH-021); +exit-коды хука (0/1/2); ручной approve прод-деплоя (INV-3). Non-self merge — за агентом `deployer`. + +## Последствия + +**Плюсы** +- Невозможно состояние «`done` + прод задеплоен, а PR `open`»: либо merge подтверждён → `done`, + либо HOLD + alert (G2/критерий успеха BRD §8). +- Единая врезка в `advance_stage` гейтит ВСЕ пути (finalizer/reconciler/reaper) — нет обходных + дверей к `done`. +- Merge в restart-surviving Phase C структурно не убивается рестартом прода (G3, урок №3). +- Минимальный blast-radius: `STAGE_TRANSITIONS`/`check_deploy_status`/схема БД/реестр QG — нетронуты; + раскат за kill-switch. + +**Минусы / ограничения** +- При недоступной Gitea verify консервативно даёт `False` → возможен ложный not-merged alert и + HOLD; снимается повтором после восстановления Gitea (приемлемо: fail-closed для `done` важнее). +- HOLD при not-merged требует ручного вмешательства (ALERT-only) — осознанно (not-merged — + инфра-дефект, авто-откат на `development` запрещён FR-3). +- Появляется реальный исходящий merge-вызов из кода — должно покрываться mock-тестами Gitea + (AC-2) и smoke рестарта (AC-3). + +## Альтернативы (отвергнуто) +- **Merge в Phase B (до рестарта).** Гонка с асинхронным рестартом прода → merge может быть убит + на полушаге (постмортем-урок №3). Отвергнуто в пользу restart-surviving Phase C. +- **Новый зарегистрированный QG `check_merged_to_main` на стадии `deploy`.** У стадии один QG + (`check_deploy_status`); второй потребовал бы менять `STAGE_TRANSITIONS`/контракт. Врезка + под-гейта в `advance_stage` (как merge-gate) даёт тот же охват без изменения реестра. +- **Авто-откат на `development` при not-merged.** Запрещено FR-3: not-merged — инфра-дефект, + не код; реакция = alert + ручное вмешательство. diff --git a/docs/work-items/ORCH-071/07-infra-requirements.md b/docs/work-items/ORCH-071/07-infra-requirements.md new file mode 100644 index 0000000..5aeada1 --- /dev/null +++ b/docs/work-items/ORCH-071/07-infra-requirements.md @@ -0,0 +1,47 @@ +# 07 — Требования к инфраструктуре (ORCH-071) + +## Топология — без изменений +Новой топологии не вводится. Прод `orchestrator` (8500) и staging (8501) — как есть. +Merge выполняется детерминированным кодом в уже существующем restart-surviving Phase C +finalizer (новый контейнер после рестарта), без новых сервисов/портов/контейнеров. + +## I-1. Gitea токен с правом merge PR (предусловие) +Merge-актор `merge_gate.merge_pr` вызывает `POST /repos/{owner}/{repo}/pulls/{index}/merge` +через существующий клиент и `settings.gitea_token` / `settings.gitea_url` / `settings.gitea_owner`. +- Требование: тот же `gitea_token`, которым агент `deployer` сегодня мержит PR в `main`, + ДОЛЖЕН иметь право write/merge на репо `orchestrator`. Так как deployer уже мержит этим + токеном — **новых прав, как правило, не требуется** (тот же токен, тот же путь API). +- Действие при раскате: убедиться, что бот-токен — член/коллаборатор репо `orchestrator` + с правом merge (иначе merge_pr вернёт HTTP-ошибку → never-raise → HOLD+alert, не падение). + +## I-2. Сетевой доступ контейнера к Gitea +Контейнер прода уже ходит в Gitea API (`pr_already_merged`, webhooks). Дополнительного +сетевого доступа не нужно. При недоступности Gitea verify консервативно даёт «не +подтверждено» → HOLD+alert (fail-closed для `done`). + +## I-3. Доступ к `origin/main` из worktree задачи +Верификатор делает `git fetch origin main` + `git merge-base --is-ancestor origin/main` +в worktree задачи (как `image_freshness`/merge-gate уже делают `git fetch`/`rebase`). +Предусловие — рабочий git-remote `origin` в worktree (есть сегодня). Ошибка fetch → +never-raise → `False` → HOLD+alert. + +## I-4. Конфигурация (env, дефолты безопасны) +| Флаг | Дефолт | Назначение | +|------|--------|------------| +| `ORCH_MERGE_VERIFY_ENABLED` | `true` | kill-switch; `false` → строго прежнее поведение (1:1 до фикса) | +| `ORCH_MERGE_VERIFY_REPOS` | `""` | CSV; пусто → только self-hosting (`orchestrator`) | +| `ORCH_MERGE_PR_TIMEOUT_S` (опц.) | напр. 30 | таймаут merge-вызова Gitea | +| `ORCH_MERGE_VERIFY_TIMEOUT_S` (опц.) | напр. 60 | таймаут git fetch/merge-base | + +Дефолты не требуют изменения `.env` для штатного раската (область = self-hosting). +Откатить фикс мгновенно можно `ORCH_MERGE_VERIFY_ENABLED=false`. + +## I-5. Раскат через staging-гейт (self-hosting safety) +Изменение касается self-deploy пути орка → раскат ОБЯЗАН пройти стадию `deploy-staging` +(8501) перед прод-деплоем (CLAUDE.md §self-hosting). Прод-деплой — только переводом задачи +в статус `Confirm Deploy` (ORCH-059), ручной approve сохранён (INV-3). Никаких рестартов +прода в рамках разработки/ревью. + +## I-6. Без миграции БД +Schema-changes запрещены. Restart-safe состояние нового шага — существующие sentinel'ы +`.deploy-state-//` + очередь `jobs` (колонка `jobs.pid`, ORCH-065, уже есть). diff --git a/docs/work-items/ORCH-071/10-tech-risks.md b/docs/work-items/ORCH-071/10-tech-risks.md new file mode 100644 index 0000000..75c8078 --- /dev/null +++ b/docs/work-items/ORCH-071/10-tech-risks.md @@ -0,0 +1,23 @@ +# 10 — Технические риски (ORCH-071) + +| ID | Риск | Вероятность / Влияние | Митигация | +|----|------|----------------------|-----------| +| R-1 | **Гонка merge с рестартом прода** (постмортем-урок №3): merge в Phase B убивается рестартом → снова фантом. | Средняя / Критич. | Merge вынесен в **Phase C finalizer** (restart-surviving, новый контейнер ПОСЛЕ рестарта). Merge физически строго после рестарта. Smoke-тест AC-3. | +| R-2 | **Обходной путь к `done`** мимо merge-шага (reconciler F-1 / reaper протолкнут `deploy → done` по зелёному `check_deploy_status`). | Средняя / Критич. | Врезка `_handle_merge_verify` в **`advance_stage`** (единственная функция перехода) → гейтит ВСЕ вызывающие пути единообразно. | +| R-3 | **Ложный not-merged alert при недоступной Gitea** (verify→`False`) → лишний HOLD. | Средняя / Низкое | Осознанный fail-closed для `done`; снимается повтором (re-drive/reconciler) после восстановления Gitea. Alert информативен, не роняет конвейер. | +| R-4 | **Дубль-merge / merge-error** при re-drive (двойной webhook, reaper-requeue). | Средняя / Среднее | `pr_already_merged` ПЕРЕД merge → no-op повтор (INV-5/AC-9). Ложного БАГ-8 отката нет (merge-verify не откатывает). | +| R-5 | **Прямой/force push в `main`** случайно. | Низкая / Критич. | Merge ТОЛЬКО через Gitea PR-merge API (`merge_pr`); код не делает `git push origin main`. INV-4/AC-8, ревью. | +| R-6 | **Verify/merge роняет прод-контейнер** (self-hosting). | Низкая / Критич. | merge_pr/verify — только API + read-only git в worktree; никаких `docker`/restart 8500. INV-2/AC-8. | +| R-7 | **Регрессия non-self деплоя** (enduro-trails). | Низкая / Среднее | Условность `merge_verify_applies` (пусто→self-hosting); non-self — no-op, merge остаётся за `deployer`. AC-4b. | +| R-8 | **HOLD-залипание**: not-merged → Blocked, никто не вмешался → задача вечно не `done`. | Средняя / Среднее | Alert (Telegram+Plane) + Plane `Blocked` (видимый сигнал). Реакция ALERT-only осознанна (not-merged — инфра-дефект, авто-откат запрещён FR-3). Runbook G4 для быстрой локализации. | +| R-9 | **Validated SHA рассинхронизирован** (verify проверяет не тот коммит). | Низкая / Среднее | Единый якорь `validated_revision` (`git rev-parse HEAD` worktree) — тот же, что у image-freshness ORCH-058. | +| R-10 | **Exception из verify валит finalizer/advance_stage**. | Низкая / Высокое | never-raise контракт на всех публичных хелперах + обёртка `_handle_merge_verify`. AC-7. | +| R-11 | **Merge ветки, чей deploy FAILED** (если бы merge был до verify статуса). | — / — | Merge выполняется на ребре `deploy → done`, достигаемом ТОЛЬКО при `deploy_status: SUCCESS`. FAILED → БАГ-8 откат ДО merge-шага (merge не вызывается). | + +## Открытые вопросы / follow-up +- **Merge-style** (`merge` / `rebase` / `squash`) в Gitea API — зафиксировать тот же стиль, + что использовал агент `deployer` (по умолчанию `merge`), чтобы не менять историю `main`. +- **Восстановление текущего `main`** (долив 022/059/066/068) — ОТДЕЛЬНАЯ ветка + `integ/restore-main-2026-06-08`, вне scope ORCH-071. +- **Полный авто-деплой** (ORCH-54) — merge-verify совместим, но INV-3 (ручной approve) на + старте сохраняется. From fb25e9a0cfdef9cda655142864a00172e6f5a4b3 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 8 Jun 2026 08:31:43 +0000 Subject: [PATCH 4/8] developer(ET): auto-commit from developer run_id=355 --- docs/operations/PHANTOM_MERGE_RUNBOOK.md | 125 +++++++++++++ src/config.py | 21 +++ src/main.py | 2 + src/merge_gate.py | 190 ++++++++++++++++++++ src/self_deploy.py | 63 +++++++ src/stage_engine.py | 116 ++++++++++++ tests/conftest.py | 20 +++ tests/test_deploy_finalizer_merge_gate.py | 188 +++++++++++++++++++ tests/test_deploy_restart_merge_recovery.py | 116 ++++++++++++ tests/test_merge_actor.py | 135 ++++++++++++++ tests/test_merge_verify.py | 126 +++++++++++++ tests/test_qg_checks.py | 23 +++ tests/test_stages.py | 18 ++ 13 files changed, 1143 insertions(+) create mode 100644 docs/operations/PHANTOM_MERGE_RUNBOOK.md create mode 100644 tests/test_deploy_finalizer_merge_gate.py create mode 100644 tests/test_deploy_restart_merge_recovery.py create mode 100644 tests/test_merge_actor.py create mode 100644 tests/test_merge_verify.py diff --git a/docs/operations/PHANTOM_MERGE_RUNBOOK.md b/docs/operations/PHANTOM_MERGE_RUNBOOK.md new file mode 100644 index 0000000..dbc11a7 --- /dev/null +++ b/docs/operations/PHANTOM_MERGE_RUNBOOK.md @@ -0,0 +1,125 @@ +# Runbook — диагностика «фантомного merge» (ORCH-071) + +> **Когда применять.** Задача дошла до `done` (или прод задеплоен «зелёным»), но есть +> подозрение, что её ветка **не влита в `main`** — следующая задача срежет ветку от +> устаревшего `main` и потеряет код предшественника (постмортем +> `docs/history/LESSONS_2026-06-08_phantom-merge.md`). Этот runbook даёт 4 проверки +> для **однозначной локализации** фантома. + +С ORCH-071 такой исход блокируется автоматически: под-гейт `deploy → done` +(`stage_engine._handle_merge_verify`) сначала **детерминированно вливает PR** +(`merge_gate.merge_pr`, Gitea PR-merge API), затем **верифицирует merge** +(`merge_gate.verify_merged_to_main`) и НЕ пускает задачу в `done`, пока merge не +подтверждён (alert + HOLD). Этот runbook — для ручной перепроверки/инцидентов +(в т.ч. при выключенном kill-switch `ORCH_MERGE_VERIFY_ENABLED=false`). + +Подставьте значения: + +```bash +OWNER=admin # settings.gitea_owner +REPO=orchestrator # репозиторий +BRANCH=feature/ORCH-071-slug # ветка задачи +GITEA=http://localhost:3000 # settings.gitea_url +TOKEN= # settings.gitea_token +FILE=src/stage_engine.py # любой файл, гарантированно изменённый задачей +``` + +--- + +## Проверка 1 — Gitea API: список PR + флаги `merged` + +Показывает, считает ли сам Gitea PR влитым. + +```bash +curl -s -H "Authorization: token $TOKEN" \ + "$GITEA/api/v1/repos/$OWNER/$REPO/pulls?state=all" \ + | python3 -c 'import sys,json; \ +[print(p["number"], p["state"], "merged="+str(p.get("merged")), p["head"]["ref"]) \ + for p in json.load(sys.stdin)]' +``` + +* **Фантом НЕ подтверждён (всё хорошо):** строка ветки `$BRANCH` имеет `merged=True`. +* **Фантом подтверждён (по этому критерию):** PR ветки `state=open` / `merged=False` + (или PR отсутствует), при том что задача в `done` / прод задеплоен. + +--- + +## Проверка 2 — md5 прод-файлов vs `git show origin/main:` + +Сверяет содержимое файла на проде с тем, что лежит в `origin/main`. + +```bash +# в прод-контейнере (или через docker exec orchestrator): +md5sum "/app/$FILE" + +# содержимое того же файла из origin/main (на хосте, в клоне репо): +git -C /home/slin/repos/$REPO fetch origin main -q +git -C /home/slin/repos/$REPO show "origin/main:$FILE" | md5sum +``` + +* **Совпало:** прод соответствует `main` (фантома нет ИЛИ задача не меняла этот файл — + возьмите файл из проверки 3/diff'а ветки). +* **Разошлось:** прод собран из ветки, а `main` его не получил → косвенный признак фантома. + +--- + +## Проверка 3 — `git merge-base` ветки vs `main` + +Главный детерминированный критерий: является ли HEAD ветки предком `origin/main`. + +```bash +git -C /home/slin/repos/$REPO fetch origin -q +SHA=$(git -C /home/slin/repos/$REPO rev-parse "origin/$BRANCH") +git -C /home/slin/repos/$REPO merge-base --is-ancestor "$SHA" origin/main \ + && echo "MERGED: ветка влита в main" \ + || echo "NOT MERGED: ветка НЕ предок origin/main (ФАНТОМ)" +``` + +Это ровно та проверка, что выполняет `merge_gate.verify_merged_to_main` (rc=0 → влито). + +* **`MERGED`:** фантома нет. +* **`NOT MERGED`:** фантом подтверждён — `main` не содержит коммитов задачи. + +--- + +## Проверка 4 — таймлайн деплой-логов + +Восстанавливает порядок событий: был ли merge до/после деплоя, и был ли он вообще. + +```bash +# Вердикт деплоя + новое поле merge-верификации (ORCH-071): +git -C /home/slin/repos/$REPO show "origin/$BRANCH:docs/work-items//14-deploy-log.md" \ + | sed -n '1,12p' # frontmatter: deploy_status:, merged_to_main: + +# Наблюдаемость под-гейта в живом сервисе: +curl -s "$GITEA_HEALTH/queue" | python3 -c \ + 'import sys,json; print(json.load(sys.stdin)["merge_verify"])' +# -> {"enabled":..., "merge_verified_total":..., "not_merged_alerts_total":..., "last_alert_wi":...} + +# Журнал хоста по деплою (sentinel-каталог задачи): +ls -la /home/slin/repos/.deploy-state-$REPO// +cat /home/slin/repos/.deploy-state-$REPO//hook.log +``` + +* `deploy_status: SUCCESS` + `merged_to_main: false` → деплой прошёл, merge — нет + (это и есть класс ORCH-071; задача должна быть удержана на `deploy`, не `done`). +* `not_merged_alerts_total` растёт / `last_alert_wi == ` → под-гейт уже поднял alert. + +--- + +## Критерий «фантом подтверждён» + +Фантомный merge считается **подтверждённым**, если выполняется ХОТЯ БЫ ОДНО из: + +1. Проверка 1: PR ветки `state=open` / `merged=False` (или PR нет), а задача в `done`. +2. Проверка 3: `merge-base --is-ancestor` вернул **NOT MERGED** (HEAD ветки не предок `origin/main`). +3. Проверка 4: `14-deploy-log.md` имеет `deploy_status: SUCCESS` при `merged_to_main: false`. + +Проверка 2 — вспомогательная (зависит от того, менял ли файл задачей), используется +для подтверждения проверок 1/3. + +### Что делать при подтверждённом фантоме + +1. **Влить PR вручную** через Gitea (PR-merge API / UI) — НИКОГДА не `git push`/`--force` в `main` (INV-4). +2. Повторить approve задачи (re-drive) — под-гейт переоценит: merge подтвердится → задача уйдёт в `done`. +3. Если фантом случился при выключенном kill-switch — включить `ORCH_MERGE_VERIFY_ENABLED=true`. diff --git a/src/config.py b/src/config.py index 39f3d31..b9ad1e3 100644 --- a/src/config.py +++ b/src/config.py @@ -374,6 +374,27 @@ class Settings(BaseSettings): reaper_finalize_grace_s: int = 300 lease_reclaim_enabled: bool = True + # ORCH-071: merge-verify under-gate on the `deploy -> done` edge. For the + # self-hosting repo the `deploy` stage runs the DETERMINISTIC self-deploy path + # (Phase A/B/C), where the LLM `deployer` agent — historically the ONLY actor + # that merged the feature PR into `main` — never runs. Result: a "green" deploy + # could reach `done` while the PR stayed `open` (phantom merge, postmortem + # LESSONS_2026-06-08). This under-gate (врезка in advance_stage, NOT a new + # STAGE_TRANSITIONS edge or registered QG) runs a deterministic merge-actor + + # post-deploy verification before `done`: not-merged -> alert + HOLD (no done), + # merged -> normal advance. Mirrors merge_gate_* / image_freshness_* rollout. + # merge_verify_enabled -> global kill-switch; False -> strictly the prior + # behaviour (no merge/verify), env ORCH_MERGE_VERIFY_ENABLED. + # merge_verify_repos -> CSV of repos where the under-gate is REAL; empty -> + # only the self-hosting repo (orchestrator). Mirrors + # merge_gate_repos / self_deploy_repos. + # merge_pr_timeout_s -> per Gitea merge/list HTTP call timeout. + # merge_verify_timeout_s-> git fetch/merge-base timeout for the ancestor check. + merge_verify_enabled: bool = True + merge_verify_repos: str = "" + merge_pr_timeout_s: int = 60 + merge_verify_timeout_s: int = 60 + # Telegram notifications telegram_bot_token: str = "" telegram_chat_id: str = "" diff --git a/src/main.py b/src/main.py index b610cb3..cc23797 100644 --- a/src/main.py +++ b/src/main.py @@ -147,6 +147,7 @@ async def queue(): from .reconciler import reconciler from .job_reaper import reaper from . import post_deploy + from . import merge_gate return { "counts": job_status_counts(), "max_concurrency": worker.max_concurrency, @@ -155,5 +156,6 @@ async def queue(): "reconcile": reconciler.status(), "reaper": reaper.status(), "post_deploy": post_deploy.status(), + "merge_verify": merge_gate.merge_verify_status(), "recent": recent_jobs(10), } diff --git a/src/merge_gate.py b/src/merge_gate.py index dd14251..6b3eb7a 100644 --- a/src/merge_gate.py +++ b/src/merge_gate.py @@ -485,3 +485,193 @@ def pr_already_merged(repo: str, branch: str) -> bool: except Exception as e: # noqa: BLE001 - never-raise contract logger.warning("pr_already_merged check failed for %s/%s: %s", repo, branch, e) return False + + +# --------------------------------------------------------------------------- +# ORCH-071: deterministic merge-actor + post-deploy merge verification. +# +# For the self-hosting repo the `deploy` stage runs the deterministic self-deploy +# path (Phase A/B/C) and the LLM `deployer` agent — historically the ONLY actor +# that merged the feature PR into `main` — never runs. These two helpers close the +# "phantom merge" gap (LESSONS_2026-06-08): a deterministic actor merges the PR via +# the Gitea PR-merge API (NEVER a push/force-push to main, INV-4) and a verifier +# confirms `main` actually received the commit before the pipeline reaches `done`. +# Both wire into the `deploy -> done` under-gate (stage_engine._handle_merge_verify). +# --------------------------------------------------------------------------- + +# Lightweight in-process observability counters (D8). Reset only on process start; +# surfaced read-only via `merge_verify_status()` in GET /queue. Never the source of +# truth for any decision — purely informational. +_MERGE_VERIFY_COUNTERS: dict = { + "merge_verified_total": 0, + "not_merged_alerts_total": 0, + "last_alert_wi": None, +} + + +def note_merge_verified() -> None: + """Bump the 'merge verified -> done' counter (observability only). Never raises.""" + try: + _MERGE_VERIFY_COUNTERS["merge_verified_total"] += 1 + except Exception: # noqa: BLE001 - observability must never break a decision + pass + + +def note_not_merged_alert(work_item_id: str | None) -> None: + """Bump the 'deploy succeeded but not merged' counter. Never raises.""" + try: + _MERGE_VERIFY_COUNTERS["not_merged_alerts_total"] += 1 + _MERGE_VERIFY_COUNTERS["last_alert_wi"] = work_item_id + except Exception: # noqa: BLE001 - observability must never break a decision + pass + + +def merge_verify_status() -> dict: + """Snapshot of the merge-verify under-gate for GET /queue. Never raises.""" + try: + return { + "enabled": bool(settings.merge_verify_enabled), + "repos": settings.merge_verify_repos or "", + "merge_verified_total": _MERGE_VERIFY_COUNTERS["merge_verified_total"], + "not_merged_alerts_total": _MERGE_VERIFY_COUNTERS["not_merged_alerts_total"], + "last_alert_wi": _MERGE_VERIFY_COUNTERS["last_alert_wi"], + } + except Exception as e: # noqa: BLE001 - never-raise contract + logger.warning("merge_verify_status error: %s", e) + return {"enabled": False} + + +def merge_verify_applies(repo: str) -> bool: + """Whether the ORCH-071 merge-verify under-gate is REAL for this repo. + + Mirrors ``self_deploy_applies`` / ``image_freshness_applies`` (FR-5 / AC-10): + * ``merge_verify_enabled=False`` -> always False (global kill-switch -> the + pipeline behaves exactly as before ORCH-071 for everyone). + * ``merge_verify_repos`` (CSV) non-empty -> real only for listed repos. + * empty CSV -> real ONLY for the self-hosting repo (``orchestrator``); other + repos keep the LLM-``deployer`` merge path unchanged (AC-4b). + Never raises (any error -> False = no-op, the safe default). + """ + try: + if not settings.merge_verify_enabled: + return False + raw = (settings.merge_verify_repos or "").strip() + if raw: + allowed = {r.strip().lower() for r in raw.split(",") if r.strip()} + return (repo or "").strip().lower() in allowed + # Lazy import keeps this a leaf-ish module (qg.checks imports merge_gate lazily). + from .qg.checks import is_self_hosting_repo + return is_self_hosting_repo(repo) + except Exception as e: # noqa: BLE001 - never-raise contract + logger.warning("merge_verify_applies error for %s: %s", repo, e) + return False + + +def merge_pr(repo: str, branch: str) -> tuple[bool, str]: + """Deterministically merge the open PR for ``branch`` via the Gitea PR-merge API. + + The self-hosting deterministic merge-actor (FR-1 / D3). NEVER pushes or + force-pushes ``main`` (INV-4/AC-8) — the ONLY mutation is the Gitea + ``POST /pulls/{index}/merge`` call, exactly what the LLM ``deployer`` used to do + on non-self repos. + + Algorithm: + 1. ``pr_already_merged`` -> True -> no-op ``(True, "already-merged")`` (INV-5/AC-9). + 2. ``GET /repos/{owner}/{repo}/pulls?state=open`` -> the open PR whose head ref + == ``branch`` -> its index. No open PR -> ``(False, "no open PR")``. + 3. ``POST /repos/{owner}/{repo}/pulls/{index}/merge`` (Do: ``merge``) -> + 200/201 -> ``(True, "merged PR #")``; otherwise ``(False, "")``. + + Never-raise (INV-1/AC-9 / TC-09): any HTTP/parse error -> ``(False, reason)``. + """ + try: + if pr_already_merged(repo, branch): + logger.info("merge_pr: %s/%s already merged -> no-op", repo, branch) + return True, "already-merged" + + 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 + + resp = httpx.get( + f"{base}/pulls", params={"state": "open"}, headers=headers, timeout=timeout + ) + if resp.status_code != 200: + return False, f"list PRs failed: HTTP {resp.status_code}" + index = None + for pr in resp.json() or []: + if pr.get("head", {}).get("ref") == branch: + index = pr.get("number") + break + if index is None: + return False, "no open PR" + + m = httpx.post( + f"{base}/pulls/{index}/merge", + json={"Do": "merge"}, + headers=headers, + timeout=timeout, + ) + if m.status_code in (200, 201): + logger.info("merge_pr: merged PR #%s for %s/%s", index, repo, branch) + return True, f"merged PR #{index}" + detail = (m.text or "").strip()[:200] + logger.warning( + "merge_pr: merge failed for %s/%s PR #%s: HTTP %s %s", + repo, branch, index, m.status_code, detail, + ) + return False, f"merge failed: HTTP {m.status_code}" + except Exception as e: # noqa: BLE001 - never-raise contract + logger.warning("merge_pr unexpected error for %s/%s: %s", repo, branch, e) + return False, f"merge error: {e}" + + +def verify_merged_to_main(repo: str, branch: str, sha: str) -> bool: + """Return True iff the deployed commit is confirmed merged into ``origin/main``. + + Post-deploy verification (FR-2 / D4): the merge is confirmed when EITHER + * ``pr_already_merged(repo, branch)`` is True (Gitea ``PR.merged == true``), OR + * ``git merge-base --is-ancestor origin/main`` succeeds in the per-branch + worktree (after ``git fetch origin main``), i.e. the validated SHA is an + ancestor of the current ``origin/main``. + + ``sha`` is the validated commit (``image_freshness.validated_revision`` = + worktree ``git rev-parse HEAD``). An empty ``sha`` makes the git branch + inconclusive (only the PR-merged branch can then confirm). + + Never-raise (INV-1/AC-7 / TC-04): any git/HTTP error -> ``False`` (= "not + confirmed" -> fail-closed for ``done``: alert + HOLD). The exception is NEVER + propagated into ``advance_stage``. + """ + try: + if pr_already_merged(repo, branch): + return True + if not sha: + logger.warning( + "verify_merged_to_main: empty SHA for %s/%s and PR not known-merged", + repo, branch, + ) + return False + try: + wt = ensure_worktree(repo, branch) + except Exception as e: # noqa: BLE001 - never-raise contract + logger.warning( + "verify_merged_to_main: worktree error for %s/%s: %s", repo, branch, e + ) + return False + subprocess.run( + ["git", "-C", wt, "fetch", "origin", "main"], + capture_output=True, timeout=settings.merge_verify_timeout_s, + ) + r = subprocess.run( + ["git", "-C", wt, "merge-base", "--is-ancestor", sha, "origin/main"], + capture_output=True, timeout=settings.merge_verify_timeout_s, + ) + return r.returncode == 0 + except Exception as e: # noqa: BLE001 - never-raise contract + logger.warning( + "verify_merged_to_main unexpected error for %s/%s: %s", repo, branch, e + ) + return False diff --git a/src/self_deploy.py b/src/self_deploy.py index 17a14a7..853268d 100644 --- a/src/self_deploy.py +++ b/src/self_deploy.py @@ -349,3 +349,66 @@ def write_deploy_log(repo: str, work_item_id: str, branch: str, exit_code, statu except (subprocess.SubprocessError, OSError) as e: logger.warning("write_deploy_log: git commit/push best-effort failed: %s", e) return True + + +def record_merged_to_main(repo: str, work_item_id: str, branch: str, merged: bool) -> bool: + """Stamp ``merged_to_main: true|false`` into 14-deploy-log.md frontmatter (ORCH-071). + + Machine-readable observability for the merge-verify under-gate. ONLY the + ``merged_to_main:`` line is added/updated inside the YAML frontmatter block; the + ``deploy_status:`` field is left untouched, so the ``check_deploy_status`` / + ``_parse_deploy_status`` parsing contract is unchanged (TRZ §6 / AC §5). + + Best-effort and idempotent: a missing log or any I/O error is logged and + swallowed. Never raises. + """ + from .git_worktree import get_worktree_path + + rel = f"docs/work-items/{work_item_id}/14-deploy-log.md" + try: + wt = get_worktree_path(repo, branch) + except Exception as e: # noqa: BLE001 - never-raise + logger.warning("record_merged_to_main: worktree error for %s/%s: %s", repo, branch, e) + return False + path = os.path.join(wt, rel) + try: + with open(path, "r", encoding="utf-8") as f: + content = f.read() + except FileNotFoundError: + logger.info("record_merged_to_main: no deploy log at %s (skip)", path) + return False + except OSError as e: + logger.warning("record_merged_to_main: read error at %s: %s", path, e) + return False + + value = "true" if merged else "false" + if not content.startswith("---"): + # No frontmatter to amend — do not fabricate one (keep the contract minimal). + logger.info("record_merged_to_main: no frontmatter in %s (skip)", path) + return False + parts = content.split("---", 2) + if len(parts) < 3: + return False + fm_lines = parts[1].splitlines() + new_lines = [] + replaced = False + for ln in fm_lines: + if ln.strip().lower().startswith("merged_to_main:"): + new_lines.append(f"merged_to_main: {value}") + replaced = True + else: + new_lines.append(ln) + if not replaced: + # Insert before the closing of the frontmatter block (append to the body). + if new_lines and new_lines[0] == "": + new_lines = new_lines[1:] + new_lines.append(f"merged_to_main: {value}") + new_fm = "\n".join(new_lines) + new_content = "---\n" + new_fm.strip("\n") + "\n---" + parts[2] + try: + with open(path, "w", encoding="utf-8") as f: + f.write(new_content) + except OSError as e: + logger.warning("record_merged_to_main: write error at %s: %s", path, e) + return False + return True diff --git a/src/stage_engine.py b/src/stage_engine.py index 36de7a7..94e207b 100644 --- a/src/stage_engine.py +++ b/src/stage_engine.py @@ -346,6 +346,22 @@ def advance_stage( ) return result + # --- ORCH-071 merge-verify under-gate (deploy -> done edge) ---------- + # The SINGLE choke-point that gates EVERY path into terminal `done` + # (finalizer Phase C, reconciler F-1, job-reaper re-drive) on a CONFIRMED + # merge of the feature PR into `main`. For the self-hosting repo the + # deterministic self-deploy path never runs the LLM `deployer` that used to + # merge the PR, so a green deploy could reach `done` while the PR stayed + # `open` (phantom merge, ORCH-071). This врезка runs a deterministic + # merge-actor + post-deploy verification BEFORE update_task_stage; if the + # merge is not confirmed it HOLDs (alert, NO done, NO rollback) and returns + # without advancing. Not a STAGE_TRANSITIONS edge / registered QG — it is an + # edge sub-gate (mirrors the merge-gate врезка), so those contracts are + # unchanged. No-op for non-self repos / kill-switch off (1:1 prior behaviour). + if current_stage == "deploy" and next_stage == "done": + if _handle_merge_verify(task_id, repo, work_item_id, branch, result): + return result + # --- Advance --------------------------------------------------------- update_task_stage(task_id, next_stage) # Telegram live tracker: the analysis->architecture advance is the human @@ -1260,6 +1276,106 @@ def _deploy_finalize_defer_count(task_id: int) -> int: return n +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. + + Returns: + * ``True`` -> INTERVENED (HOLD): the merge is NOT confirmed -> alert + + ``set_issue_blocked`` (Plane non-terminal), task stays on `deploy`, NO + ``done``, NO rollback to development (not-merged is an INFRA defect, not a + code fault -> ALERT-only, FR-3). The caller returns without advancing. A + later re-drive (reaper / reconciler / re-approve) re-evaluates and, once the + merge is fixed, lets the task advance to `done`. + * ``False`` -> the merge is CONFIRMED (or the under-gate does not apply for + this repo / kill-switch off) -> ``advance_stage`` proceeds to `done` + unchanged (happy-path AC-4 / AC-4b). + + Steps (D5): + 1. Conditionality (FR-5): not applicable -> return False (1:1 prior behaviour). + 2. Resolve the validated SHA; run the deterministic merge-actor + ``merge_gate.merge_pr`` (no-op if already merged, INV-5). + 3. ``merge_gate.verify_merged_to_main`` -> confirmed? + * yes -> stamp ``merged_to_main: true``, return False (advance). + * no -> alert + Blocked + stamp ``merged_to_main: false``, return True (HOLD). + + Wrapped never-raise (INV-1/AC-7): any internal error is treated as "not + confirmed" (HOLD + alert), never a propagated exception into ``advance_stage``. + """ + try: + if not merge_gate.merge_verify_applies(repo): + return False # non-self / kill-switch off -> behave exactly as before. + + from . import image_freshness + sha = image_freshness.validated_revision(repo, branch) + + # 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( + f"Task {task_id}: merge-verify merge_pr -> ok={merged_ok} ({merge_msg})" + ) + + confirmed = merge_gate.verify_merged_to_main(repo, branch, sha) + if confirmed: + merge_gate.note_merge_verified() + try: + self_deploy.record_merged_to_main(repo, work_item_id, branch, True) + except Exception as e: # noqa: BLE001 - observability best-effort + logger.warning(f"Task {task_id}: record merged_to_main(true) failed: {e}") + logger.info(f"Task {task_id}: merge-verify CONFIRMED -> deploy->done allowed") + return False + + # Not confirmed -> alert + HOLD (no done, no rollback). + merge_gate.note_not_merged_alert(work_item_id) + try: + self_deploy.record_merged_to_main(repo, work_item_id, branch, False) + except Exception as e: # noqa: BLE001 - observability best-effort + logger.warning(f"Task {task_id}: record merged_to_main(false) failed: {e}") + msg = ( + f"deploy succeeded but not merged: {work_item_id} (repo={repo}, " + f"branch={branch}). `main` НЕ получил commit задачи — задача удержана " + f"на `deploy` (НЕ done). Нужно ручное вмешательство." + ) + 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 Deploy прошёл, но PR НЕ влит в `main` " + f"(merge: {merge_msg}). Задача удержана на `deploy` (НЕ done). " + "Нужно влить PR вручную и повторить approve.", + author="deployer", + ) + except Exception as e: # noqa: BLE001 - never break the HOLD + logger.warning(f"Task {task_id}: plane not-merged 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}: not-merged telegram failed: {e}") + result.alerted = True + result.note = "merge-not-verified-hold" + result.advanced = False + return True + except Exception as e: # noqa: BLE001 - never-raise contract (INV-1/AC-7) + # Any internal error -> treat as "not confirmed" -> HOLD + alert, never crash. + logger.error(f"Task {task_id}: _handle_merge_verify error: {e}") + try: + merge_gate.note_not_merged_alert(work_item_id) + send_telegram( + f"\U0001f6a8 {work_item_id}: ошибка merge-verify ({e}). " + f"Задача удержана на `deploy` (НЕ done)." + ) + except Exception: # noqa: BLE001 - best-effort alert + pass + result.alerted = True + result.note = f"merge-verify-error: {e}" + result.advanced = False + return True + + def run_deploy_finalizer(job: dict): """Phase C — deterministic finalizer (reserved-agent `deploy-finalizer`, no LLM). diff --git a/tests/conftest.py b/tests/conftest.py index 58be4cb..70cd388 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -75,3 +75,23 @@ def _reset_webhook_secrets(monkeypatch): if db_path_env: monkeypatch.setattr(db_mod.settings, "db_path", db_path_env, raising=False) yield + + +@pytest.fixture(autouse=True) +def _disable_merge_verify(monkeypatch): + """ORCH-071: disable the merge-verify under-gate by default in ALL tests. + + The under-gate (deploy -> done) runs a deterministic merge-actor + a + post-deploy merge verification that make REAL Gitea/git calls. Leaving it ON + by default would (a) reach the network from unrelated deploy->done tests and + (b) make them pass/fail by ACCIDENT depending on whether the live Gitea still + has the historical PR merged (a hidden CI flake). We therefore default it to + its documented kill-switch OFF state (``merge_verify_enabled=False`` == 1:1 + pre-ORCH-071 behaviour). Tests that specifically target the under-gate + (test_merge_verify / test_deploy_finalizer_merge_gate / test_merge_actor / + test_deploy_restart_merge_recovery) re-enable it via their own monkeypatch + AFTER this autouse fixture, scoping the feature ON to just those tests. + """ + from src import config as _cfg + monkeypatch.setattr(_cfg.settings, "merge_verify_enabled", False, raising=False) + yield diff --git a/tests/test_deploy_finalizer_merge_gate.py b/tests/test_deploy_finalizer_merge_gate.py new file mode 100644 index 0000000..869a40a --- /dev/null +++ b/tests/test_deploy_finalizer_merge_gate.py @@ -0,0 +1,188 @@ +"""ORCH-071 — Phase C finalizer x merge-verify under-gate (integration). + +Covers TC-05 (FR-3/G2/AC-1: deploy SUCCESS but PR open -> NOT done + alert), +TC-06 (AC-4: deploy SUCCESS + merge confirmed -> done) and TC-14 (AC-11: Phase B +runs only on confirm_deploy; merge/verify never introduce an auto-deploy). + +Mirrors tests/test_deploy_terminal_sync.py: the finalizer drives advance_stage, +the deploy gate is forced green, and the merge-actor/verifier are mocked so the +test stays deterministic (no real Gitea/git). +""" + +import os +import tempfile + +import pytest + +_test_db = os.path.join(tempfile.gettempdir(), "test_orch_merge_verify.db") +os.environ["ORCH_DB_PATH"] = _test_db +os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir() +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") + +from unittest.mock import MagicMock # noqa: E402 + +import src.db as _db # noqa: E402 +from src.db import init_db, get_db # noqa: E402 +from src import stage_engine # noqa: E402 +from src import self_deploy # noqa: E402 + + +@pytest.fixture(autouse=True) +def fresh_db(monkeypatch, tmp_path): + monkeypatch.setattr(_db.settings, "db_path", _test_db) + if os.path.exists(_test_db): + os.unlink(_test_db) + init_db() + monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path)) + monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path)) + monkeypatch.setattr(stage_engine.self_deploy, "write_deploy_log", MagicMock(return_value=True)) + # The under-gate is disabled by conftest default; these tests target it. + monkeypatch.setattr(stage_engine.merge_gate.settings, "merge_verify_enabled", True) + monkeypatch.setattr(stage_engine.merge_gate.settings, "merge_verify_repos", "") + # The merged_to_main stamp is an observability side effect (no log file here). + monkeypatch.setattr( + stage_engine.self_deploy, "record_merged_to_main", MagicMock(return_value=True) + ) + # ORCH-021 post-deploy monitor is orthogonal; keep it off for these tests. + monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_monitor_enabled", False) + yield + + +@pytest.fixture(autouse=True) +def silence_side_effects(monkeypatch): + for name in ( + "notify_stage_change", "notify_qg_failure", "notify_approve_requested", + "send_telegram", "plane_notify_stage", "plane_notify_qg", "plane_add_comment", + "set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress", + "set_issue_blocked", "set_issue_done", "set_issue_analysis", + "set_issue_awaiting_deploy", "set_issue_deploying", "set_issue_monitoring", + ): + monkeypatch.setattr(stage_engine, name, MagicMock()) + monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", MagicMock()) + + +def _pass(*a, **k): + return (True, "ok") + + +def _make_task(stage, repo="orchestrator", branch="feature/ORCH-071-x", wi="ORCH-071"): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) VALUES (?, ?, ?, ?, ?)", + (f"plane-{wi}", wi, repo, branch, stage), + ) + task_id = cur.lastrowid + conn.commit() + conn.close() + return task_id + + +def _stage(task_id): + conn = get_db() + row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone() + conn.close() + return row[0] + + +def _force_deploy_gate_green(monkeypatch): + monkeypatch.setattr( + stage_engine, "QG_CHECKS", + {**stage_engine.QG_CHECKS, "check_deploy_status": _pass}, + ) + + +# --------------------------------------------------------------------------- +# TC-05 (AC-1): deploy_status=SUCCESS but PR open -> task is HELD (not done) + alert. +# --------------------------------------------------------------------------- +def test_tc05_success_but_not_merged_holds_and_alerts(monkeypatch): + self_deploy.write_marker("orchestrator", "ORCH-071", self_deploy.RESULT, "0") + _force_deploy_gate_green(monkeypatch) + # The merge-actor finds no merge and the verifier confirms NOT merged. + monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", MagicMock(return_value=(False, "no open PR"))) + monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", MagicMock(return_value=False)) + + task_id = _make_task("deploy") + stage_engine.run_deploy_finalizer( + {"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "deploy-finalizer"} + ) + + # AC-1 PASS: the task did NOT reach done and was Blocked for manual handling. + assert _stage(task_id) == "deploy" + assert stage_engine.set_issue_blocked.called + assert not stage_engine.set_issue_done.called + assert not stage_engine.set_issue_monitoring.called + # An alert was sent ("deploy succeeded but not merged"). + assert stage_engine.send_telegram.called + + +# --------------------------------------------------------------------------- +# TC-06 (AC-4): deploy_status=SUCCESS + merge confirmed -> done (happy-path). +# --------------------------------------------------------------------------- +def test_tc06_success_and_merged_reaches_done(monkeypatch): + self_deploy.write_marker("orchestrator", "ORCH-071", self_deploy.RESULT, "0") + _force_deploy_gate_green(monkeypatch) + merge_pr = MagicMock(return_value=(True, "merged PR #1")) + verify = MagicMock(return_value=True) + monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", merge_pr) + monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", verify) + + task_id = _make_task("deploy") + stage_engine.run_deploy_finalizer( + {"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "deploy-finalizer"} + ) + + assert _stage(task_id) == "done" + # The deterministic merge-actor + verifier both ran on the deploy->done edge. + assert merge_pr.called + assert verify.called + # Self-hosting: terminal status -> Monitoring (post_deploy off here -> Done set). + assert not stage_engine.set_issue_blocked.called + + +# --------------------------------------------------------------------------- +# TC-14 (AC-11): a plain Approved on `deploy` (confirm_deploy=False) is a no-op — +# Phase B (prod deploy) requires "Confirm Deploy", and merge/verify do NOT run +# (the under-gate never introduces an auto-deploy). +# --------------------------------------------------------------------------- +def test_tc14_plain_approved_on_deploy_is_noop_no_merge(monkeypatch): + monkeypatch.setattr(stage_engine.self_deploy.settings, "self_deploy_enabled", True) + monkeypatch.setattr(stage_engine.self_deploy.settings, "self_deploy_repos", "") + monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True) + + merge_pr = MagicMock() + verify = MagicMock() + initiate = MagicMock(return_value=(True, "ok")) + monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", merge_pr) + monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", verify) + monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate) + + task_id = _make_task("deploy") + # finished_agent=None + confirm_deploy=False == a plain Approved on `deploy`. + result = stage_engine.advance_stage( + task_id, "deploy", "orchestrator", "ORCH-071", "feature/ORCH-071-x", + finished_agent=None, confirm_deploy=False, + ) + + assert result.note == "approved-on-deploy-noop" + assert _stage(task_id) == "deploy" + # No prod deploy initiated and the merge-verify under-gate never fired. + assert not initiate.called + assert not merge_pr.called + assert not verify.called + + +def test_tc14_confirm_deploy_initiates_phase_b(monkeypatch): + monkeypatch.setattr(stage_engine.self_deploy.settings, "self_deploy_enabled", True) + monkeypatch.setattr(stage_engine.self_deploy.settings, "self_deploy_repos", "") + monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True) + initiate = MagicMock(return_value=(True, "ok")) + monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate) + + task_id = _make_task("deploy") + stage_engine.advance_stage( + task_id, "deploy", "orchestrator", "ORCH-071", "feature/ORCH-071-x", + finished_agent=None, confirm_deploy=True, + ) + # Only the dedicated "Confirm Deploy" signal initiates the prod deploy. + assert initiate.called diff --git a/tests/test_deploy_restart_merge_recovery.py b/tests/test_deploy_restart_merge_recovery.py new file mode 100644 index 0000000..0f8d9ae --- /dev/null +++ b/tests/test_deploy_restart_merge_recovery.py @@ -0,0 +1,116 @@ +"""ORCH-071 TC-10 (AC-3/G3) — merge survives a restart during Phase B (smoke). + +Scenario: the prod container "dies" during Phase B BEFORE the feature PR is merged +(the holder of the merge step is gone). Because the merge runs in the +restart-surviving Phase C finalizer (deploy->done under-gate), a re-drive of the +finalizer in the NEW container catches the merge up: it merges the PR, the verifier +turns green and the task finally reaches ``done`` — never stuck without an alert and +never ``done`` without a confirmed merge. + +The first finalizer pass models "died before merge": the merge-actor cannot complete +and the verifier is red -> HOLD + alert (task stays on ``deploy``). The second pass +models the re-drive after the restart: the merge lands, verify is green -> ``done``. +""" + +import os +import tempfile + +import pytest + +_test_db = os.path.join(tempfile.gettempdir(), "test_orch_merge_recovery.db") +os.environ["ORCH_DB_PATH"] = _test_db +os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir() +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") + +from unittest.mock import MagicMock # noqa: E402 + +import src.db as _db # noqa: E402 +from src.db import init_db, get_db # noqa: E402 +from src import stage_engine # noqa: E402 +from src import self_deploy # noqa: E402 + + +@pytest.fixture(autouse=True) +def fresh_db(monkeypatch, tmp_path): + monkeypatch.setattr(_db.settings, "db_path", _test_db) + if os.path.exists(_test_db): + os.unlink(_test_db) + init_db() + monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path)) + monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path)) + monkeypatch.setattr(stage_engine.self_deploy, "write_deploy_log", MagicMock(return_value=True)) + monkeypatch.setattr(stage_engine.merge_gate.settings, "merge_verify_enabled", True) + monkeypatch.setattr(stage_engine.merge_gate.settings, "merge_verify_repos", "") + monkeypatch.setattr( + stage_engine.self_deploy, "record_merged_to_main", MagicMock(return_value=True) + ) + monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_monitor_enabled", False) + yield + + +@pytest.fixture(autouse=True) +def silence_side_effects(monkeypatch): + for name in ( + "notify_stage_change", "notify_qg_failure", "notify_approve_requested", + "send_telegram", "plane_notify_stage", "plane_notify_qg", "plane_add_comment", + "set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress", + "set_issue_blocked", "set_issue_done", "set_issue_analysis", + "set_issue_awaiting_deploy", "set_issue_deploying", "set_issue_monitoring", + ): + monkeypatch.setattr(stage_engine, name, MagicMock()) + monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", MagicMock()) + + +def _stage(task_id): + conn = get_db() + row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone() + conn.close() + return row[0] + + +def test_tc10_merge_recovers_after_restart(monkeypatch): + self_deploy.write_marker("orchestrator", "ORCH-071", self_deploy.RESULT, "0") + monkeypatch.setattr( + stage_engine, "QG_CHECKS", + {**stage_engine.QG_CHECKS, "check_deploy_status": lambda *a, **k: (True, "ok")}, + ) + + # Stateful merge: the FIRST attempt (pre-restart) cannot complete; the SECOND + # (the re-driven finalizer after the restart) merges and the verifier goes green. + state = {"attempts": 0, "merged": False} + + def fake_merge_pr(repo, branch): + state["attempts"] += 1 + if state["attempts"] == 1: + return (False, "interrupted by restart") + state["merged"] = True + return (True, "merged PR #1") + + def fake_verify(repo, branch, sha): + return state["merged"] + + monkeypatch.setattr(stage_engine.merge_gate, "merge_pr", fake_merge_pr) + monkeypatch.setattr(stage_engine.merge_gate, "verify_merged_to_main", fake_verify) + + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) VALUES (?, ?, ?, ?, ?)", + ("plane-ORCH-071", "ORCH-071", "orchestrator", "feature/ORCH-071-x", "deploy"), + ) + task_id = cur.lastrowid + conn.commit() + conn.close() + + job = {"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "deploy-finalizer"} + + # Pass 1 (process died before merge): HOLD — not done, alerted, Blocked. + stage_engine.run_deploy_finalizer(job) + assert _stage(task_id) == "deploy" + assert stage_engine.set_issue_blocked.called + assert not stage_engine.set_issue_done.called + + # Pass 2 (finalizer re-driven after restart): merge lands, verify green -> done. + stage_engine.run_deploy_finalizer(job) + assert _stage(task_id) == "done" + assert state["merged"] is True diff --git a/tests/test_merge_actor.py b/tests/test_merge_actor.py new file mode 100644 index 0000000..5065d35 --- /dev/null +++ b/tests/test_merge_actor.py @@ -0,0 +1,135 @@ +"""ORCH-071 — deterministic merge-actor (merge_gate.merge_pr). + +Covers TC-07 (FR-1: merge via Gitea PR-merge API, no push/force-push), TC-08 +(AC-9: idempotency — already-merged -> no-op), TC-09 (AC-7: never-raise) and TC-13 +(AC-8/INV-2: self-hosting safety — no prod restart, no direct/force push to main). +Gitea HTTP is mocked; the actor must NEVER shell out to git/docker/ssh. +""" + +import httpx +import pytest + +from src import merge_gate + + +class _Resp: + 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_verify_enabled", True) + monkeypatch.setattr(merge_gate.settings, "gitea_url", "http://gitea.test") + monkeypatch.setattr(merge_gate.settings, "gitea_owner", "admin") + monkeypatch.setattr(merge_gate.settings, "gitea_token", "tok") + monkeypatch.setattr(merge_gate.settings, "merge_pr_timeout_s", 5) + + +# --------------------------------------------------------------------------- +# TC-07: an OPEN PR -> the actor calls Gitea POST /pulls/{index}/merge (Do: merge). +# --------------------------------------------------------------------------- +def test_tc07_merge_actor_calls_gitea_merge(monkeypatch): + monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False) + + branch = "feature/ORCH-071-x" + get_calls, post_calls = [], [] + + def fake_get(url, params=None, headers=None, timeout=None): + get_calls.append((url, params)) + return _Resp(200, [{"head": {"ref": branch}, "number": 7}]) + + def fake_post(url, json=None, headers=None, timeout=None): + post_calls.append((url, json)) + return _Resp(200) + + monkeypatch.setattr(httpx, "get", fake_get) + monkeypatch.setattr(httpx, "post", fake_post) + + ok, msg = merge_gate.merge_pr("orchestrator", branch) + assert ok is True + assert "PR #7" in msg + # POST hit the PR-merge API endpoint with Do=merge. + assert len(post_calls) == 1 + url, body = post_calls[0] + assert url.endswith("/repos/admin/orchestrator/pulls/7/merge") + assert body == {"Do": "merge"} + + +# --------------------------------------------------------------------------- +# TC-08 (AC-9): already-merged PR -> no-op (no second merge, no Gitea error). +# --------------------------------------------------------------------------- +def test_tc08_idempotent_already_merged(monkeypatch): + monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: True) + + def must_not_call(*a, **k): + raise AssertionError("no Gitea call must be made when already merged") + + monkeypatch.setattr(httpx, "get", must_not_call) + monkeypatch.setattr(httpx, "post", must_not_call) + + ok, msg = merge_gate.merge_pr("orchestrator", "feature/ORCH-071-x") + assert ok is True + assert msg == "already-merged" + + +def test_tc08_no_open_pr_is_not_an_error(monkeypatch): + monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False) + monkeypatch.setattr(httpx, "get", lambda *a, **k: _Resp(200, [])) + ok, msg = merge_gate.merge_pr("orchestrator", "feature/ORCH-071-x") + assert ok is False + assert msg == "no open PR" + + +# --------------------------------------------------------------------------- +# TC-09 (AC-7): a Gitea HTTP error -> (False, reason), exception not propagated. +# --------------------------------------------------------------------------- +def test_tc09_never_raise_on_http_error(monkeypatch): + monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False) + + def boom(*a, **k): + raise httpx.ConnectError("gitea unreachable") + + monkeypatch.setattr(httpx, "get", boom) + ok, msg = merge_gate.merge_pr("orchestrator", "feature/ORCH-071-x") + assert ok is False + assert "merge error" in msg + + +def test_tc09_merge_endpoint_non_2xx_is_false(monkeypatch): + monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False) + monkeypatch.setattr( + httpx, "get", lambda *a, **k: _Resp(200, [{"head": {"ref": "feature/ORCH-071-x"}, "number": 3}]) + ) + monkeypatch.setattr(httpx, "post", lambda *a, **k: _Resp(409, text="conflict")) + ok, msg = merge_gate.merge_pr("orchestrator", "feature/ORCH-071-x") + assert ok is False + assert "HTTP 409" in msg + + +# --------------------------------------------------------------------------- +# TC-13 (AC-8/INV-2): the merge-actor NEVER shells out (no git push/force-push, +# no docker/ssh prod restart) — the only side effect is the Gitea PR-merge API. +# --------------------------------------------------------------------------- +def test_tc13_no_shell_out_no_force_push(monkeypatch): + monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False) + monkeypatch.setattr( + httpx, "get", lambda *a, **k: _Resp(200, [{"head": {"ref": "feature/ORCH-071-x"}, "number": 9}]) + ) + monkeypatch.setattr(httpx, "post", lambda *a, **k: _Resp(200)) + + subprocess_calls = [] + monkeypatch.setattr( + merge_gate.subprocess, "run", + lambda cmd, *a, **k: subprocess_calls.append(cmd), + ) + + ok, _ = merge_gate.merge_pr("orchestrator", "feature/ORCH-071-x") + assert ok is True + # No subprocess (git/docker/ssh) was invoked by the merge-actor at all. + assert subprocess_calls == [] diff --git a/tests/test_merge_verify.py b/tests/test_merge_verify.py new file mode 100644 index 0000000..1d9f9b4 --- /dev/null +++ b/tests/test_merge_verify.py @@ -0,0 +1,126 @@ +"""ORCH-071 — post-deploy merge verification + rollout conditionality. + +Covers TC-01..04 (FR-2/G1/AC-1/AC-7: verify_merged_to_main), TC-11 (AC-4b: non-self +repo no-op) and TC-12 (AC-10: kill-switch). All deterministic: git/HTTP are mocked, +the verifier honours the never-raise contract. +""" + +import pytest + +from src import merge_gate + + +class _R: + """Minimal stand-in for a completed subprocess result (returncode only).""" + + def __init__(self, rc): + self.returncode = rc + self.stdout = "" + self.stderr = "" + + +@pytest.fixture(autouse=True) +def _enable(monkeypatch): + # The conftest disables the under-gate by default; these tests target it, so + # re-enable the feature and pin the scope to self-hosting only (empty CSV). + monkeypatch.setattr(merge_gate.settings, "merge_verify_enabled", True) + monkeypatch.setattr(merge_gate.settings, "merge_verify_repos", "") + monkeypatch.setattr(merge_gate.settings, "merge_verify_timeout_s", 5) + + +# --------------------------------------------------------------------------- +# TC-01: validated SHA is an ancestor of origin/main (merge-base rc=0) -> True. +# --------------------------------------------------------------------------- +def test_tc01_verify_true_when_sha_is_ancestor(monkeypatch): + monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False) + monkeypatch.setattr(merge_gate, "ensure_worktree", lambda r, b: "/wt") + + calls = [] + + def fake_run(cmd, *a, **k): + calls.append(cmd) + # fetch -> rc 0; merge-base --is-ancestor -> rc 0 (is ancestor). + return _R(0) + + monkeypatch.setattr(merge_gate.subprocess, "run", fake_run) + assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "abc123") is True + # The verifier consulted git merge-base --is-ancestor on origin/main. + assert any("merge-base" in c and "--is-ancestor" in c and "origin/main" in c for c in calls) + + +# --------------------------------------------------------------------------- +# TC-02: PR.merged==true short-circuits to True even if git is unavailable. +# --------------------------------------------------------------------------- +def test_tc02_verify_true_when_pr_merged_even_without_git(monkeypatch): + monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: True) + + def boom(*a, **k): + raise RuntimeError("git must NOT be consulted when PR is already merged") + + monkeypatch.setattr(merge_gate, "ensure_worktree", boom) + monkeypatch.setattr(merge_gate.subprocess, "run", boom) + assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "") is True + + +# --------------------------------------------------------------------------- +# TC-03: not an ancestor (rc=1) AND PR not merged -> False (phantom merge). +# --------------------------------------------------------------------------- +def test_tc03_verify_false_when_phantom(monkeypatch): + monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False) + monkeypatch.setattr(merge_gate, "ensure_worktree", lambda r, b: "/wt") + + def fake_run(cmd, *a, **k): + if "merge-base" in cmd: + return _R(1) # NOT an ancestor. + return _R(0) # fetch ok. + + monkeypatch.setattr(merge_gate.subprocess, "run", fake_run) + assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "abc123") is False + + +# --------------------------------------------------------------------------- +# TC-04 (AC-7): never-raise — a git/OS error -> False, exception not propagated. +# --------------------------------------------------------------------------- +def test_tc04_verify_never_raises_on_git_error(monkeypatch): + monkeypatch.setattr(merge_gate, "pr_already_merged", lambda r, b: False) + monkeypatch.setattr(merge_gate, "ensure_worktree", lambda r, b: "/wt") + + def boom(*a, **k): + raise OSError("git exploded") + + monkeypatch.setattr(merge_gate.subprocess, "run", boom) + # No exception escapes; the conservative verdict is "not confirmed". + assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "abc123") is False + + +def test_tc04_verify_never_raises_on_http_error(monkeypatch): + def boom(r, b): + raise RuntimeError("gitea down") + + monkeypatch.setattr(merge_gate, "pr_already_merged", boom) + assert merge_gate.verify_merged_to_main("orchestrator", "feature/ORCH-071-x", "abc123") is False + + +# --------------------------------------------------------------------------- +# TC-11 (AC-4b): non-self repo -> under-gate is a no-op (merge stays with deployer). +# --------------------------------------------------------------------------- +def test_tc11_non_self_repo_does_not_apply(monkeypatch): + # Empty CSV -> only the self-hosting repo is in scope. + assert merge_gate.merge_verify_applies("orchestrator") is True + assert merge_gate.merge_verify_applies("enduro-trails") is False + + +def test_tc11_csv_scopes_to_listed_repos(monkeypatch): + monkeypatch.setattr(merge_gate.settings, "merge_verify_repos", "enduro-trails") + assert merge_gate.merge_verify_applies("enduro-trails") is True + # When the CSV is set, the self repo is NOT auto-included. + assert merge_gate.merge_verify_applies("orchestrator") is False + + +# --------------------------------------------------------------------------- +# TC-12 (AC-10): kill-switch off -> applies False for everyone (1:1 prior behaviour). +# --------------------------------------------------------------------------- +def test_tc12_kill_switch_disables_under_gate(monkeypatch): + monkeypatch.setattr(merge_gate.settings, "merge_verify_enabled", False) + assert merge_gate.merge_verify_applies("orchestrator") is False + assert merge_gate.merge_verify_applies("enduro-trails") is False diff --git a/tests/test_qg_checks.py b/tests/test_qg_checks.py index 2ab3ea2..a81e254 100644 --- a/tests/test_qg_checks.py +++ b/tests/test_qg_checks.py @@ -53,6 +53,29 @@ def test_tc15_finalizer_log_roundtrips_through_parser(): assert ok_f is False +# --------------------------------------------------------------------------- +# ORCH-071 TC-15: the deploy-status parsing contract is UNCHANGED by the new +# merge-verify under-gate. The ``merged_to_main:`` observability field the +# under-gate stamps into 14-deploy-log.md must NOT influence ``deploy_status:`` +# parsing — the gate keeps reading ONLY the ``deploy_status:`` frontmatter. +# --------------------------------------------------------------------------- +def test_tc15_merged_to_main_field_does_not_affect_deploy_status(): + ok_s, _ = _parse_deploy_status( + "---\ndeploy_status: SUCCESS\nmerged_to_main: false\n---\n\nbody" + ) + # deploy_status is the ONLY field read: SUCCESS stays SUCCESS regardless of + # the merged_to_main observability stamp (which the under-gate enforces + # separately, outside this parser). + assert ok_s is True + ok_f, _ = _parse_deploy_status( + "---\ndeploy_status: FAILED\nmerged_to_main: true\n---\n\nbody" + ) + assert ok_f is False + # merged_to_main alone (no deploy_status) is NOT a verdict. + ok_n, _ = _parse_deploy_status("---\nmerged_to_main: true\n---\n") + assert ok_n is False + + # --------------------------------------------------------------------------- # ORCH-061 / TC-04 + TC-05: infra-tolerant staging verdict (pure logic, AC-2/AC-3). # diff --git a/tests/test_stages.py b/tests/test_stages.py index 1ecaf7a..dba3340 100644 --- a/tests/test_stages.py +++ b/tests/test_stages.py @@ -39,3 +39,21 @@ def test_tc16_deploy_staging_transition_unchanged(): def test_tc16_done_is_terminal(): assert get_next_stage("done") is None + + +# --------------------------------------------------------------------------- +# ORCH-071 TC-16: the merge-verify under-gate is an EDGE sub-gate врезанный in +# advance_stage (like the merge-gate), NOT a new STAGE_TRANSITIONS edge and NOT a +# new registered QG. The state machine + QG registry must stay untouched. +# --------------------------------------------------------------------------- +def test_tc16_merge_verify_adds_no_stage_or_qg(): + # The deploy->done edge keeps its single gate (no second registered QG). + assert STAGE_TRANSITIONS["deploy"]["qg"] == "check_deploy_status" + # No new stage was introduced for merge verification. + assert "merge-verify" not in STAGE_TRANSITIONS + assert "merge_verify" not in STAGE_TRANSITIONS + + from src.qg.checks import QG_CHECKS + # The under-gate is NOT a registered quality-gate check. + assert "check_merged_to_main" not in QG_CHECKS + assert "check_merge_verify" not in QG_CHECKS From cc87beb2b43f90f8154fd37f8f60e94d9a713f43 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 8 Jun 2026 08:35:24 +0000 Subject: [PATCH 5/8] reviewer(ET): auto-commit from reviewer run_id=356 --- docs/work-items/ORCH-071/12-review.md | 53 +++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 docs/work-items/ORCH-071/12-review.md diff --git a/docs/work-items/ORCH-071/12-review.md b/docs/work-items/ORCH-071/12-review.md new file mode 100644 index 0000000..654e505 --- /dev/null +++ b/docs/work-items/ORCH-071/12-review.md @@ -0,0 +1,53 @@ +--- +type: review +work_item_id: ORCH-071 +verdict: REQUEST_CHANGES +version: 1 +--- + +# Review ORCH-071 + +## Summary +Фикс «фантомного merge» реализован архитектурно корректно и полно: детерминированный +merge-актор + пост-деплой верификация как под-гейт ребра `deploy → done`, врезка +`_handle_merge_verify` в единственную точку перехода `advance_stage` (гейтит все пути — +finalizer Phase C, reconciler F-1, job-reaper), merge выполняется в restart-surviving +Phase C (G3), kill-switch и условность раската по образцу ORCH-35/43/58. Все FR-1..FR-5 и +AC-1..AC-11 покрыты содержательными тестами; `pytest tests/ -q` зелёный (853 passed). +Код соответствует ADR-001 (D1–D9) и глобальному adr-0013, never-raise контракты соблюдены, +INV-4 (только PR-merge API, без push/force-push) выдержан. + +**Единственный блокер — документация:** `CHANGELOG.md` НЕ обновлён, хотя задача меняет +`src/`. По CLAUDE.md §6 и AC-5 это обязательно → REQUEST_CHANGES. + +## Findings + +### P0 — Blocker +- [ ] **`CHANGELOG.md` не обновлён при изменении `src/`.** Изменены `src/config.py`, + `src/main.py`, `src/merge_gate.py`, `src/self_deploy.py`, `src/stage_engine.py`, но в + `CHANGELOG.md` нет ни одной записи про ORCH-071 (`grep -c "ORCH-071" CHANGELOG.md` → 0, + `git diff main...HEAD -- CHANGELOG.md` пуст). Это прямое нарушение CLAUDE.md §2/§6 + («Документация = golden source… обнови `CHANGELOG.md`») и AC-5 (PASS требует обновлённый + CHANGELOG; FAIL → reviewer REQUEST_CHANGES). **Требуется:** добавить запись в раздел + `## [Unreleased] → ### Added/Fixed` по образцу соседних задач (ORCH-022 и т.п.), + описывающую merge-verify под-гейт, merge-актор, пост-деплой верификацию и kill-switch. + +### P1 — Must fix +- (нет) + +### P2 — Should fix +- (нет; `.openclaw/agents/deployer.md` про self-hosting не уточнён, но TRZ §1 помечает это + как «возможное» изменение, а non-self путь по ADR не меняется — не блокер.) + +## Документация +- `docs/architecture/README.md` — ✅ обновлён: добавлен раздел «Merge-в-main + пост-деплой + верификация как условие `done` (ORCH-071)», точно описывает врезку, Phase C, merge-актор, + верификатор, условность и инварианты. +- `docs/architecture/adr/adr-0013-merge-verify-gate.md` — ✅ создан (global ADR), корректен. +- `docs/work-items/ORCH-071/06-adr/ADR-001-merge-verify-gate.md` — ✅ детальный ADR (D1–D9). +- `docs/operations/PHANTOM_MERGE_RUNBOOK.md` — ✅ создан, 4 проверки постмортема с + copy-paste командами + критерий «фантом подтверждён» + remediation (FR-4/D9). +- **`CHANGELOG.md` — ❌ НЕ обновлён.** ← блокер (P0 выше). + +После добавления записи в `CHANGELOG.md` (и при сохранении зелёного pytest) задача готова к +повторному ревью и APPROVED. From 034343ec5d647dc61cefc5fd1ac3c770914a200f Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 8 Jun 2026 08:37:25 +0000 Subject: [PATCH 6/8] docs(changelog): add ORCH-071 merge-verify gate entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add CHANGELOG entry for the phantom-merge fix (merge-verify sub-gate, deterministic merge actor, post-deploy verification, kill-switch). Addresses P0 blocker from reviewer (attempt 2/3): docs = golden source per CLAUDE.md §2/§6 and AC-5. Refs: ORCH-071 Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a06421..6f5361f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ## [Unreleased] ### Added +- **Merge-в-`main` + пост-деплой верификация как обязательное условие `done` (фикс «фантомного merge»)** (ORCH-071): задача могла дойти до `done`, хотя ветка фактически НЕ влита в `main` («фантомный merge») — конвейер рапортовал успех без реального состояния репозитория. Введён под-гейт ребра `deploy → done`: единственная точка перехода `advance_stage` теперь гейтится `_handle_merge_verify` (`src/stage_engine.py`), который покрывает ВСЕ пути финализации (finalizer Phase C, reconciler F-1, job-reaper). Добавлены детерминированный merge-актор и пост-деплой верификатор (`src/merge_gate.py`): merge выполняется ТОЛЬКО через PR-merge API (без push/force-push, INV-4) в restart-surviving Phase C, верификация подтверждает фактическое слияние в `main` прежде чем разрешить переход в `done`. Раскат условный и снабжён kill-switch (`src/config.py`, `src/main.py`, по образцу условности ORCH-35/43/58), never-raise контракты соблюдены. Документация: глобальный `docs/architecture/adr/adr-0013-merge-verify-gate.md`, детальный `docs/work-items/ORCH-071/06-adr/ADR-001-merge-verify-gate.md` (D1–D9), раздел в `docs/architecture/README.md`, runbook постмортема `docs/operations/PHANTOM_MERGE_RUNBOOK.md` (4 проверки + критерий «фантом подтверждён» + remediation). Тесты: `tests/test_merge_actor.py`, `tests/test_merge_verify.py`, `tests/test_deploy_finalizer_merge_gate.py`, `tests/test_deploy_restart_merge_recovery.py`, `tests/test_qg_checks.py`, `tests/test_stages.py`. - **Security-гейт: secret-scanning (gitleaks) + dependency audit (pip-audit) перед мержем** (ORCH-022): автономный конвейер вливал ветку в `main` без проверки на утёкший секрет (ключ/токен/пароль/приватный ключ) и уязвимую зависимость (известный CVE) — для self-hosting `orchestrator` это особенно остро: один общий прод-инстанс обслуживает все проекты из общей БД, поэтому секрет/CVE, проскочивший через одну задачу, уезжает в прод всех проектов (CLAUDE.md §self-hosting, §8). ORCH-022 вводит детерминированный (без LLM) **security-гейт как под-гейт ребра `deploy-staging → deploy`**, исполняемый **ПЕРВЫМ** среди edge-под-гейтов (ДО merge-gate ORCH-043 и image-freshness ORCH-058) — дёшево фейлить до дорогих rebase/rebuild, а скан ветки ДО rebase не «обвиняет» задачу в CVE из обновившегося `main`. Паттерн соседей: новый leaf-модуль `src/security_gate.py` (контракт «never-raise», по образцу `merge_gate`/`image_freshness`/`staging_verdict`) + тонкая обёртка `check_security_gate` в реестре `QG_CHECKS` (`src/qg/checks.py`, lazy-import → нет цикла) + врезка `_handle_security_gate` в `src/stage_engine.py` в блок `current_stage == "deploy-staging"` ПЕРВОЙ. `STAGE_TRANSITIONS` и схема БД — **без изменений**. **Secret-scanning (`gitleaks`, offline):** скан диапазона `origin/main..HEAD` (ровно коммиты задачи); любой секрет вне аллоулиста версионируемого `.gitleaks.toml` → вклад в FAIL. Полностью оффлайн (локальные правила) → гарантия «секрет всегда блокирует» (BR-2) безусловна, не зависит от сети; **fail-closed** при ошибке инструмента/отсутствии бинаря/таймауте (нельзя доказать «секретов нет» → FAIL). Контракт exit-кодов: 0=чисто, 1=найдено, ≥2=ошибка. **Dependency audit (`pip-audit`, OSV/PyPI):** аудит `requirements.txt`; severity ≥ `security_dep_block_severity` (дефолт `HIGH`, порядок CRITICAL>HIGH>MEDIUM>LOW) → вклад в FAIL (`deps_blocking`); ниже порога / UNKNOWN → warning (`deps_warning`, анти-петля Р-4, не авто-блок). Источник advisory требует сети → недоступность фида **fail-open + громкий warning** по умолчанию (`deps_audit_degraded: true` + Telegram + лог; прецедент анти-петли ORCH-061), флаг `security_dep_audit_fail_closed` переводит в строгий режим без редеплоя кода. **Артефакт `17-security-report.md`** (YAML-frontmatter `security_status`/`secrets_found`/`deps_blocking`/`deps_warning`/`deps_audit_degraded` + тело-списки находок); машинный вердикт читается ТОЛЬКО из frontmatter (гейт пишет → читает обратно через `parse_security_status` → возвращает ровно то, что записал: единый источник истины, AC-8), negative-токен (FAIL) авторитетен, нет frontmatter/битый YAML/нет поля → **fail-closed** на чтении; значения секретов в артефакте маскируются (не ре-лик). **FAIL → откат на `development`** + developer-retry (общий `_developer_retry_count`, cap `MAX_DEVELOPER_RETRIES`=3, затем `set_issue_blocked` + Telegram, без бесконечного баунса); `task_desc` перезапущенного developer'а несёт дословные находки (`extract_security_findings`, паттерн ORCH-046) + ссылку на артефакт. **Self-hosting safety:** гейт только читает/сканирует/пишет артефакт — не вызывает деплой-хук, не рестартит прод-контейнер (под-гейт исполняется ДО захвата merge-lease → при FAIL lease освобождать не нужно). **Условность как ORCH-35/43/58:** `security_gate_enabled` (kill-switch) + `security_gate_repos` (CSV; пусто → только self-hosting `orchestrator`); таймаут `security_scan_timeout_s`; never-raise. v1 — Python-only стек; SAST/мульти-стек — follow-up (BR-14). Инфраструктура: pinned `gitleaks` (статический Go-бинарь) в `Dockerfile` (+ `curl`/`ca-certificates`), `pip-audit` (pinned) в `requirements.txt`, `.gitleaks.toml` в корне репо. Новые настройки: `ORCH_SECURITY_GATE_ENABLED` (true), `ORCH_SECURITY_GATE_REPOS` (""), `ORCH_SECURITY_DEP_BLOCK_SEVERITY` (HIGH), `ORCH_SECURITY_SCAN_TIMEOUT_S` (300), `ORCH_SECURITY_DEP_AUDIT_FAIL_CLOSED` (false), `ORCH_SECURITY_SECRETS_BLOCK` (true). Инварианты НЕ менялись: `STAGE_TRANSITIONS` (9 стадий), `check_branch_mergeable`/`check_staging_image_fresh` и их под-гейты, БАГ-8 откат, terminal-sync, схема БД (без миграций). ADR `docs/work-items/ORCH-022/06-adr/ADR-001-security-gate.md`, глобальный `docs/architecture/adr/adr-0012-security-gate.md`. Документация: `docs/architecture/README.md`, `CLAUDE.md`, `.env.example`. Тесты: `tests/test_security_gate.py`, `tests/test_qg_security.py`, `tests/test_stage_engine_security_gate.py`, `tests/test_qg_registry_snapshot.py`, `tests/test_config.py`. - **Выделенный статус-триггер прод-деплоя «Confirm Deploy»** (ORCH-059): жест запуска прод-деплоя отделён от человеческого гейта одобрения. Раньше один Plane-статус `Approved` был перегружен: на `analysis` он работал как человеческий гейт BRD (`check_analysis_approved`), а на `deploy` — молча триггерил Фазу B прод-деплоя ORCH-036 (`advance_stage(deploy, finished_agent=None) → _handle_self_deploy_phase_b → detached host-рестарт прод-контейнера 8500`). Привычный жест approve = групповой self-hosting риск (прод обслуживает ВСЕ проекты из одного инстанса). ORCH-059 вводит отдельный логический статус `confirm_deploy` («Confirm Deploy»), который триггерит **ТОЛЬКО** Фазу B на `deploy`; `Approved` остаётся исключительно гейтом конвейера. Четыре точечные правки в трёх модулях: (1) `src/plane_sync.py` — маппинг `"Confirm Deploy" → "confirm_deploy"` в `_PLANE_NAME_TO_KEY`; ключ намеренно НЕ добавлен в `_DEFAULT_STATES` (нет UUID для enduro/fallback) → **fail-closed**: для проекта ORCH резолвится из живого Plane API (`get_project_states(orch)["confirm_deploy"]` → реальный UUID), для сред без статуса (enduro / недоступный API / доска без статуса) ключ просто отсутствует, доступ через `.get("confirm_deploy")` → `None`, без `KeyError`. (2) `src/webhooks/plane.py` — `handle_issue_updated` ДО ветки `approved` добавляет fail-closed-ветку `confirm_state = proj_states.get("confirm_deploy"); if confirm_state and new_state == confirm_state: handle_confirm_deploy(...)`; новый `handle_confirm_deploy` резолвит задачу, гард `stage == "deploy"` (иначе no-op с логом — защищает прочие гейты от случайного триггера), иначе → `_try_advance_stage(..., confirm_deploy=True)`. `handle_verdict(approved=True)` не изменён (продолжает звать `_try_advance_stage` с дефолтным `confirm_deploy=False`). (3) `src/stage_engine.py` — `advance_stage` получил keyword-only параметр `confirm_deploy: bool = False` (обратносовместимо: все существующие вызовы из launcher/reconciler/finalizer передают `finished_agent`); блок Фазы B теперь **всегда возвращается рано** для `deploy + finished_agent is None` self-hosting, но `_handle_self_deploy_phase_b` вызывается ТОЛЬКО при `confirm_deploy=True`, иначе (обычный `Approved`) — детерминированный **no-op** (`result.note = "approved-on-deploy-noop"`): возврат ДО блока Quality Gate → `check_deploy_status` не запускается → нет ложного отката БАГ-8 (вердикта ещё нет, R-2). (4) CTA Фазы A (`_handle_self_deploy_phase_a`) — Plane-коммент и Telegram просят перевести задачу в статус «Confirm Deploy» (а не «Approved»). Следствие для reconciler F-1 на `deploy` (ORCH-053): попадает в no-op-ветку вместо неявного запуска Фазы B → прод-деплой нельзя инициировать автоматически, только явным человеческим «Confirm Deploy» (усиление safety). Условность как ORCH-35/36 (реально только для `self_deploy.self_deploy_applies("orchestrator")`; прочие репо — прежний синхронный ssh-деплой агентом, статус не нужен и не влияет). Контракты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status`, exit-код-контракт хука (0/1/2), Фазы A/C, merge-gate, terminal-sync, схема БД (статусы — на стороне Plane; restart-safe состояние деплоя — существующие sentinel-файлы ORCH-036). Эксплуатационное предусловие: в Plane-проекте ORCH создать статус доски «Confirm Deploy» (точное имя, регистр) + сброс кэша состояний — `docs/work-items/ORCH-059/07-infra-requirements.md`. До создания статуса прод-деплой через approve не запустится (желаемое fail-closed-поведение). ADR `docs/work-items/ORCH-059/06-adr/ADR-001-confirm-deploy-status.md` (уточняет триггер Фазы B относительно adr-0007). Тесты: `tests/test_plane_states.py`, `tests/test_plane_confirm_deploy.py`, `tests/test_stage_engine_phase_b.py`, `tests/test_stage_engine_phase_a_cta.py`, `tests/test_confirm_deploy_integration.py`, `tests/test_deploy_approve.py` (обновлён под новый триггер). - **Осмысленная статусная модель Plane (слой B — индикация)** (ORCH-066): Plane больше не показывает наблюдателю огрублённую/вводящую в заблуждение картину — статусы доски приведены к смыслу стадий конвейера, при этом статус остаётся **индикацией, а не управлением**. Архитектурный инвариант (ADR-001): меняется ТОЛЬКО слой B (отображение в Plane — `src/plane_sync.py` и точки выставления статуса в `stage_engine.py`/`webhooks/plane.py`/`reconciler.py`), слой A (машина стадий `src/stages.py::STAGE_TRANSITIONS`) остаётся **байт-в-байт неизменным** (AC-21, регресс-тест TC-22 сверяет полный литерал словаря). Целевая модель: `Backlog → Todo → [To Analyse] → Analysis → [In Review → Approved] → Architecture → Development → Code-Review → Testing → Awaiting Deploy → [Confirm Deploy] → Deploying → Monitoring after Deploy → Done`. Добавлены **6 новых логических ключей статуса** (`to_analyse`, `analysis`, `code_review`, `awaiting_deploy`, `deploying`, `monitoring`) в `_DEFAULT_STATES`/`_PLANE_NAME_TO_KEY` плюс `STAGE_VISIBILITY_STATE` (`analysis→analysis`, `review→code_review`) и `_STAGE_TO_STATE_KEY`; новые сеттеры `set_issue_analysis/code_review/awaiting_deploy/deploying/monitoring` + диспетчер `set_issue_stage_state`. **Project-relative alias-fallback (BR-12):** если оператор ещё не создал новый статус в конкретном Plane-проекте, ключ деградирует на базовый UUID **ТОГО ЖЕ** проекта (`_STATE_ALIAS_FALLBACK`: `analysis→in_progress`, `code_review→review`, `awaiting_deploy→in_review`, `deploying→in_progress`, `monitoring→done`, `to_analyse→in_progress`), поэтому PATCH остаётся валидным на частичных конфигах, а enduro-trails схлопывает новые ключи на старые базовые статусы → **нулевая регрессия**. **Самодеплой (ORCH-036) теперь индицирует фазы:** Phase A → `Awaiting Deploy` (ожидание ручного approve), Phase B → `Deploying`, terminal-sync `deploy→done` ветвится — для self-hosting (`post_deploy.post_deploy_applies(repo)`) issue входит в окно `Monitoring after Deploy` (НЕ терминальный Done), для прочих репо — прежний терминальный `Done` (нулевая регрессия, TC-08/TC-09). **Post-deploy монитор (ORCH-021)** на закрытии окна: HEALTHY → `set_issue_done`, DEGRADED → `set_issue_blocked` (только индикация; self-hosting остаётся ALERT_ONLY, прод НИКОГДА не рестартится/не откатывается — BR-5, TC-10/11/12). **Reconciler:** F-2 триггер старта/резюма расширен на `To Analyse` (TC-20), Guard 2 `_is_blocked_or_needs_input` учитывает новые активные ожидания (`awaiting_deploy/deploying/monitoring`) с вычитанием базовых рабочих статусов, чтобы алиасинг на частичных проектах не расширял skip-set (анти-регресс, TC-21). Контракт **never-raise** на всех сеттерах и резолвере состояний сохранён (API Plane недоступен → identity-фоллбэк, сеттеры не бросают — TC-16/17/18). **Раскатка** управляется оператором (создание 6 статусов в Plane), отдельного kill-switch не вводится — на «голом» Plane всё деградирует на прежнее поведение. Инварианты НЕ менялись (TC-22/TC-23): `STAGE_TRANSITIONS` (9 стадий), реестр `QG_CHECKS` (12 чеков), сигнатура `check_deploy_status(repo, work_item_id, branch)`, exit-код-контракт хука, merge-gate, схема БД (без миграций). ADR `docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md`. Тесты: `tests/test_plane_status_model.py`, `tests/test_plane_to_analyse_resume.py`, `tests/test_plane_status_failclosed.py`, `tests/test_plane_webhook.py` (TC-15), `tests/test_deploy_terminal_sync.py` (TC-08/09), `tests/test_post_deploy_integration.py` (TC-10/11/12), `tests/test_orch10_states.py` (TC-19), `tests/test_reconciler.py` (TC-21), `tests/test_reconciler_plane.py` (TC-20). From e7a5b50f97041fd36d6f94fee370ad4a42f19ed4 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 8 Jun 2026 08:40:17 +0000 Subject: [PATCH 7/8] reviewer(ET): auto-commit from reviewer run_id=358 --- docs/work-items/ORCH-071/12-review.md | 58 +++++++++++++-------------- 1 file changed, 28 insertions(+), 30 deletions(-) diff --git a/docs/work-items/ORCH-071/12-review.md b/docs/work-items/ORCH-071/12-review.md index 654e505..f986442 100644 --- a/docs/work-items/ORCH-071/12-review.md +++ b/docs/work-items/ORCH-071/12-review.md @@ -1,53 +1,51 @@ --- type: review work_item_id: ORCH-071 -verdict: REQUEST_CHANGES -version: 1 +verdict: APPROVED +version: 2 --- # Review ORCH-071 ## Summary Фикс «фантомного merge» реализован архитектурно корректно и полно: детерминированный -merge-актор + пост-деплой верификация как под-гейт ребра `deploy → done`, врезка -`_handle_merge_verify` в единственную точку перехода `advance_stage` (гейтит все пути — -finalizer Phase C, reconciler F-1, job-reaper), merge выполняется в restart-surviving -Phase C (G3), kill-switch и условность раската по образцу ORCH-35/43/58. Все FR-1..FR-5 и -AC-1..AC-11 покрыты содержательными тестами; `pytest tests/ -q` зелёный (853 passed). -Код соответствует ADR-001 (D1–D9) и глобальному adr-0013, never-raise контракты соблюдены, -INV-4 (только PR-merge API, без push/force-push) выдержан. +merge-актор (`merge_gate.merge_pr`) + пост-деплой верификатор (`merge_gate.verify_merged_to_main`) +как под-гейт ребра `deploy → done`, врезанный в единственную точку перехода +`advance_stage` (`_handle_merge_verify`) — гейтит ВСЕ пути к `done` (finalizer Phase C, +reconciler F-1, job-reaper re-drive). Merge выполняется в restart-surviving Phase C (G3), +ТОЛЬКО через Gitea PR-merge API (INV-4, без push/force-push в `main`), идемпотентно +(`pr_already_merged`, INV-5). Условность раската и kill-switch по образцу ORCH-35/43/58, +never-raise контракты соблюдены на всех публичных функциях и в самой врезке. -**Единственный блокер — документация:** `CHANGELOG.md` НЕ обновлён, хотя задача меняет -`src/`. По CLAUDE.md §6 и AC-5 это обязательно → REQUEST_CHANGES. +Все FR-1..FR-5 и AC-1..AC-11 покрыты содержательными тестами (verify true/false/never-raise, +PR-merged short-circuit, kill-switch, non-self no-op, restart-recovery smoke с двухпроходным +re-drive). `pytest tests/ -q` зелёный (853 passed). Код соответствует ADR-001 (D1–D9) и +глобальному adr-0013, `STAGE_TRANSITIONS` / `check_deploy_status` / реестр `QG_CHECKS` / +схема БД — не тронуты. + +**Прежний блокер (v1) устранён:** `CHANGELOG.md` теперь содержит запись ORCH-071 в +`## [Unreleased] → ### Added` (коммит `ca69ad4`). Документация обновлена полностью. ## Findings ### P0 — Blocker -- [ ] **`CHANGELOG.md` не обновлён при изменении `src/`.** Изменены `src/config.py`, - `src/main.py`, `src/merge_gate.py`, `src/self_deploy.py`, `src/stage_engine.py`, но в - `CHANGELOG.md` нет ни одной записи про ORCH-071 (`grep -c "ORCH-071" CHANGELOG.md` → 0, - `git diff main...HEAD -- CHANGELOG.md` пуст). Это прямое нарушение CLAUDE.md §2/§6 - («Документация = golden source… обнови `CHANGELOG.md`») и AC-5 (PASS требует обновлённый - CHANGELOG; FAIL → reviewer REQUEST_CHANGES). **Требуется:** добавить запись в раздел - `## [Unreleased] → ### Added/Fixed` по образцу соседних задач (ORCH-022 и т.п.), - описывающую merge-verify под-гейт, merge-актор, пост-деплой верификацию и kill-switch. +- (нет) ### P1 — Must fix - (нет) ### P2 — Should fix -- (нет; `.openclaw/agents/deployer.md` про self-hosting не уточнён, но TRZ §1 помечает это - как «возможное» изменение, а non-self путь по ADR не меняется — не блокер.) +- (нет; `.openclaw/agents/deployer.md` про self-hosting явно не уточнён, но TRZ §1 помечает + это как «возможное» изменение, а non-self merge-путь по ADR не меняется — не блокер.) ## Документация -- `docs/architecture/README.md` — ✅ обновлён: добавлен раздел «Merge-в-main + пост-деплой - верификация как условие `done` (ORCH-071)», точно описывает врезку, Phase C, merge-актор, - верификатор, условность и инварианты. -- `docs/architecture/adr/adr-0013-merge-verify-gate.md` — ✅ создан (global ADR), корректен. +- `CHANGELOG.md` — ✅ обновлён: запись ORCH-071 (под-гейт, merge-актор, верификация, kill-switch, + ссылки на ADR/runbook/тесты). +- `docs/architecture/README.md` — ✅ раздел «Merge-в-main + пост-деплой верификация как условие + `done` (ORCH-071)»: врезка, Phase C, merge-актор, верификатор, условность, инварианты. +- `docs/architecture/adr/adr-0013-merge-verify-gate.md` — ✅ global ADR создан. - `docs/work-items/ORCH-071/06-adr/ADR-001-merge-verify-gate.md` — ✅ детальный ADR (D1–D9). -- `docs/operations/PHANTOM_MERGE_RUNBOOK.md` — ✅ создан, 4 проверки постмортема с - copy-paste командами + критерий «фантом подтверждён» + remediation (FR-4/D9). -- **`CHANGELOG.md` — ❌ НЕ обновлён.** ← блокер (P0 выше). +- `docs/operations/PHANTOM_MERGE_RUNBOOK.md` — ✅ runbook: 4 проверки постмортема с copy-paste + командами + критерий «фантом подтверждён» + remediation (FR-4/D9). -После добавления записи в `CHANGELOG.md` (и при сохранении зелёного pytest) задача готова к -повторному ревью и APPROVED. +Задача соответствует ТЗ, ADR и правилам документирования (CLAUDE.md §2/§6). APPROVED. From d49e88cf3f7b2799c7cb72b8d20c61926bb65371 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 8 Jun 2026 08:42:01 +0000 Subject: [PATCH 8/8] tester(ET): auto-commit from tester run_id=359 --- docs/work-items/ORCH-071/13-test-report.md | 70 ++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 docs/work-items/ORCH-071/13-test-report.md diff --git a/docs/work-items/ORCH-071/13-test-report.md b/docs/work-items/ORCH-071/13-test-report.md new file mode 100644 index 0000000..9aa55f3 --- /dev/null +++ b/docs/work-items/ORCH-071/13-test-report.md @@ -0,0 +1,70 @@ +--- +type: test-report +work_item_id: ORCH-071 +result: PASS +--- + +# Test Report — ORCH-071 + +Верификация merge-в-main как условие `done` (фантомный merge). + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 +- Ветка: `feature/ORCH-071-crit-bug-merge-main` (HEAD `d72b1f5`) +- Review verdict: APPROVED (`12-review.md`) +- Дата: 2026-06-08 + +## Smoke test API (prod 8500, read-only) +| Endpoint | Результат | +|----------|-----------| +| `GET /health` | PASS — `{"status":"ok","service":"orchestrator"}` | +| `GET /status` | PASS — отдаёт активные задачи (ORCH-071 на стадии testing) | +| `GET /queue` | PASS — counts/resilience/reconcile/reaper/post_deploy в норме, breaker=closed, preflight_ok | + +## Результаты по тест-плану (`04-test-plan.yaml`) + +| TC ID | Описание | Тест | Результат | +|-------|----------|------|-----------| +| TC-01 | verify True: deployed SHA — предок origin/main | test_tc01_verify_true_when_sha_is_ancestor | PASS | +| TC-02 | verify True: PR.merged==true даже без git | test_tc02_verify_true_when_pr_merged_even_without_git | PASS | +| TC-03 | verify False: фантом (не предок И merged==false) | test_tc03_verify_false_when_phantom | PASS | +| TC-04 | never-raise (AC-7): ошибка git/HTTP → False | test_tc04_verify_never_raises_on_git_error / _http_error | PASS | +| TC-05 | finalizer: SUCCESS но PR open → НЕ done + alert | test_tc05_success_but_not_merged_holds_and_alerts | PASS | +| TC-06 | finalizer: SUCCESS + merge подтверждён → done | test_tc06_success_and_merged_reaches_done | PASS | +| TC-07 | merge-актор зовёт Gitea POST /pulls/{i}/merge | test_tc07_merge_actor_calls_gitea_merge | PASS | +| TC-08 | идемпотентность: already_merged → no-op | test_tc08_idempotent_already_merged / _no_open_pr_is_not_an_error | PASS | +| TC-09 | merge-актор never-raise: ошибка Gitea → (False, reason) | test_tc09_never_raise_on_http_error / _non_2xx_is_false | PASS | +| TC-10 | smoke (AC-3): рестарт в Phase B → re-drive докатывает merge → done | test_tc10_merge_recovers_after_restart | PASS | +| TC-11 | non-self репо: новая логика = no-op | test_tc11_non_self_repo_does_not_apply / _csv_scopes_to_listed_repos | PASS | +| TC-12 | kill-switch off → прежнее поведение | test_tc12_kill_switch_disables_under_gate | PASS | +| TC-13 | self-hosting safety: нет shell-out / force-push в main | test_tc13_no_shell_out_no_force_push | PASS | +| TC-14 | Phase B только при confirm_deploy=True; Approved → no-op | test_tc14_plain_approved_on_deploy_is_noop_no_merge / _confirm_deploy_initiates_phase_b | PASS | +| TC-15 | регресс: check_deploy_status / _parse_deploy_status неизменны | test_tc15_* (7 кейсов) | PASS | +| TC-16 | регресс: STAGE_TRANSITIONS / QG_CHECKS, deploy→done на месте | test_tc16_* (4 кейса) | PASS | + +Покрыты все критерии приёмки AC-1..AC-11 (`03-acceptance-criteria.md`). + +## Целевой прогон модулей ORCH-071 +``` +tests/test_merge_verify.py ................ 8 passed +tests/test_merge_actor.py ................. 6 passed +tests/test_deploy_finalizer_merge_gate.py . 4 passed +tests/test_deploy_restart_merge_recovery.py 1 passed +tests/test_qg_checks.py ................... 13 passed +tests/test_stages.py ...................... 4 passed +======================== 36 passed, 1 warning in 0.61s ========================= +``` + +## Полный регресс +``` +pytest tests/ -v --tb=short +======================= 853 passed, 1 warning in 22.77s ======================== +``` +(1 warning — PydanticDeprecatedSince20 в `src/config.py`, не связан с задачей.) + +## Итог +**PASS** — все 853 теста зелёные, целевые 36 тестов ORCH-071 (TC-01..TC-16) PASS, +smoke API (health/status/queue) OK. Регрессы существующих контрактов +(`STAGE_TRANSITIONS`, `QG_CHECKS`, `check_deploy_status`) не выявлены. +Задача готова к переходу на `deploy-staging`.