Compare commits
8 Commits
docs/ORCH-
...
9de016047b
| Author | SHA1 | Date | |
|---|---|---|---|
| 9de016047b | |||
| d72b1f5d51 | |||
| ca69ad4f39 | |||
| aa294424bc | |||
| bd749a53a7 | |||
| f305dc2584 | |||
| bb5a439f42 | |||
| c66490aa6f |
File diff suppressed because one or more lines are too long
@@ -121,6 +121,44 @@ sentinel-файлы (`<repos_dir>/.deploy-state-<repo>/<wi>/`), без мигр
|
||||
Детально — `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 <validated_sha> 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 —
|
||||
|
||||
63
docs/architecture/adr/adr-0013-merge-verify-gate.md
Normal file
63
docs/architecture/adr/adr-0013-merge-verify-gate.md
Normal file
@@ -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 <validated_sha> 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).
|
||||
125
docs/operations/PHANTOM_MERGE_RUNBOOK.md
Normal file
125
docs/operations/PHANTOM_MERGE_RUNBOOK.md
Normal file
@@ -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=<gitea_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:<file>`
|
||||
|
||||
Сверяет содержимое файла на проде с тем, что лежит в `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/<WI>/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/<WI>/
|
||||
cat /home/slin/repos/.deploy-state-$REPO/<WI>/hook.log
|
||||
```
|
||||
|
||||
* `deploy_status: SUCCESS` + `merged_to_main: false` → деплой прошёл, merge — нет
|
||||
(это и есть класс ORCH-071; задача должна быть удержана на `deploy`, не `done`).
|
||||
* `not_merged_alerts_total` растёт / `last_alert_wi == <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`.
|
||||
7
docs/work-items/ORCH-071/00-business-request.md
Normal file
7
docs/work-items/ORCH-071/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: CRIT BUG: фантомный merge — деплой без слияния в main
|
||||
|
||||
Work Item ID: ORCH-071
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
53
docs/work-items/ORCH-071/01-brd.md
Normal file
53
docs/work-items/ORCH-071/01-brd.md
Normal file
@@ -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.
|
||||
78
docs/work-items/ORCH-071/02-trz.md
Normal file
78
docs/work-items/ORCH-071/02-trz.md
Normal file
@@ -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 <sha> 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:<file>`.
|
||||
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-<repo>/<wi>/`, как 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), схема БД.
|
||||
61
docs/work-items/ORCH-071/03-acceptance-criteria.md
Normal file
61
docs/work-items/ORCH-071/03-acceptance-criteria.md
Normal file
@@ -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 запускается без человеческого триггера.
|
||||
103
docs/work-items/ORCH-071/04-test-plan.yaml
Normal file
103
docs/work-items/ORCH-071/04-test-plan.yaml
Normal file
@@ -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
|
||||
186
docs/work-items/ORCH-071/06-adr/ADR-001-merge-verify-gate.md
Normal file
186
docs/work-items/ORCH-071/06-adr/ADR-001-merge-verify-gate.md
Normal file
@@ -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=<branch>` → индекс открытого 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 <sha> origin/main` в worktree задачи (после `git fetch origin main`),
|
||||
где `<sha>` — 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:<file>`;
|
||||
(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-<repo>/<wi>/` + очередь `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 + ручное вмешательство.
|
||||
47
docs/work-items/ORCH-071/07-infra-requirements.md
Normal file
47
docs/work-items/ORCH-071/07-infra-requirements.md
Normal file
@@ -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 <sha> 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-<repo>/<wi>/` + очередь `jobs` (колонка `jobs.pid`, ORCH-065, уже есть).
|
||||
23
docs/work-items/ORCH-071/10-tech-risks.md
Normal file
23
docs/work-items/ORCH-071/10-tech-risks.md
Normal file
@@ -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) на
|
||||
старте сохраняется.
|
||||
51
docs/work-items/ORCH-071/12-review.md
Normal file
51
docs/work-items/ORCH-071/12-review.md
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-071
|
||||
verdict: APPROVED
|
||||
version: 2
|
||||
---
|
||||
|
||||
# Review ORCH-071
|
||||
|
||||
## Summary
|
||||
Фикс «фантомного merge» реализован архитектурно корректно и полно: детерминированный
|
||||
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 контракты соблюдены на всех публичных функциях и в самой врезке.
|
||||
|
||||
Все 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
|
||||
- (нет)
|
||||
|
||||
### P1 — Must fix
|
||||
- (нет)
|
||||
|
||||
### P2 — Should fix
|
||||
- (нет; `.openclaw/agents/deployer.md` про self-hosting явно не уточнён, но TRZ §1 помечает
|
||||
это как «возможное» изменение, а non-self merge-путь по 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` — ✅ runbook: 4 проверки постмортема с copy-paste
|
||||
командами + критерий «фантом подтверждён» + remediation (FR-4/D9).
|
||||
|
||||
Задача соответствует ТЗ, ADR и правилам документирования (CLAUDE.md §2/§6). APPROVED.
|
||||
70
docs/work-items/ORCH-071/13-test-report.md
Normal file
70
docs/work-items/ORCH-071/13-test-report.md
Normal file
@@ -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`.
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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 #<n>")``; otherwise ``(False, "<reason>")``.
|
||||
|
||||
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 <sha> 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
188
tests/test_deploy_finalizer_merge_gate.py
Normal file
188
tests/test_deploy_finalizer_merge_gate.py
Normal file
@@ -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
|
||||
116
tests/test_deploy_restart_merge_recovery.py
Normal file
116
tests/test_deploy_restart_merge_recovery.py
Normal file
@@ -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
|
||||
135
tests/test_merge_actor.py
Normal file
135
tests/test_merge_actor.py
Normal file
@@ -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 == []
|
||||
126
tests/test_merge_verify.py
Normal file
126
tests/test_merge_verify.py
Normal file
@@ -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
|
||||
@@ -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).
|
||||
#
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user