From b6c0e11e4d8ea470ab97244c6ff9ce15605809f0 Mon Sep 17 00:00:00 2001 From: Slava Date: Mon, 8 Jun 2026 00:00:15 +0300 Subject: [PATCH 1/8] docs: init ORCH-066 business request --- docs/work-items/ORCH-066/00-business-request.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 docs/work-items/ORCH-066/00-business-request.md diff --git a/docs/work-items/ORCH-066/00-business-request.md b/docs/work-items/ORCH-066/00-business-request.md new file mode 100644 index 0000000..bf2dac6 --- /dev/null +++ b/docs/work-items/ORCH-066/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: [высокий] Статусная модель Plane: осмысленные статусы этапов + +Work Item ID: ORCH-066 + +## Description + +TBD From 4a06537afde18cc92da2c0088607d77874e910a7 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 21:05:49 +0000 Subject: [PATCH 2/8] analyst(ET): auto-commit from analyst run_id=339 --- docs/work-items/ORCH-066/01-brd.md | 110 +++++++++++ docs/work-items/ORCH-066/02-trz.md | 178 +++++++++++++++++ .../ORCH-066/03-acceptance-criteria.md | 71 +++++++ docs/work-items/ORCH-066/04-test-plan.yaml | 184 ++++++++++++++++++ 4 files changed, 543 insertions(+) create mode 100644 docs/work-items/ORCH-066/01-brd.md create mode 100644 docs/work-items/ORCH-066/02-trz.md create mode 100644 docs/work-items/ORCH-066/03-acceptance-criteria.md create mode 100644 docs/work-items/ORCH-066/04-test-plan.yaml diff --git a/docs/work-items/ORCH-066/01-brd.md b/docs/work-items/ORCH-066/01-brd.md new file mode 100644 index 0000000..42e382b --- /dev/null +++ b/docs/work-items/ORCH-066/01-brd.md @@ -0,0 +1,110 @@ +# 01 — Business Requirements Document (BRD) + +**Work Item:** ORCH-066 +**Заголовок:** [высокий] Статусная модель Plane: осмысленные статусы этапов +**Стадия:** analysis +**Автор:** Analyst +**Дата:** 2026-06-07 + +--- + +## 1. Контекст и проблема + +Статусная модель Plane оркестратора имеет **семантические перегрузки**: один и тот +же Plane-статус используется для несовместимых смыслов, из-за чего: + +- оператор не понимает, на каком реально этапе стоит задача (доска нечитаема); +- повышается риск ошибки оператора (например, неверный ручной перевод статуса); +- `In Progress` одновременно означает «человек запускает конвейер», «идёт анализ», + «идёт прод-деплой» и «возврат из Needs Input» — четыре разных смысла на одном статусе. + +Уже частично исправлено: ORCH-059 ввёл отдельный статус для подтверждения деплоя +(`Confirm Deploy`), разгрузив перегруженный `Approved`. ORCH-066 завершает наведение +порядка по **утверждённой Owner** статусной модели. + +### Два слоя (критично различать) + +| Слой | Что это | Источник | Трогаем? | +|------|---------|----------|----------| +| **A** | `STAGE_TRANSITIONS` — внутренняя машина стадий (`created→analysis→…→done`) | `src/stages.py` | **НЕТ (инвариант)** | +| **B** | Plane-статусы — индикация на доске | `src/plane_sync.py` + точки в `src/stage_engine.py` / `src/webhooks/plane.py` | **ДА** | + +ORCH-066 меняет **только слой B** и точки, где код вручную проставляет Plane-статусы. + +--- + +## 2. Целевая статусная модель (решение Owner) + +``` +Backlog → Todo → [To Analyse] → Analysis → [In Review → Approved] → Architecture → +Development → Code-Review → Testing → Awaiting Deploy → [Confirm Deploy] → Deploying → +Monitoring after Deploy → Done +``` + +- `[...]` = **действие человека** (вход-триггер). +- Остальное ставит **орк** (индикация). + +### Ветки (нелинейные исходы) +- **Rejected** — откат на предыдущую стадию (человек). +- **Needs Input** — ТОЛЬКО аналитик (НЕ расширять на других агентов). +- **Blocked** — затык / фейл деплоя / деградация прода. +- **Cancelled** — человек решил не делать задачу (валидный выход из In Review). + +--- + +## 3. Бизнес-требования + +| ID | Требование | Приоритет | +|----|------------|-----------| +| **BR-1** | Каждый этап конвейера показывается на доске Plane осмысленным статусом (To Analyse / Analysis / Code-Review / Awaiting Deploy / Deploying / Monitoring after Deploy). | Must | +| **BR-2** | `To Analyse` — единый человеческий вход: (а) старт нового конвейера, (б) resume/relaunch аналитика при возврате из Needs Input. Заменяет роль `In Progress` как входа-триггера. | Must | +| **BR-3** | Стадия `analysis` индицируется отдельным статусом `Analysis` (орк ставит при старте/relaunch аналитика), а не `In Progress`. | Must | +| **BR-4** | Стадия `review` индицируется Plane-статусом `Code-Review` (переименование `Review`). | Must | +| **BR-5** | Self-deploy Phase A (approval-pending) ставит `Awaiting Deploy` вместо `In Review`. | Must | +| **BR-6** | Self-deploy Phase B (старт прод-деплоя) ставит `Deploying`. | Must | +| **BR-7** | Self-deploy Phase C (health-OK финализация) ставит `Monitoring after Deploy` (НЕ `Done` сразу). | Must | +| **BR-8** | Post-deploy monitor (ORCH-021): чистое закрытие окна (HEALTHY) → `Done`; UNHEALTHY/деградация → `Blocked`. | Must | +| **BR-9** | `In Review` разгрузить: оставить ТОЛЬКО за approve-pending артефактов конвейера (BRD/ревью). Выходы: `Approved` (вперёд), `Rejected` (откат), `Cancelled` (человек отменил). | Must | +| **BR-10** | `Needs Input` — БЕЗ ИЗМЕНЕНИЙ. Остаётся только у аналитика (`01-questions.md` → `set_issue_needs_input`). Механизм не трогать. | Must | +| **BR-11** | Возврат аналитика из Needs Input выполняется через `To Analyse` (а НЕ через `In Progress`). Логика fork «старт vs resume» (по наличию task + active-job) сохраняется. | Must (грабли R1) | +| **BR-12** | **Fail-closed:** отсутствие нового статуса в проекте (enduro / Plane API down / fallback `_DEFAULT_STATES`) НЕ приводит к падению; поведение остаётся backward-compatible (паттерн ORCH-059 AC-7). | Must | +| **BR-13** | Reconciler не «оживляет» активные ожидания (`Awaiting Deploy` / `Deploying` / `Monitoring after Deploy`) как зависшие задачи (Guard 2 skip-list). | Must | +| **BR-14** | Документация (golden source) обновлена в том же PR: `CLAUDE.md`, `docs/architecture/README.md`, `CHANGELOG.md`, ADR per-work-item. | Must | + +--- + +## 4. Границы (Out of Scope / НЕ трогать) + +- `STAGE_TRANSITIONS` (`src/stages.py`) — машина стадий, инвариант. +- `QG_CHECKS`, `check_deploy_status`, exit-коды хука (0/1/2), merge-gate, схема БД. +- `Confirm Deploy` (уже работает, ORCH-059). +- Механизм `Needs Input` (analyst-only) — не расширять, не менять. +- Поведение прод-деплоя **не-self** репозиториев (enduro-trails): для них терминальный + переход остаётся `deploy → Done` как сейчас (Monitoring after Deploy не применяется — + post-deploy monitor армится только для self-hosting). +- Автоматический approve / авто-rollback self-hosting (ORCH-54 / ORCH-021 политика + ALERT_ONLY) — не меняется. + +--- + +## 5. Инфра-предусловие (вне кода, делает оператор) + +Новые Plane-статусы в проекте **ORCH** создаёт оператор через Plane API **ДО** эксплуатации: +`To Analyse`, `Analysis`, `Code-Review`, `Awaiting Deploy`, `Deploying`, +`Monitoring after Deploy` (`Confirm Deploy` уже есть). + +Резолвер (`_PLANE_NAME_TO_KEY` + `get_project_states`) подхватывает их **по имени** с +**fail-closed fallback** на `_DEFAULT_STATES` (см. BR-12). Документируется в +`07-infra-requirements.md` (создаёт архитектор) и в `docs/operations/`. + +--- + +## 6. Definition of Done + +- Plane показывает осмысленные статусы на каждом этапе. +- Возврат аналитика из Needs Input работает через `To Analyse`. +- Phase A → `Awaiting Deploy`, Phase B → `Deploying`, Phase C → `Monitoring after Deploy`, + окно HEALTHY → `Done`, фейл → `Blocked`. +- `STAGE_TRANSITIONS` не изменён. +- `pytest tests/ -q` — зелёный. Fail-closed покрыт тестами. +- Документация обновлена. diff --git a/docs/work-items/ORCH-066/02-trz.md b/docs/work-items/ORCH-066/02-trz.md new file mode 100644 index 0000000..a38860c --- /dev/null +++ b/docs/work-items/ORCH-066/02-trz.md @@ -0,0 +1,178 @@ +# 02 — Техническое задание (ТЗ) + +**Work Item:** ORCH-066 +**Стадия:** analysis → (вход для architecture) +**Автор:** Analyst + +> ТЗ фиксирует ТРЕБУЕМОЕ ПОВЕДЕНИЕ и затронутые точки кода. Конкретную архитектуру +> резолвера (точные имена ключей/функций) финализирует архитектор в ADR. Ниже — +> опорный контракт, согласованный с бизнес-запросом Owner. + +--- + +## 1. Задействованные модули `src/` + +| Модуль | Роль в задаче | +|--------|---------------| +| `src/plane_sync.py` | **Ядро изменений (слой B):** реестр логических статусов (`_DEFAULT_STATES`), `_PLANE_NAME_TO_KEY`, маппинг стадия→статус (`_STAGE_TO_STATE_KEY`, `STAGE_VISIBILITY_STATE`), хелперы `set_issue_*`. | +| `src/webhooks/plane.py` | Маршрутизация входящего статуса (`handle_issue_updated`): `To Analyse` → `handle_status_start` (старт **или** resume). | +| `src/stage_engine.py` | Точки ручной простановки статуса: analyst-flow (`Analysis`/`Needs Input`/`In Review`), Phase A (`Awaiting Deploy`), Phase B (`Deploying`), Phase C → `Monitoring after Deploy`, post-deploy monitor → `Done`/`Blocked`. | +| `src/reconciler.py` | F-2 запрос статусов (`To Analyse` в список), Guard 2 skip-list (активные ожидания). | +| `src/stages.py` | **НЕ менять** (инвариант слоя A). Используется только для чтения переходов. | +| `src/config.py` | (При необходимости) kill-switch для новой статусной модели — на усмотрение архитектора (см. §6). | + +--- + +## 2. Изменения статусной модели (слой B) + +### 2.1. Реестр логических статусов (`src/plane_sync.py`) + +Ввести новые **логические ключи** и их имена в `_PLANE_NAME_TO_KEY`: + +| Логический ключ | Plane name | Назначение | +|-----------------|-----------|------------| +| `to_analyse` | `To Analyse` | Вход-триггер (старт + resume аналитика). | +| `analysis` | `Analysis` | Индикация стадии analysis (орк). | +| `code_review` | `Code-Review` | Индикация стадии review (орк). Заменяет `review`. | +| `awaiting_deploy` | `Awaiting Deploy` | Phase A approval-pending (орк). | +| `deploying` | `Deploying` | Phase B прод-деплой идёт (орк). | +| `monitoring` | `Monitoring after Deploy` | Phase C / post-deploy окно (орк). | + +Сохранить существующие: `backlog`, `todo`, `in_progress` (backward-compat), `needs_input`, +`in_review`, `blocked`, `done`, `cancelled`, `architecture`, `development`, `testing`, +`approved`, `rejected`. `Cancelled` уже присутствует в `_PLANE_NAME_TO_KEY`. + +### 2.2. Fail-closed резолюция (КРИТИЧНО — BR-12) + +`get_project_states()` после резолва по API делает `setdefault(k, v)` из `_DEFAULT_STATES`. +Чтобы отсутствие нового статуса в проекте (enduro / Plane down / частичная конфигурация) +**не ломало** конвейер, новые логические ключи в `_DEFAULT_STATES` должны +**алиаситься на существующие UUID** (degrade-to-current): + +| Новый ключ | Default-алиас (UUID) | Деградированное поведение | +|------------|----------------------|---------------------------| +| `to_analyse` | = `in_progress` | enduro/старый проект: `In Progress` по-прежнему триггерит старт/resume. | +| `analysis` | = `in_progress` | analysis показывается как `In Progress` (как сейчас). | +| `code_review` | = `review` | review показывается как `Review` (как сейчас). | +| `awaiting_deploy` | = `in_review` | Phase A показывается как `In Review` (как сейчас). | +| `deploying` | = `in_progress` | Phase B показывается как `In Progress` (как сейчас). | +| `monitoring` | = `done` | Phase C показывается как `Done` (как сейчас); монитор затем держит Done / флипает Blocked. | + +> Эффект: если оператор НЕ создал новый статус — система работает строго как до ORCH-066 +> (никаких падений, никаких 404 от Plane PATCH). Если создал — резолвится по имени и +> используется новый UUID. Это ровно паттерн ORCH-059 AC-7. + +### 2.3. Маппинг стадия → статус + +`src/plane_sync.py`: +- `_STAGE_TO_STATE_KEY`: `analysis` → `analysis` (было `in_progress`); `review` → `code_review` + (было `review`). `deploy` остаётся (управляется Phase A/B/C напрямую, не через + `update_issue_state`). `created`/`architecture`/`development`/`testing`/`done` — без изменений. +- `STAGE_VISIBILITY_STATE`: `review` → `code_review` (было `review`). Добавить + `analysis` → `analysis`, если индикация analysis ставится через `set_issue_stage_state` + (решает архитектор; альтернатива — отдельный хелпер `set_issue_analysis`). +- Сохранить совместимость `STAGE_TO_STATE` / `PLANE_STATES` алиасов (импортируются тестами). + +### 2.4. Точки простановки статуса + +| Место (файл:симв.) | Сейчас | Должно стать | +|--------------------|--------|--------------| +| `webhooks/plane.py` `handle_issue_updated` | `new_state == in_progress` → `handle_status_start` | `new_state == to_analyse` (с fail-closed: при алиасе совпадает с `in_progress`) → `handle_status_start` | +| `webhooks/plane.py` `start_pipeline` (старт) | статус остаётся `In Progress` | при старте/enqueue analyst орк ставит `Analysis` | +| `webhooks/plane.py` `handle_status_start` (resume из Needs Input) | relaunch на `In Progress`-триггере | relaunch на `To Analyse`-триггере; при relaunch орк ставит `Analysis`. Fork «старт vs resume» (по `get_task_by_plane_id` + `has_active_job_for_task`) — **сохранить как есть.** | +| `stage_engine.py` `_handle_analysis_approved_flow` (artifacts ready) | `set_issue_in_review` | оставить `In Review` (BR-9: In Review только за approve-pending конвейера) ✔ без изменений | +| `stage_engine.py` `_handle_analysis_approved_flow` (questions) | `set_issue_needs_input` | **без изменений** (BR-10) | +| `stage_engine.py` `_handle_self_deploy_phase_a` | `set_issue_in_review` | `Awaiting Deploy` (`set_issue_awaiting_deploy` или аналог) | +| `stage_engine.py` `_handle_self_deploy_phase_b` | (статус не меняет) | `Deploying` | +| `stage_engine.py` advance `deploy → done` (terminal-sync, строка ~338) | `set_issue_done` для всех | **self-hosting:** `Monitoring after Deploy` (перед/вместо арма монитора); **не-self:** `Done` как сейчас | +| `stage_engine.py` `run_post_deploy_monitor` (HEALTHY, окно закрыто) | пишет лог + коммент, статус Plane НЕ трогает (остаётся Done) | `Done` (явно) | +| `stage_engine.py` `run_post_deploy_monitor` (DEGRADED) | пишет лог + alert | `Blocked` | + +> **Замечание по terminal-sync (важно для архитектора):** сейчас `advance_stage` на +> `next_stage == "done"` вызывает `set_issue_done` безусловно (строка ~338), затем армит +> post-deploy monitor для self-hosting (~361). Нужно развести: для репо, где +> `post_deploy.post_deploy_applies(repo)` истинно (self-hosting) — ставить `Monitoring +> after Deploy` вместо `Done`, и переложить простановку `Done`/`Blocked` на финал +> монитора (`run_post_deploy_monitor`). Для прочих репо — `Done` как сейчас. + +### 2.5. Новые хелперы `src/plane_sync.py` + +Добавить тонкие обёртки по образцу `set_issue_in_review` (резолв per-project UUID + +`_set_issue_state_direct`), never-raise при отсутствии issue: +- `set_issue_analysis(work_item_id, project_id=None)` +- `set_issue_code_review(...)` (или через `set_issue_stage_state("review")`) +- `set_issue_awaiting_deploy(...)` +- `set_issue_deploying(...)` +- `set_issue_monitoring(...)` + +(Точный набор/именование — на усмотрение архитектора; контракт: per-project резолв + +fail-closed.) + +--- + +## 3. Изменения reconciler (`src/reconciler.py`) + +- **F-2** `_reconcile_plane_project`: добавить `to_analyse` в список запрашиваемых + статусов (`list_issues_by_state([... , to_analyse])`) и в `_reconcile_plane_issue` + маршрутизировать `new_state == to_analyse` → `handle_status_start` (старт при `task is + None`, resume при существующем task без active-job — логика уже в `handle_status_start`). + Сохранить обработку `approved`/`rejected`. При fail-closed алиасе `to_analyse==in_progress` + поведение не дублируется (один и тот же UUID). +- **Guard 2** `_is_blocked_or_needs_input` (F-1 skip): расширить skip-множество активными + ожиданиями — `awaiting_deploy`, `deploying`, `monitoring` — чтобы реконсилер НЕ + «оживлял» их как зависшие (BR-13). Имя метода/семантику можно обобщить + («human-or-active-wait»), флаг `reconcile_skip_blocked_enabled` продолжает управлять + этим networked-чеком. + +> Примечание: F-1 и так не тронет Phase A (`check_deploy_status` red → silent), +> Deploying (active finalizer job), Monitoring (стадия `done`). Guard 2 — явная +> defense-in-depth по требованию Owner. + +--- + +## 4. Изменения API / эндпоинтов + +**Нет** новых HTTP-эндпоинтов. `GET /queue` / `GET /status` — без изменений контракта +(статусы Plane там не отражаются). Изменения только во внешней индикации Plane (PATCH +issue state — существующий механизм). + +--- + +## 5. Изменения схемы БД + +**Нет.** `tasks` не хранит Plane-статус (источник истины — стадия в БД + Plane API). +Миграции не требуются. + +--- + +## 6. Требования к новым QG checks + +**Нет.** `QG_CHECKS` не расширяется. Статусы — индикация, не управление (канон: +машинные вердикты читаются из YAML-frontmatter артефактов, не из Plane-статуса). + +Опционально (на усмотрение архитектора): единый kill-switch новой статусной модели +(env-флаг) для безопасного раската, по образцу `staging_infra_tolerance_enabled` / +`reconcile_skip_blocked_enabled`. Не обязателен, т.к. fail-closed алиасинг (§2.2) уже даёт +backward-compatible деградацию. + +--- + +## 7. Артефакты pipeline, создаваемые/обновляемые + +- `06-adr/ADR-001-plane-status-model.md` — архитектор (решение по резолверу, + алиасингу, разводке terminal-sync). +- `07-infra-requirements.md` — архитектор (список Plane-статусов для ручного создания + оператором + Plane API инструкция). +- Документация (golden source, тот же PR): `CLAUDE.md` (секция статусной модели), + `docs/architecture/README.md` (секция статусов рядом с ORCH-036/ORCH-021), + `CHANGELOG.md`. + +--- + +## 8. Инварианты (проверяемые) + +- `src/stages.py` `STAGE_TRANSITIONS` — байт-в-байт без изменений. +- `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status`, exit-коды хука, merge-gate, + схема БД, `Confirm Deploy`, механизм `Needs Input` — без изменений. +- Все новые `set_issue_*` / резолв — never-raise (Plane down ⇒ degrade, не crash). +- Поведение enduro (не-self) и его терминальный `Done` — без регресса. diff --git a/docs/work-items/ORCH-066/03-acceptance-criteria.md b/docs/work-items/ORCH-066/03-acceptance-criteria.md new file mode 100644 index 0000000..73252f3 --- /dev/null +++ b/docs/work-items/ORCH-066/03-acceptance-criteria.md @@ -0,0 +1,71 @@ +# 03 — Критерии приёмки (Acceptance Criteria) + +**Work Item:** ORCH-066 + +Каждый критерий — чёткое условие PASS/FAIL. Покрытие тестами — см. `04-test-plan.yaml`. + +--- + +## Группа A — Вход и стадия анализа + +| ID | Критерий | PASS | FAIL | +|----|----------|------|------| +| **AC-1** | `To Analyse` запускает конвейер | Перевод issue без task в `To Analyse` → `handle_status_start` → `start_pipeline` (создаётся task, ветка, enqueue analyst). | Не запускается / запускается на другом статусе. | +| **AC-2** | `To Analyse` делает resume аналитика из Needs Input | Существующий task без active-job + перевод в `To Analyse` → relaunch агента текущей стадии (analyst читает свежие комменты). Fork «старт vs resume» определяется по `get_task_by_plane_id` + `has_active_job_for_task` (как раньше). | Создаётся второй task / двойной запуск / resume не происходит. | +| **AC-3** | Стадия `analysis` индицируется статусом `Analysis` | При старте/relaunch аналитика орк ставит `Analysis`. | Остаётся `In Progress` (при наличии статуса `Analysis` в проекте). | +| **AC-4** | Busy-guard сохранён | `To Analyse` при существующем active-job для task → НЕ relaunch (no double launch). | Двойной запуск агента. | + +## Группа B — Code-Review + +| ID | Критерий | PASS | FAIL | +|----|----------|------|------| +| **AC-5** | Стадия `review` индицируется `Code-Review` | Вход в стадию `review` → Plane-статус `Code-Review`. | Остаётся `Review` (при наличии нового статуса). | + +## Группа C — Self-deploy фазы + +| ID | Критерий | PASS | FAIL | +|----|----------|------|------| +| **AC-6** | Phase A → `Awaiting Deploy` | `_handle_self_deploy_phase_a` ставит `Awaiting Deploy` (не `In Review`). | Ставит `In Review` (при наличии нового статуса). | +| **AC-7** | Phase B → `Deploying` | `_handle_self_deploy_phase_b` при успешном `initiate_deploy` ставит `Deploying`. | Статус не меняется / иной. | +| **AC-8** | Phase C → `Monitoring after Deploy` (self) | Финализатор SUCCESS для self-hosting → статус `Monitoring after Deploy`, НЕ `Done` сразу. | Ставит `Done` немедленно (для self-hosting). | +| **AC-9** | Не-self deploy → `Done` без регресса | Для не-self репо (`post_deploy_applies==False`) терминальный `deploy → done` ставит `Done` как сейчас. | Не-self репо получает `Monitoring after Deploy` / иной регресс. | + +## Группа D — Post-deploy monitor + +| ID | Критерий | PASS | FAIL | +|----|----------|------|------| +| **AC-10** | Чистое окно → `Done` | `run_post_deploy_monitor` HEALTHY + окно исчерпано → статус `Done`. | Остаётся `Monitoring after Deploy` / иной. | +| **AC-11** | Деградация → `Blocked` | `run_post_deploy_monitor` DEGRADED → статус `Blocked` (+ существующий ALERT_ONLY для self). | Остаётся в Monitoring / ставит Done. | +| **AC-12** | Self-hosting монитор не рестартит прод | Тик НИКОГДА не рестартит/откатывает прод-контейнер (ORCH-021 BR-5 сохранён). | Тик трогает прод-контейнер. | + +## Группа E — In Review / Needs Input / ветки + +| ID | Критерий | PASS | FAIL | +|----|----------|------|------| +| **AC-13** | `In Review` только за approve-pending конвейера | `In Review` ставится лишь для approve артефактов (analyst BRD/ревью), не для Phase A. | Phase A / иные стадии ставят `In Review`. | +| **AC-14** | `Needs Input` без изменений | Поведение `set_issue_needs_input` (analyst `01-questions.md`) идентично прежнему; не расширено на других агентов. | Механизм изменён / расширен. | +| **AC-15** | `Cancelled` — валидный выход из In Review без действий конвейера | Перевод в `Cancelled` → орк не выполняет advance/rollback (индикация, не управление). | Орк совершает действие конвейера на `Cancelled`. | + +## Группа F — Fail-closed (КРИТИЧНО) + +| ID | Критерий | PASS | FAIL | +|----|----------|------|------| +| **AC-16** | Отсутствие нового статуса не ломает конвейер | Проект без новых статусов (enduro/частичный/Plane down) → `get_project_states` отдаёт default-алиасы; все `set_issue_*`/триггеры работают backward-compatible, без исключений и без 404 PATCH. | Падение / необработанное исключение / зависание задачи. | +| **AC-17** | enduro `In Progress` по-прежнему стартует конвейер | Через `to_analyse`-алиас (= `in_progress` UUID) перевод enduro-issue в `In Progress` запускает старт/resume. | enduro-старт сломан. | +| **AC-18** | Резолв по имени | При наличии статуса в проекте по `name` (`_PLANE_NAME_TO_KEY`) используется его UUID, а не default-алиас. | Используется неверный UUID. | + +## Группа G — Reconciler + +| ID | Критерий | PASS | FAIL | +|----|----------|------|------| +| **AC-19** | F-2 реконсилирует `To Analyse` | `_reconcile_plane_project` запрашивает `to_analyse` и маршрутизирует к `handle_status_start` (старт/resume при потерянном webhook). | `To Analyse`-старты не реконсилируются. | +| **AC-20** | Guard 2 skip активных ожиданий | Задачи в `Awaiting Deploy` / `Deploying` / `Monitoring after Deploy` НЕ «оживляются» F-1 как зависшие. | Реконсилер advance'ит активное ожидание. | + +## Группа H — Инварианты и документация + +| ID | Критерий | PASS | FAIL | +|----|----------|------|------| +| **AC-21** | `STAGE_TRANSITIONS` не изменён | `src/stages.py` `STAGE_TRANSITIONS` идентичен (diff пуст). | Любое изменение слоя A. | +| **AC-22** | Реестры/контракты не изменены | `QG_CHECKS`, `check_deploy_status`, exit-коды хука, merge-gate, схема БД, `Confirm Deploy` — без изменений. | Любое изменение перечисленного. | +| **AC-23** | Тесты зелёные | `pytest tests/ -q` проходит полностью; новые fail-closed тесты присутствуют и зелёные. | Любой красный тест. | +| **AC-24** | Документация обновлена (golden source) | `CLAUDE.md`, `docs/architecture/README.md`, `CHANGELOG.md` обновлены; заведён `06-adr/ADR-001-*`. | Любой из артефактов не обновлён. | diff --git a/docs/work-items/ORCH-066/04-test-plan.yaml b/docs/work-items/ORCH-066/04-test-plan.yaml new file mode 100644 index 0000000..4355822 --- /dev/null +++ b/docs/work-items/ORCH-066/04-test-plan.yaml @@ -0,0 +1,184 @@ +work_item: ORCH-066 +description: > + Тест-план статусной модели Plane (слой B). Покрывает осмысленные статусы этапов, + возврат аналитика через To Analyse, фазы self-deploy, post-deploy monitor, + fail-closed деградацию и reconciler. Слой A (STAGE_TRANSITIONS) проверяется на + неизменность. Все тесты — pytest; Plane API мокается (httpx), как в существующих + tests/test_plane_*.py / tests/test_orch10_states.py. + +tests: + # --- Группа A: вход и стадия анализа --- + - id: TC-01 + type: unit + description: "To Analyse без существующего task -> handle_status_start -> start_pipeline (старт конвейера)." + module: tests/test_status_trigger.py + covers: [AC-1] + expected: PASS + + - id: TC-02 + type: integration + description: "To Analyse при существующем task без active-job -> relaunch агента стадии (resume из Needs Input), новый task НЕ создаётся." + module: tests/test_plane_to_analyse_resume.py + covers: [AC-2, BR-11] + expected: PASS + + - id: TC-03 + type: unit + description: "Старт/relaunch аналитика ставит Plane-статус Analysis (а не In Progress) при наличии статуса в проекте." + module: tests/test_plane_status_model.py + covers: [AC-3] + expected: PASS + + - id: TC-04 + type: unit + description: "To Analyse при существующем task с active-job -> НЕ relaunch (busy-guard)." + module: tests/test_plane_to_analyse_resume.py + covers: [AC-4] + expected: PASS + + # --- Группа B: Code-Review --- + - id: TC-05 + type: unit + description: "Вход в стадию review -> Plane-статус Code-Review (маппинг _STAGE_TO_STATE_KEY / STAGE_VISIBILITY_STATE)." + module: tests/test_plane_status_model.py + covers: [AC-5] + expected: PASS + + # --- Группа C: self-deploy фазы --- + - id: TC-06 + type: unit + description: "_handle_self_deploy_phase_a ставит Awaiting Deploy (не In Review)." + module: tests/test_deploy_approve.py + covers: [AC-6, AC-13] + expected: PASS + + - id: TC-07 + type: unit + description: "_handle_self_deploy_phase_b при успешном initiate_deploy ставит Deploying." + module: tests/test_deploy_approve.py + covers: [AC-7] + expected: PASS + + - id: TC-08 + type: integration + description: "Phase C (finalizer SUCCESS) для self-hosting ставит Monitoring after Deploy, НЕ Done; армит post-deploy monitor." + module: tests/test_deploy_terminal_sync.py + covers: [AC-8] + expected: PASS + + - id: TC-09 + type: integration + description: "Не-self репо: deploy->done ставит Done (без регресса, Monitoring не применяется)." + module: tests/test_deploy_terminal_sync.py + covers: [AC-9] + expected: PASS + + # --- Группа D: post-deploy monitor --- + - id: TC-10 + type: unit + description: "run_post_deploy_monitor HEALTHY + окно исчерпано -> Plane-статус Done." + module: tests/test_post_deploy.py + covers: [AC-10] + expected: PASS + + - id: TC-11 + type: unit + description: "run_post_deploy_monitor DEGRADED -> Plane-статус Blocked (+ ALERT_ONLY для self)." + module: tests/test_post_deploy.py + covers: [AC-11] + expected: PASS + + - id: TC-12 + type: unit + description: "Self-hosting тик НЕ рестартит/не откатывает прод-контейнер (ORCH-021 BR-5 сохранён)." + module: tests/test_post_deploy.py + covers: [AC-12] + expected: PASS + + # --- Группа E: In Review / Needs Input / Cancelled --- + - id: TC-13 + type: unit + description: "In Review ставится только за approve-pending конвейера (analyst BRD ready), не Phase A." + module: tests/test_analyst_status_only_regression.py + covers: [AC-13] + expected: PASS + + - id: TC-14 + type: unit + description: "set_issue_needs_input (analyst 01-questions.md) поведение идентично прежнему; не расширено на других агентов." + module: tests/test_plane_status_model.py + covers: [AC-14, BR-10] + expected: PASS + + - id: TC-15 + type: unit + description: "Перевод в Cancelled -> handle_issue_updated не выполняет advance/rollback (индикация, не управление)." + module: tests/test_plane_webhook.py + covers: [AC-15] + expected: PASS + + # --- Группа F: fail-closed (критично) --- + - id: TC-16 + type: unit + description: "Проект без новых статусов: get_project_states отдаёт default-алиасы (to_analyse=in_progress, code_review=review, awaiting_deploy=in_review, monitoring=done); исключений нет." + module: tests/test_plane_status_failclosed.py + covers: [AC-16, BR-12] + expected: PASS + + - id: TC-17 + type: unit + description: "Plane API down -> get_project_states fallback на _DEFAULT_STATES; set_issue_* never-raise." + module: tests/test_plane_status_failclosed.py + covers: [AC-16] + expected: PASS + + - id: TC-18 + type: integration + description: "enduro In Progress по-прежнему стартует конвейер через to_analyse-алиас." + module: tests/test_plane_status_failclosed.py + covers: [AC-17] + expected: PASS + + - id: TC-19 + type: unit + description: "Резолв по имени: при наличии статуса в проекте используется его UUID, а не default-алиас." + module: tests/test_orch10_states.py + covers: [AC-18] + expected: PASS + + # --- Группа G: reconciler --- + - id: TC-20 + type: integration + description: "F-2 _reconcile_plane_project запрашивает to_analyse и маршрутизирует к handle_status_start (потерянный webhook старта/resume)." + module: tests/test_reconciler_plane.py + covers: [AC-19] + expected: PASS + + - id: TC-21 + type: unit + description: "Guard 2: задачи в Awaiting Deploy / Deploying / Monitoring after Deploy НЕ оживляются F-1 как зависшие." + module: tests/test_reconciler.py + covers: [AC-20, BR-13] + expected: PASS + + # --- Группа H: инварианты --- + - id: TC-22 + type: unit + description: "STAGE_TRANSITIONS не изменён (явная проверка ключей/значений слоя A)." + module: tests/test_plane_status_model.py + covers: [AC-21] + expected: PASS + + - id: TC-23 + type: unit + description: "QG_CHECKS реестр и check_deploy_status контракты не изменены." + module: tests/test_plane_status_model.py + covers: [AC-22] + expected: PASS + + - id: TC-24 + type: integration + description: "Полный прогон pytest tests/ -q зелёный (регрессия)." + module: tests/ + covers: [AC-23] + expected: PASS From 22d3b7742671adc22a7eb96e4f61a1666b045cd7 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 21:29:28 +0000 Subject: [PATCH 3/8] architect(ET): auto-commit from architect run_id=340 --- docs/architecture/README.md | 42 ++- .../06-adr/ADR-001-plane-status-model.md | 287 ++++++++++++++++++ .../ORCH-066/07-infra-requirements.md | 96 ++++++ docs/work-items/ORCH-066/10-tech-risks.md | 31 ++ 4 files changed, 455 insertions(+), 1 deletion(-) create mode 100644 docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md create mode 100644 docs/work-items/ORCH-066/07-infra-requirements.md create mode 100644 docs/work-items/ORCH-066/10-tech-risks.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 0b1d743..220e428 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -249,6 +249,46 @@ ORCH-065 вводит фоновый watchdog, чтобы смерть проц Подробнее: [adr-0011](adr/adr-0011-job-reaper-lease-reclaim.md), детально — `docs/work-items/ORCH-065/06-adr/ADR-001-job-reaper-and-lease-reclaim.md`. +### Осмысленная статусная модель Plane (ORCH-066 — design) +Plane-доска была семантически перегружена: `In Progress` означал «человек запускает +конвейер», «идёт анализ», «идёт прод-деплой» и «возврат из Needs Input» одновременно. +ORCH-066 наводит порядок по утверждённой Owner модели, меняя **только слой B** +(Plane-индикация: `src/plane_sync.py` + точки простановки в `src/stage_engine.py`/ +`src/webhooks/plane.py`/`src/reconciler.py`) и **не трогая слой A** (`STAGE_TRANSITIONS`, +инвариант). Статус — индикация, не управление (вердикты по-прежнему из YAML-frontmatter): +``` +Backlog → Todo → [To Analyse] → Analysis → [In Review → Approved] → Architecture → +Development → Code-Review → Testing → Awaiting Deploy → [Confirm Deploy] → Deploying → +Monitoring after Deploy → Done +``` +`[...]` = человеческий вход-триггер; остальное ставит орк. +- **6 новых логических ключей** (`to_analyse`/`analysis`/`code_review`/`awaiting_deploy`/ + `deploying`/`monitoring`) в `_PLANE_NAME_TO_KEY` (резолв по имени) + `_DEFAULT_STATES`. + `To Analyse` заменяет `In Progress` как вход-триггер (старт + resume аналитика из Needs + Input; fork «старт vs resume» по `get_task_by_plane_id`+`has_active_job_for_task` — + сохранён). Стадии: analysis→`Analysis`, review→`Code-Review` (`_STAGE_TO_STATE_KEY`). +- **Self-deploy фазы:** Phase A → `Awaiting Deploy` (разгружает `In Review`), Phase B → + `Deploying`, Phase C/terminal-sync (self) → `Monitoring after Deploy` (НЕ `Done` сразу); + post-deploy monitor (ORCH-021): HEALTHY-окно → `Done`, DEGRADED → `Blocked` (тик + по-прежнему НИКОГДА не рестартит прод — ALERT_ONLY). Не-self репо: `deploy → Done` как + сейчас (terminal-sync разводится по `post_deploy.post_deploy_applies`). +- **Fail-closed (project-relative alias-fallback):** отсутствующий новый статус в проекте + деградирует на **собственный базовый UUID того же проекта** (`to_analyse/analysis→in_progress`, + `code_review→review`, `awaiting_deploy→in_review`, `deploying→in_progress`, + `monitoring→done`) — индикация откатывается к текущей, конвейер не ломается, PATCH валиден + даже при частичной конфигурации. Enduro (статусы не создаются) → строго прежнее поведение. + Усиленный паттерн ORCH-059 AC-7. +- **Reconciler:** F-2 триггер `in_progress`→`to_analyse`; Guard 2 skip-set расширен + активными ожиданиями (`awaiting_deploy`/`deploying`/`monitoring`) с **вычитанием базовых + рабочих статусов** — на enduro (алиасы схлопнуты) нулевой регресс, на orchestrator skip + реальных ожиданий (BR-13). +- **Инварианты:** `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_deploy_status`, exit-коды хука, + merge-gate, `Confirm Deploy`, механизм `Needs Input` (analyst-only), схема БД — без + изменений. Без нового kill-switch (раскат гейтится созданием Plane-статусов оператором). + Инфра-предусловие — `docs/work-items/ORCH-066/07-infra-requirements.md`. + +Подробнее: `docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md`. + ## Откаты - Reviewer REQUEST_CHANGES → откат на `development` + retry (`MAX_DEVELOPER_RETRIES = 3`). - Tester `check_tests_passed` FAIL → откат на `development` + retry. @@ -306,4 +346,4 @@ ORCH-065 вводит фоновый watchdog, чтобы смерть проц Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.md). --- -*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест).* +*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест); ORCH-066 (осмысленная статусная модель Plane — слой B, `docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md`) — design, ветка feature/ORCH-066-plane (только Plane-индикация: новые ключи `to_analyse`/`analysis`/`code_review`/`awaiting_deploy`/`deploying`/`monitoring` в `_PLANE_NAME_TO_KEY`/`_DEFAULT_STATES` + project-relative `_STATE_ALIAS_FALLBACK` в get_project_states + `_STAGE_TO_STATE_KEY` analysis/review + 5 новых `set_issue_*` в src/plane_sync.py; триггер `in_progress`→`to_analyse` и `set_issue_analysis` в src/webhooks/plane.py; Phase A→Awaiting Deploy / Phase B→Deploying / terminal-sync split monitoring↔done / post-deploy monitor HEALTHY→Done DEGRADED→Blocked в src/stage_engine.py; F-2 триггер `to_analyse` + Guard 2 skip-set с вычитанием base_working в src/reconciler.py; `STAGE_TRANSITIONS`/QG/схема БД НЕ трогаются; без kill-switch — раскат гейтится созданием 6 Plane-статусов оператором, `docs/work-items/ORCH-066/07-infra-requirements.md`; обновлять при изменении этих мест).* diff --git a/docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md b/docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md new file mode 100644 index 0000000..bc25cb4 --- /dev/null +++ b/docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md @@ -0,0 +1,287 @@ +# ADR-001: Осмысленная статусная модель Plane (слой B) + +**Work Item:** ORCH-066 +**Стадия:** architecture +**Автор:** Architect +**Дата:** 2026-06-07 +**Статус:** Accepted + +> Контракт резолвера, алиасинга и разводки точек простановки статуса. Опирается на +> BRD (`01-brd.md`), ТЗ (`02-trz.md`), критерии приёмки (`03-acceptance-criteria.md`). +> Инфра-предусловие (статусы, создаваемые оператором) — `07-infra-requirements.md`, +> риски — `10-tech-risks.md`. + +--- + +## 1. Контекст + +Plane-доска оркестратора семантически перегружена: `In Progress` одновременно +означает «человек запускает конвейер», «идёт анализ», «идёт прод-деплой» и «возврат +из Needs Input». Оператор не различает реальный этап задачи → риск ошибочного ручного +перевода статуса. ORCH-059 уже разгрузил `Approved` отдельным `Confirm Deploy`; +ORCH-066 завершает наведение порядка по утверждённой Owner модели. + +**Жёсткое разделение двух слоёв (инвариант проекта):** + +| Слой | Что | Источник | ORCH-066 | +|------|-----|----------|----------| +| **A** | `STAGE_TRANSITIONS` — машина стадий | `src/stages.py` | **НЕ трогаем** | +| **B** | Plane-статусы — индикация на доске | `src/plane_sync.py` + точки простановки | **меняем только это** | + +Статус — **индикация, не управление**. Машинные вердикты по-прежнему читаются только +из YAML-frontmatter артефактов (канон гейтов). Конвейер движут гейты слоя A; смена +Plane-статуса не может продвинуть/откатить задачу (кроме существующих человеческих +триггеров `To Analyse`/`Approved`/`Rejected`, которые и раньше были входами). + +Целевая модель Owner: + +``` +Backlog → Todo → [To Analyse] → Analysis → [In Review → Approved] → Architecture → +Development → Code-Review → Testing → Awaiting Deploy → [Confirm Deploy] → Deploying → +Monitoring after Deploy → Done +``` +`[...]` = действие человека (вход-триггер); остальное ставит орк (индикация). +Ветки: **Rejected** (откат), **Needs Input** (только аналитик), **Blocked** (затык/фейл +деплоя/деградация), **Cancelled** (человек отменил задачу). + +--- + +## 2. Решение + +### 2.1. Реестр логических статусов (`src/plane_sync.py`) + +Вводим 6 новых **логических ключей**. Имена в `_PLANE_NAME_TO_KEY` (резолв по имени из +Plane API): + +| Логический ключ | Plane name | Назначение | +|-----------------|-----------|------------| +| `to_analyse` | `To Analyse` | Вход-триггер: старт нового конвейера **и** resume аналитика из Needs Input. | +| `analysis` | `Analysis` | Индикация стадии analysis (орк). | +| `code_review` | `Code-Review` | Индикация стадии review (орк). Заменяет `review` как видимый статус. | +| `awaiting_deploy` | `Awaiting Deploy` | Phase A approval-pending (орк). | +| `deploying` | `Deploying` | Phase B прод-деплой идёт (орк). | +| `monitoring` | `Monitoring after Deploy` | Phase C / post-deploy окно (орк). | + +Существующие ключи сохраняются: `backlog`, `todo`, `in_progress`, `needs_input`, +`in_review`, `blocked`, `done`, `cancelled`, `architecture`, `development`, `review`, +`testing`, `approved`, `rejected`. `Cancelled` уже присутствует. + +### 2.2. Fail-closed резолюция — **project-relative alias-fallback** (КРИТИЧНО, BR-12) + +ТЗ §2.2 предложил статические алиасы на enduro-UUID в `_DEFAULT_STATES`. Архитектурное +уточнение: для **частично сконфигурированного** проекта (оператор создал не все новые +статусы) статический enduro-UUID в orchestrator-проекте даст невалидный `state` → PATCH +422/404. Поэтому деградация делается **относительно того же проекта**, а не на чужой +UUID. + +**Два уровня fallback в `get_project_states()` (success-path), строго в порядке:** + +1. Резолв по имени из Plane API (как сейчас). +2. **Alias-fallback (новый):** для каждого отсутствующего нового ключа — UUID его + **базового ключа из этого же проекта**: + + ```python + _STATE_ALIAS_FALLBACK = { + "to_analyse": "in_progress", + "analysis": "in_progress", + "code_review": "review", + "awaiting_deploy": "in_review", + "deploying": "in_progress", + "monitoring": "done", + } + # после резолва по имени, ДО _DEFAULT_STATES.setdefault: + for new_key, base_key in _STATE_ALIAS_FALLBACK.items(): + if new_key not in resolved and resolved.get(base_key): + resolved[new_key] = resolved[base_key] + ``` +3. `_DEFAULT_STATES.setdefault(...)` (как сейчас) — последний резерв для путей, где + API недоступен целиком (`if not project_id: return _DEFAULT_STATES`, полный провал + запроса). В `_DEFAULT_STATES` новые ключи ТОЖЕ добавляются (= enduro-UUID базового + ключа), чтобы любой caller всегда получал полный словарь и `states[key]` не кидал + `KeyError`. + +**Эффект деградации:** + +| Сценарий | Поведение | +|----------|-----------| +| Orchestrator: все новые статусы созданы | резолв по имени → новые UUID (целевая модель). | +| Orchestrator: создана ЧАСТЬ новых статусов | отсутствующие → **собственный** базовый UUID проекта → индикация деградирует до текущего статуса, PATCH валиден. | +| Enduro (новые статусы не создаются никогда) | alias-fallback → собственные enduro базовые UUID → строго прежнее поведение (`In Progress`/`Review`/`Done`). | +| Plane API down целиком | `_DEFAULT_STATES` (enduro-UUID) — без регресса относительно сегодняшнего поведения. | + +Это паттерн ORCH-059 AC-7, усиленный project-relative разрешением. Все `set_issue_*` и +`_set_issue_state_direct` остаются **never-raise** (PATCH-исключение логируется, не +пробрасывается) — индикация деградирует, слой A не затрагивается. + +### 2.3. Маппинг стадия → статус + +- `_STAGE_TO_STATE_KEY` (живой путь `update_issue_state`→`stage_to_state`): + `analysis` → `analysis` (было `in_progress`); `review` → `code_review` (было `review`). + `deploy` остаётся `in_progress` (управляется Phase A/B/C напрямую). Остальные — без + изменений. +- `STAGE_VISIBILITY_STATE`: `review` → `code_review`; добавить `analysis` → `analysis` + (для консистентности; `set_issue_stage_state` сейчас dormant, но карта обновляется). +- `STAGE_TO_STATE` (legacy/test-only) — обновить `analysis`→`_DEFAULT_STATES["analysis"]`, + `review`→`_DEFAULT_STATES["code_review"]`. UUID-значения **байт-в-байт прежние** (это + алиасы на те же in_progress/review UUID) → тесты на конкретные UUID не краснеют. + +### 2.4. Новые хелперы `src/plane_sync.py` + +Тонкие обёртки по образцу `set_issue_in_review` (per-project резолв + `_set_issue_state_direct`, +never-raise): + +- `set_issue_analysis(work_item_id, project_id=None)` +- `set_issue_code_review(work_item_id, project_id=None)` +- `set_issue_awaiting_deploy(work_item_id, project_id=None)` +- `set_issue_deploying(work_item_id, project_id=None)` +- `set_issue_monitoring(work_item_id, project_id=None)` + +`get_project_states` всегда возвращает полный словарь (см. §2.2), поэтому `[key]` не +кидает `KeyError`. + +### 2.5. Точки простановки статуса (разводка) + +| Файл:место | Сейчас | Должно стать | AC | +|------------|--------|--------------|----| +| `webhooks/plane.py` `handle_issue_updated` | `new_state == in_progress` → `handle_status_start` | `new_state == to_analyse` → `handle_status_start` (при алиасе совпадает с `in_progress`) | AC-1, AC-17 | +| `webhooks/plane.py` `start_pipeline` (успешный старт) | статус остаётся `In Progress` | в конце старта орк ставит `set_issue_analysis` | AC-3 | +| `webhooks/plane.py` `handle_status_start` (resume-ветка) | relaunch агента стадии | при relaunch орк ставит `set_issue_analysis`; fork «старт vs resume» (`get_task_by_plane_id` + `has_active_job_for_task`) — **без изменений** | AC-2, AC-4 | +| `webhooks/plane.py` `_rollback_stage` (reject@analysis, ~583) | `set_issue_in_progress` | `set_issue_analysis` | AC-3 | +| `stage_engine.py` `_handle_analysis_approved_flow` (artifacts ready) | `set_issue_in_review` | **без изменений** (BR-9) | AC-13 | +| `stage_engine.py` `_handle_analysis_approved_flow` (questions) | `set_issue_needs_input` | **без изменений** (BR-10) | AC-14 | +| `stage_engine.py` rollback@analysis (architect conflict, ~669) | `set_issue_in_progress` | `set_issue_analysis` | AC-3 | +| `stage_engine.py` `_handle_self_deploy_phase_a` (~1012) | `set_issue_in_review` | `set_issue_awaiting_deploy` | AC-6, AC-13 | +| `stage_engine.py` `_handle_self_deploy_phase_b` (после `INITIATED` marker) | статус не меняет | `set_issue_deploying` | AC-7 | +| `stage_engine.py` terminal-sync `deploy → done` (~338) | `set_issue_done` для всех | **self (`post_deploy_applies`):** `set_issue_monitoring`; **не-self:** `set_issue_done` как сейчас | AC-8, AC-9 | +| `stage_engine.py` `run_post_deploy_monitor` HEALTHY+окно закрыто (~1260) | статус не трогает | `set_issue_done` (явно) | AC-10 | +| `stage_engine.py` `run_post_deploy_monitor` DEGRADED (~1273) | alert/log | `set_issue_blocked` (+ существующий ALERT_ONLY) | AC-11 | + +**Разводка terminal-sync (детально, AC-8/AC-9).** Текущий код безусловно зовёт +`set_issue_done` на `next_stage == "done"`, затем (для self) армит post-deploy monitor. +Разводим по `post_deploy.post_deploy_applies(repo)`: + +```python +if next_stage == "done" and work_item_id: + if post_deploy.post_deploy_applies(repo): + set_issue_monitoring(work_item_id) # self: окно наблюдения, НЕ Done сразу + else: + set_issue_done(work_item_id) # не-self: терминальный Done как сейчас +# арм монитора (существующий блок ~361) — без изменений +``` +Финальный `Done`/`Blocked` для self-hosting перекладывается на `run_post_deploy_monitor`. +При деградированном алиасе `monitoring==done` self-hosting показывает `Done` и затем +монитор держит `Done`/флипает `Blocked` — поведение идентично сегодняшнему. + +**AC-12 (инвариант ORCH-021):** добавление `set_issue_blocked` в DEGRADED-ветку — +**только индикация**; тик по-прежнему НИКОГДА не рестартит/откатывает прод-контейнер +(self-hosting остаётся `ALERT_ONLY`). `set_issue_blocked` — Plane-PATCH, не действие над +контейнером. + +**Cancelled (AC-15):** изменений кода НЕ требует. `handle_issue_updated` реагирует только +на `to_analyse`/`approved`/`rejected`; `Cancelled` падает в `else` → «no pipeline action». +Орк не делает advance/rollback — индикация, не управление. Критерий выполнен существующим +кодом. + +### 2.6. Reconciler (`src/reconciler.py`) + +- **F-2 `_reconcile_plane_project`:** заменить триггер `in_progress` → `to_analyse` в + списке запрашиваемых статусов (`list_issues_by_state([to_analyse, approved, rejected])`) + и в `_reconcile_plane_issue` маршрутизировать `new_state == to_analyse` → + `handle_status_start`. При алиасе `to_analyse == in_progress` (enduro) поведение + идентично текущему (один UUID; `list_issues_by_state` дедуплицирует через `set`). AC-19. +- **Guard 2 `_is_blocked_or_needs_input`:** расширить skip-множество активными ожиданиями + `awaiting_deploy`/`deploying`/`monitoring` (BR-13, AC-20). **Анти-регресс enduro + (КРИТИЧНО):** новые ключи алиасятся на `in_review`/`in_progress`/`done`; добавить их в + skip «как есть» → на enduro `In Progress`/`Done`-задачи начнут ошибочно пропускаться + F-1 (регресс ORCH-053/060). Поэтому активные ожидания включаются в skip **только когда + они РАЗЛИЧНЫ от базовых рабочих статусов проекта** (т.е. реально созданы): + + ```python + base_working = {states.get(k) for k in ( + "backlog","todo","in_progress","in_review","review", + "architecture","development","testing","approved","rejected","done")} + extra_waits = {states.get("awaiting_deploy"), + states.get("deploying"), + states.get("monitoring")} - base_working - {None} + skip_set = {states.get("blocked"), states.get("needs_input")} | extra_waits + return cur in skip_set + ``` + Enduro (алиасы схлопываются в base) → `extra_waits == {}` → нулевой регресс. Orchestrator + (отдельные UUID) → три реальных статуса в skip → BR-13. Семантику метода обобщаем до + «human-or-active-wait»; флаг `reconcile_skip_blocked_enabled` продолжает гасить этот + networked-чек. F-1 и так структурно не оживляет эти состояния (Phase A: `check_deploy_status` + red → silent; Deploying: active finalizer job → active-job guard; Monitoring: стадия + `done` → не итерируется) — Guard 2 это defense-in-depth по требованию Owner. + +### 2.7. Без kill-switch + +Отдельный env-флаг новой модели **не вводится**. Раскат естественно гейтится +**инфра-предусловием**: пока оператор не создал новые статусы — alias-fallback (§2.2) +держит строго прежнее поведение; создал — резолв по имени включает новую модель. Это +проще отдельного флага и соответствует принципу «минимум зависимостей». (ТЗ §6 допускает +флаг как опциональный — сознательно отказываемся.) + +--- + +## 3. Затронутые модули (карта изменений) + +| Модуль | Изменение | +|--------|-----------| +| `src/plane_sync.py` | `_PLANE_NAME_TO_KEY` +6; `_DEFAULT_STATES` +6 (enduro-alias UUID); `_STATE_ALIAS_FALLBACK` (новое) + применение в `get_project_states`; `_STAGE_TO_STATE_KEY` (analysis/review); `STAGE_VISIBILITY_STATE`; `STAGE_TO_STATE` (legacy); 5 новых `set_issue_*`. | +| `src/webhooks/plane.py` | триггер `in_progress`→`to_analyse` в `handle_issue_updated`; `set_issue_analysis` в `start_pipeline` и resume-ветке `handle_status_start`; `_rollback_stage` reject@analysis → `set_issue_analysis`. | +| `src/stage_engine.py` | Phase A → `set_issue_awaiting_deploy`; Phase B → `set_issue_deploying`; terminal-sync split (`monitoring` vs `done`); post-deploy monitor HEALTHY→`set_issue_done`, DEGRADED→`set_issue_blocked`; rollback@analysis (architect conflict) `set_issue_in_progress`→`set_issue_analysis`. | +| `src/reconciler.py` | F-2 триггер `to_analyse`; Guard 2 skip-set + анти-регресс subtraction. | +| `src/stages.py` | **НЕ трогаем** (инвариант слоя A). | +| `src/config.py` | Без изменений (kill-switch не вводится). | + +--- + +## 4. Инварианты (проверяемые, AC-21/AC-22) + +- `src/stages.py` `STAGE_TRANSITIONS` — diff пуст (байт-в-байт). +- `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status`, exit-коды хука (0/1/2), + merge-gate, `check_branch_mergeable`/`check_staging_image_fresh`, схема БД — без изменений. +- `Confirm Deploy` (ORCH-059), механизм `Needs Input` (analyst-only) — без изменений. +- Новых HTTP-эндпоинтов нет; `GET /queue`/`GET /status` контракт без изменений. +- Миграций БД нет (`tasks` не хранит Plane-статус; источник истины — стадия в БД + Plane API). +- Все новые `set_issue_*` / резолв — never-raise. +- Не-self (enduro) терминальный `deploy → Done` — без регресса. + +--- + +## 5. Последствия + +**Плюсы** +- Доска читаема: каждый этап = осмысленный статус; человеческие входы визуально отделены + от индикации. +- `In Progress` разгружен: больше не «всё подряд». +- Fail-closed усилен (project-relative): частичная конфигурация не ломает ни индикацию, + ни конвейер. +- Слой A нетронут → нулевой риск для машины стадий и гейтов всех проектов (self-hosting). +- Нет нового флага/таблицы → меньше движущихся частей. + +**Минусы / ограничения** +- Требуется ручное инфра-действие оператора (создать 6 статусов в проекте ORCH) — до + этого orchestrator деградирует до старой индикации (см. `07-infra-requirements.md`). +- Статусы кэшируются per-process (`_STATES_CACHE`): после создания статусов нужен + `reload_project_states()` или рестарт **staging** (не прод — см. self-hosting риск). +- Guard-2 subtraction добавляет немного логики; покрывается тестами (enduro-алиас → пустой + extra; orchestrator → три статуса). + +**Self-hosting (⚠️):** изменения — слой B (Plane-индикация) + reconciler-гварды; машина +стадий и контракты деплоя нетронуты. Выкладка ОБЯЗАТЕЛЬНО через `deploy-staging` (8501) +до прод-деплоя орка. Прод-контейнер не рестартить в рамках задачи вне штатного staging-гейта. + +--- + +## 6. Альтернативы (отклонены) + +- **Статический enduro-UUID алиас (ТЗ §2.2 буквально):** ломается на частичной + конфигурации orchestrator-проекта (чужой UUID → PATCH 422). Заменён project-relative + alias-fallback (§2.2). +- **Глобальный env kill-switch новой модели:** избыточен — инфра-предусловие уже даёт + естественный гейт раската (§2.7). +- **Хранить Plane-статус в `tasks` (миграция БД):** не нужно; источник истины — стадия + + живой Plane API. Нарушило бы инвариант «без лишних зависимостей». +- **Менять `STAGE_TRANSITIONS` ради новых статусов:** запрещено (инвариант слоя A); + статусы — индикация, отделены от машины стадий. diff --git a/docs/work-items/ORCH-066/07-infra-requirements.md b/docs/work-items/ORCH-066/07-infra-requirements.md new file mode 100644 index 0000000..214d3a2 --- /dev/null +++ b/docs/work-items/ORCH-066/07-infra-requirements.md @@ -0,0 +1,96 @@ +# 07 — Требования к инфраструктуре + +**Work Item:** ORCH-066 +**Автор:** Architect +**Дата:** 2026-06-07 + +> ORCH-066 не меняет топологию (контейнеры/порты/сеть — без изменений, см. +> `docs/operations/INFRA.md`). Единственное инфра-действие — создание новых +> Plane-статусов в проекте **ORCH** руками оператора через Plane API. Это +> **предусловие эксплуатации**, не часть кодового PR. + +--- + +## 1. Что нужно сделать оператору (ДО эксплуатации новой модели) + +Создать в Plane-проекте **ORCH** следующие статусы (states) с точными именами — +резолвер сопоставляет их по `name` (`_PLANE_NAME_TO_KEY`): + +| Plane name (точно) | Логический ключ | Группа Plane (рекомендуемая) | Назначение | +|--------------------|-----------------|------------------------------|------------| +| `To Analyse` | `to_analyse` | unstarted / started | Человеческий вход: старт конвейера + resume аналитика из Needs Input. | +| `Analysis` | `analysis` | started | Индикация стадии анализа. | +| `Code-Review` | `code_review` | started | Индикация стадии review. | +| `Awaiting Deploy` | `awaiting_deploy` | started | Phase A: ожидание ручного approve на прод-деплой. | +| `Deploying` | `deploying` | started | Phase B: идёт прод-деплой. | +| `Monitoring after Deploy` | `monitoring` | started | Phase C / окно пост-деплой наблюдения. | + +`Confirm Deploy` (ORCH-059) и базовые статусы (`Backlog`, `Todo`, `In Progress`, +`Architecture`, `Development`, `Review`, `Testing`, `Approved`, `Rejected`, `Done`, +`Cancelled`, `Needs Input`, `In Review`, `Blocked`) уже существуют — **не трогать**. + +> ⚠️ **Точность имён критична.** Резолв идёт по строковому `name`. Опечатка/иной регистр +> → статус не сопоставится → ключ деградирует на собственный базовый UUID проекта +> (alias-fallback, ADR §2.2): индикация откатится к старому статусу, но конвейер +> продолжит работать. Дефис в `Code-Review` — обязателен. + +--- + +## 2. Plane API — как создать статус + +Эндпоинт (как в `src/plane_sync.py`, `PLANE_BASE = {plane_api_url}/api/v1`): + +``` +POST {PLANE_BASE}/workspaces/{WORKSPACE}/projects/{ORCH_PROJECT_ID}/states/ +Headers: X-API-Key: (или соответствующий бот-токен с правами) +Body (JSON): + { "name": "To Analyse", "group": "started", "color": "#3f76ff" } +``` + +Повторить для каждого имени из таблицы §1. `group` влияет только на колонку доски; +оркестратор `group` не читает (резолв строго по `name`). `color` — на вкус оператора. + +Проверка после создания: + +``` +GET {PLANE_BASE}/workspaces/{WORKSPACE}/projects/{ORCH_PROJECT_ID}/states/ +``` +В ответе должны присутствовать все 6 имён. + +--- + +## 3. Сброс кэша статусов (важно) + +`get_project_states` кэширует резолв per-process (`_STATES_CACHE`). После создания +статусов оркестратор подхватит их **только** после сброса кэша: + +- штатно — `plane_sync.reload_project_states(project_id)` (или рестарт процесса); +- на **staging** (8501) — безопасный рестарт песочницы; +- на **прод** (8500) — **НЕ рестартить контейнер ради этого** в рамках задачи + (self-hosting: общий контейнер всех проектов). Кэш заполняется при первом обращении к + проекту; если статусы созданы ДО первого PATCH в цикле новой версии — отдельный сброс не + нужен. Если созданы позже — дождаться штатного цикла обновления/деплоя орка. + +--- + +## 4. Порядок раската (рекомендация) + +1. Слить кодовый PR ORCH-066 через `deploy-staging` (8501). +2. Создать 6 статусов в проекте ORCH (§1–§2). +3. Сбросить кэш / поднять staging, прогнать sandbox-задачу — убедиться, что доска + показывает `Analysis` / `Code-Review` / `Awaiting Deploy` / `Deploying` / + `Monitoring after Deploy` / `Done` на соответствующих этапах. +4. Прод-деплой орка штатным self-deploy (Phase A → approve → Phase B/C). + +**До шага 2** система работает строго как до ORCH-066 (alias-fallback) — раскат +безопасно обратим: не создавать/удалить статусы = откат индикации к старой модели, +без изменения кода. + +--- + +## 5. Что НЕ требуется + +- Никаких изменений docker-compose, портов, сети, томов, `.env`/`.env.staging`. +- Никаких миграций БД (`tasks` не хранит Plane-статус). +- Никаких изменений в проекте **enduro-trails** — там новые статусы не создаются; + alias-fallback держит прежнюю индикацию (`In Progress`/`Review`/`Done`). diff --git a/docs/work-items/ORCH-066/10-tech-risks.md b/docs/work-items/ORCH-066/10-tech-risks.md new file mode 100644 index 0000000..1735d9b --- /dev/null +++ b/docs/work-items/ORCH-066/10-tech-risks.md @@ -0,0 +1,31 @@ +# 10 — Технические риски + +**Work Item:** ORCH-066 +**Автор:** Architect +**Дата:** 2026-06-07 + +Риски слоя B (Plane-индикация). Слой A (`STAGE_TRANSITIONS`/гейты) не затрагивается, поэтому +класс «сломали конвейер» структурно исключён — худший исход любого риска ниже = неверная +**индикация**, не остановка конвейера. + +| ID | Риск | Вероятность | Влияние | Митигация | +|----|------|-------------|---------|-----------| +| **R1** | Частичная конфигурация: оператор создал не все 6 статусов в ORCH → отсутствующий ключ деградирует. Наивный статический enduro-UUID дал бы невалидный `state` (PATCH 422) на orchestrator-issue. | Средняя | Средн. | **Project-relative alias-fallback** (ADR §2.2): отсутствующий ключ → собственный базовый UUID проекта → PATCH валиден, индикация откатывается к текущему статусу. Покрыть тестом partial-config. | +| **R2** | Enduro-регресс через Guard 2: новые ключи алиасятся на `in_progress`/`in_review`/`done`; наивное добавление в skip-set заставит F-1 пропускать enduro `In Progress`/`Done` → сломанная реконсиляция (ORCH-053/060). | Средняя | Высок. | **Subtraction базовых рабочих статусов** (ADR §2.6): `extra_waits -= base_working`. На enduro (алиасы схлопнуты) `extra_waits == {}` → нулевой регресс. Тест: enduro-алиас не добавляет skip, orchestrator-distinct добавляет. | +| **R3** | Двойной триггер старта: F-2 reconciler и webhook оба маршрутизируют `to_analyse`; при алиасе `to_analyse == in_progress` возможен повтор. | Низкая | Низк. | `list_issues_by_state` дедуплицирует UUID через `set`; active-job guard + atomic create-claim в `handle_status_start` (`get_task_by_plane_id` + `has_active_job_for_task`) — без двойного старта (AC-4). Сохранить fork как есть. | +| **R4** | Кэш статусов: после создания статусов `_STATES_CACHE` отдаёт старый резолв до сброса → доска не обновляется. | Средняя | Низк. | `reload_project_states()` / рестарт **staging**. Документировано в `07-infra-requirements.md §3`. Прод-рестарт ради кэша — запрещён (self-hosting). | +| **R5** | Опечатка в имени статуса оператором (`Code Review` без дефиса и т.п.) → ключ не резолвится. | Средняя | Низк. | Резолв по точному `name`; при промахе — alias-fallback (деградация, не падение). Точные имена и проверка в `07-infra-requirements.md §1–2`. | +| **R6** | Terminal-sync split: ошибка ветвления `post_deploy_applies` → enduro получает `Monitoring after Deploy` вместо `Done` (регресс AC-9) или self уходит в `Done` минуя окно (AC-8). | Низкая | Средн. | Единый источник условности — `post_deploy.post_deploy_applies(repo)` (та же функция, что армит монитор). Тесты AC-8 (self→monitoring) и AC-9 (не-self→done). | +| **R7** | Phase B: `set_issue_deploying` поставлен до фактического старта детача → ложная индикация при провале `initiate_deploy`. | Низкая | Низк. | Ставить `set_issue_deploying` **после** успешного `initiate_deploy` и записи `INITIATED` marker (ADR §2.5); провал `initiate_deploy` оставляет `Awaiting Deploy` + просьбу повторить approve. | +| **R8** | Post-deploy DEGRADED → `set_issue_blocked` ошибочно трактуется как «действие над продом». | Низкая | Высок.(если) | `set_issue_blocked` — только Plane-PATCH. Тик остаётся `ALERT_ONLY`, НИКОГДА не рестартит/откатывает прод-контейнер (AC-12, ORCH-021 BR-5). Явный тест: self DEGRADED не трогает контейнер. | +| **R9** | Plane API недоступен в момент простановки статуса → PATCH падает. | Низкая | Низк. | Все `set_issue_*`/`_set_issue_state_direct` — never-raise (логируют, не пробрасывают). Индикация пропускается, слой A не затронут. | +| **R10** | Регресс на тестах, читающих `STAGE_TO_STATE`/`PLANE_STATES` конкретные UUID. | Низкая | Низк. | Новые ключи в `_DEFAULT_STATES` = алиасы на те же in_progress/review/done UUID → значения байт-в-байт; `STAGE_TO_STATE` analysis/review остаются прежними UUID (ADR §2.3). | +| **R11** | Self-hosting: выкладка орка минуя staging. | Низкая | Высок. | Обязательный `deploy-staging` гейт (8501); прод не рестартить вне штатного self-deploy. Раскат обратим (не создавать статусы = старое поведение). | + +## Сводный вывод + +Все риски снижаемы в рамках принятой архитектуры; ни один не способен остановить конвейер +(слой A инвариантен). Два ключевых требуют аккуратной реализации и обязательных тестов: +**R1** (project-relative alias-fallback) и **R2** (Guard-2 anti-regress subtraction) — +оба зафиксированы в ADR §2.2 и §2.6 как явные контракты. Эскалации `arch:major-change` не +требуется: изменение локализовано в слое B, без новых компонентов/стадий/QG/миграций. From 0dfddf93f02da68abc49878e83d606912489b44f Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 21:54:12 +0000 Subject: [PATCH 4/8] =?UTF-8?q?feat(plane):=20=D0=BE=D1=81=D0=BC=D1=8B?= =?UTF-8?q?=D1=81=D0=BB=D0=B5=D0=BD=D0=BD=D0=B0=D1=8F=20=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D1=83=D1=81=D0=BD=D0=B0=D1=8F=20=D0=BC=D0=BE=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D1=8C=20Plane=20(=D1=81=D0=BB=D0=BE=D0=B9=20B=20?= =?UTF-8?q?=E2=80=94=20=D0=B8=D0=BD=D0=B4=D0=B8=D0=BA=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Приводит статусы доски Plane к смыслу стадий конвейера, сохраняя инвариант «статус — индикация, а не управление». Меняется только слой B (отображение: src/plane_sync.py + точки выставления статуса в stage_engine.py/webhooks/plane.py/reconciler.py); слой A — машина стадий src/stages.py::STAGE_TRANSITIONS — остаётся байт-в-байт неизменным (AC-21). - 6 новых логических ключей статуса (to_analyse, analysis, code_review, awaiting_deploy, deploying, monitoring) + сеттеры и диспетчер set_issue_stage_state. - Project-relative alias-fallback (BR-12): новый ключ деградирует на базовый UUID того же проекта → нулевая регрессия для enduro-trails. - Самодеплой (ORCH-036) индицирует фазы: Awaiting Deploy / Deploying; terminal-sync для self-hosting → Monitoring after Deploy, для прочих → терминальный Done. - Post-deploy монитор (ORCH-021): HEALTHY → Done, DEGRADED → Blocked (только индикация; self-hosting ALERT_ONLY, прод не трогается, BR-5). - Reconciler: триггер старта/резюма на To Analyse; Guard 2 учитывает новые активные ожидания без расширения skip-set на алиасах. - never-raise контракт сеттеров и резолвера состояний сохранён. - Раскатка — созданием статусов в Plane оператором, без kill-switch. Инварианты не менялись: STAGE_TRANSITIONS, QG_CHECKS (12 чеков), check_deploy_status, exit-код-контракт хука, merge-gate, схема БД. ADR: docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md Тесты: test_plane_status_model, test_plane_to_analyse_resume, test_plane_status_failclosed + TC в существующих наборах. 774 passed. Refs: ORCH-066 Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 1 + CLAUDE.md | 3 + docs/architecture/README.md | 4 +- src/plane_sync.py | 123 +++++++++++++++++++-- src/reconciler.py | 57 ++++++++-- src/stage_engine.py | 57 ++++++++-- src/webhooks/plane.py | 28 ++++- tests/test_deploy_approve.py | 8 ++ tests/test_deploy_terminal_sync.py | 56 ++++++++++ tests/test_m6_sequence.py | 4 + tests/test_orch10_states.py | 56 ++++++++++ tests/test_plane_status_failclosed.py | 131 ++++++++++++++++++++++ tests/test_plane_status_model.py | 151 ++++++++++++++++++++++++++ tests/test_plane_to_analyse_resume.py | 114 +++++++++++++++++++ tests/test_plane_webhook.py | 40 +++++++ tests/test_post_deploy_integration.py | 78 +++++++++++++ tests/test_reconciler.py | 66 ++++++++++- tests/test_reconciler_plane.py | 48 +++++++- tests/test_stage_visibility.py | 14 ++- 19 files changed, 999 insertions(+), 40 deletions(-) create mode 100644 tests/test_plane_status_failclosed.py create mode 100644 tests/test_plane_status_model.py create mode 100644 tests/test_plane_to_analyse_resume.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 09dfba8..3b4d531 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ## [Unreleased] ### Added +- **Осмысленная статусная модель Plane (слой B — индикация)** (ORCH-066): Plane больше не показывает наблюдателю огрублённую/вводящую в заблуждение картину — статусы доски приведены к смыслу стадий конвейера, при этом статус остаётся **индикацией, а не управлением**. Архитектурный инвариант (ADR-001): меняется ТОЛЬКО слой B (отображение в Plane — `src/plane_sync.py` и точки выставления статуса в `stage_engine.py`/`webhooks/plane.py`/`reconciler.py`), слой A (машина стадий `src/stages.py::STAGE_TRANSITIONS`) остаётся **байт-в-байт неизменным** (AC-21, регресс-тест TC-22 сверяет полный литерал словаря). Целевая модель: `Backlog → Todo → [To Analyse] → Analysis → [In Review → Approved] → Architecture → Development → Code-Review → Testing → Awaiting Deploy → [Confirm Deploy] → Deploying → Monitoring after Deploy → Done`. Добавлены **6 новых логических ключей статуса** (`to_analyse`, `analysis`, `code_review`, `awaiting_deploy`, `deploying`, `monitoring`) в `_DEFAULT_STATES`/`_PLANE_NAME_TO_KEY` плюс `STAGE_VISIBILITY_STATE` (`analysis→analysis`, `review→code_review`) и `_STAGE_TO_STATE_KEY`; новые сеттеры `set_issue_analysis/code_review/awaiting_deploy/deploying/monitoring` + диспетчер `set_issue_stage_state`. **Project-relative alias-fallback (BR-12):** если оператор ещё не создал новый статус в конкретном Plane-проекте, ключ деградирует на базовый UUID **ТОГО ЖЕ** проекта (`_STATE_ALIAS_FALLBACK`: `analysis→in_progress`, `code_review→review`, `awaiting_deploy→in_review`, `deploying→in_progress`, `monitoring→done`, `to_analyse→in_progress`), поэтому PATCH остаётся валидным на частичных конфигах, а enduro-trails схлопывает новые ключи на старые базовые статусы → **нулевая регрессия**. **Самодеплой (ORCH-036) теперь индицирует фазы:** Phase A → `Awaiting Deploy` (ожидание ручного approve), Phase B → `Deploying`, terminal-sync `deploy→done` ветвится — для self-hosting (`post_deploy.post_deploy_applies(repo)`) issue входит в окно `Monitoring after Deploy` (НЕ терминальный Done), для прочих репо — прежний терминальный `Done` (нулевая регрессия, TC-08/TC-09). **Post-deploy монитор (ORCH-021)** на закрытии окна: HEALTHY → `set_issue_done`, DEGRADED → `set_issue_blocked` (только индикация; self-hosting остаётся ALERT_ONLY, прод НИКОГДА не рестартится/не откатывается — BR-5, TC-10/11/12). **Reconciler:** F-2 триггер старта/резюма расширен на `To Analyse` (TC-20), Guard 2 `_is_blocked_or_needs_input` учитывает новые активные ожидания (`awaiting_deploy/deploying/monitoring`) с вычитанием базовых рабочих статусов, чтобы алиасинг на частичных проектах не расширял skip-set (анти-регресс, TC-21). Контракт **never-raise** на всех сеттерах и резолвере состояний сохранён (API Plane недоступен → identity-фоллбэк, сеттеры не бросают — TC-16/17/18). **Раскатка** управляется оператором (создание 6 статусов в Plane), отдельного kill-switch не вводится — на «голом» Plane всё деградирует на прежнее поведение. Инварианты НЕ менялись (TC-22/TC-23): `STAGE_TRANSITIONS` (9 стадий), реестр `QG_CHECKS` (12 чеков), сигнатура `check_deploy_status(repo, work_item_id, branch)`, exit-код-контракт хука, merge-gate, схема БД (без миграций). ADR `docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md`. Тесты: `tests/test_plane_status_model.py`, `tests/test_plane_to_analyse_resume.py`, `tests/test_plane_status_failclosed.py`, `tests/test_plane_webhook.py` (TC-15), `tests/test_deploy_terminal_sync.py` (TC-08/09), `tests/test_post_deploy_integration.py` (TC-10/11/12), `tests/test_orch10_states.py` (TC-19), `tests/test_reconciler.py` (TC-21), `tests/test_reconciler_plane.py` (TC-20). - **Job-reaper + проактивный реклейм протухшего merge-lease + идемпотентная финализация merge** (ORCH-065): закрыт класс инцидентов «zombie jobs» — статус job выставлялся ТОЛЬКО в живом процессе launcher'а, поэтому гибель процесса (OOM/рестарт инстанса/segfault Claude-CLI) оставляла строку `jobs.status='running'` навсегда; при `max_concurrency=1` один такой зомби намертво блокировал очередь ВСЕХ проектов (self-hosting: enduro-trails встаёт из-за зомби ORCH-задачи). Плюс два смежных дефекта: застрявший merge-lease (`.merge-lease-.json` реклеймился лишь лениво по TTL при чужом acquire, живость pid-holder'а не проверялась) и неидемпотентная финализация merge (rebase+re-test зелёные, но процесс умер до самого merge → нет повторного проигрывания). Решение — новый фоновый daemon-поток **`src/job_reaper.py`** (контракт «never-raise на единицу работы», паттерн `reconciler`/`queue_worker`): периодический тик (`reaper_interval_s`) сканирует `running`-jobs трёхуровневой проверкой живости (ADR Р-1): **Tier-1** мёртвый pid (`os.kill(pid, 0)` → `ProcessLookupError`) с анти-false-positive порогом `reaper_dead_ticks` подряд-мёртвых тиков (стрик в памяти); **Tier-2** `agent_runs.exit_code` записан, но job всё ещё `running` — но только после finalization-grace `reaper_finalize_grace_s` (окно неоднозначно: живой monitor пишет exit_code ПЕРВЫМ, затем git push/PR/Plane-комментарии и лишь потом `_finalize_job`, а pid агента к этому моменту мёртв в обоих случаях — живой финализирующий monitor НЕ реапится); **Tier-3** backstop-потолок `reaper_max_running_s`. Единственная мутирующая запись reaper'а — атомарный терминальный флип через `db.reap_running_job(... WHERE status='running')` (rowcount==1 у победителя, проигравший в гонке с `requeue_running_jobs`/launcher видит rowcount==0 — без двойной обработки, TC-06). Для Tier-2 exit0 действие построено по принципу **claim-before-act** (ADR Р-1): источник истины — канонический QG (не «exit0»), он оценивается read-only (`_gate_is_green` → `stage_engine._run_qg`, как у reconciler) ПЕРЕД claim, затем атомарный claim `done` ПЕРВЫМ и только победитель claim делает gate-driven advance (`_gate_driven_advance` → штатный `launcher._try_advance_stage`, кандидат-стадии агента из `STAGE_TRANSITIONS`) — проигравший claim не выполняет НИКАКИХ побочных эффектов (нет дубль-advance / дубль-enqueue следующей стадии); зелёный гейт → `done`+advance, красный → путь неуспеха (requeue в пределах `attempts post_deploy_5xx_threshold`; иначе `HEALTHY` — одиночный глюк не откатывает), `decide_action` (self-hosting → ВСЕГДА `ALERT_ONLY`; не-self + `post_deploy_auto_rollback=true` → `ROLLBACK`; иначе `ALERT_ONLY`), `map_rollback_exit_code` (`0→ROLLBACK_OK`, иначе `ROLLBACK_FAILED`), sentinel-state хелперы (`armed`/`series`/`done` под `/.post-deploy-state-//`, restart-safe счётчики), `build_rollback_command`/`run_rollback` (ssh-хук `--rollback` с прод-env, синхронно — только для не-self), `build/write_post_deploy_log` (артефакт `16-post-deploy-log.md`), `arm_monitor` (идемпотентный арм + первый отложенный job), `status` (снимок для `/queue`). **Механизм наблюдения — reserved-agent job `post-deploy-monitor`** (детерминированный, no-LLM, калька `deploy-finalizer`, НЕ стадия и НЕ daemon): арм в `stage_engine.advance_stage` в блоке `next_stage == "done"` ПОСЛЕ terminal-sync/release-lease (`post_deploy.arm_monitor`, sentinel `armed` = идемпотентность при двойном webhook/reconciler/finalizer); один тик = один job — перехват в `agents/launcher.launch_job` ДО `_spawn` → `stage_engine.run_post_deploy_monitor` (один опрос → append в `series` → `classify` → перепостановка с задержкой `available_at_delay_s` ИЛИ реакция+артефакт+`mark_done`); бюджет тиков `window_s/interval_s` (анти-livelock). **Self-hosting safety (BR-5):** для `orchestrator` тик НИКОГДА не откатывает/рестартит прод-контейнер — реакция всегда `ALERT_ONLY` (громкий Telegram + Plane-коммент с запросом ручного approve); авто-rollback хуком `--rollback` — только для не-self репо при `post_deploy_auto_rollback=true` (целевой контейнер ≠ orchestrator). Наблюдаемость — блок `post_deploy` в `GET /queue` (enabled/window/interval/активные наблюдения). Артефакт `16-post-deploy-log.md` (YAML-frontmatter `post_deploy_status`/`action_taken`/`window_s`/`checks_total`/`checks_failed`) — машиночитаемо для петли уроков ORCH-8; best-effort. Новые настройки: `ORCH_POST_DEPLOY_MONITOR_ENABLED` (true, kill-switch), `ORCH_POST_DEPLOY_REPOS` (CSV; пусто → только self-hosting), `ORCH_POST_DEPLOY_WINDOW_S` (900), `ORCH_POST_DEPLOY_INTERVAL_S` (30), `ORCH_POST_DEPLOY_FAIL_THRESHOLD` (3), `ORCH_POST_DEPLOY_5XX_THRESHOLD` (0.5), `ORCH_POST_DEPLOY_AUTO_ROLLBACK` (false), `ORCH_POST_DEPLOY_BASE_URL` (http://localhost:8500); параметры отката переиспользуют `deploy_prod_*`. Инварианты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status`, terminal-sync `deploy→done`, merge-gate, exit-код-контракт хука (0/1/2), схема БД (без миграций; состояние — sentinel-файлы). Условность как ORCH-35/36/43/58. ADR `docs/work-items/ORCH-021/06-adr/ADR-001-post-deploy-monitor.md`, глобальный `docs/architecture/adr/adr-0010-post-deploy-monitor.md`. Тесты: `tests/test_post_deploy.py`, `tests/test_post_deploy_integration.py`. - **Провенанс staging-образа перед BUILD-ONCE retag в прод (свежесть артефакта, INV-FRESH)** (ORCH-058): BUILD-ONCE retag (ORCH-036) промоутит staging-образ (`orchestrator-orchestrator-staging`) в прод **без rebuild**, полагаясь на «образ свеж и провалидирован» — гарантии не было: конвейер нигде не пересобирал staging-образ из провалидированного коммита, поэтому retag мог тихо промоутнуть УСТАРЕВШИЙ образ (инцидент LESSONS_ORCH-036 п.4 — зелёный деплой молча откатывал прод). Закрыто **двумя слоями (defense in depth), только для self-hosting**. Новый модуль `src/image_freshness.py` (контракт «never raise», по образцу `merge_gate`): `provenance_verdict` (чистая функция вердикта match/mismatch/fail-closed), `validated_revision` (`git rev-parse HEAD` в worktree валидированного коммита — единый якорь и для штампа A, и для `EXPECTED_REVISION` B), `image_revision` (OCI-лейбл `org.opencontainers.image.revision` через `docker image inspect`, ``/ошибка → пусто), `rebuild_staging_image` (ssh-хук `--build-staging`), `image_freshness_applies` (условность), `check_staging_image_fresh` (композитный QG). **Strategy A (liveness):** новый детерминированный QG-под-чек `check_staging_image_fresh` (зарегистрирован в `QG_CHECKS`, `src/qg/checks.py`) на ребре `deploy-staging → deploy` ПОСЛЕ merge-gate и ДО Phase A — пересобирает staging-образ из worktree валидированного коммита (хук `--build-staging`, `--build-arg GIT_SHA=`), пересоздаёт 8501 и прогоняет `staging_check.py --mode stub` против свежего 8501 (health + e2e, внутри staging-контейнера через `docker exec` — канон ORCH-048) → валидируем РОВНО тот артефакт (build + e2e), что промоутится в прод (AC-4); FAIL/не-ноль staging_check → откат на `development` (как merge-gate, кап `MAX_DEVELOPER_RETRIES`). `rebuild_staging_image` пробрасывает в хук **явный** staging-таргет (service/port/profile/container), исключая дрейф на прод 8500. Сборки/recreate/validate — **только staging (8501)**, прод (8500) не трогается. **Strategy B (safety):** `Dockerfile` штампует `LABEL org.opencontainers.image.revision=$GIT_SHA` (`ARG GIT_SHA`); `build_deploy_command` (`src/self_deploy.py`) пробрасывает `EXPECTED_REVISION`; хост-хук шагом 2b ПЕРЕД `docker tag` fail-closed сверяет лейбл `revision` у `SOURCE_IMAGE` с `EXPECTED_REVISION` — несовпадение / пустой лейбл / ошибка inspect → `exit 1` (FAILED → БАГ-8 откат), делает тихий промоут устаревшего образа структурно невозможным даже при проигравшей гонку/отключённой A. Хост-хук `scripts/orchestrator-deploy-hook.sh` расширен **обратно-совместимым** режимом `--build-staging` (пересборка+recreate staging, exit 0/1) и fail-closed guard'ом (активен только при заданном `EXPECTED_REVISION`). Единый kill-switch `ORCH_IMAGE_FRESHNESS_ENABLED` (true) включает A+B **как целое** (нет «B без A» = вечного fail-fast); область — `ORCH_IMAGE_FRESHNESS_REPOS` (CSV; пусто → только self-hosting `orchestrator`). Контракты НЕ менялись: `STAGE_TRANSITIONS` (под-гейт ребра, не стадия), exit-code-контракт хука (0/1/2), `map_exit_code_to_status`, `check_deploy_status`/`_parse_deploy_status`, БАГ-8, terminal-sync, merge-gate; схема БД — без миграций. ADR `docs/work-items/ORCH-058/06-adr/ADR-001-staging-image-provenance.md`, глобальный `docs/architecture/adr/adr-0008-staging-image-provenance.md`. Документация: `docs/architecture/README.md`, `docs/operations/DEPLOY_HOOK.md`, `docs/operations/STAGING.md`, `docs/operations/INFRA.md`, `.env.example`. Тесты: `tests/test_image_freshness.py`, `tests/test_deploy_hook_provenance.py`, `tests/test_deploy_build_once.py` (TC-06), `tests/test_deploy_hook_mapping.py` (TC-09), `tests/test_stage_engine.py::TestImageFreshnessGate`, `tests/test_qg_registry_snapshot.py`, `tests/test_config.py`. diff --git a/CLAUDE.md b/CLAUDE.md index 63cf19e..c168733 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,6 +38,9 @@ created → analysis → architecture → development → review → testing → └──── REQUEST_CHANGES ──────┘ (откат на development, max 3) ``` +## Статусная модель Plane (ORCH-066) — индикация ≠ управление +Статусы Plane — это **слой B (индикация)**, отдельный от **слоя A (машина стадий)** `src/stages.py::STAGE_TRANSITIONS`. Plane показывает наблюдателю осмысленную картину (`Backlog → Todo → Analysis → Architecture → Development → Code-Review → Testing → Awaiting Deploy → Deploying → Monitoring after Deploy → Done` + человеческие гейты `In Review/Approved`, `Confirm Deploy`), но НИКОГДА не управляет конвейером. Маппинг и сеттеры — `src/plane_sync.py` (6 новых ключей: `to_analyse/analysis/code_review/awaiting_deploy/deploying/monitoring`), с project-relative alias-fallback: на частично сконфигурированном проекте новый ключ деградирует на базовый UUID ТОГО ЖЕ проекта (нулевая регрессия для enduro-trails). Детали — `docs/architecture/README.md`. + ## Конвенции - Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`) - Ветки: `feature/ORCH-NNN-slug`, `fix/ORCH-NNN-slug` diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 220e428..db52614 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -249,7 +249,7 @@ ORCH-065 вводит фоновый watchdog, чтобы смерть проц Подробнее: [adr-0011](adr/adr-0011-job-reaper-lease-reclaim.md), детально — `docs/work-items/ORCH-065/06-adr/ADR-001-job-reaper-and-lease-reclaim.md`. -### Осмысленная статусная модель Plane (ORCH-066 — design) +### Осмысленная статусная модель Plane (ORCH-066 — реализовано) Plane-доска была семантически перегружена: `In Progress` означал «человек запускает конвейер», «идёт анализ», «идёт прод-деплой» и «возврат из Needs Input» одновременно. ORCH-066 наводит порядок по утверждённой Owner модели, меняя **только слой B** @@ -346,4 +346,4 @@ Monitoring after Deploy → Done Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.md). --- -*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест); ORCH-066 (осмысленная статусная модель Plane — слой B, `docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md`) — design, ветка feature/ORCH-066-plane (только Plane-индикация: новые ключи `to_analyse`/`analysis`/`code_review`/`awaiting_deploy`/`deploying`/`monitoring` в `_PLANE_NAME_TO_KEY`/`_DEFAULT_STATES` + project-relative `_STATE_ALIAS_FALLBACK` в get_project_states + `_STAGE_TO_STATE_KEY` analysis/review + 5 новых `set_issue_*` в src/plane_sync.py; триггер `in_progress`→`to_analyse` и `set_issue_analysis` в src/webhooks/plane.py; Phase A→Awaiting Deploy / Phase B→Deploying / terminal-sync split monitoring↔done / post-deploy monitor HEALTHY→Done DEGRADED→Blocked в src/stage_engine.py; F-2 триггер `to_analyse` + Guard 2 skip-set с вычитанием base_working в src/reconciler.py; `STAGE_TRANSITIONS`/QG/схема БД НЕ трогаются; без kill-switch — раскат гейтится созданием 6 Plane-статусов оператором, `docs/work-items/ORCH-066/07-infra-requirements.md`; обновлять при изменении этих мест).* +*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест); ORCH-066 (осмысленная статусная модель Plane — слой B, `docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md`) — реализовано в ветке feature/ORCH-066-plane (только Plane-индикация: новые ключи `to_analyse`/`analysis`/`code_review`/`awaiting_deploy`/`deploying`/`monitoring` в `_PLANE_NAME_TO_KEY`/`_DEFAULT_STATES` + project-relative `_STATE_ALIAS_FALLBACK` в get_project_states + `_STAGE_TO_STATE_KEY` analysis/review + 5 новых `set_issue_*` в src/plane_sync.py; триггер `in_progress`→`to_analyse` и `set_issue_analysis` в src/webhooks/plane.py; Phase A→Awaiting Deploy / Phase B→Deploying / terminal-sync split monitoring↔done / post-deploy monitor HEALTHY→Done DEGRADED→Blocked в src/stage_engine.py; F-2 триггер `to_analyse` + Guard 2 skip-set с вычитанием base_working в src/reconciler.py; `STAGE_TRANSITIONS`/QG/схема БД НЕ трогаются; без kill-switch — раскат гейтится созданием 6 Plane-статусов оператором, `docs/work-items/ORCH-066/07-infra-requirements.md`; обновлять при изменении этих мест).* diff --git a/src/plane_sync.py b/src/plane_sync.py index f6ed56f..8655d73 100644 --- a/src/plane_sync.py +++ b/src/plane_sync.py @@ -107,6 +107,19 @@ _DEFAULT_STATES = { # Feature 2 (verdict statuses) — Approved / Rejected. "approved": "a519a341-dada-4a91-8910-7604f82b79c5", "rejected": "ba958f3c-5db5-461d-8f82-89425e413b97", + # ORCH-066 (meaningful Plane status model, layer B): six new logical keys. + # Their _DEFAULT_STATES values alias the enduro-trails UUID of their BASE key + # (see _STATE_ALIAS_FALLBACK) so a project without these statuses created + # (enduro / Plane down / partial config) degrades to the current behaviour + # instead of producing an invalid PATCH state. The project-relative + # alias-fallback in get_project_states() overrides these with the *project's + # own* base UUID on the success path; these defaults are the last resort. + "to_analyse": "b873d9eb-993c-48cd-97ac-99a9b1623967", # = in_progress + "analysis": "b873d9eb-993c-48cd-97ac-99a9b1623967", # = in_progress + "code_review": "ba0d802c-5218-41d4-ab43-978b0ea123ed", # = review + "awaiting_deploy": "38fb1f64-aa1e-48a3-92e0-0b109679046b", # = in_review + "deploying": "b873d9eb-993c-48cd-97ac-99a9b1623967", # = in_progress + "monitoring": "381a2833-3c4e-4be5-bd0f-be84cb946ad8", # = done } # Backward-compat alias — do NOT remove (tests + webhooks/plane.py import it). @@ -128,6 +141,29 @@ _PLANE_NAME_TO_KEY: dict[str, str] = { "Needs Input": "needs_input", "In Review": "in_review", "Blocked": "blocked", + # ORCH-066: meaningful per-stage / human-input statuses (layer B). + "To Analyse": "to_analyse", + "Analysis": "analysis", + "Code-Review": "code_review", + "Awaiting Deploy": "awaiting_deploy", + "Deploying": "deploying", + "Monitoring after Deploy": "monitoring", +} + +# ORCH-066 (BR-12): project-relative alias-fallback for the new logical keys. +# After resolving states by name from the Plane API, any NEW key the project did +# not define degrades to the UUID of its BASE key **from the same project** — so +# the indication falls back to the current status and the PATCH stays valid even +# for a partially-configured project. Enduro (none of the new statuses created) +# collapses every new key onto its base, i.e. strictly the pre-ORCH-066 +# behaviour. Strengthened ORCH-059 AC-7 pattern. +_STATE_ALIAS_FALLBACK: dict[str, str] = { + "to_analyse": "in_progress", + "analysis": "in_progress", + "code_review": "review", + "awaiting_deploy": "in_review", + "deploying": "in_progress", + "monitoring": "done", } # Per-project state cache: {project_id: {logical_key: state_uuid}} @@ -175,6 +211,16 @@ def get_project_states(project_id: str) -> dict[str, str]: if not resolved: raise ValueError("no recognisable states in API response") + # ORCH-066 (BR-12): project-relative alias-fallback. For each NEW key the + # project did not define, reuse the UUID of its BASE key FROM THIS SAME + # PROJECT (never a foreign/enduro UUID — that would yield an invalid PATCH + # state on a partially-configured orchestrator project). Runs BEFORE the + # _DEFAULT_STATES.setdefault below so a project's own base UUID wins over + # the static enduro default. + for new_key, base_key in _STATE_ALIAS_FALLBACK.items(): + if new_key not in resolved and resolved.get(base_key): + resolved[new_key] = resolved[base_key] + # Fill any missing keys from _DEFAULT_STATES so callers always get a # complete mapping (defensive against partial Plane configs). for k, v in _DEFAULT_STATES.items(): @@ -210,14 +256,16 @@ def reload_project_states(project_id: str = None) -> None: # Feature 3: map an orchestrator stage -> the Plane status to show on the board -# when the pipeline ENTERS that stage. analysis stays driven by the existing -# in_progress/in_review/needs_input logic (no dedicated status). deploy keeps -# in_progress until done. Needs Input / In Review / Blocked remain higher -# priority and are set explicitly elsewhere — do NOT override them from here. +# when the pipeline ENTERS that stage. ORCH-066: analysis -> Analysis and +# review -> Code-Review now have dedicated statuses. deploy keeps in_progress +# until its own Phase A/B/C statuses drive it. Needs Input / In Review / Blocked +# remain higher priority and are set explicitly elsewhere — do NOT override them +# from here. STAGE_VISIBILITY_STATE = { + "analysis": "analysis", # ORCH-066: analysis stage -> Analysis status "architecture": "architecture", "development": "development", - "review": "review", + "review": "code_review", # ORCH-066: review stage -> Code-Review status "testing": "testing", } @@ -225,22 +273,27 @@ STAGE_VISIBILITY_STATE = { # update_issue_state now calls stage_to_state() instead of looking up here. STAGE_TO_STATE = { "created": _DEFAULT_STATES["todo"], - "analysis": _DEFAULT_STATES["in_progress"], + # ORCH-066: analysis -> Analysis, review -> Code-Review. The new keys alias + # the same in_progress / review UUIDs in _DEFAULT_STATES, so legacy callers / + # tests that compare against concrete UUIDs see byte-identical values. + "analysis": _DEFAULT_STATES["analysis"], "architecture": _DEFAULT_STATES["architecture"], "development": _DEFAULT_STATES["development"], - "review": _DEFAULT_STATES["review"], + "review": _DEFAULT_STATES["code_review"], "testing": _DEFAULT_STATES["testing"], "deploy": _DEFAULT_STATES["in_progress"], "done": _DEFAULT_STATES["done"], } # Map orchestrator stage -> logical state key (project-independent). +# ORCH-066: analysis -> analysis, review -> code_review (was in_progress/review). +# deploy stays in_progress (Phase A/B/C drive it directly, not update_issue_state). _STAGE_TO_STATE_KEY = { "created": "todo", - "analysis": "in_progress", + "analysis": "analysis", "architecture": "architecture", "development": "development", - "review": "review", + "review": "code_review", "testing": "testing", "deploy": "in_progress", "done": "done", @@ -575,6 +628,58 @@ def set_issue_in_progress(work_item_id: str, project_id: str = None): _set_issue_state_direct(work_item_id, state_id, project_id) +def set_issue_analysis(work_item_id: str, project_id: str = None): + """ORCH-066: set issue to 'Analysis' — analyst is working (start / resume). + + Degrades to the project's In Progress UUID when the 'Analysis' status is not + created (alias-fallback). never-raise (via _set_issue_state_direct). + """ + project_id = _resolve_project_id(work_item_id, project_id) + state_id = get_project_states(project_id)["analysis"] + _set_issue_state_direct(work_item_id, state_id, project_id) + + +def set_issue_code_review(work_item_id: str, project_id: str = None): + """ORCH-066: set issue to 'Code-Review' — review stage indication. + + Degrades to the project's Review UUID when 'Code-Review' is not created. + """ + project_id = _resolve_project_id(work_item_id, project_id) + state_id = get_project_states(project_id)["code_review"] + _set_issue_state_direct(work_item_id, state_id, project_id) + + +def set_issue_awaiting_deploy(work_item_id: str, project_id: str = None): + """ORCH-066: set issue to 'Awaiting Deploy' — self-deploy Phase A approval-pending. + + Degrades to the project's In Review UUID when 'Awaiting Deploy' is not created. + """ + project_id = _resolve_project_id(work_item_id, project_id) + state_id = get_project_states(project_id)["awaiting_deploy"] + _set_issue_state_direct(work_item_id, state_id, project_id) + + +def set_issue_deploying(work_item_id: str, project_id: str = None): + """ORCH-066: set issue to 'Deploying' — self-deploy Phase B prod deploy in flight. + + Degrades to the project's In Progress UUID when 'Deploying' is not created. + """ + project_id = _resolve_project_id(work_item_id, project_id) + state_id = get_project_states(project_id)["deploying"] + _set_issue_state_direct(work_item_id, state_id, project_id) + + +def set_issue_monitoring(work_item_id: str, project_id: str = None): + """ORCH-066: set issue to 'Monitoring after Deploy' — post-deploy window open. + + Degrades to the project's Done UUID when 'Monitoring after Deploy' is not + created (so the board shows Done, exactly as before ORCH-066). + """ + project_id = _resolve_project_id(work_item_id, project_id) + state_id = get_project_states(project_id)["monitoring"] + _set_issue_state_direct(work_item_id, state_id, project_id) + + def set_issue_stage_state(work_item_id: str, stage: str, project_id: str = None): """Feature 3: move the issue to the board status for a pipeline stage. diff --git a/src/reconciler.py b/src/reconciler.py index 6d65baa..4225703 100644 --- a/src/reconciler.py +++ b/src/reconciler.py @@ -193,12 +193,22 @@ class Reconciler: self._note_unblock(task.get("work_item_id") or str(task_id), stage) def _is_blocked_or_needs_input(self, task: dict) -> bool: - """ORCH-060 Guard 2: is this issue in an explicit human Plane gate? + """Guard 2 (ORCH-060 + ORCH-066): is this issue waiting for a human OR in + an active orchestrator wait that F-1 must not "revive"? Variant A (no schema migration): resolve the task's Plane project, fetch - the issue's current state uuid and compare against the project's - ``blocked`` / ``needs_input`` states. ``tasks`` has no status column, so - the live Plane state is the source of truth. + the issue's current state uuid and compare against a skip-set. ``tasks`` + has no status column, so the live Plane state is the source of truth. + + Skip-set = explicit human gates (``blocked`` / ``needs_input``) PLUS the + ORCH-066 active waits (``awaiting_deploy`` / ``deploying`` / ``monitoring``, + BR-13). **Anti-regress (CRITICAL):** the active-wait keys alias onto + ``in_review`` / ``in_progress`` / ``done`` on a project that did not create + them. Adding them verbatim would make F-1 wrongly skip enduro + In Progress / Done tasks (regression of ORCH-053/060). So they are + included ONLY when DISTINCT from the project's base working statuses + (i.e. actually created as separate statuses): enduro collapses them to {} + -> zero regress; orchestrator keeps three real statuses -> BR-13. **Never-raise, conservative fallback.** Any error / unresolved project / missing state -> return ``True`` (treat as "possibly blocked" -> skip): @@ -219,7 +229,22 @@ class Reconciler: cur = fetch_issue_state(issue_id, pid) if cur is None: return True # Plane unreachable / no state -> conservative skip - return cur in {states.get("blocked"), states.get("needs_input")} + # ORCH-066 BR-13: active orchestrator waits, minus base working + # statuses so aliased (enduro) keys never widen the skip-set. + base_working = { + states.get(k) for k in ( + "backlog", "todo", "in_progress", "in_review", "review", + "architecture", "development", "testing", + "approved", "rejected", "done", + ) + } + extra_waits = { + states.get("awaiting_deploy"), + states.get("deploying"), + states.get("monitoring"), + } - base_working - {None} + skip_set = {states.get("blocked"), states.get("needs_input")} | extra_waits + return cur in skip_set except Exception as e: # noqa: BLE001 - never break the tick logger.warning( f"reconciler Guard 2: blocked-check failed for task " @@ -241,15 +266,19 @@ class Reconciler: def _reconcile_plane_project(self, proj) -> None: pid = proj.plane_project_id # Resolve the actionable state uuids per-project (never hardcode). + # ORCH-066 (AC-19): the start/resume trigger is `To Analyse` (was + # In Progress). On a project without that status, `to_analyse` aliases to + # the project's own `in_progress` UUID, so enduro behaviour is identical + # (and `list_issues_by_state` deduplicates the uuid via its internal set). states = get_project_states(pid) - in_progress = states["in_progress"] + to_analyse = states["to_analyse"] approved = states["approved"] rejected = states["rejected"] - issues = list_issues_by_state(pid, [in_progress, approved, rejected]) + issues = list_issues_by_state(pid, [to_analyse, approved, rejected]) for issue in issues: try: self._reconcile_plane_issue( - issue, pid, in_progress, approved, rejected + issue, pid, to_analyse, approved, rejected ) except Exception as e: # noqa: BLE001 - isolate one issue's failure logger.error( @@ -258,7 +287,7 @@ class Reconciler: def _reconcile_plane_issue( self, issue: dict, project_id: str, - in_progress: str, approved: str, rejected: str, + to_analyse: str, approved: str, rejected: str, ) -> None: issue_id = str(issue.get("id") or "") if not issue_id: @@ -288,10 +317,16 @@ class Reconciler: "description_stripped": issue.get("description_stripped", ""), } - if new_state == in_progress and task is None: - # In Progress without a task -> start the pipeline (lost start webhook). + if new_state == to_analyse and task is None: + # To Analyse without a task -> start the pipeline (lost start webhook). self._dispatch(handle_status_start, issue_data, project_id) self._note_unblock(issue_id, "analysis") + elif new_state == to_analyse and task is not None: + # To Analyse with an existing (idle) task -> resume the analyst from + # Needs Input (lost resume webhook). handle_status_start applies its + # own busy-guard / start-vs-resume fork. + self._dispatch(handle_status_start, issue_data, project_id) + self._note_unblock(task.get("work_item_id") or issue_id, task["stage"]) elif new_state == approved and task is not None: # Approved but the stage never advanced -> replay the verdict. self._dispatch(handle_verdict, issue_data, project_id, approved=True) diff --git a/src/stage_engine.py b/src/stage_engine.py index df84ca5..2de7a31 100644 --- a/src/stage_engine.py +++ b/src/stage_engine.py @@ -53,6 +53,10 @@ from .plane_sync import ( set_issue_in_progress, set_issue_blocked, set_issue_done, + set_issue_analysis, + set_issue_awaiting_deploy, + set_issue_deploying, + set_issue_monitoring, ) from .config import settings @@ -335,14 +339,28 @@ def advance_stage( # here, so explicitly drive the Plane issue into the terminal Done state # (PLANE_STATES['done'] — mapping unchanged) in addition to the # stage-change comment above. + # ORCH-066 (AC-8/AC-9): split terminal-sync by whether post-deploy + # monitoring applies. For self-hosting (post_deploy_applies==True) the + # task enters a `Monitoring after Deploy` window, NOT terminal Done yet — + # the monitor finalises Done/Blocked (run_post_deploy_monitor). For + # non-self repos the behaviour is unchanged: terminal Done immediately. + # Where the `Monitoring after Deploy` status is absent, set_issue_monitoring + # degrades to the project's Done UUID -> identical to today. if next_stage == "done" and work_item_id: try: - set_issue_done(work_item_id) - logger.info( - f"Task {task_id}: deploy->done, Plane state forced to Done" - ) + if post_deploy.post_deploy_applies(repo): + set_issue_monitoring(work_item_id) + logger.info( + f"Task {task_id}: deploy->done (self), Plane state -> " + f"Monitoring after Deploy (post-deploy window)" + ) + else: + set_issue_done(work_item_id) + logger.info( + f"Task {task_id}: deploy->done, Plane state forced to Done" + ) except Exception as e: - logger.error(f"Task {task_id}: failed to set Plane Done: {e}") + logger.error(f"Task {task_id}: failed to set Plane terminal state: {e}") # ORCH-043: the merge has landed (deploy->done). Release the merge lease as # a backstop in case the PR-merged webhook was lost (holder-aware no-op if a @@ -666,7 +684,9 @@ def _handle_qg_failure_rollbacks( notify_stage_change(task_id, current_stage, "analysis") plane_notify_stage(work_item_id, current_stage, "analysis") result.rolled_back_to = "analysis" - set_issue_in_progress(work_item_id) + # ORCH-066 (AC-3): rolled back to analysis -> indicate `Analysis` + # (degrades to In Progress where the status is not created). + set_issue_analysis(work_item_id) with open(conflict_path, "r") as cf: conflict_text = cf.read()[:500] plane_add_comment( @@ -1009,7 +1029,11 @@ def _handle_self_deploy_phase_a( result.note = "self-deploy-approval-pending" if work_item_id: - set_issue_in_review(work_item_id) + # ORCH-066 (AC-6/AC-13): Phase A approval-pending is now `Awaiting Deploy`, + # which discharges `In Review` of the deploy-approval meaning (In Review + # stays for analyst BRD/review approve-pending only). Degrades to In Review + # where the status is not created. + set_issue_awaiting_deploy(work_item_id) # ORCH-036: belt-and-suspenders — wipe any STALE deploy-state markers before # arming a fresh approve. A prior FAILED pass clears on rollback, but clearing # here too guarantees the entry to every new prod-deploy pass starts clean @@ -1069,6 +1093,10 @@ def _handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result: Adv self_deploy.write_marker( repo, work_item_id, self_deploy.INITIATED, content=str(time.time()) ) + # ORCH-066 (AC-7): the prod deploy is now in flight -> indicate `Deploying` + # (degrades to In Progress where the status is not created). + if work_item_id: + set_issue_deploying(work_item_id) task_desc = ( f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n" f"Stage: deploy\nNote: deploy-finalize poll (prod self-deploy initiated)." @@ -1263,6 +1291,12 @@ def run_post_deploy_monitor(job: dict): settings.post_deploy_window_s, checks_total, checks_failed, ) post_deploy.mark_done(repo, work_item_id) + # ORCH-066 (AC-10): the post-deploy window closed clean -> terminal Done. + if work_item_id: + try: + set_issue_done(work_item_id) + except Exception as e: # noqa: BLE001 - never break the tick + logger.warning(f"post-deploy: set Done failed for {work_item_id}: {e}") _notify_post_deploy( work_item_id, f"✅ {work_item_id}: пост-деплой окно завершено чисто " @@ -1303,6 +1337,15 @@ def run_post_deploy_monitor(job: dict): f"self-hosting запрещён (BR-5).", ) + # ORCH-066 (AC-11/AC-12): a confirmed degradation -> indicate `Blocked` for + # manual intervention. This is INDICATION ONLY — the tick NEVER restarts / + # rolls back the prod container (self-hosting stays ALERT_ONLY, BR-5). + if work_item_id: + try: + set_issue_blocked(work_item_id) + except Exception as e: # noqa: BLE001 - never break the tick + logger.warning(f"post-deploy: set Blocked failed for {work_item_id}: {e}") + post_deploy.write_post_deploy_log( repo, work_item_id, branch, post_deploy.DEGRADED, action_taken, settings.post_deploy_window_s, checks_total, checks_failed, diff --git a/src/webhooks/plane.py b/src/webhooks/plane.py index b14ab3b..126ffca 100644 --- a/src/webhooks/plane.py +++ b/src/webhooks/plane.py @@ -147,10 +147,15 @@ async def handle_issue_updated(data: dict, project_id: str = ""): return # ORCH-10: resolve expected state UUIDs per the incoming issue's project so - # both enduro (b873d9eb) and orchestrator (e331bfb3) In Progress trigger the + # both enduro (b873d9eb) and orchestrator (e331bfb3) statuses trigger the # pipeline. Using PLANE_STATES["in_progress"] here was the root-cause blocker. + # ORCH-066: the start/resume trigger is now `To Analyse` (human entry-point), + # which discharges `In Progress` of its overloaded "start the pipeline" + # meaning. Fail-closed: on a project without the `To Analyse` status, + # `to_analyse` aliases to the project's own `in_progress` UUID, so moving an + # enduro issue to In Progress still triggers start/resume (AC-17). proj_states = get_project_states(project_id) - if new_state == proj_states["in_progress"]: + if new_state == proj_states["to_analyse"]: await handle_status_start(data, project_id) elif new_state == proj_states["approved"]: await handle_verdict(data, project_id, approved=True) @@ -235,9 +240,14 @@ async def handle_status_start(data: dict, project_id: str = ""): ) job_id = enqueue_job(stage_agent, repo, task_desc, task_id=task_id) logger.info( - f"Task {task_id}: returned to In Progress (Needs Input answered), " + f"Task {task_id}: returned to To Analyse (Needs Input answered), " f"relaunched {stage_agent} for stage {current_stage} (job_id={job_id})" ) + # ORCH-066 (AC-3): a resume of the analyst (the only Needs-Input owner) is + # re-indicated as `Analysis`; other stages keep their own indication. + if current_stage == "analysis": + from ..plane_sync import set_issue_analysis as _set_analysis + _set_analysis(work_item_id) try: _add_comment( work_item_id, @@ -538,6 +548,10 @@ async def start_pipeline(data: dict, project_id: str = ""): ) job_id = enqueue_job("analyst", repo, task_desc, task_id=task_id) logger.info(f"Task {task_id}: enqueued analyst (job_id={job_id})") + # ORCH-066 (AC-3): indicate the analysis stage with the dedicated + # `Analysis` status (degrades to In Progress where it is not created). + from ..plane_sync import set_issue_analysis as _set_analysis + _set_analysis(work_item_id, plane_project_id) # Post start comment to Plane from ..plane_sync import add_comment as _add_comment _add_comment(work_item_id, "\U0001f50d Analyst \u0437\u0430\u043f\u0443\u0449\u0435\u043d. BRD/\u0422\u0417/AC/TestPlan \u0432 \u0440\u0430\u0431\u043e\u0442\u0435 (\u043e\u0436\u0438\u0434\u0430\u0439\u0442\u0435 8-15 \u043c\u0438\u043d).", author="analyst") @@ -579,9 +593,11 @@ async def _rollback_stage( (via the existing rollback notify + an enqueue of the prev-stage agent). """ if current_stage == "analysis": - # Already in analysis — just relaunch analyst with rejection reason - from ..plane_sync import set_issue_in_progress - set_issue_in_progress(work_item_id) + # Already in analysis — just relaunch analyst with rejection reason. + # ORCH-066 (AC-3): indicate `Analysis` (degrades to In Progress where the + # status is not created). + from ..plane_sync import set_issue_analysis + set_issue_analysis(work_item_id) task_desc = ( f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n" f"Stage: analysis\nNote: Stakeholder REJECTED your artifacts. " diff --git a/tests/test_deploy_approve.py b/tests/test_deploy_approve.py index 146a8e4..fd0bd21 100644 --- a/tests/test_deploy_approve.py +++ b/tests/test_deploy_approve.py @@ -48,6 +48,9 @@ def silence_side_effects(monkeypatch): "send_telegram", "plane_notify_stage", "plane_notify_qg", "plane_add_comment", "set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress", "set_issue_blocked", "set_issue_done", + # ORCH-066 status setters. + "set_issue_analysis", "set_issue_awaiting_deploy", "set_issue_deploying", + "set_issue_monitoring", ): monkeypatch.setattr(stage_engine, name, MagicMock()) @@ -127,6 +130,9 @@ def test_tc05_no_approve_does_not_call_prod_hook(monkeypatch): assert _jobs() == [] # The restart-safe approve-requested marker was written. assert self_deploy.has_marker("orchestrator", "ORCH-036", self_deploy.APPROVE_REQUESTED) + # ORCH-066 AC-6/AC-13: Phase A indicates `Awaiting Deploy`, NOT `In Review`. + stage_engine.set_issue_awaiting_deploy.assert_called_once_with("ORCH-036") + stage_engine.set_issue_in_review.assert_not_called() # --------------------------------------------------------------------------- @@ -151,6 +157,8 @@ def test_tc06_approved_calls_prod_hook_exactly_once(monkeypatch): # The finalizer was enqueued. assert any(j["agent"] == "deploy-finalizer" for j in _jobs()) assert self_deploy.has_marker("orchestrator", "ORCH-036", self_deploy.INITIATED) + # ORCH-066 AC-7: Phase B indicates `Deploying` on a successful initiate. + stage_engine.set_issue_deploying.assert_called_once_with("ORCH-036") # 2nd (duplicate) Approved -> idempotent no-op, hook NOT called again. res2 = advance_stage( diff --git a/tests/test_deploy_terminal_sync.py b/tests/test_deploy_terminal_sync.py index d7b9b5e..c417cef 100644 --- a/tests/test_deploy_terminal_sync.py +++ b/tests/test_deploy_terminal_sync.py @@ -45,6 +45,9 @@ def silence_side_effects(monkeypatch): "send_telegram", "plane_notify_stage", "plane_notify_qg", "plane_add_comment", "set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress", "set_issue_blocked", "set_issue_done", + # ORCH-066 status setters. + "set_issue_analysis", "set_issue_awaiting_deploy", "set_issue_deploying", + "set_issue_monitoring", ): monkeypatch.setattr(stage_engine, name, MagicMock()) @@ -106,3 +109,56 @@ def test_tc17_success_deploy_syncs_terminal_done(monkeypatch): release.assert_called_once_with("orchestrator", "feature/ORCH-036-x") # No agent is launched leaving deploy (terminal). assert _jobs() == [] + + +# --------------------------------------------------------------------------- +# ORCH-066 TC-08 (AC-8): self-hosting deploy->done -> Monitoring after Deploy, +# NOT terminal Done. The post-deploy monitor finalises. +# --------------------------------------------------------------------------- +def test_tc08_self_deploy_done_sets_monitoring_not_done(monkeypatch): + self_deploy.write_marker("orchestrator", "ORCH-036", self_deploy.RESULT, "0") + monkeypatch.setattr( + stage_engine, "QG_CHECKS", + {**stage_engine.QG_CHECKS, "check_deploy_status": _pass}, + ) + monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", MagicMock()) + # post_deploy applies for the self-hosting repo with the monitor enabled. + monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_monitor_enabled", True) + monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_repos", "") + # arm_monitor is orthogonal; stub it so this test stays on the status contract. + monkeypatch.setattr(stage_engine.post_deploy, "arm_monitor", MagicMock(return_value=True)) + + task_id = _make_task("deploy") + stage_engine.run_deploy_finalizer( + {"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "deploy-finalizer"} + ) + + assert _stage(task_id) == "done" + # Self-hosting: the issue enters the Monitoring window, NOT terminal Done yet. + stage_engine.set_issue_monitoring.assert_called_once_with("ORCH-036") + stage_engine.set_issue_done.assert_not_called() + + +# --------------------------------------------------------------------------- +# ORCH-066 TC-09 (AC-9): non-self repo deploy->done -> terminal Done (no regress). +# --------------------------------------------------------------------------- +def test_tc09_non_self_deploy_done_sets_done(monkeypatch): + self_deploy.write_marker("enduro-trails", "ET-042", self_deploy.RESULT, "0") + monkeypatch.setattr( + stage_engine, "QG_CHECKS", + {**stage_engine.QG_CHECKS, "check_deploy_status": _pass}, + ) + monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", MagicMock()) + # Monitor enabled, but the empty CSV means it applies ONLY to the self repo; + # a non-self repo therefore takes the unchanged terminal-Done path. + monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_monitor_enabled", True) + monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_repos", "") + + task_id = _make_task("deploy", repo="enduro-trails", branch="feature/ET-042-x", wi="ET-042") + stage_engine.run_deploy_finalizer( + {"task_id": task_id, "repo": "enduro-trails", "id": 1, "agent": "deploy-finalizer"} + ) + + assert _stage(task_id) == "done" + stage_engine.set_issue_done.assert_called_once_with("ET-042") + stage_engine.set_issue_monitoring.assert_not_called() diff --git a/tests/test_m6_sequence.py b/tests/test_m6_sequence.py index 733a267..13b80cb 100644 --- a/tests/test_m6_sequence.py +++ b/tests/test_m6_sequence.py @@ -40,11 +40,15 @@ ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c" _PROJECT_STATES = { ENDURO_PLANE_ID: { "in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967", + # ORCH-066: To Analyse is the start trigger; with the status absent it + # aliases to in_progress (the real get_project_states fallback). + "to_analyse": "b873d9eb-993c-48cd-97ac-99a9b1623967", "approved": "a519a341-dada-4a91-8910-7604f82b79c5", "rejected": "ba958f3c-5db5-461d-8f82-89425e413b97", }, ORCH_PLANE_ID: { "in_progress": "e331bfb3-e17e-4699-ba48-4abb89c21b7b", + "to_analyse": "e331bfb3-e17e-4699-ba48-4abb89c21b7b", "approved": "63f2c8fe-dcda-4ace-952f-dd88bd0118ff", "rejected": "4c769e90-bf80-4a52-b97a-e1c84904bfc3", }, diff --git a/tests/test_orch10_states.py b/tests/test_orch10_states.py index c753fd5..292e48a 100644 --- a/tests/test_orch10_states.py +++ b/tests/test_orch10_states.py @@ -460,3 +460,59 @@ def test_default_states_et_values(): assert ps._DEFAULT_STATES[key] == expected, ( f"_DEFAULT_STATES['{key}']: expected {expected}, got {ps._DEFAULT_STATES.get(key)}" ) + + +# --------------------------------------------------------------------------- +# ORCH-066 TC-19 (AC-18): resolve-by-name — when a project DEFINES one of the +# new statuses, get_project_states must use its OWN UUID, not the default alias. +# --------------------------------------------------------------------------- +def test_orch066_tc19_name_resolution_beats_alias(): + """A project that created 'Analysis' / 'Code-Review' / 'Awaiting Deploy' / + 'Deploying' / 'Monitoring after Deploy' resolves each to its own project + UUID (via _PLANE_NAME_TO_KEY), NOT the aliased base-key UUID.""" + import src.plane_sync as ps + + new_uuids = { + "Analysis": "11111111-0000-0000-0000-000000000001", + "Code-Review": "11111111-0000-0000-0000-000000000002", + "Awaiting Deploy": "11111111-0000-0000-0000-000000000003", + "Deploying": "11111111-0000-0000-0000-000000000004", + "Monitoring after Deploy": "11111111-0000-0000-0000-000000000005", + "To Analyse": "11111111-0000-0000-0000-000000000006", + } + # Start from the full ORCH base set, then add the dedicated new statuses. + results = _make_states_response(ORCH_STATES)["results"] + results += [{"id": uid, "name": name} for name, uid in new_uuids.items()] + + with patch("src.plane_sync.httpx.get") as mock_get: + mock_get.return_value = _fake_response({"results": results}) + states = ps.get_project_states(ORCH_PROJECT_ID) + + # Each new key resolved to the project's OWN UUID, not the base-key alias. + assert states["analysis"] == new_uuids["Analysis"] + assert states["code_review"] == new_uuids["Code-Review"] + assert states["awaiting_deploy"] == new_uuids["Awaiting Deploy"] + assert states["deploying"] == new_uuids["Deploying"] + assert states["monitoring"] == new_uuids["Monitoring after Deploy"] + assert states["to_analyse"] == new_uuids["To Analyse"] + # Sanity: they are NOT the aliased base UUIDs. + assert states["analysis"] != states["in_progress"] + assert states["code_review"] != states["review"] + assert states["awaiting_deploy"] != states["in_review"] + + +def test_orch066_tc19_missing_new_status_aliases_to_project_base(): + """BR-12: a project WITHOUT the new statuses degrades each new key to its OWN + base UUID (not a foreign enduro UUID) — keeping the PATCH state valid.""" + import src.plane_sync as ps + with patch("src.plane_sync.httpx.get") as mock_get: + mock_get.return_value = _fake_response(_make_states_response(ORCH_STATES)) + states = ps.get_project_states(ORCH_PROJECT_ID) + + # No dedicated new statuses -> alias to THIS project's base UUIDs. + assert states["analysis"] == ORCH_STATES["in_progress"] + assert states["to_analyse"] == ORCH_STATES["in_progress"] + assert states["code_review"] == ORCH_STATES["review"] + assert states["awaiting_deploy"] == ORCH_STATES["in_review"] + assert states["deploying"] == ORCH_STATES["in_progress"] + assert states["monitoring"] == ORCH_STATES["done"] diff --git a/tests/test_plane_status_failclosed.py b/tests/test_plane_status_failclosed.py new file mode 100644 index 0000000..14ad2e3 --- /dev/null +++ b/tests/test_plane_status_failclosed.py @@ -0,0 +1,131 @@ +"""ORCH-066 fail-closed (CRITICAL) — the new status model must never wedge the +pipeline when the 6 Plane statuses are absent or Plane is unreachable. + + * TC-16 (AC-16, BR-12) — a project WITHOUT the new statuses resolves each new + logical key to its OWN base UUID (to_analyse=in_progress, code_review=review, + awaiting_deploy=in_review, monitoring=done); no exception. + * TC-17 (AC-16) — Plane API down -> get_project_states falls back to + _DEFAULT_STATES; every set_issue_* helper is never-raise. + * TC-18 (AC-17) — enduro In Progress STILL starts the pipeline through + the to_analyse alias (= in_progress UUID). + +httpx is mocked; no network. +""" + +import os + +os.environ.setdefault("ORCH_PLANE_API_URL", "http://plane.local") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_WORKSPACE_SLUG", "test-ws") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") + +from unittest.mock import patch, MagicMock, AsyncMock # noqa: E402 + +import pytest # noqa: E402 + +from src import plane_sync as PS # noqa: E402 + +ENDURO_PROJECT_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c" + +# An enduro-style states response: the 6 ORCH-066 statuses are NOT created. +_ENDURO_BASE = { + "Backlog": "backlog-u", "Todo": "todo-u", "In Progress": "ip-u", + "Review": "review-u", "In Review": "inrev-u", "Approved": "appr-u", + "Rejected": "rej-u", "Done": "done-u", "Needs Input": "ni-u", + "Blocked": "blk-u", +} + + +def _states_response(name_to_uuid): + return {"results": [{"id": uid, "name": name} for name, uid in name_to_uuid.items()]} + + +def _fake_resp(data, status=200): + m = MagicMock() + m.status_code = status + m.json.return_value = data + m.raise_for_status.return_value = None + return m + + +@pytest.fixture(autouse=True) +def _reset_cache(): + PS.reload_project_states() + yield + PS.reload_project_states() + + +# --------------------------------------------------------------------------- +# TC-16 (AC-16 / BR-12): partial project -> alias to its own base UUIDs, no raise. +# --------------------------------------------------------------------------- +def test_tc16_partial_project_aliases_to_base_uuids(): + with patch("src.plane_sync.httpx.get") as mock_get: + mock_get.return_value = _fake_resp(_states_response(_ENDURO_BASE)) + states = PS.get_project_states(ENDURO_PROJECT_ID) + + # The new keys degrade to THIS project's base UUIDs (not foreign defaults). + assert states["to_analyse"] == states["in_progress"] == "ip-u" + assert states["analysis"] == "ip-u" + assert states["code_review"] == states["review"] == "review-u" + assert states["awaiting_deploy"] == states["in_review"] == "inrev-u" + assert states["deploying"] == "ip-u" + assert states["monitoring"] == states["done"] == "done-u" + + +# --------------------------------------------------------------------------- +# TC-17 (AC-16): Plane API down -> _DEFAULT_STATES; set_issue_* never-raise. +# --------------------------------------------------------------------------- +def test_tc17_api_down_falls_back_to_defaults(): + with patch("src.plane_sync.httpx.get", side_effect=Exception("plane down")): + states = PS.get_project_states(ENDURO_PROJECT_ID) + assert states is PS._DEFAULT_STATES + # All new keys exist in the defaults (so callers never KeyError). + for k in ("to_analyse", "analysis", "code_review", "awaiting_deploy", + "deploying", "monitoring"): + assert k in states + + +def test_tc17_set_issue_helpers_never_raise_when_issue_missing(): + # find_issue_id returns None (issue not in Plane) -> helpers log + return, + # they must NOT raise. Covers every ORCH-066 setter. + setters = [ + PS.set_issue_analysis, PS.set_issue_code_review, + PS.set_issue_awaiting_deploy, PS.set_issue_deploying, + PS.set_issue_monitoring, + ] + with patch("src.plane_sync._resolve_project_id", return_value="proj-1"), \ + patch("src.plane_sync.get_project_states", return_value=PS._DEFAULT_STATES), \ + patch("src.plane_sync.find_issue_id", return_value=None), \ + patch("src.plane_sync.httpx.patch") as mock_patch: + for setter in setters: + setter("ET-1") # must not raise + # No PATCH issued because the issue could not be resolved. + mock_patch.assert_not_called() + + +def test_tc17_set_issue_helpers_never_raise_when_patch_errors(): + # The PATCH itself blows up -> _set_issue_state_direct swallows it. + with patch("src.plane_sync._resolve_project_id", return_value="proj-1"), \ + patch("src.plane_sync.get_project_states", return_value=PS._DEFAULT_STATES), \ + patch("src.plane_sync.find_issue_id", return_value="issue-uuid"), \ + patch("src.plane_sync.httpx.patch", side_effect=Exception("boom")): + PS.set_issue_monitoring("ET-1") # must not raise + + +# --------------------------------------------------------------------------- +# TC-18 (AC-17): enduro In Progress still starts the pipeline via to_analyse alias. +# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_tc18_enduro_in_progress_still_starts_via_alias(): + from src.webhooks.plane import handle_issue_updated + + with patch("src.plane_sync.httpx.get") as mock_get, \ + patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) as mock_start, \ + patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) as mock_verdict: + mock_get.return_value = _fake_resp(_states_response(_ENDURO_BASE)) + # enduro never created 'To Analyse' -> to_analyse aliases In Progress (ip-u). + data = {"id": "et-issue", "state": {"id": "ip-u", "name": "In Progress"}} + await handle_issue_updated(data, ENDURO_PROJECT_ID) + + mock_start.assert_called_once() + mock_verdict.assert_not_called() diff --git a/tests/test_plane_status_model.py b/tests/test_plane_status_model.py new file mode 100644 index 0000000..a6bc81e --- /dev/null +++ b/tests/test_plane_status_model.py @@ -0,0 +1,151 @@ +"""ORCH-066: the meaningful Plane status model (layer B) — unit coverage. + +These tests pin the layer-B behaviour WITHOUT touching layer A (the stage +machine). httpx is mocked; no network. + + * TC-03 (AC-3) — the analyst start/resume indicates `Analysis`, not In Progress. + * TC-05 (AC-5) — entering the `review` stage indicates `Code-Review`. + * TC-14 (AC-14) — set_issue_needs_input is unchanged (still PATCHes Needs Input). + * TC-22 (AC-21) — STAGE_TRANSITIONS (layer A) is byte-identical (explicit pin). + * TC-23 (AC-22) — QG_CHECKS registry + check_deploy_status contract unchanged. +""" + +import os + +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") + +from unittest.mock import patch, MagicMock # noqa: E402 + +from src import plane_sync as PS # noqa: E402 + + +# A per-project state map that DEFINES the new ORCH-066 statuses with distinct +# UUIDs, so we can prove the dedicated status (not the base alias) is used. +_STATES_WITH_NEW = { + "in_progress": "ip-uuid", + "review": "review-uuid", + "in_review": "inrev-uuid", + "needs_input": "ni-uuid", + "done": "done-uuid", + "analysis": "analysis-uuid", + "code_review": "codereview-uuid", + "awaiting_deploy": "awaiting-uuid", + "deploying": "deploying-uuid", + "monitoring": "monitoring-uuid", +} + + +def _patch_resolve(states): + """Patch find_issue_id + _resolve_project_id + get_project_states so a + set_issue_* helper reaches the PATCH with a known per-project state map.""" + return ( + patch("src.plane_sync.httpx.patch"), + patch("src.plane_sync.find_issue_id", return_value="issue-uuid"), + patch("src.plane_sync._resolve_project_id", return_value="proj-1"), + patch("src.plane_sync.get_project_states", return_value=states), + ) + + +def _run_setter(setter, states): + p_patch, p_find, p_res, p_states = _patch_resolve(states) + with p_patch as mock_patch, p_find, p_res, p_states: + resp = MagicMock() + resp.raise_for_status.return_value = None + mock_patch.return_value = resp + setter("ET-1") + return mock_patch + + +# --------------------------------------------------------------------------- +# TC-03 (AC-3): analyst start/resume indicates Analysis. +# --------------------------------------------------------------------------- +def test_tc03_set_issue_analysis_patches_analysis_uuid(): + mock_patch = _run_setter(PS.set_issue_analysis, _STATES_WITH_NEW) + # The dedicated Analysis UUID is used (NOT the in_progress base alias). + assert mock_patch.call_args.kwargs["json"]["state"] == "analysis-uuid" + assert mock_patch.call_args.kwargs["json"]["state"] != _STATES_WITH_NEW["in_progress"] + + +def test_tc03_analysis_aliases_in_progress_when_absent(): + # A project without the Analysis status -> get_project_states already aliased + # 'analysis' onto its in_progress UUID, so the PATCH degrades gracefully. + aliased = dict(_STATES_WITH_NEW) + aliased["analysis"] = aliased["in_progress"] + mock_patch = _run_setter(PS.set_issue_analysis, aliased) + assert mock_patch.call_args.kwargs["json"]["state"] == aliased["in_progress"] + + +# --------------------------------------------------------------------------- +# TC-05 (AC-5): the review stage indicates Code-Review. +# --------------------------------------------------------------------------- +def test_tc05_review_stage_maps_to_code_review(): + # Both the stage->state-key map and the stage-visibility map point review at + # the new code_review logical key (layer B only). + assert PS._STAGE_TO_STATE_KEY["review"] == "code_review" + assert PS.STAGE_VISIBILITY_STATE["review"] == "code_review" + + +def test_tc05_set_issue_stage_state_review_patches_code_review_uuid(): + p_patch, p_find, p_res, p_states = _patch_resolve(_STATES_WITH_NEW) + with p_patch as mock_patch, p_find, p_res, p_states: + resp = MagicMock() + resp.raise_for_status.return_value = None + mock_patch.return_value = resp + PS.set_issue_stage_state("ET-1", "review") + assert mock_patch.call_args.kwargs["json"]["state"] == "codereview-uuid" + + +def test_tc05_set_issue_code_review_helper_patches_code_review_uuid(): + mock_patch = _run_setter(PS.set_issue_code_review, _STATES_WITH_NEW) + assert mock_patch.call_args.kwargs["json"]["state"] == "codereview-uuid" + + +# --------------------------------------------------------------------------- +# TC-14 (AC-14): Needs Input behaviour unchanged. +# --------------------------------------------------------------------------- +def test_tc14_needs_input_unchanged(): + mock_patch = _run_setter(PS.set_issue_needs_input, _STATES_WITH_NEW) + assert mock_patch.call_args.kwargs["json"]["state"] == "ni-uuid" + + +# --------------------------------------------------------------------------- +# TC-22 (AC-21): STAGE_TRANSITIONS (layer A) is byte-identical. ORCH-066 changes +# ONLY layer B — the machine must not move. +# --------------------------------------------------------------------------- +def test_tc22_stage_transitions_unchanged(): + from src.stages import STAGE_TRANSITIONS + assert STAGE_TRANSITIONS == { + "created": {"next": "analysis", "agent": "analyst", "qg": None}, + "analysis": {"next": "architecture", "agent": "architect", "qg": "check_analysis_approved"}, + "architecture": {"next": "development", "agent": "developer", "qg": "check_architecture_done"}, + "development": {"next": "review", "agent": "reviewer", "qg": "check_ci_green"}, + "review": {"next": "testing", "agent": "tester", "qg": "check_reviewer_verdict"}, + "testing": {"next": "deploy-staging", "agent": "deployer", "qg": "check_tests_passed"}, + "deploy-staging": {"next": "deploy", "agent": "deployer", "qg": "check_staging_status"}, + "deploy": {"next": "done", "agent": None, "qg": "check_deploy_status"}, + "done": {"next": None, "agent": None, "qg": None}, + } + + +# --------------------------------------------------------------------------- +# TC-23 (AC-22): QG_CHECKS registry + check_deploy_status contract unchanged. +# --------------------------------------------------------------------------- +def test_tc23_qg_checks_registry_unchanged(): + from src.qg.checks import QG_CHECKS + assert set(QG_CHECKS.keys()) == { + "check_analysis_approved", "check_analysis_complete", "check_architecture_done", + "check_ci_green", "check_review_approved", "check_tests_passed", + "check_reviewer_verdict", "check_tests_local", "check_deploy_status", + "check_staging_status", "check_branch_mergeable", "check_staging_image_fresh", + } + + +def test_tc23_check_deploy_status_signature_unchanged(): + import inspect + from src.qg.checks import check_deploy_status, QG_CHECKS + # Registry still points at the same callable. + assert QG_CHECKS["check_deploy_status"] is check_deploy_status + # (repo, work_item_id, branch=None) -> tuple[bool, str] contract intact. + params = list(inspect.signature(check_deploy_status).parameters) + assert params == ["repo", "work_item_id", "branch"] diff --git a/tests/test_plane_to_analyse_resume.py b/tests/test_plane_to_analyse_resume.py new file mode 100644 index 0000000..8394c9d --- /dev/null +++ b/tests/test_plane_to_analyse_resume.py @@ -0,0 +1,114 @@ +"""ORCH-066: To Analyse resume semantics (F-1 status-only model). + +`handle_status_start` forks on (existing task?) + (active job?): + + * TC-02 (AC-2, BR-11) — an EXISTING task with NO active job + To Analyse -> + RELAUNCH the current stage's agent (the analyst resumes from Needs Input); + NO second task is created; the issue is re-indicated `Analysis`. + * TC-04 (AC-4) — an EXISTING task WITH an active job + To Analyse -> + busy-guard: NO relaunch (no double launch). + +handle_status_start is exercised directly; enqueue_job + Plane side-effects are +mocked. A real isolated sqlite DB backs get_task_by_plane_id / the job guard. +""" + +import os +import tempfile + +import pytest + +_test_db = os.path.join(tempfile.gettempdir(), "test_orch066_to_analyse_resume.db") +os.environ["ORCH_DB_PATH"] = _test_db +os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir() +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") + +from unittest.mock import patch, AsyncMock, MagicMock # noqa: E402 + +import src.db as _db # noqa: E402 +from src.db import init_db, get_db # noqa: E402 +from src.webhooks.plane import handle_status_start # noqa: E402 + + +@pytest.fixture(autouse=True) +def fresh_db(monkeypatch): + monkeypatch.setattr(_db.settings, "db_path", _test_db) + if os.path.exists(_test_db): + os.unlink(_test_db) + init_db() + yield + if os.path.exists(_test_db): + os.unlink(_test_db) + + +def _make_task(plane_id="resume-1", stage="analysis", repo="enduro-trails", + branch="feature/ET-001-x", wi="ET-001"): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) " + "VALUES (?, ?, ?, ?, ?)", + (plane_id, wi, repo, branch, stage), + ) + tid = cur.lastrowid + conn.commit() + conn.close() + return tid + + +def _count(plane_id): + conn = get_db() + n = conn.execute("SELECT COUNT(*) FROM tasks WHERE plane_id=?", (plane_id,)).fetchone()[0] + conn.close() + return n + + +# --------------------------------------------------------------------------- +# TC-02 (AC-2 / BR-11): existing task, no active job -> RELAUNCH (resume), no dup. +# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_tc02_to_analyse_resume_relaunches_analyst_no_duplicate(): + _make_task("resume-1", stage="analysis") + data = {"id": "resume-1", "state": {"id": "ip-uuid", "name": "To Analyse"}} + + with patch("src.webhooks.plane.enqueue_job", return_value=7) as mock_enqueue, \ + patch("src.webhooks.plane.start_pipeline", new_callable=AsyncMock) as mock_start, \ + patch("src.plane_sync.add_comment", MagicMock()), \ + patch("src.plane_sync.set_issue_analysis") as mock_analysis: + await handle_status_start(data, "proj-1") + + # No new pipeline start (it is a resume, not a fresh task). + mock_start.assert_not_called() + assert _count("resume-1") == 1 # NO duplicate task + # The current stage's agent (analyst) was relaunched exactly once. + assert mock_enqueue.call_count == 1 + assert mock_enqueue.call_args.args[0] == "analyst" + # AC-3: the resumed analysis stage is re-indicated as Analysis. + mock_analysis.assert_called_once_with("ET-001") + + +# --------------------------------------------------------------------------- +# TC-04 (AC-4): existing task WITH active job -> busy-guard, NO relaunch. +# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_tc04_to_analyse_with_active_job_does_not_relaunch(): + tid = _make_task("resume-2", stage="analysis") + # Seed an active (queued) job so has_active_job_for_task reports busy. + conn = get_db() + conn.execute( + "INSERT INTO jobs (agent, repo, task_id, status) VALUES (?, ?, ?, 'queued')", + ("analyst", "enduro-trails", tid), + ) + conn.commit() + conn.close() + + data = {"id": "resume-2", "state": {"id": "ip-uuid", "name": "To Analyse"}} + with patch("src.webhooks.plane.enqueue_job", return_value=9) as mock_enqueue, \ + patch("src.webhooks.plane.start_pipeline", new_callable=AsyncMock) as mock_start, \ + patch("src.plane_sync.add_comment", MagicMock()), \ + patch("src.plane_sync.set_issue_analysis") as mock_analysis: + await handle_status_start(data, "proj-1") + + mock_start.assert_not_called() + mock_enqueue.assert_not_called() # busy-guard held: NO double launch + mock_analysis.assert_not_called() + assert _count("resume-2") == 1 diff --git a/tests/test_plane_webhook.py b/tests/test_plane_webhook.py index 961001f..ec73c30 100644 --- a/tests/test_plane_webhook.py +++ b/tests/test_plane_webhook.py @@ -47,13 +47,18 @@ UNKNOWN_PLANE_ID = "deadbeef-0000-0000-0000-000000000000" _PROJECT_STATES = { ENDURO_PLANE_ID: { "in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967", + # ORCH-066: To Analyse is the start trigger; absent -> aliases in_progress. + "to_analyse": "b873d9eb-993c-48cd-97ac-99a9b1623967", "approved": "a519a341-dada-4a91-8910-7604f82b79c5", "rejected": "ba958f3c-5db5-461d-8f82-89425e413b97", + "cancelled": "b1cae7f9-961d-4889-a179-f3acea697d17", }, ORCH_PLANE_ID: { "in_progress": "e331bfb3-e17e-4699-ba48-4abb89c21b7b", + "to_analyse": "e331bfb3-e17e-4699-ba48-4abb89c21b7b", "approved": "63f2c8fe-dcda-4ace-952f-dd88bd0118ff", "rejected": "4c769e90-bf80-4a52-b97a-e1c84904bfc3", + "cancelled": "59d1d210-8e3a-4a83-930a-cbc5dbf6ad85", }, } @@ -219,3 +224,38 @@ def test_prefixes_independent_per_project(mock_branch, mock_docs, mock_launcher) assert rows["o1"] == "ORCH-001" assert rows["o2"] == "ORCH-002" assert rows["e1"] == "ET-001" + + +# --------------------------------------------------------------------------- +# ORCH-066 TC-15 (AC-15): Cancelled is a valid human exit — the orchestrator +# performs NO advance/rollback (indication, not control). +# --------------------------------------------------------------------------- +@patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) +@patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) +@patch("src.webhooks.plane.launcher") +def test_cancelled_state_does_no_pipeline_action(mock_launcher, mock_start, mock_verdict): + cancelled = _PROJECT_STATES[ORCH_PLANE_ID]["cancelled"] + resp = client.post( + "/webhook/plane", + json={ + "event": "issue", + "action": "updated", + "data": { + "id": "cancel-1", + "name": "A cancelled work item", + "description_stripped": "This is a sufficiently long description.", + "project": ORCH_PLANE_ID, + "state": {"id": cancelled, "name": "Cancelled", "group": "cancelled"}, + }, + }, + ) + assert resp.status_code == 200 + # Neither the start nor the verdict (advance/rollback) handler ran. + mock_start.assert_not_called() + mock_verdict.assert_not_called() + mock_launcher.launch.assert_not_called() + # No task created off a Cancelled transition. + conn = get_db() + task = conn.execute("SELECT * FROM tasks WHERE plane_id='cancel-1'").fetchone() + conn.close() + assert task is None diff --git a/tests/test_post_deploy_integration.py b/tests/test_post_deploy_integration.py index 7e1e8f6..beecc26 100644 --- a/tests/test_post_deploy_integration.py +++ b/tests/test_post_deploy_integration.py @@ -47,6 +47,9 @@ def silence_side_effects(monkeypatch): "send_telegram", "plane_notify_stage", "plane_notify_qg", "plane_add_comment", "set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress", "set_issue_blocked", "set_issue_done", + # ORCH-066 status setters. + "set_issue_analysis", "set_issue_awaiting_deploy", "set_issue_deploying", + "set_issue_monitoring", ): monkeypatch.setattr(stage_engine, name, MagicMock()) @@ -242,6 +245,81 @@ def test_finished_window_tick_is_noop(monkeypatch): probe.assert_not_called() +# --------------------------------------------------------------------------- +# ORCH-066 TC-10 (AC-10): HEALTHY + window exhausted -> Plane state Done. +# --------------------------------------------------------------------------- +def test_orch066_tc10_clean_window_close_sets_done(monkeypatch): + monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True) + monkeypatch.setattr(post_deploy.settings, "post_deploy_window_s", 30) + monkeypatch.setattr(post_deploy.settings, "post_deploy_interval_s", 30) # budget=1 + monkeypatch.setattr( + post_deploy, "probe_signals", + lambda url: post_deploy.ProbeResult(True, 2, 0, "ok"), + ) + task_id = _make_task("done") + post_deploy.write_marker("orchestrator", "ORCH-021", post_deploy.ARMED, "armed") + stage_engine.run_post_deploy_monitor( + {"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "post-deploy-monitor"} + ) + # Clean window close -> terminal Done indicated on Plane; window marked done. + stage_engine.set_issue_done.assert_called_once_with("ORCH-021") + stage_engine.set_issue_blocked.assert_not_called() + assert post_deploy.has_marker("orchestrator", "ORCH-021", post_deploy.DONE) + # No follow-up tick once the window closed. + assert _jobs("post-deploy-monitor") == [] + + +# --------------------------------------------------------------------------- +# ORCH-066 TC-11 (AC-11): DEGRADED -> Plane state Blocked (self-hosting alert). +# --------------------------------------------------------------------------- +def test_orch066_tc11_degraded_sets_blocked(monkeypatch): + monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True) + monkeypatch.setattr(post_deploy.settings, "post_deploy_fail_threshold", 1) + monkeypatch.setattr(post_deploy.settings, "post_deploy_window_s", 30) + monkeypatch.setattr(post_deploy.settings, "post_deploy_interval_s", 30) + monkeypatch.setattr( + post_deploy, "probe_signals", + lambda url: post_deploy.ProbeResult(False, 2, 2, "down"), + ) + monkeypatch.setattr(stage_engine, "_notify_post_deploy", MagicMock()) + task_id = _make_task("done") + post_deploy.write_marker("orchestrator", "ORCH-021", post_deploy.ARMED, "armed") + stage_engine.run_post_deploy_monitor( + {"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "post-deploy-monitor"} + ) + # DEGRADED -> Blocked indication (NOT Done); window finalised. + stage_engine.set_issue_blocked.assert_called_once_with("ORCH-021") + stage_engine.set_issue_done.assert_not_called() + assert post_deploy.has_marker("orchestrator", "ORCH-021", post_deploy.DONE) + + +# --------------------------------------------------------------------------- +# ORCH-066 TC-12 (AC-12): a self-hosting tick NEVER restarts/rolls back prod — +# the Blocked indication is the ONLY mutation (ORCH-021 BR-5 preserved). +# --------------------------------------------------------------------------- +def test_orch066_tc12_self_tick_never_restarts_prod(monkeypatch): + monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True) + monkeypatch.setattr(post_deploy.settings, "post_deploy_auto_rollback", True) + monkeypatch.setattr(post_deploy.settings, "post_deploy_fail_threshold", 1) + monkeypatch.setattr(post_deploy.settings, "post_deploy_window_s", 30) + monkeypatch.setattr(post_deploy.settings, "post_deploy_interval_s", 30) + monkeypatch.setattr( + post_deploy, "probe_signals", + lambda url: post_deploy.ProbeResult(False, 2, 2, "down"), + ) + monkeypatch.setattr(stage_engine, "_notify_post_deploy", MagicMock()) + # The rollback hook (the only restart-capable path) MUST stay untouched for self. + rollback = MagicMock(return_value=(0, "ok")) + monkeypatch.setattr(post_deploy, "run_rollback", rollback) + task_id = _make_task("done") + post_deploy.write_marker("orchestrator", "ORCH-021", post_deploy.ARMED, "armed") + stage_engine.run_post_deploy_monitor( + {"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "post-deploy-monitor"} + ) + rollback.assert_not_called() # never restarts/rolls back the prod self-container + stage_engine.set_issue_blocked.assert_called_once_with("ORCH-021") # indication only + + # --------------------------------------------------------------------------- # TC-20 — /queue observability block # --------------------------------------------------------------------------- diff --git a/tests/test_reconciler.py b/tests/test_reconciler.py index 8e47314..f28489a 100644 --- a/tests/test_reconciler.py +++ b/tests/test_reconciler.py @@ -572,7 +572,7 @@ def test_tc060_08_no_gate_call_on_escalated(monkeypatch): # --------------------------------------------------------------------------- def test_tc060_09_f2_does_not_replay_blocked(monkeypatch): states = { - "in_progress": "IP", "approved": "AP", "rejected": "RJ", + "in_progress": "IP", "to_analyse": "IP", "approved": "AP", "rejected": "RJ", "blocked": "BL", "needs_input": "NI", } monkeypatch.setattr( @@ -680,3 +680,67 @@ def test_tc060_subflag_disables_only_guard2(monkeypatch): assert _stage_of(blocked) == "review" # Guard 2 muted assert _stage_of(escalated) == "development" # Guard 1 still skips + + +# --------------------------------------------------------------------------- +# ORCH-066 TC-21 (AC-20 / BR-13): Guard 2 skips the active orchestrator waits +# (Awaiting Deploy / Deploying / Monitoring after Deploy) ONLY when they are +# DISTINCT statuses — an aliased (enduro) project must NOT widen the skip-set. +# --------------------------------------------------------------------------- +def _guard2(monkeypatch, states, cur_state): + """Drive _is_blocked_or_needs_input with a chosen project state map + the + issue's current Plane state uuid.""" + monkeypatch.setattr(reconciler_mod, "get_project_states", + MagicMock(return_value=states)) + monkeypatch.setattr(reconciler_mod, "fetch_issue_state", + MagicMock(return_value=cur_state)) + monkeypatch.setattr( + reconciler_mod.projects, "get_project_by_repo", + MagicMock(return_value=MagicMock(plane_project_id="proj-test")), + ) + monkeypatch.setattr( + reconciler_mod.settings, "reconcile_skip_blocked_enabled", True + ) + task = {"id": 1, "repo": "orchestrator", "plane_id": "iss-1"} + return Reconciler()._is_blocked_or_needs_input(task) + + +# orchestrator has the three new statuses as DISTINCT UUIDs. +_DISTINCT_STATES = { + "backlog": "bl-u", "todo": "td-u", "in_progress": "ip-u", "in_review": "inrev-u", + "review": "rev-u", "architecture": "arch-u", "development": "dev-u", + "testing": "test-u", "approved": "appr-u", "rejected": "rej-u", "done": "done-u", + "blocked": "blocked-u", "needs_input": "ni-u", + "awaiting_deploy": "await-u", "deploying": "deploying-u", "monitoring": "monitor-u", +} + + +def test_tc21_guard2_skips_distinct_active_waits(monkeypatch): + # Each active-wait status (distinct UUID) -> skipped (not revived). + assert _guard2(monkeypatch, _DISTINCT_STATES, "await-u") is True + assert _guard2(monkeypatch, _DISTINCT_STATES, "deploying-u") is True + assert _guard2(monkeypatch, _DISTINCT_STATES, "monitor-u") is True + # Explicit human gates still skip. + assert _guard2(monkeypatch, _DISTINCT_STATES, "blocked-u") is True + assert _guard2(monkeypatch, _DISTINCT_STATES, "ni-u") is True + # A normal working state is NOT skipped (gets reconciled). + assert _guard2(monkeypatch, _DISTINCT_STATES, "ip-u") is False + + +def test_tc21_guard2_aliased_waits_do_not_widen_skipset(monkeypatch): + # enduro: the new keys alias onto base working statuses -> they must NOT make + # F-1 skip a genuinely In Progress / In Review / Done task (anti-regress). + aliased = { + "backlog": "bl-u", "todo": "td-u", "in_progress": "ip-u", "in_review": "inrev-u", + "review": "rev-u", "architecture": "arch-u", "development": "dev-u", + "testing": "test-u", "approved": "appr-u", "rejected": "rej-u", "done": "done-u", + "blocked": "blocked-u", "needs_input": "ni-u", + # aliased onto base UUIDs (project did not create dedicated statuses). + "awaiting_deploy": "inrev-u", "deploying": "ip-u", "monitoring": "done-u", + } + # In Progress / In Review / Done are base working states -> NOT skipped. + assert _guard2(monkeypatch, aliased, "ip-u") is False + assert _guard2(monkeypatch, aliased, "inrev-u") is False + assert _guard2(monkeypatch, aliased, "done-u") is False + # The explicit human gates still skip. + assert _guard2(monkeypatch, aliased, "blocked-u") is True diff --git a/tests/test_reconciler_plane.py b/tests/test_reconciler_plane.py index e68d498..8f03475 100644 --- a/tests/test_reconciler_plane.py +++ b/tests/test_reconciler_plane.py @@ -59,6 +59,9 @@ def single_project(monkeypatch): reconciler_mod, "get_project_states", lambda pid: { "in_progress": _IN_PROGRESS, + # ORCH-066: To Analyse is the F-2 start/resume trigger; absent in this + # project -> aliases in_progress (real get_project_states fallback). + "to_analyse": _IN_PROGRESS, "approved": _APPROVED, "rejected": _REJECTED, }, @@ -114,6 +117,46 @@ def test_tc11_in_progress_without_task_starts_pipeline(monkeypatch, single_proje verdict.assert_not_called() +# --------------------------------------------------------------------------- +# ORCH-066 TC-20 (AC-19): F-2 polls the DISTINCT To Analyse status and routes it +# to handle_status_start (a lost start/resume webhook is recovered). +# --------------------------------------------------------------------------- +def test_tc20_distinct_to_analyse_polled_and_routed(monkeypatch): + _TO_ANALYSE = "uuid-to-analyse" # distinct from in_progress + monkeypatch.setattr( + reconciler_mod, "get_project_states", + lambda pid: { + "in_progress": _IN_PROGRESS, + "to_analyse": _TO_ANALYSE, # dedicated status created + "approved": _APPROVED, + "rejected": _REJECTED, + }, + ) + monkeypatch.setattr( + reconciler_mod.projects, "PROJECTS", + [SimpleNamespace(plane_project_id="proj-1", repo="enduro-trails", + work_item_prefix="ET")], + ) + start, verdict = _patch_handlers(monkeypatch) + + polled = {} + + def fake_list(pid, states): + polled["states"] = list(states) + return [{"id": "iss-ta", "state": {"id": _TO_ANALYSE}, "updated_at": _OLD_TS, + "name": "Lost start"}] + + monkeypatch.setattr(reconciler_mod, "list_issues_by_state", fake_list) + + Reconciler().reconcile_plane_once() + + # The To Analyse UUID is in the polled set and routed to start (not verdict). + assert _TO_ANALYSE in polled["states"] + assert start.call_count == 1 + assert start.call_args.args[0]["id"] == "iss-ta" + verdict.assert_not_called() + + # --------------------------------------------------------------------------- # TC-12: Approved with an existing task, no active job -> handle_verdict(True). # --------------------------------------------------------------------------- @@ -279,7 +322,10 @@ def test_tc17_polls_all_projects_resolves_states_per_project(monkeypatch): def fake_states(pid): states_calls.append(pid) - return {"in_progress": _IN_PROGRESS, "approved": _APPROVED, "rejected": _REJECTED} + return { + "in_progress": _IN_PROGRESS, "to_analyse": _IN_PROGRESS, + "approved": _APPROVED, "rejected": _REJECTED, + } def fake_issues(pid, states): issues_calls.append((pid, tuple(states))) diff --git a/tests/test_stage_visibility.py b/tests/test_stage_visibility.py index a41f5c7..d7be813 100644 --- a/tests/test_stage_visibility.py +++ b/tests/test_stage_visibility.py @@ -68,10 +68,18 @@ def test_set_issue_stage_state_patches_correct_uuid(mock_proj, mock_find, mock_p @patch("src.plane_sync.httpx.patch") @patch("src.plane_sync.find_issue_id", return_value="issue-uuid") @patch("src.plane_sync._resolve_project_id", return_value="proj-1") -def test_set_issue_stage_state_noop_for_analysis(mock_proj, mock_find, mock_patch): - # analysis has no dedicated board status -> no PATCH at all. +def test_set_issue_stage_state_noop_for_deploy(mock_proj, mock_find, mock_patch): + # ORCH-066: analysis now HAS a dedicated status (Analysis) -> it PATCHes. + # deploy still has no board status here (driven by Phase A/B/C) -> no-op. + resp = MagicMock() + resp.raise_for_status.return_value = None + mock_patch.return_value = resp + PS.set_issue_stage_state("ET-1", "analysis") - mock_patch.assert_not_called() + # analysis aliases in_progress when the Analysis status is absent. + assert mock_patch.call_args.kwargs["json"]["state"] == PS.PLANE_STATES["analysis"] + + mock_patch.reset_mock() PS.set_issue_stage_state("ET-1", "deploy") mock_patch.assert_not_called() From 62c1fe3461551009f486e0538dcf349504dd8482 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 21:57:56 +0000 Subject: [PATCH 5/8] reviewer(ET): auto-commit from reviewer run_id=342 --- docs/work-items/ORCH-066/12-review.md | 89 +++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 docs/work-items/ORCH-066/12-review.md diff --git a/docs/work-items/ORCH-066/12-review.md b/docs/work-items/ORCH-066/12-review.md new file mode 100644 index 0000000..92186ec --- /dev/null +++ b/docs/work-items/ORCH-066/12-review.md @@ -0,0 +1,89 @@ +--- +type: review +work_item_id: ORCH-066 +verdict: APPROVED +version: 1 +--- + +# Review ORCH-066 + +## Summary +Осмысленная статусная модель Plane (слой B — индикация). Реализация затрагивает +строго слой B (`src/plane_sync.py`, точки простановки в `src/stage_engine.py` / +`src/webhooks/plane.py` / `src/reconciler.py`) и **не трогает слой A** +(`src/stages.py::STAGE_TRANSITIONS` — diff пуст). Все 4 оси проверки (ТЗ, ADR, +качество кода, тесты) и проверка документации — пройдены. `pytest tests/ -q`: +**774 passed**. Вердикт — **APPROVED**. + +## Соответствие ТЗ (02-trz.md) +- §2.1 — 6 новых логических ключей в `_PLANE_NAME_TO_KEY` + `_DEFAULT_STATES`. ✔ +- §2.2 — fail-closed резолюция (BR-12). ✔ (реализована усиленная project-relative + версия — см. ADR ниже). +- §2.3 — `_STAGE_TO_STATE_KEY` (analysis→analysis, review→code_review), + `STAGE_VISIBILITY_STATE`, legacy `STAGE_TO_STATE` (UUID байт-в-байт прежние). ✔ +- §2.4 — точки простановки разведены (handle_issue_updated триггер `to_analyse`, + start_pipeline/resume → Analysis, Phase A → Awaiting Deploy, Phase B → Deploying, + terminal-sync split, post-deploy HEALTHY→Done / DEGRADED→Blocked, + rollback@analysis → Analysis). ✔ +- §2.5 — 5 новых never-raise хелперов `set_issue_*`. ✔ +- §3 — reconciler F-2 триггер `to_analyse` (+ resume-ветка), Guard 2 skip-set с + вычитанием base_working. ✔ +- §4/§5/§6 — нет новых эндпоинтов, нет миграций БД, `QG_CHECKS` не расширен. ✔ + +## Соответствие ADR (06-adr/ADR-001) +- §2.2 project-relative alias-fallback (`_STATE_ALIAS_FALLBACK`, применён ДО + `_DEFAULT_STATES.setdefault`) — реализован точно по контракту, деградация на + собственный базовый UUID проекта, PATCH остаётся валидным на частичной + конфигурации. ✔ +- §2.5 terminal-sync split по `post_deploy.post_deploy_applies(repo)` — реализован + как в ADR (self → Monitoring, не-self → Done). ✔ +- §2.6 Guard 2 анти-регресс (extra_waits − base_working − {None}) — реализован + дословно, enduro-алиасы схлопываются → нулевой регресс. ✔ +- §2.7 без kill-switch — config.py не изменён (diff пуст). ✔ + +## Качество кода +- Все новые `set_issue_*` следуют образцу `set_issue_in_review` (per-project резолв + + `_set_issue_state_direct`), контракт never-raise сохранён, есть docstrings. ✔ +- Post-deploy/terminal-sync простановки обёрнуты в try/except с warning-логом + (never break the tick). ✔ +- Переменные в scope корректны (`work_item_id` определён до всех новых вызовов в + `start_pipeline`/`handle_status_start`/stage_engine). ✔ +- AC-12 соблюдён: `set_issue_blocked` в DEGRADED-ветке — только индикация, тик + прод-контейнер не трогает. ✔ + +## Качество тестов +- Содержательные, не тривиальные: `test_plane_status_failclosed.py` + (TC-16/17/18 — partial project, API down, never-raise сеттеров, enduro alias + старт), `test_plane_to_analyse_resume.py`, `test_plane_status_model.py`, + `test_deploy_terminal_sync.py` (self/не-self split), `test_post_deploy_integration.py`, + `test_reconciler*.py` (F-2 to_analyse + Guard 2). ✔ + +## Инварианты (AC-21/AC-22) +- `src/stages.py` — diff 0 строк (STAGE_TRANSITIONS байт-в-байт). ✔ +- `src/qg/checks.py` — diff 0 строк (QG_CHECKS, check_deploy_status). ✔ +- `src/config.py` — diff 0 строк. ✔ +- Схема БД — без миграций. ✔ + +## Findings + +### P0 — Blocker +- нет + +### P1 — Must fix +- нет + +### P2 — Should fix +- нет + +## Документация +Обновлена в том же PR (golden source соблюдён): +- `CLAUDE.md` — добавлена секция «Статусная модель Plane (ORCH-066)». ✔ +- `docs/architecture/README.md` — секция «Осмысленная статусная модель Plane + (ORCH-066)» + обновлён статусный footer. ✔ +- `CHANGELOG.md` — подробная запись в [Unreleased]/Added. ✔ +- `06-adr/ADR-001-plane-status-model.md` — заведён. ✔ +- `07-infra-requirements.md` — присутствует (инфра-предусловие: 6 Plane-статусов + создаёт оператор). ✔ + +Изменения `src/` полностью отражены в документации → требование +«документация обновлена при изменении src/» выполнено. From bc2347abd3f8f44ae89edbd09830a6dd801a28e2 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 21:59:41 +0000 Subject: [PATCH 6/8] tester(ET): auto-commit from tester run_id=343 --- docs/work-items/ORCH-066/13-test-report.md | 77 ++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 docs/work-items/ORCH-066/13-test-report.md diff --git a/docs/work-items/ORCH-066/13-test-report.md b/docs/work-items/ORCH-066/13-test-report.md new file mode 100644 index 0000000..9eb8b83 --- /dev/null +++ b/docs/work-items/ORCH-066/13-test-report.md @@ -0,0 +1,77 @@ +--- +type: test-report +work_item_id: ORCH-066 +result: PASS +--- + +# Test Report — ORCH-066 + +Осмысленная статусная модель Plane (слой B — индикация). Прогон полного регресса + +покрытие тест-плана `04-test-plan.yaml` + проверка инвариантов слоя A. + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 +- Ветка: feature/ORCH-066-plane +- Дата: 2026-06-07 + +## Результаты по тест-плану (04-test-plan.yaml) + +| TC ID | Покрывает | Описание | Модуль | Результат | +|-------|-----------|----------|--------|-----------| +| TC-01 | AC-1 | To Analyse без task → start_pipeline | test_status_trigger.py | PASS | +| TC-02 | AC-2,BR-11 | To Analyse resume аналитика, без двойного task | test_plane_to_analyse_resume.py | PASS | +| TC-03 | AC-3 | Старт/relaunch → статус Analysis | test_plane_status_model.py | PASS | +| TC-04 | AC-4 | Busy-guard: active-job → не relaunch | test_plane_to_analyse_resume.py | PASS | +| TC-05 | AC-5 | review → статус Code-Review | test_plane_status_model.py | PASS | +| TC-06 | AC-6,AC-13 | Phase A → Awaiting Deploy (не In Review) | test_deploy_approve.py | PASS | +| TC-07 | AC-7 | Phase B → Deploying | test_deploy_approve.py | PASS | +| TC-08 | AC-8 | Phase C self → Monitoring after Deploy | test_deploy_terminal_sync.py | PASS | +| TC-09 | AC-9 | Не-self deploy→done → Done (без регресса) | test_deploy_terminal_sync.py | PASS | +| TC-10 | AC-10 | Post-deploy HEALTHY → Done | test_post_deploy.py | PASS | +| TC-11 | AC-11 | Post-deploy DEGRADED → Blocked | test_post_deploy.py | PASS | +| TC-12 | AC-12 | Self-тик не рестартит прод | test_post_deploy.py | PASS | +| TC-13 | AC-13 | In Review только за approve-pending | test_analyst_status_only_regression.py | PASS | +| TC-14 | AC-14,BR-10 | Needs Input без изменений | test_plane_status_model.py | PASS | +| TC-15 | AC-15 | Cancelled → нет действий конвейера | test_plane_webhook.py | PASS | +| TC-16 | AC-16,BR-12 | Fail-closed default-алиасы, нет исключений | test_plane_status_failclosed.py | PASS | +| TC-17 | AC-16 | Plane API down → fallback, never-raise | test_plane_status_failclosed.py | PASS | +| TC-18 | AC-17 | enduro In Progress стартует через алиас | test_plane_status_failclosed.py | PASS | +| TC-19 | AC-18 | Резолв по имени → корректный UUID | test_orch10_states.py | PASS | +| TC-20 | AC-19 | F-2 реконсилирует To Analyse | test_reconciler_plane.py | PASS | +| TC-21 | AC-20,BR-13 | Guard 2 skip активных ожиданий | test_reconciler.py | PASS | +| TC-22 | AC-21 | STAGE_TRANSITIONS не изменён | test_plane_status_model.py | PASS | +| TC-23 | AC-22 | QG_CHECKS/check_deploy_status не изменены | test_plane_status_model.py | PASS | +| TC-24 | AC-23 | Полный регресс pytest зелёный | tests/ | PASS | + +Все 24 тест-кейса — PASS. + +## Инварианты слоя A (AC-21 / AC-22) +Diff против `origin/main` (merge-base `4815e378`): +- `src/stages.py` (STAGE_TRANSITIONS) — diff пуст ✔ +- `src/qg/checks.py` (QG_CHECKS, check_deploy_status) — diff пуст ✔ +- `src/config.py` (без kill-switch) — diff пуст ✔ + +## Smoke test API (TestClient — прод-контейнер 8500 не трогался) +> `curl` в окружении недоступен; smoke прогнан через FastAPI TestClient (lifespan), +> без рестарта/обращения к прод-контейнеру (self-hosting safety). + +| Endpoint | Статус | Тело (фрагмент) | +|----------|--------|-----------------| +| GET /health | 200 | `{"status":"ok","service":"orchestrator"}` | +| GET /status | 200 | `{"active_tasks":[...]}` | +| GET /queue | 200 | `{"counts":{...},"max_concurrency":1,...}` | + +## Вывод pytest +``` +======================= 774 passed, 1 warning in 17.68s ======================== +``` +(единственный warning — PydanticDeprecatedSince20 в src/config.py, предсуществующий, +не связан с ORCH-066) + +Прогон по модулям тест-плана: `117 passed` (ORCH-066-специфичные файлы). + +## Итог +PASS — все тесты зелёные (774 passed), все 24 TC покрыты, инварианты слоя A +сохранены (diff пуст), smoke-эндпоинты отвечают 200. Review-вердикт APPROVED. +Задача готова к переходу на стадию deploy-staging. From 348cf8c16444f05d3c7db1f9c1557d5f9b27b0ae Mon Sep 17 00:00:00 2001 From: deploy-finalizer Date: Sun, 7 Jun 2026 22:18:32 +0000 Subject: [PATCH 7/8] deploy(ORCH-036): finalize SUCCESS for ORCH-066 --- docs/work-items/ORCH-066/14-deploy-log.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 docs/work-items/ORCH-066/14-deploy-log.md diff --git a/docs/work-items/ORCH-066/14-deploy-log.md b/docs/work-items/ORCH-066/14-deploy-log.md new file mode 100644 index 0000000..d72b4a7 --- /dev/null +++ b/docs/work-items/ORCH-066/14-deploy-log.md @@ -0,0 +1,12 @@ +--- +deploy_status: SUCCESS +work_item: ORCH-066 +hook_exit_code: 0 +deployed_by: deploy-finalizer +--- + +# Deploy log — ORCH-036 executable self-deploy + +Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`. + +Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM. From 72d662ae889a7495120672b70ccf8cfe1a2d60da Mon Sep 17 00:00:00 2001 From: post-deploy-monitor Date: Sun, 7 Jun 2026 22:33:36 +0000 Subject: [PATCH 8/8] docs(ORCH-021): post-deploy HEALTHY/NONE for ORCH-066 --- docs/work-items/ORCH-066/16-post-deploy-log.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 docs/work-items/ORCH-066/16-post-deploy-log.md diff --git a/docs/work-items/ORCH-066/16-post-deploy-log.md b/docs/work-items/ORCH-066/16-post-deploy-log.md new file mode 100644 index 0000000..569090f --- /dev/null +++ b/docs/work-items/ORCH-066/16-post-deploy-log.md @@ -0,0 +1,14 @@ +--- +post_deploy_status: HEALTHY +action_taken: NONE +work_item: ORCH-066 +window_s: 900 +checks_total: 30 +checks_failed: 0 +--- + +# Post-deploy log — ORCH-021 post-deploy monitor + +Наблюдение прода завершено: `post_deploy_status: HEALTHY`, `action_taken: NONE`. + +Окно наблюдения: 900s; опросов всего: 30, из них с провалом: 0.