diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a3778b..3550a11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ### Added - **Security-гейт: secret-scanning (gitleaks) + dependency audit (pip-audit) перед мержем** (ORCH-022): автономный конвейер вливал ветку в `main` без проверки на утёкший секрет (ключ/токен/пароль/приватный ключ) и уязвимую зависимость (известный CVE) — для self-hosting `orchestrator` это особенно остро: один общий прод-инстанс обслуживает все проекты из общей БД, поэтому секрет/CVE, проскочивший через одну задачу, уезжает в прод всех проектов (CLAUDE.md §self-hosting, §8). ORCH-022 вводит детерминированный (без LLM) **security-гейт как под-гейт ребра `deploy-staging → deploy`**, исполняемый **ПЕРВЫМ** среди edge-под-гейтов (ДО merge-gate ORCH-043 и image-freshness ORCH-058) — дёшево фейлить до дорогих rebase/rebuild, а скан ветки ДО rebase не «обвиняет» задачу в CVE из обновившегося `main`. Паттерн соседей: новый leaf-модуль `src/security_gate.py` (контракт «never-raise», по образцу `merge_gate`/`image_freshness`/`staging_verdict`) + тонкая обёртка `check_security_gate` в реестре `QG_CHECKS` (`src/qg/checks.py`, lazy-import → нет цикла) + врезка `_handle_security_gate` в `src/stage_engine.py` в блок `current_stage == "deploy-staging"` ПЕРВОЙ. `STAGE_TRANSITIONS` и схема БД — **без изменений**. **Secret-scanning (`gitleaks`, offline):** скан диапазона `origin/main..HEAD` (ровно коммиты задачи); любой секрет вне аллоулиста версионируемого `.gitleaks.toml` → вклад в FAIL. Полностью оффлайн (локальные правила) → гарантия «секрет всегда блокирует» (BR-2) безусловна, не зависит от сети; **fail-closed** при ошибке инструмента/отсутствии бинаря/таймауте (нельзя доказать «секретов нет» → FAIL). Контракт exit-кодов: 0=чисто, 1=найдено, ≥2=ошибка. **Dependency audit (`pip-audit`, OSV/PyPI):** аудит `requirements.txt`; severity ≥ `security_dep_block_severity` (дефолт `HIGH`, порядок CRITICAL>HIGH>MEDIUM>LOW) → вклад в FAIL (`deps_blocking`); ниже порога / UNKNOWN → warning (`deps_warning`, анти-петля Р-4, не авто-блок). Источник advisory требует сети → недоступность фида **fail-open + громкий warning** по умолчанию (`deps_audit_degraded: true` + Telegram + лог; прецедент анти-петли ORCH-061), флаг `security_dep_audit_fail_closed` переводит в строгий режим без редеплоя кода. **Артефакт `17-security-report.md`** (YAML-frontmatter `security_status`/`secrets_found`/`deps_blocking`/`deps_warning`/`deps_audit_degraded` + тело-списки находок); машинный вердикт читается ТОЛЬКО из frontmatter (гейт пишет → читает обратно через `parse_security_status` → возвращает ровно то, что записал: единый источник истины, AC-8), negative-токен (FAIL) авторитетен, нет frontmatter/битый YAML/нет поля → **fail-closed** на чтении; значения секретов в артефакте маскируются (не ре-лик). **FAIL → откат на `development`** + developer-retry (общий `_developer_retry_count`, cap `MAX_DEVELOPER_RETRIES`=3, затем `set_issue_blocked` + Telegram, без бесконечного баунса); `task_desc` перезапущенного developer'а несёт дословные находки (`extract_security_findings`, паттерн ORCH-046) + ссылку на артефакт. **Self-hosting safety:** гейт только читает/сканирует/пишет артефакт — не вызывает деплой-хук, не рестартит прод-контейнер (под-гейт исполняется ДО захвата merge-lease → при FAIL lease освобождать не нужно). **Условность как ORCH-35/43/58:** `security_gate_enabled` (kill-switch) + `security_gate_repos` (CSV; пусто → только self-hosting `orchestrator`); таймаут `security_scan_timeout_s`; never-raise. v1 — Python-only стек; SAST/мульти-стек — follow-up (BR-14). Инфраструктура: pinned `gitleaks` (статический Go-бинарь) в `Dockerfile` (+ `curl`/`ca-certificates`), `pip-audit` (pinned) в `requirements.txt`, `.gitleaks.toml` в корне репо. Новые настройки: `ORCH_SECURITY_GATE_ENABLED` (true), `ORCH_SECURITY_GATE_REPOS` (""), `ORCH_SECURITY_DEP_BLOCK_SEVERITY` (HIGH), `ORCH_SECURITY_SCAN_TIMEOUT_S` (300), `ORCH_SECURITY_DEP_AUDIT_FAIL_CLOSED` (false), `ORCH_SECURITY_SECRETS_BLOCK` (true). Инварианты НЕ менялись: `STAGE_TRANSITIONS` (9 стадий), `check_branch_mergeable`/`check_staging_image_fresh` и их под-гейты, БАГ-8 откат, terminal-sync, схема БД (без миграций). ADR `docs/work-items/ORCH-022/06-adr/ADR-001-security-gate.md`, глобальный `docs/architecture/adr/adr-0012-security-gate.md`. Документация: `docs/architecture/README.md`, `CLAUDE.md`, `.env.example`. Тесты: `tests/test_security_gate.py`, `tests/test_qg_security.py`, `tests/test_stage_engine_security_gate.py`, `tests/test_qg_registry_snapshot.py`, `tests/test_config.py`. +- **Выделенный статус-триггер прод-деплоя «Confirm Deploy»** (ORCH-059): жест запуска прод-деплоя отделён от человеческого гейта одобрения. Раньше один Plane-статус `Approved` был перегружен: на `analysis` он работал как человеческий гейт BRD (`check_analysis_approved`), а на `deploy` — молча триггерил Фазу B прод-деплоя ORCH-036 (`advance_stage(deploy, finished_agent=None) → _handle_self_deploy_phase_b → detached host-рестарт прод-контейнера 8500`). Привычный жест approve = групповой self-hosting риск (прод обслуживает ВСЕ проекты из одного инстанса). ORCH-059 вводит отдельный логический статус `confirm_deploy` («Confirm Deploy»), который триггерит **ТОЛЬКО** Фазу B на `deploy`; `Approved` остаётся исключительно гейтом конвейера. Четыре точечные правки в трёх модулях: (1) `src/plane_sync.py` — маппинг `"Confirm Deploy" → "confirm_deploy"` в `_PLANE_NAME_TO_KEY`; ключ намеренно НЕ добавлен в `_DEFAULT_STATES` (нет UUID для enduro/fallback) → **fail-closed**: для проекта ORCH резолвится из живого Plane API (`get_project_states(orch)["confirm_deploy"]` → реальный UUID), для сред без статуса (enduro / недоступный API / доска без статуса) ключ просто отсутствует, доступ через `.get("confirm_deploy")` → `None`, без `KeyError`. (2) `src/webhooks/plane.py` — `handle_issue_updated` ДО ветки `approved` добавляет fail-closed-ветку `confirm_state = proj_states.get("confirm_deploy"); if confirm_state and new_state == confirm_state: handle_confirm_deploy(...)`; новый `handle_confirm_deploy` резолвит задачу, гард `stage == "deploy"` (иначе no-op с логом — защищает прочие гейты от случайного триггера), иначе → `_try_advance_stage(..., confirm_deploy=True)`. `handle_verdict(approved=True)` не изменён (продолжает звать `_try_advance_stage` с дефолтным `confirm_deploy=False`). (3) `src/stage_engine.py` — `advance_stage` получил keyword-only параметр `confirm_deploy: bool = False` (обратносовместимо: все существующие вызовы из launcher/reconciler/finalizer передают `finished_agent`); блок Фазы B теперь **всегда возвращается рано** для `deploy + finished_agent is None` self-hosting, но `_handle_self_deploy_phase_b` вызывается ТОЛЬКО при `confirm_deploy=True`, иначе (обычный `Approved`) — детерминированный **no-op** (`result.note = "approved-on-deploy-noop"`): возврат ДО блока Quality Gate → `check_deploy_status` не запускается → нет ложного отката БАГ-8 (вердикта ещё нет, R-2). (4) CTA Фазы A (`_handle_self_deploy_phase_a`) — Plane-коммент и Telegram просят перевести задачу в статус «Confirm Deploy» (а не «Approved»). Следствие для reconciler F-1 на `deploy` (ORCH-053): попадает в no-op-ветку вместо неявного запуска Фазы B → прод-деплой нельзя инициировать автоматически, только явным человеческим «Confirm Deploy» (усиление safety). Условность как ORCH-35/36 (реально только для `self_deploy.self_deploy_applies("orchestrator")`; прочие репо — прежний синхронный ssh-деплой агентом, статус не нужен и не влияет). Контракты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status`, exit-код-контракт хука (0/1/2), Фазы A/C, merge-gate, terminal-sync, схема БД (статусы — на стороне Plane; restart-safe состояние деплоя — существующие sentinel-файлы ORCH-036). Эксплуатационное предусловие: в Plane-проекте ORCH создать статус доски «Confirm Deploy» (точное имя, регистр) + сброс кэша состояний — `docs/work-items/ORCH-059/07-infra-requirements.md`. До создания статуса прод-деплой через approve не запустится (желаемое fail-closed-поведение). ADR `docs/work-items/ORCH-059/06-adr/ADR-001-confirm-deploy-status.md` (уточняет триггер Фазы B относительно adr-0007). Тесты: `tests/test_plane_states.py`, `tests/test_plane_confirm_deploy.py`, `tests/test_stage_engine_phase_b.py`, `tests/test_stage_engine_phase_a_cta.py`, `tests/test_confirm_deploy_integration.py`, `tests/test_deploy_approve.py` (обновлён под новый триггер). - **Job-reaper + проактивный реклейм протухшего merge-lease + идемпотентная финализация merge** (ORCH-065): закрыт класс инцидентов «zombie jobs» — статус job выставлялся ТОЛЬКО в живом процессе launcher'а, поэтому гибель процесса (OOM/рестарт инстанса/segfault Claude-CLI) оставляла строку `jobs.status='running'` навсегда; при `max_concurrency=1` один такой зомби намертво блокировал очередь ВСЕХ проектов (self-hosting: enduro-trails встаёт из-за зомби ORCH-задачи). Плюс два смежных дефекта: застрявший merge-lease (`.merge-lease-.json` реклеймился лишь лениво по TTL при чужом acquire, живость pid-holder'а не проверялась) и неидемпотентная финализация merge (rebase+re-test зелёные, но процесс умер до самого merge → нет повторного проигрывания). Решение — новый фоновый daemon-поток **`src/job_reaper.py`** (контракт «never-raise на единицу работы», паттерн `reconciler`/`queue_worker`): периодический тик (`reaper_interval_s`) сканирует `running`-jobs трёхуровневой проверкой живости (ADR Р-1): **Tier-1** мёртвый pid (`os.kill(pid, 0)` → `ProcessLookupError`) с анти-false-positive порогом `reaper_dead_ticks` подряд-мёртвых тиков (стрик в памяти); **Tier-2** `agent_runs.exit_code` записан, но job всё ещё `running` — но только после finalization-grace `reaper_finalize_grace_s` (окно неоднозначно: живой monitor пишет exit_code ПЕРВЫМ, затем git push/PR/Plane-комментарии и лишь потом `_finalize_job`, а pid агента к этому моменту мёртв в обоих случаях — живой финализирующий monitor НЕ реапится); **Tier-3** backstop-потолок `reaper_max_running_s`. Единственная мутирующая запись reaper'а — атомарный терминальный флип через `db.reap_running_job(... WHERE status='running')` (rowcount==1 у победителя, проигравший в гонке с `requeue_running_jobs`/launcher видит rowcount==0 — без двойной обработки, TC-06). Для Tier-2 exit0 действие построено по принципу **claim-before-act** (ADR Р-1): источник истины — канонический QG (не «exit0»), он оценивается read-only (`_gate_is_green` → `stage_engine._run_qg`, как у reconciler) ПЕРЕД claim, затем атомарный claim `done` ПЕРВЫМ и только победитель claim делает gate-driven advance (`_gate_driven_advance` → штатный `launcher._try_advance_stage`, кандидат-стадии агента из `STAGE_TRANSITIONS`) — проигравший claim не выполняет НИКАКИХ побочных эффектов (нет дубль-advance / дубль-enqueue следующей стадии); зелёный гейт → `done`+advance, красный → путь неуспеха (requeue в пределах `attempts 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`. diff --git a/CLAUDE.md b/CLAUDE.md index a407582..9a93f4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,10 @@ created → analysis → architecture → development → review → testing → - **НЕ перезапускать / не ронять прод-контейнер** `orchestrator` в рамках задачи — встанет конвейер всех проектов. - Любой деплой/рестарт self = групповой риск. Детали и топология — `docs/operations/INFRA.md`. - Стадия `deploy-staging` (порт 8501) — обязательная страховка перед прод-деплоем орка. +- Прод-деплой орка запускается ТОЛЬКО переводом задачи на стадии `deploy` в выделенный + Plane-статус **«Confirm Deploy»** (ORCH-059). Статус `Approved` — человеческий гейт + конвейера и прод-деплой НЕ запускает (на `deploy` — no-op). Это разделяет «одобрить + артефакт» и «выкатить в прод», чтобы привычный approve не ронял прод случайным кликом. --- *Паспорт проекта orchestrator. Поддерживается агентами при каждой доработке. Изолирован: описывает только этот проект (канон per-repo, см. ORCH-9).* diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 140485f..a0d8902 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -70,21 +70,25 @@ Self-hosting зацикливался на `deploy-staging`: `scripts/staging_ch а `deploy_status: SUCCESS` означает доказанный health-ok, не декларацию LLM. Три фазы (детерминированно, без LLM в критическом пути self-restart): - **Фаза A (вход в `deploy`)** — при `deploy_require_manual_approve=true` вместо запуска - прод-deployer выставляется approval-pending статус Plane + запрос approve - (Plane-коммент + Telegram). Перехват в `advance_stage` ПОСЛЕ `check_staging_status` - и merge-gate. -- **Фаза B (Plane → `Approved`)** — `advance_stage(deploy, finished_agent=None)` + прод-deployer выставляется approval-pending статус Plane + запрос перевести задачу + в статус **«Confirm Deploy»** (ORCH-059; Plane-коммент + Telegram). Перехват в + `advance_stage` ПОСЛЕ `check_staging_status` и merge-gate. +- **Фаза B (Plane → `Confirm Deploy`, ORCH-059)** — + `advance_stage(deploy, finished_agent=None, confirm_deploy=True)` запускает **detached host-процесс** (ssh + setsid → хук с прод-параметрами + build-once retag `SOURCE_IMAGE`) и ставит детерминированный **finalizer-job**; маркер `initiated` — идемпотентность. Возврат БЕЗ advance (вердикта ещё нет). + Обычный `Approved` на `deploy` (`confirm_deploy=False`) — детерминированный no-op + (не деплоит и не откатывает). - **Фаза C (finalizer)** — новый контейнер после рестарта читает sentinel `result` (exit-code хука), маппит `0→SUCCESS / иначе→FAILED`, пишет `14-deploy-log.md`, вызывает `advance_stage(deploy, finished_agent="deployer")` → существующие контракты: `SUCCESS → done`, `FAILED → откат БАГ-8 на development`. -Approve = смена статуса Plane на `Approved` (status-only verdict model; комментарии -не управляют конвейером). На старте — обязательный ручной approve (флаг `true`); полный -авто — отдельная задача (ORCH-54). Условность как ORCH-35: реально для `orchestrator`, +Триггер прод-деплоя = смена статуса Plane на `Confirm Deploy` (ORCH-059; status-only +verdict model; комментарии не управляют конвейером). `Approved` остаётся исключительно +человеческим гейтом конвейера и прод-деплой не запускает. На старте — обязательный +ручной approve (флаг `true`); полный авто — отдельная задача (ORCH-54). Условность как ORCH-35: реально для `orchestrator`, прочие репо — прежний синхронный ssh-деплой агентом. Контракты не меняются: `STAGE_TRANSITIONS`, реестр QG, `check_deploy_status`/`_parse_deploy_status`, БАГ-8, terminal-sync, merge-gate, exit-code-контракт хука. Restart-safe состояние — @@ -92,6 +96,31 @@ 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`. +#### Выделенный статус-триггер прод-деплоя «Confirm Deploy» (ORCH-059 — реализовано) +Перегрузка: один Plane-статус `Approved` служил И человеческим гейтом BRD на +`analysis` (`check_analysis_approved`), И триггером Фазы B прод-деплоя на `deploy` +— привычный жест approve молча запускал прод-рестарт (групповой self-hosting +риск). ORCH-059 разделяет жесты: вводится отдельный логический статус +`confirm_deploy` («Confirm Deploy»), который триггерит **ТОЛЬКО** Фазу B на +`deploy`; `Approved` остаётся исключительно гейтом конвейера. +- `_PLANE_NAME_TO_KEY` += `"Confirm Deploy" → "confirm_deploy"`; в + `_DEFAULT_STATES` ключ НЕ добавляется (нет UUID для enduro/fallback) → + **fail-closed**: нет статуса → нет деплоя, без `KeyError` (доступ через `.get`). +- `handle_issue_updated` маршрутизирует `Confirm Deploy` → `handle_confirm_deploy` + (гард `stage=="deploy"`) → `_try_advance_stage(..., confirm_deploy=True)`. +- `advance_stage` получает kwarg `confirm_deploy: bool=False`; блок Фазы B + (`deploy`+`finished_agent is None`+self-hosting) деплоит ТОЛЬКО при + `confirm_deploy=True`, иначе (обычный `Approved`) — **no-op** (`check_deploy_status` + не запускается → нет ложного отката БАГ-8). +- CTA Фазы A (`_handle_self_deploy_phase_a`) просит «Confirm Deploy», не «Approved». +- Условность как ORCH-35/36 (только `orchestrator`); Фазы A/C, `STAGE_TRANSITIONS`, + `QG_CHECKS`, `check_deploy_status`, merge-gate, схема БД — без изменений. +- Эксплуатация: в Plane-проекте ORCH создать статус «Confirm Deploy» + сброс кэша + состояний (`docs/work-items/ORCH-059/07-infra-requirements.md`). + +Детально — `docs/work-items/ORCH-059/06-adr/ADR-001-confirm-deploy-status.md` +(уточняет/триггер Фазы B относительно adr-0007). + ### Post-deploy наблюдение прода + реакция на деградацию (ORCH-021 — реализовано) Конвейер заканчивался на `deploy → done` и **забывал про прод**: «успех» = health-check в момент рестарта (~60с). Класс «зелёный деплой, красный прод» (прецедент ET-8 — @@ -339,3 +368,4 @@ ORCH-065 вводит фоновый watchdog, чтобы смерть проц --- *Актуально на 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-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест); ORCH-022 (security-гейт: secret-scanning gitleaks + dependency audit pip-audit как под-гейт ребра `deploy-staging → deploy` ПЕРВЫМ, adr-0012, `docs/work-items/ORCH-022/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-022-security-secret-scanning (leaf src/security_gate.py never-raise + check_security_gate в src/qg/checks.py `QG_CHECKS` + врезка _handle_security_gate в src/stage_engine.py блок `current_stage == "deploy-staging"` ПЕРВОЙ; флаги `security_*` в src/config.py; gitleaks (pinned) в Dockerfile, pip-audit в requirements.txt, `.gitleaks.toml` в корне; артефакт 17-security-report.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-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест); ORCH-059 (выделенный статус-триггер прод-деплоя «Confirm Deploy», ADR `docs/work-items/ORCH-059/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-059 (маппинг `"Confirm Deploy"→"confirm_deploy"` в src/plane_sync.py `_PLANE_NAME_TO_KEY`, НЕ в `_DEFAULT_STATES` = fail-closed; ветка `handle_confirm_deploy` + fail-closed `.get("confirm_deploy")` в src/webhooks/plane.py `handle_issue_updated`; keyword-only `confirm_deploy` в src/stage_engine.py `advance_stage` — Фаза B деплоит ТОЛЬКО при `confirm_deploy=True`, иначе `Approved`-на-`deploy` = no-op; CTA Фазы A просит «Confirm Deploy»; эксплуатация — статус доски «Confirm Deploy» в Plane-проекте ORCH, `docs/work-items/ORCH-059/07-infra-requirements.md`).* diff --git a/docs/work-items/ORCH-059/00-business-request.md b/docs/work-items/ORCH-059/00-business-request.md new file mode 100644 index 0000000..3e790a5 --- /dev/null +++ b/docs/work-items/ORCH-059/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: Approve деплоя через статус Confirm Deploy (вместо перегруженного Approved) + +Work Item ID: ORCH-059 + +## Description + +TBD diff --git a/docs/work-items/ORCH-059/01-brd.md b/docs/work-items/ORCH-059/01-brd.md new file mode 100644 index 0000000..67dc05f --- /dev/null +++ b/docs/work-items/ORCH-059/01-brd.md @@ -0,0 +1,115 @@ +# 01 — BRD: Approve прод-деплоя через выделенный статус «Confirm Deploy» + +Work Item: **ORCH-059** +Repo: `orchestrator` +Stage: analysis +Тип: enhancement / risk-reduction (self-hosting) + +## 1. Контекст и проблема + +В ORCH-036 («исполняемый самодеплой стадии `deploy`») прод-деплой self-hosting +инстанса (контейнер `orchestrator`, порт 8500) запускается **Фазой B**: человек +переводит issue в Plane-статус **`Approved`**, webhook +`work_item.updated` → `handle_issue_updated` → `handle_verdict(approved=True)` +→ `_try_advance_stage` → `advance_stage(finished_agent=None)`, и в +`stage_engine.advance_stage` срабатывает блок +`current_stage == "deploy" and finished_agent is None` → +`_handle_self_deploy_phase_b` → detached host-деплой прода. + +**Перегрузка статуса.** Тот же самый Plane-статус `Approved` (UUID +`a519a341-…`) используется как **человеческий гейт одобрения BRD** на ранней +стадии `analysis` (`check_analysis_approved`: analysis → architecture) и в общем +verdict-роутинге `handle_verdict`. Один и тот же визуальный «Approved» на доске +означает две принципиально разные вещи: + +- на `analysis` — «BRD/ТЗ/AC приняты, продолжай конвейер» (дёшево, обратимо); +- на `deploy` — «**ВЫКАТИ В ПРОД** инструмент, который прямо сейчас обслуживает + все проекты из одного инстанса с общей БД» (дорого, групповой риск, см. + раздел Self-hosting в `CLAUDE.md`). + +### Последствия (Pain) +- **Двусмысленность семантики.** Один статус — два смысла; оператор не видит из + названия, что клик на `deploy` запускает реальный прод-рестарт. +- **Риск случайного клика.** Привычный жест «Approved» (которым оператор + штатно одобряет BRD десятки раз) на стадии `deploy` молча триггерит + прод-деплой. Цена ошибки — незапланированный рестарт прод-инстанса, + встающий конвейер всех проектов. +- **Несоответствие ожиданиям ORCH-036.** В scope ORCH-36 заявлялась Telegram + inline-кнопка подтверждения; в коде её **нет** — developer реализовал approve + исключительно через Plane-статус. Отдельного «осознанного» жеста подтверждения + деплоя в системе сейчас не существует. + +## 2. Решение Owner + +Ввести **отдельный Plane-статус `Confirm Deploy`** в проекте ORCH, который +триггерит **ТОЛЬКО** Фазу B self-deploy на стадии `deploy`. Статус `Approved` +перестаёт запускать прод-деплой и сохраняет единственный смысл — человеческое +одобрение на гейтах конвейера (прежде всего BRD на `analysis`). + +Минимальная правка: `handle_verdict` в `src/webhooks/plane.py` + регистрация +нового состояния в проекте ORCH (Plane + резолвер состояний). + +## 3. Бизнес-цели +- **BG-1.** Убрать двусмысленность: жест «запустить прод-деплой» отделён от жеста + «одобрить артефакт». +- **BG-2.** Снизить риск случайного прод-деплоя: запуск прода требует явного, + редко используемого статуса `Confirm Deploy`, а не привычного `Approved`. +- **BG-3.** Не сломать работающий self-hosting конвейер при доработке самого + инструмента (нулевая регрессия `analysis`-гейта и не-self репозиториев). + +## 4. Объём (Scope) + +### В объёме +- Новый логический статус `confirm_deploy` («Confirm Deploy») в резолвере + состояний Plane (`src/plane_sync.py`). +- Маршрутизация нового статуса в `src/webhooks/plane.py` + (`handle_issue_updated` / `handle_verdict`) на путь Фазы B прод-деплоя. +- Прекращение триггера Фазы B по статусу `Approved` на стадии `deploy`. +- Обновление текста CTA Фазы A (Plane-комментарий + Telegram в + `stage_engine._handle_self_deploy_phase_a`): инструктировать оператора + переводить задачу в `Confirm Deploy`, а не в `Approved`. +- Конфигурация Plane: создание статуса «Confirm Deploy» в проекте ORCH + (предусловие эксплуатации — фиксируется в TRZ/AC как требование среды). +- Обновление документации (`CLAUDE.md`, `docs/architecture/README.md` секция + ORCH-036, `CHANGELOG.md`) и ADR per-work-item. + +### Вне объёма +- Telegram inline-кнопки подтверждения деплоя (отдельная задача; здесь не + реализуем — управление по-прежнему статусом Plane). +- Полностью автоматический approve деплоя (ORCH-54). +- Изменение Фаз A/C, exit-кодов хука, merge-gate, `check_deploy_status`, + схемы БД, реестров `STAGE_TRANSITIONS` / `QG_CHECKS`. +- Поведение прод-деплоя для не-self репозиториев (остаётся прежним). +- Post-deploy наблюдение (ORCH-021) — не затрагивается. + +## 5. Заинтересованные стороны +- **Owner/оператор** — переводит задачи по статусам; главный выгодоприобретатель + снижения риска. +- **Self-hosting конвейер** — все проекты на общем инстансе; косвенно зависят от + безопасности прод-деплоя орка. + +## 6. Допущения +- A-1. Plane позволяет добавить кастомный статус «Confirm Deploy» в проект ORCH; + его UUID резолвится через `get_project_states` (API `/states/`). +- A-2. Статус `Confirm Deploy` нужен только проекту ORCH (self-hosting). Прочие + проекты прод-деплой через Plane-approve не используют + (`self_deploy_applies` → только `orchestrator`). +- A-3. Оператор переводит задачу в `Confirm Deploy` только когда она реально + находится на стадии `deploy` (approval-pending после Фазы A). + +## 7. Риски (детально — 10-tech-risks.md, ведёт архитектор) +- R-1. Новый логический ключ `confirm_deploy` отсутствует в fallback + `_DEFAULT_STATES` и в проектах без этого статуса → обращение к ключу должно + быть безопасным (fail-closed: нет статуса → нет деплоя, не падение). +- R-2. Регрессия: `Approved` на `deploy` после правки не должен НИ + запускать деплой, НИ вызывать ложный откат/advance. +- R-3. Самоправка прода: правка не должна потребовать ручного рестарта прод- + контейнера вне штатной стадии deploy-staging → deploy. + +## 8. Definition of Done (бизнес-уровень) +- Перевод задачи стадии `deploy` в `Confirm Deploy` запускает прод-деплой + (Фаза B) ровно так же, как раньше делал `Approved`. +- Перевод задачи стадии `deploy` в `Approved` прод-деплой НЕ запускает. +- `Approved` на `analysis` (и прочих человеческих гейтах) работает без изменений. +- CTA Фазы A просит `Confirm Deploy`. +- Документация и ADR обновлены в том же PR. diff --git a/docs/work-items/ORCH-059/02-trz.md b/docs/work-items/ORCH-059/02-trz.md new file mode 100644 index 0000000..2b4f6cb --- /dev/null +++ b/docs/work-items/ORCH-059/02-trz.md @@ -0,0 +1,103 @@ +# 02 — ТЗ: выделенный статус «Confirm Deploy» как триггер прод-деплоя + +Work Item: **ORCH-059** · Repo: `orchestrator` · Stage: analysis + +> ТЗ описывает **что** должно измениться и **поведенческий контракт**. Конкретный +> дизайн (сигнатуры, способ проброса признака «confirm-deploy» из webhook в +> `stage_engine`, sentinel-обработка) — за архитектором (ADR per-work-item). +> Точки касания ниже заданы бизнес-запросом Owner и текущей реализацией ORCH-036. + +## 1. Задействованные модули `src/` + +| Модуль | Роль в задаче | +|--------|---------------| +| `src/plane_sync.py` | Резолвер состояний Plane. Добавить логический ключ `confirm_deploy` ↔ имя статуса «Confirm Deploy»; обеспечить безопасный доступ при отсутствии статуса (fallback/неполный конфиг). | +| `src/webhooks/plane.py` | `handle_issue_updated` — маршрутизация нового статуса; `handle_verdict` — отделить «подтверждение деплоя» от обычного approve; снять триггер Фазы B со статуса `Approved` на `deploy`. | +| `src/stage_engine.py` | Блок Фазы B (`current_stage == "deploy" and finished_agent is None`) должен срабатывать ТОЛЬКО по сигналу confirm-deploy, не по обычному Approved. Обновить CTA-текст Фазы A (`_handle_self_deploy_phase_a`). | +| `src/config.py` | (опционально, на усмотрение архитектора) флаг/имя статуса, если потребуется конфигурируемость. По умолчанию — не требуется. | + +## 2. Поведенческий контракт (требования) + +### TRZ-1. Регистрация статуса «Confirm Deploy» +Резолвер состояний (`get_project_states`) обязан возвращать UUID статуса +«Confirm Deploy» под логическим ключом `confirm_deploy` для проекта ORCH. +Маппинг имени `"Confirm Deploy" → "confirm_deploy"` добавляется в +`_PLANE_NAME_TO_KEY`. Для проектов/сред, где статус отсутствует (enduro, +fallback `_DEFAULT_STATES`, недоступный API), ключ может отсутствовать — +обращение к нему должно быть **fail-closed**: «нет статуса → ветка confirm-deploy +не активируется», без `KeyError`/исключения. + +### TRZ-2. Триггер прод-деплоя по «Confirm Deploy» +Когда задача находится на стадии `deploy` и issue переводится в статус +`Confirm Deploy`, система обязана инициировать **Фазу B** прод-деплоя +(эквивалент текущего `_handle_self_deploy_phase_b`: idempotency-guard `initiated`, +`self_deploy.initiate_deploy`, постановка `deploy-finalizer`, комментарии/Telegram). +Поведение, идемпотентность и Фаза C — **без изменений** относительно ORCH-036; +меняется только **что именно является триггером**. + +### TRZ-3. `Approved` больше не запускает прод-деплой +Перевод задачи стадии `deploy` в статус `Approved` **не должен** инициировать +Фазу B. Он не должен также вызывать ложный откат (БАГ-8) или ложный advance +по `check_deploy_status` (вердикта ещё нет). Допустимое поведение — **no-op с +логированием** (issue остаётся на `deploy`/approval-pending). Конкретный способ +(игнор на уровне webhook-роутинга или на уровне `stage_engine`) — за архитектором. + +### TRZ-4. Сохранность гейта `Approved` на остальных стадиях +Статус `Approved` обязан продолжать работать как человеческий гейт: +- `analysis` → `architecture` (`check_analysis_approved`, approved-via-status); +- любой иной человеческий approve-advance, существующий сегодня. +Регрессия `handle_verdict(approved=True)` для НЕ-`deploy` стадий недопустима. + +### TRZ-5. CTA Фазы A +Текст запроса approve в `_handle_self_deploy_phase_a` (Plane-комментарий + Telegram) +обязан инструктировать оператора переводить задачу в статус **`Confirm Deploy`** +(а не `Approved`) для запуска прод-деплоя. + +### TRZ-6. Условность (как ORCH-35/36) +Ветка confirm-deploy реальна только для self-hosting +(`self_deploy.self_deploy_applies(repo)` → `orchestrator`). Для прочих репо — +прежнее поведение (синхронный деплой агентом), статус `Confirm Deploy` не +требуется и не влияет. + +## 3. Изменения API +Изменений HTTP-эндпоинтов **нет**. Канал — существующий `POST /webhook/plane` +(событие `work_item.updated`). Внешнее изменение: в проекте ORCH появляется +дополнительный статус доски «Confirm Deploy» (Plane-конфигурация, не код-API). + +## 4. Изменения схемы БД +**Нет.** `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, таблицы `tasks`/`jobs`/ +`agent_runs`/`events` — без изменений. Статусы — на стороне Plane; restart-safe +состояние деплоя — существующие sentinel-файлы ORCH-036 (без миграций). + +## 5. Требования к новым QG checks +**Нет.** Новый Quality Gate не вводится. `check_deploy_status` / +`_parse_deploy_status` и контракт exit-кодов хука (0/1/2) — без изменений. + +## 6. Конфигурация среды (предусловие эксплуатации) +- В проекте ORCH в Plane создаётся статус доски **«Confirm Deploy»** (точное имя, + чувствительно к регистру — должно совпасть с ключом `_PLANE_NAME_TO_KEY`). +- Размещение статуса на доске — рядом со стадией deploy/approval-pending + (рекомендация эксплуатации, не код). +- Кэш состояний (`get_project_states` / `reload_project_states`): после создания + статуса может потребоваться сброс кэша или рестарт по штатной стадии deploy. + +## 7. Артефакты, создаваемые/обновляемые по pipeline +- `docs/work-items/ORCH-059/06-adr/ADR-001-confirm-deploy-status.md` — решение + (как отличается триггер; где разрезается перегрузка `Approved`; fail-closed + при отсутствии статуса) — **ведёт архитектор**. +- `CLAUDE.md` — упоминание выделенного статуса approve прод-деплоя (раздел + self-hosting / артефакты). +- `docs/architecture/README.md` — секция ORCH-036: уточнить, что Фаза B + триггерится статусом `Confirm Deploy`, а не `Approved`. +- `CHANGELOG.md` — запись ORCH-059. +- `12-review.md`, `13-test-report.md`, `14-deploy-log.md`, `15-staging-log.md` — + штатно по стадиям конвейера. + +## 8. Совместимость и инварианты +- Не меняются: `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_deploy_status`, + БАГ-8 (FAILED → откат на development), merge-gate, exit-коды хука, Фазы A/C, + схема БД, post-deploy (ORCH-021). +- Self-hosting safety: правка НЕ требует внепланового рестарта прод-контейнера; + выкат — через штатный deploy-staging (8501) → deploy. +- Never-crash: отсутствие статуса `Confirm Deploy` в резолвере не приводит к + исключению в webhook-пути. diff --git a/docs/work-items/ORCH-059/03-acceptance-criteria.md b/docs/work-items/ORCH-059/03-acceptance-criteria.md new file mode 100644 index 0000000..fdd1fb4 --- /dev/null +++ b/docs/work-items/ORCH-059/03-acceptance-criteria.md @@ -0,0 +1,76 @@ +# 03 — Критерии приёмки: ORCH-059 + +Repo: `orchestrator` · Stage: analysis +Каждый критерий — однозначный PASS/FAIL. Проверка: unit/integration (см. +`04-test-plan.yaml`) + ручная верификация для инфра-предусловий. + +## AC-1 — Статус «Confirm Deploy» резолвится +**Given** проект ORCH со статусом доски «Confirm Deploy» +**When** вызывается резолвер состояний для проекта ORCH +**Then** возвращается логический ключ `confirm_deploy` с непустым UUID, +а маппинг `"Confirm Deploy" → "confirm_deploy"` присутствует в `_PLANE_NAME_TO_KEY`. +**FAIL:** ключ отсутствует или указывает на UUID статуса `Approved`. + +## AC-2 — «Confirm Deploy» на стадии `deploy` запускает Фазу B +**Given** задача self-hosting (`orchestrator`) на стадии `deploy`, +`deploy_require_manual_approve=true`, маркер `initiated` отсутствует +**When** приходит `work_item.updated` со статусом `Confirm Deploy` +**Then** инициируется Фаза B: вызывается `self_deploy.initiate_deploy`, +ставится job `deploy-finalizer`, пишется маркер `initiated`. +**FAIL:** прод-деплой не инициирован, либо finalizer не поставлен. + +## AC-3 — «Approved» на стадии `deploy` НЕ запускает прод-деплой +**Given** та же задача на стадии `deploy` +**When** приходит `work_item.updated` со статусом `Approved` +**Then** `self_deploy.initiate_deploy` **НЕ** вызывается; Фаза B не стартует; +задача не откатывается (БАГ-8 не срабатывает) и не «доходит» по +`check_deploy_status` (вердикта нет); событие залогировано как no-op. +**FAIL:** вызван `initiate_deploy`, либо произошёл откат/ложный advance. + +## AC-4 — «Approved» на `analysis` работает без регрессии +**Given** задача на стадии `analysis` (BRD готов, approval-pending) +**When** issue переводится в `Approved` +**Then** срабатывает approved-via-status и задача продвигается +`analysis → architecture` (как до правки). +**FAIL:** approve на analysis перестал продвигать конвейер. + +## AC-5 — Идемпотентность Фазы B по «Confirm Deploy» +**Given** задача на `deploy`, маркер `initiated` уже существует +**When** повторно приходит статус `Confirm Deploy` (двойной клик / дубль webhook) +**Then** повторного `initiate_deploy` не происходит (no-op, +`self-deploy-already-initiated`). +**FAIL:** прод-деплой запускается повторно. + +## AC-6 — CTA Фазы A просит «Confirm Deploy» +**Given** Фаза A (`deploy-staging → deploy`, approval-pending) +**When** формируются Plane-комментарий и Telegram-уведомление запроса approve +**Then** текст инструктирует перевести задачу в статус **`Confirm Deploy`** +(а не «Approved») для запуска прод-деплоя. +**FAIL:** CTA по-прежнему упоминает только «Approved». + +## AC-7 — Fail-closed при отсутствии статуса +**Given** среда без статуса «Confirm Deploy» (enduro / fallback `_DEFAULT_STATES` +/ недоступный Plane API) +**When** обрабатывается `work_item.updated` +**Then** webhook-путь не выбрасывает исключение; ветка confirm-deploy не +активируется (прод-деплой не запускается «вслепую»). +**FAIL:** `KeyError`/исключение в обработчике, либо ложный запуск Фазы B. + +## AC-8 — Условность для не-self репозиториев +**Given** не-self репозиторий (`self_deploy_applies(repo) == False`) +**When** приходит любой verdict-статус на стадии `deploy` +**Then** поведение прод-деплоя не меняется относительно текущего (синхронный +деплой агентом); статус `Confirm Deploy` не требуется. +**FAIL:** изменилось поведение деплоя не-self проекта. + +## AC-9 — Инварианты не нарушены +**Then** `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_deploy_status`/ +`_parse_deploy_status`, контракт exit-кодов хука (0/1/2), Фазы A/C, merge-gate, +схема БД — без изменений; `pytest tests/ -q` зелёный. +**FAIL:** изменён любой из перечисленных контрактов или красные тесты. + +## AC-10 — Документация обновлена (golden source) +**Then** в том же PR обновлены `CLAUDE.md`, секция ORCH-036 в +`docs/architecture/README.md`, `CHANGELOG.md`; заведён +`06-adr/ADR-001-confirm-deploy-status.md`. +**FAIL:** функционал изменён, документация — нет (Reviewer → REQUEST_CHANGES). diff --git a/docs/work-items/ORCH-059/04-test-plan.yaml b/docs/work-items/ORCH-059/04-test-plan.yaml new file mode 100644 index 0000000..97e8daf --- /dev/null +++ b/docs/work-items/ORCH-059/04-test-plan.yaml @@ -0,0 +1,109 @@ +work_item: ORCH-059 +title: Approve прод-деплоя через выделенный статус «Confirm Deploy» +repo: orchestrator +stage: analysis + +# Контракт-тесты: триггер прод-деплоя смещается с перегруженного `Approved` +# на выделенный статус `Confirm Deploy`. Деплой и сетевые вызовы мокаются. +tests: + - id: TC-01 + type: unit + description: "_PLANE_NAME_TO_KEY содержит маппинг 'Confirm Deploy' -> 'confirm_deploy'" + module: tests/test_plane_states.py + expected: PASS + + - id: TC-02 + type: unit + description: >- + get_project_states для проекта ORCH (мок API со статусом 'Confirm Deploy') + возвращает непустой UUID под ключом 'confirm_deploy', отличный от 'approved' + module: tests/test_plane_states.py + expected: PASS + + - id: TC-03 + type: unit + description: >- + Fail-closed: при отсутствии статуса 'Confirm Deploy' (fallback _DEFAULT_STATES / + недоступный API) доступ к ключу confirm_deploy не выбрасывает исключение + и не активирует ветку confirm-deploy + module: tests/test_plane_states.py + expected: PASS + + - id: TC-04 + type: unit + description: >- + handle_issue_updated: статус 'Confirm Deploy' на задаче стадии deploy + маршрутизируется на путь Фазы B (а не на обычный approve/advance) + module: tests/test_plane_confirm_deploy.py + expected: PASS + + - id: TC-05 + type: unit + description: >- + handle_verdict/Approved на стадии deploy НЕ вызывает self_deploy.initiate_deploy + (initiate_deploy замокан и не должен быть вызван) + module: tests/test_plane_confirm_deploy.py + expected: PASS + + - id: TC-06 + type: unit + description: >- + Approved на стадии analysis по-прежнему продвигает analysis -> architecture + (approved-via-status, регрессия гейта check_analysis_approved) + module: tests/test_plane_confirm_deploy.py + expected: PASS + + - id: TC-07 + type: unit + description: >- + stage_engine: блок Фазы B (current_stage==deploy, finished_agent is None) + инициирует deploy ТОЛЬКО по сигналу confirm-deploy; Approved-сигнал -> no-op + module: tests/test_stage_engine_phase_b.py + expected: PASS + + - id: TC-08 + type: unit + description: >- + Идемпотентность: при существующем маркере 'initiated' повторный + Confirm Deploy не вызывает initiate_deploy (self-deploy-already-initiated) + module: tests/test_stage_engine_phase_b.py + expected: PASS + + - id: TC-09 + type: unit + description: >- + CTA Фазы A (_handle_self_deploy_phase_a): текст Plane-комментария и Telegram + содержат 'Confirm Deploy' и не предлагают 'Approved' как триггер деплоя + module: tests/test_stage_engine_phase_a_cta.py + expected: PASS + + - id: TC-10 + type: integration + description: >- + E2E (мок Plane API + self_deploy): задача на deploy -> webhook Confirm Deploy + -> initiate_deploy вызван, deploy-finalizer поставлен, маркер initiated записан + module: tests/test_confirm_deploy_integration.py + expected: PASS + + - id: TC-11 + type: integration + description: >- + E2E: задача на deploy -> webhook Approved -> прод-деплой НЕ инициирован, + задача остаётся на deploy (нет отката, нет advance в done) + module: tests/test_confirm_deploy_integration.py + expected: PASS + + - id: TC-12 + type: integration + description: >- + Условность: для не-self репозитория verdict-статусы на deploy не меняют + поведение деплоя (self_deploy_applies == False) + module: tests/test_confirm_deploy_integration.py + expected: PASS + +regression: + - id: RG-01 + type: integration + description: "pytest tests/ -q зелёный; STAGE_TRANSITIONS и QG_CHECKS без изменений" + module: tests/ + expected: PASS diff --git a/docs/work-items/ORCH-059/06-adr/ADR-001-confirm-deploy-status.md b/docs/work-items/ORCH-059/06-adr/ADR-001-confirm-deploy-status.md new file mode 100644 index 0000000..3077474 --- /dev/null +++ b/docs/work-items/ORCH-059/06-adr/ADR-001-confirm-deploy-status.md @@ -0,0 +1,156 @@ +# ADR-001 (ORCH-059): Выделенный статус «Confirm Deploy» как триггер прод-деплоя + +## Статус +Accepted (design) — реализация в ветке `feature/ORCH-059-approve-confirm-deploy-approve`. + +## Контекст +ORCH-036 (исполняемый самодеплой стадии `deploy`) запускает прод-деплой +self-hosting инстанса **Фазой B**: человек переводит issue в Plane-статус +`Approved` → webhook `work_item.updated` → `handle_issue_updated` → +`handle_verdict(approved=True)` → `_try_advance_stage` → +`advance_stage(finished_agent=None)`; в `stage_engine.advance_stage` блок +`current_stage == "deploy" and finished_agent is None` → +`_handle_self_deploy_phase_b` → detached host-деплой прода (8500). + +Тот же UUID `Approved` (`a519a341-…`, `_DEFAULT_STATES["approved"]`) — это +**человеческий гейт одобрения** на стадии `analysis` +(`check_analysis_approved`, путь `approved-via-status`) и общий verdict-роутинг +в `handle_verdict`. Один визуальный «Approved» на доске значит две принципиально +разные вещи: «принять BRD» (дёшево, обратимо) и «**ВЫКАТИТЬ В ПРОД** инструмент, +обслуживающий все проекты из одного инстанса с общей БД» (дорого, групповой +риск). Привычный жест approve на стадии `deploy` молча триггерит прод-рестарт — +цена случайного клика высока (см. self-hosting в `CLAUDE.md`). + +Ограничения, формирующие дизайн (см. `02-trz.md`, `03-acceptance-criteria.md`): +1. **Нулевая регрессия** гейта `Approved` на `analysis` и прочих стадиях (TRZ-4). +2. **Fail-closed**: среды без статуса (enduro, fallback `_DEFAULT_STATES`, + недоступный API) не должны падать и не должны «вслепую» деплоить (TRZ-1, R-1). +3. **`Approved` на `deploy` не должен** запускать Фазу B И не должен вызывать + ложный откат (БАГ-8) или ложный advance по `check_deploy_status` — вердикта + ещё нет (TRZ-3, R-2). +4. **Без правки контрактов**: `STAGE_TRANSITIONS`, `QG_CHECKS`, + `check_deploy_status`, Фазы A/C, merge-gate, exit-коды хука, схема БД (TRZ-8). +5. **Self-hosting safety**: правка — чистая маршрутизация, не требует внепланового + рестарта прода; выкат через штатный `deploy-staging` (8501) → `deploy` (R-3). + +## Решение +Ввести отдельный логический статус `confirm_deploy` («Confirm Deploy»), который +триггерит **ТОЛЬКО** Фазу B на стадии `deploy`. `Approved` теряет смысл «запусти +прод-деплой» и остаётся исключительно человеческим гейтом конвейера. + +Четыре точечные правки в трёх модулях: + +### 1. Резолвер состояний — `src/plane_sync.py` +- В `_PLANE_NAME_TO_KEY` добавить маппинг `"Confirm Deploy" → "confirm_deploy"`. +- В `_DEFAULT_STATES` ключ `confirm_deploy` **НЕ добавлять** (реального UUID для + enduro/fallback нет; отсутствие ключа = fail-closed). Для проекта ORCH ключ + резолвится `get_project_states` из живого Plane API; для проектов без статуса и + на fallback-пути ключ просто отсутствует в результирующем словаре. +- Следствие: `get_project_states(orch)["confirm_deploy"]` → реальный UUID; + `get_project_states(enduro).get("confirm_deploy")` → `None`. + +### 2. Маршрутизация webhook — `src/webhooks/plane.py` +В `handle_issue_updated`, **до** ветки `approved`, добавить fail-closed-ветку: +```python +confirm_state = proj_states.get("confirm_deploy") # .get -> AC-7/R-1 +if confirm_state and new_state == confirm_state: + await handle_confirm_deploy(data, project_id) +elif new_state == proj_states["in_progress"]: + ... +elif new_state == proj_states["approved"]: + await handle_verdict(data, project_id, approved=True) +``` +Новый `handle_confirm_deploy(data, project_id)`: +- резолвит задачу по `plane_id`; +- если `stage != "deploy"` → **no-op с логом** (Confirm Deploy осмыслен только на + approval-pending стадии `deploy`; защищает прочие гейты от случайного approve); +- иначе → `_try_advance_stage(..., confirm_deploy=True)`. + +`handle_verdict(approved=True)` не меняется — продолжает звать `_try_advance_stage` +с `confirm_deploy=False` (дефолт). + +### 3. Сигнал в движок — `src/stage_engine.advance_stage(...)` +Добавить keyword-only параметр `confirm_deploy: bool = False` (back-compat: все +существующие вызовы из launcher/reconciler/finalizer/webhook передают +`finished_agent`, новый kwarg дефолтный). Блок Фазы B переписать так, чтобы он +**всегда возвращался рано** для `deploy + finished_agent is None` self-hosting, +но деплоил только по сигналу: +```python +if (current_stage == "deploy" and finished_agent is None + and settings.deploy_require_manual_approve + and self_deploy.self_deploy_applies(repo)): + if confirm_deploy: + _handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result) + else: + # TRZ-3/R-2: обычный Approved на deploy — no-op; НЕ запускаем + # check_deploy_status (вердикта ещё нет -> ложный откат БАГ-8). + result.note = "approved-on-deploy-noop" + return result +``` +Ключевое: возврат **до** блока Quality Gate в обоих случаях → `check_deploy_status` +по `Approved` на `deploy` не исполняется. Фаза C (finalizer, +`finished_agent="deployer"`) не затронута — условие требует `finished_agent is +None`. + +### 4. CTA Фазы A — `src/stage_engine._handle_self_deploy_phase_a` +Текст Plane-комментария и Telegram изменить: вместо «смените статус на Approved» +инструктировать перевести задачу в статус **«Confirm Deploy»** для запуска +прод-деплоя (TRZ-5/AC-6). + +### Условность (как ORCH-35/36) +Вся ветка реальна только для `self_deploy.self_deploy_applies(repo)` → +`orchestrator`. Прочие репо — прежний синхронный ssh-деплой агентом; статус +`Confirm Deploy` им не нужен и на них не влияет (AC-8). + +## Альтернативы +- **A. Telegram inline-кнопка подтверждения** вместо нового статуса — отклонено: + кнопочная инфраструктура в коде отсутствует, заявлено вне scope (ORCH-036 п. + «inline-кнопка» не реализован); управление остаётся статусом Plane. +- **B. Добавить `confirm_deploy` в `_DEFAULT_STATES`** — отклонено: реального UUID + «Confirm Deploy» для enduro/fallback нет; пришлось бы подставить фиктивный или + дублирующий UUID, что ломает fail-closed (enduro «получил бы» триггер деплоя) и + смешивает семантику. +- **C. Отдельный публичный entrypoint `stage_engine.initiate_confirm_deploy()`**, + минующий `advance_stage` — отклонено: дублирует гарды + (`deploy_require_manual_approve`, `self_deploy_applies`, idempotency `initiated`), + и всё равно пришлось бы внутри `advance_stage` гасить `Approved`-на-`deploy` в + no-op. Параметр-сигнал проще и держит единую точку правды. +- **D. Сигнал через sentinel-маркер, записываемый webhook’ом** — отклонено: вызов + синхронный в пределах одного `advance_stage`, persistence не нужна; параметр + явнее и не плодит файловое состояние. + +## Последствия +**Плюсы** +- Жест «запустить прод-деплой» отделён от «одобрить артефакт»; случайный approve + на доске больше не роняет прод (BG-1, BG-2). +- `Approved` на `deploy` детерминированно безопасен: no-op без отката/advance + (закрывает R-2). +- Fail-closed: нет статуса → нет деплоя, нет исключения (R-1, AC-7). +- Минимальный диффузный риск: контракты `STAGE_TRANSITIONS`/`QG_CHECKS`/ + `check_deploy_status`/Фазы A/C/merge-gate/схема БД не тронуты (AC-9). +- Реконсилятор F-1 на `deploy` (finished_agent=None) теперь попадает в no-op-ветку + вместо прежнего неявного запуска Фазы B → прод-деплой невозможно инициировать + автоматически, только явным человеческим `Confirm Deploy` (усиление safety). + +**Минусы / цена** +- Эксплуатационное предусловие: в Plane-проекте ORCH нужно создать статус доски + «Confirm Deploy» (точное имя, регистр) и сбросить кэш состояний — см. + `07-infra-requirements.md`. До создания статуса прод-деплой через approve не + запустится (это и есть желаемое fail-closed-поведение). +- Сигнатура `advance_stage` расширена одним kwarg (обратносовместимо). + +**Хэндофф документации (golden source, в том же PR — стадия development).** +ADR (этот файл) — артефакт архитектора. Переписать `Approve = Approved` → +`Confirm Deploy` в `docs/architecture/README.md` (секция ORCH-036), `CLAUDE.md` +(self-hosting/артефакты) и добавить запись в `CHANGELOG.md` обязан developer +одновременно с кодом (AC-10), чтобы доки не описывали ещё не существующее +поведение. В README на стадии architecture добавлена forward-looking пометка +ORCH-059 (design), как принято для незамёрженных доработок. + +## Связанные ADR +- `adr-0007-executable-self-deploy.md` (ORCH-036) — задаёт Фазы A/B/C; ORCH-059 + меняет **только триггер** Фазы B (`Approved` → `Confirm Deploy`) и делает + `Approved`-на-`deploy` no-op; Фазы внутренне не меняются. +- `adr-0003-staging-gate.md` (ORCH-35) — паттерн условности self-hosting. +- `adr-0007-reconciler.md` (ORCH-053) — реконсилятор F-1: поведение на `deploy` + становится no-op (см. Последствия). diff --git a/docs/work-items/ORCH-059/07-infra-requirements.md b/docs/work-items/ORCH-059/07-infra-requirements.md new file mode 100644 index 0000000..7435c50 --- /dev/null +++ b/docs/work-items/ORCH-059/07-infra-requirements.md @@ -0,0 +1,44 @@ +# 07 — Требования к инфраструктуре: ORCH-059 + +Work Item: **ORCH-059** · Repo: `orchestrator` +Связано: `06-adr/ADR-001-confirm-deploy-status.md`, `02-trz.md` §6. + +> Топология контейнеров/портов/деплоя НЕ меняется (см. `docs/operations/INFRA.md`). +> Единственное инфра-требование ORCH-059 — конфигурация Plane-доски проекта ORCH. + +## IR-1. Статус доски «Confirm Deploy» в проекте ORCH (предусловие эксплуатации) +- В Plane-проекте **ORCH** создать кастомный статус доски с **точным** именем + `Confirm Deploy` (case-sensitive, ровно один пробел) — должно посимвольно + совпасть с ключом `_PLANE_NAME_TO_KEY["Confirm Deploy"]`. Несовпадение → + fail-closed (деплой не запустится), не краш (R-9). +- UUID статуса генерирует Plane; код резолвит его через `get_project_states` + (`GET /workspaces//projects//states/`). Хардкодить UUID не нужно. +- **Размещение** на доске — рядом с approval-pending/`deploy` (рекомендация + эксплуатации, на поведение кода не влияет). +- **Только проект ORCH** (self-hosting). Для enduro и прочих проектов статус НЕ + создаётся и НЕ требуется — `self_deploy_applies` истинно лишь для `orchestrator`. + +## IR-2. Сброс кэша состояний после создания статуса +`get_project_states` кэширует резолв per-project на время жизни процесса +(`_STATES_CACHE`). После создания статуса в Plane закэшированный словарь не +содержит `confirm_deploy` (R-5). Применить ОДНО из: +- вызвать `reload_project_states()` (или полный сброс), либо +- штатно перезапустить прод по конвейеру `deploy-staging → deploy` (рестарт + процесса очищает кэш). + +> Внеплановый ручной рестарт прод-контейнера для применения этой задачи **не +> требуется** и противопоказан (self-hosting групповой риск). Выкат — только через +> штатный staging→deploy. + +## IR-3. Контрольная проверка готовности среды +После IR-1+IR-2: +1. `get_project_states()` содержит `confirm_deploy` с непустым UUID, + отличным от `approved` (AC-1, TC-02). +2. Перевод тестовой задачи стадии `deploy` (sandbox) в `Confirm Deploy` запускает + Фазу B; перевод в `Approved` — нет (AC-2/AC-3). + +## Что НЕ меняется +- Порты (8500 prod / 8501 staging), контейнеры, compose-профили, env-карта, + деплой-хук, схема БД, sentinel-каталоги ORCH-036 — без изменений. +- HTTP-эндпоинты (`POST /webhook/plane` тот же канал, событие + `work_item.updated`). diff --git a/docs/work-items/ORCH-059/10-tech-risks.md b/docs/work-items/ORCH-059/10-tech-risks.md new file mode 100644 index 0000000..eb2b72f --- /dev/null +++ b/docs/work-items/ORCH-059/10-tech-risks.md @@ -0,0 +1,25 @@ +# 10 — Технические риски: ORCH-059 + +Work Item: **ORCH-059** · Repo: `orchestrator` · ведёт: архитектор +Связано: `06-adr/ADR-001-confirm-deploy-status.md`. + +| ID | Риск | Вероятн. | Влияние | Митигация | Проверка | +|----|------|----------|---------|-----------|----------| +| R-1 | Ключ `confirm_deploy` отсутствует в `_DEFAULT_STATES` / у проектов без статуса → `KeyError` в webhook-пути | Сред | Выс (краш обработчика) | Доступ ТОЛЬКО через `.get("confirm_deploy")`; `_DEFAULT_STATES` не содержит ключ намеренно; отсутствие → ветка не активируется (fail-closed) | TC-03, AC-7 | +| R-2 | `Approved` на `deploy` после правки вызывает `check_deploy_status` (вердикта нет) → ложный откат БАГ-8 / ложный advance | Выс | Выс (петля dev↔deploy, ложный rollback прода) | Блок Фазы B возвращается рано для `deploy + finished_agent is None` self-hosting в ОБОИХ случаях; `Approved` → `note=approved-on-deploy-noop`, QG не запускается | TC-05, TC-07, TC-11, AC-3 | +| R-3 | Самоправка прода требует внепланового рестарта прод-контейнера | Низ | Выс (встаёт конвейер всех проектов) | Изменение — чистая маршрутизация в коде; выкат через штатный `deploy-staging` (8501) → `deploy`; sentinel-состояние ORCH-036 не трогаем | AC-9, RG-01 | +| R-4 | `Confirm Deploy` прислан на не-`deploy` стадии (оператор ошибся) → срабатывает как обычный approve и продвигает чужой гейт | Низ | Сред | `handle_confirm_deploy` гардит `stage == "deploy"`; иначе no-op с логом | TC-04 (+ ручная верификация) | +| R-5 | Кэш `get_project_states` закэширован до создания статуса «Confirm Deploy» → ключ не виден после конфигурации Plane | Сред | Сред (деплой не запускается) | После создания статуса в Plane — `reload_project_states(orch)` или штатный рестарт по стадии `deploy`; зафиксировано в `07-infra-requirements.md` | ручная верификация | +| R-6 | Новый kwarg `confirm_deploy` ломает существующие вызовы `advance_stage` (launcher/reconciler/finalizer) | Низ | Выс | keyword-only с дефолтом `False`; все вызовы передают `finished_agent`; не-`deploy`/finished_agent≠None пути не затронуты | RG-01, AC-9 | +| R-7 | Регрессия идемпотентности Фазы B (двойной `Confirm Deploy`) | Низ | Сред | Внутренности `_handle_self_deploy_phase_b` (маркер `initiated`) не меняются; меняется только триггер | TC-08, AC-5 | +| R-8 | Реконсилятор F-1 на `deploy` (finished_agent=None) меняет поведение | Низ | Низ (улучшение) | Намеренно: раньше неявно мог войти в Фазу B, теперь → no-op. Прод-деплой инициируется только явным `Confirm Deploy`. Документировано в ADR/README | RG-01 | +| R-9 | Несовпадение имени статуса в Plane и `_PLANE_NAME_TO_KEY` (регистр/пробел) → ключ не резолвится | Сред | Сред (деплой не запускается, fail-closed) | Точное имя «Confirm Deploy» (case-sensitive) — требование среды в `07-infra-requirements.md`; маппинг ровно этой строкой | TC-01, TC-02 | + +## Сводный вывод +Все риски — низкого/среднего остаточного уровня после митигаций. Доминирующий +класс — **fail-closed**: любая неполнота конфигурации (нет статуса, протухший кэш, +недоступный API) приводит к «деплой не запускается», а не к «деплой запускается +вслепую» или к крашу. Контракты конвейера (`STAGE_TRANSITIONS`, `QG_CHECKS`, +`check_deploy_status`, Фазы A/C, merge-gate, схема БД) не затрагиваются, поэтому +поверхность регрессии ограничена тремя модулями (`plane_sync.py`, +`webhooks/plane.py`, `stage_engine.py`). diff --git a/docs/work-items/ORCH-059/12-review.md b/docs/work-items/ORCH-059/12-review.md new file mode 100644 index 0000000..d4b4add --- /dev/null +++ b/docs/work-items/ORCH-059/12-review.md @@ -0,0 +1,59 @@ +--- +type: review +work_item_id: ORCH-059 +verdict: APPROVED +version: 1 +--- + +# Review ORCH-059 + +## Summary +Выделенный Plane-статус «Confirm Deploy» как единственный триггер Фазы B прод-деплоя +self-hosting; `Approved` на стадии `deploy` становится детерминированным no-op. Реализация +точно соответствует ТЗ (TRZ-1..6), ADR-001 и критериям приёмки (AC-1..10). Четыре точечные +правки в трёх модулях (`plane_sync.py`, `webhooks/plane.py`, `stage_engine.py`), без изменения +контрактов (`STAGE_TRANSITIONS`, `QG_CHECKS`, `check_deploy_status`, Фазы A/C, merge-gate, схема +БД). Документация обновлена в том же PR. `pytest tests/ -q` — 763 passed. + +## Соответствие ТЗ и ADR +- **TRZ-1 / AC-1** — `"Confirm Deploy" → "confirm_deploy"` добавлен в `_PLANE_NAME_TO_KEY`; + намеренно отсутствует в `_DEFAULT_STATES` → fail-closed. Покрыто `test_tc01/tc02`. +- **TRZ-2 / AC-2** — `handle_confirm_deploy` (гард `stage=="deploy"`) → + `_try_advance_stage(..., confirm_deploy=True)` → Фаза B. Покрыто `test_tc04/tc07/tc10`. +- **TRZ-3 / AC-3** — `Approved` на `deploy`: ранний возврат ДО Quality Gate с + `note="approved-on-deploy-noop"`, без `initiate_deploy`, без ложного отката БАГ-8. + Покрыто `test_tc05/tc07_approved_without_confirm_is_noop/tc11`. +- **TRZ-4 / AC-4** — `handle_verdict(approved=True)` не тронут; approve на `analysis` + продвигает конвейер. Покрыто `test_tc06_approved_on_analysis_still_advances`. +- **AC-5** — идемпотентность повторного «Confirm Deploy» (`self-deploy-already-initiated`). + Покрыто `test_tc08`, `test_tc06_approved_calls_prod_hook_exactly_once`. +- **TRZ-5 / AC-6** — CTA Фазы A (Plane-коммент + Telegram) просит «Confirm Deploy» и явно + отмечает, что «Approved» прод-деплой не запускает. Покрыто `test_tc09`. +- **TRZ-1 / AC-7** — доступ через `.get("confirm_deploy")`, отсутствие статуса → ветка не + активируется, без `KeyError`. Покрыто `test_tc03` (API недоступен / статуса нет на доске). +- **TRZ-6 / AC-8** — условность через `self_deploy.self_deploy_applies`; не-self репо без + изменений. Покрыто `test_tc12`. +- **AC-9** — контракты и схема БД не изменены; 763 теста зелёные. + +## Findings + +### P0 — Blocker +- нет + +### P1 — Must fix +- нет + +### P2 — Should fix +- нет + +## Документация +Обновлено в том же PR (AC-10 выполнен): +- `CLAUDE.md` — раздел self-hosting: прод-деплой только через «Confirm Deploy», `Approved` = no-op. +- `docs/architecture/README.md` — секция ORCH-036 уточнена + добавлена подсекция ORCH-059 + (статус-триггер «Confirm Deploy»), запись в перечне статусов доработок. +- `CHANGELOG.md` — запись ORCH-059 в `[Unreleased] / Added`. +- ADR `docs/work-items/ORCH-059/06-adr/ADR-001-confirm-deploy-status.md` — заведён, отражает + реализацию (4 правки, fail-closed, рассмотренные альтернативы). +- `07-infra-requirements.md` — эксплуатационное предусловие (создать статус доски + сброс кэша). + +Документация консистентна с кодом; golden-source инвариант соблюдён. diff --git a/docs/work-items/ORCH-059/13-test-report.md b/docs/work-items/ORCH-059/13-test-report.md new file mode 100644 index 0000000..d5ea52d --- /dev/null +++ b/docs/work-items/ORCH-059/13-test-report.md @@ -0,0 +1,71 @@ +--- +type: test-report +work_item_id: ORCH-059 +result: PASS +--- + +# Test Report — ORCH-059 + +Выделенный Plane-статус «Confirm Deploy» как единственный триггер Фазы B прод-деплоя +self-hosting; `Approved` на стадии `deploy` — детерминированный no-op. + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 +- Prod orchestrator (8500): `/health` → `{"status":"ok"}` +- Дата: 2026-06-07 + +## Результаты (контракт-тесты `04-test-plan.yaml`) + +| TC ID | Описание | Тест | Результат | +|-------|----------|------|-----------| +| TC-01 | `_PLANE_NAME_TO_KEY`: `'Confirm Deploy' → 'confirm_deploy'` | test_tc01_confirm_deploy_name_to_key_mapping; test_tc01_confirm_deploy_not_in_default_states | PASS | +| TC-02 | `get_project_states` ORCH резолвит непустой UUID под `confirm_deploy`, ≠ `approved` | test_tc02_get_project_states_resolves_confirm_deploy | PASS | +| TC-03 | Fail-closed при отсутствии статуса (API недоступен / нет на доске) — без исключения | test_tc03_fail_closed_when_api_unreachable; test_tc03_fail_closed_when_status_not_on_board | PASS | +| TC-04 | `handle_issue_updated`: `Confirm Deploy` на `deploy` → путь Фазы B | test_tc04_confirm_deploy_routes_phase_b; test_tc04b_confirm_deploy_off_deploy_stage_is_noop | PASS | +| TC-05 | `Approved` на `deploy` НЕ вызывает `initiate_deploy` | test_tc05_approved_on_deploy_does_not_initiate | PASS | +| TC-06 | `Approved` на `analysis` по-прежнему продвигает → architecture | test_tc06_approved_on_analysis_still_advances | PASS | +| TC-07 | stage_engine: Фаза B только по confirm-deploy; `Approved` → no-op | test_tc07_confirm_deploy_initiates; test_tc07_approved_without_confirm_is_noop | PASS | +| TC-08 | Идемпотентность: повтор `Confirm Deploy` при маркере `initiated` → no-op | test_tc08_idempotent_repeat_confirm_deploy | PASS | +| TC-09 | CTA Фазы A содержит «Confirm Deploy», не предлагает «Approved» как триггер | test_tc09_phase_a_cta_requests_confirm_deploy | PASS | +| TC-10 | E2E: `Confirm Deploy` → `initiate_deploy` вызван, finalizer поставлен, маркер записан | test_tc10_confirm_deploy_e2e_initiates | PASS | +| TC-11 | E2E: `Approved` → деплой НЕ инициирован, задача остаётся на `deploy` | test_tc11_approved_e2e_noop | PASS | +| TC-12 | Условность: не-self репо verdict-статусы не меняют поведение деплоя | test_tc12_non_self_repo_unaffected | PASS | +| RG-01 | Полный регресс зелёный; STAGE_TRANSITIONS / QG_CHECKS без изменений | tests/ (763 passed) | PASS | + +Все 16 целевых тестов ORCH-059 (TC-01..TC-12) — PASS. + +## Сопоставление с критериями приёмки (`03-acceptance-criteria.md`) + +| AC | Покрытие | Результат | +|----|----------|-----------| +| AC-1 Статус резолвится | TC-01, TC-02 | PASS | +| AC-2 Confirm Deploy на `deploy` → Фаза B | TC-04, TC-07, TC-10 | PASS | +| AC-3 Approved на `deploy` НЕ деплоит | TC-05, TC-07, TC-11 | PASS | +| AC-4 Approved на `analysis` без регрессии | TC-06 | PASS | +| AC-5 Идемпотентность Фазы B | TC-08 | PASS | +| AC-6 CTA Фазы A просит Confirm Deploy | TC-09 | PASS | +| AC-7 Fail-closed без статуса | TC-03 | PASS | +| AC-8 Условность для не-self | TC-12 | PASS | +| AC-9 Инварианты, pytest зелёный | RG-01 (763 passed) | PASS | +| AC-10 Документация обновлена | проверено reviewer (12-review.md, APPROVED) | PASS | + +## Smoke test API (prod 8500) +- `GET /health` → `{"status":"ok","service":"orchestrator"}` +- `GET /status` → 200, активные задачи отдаются (вкл. ORCH-059 на `testing`) +- `GET /queue` → 200, counts + resilience + reconcile + reaper + post_deploy + +## Вывод pytest +``` +======================= 763 passed, 1 warning in 15.45s ======================== +``` +Целевой набор ORCH-059: +``` +======================== 16 passed, 1 warning in 0.75s ========================= +``` +(1 warning — PydanticDeprecatedSince20 в `src/config.py`, не относится к ORCH-059.) + +## Итог +**PASS** — все контракт-тесты (TC-01..TC-12) и регресс (763 passed) зелёные, +критерии приёмки AC-1..AC-10 покрыты, smoke API OK. Задача готова к стадии +deploy-staging. diff --git a/docs/work-items/ORCH-059/14-deploy-log.md b/docs/work-items/ORCH-059/14-deploy-log.md new file mode 100644 index 0000000..c323abb --- /dev/null +++ b/docs/work-items/ORCH-059/14-deploy-log.md @@ -0,0 +1,12 @@ +--- +deploy_status: SUCCESS +work_item: ORCH-059 +hook_exit_code: 0 +deployed_by: deploy-finalizer +--- + +# Deploy log — ORCH-036 executable self-deploy + +Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`. + +Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM. diff --git a/docs/work-items/ORCH-059/16-post-deploy-log.md b/docs/work-items/ORCH-059/16-post-deploy-log.md new file mode 100644 index 0000000..5d96374 --- /dev/null +++ b/docs/work-items/ORCH-059/16-post-deploy-log.md @@ -0,0 +1,14 @@ +--- +post_deploy_status: HEALTHY +action_taken: NONE +work_item: ORCH-059 +window_s: 900 +checks_total: 30 +checks_failed: 0 +--- + +# Post-deploy log — ORCH-021 post-deploy monitor + +Наблюдение прода завершено: `post_deploy_status: HEALTHY`, `action_taken: NONE`. + +Окно наблюдения: 900s; опросов всего: 30, из них с провалом: 0. diff --git a/src/plane_sync.py b/src/plane_sync.py index f6ed56f..913fe14 100644 --- a/src/plane_sync.py +++ b/src/plane_sync.py @@ -128,6 +128,12 @@ _PLANE_NAME_TO_KEY: dict[str, str] = { "Needs Input": "needs_input", "In Review": "in_review", "Blocked": "blocked", + # ORCH-059: dedicated prod-deploy trigger status, distinct from the + # human-gate "Approved". Resolved from the live Plane API for the ORCH + # project; intentionally ABSENT from _DEFAULT_STATES so environments without + # this board status (enduro / API fallback) fail-closed — no UUID, no + # confirm-deploy branch, no KeyError (accessed via .get). + "Confirm Deploy": "confirm_deploy", } # Per-project state cache: {project_id: {logical_key: state_uuid}} diff --git a/src/stage_engine.py b/src/stage_engine.py index f4797fc..461ad40 100644 --- a/src/stage_engine.py +++ b/src/stage_engine.py @@ -172,6 +172,8 @@ def advance_stage( work_item_id: str, branch: str, finished_agent: str | None = None, + *, + confirm_deploy: bool = False, ) -> AdvanceResult: """Run the current stage's quality gate and advance / roll back the pipeline. @@ -188,6 +190,13 @@ def advance_stage( approved/REQUEST_CHANGES/tester/architect branches. In the plane webhook path it is None, so those agent-specific branches simply do not trigger (matches old plane behavior). + confirm_deploy: ORCH-059 — keyword-only signal that the human flipped the + issue to the dedicated "Confirm Deploy" status. ONLY this + signal initiates Phase B of the self-hosting prod deploy on + the `deploy` stage. A plain `Approved` on `deploy` + (confirm_deploy=False) is a deliberate no-op (no prod + deploy, no false БАГ-8 rollback). All non-webhook callers + leave it at the default. Returns AdvanceResult describing what happened. """ @@ -204,21 +213,32 @@ def advance_stage( result.note = "terminal" return result - # --- ORCH-036 Phase B: human Approved on `deploy` -> initiate deploy -- - # A human flipping the Plane status to Approved on the `deploy` stage - # (finished_agent is None) is the prod-deploy trigger for the self-hosting - # repo. Initiate the DETACHED host deploy + enqueue the finalizer and - # return WITHOUT running check_deploy_status (the verdict does not exist - # yet — running the gate now would read a stale/absent log and falsely - # roll back, R-2). The finalizer (Phase C, finished_agent="deployer") - # records the verdict later; that path is NOT intercepted here. + # --- ORCH-036/059 Phase B: "Confirm Deploy" on `deploy` -> initiate ---- + # ORCH-059: the prod-deploy trigger is now the DEDICATED "Confirm Deploy" + # status (confirm_deploy=True), NOT the overloaded "Approved". On the + # `deploy` stage (finished_agent is None) for the self-hosting repo we + # always return early WITHOUT running check_deploy_status (the verdict + # does not exist yet — running the gate now would read a stale/absent log + # and falsely roll back, R-2/БАГ-8), but we only initiate the DETACHED + # host deploy + enqueue the finalizer when confirm_deploy is set. A plain + # Approved (confirm_deploy=False) is a deliberate no-op — it neither + # deploys nor rolls back (TRZ-3/AC-3). The finalizer (Phase C, + # finished_agent="deployer") records the verdict later; that path is NOT + # intercepted here (it requires finished_agent set). if ( current_stage == "deploy" and finished_agent is None and settings.deploy_require_manual_approve and self_deploy.self_deploy_applies(repo) ): - _handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result) + if confirm_deploy: + _handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result) + else: + result.note = "approved-on-deploy-noop" + logger.info( + f"Task {task_id}: Approved on `deploy` without Confirm Deploy " + f"— no-op (prod deploy requires the 'Confirm Deploy' status)" + ) return result # --- Quality gate ---------------------------------------------------- @@ -1098,9 +1118,11 @@ def _handle_self_deploy_phase_a( Staging is green and the branch is mergeable; for the self-hosting repo we do NOT auto-deploy to prod. Move the task onto the `deploy` stage (so a later - human Approved lands there -> Phase B), set the issue approval-pending and ask - the human to flip the status to Approved. A restart-safe `approve-requested` - marker records that Phase A ran. The merge lease stays HELD. + human "Confirm Deploy" lands there -> Phase B), set the issue approval-pending + and ask the human to flip the status to "Confirm Deploy" (ORCH-059: the + dedicated prod-deploy trigger, distinct from the human-gate "Approved"). A + restart-safe `approve-requested` marker records that Phase A ran. The merge + lease stays HELD. """ update_task_stage(task_id, "deploy") notify_stage_change(task_id, current_stage, "deploy") @@ -1122,13 +1144,14 @@ def _handle_self_deploy_phase_a( if work_item_id: plane_add_comment( work_item_id, - "\U0001f7e1 Staging зелёный. Требуется ручной approve для ПРОД-деплоя: " - "смените статус задачи на «Approved», чтобы запустить деплой в прод (8500).", + "\U0001f7e1 Staging зелёный. Требуется ручное подтверждение ПРОД-деплоя: " + "смените статус задачи на «Confirm Deploy», чтобы запустить деплой в прод " + "(8500). Статус «Approved» прод-деплой НЕ запускает.", author="deployer", ) send_telegram( - f"\U0001f7e1 {work_item_id}: staging OK. Ждёт approve на ПРОД-деплой " - f"(смените статус на Approved)." + f"\U0001f7e1 {work_item_id}: staging OK. Ждёт подтверждения ПРОД-деплоя " + f"(смените статус на «Confirm Deploy»)." ) logger.info( f"Task {task_id}: self-deploy Phase A — advanced to deploy, " diff --git a/src/webhooks/plane.py b/src/webhooks/plane.py index b14ab3b..1bbff6b 100644 --- a/src/webhooks/plane.py +++ b/src/webhooks/plane.py @@ -150,8 +150,15 @@ async def handle_issue_updated(data: dict, project_id: str = ""): # both enduro (b873d9eb) and orchestrator (e331bfb3) In Progress trigger the # pipeline. Using PLANE_STATES["in_progress"] here was the root-cause blocker. proj_states = get_project_states(project_id) + # ORCH-059: the dedicated "Confirm Deploy" status is the prod-deploy trigger. + # fail-closed via .get — environments without the status (enduro / API + # fallback) resolve to None, so the branch simply never activates (no KeyError, + # no blind deploy). Checked before `approved` so the two gestures never alias. + confirm_state = proj_states.get("confirm_deploy") if new_state == proj_states["in_progress"]: await handle_status_start(data, project_id) + elif confirm_state and new_state == confirm_state: + await handle_confirm_deploy(data, project_id) elif new_state == proj_states["approved"]: await handle_verdict(data, project_id, approved=True) elif new_state == proj_states["rejected"]: @@ -160,6 +167,45 @@ async def handle_issue_updated(data: dict, project_id: str = ""): logger.info(f"issue {plane_id} updated to state {new_state[:8]}..., no pipeline action") +async def handle_confirm_deploy(data: dict, project_id: str = ""): + """ORCH-059: a human flipped the issue to the dedicated "Confirm Deploy" + status — the explicit trigger for the self-hosting prod deploy (Phase B). + + Guarded to the `deploy` stage: "Confirm Deploy" is only meaningful on the + approval-pending `deploy` stage (Phase A advanced the task there). On any + other stage it is a no-op-with-log, so a stray Confirm Deploy can never + perturb another gate. + + Routes to the unified stage engine with ``confirm_deploy=True`` so ONLY this + path initiates Phase B; a plain Approved on `deploy` stays a no-op (TRZ-3). + """ + plane_id = str(data.get("id") or "") + task = get_task_by_plane_id(plane_id) + if not task: + logger.warning(f"Confirm Deploy for {plane_id} but no task found, ignoring") + return + + task_id = task["id"] + current_stage = task["stage"] + repo = task["repo"] + work_item_id = task.get("work_item_id", "") + branch = task.get("branch", "") + + if current_stage != "deploy": + logger.info( + f"Confirm Deploy for {plane_id} but stage is '{current_stage}' " + f"(not 'deploy'); no-op" + ) + return + + logger.info( + f"Task {task_id}: Confirm Deploy status on `deploy` -> initiate Phase B prod deploy" + ) + await _try_advance_stage( + task_id, current_stage, repo, work_item_id, branch, confirm_deploy=True + ) + + async def handle_status_start(data: dict, project_id: str = ""): """An issue moved into In Progress. @@ -633,7 +679,8 @@ async def _rollback_stage( async def _try_advance_stage( - task_id: int, current_stage: str, repo: str, work_item_id: str, branch: str + task_id: int, current_stage: str, repo: str, work_item_id: str, branch: str, + confirm_deploy: bool = False, ): """Thin async wrapper over the unified stage engine (ORCH-4 / M-3). @@ -642,10 +689,15 @@ async def _try_advance_stage( is synchronous. We run it off the event loop via asyncio.to_thread so there is exactly one implementation shared with the launcher. - finished_agent is None on this webhook path (a human Approved status change, - not a finished agent), so the agent-specific rollback branches inside the - engine intentionally do not trigger — the webhook path only runs the QG and - either advances or reports the failure. + finished_agent is None on this webhook path (a human status change, not a + finished agent), so the agent-specific rollback branches inside the engine + intentionally do not trigger — the webhook path only runs the QG and either + advances or reports the failure. + + ORCH-059: ``confirm_deploy`` is threaded through (keyword-only on + advance_stage). It is True ONLY on the "Confirm Deploy" path + (handle_confirm_deploy) and gates Phase B of the self-hosting prod deploy; the + plain Approved path (handle_verdict) leaves it at the default False. """ import asyncio from ..stage_engine import advance_stage @@ -658,6 +710,7 @@ async def _try_advance_stage( work_item_id, branch, None, + confirm_deploy=confirm_deploy, ) diff --git a/tests/test_confirm_deploy_integration.py b/tests/test_confirm_deploy_integration.py new file mode 100644 index 0000000..2405878 --- /dev/null +++ b/tests/test_confirm_deploy_integration.py @@ -0,0 +1,171 @@ +"""ORCH-059 TC-10/11/12: end-to-end routing from a Plane webhook payload through +handle_issue_updated into the stage engine, with the host deploy mocked. + +Contract (AC-2, AC-3, AC-8): + * TC-10 — task on `deploy` + webhook "Confirm Deploy" -> initiate_deploy called, + `deploy-finalizer` enqueued, `initiated` marker written. + * TC-11 — task on `deploy` + webhook "Approved" -> NO prod deploy initiated, the + task stays on `deploy` (no rollback, no advance to done). + * TC-12 — non-self repo: verdict statuses on `deploy` do not change deploy + behaviour (self_deploy_applies == False; the confirm-deploy branch is inert). +""" + +import os +import tempfile + +import pytest + +_test_db = os.path.join(tempfile.gettempdir(), "test_orch_confirm_e2e.db") +os.environ["ORCH_DB_PATH"] = _test_db +os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir() +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") + +from unittest.mock import MagicMock # noqa: E402 + +import src.db as _db # noqa: E402 +from src.db import init_db, get_db # noqa: E402 +from src import stage_engine # noqa: E402 +from src import self_deploy # noqa: E402 +import src.plane_sync as plane_sync # noqa: E402 +import src.webhooks.plane as wh # noqa: E402 + +IN_PROGRESS = "11111111-1111-1111-1111-111111111111" +APPROVED = "22222222-2222-2222-2222-222222222222" +REJECTED = "33333333-3333-3333-3333-333333333333" +CONFIRM = "44444444-4444-4444-4444-444444444444" + +# ORCH project: Confirm Deploy resolved. enduro-like project: NO confirm_deploy key. +_STATES_SELF = { + "in_progress": IN_PROGRESS, + "approved": APPROVED, + "rejected": REJECTED, + "confirm_deploy": CONFIRM, +} +_STATES_NONSELF = { + "in_progress": IN_PROGRESS, + "approved": APPROVED, + "rejected": REJECTED, +} + + +@pytest.fixture(autouse=True) +def fresh_db(monkeypatch, tmp_path): + monkeypatch.setattr(_db.settings, "db_path", _test_db) + if os.path.exists(_test_db): + os.unlink(_test_db) + init_db() + monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path)) + monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path)) + monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True) + yield + + +@pytest.fixture(autouse=True) +def silence_engine(monkeypatch): + for name in ( + "notify_stage_change", "notify_qg_failure", "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(), raising=False) + + +def _make_task(stage, repo, branch, wi, plane_id): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) " + "VALUES (?, ?, ?, ?, ?)", + (plane_id, wi, repo, branch, stage), + ) + task_id = cur.lastrowid + conn.commit() + conn.close() + return task_id + + +def _stage(task_id): + conn = get_db() + row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone() + conn.close() + return row[0] + + +def _jobs(): + conn = get_db() + rows = conn.execute("SELECT agent FROM jobs ORDER BY id").fetchall() + conn.close() + return [r[0] for r in rows] + + +def _payload(state_uuid, plane_id): + return {"id": plane_id, "state": {"id": state_uuid}} + + +# --------------------------------------------------------------------------- +# TC-10: E2E Confirm Deploy -> prod deploy initiated +# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_tc10_confirm_deploy_e2e_initiates(monkeypatch): + monkeypatch.setattr(plane_sync, "get_project_states", lambda pid: _STATES_SELF) + initiate = MagicMock(return_value=(True, "ok")) + monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate) + + task_id = _make_task("deploy", "orchestrator", "feature/ORCH-059-x", + "ORCH-059", "plane-ORCH-059") + + await wh.handle_issue_updated(_payload(CONFIRM, "plane-ORCH-059"), "orch-proj") + + initiate.assert_called_once() + assert "deploy-finalizer" in _jobs() + assert self_deploy.has_marker("orchestrator", "ORCH-059", self_deploy.INITIATED) + # Verdict comes later via the finalizer — still on `deploy`. + assert _stage(task_id) == "deploy" + + +# --------------------------------------------------------------------------- +# TC-11: E2E Approved -> no prod deploy, task stays on deploy +# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_tc11_approved_e2e_noop(monkeypatch): + monkeypatch.setattr(plane_sync, "get_project_states", lambda pid: _STATES_SELF) + initiate = MagicMock(return_value=(True, "ok")) + monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate) + + task_id = _make_task("deploy", "orchestrator", "feature/ORCH-059-x", + "ORCH-059", "plane-ORCH-059") + + await wh.handle_issue_updated(_payload(APPROVED, "plane-ORCH-059"), "orch-proj") + + initiate.assert_not_called() + assert "deploy-finalizer" not in _jobs() + assert _stage(task_id) == "deploy" # no rollback, no advance to done + assert not self_deploy.has_marker("orchestrator", "ORCH-059", self_deploy.INITIATED) + + +# --------------------------------------------------------------------------- +# TC-12: non-self repo -> confirm-deploy branch inert (fail-closed, no key) +# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_tc12_non_self_repo_unaffected(monkeypatch): + # Non-self project has no confirm_deploy key at all -> the branch never fires. + monkeypatch.setattr(plane_sync, "get_project_states", lambda pid: _STATES_NONSELF) + initiate = MagicMock(return_value=(True, "ok")) + monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate) + # Stub the deploy gate so the legacy non-self path stays deterministic (no + # real git/network); its verdict is irrelevant to this test's assertions. + monkeypatch.setattr( + stage_engine, "QG_CHECKS", + {**stage_engine.QG_CHECKS, "check_deploy_status": lambda *a, **k: (True, "ok")}, + ) + + task_id = _make_task("deploy", "enduro-trails", "feature/ET-009-x", + "ET-009", "plane-ET-009") + + # An Approved on a non-self deploy task does not initiate self-deploy logic. + await wh.handle_issue_updated(_payload(APPROVED, "plane-ET-009"), "enduro-proj") + + initiate.assert_not_called() + # The (absent) Confirm Deploy status simply maps to no pipeline action. + assert self_deploy.self_deploy_applies("enduro-trails") is False diff --git a/tests/test_deploy_approve.py b/tests/test_deploy_approve.py index e5d5182..c877a0e 100644 --- a/tests/test_deploy_approve.py +++ b/tests/test_deploy_approve.py @@ -140,12 +140,14 @@ def test_tc06_approved_calls_prod_hook_exactly_once(monkeypatch): ssh_run = MagicMock(return_value=MagicMock(returncode=0, stdout="", stderr="")) monkeypatch.setattr(self_deploy.subprocess, "run", ssh_run) - task_id = _make_task("deploy") # already on deploy, awaiting Approved + task_id = _make_task("deploy") # already on deploy, awaiting Confirm Deploy - # 1st human Approved -> Phase B initiates the detached deploy. + # ORCH-059: Phase B is now triggered by the dedicated "Confirm Deploy" status + # (confirm_deploy=True), NOT by a plain Approved. 1st Confirm Deploy -> + # Phase B initiates the detached deploy. res1 = advance_stage( task_id, "deploy", "orchestrator", "ORCH-036", - "feature/ORCH-036-x", finished_agent=None, + "feature/ORCH-036-x", finished_agent=None, confirm_deploy=True, ) assert res1.note == "self-deploy-initiated" assert ssh_run.call_count == 1 @@ -153,10 +155,10 @@ def test_tc06_approved_calls_prod_hook_exactly_once(monkeypatch): assert any(j["agent"] == "deploy-finalizer" for j in _jobs()) assert self_deploy.has_marker("orchestrator", "ORCH-036", self_deploy.INITIATED) - # 2nd (duplicate) Approved -> idempotent no-op, hook NOT called again. + # 2nd (duplicate) Confirm Deploy -> idempotent no-op, hook NOT called again. res2 = advance_stage( task_id, "deploy", "orchestrator", "ORCH-036", - "feature/ORCH-036-x", finished_agent=None, + "feature/ORCH-036-x", finished_agent=None, confirm_deploy=True, ) assert res2.note == "self-deploy-already-initiated" assert ssh_run.call_count == 1 # still exactly one prod deploy diff --git a/tests/test_plane_confirm_deploy.py b/tests/test_plane_confirm_deploy.py new file mode 100644 index 0000000..1b9d136 --- /dev/null +++ b/tests/test_plane_confirm_deploy.py @@ -0,0 +1,152 @@ +"""ORCH-059 TC-04/05/06: webhook routing for the dedicated "Confirm Deploy" +status vs. the overloaded "Approved". + +Contract (AC-2, AC-3, AC-4): + * TC-04 — handle_issue_updated routes a "Confirm Deploy" status on a `deploy` + task to the Phase B path (handle_confirm_deploy -> advance_stage with + confirm_deploy=True), NOT the plain approve/advance path. + * TC-05 — an "Approved" status on a `deploy` task does NOT initiate the prod + deploy (self_deploy.initiate_deploy is never called). + * TC-06 — an "Approved" status on an `analysis` task still advances + analysis -> architecture (the approved-via-status human gate is intact). +""" + +import os +import tempfile + +import pytest + +_test_db = os.path.join(tempfile.gettempdir(), "test_orch_confirm_routing.db") +os.environ["ORCH_DB_PATH"] = _test_db +os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir() +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") + +from unittest.mock import AsyncMock, MagicMock # noqa: E402 + +import src.db as _db # noqa: E402 +from src.db import init_db, get_db # noqa: E402 +from src import stage_engine # noqa: E402 +from src import self_deploy # noqa: E402 +import src.plane_sync as plane_sync # noqa: E402 +import src.webhooks.plane as wh # noqa: E402 + +IN_PROGRESS = "11111111-1111-1111-1111-111111111111" +APPROVED = "22222222-2222-2222-2222-222222222222" +REJECTED = "33333333-3333-3333-3333-333333333333" +CONFIRM = "44444444-4444-4444-4444-444444444444" + +_STATES = { + "in_progress": IN_PROGRESS, + "approved": APPROVED, + "rejected": REJECTED, + "confirm_deploy": CONFIRM, +} + + +@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() + # Deterministic per-project states (no network). handle_issue_updated imports + # get_project_states locally from ..plane_sync, so patch it at the source. + monkeypatch.setattr(plane_sync, "get_project_states", lambda pid: _STATES) + # Isolate sentinel dirs. + monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path)) + monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path)) + yield + + +@pytest.fixture(autouse=True) +def silence_engine(monkeypatch): + for name in ( + "notify_stage_change", "notify_qg_failure", "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(), raising=False) + + +def _make_task(stage, repo="orchestrator", branch="feature/ORCH-059-x", + wi="ORCH-059", plane_id="plane-ORCH-059"): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) " + "VALUES (?, ?, ?, ?, ?)", + (plane_id, wi, repo, branch, stage), + ) + task_id = cur.lastrowid + conn.commit() + conn.close() + return task_id + + +def _payload(state_uuid, plane_id="plane-ORCH-059"): + return {"id": plane_id, "state": {"id": state_uuid}} + + +# --------------------------------------------------------------------------- +# TC-04: "Confirm Deploy" routes to the Phase B path with confirm_deploy=True +# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_tc04_confirm_deploy_routes_phase_b(monkeypatch): + _make_task("deploy") + spy = AsyncMock() + monkeypatch.setattr(wh, "_try_advance_stage", spy) + # handle_verdict must NOT be taken for the confirm-deploy status. + verdict_spy = AsyncMock() + monkeypatch.setattr(wh, "handle_verdict", verdict_spy) + + await wh.handle_issue_updated(_payload(CONFIRM), "proj") + + spy.assert_awaited_once() + # confirm_deploy=True must be threaded through. + assert spy.await_args.kwargs.get("confirm_deploy") is True + verdict_spy.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_tc04b_confirm_deploy_off_deploy_stage_is_noop(monkeypatch): + """Guard: a stray "Confirm Deploy" on a non-deploy stage is a no-op (no advance).""" + _make_task("analysis") + spy = AsyncMock() + monkeypatch.setattr(wh, "_try_advance_stage", spy) + + await wh.handle_confirm_deploy(_payload(CONFIRM), "proj") + + spy.assert_not_awaited() + + +# --------------------------------------------------------------------------- +# TC-05: "Approved" on `deploy` does NOT initiate the prod deploy +# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_tc05_approved_on_deploy_does_not_initiate(monkeypatch): + monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True) + _make_task("deploy") + initiate = MagicMock() + monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate) + + # Real routing: Approved -> handle_verdict -> _try_advance_stage(confirm_deploy=False) + # -> advance_stage -> the deploy block no-ops (does not initiate). + await wh.handle_issue_updated(_payload(APPROVED), "proj") + + initiate.assert_not_called() + + +# --------------------------------------------------------------------------- +# TC-06: "Approved" on `analysis` still advances analysis -> architecture +# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_tc06_approved_on_analysis_still_advances(monkeypatch): + task_id = _make_task("analysis") + + await wh.handle_issue_updated(_payload(APPROVED), "proj") + + conn = get_db() + stage = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone()[0] + conn.close() + assert stage == "architecture" diff --git a/tests/test_plane_states.py b/tests/test_plane_states.py new file mode 100644 index 0000000..868e54f --- /dev/null +++ b/tests/test_plane_states.py @@ -0,0 +1,120 @@ +"""ORCH-059 TC-01/02/03: resolver registration of the dedicated "Confirm Deploy" +status and its fail-closed absence in fallback environments. + +Contract (AC-1, AC-7): + * TC-01 — _PLANE_NAME_TO_KEY maps the board name "Confirm Deploy" to the logical + key "confirm_deploy". + * TC-02 — get_project_states for an ORCH-like project (Plane API mocked to + include a "Confirm Deploy" state) returns a NON-empty uuid under + "confirm_deploy", distinct from "approved". + * TC-03 — fail-closed: when the status is absent (API fallback to + _DEFAULT_STATES / unreachable Plane), the key is simply missing and a .get + access yields None WITHOUT raising — the confirm-deploy branch never activates. +""" + +import os +import tempfile + +import pytest + +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch_plane_states.db") + +import src.plane_sync as plane_sync # noqa: E402 +from src.plane_sync import ( # noqa: E402 + _PLANE_NAME_TO_KEY, + _DEFAULT_STATES, + get_project_states, + reload_project_states, +) + + +@pytest.fixture(autouse=True) +def fresh_cache(): + reload_project_states() + yield + reload_project_states() + + +# --------------------------------------------------------------------------- +# TC-01: name -> key mapping is registered +# --------------------------------------------------------------------------- +def test_tc01_confirm_deploy_name_to_key_mapping(): + assert _PLANE_NAME_TO_KEY.get("Confirm Deploy") == "confirm_deploy" + + +def test_tc01_confirm_deploy_not_in_default_states(): + """Fail-closed by construction: NO fallback UUID exists for confirm_deploy, so + enduro / API-fallback environments never resolve a (wrong) deploy trigger.""" + assert "confirm_deploy" not in _DEFAULT_STATES + + +# --------------------------------------------------------------------------- +# TC-02: live API resolves a real, distinct uuid for an ORCH-like project +# --------------------------------------------------------------------------- +def test_tc02_get_project_states_resolves_confirm_deploy(monkeypatch): + confirm_uuid = "cfd00000-0000-0000-0000-000000000059" + approved_uuid = "a519a341-dada-4a91-8910-7604f82b79c5" + + class _Resp: + def raise_for_status(self): + pass + + def json(self): + return { + "results": [ + {"name": "In Progress", "id": "b873d9eb-993c-48cd-97ac-99a9b1623967"}, + {"name": "Approved", "id": approved_uuid}, + {"name": "Confirm Deploy", "id": confirm_uuid}, + ] + } + + monkeypatch.setattr(plane_sync.httpx, "get", lambda *a, **k: _Resp()) + + states = get_project_states("orch-project-uuid") + assert states.get("confirm_deploy") == confirm_uuid + # Distinct gestures: confirm-deploy must NOT alias the human "Approved" gate. + assert states["confirm_deploy"] != states["approved"] + + +# --------------------------------------------------------------------------- +# TC-03: fail-closed when the status is absent (API fallback / unreachable) +# --------------------------------------------------------------------------- +def test_tc03_fail_closed_when_api_unreachable(monkeypatch): + """A Plane outage -> get_project_states falls back to _DEFAULT_STATES, which + has no confirm_deploy key. .get must yield None, never raise.""" + + def _boom(*a, **k): + raise RuntimeError("plane down") + + monkeypatch.setattr(plane_sync.httpx, "get", _boom) + + states = get_project_states("any-project-uuid") + # No KeyError, branch never activates. + assert states.get("confirm_deploy") is None + # The human gate "Approved" still resolves (fallback is intact). + assert states.get("approved") == _DEFAULT_STATES["approved"] + + +def test_tc03_fail_closed_when_status_not_on_board(monkeypatch): + """Project whose board lacks "Confirm Deploy": the key is filled by NEITHER the + API loop NOR the _DEFAULT_STATES backfill -> absent -> fail-closed.""" + + class _Resp: + def raise_for_status(self): + pass + + def json(self): + return { + "results": [ + {"name": "In Progress", "id": "b873d9eb-993c-48cd-97ac-99a9b1623967"}, + {"name": "Approved", "id": "a519a341-dada-4a91-8910-7604f82b79c5"}, + ] + } + + monkeypatch.setattr(plane_sync.httpx, "get", lambda *a, **k: _Resp()) + + states = get_project_states("board-without-confirm") + assert states.get("confirm_deploy") is None + assert states.get("approved") == "a519a341-dada-4a91-8910-7604f82b79c5" diff --git a/tests/test_stage_engine_phase_a_cta.py b/tests/test_stage_engine_phase_a_cta.py new file mode 100644 index 0000000..a448458 --- /dev/null +++ b/tests/test_stage_engine_phase_a_cta.py @@ -0,0 +1,101 @@ +"""ORCH-059 TC-09: the Phase A CTA asks the operator for "Confirm Deploy". + +Contract (AC-6): when Phase A advances `deploy-staging` -> `deploy` and requests +manual approval, both the Plane comment and the Telegram notification must +instruct the operator to flip the status to "Confirm Deploy" (the dedicated +prod-deploy trigger) — and must NOT present "Approved" as the deploy trigger. +""" + +import os +import tempfile + +import pytest + +_test_db = os.path.join(tempfile.gettempdir(), "test_orch_phase_a_cta.db") +os.environ["ORCH_DB_PATH"] = _test_db +os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir() +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") + +from unittest.mock import MagicMock # noqa: E402 + +import src.db as _db # noqa: E402 +from src.db import init_db, get_db # noqa: E402 +from src import stage_engine # noqa: E402 +from src import self_deploy # noqa: E402 +from src.stage_engine import advance_stage # noqa: E402 + + +def _pass(*a, **k): + return (True, "ok") + + +@pytest.fixture(autouse=True) +def fresh_db(monkeypatch, tmp_path): + monkeypatch.setattr(_db.settings, "db_path", _test_db) + if os.path.exists(_test_db): + os.unlink(_test_db) + init_db() + monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path)) + monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path)) + monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True) + # Pass the staging / merge / freshness sub-gates so the edge reaches Phase A. + monkeypatch.setattr( + stage_engine, "QG_CHECKS", + {**stage_engine.QG_CHECKS, + "check_staging_status": _pass, + "check_branch_mergeable": _pass, + "check_staging_image_fresh": _pass}, + ) + yield + + +def _make_task(stage="deploy-staging", repo="orchestrator", + branch="feature/ORCH-059-x", wi="ORCH-059"): + 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 test_tc09_phase_a_cta_requests_confirm_deploy(monkeypatch): + # Silence everything EXCEPT the two CTA channels we want to inspect. + for name in ( + "notify_stage_change", "notify_qg_failure", "plane_notify_stage", + "plane_notify_qg", "set_issue_in_review", "set_issue_needs_input", + "set_issue_in_progress", "set_issue_blocked", "set_issue_done", + ): + monkeypatch.setattr(stage_engine, name, MagicMock(), raising=False) + plane_comment = MagicMock() + telegram = MagicMock() + monkeypatch.setattr(stage_engine, "plane_add_comment", plane_comment) + monkeypatch.setattr(stage_engine, "send_telegram", telegram) + + task_id = _make_task() + res = advance_stage( + task_id, "deploy-staging", "orchestrator", "ORCH-059", + "feature/ORCH-059-x", finished_agent="deployer", + ) + + assert res.note == "self-deploy-approval-pending" + + # The Plane comment CTA mentions "Confirm Deploy" as the trigger. + plane_comment.assert_called_once() + comment_text = plane_comment.call_args.args[1] + assert "Confirm Deploy" in comment_text + # The Telegram CTA mentions "Confirm Deploy" too. + telegram.assert_called_once() + tg_text = telegram.call_args.args[0] + assert "Confirm Deploy" in tg_text + + # Neither CTA presents bare "Approved" as the deploy trigger. (The comment may + # mention Approved only to clarify it does NOT trigger; assert no instruction + # to "set status to Approved".) + assert "статус задачи на «Approved»" not in comment_text + assert "на Approved" not in tg_text diff --git a/tests/test_stage_engine_phase_b.py b/tests/test_stage_engine_phase_b.py new file mode 100644 index 0000000..2765536 --- /dev/null +++ b/tests/test_stage_engine_phase_b.py @@ -0,0 +1,141 @@ +"""ORCH-059 TC-07/08: the Phase B block in stage_engine.advance_stage initiates +the prod deploy ONLY on the confirm-deploy signal. + +Contract (AC-2, AC-3, AC-5): + * TC-07 — on (current_stage=="deploy", finished_agent is None) for the + self-hosting repo: confirm_deploy=True -> Phase B initiates; confirm_deploy + omitted/False (a plain Approved) -> a no-op that neither initiates the deploy + nor runs check_deploy_status (no false БАГ-8 rollback). + * TC-08 — idempotency: with the `initiated` marker already present, a repeated + confirm-deploy does NOT initiate again. +""" + +import os +import tempfile + +import pytest + +_test_db = os.path.join(tempfile.gettempdir(), "test_orch_phase_b.db") +os.environ["ORCH_DB_PATH"] = _test_db +os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir() +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") + +from unittest.mock import MagicMock # noqa: E402 + +import src.db as _db # noqa: E402 +from src.db import init_db, get_db # noqa: E402 +from src import stage_engine # noqa: E402 +from src import self_deploy # noqa: E402 +from src.stage_engine import advance_stage # noqa: E402 + + +@pytest.fixture(autouse=True) +def fresh_db(monkeypatch, tmp_path): + monkeypatch.setattr(_db.settings, "db_path", _test_db) + if os.path.exists(_test_db): + os.unlink(_test_db) + init_db() + monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path)) + monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path)) + monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True) + yield + + +@pytest.fixture(autouse=True) +def silence_side_effects(monkeypatch): + for name in ( + "notify_stage_change", "notify_qg_failure", "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(), raising=False) + + +def _make_task(stage, repo="orchestrator", branch="feature/ORCH-059-x", wi="ORCH-059"): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) " + "VALUES (?, ?, ?, ?, ?)", + (f"plane-{wi}", wi, repo, branch, stage), + ) + task_id = cur.lastrowid + conn.commit() + conn.close() + return task_id + + +def _stage(task_id): + conn = get_db() + row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone() + conn.close() + return row[0] + + +# --------------------------------------------------------------------------- +# TC-07: confirm-deploy initiates; plain Approved is a no-op +# --------------------------------------------------------------------------- +def test_tc07_confirm_deploy_initiates(monkeypatch): + initiate = MagicMock(return_value=(True, "ok")) + monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate) + + task_id = _make_task("deploy") + res = advance_stage( + task_id, "deploy", "orchestrator", "ORCH-059", + "feature/ORCH-059-x", finished_agent=None, confirm_deploy=True, + ) + + assert res.note == "self-deploy-initiated" + initiate.assert_called_once() + assert self_deploy.has_marker("orchestrator", "ORCH-059", self_deploy.INITIATED) + # Did NOT advance off deploy — the finalizer records the verdict later. + assert _stage(task_id) == "deploy" + + +def test_tc07_approved_without_confirm_is_noop(monkeypatch): + """A plain Approved on `deploy` (confirm_deploy defaults to False): no + initiate_deploy, no rollback, no advance — a deterministic no-op (AC-3).""" + initiate = MagicMock(return_value=(True, "ok")) + monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate) + # If check_deploy_status were (wrongly) run, it would intervene; spy to prove + # it is never invoked on this no-op path. + gate = MagicMock(return_value=(False, "FAILED")) + monkeypatch.setattr( + stage_engine, "QG_CHECKS", + {**stage_engine.QG_CHECKS, "check_deploy_status": gate}, + ) + + task_id = _make_task("deploy") + res = advance_stage( + task_id, "deploy", "orchestrator", "ORCH-059", + "feature/ORCH-059-x", finished_agent=None, # confirm_deploy omitted -> False + ) + + assert res.note == "approved-on-deploy-noop" + initiate.assert_not_called() + gate.assert_not_called() # check_deploy_status NOT run -> no false БАГ-8 + assert res.advanced is False + assert res.rolled_back_to is None + assert _stage(task_id) == "deploy" # stays put, no rollback to development + assert not self_deploy.has_marker("orchestrator", "ORCH-059", self_deploy.INITIATED) + + +# --------------------------------------------------------------------------- +# TC-08: idempotency — existing `initiated` marker -> repeat is a no-op +# --------------------------------------------------------------------------- +def test_tc08_idempotent_repeat_confirm_deploy(monkeypatch): + initiate = MagicMock(return_value=(True, "ok")) + monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate) + + task_id = _make_task("deploy") + # Pre-seed the initiated marker (a deploy already in flight). + self_deploy.write_marker("orchestrator", "ORCH-059", self_deploy.INITIATED, content="1") + + res = advance_stage( + task_id, "deploy", "orchestrator", "ORCH-059", + "feature/ORCH-059-x", finished_agent=None, confirm_deploy=True, + ) + + assert res.note == "self-deploy-already-initiated" + initiate.assert_not_called()