From 961c5e9eee83824a5281c67f3685230cc12b6cea Mon Sep 17 00:00:00 2001 From: Slava Date: Sun, 7 Jun 2026 16:42:30 +0300 Subject: [PATCH 1/9] docs: init ORCH-021 business request --- docs/work-items/ORCH-021/00-business-request.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 docs/work-items/ORCH-021/00-business-request.md diff --git a/docs/work-items/ORCH-021/00-business-request.md b/docs/work-items/ORCH-021/00-business-request.md new file mode 100644 index 0000000..82a3408 --- /dev/null +++ b/docs/work-items/ORCH-021/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: [★ высокий] Post-deploy мониторинг прода + авто-rollback при деградации + +Work Item ID: ORCH-021 + +## Description + +TBD From db83b89467ba6f6bebada1bef657c8adf45c12f0 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 13:47:52 +0000 Subject: [PATCH 2/9] analyst(ET): auto-commit from analyst run_id=305 --- docs/work-items/ORCH-021/01-brd.md | 88 ++++++++++ docs/work-items/ORCH-021/02-trz.md | 165 ++++++++++++++++++ .../ORCH-021/03-acceptance-criteria.md | 106 +++++++++++ docs/work-items/ORCH-021/04-test-plan.yaml | 163 +++++++++++++++++ 4 files changed, 522 insertions(+) create mode 100644 docs/work-items/ORCH-021/01-brd.md create mode 100644 docs/work-items/ORCH-021/02-trz.md create mode 100644 docs/work-items/ORCH-021/03-acceptance-criteria.md create mode 100644 docs/work-items/ORCH-021/04-test-plan.yaml diff --git a/docs/work-items/ORCH-021/01-brd.md b/docs/work-items/ORCH-021/01-brd.md new file mode 100644 index 0000000..602578c --- /dev/null +++ b/docs/work-items/ORCH-021/01-brd.md @@ -0,0 +1,88 @@ +# BRD — ORCH-021: Post-deploy мониторинг прода + авто-rollback при деградации + +Work Item: ORCH-021 +Приоритет: высокий (★) +Источник: предложение Стрим, одобрено Славой (2026-06-04) +Стадия: analysis + +## 1. Проблема (Why) + +Сейчас конвейер заканчивается на `deploy → done`: как только `check_deploy_status` +видит `deploy_status: SUCCESS`, задача закрывается и оркестратор **забывает про прод**. +«Успех» деплоя сегодня означает только то, что health-check в момент рестарта +прошёл (10×6с в `scripts/orchestrator-deploy-hook.sh`) — узкое окно ~60 секунд. + +**Прямой урок ET-8:** деплой отрапортовал SUCCESS, а на проде фича не работала. +Класс инцидентов — «зелёный деплой, красный прод»: +- деградация проявляется через минуты, а не в первые 60с (прогрев кэшей, фоновые + миграции, отложенные запросы, утечки, рост 5xx под реальным трафиком); +- health-эндпоинт отвечает `200 ok`, но ключевая функциональность сломана; +- регресс виден только под боевым трафиком, которого нет в момент рестарта. + +После закрытия задачи никакого пригляда за продом нет — деградацию замечает человек +постфактум. Для self-hosting это особенно опасно: сломанный прод-орк (8500) обслуживает +ВСЕ проекты (enduro-trails) из общего инстанса. + +## 2. Цель (What) + +Продлить ответственность конвейера за прод **после** `deploy → done`: в течение +заданного окна наблюдать ключевые сигналы здоровья прода и при доказанной деградации +выполнить реакцию (откат на предыдущий образ или громкий алерт с запросом ручного +отката). Закрыть класс «зелёный деплой, красный прод». + +Механизм частичного отката уже есть: `do_rollback()` и режим `--rollback` в +`scripts/orchestrator-deploy-hook.sh` умеют вернуть предыдущий образ из +`PREV_IMAGE_FILE` (`.deploy-prev-image-prod`), который сохраняется при каждом деплое. +Задача — построить **наблюдение поверх** этого и привязать решение к измеримым порогам. + +## 3. Заинтересованные стороны +- **Owner (Слава)** — принимает риск авто-отката прода; получает алерты. +- **Стрим** — инициатор; потребитель сигнала деградации для петли уроков (ORCH-8). +- **Другие проекты (enduro-trails)** — косвенно: устойчивость общего инстанса. + +## 4. Бизнес-требования + +| # | Требование | Приоритет | +|---|------------|-----------| +| BR-1 | После `deploy → done` прод наблюдается в течение конфигурируемого окна (дефолт ~15 мин), а не забывается. | Must | +| BR-2 | Деградация определяется по **детерминированным измеримым сигналам**: периодический `/health` (HTTP 200 + `{"status":"ok"}`) и доля HTTP 5xx на ключевых эндпоинтах (`/status`, `/queue`). | Must | +| BR-3 | Деградация фиксируется только по **порогам** (N последовательных провалов / окно), а не по разовому сетевому глюку — чтобы не было ложных откатов. | Must | +| BR-4 | При подтверждённой деградации система выполняет реакцию: **авто-rollback** на `.deploy-prev-image-prod` (через существующий хук `--rollback`) **либо** громкий алерт с запросом ручного отката — в зависимости от политики репозитория. | Must | +| BR-5 | **Self-hosting safety:** для самого `orchestrator` авто-откат прода = рестарт инструмента, обслуживающего все проекты. По умолчанию для self-hosting реакция — **алерт + ручной approve отката** (по образцу deploy Phase A/B), НЕ автоматический откат. Для не-self репозиториев допустим авто-откат. | Must | +| BR-6 | Любой исход (наблюдение начато, деградация, откат, откат-провал, окно завершилось чисто) уведомляется в Telegram и комментарием в Plane; результат наблюдения фиксируется артефактом. | Must | +| BR-7 | Мониторинг — **restart-safe**: рестарт оркестратора (в т.ч. сам деплой) не теряет и не задваивает наблюдение. Идемпотентность по образцу reconciler / deploy-finalizer. | Must | +| BR-8 | Глобальный kill-switch (env-флаг) и список репозиториев, на которые распространяется фича (по образцу `merge_gate_enabled` / `image_freshness_enabled` / `self_deploy_repos`). Выключенный флаг = прежнее поведение (наблюдения нет). | Must | +| BR-9 | Наблюдаемость: текущее состояние пост-деплой наблюдения отражается в `GET /queue` (по образцу блока `reconcile`). | Should | +| BR-10 | Сигнал деградации пригоден для будущей петли уроков (ORCH-8): фиксируется в артефакте/логе в машиночитаемом виде. | Should | +| BR-11 | Доменный smoke результата фичи (проверка, что конкретная фича реально работает) — желателен, но выносится в follow-up; MVP ограничивается health + 5xx. | Could | + +## 5. Вне рамок (Out of scope) +- Полноценная система метрик/APM (Prometheus, дашборды) — фича опирается на уже + существующие HTTP-эндпоинты, не вводит сбор метрик. +- Универсальный доменный smoke для произвольной фичи (BR-11 — follow-up). +- Полностью автоматический откат прод-орка без участия человека (противоречит + self-hosting safety; отдельная задача при наборе доверия, аналогично ORCH-54 для deploy). +- Изменение момента вердикта `deploy_status` / контракта `check_deploy_status` + (наблюдение происходит ПОСЛЕ `done`, не заменяет deploy-gate). + +## 6. Связи +- **ET-8** — прецедент «deploy SUCCESS, прод не работает». Обоснование задачи. +- **ORCH-36** (`docs/architecture/adr/adr-0007-executable-self-deploy.md`) — Phase A/B/C + исполняемого самодеплоя; пост-деплой наблюдение продлевает ответственность ЗА `done`, + переиспользует sentinel-паттерн и detached-host-процесс для self-rollback. +- **ORCH-53** (`src/reconciler.py`) — каноничный паттерн фонового daemon-потока + (watchdog), запускаемого в `main.lifespan`; образец для пост-деплой наблюдателя. +- **ORCH-58** — `.deploy-prev-image` и хук-механика отката, на которые опирается реакция. +- **ORCH-8** — деградация прода = сигнал для петли уроков (BR-10). +- **ORCH-12** — фича может оформиться как пост-deploy стадия ИЛИ как watchdog (решение + архитектора, см. §7). + +## 7. Открытые архитектурные вопросы (для архитектора, НЕ решаются в анализе) +1. **Где живёт наблюдение:** отдельная пост-deploy стадия конвейера vs фоновый + watchdog-daemon (по образцу `reconciler`) vs reserved-agent job (по образцу + `deploy-finalizer`). Анализ задаёт требования (BR-1, BR-7), выбор механизма — за архитектором. +2. **Механизм self-rollback для self-hosting:** откат прод-орка требует detached + host-процесса (контейнер не может надёжно откатить себя, умирая) — переиспользовать + ли `self_deploy.initiate_deploy` / хук `--rollback`. +3. Точные пороги и веса сигналов (BR-3) — анализ предлагает дефолты (см. AC), архитектор + фиксирует реализацию. diff --git a/docs/work-items/ORCH-021/02-trz.md b/docs/work-items/ORCH-021/02-trz.md new file mode 100644 index 0000000..83d3035 --- /dev/null +++ b/docs/work-items/ORCH-021/02-trz.md @@ -0,0 +1,165 @@ +# ТЗ — ORCH-021: Post-deploy мониторинг прода + авто-rollback + +Work Item: ORCH-021 +Стадия: analysis → (architecture) + +> Документ описывает ТРЕБОВАНИЯ к изменениям и НАЗЫВАЕТ задействованные модули. +> Выбор механизма (стадия vs watchdog vs reserved-agent) и точная реализация — +> зона архитектора (см. BRD §7). Здесь фиксируется, ЧТО должно измениться и КАКИЕ +> контракты НЕЛЬЗЯ ломать. + +## 1. Контекст в коде (как есть сейчас) + +- Конвейер заканчивается в `src/stages.py`: `deploy → done`, gate `check_deploy_status`. + Терминальный переход `deploy → done` исполняется в `src/stage_engine.py::advance_stage` + (блок «Terminal sync», `set_issue_done`, release merge-lease). После этого ничего + не наблюдает за продом. +- `scripts/orchestrator-deploy-hook.sh` уже умеет: + - `health_check(max_attempts, sleep, label)` — опрос `http://localhost:$TARGET_PORT/health` + с проверкой `"status":"ok"`; + - `do_rollback()` — retag `PREV_IMAGE_FILE` → `TARGET_IMAGE` + рестарт + пост-rollback + health-check; коды возврата 0 (ок) / 1 (нет prev-образа) / 2 (rollback тоже упал); + - режим `--rollback` (ручной откат); + - при обычном деплое сохраняет `PREV_IMG` в `PREV_IMAGE_FILE` + (`.deploy-prev-image-prod` для прода, см. `settings.deploy_prod_prev_image_file`). +- Self-deploy прода идёт через detached host-процесс: `src/self_deploy.py` + (`build_deploy_command`, `initiate_deploy`, sentinel-маркеры под + `.deploy-state-//`, `read_result`, `map_exit_code_to_status`). +- Фоновый daemon-паттерн: `src/reconciler.py` (`threading.Thread(daemon=True)` + + `threading.Event`, старт/стоп в `src/main.py::lifespan` после `worker.start()` / + перед `worker.stop()`, `status()` в `GET /queue`). +- Reserved-agent (детерминированный no-LLM job) паттерн: `deploy-finalizer` — + перехват в `src/agents/launcher.py::launch_job` ДО `_spawn`, исполнение + `stage_engine.run_deploy_finalizer`, отложенная постановка через + `enqueue_job(..., available_at_delay_s=...)`. +- Условность self-hosting: `src/qg/checks.py::is_self_hosting_repo`, + `src/self_deploy.py::self_deploy_applies` (флаг + CSV-репо; пусто → только `orchestrator`). +- Наблюдаемые эндпоинты прода (`src/main.py`): `GET /health`, `GET /status`, `GET /queue`. +- API БД: `src/db.py::enqueue_job` (с `available_at_delay_s`), `get_db`, + `update_task_stage`, `get_active_tasks_for_reconcile`. + +## 2. Требуемые изменения + +### 2.1. Новый leaf-модуль чистой логики наблюдения — `src/post_deploy.py` (новый) +Контракт **never-raise** (по образцу `self_deploy.py` / `staging_verdict.py`). +Чистые, юнит-тестируемые функции: +- **Опрос сигналов:** функция, опрашивающая `/health` и ключевые эндпоинты + (`/status`, `/queue`) прод-инстанса (base-url из config), возвращающая структуру + с результатами (код ответа, ok-флаг, доля 5xx). Сеть/таймаут → консервативный + результат, не исключение. +- **Классификация деградации** (чистая, без сети): на вход — серия результатов + опросов; на выход — вердикт `HEALTHY | DEGRADED` по порогам (BR-3): + `≥ post_deploy_fail_threshold` последовательных провалов health ИЛИ доля 5xx + выше `post_deploy_5xx_threshold` на окне. Эта функция — основной предмет + юнит-тестов (детерминированная, как `compute_staging_verdict` в ORCH-061). +- **Решение о реакции** (чистая): по `(repo, вердикт, политика)` → одно из + `NONE | ROLLBACK | ALERT_ONLY`, с учётом self-hosting (BR-5). +- **Запись артефакта** результата наблюдения (см. §2.5), best-effort. +- Условность: хелпер `post_deploy_applies(repo)` (флаг + CSV-репо, пусто → + только self-hosting), по образцу `self_deploy_applies` / `_merge_gate_applies`. + +### 2.2. Оркестрация наблюдения (механизм — выбор архитектора) +Требования к механизму (независимо от выбора стадия/watchdog/reserved-agent): +- запускается ПОСЛЕ перехода `deploy → done` для применимого репозитория (BR-1); +- наблюдает окно `post_deploy_window_s` с интервалом `post_deploy_interval_s`; +- **restart-safe и идемпотентен** (BR-7): состояние наблюдения — в sentinel-файлах + (по образцу `.deploy-state-//`, напр. маркеры `monitor-started` / + `monitor-done`) ИЛИ через отложенные `enqueue_job(available_at_delay_s=...)`; + повторный старт не задваивает наблюдение и не теряет его при рестарте; +- по итогу вызывает «Решение о реакции» из `src/post_deploy.py` и исполняет реакцию (§2.3). + +Кандидатные точки интеграции (на выбор архитектора, см. BRD §7): +- хук в `stage_engine.advance_stage` в блоке `next_stage == "done"` — арм наблюдения; +- reserved-agent `post-deploy-monitor` (расширение `launcher.launch_job` ДО `_spawn`, + как `deploy-finalizer`), с само-перепостановкой через `available_at_delay_s`; +- отдельный daemon-поток `PostDeployWatcher` (как `Reconciler`), старт/стоп в `main.lifespan`. + +### 2.3. Реакция на деградацию +- **Не-self репозитории / политика auto:** вызвать существующий хук в режиме отката + (`scripts/orchestrator-deploy-hook.sh --rollback` с прод-параметрами окружения, + как в `self_deploy.build_deploy_command`, но action=`--rollback`). Маппинг + exit-code хука (0/1/2) в исход переиспользует логику `self_deploy.map_exit_code_to_status` + по смыслу (0 → откат успешен; 1/2 → откат не выполнен/провалился → громкий алерт). +- **Self-hosting (`orchestrator`) по умолчанию (BR-5):** НЕ откатывать автоматически. + Сформировать громкий алерт (Telegram + Plane-коммент) и запросить ручной approve + отката (по образцу deploy Phase A — статус Plane / Telegram CTA). Откат самого + прод-орка, если выполняется, — только через detached host-процесс (нельзя надёжно + откатить контейнер, который при этом умирает; переиспользовать механику + `self_deploy.initiate_deploy`). +- Команда отката для self НЕ должна ронять прод-контейнер в рамках обычного тика + наблюдения (CLAUDE.md: не ронять/не рестартить прод-контейнер вне явного действия). + +### 2.4. Конфигурация — `src/config.py` (расширение `Settings`) +Добавить (env-префикс `ORCH_`, дефолты безопасные): +- `post_deploy_monitor_enabled: bool = True` — глобальный kill-switch (BR-8). +- `post_deploy_repos: str = ""` — CSV применимых репо; пусто → только self-hosting + (по образцу `self_deploy_repos` / `merge_gate_repos` / `image_freshness_repos`). +- `post_deploy_window_s: int = 900` — длина окна наблюдения (дефолт ~15 мин, BR-1). +- `post_deploy_interval_s: int = 30` — интервал между опросами. +- `post_deploy_fail_threshold: int = 3` — N последовательных провалов health → DEGRADED. +- `post_deploy_5xx_threshold: float = 0.5` — порог доли 5xx на окне → DEGRADED. +- `post_deploy_auto_rollback: bool = False` — глобально разрешён ли авто-откат; + при `True` действует для не-self репо; для self всегда требует approve (BR-5). +- `post_deploy_base_url: str = "http://localhost:8500"` — base-url наблюдаемого прода. +- `post_deploy_target` параметры отката — переиспользовать существующие + `deploy_prod_*` (service/port/image/prev_image_file), новых дублей не вводить. + +### 2.5. Артефакт задачи — `16-post-deploy-log.md` (новый) +В `docs/work-items//`. YAML-frontmatter (машиночитаемо, канон гейтов; +для будущей петли уроков BR-10): +``` +--- +post_deploy_status: HEALTHY | DEGRADED +action_taken: NONE | ROLLBACK_OK | ROLLBACK_FAILED | ALERT_ONLY +work_item: +window_s: +checks_total: +checks_failed: +--- +``` +Тело — человекочитаемая сводка опросов. Записывается best-effort (по образцу +`self_deploy.write_deploy_log`); отсутствие файла не должно ничего ронять. +> Артефакт `16-post-deploy-log.md` добавить в перечень артефактов в `CLAUDE.md` +> и таблицу/описание в `docs/architecture/README.md` (golden-source, в том же PR). + +### 2.6. Наблюдаемость — `GET /queue` (`src/main.py`) (BR-9) +Добавить блок `post_deploy` со снимком состояния (enabled, window, активные +наблюдения, последний исход) — по образцу блока `reconcile` (метод `status()`). + +### 2.7. Изменения схемы БД +**Не требуются.** Состояние наблюдения — sentinel-файлы (restart-safe, без миграции, +по образцу ORCH-36) и/или отложенные jobs. Если архитектор выберет колонку в `tasks` +для отметки наблюдения — потребуется миграция; предпочтительно избежать (как ORCH-36/53/58). + +### 2.8. Новые QG checks +**Не требуются.** Наблюдение происходит ПОСЛЕ `done` и не является gate'ом стадии; +реестр `QG_CHECKS` и `STAGE_TRANSITIONS` не меняются (если архитектор НЕ выберет +вариант «отдельная пост-deploy стадия» — тогда потребуется новая стадия+gate, что +надо явно отразить в ADR; по умолчанию предпочтителен вариант без изменения реестров). + +## 3. Инварианты (НЕ ломать) +- `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, контракт `check_deploy_status` / + `_parse_deploy_status`, момент вердикта `deploy_status`, БАГ-8 откат, terminal-sync + `deploy → done`, merge-gate, exit-code-контракт хука (0/1/2) — без изменений. +- Контракт хука: дефолты STAGING-безопасны; прод-параметры приходят только через env. +- Условность как ORCH-35/36/43/58: реально для `orchestrator`/listed-repos, прочие — no-op. +- Never-raise: ошибка в наблюдении не роняет worker / lifespan / конвейер других проектов. +- Self-hosting: тик наблюдения НИКОГДА не рестартит прод-контейнер сам по себе (BR-5). + +## 4. Задействованные модули (сводка) +| Модуль | Изменение | +|--------|-----------| +| `src/post_deploy.py` | **новый** — чистая логика опроса/классификации/решения/артефакта, never-raise | +| `src/config.py` | +параметры `post_deploy_*` (kill-switch, окно, пороги, политика) | +| `src/stage_engine.py` и/или `src/agents/launcher.py` и/или `src/main.py` | арм/исполнение наблюдения (точка — за архитектором) | +| `scripts/orchestrator-deploy-hook.sh` | переиспользуется (`--rollback`); правки — только если откат self требует отдельной ветки (за архитектором) | +| `src/main.py` | блок `post_deploy` в `GET /queue` (BR-9); возможный старт daemon в `lifespan` | +| `docs/work-items//16-post-deploy-log.md` | **новый** артефакт | +| `CLAUDE.md`, `docs/architecture/README.md`, `CHANGELOG.md` | обновить (golden-source, в том же PR) | +| ADR | `docs/work-items/ORCH-021/06-adr/ADR-001-*.md` (+ возможный сквозной `adr/adr-00NN`) | + +## 5. Артефакты по pipeline, которые должны появиться/обновиться +- `16-post-deploy-log.md` (новый, машиночитаемый frontmatter). +- Обновлённые `CLAUDE.md` (перечень артефактов), `docs/architecture/README.md` + (описание пост-деплой наблюдения), `CHANGELOG.md`. +- ADR work-item (`06-adr/`) с зафиксированным выбором механизма и порогов. diff --git a/docs/work-items/ORCH-021/03-acceptance-criteria.md b/docs/work-items/ORCH-021/03-acceptance-criteria.md new file mode 100644 index 0000000..af7ca32 --- /dev/null +++ b/docs/work-items/ORCH-021/03-acceptance-criteria.md @@ -0,0 +1,106 @@ +# Критерии приёмки — ORCH-021 + +Work Item: ORCH-021 +Формат: каждый критерий имеет чёткое условие PASS/FAIL и проверяется тестом +из `04-test-plan.yaml`. + +## Наблюдение и сигналы + +### AC-1 — наблюдение армится после deploy→done +- **PASS:** для применимого репозитория после терминального перехода `deploy → done` + пост-деплой наблюдение инициируется (создаётся sentinel/отложенный job/запись в watcher). +- **FAIL:** переход `deploy → done` не приводит к старту наблюдения. + +### AC-2 — наблюдение НЕ армится для неприменимых репо +- **PASS:** для репозитория вне области (не self-hosting и не в `post_deploy_repos`) + `post_deploy_applies(repo)` → False; наблюдение не стартует; конвейер не меняется. +- **FAIL:** наблюдение стартует для неприменимого репо. + +### AC-3 — классификация HEALTHY +- **PASS:** серия опросов без провалов (или провалов меньше `post_deploy_fail_threshold` + и доля 5xx ниже `post_deploy_5xx_threshold`) → вердикт `HEALTHY`. +- **FAIL:** при здоровых сигналах возвращается `DEGRADED`. + +### AC-4 — классификация DEGRADED по порогу провалов health +- **PASS:** `≥ post_deploy_fail_threshold` ПОСЛЕДОВАТЕЛЬНЫХ провалов health → `DEGRADED`. +- **FAIL:** порог достигнут, но вердикт не `DEGRADED`. + +### AC-5 — классификация DEGRADED по доле 5xx +- **PASS:** доля 5xx на окне выше `post_deploy_5xx_threshold` → `DEGRADED`, + даже если `/health` отвечает 200. +- **FAIL:** превышение порога 5xx не даёт `DEGRADED`. + +### AC-6 — устойчивость к разовому глюку (нет ложного срабатывания) +- **PASS:** одиночный провал (1 < `post_deploy_fail_threshold`) с последующим + восстановлением → итог `HEALTHY`, реакции нет. +- **FAIL:** одиночный разовый провал приводит к `DEGRADED`/откату. + +## Реакция + +### AC-7 — авто-rollback для не-self репо при политике auto +- **PASS:** при `post_deploy_auto_rollback=True` и НЕ-self репо вердикт `DEGRADED` + приводит к вызову отката (хук `--rollback` с прод-параметрами); `action_taken` + фиксируется как `ROLLBACK_OK`/`ROLLBACK_FAILED` по exit-code. +- **FAIL:** откат не вызывается, либо вызывается с staging-дефолтами, либо роняет прод напрямую. + +### AC-8 — self-hosting НЕ откатывается автоматически (safety) +- **PASS:** для `orchestrator` вердикт `DEGRADED` НЕ приводит к автоматическому + откату/рестарту прод-контейнера в тике наблюдения; вместо этого формируется + громкий алерт + запрос ручного approve (`action_taken: ALERT_ONLY`). +- **FAIL:** тик наблюдения автоматически откатывает/рестартит прод-орк. + +### AC-9 — откат-провал эскалируется +- **PASS:** если откат вызван и вернул код 1/2 (нет prev-образа / откат тоже упал) → + `action_taken: ROLLBACK_FAILED` + громкий Telegram-алерт о необходимости ручного вмешательства. +- **FAIL:** провал отката проглатывается тихо. + +## Конфигурация и совместимость + +### AC-10 — kill-switch выключает фичу +- **PASS:** `post_deploy_monitor_enabled=False` → наблюдение не армится ни для кого; + поведение конвейера 1:1 как до ORCH-021. +- **FAIL:** при выключенном флаге наблюдение всё равно работает. + +### AC-11 — пороги/окно конфигурируемы через env +- **PASS:** `post_deploy_window_s`, `post_deploy_interval_s`, `post_deploy_fail_threshold`, + `post_deploy_5xx_threshold` читаются из `Settings` (env `ORCH_*`) и влияют на поведение. +- **FAIL:** значения захардкожены. + +### AC-12 — реестры и схема БД не изменены +- **PASS:** `STAGE_TRANSITIONS`, `QG_CHECKS`, контракт `check_deploy_status` и схема + таблиц БД не изменены (если архитектор не вводит явно новую стадию — тогда это + отражено в ADR и тестах). Существующие тесты deploy/staging/merge-gate зелёные. +- **FAIL:** молча сломан какой-либо существующий контракт/тест. + +## Наблюдаемость, артефакт, идемпотентность + +### AC-13 — артефакт 16-post-deploy-log.md с машиночитаемым frontmatter +- **PASS:** по итогу наблюдения пишется `16-post-deploy-log.md` с валидным YAML-frontmatter + (`post_deploy_status`, `action_taken`); запись best-effort (её отсутствие ничего не роняет). +- **FAIL:** артефакт не пишется или frontmatter невалиден/непарсится. + +### AC-14 — наблюдаемость в /queue +- **PASS:** `GET /queue` содержит блок `post_deploy` со снимком состояния (enabled, + window, активные/последний исход). +- **FAIL:** состояние наблюдения нигде не видно. + +### AC-15 — идемпотентность / restart-safe +- **PASS:** повторный арм для той же задачи (двойной webhook / рестарт оркестратора) + не создаёт второе параллельное наблюдение и не теряет уже идущее. +- **FAIL:** дублируется наблюдение или теряется при рестарте. + +### AC-16 — never-raise +- **PASS:** любая ошибка опроса/сети/файлов/классификации логируется и НЕ роняет + worker / lifespan / конвейер других проектов. +- **FAIL:** исключение из наблюдения всплывает и ломает обслуживание других проектов. + +### AC-17 — уведомления +- **PASS:** ключевые события (наблюдение начато, DEGRADED, откат/алерт, чистое + завершение окна) уведомляются в Telegram и/или Plane-комментарием. +- **FAIL:** деградация/откат происходят молча. + +### AC-18 — документация обновлена (golden-source) +- **PASS:** в том же PR обновлены `CLAUDE.md` (артефакт `16-post-deploy-log.md`), + `docs/architecture/README.md` (описание пост-деплой наблюдения), `CHANGELOG.md`, + и заведён ADR work-item. +- **FAIL:** функционал есть, документация не обновлена (reviewer → REQUEST_CHANGES). diff --git a/docs/work-items/ORCH-021/04-test-plan.yaml b/docs/work-items/ORCH-021/04-test-plan.yaml new file mode 100644 index 0000000..3a9f8e6 --- /dev/null +++ b/docs/work-items/ORCH-021/04-test-plan.yaml @@ -0,0 +1,163 @@ +work_item: ORCH-021 +description: > + Тест-план пост-деплой мониторинга прода + авто-rollback. Упор на детерминированную + чистую логику классификации/решения (юнит, без сети/LLM) и на интеграцию + армирования наблюдения после deploy->done. Сетевые опросы и хук-вызовы мокируются. + Имена модулей/функций — целевые (src/post_deploy.py); архитектор уточняет точную + сигнатуру, тесты адаптируются под ADR. + +tests: + # --- Классификация деградации (чистая логика, ядро) --- + - id: TC-01 + type: unit + description: "HEALTHY: серия опросов без провалов (< порога) -> вердикт HEALTHY" + module: tests/test_post_deploy.py + covers: [AC-3] + expected: PASS + + - id: TC-02 + type: unit + description: "DEGRADED: N последовательных провалов health (== fail_threshold) -> DEGRADED" + module: tests/test_post_deploy.py + covers: [AC-4] + expected: PASS + + - id: TC-03 + type: unit + description: "DEGRADED по 5xx: доля 5xx выше порога при health=200 -> DEGRADED" + module: tests/test_post_deploy.py + covers: [AC-5] + expected: PASS + + - id: TC-04 + type: unit + description: "Нет ложного срабатывания: одиночный провал (1 < threshold) + восстановление -> HEALTHY" + module: tests/test_post_deploy.py + covers: [AC-6] + expected: PASS + + - id: TC-05 + type: unit + description: "Пороги читаются из Settings (env ORCH_*), изменение порога меняет вердикт на тех же данных" + module: tests/test_post_deploy.py + covers: [AC-11] + expected: PASS + + # --- Решение о реакции (чистая логика + self-hosting safety) --- + - id: TC-06 + type: unit + description: "Решение: не-self репо + auto_rollback=True + DEGRADED -> ROLLBACK" + module: tests/test_post_deploy.py + covers: [AC-7] + expected: PASS + + - id: TC-07 + type: unit + description: "Решение self-hosting: orchestrator + DEGRADED -> ALERT_ONLY (НИКОГДА не авто-rollback)" + module: tests/test_post_deploy.py + covers: [AC-8] + expected: PASS + + - id: TC-08 + type: unit + description: "Решение: HEALTHY -> NONE (реакции нет) для любого репо" + module: tests/test_post_deploy.py + covers: [AC-3] + expected: PASS + + # --- Условность / kill-switch --- + - id: TC-09 + type: unit + description: "post_deploy_applies: пусто в repos -> True только для orchestrator, False для enduro-trails" + module: tests/test_post_deploy.py + covers: [AC-2] + expected: PASS + + - id: TC-10 + type: unit + description: "kill-switch: post_deploy_monitor_enabled=False -> applies()=False для всех; наблюдение не армится" + module: tests/test_post_deploy.py + covers: [AC-10] + expected: PASS + + # --- Маппинг exit-code отката -> исход --- + - id: TC-11 + type: unit + description: "Откат exit 0 -> action_taken=ROLLBACK_OK" + module: tests/test_post_deploy.py + covers: [AC-7] + expected: PASS + + - id: TC-12 + type: unit + description: "Откат exit 1/2 (нет prev-образа / откат упал) -> ROLLBACK_FAILED + эскалация-алерт" + module: tests/test_post_deploy.py + covers: [AC-9] + expected: PASS + + # --- Артефакт --- + - id: TC-13 + type: unit + description: "16-post-deploy-log.md пишется с валидным YAML-frontmatter (post_deploy_status/action_taken), парсится yaml.safe_load" + module: tests/test_post_deploy.py + covers: [AC-13] + expected: PASS + + # --- never-raise --- + - id: TC-14 + type: unit + description: "Опрос при сетевой ошибке/таймауте -> консервативный результат (провал-как-down), исключение НЕ всплывает" + module: tests/test_post_deploy.py + covers: [AC-16] + expected: PASS + + - id: TC-15 + type: unit + description: "Ошибка записи артефакта (нет каталога/IO) -> логируется, функция возвращает False, не raise" + module: tests/test_post_deploy.py + covers: [AC-16, AC-13] + expected: PASS + + # --- Интеграция: армирование после deploy->done --- + - id: TC-16 + type: integration + description: "advance_stage deploy->done для orchestrator армит наблюдение (sentinel/job создан); для enduro-trails — нет" + module: tests/test_post_deploy_integration.py + covers: [AC-1, AC-2] + expected: PASS + + - id: TC-17 + type: integration + description: "Идемпотентность: повторный арм той же задачи (двойной webhook) не создаёт второе наблюдение" + module: tests/test_post_deploy_integration.py + covers: [AC-15] + expected: PASS + + - id: TC-18 + type: integration + description: "Полный цикл DEGRADED -> для не-self вызывается откат (хук замокан), пишется лог, шлётся уведомление" + module: tests/test_post_deploy_integration.py + covers: [AC-7, AC-13, AC-17] + expected: PASS + + - id: TC-19 + type: integration + description: "Self-hosting DEGRADED: тик НЕ вызывает рестарт/откат прод-контейнера, формирует алерт+approve-запрос" + module: tests/test_post_deploy_integration.py + covers: [AC-8, AC-17] + expected: PASS + + # --- Наблюдаемость и обратная совместимость --- + - id: TC-20 + type: integration + description: "GET /queue содержит блок post_deploy со снимком состояния" + module: tests/test_post_deploy_integration.py + covers: [AC-14] + expected: PASS + + - id: TC-21 + type: integration + description: "Регресс: существующие тесты deploy/staging/merge-gate/reconciler зелёные; STAGE_TRANSITIONS и QG_CHECKS не изменены" + module: tests/test_stages.py + covers: [AC-12] + expected: PASS From 2bdba532d53f6395e68d6d70c0c3d94d0ff0f5f7 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 14:01:19 +0000 Subject: [PATCH 3/9] architect(ET): auto-commit from architect run_id=306 --- docs/architecture/README.md | 40 +++- docs/architecture/adr/README.md | 3 +- .../adr/adr-0010-post-deploy-monitor.md | 85 +++++++ .../06-adr/ADR-001-post-deploy-monitor.md | 212 ++++++++++++++++++ .../ORCH-021/07-infra-requirements.md | 56 +++++ .../ORCH-021/08-data-requirements.md | 40 ++++ docs/work-items/ORCH-021/10-tech-risks.md | 20 ++ 7 files changed, 453 insertions(+), 3 deletions(-) create mode 100644 docs/architecture/adr/adr-0010-post-deploy-monitor.md create mode 100644 docs/work-items/ORCH-021/06-adr/ADR-001-post-deploy-monitor.md create mode 100644 docs/work-items/ORCH-021/07-infra-requirements.md create mode 100644 docs/work-items/ORCH-021/08-data-requirements.md create mode 100644 docs/work-items/ORCH-021/10-tech-risks.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index c8b2de5..11a6e47 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -91,6 +91,42 @@ sentinel-файлы (`/.deploy-state-//`), без мигр Подробнее: [adr-0007](adr/adr-0007-executable-self-deploy.md), детально — `docs/work-items/ORCH-036/06-adr/ADR-001-executable-self-deploy.md`. +### Post-deploy наблюдение прода + реакция на деградацию (ORCH-021 — design) +Конвейер заканчивался на `deploy → done` и **забывал про прод**: «успех» = health-check +в момент рестарта (~60с). Класс «зелёный деплой, красный прод» (прецедент ET-8 — +деградация через минуты под трафиком, health `200 ok`, фича сломана). ORCH-021 продлевает +ответственность **ЗА** `done`: для применимого репо после терминального перехода армится +наблюдение окна `post_deploy_window_s` (~15 мин) с интервалом `post_deploy_interval_s`; +деградация фиксируется по детерминированным порогам, при подтверждении — реакция. + +Механизм — **reserved-agent job `post-deploy-monitor`** (калька `deploy-finalizer`, НЕ +стадия и НЕ daemon): арм в `advance_stage` в блоке `next_stage == "done"` +(`post_deploy.arm_monitor`, sentinel `armed` = идемпотентность); тик перехватывается в +`launcher.launch_job` ДО `_spawn` → `stage_engine.run_post_deploy_monitor` (один опрос → +append в `series` → классификация → перепостановка с задержкой ИЛИ реакция+артефакт+`done`). +Чистая логика — новый leaf-модуль `src/post_deploy.py` (never-raise): `post_deploy_applies`, +`probe_signals` (`/health` 200+`{"status":"ok"}` + доля 5xx на `/status`,`/queue`), +`classify` (HEALTHY|DEGRADED — главный предмет юнит-тестов), `decide_action`, +sentinel-state, `write_post_deploy_log`. +- **Пороги (BR-3):** `DEGRADED` ⇔ `≥ post_deploy_fail_threshold` ПОСЛЕДОВАТЕЛЬНЫХ провалов + health ИЛИ доля 5xx `> post_deploy_5xx_threshold`; одиночный глюк → HEALTHY (нет ложных + откатов). +- **Реакция:** self-hosting (`orchestrator`) — ВСЕГДА `ALERT_ONLY` (Telegram+Plane, ручной + approve; тик НИКОГДА не откатывает/рестартит прод-контейнер); не-self + + `post_deploy_auto_rollback=true` → хук `--rollback` (`0→ROLLBACK_OK`, + `1/2→ROLLBACK_FAILED`+алерт); дефолт → `ALERT_ONLY`. +- **Артефакт** `16-post-deploy-log.md` (YAML-frontmatter `post_deploy_status`/ + `action_taken`/…) — машиночитаемо для петли уроков ORCH-8; best-effort. +- **Наблюдаемость** — блок `post_deploy` в `GET /queue` (образец `reconcile`). +- **Инварианты:** `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_deploy_status`, terminal-sync, + merge-gate, exit-коды хука (0/1/2), схема БД — НЕ меняются. Restart-safe (sentinel + `.post-deploy-state-//` + jobs-очередь). Kill-switch + `post_deploy_monitor_enabled`, область `post_deploy_repos` (пусто → self-hosting). + Условность как ORCH-35/36/43/58. + +Подробнее: [adr-0010](adr/adr-0010-post-deploy-monitor.md), детально — +`docs/work-items/ORCH-021/06-adr/ADR-001-post-deploy-monitor.md`. + ### Свежесть артефакта BUILD-ONCE: провенанс staging-образа (ORCH-058 — реализовано) BUILD-ONCE retag (ORCH-36) промоутит `SOURCE_IMAGE=orchestrator-orchestrator-staging` в прод **без rebuild**, полагаясь на «staging-образ свеж и провалидирован». Этой гарантии нет: @@ -197,7 +233,7 @@ never-raise на единицу работы; тишина при синхрон |--------|------|----------| | GET | `/health` | health check | | GET | `/status` | активные задачи (stage != done) | -| GET | `/queue` | очередь: counts + max_concurrency + resilience + reconcile (ORCH-053) + последние jobs | +| GET | `/queue` | очередь: counts + max_concurrency + resilience + reconcile (ORCH-053) + post_deploy (ORCH-021) + последние jobs | | POST | `/webhook/plane` | Plane webhook | | POST | `/webhook/gitea` | Gitea webhook (push, PR, CI status) | @@ -211,4 +247,4 @@ never-raise на единицу работы; тишина при синхрон Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.md). --- -*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled).* +*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — design, ветка feature/ORCH-021-post-deploy-rollback (`arch:major-change`; при реализации обновлять также при изменении src/post_deploy.py, src/stage_engine.py арм/run_post_deploy_monitor, src/agents/launcher.py перехват, флаги post_deploy_*; артефакт 16-post-deploy-log.md).* diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md index cac6d70..76ffba8 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -15,11 +15,12 @@ Per-work-item решения живут в `docs/work-items//06-adr/ADR-NNN- | adr-0007 | Исполняемый самодеплой стадии `deploy` (файл adr-0007-executable-self-deploy) | accepted | 2026-06-06 | ORCH-036 | | adr-0008 | Провенанс staging-образа перед BUILD-ONCE retag | accepted | 2026-06-06 | ORCH-058 | | adr-0009 | Толерантность staging-вердикта к инфраструктурным FAIL | accepted | 2026-06-07 | ORCH-061 | +| adr-0010 | Post-deploy мониторинг прода + реакция на деградацию | proposed | 2026-06-07 | ORCH-021 | > ⚠️ Историческая коллизия: номер `0007` занят двумя файлами — > `adr-0007-reconciler.md` (ORCH-053) и `adr-0007-executable-self-deploy.md` > (ORCH-036). Оба accepted; для новых сквозных ADR использовать следующий -> свободный номер (текущий максимум — `0009`). +> свободный номер (текущий максимум — `0010`). ## Формат **Контекст → Решение → Альтернативы → Последствия → Связи.** Статус: proposed / accepted / superseded. diff --git a/docs/architecture/adr/adr-0010-post-deploy-monitor.md b/docs/architecture/adr/adr-0010-post-deploy-monitor.md new file mode 100644 index 0000000..2fc7883 --- /dev/null +++ b/docs/architecture/adr/adr-0010-post-deploy-monitor.md @@ -0,0 +1,85 @@ +# adr-0010: Post-deploy мониторинг прода + реакция на деградацию + +- **Статус:** proposed (design) — реализация в ветке `feature/ORCH-021-post-deploy-rollback` +- **Дата:** 2026-06-07 +- **Задача:** ORCH-021 +- **Метка:** `arch:major-change` (новая под-компонента + новый reserved-agent job-kind) +- **Детальный ADR:** `docs/work-items/ORCH-021/06-adr/ADR-001-post-deploy-monitor.md` + +## Контекст +Конвейер заканчивается на `deploy → done`: `check_deploy_status` видит +`deploy_status: SUCCESS` → terminal-sync (Plane → Done, release merge-lease), и +оркестратор **забывает про прод**. «Успех» сегодня = health-check в момент рестарта +(~60с окно в `orchestrator-deploy-hook.sh`). Класс инцидентов «зелёный деплой, красный +прод» (прецедент **ET-8**): деградация проявляется через минуты под боевым трафиком, +health отвечает `200 ok`, фича сломана. Для self-hosting опасно вдвойне — сломанный +прод-орк (8500) обслуживает ВСЕ проекты из общего инстанса. + +## Решение +Продлить ответственность конвейера **ЗА** `done`: после терминального перехода для +применимого репо армится пост-деплой наблюдение окна `post_deploy_window_s` (дефолт +~15 мин) с интервалом `post_deploy_interval_s`; деградация фиксируется по +**детерминированным порогам**, при подтверждении выполняется реакция. + +**Механизм — reserved-agent job `post-deploy-monitor`** (калька `deploy-finalizer`, +ORCH-36), НЕ отдельная стадия и НЕ daemon-поток: +- **Арм:** в `stage_engine.advance_stage`, в блоке `next_stage == "done"`, при + `post_deploy.post_deploy_applies(repo)` → `post_deploy.arm_monitor(...)` (sentinel + `armed` = идемпотентность, первый job через `enqueue_job(available_at_delay_s=...)`). +- **Тик:** `launcher.launch_job` перехватывает `agent == "post-deploy-monitor"` ДО + `_spawn` → `stage_engine.run_post_deploy_monitor(job)`: один опрос сигналов, append в + персистентный `series`, классификация; HEALTHY и окно не истекло → перепостановка с + задержкой; иначе → реакция + артефакт + `mark_done`. +- **Чистая логика — новый leaf-модуль `src/post_deploy.py`** (never-raise, по образцу + `self_deploy.py`/`staging_verdict.py`): `post_deploy_applies`, `probe_signals` + (опрос `/health` + доля 5xx на `/status`,`/queue`), `classify` (HEALTHY|DEGRADED — + главный предмет юнит-тестов), `decide_action` (NONE|ROLLBACK|ALERT_ONLY с учётом + self-hosting), sentinel-state хелперы, `write_post_deploy_log`. + +**Сигналы и пороги (детерминированно, AC-3…AC-6):** `DEGRADED` ⇔ `≥ +post_deploy_fail_threshold` ПОСЛЕДОВАТЕЛЬНЫХ провалов health ИЛИ доля 5xx на окне `> +post_deploy_5xx_threshold`. Одиночный глюк < порога → HEALTHY (нет ложных откатов). + +**Реакция (BR-4/BR-5):** +- **Self-hosting (`orchestrator`) — ВСЕГДА `ALERT_ONLY`:** громкий Telegram + Plane, + запрос ручного approve отката. Тик НИКОГДА не откатывает/рестартит прод-контейнер + (структурный инвариант). Откат прод-орка, если оператор решит, — только detached + host-процесс (`self_deploy.initiate_deploy`), вне тика (MVP). +- **Не-self + `post_deploy_auto_rollback=True`:** хук `--rollback` с прод-env; exit + `0 → ROLLBACK_OK`, `1/2 → ROLLBACK_FAILED` + громкий алерт. +- Дефолт (`auto_rollback=False`) → `ALERT_ONLY`. + +**Артефакт `16-post-deploy-log.md`** (новый) с YAML-frontmatter (`post_deploy_status`, +`action_taken`, `window_s`, `checks_total/failed`) — машиночитаемо для петли уроков +ORCH-8; best-effort. **Наблюдаемость** — блок `post_deploy` в `GET /queue` (образец +`reconcile.status()`). + +## Альтернативы +- **Daemon-watchdog (как reconciler)** — отклонён: per-task серия опросов в памяти не + restart-safe (а деплой орка = рестарт); restart-safe-вариант требует тех же sentinel, + reserved-agent проще и уже имеет проверенную jobs+sentinel машинерию. +- **Отдельная пост-deploy стадия + QG** — отклонён: меняет `STAGE_TRANSITIONS`/ + `QG_CHECKS`, ломает семантику терминального `done`; наблюдение принципиально ПОСЛЕ + `done`. +- **Авто-rollback прод-орка из тика** — отклонён (self-hosting safety): групповой риск; + контейнер не откатит себя надёжно. Self → alert + ручной approve (как ORCH-54). +- **Колонка в `tasks`** — отклонён: миграция на проде; sentinel-файлы restart-safe + (как ORCH-36/53/58). + +## Последствия +- Класс «зелёный деплой, красный прод» закрыт измеримыми порогами; деградация = + сигнал для ORCH-8. +- Реестры (`STAGE_TRANSITIONS`/`QG_CHECKS`), контракт `check_deploy_status`, + terminal-sync, merge-gate, exit-code-контракт хука, схема БД — **не меняются**. +- Дефолты безопасны: kill-switch on, auto-rollback off, self только alert. +- Ограничение: монитор self бежит внутри наблюдаемого прода — полностью wedged + контейнер = пропущенный тик/алерт (known MVP gap; внешний watchdog — follow-up). +- Self-hosting: тик не рестартит/не роняет прод-контейнер; kill-switch + `post_deploy_monitor_enabled` обязателен; поэтапный раскат через `post_deploy_repos`. + +## Связи +adr-0007-executable-self-deploy (ORCH-36 — sentinel/detached-host/finalizer образец, +`map_exit_code_to_status`), adr-0007-reconciler (ORCH-53 — daemon/`status()` образец, +отклонён как основной механизм), adr-0006 (merge-gate — условность/флаги раската), +adr-0003 (staging-gate — образец условности), adr-0008 (provenance — `.deploy-prev-image`/ +хук-откат). Прецедент ET-8. Будущее: ORCH-8 (петля уроков), ORCH-54 (полный авто). diff --git a/docs/work-items/ORCH-021/06-adr/ADR-001-post-deploy-monitor.md b/docs/work-items/ORCH-021/06-adr/ADR-001-post-deploy-monitor.md new file mode 100644 index 0000000..7e6d225 --- /dev/null +++ b/docs/work-items/ORCH-021/06-adr/ADR-001-post-deploy-monitor.md @@ -0,0 +1,212 @@ +# ADR-001 (ORCH-021): Post-deploy мониторинг прода + реакция на деградацию + +## Статус +Proposed (design) — реализация в ветке `feature/ORCH-021-post-deploy-rollback`. +Сквозной индексный ADR: `docs/architecture/adr/adr-0010-post-deploy-monitor.md`. +Помечено `arch:major-change` (новая под-компонента + новый reserved-agent job-kind). + +## Контекст +Конвейер заканчивается на `deploy → done` (`check_deploy_status` видит +`deploy_status: SUCCESS` → terminal-sync, Plane → Done, release merge-lease). После +этого оркестратор **забывает про прод**. «Успех» сегодня = прохождение health-check +в момент рестарта (10×6с в `scripts/orchestrator-deploy-hook.sh`) — узкое окно ~60с. + +Класс инцидентов «зелёный деплой, красный прод» (прецедент **ET-8**): деградация +проявляется через минуты под боевым трафиком (прогрев кэшей, фоновые миграции, +утечки, рост 5xx), health отвечает `200 ok`, но фича сломана. Для self-hosting это +критично: сломанный прод-орк (8500) обслуживает ВСЕ проекты из общего инстанса. + +BRD/ТЗ задают требования (BR-1…BR-11, AC-1…AC-18) и оставляют архитектору **три +открытых вопроса** (BRD §7): (1) где живёт наблюдение — стадия / watchdog-daemon / +reserved-agent job; (2) механизм self-rollback; (3) пороги/веса сигналов. + +Существующие переиспользуемые механики: +- **deploy-finalizer** (ORCH-36, `stage_engine.run_deploy_finalizer` + перехват в + `launcher.launch_job` ДО `_spawn`) — детерминированный no-LLM reserved-agent job, + само-перепостановка через `enqueue_job(available_at_delay_s=...)`, defer-budget, + restart-safe (jobs-очередь + sentinel-файлы `.deploy-state-//`). +- **self_deploy.py** — sentinel-state хелперы (`write_marker`/`has_marker`/ + `read_result`/`clear_state`), detached host-процесс (`build_deploy_command`/ + `initiate_deploy`: ssh + setsid), `map_exit_code_to_status`, `self_deploy_applies`. +- **reconciler.py** — daemon-поток + `status()` в `GET /queue`. +- **хук `--rollback`** (`do_rollback`): retag `PREV_IMAGE_FILE` → `TARGET_IMAGE` + + рестарт + health, коды 0 / 1 (нет prev-образа) / 2 (rollback тоже упал). +- **Условность** ORCH-35/36/43/58: `is_self_hosting_repo`, флаг + CSV-репо. + +## Решение + +### 1. Механизм наблюдения — reserved-agent job `post-deploy-monitor` (Вариант B) +Наблюдение реализуется как **детерминированный no-LLM reserved-agent job**, точная +калька **deploy-finalizer**. Один «тик» наблюдения = один job: он делает ОДИН опрос +сигналов, обновляет персистентные счётчики в sentinel-файлах, классифицирует и либо +**перепостанавливает себя** с задержкой `post_deploy_interval_s` (окно не истекло и +ещё не DEGRADED), либо завершает наблюдение (DEGRADED → реакция; либо окно истекло → +HEALTHY). Это «watchdog поверх очереди»: между тиками job не выполняется (он +запланирован в будущем через `available_at_delay_s`), worker свободен для других +проектов — ровно как defer у finalizer. + +**Почему НЕ daemon-watchdog (Вариант A, как reconciler):** daemon тикает глобально, а +не per-task; серию опросов (последовательные провалы health, доля 5xx на окне) пришлось +бы держать в памяти → теряется/двоится при рестарте (а сам деплой орка = рестарт). Чтобы +сделать daemon restart-safe, всё равно нужны персистентные per-task счётчики в sentinel — +тогда reserved-agent проще и уже имеет проверенную restart-safe машинерию (jobs-очередь ++ `requeue_running_jobs` + sentinels). Per-task жизненный цикл естественно ложится на +job-цепочку, а не на глобальный sweep. + +**Почему НЕ отдельная пост-deploy стадия (Вариант C):** меняет `STAGE_TRANSITIONS` + +реестр `QG_CHECKS` (нарушает AC-12, ТЗ §2.8 — явно непредпочтительно); ломает семантику +`deploy → done` как терминального перехода (Plane уже Done). Наблюдение происходит +**ПОСЛЕ** `done` — «продление ответственности ЗА done», а не новая стадия конвейера. + +### 2. Арм наблюдения — хук в terminal-блоке `advance_stage` +В `stage_engine.advance_stage`, в существующем блоке `next_stage == "done"` (после +`set_issue_done` и `release_merge_lease`), добавляется арм: +``` +if next_stage == "done" and post_deploy.post_deploy_applies(repo): + post_deploy.arm_monitor(repo, work_item_id, branch, task_id) +``` +`arm_monitor` (never-raise): если sentinel `armed` отсутствует → создаёт state-dir, +пишет `armed` (идемпотентность, по образцу `INITIATED`), инициализирует `series`-файл, +ставит первый `post-deploy-monitor` job через `enqueue_job(available_at_delay_s= +post_deploy_interval_s)`. Если `armed` уже есть → no-op (двойной webhook / reconciler +F-1 / finalizer Phase C могут довести `done` повторно — AC-15). Выключенный +kill-switch / неприменимый репо → `post_deploy_applies` False → арма нет (AC-2/AC-10). + +### 3. Чистая логика — новый leaf-модуль `src/post_deploy.py` (never-raise) +По образцу `self_deploy.py` / `staging_verdict.py`. Импортирует только config (+lazy +`qg.checks.is_self_hosting_repo`), НЕ импортирует `stage_engine`/`launcher`. Функции: +- **`post_deploy_applies(repo) -> bool`** — флаг `post_deploy_monitor_enabled` + + CSV `post_deploy_repos` (пусто → только self-hosting). Калька `self_deploy_applies`. +- **`probe_signals(base_url) -> ProbeResult`** — один опрос: `GET /health` (HTTP 200 + + `{"status":"ok"}`) и ключевые эндпоинты `/status`, `/queue` (учёт доли 5xx). + Сеть/таймаут → консервативный «провал»-результат, не исключение. +- **`classify(series, fail_threshold, 5xx_threshold) -> "HEALTHY"|"DEGRADED"`** — + чистая, без сети, **главный предмет юнит-тестов** (детерминированная, как + `compute_staging_verdict`): `DEGRADED` если `≥ fail_threshold` ПОСЛЕДОВАТЕЛЬНЫХ + провалов health (AC-4) ИЛИ доля 5xx на окне `> 5xx_threshold` (AC-5). Иначе + `HEALTHY` (одиночный провал < порога с восстановлением → HEALTHY, AC-3/AC-6). +- **`decide_action(repo, verdict) -> "NONE"|"ROLLBACK"|"ALERT_ONLY"`** — чистая: + `HEALTHY → NONE`; `DEGRADED` + self-hosting → `ALERT_ONLY` (BR-5/AC-8, ВСЕГДА); + `DEGRADED` + не-self + `post_deploy_auto_rollback=True` → `ROLLBACK`; иначе → + `ALERT_ONLY`. +- **Sentinel-state хелперы** (state-dir `.post-deploy-state-//`, по образцу + `self_deploy._state_dir`): `armed`, `series` (JSON-список результатов опросов, + append каждый тик — restart-safe счётчики), `done`. `read_series`/`append_probe`/ + `mark_done`/`has_marker` — never-raise. +- **`write_post_deploy_log(...)`** — артефакт `16-post-deploy-log.md`, best-effort + (по образцу `self_deploy.write_deploy_log`). +- **`build_rollback_command(repo)`** — argv хука `--rollback` с прод-env (как + `build_deploy_command`, но action=`--rollback`; переиспользует `deploy_prod_*`). + +### 4. Исполнение тика — `stage_engine.run_post_deploy_monitor(job)` + перехват в launcher +По образцу `run_deploy_finalizer` / `_run_deploy_finalizer_job`: +`launcher.launch_job` перехватывает `agent == "post-deploy-monitor"` ДО `_spawn` → +`stage_engine.run_post_deploy_monitor(job)`. Алгоритм тика (never-raise): +1. `mark_done` уже стоит → no-op (AC-15, защита от дубля). +2. `probe = post_deploy.probe_signals(base_url)`; `append_probe(series, probe)`. +3. `verdict = classify(series, ...)`. +4. **Если `HEALTHY` и окно не истекло** (число тиков < `window_s/interval_s`) → + перепостановка `post-deploy-monitor` через `available_at_delay_s=interval_s` + (как finalizer defer; счётчик тиков — из jobs-очереди/`series`, restart-safe). +5. **Если `HEALTHY` и окно истекло** → исход `NONE`, `write_post_deploy_log(HEALTHY, + NONE)`, `mark_done`, нотификация «окно завершилось чисто» (BR-6/AC-17). +6. **Если `DEGRADED`** → `action = decide_action(...)`; исполнить реакцию (§5), + `write_post_deploy_log`, `mark_done`, нотификации. + +`mark_done` + sentinel `armed` дают идемпотентность; jobs-очередь + +`requeue_running_jobs` + `series` дают restart-safe (AC-15). Бюджет тиков bounded +(`window_s/interval_s`) — анти-livelock, как `deploy_finalize_max_attempts`. + +### 5. Реакция на деградацию +- **Self-hosting (`orchestrator`), всегда (BR-5/AC-8):** `ALERT_ONLY`. НЕ откатывать + и НЕ рестартить прод-контейнер в тике. Громкий Telegram + Plane-коммент с запросом + ручного approve отката (по образцу deploy Phase A CTA). `action_taken: ALERT_ONLY`. + Откат самого прод-орка (если оператор решит) — ТОЛЬКО через detached host-процесс + (контейнер не откатит себя, умирая); переиспользуется механика + `self_deploy.initiate_deploy`, но в MVP она вне тика наблюдения (ручной approve → + отдельный путь, как ORCH-54 для авто-deploy). Тик self НИКОГДА не запускает хук + `--rollback` (структурный инвариант). +- **Не-self + `post_deploy_auto_rollback=True` (AC-7):** вызвать хук `--rollback` с + прод-env (`build_rollback_command`). Маппинг exit-code по смыслу + `map_exit_code_to_status`: `0 → ROLLBACK_OK`; `1/2 → ROLLBACK_FAILED` + громкий + Telegram о необходимости ручного вмешательства (AC-9). Целевой контейнер не есть + orchestrator → его рестарт безопасен для конвейера. +- **Не-self + auto_rollback=False (дефолт):** `ALERT_ONLY`. + +### 6. Артефакт `16-post-deploy-log.md` (новый, машиночитаемый) +YAML-frontmatter (канон гейтов; для петли уроков ORCH-8, BR-10): +``` +--- +post_deploy_status: HEALTHY | DEGRADED +action_taken: NONE | ROLLBACK_OK | ROLLBACK_FAILED | ALERT_ONLY +work_item: +window_s: +checks_total: +checks_failed: +--- +``` +Тело — человекочитаемая сводка опросов. Best-effort (отсутствие файла ничего не роняет, +AC-13). **Не** читается ни одним гейтом — наблюдение происходит после `done`. + +### 7. Конфигурация — `src/config.py` (env-префикс `ORCH_`) +- `post_deploy_monitor_enabled: bool = True` — глобальный kill-switch (BR-8/AC-10). +- `post_deploy_repos: str = ""` — CSV применимых репо; пусто → только self-hosting. +- `post_deploy_window_s: int = 900` — окно наблюдения (~15 мин, BR-1). +- `post_deploy_interval_s: int = 30` — интервал опросов. +- `post_deploy_fail_threshold: int = 3` — N послед. провалов health → DEGRADED. +- `post_deploy_5xx_threshold: float = 0.5` — порог доли 5xx → DEGRADED. +- `post_deploy_auto_rollback: bool = False` — глоб. разрешение авто-отката (для self + всегда требует approve, BR-5). +- `post_deploy_base_url: str = "http://localhost:8500"` — наблюдаемый прод. +- Параметры отката — переиспользовать существующие `deploy_prod_*` (новых дублей нет). + +### 8. Наблюдаемость — блок `post_deploy` в `GET /queue` (BR-9/AC-14) +По образцу блока `reconcile` (метод `status()`): `enabled`, `window_s`, `interval_s`, +активные наблюдения (по sentinel `armed` без `done`), последний исход +(`post_deploy_status`/`action_taken`). Best-effort, never-raise. + +### Инварианты (НЕ меняются) +`STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status`, +момент вердикта `deploy_status`, БАГ-8 откат, terminal-sync `deploy → done`, merge-gate, +exit-code-контракт хука (0/1/2), схема БД. Условность как ORCH-35/36/43/58. Never-raise +во всём наблюдении (AC-16). Тик self НИКОГДА не рестартит прод-контейнер (AC-8). + +## Альтернативы +- **Daemon-watchdog (как reconciler)** — отклонён: per-task серия в памяти не + restart-safe; restart-safe-вариант требует тех же sentinel-счётчиков → reserved-agent + проще и уже проверен. +- **Отдельная пост-deploy стадия + QG** — отклонён: меняет реестры (AC-12), ломает + семантику терминального `done`; наблюдение принципиально ПОСЛЕ `done`. +- **Авто-rollback прод-орка из тика** — отклонён (BR-5): контейнер не откатит себя + надёжно; групповой риск для всех проектов. Self → только ALERT + ручной approve. +- **Новая колонка в `tasks` для отметки наблюдения** — отклонён: миграция на проде + (риск, как в adr-0007); sentinel-файлы достаточны и restart-safe (как ORCH-36/53/58). +- **Прометей/APM** — вне рамок (BR out-of-scope): опираемся на существующие + HTTP-эндпоинты, не вводим сбор метрик. + +## Последствия +- Класс «зелёный деплой, красный прод» закрыт измеримыми порогами; деградация — + машиночитаемый сигнал для петли уроков (ORCH-8). +- Плюс: максимальное переиспользование проверенной finalizer/sentinel/hook-машинерии; + нулевая миграция БД; реестры не тронуты; дефолты безопасны (auto-rollback off, self + только alert). +- Минус/ограничение: монитор self бежит ВНУТРИ наблюдаемого прод-контейнера — если + контейнер полностью wedged, worker может не выполнить тик и алерта не будет (gap). + Это known limitation MVP; внешний независимый watchdog — follow-up (вне рамок). +- Минус: каждый тик на короткое время занимает single-worker (`max_concurrency=1`); + митигируется коротким опросом (~секунды) и `interval_s` между тиками (defer не держит + worker), как finalizer. +- Доменный smoke результата фичи (BR-11) — follow-up; MVP = health + 5xx. + +## Связи +- **ET-8** — обоснование (deploy SUCCESS, прод не работает). +- **adr-0007-executable-self-deploy** (ORCH-36) — sentinel-паттерн, detached + host-процесс, `map_exit_code_to_status`, deploy-finalizer reserved-agent (образец). +- **adr-0007-reconciler** (ORCH-53) — daemon/`status()` образец (рассмотрен и отклонён + как основной механизм; `status()`-снимок в `/queue` переиспользуется). +- **adr-0006-merge-gate** / **adr-0003-staging-gate** — образец условности и флагов + раската (`*_enabled` + `*_repos`). +- **adr-0008-staging-image-provenance** — `.deploy-prev-image` / хук-механика отката. +- **ORCH-8** — петля уроков (потребитель `16-post-deploy-log.md`). +- **ORCH-54** — будущий полный авто (включая авто-approve отката self), по аналогии + с авто-deploy. diff --git a/docs/work-items/ORCH-021/07-infra-requirements.md b/docs/work-items/ORCH-021/07-infra-requirements.md new file mode 100644 index 0000000..aca365a --- /dev/null +++ b/docs/work-items/ORCH-021/07-infra-requirements.md @@ -0,0 +1,56 @@ +# 07 — Инфраструктурные требования (ORCH-021) + +> Топология НЕ меняется. Фича опирается на уже существующие HTTP-эндпоинты прода и +> существующий деплой-хук. Этот документ фиксирует, какие инфра-предпосылки должны +> выполняться, чтобы наблюдение и реакция работали. + +## 1. Топология — без изменений +- Прод `orchestrator` (8500), staging `orchestrator-staging` (8501), один сервер + mva154 (см. `docs/operations/INFRA.md`). Новых контейнеров/портов/сервисов нет. +- Наблюдение — внутрипроцессный reserved-agent job в worker'е прод-контейнера. + Daemon-потоков не добавляется (в отличие от reconciler). + +## 2. Наблюдаемый прод — HTTP-эндпоинты +- Монитор опрашивает `post_deploy_base_url` (дефолт `http://localhost:8500`): + - `GET /health` → ожидается HTTP 200 + тело `{"status":"ok"}` (BR-2); + - `GET /status`, `GET /queue` → учёт доли HTTP 5xx (BR-2). +- Эндпоинты уже существуют (`src/main.py`). Новых эндпоинтов фича НЕ вводит + (out-of-scope APM/метрики). +- Для self-hosting `base_url=localhost:8500` означает: монитор бьёт по собственному + контейнеру. Это допустимо для MVP (см. риск R-1 в `10-tech-risks.md`). + +## 3. Деплой-хук `--rollback` — предпосылки реакции +- Реакция ROLLBACK (только не-self + `post_deploy_auto_rollback=True`) вызывает + `scripts/orchestrator-deploy-hook.sh --rollback` с прод-env (переиспользуются + `deploy_prod_*`: `TARGET_SERVICE`/`TARGET_PORT`/`TARGET_IMAGE`/`COMPOSE_PROFILE`/ + `PREV_IMAGE_FILE`), по образцу `self_deploy.build_deploy_command`. +- Предпосылка: при штатном деплое хук сохраняет предыдущий образ в + `PREV_IMAGE_FILE` (`.deploy-prev-image-prod`). Без снимка → хук вернёт exit 1 + («нет prev-образа») → `ROLLBACK_FAILED` + алерт (AC-9). Контракт exit-кодов хука + (0/1/2) НЕ меняется. +- **Self-hosting:** откат прод-орка хуком в тике ЗАПРЕЩЁН (контейнер не откатит себя, + умирая). Если оператор по алерту решит откатить — только detached host-процесс + (ssh + setsid, механика `self_deploy.initiate_deploy`), как у Phase B самодеплоя. + Предпосылки для detached-пути (ssh-доступ host, shared-mount state-dir) уже + выполнены для ORCH-36; в MVP detached-откат self вне тика наблюдения. + +## 4. Restart-safe состояние — shared mount +- Состояние наблюдения — sentinel-файлы под `.post-deploy-state-//` + (`armed`, `series`, `done`) на том же mount `settings.repos_dir`, что и + `.deploy-state-*` (ORCH-36). Миграции БД нет (см. `08-data-requirements.md`). +- `requeue_running_jobs` (ORCH-1) восстанавливает claimed `post-deploy-monitor` job + после рестарта; `series` хранит счётчики опросов → наблюдение продолжается + с того же места (BR-7/AC-15). + +## 5. Конфигурация окружения (env `ORCH_*`) +Новые ключи (дефолты безопасны, в `.env`/`.env.staging` по необходимости): +`post_deploy_monitor_enabled` (kill-switch, дефолт true), `post_deploy_repos` (CSV, +пусто → self-hosting), `post_deploy_window_s` (900), `post_deploy_interval_s` (30), +`post_deploy_fail_threshold` (3), `post_deploy_5xx_threshold` (0.5), +`post_deploy_auto_rollback` (false), `post_deploy_base_url` (localhost:8500). +Параметры отката — существующие `deploy_prod_*`, новых дублей не вводить. + +## 6. Чего НЕ требуется +- Новых контейнеров, портов, сетевых правил, секретов. +- Prometheus / Grafana / APM (out-of-scope). +- Изменений compose-топологии или деплой-пути не-self репо. diff --git a/docs/work-items/ORCH-021/08-data-requirements.md b/docs/work-items/ORCH-021/08-data-requirements.md new file mode 100644 index 0000000..bc7b040 --- /dev/null +++ b/docs/work-items/ORCH-021/08-data-requirements.md @@ -0,0 +1,40 @@ +# 08 — Требования к данным / схеме БД (ORCH-021) + +## Вывод: миграция БД НЕ требуется +Состояние наблюдения хранится в **sentinel-файлах** (restart-safe, без миграции — +по образцу ORCH-36/53/58), а не в таблицах. Реестры и схема не меняются (AC-12). + +## 1. Существующие таблицы — без изменений +- `events`, `tasks`, `agent_runs`, `jobs` — структура не меняется. +- В `tasks` НЕ вводится колонка статуса/окна наблюдения (намеренно — миграция на + проде = риск, как обосновано в adr-0007; альтернатива отклонена в ADR-001 §Альтернативы). + +## 2. Очередь `jobs` — переиспользование, без схемы +- `post-deploy-monitor` — новый **job-kind** (значение в существующей колонке + `agent`/`task_content`), НЕ новая колонка. Ставится через существующий + `enqueue_job(..., available_at_delay_s=...)` (ORCH-1). +- Счётчик тиков/деферов восстанавливается из jobs-очереди (как + `_deploy_finalize_defer_count` считает по `task_content LIKE`), restart-safe. + +## 3. Sentinel-состояние (файлы, не БД) +State-dir `.post-deploy-state-//` на `settings.repos_dir` +(по образцу `.deploy-state-*`): +| Файл | Назначение | +|------|------------| +| `armed` | наблюдение заармлено (идемпотентность арма; калька `INITIATED`) | +| `series` | JSON-список результатов опросов (счётчики health-fail / 5xx; restart-safe) | +| `done` | наблюдение завершено (защита от повторной обработки) | + +Все обращения — never-raise (по образцу `self_deploy.has_marker`/`write_marker`/ +`read_result`). Отсутствие/битость файла → консервативный фоллбэк, не исключение. + +## 4. Артефакт `16-post-deploy-log.md` — файл репозитория, не БД +Машиночитаемый YAML-frontmatter (`post_deploy_status`, `action_taken`, `window_s`, +`checks_total`, `checks_failed`) пишется best-effort в `docs/work-items//`; в БД +не реплицируется. Источник для петли уроков ORCH-8 (BR-10). + +## 5. Очистка состояния +По завершении окна / реакции `done`-маркер ставится; state-dir можно чистить +best-effort (по образцу `self_deploy.clear_state`) — необязательно для корректности, +но желательно для гигиены. Stale-`armed` без `done` после краха → виден в `/queue` +как «активное наблюдение» и доигрывается восстановленным job'ом. diff --git a/docs/work-items/ORCH-021/10-tech-risks.md b/docs/work-items/ORCH-021/10-tech-risks.md new file mode 100644 index 0000000..5ddd1e8 --- /dev/null +++ b/docs/work-items/ORCH-021/10-tech-risks.md @@ -0,0 +1,20 @@ +# 10 — Технические риски (ORCH-021) + +| # | Риск | Вероятн. | Влияние | Митигация | +|---|------|----------|---------|-----------| +| R-1 | **Монитор self бежит внутри наблюдаемого прода.** Полностью wedged прод-контейнер → worker не выполнит тик → деградация не замечена, алерта нет. | Сред. | Высок. | Known MVP limitation (зафиксировано в ADR-001 §Последствия). Health в момент рестарта (хук) + reconciler ловят часть случаев. Внешний независимый watchdog — follow-up (вне рамок). | +| R-2 | **Ложный авто-rollback** по сетевому глюку. | Низк. | Высок. | Пороги по N ПОСЛЕДОВАТЕЛЬНЫХ провалов + доля 5xx на окне (BR-3/AC-6), а не разовый провал. Self ВСЕГДА `ALERT_ONLY` (BR-5). `auto_rollback=False` по умолчанию. | +| R-3 | **Авто-rollback прод-орка убивает инструмент всех проектов.** | Низк. | Критич. | Структурный инвариант: тик self НИКОГДА не откатывает/рестартит прод-контейнер (AC-8). Self → только alert + ручной approve. Откат self — только detached host-процесс вне тика. | +| R-4 | **Нет prev-образа** при ROLLBACK → откат невозможен. | Сред. | Сред. | Хук возвращает exit 1 → `ROLLBACK_FAILED` + громкий алерт (AC-9), деградация не проглатывается тихо. | +| R-5 | **Дубль/потеря наблюдения** при двойном webhook / рестарте. | Сред. | Сред. | Идемпотентность: sentinel `armed` (арм-гард) + `done` (защита от повторной обработки) + restart-safe jobs-очередь + `series` (AC-15). По образцу finalizer. | +| R-6 | **Исключение в наблюдении роняет worker / конвейер других проектов.** | Низк. | Высок. | Контракт never-raise во всём `post_deploy.py` и `run_post_deploy_monitor` (AC-16), по образцу `self_deploy`/`staging_verdict`. | +| R-7 | **Тик занимает single-worker** (`max_concurrency=1`) → задержка других задач. | Низк. | Низк. | Опрос короткий (~секунды), между тиками job не выполняется (defer через `available_at_delay_s`) — worker свободен, как у finalizer. Окно bounded (`window_s/interval_s`). | +| R-8 | **Скрытое изменение контракта** (реестры/гейты/exit-коды/схема). | Низк. | Высок. | Инвариант: `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_deploy_status`/terminal-sync/merge-gate/exit-коды/схема БД НЕ меняются (AC-12). Существующие тесты deploy/staging/merge-gate должны остаться зелёными. | +| R-9 | **5xx на `/queue`/`/status` из-за самого монитора** (рекурсивная нагрузка). | Низк. | Низк. | Интервал `post_deploy_interval_s` (30с) — низкая частота; опрос лёгкий GET. | +| R-10 | **Артефакт `16-post-deploy-log.md` не пишется / невалиден** → петля уроков без данных. | Низк. | Низк. | Best-effort запись с валидным frontmatter (AC-13); отсутствие файла ничего не роняет. Парсинг — defensive. | + +## Эскалация +- Изменение помечено `arch:major-change` (новая под-компонента `src/post_deploy.py` + + новый reserved-agent job-kind `post-deploy-monitor`). +- R-1 (gap наблюдения для wedged self-контейнера) — кандидат на отдельную задачу + (внешний watchdog), вне рамок ORCH-021. From 2f4c553fd8250395f046e1ca9857b1387d433ddd Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 14:16:12 +0000 Subject: [PATCH 4/9] feat(post-deploy): post-deploy prod monitoring + degradation reaction (ORCH-021) Extend pipeline responsibility past deploy->done: after the terminal transition for an applicable repo, arm a ~15min observation window that probes prod and reacts to a degradation the restart-time health-check missed ("green deploy, red prod"). - src/post_deploy.py: new leaf module (config + lazy qg/db only). Sentinel-file restart-safe state (.post-deploy-state-//), no DB migration. probe_signals/classify/decide_action/run_rollback, all never-raise. - Reserved-agent job `post-deploy-monitor` (no-LLM, Variant B, calque of deploy-finalizer): self-requeues each tick via enqueue_job. - Deterministic classify: DEGRADED iff >= fail_threshold consecutive health failures OR window 5xx ratio > 5xx_threshold; fail-safe HEALTHY. - Self-hosting invariant (BR-5/AC-8): a tick NEVER restarts the prod orchestrator container -> orchestrator is ALWAYS ALERT_ONLY. - Conditionality (ORCH-35/36/43/58): kill-switch + CSV repos, empty -> self-hosting only. - QG_CHECKS / STAGE_TRANSITIONS / schema unchanged (AC-12). - Docs: CHANGELOG, CLAUDE artefact list (16-post-deploy-log.md), architecture README, .env.example (ORCH_POST_DEPLOY_*). Refs: ORCH-021 Co-Authored-By: Claude Opus 4.7 --- .env.example | 24 + CHANGELOG.md | 1 + CLAUDE.md | 2 +- docs/architecture/README.md | 4 +- src/agents/launcher.py | 26 ++ src/config.py | 31 ++ src/main.py | 2 + src/post_deploy.py | 614 ++++++++++++++++++++++++++ src/stage_engine.py | 148 +++++++ tests/test_deploy_terminal_sync.py | 4 + tests/test_post_deploy.py | 210 +++++++++ tests/test_post_deploy_integration.py | 259 +++++++++++ 12 files changed, 1322 insertions(+), 3 deletions(-) create mode 100644 src/post_deploy.py create mode 100644 tests/test_post_deploy.py create mode 100644 tests/test_post_deploy_integration.py diff --git a/.env.example b/.env.example index eb9fbfa..9a74109 100644 --- a/.env.example +++ b/.env.example @@ -116,3 +116,27 @@ ORCH_RECONCILE_GRACE_DEFAULT_S=600 ORCH_RECONCILE_GRACE_OVERRIDES_JSON= ORCH_RECONCILE_NOTIFY_UNBLOCK=true ORCH_RECONCILE_SKIP_BLOCKED_ENABLED=true + +# ORCH-021: post-deploy production monitoring + degradation reaction. After the +# terminal deploy->done transition for an applicable repo, a reserved-agent job +# `post-deploy-monitor` (no LLM, modelled on deploy-finalizer) probes prod over a +# window and reacts to a degradation the restart-time health-check missed (class +# "green deploy, red prod", precedent ET-8). State is in sentinel files +# (.post-deploy-state-//), no DB migration. +# MONITOR_ENABLED -> global kill-switch; false -> pipeline is 1:1 as before ORCH-021. +# REPOS -> CSV of repos where monitoring is REAL; empty -> only self-hosting. +# WINDOW_S -> observation window length (~15 min). +# INTERVAL_S -> seconds between probe ticks. +# FAIL_THRESHOLD -> N CONSECUTIVE health failures -> DEGRADED. +# 5XX_THRESHOLD -> window 5xx ratio above this -> DEGRADED. +# AUTO_ROLLBACK -> allow auto-rollback; acts ONLY for non-self repos. Self-hosting +# is ALWAYS ALERT_ONLY (a tick NEVER restarts the prod container). +# BASE_URL -> base URL of the observed prod instance. +ORCH_POST_DEPLOY_MONITOR_ENABLED=true +ORCH_POST_DEPLOY_REPOS= +ORCH_POST_DEPLOY_WINDOW_S=900 +ORCH_POST_DEPLOY_INTERVAL_S=30 +ORCH_POST_DEPLOY_FAIL_THRESHOLD=3 +ORCH_POST_DEPLOY_5XX_THRESHOLD=0.5 +ORCH_POST_DEPLOY_AUTO_ROLLBACK=false +ORCH_POST_DEPLOY_BASE_URL=http://localhost:8500 diff --git a/CHANGELOG.md b/CHANGELOG.md index 655c084..054341f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ## [Unreleased] ### Added +- **Post-deploy наблюдение прода + реакция на деградацию** (ORCH-021): конвейер больше не «забывает про прод» после `deploy → done` — раньше «успех» означал прохождение health-check лишь в момент рестарта (~60с-окно хука), и класс инцидентов «зелёный деплой, красный прод» (прецедент ET-8: деградация проявляется через минуты под трафиком, `/health` отвечает `200 ok`, но фича сломана) не ловился. ORCH-021 продлевает ответственность **ЗА** `done`: для применимого репозитория после терминального перехода армится наблюдение окна `post_deploy_window_s` (~15 мин) с интервалом `post_deploy_interval_s`; деградация фиксируется детерминированными порогами, при подтверждении — реакция. Новый leaf-модуль `src/post_deploy.py` (контракт «never raise», по образцу `self_deploy.py`/`staging_verdict.py`; импортирует только config + lazy `qg.checks.is_self_hosting_repo`): `post_deploy_applies` (условность раската), `probe_signals` (один опрос `/health` 200+`{"status":"ok"}` + доля 5xx на `/status`,`/queue`; сеть/таймаут → консервативный провал, не исключение), `classify` (чистая, главный предмет юнит-тестов: `DEGRADED` ⇔ `≥ post_deploy_fail_threshold` ПОСЛЕДОВАТЕЛЬНЫХ провалов health ИЛИ доля 5xx окна `> post_deploy_5xx_threshold`; иначе `HEALTHY` — одиночный глюк не откатывает), `decide_action` (self-hosting → ВСЕГДА `ALERT_ONLY`; не-self + `post_deploy_auto_rollback=true` → `ROLLBACK`; иначе `ALERT_ONLY`), `map_rollback_exit_code` (`0→ROLLBACK_OK`, иначе `ROLLBACK_FAILED`), sentinel-state хелперы (`armed`/`series`/`done` под `/.post-deploy-state-//`, restart-safe счётчики), `build_rollback_command`/`run_rollback` (ssh-хук `--rollback` с прод-env, синхронно — только для не-self), `build/write_post_deploy_log` (артефакт `16-post-deploy-log.md`), `arm_monitor` (идемпотентный арм + первый отложенный job), `status` (снимок для `/queue`). **Механизм наблюдения — reserved-agent job `post-deploy-monitor`** (детерминированный, no-LLM, калька `deploy-finalizer`, НЕ стадия и НЕ daemon): арм в `stage_engine.advance_stage` в блоке `next_stage == "done"` ПОСЛЕ terminal-sync/release-lease (`post_deploy.arm_monitor`, sentinel `armed` = идемпотентность при двойном webhook/reconciler/finalizer); один тик = один job — перехват в `agents/launcher.launch_job` ДО `_spawn` → `stage_engine.run_post_deploy_monitor` (один опрос → append в `series` → `classify` → перепостановка с задержкой `available_at_delay_s` ИЛИ реакция+артефакт+`mark_done`); бюджет тиков `window_s/interval_s` (анти-livelock). **Self-hosting safety (BR-5):** для `orchestrator` тик НИКОГДА не откатывает/рестартит прод-контейнер — реакция всегда `ALERT_ONLY` (громкий Telegram + Plane-коммент с запросом ручного approve); авто-rollback хуком `--rollback` — только для не-self репо при `post_deploy_auto_rollback=true` (целевой контейнер ≠ orchestrator). Наблюдаемость — блок `post_deploy` в `GET /queue` (enabled/window/interval/активные наблюдения). Артефакт `16-post-deploy-log.md` (YAML-frontmatter `post_deploy_status`/`action_taken`/`window_s`/`checks_total`/`checks_failed`) — машиночитаемо для петли уроков ORCH-8; best-effort. Новые настройки: `ORCH_POST_DEPLOY_MONITOR_ENABLED` (true, kill-switch), `ORCH_POST_DEPLOY_REPOS` (CSV; пусто → только self-hosting), `ORCH_POST_DEPLOY_WINDOW_S` (900), `ORCH_POST_DEPLOY_INTERVAL_S` (30), `ORCH_POST_DEPLOY_FAIL_THRESHOLD` (3), `ORCH_POST_DEPLOY_5XX_THRESHOLD` (0.5), `ORCH_POST_DEPLOY_AUTO_ROLLBACK` (false), `ORCH_POST_DEPLOY_BASE_URL` (http://localhost:8500); параметры отката переиспользуют `deploy_prod_*`. Инварианты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status`, terminal-sync `deploy→done`, merge-gate, exit-код-контракт хука (0/1/2), схема БД (без миграций; состояние — sentinel-файлы). Условность как ORCH-35/36/43/58. ADR `docs/work-items/ORCH-021/06-adr/ADR-001-post-deploy-monitor.md`, глобальный `docs/architecture/adr/adr-0010-post-deploy-monitor.md`. Тесты: `tests/test_post_deploy.py`, `tests/test_post_deploy_integration.py`. - **Провенанс staging-образа перед BUILD-ONCE retag в прод (свежесть артефакта, INV-FRESH)** (ORCH-058): BUILD-ONCE retag (ORCH-036) промоутит staging-образ (`orchestrator-orchestrator-staging`) в прод **без rebuild**, полагаясь на «образ свеж и провалидирован» — гарантии не было: конвейер нигде не пересобирал staging-образ из провалидированного коммита, поэтому retag мог тихо промоутнуть УСТАРЕВШИЙ образ (инцидент LESSONS_ORCH-036 п.4 — зелёный деплой молча откатывал прод). Закрыто **двумя слоями (defense in depth), только для self-hosting**. Новый модуль `src/image_freshness.py` (контракт «never raise», по образцу `merge_gate`): `provenance_verdict` (чистая функция вердикта match/mismatch/fail-closed), `validated_revision` (`git rev-parse HEAD` в worktree валидированного коммита — единый якорь и для штампа A, и для `EXPECTED_REVISION` B), `image_revision` (OCI-лейбл `org.opencontainers.image.revision` через `docker image inspect`, ``/ошибка → пусто), `rebuild_staging_image` (ssh-хук `--build-staging`), `image_freshness_applies` (условность), `check_staging_image_fresh` (композитный QG). **Strategy A (liveness):** новый детерминированный QG-под-чек `check_staging_image_fresh` (зарегистрирован в `QG_CHECKS`, `src/qg/checks.py`) на ребре `deploy-staging → deploy` ПОСЛЕ merge-gate и ДО Phase A — пересобирает staging-образ из worktree валидированного коммита (хук `--build-staging`, `--build-arg GIT_SHA=`), пересоздаёт 8501 и прогоняет `staging_check.py --mode stub` против свежего 8501 (health + e2e, внутри staging-контейнера через `docker exec` — канон ORCH-048) → валидируем РОВНО тот артефакт (build + e2e), что промоутится в прод (AC-4); FAIL/не-ноль staging_check → откат на `development` (как merge-gate, кап `MAX_DEVELOPER_RETRIES`). `rebuild_staging_image` пробрасывает в хук **явный** staging-таргет (service/port/profile/container), исключая дрейф на прод 8500. Сборки/recreate/validate — **только staging (8501)**, прод (8500) не трогается. **Strategy B (safety):** `Dockerfile` штампует `LABEL org.opencontainers.image.revision=$GIT_SHA` (`ARG GIT_SHA`); `build_deploy_command` (`src/self_deploy.py`) пробрасывает `EXPECTED_REVISION`; хост-хук шагом 2b ПЕРЕД `docker tag` fail-closed сверяет лейбл `revision` у `SOURCE_IMAGE` с `EXPECTED_REVISION` — несовпадение / пустой лейбл / ошибка inspect → `exit 1` (FAILED → БАГ-8 откат), делает тихий промоут устаревшего образа структурно невозможным даже при проигравшей гонку/отключённой A. Хост-хук `scripts/orchestrator-deploy-hook.sh` расширен **обратно-совместимым** режимом `--build-staging` (пересборка+recreate staging, exit 0/1) и fail-closed guard'ом (активен только при заданном `EXPECTED_REVISION`). Единый kill-switch `ORCH_IMAGE_FRESHNESS_ENABLED` (true) включает A+B **как целое** (нет «B без A» = вечного fail-fast); область — `ORCH_IMAGE_FRESHNESS_REPOS` (CSV; пусто → только self-hosting `orchestrator`). Контракты НЕ менялись: `STAGE_TRANSITIONS` (под-гейт ребра, не стадия), exit-code-контракт хука (0/1/2), `map_exit_code_to_status`, `check_deploy_status`/`_parse_deploy_status`, БАГ-8, terminal-sync, merge-gate; схема БД — без миграций. ADR `docs/work-items/ORCH-058/06-adr/ADR-001-staging-image-provenance.md`, глобальный `docs/architecture/adr/adr-0008-staging-image-provenance.md`. Документация: `docs/architecture/README.md`, `docs/operations/DEPLOY_HOOK.md`, `docs/operations/STAGING.md`, `docs/operations/INFRA.md`, `.env.example`. Тесты: `tests/test_image_freshness.py`, `tests/test_deploy_hook_provenance.py`, `tests/test_deploy_build_once.py` (TC-06), `tests/test_deploy_hook_mapping.py` (TC-09), `tests/test_stage_engine.py::TestImageFreshnessGate`, `tests/test_qg_registry_snapshot.py`, `tests/test_config.py`. - **Исполняемый самодеплой стадии `deploy` (стадия дёргает хост-хук, manual-approve)** (ORCH-036): стадия `deploy` перестаёт быть «бумажной» — для self-hosting репозитория `orchestrator` `deploy_status: SUCCESS` означает ДОКАЗАННЫЙ health-ok реального рестарта прод-контейнера (8500), а не декларацию LLM. Критический путь self-restart детерминирован (без LLM), по образцу merge-gate ORCH-043, и разбит на три фазы (`src/stage_engine.py` + новый модуль `src/self_deploy.py`): **Фаза A** (вход в `deploy`) — вместо запуска прод-deployer'а при `deploy_require_manual_approve=true` задача переводится в approval-pending (`set_issue_in_review`) и ждёт ручного approve; restart-safe маркер `approve-requested`. **Фаза B** (человек ставит статус Plane → `Approved`; `advance_stage(deploy, finished_agent=None)`) — запускается **detached host-процесс** (`ssh + setsid` → `scripts/orchestrator-deploy-hook.sh`, чтобы рестарт 8500 пережил гибель контейнера; орк НЕ убивает себя из docker.sock) с build-once retag staging-образа (`SOURCE_IMAGE`), ставится детерминированный **finalizer-job**; маркер `initiated` — идемпотентность повторного Approved. **Фаза C** (`run_deploy_finalizer`, reserved-agent `deploy-finalizer`, claim'ится новым контейнером после рестарта) — читает sentinel `result` (exit-code хука, записан host-обёрткой), `not-ready` → defer (бюджет `deploy_finalize_max_attempts`, restart-safe по `task_content`), маппит `0→SUCCESS / 1|2|иное→FAILED` (чистая функция `map_exit_code_to_status`, unit-тест), пишет `14-deploy-log.md` и вызывает `advance_stage(deploy, finished_agent="deployer")` → существующие контракты: `SUCCESS → done` + release merge-lease, `FAILED → откат БАГ-8 на development` + `set_issue_blocked`. Уведомления Plane+Telegram на approve-request / initiate / success / rollback (BR-5, ни одного «молчаливого» деплоя). Хост-хук `scripts/orchestrator-deploy-hook.sh` расширен **обратно-совместимым** `SOURCE_IMAGE`: при заданном — `docker tag $SOURCE_IMAGE $TARGET_IMAGE` перед `up -d --no-build` (деплой РОВНО протестированного образа, без `docker build`); не задан → прежнее поведение; exit-code-контракт (0/1/2) и health-loop (10×6с, авто-rollback) не тронуты. Restart-safe состояние — sentinel-файлы (`/.deploy-state-//`), без миграции БД. Условность как ORCH-35: реальный самодеплой только для `is_self_hosting_repo("orchestrator")`; прочие репо (enduro-trails) — прежний синхронный ssh-путь агентом. Контракты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status` (frontmatter-only), terminal-sync `deploy→done`, merge-gate (ORCH-43), БАГ-8. Флаг `DEPLOY_REQUIRE_MANUAL_APPROVE` остаётся `true` (полный авто — отдельная задача ORCH-54). Новые настройки: `ORCH_DEPLOY_REQUIRE_MANUAL_APPROVE` (true), `ORCH_DEPLOY_SSH_USER`, `ORCH_DEPLOY_SSH_HOST`, `ORCH_DEPLOY_HOOK_SCRIPT`, `ORCH_DEPLOY_PROD_SOURCE_IMAGE`, `ORCH_DEPLOY_PROD_TARGET_SERVICE/PORT/IMAGE`, `ORCH_DEPLOY_FINALIZE_DELAY_S`, `ORCH_DEPLOY_FINALIZE_MAX_ATTEMPTS`. ADR `docs/work-items/ORCH-036/06-adr/ADR-001-executable-self-deploy.md`, глобальный `docs/architecture/adr/adr-0007-executable-self-deploy.md`. Документация: `.openclaw/agents/deployer.md` (стадия `deploy` = вызов хука, запрет self-restart), `docs/operations/INFRA.md`, `docs/operations/DEPLOY_HOOK.md`. Тесты: `tests/test_deploy_hook_mapping.py`, `tests/test_deploy_approve.py`, `tests/test_deploy_routing.py`, `tests/test_deploy_rollback.py`, `tests/test_deploy_notifications.py`, `tests/test_deploy_build_once.py`, `tests/test_deploy_terminal_sync.py`, `tests/test_staging_precondition.py`, `tests/test_deploy_hook_rollback_sim.py`. - **Sweeper потерянных webhook (реконсиляция застрявших стадий)** (ORCH-053): фоновый daemon-поток `src/reconciler.py` (паттерн `queue_worker`), который устраняет тихое застревание задач, когда конвейер не двигается из-за потерянного события (502 на ребилде инстанса, отсутствие ретраев у Plane/Gitea, неразрезолвленный `sha→branch` — класс инцидента ORCH-044). Реконсилятор периодически (`reconcile_interval_s`) доигрывает пропущенный переход **через те же штатные гейты/обработчики**, что и webhook, не дублируя логику конвейера: **F-1 gate-side** (`reconcile_gate_once`) — для задач `stage≠done`, без активного job и `age(updated_at) ≥ grace_for_stage(stage)` делает read-only пред-оценку канонического QG стадии; зелёный → продвижение строго через неизменный `stage_engine.advance_stage(..., finished_agent=None)`; красный → тишина (спам нотификаций структурно невозможен — `advance_stage` на красном гейте не вызывается вовсе); `analysis` F-1 не трогает (человеческий гейт). **F-2 plane-side** (`reconcile_plane_once`) — опрос Plane API per-project (новый `plane_sync.list_issues_by_state`, курсорная пагинация, never-raise) и реплей In Progress / Approved / Rejected через существующие `webhooks.plane.handle_status_start` / `handle_verdict` (async-обработчики вызываются из sync-потока через `asyncio.run`). **F-3** — усиление `sha→branch` в `handle_ci_status`: при неразрезолвленном sha — БД-fallback по единственной development-задаче repo (`db.get_development_tasks_by_repo`; неоднозначность → не резолвим, ложного матча нет), `logger.debug`→`logger.info` для видимости потерянного CI-события. Анти-дубль на создании задачи (`db.create_task_atomic` под process-wide `threading.Lock`: SELECT-exists→INSERT, проигравший в гонке reconcile↔webhook не плодит второй task/branch/worktree/стартовый analyst-job). Старт/стоп в `main.lifespan` (после `worker.start()` / перед `worker.stop()`), restart-safe, never-raise на единицу работы. Наблюдаемость (F-4): при разблокировке — лог-строка `reconciler: разблокирована (потерян webhook)` + Telegram (`reconcile_notify_unblock`) и блок `reconcile` в `GET /queue`. Kill-switches: `ORCH_RECONCILE_ENABLED` (глобально), `ORCH_RECONCILE_PLANE_ENABLED` (гасит только F-2), `ORCH_RECONCILE_INTERVAL_S` (120), `ORCH_RECONCILE_GRACE_DEFAULT_S` (600), `ORCH_RECONCILE_GRACE_OVERRIDES_JSON` (per-stage), `ORCH_RECONCILE_NOTIFY_UNBLOCK` (true). Схема БД и реестры (`STAGE_TRANSITIONS`/`QG_CHECKS`) НЕ менялись. ADR `docs/work-items/ORCH-053/06-adr/ADR-001-stuck-task-reconciler.md`, глобальный `docs/architecture/adr/adr-0007-reconciler.md`. Тесты: `tests/test_reconciler.py`, `tests/test_reconciler_plane.py`, `tests/test_gitea_sha_resolve.py`, `tests/test_config.py`. diff --git a/CLAUDE.md b/CLAUDE.md index 1a9f279..63cf19e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ created → analysis → architecture → development → review → testing → - Машинные вердикты Quality Gate — строго YAML-frontmatter (`verdict:`, `deploy_status:`, `staging_status:`), никогда проза ## Артефакты задачи (`docs/work-items//`) -`00-business-request.md`, `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`, `04-test-plan.yaml`, `06-adr/ADR-NNN-slug.md`, `07-infra-requirements.md`, `08-data-requirements.md`, `10-tech-risks.md`, `12-review.md`, `13-test-report.md`, `14-deploy-log.md`, `15-staging-log.md`. +`00-business-request.md`, `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`, `04-test-plan.yaml`, `06-adr/ADR-NNN-slug.md`, `07-infra-requirements.md`, `08-data-requirements.md`, `10-tech-risks.md`, `12-review.md`, `13-test-report.md`, `14-deploy-log.md`, `15-staging-log.md`, `16-post-deploy-log.md` (post-deploy наблюдение, ORCH-021). ## Правила для агентов 1. Перед любым действием прочесть этот файл и `docs/architecture/README.md`. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 11a6e47..4ae2094 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -91,7 +91,7 @@ sentinel-файлы (`/.deploy-state-//`), без мигр Подробнее: [adr-0007](adr/adr-0007-executable-self-deploy.md), детально — `docs/work-items/ORCH-036/06-adr/ADR-001-executable-self-deploy.md`. -### Post-deploy наблюдение прода + реакция на деградацию (ORCH-021 — design) +### Post-deploy наблюдение прода + реакция на деградацию (ORCH-021 — реализовано) Конвейер заканчивался на `deploy → done` и **забывал про прод**: «успех» = health-check в момент рестарта (~60с). Класс «зелёный деплой, красный прод» (прецедент ET-8 — деградация через минуты под трафиком, health `200 ok`, фича сломана). ORCH-021 продлевает @@ -247,4 +247,4 @@ never-raise на единицу работы; тишина при синхрон Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.md). --- -*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — design, ветка feature/ORCH-021-post-deploy-rollback (`arch:major-change`; при реализации обновлять также при изменении src/post_deploy.py, src/stage_engine.py арм/run_post_deploy_monitor, src/agents/launcher.py перехват, флаги post_deploy_*; артефакт 16-post-deploy-log.md).* +*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата).* diff --git a/src/agents/launcher.py b/src/agents/launcher.py index 31454ef..ec957d8 100644 --- a/src/agents/launcher.py +++ b/src/agents/launcher.py @@ -249,6 +249,11 @@ class AgentLauncher: """ if job.get("agent") == "deploy-finalizer": return self._run_deploy_finalizer_job(job) + # ORCH-021: the reserved-agent `post-deploy-monitor` is also a + # DETERMINISTIC (no-LLM) tick — intercept it BEFORE _spawn and run one + # observation tick synchronously. Returns None (no agent_run row). + if job.get("agent") == "post-deploy-monitor": + return self._run_post_deploy_monitor_job(job) return self._spawn( job["agent"], job["repo"], @@ -278,6 +283,27 @@ class AgentLauncher: pass return None + def _run_post_deploy_monitor_job(self, job: dict): + """ORCH-021: run one deterministic post-deploy monitor tick for a job. + + Not an LLM spawn — there is no subprocess/monitor, so we mark the jobs row + done/failed here. The tick never-raises, but we guard anyway so a monitor + fault can never wedge the worker / starve other projects (AC-16). + """ + from ..db import mark_job + from .. import stage_engine + try: + stage_engine.run_post_deploy_monitor(job) + mark_job(job["id"], "done") + logger.info(f"post-deploy-monitor job {job['id']} done") + except Exception as e: + logger.error(f"post-deploy-monitor job {job['id']} failed: {e}") + try: + mark_job(job["id"], "failed", error=f"post-deploy-monitor error: {e}") + except Exception: + pass + return None + def _spawn(self, agent: str, repo: str, task_content: str = None, task_id: int = None, job_id: int = None) -> int: """Shared spawn implementation for launch() and launch_job(). diff --git a/src/config.py b/src/config.py index 1a0612b..1161959 100644 --- a/src/config.py +++ b/src/config.py @@ -265,6 +265,37 @@ class Settings(BaseSettings): reconcile_notify_unblock: bool = True reconcile_skip_blocked_enabled: bool = True + # ORCH-021: post-deploy production monitoring + degradation reaction. After + # the terminal deploy->done transition for an applicable repo, a reserved-agent + # `post-deploy-monitor` job (no LLM, modelled on deploy-finalizer) probes prod + # over a window and reacts to a degradation the restart-time health-check + # missed (class "green deploy, red prod", precedent ET-8). State is in sentinel + # files (.post-deploy-state-//), no DB migration. See + # docs/architecture/adr/adr-0010-post-deploy-monitor.md. + # post_deploy_monitor_enabled -> global kill-switch (BR-8); False -> the + # pipeline is 1:1 as before ORCH-021 (no arm). + # post_deploy_repos -> CSV of repos where monitoring is REAL; empty + # -> only the self-hosting repo (orchestrator). + # Mirrors self_deploy_repos / merge_gate_repos. + # post_deploy_window_s -> observation window length (~15 min, BR-1). + # post_deploy_interval_s -> seconds between probe ticks. + # post_deploy_fail_threshold -> N CONSECUTIVE health failures -> DEGRADED. + # post_deploy_5xx_threshold -> window 5xx ratio above this -> DEGRADED. + # post_deploy_auto_rollback -> globally allow auto-rollback; True acts ONLY + # for non-self repos. For self-hosting the + # reaction is ALWAYS ALERT_ONLY (BR-5) — a tick + # NEVER restarts the prod orchestrator container. + # post_deploy_base_url -> base URL of the observed prod instance. + # Rollback target params reuse the existing deploy_prod_* settings (no dupes). + post_deploy_monitor_enabled: bool = True + post_deploy_repos: str = "" + post_deploy_window_s: int = 900 + post_deploy_interval_s: int = 30 + post_deploy_fail_threshold: int = 3 + post_deploy_5xx_threshold: float = 0.5 + post_deploy_auto_rollback: bool = False + post_deploy_base_url: str = "http://localhost:8500" + # Telegram notifications telegram_bot_token: str = "" telegram_chat_id: str = "" diff --git a/src/main.py b/src/main.py index 0d9314d..c21e5b2 100644 --- a/src/main.py +++ b/src/main.py @@ -123,11 +123,13 @@ async def queue(): from .db import job_status_counts, recent_jobs from .queue_worker import worker from .reconciler import reconciler + from . import post_deploy return { "counts": job_status_counts(), "max_concurrency": worker.max_concurrency, "poll_interval": worker.poll_interval, "resilience": worker.status(), "reconcile": reconciler.status(), + "post_deploy": post_deploy.status(), "recent": recent_jobs(10), } diff --git a/src/post_deploy.py b/src/post_deploy.py new file mode 100644 index 0000000..75afe42 --- /dev/null +++ b/src/post_deploy.py @@ -0,0 +1,614 @@ +"""Post-deploy production monitoring + degradation reaction (ORCH-021). + +The pipeline used to end at ``deploy -> done`` and then **forget about prod**: +"success" meant the health-check passed at restart (~60s window in +``scripts/orchestrator-deploy-hook.sh``). The class of incidents "green deploy, +red prod" (precedent ET-8 — degradation appears minutes later under real +traffic; ``/health`` answers ``200 ok`` while the feature is broken) was never +caught. ORCH-021 extends responsibility **PAST** ``done``: after the terminal +transition for an applicable repo we arm an observation window +(``post_deploy_window_s`` ~15 min, interval ``post_deploy_interval_s``); +degradation is detected by deterministic thresholds and, when confirmed, +triggers a reaction. + +The observation mechanism (ADR-001 §1, Variant B) is a **reserved-agent job** +``post-deploy-monitor`` — a deterministic, no-LLM job modelled exactly on +``deploy-finalizer``. One "tick" == one job: it does ONE probe, appends to a +persisted ``series`` file, classifies, and either re-queues itself with a delay +(``available_at_delay_s``) or finishes (DEGRADED -> reaction; or window expired +-> HEALTHY). Between ticks no job runs (it is scheduled in the future), so the +single worker stays free for other projects — exactly like the finalizer defer. + +This module is a **leaf** (mirrors ``self_deploy.py`` / ``staging_verdict.py``): +it imports only config (and lazily ``qg.checks.is_self_hosting_repo``), never +``stage_engine`` / ``launcher`` — the orchestration that needs those lives in +``stage_engine.run_post_deploy_monitor``. Every public helper honours a +**never-raise** contract so a monitoring hiccup can never crash the worker / +lifespan / the pipeline of other projects (AC-16). + +Restart-safe state lives in sentinel files under +``/.post-deploy-state-//`` (mirrors the +deploy-state pattern, no DB migration — ТЗ §2.7): + * ``armed`` — monitoring armed for this work item (idempotency-guard, AC-15); + * ``series`` — JSON list of probe results (restart-safe streak/5xx counters); + * ``done`` — monitoring finished (anti-dupe, AC-15). + +Self-hosting safety (BR-5 / AC-8): a monitor tick NEVER auto-rolls-back or +restarts the prod ``orchestrator`` container — for ``orchestrator`` the reaction +is ALWAYS ``ALERT_ONLY`` (loud Telegram + Plane, manual approve). +""" + +from __future__ import annotations + +import glob +import json +import logging +import os +import shlex +import subprocess +import urllib.error +import urllib.request +from dataclasses import dataclass + +from .config import settings + +logger = logging.getLogger("orchestrator.post_deploy") + +# Sentinel marker filenames (see module docstring). +ARMED = "armed" +SERIES = "series" +DONE = "done" + +# Verdicts (classify). +HEALTHY = "HEALTHY" +DEGRADED = "DEGRADED" + +# Reaction decisions (decide_action). +NONE = "NONE" +ROLLBACK = "ROLLBACK" +ALERT_ONLY = "ALERT_ONLY" + +# action_taken values written to the artefact frontmatter. +ROLLBACK_OK = "ROLLBACK_OK" +ROLLBACK_FAILED = "ROLLBACK_FAILED" + +# The 5xx-monitored endpoints (besides /health, whose 200+ok is its own signal). +_FIVEXX_ENDPOINTS = ("/status", "/queue") + +_PROBE_TIMEOUT = 5 +_SSH_TIMEOUT = 60 +_GIT_TIMEOUT = 60 + + +# --------------------------------------------------------------------------- +# Conditionality (mirrors self_deploy_applies / _merge_gate_applies) +# --------------------------------------------------------------------------- +def post_deploy_applies(repo: str) -> bool: + """Whether post-deploy monitoring is REAL for this repo (AC-2 / AC-10). + + Mirrors the ORCH-35/36/43/58 conditional rollout: + * ``post_deploy_monitor_enabled=False`` -> always False (global + kill-switch); the pipeline is 1:1 as before ORCH-021 (AC-10). + * ``post_deploy_repos`` (CSV) non-empty -> real only for listed repos. + * empty CSV -> real ONLY for the self-hosting repo (``orchestrator``). + Never raises. + """ + try: + if not settings.post_deploy_monitor_enabled: + return False + raw = (settings.post_deploy_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 module a leaf (avoid importing qg at load time). + 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("post_deploy_applies error for %s: %s", repo, e) + return False + + +# --------------------------------------------------------------------------- +# Signal probe (one tick) +# --------------------------------------------------------------------------- +@dataclass +class ProbeResult: + """Outcome of ONE probe tick (JSON-serialisable via ``as_dict``). + + ``health_ok`` — ``/health`` answered HTTP 200 with ``{"status": "ok"}``. + ``total`` — number of 5xx-monitored endpoints probed (``/status``, + ``/queue``) — the denominator of the window 5xx ratio. + ``fivexx`` — how many of those returned 5xx (or were unreachable, which + is conservatively counted as a server failure). + ``detail`` — human-readable note (logs / artefact body). + """ + + health_ok: bool + total: int + fivexx: int + detail: str = "" + + def as_dict(self) -> dict: + return { + "health_ok": bool(self.health_ok), + "total": int(self.total), + "fivexx": int(self.fivexx), + "detail": str(self.detail), + } + + +def _http_status(url: str) -> tuple[int, str]: + """GET ``url`` -> (http_code, body). Network/timeout -> (0, ""). + + Never raises. ``urllib`` raises ``HTTPError`` for >=400 responses; we treat + that as a real status code (so a 5xx is observed, not swallowed). + """ + try: + with urllib.request.urlopen(url, timeout=_PROBE_TIMEOUT) as resp: # noqa: S310 + body = resp.read(4096).decode("utf-8", "replace") + return int(getattr(resp, "status", resp.getcode())), body + except urllib.error.HTTPError as e: + try: + body = e.read(4096).decode("utf-8", "replace") + except Exception: + body = "" + return int(e.code), body + except Exception as e: # noqa: BLE001 - URLError / socket timeout / anything + logger.warning("post_deploy probe error for %s: %s", url, e) + return 0, "" + + +def probe_signals(base_url: str) -> ProbeResult: + """Probe ``/health`` + the key endpoints of the prod instance ONCE (AC-16). + + ``/health`` is healthy iff HTTP 200 AND the body parses to + ``{"status": "ok"}``. ``/status`` and ``/queue`` contribute to the window + 5xx ratio: an HTTP 5xx OR an unreachable endpoint (network error / timeout, + code 0) is counted as a failure (conservative — a down server is bad). A + network failure yields a conservative "failed" probe, NEVER an exception + (TC-14). + """ + base = (base_url or "").rstrip("/") + # --- /health: the primary liveness signal --- + code, body = _http_status(base + "/health") + health_ok = False + if code == 200: + try: + health_ok = json.loads(body).get("status") == "ok" + except Exception: + health_ok = False + # --- /status, /queue: 5xx ratio over the window --- + total = 0 + fivexx = 0 + for ep in _FIVEXX_ENDPOINTS: + total += 1 + ep_code, _ = _http_status(base + ep) + if ep_code == 0 or 500 <= ep_code <= 599: + fivexx += 1 + detail = f"health={code}({'ok' if health_ok else 'bad'}) 5xx={fivexx}/{total}" + return ProbeResult(health_ok=health_ok, total=total, fivexx=fivexx, detail=detail) + + +# --------------------------------------------------------------------------- +# Classification (pure, no I/O — the MAIN unit-test subject, like +# compute_staging_verdict in ORCH-061) +# --------------------------------------------------------------------------- +def classify(series, fail_threshold: int, fivexx_threshold: float) -> str: + """Fold a probe series into ``HEALTHY`` | ``DEGRADED`` (deterministic, pure). + + ``series`` — iterable of probe dicts (``{"health_ok", "total", "fivexx"}``), + as persisted by :func:`append_probe`. + + Decision (BR-3 / AC-3..AC-6): + * ``>= fail_threshold`` CONSECUTIVE health failures -> ``DEGRADED`` (AC-4); + * window 5xx ratio ``sum(fivexx)/sum(total)`` strictly ``> fivexx_threshold`` + -> ``DEGRADED`` even if ``/health`` answers 200 (AC-5); + * otherwise ``HEALTHY`` — a single glitch below the threshold that recovers + does NOT trip (AC-3 / AC-6, no false rollback). + + Never raises: on malformed input it returns ``HEALTHY`` (fail-SAFE — a false + ``DEGRADED`` would trigger an unwanted rollback, the worse outcome). + """ + try: + # Non-list input is malformed -> fail-safe HEALTHY (never a false rollback). + if not isinstance(series, (list, tuple)): + return HEALTHY + # Longest run of consecutive health failures. + streak = 0 + best = 0 + total = 0 + fivexx = 0 + for row in series: + # A non-dict row is malformed: skip it (do NOT count it as a failure, + # which could fabricate a DEGRADED streak from garbage). + if not isinstance(row, dict): + continue + ok = bool(row.get("health_ok")) + total += int(row.get("total") or 0) + fivexx += int(row.get("fivexx") or 0) + if ok: + streak = 0 + else: + streak += 1 + if streak > best: + best = streak + if best >= int(fail_threshold): + return DEGRADED + if total > 0 and (fivexx / total) > float(fivexx_threshold): + return DEGRADED + return HEALTHY + except Exception as e: # noqa: BLE001 - never-raise; fail-safe to HEALTHY + logger.warning("post_deploy classify error: %s", e) + return HEALTHY + + +def decide_action(repo: str, verdict: str) -> str: + """Decide the reaction for ``(repo, verdict)`` (pure, BR-5 / AC-7 / AC-8). + + * ``HEALTHY`` -> ``NONE`` (no reaction, any repo); + * ``DEGRADED`` + self-hosting -> ``ALERT_ONLY`` (ALWAYS — the tick + NEVER auto-rolls-back / restarts the prod orchestrator container, AC-8); + * ``DEGRADED`` + non-self + ``post_deploy_auto_rollback=True`` -> ``ROLLBACK``; + * ``DEGRADED`` + non-self + auto_rollback False (default) -> ``ALERT_ONLY``. + + Never raises: on doubt returns ``ALERT_ONLY`` (never an unexpected rollback). + """ + try: + if verdict != DEGRADED: + return NONE + from .qg.checks import is_self_hosting_repo + if is_self_hosting_repo(repo): + return ALERT_ONLY # BR-5: self-hosting is NEVER auto-rolled-back + if settings.post_deploy_auto_rollback: + return ROLLBACK + return ALERT_ONLY + except Exception as e: # noqa: BLE001 - never-raise; safe default + logger.warning("post_deploy decide_action error for %s: %s", repo, e) + return ALERT_ONLY + + +def map_rollback_exit_code(exit_code) -> str: + """Map a ``--rollback`` hook exit-code to an ``action_taken`` (pure, AC-9). + + Hook exit-code contract (unchanged, 0/1/2): + * ``0`` -> ``ROLLBACK_OK`` (rollback proven healthy); + * ``1`` (no prev image), ``2`` (rollback also failed), anything else, or a + non-int/None -> ``ROLLBACK_FAILED`` (fail-closed -> loud escalation). + """ + try: + code = int(exit_code) + except (TypeError, ValueError): + return ROLLBACK_FAILED + return ROLLBACK_OK if code == 0 else ROLLBACK_FAILED + + +# --------------------------------------------------------------------------- +# Sentinel state (restart-safe, no DB migration — ТЗ §2.7) +# --------------------------------------------------------------------------- +def _state_dir(base: str, repo: str, work_item_id: str | None) -> str: + return os.path.join(base, f".post-deploy-state-{repo}", (work_item_id or "_")) + + +def state_dir(repo: str, work_item_id: str | None) -> str: + """State dir as seen from the container (``settings.repos_dir`` mount).""" + return _state_dir(settings.repos_dir, repo, work_item_id) + + +def host_state_dir(repo: str, work_item_id: str | None) -> str: + """State dir as seen from the HOST (``settings.host_repos_dir``). + + Same physical directory as :func:`state_dir` via the shared mount; the host + path is what we embed in an ssh command if a host-side helper needs it. + """ + return _state_dir(settings.host_repos_dir, repo, work_item_id) + + +def marker_path(repo: str, work_item_id: str | None, name: str) -> str: + return os.path.join(state_dir(repo, work_item_id), name) + + +def has_marker(repo: str, work_item_id: str | None, name: str) -> bool: + """True iff the named sentinel exists. Never raises.""" + try: + return os.path.isfile(marker_path(repo, work_item_id, name)) + except Exception as e: # noqa: BLE001 - never-raise + logger.warning("has_marker error for %s/%s/%s: %s", repo, work_item_id, name, e) + return False + + +def write_marker(repo: str, work_item_id: str | None, name: str, content: str = "") -> bool: + """Create/overwrite a sentinel (best-effort). Returns True on success.""" + try: + d = state_dir(repo, work_item_id) + os.makedirs(d, exist_ok=True) + with open(os.path.join(d, name), "w", encoding="utf-8") as f: + f.write(str(content)) + return True + except OSError as e: + logger.warning("write_marker error for %s/%s/%s: %s", repo, work_item_id, name, e) + return False + + +def mark_done(repo: str, work_item_id: str | None) -> bool: + """Mark monitoring finished for this work item (anti-dupe, AC-15).""" + return write_marker(repo, work_item_id, DONE, "done") + + +def read_series(repo: str, work_item_id: str | None) -> list: + """Read the persisted probe series (JSON list). Missing/corrupt -> ``[]``. + + Never raises — restart-safe streak/5xx counters survive a container restart. + """ + p = marker_path(repo, work_item_id, SERIES) + try: + with open(p, "r", encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, list) else [] + except FileNotFoundError: + return [] + except Exception as e: # noqa: BLE001 - never-raise; corrupt -> empty + logger.warning("read_series error for %s/%s: %s", repo, work_item_id, e) + return [] + + +def append_probe(repo: str, work_item_id: str | None, probe: ProbeResult) -> list: + """Append a probe to the persisted series and return the new list. + + Best-effort (a write error logs and returns the in-memory list so the tick + still classifies). Never raises. + """ + series = read_series(repo, work_item_id) + try: + series.append(probe.as_dict() if isinstance(probe, ProbeResult) else dict(probe)) + except Exception as e: # noqa: BLE001 + logger.warning("append_probe coerce error for %s/%s: %s", repo, work_item_id, e) + return series + try: + d = state_dir(repo, work_item_id) + os.makedirs(d, exist_ok=True) + with open(os.path.join(d, SERIES), "w", encoding="utf-8") as f: + json.dump(series, f) + except OSError as e: + logger.warning("append_probe write error for %s/%s: %s", repo, work_item_id, e) + return series + + +def arm_monitor(repo: str, work_item_id: str | None, branch: str, task_id: int) -> bool: + """Arm post-deploy monitoring after ``deploy -> done`` (AC-1 / AC-15). + + Idempotent: if the ``armed`` sentinel already exists this is a no-op (a double + webhook / reconciler F-1 / finalizer Phase C can drive ``done`` more than once, + AC-15). Otherwise creates the state dir, writes ``armed`` + an empty ``series``, + and enqueues the FIRST ``post-deploy-monitor`` job with a delay of one interval + (so the prod has settled before the first probe). Returns True iff it armed a + NEW monitor. Never raises — the caller (terminal block of ``advance_stage``) + must never be crashed by a monitoring hiccup. + """ + try: + if has_marker(repo, work_item_id, ARMED): + logger.info("arm_monitor: already armed for %s/%s (no-op)", repo, work_item_id) + return False + write_marker(repo, work_item_id, ARMED, "armed") + # Initialise an empty series so read_series is well-defined from tick 1. + try: + d = state_dir(repo, work_item_id) + os.makedirs(d, exist_ok=True) + with open(os.path.join(d, SERIES), "w", encoding="utf-8") as f: + json.dump([], f) + except OSError as e: + logger.warning("arm_monitor: series init error for %s/%s: %s", repo, work_item_id, e) + # Lazy import keeps this module a leaf (db is a low-level dependency). + from .db import enqueue_job + task_desc = ( + f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n" + f"Stage: post-deploy\nNote: post-deploy monitor tick 1 " + f"(window {settings.post_deploy_window_s}s, interval " + f"{settings.post_deploy_interval_s}s)." + ) + job_id = enqueue_job( + "post-deploy-monitor", repo, task_desc, task_id=task_id, + available_at_delay_s=settings.post_deploy_interval_s, + ) + logger.info( + "arm_monitor: armed post-deploy monitor for %s/%s (job_id=%s)", + repo, work_item_id, job_id, + ) + return True + except Exception as e: # noqa: BLE001 - never-raise contract + logger.error("arm_monitor error for %s/%s: %s", repo, work_item_id, e) + return False + + +def max_ticks() -> int: + """Bounded tick budget for the window (anti-livelock, like + ``deploy_finalize_max_attempts``): ``window_s // interval_s`` (>= 1).""" + try: + interval = max(1, int(settings.post_deploy_interval_s)) + return max(1, int(settings.post_deploy_window_s) // interval) + except Exception: # noqa: BLE001 - never-raise + return 1 + + +# --------------------------------------------------------------------------- +# Rollback command (non-self repos only; reuses deploy_prod_* env — ТЗ §2.4) +# --------------------------------------------------------------------------- +def build_rollback_command(repo: str) -> list[str]: + """Build the ssh argv that runs the deploy hook in ``--rollback`` mode. + + Mirrors ``self_deploy.build_deploy_command`` (same prod-env, INFRA P-2 ssh + target) but the action is ``--rollback`` and the call is SYNCHRONOUS (the + target container is NOT the orchestrator, so it is safe to wait for the hook + exit-code directly — no detached setsid wrapper, no ``result`` sentinel). + Reuses the existing ``deploy_prod_*`` settings; no new duplicate config. + """ + env_assignments = ( + f"TARGET_SERVICE={shlex.quote(settings.deploy_prod_target_service)} " + f"TARGET_PORT={int(settings.deploy_prod_target_port)} " + f"TARGET_IMAGE={shlex.quote(settings.deploy_prod_target_image)} " + f"COMPOSE_PROFILE={shlex.quote(settings.deploy_prod_compose_profile)} " + f"PREV_IMAGE_FILE={shlex.quote(settings.deploy_prod_prev_image_file)}" + ) + inner = ( + f"cd {shlex.quote(settings.deploy_host_repo_path)} && " + f"{env_assignments} " + f"bash {shlex.quote(settings.deploy_hook_script)} --rollback" + ) + user = (settings.deploy_ssh_user or "").strip() + host = (settings.deploy_ssh_host or "").strip() + target = f"{user}@{host}" if user else host + return ["ssh", "-o", "StrictHostKeyChecking=no", target, inner] + + +def run_rollback(repo: str) -> tuple[int, str]: + """Run the ``--rollback`` hook synchronously. Returns ``(exit_code, detail)``. + + Never raises: an ssh launch error / timeout maps to a non-zero exit-code so + the caller records ``ROLLBACK_FAILED`` and escalates (AC-9). NEVER used for + the self-hosting repo (``decide_action`` returns ``ALERT_ONLY`` there) — the + structural guard against a tick restarting the prod orchestrator (AC-8). + """ + cmd = build_rollback_command(repo) + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=_SSH_TIMEOUT) + except subprocess.TimeoutExpired: + return 2, "rollback ssh timeout" + except (subprocess.SubprocessError, OSError) as e: + return 2, f"rollback ssh error: {e}" + detail = ((r.stderr or "") + (r.stdout or "")).strip()[:200] + return int(r.returncode), detail + + +# --------------------------------------------------------------------------- +# Artefact 16-post-deploy-log.md (machine-readable frontmatter — ТЗ §2.5) +# --------------------------------------------------------------------------- +def build_post_deploy_log( + work_item_id: str, + status: str, + action_taken: str, + window_s: int, + checks_total: int, + checks_failed: int, + body_extra: str = "", +) -> str: + """Render a 16-post-deploy-log.md body. Only the YAML-frontmatter is machine + read (canon of gates; the loop-of-lessons ORCH-8 consumes it, BR-10). The + body is informational. Parseable by ``yaml.safe_load`` (AC-13). + """ + return ( + "---\n" + f"post_deploy_status: {status}\n" + f"action_taken: {action_taken}\n" + f"work_item: {work_item_id}\n" + f"window_s: {int(window_s)}\n" + f"checks_total: {int(checks_total)}\n" + f"checks_failed: {int(checks_failed)}\n" + "---\n\n" + "# Post-deploy log — ORCH-021 post-deploy monitor\n\n" + f"Наблюдение прода завершено: `post_deploy_status: {status}`, " + f"`action_taken: {action_taken}`.\n\n" + f"Окно наблюдения: {int(window_s)}s; опросов всего: {int(checks_total)}, " + f"из них с провалом: {int(checks_failed)}.\n" + f"{body_extra}" + ) + + +def write_post_deploy_log( + repo: str, + work_item_id: str, + branch: str, + status: str, + action_taken: str, + window_s: int, + checks_total: int, + checks_failed: int, + body_extra: str = "", +) -> bool: + """Write 16-post-deploy-log.md into the task worktree and best-effort + commit+push it. Returns True iff the file was written. Never raises — the + artefact is best-effort, its absence rolls nothing back (AC-13 / TC-15). + """ + from .git_worktree import get_worktree_path + + rel = f"docs/work-items/{work_item_id}/16-post-deploy-log.md" + try: + wt = get_worktree_path(repo, branch) + except Exception as e: # noqa: BLE001 - never-raise + logger.error("write_post_deploy_log: worktree error for %s/%s: %s", repo, branch, e) + return False + + path = os.path.join(wt, rel) + content = build_post_deploy_log( + work_item_id, status, action_taken, window_s, checks_total, checks_failed, body_extra + ) + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + f.write(content) + except OSError as e: + logger.error("write_post_deploy_log: write error at %s: %s", path, e) + return False + + git_env = { + **os.environ, + "HOME": "/home/slin", + "GIT_AUTHOR_NAME": "post-deploy-monitor", + "GIT_AUTHOR_EMAIL": "post-deploy-monitor@mva154.local", + "GIT_COMMITTER_NAME": "post-deploy-monitor", + "GIT_COMMITTER_EMAIL": "post-deploy-monitor@mva154.local", + } + try: + subprocess.run(["git", "-C", wt, "add", rel], + capture_output=True, timeout=_GIT_TIMEOUT, env=git_env) + commit = subprocess.run( + ["git", "-C", wt, "commit", "-m", + f"docs(ORCH-021): post-deploy {status}/{action_taken} for {work_item_id}"], + capture_output=True, text=True, timeout=_GIT_TIMEOUT, env=git_env, + ) + if commit.returncode == 0: + subprocess.run(["git", "-C", wt, "push", "origin", branch], + capture_output=True, timeout=_GIT_TIMEOUT, env=git_env) + except (subprocess.SubprocessError, OSError) as e: + logger.warning("write_post_deploy_log: git commit/push best-effort failed: %s", e) + return True + + +# --------------------------------------------------------------------------- +# Observability snapshot for GET /queue (BR-9 / AC-14) +# --------------------------------------------------------------------------- +def status() -> dict: + """Post-deploy snapshot for /queue observability. Never raises. + + ``active`` — work items with an ``armed`` sentinel but no ``done`` yet (a + monitoring window in flight). ``last_outcome`` — best-effort last finished + window read from the most-recent ``done`` state dir's series length. + """ + snap = { + "enabled": False, + "window_s": None, + "interval_s": None, + "repos": "", + "active": [], + "active_count": 0, + } + try: + snap["enabled"] = bool(settings.post_deploy_monitor_enabled) + snap["window_s"] = int(settings.post_deploy_window_s) + snap["interval_s"] = int(settings.post_deploy_interval_s) + snap["repos"] = settings.post_deploy_repos or "" + pattern = os.path.join(settings.repos_dir, ".post-deploy-state-*", "*") + active: list[str] = [] + for d in glob.glob(pattern): + try: + if not os.path.isdir(d): + continue + if os.path.isfile(os.path.join(d, ARMED)) and not os.path.isfile( + os.path.join(d, DONE) + ): + active.append(os.path.basename(d)) + except Exception: # noqa: BLE001 - skip one dir + continue + snap["active"] = sorted(active) + snap["active_count"] = len(active) + except Exception as e: # noqa: BLE001 - never-raise + logger.warning("post_deploy status snapshot error: %s", e) + return snap diff --git a/src/stage_engine.py b/src/stage_engine.py index 9cc3b1a..df84ca5 100644 --- a/src/stage_engine.py +++ b/src/stage_engine.py @@ -37,6 +37,7 @@ from .review_parse import extract_review_findings, extract_test_failures from .qg.checks import QG_CHECKS from . import merge_gate from . import self_deploy +from . import post_deploy from .notifications import ( notify_stage_change, notify_qg_failure, @@ -352,6 +353,17 @@ def advance_stage( except Exception as e: # noqa: BLE001 - defensive logger.warning(f"Task {task_id}: merge-lease release on done failed: {e}") + # ORCH-021: arm post-deploy monitoring PAST `done`. Responsibility extends + # beyond the restart-time health-check to catch the "green deploy, red prod" + # class (ET-8). Idempotent (sentinel `armed`) + conditional (applies()), so a + # double webhook / reconciler / finalizer re-driving `done` never doubles it + # and non-applicable repos are untouched. never-raise (arm_monitor + guard). + if next_stage == "done" and post_deploy.post_deploy_applies(repo): + try: + post_deploy.arm_monitor(repo, work_item_id, branch, task_id) + except Exception as e: # noqa: BLE001 - monitoring must never crash done + logger.warning(f"Task {task_id}: post-deploy arm failed: {e}") + # --- Launch the next agent (ORCH-4 fix: current_stage, not next) ----- next_agent = get_agent_for_stage(current_stage) if next_agent: @@ -1176,3 +1188,139 @@ def run_deploy_finalizer(job: dict): branch=branch, finished_agent="deployer", ) + + +def run_post_deploy_monitor(job: dict): + """ORCH-021 — one post-deploy monitor tick (reserved-agent, no LLM). + + A deterministic tick modelled on ``run_deploy_finalizer``: it does ONE probe + of the prod instance, appends to the persisted ``series`` (restart-safe + streak/5xx counters), classifies, and then either RE-QUEUES itself with a + delay (window not over and still HEALTHY) or FINISHES the window (DEGRADED -> + reaction; window expired -> HEALTHY). Observation happens entirely AFTER the + terminal ``done`` — it never touches ``STAGE_TRANSITIONS`` / ``QG_CHECKS`` and + never restarts the prod orchestrator container itself (AC-8 / AC-12). + + never-raise into the caller (the launcher marks the job done/failed); each + branch is individually defensive. + """ + task_id = job.get("task_id") + repo = job.get("repo") + try: + conn = get_db() + row = conn.execute( + "SELECT work_item_id, branch FROM tasks WHERE id=?", (task_id,) + ).fetchone() + conn.close() + except Exception as e: # noqa: BLE001 - never-raise + logger.error(f"post-deploy-monitor: db error for task_id={task_id}: {e}") + return + if not row: + logger.error(f"post-deploy-monitor: no task row for task_id={task_id}") + return + work_item_id, branch = row[0], row[1] + + # AC-15: a finished window is a no-op (defends against a duplicate job). + if post_deploy.has_marker(repo, work_item_id, post_deploy.DONE): + logger.info(f"post-deploy-monitor: {work_item_id} already done (no-op)") + return + + # One probe -> append -> classify (restart-safe via the persisted series). + probe = post_deploy.probe_signals(settings.post_deploy_base_url) + series = post_deploy.append_probe(repo, work_item_id, probe) + verdict = post_deploy.classify( + series, + settings.post_deploy_fail_threshold, + settings.post_deploy_5xx_threshold, + ) + ticks = len(series) + budget = post_deploy.max_ticks() + logger.info( + f"post-deploy-monitor: {work_item_id} tick {ticks}/{budget} " + f"probe=[{probe.detail}] verdict={verdict}" + ) + + # HEALTHY and window not exhausted -> defer the next tick (worker stays free). + if verdict == post_deploy.HEALTHY and ticks < budget: + task_desc = ( + f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n" + f"Stage: post-deploy\nNote: post-deploy monitor tick {ticks + 1} " + f"(healthy so far; re-poll after {settings.post_deploy_interval_s}s)." + ) + enqueue_job( + "post-deploy-monitor", repo, task_desc, task_id=task_id, + available_at_delay_s=settings.post_deploy_interval_s, + ) + return + + checks_total = ticks + checks_failed = sum(1 for r in series if not r.get("health_ok")) + + # HEALTHY and window exhausted -> clean finish (BR-6 / AC-17). + if verdict == post_deploy.HEALTHY: + post_deploy.write_post_deploy_log( + repo, work_item_id, branch, post_deploy.HEALTHY, post_deploy.NONE, + settings.post_deploy_window_s, checks_total, checks_failed, + ) + post_deploy.mark_done(repo, work_item_id) + _notify_post_deploy( + work_item_id, + f"✅ {work_item_id}: пост-деплой окно завершено чисто " + f"(HEALTHY, {checks_total} опросов).", + ) + return + + # DEGRADED -> decide + execute the reaction (§5), write artefact, finish. + action = post_deploy.decide_action(repo, verdict) + action_taken = post_deploy.ALERT_ONLY + if action == post_deploy.ROLLBACK: + # Non-self repo + auto policy: run the --rollback hook synchronously (the + # target is NOT the orchestrator, so its restart is safe for the pipeline). + exit_code, detail = post_deploy.run_rollback(repo) + action_taken = post_deploy.map_rollback_exit_code(exit_code) + if action_taken == post_deploy.ROLLBACK_OK: + _notify_post_deploy( + work_item_id, + f"⚠️ {work_item_id}: пост-деплой DEGRADED -> авто-rollback выполнен " + f"(exit {exit_code}).", + ) + else: + # AC-9: a failed rollback escalates loudly for manual intervention. + _notify_post_deploy( + work_item_id, + f"🚨 {work_item_id}: пост-деплой DEGRADED -> авто-rollback ПРОВАЛИЛСЯ " + f"(exit {exit_code}: {detail}). Нужно ручное вмешательство.", + ) + else: + # ALERT_ONLY: self-hosting ALWAYS lands here — the tick NEVER auto-rolls-back + # or restarts the prod orchestrator container (BR-5 / AC-8). Loud alert + + # manual-approve request (mirrors deploy Phase A CTA). + action_taken = post_deploy.ALERT_ONLY + _notify_post_deploy( + work_item_id, + f"🚨 {work_item_id}: пост-деплой DEGRADED ({checks_failed}/{checks_total} " + f"провалов). Требуется ручной approve отката — авто-rollback для " + f"self-hosting запрещён (BR-5).", + ) + + post_deploy.write_post_deploy_log( + repo, work_item_id, branch, post_deploy.DEGRADED, action_taken, + settings.post_deploy_window_s, checks_total, checks_failed, + ) + post_deploy.mark_done(repo, work_item_id) + + +def _notify_post_deploy(work_item_id: str, message: str) -> None: + """Best-effort Telegram + Plane notification for a post-deploy event (AC-17). + + Never raises — a notification failure must not wedge the monitor tick. + """ + try: + send_telegram(message) + except Exception as e: # noqa: BLE001 - never break the tick + logger.warning(f"post-deploy notify telegram failed for {work_item_id}: {e}") + if work_item_id: + try: + plane_add_comment(work_item_id, message, author="deployer") + except Exception as e: # noqa: BLE001 - never break the tick + logger.warning(f"post-deploy notify plane failed for {work_item_id}: {e}") diff --git a/tests/test_deploy_terminal_sync.py b/tests/test_deploy_terminal_sync.py index 5aae57e..d7b9b5e 100644 --- a/tests/test_deploy_terminal_sync.py +++ b/tests/test_deploy_terminal_sync.py @@ -90,6 +90,10 @@ def test_tc17_success_deploy_syncs_terminal_done(monkeypatch): # Spy the merge-lease release to confirm the terminal-sync still frees it. release = MagicMock() monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", release) + # ORCH-021 arms an orthogonal post-deploy-monitor reserved job at deploy->done + # for the self-hosting repo; disable it here so this test stays focused on the + # ORCH-036 terminal-sync contract (no PIPELINE agent launched leaving deploy). + monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_monitor_enabled", False) task_id = _make_task("deploy") stage_engine.run_deploy_finalizer( diff --git a/tests/test_post_deploy.py b/tests/test_post_deploy.py new file mode 100644 index 0000000..dabea89 --- /dev/null +++ b/tests/test_post_deploy.py @@ -0,0 +1,210 @@ +"""ORCH-021 unit tests — post-deploy monitor pure logic (TC-01..TC-15). + +The deterministic, network-free core (classification + reaction decision + +exit-code mapping + artefact frontmatter + never-raise) of ``src/post_deploy.py``. +Network probes and the rollback hook are exercised via mocks; the classifier is +the main subject (mirrors compute_staging_verdict in ORCH-061). +""" + +import os +import tempfile + +import pytest +import yaml + +# Isolate the settings singleton onto a tmp repos_dir BEFORE importing the module. +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") + +from src import post_deploy # noqa: E402 + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +def _probe(health_ok=True, total=2, fivexx=0): + return {"health_ok": health_ok, "total": total, "fivexx": fivexx} + + +@pytest.fixture(autouse=True) +def _tmp_state(monkeypatch, tmp_path): + monkeypatch.setattr(post_deploy.settings, "repos_dir", str(tmp_path)) + monkeypatch.setattr(post_deploy.settings, "host_repos_dir", str(tmp_path)) + yield + + +# --------------------------------------------------------------------------- +# TC-01..TC-05 — classification (the core) +# --------------------------------------------------------------------------- +def test_tc01_healthy_no_failures(): + series = [_probe() for _ in range(5)] + assert post_deploy.classify(series, fail_threshold=3, fivexx_threshold=0.5) == "HEALTHY" + + +def test_tc02_degraded_consecutive_health_failures(): + # Exactly fail_threshold consecutive failures -> DEGRADED (>= contract). + series = [_probe(health_ok=False) for _ in range(3)] + assert post_deploy.classify(series, fail_threshold=3, fivexx_threshold=0.5) == "DEGRADED" + + +def test_tc03_degraded_by_5xx_ratio_even_when_health_200(): + # /health stays 200 (health_ok True) but the 5xx ratio is above threshold. + series = [_probe(health_ok=True, total=2, fivexx=2) for _ in range(3)] + assert post_deploy.classify(series, fail_threshold=10, fivexx_threshold=0.5) == "DEGRADED" + + +def test_tc04_no_false_trip_single_glitch_then_recovery(): + # One isolated failure (1 < threshold) surrounded by healthy probes -> HEALTHY. + series = [_probe(), _probe(health_ok=False), _probe(), _probe()] + assert post_deploy.classify(series, fail_threshold=3, fivexx_threshold=0.5) == "HEALTHY" + + +def test_tc05_thresholds_change_verdict_on_same_data(): + # Same data, different threshold flips the verdict (AC-11): two consecutive fails. + series = [_probe(health_ok=False), _probe(health_ok=False)] + assert post_deploy.classify(series, fail_threshold=3, fivexx_threshold=0.5) == "HEALTHY" + assert post_deploy.classify(series, fail_threshold=2, fivexx_threshold=0.5) == "DEGRADED" + + +def test_classify_uses_settings_thresholds(monkeypatch): + # The tick reads thresholds from Settings (env ORCH_*) — verify the wiring point. + monkeypatch.setattr(post_deploy.settings, "post_deploy_fail_threshold", 2) + series = [_probe(health_ok=False), _probe(health_ok=False)] + assert post_deploy.classify( + series, + post_deploy.settings.post_deploy_fail_threshold, + post_deploy.settings.post_deploy_5xx_threshold, + ) == "DEGRADED" + + +# --------------------------------------------------------------------------- +# TC-06..TC-08 — reaction decision (self-hosting safety) +# --------------------------------------------------------------------------- +def test_tc06_nonself_auto_rollback_degraded_rolls_back(monkeypatch): + monkeypatch.setattr(post_deploy.settings, "post_deploy_auto_rollback", True) + assert post_deploy.decide_action("enduro-trails", "DEGRADED") == "ROLLBACK" + + +def test_tc07_self_hosting_degraded_never_rolls_back(monkeypatch): + # orchestrator (self-hosting) is ALWAYS ALERT_ONLY, even with auto_rollback on. + monkeypatch.setattr(post_deploy.settings, "post_deploy_auto_rollback", True) + assert post_deploy.decide_action("orchestrator", "DEGRADED") == "ALERT_ONLY" + + +def test_tc08_healthy_means_none_for_any_repo(): + assert post_deploy.decide_action("orchestrator", "HEALTHY") == "NONE" + assert post_deploy.decide_action("enduro-trails", "HEALTHY") == "NONE" + + +def test_nonself_default_policy_alert_only(monkeypatch): + monkeypatch.setattr(post_deploy.settings, "post_deploy_auto_rollback", False) + assert post_deploy.decide_action("enduro-trails", "DEGRADED") == "ALERT_ONLY" + + +# --------------------------------------------------------------------------- +# TC-09..TC-10 — conditionality / kill-switch +# --------------------------------------------------------------------------- +def test_tc09_applies_empty_repos_only_self_hosting(monkeypatch): + monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True) + monkeypatch.setattr(post_deploy.settings, "post_deploy_repos", "") + assert post_deploy.post_deploy_applies("orchestrator") is True + assert post_deploy.post_deploy_applies("enduro-trails") is False + + +def test_tc09_applies_explicit_repos_csv(monkeypatch): + monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True) + monkeypatch.setattr(post_deploy.settings, "post_deploy_repos", "enduro-trails") + assert post_deploy.post_deploy_applies("enduro-trails") is True + assert post_deploy.post_deploy_applies("orchestrator") is False + + +def test_tc10_kill_switch_disables_for_everyone(monkeypatch): + monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", False) + assert post_deploy.post_deploy_applies("orchestrator") is False + assert post_deploy.post_deploy_applies("enduro-trails") is False + + +# --------------------------------------------------------------------------- +# TC-11..TC-12 — rollback exit-code mapping +# --------------------------------------------------------------------------- +def test_tc11_rollback_exit0_is_ok(): + assert post_deploy.map_rollback_exit_code(0) == "ROLLBACK_OK" + + +def test_tc12_rollback_exit_nonzero_is_failed(): + assert post_deploy.map_rollback_exit_code(1) == "ROLLBACK_FAILED" + assert post_deploy.map_rollback_exit_code(2) == "ROLLBACK_FAILED" + assert post_deploy.map_rollback_exit_code(None) == "ROLLBACK_FAILED" + assert post_deploy.map_rollback_exit_code("garbage") == "ROLLBACK_FAILED" + + +# --------------------------------------------------------------------------- +# TC-13 — artefact frontmatter +# --------------------------------------------------------------------------- +def test_tc13_log_frontmatter_parses(): + body = post_deploy.build_post_deploy_log( + "ORCH-021", "DEGRADED", "ALERT_ONLY", 900, 12, 4 + ) + assert body.startswith("---\n") + fm = body.split("---", 2)[1] + data = yaml.safe_load(fm) + assert data["post_deploy_status"] == "DEGRADED" + assert data["action_taken"] == "ALERT_ONLY" + assert data["work_item"] == "ORCH-021" + assert data["window_s"] == 900 + assert data["checks_total"] == 12 + assert data["checks_failed"] == 4 + + +# --------------------------------------------------------------------------- +# TC-14..TC-15 — never-raise +# --------------------------------------------------------------------------- +def test_tc14_probe_network_error_is_conservative_not_raise(monkeypatch): + # urlopen raises on every call -> health bad + monitored endpoints counted as + # 5xx, but NO exception propagates (the helper swallows and reports code 0). + def boom(*a, **k): + raise OSError("network down") + + monkeypatch.setattr(post_deploy.urllib.request, "urlopen", boom) + res = post_deploy.probe_signals("http://localhost:8500") + assert res.health_ok is False + assert res.total == 2 + assert res.fivexx == 2 # unreachable endpoints counted as failures + + +def test_tc14_classify_junk_input_swallowed(): + # If classify gets junk it must not raise (fail-safe to HEALTHY). + assert post_deploy.classify("not-a-list", 3, 0.5) == "HEALTHY" + assert post_deploy.classify([{"bad": "row"}], 3, 0.5) == "HEALTHY" + assert post_deploy.classify(None, 3, 0.5) == "HEALTHY" + + +def test_tc15_write_log_no_worktree_returns_false(monkeypatch): + # get_worktree_path raises -> write returns False, no exception (best-effort). + def boom(repo, branch): + raise FileNotFoundError("no worktree") + + monkeypatch.setattr("src.git_worktree.get_worktree_path", boom) + ok = post_deploy.write_post_deploy_log( + "nope-repo", "ORCH-021", "feature/x", "HEALTHY", "NONE", 900, 3, 0 + ) + assert ok is False + + +# --------------------------------------------------------------------------- +# Sentinel state restart-safe counters +# --------------------------------------------------------------------------- +def test_series_append_and_read_roundtrip(): + post_deploy.write_marker("orchestrator", "ORCH-021", post_deploy.ARMED, "armed") + post_deploy.append_probe("orchestrator", "ORCH-021", post_deploy.ProbeResult(False, 2, 1, "x")) + post_deploy.append_probe("orchestrator", "ORCH-021", post_deploy.ProbeResult(True, 2, 0, "y")) + series = post_deploy.read_series("orchestrator", "ORCH-021") + assert len(series) == 2 + assert series[0]["health_ok"] is False + assert series[1]["health_ok"] is True + + +def test_mark_done_idempotency_marker(): + assert post_deploy.has_marker("orchestrator", "ORCH-021", post_deploy.DONE) is False + post_deploy.mark_done("orchestrator", "ORCH-021") + assert post_deploy.has_marker("orchestrator", "ORCH-021", post_deploy.DONE) is True diff --git a/tests/test_post_deploy_integration.py b/tests/test_post_deploy_integration.py new file mode 100644 index 0000000..7e1e8f6 --- /dev/null +++ b/tests/test_post_deploy_integration.py @@ -0,0 +1,259 @@ +"""ORCH-021 integration tests — arming + tick orchestration (TC-16..TC-20). + +Exercises the wiring in ``stage_engine`` (arm on deploy->done, +``run_post_deploy_monitor`` tick + reaction) and the ``/queue`` observability +block, with the network probe and the rollback hook mocked. Mirrors the +test_deploy_terminal_sync.py harness. +""" + +import os +import tempfile + +import pytest + +_test_db = os.path.join(tempfile.gettempdir(), "test_orch_post_deploy.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 post_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() + # State sentinels live under the tmp repos_dir (container view). + monkeypatch.setattr(post_deploy.settings, "repos_dir", str(tmp_path)) + monkeypatch.setattr(post_deploy.settings, "host_repos_dir", str(tmp_path)) + monkeypatch.setattr(stage_engine.settings, "repos_dir", str(tmp_path)) + # The artefact write is best-effort; stub it so no worktree is needed. + monkeypatch.setattr(post_deploy, "write_post_deploy_log", MagicMock(return_value=True)) + 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", + ): + monkeypatch.setattr(stage_engine, name, MagicMock()) + + +def _make_task(stage, repo="orchestrator", branch="feature/ORCH-021-x", wi="ORCH-021"): + 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 _jobs(agent=None): + conn = get_db() + if agent: + rows = conn.execute( + "SELECT agent FROM jobs WHERE agent=? ORDER BY id", (agent,) + ).fetchall() + else: + rows = conn.execute("SELECT agent FROM jobs ORDER BY id").fetchall() + conn.close() + return [r[0] for r in rows] + + +def _pass(*a, **k): + return (True, "ok") + + +def _drive_deploy_to_done(monkeypatch, task_id, repo="orchestrator", + branch="feature/ORCH-021-x", wi="ORCH-021"): + """Advance a deploy-stage task to done through the real terminal block.""" + monkeypatch.setattr( + stage_engine, "QG_CHECKS", + {**stage_engine.QG_CHECKS, "check_deploy_status": _pass}, + ) + monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", MagicMock()) + return stage_engine.advance_stage( + task_id=task_id, current_stage="deploy", repo=repo, + work_item_id=wi, branch=branch, finished_agent="deployer", + ) + + +# --------------------------------------------------------------------------- +# TC-16 — arm on deploy->done (applicable repo only) +# --------------------------------------------------------------------------- +def test_tc16_arm_for_self_hosting(monkeypatch): + monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True) + monkeypatch.setattr(post_deploy.settings, "post_deploy_repos", "") + task_id = _make_task("deploy") + _drive_deploy_to_done(monkeypatch, task_id) + + assert post_deploy.has_marker("orchestrator", "ORCH-021", post_deploy.ARMED) + assert "post-deploy-monitor" in _jobs("post-deploy-monitor") + + +def test_tc16_no_arm_for_nonself(monkeypatch): + monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True) + monkeypatch.setattr(post_deploy.settings, "post_deploy_repos", "") + task_id = _make_task("deploy", repo="enduro-trails", branch="feature/ET-9", wi="ET-9") + _drive_deploy_to_done(monkeypatch, task_id, repo="enduro-trails", + branch="feature/ET-9", wi="ET-9") + + assert not post_deploy.has_marker("enduro-trails", "ET-9", post_deploy.ARMED) + assert _jobs("post-deploy-monitor") == [] + + +def test_tc16_no_arm_when_kill_switch_off(monkeypatch): + monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", False) + task_id = _make_task("deploy") + _drive_deploy_to_done(monkeypatch, task_id) + assert not post_deploy.has_marker("orchestrator", "ORCH-021", post_deploy.ARMED) + assert _jobs("post-deploy-monitor") == [] + + +# --------------------------------------------------------------------------- +# TC-17 — idempotent arm (double webhook) +# --------------------------------------------------------------------------- +def test_tc17_double_arm_is_noop(monkeypatch): + monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True) + armed1 = post_deploy.arm_monitor("orchestrator", "ORCH-021", "feature/ORCH-021-x", 1) + armed2 = post_deploy.arm_monitor("orchestrator", "ORCH-021", "feature/ORCH-021-x", 1) + assert armed1 is True + assert armed2 is False + # Exactly ONE monitor job enqueued despite two arm calls. + assert _jobs("post-deploy-monitor") == ["post-deploy-monitor"] + + +# --------------------------------------------------------------------------- +# TC-18 — DEGRADED -> non-self auto-rollback (hook mocked) +# --------------------------------------------------------------------------- +def test_tc18_degraded_nonself_rolls_back(monkeypatch): + monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True) + monkeypatch.setattr(post_deploy.settings, "post_deploy_repos", "enduro-trails") + monkeypatch.setattr(post_deploy.settings, "post_deploy_auto_rollback", True) + monkeypatch.setattr(post_deploy.settings, "post_deploy_fail_threshold", 1) + monkeypatch.setattr(post_deploy.settings, "post_deploy_window_s", 30) + monkeypatch.setattr(post_deploy.settings, "post_deploy_interval_s", 30) # budget=1 tick + # Probe reports unhealthy. + monkeypatch.setattr( + post_deploy, "probe_signals", + lambda url: post_deploy.ProbeResult(False, 2, 2, "down"), + ) + rollback = MagicMock(return_value=(0, "ok")) + monkeypatch.setattr(post_deploy, "run_rollback", rollback) + notify = MagicMock() + monkeypatch.setattr(stage_engine, "_notify_post_deploy", notify) + logspy = MagicMock(return_value=True) + monkeypatch.setattr(post_deploy, "write_post_deploy_log", logspy) + + task_id = _make_task("done", repo="enduro-trails", branch="feature/ET-9", wi="ET-9") + post_deploy.write_marker("enduro-trails", "ET-9", post_deploy.ARMED, "armed") + stage_engine.run_post_deploy_monitor( + {"task_id": task_id, "repo": "enduro-trails", "id": 1, "agent": "post-deploy-monitor"} + ) + + rollback.assert_called_once_with("enduro-trails") + assert post_deploy.has_marker("enduro-trails", "ET-9", post_deploy.DONE) + # Artefact written with ROLLBACK_OK; a notification was sent. + args = logspy.call_args[0] + assert "DEGRADED" in args + assert "ROLLBACK_OK" in args + assert notify.called + + +# --------------------------------------------------------------------------- +# TC-19 — self-hosting DEGRADED never rolls back, alerts instead +# --------------------------------------------------------------------------- +def test_tc19_degraded_self_hosting_alert_only(monkeypatch): + monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True) + monkeypatch.setattr(post_deploy.settings, "post_deploy_auto_rollback", True) + monkeypatch.setattr(post_deploy.settings, "post_deploy_fail_threshold", 1) + monkeypatch.setattr(post_deploy.settings, "post_deploy_window_s", 30) + monkeypatch.setattr(post_deploy.settings, "post_deploy_interval_s", 30) + monkeypatch.setattr( + post_deploy, "probe_signals", + lambda url: post_deploy.ProbeResult(False, 2, 2, "down"), + ) + # Rollback hook MUST NOT be called for self-hosting (AC-8 structural invariant). + rollback = MagicMock(return_value=(0, "ok")) + monkeypatch.setattr(post_deploy, "run_rollback", rollback) + notify = MagicMock() + monkeypatch.setattr(stage_engine, "_notify_post_deploy", notify) + logspy = MagicMock(return_value=True) + monkeypatch.setattr(post_deploy, "write_post_deploy_log", logspy) + + task_id = _make_task("done") + post_deploy.write_marker("orchestrator", "ORCH-021", post_deploy.ARMED, "armed") + stage_engine.run_post_deploy_monitor( + {"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "post-deploy-monitor"} + ) + + rollback.assert_not_called() + assert post_deploy.has_marker("orchestrator", "ORCH-021", post_deploy.DONE) + args = logspy.call_args[0] + assert "DEGRADED" in args + assert "ALERT_ONLY" in args + assert notify.called + + +def test_healthy_tick_requeues_without_finishing(monkeypatch): + # HEALTHY and window not exhausted -> re-queue, do NOT mark done. + monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True) + monkeypatch.setattr(post_deploy.settings, "post_deploy_window_s", 90) + monkeypatch.setattr(post_deploy.settings, "post_deploy_interval_s", 30) # budget=3 + monkeypatch.setattr( + post_deploy, "probe_signals", + lambda url: post_deploy.ProbeResult(True, 2, 0, "ok"), + ) + task_id = _make_task("done") + post_deploy.write_marker("orchestrator", "ORCH-021", post_deploy.ARMED, "armed") + stage_engine.run_post_deploy_monitor( + {"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "post-deploy-monitor"} + ) + assert not post_deploy.has_marker("orchestrator", "ORCH-021", post_deploy.DONE) + # A follow-up tick job was enqueued. + assert _jobs("post-deploy-monitor") == ["post-deploy-monitor"] + + +def test_finished_window_tick_is_noop(monkeypatch): + # AC-15: a tick after the window is done is a no-op (no new job, no re-probe). + probe = MagicMock() + monkeypatch.setattr(post_deploy, "probe_signals", probe) + task_id = _make_task("done") + post_deploy.mark_done("orchestrator", "ORCH-021") + stage_engine.run_post_deploy_monitor( + {"task_id": task_id, "repo": "orchestrator", "id": 9, "agent": "post-deploy-monitor"} + ) + probe.assert_not_called() + + +# --------------------------------------------------------------------------- +# TC-20 — /queue observability block +# --------------------------------------------------------------------------- +def test_tc20_queue_block_present(monkeypatch): + monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True) + post_deploy.write_marker("orchestrator", "ORCH-021", post_deploy.ARMED, "armed") + snap = post_deploy.status() + assert snap["enabled"] is True + assert snap["window_s"] == post_deploy.settings.post_deploy_window_s + assert "ORCH-021" in snap["active"] + assert snap["active_count"] >= 1 + # A finished window drops out of "active". + post_deploy.mark_done("orchestrator", "ORCH-021") + snap2 = post_deploy.status() + assert "ORCH-021" not in snap2["active"] From fbfcd84b16b974777d914656279bf4be0458dcf8 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 14:19:44 +0000 Subject: [PATCH 5/9] reviewer(ET): auto-commit from reviewer run_id=308 --- docs/work-items/ORCH-021/12-review.md | 73 +++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 docs/work-items/ORCH-021/12-review.md diff --git a/docs/work-items/ORCH-021/12-review.md b/docs/work-items/ORCH-021/12-review.md new file mode 100644 index 0000000..b84f902 --- /dev/null +++ b/docs/work-items/ORCH-021/12-review.md @@ -0,0 +1,73 @@ +--- +type: review +work_item_id: ORCH-021 +verdict: APPROVED +version: 1 +--- + +# Review ORCH-021 — Post-deploy мониторинг прода + реакция на деградацию + +## Summary +Реализация продлевает ответственность конвейера ЗА `deploy → done` через +reserved-agent job `post-deploy-monitor` (вариант B из ADR-001, калька +`deploy-finalizer`). Новый leaf-модуль `src/post_deploy.py` (never-raise), арм в +terminal-блоке `advance_stage`, перехват тика в `launcher.launch_job` ДО `_spawn`, +исполнение в `stage_engine.run_post_deploy_monitor`, блок `post_deploy` в `GET /queue`. +Соответствует ТЗ, ADR и критериям приёмки. Все **700 тестов зелёные** +(`pytest tests/ -q`), включая 30 новых (`test_post_deploy.py`, +`test_post_deploy_integration.py`). Документация обновлена в том же PR. P0/P1 нет. + +## Соответствие ТЗ / ADR +- §2.1 `src/post_deploy.py` — leaf-модуль, never-raise, `post_deploy_applies`, + `probe_signals`, `classify`, `decide_action`, sentinel-state, артефакт, rollback-команда. ✔ +- §2.2 механизм — reserved-agent job, restart-safe (sentinel `armed`/`series`/`done` + + jobs-очередь), идемпотентный арм. ✔ (соответствует выбору архитектора в ADR §1) +- §2.3 реакция — self-hosting ВСЕГДА `ALERT_ONLY` (структурно: `decide_action` → + `run_rollback` недостижим для self), не-self+auto → `--rollback`, exit 1/2 → эскалация. ✔ +- §2.4 конфигурация — все `post_deploy_*` параметры с безопасными дефолтами; откат + переиспользует `deploy_prod_*`. ✔ +- §2.5 артефакт `16-post-deploy-log.md` — валидный YAML-frontmatter, best-effort. ✔ +- §2.6 `GET /queue` блок `post_deploy`. ✔ +- §3 инварианты — `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_deploy_status`, terminal-sync, + merge-gate, exit-коды хука, схема БД — НЕ изменены (AC-12; подтверждено зелёным + полным прогоном). ✔ + +## Критерии приёмки +AC-1…AC-18 покрыты тестами TC01–TC20 (classify HEALTHY/DEGRADED по обоим порогам, +устойчивость к одиночному глюку, kill-switch, условность репо, idempotent арм, +self-hosting ALERT_ONLY, non-self rollback + эскалация, never-raise, артефакт, +`/queue`). AC-18 (документация) — выполнен (см. ниже). + +## Findings + +### P0 — Blocker +- нет + +### P1 — Must fix +- нет + +### P2 — Should fix +- нет + +### P3 — Nice-to-have (не блокируют) +- `build_rollback_command(repo)` принимает `repo`, но не использует его (симметрия с + `build_deploy_command`); можно убрать или задокументировать. +- `status()` в `/queue` формирует `active` по `os.path.basename(d)` (только work_item_id, + без repo) — для разных репо с одинаковым wi возможна косметическая коллизия в выводе. +- Теоретическое раздвоение цепочки тиков при дубле job (как у `deploy-finalizer`); + на практике гасится `max_concurrency=1` + `done`-маркером. Принятый паттерн, не регресс. + +## Документация +Обновлена в том же PR (golden-source, AC-18 PASS): +- `CLAUDE.md` — `16-post-deploy-log.md` добавлен в перечень артефактов. +- `docs/architecture/README.md` — раздел «Post-deploy наблюдение…», блок `/queue`, + заметка об обновлении. +- `CHANGELOG.md` — запись в `[Unreleased] / Added`. +- `.env.example` — переменные `ORCH_POST_DEPLOY_*`. +- ADR work-item `docs/work-items/ORCH-021/06-adr/ADR-001-post-deploy-monitor.md` + + сквозной `docs/architecture/adr/adr-0010-post-deploy-monitor.md`. + +## Заметка о diff +`git diff main...HEAD` содержит файлы ORCH-060/ORCH-061/`staging_verdict.py`/`reconciler.py` +из-за устаревшей merge-base ветки (эти изменения уже прошли свои PR в `main`). +Предмет ревью — единственный коммит ORCH-021 `c5b516b` (12 файлов). From b04fae748e45ed0f126daaa2e3b9e55a1242fcd7 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 14:21:29 +0000 Subject: [PATCH 6/9] tester(ET): auto-commit from tester run_id=309 --- docs/work-items/ORCH-021/13-test-report.md | 82 ++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 docs/work-items/ORCH-021/13-test-report.md diff --git a/docs/work-items/ORCH-021/13-test-report.md b/docs/work-items/ORCH-021/13-test-report.md new file mode 100644 index 0000000..a3d08c7 --- /dev/null +++ b/docs/work-items/ORCH-021/13-test-report.md @@ -0,0 +1,82 @@ +--- +type: test-report +work_item_id: ORCH-021 +result: PASS +--- + +# Test Report — ORCH-021 + +Post-deploy наблюдение прода + реакция на деградацию (reserved-agent job +`post-deploy-monitor`, leaf-модуль `src/post_deploy.py`). + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 (asyncio mode=AUTO, anyio 4.13.0) +- Ветка: feature/ORCH-021-post-deploy-rollback +- Дата: 2026-06-07 + +## Прогон +- `pytest tests/ -v --tb=short` → **700 passed, 1 warning** (Pydantic V2 deprecation, не относится к задаче). +- Целевые модули `tests/test_post_deploy.py` + `tests/test_post_deploy_integration.py` → **30 passed**. + +## Smoke-test (read-only, прод 8500) +`curl` в окружении недоступен — опрос через `python urllib` (read-only, прод-контейнер не трогается). + +| Эндпоинт | Результат | +|----------|-----------| +| `GET /health` | 200 `{"status":"ok","service":"orchestrator"}` | +| `GET /status` | 200, активная задача ORCH-021 на стадии `testing` | +| `GET /queue` | 200, counts/resilience/reconcile присутствуют | + +> Примечание: блок `post_deploy` в **живом** `/queue` отсутствует — это ожидаемо: прод +> сейчас работает на коде ДО ORCH-021 (задача ещё не задеплоена, стадия testing). +> Наличие блока (AC-14) проверяется интеграционным тестом TC-20 против кода ветки → PASS. +> Smoke-проверка подтверждает живость окружения, не версию ветки. + +## Результаты по тест-плану (04-test-plan.yaml) + +| TC ID | Описание | Покрывает AC | Тест-функция | Результат | +|-------|----------|--------------|--------------|-----------| +| TC-01 | HEALTHY: серия без провалов < порога | AC-3 | test_tc01_healthy_no_failures | PASS | +| TC-02 | DEGRADED: N посл. провалов health == threshold | AC-4 | test_tc02_degraded_consecutive_health_failures | PASS | +| TC-03 | DEGRADED по 5xx при health=200 | AC-5 | test_tc03_degraded_by_5xx_ratio_even_when_health_200 | PASS | +| TC-04 | Нет ложного срабатывания: одиночный глюк + восстановление | AC-6 | test_tc04_no_false_trip_single_glitch_then_recovery | PASS | +| TC-05 | Пороги из Settings меняют вердикт на тех же данных | AC-11 | test_tc05_thresholds_change_verdict_on_same_data, test_classify_uses_settings_thresholds | PASS | +| TC-06 | не-self + auto_rollback=True + DEGRADED → ROLLBACK | AC-7 | test_tc06_nonself_auto_rollback_degraded_rolls_back | PASS | +| TC-07 | self-hosting + DEGRADED → ALERT_ONLY (никогда не авто-rollback) | AC-8 | test_tc07_self_hosting_degraded_never_rolls_back | PASS | +| TC-08 | HEALTHY → NONE для любого репо | AC-3 | test_tc08_healthy_means_none_for_any_repo, test_nonself_default_policy_alert_only | PASS | +| TC-09 | post_deploy_applies: пусто → только orchestrator | AC-2 | test_tc09_applies_empty_repos_only_self_hosting, test_tc09_applies_explicit_repos_csv | PASS | +| TC-10 | kill-switch: monitor_enabled=False → applies()=False для всех | AC-10 | test_tc10_kill_switch_disables_for_everyone | PASS | +| TC-11 | Откат exit 0 → ROLLBACK_OK | AC-7 | test_tc11_rollback_exit0_is_ok | PASS | +| TC-12 | Откат exit 1/2 → ROLLBACK_FAILED + эскалация | AC-9 | test_tc12_rollback_exit_nonzero_is_failed | PASS | +| TC-13 | 16-post-deploy-log.md: валидный YAML-frontmatter | AC-13 | test_tc13_log_frontmatter_parses | PASS | +| TC-14 | Опрос при сетевой ошибке → консервативный, не raise | AC-16 | test_tc14_probe_network_error_is_conservative_not_raise, test_tc14_classify_junk_input_swallowed | PASS | +| TC-15 | Ошибка записи артефакта → False, не raise | AC-16, AC-13 | test_tc15_write_log_no_worktree_returns_false | PASS | +| TC-16 | advance_stage deploy→done армит наблюдение (self), не армит (non-self) | AC-1, AC-2 | test_tc16_arm_for_self_hosting, test_tc16_no_arm_for_nonself, test_tc16_no_arm_when_kill_switch_off | PASS | +| TC-17 | Идемпотентность: повторный арм не задваивает | AC-15 | test_tc17_double_arm_is_noop | PASS | +| TC-18 | Полный цикл DEGRADED → не-self откат + лог + уведомление | AC-7, AC-13, AC-17 | test_tc18_degraded_nonself_rolls_back | PASS | +| TC-19 | Self-hosting DEGRADED → НЕ рестарт/откат, алерт+approve | AC-8, AC-17 | test_tc19_degraded_self_hosting_alert_only | PASS | +| TC-20 | GET /queue содержит блок post_deploy | AC-14 | test_tc20_queue_block_present | PASS | +| TC-21 | Регресс: deploy/staging/merge-gate/reconciler зелёные; STAGE_TRANSITIONS/QG_CHECKS не изменены | AC-12 | tests/test_stages.py (+ полный прогон 700) | PASS | + +Доп. тесты ветки (не из плана, подтверждают контракты): `test_series_append_and_read_roundtrip`, +`test_mark_done_idempotency_marker`, `test_healthy_tick_requeues_without_finishing`, +`test_finished_window_tick_is_noop` — все PASS. + +## Покрытие критериев приёмки +AC-1…AC-18 — все покрыты прошедшими тестами (см. таблицу). AC-12 (реестры/схема БД +не изменены) дополнительно подтверждён зелёным полным регрессом 700 тестов, включая +deploy/staging/merge-gate/reconciler. AC-18 (документация) — вне scope прогона тестов, +подтверждён ревью (12-review.md, verdict APPROVED). + +## Вывод pytest (хвост) +``` +======================= 700 passed, 1 warning in 14.10s ======================== +``` +``` +======================== 30 passed, 1 warning in 0.64s ========================= +``` + +## Итог +**PASS.** Все 21 тест-кейс плана зелёные, полный регресс (700) зелёный, smoke прод-эндпоинтов +OK (окружение живо). Существующие контракты не сломаны. Задача готова к стадии deploy-staging. From b9bcdc1545d958aa040efafda247d808b12e40ae Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 14:30:02 +0000 Subject: [PATCH 7/9] fix(deploy): drop COPY data/ from Dockerfile so worktree-context staging build succeeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ORCH-058 staging rebuild (check_staging_image_fresh) builds the image with the task git-worktree as the docker build context. A fresh worktree holds only tracked files, but the Dockerfile did `COPY data/ ./data/` — and `data/` (the SQLite dir) is gitignored, so it is absent from that context: `docker build` failed with exit 1 ("BUILD-STAGING: docker build failed - aborting"), bouncing the task off deploy-staging back to development in a loop. The COPY was dead weight regardless: `data/` is always supplied at runtime as a bind-mount volume (./data:/app/data, see docker-compose.yml) which shadows anything baked into the image. Replace it with `RUN mkdir -p /app/data` so the mountpoint exists without depending on the build context. Regression guard: test_tc08b_dockerfile_does_not_copy_gitignored_data_dir forbids COPY of any gitignored path (the worktree-context invariant). Refs: ORCH-021 Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 1 + Dockerfile | 9 ++++++++- tests/test_deploy_hook_provenance.py | 25 +++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 054341f..e3f1d8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ - Цепочка стадий: `... testing → deploy-staging → deploy → done` (была без `deploy-staging`). ### Fixed +- **Staging-rebuild больше не падает на `COPY data/` (worktree-контекст)** (ORCH-021): `check_staging_image_fresh` (ORCH-058, Strategy A) пересобирает staging-образ с **worktree задачи** в качестве docker build context (`docker build … "$BUILD_CONTEXT"`). Свежий git-worktree содержит только трекаемые файлы, а `Dockerfile` делал `COPY data/ ./data/` — но `data/` (директория SQLite) **gitignored** и в worktree-контексте отсутствует → `docker build` падал с `exit 1` («BUILD-STAGING: docker build failed - aborting»), задачу заворачивало с `deploy-staging` на `development` (петля, выжигание developer-ретраев, инцидент текущего прогона ORCH-021). При этом COPY был мёртвым грузом: `data/` всегда приходит рантайм-volume'ом (`./data:/app/data` / `./data/staging:/app/data` в `docker-compose.yml`), который затеняет всё, что было запечено в образ. Заменено на `RUN mkdir -p /app/data` (директория-mountpoint существует и без bind-mount, без зависимости от build-контекста). Контракты `STAGE_TRANSITIONS`/`QG_CHECKS`, штамп `LABEL org.opencontainers.image.revision=$GIT_SHA` (ORCH-058 Strategy B), exit-код-контракт хука — не тронуты. Регресс-гард: `tests/test_deploy_hook_provenance.py::test_tc08b_dockerfile_does_not_copy_gitignored_data_dir` (запрещает `COPY` любого gitignored-пути). - **`deploy-staging` больше не зацикливается на infra-only FAIL песочницы (C9a/C9b)** (ORCH-061): self-hosting `orchestrator` крутился в петле `deploy-staging → development` — `scripts/staging_check.py` давал `exit 1` при ЛЮБОМ упавшем чеке, поэтому две чисто инфраструктурные проверки **C9a** (ветка не появилась в `orchestrator-sandbox`) и **C9b** (job аналитика не встал в очередь staging) — вызванные тем, что SANDBOX-бот-аккаунты не состоят в sandbox-проекте Plane (шаги 6+ конвейера в песочнице недостижимы, это НЕ регресс конвейера) — приводили к `staging_status: FAILED` → откат → цикл (выжигание developer-ретраев, токенов, паразитная нагрузка общего инстанса). Решение (Direction «б», ADR-001): чеки классифицируются на `REAL` (все проверки конвейера A*/B*/C7/C8 — fail-closed) и `SANDBOX_INFRA` (строго allowlist `{C9a, C9b}` — waivable). Новый leaf-модуль `src/staging_verdict.py` (stdlib-only, контракт «never raise», по образцу `merge_gate`/`image_freshness`): `classify_check(label)` (allowlist по ведущему токену, всё неизвестное/малформенное → `REAL` fail-closed) и `compute_staging_verdict(items, infra_tolerant) -> StagingVerdict`: любой REAL-FAIL → `FAILED`/exit 1 (страховка при ЛЮБОМ значении флага); упали ТОЛЬКО C9a/C9b и толерантность включена → `SUCCESS`/exit 0 + упавшие метки в `waived` (наблюдаемость); только C9a/C9b и толерантность выключена → `FAILED`/exit 1 (legacy-строгий); любая внутренняя ошибка вердикта → `FAILED`/exit 1 (никогда не ложный green). `scripts/staging_check.py`: `Results` авто-классифицирует каждый чек (публичная 3-tuple форма `_items` сохранена — регрессия-гард ORCH-048 b6), `categorized_items()` отдаёт категорию, `summary()` печатает разбивку REAL/SANDBOX_INFRA; `main()` сворачивает прогон через `_verdict(...)`, печатает строки `INFRA-WAIVED:`/`VERDICT:` и делает `sys.exit(verdict.exit_code)`; новый флаг `--strict` форсит строгий режим для одного запуска. Глобальный kill-switch `ORCH_STAGING_INFRA_TOLERANCE_ENABLED` (`Settings.staging_infra_tolerance_enabled`, default `true`; `false` → строгий 1:1 до ORCH-061), живёт в `.env.staging`; `--strict` имеет приоритет над env. Наблюдаемость на стороне конвейера: `src/agents/launcher.py` получил `action_stage_no_changes_note(stage, repo)` — на action-стадиях (`deploy-staging`/`deploy`) self-hosting-репо «нет изменений для коммита» логируется как ожидаемое, а не трактуется как недопоставка. Контракты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, frontmatter `staging_status: SUCCESS|FAILED` / `deploy_status:` (толерантность применяется в скрипте ДО записи артефакта деплоером), exit-code-контракт хука (0/1/2), `check_staging_status`/`_parse_staging_status`; схема БД — без миграций. ADR `docs/work-items/ORCH-061/06-adr/ADR-001-staging-infra-tolerance.md`. Документация: `docs/architecture/README.md`, `docs/operations/STAGING_CHECK.md`, `.openclaw/agents/deployer.md`. Тесты: `tests/test_staging_check_b6.py`, `tests/test_qg_checks.py`, `tests/test_config.py`, `tests/test_launcher.py`, `tests/test_qg.py`, `tests/test_stage_engine.py::TestStagingInfraTolerance`. - **Reconciler (F-1) больше не разблокирует escalated / Blocked / Needs-Input задачи** (ORCH-060): sweeper потерянных webhook (ORCH-053) не отличал «застряла из-за потерянного события» от «исчерпала лимит developer-ретраев и ждёт человека» — если CI зелёный, а reviewer слал REQUEST_CHANGES до `MAX_DEVELOPER_RETRIES`, каждый тик F-1 видел зелёный `check_ci_green` и доигрывал `development → review` → reviewer снова REQUEST_CHANGES → откат (стадия не меняется, escalated в `gitea.py` лишь шлёт `notify_error`) → следующий тик снова разблокировал. Бесконечная петля (инцидент ET-013: 10 разблокировок за ночь, лишние запуски агентов/токены, спам в Telegram, паразитная нагрузка общего self-hosting-инстанса). В `Reconciler._reconcile_gate_task` (`src/reconciler.py`) ПОСЛЕ существующих гардов (`analysis` carve-out, нет гейта, активный job, grace) и ДО пред-оценки гейта добавлены два пред-гарда с ранним `return` (молчаливый skip — без `advance`, без инкремента `unblocked_total`, без нотификаций): **Guard 1 (escalated, детерминированный, без сети, проверяется первым)** — `developer_retry_count(task_id) >= MAX_DEVELOPER_RETRIES`; приватный `stage_engine._developer_retry_count` повышен до публичного `developer_retry_count` (единый источник истины по подсчёту ретраев `agent_runs`, приватное имя сохранено как алиас), граница берётся из `stage_engine.MAX_DEVELOPER_RETRIES` (не хардкод `3`). **Guard 2 (явный человеческий Plane-статус, Вариант A — без миграции БД)** — новый never-raise хелпер `plane_sync.fetch_issue_state(issue_id, project_id) -> str|None` (тот же endpoint/headers, что `fetch_issue_sequence_id`) + `Reconciler._is_blocked_or_needs_input(task)`: резолв проекта (`projects.get_project_by_repo`) → `get_project_states(pid)` → сверка текущего state issue с `blocked`/`needs_input`; любая ошибка/`None`/нерезолвленный проект → консервативный skip (`True`: не-разблокировать безопаснее). F-2 по существу не менялся: Blocked/Needs Input не входят в опрашиваемый набор `{in_progress, approved, rejected}` → не доигрываются (зафиксировано регресс-тестом). Новый под-флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED` (true) гасит ТОЛЬКО сетевой Guard 2 (escape hatch при Plane-outage); Guard 1 всегда активен. Схема БД, `STAGE_TRANSITIONS`, `QG_CHECKS`, never-raise на единицу работы, `analysis` carve-out и kill-switch'и (`reconcile_enabled`/`reconcile_plane_enabled`) не менялись. ADR `docs/work-items/ORCH-060/06-adr/ADR-001-reconciler-skip-escalated.md`. Тесты: `tests/test_reconciler.py` (TC-01…TC-11 + регресс ORCH-053). - **Re-deploy после отката больше не зависает на `deploy`; `.env.example` дополнен** (ORCH-036, review-fix): sentinel-маркеры самодеплоя (`approve-requested`/`initiated`/`result`) ключуются по стабильному `work_item_id`, поэтому при FAILED-деплое и откате БАГ-8 (`deploy → development`) они оставались на диске — после фикса developer-ом и повторного захода задачи на `deploy` Фаза B по idempotency-guard видела STALE `initiated` и становилась no-op: detached-хук не перезапускался, finalizer не ставился, задача висела на `deploy` навсегда (нарушался retry-контракт стадии, AC-4/AC-10; устаревший `result` к тому же был бы перечитан новым finalizer'ом). Добавлен `self_deploy.clear_state(repo, work_item_id)` (never-raise, idempotent, рекурсивное удаление `/.deploy-state-//`), вызывается в ветке БАГ-8-отката `check_deploy_status` FAILED (`src/stage_engine.py`) и дополнительно в начале Фазы A (`_handle_self_deploy_phase_a`) — каждый новый прод-деплой-проход стартует с чистого состояния. Отдельно: канонический `.env.example` (CLAUDE.md правило №8, ТЗ §2.6) дополнен полным блоком новых дескрипторов `ORCH_SELF_DEPLOY_*` / `ORCH_DEPLOY_*` (плейсхолдеры, секреты не коммитятся) по образцу merge-gate ORCH-043. Контракты `STAGE_TRANSITIONS` / `QG_CHECKS` / `_parse_deploy_status` / БАГ-8 / merge-gate не тронуты. Тесты: `tests/test_deploy_rollback.py::test_tc11_re_deploy_after_rollback_not_wedged`, `tests/test_deploy_hook_mapping.py::test_clear_state_removes_all_markers_and_is_idempotent`. diff --git a/Dockerfile b/Dockerfile index b8b34dc..890aef5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,6 +20,13 @@ RUN groupadd -g 1000 app && useradd -u 1000 -g 1000 -m -d /home/slin -s /bin/bas COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY src/ ./src/ -COPY data/ ./data/ +# ORCH-021: do NOT `COPY data/ ./data/`. `data/` is gitignored (SQLite DB dir) and +# is provided at runtime as a bind-mount volume (`./data:/app/data`, see +# docker-compose.yml) which shadows anything baked into the image — so the COPY was +# dead weight. Worse, the ORCH-058 staging rebuild (`check_staging_image_fresh`) +# builds with the task *worktree* as the docker build context; a fresh worktree never +# contains the untracked `data/`, so `COPY data/` failed `docker build` with exit 1 +# and bounced the task off `deploy-staging`. We just ensure the mountpoint exists. +RUN mkdir -p /app/data ENV PYTHONPATH=/app CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8500"] diff --git a/tests/test_deploy_hook_provenance.py b/tests/test_deploy_hook_provenance.py index c742763..8d5cf8a 100644 --- a/tests/test_deploy_hook_provenance.py +++ b/tests/test_deploy_hook_provenance.py @@ -102,6 +102,31 @@ def test_tc08_dockerfile_stamps_revision_label(): assert "LABEL org.opencontainers.image.revision=$GIT_SHA" in text +# --------------------------------------------------------------------------- +# TC-08b (ORCH-021 regression): the Dockerfile must not COPY a gitignored path. +# The ORCH-058 staging rebuild builds with the task *worktree* as the docker build +# context. A fresh worktree contains only tracked files, so any `COPY ` +# (notably `data/`, the SQLite dir) makes `docker build` fail with exit 1 and bounces +# the task off `deploy-staging`. `data/` is a runtime bind-mount volume anyway, so it +# must never be a COPY source. +# --------------------------------------------------------------------------- +def test_tc08b_dockerfile_does_not_copy_gitignored_data_dir(): + text = _DOCKERFILE.read_text(encoding="utf-8") + gitignore = (_ROOT / ".gitignore").read_text(encoding="utf-8").splitlines() + # Precondition: `data/` really is gitignored (the build context will not have it). + assert "data/" in [ln.strip() for ln in gitignore] + # The Dockerfile must not COPY it (would break the worktree-context staging build). + copy_sources = [ + line.split()[1] + for line in text.splitlines() + if line.strip().upper().startswith("COPY") and len(line.split()) >= 3 + ] + assert "data/" not in copy_sources, ( + "Dockerfile must not `COPY data/` — it's gitignored and absent from the " + "worktree build context used by the ORCH-058 staging rebuild (exit 1)." + ) + + # --------------------------------------------------------------------------- # TC-09: caller↔hook contract — rebuild_staging_image builds the right command # --------------------------------------------------------------------------- From 03d899812ce766e9e63dba3c8351710dcea2f779 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 14:33:57 +0000 Subject: [PATCH 8/9] reviewer(ET): auto-commit from reviewer run_id=312 --- docs/work-items/ORCH-021/12-review.md | 120 ++++++++++++++++---------- 1 file changed, 73 insertions(+), 47 deletions(-) diff --git a/docs/work-items/ORCH-021/12-review.md b/docs/work-items/ORCH-021/12-review.md index b84f902..2649d01 100644 --- a/docs/work-items/ORCH-021/12-review.md +++ b/docs/work-items/ORCH-021/12-review.md @@ -2,41 +2,64 @@ type: review work_item_id: ORCH-021 verdict: APPROVED -version: 1 +version: 2 --- # Review ORCH-021 — Post-deploy мониторинг прода + реакция на деградацию ## Summary -Реализация продлевает ответственность конвейера ЗА `deploy → done` через -reserved-agent job `post-deploy-monitor` (вариант B из ADR-001, калька -`deploy-finalizer`). Новый leaf-модуль `src/post_deploy.py` (never-raise), арм в -terminal-блоке `advance_stage`, перехват тика в `launcher.launch_job` ДО `_spawn`, -исполнение в `stage_engine.run_post_deploy_monitor`, блок `post_deploy` в `GET /queue`. -Соответствует ТЗ, ADR и критериям приёмки. Все **700 тестов зелёные** -(`pytest tests/ -q`), включая 30 новых (`test_post_deploy.py`, -`test_post_deploy_integration.py`). Документация обновлена в том же PR. P0/P1 нет. +Реализация продлевает ответственность конвейера ЗА терминальный переход +`deploy → done`, закрывая класс инцидентов «зелёный деплой, красный прод» (ET-8). +Механизм — детерминированный reserved-agent job `post-deploy-monitor` (вариант B +из ADR-001, точная калька `deploy-finalizer`): арм в `stage_engine.advance_stage` +(блок `next_stage == "done"`), один тик = один job (перехват в +`launcher.launch_job` ДО `_spawn` → `stage_engine.run_post_deploy_monitor`), +чистая логика в новом leaf-модуле `src/post_deploy.py` (never-raise). -## Соответствие ТЗ / ADR -- §2.1 `src/post_deploy.py` — leaf-модуль, never-raise, `post_deploy_applies`, - `probe_signals`, `classify`, `decide_action`, sentinel-state, артефакт, rollback-команда. ✔ -- §2.2 механизм — reserved-agent job, restart-safe (sentinel `armed`/`series`/`done` - + jobs-очередь), идемпотентный арм. ✔ (соответствует выбору архитектора в ADR §1) -- §2.3 реакция — self-hosting ВСЕГДА `ALERT_ONLY` (структурно: `decide_action` → - `run_rollback` недостижим для self), не-self+auto → `--rollback`, exit 1/2 → эскалация. ✔ -- §2.4 конфигурация — все `post_deploy_*` параметры с безопасными дефолтами; откат - переиспользует `deploy_prod_*`. ✔ -- §2.5 артефакт `16-post-deploy-log.md` — валидный YAML-frontmatter, best-effort. ✔ -- §2.6 `GET /queue` блок `post_deploy`. ✔ -- §3 инварианты — `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_deploy_status`, terminal-sync, - merge-gate, exit-коды хука, схема БД — НЕ изменены (AC-12; подтверждено зелёным - полным прогоном). ✔ +Проверены все четыре оси. Реализация соответствует ТЗ (`02-trz.md`), ADR-001 и +глобальному adr-0010, удовлетворяет всем критериям приёмки AC-1…AC-18. +Документация (golden-source) обновлена в том же PR. Регрессов нет. -## Критерии приёмки -AC-1…AC-18 покрыты тестами TC01–TC20 (classify HEALTHY/DEGRADED по обоим порогам, -устойчивость к одиночному глюку, kill-switch, условность репо, idempotent арм, -self-hosting ALERT_ONLY, non-self rollback + эскалация, never-raise, артефакт, -`/queue`). AC-18 (документация) — выполнен (см. ниже). +## Соответствие ТЗ +- §2.1 `src/post_deploy.py` (leaf, never-raise): `post_deploy_applies`, + `probe_signals`, `classify`, `decide_action`, sentinel-state, артефакт, + `build_rollback_command` — все на месте. ✅ +- §2.2 Оркестрация: арм в terminal-блоке + reserved-agent тик с + само-перепостановкой через `available_at_delay_s`; restart-safe (sentinel + `armed`/`series`/`done` + jobs-очередь). ✅ +- §2.3 Реакция: non-self+auto → хук `--rollback` (синхронно, целевой ≠ orch); + self-hosting → ВСЕГДА `ALERT_ONLY`. ✅ +- §2.4 Конфигурация: все `post_deploy_*` в `src/config.py`, дефолты безопасны + (kill-switch on, auto-rollback off), параметры отката переиспользуют + `deploy_prod_*`. ✅ +- §2.5 Артефакт `16-post-deploy-log.md` с машиночитаемым frontmatter, + best-effort. ✅ +- §2.6 Блок `post_deploy` в `GET /queue`. ✅ +- §2.7/§2.8/§3 Инварианты: `STAGE_TRANSITIONS`, `QG_CHECKS`, + `check_deploy_status`, terminal-sync, merge-gate, exit-code-контракт хука, + схема БД — не тронуты (подтверждено зелёным полным прогоном). ✅ + +## Соответствие ADR +Реализация 1:1 повторяет ADR-001: механизм (reserved-agent, не стадия/не daemon), +точки интеграции, пороги BR-3, политика реакции BR-5 (self never auto-rollback — +структурный инвариант в `decide_action` + отсутствие вызова `run_rollback` на +ALERT_ONLY). Нарушений глобальных ADR не выявлено. + +## Качество кода +- Контракт never-raise выдержан во всех публичных функциях и в каждой ветке + `run_post_deploy_monitor`; launcher оборачивает тик в доп. guard (AC-16). +- `classify` fail-safe → HEALTHY на мусорном входе (ложный DEGRADED опаснее). +- Docstrings содержательные, со ссылками на AC/BR. +- Условность раската по образцу ORCH-35/36/43/58 (флаг + CSV-репо). + +## Тесты +30 тестов ORCH-021 (`tests/test_post_deploy.py`, +`tests/test_post_deploy_integration.py`) — содержательные, покрывают +классификацию (AC-3..6), self-hosting safety (TC-19 явно проверяет, что хук +`--rollback` НЕ вызывается для self — AC-8), idempotency двойного арма (AC-15), +kill-switch/условность (AC-2/10/11), exit-code маппинг (AC-9), frontmatter +артефакта (AC-13), never-raise (AC-16), `/queue` (AC-14). Полный прогон +`pytest tests/` — **701 passed** (регрессов нет, AC-12). ## Findings @@ -49,25 +72,28 @@ self-hosting ALERT_ONLY, non-self rollback + эскалация, never-raise, а ### P2 — Should fix - нет -### P3 — Nice-to-have (не блокируют) -- `build_rollback_command(repo)` принимает `repo`, но не использует его (симметрия с - `build_deploy_command`); можно убрать или задокументировать. -- `status()` в `/queue` формирует `active` по `os.path.basename(d)` (только work_item_id, - без repo) — для разных репо с одинаковым wi возможна косметическая коллизия в выводе. -- Теоретическое раздвоение цепочки тиков при дубле job (как у `deploy-finalizer`); - на практике гасится `max_concurrency=1` + `done`-маркером. Принятый паттерн, не регресс. +### P3 — Nice to have +- [ ] `run_post_deploy_monitor`: в ветке `ALERT_ONLY` для **не-self** репо при + `post_deploy_auto_rollback=false` текст алерта упоминает «авто-rollback для + self-hosting запрещён (BR-5)», что для не-self случая формулировка не совсем + точна (косметика сообщения; на поведение не влияет). +- [ ] `write_post_deploy_log` коммитит/пушит артефакт в ветку задачи, которая к + моменту наблюдения уже слита/может быть удалена — артефакт может не попасть в + `main`. Контракт best-effort соблюдён (never-raise, ничего не роняет); как + улучшение наблюдаемости — рассмотреть запись лог-артефакта отдельным путём. ## Документация -Обновлена в том же PR (golden-source, AC-18 PASS): -- `CLAUDE.md` — `16-post-deploy-log.md` добавлен в перечень артефактов. -- `docs/architecture/README.md` — раздел «Post-deploy наблюдение…», блок `/queue`, - заметка об обновлении. -- `CHANGELOG.md` — запись в `[Unreleased] / Added`. -- `.env.example` — переменные `ORCH_POST_DEPLOY_*`. -- ADR work-item `docs/work-items/ORCH-021/06-adr/ADR-001-post-deploy-monitor.md` - + сквозной `docs/architecture/adr/adr-0010-post-deploy-monitor.md`. +Обновлено в том же PR (golden-source, AC-18 — PASS): +- `CLAUDE.md` — `16-post-deploy-log.md` добавлен в перечень артефактов; +- `docs/architecture/README.md` — раздел «Post-deploy наблюдение прода» + блок + `post_deploy` в таблице API `/queue`; +- `docs/architecture/adr/adr-0010-post-deploy-monitor.md` — новый сквозной ADR; +- `docs/work-items/ORCH-021/06-adr/ADR-001-post-deploy-monitor.md` — детальный ADR; +- `CHANGELOG.md` — запись в `Added` (+ fix Dockerfile `COPY data/`); +- `README.md` / `.env.example` — все `ORCH_POST_DEPLOY_*` env задокументированы. -## Заметка о diff -`git diff main...HEAD` содержит файлы ORCH-060/ORCH-061/`staging_verdict.py`/`reconciler.py` -из-за устаревшей merge-base ветки (эти изменения уже прошли свои PR в `main`). -Предмет ревью — единственный коммит ORCH-021 `c5b516b` (12 файлов). +Изменение `src/` сопровождено обновлением документации — правило CLAUDE.md №2/№6 +выполнено. + +## Вердикт +Только P3 (nice-to-have) findings, блокеров и must-fix нет → **APPROVED**. From 1c89ac9df98b3b0d4d31312a8eabe394e1b26712 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 14:36:16 +0000 Subject: [PATCH 9/9] tester(ET): auto-commit from tester run_id=313 --- docs/work-items/ORCH-021/13-test-report.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/work-items/ORCH-021/13-test-report.md b/docs/work-items/ORCH-021/13-test-report.md index a3d08c7..77c09da 100644 --- a/docs/work-items/ORCH-021/13-test-report.md +++ b/docs/work-items/ORCH-021/13-test-report.md @@ -16,7 +16,7 @@ Post-deploy наблюдение прода + реакция на деграда - Дата: 2026-06-07 ## Прогон -- `pytest tests/ -v --tb=short` → **700 passed, 1 warning** (Pydantic V2 deprecation, не относится к задаче). +- `pytest tests/ -v --tb=short` → **701 passed, 1 warning** (Pydantic V2 deprecation, не относится к задаче). - Целевые модули `tests/test_post_deploy.py` + `tests/test_post_deploy_integration.py` → **30 passed**. ## Smoke-test (read-only, прод 8500) @@ -57,7 +57,7 @@ Post-deploy наблюдение прода + реакция на деграда | TC-18 | Полный цикл DEGRADED → не-self откат + лог + уведомление | AC-7, AC-13, AC-17 | test_tc18_degraded_nonself_rolls_back | PASS | | TC-19 | Self-hosting DEGRADED → НЕ рестарт/откат, алерт+approve | AC-8, AC-17 | test_tc19_degraded_self_hosting_alert_only | PASS | | TC-20 | GET /queue содержит блок post_deploy | AC-14 | test_tc20_queue_block_present | PASS | -| TC-21 | Регресс: deploy/staging/merge-gate/reconciler зелёные; STAGE_TRANSITIONS/QG_CHECKS не изменены | AC-12 | tests/test_stages.py (+ полный прогон 700) | PASS | +| TC-21 | Регресс: deploy/staging/merge-gate/reconciler зелёные; STAGE_TRANSITIONS/QG_CHECKS не изменены | AC-12 | tests/test_stages.py (+ полный прогон 701) | PASS | Доп. тесты ветки (не из плана, подтверждают контракты): `test_series_append_and_read_roundtrip`, `test_mark_done_idempotency_marker`, `test_healthy_tick_requeues_without_finishing`, @@ -65,18 +65,18 @@ Post-deploy наблюдение прода + реакция на деграда ## Покрытие критериев приёмки AC-1…AC-18 — все покрыты прошедшими тестами (см. таблицу). AC-12 (реестры/схема БД -не изменены) дополнительно подтверждён зелёным полным регрессом 700 тестов, включая +не изменены) дополнительно подтверждён зелёным полным регрессом 701 теста, включая deploy/staging/merge-gate/reconciler. AC-18 (документация) — вне scope прогона тестов, подтверждён ревью (12-review.md, verdict APPROVED). ## Вывод pytest (хвост) ``` -======================= 700 passed, 1 warning in 14.10s ======================== +======================= 701 passed, 1 warning in 12.71s ======================== ``` ``` -======================== 30 passed, 1 warning in 0.64s ========================= +======================== 30 passed, 1 warning in 0.58s ========================= ``` ## Итог -**PASS.** Все 21 тест-кейс плана зелёные, полный регресс (700) зелёный, smoke прод-эндпоинтов +**PASS.** Все 21 тест-кейс плана зелёные, полный регресс (701) зелёный, smoke прод-эндпоинтов OK (окружение живо). Существующие контракты не сломаны. Задача готова к стадии deploy-staging.