diff --git a/CHANGELOG.md b/CHANGELOG.md index 09dfba8..3b4d531 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ## [Unreleased] ### Added +- **Осмысленная статусная модель Plane (слой B — индикация)** (ORCH-066): Plane больше не показывает наблюдателю огрублённую/вводящую в заблуждение картину — статусы доски приведены к смыслу стадий конвейера, при этом статус остаётся **индикацией, а не управлением**. Архитектурный инвариант (ADR-001): меняется ТОЛЬКО слой B (отображение в Plane — `src/plane_sync.py` и точки выставления статуса в `stage_engine.py`/`webhooks/plane.py`/`reconciler.py`), слой A (машина стадий `src/stages.py::STAGE_TRANSITIONS`) остаётся **байт-в-байт неизменным** (AC-21, регресс-тест TC-22 сверяет полный литерал словаря). Целевая модель: `Backlog → Todo → [To Analyse] → Analysis → [In Review → Approved] → Architecture → Development → Code-Review → Testing → Awaiting Deploy → [Confirm Deploy] → Deploying → Monitoring after Deploy → Done`. Добавлены **6 новых логических ключей статуса** (`to_analyse`, `analysis`, `code_review`, `awaiting_deploy`, `deploying`, `monitoring`) в `_DEFAULT_STATES`/`_PLANE_NAME_TO_KEY` плюс `STAGE_VISIBILITY_STATE` (`analysis→analysis`, `review→code_review`) и `_STAGE_TO_STATE_KEY`; новые сеттеры `set_issue_analysis/code_review/awaiting_deploy/deploying/monitoring` + диспетчер `set_issue_stage_state`. **Project-relative alias-fallback (BR-12):** если оператор ещё не создал новый статус в конкретном Plane-проекте, ключ деградирует на базовый UUID **ТОГО ЖЕ** проекта (`_STATE_ALIAS_FALLBACK`: `analysis→in_progress`, `code_review→review`, `awaiting_deploy→in_review`, `deploying→in_progress`, `monitoring→done`, `to_analyse→in_progress`), поэтому PATCH остаётся валидным на частичных конфигах, а enduro-trails схлопывает новые ключи на старые базовые статусы → **нулевая регрессия**. **Самодеплой (ORCH-036) теперь индицирует фазы:** Phase A → `Awaiting Deploy` (ожидание ручного approve), Phase B → `Deploying`, terminal-sync `deploy→done` ветвится — для self-hosting (`post_deploy.post_deploy_applies(repo)`) issue входит в окно `Monitoring after Deploy` (НЕ терминальный Done), для прочих репо — прежний терминальный `Done` (нулевая регрессия, TC-08/TC-09). **Post-deploy монитор (ORCH-021)** на закрытии окна: HEALTHY → `set_issue_done`, DEGRADED → `set_issue_blocked` (только индикация; self-hosting остаётся ALERT_ONLY, прод НИКОГДА не рестартится/не откатывается — BR-5, TC-10/11/12). **Reconciler:** F-2 триггер старта/резюма расширен на `To Analyse` (TC-20), Guard 2 `_is_blocked_or_needs_input` учитывает новые активные ожидания (`awaiting_deploy/deploying/monitoring`) с вычитанием базовых рабочих статусов, чтобы алиасинг на частичных проектах не расширял skip-set (анти-регресс, TC-21). Контракт **never-raise** на всех сеттерах и резолвере состояний сохранён (API Plane недоступен → identity-фоллбэк, сеттеры не бросают — TC-16/17/18). **Раскатка** управляется оператором (создание 6 статусов в Plane), отдельного kill-switch не вводится — на «голом» Plane всё деградирует на прежнее поведение. Инварианты НЕ менялись (TC-22/TC-23): `STAGE_TRANSITIONS` (9 стадий), реестр `QG_CHECKS` (12 чеков), сигнатура `check_deploy_status(repo, work_item_id, branch)`, exit-код-контракт хука, merge-gate, схема БД (без миграций). ADR `docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md`. Тесты: `tests/test_plane_status_model.py`, `tests/test_plane_to_analyse_resume.py`, `tests/test_plane_status_failclosed.py`, `tests/test_plane_webhook.py` (TC-15), `tests/test_deploy_terminal_sync.py` (TC-08/09), `tests/test_post_deploy_integration.py` (TC-10/11/12), `tests/test_orch10_states.py` (TC-19), `tests/test_reconciler.py` (TC-21), `tests/test_reconciler_plane.py` (TC-20). - **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 63cf19e..c168733 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,9 @@ created → analysis → architecture → development → review → testing → └──── REQUEST_CHANGES ──────┘ (откат на development, max 3) ``` +## Статусная модель Plane (ORCH-066) — индикация ≠ управление +Статусы Plane — это **слой B (индикация)**, отдельный от **слоя A (машина стадий)** `src/stages.py::STAGE_TRANSITIONS`. Plane показывает наблюдателю осмысленную картину (`Backlog → Todo → Analysis → Architecture → Development → Code-Review → Testing → Awaiting Deploy → Deploying → Monitoring after Deploy → Done` + человеческие гейты `In Review/Approved`, `Confirm Deploy`), но НИКОГДА не управляет конвейером. Маппинг и сеттеры — `src/plane_sync.py` (6 новых ключей: `to_analyse/analysis/code_review/awaiting_deploy/deploying/monitoring`), с project-relative alias-fallback: на частично сконфигурированном проекте новый ключ деградирует на базовый UUID ТОГО ЖЕ проекта (нулевая регрессия для enduro-trails). Детали — `docs/architecture/README.md`. + ## Конвенции - Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`) - Ветки: `feature/ORCH-NNN-slug`, `fix/ORCH-NNN-slug` diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 220e428..db52614 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -249,7 +249,7 @@ ORCH-065 вводит фоновый watchdog, чтобы смерть проц Подробнее: [adr-0011](adr/adr-0011-job-reaper-lease-reclaim.md), детально — `docs/work-items/ORCH-065/06-adr/ADR-001-job-reaper-and-lease-reclaim.md`. -### Осмысленная статусная модель Plane (ORCH-066 — design) +### Осмысленная статусная модель Plane (ORCH-066 — реализовано) Plane-доска была семантически перегружена: `In Progress` означал «человек запускает конвейер», «идёт анализ», «идёт прод-деплой» и «возврат из Needs Input» одновременно. ORCH-066 наводит порядок по утверждённой Owner модели, меняя **только слой B** @@ -346,4 +346,4 @@ Monitoring after Deploy → Done Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.md). --- -*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке 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-066 (осмысленная статусная модель Plane — слой B, `docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md`) — design, ветка feature/ORCH-066-plane (только Plane-индикация: новые ключи `to_analyse`/`analysis`/`code_review`/`awaiting_deploy`/`deploying`/`monitoring` в `_PLANE_NAME_TO_KEY`/`_DEFAULT_STATES` + project-relative `_STATE_ALIAS_FALLBACK` в get_project_states + `_STAGE_TO_STATE_KEY` analysis/review + 5 новых `set_issue_*` в src/plane_sync.py; триггер `in_progress`→`to_analyse` и `set_issue_analysis` в src/webhooks/plane.py; Phase A→Awaiting Deploy / Phase B→Deploying / terminal-sync split monitoring↔done / post-deploy monitor HEALTHY→Done DEGRADED→Blocked в src/stage_engine.py; F-2 триггер `to_analyse` + Guard 2 skip-set с вычитанием base_working в src/reconciler.py; `STAGE_TRANSITIONS`/QG/схема БД НЕ трогаются; без kill-switch — раскат гейтится созданием 6 Plane-статусов оператором, `docs/work-items/ORCH-066/07-infra-requirements.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-066 (осмысленная статусная модель Plane — слой B, `docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md`) — реализовано в ветке feature/ORCH-066-plane (только Plane-индикация: новые ключи `to_analyse`/`analysis`/`code_review`/`awaiting_deploy`/`deploying`/`monitoring` в `_PLANE_NAME_TO_KEY`/`_DEFAULT_STATES` + project-relative `_STATE_ALIAS_FALLBACK` в get_project_states + `_STAGE_TO_STATE_KEY` analysis/review + 5 новых `set_issue_*` в src/plane_sync.py; триггер `in_progress`→`to_analyse` и `set_issue_analysis` в src/webhooks/plane.py; Phase A→Awaiting Deploy / Phase B→Deploying / terminal-sync split monitoring↔done / post-deploy monitor HEALTHY→Done DEGRADED→Blocked в src/stage_engine.py; F-2 триггер `to_analyse` + Guard 2 skip-set с вычитанием base_working в src/reconciler.py; `STAGE_TRANSITIONS`/QG/схема БД НЕ трогаются; без kill-switch — раскат гейтится созданием 6 Plane-статусов оператором, `docs/work-items/ORCH-066/07-infra-requirements.md`; обновлять при изменении этих мест).* diff --git a/src/plane_sync.py b/src/plane_sync.py index f6ed56f..8655d73 100644 --- a/src/plane_sync.py +++ b/src/plane_sync.py @@ -107,6 +107,19 @@ _DEFAULT_STATES = { # Feature 2 (verdict statuses) — Approved / Rejected. "approved": "a519a341-dada-4a91-8910-7604f82b79c5", "rejected": "ba958f3c-5db5-461d-8f82-89425e413b97", + # ORCH-066 (meaningful Plane status model, layer B): six new logical keys. + # Their _DEFAULT_STATES values alias the enduro-trails UUID of their BASE key + # (see _STATE_ALIAS_FALLBACK) so a project without these statuses created + # (enduro / Plane down / partial config) degrades to the current behaviour + # instead of producing an invalid PATCH state. The project-relative + # alias-fallback in get_project_states() overrides these with the *project's + # own* base UUID on the success path; these defaults are the last resort. + "to_analyse": "b873d9eb-993c-48cd-97ac-99a9b1623967", # = in_progress + "analysis": "b873d9eb-993c-48cd-97ac-99a9b1623967", # = in_progress + "code_review": "ba0d802c-5218-41d4-ab43-978b0ea123ed", # = review + "awaiting_deploy": "38fb1f64-aa1e-48a3-92e0-0b109679046b", # = in_review + "deploying": "b873d9eb-993c-48cd-97ac-99a9b1623967", # = in_progress + "monitoring": "381a2833-3c4e-4be5-bd0f-be84cb946ad8", # = done } # Backward-compat alias — do NOT remove (tests + webhooks/plane.py import it). @@ -128,6 +141,29 @@ _PLANE_NAME_TO_KEY: dict[str, str] = { "Needs Input": "needs_input", "In Review": "in_review", "Blocked": "blocked", + # ORCH-066: meaningful per-stage / human-input statuses (layer B). + "To Analyse": "to_analyse", + "Analysis": "analysis", + "Code-Review": "code_review", + "Awaiting Deploy": "awaiting_deploy", + "Deploying": "deploying", + "Monitoring after Deploy": "monitoring", +} + +# ORCH-066 (BR-12): project-relative alias-fallback for the new logical keys. +# After resolving states by name from the Plane API, any NEW key the project did +# not define degrades to the UUID of its BASE key **from the same project** — so +# the indication falls back to the current status and the PATCH stays valid even +# for a partially-configured project. Enduro (none of the new statuses created) +# collapses every new key onto its base, i.e. strictly the pre-ORCH-066 +# behaviour. Strengthened ORCH-059 AC-7 pattern. +_STATE_ALIAS_FALLBACK: dict[str, str] = { + "to_analyse": "in_progress", + "analysis": "in_progress", + "code_review": "review", + "awaiting_deploy": "in_review", + "deploying": "in_progress", + "monitoring": "done", } # Per-project state cache: {project_id: {logical_key: state_uuid}} @@ -175,6 +211,16 @@ def get_project_states(project_id: str) -> dict[str, str]: if not resolved: raise ValueError("no recognisable states in API response") + # ORCH-066 (BR-12): project-relative alias-fallback. For each NEW key the + # project did not define, reuse the UUID of its BASE key FROM THIS SAME + # PROJECT (never a foreign/enduro UUID — that would yield an invalid PATCH + # state on a partially-configured orchestrator project). Runs BEFORE the + # _DEFAULT_STATES.setdefault below so a project's own base UUID wins over + # the static enduro default. + for new_key, base_key in _STATE_ALIAS_FALLBACK.items(): + if new_key not in resolved and resolved.get(base_key): + resolved[new_key] = resolved[base_key] + # Fill any missing keys from _DEFAULT_STATES so callers always get a # complete mapping (defensive against partial Plane configs). for k, v in _DEFAULT_STATES.items(): @@ -210,14 +256,16 @@ def reload_project_states(project_id: str = None) -> None: # Feature 3: map an orchestrator stage -> the Plane status to show on the board -# when the pipeline ENTERS that stage. analysis stays driven by the existing -# in_progress/in_review/needs_input logic (no dedicated status). deploy keeps -# in_progress until done. Needs Input / In Review / Blocked remain higher -# priority and are set explicitly elsewhere — do NOT override them from here. +# when the pipeline ENTERS that stage. ORCH-066: analysis -> Analysis and +# review -> Code-Review now have dedicated statuses. deploy keeps in_progress +# until its own Phase A/B/C statuses drive it. Needs Input / In Review / Blocked +# remain higher priority and are set explicitly elsewhere — do NOT override them +# from here. STAGE_VISIBILITY_STATE = { + "analysis": "analysis", # ORCH-066: analysis stage -> Analysis status "architecture": "architecture", "development": "development", - "review": "review", + "review": "code_review", # ORCH-066: review stage -> Code-Review status "testing": "testing", } @@ -225,22 +273,27 @@ STAGE_VISIBILITY_STATE = { # update_issue_state now calls stage_to_state() instead of looking up here. STAGE_TO_STATE = { "created": _DEFAULT_STATES["todo"], - "analysis": _DEFAULT_STATES["in_progress"], + # ORCH-066: analysis -> Analysis, review -> Code-Review. The new keys alias + # the same in_progress / review UUIDs in _DEFAULT_STATES, so legacy callers / + # tests that compare against concrete UUIDs see byte-identical values. + "analysis": _DEFAULT_STATES["analysis"], "architecture": _DEFAULT_STATES["architecture"], "development": _DEFAULT_STATES["development"], - "review": _DEFAULT_STATES["review"], + "review": _DEFAULT_STATES["code_review"], "testing": _DEFAULT_STATES["testing"], "deploy": _DEFAULT_STATES["in_progress"], "done": _DEFAULT_STATES["done"], } # Map orchestrator stage -> logical state key (project-independent). +# ORCH-066: analysis -> analysis, review -> code_review (was in_progress/review). +# deploy stays in_progress (Phase A/B/C drive it directly, not update_issue_state). _STAGE_TO_STATE_KEY = { "created": "todo", - "analysis": "in_progress", + "analysis": "analysis", "architecture": "architecture", "development": "development", - "review": "review", + "review": "code_review", "testing": "testing", "deploy": "in_progress", "done": "done", @@ -575,6 +628,58 @@ def set_issue_in_progress(work_item_id: str, project_id: str = None): _set_issue_state_direct(work_item_id, state_id, project_id) +def set_issue_analysis(work_item_id: str, project_id: str = None): + """ORCH-066: set issue to 'Analysis' — analyst is working (start / resume). + + Degrades to the project's In Progress UUID when the 'Analysis' status is not + created (alias-fallback). never-raise (via _set_issue_state_direct). + """ + project_id = _resolve_project_id(work_item_id, project_id) + state_id = get_project_states(project_id)["analysis"] + _set_issue_state_direct(work_item_id, state_id, project_id) + + +def set_issue_code_review(work_item_id: str, project_id: str = None): + """ORCH-066: set issue to 'Code-Review' — review stage indication. + + Degrades to the project's Review UUID when 'Code-Review' is not created. + """ + project_id = _resolve_project_id(work_item_id, project_id) + state_id = get_project_states(project_id)["code_review"] + _set_issue_state_direct(work_item_id, state_id, project_id) + + +def set_issue_awaiting_deploy(work_item_id: str, project_id: str = None): + """ORCH-066: set issue to 'Awaiting Deploy' — self-deploy Phase A approval-pending. + + Degrades to the project's In Review UUID when 'Awaiting Deploy' is not created. + """ + project_id = _resolve_project_id(work_item_id, project_id) + state_id = get_project_states(project_id)["awaiting_deploy"] + _set_issue_state_direct(work_item_id, state_id, project_id) + + +def set_issue_deploying(work_item_id: str, project_id: str = None): + """ORCH-066: set issue to 'Deploying' — self-deploy Phase B prod deploy in flight. + + Degrades to the project's In Progress UUID when 'Deploying' is not created. + """ + project_id = _resolve_project_id(work_item_id, project_id) + state_id = get_project_states(project_id)["deploying"] + _set_issue_state_direct(work_item_id, state_id, project_id) + + +def set_issue_monitoring(work_item_id: str, project_id: str = None): + """ORCH-066: set issue to 'Monitoring after Deploy' — post-deploy window open. + + Degrades to the project's Done UUID when 'Monitoring after Deploy' is not + created (so the board shows Done, exactly as before ORCH-066). + """ + project_id = _resolve_project_id(work_item_id, project_id) + state_id = get_project_states(project_id)["monitoring"] + _set_issue_state_direct(work_item_id, state_id, project_id) + + def set_issue_stage_state(work_item_id: str, stage: str, project_id: str = None): """Feature 3: move the issue to the board status for a pipeline stage. diff --git a/src/reconciler.py b/src/reconciler.py index 6d65baa..4225703 100644 --- a/src/reconciler.py +++ b/src/reconciler.py @@ -193,12 +193,22 @@ class Reconciler: self._note_unblock(task.get("work_item_id") or str(task_id), stage) def _is_blocked_or_needs_input(self, task: dict) -> bool: - """ORCH-060 Guard 2: is this issue in an explicit human Plane gate? + """Guard 2 (ORCH-060 + ORCH-066): is this issue waiting for a human OR in + an active orchestrator wait that F-1 must not "revive"? Variant A (no schema migration): resolve the task's Plane project, fetch - the issue's current state uuid and compare against the project's - ``blocked`` / ``needs_input`` states. ``tasks`` has no status column, so - the live Plane state is the source of truth. + the issue's current state uuid and compare against a skip-set. ``tasks`` + has no status column, so the live Plane state is the source of truth. + + Skip-set = explicit human gates (``blocked`` / ``needs_input``) PLUS the + ORCH-066 active waits (``awaiting_deploy`` / ``deploying`` / ``monitoring``, + BR-13). **Anti-regress (CRITICAL):** the active-wait keys alias onto + ``in_review`` / ``in_progress`` / ``done`` on a project that did not create + them. Adding them verbatim would make F-1 wrongly skip enduro + In Progress / Done tasks (regression of ORCH-053/060). So they are + included ONLY when DISTINCT from the project's base working statuses + (i.e. actually created as separate statuses): enduro collapses them to {} + -> zero regress; orchestrator keeps three real statuses -> BR-13. **Never-raise, conservative fallback.** Any error / unresolved project / missing state -> return ``True`` (treat as "possibly blocked" -> skip): @@ -219,7 +229,22 @@ class Reconciler: cur = fetch_issue_state(issue_id, pid) if cur is None: return True # Plane unreachable / no state -> conservative skip - return cur in {states.get("blocked"), states.get("needs_input")} + # ORCH-066 BR-13: active orchestrator waits, minus base working + # statuses so aliased (enduro) keys never widen the skip-set. + base_working = { + states.get(k) for k in ( + "backlog", "todo", "in_progress", "in_review", "review", + "architecture", "development", "testing", + "approved", "rejected", "done", + ) + } + extra_waits = { + states.get("awaiting_deploy"), + states.get("deploying"), + states.get("monitoring"), + } - base_working - {None} + skip_set = {states.get("blocked"), states.get("needs_input")} | extra_waits + return cur in skip_set except Exception as e: # noqa: BLE001 - never break the tick logger.warning( f"reconciler Guard 2: blocked-check failed for task " @@ -241,15 +266,19 @@ class Reconciler: def _reconcile_plane_project(self, proj) -> None: pid = proj.plane_project_id # Resolve the actionable state uuids per-project (never hardcode). + # ORCH-066 (AC-19): the start/resume trigger is `To Analyse` (was + # In Progress). On a project without that status, `to_analyse` aliases to + # the project's own `in_progress` UUID, so enduro behaviour is identical + # (and `list_issues_by_state` deduplicates the uuid via its internal set). states = get_project_states(pid) - in_progress = states["in_progress"] + to_analyse = states["to_analyse"] approved = states["approved"] rejected = states["rejected"] - issues = list_issues_by_state(pid, [in_progress, approved, rejected]) + issues = list_issues_by_state(pid, [to_analyse, approved, rejected]) for issue in issues: try: self._reconcile_plane_issue( - issue, pid, in_progress, approved, rejected + issue, pid, to_analyse, approved, rejected ) except Exception as e: # noqa: BLE001 - isolate one issue's failure logger.error( @@ -258,7 +287,7 @@ class Reconciler: def _reconcile_plane_issue( self, issue: dict, project_id: str, - in_progress: str, approved: str, rejected: str, + to_analyse: str, approved: str, rejected: str, ) -> None: issue_id = str(issue.get("id") or "") if not issue_id: @@ -288,10 +317,16 @@ class Reconciler: "description_stripped": issue.get("description_stripped", ""), } - if new_state == in_progress and task is None: - # In Progress without a task -> start the pipeline (lost start webhook). + if new_state == to_analyse and task is None: + # To Analyse without a task -> start the pipeline (lost start webhook). self._dispatch(handle_status_start, issue_data, project_id) self._note_unblock(issue_id, "analysis") + elif new_state == to_analyse and task is not None: + # To Analyse with an existing (idle) task -> resume the analyst from + # Needs Input (lost resume webhook). handle_status_start applies its + # own busy-guard / start-vs-resume fork. + self._dispatch(handle_status_start, issue_data, project_id) + self._note_unblock(task.get("work_item_id") or issue_id, task["stage"]) elif new_state == approved and task is not None: # Approved but the stage never advanced -> replay the verdict. self._dispatch(handle_verdict, issue_data, project_id, approved=True) diff --git a/src/stage_engine.py b/src/stage_engine.py index df84ca5..2de7a31 100644 --- a/src/stage_engine.py +++ b/src/stage_engine.py @@ -53,6 +53,10 @@ from .plane_sync import ( set_issue_in_progress, set_issue_blocked, set_issue_done, + set_issue_analysis, + set_issue_awaiting_deploy, + set_issue_deploying, + set_issue_monitoring, ) from .config import settings @@ -335,14 +339,28 @@ def advance_stage( # here, so explicitly drive the Plane issue into the terminal Done state # (PLANE_STATES['done'] — mapping unchanged) in addition to the # stage-change comment above. + # ORCH-066 (AC-8/AC-9): split terminal-sync by whether post-deploy + # monitoring applies. For self-hosting (post_deploy_applies==True) the + # task enters a `Monitoring after Deploy` window, NOT terminal Done yet — + # the monitor finalises Done/Blocked (run_post_deploy_monitor). For + # non-self repos the behaviour is unchanged: terminal Done immediately. + # Where the `Monitoring after Deploy` status is absent, set_issue_monitoring + # degrades to the project's Done UUID -> identical to today. if next_stage == "done" and work_item_id: try: - set_issue_done(work_item_id) - logger.info( - f"Task {task_id}: deploy->done, Plane state forced to Done" - ) + if post_deploy.post_deploy_applies(repo): + set_issue_monitoring(work_item_id) + logger.info( + f"Task {task_id}: deploy->done (self), Plane state -> " + f"Monitoring after Deploy (post-deploy window)" + ) + else: + set_issue_done(work_item_id) + logger.info( + f"Task {task_id}: deploy->done, Plane state forced to Done" + ) except Exception as e: - logger.error(f"Task {task_id}: failed to set Plane Done: {e}") + logger.error(f"Task {task_id}: failed to set Plane terminal state: {e}") # ORCH-043: the merge has landed (deploy->done). Release the merge lease as # a backstop in case the PR-merged webhook was lost (holder-aware no-op if a @@ -666,7 +684,9 @@ def _handle_qg_failure_rollbacks( notify_stage_change(task_id, current_stage, "analysis") plane_notify_stage(work_item_id, current_stage, "analysis") result.rolled_back_to = "analysis" - set_issue_in_progress(work_item_id) + # ORCH-066 (AC-3): rolled back to analysis -> indicate `Analysis` + # (degrades to In Progress where the status is not created). + set_issue_analysis(work_item_id) with open(conflict_path, "r") as cf: conflict_text = cf.read()[:500] plane_add_comment( @@ -1009,7 +1029,11 @@ def _handle_self_deploy_phase_a( result.note = "self-deploy-approval-pending" if work_item_id: - set_issue_in_review(work_item_id) + # ORCH-066 (AC-6/AC-13): Phase A approval-pending is now `Awaiting Deploy`, + # which discharges `In Review` of the deploy-approval meaning (In Review + # stays for analyst BRD/review approve-pending only). Degrades to In Review + # where the status is not created. + set_issue_awaiting_deploy(work_item_id) # ORCH-036: belt-and-suspenders — wipe any STALE deploy-state markers before # arming a fresh approve. A prior FAILED pass clears on rollback, but clearing # here too guarantees the entry to every new prod-deploy pass starts clean @@ -1069,6 +1093,10 @@ def _handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result: Adv self_deploy.write_marker( repo, work_item_id, self_deploy.INITIATED, content=str(time.time()) ) + # ORCH-066 (AC-7): the prod deploy is now in flight -> indicate `Deploying` + # (degrades to In Progress where the status is not created). + if work_item_id: + set_issue_deploying(work_item_id) task_desc = ( f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n" f"Stage: deploy\nNote: deploy-finalize poll (prod self-deploy initiated)." @@ -1263,6 +1291,12 @@ def run_post_deploy_monitor(job: dict): settings.post_deploy_window_s, checks_total, checks_failed, ) post_deploy.mark_done(repo, work_item_id) + # ORCH-066 (AC-10): the post-deploy window closed clean -> terminal Done. + if work_item_id: + try: + set_issue_done(work_item_id) + except Exception as e: # noqa: BLE001 - never break the tick + logger.warning(f"post-deploy: set Done failed for {work_item_id}: {e}") _notify_post_deploy( work_item_id, f"✅ {work_item_id}: пост-деплой окно завершено чисто " @@ -1303,6 +1337,15 @@ def run_post_deploy_monitor(job: dict): f"self-hosting запрещён (BR-5).", ) + # ORCH-066 (AC-11/AC-12): a confirmed degradation -> indicate `Blocked` for + # manual intervention. This is INDICATION ONLY — the tick NEVER restarts / + # rolls back the prod container (self-hosting stays ALERT_ONLY, BR-5). + if work_item_id: + try: + set_issue_blocked(work_item_id) + except Exception as e: # noqa: BLE001 - never break the tick + logger.warning(f"post-deploy: set Blocked failed for {work_item_id}: {e}") + post_deploy.write_post_deploy_log( repo, work_item_id, branch, post_deploy.DEGRADED, action_taken, settings.post_deploy_window_s, checks_total, checks_failed, diff --git a/src/webhooks/plane.py b/src/webhooks/plane.py index b14ab3b..126ffca 100644 --- a/src/webhooks/plane.py +++ b/src/webhooks/plane.py @@ -147,10 +147,15 @@ async def handle_issue_updated(data: dict, project_id: str = ""): return # ORCH-10: resolve expected state UUIDs per the incoming issue's project so - # both enduro (b873d9eb) and orchestrator (e331bfb3) In Progress trigger the + # both enduro (b873d9eb) and orchestrator (e331bfb3) statuses trigger the # pipeline. Using PLANE_STATES["in_progress"] here was the root-cause blocker. + # ORCH-066: the start/resume trigger is now `To Analyse` (human entry-point), + # which discharges `In Progress` of its overloaded "start the pipeline" + # meaning. Fail-closed: on a project without the `To Analyse` status, + # `to_analyse` aliases to the project's own `in_progress` UUID, so moving an + # enduro issue to In Progress still triggers start/resume (AC-17). proj_states = get_project_states(project_id) - if new_state == proj_states["in_progress"]: + if new_state == proj_states["to_analyse"]: await handle_status_start(data, project_id) elif new_state == proj_states["approved"]: await handle_verdict(data, project_id, approved=True) @@ -235,9 +240,14 @@ async def handle_status_start(data: dict, project_id: str = ""): ) job_id = enqueue_job(stage_agent, repo, task_desc, task_id=task_id) logger.info( - f"Task {task_id}: returned to In Progress (Needs Input answered), " + f"Task {task_id}: returned to To Analyse (Needs Input answered), " f"relaunched {stage_agent} for stage {current_stage} (job_id={job_id})" ) + # ORCH-066 (AC-3): a resume of the analyst (the only Needs-Input owner) is + # re-indicated as `Analysis`; other stages keep their own indication. + if current_stage == "analysis": + from ..plane_sync import set_issue_analysis as _set_analysis + _set_analysis(work_item_id) try: _add_comment( work_item_id, @@ -538,6 +548,10 @@ async def start_pipeline(data: dict, project_id: str = ""): ) job_id = enqueue_job("analyst", repo, task_desc, task_id=task_id) logger.info(f"Task {task_id}: enqueued analyst (job_id={job_id})") + # ORCH-066 (AC-3): indicate the analysis stage with the dedicated + # `Analysis` status (degrades to In Progress where it is not created). + from ..plane_sync import set_issue_analysis as _set_analysis + _set_analysis(work_item_id, plane_project_id) # Post start comment to Plane from ..plane_sync import add_comment as _add_comment _add_comment(work_item_id, "\U0001f50d Analyst \u0437\u0430\u043f\u0443\u0449\u0435\u043d. BRD/\u0422\u0417/AC/TestPlan \u0432 \u0440\u0430\u0431\u043e\u0442\u0435 (\u043e\u0436\u0438\u0434\u0430\u0439\u0442\u0435 8-15 \u043c\u0438\u043d).", author="analyst") @@ -579,9 +593,11 @@ async def _rollback_stage( (via the existing rollback notify + an enqueue of the prev-stage agent). """ if current_stage == "analysis": - # Already in analysis — just relaunch analyst with rejection reason - from ..plane_sync import set_issue_in_progress - set_issue_in_progress(work_item_id) + # Already in analysis — just relaunch analyst with rejection reason. + # ORCH-066 (AC-3): indicate `Analysis` (degrades to In Progress where the + # status is not created). + from ..plane_sync import set_issue_analysis + set_issue_analysis(work_item_id) task_desc = ( f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n" f"Stage: analysis\nNote: Stakeholder REJECTED your artifacts. " diff --git a/tests/test_deploy_approve.py b/tests/test_deploy_approve.py index 146a8e4..fd0bd21 100644 --- a/tests/test_deploy_approve.py +++ b/tests/test_deploy_approve.py @@ -48,6 +48,9 @@ def silence_side_effects(monkeypatch): "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", + # ORCH-066 status setters. + "set_issue_analysis", "set_issue_awaiting_deploy", "set_issue_deploying", + "set_issue_monitoring", ): monkeypatch.setattr(stage_engine, name, MagicMock()) @@ -127,6 +130,9 @@ def test_tc05_no_approve_does_not_call_prod_hook(monkeypatch): assert _jobs() == [] # The restart-safe approve-requested marker was written. assert self_deploy.has_marker("orchestrator", "ORCH-036", self_deploy.APPROVE_REQUESTED) + # ORCH-066 AC-6/AC-13: Phase A indicates `Awaiting Deploy`, NOT `In Review`. + stage_engine.set_issue_awaiting_deploy.assert_called_once_with("ORCH-036") + stage_engine.set_issue_in_review.assert_not_called() # --------------------------------------------------------------------------- @@ -151,6 +157,8 @@ def test_tc06_approved_calls_prod_hook_exactly_once(monkeypatch): # The finalizer was enqueued. assert any(j["agent"] == "deploy-finalizer" for j in _jobs()) assert self_deploy.has_marker("orchestrator", "ORCH-036", self_deploy.INITIATED) + # ORCH-066 AC-7: Phase B indicates `Deploying` on a successful initiate. + stage_engine.set_issue_deploying.assert_called_once_with("ORCH-036") # 2nd (duplicate) Approved -> idempotent no-op, hook NOT called again. res2 = advance_stage( diff --git a/tests/test_deploy_terminal_sync.py b/tests/test_deploy_terminal_sync.py index d7b9b5e..c417cef 100644 --- a/tests/test_deploy_terminal_sync.py +++ b/tests/test_deploy_terminal_sync.py @@ -45,6 +45,9 @@ def silence_side_effects(monkeypatch): "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", + # ORCH-066 status setters. + "set_issue_analysis", "set_issue_awaiting_deploy", "set_issue_deploying", + "set_issue_monitoring", ): monkeypatch.setattr(stage_engine, name, MagicMock()) @@ -106,3 +109,56 @@ def test_tc17_success_deploy_syncs_terminal_done(monkeypatch): release.assert_called_once_with("orchestrator", "feature/ORCH-036-x") # No agent is launched leaving deploy (terminal). assert _jobs() == [] + + +# --------------------------------------------------------------------------- +# ORCH-066 TC-08 (AC-8): self-hosting deploy->done -> Monitoring after Deploy, +# NOT terminal Done. The post-deploy monitor finalises. +# --------------------------------------------------------------------------- +def test_tc08_self_deploy_done_sets_monitoring_not_done(monkeypatch): + self_deploy.write_marker("orchestrator", "ORCH-036", self_deploy.RESULT, "0") + monkeypatch.setattr( + stage_engine, "QG_CHECKS", + {**stage_engine.QG_CHECKS, "check_deploy_status": _pass}, + ) + monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", MagicMock()) + # post_deploy applies for the self-hosting repo with the monitor enabled. + monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_monitor_enabled", True) + monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_repos", "") + # arm_monitor is orthogonal; stub it so this test stays on the status contract. + monkeypatch.setattr(stage_engine.post_deploy, "arm_monitor", MagicMock(return_value=True)) + + task_id = _make_task("deploy") + stage_engine.run_deploy_finalizer( + {"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "deploy-finalizer"} + ) + + assert _stage(task_id) == "done" + # Self-hosting: the issue enters the Monitoring window, NOT terminal Done yet. + stage_engine.set_issue_monitoring.assert_called_once_with("ORCH-036") + stage_engine.set_issue_done.assert_not_called() + + +# --------------------------------------------------------------------------- +# ORCH-066 TC-09 (AC-9): non-self repo deploy->done -> terminal Done (no regress). +# --------------------------------------------------------------------------- +def test_tc09_non_self_deploy_done_sets_done(monkeypatch): + self_deploy.write_marker("enduro-trails", "ET-042", self_deploy.RESULT, "0") + monkeypatch.setattr( + stage_engine, "QG_CHECKS", + {**stage_engine.QG_CHECKS, "check_deploy_status": _pass}, + ) + monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", MagicMock()) + # Monitor enabled, but the empty CSV means it applies ONLY to the self repo; + # a non-self repo therefore takes the unchanged terminal-Done path. + monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_monitor_enabled", True) + monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_repos", "") + + task_id = _make_task("deploy", repo="enduro-trails", branch="feature/ET-042-x", wi="ET-042") + stage_engine.run_deploy_finalizer( + {"task_id": task_id, "repo": "enduro-trails", "id": 1, "agent": "deploy-finalizer"} + ) + + assert _stage(task_id) == "done" + stage_engine.set_issue_done.assert_called_once_with("ET-042") + stage_engine.set_issue_monitoring.assert_not_called() diff --git a/tests/test_m6_sequence.py b/tests/test_m6_sequence.py index 733a267..13b80cb 100644 --- a/tests/test_m6_sequence.py +++ b/tests/test_m6_sequence.py @@ -40,11 +40,15 @@ ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c" _PROJECT_STATES = { ENDURO_PLANE_ID: { "in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967", + # ORCH-066: To Analyse is the start trigger; with the status absent it + # aliases to in_progress (the real get_project_states fallback). + "to_analyse": "b873d9eb-993c-48cd-97ac-99a9b1623967", "approved": "a519a341-dada-4a91-8910-7604f82b79c5", "rejected": "ba958f3c-5db5-461d-8f82-89425e413b97", }, ORCH_PLANE_ID: { "in_progress": "e331bfb3-e17e-4699-ba48-4abb89c21b7b", + "to_analyse": "e331bfb3-e17e-4699-ba48-4abb89c21b7b", "approved": "63f2c8fe-dcda-4ace-952f-dd88bd0118ff", "rejected": "4c769e90-bf80-4a52-b97a-e1c84904bfc3", }, diff --git a/tests/test_orch10_states.py b/tests/test_orch10_states.py index c753fd5..292e48a 100644 --- a/tests/test_orch10_states.py +++ b/tests/test_orch10_states.py @@ -460,3 +460,59 @@ def test_default_states_et_values(): assert ps._DEFAULT_STATES[key] == expected, ( f"_DEFAULT_STATES['{key}']: expected {expected}, got {ps._DEFAULT_STATES.get(key)}" ) + + +# --------------------------------------------------------------------------- +# ORCH-066 TC-19 (AC-18): resolve-by-name — when a project DEFINES one of the +# new statuses, get_project_states must use its OWN UUID, not the default alias. +# --------------------------------------------------------------------------- +def test_orch066_tc19_name_resolution_beats_alias(): + """A project that created 'Analysis' / 'Code-Review' / 'Awaiting Deploy' / + 'Deploying' / 'Monitoring after Deploy' resolves each to its own project + UUID (via _PLANE_NAME_TO_KEY), NOT the aliased base-key UUID.""" + import src.plane_sync as ps + + new_uuids = { + "Analysis": "11111111-0000-0000-0000-000000000001", + "Code-Review": "11111111-0000-0000-0000-000000000002", + "Awaiting Deploy": "11111111-0000-0000-0000-000000000003", + "Deploying": "11111111-0000-0000-0000-000000000004", + "Monitoring after Deploy": "11111111-0000-0000-0000-000000000005", + "To Analyse": "11111111-0000-0000-0000-000000000006", + } + # Start from the full ORCH base set, then add the dedicated new statuses. + results = _make_states_response(ORCH_STATES)["results"] + results += [{"id": uid, "name": name} for name, uid in new_uuids.items()] + + with patch("src.plane_sync.httpx.get") as mock_get: + mock_get.return_value = _fake_response({"results": results}) + states = ps.get_project_states(ORCH_PROJECT_ID) + + # Each new key resolved to the project's OWN UUID, not the base-key alias. + assert states["analysis"] == new_uuids["Analysis"] + assert states["code_review"] == new_uuids["Code-Review"] + assert states["awaiting_deploy"] == new_uuids["Awaiting Deploy"] + assert states["deploying"] == new_uuids["Deploying"] + assert states["monitoring"] == new_uuids["Monitoring after Deploy"] + assert states["to_analyse"] == new_uuids["To Analyse"] + # Sanity: they are NOT the aliased base UUIDs. + assert states["analysis"] != states["in_progress"] + assert states["code_review"] != states["review"] + assert states["awaiting_deploy"] != states["in_review"] + + +def test_orch066_tc19_missing_new_status_aliases_to_project_base(): + """BR-12: a project WITHOUT the new statuses degrades each new key to its OWN + base UUID (not a foreign enduro UUID) — keeping the PATCH state valid.""" + import src.plane_sync as ps + with patch("src.plane_sync.httpx.get") as mock_get: + mock_get.return_value = _fake_response(_make_states_response(ORCH_STATES)) + states = ps.get_project_states(ORCH_PROJECT_ID) + + # No dedicated new statuses -> alias to THIS project's base UUIDs. + assert states["analysis"] == ORCH_STATES["in_progress"] + assert states["to_analyse"] == ORCH_STATES["in_progress"] + assert states["code_review"] == ORCH_STATES["review"] + assert states["awaiting_deploy"] == ORCH_STATES["in_review"] + assert states["deploying"] == ORCH_STATES["in_progress"] + assert states["monitoring"] == ORCH_STATES["done"] diff --git a/tests/test_plane_status_failclosed.py b/tests/test_plane_status_failclosed.py new file mode 100644 index 0000000..14ad2e3 --- /dev/null +++ b/tests/test_plane_status_failclosed.py @@ -0,0 +1,131 @@ +"""ORCH-066 fail-closed (CRITICAL) — the new status model must never wedge the +pipeline when the 6 Plane statuses are absent or Plane is unreachable. + + * TC-16 (AC-16, BR-12) — a project WITHOUT the new statuses resolves each new + logical key to its OWN base UUID (to_analyse=in_progress, code_review=review, + awaiting_deploy=in_review, monitoring=done); no exception. + * TC-17 (AC-16) — Plane API down -> get_project_states falls back to + _DEFAULT_STATES; every set_issue_* helper is never-raise. + * TC-18 (AC-17) — enduro In Progress STILL starts the pipeline through + the to_analyse alias (= in_progress UUID). + +httpx is mocked; no network. +""" + +import os + +os.environ.setdefault("ORCH_PLANE_API_URL", "http://plane.local") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_WORKSPACE_SLUG", "test-ws") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") + +from unittest.mock import patch, MagicMock, AsyncMock # noqa: E402 + +import pytest # noqa: E402 + +from src import plane_sync as PS # noqa: E402 + +ENDURO_PROJECT_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c" + +# An enduro-style states response: the 6 ORCH-066 statuses are NOT created. +_ENDURO_BASE = { + "Backlog": "backlog-u", "Todo": "todo-u", "In Progress": "ip-u", + "Review": "review-u", "In Review": "inrev-u", "Approved": "appr-u", + "Rejected": "rej-u", "Done": "done-u", "Needs Input": "ni-u", + "Blocked": "blk-u", +} + + +def _states_response(name_to_uuid): + return {"results": [{"id": uid, "name": name} for name, uid in name_to_uuid.items()]} + + +def _fake_resp(data, status=200): + m = MagicMock() + m.status_code = status + m.json.return_value = data + m.raise_for_status.return_value = None + return m + + +@pytest.fixture(autouse=True) +def _reset_cache(): + PS.reload_project_states() + yield + PS.reload_project_states() + + +# --------------------------------------------------------------------------- +# TC-16 (AC-16 / BR-12): partial project -> alias to its own base UUIDs, no raise. +# --------------------------------------------------------------------------- +def test_tc16_partial_project_aliases_to_base_uuids(): + with patch("src.plane_sync.httpx.get") as mock_get: + mock_get.return_value = _fake_resp(_states_response(_ENDURO_BASE)) + states = PS.get_project_states(ENDURO_PROJECT_ID) + + # The new keys degrade to THIS project's base UUIDs (not foreign defaults). + assert states["to_analyse"] == states["in_progress"] == "ip-u" + assert states["analysis"] == "ip-u" + assert states["code_review"] == states["review"] == "review-u" + assert states["awaiting_deploy"] == states["in_review"] == "inrev-u" + assert states["deploying"] == "ip-u" + assert states["monitoring"] == states["done"] == "done-u" + + +# --------------------------------------------------------------------------- +# TC-17 (AC-16): Plane API down -> _DEFAULT_STATES; set_issue_* never-raise. +# --------------------------------------------------------------------------- +def test_tc17_api_down_falls_back_to_defaults(): + with patch("src.plane_sync.httpx.get", side_effect=Exception("plane down")): + states = PS.get_project_states(ENDURO_PROJECT_ID) + assert states is PS._DEFAULT_STATES + # All new keys exist in the defaults (so callers never KeyError). + for k in ("to_analyse", "analysis", "code_review", "awaiting_deploy", + "deploying", "monitoring"): + assert k in states + + +def test_tc17_set_issue_helpers_never_raise_when_issue_missing(): + # find_issue_id returns None (issue not in Plane) -> helpers log + return, + # they must NOT raise. Covers every ORCH-066 setter. + setters = [ + PS.set_issue_analysis, PS.set_issue_code_review, + PS.set_issue_awaiting_deploy, PS.set_issue_deploying, + PS.set_issue_monitoring, + ] + with patch("src.plane_sync._resolve_project_id", return_value="proj-1"), \ + patch("src.plane_sync.get_project_states", return_value=PS._DEFAULT_STATES), \ + patch("src.plane_sync.find_issue_id", return_value=None), \ + patch("src.plane_sync.httpx.patch") as mock_patch: + for setter in setters: + setter("ET-1") # must not raise + # No PATCH issued because the issue could not be resolved. + mock_patch.assert_not_called() + + +def test_tc17_set_issue_helpers_never_raise_when_patch_errors(): + # The PATCH itself blows up -> _set_issue_state_direct swallows it. + with patch("src.plane_sync._resolve_project_id", return_value="proj-1"), \ + patch("src.plane_sync.get_project_states", return_value=PS._DEFAULT_STATES), \ + patch("src.plane_sync.find_issue_id", return_value="issue-uuid"), \ + patch("src.plane_sync.httpx.patch", side_effect=Exception("boom")): + PS.set_issue_monitoring("ET-1") # must not raise + + +# --------------------------------------------------------------------------- +# TC-18 (AC-17): enduro In Progress still starts the pipeline via to_analyse alias. +# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_tc18_enduro_in_progress_still_starts_via_alias(): + from src.webhooks.plane import handle_issue_updated + + with patch("src.plane_sync.httpx.get") as mock_get, \ + patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) as mock_start, \ + patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) as mock_verdict: + mock_get.return_value = _fake_resp(_states_response(_ENDURO_BASE)) + # enduro never created 'To Analyse' -> to_analyse aliases In Progress (ip-u). + data = {"id": "et-issue", "state": {"id": "ip-u", "name": "In Progress"}} + await handle_issue_updated(data, ENDURO_PROJECT_ID) + + mock_start.assert_called_once() + mock_verdict.assert_not_called() diff --git a/tests/test_plane_status_model.py b/tests/test_plane_status_model.py new file mode 100644 index 0000000..a6bc81e --- /dev/null +++ b/tests/test_plane_status_model.py @@ -0,0 +1,151 @@ +"""ORCH-066: the meaningful Plane status model (layer B) — unit coverage. + +These tests pin the layer-B behaviour WITHOUT touching layer A (the stage +machine). httpx is mocked; no network. + + * TC-03 (AC-3) — the analyst start/resume indicates `Analysis`, not In Progress. + * TC-05 (AC-5) — entering the `review` stage indicates `Code-Review`. + * TC-14 (AC-14) — set_issue_needs_input is unchanged (still PATCHes Needs Input). + * TC-22 (AC-21) — STAGE_TRANSITIONS (layer A) is byte-identical (explicit pin). + * TC-23 (AC-22) — QG_CHECKS registry + check_deploy_status contract unchanged. +""" + +import os + +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") + +from unittest.mock import patch, MagicMock # noqa: E402 + +from src import plane_sync as PS # noqa: E402 + + +# A per-project state map that DEFINES the new ORCH-066 statuses with distinct +# UUIDs, so we can prove the dedicated status (not the base alias) is used. +_STATES_WITH_NEW = { + "in_progress": "ip-uuid", + "review": "review-uuid", + "in_review": "inrev-uuid", + "needs_input": "ni-uuid", + "done": "done-uuid", + "analysis": "analysis-uuid", + "code_review": "codereview-uuid", + "awaiting_deploy": "awaiting-uuid", + "deploying": "deploying-uuid", + "monitoring": "monitoring-uuid", +} + + +def _patch_resolve(states): + """Patch find_issue_id + _resolve_project_id + get_project_states so a + set_issue_* helper reaches the PATCH with a known per-project state map.""" + return ( + patch("src.plane_sync.httpx.patch"), + patch("src.plane_sync.find_issue_id", return_value="issue-uuid"), + patch("src.plane_sync._resolve_project_id", return_value="proj-1"), + patch("src.plane_sync.get_project_states", return_value=states), + ) + + +def _run_setter(setter, states): + p_patch, p_find, p_res, p_states = _patch_resolve(states) + with p_patch as mock_patch, p_find, p_res, p_states: + resp = MagicMock() + resp.raise_for_status.return_value = None + mock_patch.return_value = resp + setter("ET-1") + return mock_patch + + +# --------------------------------------------------------------------------- +# TC-03 (AC-3): analyst start/resume indicates Analysis. +# --------------------------------------------------------------------------- +def test_tc03_set_issue_analysis_patches_analysis_uuid(): + mock_patch = _run_setter(PS.set_issue_analysis, _STATES_WITH_NEW) + # The dedicated Analysis UUID is used (NOT the in_progress base alias). + assert mock_patch.call_args.kwargs["json"]["state"] == "analysis-uuid" + assert mock_patch.call_args.kwargs["json"]["state"] != _STATES_WITH_NEW["in_progress"] + + +def test_tc03_analysis_aliases_in_progress_when_absent(): + # A project without the Analysis status -> get_project_states already aliased + # 'analysis' onto its in_progress UUID, so the PATCH degrades gracefully. + aliased = dict(_STATES_WITH_NEW) + aliased["analysis"] = aliased["in_progress"] + mock_patch = _run_setter(PS.set_issue_analysis, aliased) + assert mock_patch.call_args.kwargs["json"]["state"] == aliased["in_progress"] + + +# --------------------------------------------------------------------------- +# TC-05 (AC-5): the review stage indicates Code-Review. +# --------------------------------------------------------------------------- +def test_tc05_review_stage_maps_to_code_review(): + # Both the stage->state-key map and the stage-visibility map point review at + # the new code_review logical key (layer B only). + assert PS._STAGE_TO_STATE_KEY["review"] == "code_review" + assert PS.STAGE_VISIBILITY_STATE["review"] == "code_review" + + +def test_tc05_set_issue_stage_state_review_patches_code_review_uuid(): + p_patch, p_find, p_res, p_states = _patch_resolve(_STATES_WITH_NEW) + with p_patch as mock_patch, p_find, p_res, p_states: + resp = MagicMock() + resp.raise_for_status.return_value = None + mock_patch.return_value = resp + PS.set_issue_stage_state("ET-1", "review") + assert mock_patch.call_args.kwargs["json"]["state"] == "codereview-uuid" + + +def test_tc05_set_issue_code_review_helper_patches_code_review_uuid(): + mock_patch = _run_setter(PS.set_issue_code_review, _STATES_WITH_NEW) + assert mock_patch.call_args.kwargs["json"]["state"] == "codereview-uuid" + + +# --------------------------------------------------------------------------- +# TC-14 (AC-14): Needs Input behaviour unchanged. +# --------------------------------------------------------------------------- +def test_tc14_needs_input_unchanged(): + mock_patch = _run_setter(PS.set_issue_needs_input, _STATES_WITH_NEW) + assert mock_patch.call_args.kwargs["json"]["state"] == "ni-uuid" + + +# --------------------------------------------------------------------------- +# TC-22 (AC-21): STAGE_TRANSITIONS (layer A) is byte-identical. ORCH-066 changes +# ONLY layer B — the machine must not move. +# --------------------------------------------------------------------------- +def test_tc22_stage_transitions_unchanged(): + from src.stages import STAGE_TRANSITIONS + assert STAGE_TRANSITIONS == { + "created": {"next": "analysis", "agent": "analyst", "qg": None}, + "analysis": {"next": "architecture", "agent": "architect", "qg": "check_analysis_approved"}, + "architecture": {"next": "development", "agent": "developer", "qg": "check_architecture_done"}, + "development": {"next": "review", "agent": "reviewer", "qg": "check_ci_green"}, + "review": {"next": "testing", "agent": "tester", "qg": "check_reviewer_verdict"}, + "testing": {"next": "deploy-staging", "agent": "deployer", "qg": "check_tests_passed"}, + "deploy-staging": {"next": "deploy", "agent": "deployer", "qg": "check_staging_status"}, + "deploy": {"next": "done", "agent": None, "qg": "check_deploy_status"}, + "done": {"next": None, "agent": None, "qg": None}, + } + + +# --------------------------------------------------------------------------- +# TC-23 (AC-22): QG_CHECKS registry + check_deploy_status contract unchanged. +# --------------------------------------------------------------------------- +def test_tc23_qg_checks_registry_unchanged(): + from src.qg.checks import QG_CHECKS + assert set(QG_CHECKS.keys()) == { + "check_analysis_approved", "check_analysis_complete", "check_architecture_done", + "check_ci_green", "check_review_approved", "check_tests_passed", + "check_reviewer_verdict", "check_tests_local", "check_deploy_status", + "check_staging_status", "check_branch_mergeable", "check_staging_image_fresh", + } + + +def test_tc23_check_deploy_status_signature_unchanged(): + import inspect + from src.qg.checks import check_deploy_status, QG_CHECKS + # Registry still points at the same callable. + assert QG_CHECKS["check_deploy_status"] is check_deploy_status + # (repo, work_item_id, branch=None) -> tuple[bool, str] contract intact. + params = list(inspect.signature(check_deploy_status).parameters) + assert params == ["repo", "work_item_id", "branch"] diff --git a/tests/test_plane_to_analyse_resume.py b/tests/test_plane_to_analyse_resume.py new file mode 100644 index 0000000..8394c9d --- /dev/null +++ b/tests/test_plane_to_analyse_resume.py @@ -0,0 +1,114 @@ +"""ORCH-066: To Analyse resume semantics (F-1 status-only model). + +`handle_status_start` forks on (existing task?) + (active job?): + + * TC-02 (AC-2, BR-11) — an EXISTING task with NO active job + To Analyse -> + RELAUNCH the current stage's agent (the analyst resumes from Needs Input); + NO second task is created; the issue is re-indicated `Analysis`. + * TC-04 (AC-4) — an EXISTING task WITH an active job + To Analyse -> + busy-guard: NO relaunch (no double launch). + +handle_status_start is exercised directly; enqueue_job + Plane side-effects are +mocked. A real isolated sqlite DB backs get_task_by_plane_id / the job guard. +""" + +import os +import tempfile + +import pytest + +_test_db = os.path.join(tempfile.gettempdir(), "test_orch066_to_analyse_resume.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 patch, AsyncMock, MagicMock # noqa: E402 + +import src.db as _db # noqa: E402 +from src.db import init_db, get_db # noqa: E402 +from src.webhooks.plane import handle_status_start # noqa: E402 + + +@pytest.fixture(autouse=True) +def fresh_db(monkeypatch): + monkeypatch.setattr(_db.settings, "db_path", _test_db) + if os.path.exists(_test_db): + os.unlink(_test_db) + init_db() + yield + if os.path.exists(_test_db): + os.unlink(_test_db) + + +def _make_task(plane_id="resume-1", stage="analysis", repo="enduro-trails", + branch="feature/ET-001-x", wi="ET-001"): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) " + "VALUES (?, ?, ?, ?, ?)", + (plane_id, wi, repo, branch, stage), + ) + tid = cur.lastrowid + conn.commit() + conn.close() + return tid + + +def _count(plane_id): + conn = get_db() + n = conn.execute("SELECT COUNT(*) FROM tasks WHERE plane_id=?", (plane_id,)).fetchone()[0] + conn.close() + return n + + +# --------------------------------------------------------------------------- +# TC-02 (AC-2 / BR-11): existing task, no active job -> RELAUNCH (resume), no dup. +# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_tc02_to_analyse_resume_relaunches_analyst_no_duplicate(): + _make_task("resume-1", stage="analysis") + data = {"id": "resume-1", "state": {"id": "ip-uuid", "name": "To Analyse"}} + + with patch("src.webhooks.plane.enqueue_job", return_value=7) as mock_enqueue, \ + patch("src.webhooks.plane.start_pipeline", new_callable=AsyncMock) as mock_start, \ + patch("src.plane_sync.add_comment", MagicMock()), \ + patch("src.plane_sync.set_issue_analysis") as mock_analysis: + await handle_status_start(data, "proj-1") + + # No new pipeline start (it is a resume, not a fresh task). + mock_start.assert_not_called() + assert _count("resume-1") == 1 # NO duplicate task + # The current stage's agent (analyst) was relaunched exactly once. + assert mock_enqueue.call_count == 1 + assert mock_enqueue.call_args.args[0] == "analyst" + # AC-3: the resumed analysis stage is re-indicated as Analysis. + mock_analysis.assert_called_once_with("ET-001") + + +# --------------------------------------------------------------------------- +# TC-04 (AC-4): existing task WITH active job -> busy-guard, NO relaunch. +# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_tc04_to_analyse_with_active_job_does_not_relaunch(): + tid = _make_task("resume-2", stage="analysis") + # Seed an active (queued) job so has_active_job_for_task reports busy. + conn = get_db() + conn.execute( + "INSERT INTO jobs (agent, repo, task_id, status) VALUES (?, ?, ?, 'queued')", + ("analyst", "enduro-trails", tid), + ) + conn.commit() + conn.close() + + data = {"id": "resume-2", "state": {"id": "ip-uuid", "name": "To Analyse"}} + with patch("src.webhooks.plane.enqueue_job", return_value=9) as mock_enqueue, \ + patch("src.webhooks.plane.start_pipeline", new_callable=AsyncMock) as mock_start, \ + patch("src.plane_sync.add_comment", MagicMock()), \ + patch("src.plane_sync.set_issue_analysis") as mock_analysis: + await handle_status_start(data, "proj-1") + + mock_start.assert_not_called() + mock_enqueue.assert_not_called() # busy-guard held: NO double launch + mock_analysis.assert_not_called() + assert _count("resume-2") == 1 diff --git a/tests/test_plane_webhook.py b/tests/test_plane_webhook.py index 961001f..ec73c30 100644 --- a/tests/test_plane_webhook.py +++ b/tests/test_plane_webhook.py @@ -47,13 +47,18 @@ UNKNOWN_PLANE_ID = "deadbeef-0000-0000-0000-000000000000" _PROJECT_STATES = { ENDURO_PLANE_ID: { "in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967", + # ORCH-066: To Analyse is the start trigger; absent -> aliases in_progress. + "to_analyse": "b873d9eb-993c-48cd-97ac-99a9b1623967", "approved": "a519a341-dada-4a91-8910-7604f82b79c5", "rejected": "ba958f3c-5db5-461d-8f82-89425e413b97", + "cancelled": "b1cae7f9-961d-4889-a179-f3acea697d17", }, ORCH_PLANE_ID: { "in_progress": "e331bfb3-e17e-4699-ba48-4abb89c21b7b", + "to_analyse": "e331bfb3-e17e-4699-ba48-4abb89c21b7b", "approved": "63f2c8fe-dcda-4ace-952f-dd88bd0118ff", "rejected": "4c769e90-bf80-4a52-b97a-e1c84904bfc3", + "cancelled": "59d1d210-8e3a-4a83-930a-cbc5dbf6ad85", }, } @@ -219,3 +224,38 @@ def test_prefixes_independent_per_project(mock_branch, mock_docs, mock_launcher) assert rows["o1"] == "ORCH-001" assert rows["o2"] == "ORCH-002" assert rows["e1"] == "ET-001" + + +# --------------------------------------------------------------------------- +# ORCH-066 TC-15 (AC-15): Cancelled is a valid human exit — the orchestrator +# performs NO advance/rollback (indication, not control). +# --------------------------------------------------------------------------- +@patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) +@patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) +@patch("src.webhooks.plane.launcher") +def test_cancelled_state_does_no_pipeline_action(mock_launcher, mock_start, mock_verdict): + cancelled = _PROJECT_STATES[ORCH_PLANE_ID]["cancelled"] + resp = client.post( + "/webhook/plane", + json={ + "event": "issue", + "action": "updated", + "data": { + "id": "cancel-1", + "name": "A cancelled work item", + "description_stripped": "This is a sufficiently long description.", + "project": ORCH_PLANE_ID, + "state": {"id": cancelled, "name": "Cancelled", "group": "cancelled"}, + }, + }, + ) + assert resp.status_code == 200 + # Neither the start nor the verdict (advance/rollback) handler ran. + mock_start.assert_not_called() + mock_verdict.assert_not_called() + mock_launcher.launch.assert_not_called() + # No task created off a Cancelled transition. + conn = get_db() + task = conn.execute("SELECT * FROM tasks WHERE plane_id='cancel-1'").fetchone() + conn.close() + assert task is None diff --git a/tests/test_post_deploy_integration.py b/tests/test_post_deploy_integration.py index 7e1e8f6..beecc26 100644 --- a/tests/test_post_deploy_integration.py +++ b/tests/test_post_deploy_integration.py @@ -47,6 +47,9 @@ def silence_side_effects(monkeypatch): "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", + # ORCH-066 status setters. + "set_issue_analysis", "set_issue_awaiting_deploy", "set_issue_deploying", + "set_issue_monitoring", ): monkeypatch.setattr(stage_engine, name, MagicMock()) @@ -242,6 +245,81 @@ def test_finished_window_tick_is_noop(monkeypatch): probe.assert_not_called() +# --------------------------------------------------------------------------- +# ORCH-066 TC-10 (AC-10): HEALTHY + window exhausted -> Plane state Done. +# --------------------------------------------------------------------------- +def test_orch066_tc10_clean_window_close_sets_done(monkeypatch): + monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True) + monkeypatch.setattr(post_deploy.settings, "post_deploy_window_s", 30) + monkeypatch.setattr(post_deploy.settings, "post_deploy_interval_s", 30) # budget=1 + monkeypatch.setattr( + post_deploy, "probe_signals", + lambda url: post_deploy.ProbeResult(True, 2, 0, "ok"), + ) + task_id = _make_task("done") + post_deploy.write_marker("orchestrator", "ORCH-021", post_deploy.ARMED, "armed") + stage_engine.run_post_deploy_monitor( + {"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "post-deploy-monitor"} + ) + # Clean window close -> terminal Done indicated on Plane; window marked done. + stage_engine.set_issue_done.assert_called_once_with("ORCH-021") + stage_engine.set_issue_blocked.assert_not_called() + assert post_deploy.has_marker("orchestrator", "ORCH-021", post_deploy.DONE) + # No follow-up tick once the window closed. + assert _jobs("post-deploy-monitor") == [] + + +# --------------------------------------------------------------------------- +# ORCH-066 TC-11 (AC-11): DEGRADED -> Plane state Blocked (self-hosting alert). +# --------------------------------------------------------------------------- +def test_orch066_tc11_degraded_sets_blocked(monkeypatch): + monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True) + monkeypatch.setattr(post_deploy.settings, "post_deploy_fail_threshold", 1) + monkeypatch.setattr(post_deploy.settings, "post_deploy_window_s", 30) + monkeypatch.setattr(post_deploy.settings, "post_deploy_interval_s", 30) + monkeypatch.setattr( + post_deploy, "probe_signals", + lambda url: post_deploy.ProbeResult(False, 2, 2, "down"), + ) + monkeypatch.setattr(stage_engine, "_notify_post_deploy", MagicMock()) + task_id = _make_task("done") + post_deploy.write_marker("orchestrator", "ORCH-021", post_deploy.ARMED, "armed") + stage_engine.run_post_deploy_monitor( + {"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "post-deploy-monitor"} + ) + # DEGRADED -> Blocked indication (NOT Done); window finalised. + stage_engine.set_issue_blocked.assert_called_once_with("ORCH-021") + stage_engine.set_issue_done.assert_not_called() + assert post_deploy.has_marker("orchestrator", "ORCH-021", post_deploy.DONE) + + +# --------------------------------------------------------------------------- +# ORCH-066 TC-12 (AC-12): a self-hosting tick NEVER restarts/rolls back prod — +# the Blocked indication is the ONLY mutation (ORCH-021 BR-5 preserved). +# --------------------------------------------------------------------------- +def test_orch066_tc12_self_tick_never_restarts_prod(monkeypatch): + monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True) + monkeypatch.setattr(post_deploy.settings, "post_deploy_auto_rollback", True) + monkeypatch.setattr(post_deploy.settings, "post_deploy_fail_threshold", 1) + monkeypatch.setattr(post_deploy.settings, "post_deploy_window_s", 30) + monkeypatch.setattr(post_deploy.settings, "post_deploy_interval_s", 30) + monkeypatch.setattr( + post_deploy, "probe_signals", + lambda url: post_deploy.ProbeResult(False, 2, 2, "down"), + ) + monkeypatch.setattr(stage_engine, "_notify_post_deploy", MagicMock()) + # The rollback hook (the only restart-capable path) MUST stay untouched for self. + rollback = MagicMock(return_value=(0, "ok")) + monkeypatch.setattr(post_deploy, "run_rollback", rollback) + task_id = _make_task("done") + post_deploy.write_marker("orchestrator", "ORCH-021", post_deploy.ARMED, "armed") + stage_engine.run_post_deploy_monitor( + {"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "post-deploy-monitor"} + ) + rollback.assert_not_called() # never restarts/rolls back the prod self-container + stage_engine.set_issue_blocked.assert_called_once_with("ORCH-021") # indication only + + # --------------------------------------------------------------------------- # TC-20 — /queue observability block # --------------------------------------------------------------------------- diff --git a/tests/test_reconciler.py b/tests/test_reconciler.py index 8e47314..f28489a 100644 --- a/tests/test_reconciler.py +++ b/tests/test_reconciler.py @@ -572,7 +572,7 @@ def test_tc060_08_no_gate_call_on_escalated(monkeypatch): # --------------------------------------------------------------------------- def test_tc060_09_f2_does_not_replay_blocked(monkeypatch): states = { - "in_progress": "IP", "approved": "AP", "rejected": "RJ", + "in_progress": "IP", "to_analyse": "IP", "approved": "AP", "rejected": "RJ", "blocked": "BL", "needs_input": "NI", } monkeypatch.setattr( @@ -680,3 +680,67 @@ def test_tc060_subflag_disables_only_guard2(monkeypatch): assert _stage_of(blocked) == "review" # Guard 2 muted assert _stage_of(escalated) == "development" # Guard 1 still skips + + +# --------------------------------------------------------------------------- +# ORCH-066 TC-21 (AC-20 / BR-13): Guard 2 skips the active orchestrator waits +# (Awaiting Deploy / Deploying / Monitoring after Deploy) ONLY when they are +# DISTINCT statuses — an aliased (enduro) project must NOT widen the skip-set. +# --------------------------------------------------------------------------- +def _guard2(monkeypatch, states, cur_state): + """Drive _is_blocked_or_needs_input with a chosen project state map + the + issue's current Plane state uuid.""" + monkeypatch.setattr(reconciler_mod, "get_project_states", + MagicMock(return_value=states)) + monkeypatch.setattr(reconciler_mod, "fetch_issue_state", + MagicMock(return_value=cur_state)) + monkeypatch.setattr( + reconciler_mod.projects, "get_project_by_repo", + MagicMock(return_value=MagicMock(plane_project_id="proj-test")), + ) + monkeypatch.setattr( + reconciler_mod.settings, "reconcile_skip_blocked_enabled", True + ) + task = {"id": 1, "repo": "orchestrator", "plane_id": "iss-1"} + return Reconciler()._is_blocked_or_needs_input(task) + + +# orchestrator has the three new statuses as DISTINCT UUIDs. +_DISTINCT_STATES = { + "backlog": "bl-u", "todo": "td-u", "in_progress": "ip-u", "in_review": "inrev-u", + "review": "rev-u", "architecture": "arch-u", "development": "dev-u", + "testing": "test-u", "approved": "appr-u", "rejected": "rej-u", "done": "done-u", + "blocked": "blocked-u", "needs_input": "ni-u", + "awaiting_deploy": "await-u", "deploying": "deploying-u", "monitoring": "monitor-u", +} + + +def test_tc21_guard2_skips_distinct_active_waits(monkeypatch): + # Each active-wait status (distinct UUID) -> skipped (not revived). + assert _guard2(monkeypatch, _DISTINCT_STATES, "await-u") is True + assert _guard2(monkeypatch, _DISTINCT_STATES, "deploying-u") is True + assert _guard2(monkeypatch, _DISTINCT_STATES, "monitor-u") is True + # Explicit human gates still skip. + assert _guard2(monkeypatch, _DISTINCT_STATES, "blocked-u") is True + assert _guard2(monkeypatch, _DISTINCT_STATES, "ni-u") is True + # A normal working state is NOT skipped (gets reconciled). + assert _guard2(monkeypatch, _DISTINCT_STATES, "ip-u") is False + + +def test_tc21_guard2_aliased_waits_do_not_widen_skipset(monkeypatch): + # enduro: the new keys alias onto base working statuses -> they must NOT make + # F-1 skip a genuinely In Progress / In Review / Done task (anti-regress). + aliased = { + "backlog": "bl-u", "todo": "td-u", "in_progress": "ip-u", "in_review": "inrev-u", + "review": "rev-u", "architecture": "arch-u", "development": "dev-u", + "testing": "test-u", "approved": "appr-u", "rejected": "rej-u", "done": "done-u", + "blocked": "blocked-u", "needs_input": "ni-u", + # aliased onto base UUIDs (project did not create dedicated statuses). + "awaiting_deploy": "inrev-u", "deploying": "ip-u", "monitoring": "done-u", + } + # In Progress / In Review / Done are base working states -> NOT skipped. + assert _guard2(monkeypatch, aliased, "ip-u") is False + assert _guard2(monkeypatch, aliased, "inrev-u") is False + assert _guard2(monkeypatch, aliased, "done-u") is False + # The explicit human gates still skip. + assert _guard2(monkeypatch, aliased, "blocked-u") is True diff --git a/tests/test_reconciler_plane.py b/tests/test_reconciler_plane.py index e68d498..8f03475 100644 --- a/tests/test_reconciler_plane.py +++ b/tests/test_reconciler_plane.py @@ -59,6 +59,9 @@ def single_project(monkeypatch): reconciler_mod, "get_project_states", lambda pid: { "in_progress": _IN_PROGRESS, + # ORCH-066: To Analyse is the F-2 start/resume trigger; absent in this + # project -> aliases in_progress (real get_project_states fallback). + "to_analyse": _IN_PROGRESS, "approved": _APPROVED, "rejected": _REJECTED, }, @@ -114,6 +117,46 @@ def test_tc11_in_progress_without_task_starts_pipeline(monkeypatch, single_proje verdict.assert_not_called() +# --------------------------------------------------------------------------- +# ORCH-066 TC-20 (AC-19): F-2 polls the DISTINCT To Analyse status and routes it +# to handle_status_start (a lost start/resume webhook is recovered). +# --------------------------------------------------------------------------- +def test_tc20_distinct_to_analyse_polled_and_routed(monkeypatch): + _TO_ANALYSE = "uuid-to-analyse" # distinct from in_progress + monkeypatch.setattr( + reconciler_mod, "get_project_states", + lambda pid: { + "in_progress": _IN_PROGRESS, + "to_analyse": _TO_ANALYSE, # dedicated status created + "approved": _APPROVED, + "rejected": _REJECTED, + }, + ) + monkeypatch.setattr( + reconciler_mod.projects, "PROJECTS", + [SimpleNamespace(plane_project_id="proj-1", repo="enduro-trails", + work_item_prefix="ET")], + ) + start, verdict = _patch_handlers(monkeypatch) + + polled = {} + + def fake_list(pid, states): + polled["states"] = list(states) + return [{"id": "iss-ta", "state": {"id": _TO_ANALYSE}, "updated_at": _OLD_TS, + "name": "Lost start"}] + + monkeypatch.setattr(reconciler_mod, "list_issues_by_state", fake_list) + + Reconciler().reconcile_plane_once() + + # The To Analyse UUID is in the polled set and routed to start (not verdict). + assert _TO_ANALYSE in polled["states"] + assert start.call_count == 1 + assert start.call_args.args[0]["id"] == "iss-ta" + verdict.assert_not_called() + + # --------------------------------------------------------------------------- # TC-12: Approved with an existing task, no active job -> handle_verdict(True). # --------------------------------------------------------------------------- @@ -279,7 +322,10 @@ def test_tc17_polls_all_projects_resolves_states_per_project(monkeypatch): def fake_states(pid): states_calls.append(pid) - return {"in_progress": _IN_PROGRESS, "approved": _APPROVED, "rejected": _REJECTED} + return { + "in_progress": _IN_PROGRESS, "to_analyse": _IN_PROGRESS, + "approved": _APPROVED, "rejected": _REJECTED, + } def fake_issues(pid, states): issues_calls.append((pid, tuple(states))) diff --git a/tests/test_stage_visibility.py b/tests/test_stage_visibility.py index a41f5c7..d7be813 100644 --- a/tests/test_stage_visibility.py +++ b/tests/test_stage_visibility.py @@ -68,10 +68,18 @@ def test_set_issue_stage_state_patches_correct_uuid(mock_proj, mock_find, mock_p @patch("src.plane_sync.httpx.patch") @patch("src.plane_sync.find_issue_id", return_value="issue-uuid") @patch("src.plane_sync._resolve_project_id", return_value="proj-1") -def test_set_issue_stage_state_noop_for_analysis(mock_proj, mock_find, mock_patch): - # analysis has no dedicated board status -> no PATCH at all. +def test_set_issue_stage_state_noop_for_deploy(mock_proj, mock_find, mock_patch): + # ORCH-066: analysis now HAS a dedicated status (Analysis) -> it PATCHes. + # deploy still has no board status here (driven by Phase A/B/C) -> no-op. + resp = MagicMock() + resp.raise_for_status.return_value = None + mock_patch.return_value = resp + PS.set_issue_stage_state("ET-1", "analysis") - mock_patch.assert_not_called() + # analysis aliases in_progress when the Analysis status is absent. + assert mock_patch.call_args.kwargs["json"]["state"] == PS.PLANE_STATES["analysis"] + + mock_patch.reset_mock() PS.set_issue_stage_state("ET-1", "deploy") mock_patch.assert_not_called()