diff --git a/.env.example b/.env.example index dfba7c6..90c59c4 100644 --- a/.env.example +++ b/.env.example @@ -139,6 +139,17 @@ ORCH_SERIAL_GATE_FREEZE_ENABLED=true # for enduro too). ORCH_STOP_STATUS_ENABLED=true ORCH_STOP_STATUS_REPOS= +# ORCH-094: terminal-window-aware guard for the three deploy-phase Plane status +# setters (set_issue_awaiting_deploy / set_issue_deploying / set_issue_monitoring). +# A DB stage=done task converges to Done idempotently instead of flapping +# Awaiting <-> Monitoring, EXCEPT the legitimate post-deploy Monitoring while the +# window is active (ARMED & not DONE). Leaf src/deploy_status_guard.py, never-raise; +# STAGE_TRANSITIONS / QG_CHECKS / machine-verdict keys untouched (no DB migration). +# DEPLOY_STATUS_GUARD_ENABLED=false -> setters are terminal-blind (1:1 pre-ORCH-094). +# DEPLOY_STATUS_GUARD_REPOS (CSV) -> scope; EMPTY = self-hosting only (orchestrator), +# the only repo where deploy-phase statuses are set. +ORCH_DEPLOY_STATUS_GUARD_ENABLED=true +ORCH_DEPLOY_STATUS_GUARD_REPOS= # ORCH-071/073: merge-verify under-gate on the `deploy -> done` edge (врезка in # advance_stage, NOT a new STAGE_TRANSITIONS edge / registered QG). A deterministic # merge-actor merges the feature code-PR via the Gitea PR-merge API (never push/ diff --git a/CHANGELOG.md b/CHANGELOG.md index e0721df..624f669 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу. ## [Unreleased] +- **Терминальная (done) задача держит `Done` в Plane: terminal-window-aware гард deploy-статусов** (ORCH-094, `fix`): задача с БД `stage=done` и 0 активных job'ов (верифицировано на ORCH-061, task 47) стабильно флаппила в Plane `Awaiting Deploy ⟷ Monitoring after Deploy` (273 активности парами, само не затихает) вместо `Done`. Корень: три deploy-фазовых сеттера (`set_issue_awaiting_deploy`/`set_issue_deploying`/`set_issue_monitoring`) **терминал-слепы** — любой стейл/двойной/неизвестный вызов под бот-токеном перезаписывает `Done` промежуточным deploy-статусом, и обратно, бесконечно. **Аддитивно, never-raise, под kill-switch, в зоне self-hosting:** `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict ключи (`deploy_status:`/`staging_status:`/…) / схема БД — **не тронуты** (читается существующая `tasks.stage`, без миграции). + - **Единый гард на низком чокпоинте (FR-2, D1/D2):** новый leaf `src/deploy_status_guard.py` (чистая, never-raise, config-gated логика; по образцу `serial_gate.py`/`labels.py`/`cancel.py`) — `decide(work_item_id, target, reason) -> ALLOW | CONVERGE_DONE | SUPPRESS`. Гард ставится на **входе** трёх сеттеров `plane_sync` (а не в caller'ах `stage_engine`) → перехватывает **любой** путь, включая неизвестный актор под бот-токеном. Предикат легитимности: deploy-статус легитимен ⇔ задача **нетерминальна** ИЛИ (`done` **И** активно пост-деплой-окно `post_deploy.window_active` = ARMED & не DONE). Для `done`: `monitoring`+окно-активно → `ALLOW`; иначе → `CONVERGE_DONE` (сеттер вместо PATCH'а зовёт `set_issue_done`, идемпотентно). `cancelled` → `SUPPRESS` (не штампуем поверх терминала ORCH-090). Нетерминальная задача → `ALLOW` (рабочий deploy-цикл 1:1, AC-4). Task не найден / не-self репо / kill-switch off / любое исключение → `ALLOW` (fail-safe к прежнему поведению 1:1, NFR-1). + - **Перенос арм-блока перед terminal-sync (D3, AC-4):** в `advance_stage` (ветка `next_stage=="done"`) блок `post_deploy.arm_monitor` перемещён **выше** блока `set_issue_monitoring` (стр. 404). Критично: `update_task_stage(task_id,"done")` пишет `stage='done'` **раньше** легитимного первого `Monitoring` — без переноса гард ошибочно свёл бы его к Done. Арм-первым пишет `ARMED` → `window_active==True` → `ALLOW` пропускает легитимный `Monitoring`; re-drive `deploy→done` **после** закрытия окна (`DONE` present) → `window_active==False` → `CONVERGE_DONE` (не воскрешает `Monitoring`). Перенос безопасен: `arm_monitor` лишь пишет sentinel + ставит отложенный job, не зависит от Plane-статуса/merge-lease (release остаётся после terminal-sync). Инварианты ORCH-021 (идемпотентный арм по `ARMED`) и ORCH-066 (`deploy→done` self ⇒ `Monitoring`) сохранены. + - **Харднинг пост-деплой-монитора (FR-3, D4, AC-3):** `run_post_deploy_monitor` — существующий идемпотентный страж `has_marker(DONE)` (no-op завершённого окна) сохранён; аддитивно: тик при БД `stage='cancelled'` мид-окно → закрыть окно `mark_done` **без статус-PATCH и без перепостановки** следующего тика (zombie-tick guard). Перепостановка остаётся строго при `HEALTHY and ticks < budget` (тик ≡ job; нет job → нет тика). После закрытия окна — 0 последующих статус-PATCH; любой стейл `set_issue_monitoring` добивается гардом D2. + - **Наблюдаемость (FR-4, D5, AC-5):** аддитивный BC-kwarg `reason: str | None = None` у трёх сеттеров; call-site'ы передают `"advance:deploy->done"`/`"phase_a"`/`"phase_b"`. `decide` эмитит ОДНУ структурную запись на вызов: `work_item`, `caller(reason)`, `target_status`, `db_stage`, `window_active`, `verdict` (`ALLOW` → INFO; `CONVERGE_DONE`/`SUPPRESS` → WARNING, «что подавили и почему» — атрибуция будущего флаппа). Новый read-only аксессор `db.get_task_by_work_item_id` (human-readable `work_item_id` матчит живой ряд; тумбстоны ORCH-090 имеют суффикс `#cancelled-`). + - **Конфиг/откат (FR-5, D6):** `src/config.py` `deploy_status_guard_enabled: bool = True` (env `ORCH_DEPLOY_STATUS_GUARD_ENABLED`; `False` → сеттеры терминал-слепы, поведение **1:1** прежнее) / `deploy_status_guard_repos: str = ""` (env `ORCH_DEPLOY_STATUS_GUARD_REPOS`; CSV, **пусто → self-hosting only** — не-self репо (enduro) гард не трогает, нулевая регрессия). Откат: `ORCH_DEPLOY_STATUS_GUARD_ENABLED=false` (мгновенный runtime) или revert ветки. + - **Источник флаппа (BR-7):** code-писатели deploy-статусов — только `stage_engine.py:404/1218/1316`; реконсилятор F-2 эти статусы не перебирает; live-overlay `notifications.py` — read-only. Гард — **буфер на стороне орка**, гасящий маятник за один цикл независимо от актора (известный/стейл/неизвестный под бот-токеном). Если актор — внешняя Plane-automation под другим токеном, code-фикс не закрывает её полностью, но идемпотентное схождение к Done нейтрализует видимый эффект. + - **Трассировка:** перед правкой блока `next_stage=="done"` (маркеры ORCH-021/066/043/088) прочитаны их ADR — инварианты сохранены (deploy→done self ⇒ Monitoring; монитор-close ⇒ Done; терминал-набор `{done,cancelled}`). Тесты: `tests/test_deploy_status_terminal_guard.py` (TC-01..05/12), `tests/test_post_deploy_monitor_termination.py` (TC-06..08), `tests/test_deploy_status_observability.py` (TC-09), `tests/test_reconciler_done_deploy_convergence.py` (TC-10), `tests/test_self_deploy_cycle_regression.py` (TC-11). Обновлены анти-регресс-ассерты `tests/test_deploy_terminal_sync.py`/`test_deploy_approve.py` под `reason`-kwarg. Полный регресс `tests/ -q` зелёный (1411). ADR: `docs/work-items/ORCH-094/06-adr/ADR-001-terminal-window-aware-deploy-status-guard.md`, сквозной `docs/architecture/adr/adr-0028-terminal-window-aware-deploy-status-guard.md`. - **Merge-актор ретраит транзиентные ошибки Gitea (405/5xx) + гард «ветка уже в `main`»** (ORCH-093, `fix`): две точечные доработки детерминированного merge-актора `src/merge_gate.py`, чинящие инцидент **ORCH-063**: self-deploy прошёл, staging OK, PR был `open`+`mergeable`, но `POST /pulls/{n}/merge` вернул `HTTP 405 "Please try again later"` (Gitea пересчитывал `mergeable` сразу после пуша) → one-shot `merge_pr` мгновенно вернул `False` → корректная защита ORCH-071/081 удержала задачу на `deploy` + потребовала ручной домерж; повторный прогон финализатора плодил мусорный пустой PR. **Аддитивно, never-raise, под существующими kill-switch'ами:** `STAGE_TRANSITIONS` / `QG_CHECKS` / схема БД — **не тронуты**; INV-4 (мерж только через Gitea PR-merge API, никогда `push`/`force-push` в `main`) сохранён 1:1. - **Retry-loop транзиента (FR-1/FR-2, AC-1/AC-2/AC-3, D1/D2):** `merge_pr` оборачивает **только** мутирующий `POST …/merge` в ограниченный retry-loop с экспоненциальным backoff (`min(base*2^(i-1), max)`, дефолты 2/5 с → суммарный сон `(N-1)*max ≤ 10 с`, monitor-поток не подвешивается). Классификатор `_classify_merge_response`: **транзиент** (ретрай) — `405`/`408`/любой `5xx`/`httpx`-таймаут/сетевая ошибка, **и** `409`/`422` когда PR всё ещё mergeable; **терминал** (быстрый честный `False`, защита ORCH-071/081 как прежде) — `403`/`404`/реальный конфликт (`409`/`422` при `mergeable==False`). Неоднозначный `409`/`422` разрешается доп. `GET /pulls/{index}` → `mergeable`; дефолт-политика `mergeable==None`/недоступно → **транзиент** (fail-OPEN-в-ретрай: икота Gitea — наблюдаемый кейс, бюджет конечен, backstop сохранён). Каждая попытка логируется `attempt i/N` (образец `check_ci_green`). - **Гард already-in-main (FR-3/FR-4, AC-4, D3/D4):** новый leaf `_branch_fully_in_main` (`git merge-base --is-ancestor HEAD origin/main` в per-branch worktree) вызывается в `ensure_open_pr` **между** «открытый code-PR не найден» и `POST …/pulls`: ветка целиком в `main` (нет коммитов `origin/main..HEAD`) → новый исход `"already-in-main"` **без создания PR** (нет мусорного пустого PR на уже влитой ветке). git-ошибка/ambiguous (`None`) → **fail-OPEN** (деградация на create-путь, НЕ ложный no-op). В `stage_engine._handle_merge_verify` исход `already-in-main` **пропускает** `merge_pr` (мержить нечего) и отдаёт авторитетному SHA-in-main (`verify_merged_to_main`) довести до `done`; это НЕ HOLD. SHA-in-main остаётся единственным доказательством мержа (ADR-0014). diff --git a/CLAUDE.md b/CLAUDE.md index dfb0be4..0035cb0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -41,6 +41,8 @@ created → analysis → architecture → development → review → testing → ## Статусная модель 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`. +**Terminal-window-aware гард deploy-статусов (ORCH-094).** Задача с БД `stage=done` и 0 активных job'ов стабильно держит Plane=`Done`: три deploy-фазовых сеттера (`set_issue_awaiting_deploy`/`set_issue_deploying`/`set_issue_monitoring`) были терминал-слепы и флаппили `Awaiting ⟷ Monitoring` (верифицировано на ORCH-061, task 47), т.к. любой стейл/двойной/неизвестный вызов под бот-токеном перезаписывал терминал промежуточным статусом. Новый leaf `src/deploy_status_guard.py` (чистая, never-raise, config-gated; по образцу `serial_gate`/`labels`/`cancel`) — `decide(work_item_id, target, reason) -> ALLOW | CONVERGE_DONE | SUPPRESS` на **входе** трёх сеттеров `plane_sync` (низкий чокпоинт ловит любой путь, включая неизвестный актор). Инвариант: deploy-статус легитимен ⇔ задача **нетерминальна** ИЛИ (`done` И активно пост-деплой-окно `post_deploy.window_active` = ARMED & не DONE); иначе для `done` — идемпотентное `CONVERGE_DONE` (сеттер зовёт `set_issue_done`), для `cancelled` — `SUPPRESS`. Чтобы легитимный первый `Monitoring` (БД уже `done` к моменту стр. 404) прошёл, арм-блок `post_deploy.arm_monitor` **перенесён выше** terminal-sync-блока в `advance_stage` (ADR-001 D3) → `window_active==True` до выставления `Monitoring`. Монитор-тик при БД `cancelled` мид-окно → закрыть окно без статус-PATCH (zombie-tick guard, FR-3). Наблюдаемость: BC-kwarg `reason` у трёх сеттеров + одна структурная лог-запись на вердикт (`work_item`/`caller`/`target`/`db_stage`/`window_active`/`verdict`; converge/suppress → WARNING). Read-only аксессор `db.get_task_by_work_item_id`. Флаги `deploy_status_guard_enabled` (kill-switch; `False` → 1:1 прежнее) / `deploy_status_guard_repos` (CSV; **пусто → self-hosting only**, enduro не затронут). `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схема БД — не тронуты. Детали — `docs/work-items/ORCH-094/06-adr/ADR-001-terminal-window-aware-deploy-status-guard.md`, сквозной `docs/architecture/adr/adr-0028-terminal-window-aware-deploy-status-guard.md`. + ## Нотификации / Telegram live-tracker (ORCH-042/066/067/087) Каждая задача = **одна карточка** в Telegram (`src/notifications.py`). Поведение карточки: - **Дефолт `tracker_mode` — `bump`** (ORCH-067; `edit` доступен через `ORCH_TRACKER_MODE=edit`). diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 9d953f8..08744a5 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -585,6 +585,39 @@ sentinel-state, `write_post_deploy_log`. Подробнее: [adr-0010](adr/adr-0010-post-deploy-monitor.md), детально — `docs/work-items/ORCH-021/06-adr/ADR-001-post-deploy-monitor.md`. +### Terminal-window-aware гард deploy-статусов: done-задача держит Done (ORCH-094 — реализовано) +Терминальная (`done`) задача в Plane **не держала `Done`**: непрерывный флапп +`Awaiting Deploy ⟷ Monitoring after Deploy` (верифицировано на **ORCH-061**, task 47, done с 07.06 — +273 активности, само не затихает). Причина: три code-писателя deploy-фазовых статусов +(`stage_engine.py:404/1218/1316`) делегируют в тонкие сеттеры `plane_sync`, которые **БД-стадию не +читают** ⇒ терминал-слепы; любой повторный/стейл вызов под бот-токеном орка перезаписывает `Done` +обратно. Тонкость: `update_task_stage("done")` (стр. 369) пишет стадию **раньше** легитимного +`set_issue_monitoring` (стр. 404) ⇒ пост-деплой-окно ORCH-021 by-design индицируется поверх уже-`done` +задачи; наивный гард «stage==done → Done» затёр бы легитимный `Monitoring` (регресс). + +Решение — **единый terminal-window-aware гард на входе трёх deploy-фазовых сеттеров** (новый leaf +`src/deploy_status_guard.py`, never-raise, config-gated; образец `serial_gate`/`labels`/`cancel`). +- **Инвариант:** deploy-фазовый статус легитимен ⇔ задача **нетерминальна** ИЛИ (`done` И активно + пост-деплой-окно). `decide(work_item_id, target) → ALLOW | CONVERGE_DONE | SUPPRESS`: off / чужой + issue / не-self репо / нетерминал → ALLOW; `cancelled` → SUPPRESS; `done`+`monitoring`+`window_active` + → ALLOW; `done` иначе → CONVERGE_DONE (`set_issue_done`, идемпотентно); исключение → ALLOW+warning. +- **Окно** — новый `post_deploy.window_active(repo,wi)` = `has_marker(ARMED) and not has_marker(DONE)` + (restart-safe). **Перенос арм-блока перед terminal-sync** в `advance_stage` блок `next_stage=="done"` + ⇒ на стр. 404 `ARMED` уже есть ⇒ легитимный первый `Monitoring` проходит; re-drive после закрытия + окна сходится к `Done`. +- **Харднинг монитора:** страж `has_marker(...DONE)` (ранний return) + тик no-op при `cancelled` + мид-окно; тики привязаны к активному job'у (нет job → нет тика, нет статус-PATCH). +- **Наблюдаемость:** каждый вердикт логируется (`work_item`/`caller`/`target`/`db_stage`/ + `window_active`/вердикт); подавление — явно. +- **Инварианты:** `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict ключи/схема БД — НЕ тронуты; + `main`/force-push/прод-контейнер/detached-деплой — НЕ тронуты; рабочий self-deploy-цикл 1:1; не-self + репо инертны. Kill-switch `ORCH_DEPLOY_STATUS_GUARD_ENABLED` (→ 1:1), область + `ORCH_DEPLOY_STATUS_GUARD_REPOS` (пусто → self-hosting). Ограничение: внешняя Plane-automation (если + таков актор) закрывается буфером сходимости, а не code-фиксом — локализация актора в задаче (BR-7). + +Подробнее: [adr-0028](adr/adr-0028-terminal-window-aware-deploy-status-guard.md), детально — +`docs/work-items/ORCH-094/06-adr/ADR-001-terminal-window-aware-deploy-status-guard.md`. + ### Свежесть артефакта BUILD-ONCE: провенанс staging-образа (ORCH-058 — реализовано) BUILD-ONCE retag (ORCH-36) промоутит `SOURCE_IMAGE=orchestrator-orchestrator-staging` в прод **без rebuild**, полагаясь на «staging-образ свеж и провалидирован». Этой гарантии нет: diff --git a/docs/architecture/adr/adr-0028-terminal-window-aware-deploy-status-guard.md b/docs/architecture/adr/adr-0028-terminal-window-aware-deploy-status-guard.md new file mode 100644 index 0000000..5cac26e --- /dev/null +++ b/docs/architecture/adr/adr-0028-terminal-window-aware-deploy-status-guard.md @@ -0,0 +1,96 @@ +--- +work_item: ORCH-094 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-09 +model_used: claude-opus-4-8 +--- + +# adr-0028: Terminal-window-aware гард выставления deploy-фазовых статусов Plane + +Сквозной (cross-cutting) ADR. **Амендмент** к [adr-0010](adr-0010-post-deploy-monitor.md) +(post-deploy monitor, ORCH-021) и Plane-статусной модели (ORCH-066): вводит инвариант +«deploy-фазовые Plane-статусы — terminal-window-aware» поверх общих сеттеров `plane_sync` и +переупорядочивает блок `next_stage == "done"` в `advance_stage`. Детальное решение задачи — +`docs/work-items/ORCH-094/06-adr/ADR-001-terminal-window-aware-deploy-status-guard.md`. + +> Регистрируется как сквозной, т.к. правит **общие** сеттеры `set_issue_awaiting_deploy`/ +> `set_issue_deploying`/`set_issue_monitoring` (используются системно) и трогает маркированный блок с +> `ORCH-021`/`ORCH-066` (`docs/_standards/TRACEABILITY.md`). + +## Статус +Proposed + +## Контекст + +Терминальная (`done`) задача в Plane **не держит `Done`**: непрерывный флапп +`Awaiting Deploy ⟷ Monitoring after Deploy` (верифицировано живьём на **ORCH-061**, task 47, done с +07.06 — 273 активности, само не затихает). Установлено по коду/логам/БД прода: + +- Три code-писателя deploy-фазовых статусов (`src/stage_engine.py:404/1218/1316`) делегируют в тонкие + сеттеры `src/plane_sync.py`, которые **БД-стадию не читают** ⇒ терминал-слепы: любой повторный вызов + перезаписывает `Done` обратно на промежуточный статус. +- **Ordering:** `update_task_stage("done")` (`stage_engine.py:369`) пишет `tasks.stage='done'` + **раньше** легитимного `set_issue_monitoring` (стр. 404) ⇒ пост-деплой-окно ORCH-021 — by-design + индикация поверх уже-`done` задачи. Наивный гард «stage==done → Done» ⇒ регресс легитимного окна. +- Актор всех 273 переходов — бот-токен орка (`daf4d3f4-…`), не привязан к активной task/job; в БД нет + активного post-deploy-monitor для task 47 (окно 15 мин закрыто). Реконсилятор F-1 пропускает + `done`/`cancelled`, F-2 опрашивает только `[to_analyse, approved, rejected]` ⇒ механизма привести + застрявшую на deploy-статусе done-задачу к `Done` нет. + +## Решение + +**Единый terminal-window-aware гард на низком чокпоинте** — на входе трёх deploy-фазовых сеттеров +`plane_sync`. Чистую логику держит **новый leaf-модуль `src/deploy_status_guard.py`** (never-raise, +config-gated; образец `serial_gate.py`/`labels.py`/`cancel.py`); сеттеры исполняют вердикт. + +- **Инвариант легитимности:** deploy-фазовый статус легитимен ⇔ задача **нетерминальна** ИЛИ + (`done` **И** активно пост-деплой-окно). Иначе — идемпотентное схождение к `Done`. + `decide(work_item_id, target) -> ALLOW | CONVERGE_DONE | SUPPRESS`: + kill-switch off / чужой issue / не-self репо / нетерминал → **ALLOW**; `cancelled` → **SUPPRESS**; + `done` + `target==monitoring` + `window_active` → **ALLOW**; `done` иначе → **CONVERGE_DONE** + (`set_issue_done`, идемпотентно); любое исключение → **ALLOW** + warning (never-raise). +- **Новый helper** `post_deploy.window_active(repo, wi)` = `has_marker(ARMED) and not + has_marker(DONE)` (restart-safe). +- **Перенос арм-блока** (`post_deploy.arm_monitor`) **перед** terminal-sync в блоке + `next_stage == "done"`: на стр. 404 `ARMED` уже записан ⇒ `window_active==True` ⇒ легитимный первый + `Monitoring` проходит; re-drive после закрытия окна сходится к `Done`. +- **Харднинг монитора:** идемпотентный страж `has_marker(...DONE)` (ранний return без PATCH/реэнкью) + + тик no-op при `cancelled` мид-окно; тики привязаны к активному job'у (нет job → нет тика). +- **Наблюдаемость:** каждый вердикт логируется (`work_item`/`caller`/`target`/`db_stage`/ + `window_active`/вердикт); подавление/схождение — явно. +- **Флаги** (`config.py`): `deploy_status_guard_enabled=True` + (`ORCH_DEPLOY_STATUS_GUARD_ENABLED`, kill-switch → 1:1) + `deploy_status_guard_repos=""` + (`ORCH_DEPLOY_STATUS_GUARD_REPOS`, пусто → self-hosting only) с локальным `applies(repo)`. + +## Альтернативы + +- **Гард в caller'ах `stage_engine`** — отвергнуто: не ловит неизвестный/стейл путь под бот-токеном, + размазывает инвариант. +- **Наивный «stage==done → Done» без предиката окна** — отвергнуто: регресс легитимного `Monitoring`. +- **Bypass-флаг на доверенном вызове 404** — отвергнуто в пользу переноса арм-блока (один предикат). +- **Активная сходимость в реконсиляторе F-2** — отвергнуто как основной механизм (лишний polling, + правка маркированного F-2); гард на сеттере гасит непрерывный флапп. + +## Последствия + +- Терминальная задача стабильно держит `Done`; маятник гаснет за один цикл независимо от актора. +- Легитимный пост-деплой `Monitoring` и рабочий self-deploy-цикл — 1:1 (предикат окна + перенос арм). +- `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict ключи / схема БД — **не тронуты**. +- `main`/force-push/прод-контейнер/detached-деплой — не тронуты; не-self репо инертны. +- Ограничение: если актор флаппа — внешняя Plane-automation (вне кода орка), гард — буфер на стороне + орка; локализация (FR-1) и итог документируются (BR-7). +- **Откат:** `ORCH_DEPLOY_STATUS_GUARD_ENABLED=false` → поведение 1:1; полный — revert ветки. + +## Связи + +- [adr-0010](adr-0010-post-deploy-monitor.md) (ORCH-021 — пост-деплой-окно, sentinel `armed`/`done`, + арм-блок) — амендмент: окно становится предикатом легитимности `Monitoring`. +- ORCH-066 (Plane-статусная модель — слой B индикации; `deploy→done` self ⇒ `Monitoring`) — инвариант + сохранён. +- [adr-0026](adr-0026-stop-cancel-task.md) (ORCH-090 — терминал `cancelled`) — гард не штампует + deploy-статус поверх `cancelled`. +- ORCH-068/086 (терминал-скип реконсилятора) — этот ADR распространяет идею терминал-aware на + выставление deploy-статусов. +- Детально: `docs/work-items/ORCH-094/06-adr/ADR-001-terminal-window-aware-deploy-status-guard.md`. diff --git a/docs/work-items/ORCH-094/00-business-request.md b/docs/work-items/ORCH-094/00-business-request.md new file mode 100644 index 0000000..63ef5a0 --- /dev/null +++ b/docs/work-items/ORCH-094/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: BUG: терминальная (done) задача флаппит deploy-статусы в Plane (Awaiting↔Monitoring), не держит Done + +Work Item ID: ORCH-094 + +## Description + +TBD diff --git a/docs/work-items/ORCH-094/01-brd.md b/docs/work-items/ORCH-094/01-brd.md new file mode 100644 index 0000000..6653f3b --- /dev/null +++ b/docs/work-items/ORCH-094/01-brd.md @@ -0,0 +1,155 @@ +--- +work_item: ORCH-094 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-09 +model_used: claude-opus-4-8 +--- + +# 01 — BRD (бизнес-требования): ORCH-094 — терминальная (done) задача флаппит deploy-статусы в Plane (Awaiting↔Monitoring), не держит Done + +Work Item: **ORCH-094** · Repo: **orchestrator** · Стадия: analysis + +## 1. Бизнес-контекст и проблема + +**Тип:** BUG — рассинхрон БД↔Plane / «зомби»-цикл post-deploy-статуса (self-hosting). + +**Симптом (верифицирован живьём 09.06 на ORCH-061):** +Задача ORCH-061 в БД оркестратора = `done` с 07.06 (task 47; фикс задеплоен в прод; конвейер её +не трогает — 0 активных job'ов). При этом карточка задачи в **Plane не держит Done**: непрерывно +флаппит `Monitoring after Deploy ⟷ Awaiting Deploy` парами (туда-обратно за ~2 сек), каждые +несколько минут. Накоплено 273 активности. Доходило до абсурда: 09.06 14:56 встала в `Done` → +15:48 её выдернуло обратно `Done → Awaiting Deploy`. Воспроизводится детерминированно: ручной +sync 061→Done (PATCH 200, 16:47) → через ~60 сек снова `Done → Awaiting Deploy → Monitoring` +(16:48). Само **не затихает**. + +**Установленные факты (по логам/БД прода + чтение кода ветки):** +- **Сам оркестратор не инициирует переходы из своих штатных стадийных обработчиков для done-задачи.** + В момент флаппа лог орка показывает только **входящие** webhook-и Plane + (`issue … updated to state … (Awaiting Deploy) → no pipeline action`, затем `(Monitoring) → + no pipeline action`). Обработчик `webhooks/plane.py::handle_issue_updated` для статусов + Awaiting/Monitoring логирует «no pipeline action» и **сам статус не переотправляет** (echo-loop + обработчика исключён). +- **Actor всех 273 переходов** = `daf4d3f4-55df-4016-9095-0cf9ddd8fd28` — бот-актор оркестратора + (тот же токен, под которым орк делает гигиену доски / sync). То есть PATCH-и шлёт **что-то под + токеном орка**, не привязанное к активной task/job в БД. +- В БД орка **нет активного post-deploy-monitor** для task 47 (pdm активен только у текущей + 063/task 74). `orchestrator-staging` (8501) — не источник (task 061 в его БД отсутствует). +- В коде ветки **единственные три писателя** deploy-статусов — `src/stage_engine.py`: + `set_issue_monitoring` (строка 404, на переходе `deploy → done` для self-hosting), + `set_issue_awaiting_deploy` (строка 1218, Phase A), `set_issue_deploying` (строка 1316, Phase B). + Все три — **внутри стадийных обработчиков** (`advance_stage` / `_handle_self_deploy_phase_*`), + ни один не сидит в фоновом цикле, независимом от таблицы `jobs`. +- `notifications.py::_live_plane_branch_override` **только читает** живой Plane-статус (для рендера + карточки) — писателем не является. +- Реконсилятор: F-1 пропускает задачи со `stage in ('done','cancelled')` (terminal-skip ORCH-086); + F-2 опрашивает issue **только** в статусах `[to_analyse, approved, rejected]` — статусы + `Monitoring`/`Awaiting` он не перебирает. **Механизма «привести done-задачу, застрявшую на + deploy-статусе, обратно к Done» (идемпотентного схождения) — нет.** + +**Боль:** карточка вводит наблюдателя в заблуждение («задача деплоится», хотя она в проде и done), +шумит активностью (273 события на одной задаче), **вечно жжёт API-вызовы Plane** флаппом и +маскирует реальное состояние доски. Конвейер технически не нарушен (задача в проде), поэтому +приоритет **MEDIUM**, но дефект бессрочный и самовоспроизводящийся. + +**Родственные задачи:** ORCH-091 (врущие/застывшие статусы карточки), ORCH-068/086 (терминал-скип +как защита инвариантов). ORCH-094 распространяет идею терминал-скипа на deploy-статусы и закрывает +источник флаппа. + +## 2. Объём (scope) + +### В объёме +- **G1 — устранить источник** PATCH-ей deploy-статуса на задачу, у которой в БД `stage=done` и нет + активного job'а. Терминальная (done) задача в Plane должна стабильно держать `Done` и не получать + `Awaiting`/`Monitoring`. +- **G2 — идемпотентность sync/setter'ов:** если БД=`done`, любой sync/монитор/реконсилятор/прямой + вызов приводит Plane к `Done` (не к промежуточному deploy-статусу) — терминал-скип/схождение, + распространённые на статусы `Monitoring`/`Awaiting` (как ORCH-068/086 для других статусов). +- **G3 — детерминированный конец post-deploy-monitor:** монитор завершается чётко (HEALTHY / N тиков + → Done) и не оставляет «зомби»-таймеров, переживающих завершение задачи/рестарт; тики монитора + привязаны к активному job'у в БД (нет job → нет тиков, нет статус-PATCH). +- **G4 — наблюдаемость:** лог однозначно показывает, **кто и почему** ставит deploy-статус + (caller/функция + причина), для будущей диагностики таких флаппов. +- Инструментальная локализация фактического актора флаппа на проде (воспроизведение на 061) и его + документирование (что это было) — в рамках выполнения задачи (developer/architect). + +### Вне объёма +- Изменение конвейера стадий (`STAGE_TRANSITIONS`), состава `QG_CHECKS`, семантики machine-verdict + ключей (`deploy_status:`/`staging_status:`/…) — **не трогать**. +- Изменение рабочего deploy-цикла для **реально деплоящейся** задачи (Phase A→B→C, post-deploy + HEALTHY-окно) — поведение должно сохраниться 1:1 (регресс, AC-4). +- Поведение для не-self-hosting репозиториев (enduro-trails) — нулевая регрессия. +- Архитектурное решение «где именно поставить гард» (на уровне setter'а в `plane_sync` vs на уровне + вызывающего в `stage_engine` vs реконсилятор) — определяет **архитектор** в `06-adr/`. + +## 3. Заинтересованные стороны + +- **Заказчик/репортёр:** Слава (владелец) — обнаружил на ORCH-061 09.06. +- **Затрагивает:** всех наблюдателей доски Plane проекта ORCH (ложная индикация); лимиты Plane API + (вечный флапп жжёт вызовы под общим бот-токеном). +- **Принимает результат:** Owner / CI на финальной стадии конвейера. +- **Особый риск:** self-hosting — правка идёт в инструмент, обслуживающий прод всех проектов из + общего инстанса; рабочий deploy-цикл нельзя сломать. + +## 4. Бизнес-требования (BR) + +- **BR-1** — Терминальная задача (БД `stage=done`, 0 активных job'ов), выставленная в Plane=`Done`, + **остаётся `Done`** и не получает авто-переходов в `Awaiting Deploy`/`Monitoring after Deploy`. +- **BR-2** — Любой источник синхронизации (реконсилятор, монитор, прямой вызов setter'а deploy-статуса) + для задачи с БД=`done` приводит Plane к **`Done` идемпотентно**, а не к промежуточному deploy-статусу; + повторные срабатывания не качают маятник. +- **BR-3** — Post-deploy-monitor имеет **детерминированный конец** (HEALTHY / исчерпание N тиков → Done, + или DEGRADED → Blocked+freeze) и после завершения **не производит ни одного** последующего + статус-PATCH для этой задачи; не оставляет таймера/состояния, переживающего завершение или рестарт. +- **BR-4** — Тики post-deploy-monitor **привязаны к активному job'у** в таблице `jobs`: нет активного + job'а для задачи → нет тиков → нет статус-PATCH. «Зомби»-монитор (тики без соответствующего активного + job'а) исключён. +- **BR-5** — Для **реально деплоящейся** задачи (063-подобной) deploy-окно + `Awaiting → Deploying → Monitoring → Done` работает в точности как раньше (нет регресса). +- **BR-6** — Каждый вызов, выставляющий deploy-статус, оставляет в логе однозначную запись **кто + (функция/путь) и почему** ставит статус (наблюдаемость для будущей диагностики флаппов). +- **BR-7** — Фактический источник флаппа на проде локализован и **задокументирован** (что это было) + в `06-adr/` и/или `CHANGELOG.md`. + +## 5. Нефункциональные требования (NFR) + +- **NFR-1 — never-raise:** вся новая логика (гарды/терминал-скип/идемпотентность) не бросает + исключений в горячих путях; сетевая ошибка Plane при сверке статуса → безопасная деградация + (не флаппить и не падать), а не блокировка конвейера всех проектов. +- **NFR-2 — self-hosting безопасность:** не перезапускать/не ронять прод-контейнер; не трогать + `main`/force-push/прод-деплой; правка не меняет рабочий критический путь self-deploy. +- **NFR-3 — обратимость:** поведение под kill-switch (или иным обратимым флагом) — при выключении + возврат к прежнему поведению; нулевая регрессия для не-self репозиториев. +- **NFR-4 — restart-safe:** состояние монитора/гардов корректно после рестарта контейнера (нет + «воскрешения» тиков для уже завершённой задачи). +- **NFR-5 — `pytest tests/ -q` зелёный**; `STAGE_TRANSITIONS` / `QG_CHECKS` / machine-verdict ключи / + схема БД (если без миграции) — без изменений или строго аддитивно. + +## 6. Допущения и ограничения + +- Допущение: статусы `Monitoring after Deploy` / `Awaiting Deploy` существуют в Plane-проекте ORCH + как реальные статусы (иначе alias-fallback маппит их на базовые UUID — это часть диагностики + терминал-детекта). +- Допущение: бот-токен орка (`daf4d3f4-…`) — единственный актор переходов; внешняя Plane-automation + под другим токеном считается отдельной гипотезой и проверяется при локализации (H-внешнее). +- Ограничение: установленные факты выше **не изобретать** — они верифицированы на проде; точный + актор флаппа требует инструментального воспроизведения (фикс — после локализации). +- Ограничение: правка строго в зоне self-hosting deploy/post-deploy/sync; конвейер и гейты неизменны. + +## 7. Критерии успеха + +Терминальная задача стабильно держит `Done` ≥10 мин без авто-переходов (AC-1); любой sync для done +идемпотентно сходится к `Done` (AC-2); post-deploy-monitor завершается детерминированно и не +оставляет тиков/таймеров (AC-3); рабочий deploy-цикл 063-подобной задачи не регрессирует (AC-4); +never-raise + зелёный pytest + источник флаппа задокументирован (AC-5). Детальные PASS/FAIL — в +`03-acceptance-criteria.md`. + +## 8. Риски + +- Гард терминал-скипа поставлен слишком широко → подавит легитимный `Monitoring` у реально + деплоящейся задачи (регресс AC-4). Митигировать тонкой привязкой к БД `stage=done` + активность job. +- Фактический актор флаппа окажется внешней Plane-automation (вне кода орка) → код-фикс не закроет + G1 полностью; нужно зафиксировать в ADR и, при необходимости, защититься идемпотентным схождением + к Done (BR-2) как буфером. +- Детали — `10-tech-risks.md` (заполняет архитектор). diff --git a/docs/work-items/ORCH-094/02-trz.md b/docs/work-items/ORCH-094/02-trz.md new file mode 100644 index 0000000..3c54c5e --- /dev/null +++ b/docs/work-items/ORCH-094/02-trz.md @@ -0,0 +1,129 @@ +--- +work_item: ORCH-094 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-09 +model_used: claude-opus-4-8 +--- + +# 02 — ТЗ (TRZ): ORCH-094 — устранение флаппа deploy-статусов у терминальной (done) задачи + +Work Item: **ORCH-094** · Repo: **orchestrator** · Стадия: analysis + +> ТЗ описывает **конкретные изменения к реализации**, выведенные из BRD и фактического кода ветки. +> Архитектурное обоснование (ГДЕ ставить гард: setter `plane_sync` vs caller `stage_engine` vs +> реконсилятор) — задача архитектора (`06-adr/`). Здесь — ЧТО должно выполняться и ГДЕ искать. + +## 1. Сводка изменения + +Задача с БД `stage=done` и 0 активных job'ов в Plane стабильно держит `Done`: нужно (а) закрыть +источник, который шлёт ей PATCH-и deploy-статусов (`Awaiting Deploy`/`Monitoring after Deploy`), +(б) сделать выставление любого **deploy-фазового** статуса **терминал-aware / идемпотентным** — +для задачи, чья БД-стадия терминальна (`done`/`cancelled`), любой sync/монитор/прямой вызов +сходится к `Done`, а не к промежуточному статусу, (в) гарантировать детерминированный конец +post-deploy-monitor с привязкой тиков к активному job'у (нет job → нет тиков), (г) добавить +наблюдаемость «кто/почему ставит deploy-статус». + +Изменение **аддитивное, под обратимым флагом, never-raise**, в зоне self-hosting deploy/post-deploy/sync. +`STAGE_TRANSITIONS` / `QG_CHECKS` / machine-verdict ключи — **не трогаются**. + +## 2. Задействованные модули / пути + +| Путь | Действие | Зачем | +|------|----------|-------| +| `src/plane_sync.py` | изменить | Сеттеры `set_issue_awaiting_deploy` (~954), `set_issue_deploying` (~964), `set_issue_monitoring` (~974), `set_issue_done` (~913) — кандидат на единый терминал-aware гард (FR-2). Терминал-детект статуса (группа/UUID, ORCH-068) уже здесь. | +| `src/stage_engine.py` | изменить | Три писателя deploy-статуса: `advance_stage` стр. 404 (`set_issue_monitoring` на `deploy→done`), `_handle_self_deploy_phase_a` стр. 1218, `_handle_self_deploy_phase_b` стр. 1316. `run_post_deploy_monitor` (~1698–1850) — детерминированный конец, привязка к job. `arm_monitor`-вызов (~431). Логирование caller/причины (FR-4). | +| `src/post_deploy.py` | изменить (вероятно) | `arm_monitor` (~388–411), маркеры `armed`/`series`/`done`, `enqueue_job("post-deploy-monitor", …)` — гарантия отсутствия «зомби»-тиков и привязки к активному job (FR-3). | +| `src/reconciler.py` | изменить (вероятно) | F-2 опрашивает только `[to_analyse, approved, rejected]` (стр. ~387). Нет схождения «done-задача на deploy-статусе → Done». Добавить идемпотентное схождение/терминал-детект для deploy-статусов (FR-1/FR-2) ИЛИ убедиться, что гард в setter'е делает это излишним. | +| `src/config.py` | изменить | Kill-switch/флаг новой логики (FR-5). | +| `src/webhooks/plane.py` | прочитать (диагностика) | `handle_issue_updated` (~129–180): подтверждено, что для `Awaiting`/`Monitoring` логирует «no pipeline action» и не переотправляет — echo-loop исключён; править не требуется (если локализация не покажет иное). | +| `tests/test_*` | создать/изменить | Анти-регресс по FR-1…FR-5 (см. `04-test-plan.yaml`). | +| `CHANGELOG.md`, `docs/architecture/README.md`, `CLAUDE.md` | изменить | Документация = golden source; зафиксировать фикс + локализованный источник флаппа (BR-7). | + +> **Трассировка маркеров (CLAUDE.md прав. 9):** перед правкой строк с маркерами `ORCH-066`/`ORCH-068`/ +> `ORCH-086`/`ORCH-036`/`ORCH-059`/`ORCH-071`/`ORCH-088` прочитать их `06-adr/` и не сломать инвариант +> (особенно: deploy→done ставит `Monitoring`, монитор-close ставит `Done`; терминал-скип реконсилятора; +> post-deploy DEGRADED → freeze ORCH-088). + +## 3. Функциональные требования + +### FR-1 — Источник флаппа локализован и устранён +Инструментально воспроизвести флапп на ORCH-061 (или эквивалентной терминальной задаче), определить +**фактического актора** (функция/путь под бот-токеном орка ИЛИ внешняя Plane-automation) и устранить +его так, чтобы терминальная задача не получала deploy-статус-PATCH-ей. +- Зацепки (BR diagnostics): единственные code-писатели — `stage_engine.py:404/1218/1316`; реконсилятор + F-1 done-skip есть, F-2 эти статусы не перебирает; live-overlay `notifications.py` — read-only. +- Если актор — внешняя Plane-automation (вне кода орка), это **фиксируется в ADR** (BR-7) и закрывается + буфером FR-2 (идемпотентное схождение к Done гасит маятник на стороне орка). +- Привязка: BR-1, BR-7. + +### FR-2 — Терминал-aware идемпотентность выставления deploy-статуса +Любая попытка выставить **deploy-фазовый** статус (`Awaiting Deploy`/`Deploying`/`Monitoring after +Deploy`) для задачи, чья БД-стадия **терминальна** (`stage IN ('done','cancelled')`), должна вместо +этого привести Plane к `Done` (для `done`) либо к корректному терминалу (для `cancelled`) — +идемпотентно. Повторные вызовы не качают маятник: уже-`Done` → no-op. +- Гард — терминал-aware (по БД-стадии задачи, не по живому Plane-статусу), чтобы НЕ подавлять + легитимный `Monitoring` у реально деплоящейся (нетерминальной) задачи (BR-5/AC-4). +- Реализация-кандидат (решает архитектор): единая точка в setter'ах `plane_sync` (требует доступа к + БД-стадии по `work_item_id`) ИЛИ в caller'ах `stage_engine`/`reconciler`. ТЗ требует **результат**: + done-задача сходится к Done из любого пути. +- never-raise: невозможность определить стадию/сетевая ошибка → безопасная деградация (не флаппить). +- Привязка: BR-1, BR-2. + +### FR-3 — Детерминированный конец post-deploy-monitor + привязка тиков к активному job +- Монитор завершается детерминированно: HEALTHY+исчерпание `post_deploy_budget` тиков → `set_issue_done` + + маркер `done`; DEGRADED → штатный путь (Blocked/freeze ORCH-088); после завершения — **ни одного** + последующего статус-PATCH (маркер `done` — идемпотентный страж, ~стр. 1729). +- Тик монитора **обязан** проверять, что задача не терминальна и для неё есть основание тикать (нет + активного основания/job → тик no-op, новый тик не ставится в очередь). «Зомби»-тик (тик без + соответствующего активного job'а/при БД=done) → немедленный no-op без статус-PATCH. +- Гарантировать, что `arm_monitor` не может быть вызван/перезапущен для задачи, уже находящейся в `done`, + способом, который заново ставит `Monitoring` (повторный `deploy→done` re-drive). +- restart-safe: после рестарта контейнера нет воскрешения тиков для завершённого окна. +- Привязка: BR-3, BR-4, NFR-4. + +### FR-4 — Наблюдаемость выставления deploy-статуса +Каждый вызов, выставляющий deploy-фазовый статус, логирует структурно: **work_item, caller +(функция/путь), целевой статус, причина/триггер, БД-стадия задачи на момент вызова**. Достаточно, +чтобы по логу однозначно определить «кто и почему» при будущем флаппе. Терминал-aware-подавление +(FR-2) тоже логируется (что подавили и почему). +- Привязка: BR-6, G4. + +### FR-5 — Обратимость и совместимость +- Новая логика — под kill-switch/флагом в `config.py` (env-override); `False` → прежнее поведение + 1:1 (нулевая регрессия). +- Условность self-hosting, как ORCH-035/036/043/088: для не-self репозиториев — no-op / прежнее + поведение. +- Привязка: NFR-3, BR-5. + +## 4. Изменения API + +Нет новых внешних эндпоинтов конвейера. Допустимо (на усмотрение архитектора) аддитивное read-only +поле наблюдаемости в `GET /queue` (напр. блок `post_deploy`/`deploy_status_guard` со счётчиками +подавлений), по образцу существующих блоков `serial_gate`/`reconcile`/`reaper`. Не обязательно. + +## 5. Изменения схемы БД + +Ожидается **без миграции схемы**: терминал-aware гард читает существующую `tasks.stage`; привязка +тиков к job — существующая таблица `jobs`; состояние монитора — существующие sentinel-файлы +(`post_deploy.py`). Если архитектор сочтёт необходимым durable-счётчик/маркер — строго аддитивно +(`_ensure_column`, по образцу ORCH-088/090), без изменения существующих колонок. + +## 6. Требования к новым/изменённым QG checks + +**Нет.** `QG_CHECKS` и `check_*` (включая `check_deploy_status`/`check_staging_status`) — **не +трогаются**; machine-verdict ключи (`deploy_status:`/`staging_status:`/…) — байт-в-байт. ORCH-094 — +фикс индикации/идемпотентности sync, не гейт. + +## 7. Совместимость / регресс + +- **Kill-switch** (FR-5): выключение → прежнее поведение 1:1. +- **Регресс деплоя (AC-4):** рабочий цикл 063-подобной задачи `Awaiting→Deploying→Monitoring→Done` + сохраняется — гард срабатывает строго на терминальной БД-стадии, нетерминальная задача проходит + как раньше. +- **Не-self репозитории:** условность self-hosting → нулевая регрессия (enduro-trails). +- **`STAGE_TRANSITIONS`/`QG_CHECKS`/machine-verdict/схема БД** — без изменений (или строго аддитивно). +- **never-raise / self-hosting безопасность:** не трогать `main`/force-push/прод-контейнер/детач-деплой. +- **Артефакты pipeline:** обновляются `CHANGELOG.md`, обзорные доки (`README.md`/`docs/architecture/ + README.md`), `CLAUDE.md`; `06-adr/ADR-NNN-…` с локализованным источником флаппа (BR-7). diff --git a/docs/work-items/ORCH-094/03-acceptance-criteria.md b/docs/work-items/ORCH-094/03-acceptance-criteria.md new file mode 100644 index 0000000..2fe1c9a --- /dev/null +++ b/docs/work-items/ORCH-094/03-acceptance-criteria.md @@ -0,0 +1,94 @@ +--- +work_item: ORCH-094 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-09 +model_used: claude-opus-4-8 +--- + +# 03 — Критерии приёмки (Acceptance Criteria): ORCH-094 — флапп deploy-статусов у терминальной (done) задачи + +Work Item: **ORCH-094** · Repo: **orchestrator** · Стадия: analysis + +Формат: каждый критерий имеет **PASS** (что должно быть истинно для приёмки) и **FAIL** (что +считается провалом). Reviewer/CI проверяет их буквально по файлам репозитория и/или прод-проверкой. + +--- + +## AC-1 — Терминальная задача стабильно держит Done + +**Условие:** задача с БД `stage=done` и 0 активных job'ов, выставленная в Plane=`Done`, наблюдается +≥10 минут (воспроизводящий тест на 061-подобной фикстуре и/или прод-проверка на ORCH-061). +- **PASS:** за окно наблюдения **ни одного** авто-перехода в `Awaiting Deploy`/`Monitoring after + Deploy`; статус остаётся `Done`. В тесте: после выставления `Done` ни один кодовый путь орка не + порождает PATCH deploy-статуса для этой задачи. +- **FAIL:** зафиксирован хотя бы один авто-переход done-задачи в `Awaiting`/`Monitoring`, либо флапп + продолжается. + +--- + +## AC-2 — Идемпотентное схождение к Done для done-задачи + +**Условие:** для задачи с БД `stage IN ('done','cancelled')` инициируется любой источник sync +(реконсилятор-тик, монитор-тик, прямой вызов setter'а deploy-статуса). +- **PASS:** результат — `Done` (для `done`) / корректный терминал (для `cancelled`); промежуточный + deploy-статус (`Awaiting`/`Deploying`/`Monitoring`) **не** выставляется; повторный вызов на + уже-`Done` — no-op (без PATCH-маятника). Подавление логируется (что/почему). +- **FAIL:** sync для done-задачи выставляет промежуточный deploy-статус, либо повторные вызовы + качают `Done ⟷ deploy-статус`. + +--- + +## AC-3 — Детерминированный конец post-deploy-monitor, без «зомби»-тиков + +**Условие:** post-deploy-monitor отрабатывает свой жизненный цикл (HEALTHY до исчерпания +`post_deploy_budget` тиков, либо DEGRADED). +- **PASS:** по достижении HEALTHY/N-тиков → `set_issue_done` + маркер `done`; **после завершения — + 0 последующих статус-PATCH** для этой задачи (тест: монитор отработал → последующих + `set_issue_*`-вызовов нет). Тик при БД=`done`/отсутствии активного основания → немедленный no-op + без PATCH. После рестарта контейнера тики завершённого окна не воскресают. +- **FAIL:** после завершения монитора фиксируется хотя бы один статус-PATCH; либо «зомби»-тик + выполняется без активного job'а/при БД=done и шлёт статус; либо `arm_monitor` повторно ставит + `Monitoring` уже-done-задаче. + +--- + +## AC-4 — Регресс: рабочий deploy-цикл реально деплоящейся задачи + +**Условие:** реально деплоящаяся 063-подобная задача проходит self-deploy. +- **PASS:** последовательность статусов `Awaiting Deploy → Deploying → Monitoring after Deploy → + Done` работает в точности как до ORCH-094; Phase A/B/C, merge-gate, post-deploy HEALTHY-окно, + freeze-на-DEGRADED (ORCH-088) — не затронуты; терминал-aware гард (FR-2) **не** подавляет + легитимный `Monitoring` у нетерминальной задачи. +- **FAIL:** любой шаг рабочего deploy-цикла нетерминальной задачи изменён/подавлён/сломан. + +--- + +## AC-5 — Наблюдаемость, безопасность, документация, зелёный pytest + +**Условие:** реализация завершена. +- **PASS:** + - Лог однозначно показывает **кто (функция/путь) и почему** ставит deploy-статус, и что/почему + подавлено терминал-aware гардом (FR-4). + - never-raise: новая логика не бросает исключений в горячих путях; сетевая ошибка Plane → безопасная + деградация. Не трогаются `main`/force-push/прод-контейнер/детач-деплой. + - `STAGE_TRANSITIONS` / `QG_CHECKS` / machine-verdict ключи — без изменений; новая логика под + kill-switch (`False` → прежнее поведение 1:1); не-self репозитории не затронуты. + - `pytest tests/ -q` зелёный; добавлены тесты по `04-test-plan.yaml`. + - **Источник флаппа задокументирован** (что это было) в `06-adr/ADR-NNN-…` + `CHANGELOG.md`; + обновлены `CLAUDE.md` / `docs/architecture/README.md` (golden source). +- **FAIL:** нет логирования caller/причины; new-логика бросает/без флага; тронуты гейты/verdict-ключи; + красный pytest; источник флаппа не задокументирован; затронут не-self репозиторий. + +--- + +## Сводная матрица AC ↔ FR/BR + +| AC | Покрывает | +|----|-----------| +| AC-1 | BR-1 / FR-1 | +| AC-2 | BR-2 / FR-2 | +| AC-3 | BR-3, BR-4 / FR-3 | +| AC-4 | BR-5 / FR-2, FR-5 | +| AC-5 | BR-6, BR-7 / FR-4, FR-5, NFR-1…NFR-5 | diff --git a/docs/work-items/ORCH-094/04-test-plan.yaml b/docs/work-items/ORCH-094/04-test-plan.yaml new file mode 100644 index 0000000..2bf6883 --- /dev/null +++ b/docs/work-items/ORCH-094/04-test-plan.yaml @@ -0,0 +1,97 @@ +work_item: ORCH-094 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-09 +model_used: claude-opus-4-8 +title: "Тест-план: терминальная (done) задача не флаппит deploy-статусы, держит Done" +framework: pytest +scope: > + Покрывается: терминал-aware идемпотентность выставления deploy-статусов + (Awaiting/Deploying/Monitoring) для задач с БД stage=done/cancelled; детерминированный + конец post-deploy-monitor и отсутствие "зомби"-тиков/статус-PATCH после завершения; + привязка тиков монитора к активному job; наблюдаемость caller/причины; обратимость + (kill-switch) и регресс рабочего deploy-цикла реально деплоящейся задачи. + Вне покрытия: изменение STAGE_TRANSITIONS/QG_CHECKS/machine-verdict ключей (не трогаются); + поведение не-self репозиториев (проверяется как нулевая регрессия). Точный актор флаппа на + проде локализуется инструментально (developer) и фиксируется в ADR — на это отдельный + smoke/прод-чек, не unit. +notes: > + Полный регресс tests/ должен оставаться зелёным (pytest tests/ -q). Setter'ы Plane и сетевые + вызовы — мокать (никаких реальных PATCH в Plane из тестов). Регресс = любой авто-переход + done-задачи в deploy-статус, либо статус-PATCH после завершения монитора, либо подавление + легитимного Monitoring у нетерминальной задачи. Тесты опираются на фикстуры задач со стадиями + done/deploy и на счётчики вызовов set_issue_* (через мок). + +tests: + - id: TC-01 + type: unit + description: "deploy-статус для задачи с БД stage=done сходится к Done: попытка set_issue_monitoring/awaiting/deploying при terminal-стадии выставляет Done (или no-op, если уже Done), а не промежуточный статус." + module: tests/test_deploy_status_terminal_guard.py + expected: PASS + + - id: TC-02 + type: unit + description: "Идемпотентность: повторный вызов терминал-aware setter'а на уже-Done задаче — no-op (0 дополнительных PATCH), маятник Done<->deploy-статус не возникает." + module: tests/test_deploy_status_terminal_guard.py + expected: PASS + + - id: TC-03 + type: unit + description: "Нетерминальная задача (stage=deploy) не подавляется: set_issue_monitoring/awaiting/deploying проходит штатно (регресс AC-4)." + module: tests/test_deploy_status_terminal_guard.py + expected: PASS + + - id: TC-04 + type: unit + description: "Kill-switch выключен -> прежнее поведение 1:1 (терминал-aware гард не вмешивается); включён -> done-задача сходится к Done." + module: tests/test_deploy_status_terminal_guard.py + expected: PASS + + - id: TC-05 + type: unit + description: "never-raise: при невозможности определить БД-стадию / сетевой ошибке Plane сеттер деградирует безопасно (не флаппит, не бросает исключение)." + module: tests/test_deploy_status_terminal_guard.py + expected: PASS + + - id: TC-06 + type: unit + description: "post-deploy-monitor: после завершения окна (HEALTHY, ticks==budget -> set_issue_done + маркер done) последующих статус-PATCH для задачи нет (0 set_issue_* вызовов)." + module: tests/test_post_deploy_monitor_termination.py + expected: PASS + + - id: TC-07 + type: unit + description: "post-deploy-monitor тик при БД stage=done / отсутствии активного основания -> немедленный no-op без статус-PATCH и без постановки следующего тика ('зомби'-тик исключён)." + module: tests/test_post_deploy_monitor_termination.py + expected: PASS + + - id: TC-08 + type: unit + description: "arm_monitor не пере-арминг для задачи, уже находящейся в done: повторный deploy->done re-drive не выставляет Monitoring заново (маркер armed/done -> no-op)." + module: tests/test_post_deploy_monitor_termination.py + expected: PASS + + - id: TC-09 + type: unit + description: "Наблюдаемость: каждый вызов выставления deploy-статуса логирует work_item, caller/путь, целевой статус, причину и БД-стадию; подавление терминал-aware гардом тоже логируется." + module: tests/test_deploy_status_observability.py + expected: PASS + + - id: TC-10 + type: integration + description: "Реконсилятор/sync для задачи с БД=done и Plane=Monitoring приводит к Done идемпотентно (а не к промежуточному deploy-статусу) и не качает маятник на повторных тиках." + module: tests/test_reconciler_done_deploy_convergence.py + expected: PASS + + - id: TC-11 + type: integration + description: "Регресс рабочего deploy-цикла: реально деплоящаяся (нетерминальная) 063-подобная задача проходит Awaiting -> Deploying -> Monitoring -> Done без подавления (Phase A/B/C, post-deploy HEALTHY-окно как раньше)." + module: tests/test_self_deploy_cycle_regression.py + expected: PASS + + - id: TC-12 + type: integration + description: "Не-self репозиторий (enduro-подобный): нулевая регрессия — терминал-aware гард deploy-статусов инертен (условность self-hosting)." + module: tests/test_deploy_status_terminal_guard.py + expected: PASS diff --git a/docs/work-items/ORCH-094/06-adr/ADR-001-terminal-window-aware-deploy-status-guard.md b/docs/work-items/ORCH-094/06-adr/ADR-001-terminal-window-aware-deploy-status-guard.md new file mode 100644 index 0000000..c5cae1d --- /dev/null +++ b/docs/work-items/ORCH-094/06-adr/ADR-001-terminal-window-aware-deploy-status-guard.md @@ -0,0 +1,232 @@ +--- +work_item: ORCH-094 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-09 +model_used: claude-opus-4-8 +--- + +# ADR-001: Terminal-window-aware гард выставления deploy-фазовых статусов Plane + +Work Item: **ORCH-094** — терминальная (done) задача флаппит deploy-статусы в Plane +(`Awaiting Deploy ⟷ Monitoring after Deploy`), не держит `Done`. +Стадия: **architecture** +Сквозная регистрация: **`docs/architecture/adr/adr-0028-terminal-window-aware-deploy-status-guard.md`** +(кросс-каттинг: правит общие сеттеры `plane_sync` + переупорядочивает маркированный блок +`next_stage == "done"` ORCH-021/066). + +## Статус +Proposed + +## Контекст + +Сверено по коду ветки `feature/ORCH-094-…`: + +- **Три code-писателя deploy-фазовых статусов** — все в `src/stage_engine.py`, все вызывают + тонкие сеттеры `src/plane_sync.py`, которые делегируют в общий `_set_issue_state_direct` + (PATCH issue.state; never-raise; **БД-стадию не читает**): + - `set_issue_awaiting_deploy` (Phase A, `stage_engine.py:1218`), + - `set_issue_deploying` (Phase B, `stage_engine.py:1316`), + - `set_issue_monitoring` (terminal-sync `deploy → done` для self-hosting, `stage_engine.py:404`). + - `set_issue_done` (`plane_sync.py:913`) — **терминальная цель**, отдельно. +- **Критический факт ordering'а:** в `advance_stage` строка **369** `update_task_stage(task_id, "done")` + пишет `tasks.stage='done'` **РАНЬШЕ**, чем строка **404** `set_issue_monitoring(...)`. То есть в + момент **легитимного** первого выставления `Monitoring after Deploy` задача в БД **уже `done`**. + Пост-деплой-окно ORCH-021 — это by-design индикация поверх уже-терминальной (`done`) задачи + («ответственность ЗА `done`»). ⇒ **наивный гард «stage==done → редирект на Done» подавил бы + легитимный `Monitoring` → регресс AC-4.** +- **Арм пост-деплой-монитора** (`stage_engine.py:431` → `post_deploy.arm_monitor`) выполняется + **ПОСЛЕ** строки 404. Sentinel `ARMED` пишется в `arm_monitor`; окно закрывается sentinel'ом + `DONE` (`post_deploy.mark_done`); идемпотентный страж `has_marker(...DONE)` в + `run_post_deploy_monitor` (~1729). +- **Симптом (верифицирован живьём на ORCH-061, task 47, done с 07.06):** Plane не держит `Done` — + непрерывный флапп `Awaiting ⟷ Monitoring` парами каждые ~сек, 273 активности, само не затихает. + В БД **нет активного post-deploy-monitor** для task 47 (окно 15 мин давно закрыто); реконсилятор + F-1 пропускает `done`/`cancelled`, F-2 опрашивает только `[to_analyse, approved, rejected]` — + механизма «привести застрявшую на deploy-статусе done-задачу обратно к Done» нет. Актор всех 273 + переходов — бот-токен орка (`daf4d3f4-…`), т.е. PATCH-и шлёт **что-то под токеном орка**, не + привязанное к активной task/job. Точный актор подлежит инструментальной локализации (FR-1, + developer); фикс должен быть **буфером, гасящим маятник на стороне орка независимо от актора**. + +**Почему «как есть» не годится:** сеттеры deploy-статусов терминал-слепы — любой повторный вызов +(стейл-job, двойной webhook, неизвестный внутренний путь под бот-токеном) перезаписывает `Done` +обратно на промежуточный deploy-статус, и наоборот, бесконечно. Нет ни идемпотентного схождения к +`Done` для терминальной задачи, ни наблюдаемости «кто/почему» ставит статус. + +## Решение + +### Сводка + +Вводим **единый terminal-window-aware гард на самом низком чокпоинте** — на входе трёх +deploy-фазовых сеттеров `plane_sync`. Решение принимает **новый leaf-модуль +`src/deploy_status_guard.py`** (чистая, never-raise, config-gated логика; по образцу +`serial_gate.py`/`labels.py`/`cancel.py`), сеттеры лишь исполняют вердикт. Ключевой инвариант: +**deploy-фазовый статус легитимен ⇔ задача нетерминальна ИЛИ (`done` И активно пост-деплой-окно)**; +иначе — идемпотентное схождение к `Done`. Чтобы легитимный первый `Monitoring` на строке 404 +проходил, **арм-блок переносится перед terminal-sync-блоком** (предикат «окно активно» становится +истинным до выставления `Monitoring`). Всё под kill-switch, аддитивно, в зоне self-hosting; реестры +конвейера не тронуты. + +### D1 — Где гард: единый чокпоинт в deploy-фазовых сеттерах `plane_sync` + +Гард ставится на входе **`set_issue_awaiting_deploy` / `set_issue_deploying` / `set_issue_monitoring`** +(а НЕ в caller'ах `stage_engine`). Это перехватывает **любой** путь к этим статусам — известные +(stage_engine), будущие и **неизвестный актор под бот-токеном** (если он проходит через код орка) — +одной точкой. `set_issue_done` **не гардится** (это цель схождения). Привязка: **FR-2, BR-1, BR-2**. + +> Альтернатива «гард в caller'ах stage_engine» отвергнута: не ловит неизвестный/стейл путь, который +> и есть подозреваемый источник 061-флаппа; размазывает инвариант по трём местам. См. «Альтернативы». + +### D2 — Предикат легитимности: терминал **И окно**, не только стадия + +Вердикт `deploy_status_guard.decide(work_item_id, target_status) -> ALLOW | CONVERGE_DONE | SUPPRESS`: + +1. `not settings.deploy_status_guard_enabled` → **ALLOW** (kill-switch off ⇒ поведение 1:1). +2. `task = `; `task is None` → **ALLOW** (чужой/не наш issue — не вмешиваемся). +3. `not deploy_status_guard.applies(task.repo)` → **ALLOW** (не-self репо ⇒ нулевая регрессия; для них + `Monitoring`/`Awaiting`/`Deploying` и так не выставляются — terminal-sync идёт сразу в `Done`). +4. `stage = task.stage`; `stage NOT IN ('done','cancelled')` → **ALLOW** (нетерминальная задача — + легитимный рабочий deploy-цикл; **AC-4**). +5. `stage == 'cancelled'` → **SUPPRESS** (не штампуем deploy-статус поверх терминала `cancelled`; + cancel-flow ORCH-090 уже привёл Plane к своему терминалу — гард лишь не затирает его). +6. `stage == 'done'`: + - `target == 'monitoring'` **И** `post_deploy.window_active(repo, work_item_id)` → **ALLOW** + (легитимное пост-деплой-окно — `Monitoring` корректен; **AC-4**); + - иначе → **CONVERGE_DONE** (для `done` `Awaiting`/`Deploying` всегда спуриозны — Phase A/B + случаются строго **до** `deploy → done`; и `Monitoring` при закрытом/неарм'ленном окне — + спуриозен, как 061). +7. **Любое исключение / невозможность определить стадию** → **ALLOW** + `logger.warning` + (never-raise, fail-safe к прежнему поведению; **NFR-1**). БД-чтение локальное (SQLite) и надёжное — + в штатном случае стадия читается, маятник не возникает. + +Сеттер исполняет вердикт: `ALLOW` → штатный PATCH; `CONVERGE_DONE` → `set_issue_done(work_item_id)` +(идемпотентно — уже-`Done` ⇒ no-op PATCH-эквивалент); `SUPPRESS` → ничего не патчим. Привязка: +**FR-2, BR-1, BR-2, AC-1, AC-2, AC-4**. + +**Новый helper** `post_deploy.window_active(repo, wi) -> bool` = `has_marker(ARMED) and not +has_marker(DONE)` (never-raise; restart-safe — sentinel'ы на диске переживают рестарт; **NFR-4**). + +### D3 — Перенос арм-блока перед terminal-sync (чтобы D2 пропускал легитимный первый `Monitoring`) + +В `advance_stage`, внутри ветки `next_stage == "done"`, **арм-блок** (`post_deploy.arm_monitor`, +сейчас стр. 431) перемещается **выше** terminal-sync-блока (`set_issue_monitoring`, стр. 404). После +переноса в момент строки 404: `ARMED` уже записан, `DONE` отсутствует ⇒ `window_active==True` ⇒ +вердикт **ALLOW** ⇒ легитимный `Monitoring` проходит как раньше. Re-drive `deploy → done` **после** +закрытия окна (`DONE` присутствует) ⇒ `window_active==False` ⇒ **CONVERGE_DONE** (не воскрешает +`Monitoring`). + +Перенос безопасен: `arm_monitor` лишь пишет sentinel + ставит отложенный job — не зависит ни от +Plane-статуса, ни от merge-lease (release остаётся после terminal-sync). Инварианты ORCH-021 +(идемпотентный арм по `ARMED`) и ORCH-066 (`deploy → done` для self ⇒ `Monitoring`, не `Done`) +сохранены. Привязка: **AC-4, BR-5**; маркеры `ORCH-021`/`ORCH-066` (прочитаны: `06-adr/ADR-001`, +`adr-0010`). + +> Альтернатива «bypass-флаг `force=True` на доверенном вызове 404 вместо переноса» отвергнута: плодит +> два определения «легитимности» и доверенный обход; перенос оставляет **один** предикат «окно активно». + +### D4 — Харднинг пост-деплой-монитора: нет «зомби»-тиков/PATCH после закрытия окна + +`run_post_deploy_monitor` (`stage_engine.py` ~1698): сохранить существующий идемпотентный страж +`has_marker(...DONE)` (~1729; первым — ранний `return` без PATCH/реэнкью). Аддитивно: тик +**no-op без PATCH и без перепостановки**, если задача стала терминальной аномально (`stage == +'cancelled'` мид-окно → закрыть окно `mark_done`, без статус-PATCH). Перепостановка тика остаётся +строго при `HEALTHY and ticks < budget` — тики **привязаны к активному job'у** (тик и есть job; нет +job → нет тика). После закрытия окна (`DONE`) или исчерпания бюджета — **0 последующих** статус-PATCH; +любой стейл-вызов `set_issue_monitoring` теперь добивается гардом D2 (`window_active==False` → +CONVERGE_DONE). `arm_monitor` уже идемпотентен по `ARMED` (повторный арм done-задачи → no-op). Привязка: +**FR-3, BR-3, BR-4, AC-3, NFR-4**. + +### D5 — Наблюдаемость «кто/почему» (FR-4) + +Каждый вердикт гарда логируется структурно одной записью: `work_item`, `caller` (короткая причина — +аддитивный BC-kwarg `reason: str | None = None` у трёх сеттеров; call-site передаёт напр. +`"advance:deploy->done"`/`"phase_a"`/`"phase_b"`/`"monitor-tick"`), `target_status`, `db_stage`, +`window_active`, итоговый вердикт (`ALLOW`/`CONVERGE_DONE`/`SUPPRESS`). Подавление/схождение +(`CONVERGE_DONE`/`SUPPRESS`) логируется **явно** («что подавили и почему»). Достаточно, чтобы по +логу однозначно атрибутировать будущий флапп. Привязка: **FR-4, BR-6, AC-5**. + +### D6 — Обратимость, скоуп, флаги (FR-5) + +`src/config.py` (по образцу ORCH-088/090): +- `deploy_status_guard_enabled: bool = True` — env `ORCH_DEPLOY_STATUS_GUARD_ENABLED` (kill-switch; + `False` → сеттеры терминал-слепы, поведение **1:1** прежнее). +- `deploy_status_guard_repos: str = ""` — env `ORCH_DEPLOY_STATUS_GUARD_REPOS` (CSV; **пусто → + self-hosting only**). `applies(repo)` (локальный, без сети) — единственная точка скоупа. + +Дефолт `enabled=True` + `repos=""` ⇒ активен только для self-hosting (`orchestrator`), где deploy-фазовые +статусы вообще выставляются; не-self репо (enduro-trails) гард не трогает (D2 шаг 3). Привязка: **NFR-3, +BR-5, FR-5, AC-4, AC-5**. + +### D7 — Что НЕ трогаем (инварианты) + +`STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict ключи +(`deploy_status:`/`staging_status:`/`security_status:`) — **байт-в-байт**. Схема БД — **без миграции** +(гард читает существующую `tasks.stage`; окно — существующие sentinel'ы `post_deploy.py`; привязка к +job — существующая таблица `jobs`). `main`/force-push/прод-контейнер/detached-деплой — **не трогаются**. +Рабочий критический путь self-deploy (Phase A→B→C, merge-gate, freeze-на-DEGRADED ORCH-088) — +сохранён 1:1. Реконсилятор F-1/F-2 — **без изменений** (гард на сеттере субсумирует «sync → Done»: +любой путь, дёрнувший deploy-сеттер для done-задачи, сходится к `Done`). Привязка: **NFR-2, NFR-5, AC-5**. + +### D8 — Лукап задачи по `work_item_id` (реализационная заметка для developer) + +Сеттеры принимают `work_item_id` (напр. `"ORCH-061"`). В `src/db.py` существующий +`get_task_by_plane_id` матчит `plane_id`/`plane_issue_id` (UUID-ы), **не** человекочитаемый +`work_item_id`. Developer добавляет минимальный **read-only** аксессор +`get_task_by_work_item_id(work_item_id)` (`SELECT * FROM tasks WHERE work_item_id = ?`; живой ряд +матчит точно — тумбстоны ORCH-090 имеют суффикс `#cancelled-`), **без изменения схемы**. Один +локальный SELECT отдаёт и `repo`, и `stage` для D2. + +## Альтернативы + +- **Гард в caller'ах `stage_engine` (а не в сеттерах)** — отвергнуто: не ловит неизвестный/стейл + актор под бот-токеном (вероятный источник 061-флаппа), размазывает инвариант по трём врезкам, + слабее как буфер BR-2 «сходимость из любого пути». +- **Наивный гард «stage==done → редирект на Done» (без предиката окна)** — отвергнуто: подавляет + легитимный пост-деплой `Monitoring` (он by-design поверх уже-`done` задачи, стр. 369 < 404) ⇒ + прямой регресс **AC-4**. +- **Bypass-флаг `force=True` на доверенном вызове 404** (вместо переноса арм-блока) — отвергнуто: + два определения легитимности + доверенный обход; перенос даёт один предикат «окно активно». +- **Активная сходимость в реконсиляторе (F-2 опрашивает Awaiting/Monitoring → set_issue_done)** — + отвергнуто как **основной** механизм (лишний Plane-polling, правка маркированного F-2). Гард на + сеттере уже гасит непрерывный флапп (каждый вызов актора сходится к `Done` за один цикл). Возможен + как **необязательный** follow-up для разовой зачистки quiescent-застрявшего статуса (вне scope — + такой кейс чинится разовым ручным sync; наблюдаемый дефект — непрерывный флапп, который буфер + покрывает). +- **Колонка-маркер в `tasks` для состояния окна** — отвергнуто: миграция на проде; sentinel'ы + `post_deploy.py` уже restart-safe (как ORCH-021/036). + +## Последствия + +- **+** Терминальная (`done`) задача стабильно держит `Done`: любой deploy-сеттер для неё сходится к + `Done` идемпотентно, маятник гаснет за один цикл независимо от актора (буфер BR-1/BR-2, AC-1/AC-2). +- **+** Легитимный пост-деплой `Monitoring` сохранён точно (предикат «окно активно» + перенос + арм-блока); рабочий deploy-цикл 1:1 (AC-4). +- **+** Наблюдаемость: лог однозначно атрибутирует «кто/почему» при будущем флаппе (AC-5). +- **+** Единый низкий чокпоинт ловит и неизвестный внутренний путь под бот-токеном. +- **−** Один локальный SELECT (`tasks`) на каждый deploy-фазовый PATCH-вызов self-репо. Митигейшн: + читается тот же ряд, что даёт `repo` для `applies`; SQLite-чтение пренебрежимо против сетевого PATCH; + для не-self/выключенного флага — ранний ALLOW без лукапа окна. +- **−** Если фактический актор флаппа — **внешняя** Plane-automation под другим токеном (вне кода + орка), code-фикс не закроет G1 полностью. Митигейшн: гард — буфер на стороне орка; локализация + актора (FR-1) и итог документируются (BR-7) — этот ADR фиксирует гипотезу «под бот-токеном орка». +- **−** Перенос арм-блока меняет порядок внутри маркированного блока ORCH-021/066. Митигейшн: + инварианты обоих ADR проверены сохранёнными (D3); анти-регресс — TC-11 (рабочий цикл) + структурные + тесты. +- **Откат:** `ORCH_DEPLOY_STATUS_GUARD_ENABLED=false` → сеттеры терминал-слепы, поведение 1:1 + прежнее (D2 шаг 1). Полный откат — revert ветки (перенос арм-блока + leaf + config + сеттер-врезки). + +## Ссылки + +- BRD: `docs/work-items/ORCH-094/01-brd.md` +- TRZ: `docs/work-items/ORCH-094/02-trz.md` +- Acceptance: `docs/work-items/ORCH-094/03-acceptance-criteria.md` +- Tech-risks: `docs/work-items/ORCH-094/10-tech-risks.md` +- Сквозной ADR: `docs/architecture/adr/adr-0028-terminal-window-aware-deploy-status-guard.md` +- Сверено по коду: `src/stage_engine.py` (369/404/431/1218/1316/~1698-1729), + `src/plane_sync.py` (913/954/964/974, `_set_issue_state_direct`), `src/post_deploy.py` + (`arm_monitor`/`has_marker`/`ARMED`/`DONE`/`state_dir`), `src/reconciler.py` (F-1/F-2), + `src/config.py` (флаги ORCH-088/021/036), `src/db.py` (`get_task_by_plane_id`). +- Маркеры (прочитаны, не сломаны): ORCH-021 (`adr-0010` / `06-adr/ADR-001`), ORCH-066 + (`06-adr/ADR-001-plane-status-model`), ORCH-086/068 (терминал-скип), ORCH-088 (freeze), + ORCH-090 (cancelled-терминал). diff --git a/docs/work-items/ORCH-094/10-tech-risks.md b/docs/work-items/ORCH-094/10-tech-risks.md new file mode 100644 index 0000000..0ba5fdb --- /dev/null +++ b/docs/work-items/ORCH-094/10-tech-risks.md @@ -0,0 +1,90 @@ +--- +work_item: ORCH-094 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-09 +model_used: claude-opus-4-8 +--- + +# 10 — Технические риски: ORCH-094 — terminal-window-aware гард deploy-статусов + +Work Item: **ORCH-094** · Repo: **orchestrator** · Стадия: architecture + +Формат: каждый риск — **вероятность × влияние**, причина, **митигейшн**, привязка к AC/ADR-решению. + +--- + +## R-1 — Гард подавляет ЛЕГИТИМНЫЙ `Monitoring` у реально деплоящейся задачи (регресс AC-4) +- **Вероятность:** средняя (без точного предиката — высокая) · **Влияние:** высокое. +- **Причина:** `update_task_stage("done")` (стр. 369) выполняется **раньше** `set_issue_monitoring` + (стр. 404) ⇒ в момент легитимного `Monitoring` задача в БД уже `done`. Наивный гард + «stage==done → Done» затёр бы легитимную индикацию. +- **Митигейшн:** предикат **«терминал И НЕ активное окно»** (D2 шаг 6) + **перенос арм-блока перед + terminal-sync** (D3): `window_active==True` на стр. 404 ⇒ ALLOW. Анти-регресс — **TC-11** + (рабочий цикл `Awaiting→Deploying→Monitoring→Done` без подавления) + **TC-03** (stage=deploy + проходит). + +## R-2 — Фактический актор флаппа — внешняя Plane-automation (вне кода орка) +- **Вероятность:** низкая · **Влияние:** среднее (G1 закрыт не полностью). +- **Причина:** все 273 перехода — под бот-токеном орка; гипотеза H-внешнее не исключена до + инструментальной локализации (FR-1). +- **Митигейшн:** гард — **буфер на стороне орка** (BR-2): если PATCH идёт через код орка — гасится; + developer локализует актора (FR-1) и фиксирует в ADR/CHANGELOG (BR-7). Если актор реально внешний — + это документируется как known-limitation, гард остаётся защитой от внутренних путей. + +## R-3 — Перенос арм-блока ломает инвариант ORCH-021/066 +- **Вероятность:** низкая · **Влияние:** высокое (self-hosting прод). +- **Причина:** правка порядка внутри маркированного блока `next_stage == "done"`. +- **Митигейшн:** `arm_monitor` не зависит от Plane-статуса/merge-lease (пишет sentinel + ставит + отложенный job); merge-lease release остаётся после terminal-sync; идемпотентность арма по `ARMED` + и инвариант ORCH-066 (`deploy→done` self ⇒ `Monitoring`) сохранены (D3). Прочитаны `adr-0010` + + `06-adr/ADR-001-plane-status-model`. Тесты TC-06/TC-08 + TC-11. + +## R-4 — `never-raise`-деградация маскирует флапп (fail-safe = ALLOW) +- **Вероятность:** низкая · **Влияние:** низкое. +- **Причина:** при ошибке лукапа стадии / сетевой ошибке гард делает ALLOW (прежнее поведение), что + в теории не гасит маятник. +- **Митигейшн:** БД-чтение — локальный SQLite (надёжно; ошибка редка); в штатном случае стадия + читается ⇒ сходимость работает. Деградация **логируется** `warning` (D5) ⇒ видно в диагностике. + NFR-1 приоритезирует «не падать/не блокировать конвейер всех проектов» над агрессивным подавлением. + Тест TC-05. + +## R-5 — «Зомби»-тик пост-деплой-монитора после рестарта/стейл-job шлёт статус-PATCH +- **Вероятность:** низкая · **Влияние:** среднее. +- **Причина:** стейл-job `post-deploy-monitor` в очереди после закрытия окна/рестарта мог бы дёрнуть + `set_issue_monitoring`. +- **Митигейшн:** идемпотентный страж `has_marker(...DONE)` (ранний return без PATCH/реэнкью, ~1729) + + тик no-op при `cancelled` мид-окно (D4) + **гард D2** (`window_active==False` ⇒ CONVERGE_DONE). + restart-safe (sentinel'ы на диске). Тесты TC-06/TC-07. + +## R-6 — Стоимость лукапа `tasks` на каждый deploy-PATCH +- **Вероятность:** низкая · **Влияние:** пренебрежимое. +- **Причина:** новый SELECT на каждый вызов deploy-сеттера self-репо. +- **Митигейшн:** тот же ряд даёт `repo` для `applies`; SQLite-чтение ничтожно против сетевого PATCH; + не-self/выключенный флаг → ранний ALLOW. Без кэша (корректность > микро-оптимизация). + +## R-7 — Регресс не-self репозиториев (enduro-trails) +- **Вероятность:** очень низкая · **Влияние:** среднее. +- **Причина:** общий инстанс/БД; правка общих сеттеров `plane_sync`. +- **Митигейшн:** `applies(repo)` (D2 шаг 3, `deploy_status_guard_repos=""` → self-hosting only); + для не-self deploy-фазовые статусы и так не выставляются (terminal-sync сразу `Done`). Тест TC-12. + +## R-8 — Лукап по `work_item_id` не матчит (нет аксессора) +- **Вероятность:** низкая · **Влияние:** низкое (деградирует в ALLOW). +- **Причина:** `get_task_by_plane_id` матчит UUID-ключи, не человекочитаемый `work_item_id`. +- **Митигейшн:** developer добавляет read-only `get_task_by_work_item_id` (D8, без миграции); при + промахе — ALLOW (never-raise). Тумбстоны ORCH-090 (`#cancelled-`) не коллизируют с живым рядом. + +--- + +## Сводка по инвариантам (не нарушены) + +| Инвариант | Статус | +|-----------|--------| +| `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict ключи | не тронуты (D7) | +| Схема БД | без миграции (read-only аксессор) (D7/D8) | +| `main` / force-push / прод-контейнер / detached-деплой | не тронуты (D7, NFR-2) | +| Рабочий self-deploy (Phase A→B→C, merge-gate, freeze ORCH-088) | 1:1 (D7, AC-4) | +| Реконсилятор F-1/F-2 | без изменений (гард субсумирует sync→Done) (D7) | +| Обратимость (kill-switch → 1:1) | `ORCH_DEPLOY_STATUS_GUARD_ENABLED` (D6) | diff --git a/docs/work-items/ORCH-094/12-review.md b/docs/work-items/ORCH-094/12-review.md new file mode 100644 index 0000000..14805a2 --- /dev/null +++ b/docs/work-items/ORCH-094/12-review.md @@ -0,0 +1,102 @@ +--- +verdict: APPROVED +work_item: ORCH-094 +stage: review +author_agent: reviewer +status: approved +created_at: 2026-06-09 +model_used: claude-opus-4-8 +type: review +work_item_id: ORCH-094 +version: 1 +--- + +# Review ORCH-094 — terminal-window-aware гард deploy-статусов + +## Summary + +PR устраняет флапп deploy-статусов у терминальной (`done`) задачи в Plane через единый +terminal-window-aware гард на входе трёх deploy-фазовых сеттеров `plane_sync`. Реализация +**точно следует** ADR-001 (D1–D8): новый leaf `src/deploy_status_guard.py` (чистый, never-raise, +config-gated), перенос арм-блока перед terminal-sync, харднинг пост-деплой-монитора, наблюдаемость +через `reason`-kwarg. Все 4 оси проверки — без P0/P1. + +Проверено по коду ветки: `deploy_status_guard.py`, `plane_sync.py` (врезка `_deploy_status_guarded` + +3 сеттера), `stage_engine.py` (перенос арм-блока D3 + zombie-tick guard D4 + `reason`-call-sites), +`post_deploy.py` (`window_active`), `db.py` (`get_task_by_work_item_id`), `config.py` (2 флага). + +## Findings + +### P0 — Blocker +- Нет. + +### P1 — Must fix +- Нет. + +### P2 — Should fix +- Нет. + +### P3 — Nice-to-have (информационно, вердикт не меняет) +- [ ] `post_deploy.window_active` при внутреннем исключении (`has_marker`-чтение sentinel'а) → + `False` → внутри `decide` шаг 6 даёт `CONVERGE_DONE`. Это **асимметрия** относительно общего + fail-safe-к-ALLOW контракта `decide` (шаг 7): транзиентная ошибка чтения sentinel'а в момент + легитимного первого `Monitoring` свела бы его к `Done` (индикация-глитч, не флапп). Поведение + **намеренное и задокументировано** (docstring `window_active`: «doubt → window closed → converge + to Done — safe-for-indication default»), безопасно к терминальному состоянию; SQLite/диск-чтение + локальное и надёжное. Оставлено как осознанный дизайн-выбор, фиксации не требует. + +## Соответствие ТЗ (`02-trz.md` / `03-acceptance-criteria.md`) + +- **FR-1 / AC-1** (источник флаппа локализован, done держит Done) — ✅ актор задокументирован + (BR-7: code-писатели `stage_engine.py:404/1218/1316`, F-2 не перебирает, live-overlay read-only; + гипотеза «под бот-токеном» в ADR), гард — буфер сходимости. Тесты TC-01/02/10. +- **FR-2 / AC-2** (терминал-aware идемпотентность) — ✅ `decide → ALLOW|CONVERGE_DONE|SUPPRESS`, + предикат «нетерминал ИЛИ (`done` И окно)», `done`-иначе → `set_issue_done` идемпотентно, повтор + на уже-`Done` → no-op. Тесты TC-01/02/12. +- **FR-3 / AC-3** (детерминированный конец монитора, нет зомби-тиков) — ✅ страж `has_marker(DONE)` + сохранён; добавлен `cancelled`-мид-окно → `mark_done` без PATCH и без перепостановки; тик ≡ job. + Тесты TC-06/07/08. +- **FR-4 / AC-5** (наблюдаемость) — ✅ BC-kwarg `reason` у 3 сеттеров; ровно одна структурная запись + на вердикт (`work_item`/`caller`/`target`/`db_stage`/`window_active`/`verdict`; converge/suppress → + WARNING). Тест TC-09 (полная атрибуция). +- **FR-5 / AC-4** (обратимость, регресс рабочего цикла) — ✅ kill-switch + `deploy_status_guard_enabled` (`False` → 1:1) + self-hosting-only по дефолту (`repos=""`); + нетерминальный `Awaiting/Deploying/Monitoring` проходит как раньше. Тесты TC-04/11/12 — особо + TC-11 (end-to-end `run_deploy_finalizer`: легитимный `Monitoring` НЕ свёрнут к Done). + +## Соответствие ADR (`06-adr/ADR-001` + сквозной `adr-0028`) + +- D1 (гард на входе сеттеров `plane_sync`, не в caller'ах) — ✅. +- D2 (предикат терминал **И** окно; 7 шагов) — ✅ реализован 1:1 в `decide`. +- D3 (перенос арм-блока выше terminal-sync) — ✅ подтверждён в diff `advance_stage`; merge-lease + release остаётся после terminal-sync; инварианты ORCH-021/066 сохранены. +- D4 (харднинг монитора) — ✅. D5 (наблюдаемость) — ✅. D6 (флаги) — ✅. D7 (что НЕ трогаем) — ✅ + (проверено: `src/stages.py`/`src/qg/`/`src/reconciler.py` — нулевой diff; machine-verdict ключи + байт-в-байт). D8 (`get_task_by_work_item_id` read-only) — ✅. +- **Трассировка маркеров (CLAUDE.md прав. 9 / TRACEABILITY):** правка маркированного блока + `next_stage=="done"` (ORCH-021/066/043/088) — ADR прочитаны, инварианты не сломаны (deploy→done + self ⇒ Monitoring; монитор-close ⇒ Done; терминал-набор `{done,cancelled}`; merge-lease release + не сдвинут относительно terminal-sync). Слома инвариантов нет. + +## Качество кода + +- Leaf-модуль `deploy_status_guard.py` — чистый, never-raise (двойная защита: `decide` + wrapper + `_deploy_status_guarded`), нет рекурсии (`set_issue_done` не гардится), docstrings на всех публичных + функциях, образец `serial_gate`/`labels`/`cancel` выдержан. +- Тесты содержательные (не тривиальные): 5 новых файлов, TC-01..12; TC-11 — реальный прогон + `run_deploy_finalizer` с проверкой стадии и единственного `Monitoring`-PATCH; обновлены + анти-регресс-ассерты под `reason`-kwarg. `pytest tests/ -q` — **1413 passed**. + +## Документация + +`src/` изменён → документация обновлена **в том же PR** (golden source соблюдён): +- ✅ `CHANGELOG.md` — детальная запись ORCH-094 (FR/AC/D-разбивка). +- ✅ `docs/architecture/README.md` — новый раздел «Terminal-window-aware гард deploy-статусов». +- ✅ `CLAUDE.md` — врезка в блок статусной модели Plane. +- ✅ `.env.example` — `ORCH_DEPLOY_STATUS_GUARD_ENABLED` / `_REPOS` с описанием. +- ✅ `docs/work-items/ORCH-094/06-adr/ADR-001-…md` (work-item) + сквозной + `docs/architecture/adr/adr-0028-…md` (кросс-каттинг) — оба присутствуют. +- ✅ Обзорные доки (ORCH-079): PR — баг-фикс индикации, не закрывает пункт `README.md` + «Известные ограничения»; обновления корневого `README.md` не требуется. + +Документация полная и согласована с реализацией. Расхождений код ↔ доки не найдено. diff --git a/docs/work-items/ORCH-094/13-test-report.md b/docs/work-items/ORCH-094/13-test-report.md new file mode 100644 index 0000000..6828605 --- /dev/null +++ b/docs/work-items/ORCH-094/13-test-report.md @@ -0,0 +1,84 @@ +--- +result: PASS # PASS | FAIL — машинный вердикт, UPPERCASE +work_item: ORCH-094 +stage: testing +author_agent: tester +status: pass +created_at: 2026-06-09 +model_used: claude-opus-4-8 +type: test-report +work_item_id: ORCH-094 +--- + +# Test Report — ORCH-094 — terminal-window-aware гард deploy-статусов + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 +- Дата: 2026-06-09 +- Worktree (база прогона): `/repos/_wt/orchestrator/feature_ORCH-094-bug-done-deploy-plane-awaiting` +- Ветка: `feature/ORCH-094-bug-done-deploy-plane-awaiting` +- HEAD: `11de318` (поверх `3738888 fix(deploy): terminal-window-aware guard … (ORCH-094)`) +- Review: `12-review.md` → `verdict: APPROVED` (P0/P1 — нет). + +> Прогон выполнен из worktree ветки задачи (не из общего `/repos/orchestrator`) — анти-гонка checkout. + +## Smoke API (read-only) +| Проверка | Результат | +|----------|-----------| +| `GET /health` | PASS — `{"status":"ok","service":"orchestrator"}` | +| `GET /status` | PASS — отвечает, отдаёт `active_tasks` | +| `GET /queue` | PASS — блок `serial_gate` присутствует (ORCH-088), `auto_labels` присутствует (ORCH-089) | + +Деструктивные операции не выполнялись (read-only smoke). + +## Результаты (покрытие тест-плана `04-test-plan.yaml` ↔ `03-acceptance-criteria.md`) + +| TC ID | Тип | Описание | AC | Тест | Результат | +|-------|-----|----------|----|------|-----------| +| TC-01 | unit | done-задача сходится к Done (monitoring/awaiting/deploying при terminal → Done/no-op) | AC-2 | `test_deploy_status_terminal_guard::test_tc01_*` | PASS | +| TC-02 | unit | Идемпотентность: повтор на уже-Done → no-op, нет маятника | AC-2 | `test_deploy_status_terminal_guard::test_tc02_idempotent_no_pendulum` | PASS | +| TC-03 | unit | Нетерминальная (stage=deploy) не подавляется (регресс) | AC-4 | `test_deploy_status_terminal_guard::test_tc03_non_terminal_not_suppressed` | PASS | +| TC-04 | unit | Kill-switch: off → 1:1 прежнее; on → done сходится к Done | AC-5 | `test_deploy_status_terminal_guard::test_tc04_kill_switch` | PASS | +| TC-05 | unit | never-raise: неизвестная стадия / ошибка БД → безопасная деградация | AC-5 | `test_deploy_status_terminal_guard::test_tc05_*` | PASS | +| TC-06 | unit | После завершения окна монитора (HEALTHY, ticks==budget) → 0 последующих PATCH | AC-3 | `test_post_deploy_monitor_termination::test_tc06_clean_finish_then_no_more_patches` | PASS | +| TC-07 | unit | Тик при БД=done/cancelled / нет основания → no-op без PATCH и без перепостановки | AC-3 | `test_post_deploy_monitor_termination::test_tc07_*` | PASS | +| TC-08 | unit | `arm_monitor` не пере-арминг для done; re-drive не выставляет Monitoring заново | AC-3 | `test_post_deploy_monitor_termination::test_tc08_*` | PASS | +| TC-09 | unit | Наблюдаемость: лог work_item/caller/target/reason/db_stage; подавление логируется | AC-5 | `test_deploy_status_observability::test_tc09_*` | PASS | +| TC-10 | integration | Реконсилятор/sync для done+Plane=Monitoring → Done идемпотентно, без маятника | AC-2 | `test_reconciler_done_deploy_convergence::test_tc10_repeated_sync_converges_no_pendulum` | PASS | +| TC-11 | integration | Регресс рабочего цикла: нетерминальная задача Awaiting→Deploying→Monitoring→Done не подавлена | AC-4 | `test_self_deploy_cycle_regression::test_tc11_*` | PASS | +| TC-12 | integration | Не-self репо (enduro-подобный): гард инертен (условность self-hosting) | AC-4/AC-5 | `test_deploy_status_terminal_guard::test_tc12_*` | PASS | + +**Все 12 TC выполнены и сопоставлены с критериями приёмки. Непокрытых TC нет.** + +Покрытие AC: +- **AC-1** (done держит Done; нет авто-перехода в Awaiting/Monitoring) — TC-01/02/10 ✅ +- **AC-2** (идемпотентное схождение к Done) — TC-01/02/10/12 ✅ +- **AC-3** (детерминированный конец монитора, нет зомби-тиков) — TC-06/07/08 ✅ +- **AC-4** (регресс рабочего deploy-цикла нетерминальной задачи) — TC-03/11/12 ✅ +- **AC-5** (наблюдаемость, kill-switch, never-raise, зелёный pytest) — TC-04/05/09 + полный регресс ✅ + +## Вывод pytest + +Целевые модули ORCH-094: +``` +tests/test_deploy_status_terminal_guard.py ........... (11) +tests/test_post_deploy_monitor_termination.py ..... (5) +tests/test_deploy_status_observability.py ... (3) +tests/test_reconciler_done_deploy_convergence.py . (1) +tests/test_self_deploy_cycle_regression.py .. (2) +======================== 22 passed, 1 warning in 1.43s ========================= +``` + +Полный регресс (`pytest tests/ -v --tb=short`): +``` +======================= 1413 passed, 1 warning in 44.34s ======================= +``` +> Единственное предупреждение — PydanticDeprecatedSince20 (class-based config в `src/config.py`), +> не связано с ORCH-094, не является ошибкой. + +## Итог + +**PASS** — полный регресс зелёный (1413 passed), все 12 TC из `04-test-plan.yaml` выполнены, +сопоставлены с AC и зелёные; smoke API (`/health`, `/status`, `/queue` c блоком `serial_gate`) OK. +Задача переходит на стадию `deploy-staging`. diff --git a/docs/work-items/ORCH-094/14-deploy-log.md b/docs/work-items/ORCH-094/14-deploy-log.md new file mode 100644 index 0000000..ddfe23f --- /dev/null +++ b/docs/work-items/ORCH-094/14-deploy-log.md @@ -0,0 +1,12 @@ +--- +deploy_status: SUCCESS +work_item: ORCH-094 +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/src/config.py b/src/config.py index f1313ea..dc48aa9 100644 --- a/src/config.py +++ b/src/config.py @@ -649,6 +649,32 @@ class Settings(BaseSettings): stop_status_enabled: bool = True stop_status_repos: str = "" + # ORCH-094: terminal-window-aware guard for deploy-phase Plane status setters. + # A task with DB stage='done' (and 0 active jobs) was flapping in Plane between + # `Awaiting Deploy` and `Monitoring after Deploy` instead of holding `Done`, + # because the three deploy-phase setters (set_issue_awaiting_deploy / + # set_issue_deploying / set_issue_monitoring) are terminal-blind: any stale / + # duplicate / unknown caller under the bot token re-stamps an intermediate + # deploy status over the terminal Done. ORCH-094 puts a single low choke-point + # guard on the entry of those three setters (leaf src/deploy_status_guard.py): + # for a task whose DB stage is terminal it converges to Done idempotently + # (CONVERGE_DONE), EXCEPT the legitimate post-deploy `Monitoring` while the + # window is still active (ARMED & not DONE). Additive, never-raise; reads the + # existing tasks.stage (no migration); STAGE_TRANSITIONS / QG_CHECKS / + # machine-verdict keys are NOT touched. See + # docs/work-items/ORCH-094/06-adr/ADR-001-terminal-window-aware-deploy-status-guard.md + # and the cross-cutting docs/architecture/adr/adr-0028-…md. + # deploy_status_guard_enabled -> kill-switch (env ORCH_DEPLOY_STATUS_GUARD_ENABLED). + # False -> the setters are terminal-blind, behaviour + # strictly 1:1 as before ORCH-094 (zero regression). + # deploy_status_guard_repos -> CSV scope (env ORCH_DEPLOY_STATUS_GUARD_REPOS). + # Empty -> applies ONLY to the self-hosting repo + # (orchestrator), where deploy-phase statuses are set + # at all; non-empty -> only the listed repos. Tokens + # are sanitised (^[A-Za-z0-9._-]+$) by the guard leaf. + deploy_status_guard_enabled: bool = True + deploy_status_guard_repos: str = "" + # ORCH-073 (ADR-001 Р-4): main-integrity regression guard. After the merge-verify # under-gate confirms the deployed SHA is an ancestor of origin/main (FR-1), a # secondary deterministic (no-LLM) guard checks that a declarative set of markers diff --git a/src/db.py b/src/db.py index a458e62..6701034 100644 --- a/src/db.py +++ b/src/db.py @@ -223,6 +223,28 @@ def get_task_by_plane_id(plane_id: str) -> dict | None: return None +def get_task_by_work_item_id(work_item_id: str) -> dict | None: + """ORCH-094: read-only lookup of the live task row by human-readable + ``work_item_id`` (e.g. ``"ORCH-061"``). + + ``get_task_by_plane_id`` matches the Plane UUIDs (``plane_id`` / + ``plane_issue_id``), not the human-readable ``work_item_id`` the deploy-phase + setters receive — hence this thin accessor. A live row matches exactly; the + ORCH-090 cancel tombstones carry a ``#cancelled-`` suffix on + ``work_item_id`` so they never collide with a clean id. No schema change. + """ + if not work_item_id: + return None + conn = get_db() + try: + row = conn.execute( + "SELECT * FROM tasks WHERE work_item_id = ?", (work_item_id,) + ).fetchone() + finally: + conn.close() + return dict(row) if row else None + + def get_task_by_repo_branch(repo: str, branch: str) -> dict | None: """Find task by repo and branch name.""" conn = get_db() diff --git a/src/deploy_status_guard.py b/src/deploy_status_guard.py new file mode 100644 index 0000000..d0a46e1 --- /dev/null +++ b/src/deploy_status_guard.py @@ -0,0 +1,191 @@ +"""ORCH-094: terminal-window-aware guard for deploy-phase Plane status setters. + +Leaf module — pure, never-raise, config-gated logic over the existing ``tasks`` +table and the restart-safe post-deploy sentinels. Mirrors the leaf pattern of +``src/serial_gate.py`` / ``src/labels.py`` / ``src/cancel.py``: it imports only +``config`` (and lazily ``db`` / ``post_deploy`` / ``qg.checks``), never +``plane_sync`` / ``stage_engine`` — the setters that need a verdict call +:func:`decide`, they do not live here. + +The bug (verified live on ORCH-061, task 47, done since 07.06): a task with DB +``stage='done'`` and no active job flaps in Plane between ``Awaiting Deploy`` and +``Monitoring after Deploy`` instead of holding ``Done``. The three deploy-phase +setters (``set_issue_awaiting_deploy`` / ``set_issue_deploying`` / +``set_issue_monitoring``) are **terminal-blind**: any stale / duplicate / unknown +caller under the bot token re-stamps an intermediate deploy status over the +terminal Done, and the pendulum never settles. + +The fix is a single low choke-point on the entry of those three setters. For a +task whose DB stage is terminal the verdict converges to ``Done`` idempotently, +EXCEPT the one legitimate case: the post-deploy ``Monitoring`` status while the +observation window is still active (``post_deploy.window_active`` — ARMED & not +DONE). The deploy ``Awaiting``/``Deploying`` statuses are ALWAYS spurious for a +``done`` task (Phase A/B happen strictly BEFORE ``deploy -> done``). + +Key invariant (ADR-001 D2): a deploy-phase status is legitimate iff the task is +non-terminal OR (``done`` AND the post-deploy window is active); otherwise the +verdict is idempotent convergence to ``Done`` (for ``done``) / suppression (for +``cancelled``). + +never-raise contract (self-hosting safety): any error / inability to determine +the DB stage degrades to ``ALLOW`` (fail-safe to the prior 1:1 behaviour, NFR-1) +— a local SQLite read is reliable, so in the normal case the stage is read and +the pendulum cannot arise. +""" +from __future__ import annotations + +import logging +import re + +from .config import settings + +logger = logging.getLogger("orchestrator.deploy_status_guard") + +# Verdicts returned by decide() (the setter executes them). +ALLOW = "ALLOW" # PATCH the requested deploy-phase status (normal path). +CONVERGE_DONE = "CONVERGE_DONE" # set_issue_done instead (idempotent convergence). +SUPPRESS = "SUPPRESS" # do nothing (do not stamp over a `cancelled` terminal). + +# Deploy-phase target tokens (one per guarded setter). +AWAITING = "awaiting" +DEPLOYING = "deploying" +MONITORING = "monitoring" + +# Terminal DB stages (harmonised with serial_gate / adr-0026). +_TERMINAL = ("done", "cancelled") + +# Repo tokens embedded into config CSV must match this (mirrors serial_gate R-6). +_REPO_TOKEN = re.compile(r"^[A-Za-z0-9._-]+$") + + +# --------------------------------------------------------------------------- +# Conditionality (mirrors post_deploy_applies / _merge_gate_applies) +# --------------------------------------------------------------------------- +def _scope_repos() -> set[str]: + """Sanitised set of in-scope repo tokens from ``deploy_status_guard_repos``. + + Empty/blank CSV -> empty set, meaning "self-hosting only" (resolved by the + caller via :func:`applies`). Invalid tokens (regex miss) are dropped. Never + raises. + """ + try: + raw = (settings.deploy_status_guard_repos or "").strip() + except Exception: # noqa: BLE001 + return set() + if not raw: + return set() + out: set[str] = set() + for tok in raw.split(","): + t = tok.strip() + if t and _REPO_TOKEN.match(t): + out.add(t) + elif t: + logger.warning("deploy_status_guard: dropping invalid repo token %r", t) + return out + + +def applies(repo: str) -> bool: + """Whether the guard is REAL for this repo (D6). + + * ``deploy_status_guard_enabled=False`` -> always False (kill-switch; the + setters are terminal-blind, 1:1 as before ORCH-094). + * ``deploy_status_guard_repos`` (CSV) non-empty -> real only for listed repos. + * empty CSV -> real ONLY for the self-hosting repo (``orchestrator``), where + deploy-phase statuses are set at all. Mirrors the ORCH-35/36/43/58 + self-hosting-only rollout -> non-self repos (enduro-trails) are untouched + (they never see Awaiting/Deploying/Monitoring; terminal-sync goes straight + to Done), i.e. zero regression. + Never raises -> False on error (degrade to "guard inert"). + """ + try: + if not getattr(settings, "deploy_status_guard_enabled", False): + return False + scope = _scope_repos() + if scope: + return (repo or "").strip() in scope + # Lazy import keeps this module a leaf (avoid importing qg at load time). + from .qg.checks import is_self_hosting_repo + return is_self_hosting_repo(repo) + except Exception as e: # noqa: BLE001 - never-raise + logger.warning("deploy_status_guard.applies error for %s: %s", repo, e) + return False + + +# --------------------------------------------------------------------------- +# Verdict (the single predicate — ADR-001 D2) +# --------------------------------------------------------------------------- +def decide(work_item_id: str, target_status: str, reason: str | None = None) -> str: + """Decide what a deploy-phase setter should do for ``work_item_id`` (D2). + + Returns one of :data:`ALLOW` / :data:`CONVERGE_DONE` / :data:`SUPPRESS`. + Steps (ADR-001 D2): + + 1. kill-switch off -> ALLOW (behaviour 1:1). + 2. task not found -> ALLOW (foreign/unknown issue). + 3. guard not applicable for the repo -> ALLOW (non-self / out-of-scope). + 4. DB stage non-terminal -> ALLOW (live deploy cycle, AC-4). + 5. DB stage == 'cancelled' -> SUPPRESS (do not stamp over it). + 6. DB stage == 'done': + * target == 'monitoring' AND window active -> ALLOW (legit post-deploy). + * otherwise -> CONVERGE_DONE. + 7. any exception / undeterminable stage -> ALLOW (fail-safe, NFR-1). + + Always emits exactly one structured observability line (FR-4 / D5): work_item, + caller (``reason``), target_status, db_stage, window_active, verdict. + """ + db_stage = None + window = None + verdict = ALLOW + try: + if not getattr(settings, "deploy_status_guard_enabled", False): + return ALLOW # step 1 (logged in finally) + + from . import db + task = db.get_task_by_work_item_id(work_item_id) + if task is None: + return ALLOW # step 2 + + repo = task.get("repo") + if not applies(repo): + return ALLOW # step 3 + + db_stage = (task.get("stage") or "").strip() + if db_stage not in _TERMINAL: + verdict = ALLOW # step 4 — non-terminal: legit working deploy cycle + return verdict + + if db_stage == "cancelled": + verdict = SUPPRESS # step 5 + return verdict + + # step 6 — db_stage == 'done' + if target_status == MONITORING: + from . import post_deploy + window = post_deploy.window_active(repo, work_item_id) + if window: + verdict = ALLOW + return verdict + verdict = CONVERGE_DONE + return verdict + except Exception as e: # noqa: BLE001 - never-raise; fail-safe to ALLOW + logger.warning( + "deploy_status_guard.decide error for %s (target=%s) -> ALLOW: %s", + work_item_id, target_status, e, + ) + verdict = ALLOW + return verdict + finally: + # FR-4 / D5: one structured line per call. Convergence/suppression is the + # interesting case — log it at WARNING so a future flapp is easy to attribute. + try: + msg = ( + "deploy_status_guard: work_item=%s caller=%s target=%s db_stage=%s " + "window_active=%s verdict=%s" + ) + argv = (work_item_id, reason, target_status, db_stage, window, verdict) + if verdict == ALLOW: + logger.info(msg, *argv) + else: + logger.warning(msg, *argv) + except Exception: # noqa: BLE001 - logging must never raise + pass diff --git a/src/plane_sync.py b/src/plane_sync.py index 88f651e..9507f29 100644 --- a/src/plane_sync.py +++ b/src/plane_sync.py @@ -951,32 +951,67 @@ def set_issue_code_review(work_item_id: str, project_id: str = None): _set_issue_state_direct(work_item_id, state_id, project_id) -def set_issue_awaiting_deploy(work_item_id: str, project_id: str = None): +def _deploy_status_guarded(work_item_id: str, target: str, reason: str | None) -> bool: + """ORCH-094: apply the terminal-window-aware guard for a deploy-phase setter. + + Returns True iff the caller should PROCEED with the normal PATCH (verdict + ALLOW). On CONVERGE_DONE it drives the task to terminal ``Done`` here (the + idempotent convergence target) and returns False; on SUPPRESS it does nothing + and returns False. never-raise: any error degrades to ALLOW (proceed), keeping + behaviour 1:1 with pre-ORCH-094 (the guard leaf itself fails safe to ALLOW). + """ + try: + from . import deploy_status_guard + verdict = deploy_status_guard.decide(work_item_id, target, reason=reason) + if verdict == deploy_status_guard.CONVERGE_DONE: + set_issue_done(work_item_id) + return False + if verdict == deploy_status_guard.SUPPRESS: + return False + return True + except Exception as e: # noqa: BLE001 - never-raise; proceed (1:1) on doubt + logger.warning(f"deploy_status_guard wrapper error for {work_item_id}: {e}") + return True + + +def set_issue_awaiting_deploy(work_item_id: str, project_id: str = None, reason: 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. + ORCH-094: terminal-window-aware — a task whose DB stage is terminal converges to + Done instead of stamping a spurious deploy status (``reason`` = caller, FR-4). """ + if not _deploy_status_guarded(work_item_id, "awaiting", reason): + return 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): +def set_issue_deploying(work_item_id: str, project_id: str = None, reason: 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. + ORCH-094: terminal-window-aware (see :func:`set_issue_awaiting_deploy`). """ + if not _deploy_status_guarded(work_item_id, "deploying", reason): + return 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): +def set_issue_monitoring(work_item_id: str, project_id: str = None, reason: 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). + ORCH-094: terminal-window-aware — the LEGITIMATE first Monitoring (DB already + ``done`` by the time line 404 runs, but the post-deploy window is active) is + allowed; a stale Monitoring after the window has closed converges to Done. """ + if not _deploy_status_guarded(work_item_id, "monitoring", reason): + return 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) diff --git a/src/post_deploy.py b/src/post_deploy.py index 75afe42..cd86b49 100644 --- a/src/post_deploy.py +++ b/src/post_deploy.py @@ -316,6 +316,28 @@ def has_marker(repo: str, work_item_id: str | None, name: str) -> bool: return False +def window_active(repo: str, work_item_id: str | None) -> bool: + """ORCH-094: True iff a post-deploy observation window is currently OPEN. + + A window is open iff it has been armed (``ARMED`` sentinel) and has NOT yet + finished (no ``DONE`` sentinel). The terminal-window-aware deploy-status guard + (``deploy_status_guard.decide``) uses this to keep the legitimate post-deploy + ``Monitoring after Deploy`` status for a task that is already DB-``done`` while + its window is live, and to converge to ``Done`` once the window has closed. + + Restart-safe (the sentinels live on disk) and never-raise -> False on error + (a doubt resolves to "window closed", i.e. converge to Done — the safe-for- + indication default that matches the bug we are fixing). + """ + try: + return has_marker(repo, work_item_id, ARMED) and not has_marker( + repo, work_item_id, DONE + ) + except Exception as e: # noqa: BLE001 - never-raise + logger.warning("window_active error for %s/%s: %s", repo, work_item_id, e) + return False + + def write_marker(repo: str, work_item_id: str | None, name: str, content: str = "") -> bool: """Create/overwrite a sentinel (best-effort). Returns True on success.""" try: diff --git a/src/stage_engine.py b/src/stage_engine.py index 36b169d..54f1582 100644 --- a/src/stage_engine.py +++ b/src/stage_engine.py @@ -384,6 +384,29 @@ def advance_stage( f"(auto-advance after {agent})" ) + # ORCH-021: arm post-deploy monitoring PAST `done`. Responsibility extends + # beyond the restart-time health-check to catch the "green deploy, red prod" + # class (ET-8). Idempotent (sentinel `armed`) + conditional (applies()), so a + # double webhook / reconciler / finalizer re-driving `done` never doubles it + # and non-applicable repos are untouched. never-raise (arm_monitor + guard). + # + # ORCH-094 (ADR-001 D3): the arm block is moved ABOVE the terminal-sync + # block (it used to run AFTER set_issue_monitoring). The order matters now + # that set_issue_monitoring is terminal-window-aware: by the time the + # legitimate first `Monitoring` is set, the task is ALREADY DB-`done` + # (update_task_stage ran above), so the guard must see the window as ACTIVE + # (ARMED & not DONE) to let it through. Arming first writes the ARMED + # sentinel -> window_active==True -> the guard returns ALLOW. A re-drive of + # deploy->done AFTER the window has closed (DONE present) -> window_active + # False -> the guard converges to Done (no resurrected Monitoring). The + # move is safe: arm_monitor only writes a sentinel + enqueues a deferred + # job; it depends on neither the Plane status nor the merge lease. + if next_stage == "done" and post_deploy.post_deploy_applies(repo): + try: + post_deploy.arm_monitor(repo, work_item_id, branch, task_id) + except Exception as e: # noqa: BLE001 - monitoring must never crash done + logger.warning(f"Task {task_id}: post-deploy arm failed: {e}") + # --- Terminal sync: deploy -> done must reach Plane's Done ----------- # When the deployer's check_deploy_status passes we advance to the # terminal 'done' stage. Previously a merged-PR webhook completed the @@ -401,7 +424,7 @@ def advance_stage( if next_stage == "done" and work_item_id: try: if post_deploy.post_deploy_applies(repo): - set_issue_monitoring(work_item_id) + set_issue_monitoring(work_item_id, reason="advance:deploy->done") logger.info( f"Task {task_id}: deploy->done (self), Plane state -> " f"Monitoring after Deploy (post-deploy window)" @@ -416,24 +439,14 @@ def advance_stage( # 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 - # different task already owns it). Never raises. + # different task already owns it). Never raises. ORCH-094: stays AFTER the + # terminal-sync (the arm-block move above does not touch the lease). if next_stage == "done": try: merge_gate.release_merge_lease(repo, branch) except Exception as e: # noqa: BLE001 - defensive logger.warning(f"Task {task_id}: merge-lease release on done failed: {e}") - # ORCH-021: arm post-deploy monitoring PAST `done`. Responsibility extends - # beyond the restart-time health-check to catch the "green deploy, red prod" - # class (ET-8). Idempotent (sentinel `armed`) + conditional (applies()), so a - # double webhook / reconciler / finalizer re-driving `done` never doubles it - # and non-applicable repos are untouched. never-raise (arm_monitor + guard). - if next_stage == "done" and post_deploy.post_deploy_applies(repo): - try: - post_deploy.arm_monitor(repo, work_item_id, branch, task_id) - except Exception as e: # noqa: BLE001 - monitoring must never crash done - logger.warning(f"Task {task_id}: post-deploy arm failed: {e}") - # --- Launch the next agent (ORCH-4 fix: current_stage, not next) ----- next_agent = get_agent_for_stage(current_stage) if next_agent: @@ -1214,8 +1227,8 @@ def _handle_self_deploy_phase_a( # 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) + # where the status is not created. ORCH-094: reason tags the caller (FR-4). + set_issue_awaiting_deploy(work_item_id, reason="phase_a") # 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 @@ -1312,8 +1325,9 @@ def _handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result: Adv ) # ORCH-066 (AC-7): the prod deploy is now in flight -> indicate `Deploying` # (degrades to In Progress where the status is not created). + # ORCH-094: reason tags the caller (FR-4). if work_item_id: - set_issue_deploying(work_item_id) + set_issue_deploying(work_item_id, reason="phase_b") task_desc = ( f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n" f"Stage: deploy\nNote: deploy-finalize poll (prod self-deploy initiated)." @@ -1714,7 +1728,7 @@ def run_post_deploy_monitor(job: dict): try: conn = get_db() row = conn.execute( - "SELECT work_item_id, branch FROM tasks WHERE id=?", (task_id,) + "SELECT work_item_id, branch, stage FROM tasks WHERE id=?", (task_id,) ).fetchone() conn.close() except Exception as e: # noqa: BLE001 - never-raise @@ -1723,13 +1737,28 @@ def run_post_deploy_monitor(job: dict): if not row: logger.error(f"post-deploy-monitor: no task row for task_id={task_id}") return - work_item_id, branch = row[0], row[1] + work_item_id, branch, db_stage = row[0], row[1], row[2] # AC-15: a finished window is a no-op (defends against a duplicate job). if post_deploy.has_marker(repo, work_item_id, post_deploy.DONE): logger.info(f"post-deploy-monitor: {work_item_id} already done (no-op)") return + # ORCH-094 (FR-3 / D4 / AC-3): a tick must have an active basis. If the task + # became terminal ANOMALOUSLY mid-window (cancelled via STOP, ORCH-090), the + # tick is a "zombie" — close the window WITHOUT a status PATCH and WITHOUT + # re-queueing the next tick (a cancelled task already reached its own terminal; + # stamping a deploy status over it would flapp). A `done` stage is the NORMAL + # state of a post-deploy window (it opens strictly past deploy->done) so it is + # NOT treated as an anomaly here. + if (db_stage or "").strip() == "cancelled": + logger.info( + f"post-deploy-monitor: {work_item_id} task cancelled mid-window -> " + f"closing window, no status PATCH, no re-queue (zombie-tick guard)" + ) + post_deploy.mark_done(repo, work_item_id) + return + # One probe -> append -> classify (restart-safe via the persisted series). probe = post_deploy.probe_signals(settings.post_deploy_base_url) series = post_deploy.append_probe(repo, work_item_id, probe) diff --git a/tests/test_config.py b/tests/test_config.py index 4e6da8d..581f0f3 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -292,3 +292,30 @@ def test_merge_retry_settings_env_override(monkeypatch): assert s.merge_retry_max_attempts == 5 assert s.merge_retry_backoff_base_s == 1 assert s.merge_retry_backoff_max_s == 8 + + +# --------------------------------------------------------------------------- +# ORCH-094: deploy_status_guard_* settings defaults + env override. +# --------------------------------------------------------------------------- +_DEPLOY_GUARD_ENV = ( + "ORCH_DEPLOY_STATUS_GUARD_ENABLED", + "ORCH_DEPLOY_STATUS_GUARD_REPOS", +) + + +def test_deploy_status_guard_settings_defaults(monkeypatch): + """Documented defaults: enabled True, repos empty (self-hosting only).""" + for name in _DEPLOY_GUARD_ENV: + monkeypatch.delenv(name, raising=False) + s = Settings() + assert s.deploy_status_guard_enabled is True + assert s.deploy_status_guard_repos == "" + + +def test_deploy_status_guard_settings_env_override(monkeypatch): + """Each field is read from its ORCH_DEPLOY_STATUS_GUARD_* env var.""" + monkeypatch.setenv("ORCH_DEPLOY_STATUS_GUARD_ENABLED", "false") + monkeypatch.setenv("ORCH_DEPLOY_STATUS_GUARD_REPOS", "orchestrator,enduro-trails") + s = Settings() + assert s.deploy_status_guard_enabled is False + assert s.deploy_status_guard_repos == "orchestrator,enduro-trails" diff --git a/tests/test_deploy_approve.py b/tests/test_deploy_approve.py index 256c876..1eae393 100644 --- a/tests/test_deploy_approve.py +++ b/tests/test_deploy_approve.py @@ -132,7 +132,8 @@ def test_tc05_no_approve_does_not_call_prod_hook(monkeypatch): # 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") + # ORCH-094: the caller now tags the reason (FR-4 observability). + stage_engine.set_issue_awaiting_deploy.assert_called_once_with("ORCH-036", reason="phase_a") stage_engine.set_issue_in_review.assert_not_called() @@ -161,7 +162,8 @@ 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) # ORCH-066 AC-7: Phase B indicates `Deploying` on a successful initiate. - stage_engine.set_issue_deploying.assert_called_once_with("ORCH-036") + # ORCH-094: the caller now tags the reason (FR-4 observability). + stage_engine.set_issue_deploying.assert_called_once_with("ORCH-036", reason="phase_b") # 2nd (duplicate) Confirm Deploy -> idempotent no-op, hook NOT called again. res2 = advance_stage( diff --git a/tests/test_deploy_status_observability.py b/tests/test_deploy_status_observability.py new file mode 100644 index 0000000..7c8c620 --- /dev/null +++ b/tests/test_deploy_status_observability.py @@ -0,0 +1,88 @@ +"""ORCH-094 — observability of deploy-status setting (FR-4 / AC-5 / TC-09). + +Every deploy-phase status decision emits ONE structured line carrying work_item, +caller (reason), target_status, db_stage, window_active and the verdict; a +suppression/convergence is logged explicitly so a future flapp is attributable. +""" +import logging +import os +import tempfile + +import pytest + +_test_db = os.path.join(tempfile.gettempdir(), "test_deploy_status_obs.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") + +import src.db as _db # noqa: E402 +from src.db import init_db, get_db # noqa: E402 +from src import deploy_status_guard as guard # noqa: E402 +from src import post_deploy # noqa: E402 +from src import config as cfg # 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(cfg.settings, "deploy_status_guard_enabled", True, raising=False) + monkeypatch.setattr(cfg.settings, "deploy_status_guard_repos", "", raising=False) + monkeypatch.setattr(post_deploy.settings, "repos_dir", str(tmp_path)) + monkeypatch.setattr(post_deploy.settings, "host_repos_dir", str(tmp_path)) + yield + + +def _make_task(stage, repo="orchestrator", wi="ORCH-061", branch="feature/ORCH-061-x"): + conn = get_db() + conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) " + "VALUES (?, ?, ?, ?, ?)", + (f"plane-{wi}", wi, repo, branch, stage), + ) + conn.commit() + conn.close() + + +def test_tc09_converge_logs_full_attribution(caplog): + _make_task("done") + with caplog.at_level(logging.INFO, logger="orchestrator.deploy_status_guard"): + verdict = guard.decide("ORCH-061", guard.MONITORING, reason="advance:deploy->done") + assert verdict == guard.CONVERGE_DONE + rec = [r for r in caplog.records if r.name == "orchestrator.deploy_status_guard"] + assert rec, "guard emitted no observability record" + msg = rec[-1].getMessage() + # All five attribution fields + verdict are present. + for token in ( + "work_item=ORCH-061", "caller=advance:deploy->done", "target=monitoring", + "db_stage=done", "window_active=False", "verdict=CONVERGE_DONE", + ): + assert token in msg, f"missing {token!r} in {msg!r}" + # A convergence is logged at WARNING (easy to grep on a future flapp). + assert rec[-1].levelno == logging.WARNING + + +def test_tc09_allow_active_window_logged(caplog): + _make_task("done") + post_deploy.write_marker("orchestrator", "ORCH-061", post_deploy.ARMED, "armed") + with caplog.at_level(logging.INFO, logger="orchestrator.deploy_status_guard"): + verdict = guard.decide("ORCH-061", guard.MONITORING, reason="advance:deploy->done") + assert verdict == guard.ALLOW + rec = [r for r in caplog.records if r.name == "orchestrator.deploy_status_guard"][-1] + msg = rec.getMessage() + assert "window_active=True" in msg and "verdict=ALLOW" in msg + assert rec.levelno == logging.INFO + + +def test_tc09_suppress_cancelled_logged(caplog): + _make_task("cancelled") + with caplog.at_level(logging.INFO, logger="orchestrator.deploy_status_guard"): + verdict = guard.decide("ORCH-061", guard.AWAITING, reason="phase_a") + assert verdict == guard.SUPPRESS + rec = [r for r in caplog.records if r.name == "orchestrator.deploy_status_guard"][-1] + assert "verdict=SUPPRESS" in rec.getMessage() + assert "db_stage=cancelled" in rec.getMessage() + assert rec.levelno == logging.WARNING diff --git a/tests/test_deploy_status_terminal_guard.py b/tests/test_deploy_status_terminal_guard.py new file mode 100644 index 0000000..0633ec3 --- /dev/null +++ b/tests/test_deploy_status_terminal_guard.py @@ -0,0 +1,217 @@ +"""ORCH-094 — terminal-window-aware deploy-status guard (FR-2 / FR-5). + +Covers (04-test-plan.yaml): + TC-01 deploy-status for a DB stage=done task converges to Done: a + set_issue_monitoring/awaiting/deploying attempt on a terminal task drives + Done (or no-op if already Done), never an intermediate status. + TC-02 idempotency: a repeated terminal-aware setter call on an already-Done task + never PATCHes an intermediate status (no Done<->deploy pendulum). + TC-03 a non-terminal task (stage=deploy) is NOT suppressed: the deploy setters + proceed normally (regression AC-4). + TC-04 kill-switch off -> 1:1 prior behaviour (guard inert); on -> converge. + TC-05 never-raise: an undeterminable DB stage / DB error degrades safely (ALLOW, + no flapp, no exception). + TC-12 non-self repo: zero regression — the guard is inert (self-hosting only). +""" +import os +import tempfile + +import pytest + +_test_db = os.path.join(tempfile.gettempdir(), "test_deploy_status_guard.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 deploy_status_guard as guard # noqa: E402 +from src import plane_sync # noqa: E402 +from src import post_deploy # noqa: E402 +from src import config as cfg # 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() + # Guard ON, self-hosting only (empty CSV) by default. + monkeypatch.setattr(cfg.settings, "deploy_status_guard_enabled", True, raising=False) + monkeypatch.setattr(cfg.settings, "deploy_status_guard_repos", "", raising=False) + # post-deploy sentinels live under a fresh tmp dir (window closed by default). + monkeypatch.setattr(post_deploy.settings, "repos_dir", str(tmp_path)) + monkeypatch.setattr(post_deploy.settings, "host_repos_dir", str(tmp_path)) + yield + + +def _make_task(stage, repo="orchestrator", wi="ORCH-061", branch="feature/ORCH-061-x"): + conn = get_db() + conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) " + "VALUES (?, ?, ?, ?, ?)", + (f"plane-{wi}", wi, repo, branch, stage), + ) + conn.commit() + conn.close() + + +@pytest.fixture +def spy_setters(monkeypatch): + """Spy the low-level PATCH primitive + the Done convergence target.""" + direct = MagicMock() + done = MagicMock() + monkeypatch.setattr(plane_sync, "_set_issue_state_direct", direct) + monkeypatch.setattr(plane_sync, "set_issue_done", done) + # Keep status resolution offline-deterministic. + monkeypatch.setattr(plane_sync, "_resolve_project_id", lambda w=None, p=None: "proj-1") + monkeypatch.setattr( + plane_sync, "get_project_states", + lambda pid: {"awaiting_deploy": "S-aw", "deploying": "S-dep", "monitoring": "S-mon"}, + ) + return direct, done + + +# --- TC-01 ------------------------------------------------------------------ +def test_tc01_done_task_converges_to_done(spy_setters): + direct, done = spy_setters + _make_task("done") + # Window is NOT active (no ARMED sentinel) -> Monitoring is spurious. + for setter in ( + plane_sync.set_issue_monitoring, + plane_sync.set_issue_awaiting_deploy, + plane_sync.set_issue_deploying, + ): + done.reset_mock() + direct.reset_mock() + setter("ORCH-061") + # Converged to Done; no intermediate deploy-status PATCH. + done.assert_called_once_with("ORCH-061") + direct.assert_not_called() + + +def test_tc01_decide_verdicts_for_done(): + _make_task("done") + # No window -> all three converge. + assert guard.decide("ORCH-061", guard.MONITORING) == guard.CONVERGE_DONE + assert guard.decide("ORCH-061", guard.AWAITING) == guard.CONVERGE_DONE + assert guard.decide("ORCH-061", guard.DEPLOYING) == guard.CONVERGE_DONE + + +def test_tc01_decide_allows_monitoring_in_active_window(tmp_path, monkeypatch): + _make_task("done") + # Arm the window: ARMED present, DONE absent. + post_deploy.write_marker("orchestrator", "ORCH-061", post_deploy.ARMED, "armed") + assert post_deploy.window_active("orchestrator", "ORCH-061") is True + assert guard.decide("ORCH-061", guard.MONITORING) == guard.ALLOW + # Awaiting/Deploying are ALWAYS spurious for a done task, even with a window. + assert guard.decide("ORCH-061", guard.AWAITING) == guard.CONVERGE_DONE + # Once the window closes (DONE present) Monitoring converges too. + post_deploy.mark_done("orchestrator", "ORCH-061") + assert post_deploy.window_active("orchestrator", "ORCH-061") is False + assert guard.decide("ORCH-061", guard.MONITORING) == guard.CONVERGE_DONE + + +# --- TC-02 ------------------------------------------------------------------ +def test_tc02_idempotent_no_pendulum(spy_setters): + direct, done = spy_setters + _make_task("done") + # Repeated calls keep converging to Done; the intermediate Monitoring PATCH + # never fires, so there is no Done<->deploy-status pendulum. + for _ in range(5): + plane_sync.set_issue_monitoring("ORCH-061") + assert direct.call_count == 0 + assert done.call_count == 5 # idempotent PATCH-equivalent (same terminal state) + + +# --- TC-03 ------------------------------------------------------------------ +def test_tc03_non_terminal_not_suppressed(spy_setters): + direct, done = spy_setters + _make_task("deploy") # a really-deploying task + plane_sync.set_issue_awaiting_deploy("ORCH-061") + plane_sync.set_issue_deploying("ORCH-061") + plane_sync.set_issue_monitoring("ORCH-061") + # All three proceed to a real PATCH; nothing converges to Done. + assert direct.call_count == 3 + done.assert_not_called() + assert guard.decide("ORCH-061", guard.MONITORING) == guard.ALLOW + + +# --- TC-04 ------------------------------------------------------------------ +def test_tc04_kill_switch(spy_setters, monkeypatch): + direct, done = spy_setters + _make_task("done") + # OFF -> terminal-blind, the monitoring PATCH proceeds (1:1 pre-ORCH-094). + monkeypatch.setattr(cfg.settings, "deploy_status_guard_enabled", False) + plane_sync.set_issue_monitoring("ORCH-061") + assert direct.call_count == 1 + done.assert_not_called() + # ON -> converge to Done. + monkeypatch.setattr(cfg.settings, "deploy_status_guard_enabled", True) + direct.reset_mock() + done.reset_mock() + plane_sync.set_issue_monitoring("ORCH-061") + direct.assert_not_called() + done.assert_called_once_with("ORCH-061") + + +# --- TC-05 ------------------------------------------------------------------ +def test_tc05_never_raise_on_db_error(spy_setters, monkeypatch): + direct, done = spy_setters + _make_task("done") + + def _boom(_wi): + raise RuntimeError("db down") + + monkeypatch.setattr(_db, "get_task_by_work_item_id", _boom) + # decide degrades to ALLOW (fail-safe), never raises. + assert guard.decide("ORCH-061", guard.MONITORING) == guard.ALLOW + # The setter proceeds with the normal PATCH (1:1), no convergence, no crash. + plane_sync.set_issue_monitoring("ORCH-061") + assert direct.call_count == 1 + done.assert_not_called() + + +def test_tc05_unknown_task_allows(spy_setters): + direct, done = spy_setters + # No task row at all -> ALLOW (foreign/unknown issue, not ours). + assert guard.decide("ORCH-999", guard.MONITORING) == guard.ALLOW + plane_sync.set_issue_monitoring("ORCH-999") + assert direct.call_count == 1 + done.assert_not_called() + + +def test_tc05_cancelled_is_suppressed(spy_setters): + direct, done = spy_setters + _make_task("cancelled") + assert guard.decide("ORCH-061", guard.MONITORING) == guard.SUPPRESS + plane_sync.set_issue_monitoring("ORCH-061") + # Suppressed: neither an intermediate PATCH nor a Done convergence. + direct.assert_not_called() + done.assert_not_called() + + +# --- TC-12 ------------------------------------------------------------------ +def test_tc12_non_self_repo_inert(spy_setters): + direct, done = spy_setters + # A non-self repo done task: the guard is inert (self-hosting only, empty CSV). + _make_task("done", repo="enduro-trails", wi="ET-042", branch="feature/ET-042-x") + assert guard.applies("enduro-trails") is False + assert guard.decide("ET-042", guard.MONITORING) == guard.ALLOW + plane_sync.set_issue_monitoring("ET-042") + # Behaviour unchanged: the requested PATCH proceeds, no convergence. + assert direct.call_count == 1 + done.assert_not_called() + + +def test_tc12_csv_scope_overrides_self_hosting(monkeypatch): + _make_task("done", repo="enduro-trails", wi="ET-042", branch="feature/ET-042-x") + # Explicit CSV scope brings a non-self repo in-scope. + monkeypatch.setattr(cfg.settings, "deploy_status_guard_repos", "enduro-trails") + assert guard.applies("enduro-trails") is True + assert guard.applies("orchestrator") is False # not listed -> out of scope + assert guard.decide("ET-042", guard.MONITORING) == guard.CONVERGE_DONE diff --git a/tests/test_deploy_terminal_sync.py b/tests/test_deploy_terminal_sync.py index c417cef..725db41 100644 --- a/tests/test_deploy_terminal_sync.py +++ b/tests/test_deploy_terminal_sync.py @@ -135,7 +135,10 @@ def test_tc08_self_deploy_done_sets_monitoring_not_done(monkeypatch): 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") + # ORCH-094: the terminal-sync caller now tags the reason (FR-4 observability). + stage_engine.set_issue_monitoring.assert_called_once_with( + "ORCH-036", reason="advance:deploy->done" + ) stage_engine.set_issue_done.assert_not_called() diff --git a/tests/test_post_deploy_monitor_termination.py b/tests/test_post_deploy_monitor_termination.py new file mode 100644 index 0000000..46a1113 --- /dev/null +++ b/tests/test_post_deploy_monitor_termination.py @@ -0,0 +1,170 @@ +"""ORCH-094 — deterministic post-deploy-monitor termination (FR-3 / AC-3). + +Covers (04-test-plan.yaml): + TC-06 after the window finishes (HEALTHY, ticks==budget -> set_issue_done + + `done` marker) there are NO further status PATCHes for the task (a second + tick is a no-op: 0 set_issue_* calls). + TC-07 a tick at DB stage=done with a closed window OR a task cancelled mid-window + -> immediate no-op: no status PATCH and no next-tick enqueue (zombie-tick + excluded). + TC-08 arm_monitor does not re-arm a task already in done (armed/done marker -> + no-op), and a deploy->done re-drive after the window closed converges to + Done instead of resurrecting Monitoring. +""" +import os +import tempfile + +import pytest + +_test_db = os.path.join(tempfile.gettempdir(), "test_post_deploy_termination.db") +os.environ["ORCH_DB_PATH"] = _test_db +os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir() +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") + +from unittest.mock import MagicMock # noqa: E402 + +import src.db as _db # noqa: E402 +from src.db import init_db, get_db # noqa: E402 +from src import stage_engine # noqa: E402 +from src import post_deploy # noqa: E402 +from src import config as cfg # 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(post_deploy.settings, "repos_dir", str(tmp_path)) + monkeypatch.setattr(post_deploy.settings, "host_repos_dir", str(tmp_path)) + # Small window so the budget is 1 tick (window // interval). + monkeypatch.setattr(stage_engine.settings, "post_deploy_window_s", 10) + monkeypatch.setattr(stage_engine.settings, "post_deploy_interval_s", 10) + monkeypatch.setattr(post_deploy.settings, "post_deploy_window_s", 10) + monkeypatch.setattr(post_deploy.settings, "post_deploy_interval_s", 10) + # write_post_deploy_log touches a worktree/git; stub it. + monkeypatch.setattr(post_deploy, "write_post_deploy_log", MagicMock(return_value=True)) + yield + + +@pytest.fixture +def spy_status(monkeypatch): + setters = {} + for name in ("set_issue_done", "set_issue_monitoring", "set_issue_awaiting_deploy", + "set_issue_deploying", "set_issue_blocked"): + m = MagicMock() + monkeypatch.setattr(stage_engine, name, m) + setters[name] = m + monkeypatch.setattr(stage_engine, "_notify_post_deploy", MagicMock()) + return setters + + +def _make_task(stage="done", repo="orchestrator", wi="ORCH-061", branch="feature/ORCH-061-x"): + 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), + ) + tid = cur.lastrowid + conn.commit() + conn.close() + return tid + + +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 _healthy(*a, **k): + return post_deploy.ProbeResult(health_ok=True, total=2, fivexx=0, detail="ok") + + +# --- TC-06 ------------------------------------------------------------------ +def test_tc06_clean_finish_then_no_more_patches(spy_status, monkeypatch): + monkeypatch.setattr(post_deploy, "probe_signals", _healthy) + tid = _make_task("done") + post_deploy.write_marker("orchestrator", "ORCH-061", post_deploy.ARMED, "armed") + + job = {"task_id": tid, "repo": "orchestrator", "id": 1, "agent": "post-deploy-monitor"} + # Tick 1: budget==1, ticks==1 -> HEALTHY window exhausted -> finish. + stage_engine.run_post_deploy_monitor(job) + spy_status["set_issue_done"].assert_called_once_with("ORCH-061") + assert post_deploy.has_marker("orchestrator", "ORCH-061", post_deploy.DONE) + # No next tick was enqueued (window exhausted). + assert _jobs() == [] + + # Tick 2 (e.g. duplicate job): DONE marker present -> no-op, ZERO new PATCHes. + spy_status["set_issue_done"].reset_mock() + stage_engine.run_post_deploy_monitor(job) + spy_status["set_issue_done"].assert_not_called() + spy_status["set_issue_monitoring"].assert_not_called() + assert _jobs() == [] + + +# --- TC-07 ------------------------------------------------------------------ +def test_tc07_cancelled_mid_window_is_noop(spy_status, monkeypatch): + monkeypatch.setattr(post_deploy, "probe_signals", _healthy) + tid = _make_task("cancelled") + post_deploy.write_marker("orchestrator", "ORCH-061", post_deploy.ARMED, "armed") + + job = {"task_id": tid, "repo": "orchestrator", "id": 1, "agent": "post-deploy-monitor"} + stage_engine.run_post_deploy_monitor(job) + # Zombie-tick guard: window closed, NO status PATCH, NO next tick. + for name, m in spy_status.items(): + m.assert_not_called() + assert post_deploy.has_marker("orchestrator", "ORCH-061", post_deploy.DONE) + assert _jobs() == [] + + +def test_tc07_finished_window_is_noop(spy_status, monkeypatch): + monkeypatch.setattr(post_deploy, "probe_signals", _healthy) + tid = _make_task("done") + # Window already finished (DONE marker present) -> no active basis to tick. + post_deploy.write_marker("orchestrator", "ORCH-061", post_deploy.ARMED, "armed") + post_deploy.mark_done("orchestrator", "ORCH-061") + + job = {"task_id": tid, "repo": "orchestrator", "id": 1, "agent": "post-deploy-monitor"} + stage_engine.run_post_deploy_monitor(job) + spy_status["set_issue_done"].assert_not_called() + spy_status["set_issue_monitoring"].assert_not_called() + assert _jobs() == [] + + +# --- TC-08 ------------------------------------------------------------------ +def test_tc08_arm_monitor_idempotent_no_rearm(monkeypatch): + tid = _make_task("done") + # First arm: writes ARMED + enqueues tick 1. + assert post_deploy.arm_monitor("orchestrator", "ORCH-061", "feature/ORCH-061-x", tid) is True + assert _jobs() == ["post-deploy-monitor"] + # Second arm (re-drive deploy->done): ARMED present -> no-op, no new job. + assert post_deploy.arm_monitor("orchestrator", "ORCH-061", "feature/ORCH-061-x", tid) is False + assert _jobs() == ["post-deploy-monitor"] + + +def test_tc08_redrive_after_window_closed_converges(spy_status, monkeypatch): + # Guard ON, self-hosting. + monkeypatch.setattr(cfg.settings, "deploy_status_guard_enabled", True, raising=False) + monkeypatch.setattr(cfg.settings, "deploy_status_guard_repos", "", raising=False) + _make_task("done") + # Window armed then closed (a completed post-deploy observation). + post_deploy.write_marker("orchestrator", "ORCH-061", post_deploy.ARMED, "armed") + post_deploy.mark_done("orchestrator", "ORCH-061") + # A stale re-drive calling the REAL guarded setter must converge to Done, not + # resurrect Monitoring. (Use the real plane_sync setter via stage_engine import.) + from src import plane_sync + direct = MagicMock() + done = MagicMock() + monkeypatch.setattr(plane_sync, "_set_issue_state_direct", direct) + monkeypatch.setattr(plane_sync, "set_issue_done", done) + monkeypatch.setattr(plane_sync, "_resolve_project_id", lambda w=None, p=None: "proj-1") + monkeypatch.setattr(plane_sync, "get_project_states", lambda pid: {"monitoring": "S-mon"}) + + plane_sync.set_issue_monitoring("ORCH-061", reason="advance:deploy->done") + direct.assert_not_called() + done.assert_called_once_with("ORCH-061") diff --git a/tests/test_reconciler_done_deploy_convergence.py b/tests/test_reconciler_done_deploy_convergence.py new file mode 100644 index 0000000..a3057f0 --- /dev/null +++ b/tests/test_reconciler_done_deploy_convergence.py @@ -0,0 +1,82 @@ +"""ORCH-094 — sync convergence for a done task stuck on a deploy status (TC-10). + +Integration-level: ANY sync source (reconciler tick / monitor tick / a direct +deploy-status setter call) that touches a DB-done task converges Plane to Done +idempotently instead of an intermediate deploy status, and a repeated tick does +NOT swing the Done<->deploy-status pendulum. The guard lives on the setter +(ADR-001 D1/D7), so the reconciler code itself is unchanged — driving the setter +the way a stale actor would is the faithful reproduction of the 061 flapp. +""" +import os +import tempfile + +import pytest + +_test_db = os.path.join(tempfile.gettempdir(), "test_reconciler_done_converge.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 plane_sync # noqa: E402 +from src import post_deploy # noqa: E402 +from src import config as cfg # 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(cfg.settings, "deploy_status_guard_enabled", True, raising=False) + monkeypatch.setattr(cfg.settings, "deploy_status_guard_repos", "", raising=False) + monkeypatch.setattr(post_deploy.settings, "repos_dir", str(tmp_path)) + monkeypatch.setattr(post_deploy.settings, "host_repos_dir", str(tmp_path)) + yield + + +@pytest.fixture +def spy(monkeypatch): + direct = MagicMock() + done = MagicMock() + monkeypatch.setattr(plane_sync, "_set_issue_state_direct", direct) + monkeypatch.setattr(plane_sync, "set_issue_done", done) + monkeypatch.setattr(plane_sync, "_resolve_project_id", lambda w=None, p=None: "proj-1") + monkeypatch.setattr( + plane_sync, "get_project_states", + lambda pid: {"awaiting_deploy": "S-aw", "deploying": "S-dep", "monitoring": "S-mon"}, + ) + return direct, done + + +def _make_task(stage="done", repo="orchestrator", wi="ORCH-061"): + conn = get_db() + conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) " + "VALUES (?, ?, ?, ?, ?)", + (f"plane-{wi}", wi, repo, "feature/ORCH-061-x", stage), + ) + conn.commit() + conn.close() + + +def test_tc10_repeated_sync_converges_no_pendulum(spy): + direct, done = spy + _make_task("done") # done, window closed (no ARMED sentinel) + # Simulate many sync ticks alternately trying to set Monitoring / Awaiting, + # exactly like the observed 061 pendulum (Awaiting <-> Monitoring forever). + for i in range(10): + if i % 2 == 0: + plane_sync.set_issue_monitoring("ORCH-061", reason="reconciler-tick") + else: + plane_sync.set_issue_awaiting_deploy("ORCH-061", reason="reconciler-tick") + # Every tick converged to Done; not a single intermediate deploy-status PATCH. + assert direct.call_count == 0 + assert done.call_count == 10 + # All convergence calls target the same terminal Done (no swing). + assert all(c.args == ("ORCH-061",) for c in done.call_args_list) diff --git a/tests/test_self_deploy_cycle_regression.py b/tests/test_self_deploy_cycle_regression.py new file mode 100644 index 0000000..fda6cfd --- /dev/null +++ b/tests/test_self_deploy_cycle_regression.py @@ -0,0 +1,128 @@ +"""ORCH-094 — the real deploy cycle is NOT suppressed by the guard (TC-11 / AC-4). + +A genuinely-deploying (non-terminal) self-hosting task must still walk +`Awaiting Deploy -> Deploying -> Monitoring after Deploy -> Done` exactly as before +ORCH-094. The critical regression case is the LEGITIMATE first `Monitoring`: by the +time the terminal-sync runs the task is ALREADY DB-`done` (update_task_stage ran +above), so the guard would wrongly converge it to Done UNLESS the arm-block moved +ABOVE the terminal-sync (ADR-001 D3) marks the post-deploy window active first. +This test exercises that ordering end-to-end via run_deploy_finalizer with the REAL +guard + REAL arm_monitor wired in (only the network PATCH primitive is mocked). +""" +import os +import tempfile + +import pytest + +_test_db = os.path.join(tempfile.gettempdir(), "test_self_deploy_cycle_regression.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 plane_sync # noqa: E402 +from src import post_deploy # noqa: E402 +from src import self_deploy # noqa: E402 +from src import config as cfg # 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(post_deploy.settings, "repos_dir", str(tmp_path)) + monkeypatch.setattr(post_deploy.settings, "host_repos_dir", str(tmp_path)) + # Guard ON, self-hosting only. + monkeypatch.setattr(cfg.settings, "deploy_status_guard_enabled", True, raising=False) + monkeypatch.setattr(cfg.settings, "deploy_status_guard_repos", "", raising=False) + # Post-deploy monitor applies for self repo (arm fires on deploy->done). + monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True) + monkeypatch.setattr(post_deploy.settings, "post_deploy_repos", "") + monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_monitor_enabled", True) + monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_repos", "") + # Stub the worktree/git artefact writers. + monkeypatch.setattr(stage_engine.self_deploy, "write_deploy_log", MagicMock(return_value=True)) + monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", MagicMock()) + yield + + +@pytest.fixture +def spy_plane(monkeypatch): + """Spy plane_sync's low-level PATCH + Done convergence (the REAL guard runs).""" + direct = MagicMock() + done = MagicMock() + monkeypatch.setattr(plane_sync, "_set_issue_state_direct", direct) + monkeypatch.setattr(plane_sync, "set_issue_done", done) + monkeypatch.setattr(plane_sync, "_resolve_project_id", lambda w=None, p=None: "proj-1") + monkeypatch.setattr( + plane_sync, "get_project_states", + lambda pid: {"awaiting_deploy": "S-aw", "deploying": "S-dep", "monitoring": "S-mon", + "done": "S-done"}, + ) + # stage_engine.set_issue_done is a module-level binding -> patch it too so a + # non-self / fallback Done path is observable; here we expect Monitoring though. + monkeypatch.setattr(stage_engine, "set_issue_done", done) + return direct, done + + +def _make_task(stage, repo="orchestrator", wi="ORCH-063", branch="feature/ORCH-063-x"): + 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), + ) + tid = cur.lastrowid + conn.commit() + conn.close() + return tid + + +def _pass(*a, **k): + return (True, "ok") + + +def test_tc11_non_terminal_awaiting_deploying_pass(spy_plane): + direct, done = spy_plane + _make_task("deploy") + # Phase A / Phase B statuses on a NON-terminal task proceed (no convergence). + plane_sync.set_issue_awaiting_deploy("ORCH-063", reason="phase_a") + plane_sync.set_issue_deploying("ORCH-063", reason="phase_b") + assert direct.call_count == 2 + done.assert_not_called() + + +def test_tc11_legit_monitoring_preserved_on_deploy_done(spy_plane, monkeypatch): + direct, done = spy_plane + # Hook reported exit 0. + self_deploy.write_marker("orchestrator", "ORCH-063", self_deploy.RESULT, "0") + monkeypatch.setattr( + stage_engine, "QG_CHECKS", + {**stage_engine.QG_CHECKS, "check_deploy_status": _pass}, + ) + + tid = _make_task("deploy") + stage_engine.run_deploy_finalizer( + {"task_id": tid, "repo": "orchestrator", "id": 1, "agent": "deploy-finalizer"} + ) + + # Stage advanced to done. + conn = get_db() + stage = conn.execute("SELECT stage FROM tasks WHERE id=?", (tid,)).fetchone()[0] + conn.close() + assert stage == "done" + # The arm-block ran BEFORE terminal-sync -> the window is active -> the guard + # ALLOWS the legitimate Monitoring PATCH (S-mon), it is NOT converged to Done. + assert post_deploy.has_marker("orchestrator", "ORCH-063", post_deploy.ARMED) + mon_calls = [c for c in direct.call_args_list if c.args[1] == "S-mon"] + assert len(mon_calls) == 1, f"expected one Monitoring PATCH, got {direct.call_args_list}" + done.assert_not_called()