Compare commits

..

8 Commits

Author SHA1 Message Date
post-deploy-monitor
9f4d79baee docs(ORCH-021): post-deploy HEALTHY/NONE for ORCH-059
All checks were successful
CI / test (push) Successful in 18s
CI / test (pull_request) Successful in 19s
2026-06-07 19:44:39 +00:00
deploy-finalizer
7cdef6d377 deploy(ORCH-036): finalize SUCCESS for ORCH-059
All checks were successful
CI / test (push) Successful in 18s
CI / test (pull_request) Successful in 18s
2026-06-07 19:29:34 +00:00
ca41d9210b tester(ET): auto-commit from tester run_id=337
All checks were successful
CI / test (push) Successful in 20s
CI / test (pull_request) Successful in 17s
2026-06-07 19:20:41 +00:00
48943fe10a reviewer(ET): auto-commit from reviewer run_id=336 2026-06-07 19:20:41 +00:00
86fe8dd509 feat(deploy): dedicated "Confirm Deploy" status triggers prod deploy
Split the overloaded `Approved` Plane status: it served BOTH as the human BRD
gate on `analysis` AND as the silent Phase B prod-deploy trigger on `deploy`
(ORCH-036), so a routine approve could launch a self-hosting prod restart.

ORCH-059 introduces a dedicated logical status `confirm_deploy` ("Confirm
Deploy") that triggers ONLY Phase B on `deploy`; `Approved` stays purely a
pipeline gate.

- plane_sync: map "Confirm Deploy" -> "confirm_deploy" in _PLANE_NAME_TO_KEY;
  intentionally absent from _DEFAULT_STATES => fail-closed (no UUID -> .get
  yields None, no KeyError, no blind deploy).
- webhooks/plane: handle_issue_updated routes "Confirm Deploy" (fail-closed
  .get) to new handle_confirm_deploy (guarded to stage=="deploy") ->
  _try_advance_stage(confirm_deploy=True).
- stage_engine: advance_stage gains keyword-only confirm_deploy=False; Phase B
  block returns early for deploy+finished_agent is None but only initiates the
  deploy when confirm_deploy=True; a plain Approved is a deterministic no-op
  (returns before check_deploy_status -> no false БАГ-8 rollback).
- Phase A CTA now asks the operator for "Confirm Deploy", not "Approved".

Contracts unchanged: STAGE_TRANSITIONS, QG_CHECKS, check_deploy_status, hook
exit codes, Phases A/C, merge-gate, DB schema. Conditional like ORCH-35/36
(self-hosting only). Docs updated (CLAUDE.md, architecture/README.md, CHANGELOG).

Refs: ORCH-059

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 19:20:41 +00:00
dd07b58165 architect(ET): auto-commit from architect run_id=334 2026-06-07 19:20:41 +00:00
b67a61ecef analyst(ET): auto-commit from analyst run_id=333 2026-06-07 19:20:41 +00:00
8fcb867dcf docs: init ORCH-059 business request 2026-06-07 19:20:41 +00:00
41 changed files with 1620 additions and 1457 deletions

View File

@@ -117,15 +117,6 @@ ORCH_RECONCILE_GRACE_OVERRIDES_JSON=
ORCH_RECONCILE_NOTIFY_UNBLOCK=true
ORCH_RECONCILE_SKIP_BLOCKED_ENABLED=true
# ORCH-068: TTL (seconds) for the per-project Plane states cache (plane_sync
# _STATES_CACHE). Historically the cache lived for the whole process lifetime,
# so a status added to Plane after start was invisible until a restart
# ("stale set -> no pipeline action"). With a TTL the entry self-heals by
# re-fetching /states/ once it expires (reuses reload_project_states()).
# >0 -> re-fetch after this many seconds (default 300 = 5 min);
# 0 -> disable TTL -> strictly the previous lifetime cache (back-compat).
ORCH_PLANE_STATES_TTL_S=300
# ORCH-065: job-reaper + proactive merge-lease reclaim. A background daemon thread
# (src/job_reaper.py, started LAST in main.lifespan after requeue_running_jobs) reaps
# zombie 'running' jobs whose monitor/process died before writing the terminal status

File diff suppressed because one or more lines are too long

View File

@@ -64,6 +64,10 @@ created → analysis → architecture → development → review → testing →
- **НЕ перезапускать / не ронять прод-контейнер** `orchestrator` в рамках задачи — встанет конвейер всех проектов.
- Любой деплой/рестарт self = групповой риск. Детали и топология — `docs/operations/INFRA.md`.
- Стадия `deploy-staging` (порт 8501) — обязательная страховка перед прод-деплоем орка.
- Прод-деплой орка запускается ТОЛЬКО переводом задачи на стадии `deploy` в выделенный
Plane-статус **«Confirm Deploy»** (ORCH-059). Статус `Approved` — человеческий гейт
конвейера и прод-деплой НЕ запускает (на `deploy` — no-op). Это разделяет «одобрить
артефакт» и «выкатить в прод», чтобы привычный approve не ронял прод случайным кликом.
---
*Паспорт проекта orchestrator. Поддерживается агентами при каждой доработке. Изолирован: описывает только этот проект (канон per-repo, см. ORCH-9).*

View File

@@ -14,7 +14,7 @@
- **Job-reaper** (`src/job_reaper.py`, ORCH-065 — [adr-0011](adr/adr-0011-job-reaper-lease-reclaim.md)) — фоновый daemon-поток (каркас `reconciler`), стартует/останавливается в `main.lifespan` (после `reconciler.start()` / перед `worker.stop()`). Детектирует «мёртвый» `running`-job **без рестарта** процесса (Tier-1 мёртвый `jobs.pid` после `reaper_dead_ticks` тиков; Tier-2 `agent_runs.exit_code` записан, а job ещё `running`; Tier-3 backstop `reaper_max_running_s`) и приводит строку к корректному статусу через те же контракты (`_try_advance_stage`/`_finalize_job`, gate-driven; exit≠0/неизвестно → `attempts<max``queued`, иначе `failed`+Telegram). Атомарный reap-claim (guard `status='running'`) совместим со стартовым `requeue_running_jobs`. Тот же поток периодически делает проактивный реклейм stale/dead merge-lease (см. ниже). never-raise; kill-switch `ORCH_REAPER_ENABLED`; снимок в `GET /queue` (блок `reaper`).
- **Reconciler** (`src/reconciler.py`, ORCH-053 — реализовано, [adr-0007](adr/adr-0007-reconciler.md)) — фоновый daemon-поток (паттерн `queue_worker`), стартует/останавливается в `main.lifespan` (после `worker.start()` / перед `worker.stop()`). Реконсилирует рассинхрон «источник истины ≠ стадия задачи» при потерянном webhook. F-1 gate-side (продвигает застрявшую стадию по локальной БД через штатный `advance_stage(..., finished_agent=None)`), F-2 plane-side (опрос Plane API → `handle_*` из `plane.py`), F-3 (БД-fallback `sha→branch` в `handle_ci_status`). Источник истины — гейт/Plane, не событие; идемпотентность (active-job guard + atomic-claim + grace); kill-switch `ORCH_RECONCILE_ENABLED`. `analysis` F-1 не трогает (человеческий гейт). F-1 также пропускает escalated (retry≥лимита) и Blocked/Needs-Input задачи (ORCH-060). Наблюдаемость — блок `reconcile` в `GET /queue`.
- **Project Registry** (`src/projects.py`, ORCH-6) — Plane project id → repo + prefix; фильтрация вебхуков по проекту.
- **Plane Sync** (`src/plane_sync.py`) — синхронизация статусов/комментариев в Plane. Резолв статусов проекта `get_project_states` (ORCH-10) кэширует `{logical_key→uuid}` per-project; **ORCH-068** добавляет в кэш-запись `{uuid→group}` (для терминал-исключения F-2) и **TTL** `ORCH_PLANE_STATES_TTL_S` (дефолт 300с; `0` → прежний lifetime-кэш) — устаревший набор статусов самозалечивается без рестарта процесса через существующий `reload_project_states()` (баг кэша после появления нового Plane-статуса). Форма возврата `get_project_states` неизменна (обратная совместимость).
- **Plane Sync** (`src/plane_sync.py`) — синхронизация статусов/комментариев в Plane.
## Конвейер и Quality Gates
@@ -70,21 +70,25 @@ Self-hosting зацикливался на `deploy-staging`: `scripts/staging_ch
а `deploy_status: SUCCESS` означает доказанный health-ok, не декларацию LLM. Три фазы
(детерминированно, без LLM в критическом пути self-restart):
- **Фаза A (вход в `deploy`)** — при `deploy_require_manual_approve=true` вместо запуска
прод-deployer выставляется approval-pending статус Plane + запрос approve
(Plane-коммент + Telegram). Перехват в `advance_stage` ПОСЛЕ `check_staging_status`
и merge-gate.
- **Фаза B (Plane → `Approved`)** — `advance_stage(deploy, finished_agent=None)`
прод-deployer выставляется approval-pending статус Plane + запрос перевести задачу
в статус **«Confirm Deploy»** (ORCH-059; Plane-коммент + Telegram). Перехват в
`advance_stage` ПОСЛЕ `check_staging_status` и merge-gate.
- **Фаза B (Plane → `Confirm Deploy`, ORCH-059)** —
`advance_stage(deploy, finished_agent=None, confirm_deploy=True)`
запускает **detached host-процесс** (ssh + setsid → хук с прод-параметрами +
build-once retag `SOURCE_IMAGE`) и ставит детерминированный **finalizer-job**;
маркер `initiated` — идемпотентность. Возврат БЕЗ advance (вердикта ещё нет).
Обычный `Approved` на `deploy` (`confirm_deploy=False`) — детерминированный no-op
(не деплоит и не откатывает).
- **Фаза C (finalizer)** — новый контейнер после рестарта читает sentinel `result`
(exit-code хука), маппит `0→SUCCESS / иначе→FAILED`, пишет `14-deploy-log.md`,
вызывает `advance_stage(deploy, finished_agent="deployer")` → существующие контракты:
`SUCCESS → done`, `FAILED → откат БАГ-8 на development`.
Approve = смена статуса Plane на `Approved` (status-only verdict model; комментарии
не управляют конвейером). На старте — обязательный ручной approve (флаг `true`); полный
авто — отдельная задача (ORCH-54). Условность как ORCH-35: реально для `orchestrator`,
Триггер прод-деплоя = смена статуса Plane на `Confirm Deploy` (ORCH-059; status-only
verdict model; комментарии не управляют конвейером). `Approved` остаётся исключительно
человеческим гейтом конвейера и прод-деплой не запускает. На старте — обязательный
ручной approve (флаг `true`); полный авто — отдельная задача (ORCH-54). Условность как ORCH-35: реально для `orchestrator`,
прочие репо — прежний синхронный ssh-деплой агентом. Контракты не меняются:
`STAGE_TRANSITIONS`, реестр QG, `check_deploy_status`/`_parse_deploy_status`, БАГ-8,
terminal-sync, merge-gate, exit-code-контракт хука. Restart-safe состояние —
@@ -92,6 +96,31 @@ sentinel-файлы (`<repos_dir>/.deploy-state-<repo>/<wi>/`), без мигр
Подробнее: [adr-0007](adr/adr-0007-executable-self-deploy.md), детально —
`docs/work-items/ORCH-036/06-adr/ADR-001-executable-self-deploy.md`.
#### Выделенный статус-триггер прод-деплоя «Confirm Deploy» (ORCH-059 — реализовано)
Перегрузка: один Plane-статус `Approved` служил И человеческим гейтом BRD на
`analysis` (`check_analysis_approved`), И триггером Фазы B прод-деплоя на `deploy`
— привычный жест approve молча запускал прод-рестарт (групповой self-hosting
риск). ORCH-059 разделяет жесты: вводится отдельный логический статус
`confirm_deploy` («Confirm Deploy»), который триггерит **ТОЛЬКО** Фазу B на
`deploy`; `Approved` остаётся исключительно гейтом конвейера.
- `_PLANE_NAME_TO_KEY` += `"Confirm Deploy" → "confirm_deploy"`; в
`_DEFAULT_STATES` ключ НЕ добавляется (нет UUID для enduro/fallback) →
**fail-closed**: нет статуса → нет деплоя, без `KeyError` (доступ через `.get`).
- `handle_issue_updated` маршрутизирует `Confirm Deploy``handle_confirm_deploy`
(гард `stage=="deploy"`) → `_try_advance_stage(..., confirm_deploy=True)`.
- `advance_stage` получает kwarg `confirm_deploy: bool=False`; блок Фазы B
(`deploy`+`finished_agent is None`+self-hosting) деплоит ТОЛЬКО при
`confirm_deploy=True`, иначе (обычный `Approved`) — **no-op** (`check_deploy_status`
не запускается → нет ложного отката БАГ-8).
- CTA Фазы A (`_handle_self_deploy_phase_a`) просит «Confirm Deploy», не «Approved».
- Условность как ORCH-35/36 (только `orchestrator`); Фазы A/C, `STAGE_TRANSITIONS`,
`QG_CHECKS`, `check_deploy_status`, merge-gate, схема БД — без изменений.
- Эксплуатация: в Plane-проекте ORCH создать статус «Confirm Deploy» + сброс кэша
состояний (`docs/work-items/ORCH-059/07-infra-requirements.md`).
Детально — `docs/work-items/ORCH-059/06-adr/ADR-001-confirm-deploy-status.md`
(уточняет/триггер Фазы B относительно adr-0007).
### Post-deploy наблюдение прода + реакция на деградацию (ORCH-021 — реализовано)
Конвейер заканчивался на `deploy → done` и **забывал про прод**: «успех» = health-check
в момент рестарта (~60с). Класс «зелёный деплой, красный прод» (прецедент ET-8 —
@@ -174,21 +203,11 @@ helper `validated_revision` питает и штамп A, и `EXPECTED_REVISION`
retry-count проверяется первым (дёшево, локальный SQL).
- **F-2 plane-side:** опрос Plane API per-project → `handle_status_start` /
`handle_verdict` из `webhooks/plane.py` (логика не дублируется).
**ORCH-068 (livelock-fix):** (1) задачи в **терминальной группе** Plane
(`state.group ∈ {completed, cancelled}`, fallback — логические ключи
`done`/`cancelled`) исключаются из actionable-выборки per-issue — проектно-независимо,
устойчиво к UUID-алиасингу после переименований статусов (ORCH-066); (2) `_note_unblock`
(лог + Telegram + `unblocked_total`) вызывается ТОЛЬКО при **подтверждённом state change**
(сравнение стадии задачи до/после `_dispatch`; no-op dispatch → тишина), плюс in-memory
дедуп по `issue_id→state`. Восстанавливает инвариант silence-when-in-sync (AC-9/AC-10).
Детали — `docs/work-items/ORCH-068/06-adr/ADR-001-reconciler-terminal-exclusion-and-cache-ttl.md`.
- **F-3:** усиление `sha→branch` в `handle_ci_status` (БД-fallback по единственной
development-задаче repo; неоднозначность → не резолвим).
- **F-4 observability:** при разблокировке — лог-строка `reconciler: <wi> <stage>
разблокирована (потерян webhook)` + Telegram (`reconcile_notify_unblock`); снимок
состояния в `GET /queue` (блок `reconcile`). **ORCH-068** добавляет в снимок
счётчики `skipped_terminal_total` (исключённые терминалы) и `deduped_total`
(подавленные повторные нотификации).
состояния в `GET /queue` (блок `reconcile`).
Реализация: `src/reconciler.py` (daemon-поток по образцу `queue_worker`), стартует в
`main.lifespan` **после** `worker.start()`, останавливается в `finally` **перед**
@@ -316,4 +335,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`; обновлять также при изменении этих мест); ORCH-068 (livelock-fix reconciler F-2: терминал-исключение по группе состояния + `_note_unblock` только при подтверждённом state change + дедуп; TTL `_STATES_CACHE`, `docs/work-items/ORCH-068/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-068 (D1 терминал-гард по группе `_is_terminal_state` + `get_project_state_groups` в src/plane_sync.py; D2 сравнение стадии до/после `_dispatch` + дедуп-словарь в src/reconciler.py; TTL-запись `_STATES_CACHE` + флаг `plane_states_ttl_s` в src/config.py; счётчики `skipped_terminal_total`/`deduped_total` в `/queue`; обновлять также при изменении src/reconciler.py F-2, src/plane_sync.py `get_project_states`/`get_project_state_groups`/`_STATES_CACHE`).*
*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест); ORCH-059 (выделенный статус-триггер прод-деплоя «Confirm Deploy», ADR `docs/work-items/ORCH-059/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-059 (маппинг `"Confirm Deploy"→"confirm_deploy"` в src/plane_sync.py `_PLANE_NAME_TO_KEY`, НЕ в `_DEFAULT_STATES` = fail-closed; ветка `handle_confirm_deploy` + fail-closed `.get("confirm_deploy")` в src/webhooks/plane.py `handle_issue_updated`; keyword-only `confirm_deploy` в src/stage_engine.py `advance_stage` — Фаза B деплоит ТОЛЬКО при `confirm_deploy=True`, иначе `Approved`-на-`deploy` = no-op; CTA Фазы A просит «Confirm Deploy»; эксплуатация — статус доски «Confirm Deploy» в Plane-проекте ORCH, `docs/work-items/ORCH-059/07-infra-requirements.md`).*

View File

@@ -69,15 +69,6 @@ grace + `max_concurrency=1`); never-raise на единицу работы; ти
задачи. Инварианты adr-0007 сохранены (схема/реестры не меняются, never-raise,
тишина при пропуске).
- **ORCH-068** (`docs/work-items/ORCH-068/06-adr/ADR-001-reconciler-terminal-exclusion-and-cache-ttl.md`):
фикс livelock F-2 (спам `_note_unblock` по синхронизированной done-задаче после
ORCH-066). F-2 исключает терминалы по **группе состояния** (`completed`/`cancelled`,
fallback — ключи `done`/`cancelled`) проектно-независимо; `_note_unblock` — только при
подтверждённом state change (сравнение стадии до/после `_dispatch`) + in-memory дедуп;
`_STATES_CACHE` получает TTL (`ORCH_PLANE_STATES_TTL_S`, дефолт 300с, `0`=lifetime).
Инварианты adr-0007 сохранены (источник истины — Plane; реестры/схема/`handle_*`/F-1/F-3
не меняются; never-raise; kill-switch'и).
## Связи
adr-0002 (очередь / `available_at`, single-process-singleton), adr-0003 (условный
гейт — образец условности/флагов раската), adr-0006 (merge-gate как под-гейт ребра

View File

@@ -0,0 +1,7 @@
# Business Request: Approve деплоя через статус Confirm Deploy (вместо перегруженного Approved)
Work Item ID: ORCH-059
## Description
TBD

View File

@@ -0,0 +1,115 @@
# 01 — BRD: Approve прод-деплоя через выделенный статус «Confirm Deploy»
Work Item: **ORCH-059**
Repo: `orchestrator`
Stage: analysis
Тип: enhancement / risk-reduction (self-hosting)
## 1. Контекст и проблема
В ORCH-036 («исполняемый самодеплой стадии `deploy`») прод-деплой self-hosting
инстанса (контейнер `orchestrator`, порт 8500) запускается **Фазой B**: человек
переводит issue в Plane-статус **`Approved`**, webhook
`work_item.updated``handle_issue_updated``handle_verdict(approved=True)`
`_try_advance_stage``advance_stage(finished_agent=None)`, и в
`stage_engine.advance_stage` срабатывает блок
`current_stage == "deploy" and finished_agent is None`
`_handle_self_deploy_phase_b` → detached host-деплой прода.
**Перегрузка статуса.** Тот же самый Plane-статус `Approved` (UUID
`a519a341-…`) используется как **человеческий гейт одобрения BRD** на ранней
стадии `analysis` (`check_analysis_approved`: analysis → architecture) и в общем
verdict-роутинге `handle_verdict`. Один и тот же визуальный «Approved» на доске
означает две принципиально разные вещи:
- на `analysis` — «BRD/ТЗ/AC приняты, продолжай конвейер» (дёшево, обратимо);
- на `deploy` — «**ВЫКАТИ В ПРОД** инструмент, который прямо сейчас обслуживает
все проекты из одного инстанса с общей БД» (дорого, групповой риск, см.
раздел Self-hosting в `CLAUDE.md`).
### Последствия (Pain)
- **Двусмысленность семантики.** Один статус — два смысла; оператор не видит из
названия, что клик на `deploy` запускает реальный прод-рестарт.
- **Риск случайного клика.** Привычный жест «Approved» (которым оператор
штатно одобряет BRD десятки раз) на стадии `deploy` молча триггерит
прод-деплой. Цена ошибки — незапланированный рестарт прод-инстанса,
встающий конвейер всех проектов.
- **Несоответствие ожиданиям ORCH-036.** В scope ORCH-36 заявлялась Telegram
inline-кнопка подтверждения; в коде её **нет** — developer реализовал approve
исключительно через Plane-статус. Отдельного «осознанного» жеста подтверждения
деплоя в системе сейчас не существует.
## 2. Решение Owner
Ввести **отдельный Plane-статус `Confirm Deploy`** в проекте ORCH, который
триггерит **ТОЛЬКО** Фазу B self-deploy на стадии `deploy`. Статус `Approved`
перестаёт запускать прод-деплой и сохраняет единственный смысл — человеческое
одобрение на гейтах конвейера (прежде всего BRD на `analysis`).
Минимальная правка: `handle_verdict` в `src/webhooks/plane.py` + регистрация
нового состояния в проекте ORCH (Plane + резолвер состояний).
## 3. Бизнес-цели
- **BG-1.** Убрать двусмысленность: жест «запустить прод-деплой» отделён от жеста
«одобрить артефакт».
- **BG-2.** Снизить риск случайного прод-деплоя: запуск прода требует явного,
редко используемого статуса `Confirm Deploy`, а не привычного `Approved`.
- **BG-3.** Не сломать работающий self-hosting конвейер при доработке самого
инструмента (нулевая регрессия `analysis`-гейта и не-self репозиториев).
## 4. Объём (Scope)
### В объёме
- Новый логический статус `confirm_deploy` («Confirm Deploy») в резолвере
состояний Plane (`src/plane_sync.py`).
- Маршрутизация нового статуса в `src/webhooks/plane.py`
(`handle_issue_updated` / `handle_verdict`) на путь Фазы B прод-деплоя.
- Прекращение триггера Фазы B по статусу `Approved` на стадии `deploy`.
- Обновление текста CTA Фазы A (Plane-комментарий + Telegram в
`stage_engine._handle_self_deploy_phase_a`): инструктировать оператора
переводить задачу в `Confirm Deploy`, а не в `Approved`.
- Конфигурация Plane: создание статуса «Confirm Deploy» в проекте ORCH
(предусловие эксплуатации — фиксируется в TRZ/AC как требование среды).
- Обновление документации (`CLAUDE.md`, `docs/architecture/README.md` секция
ORCH-036, `CHANGELOG.md`) и ADR per-work-item.
### Вне объёма
- Telegram inline-кнопки подтверждения деплоя (отдельная задача; здесь не
реализуем — управление по-прежнему статусом Plane).
- Полностью автоматический approve деплоя (ORCH-54).
- Изменение Фаз A/C, exit-кодов хука, merge-gate, `check_deploy_status`,
схемы БД, реестров `STAGE_TRANSITIONS` / `QG_CHECKS`.
- Поведение прод-деплоя для не-self репозиториев (остаётся прежним).
- Post-deploy наблюдение (ORCH-021) — не затрагивается.
## 5. Заинтересованные стороны
- **Owner/оператор** — переводит задачи по статусам; главный выгодоприобретатель
снижения риска.
- **Self-hosting конвейер** — все проекты на общем инстансе; косвенно зависят от
безопасности прод-деплоя орка.
## 6. Допущения
- A-1. Plane позволяет добавить кастомный статус «Confirm Deploy» в проект ORCH;
его UUID резолвится через `get_project_states` (API `/states/`).
- A-2. Статус `Confirm Deploy` нужен только проекту ORCH (self-hosting). Прочие
проекты прод-деплой через Plane-approve не используют
(`self_deploy_applies` → только `orchestrator`).
- A-3. Оператор переводит задачу в `Confirm Deploy` только когда она реально
находится на стадии `deploy` (approval-pending после Фазы A).
## 7. Риски (детально — 10-tech-risks.md, ведёт архитектор)
- R-1. Новый логический ключ `confirm_deploy` отсутствует в fallback
`_DEFAULT_STATES` и в проектах без этого статуса → обращение к ключу должно
быть безопасным (fail-closed: нет статуса → нет деплоя, не падение).
- R-2. Регрессия: `Approved` на `deploy` после правки не должен НИ
запускать деплой, НИ вызывать ложный откат/advance.
- R-3. Самоправка прода: правка не должна потребовать ручного рестарта прод-
контейнера вне штатной стадии deploy-staging → deploy.
## 8. Definition of Done (бизнес-уровень)
- Перевод задачи стадии `deploy` в `Confirm Deploy` запускает прод-деплой
(Фаза B) ровно так же, как раньше делал `Approved`.
- Перевод задачи стадии `deploy` в `Approved` прод-деплой НЕ запускает.
- `Approved` на `analysis` (и прочих человеческих гейтах) работает без изменений.
- CTA Фазы A просит `Confirm Deploy`.
- Документация и ADR обновлены в том же PR.

View File

@@ -0,0 +1,103 @@
# 02 — ТЗ: выделенный статус «Confirm Deploy» как триггер прод-деплоя
Work Item: **ORCH-059** · Repo: `orchestrator` · Stage: analysis
> ТЗ описывает **что** должно измениться и **поведенческий контракт**. Конкретный
> дизайн (сигнатуры, способ проброса признака «confirm-deploy» из webhook в
> `stage_engine`, sentinel-обработка) — за архитектором (ADR per-work-item).
> Точки касания ниже заданы бизнес-запросом Owner и текущей реализацией ORCH-036.
## 1. Задействованные модули `src/`
| Модуль | Роль в задаче |
|--------|---------------|
| `src/plane_sync.py` | Резолвер состояний Plane. Добавить логический ключ `confirm_deploy` ↔ имя статуса «Confirm Deploy»; обеспечить безопасный доступ при отсутствии статуса (fallback/неполный конфиг). |
| `src/webhooks/plane.py` | `handle_issue_updated` — маршрутизация нового статуса; `handle_verdict` — отделить «подтверждение деплоя» от обычного approve; снять триггер Фазы B со статуса `Approved` на `deploy`. |
| `src/stage_engine.py` | Блок Фазы B (`current_stage == "deploy" and finished_agent is None`) должен срабатывать ТОЛЬКО по сигналу confirm-deploy, не по обычному Approved. Обновить CTA-текст Фазы A (`_handle_self_deploy_phase_a`). |
| `src/config.py` | (опционально, на усмотрение архитектора) флаг/имя статуса, если потребуется конфигурируемость. По умолчанию — не требуется. |
## 2. Поведенческий контракт (требования)
### TRZ-1. Регистрация статуса «Confirm Deploy»
Резолвер состояний (`get_project_states`) обязан возвращать UUID статуса
«Confirm Deploy» под логическим ключом `confirm_deploy` для проекта ORCH.
Маппинг имени `"Confirm Deploy" → "confirm_deploy"` добавляется в
`_PLANE_NAME_TO_KEY`. Для проектов/сред, где статус отсутствует (enduro,
fallback `_DEFAULT_STATES`, недоступный API), ключ может отсутствовать —
обращение к нему должно быть **fail-closed**: «нет статуса → ветка confirm-deploy
не активируется», без `KeyError`/исключения.
### TRZ-2. Триггер прод-деплоя по «Confirm Deploy»
Когда задача находится на стадии `deploy` и issue переводится в статус
`Confirm Deploy`, система обязана инициировать **Фазу B** прод-деплоя
(эквивалент текущего `_handle_self_deploy_phase_b`: idempotency-guard `initiated`,
`self_deploy.initiate_deploy`, постановка `deploy-finalizer`, комментарии/Telegram).
Поведение, идемпотентность и Фаза C — **без изменений** относительно ORCH-036;
меняется только **что именно является триггером**.
### TRZ-3. `Approved` больше не запускает прод-деплой
Перевод задачи стадии `deploy` в статус `Approved` **не должен** инициировать
Фазу B. Он не должен также вызывать ложный откат (БАГ-8) или ложный advance
по `check_deploy_status` (вердикта ещё нет). Допустимое поведение — **no-op с
логированием** (issue остаётся на `deploy`/approval-pending). Конкретный способ
(игнор на уровне webhook-роутинга или на уровне `stage_engine`) — за архитектором.
### TRZ-4. Сохранность гейта `Approved` на остальных стадиях
Статус `Approved` обязан продолжать работать как человеческий гейт:
- `analysis``architecture` (`check_analysis_approved`, approved-via-status);
- любой иной человеческий approve-advance, существующий сегодня.
Регрессия `handle_verdict(approved=True)` для НЕ-`deploy` стадий недопустима.
### TRZ-5. CTA Фазы A
Текст запроса approve в `_handle_self_deploy_phase_a` (Plane-комментарий + Telegram)
обязан инструктировать оператора переводить задачу в статус **`Confirm Deploy`**
(а не `Approved`) для запуска прод-деплоя.
### TRZ-6. Условность (как ORCH-35/36)
Ветка confirm-deploy реальна только для self-hosting
(`self_deploy.self_deploy_applies(repo)``orchestrator`). Для прочих репо —
прежнее поведение (синхронный деплой агентом), статус `Confirm Deploy` не
требуется и не влияет.
## 3. Изменения API
Изменений HTTP-эндпоинтов **нет**. Канал — существующий `POST /webhook/plane`
(событие `work_item.updated`). Внешнее изменение: в проекте ORCH появляется
дополнительный статус доски «Confirm Deploy» (Plane-конфигурация, не код-API).
## 4. Изменения схемы БД
**Нет.** `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, таблицы `tasks`/`jobs`/
`agent_runs`/`events` — без изменений. Статусы — на стороне Plane; restart-safe
состояние деплоя — существующие sentinel-файлы ORCH-036 (без миграций).
## 5. Требования к новым QG checks
**Нет.** Новый Quality Gate не вводится. `check_deploy_status` /
`_parse_deploy_status` и контракт exit-кодов хука (0/1/2) — без изменений.
## 6. Конфигурация среды (предусловие эксплуатации)
- В проекте ORCH в Plane создаётся статус доски **«Confirm Deploy»** (точное имя,
чувствительно к регистру — должно совпасть с ключом `_PLANE_NAME_TO_KEY`).
- Размещение статуса на доске — рядом со стадией deploy/approval-pending
(рекомендация эксплуатации, не код).
- Кэш состояний (`get_project_states` / `reload_project_states`): после создания
статуса может потребоваться сброс кэша или рестарт по штатной стадии deploy.
## 7. Артефакты, создаваемые/обновляемые по pipeline
- `docs/work-items/ORCH-059/06-adr/ADR-001-confirm-deploy-status.md` — решение
(как отличается триггер; где разрезается перегрузка `Approved`; fail-closed
при отсутствии статуса) — **ведёт архитектор**.
- `CLAUDE.md` — упоминание выделенного статуса approve прод-деплоя (раздел
self-hosting / артефакты).
- `docs/architecture/README.md` — секция ORCH-036: уточнить, что Фаза B
триггерится статусом `Confirm Deploy`, а не `Approved`.
- `CHANGELOG.md` — запись ORCH-059.
- `12-review.md`, `13-test-report.md`, `14-deploy-log.md`, `15-staging-log.md`
штатно по стадиям конвейера.
## 8. Совместимость и инварианты
- Не меняются: `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_deploy_status`,
БАГ-8 (FAILED → откат на development), merge-gate, exit-коды хука, Фазы A/C,
схема БД, post-deploy (ORCH-021).
- Self-hosting safety: правка НЕ требует внепланового рестарта прод-контейнера;
выкат — через штатный deploy-staging (8501) → deploy.
- Never-crash: отсутствие статуса `Confirm Deploy` в резолвере не приводит к
исключению в webhook-пути.

View File

@@ -0,0 +1,76 @@
# 03 — Критерии приёмки: ORCH-059
Repo: `orchestrator` · Stage: analysis
Каждый критерий — однозначный PASS/FAIL. Проверка: unit/integration (см.
`04-test-plan.yaml`) + ручная верификация для инфра-предусловий.
## AC-1 — Статус «Confirm Deploy» резолвится
**Given** проект ORCH со статусом доски «Confirm Deploy»
**When** вызывается резолвер состояний для проекта ORCH
**Then** возвращается логический ключ `confirm_deploy` с непустым UUID,
а маппинг `"Confirm Deploy" → "confirm_deploy"` присутствует в `_PLANE_NAME_TO_KEY`.
**FAIL:** ключ отсутствует или указывает на UUID статуса `Approved`.
## AC-2 — «Confirm Deploy» на стадии `deploy` запускает Фазу B
**Given** задача self-hosting (`orchestrator`) на стадии `deploy`,
`deploy_require_manual_approve=true`, маркер `initiated` отсутствует
**When** приходит `work_item.updated` со статусом `Confirm Deploy`
**Then** инициируется Фаза B: вызывается `self_deploy.initiate_deploy`,
ставится job `deploy-finalizer`, пишется маркер `initiated`.
**FAIL:** прод-деплой не инициирован, либо finalizer не поставлен.
## AC-3 — «Approved» на стадии `deploy` НЕ запускает прод-деплой
**Given** та же задача на стадии `deploy`
**When** приходит `work_item.updated` со статусом `Approved`
**Then** `self_deploy.initiate_deploy` **НЕ** вызывается; Фаза B не стартует;
задача не откатывается (БАГ-8 не срабатывает) и не «доходит» по
`check_deploy_status` (вердикта нет); событие залогировано как no-op.
**FAIL:** вызван `initiate_deploy`, либо произошёл откат/ложный advance.
## AC-4 — «Approved» на `analysis` работает без регрессии
**Given** задача на стадии `analysis` (BRD готов, approval-pending)
**When** issue переводится в `Approved`
**Then** срабатывает approved-via-status и задача продвигается
`analysis → architecture` (как до правки).
**FAIL:** approve на analysis перестал продвигать конвейер.
## AC-5 — Идемпотентность Фазы B по «Confirm Deploy»
**Given** задача на `deploy`, маркер `initiated` уже существует
**When** повторно приходит статус `Confirm Deploy` (двойной клик / дубль webhook)
**Then** повторного `initiate_deploy` не происходит (no-op,
`self-deploy-already-initiated`).
**FAIL:** прод-деплой запускается повторно.
## AC-6 — CTA Фазы A просит «Confirm Deploy»
**Given** Фаза A (`deploy-staging → deploy`, approval-pending)
**When** формируются Plane-комментарий и Telegram-уведомление запроса approve
**Then** текст инструктирует перевести задачу в статус **`Confirm Deploy`**
(а не «Approved») для запуска прод-деплоя.
**FAIL:** CTA по-прежнему упоминает только «Approved».
## AC-7 — Fail-closed при отсутствии статуса
**Given** среда без статуса «Confirm Deploy» (enduro / fallback `_DEFAULT_STATES`
/ недоступный Plane API)
**When** обрабатывается `work_item.updated`
**Then** webhook-путь не выбрасывает исключение; ветка confirm-deploy не
активируется (прод-деплой не запускается «вслепую»).
**FAIL:** `KeyError`/исключение в обработчике, либо ложный запуск Фазы B.
## AC-8 — Условность для не-self репозиториев
**Given** не-self репозиторий (`self_deploy_applies(repo) == False`)
**When** приходит любой verdict-статус на стадии `deploy`
**Then** поведение прод-деплоя не меняется относительно текущего (синхронный
деплой агентом); статус `Confirm Deploy` не требуется.
**FAIL:** изменилось поведение деплоя не-self проекта.
## AC-9 — Инварианты не нарушены
**Then** `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_deploy_status`/
`_parse_deploy_status`, контракт exit-кодов хука (0/1/2), Фазы A/C, merge-gate,
схема БД — без изменений; `pytest tests/ -q` зелёный.
**FAIL:** изменён любой из перечисленных контрактов или красные тесты.
## AC-10 — Документация обновлена (golden source)
**Then** в том же PR обновлены `CLAUDE.md`, секция ORCH-036 в
`docs/architecture/README.md`, `CHANGELOG.md`; заведён
`06-adr/ADR-001-confirm-deploy-status.md`.
**FAIL:** функционал изменён, документация — нет (Reviewer → REQUEST_CHANGES).

View File

@@ -0,0 +1,109 @@
work_item: ORCH-059
title: Approve прод-деплоя через выделенный статус «Confirm Deploy»
repo: orchestrator
stage: analysis
# Контракт-тесты: триггер прод-деплоя смещается с перегруженного `Approved`
# на выделенный статус `Confirm Deploy`. Деплой и сетевые вызовы мокаются.
tests:
- id: TC-01
type: unit
description: "_PLANE_NAME_TO_KEY содержит маппинг 'Confirm Deploy' -> 'confirm_deploy'"
module: tests/test_plane_states.py
expected: PASS
- id: TC-02
type: unit
description: >-
get_project_states для проекта ORCH (мок API со статусом 'Confirm Deploy')
возвращает непустой UUID под ключом 'confirm_deploy', отличный от 'approved'
module: tests/test_plane_states.py
expected: PASS
- id: TC-03
type: unit
description: >-
Fail-closed: при отсутствии статуса 'Confirm Deploy' (fallback _DEFAULT_STATES /
недоступный API) доступ к ключу confirm_deploy не выбрасывает исключение
и не активирует ветку confirm-deploy
module: tests/test_plane_states.py
expected: PASS
- id: TC-04
type: unit
description: >-
handle_issue_updated: статус 'Confirm Deploy' на задаче стадии deploy
маршрутизируется на путь Фазы B (а не на обычный approve/advance)
module: tests/test_plane_confirm_deploy.py
expected: PASS
- id: TC-05
type: unit
description: >-
handle_verdict/Approved на стадии deploy НЕ вызывает self_deploy.initiate_deploy
(initiate_deploy замокан и не должен быть вызван)
module: tests/test_plane_confirm_deploy.py
expected: PASS
- id: TC-06
type: unit
description: >-
Approved на стадии analysis по-прежнему продвигает analysis -> architecture
(approved-via-status, регрессия гейта check_analysis_approved)
module: tests/test_plane_confirm_deploy.py
expected: PASS
- id: TC-07
type: unit
description: >-
stage_engine: блок Фазы B (current_stage==deploy, finished_agent is None)
инициирует deploy ТОЛЬКО по сигналу confirm-deploy; Approved-сигнал -> no-op
module: tests/test_stage_engine_phase_b.py
expected: PASS
- id: TC-08
type: unit
description: >-
Идемпотентность: при существующем маркере 'initiated' повторный
Confirm Deploy не вызывает initiate_deploy (self-deploy-already-initiated)
module: tests/test_stage_engine_phase_b.py
expected: PASS
- id: TC-09
type: unit
description: >-
CTA Фазы A (_handle_self_deploy_phase_a): текст Plane-комментария и Telegram
содержат 'Confirm Deploy' и не предлагают 'Approved' как триггер деплоя
module: tests/test_stage_engine_phase_a_cta.py
expected: PASS
- id: TC-10
type: integration
description: >-
E2E (мок Plane API + self_deploy): задача на deploy -> webhook Confirm Deploy
-> initiate_deploy вызван, deploy-finalizer поставлен, маркер initiated записан
module: tests/test_confirm_deploy_integration.py
expected: PASS
- id: TC-11
type: integration
description: >-
E2E: задача на deploy -> webhook Approved -> прод-деплой НЕ инициирован,
задача остаётся на deploy (нет отката, нет advance в done)
module: tests/test_confirm_deploy_integration.py
expected: PASS
- id: TC-12
type: integration
description: >-
Условность: для не-self репозитория verdict-статусы на deploy не меняют
поведение деплоя (self_deploy_applies == False)
module: tests/test_confirm_deploy_integration.py
expected: PASS
regression:
- id: RG-01
type: integration
description: "pytest tests/ -q зелёный; STAGE_TRANSITIONS и QG_CHECKS без изменений"
module: tests/
expected: PASS

View File

@@ -0,0 +1,156 @@
# ADR-001 (ORCH-059): Выделенный статус «Confirm Deploy» как триггер прод-деплоя
## Статус
Accepted (design) — реализация в ветке `feature/ORCH-059-approve-confirm-deploy-approve`.
## Контекст
ORCH-036 (исполняемый самодеплой стадии `deploy`) запускает прод-деплой
self-hosting инстанса **Фазой B**: человек переводит issue в Plane-статус
`Approved` → webhook `work_item.updated``handle_issue_updated`
`handle_verdict(approved=True)``_try_advance_stage`
`advance_stage(finished_agent=None)`; в `stage_engine.advance_stage` блок
`current_stage == "deploy" and finished_agent is None`
`_handle_self_deploy_phase_b` → detached host-деплой прода (8500).
Тот же UUID `Approved` (`a519a341-…`, `_DEFAULT_STATES["approved"]`) — это
**человеческий гейт одобрения** на стадии `analysis`
(`check_analysis_approved`, путь `approved-via-status`) и общий verdict-роутинг
в `handle_verdict`. Один визуальный «Approved» на доске значит две принципиально
разные вещи: «принять BRD» (дёшево, обратимо) и «**ВЫКАТИТЬ В ПРОД** инструмент,
обслуживающий все проекты из одного инстанса с общей БД» (дорого, групповой
риск). Привычный жест approve на стадии `deploy` молча триггерит прод-рестарт —
цена случайного клика высока (см. self-hosting в `CLAUDE.md`).
Ограничения, формирующие дизайн (см. `02-trz.md`, `03-acceptance-criteria.md`):
1. **Нулевая регрессия** гейта `Approved` на `analysis` и прочих стадиях (TRZ-4).
2. **Fail-closed**: среды без статуса (enduro, fallback `_DEFAULT_STATES`,
недоступный API) не должны падать и не должны «вслепую» деплоить (TRZ-1, R-1).
3. **`Approved` на `deploy` не должен** запускать Фазу B И не должен вызывать
ложный откат (БАГ-8) или ложный advance по `check_deploy_status` — вердикта
ещё нет (TRZ-3, R-2).
4. **Без правки контрактов**: `STAGE_TRANSITIONS`, `QG_CHECKS`,
`check_deploy_status`, Фазы A/C, merge-gate, exit-коды хука, схема БД (TRZ-8).
5. **Self-hosting safety**: правка — чистая маршрутизация, не требует внепланового
рестарта прода; выкат через штатный `deploy-staging` (8501) → `deploy` (R-3).
## Решение
Ввести отдельный логический статус `confirm_deploy` («Confirm Deploy»), который
триггерит **ТОЛЬКО** Фазу B на стадии `deploy`. `Approved` теряет смысл «запусти
прод-деплой» и остаётся исключительно человеческим гейтом конвейера.
Четыре точечные правки в трёх модулях:
### 1. Резолвер состояний — `src/plane_sync.py`
- В `_PLANE_NAME_TO_KEY` добавить маппинг `"Confirm Deploy" → "confirm_deploy"`.
- В `_DEFAULT_STATES` ключ `confirm_deploy` **НЕ добавлять** (реального UUID для
enduro/fallback нет; отсутствие ключа = fail-closed). Для проекта ORCH ключ
резолвится `get_project_states` из живого Plane API; для проектов без статуса и
на fallback-пути ключ просто отсутствует в результирующем словаре.
- Следствие: `get_project_states(orch)["confirm_deploy"]` → реальный UUID;
`get_project_states(enduro).get("confirm_deploy")``None`.
### 2. Маршрутизация webhook — `src/webhooks/plane.py`
В `handle_issue_updated`, **до** ветки `approved`, добавить fail-closed-ветку:
```python
confirm_state = proj_states.get("confirm_deploy") # .get -> AC-7/R-1
if confirm_state and new_state == confirm_state:
await handle_confirm_deploy(data, project_id)
elif new_state == proj_states["in_progress"]:
...
elif new_state == proj_states["approved"]:
await handle_verdict(data, project_id, approved=True)
```
Новый `handle_confirm_deploy(data, project_id)`:
- резолвит задачу по `plane_id`;
- если `stage != "deploy"`**no-op с логом** (Confirm Deploy осмыслен только на
approval-pending стадии `deploy`; защищает прочие гейты от случайного approve);
- иначе → `_try_advance_stage(..., confirm_deploy=True)`.
`handle_verdict(approved=True)` не меняется — продолжает звать `_try_advance_stage`
с `confirm_deploy=False` (дефолт).
### 3. Сигнал в движок — `src/stage_engine.advance_stage(...)`
Добавить keyword-only параметр `confirm_deploy: bool = False` (back-compat: все
существующие вызовы из launcher/reconciler/finalizer/webhook передают
`finished_agent`, новый kwarg дефолтный). Блок Фазы B переписать так, чтобы он
**всегда возвращался рано** для `deploy + finished_agent is None` self-hosting,
но деплоил только по сигналу:
```python
if (current_stage == "deploy" and finished_agent is None
and settings.deploy_require_manual_approve
and self_deploy.self_deploy_applies(repo)):
if confirm_deploy:
_handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result)
else:
# TRZ-3/R-2: обычный Approved на deploy — no-op; НЕ запускаем
# check_deploy_status (вердикта ещё нет -> ложный откат БАГ-8).
result.note = "approved-on-deploy-noop"
return result
```
Ключевое: возврат **до** блока Quality Gate в обоих случаях → `check_deploy_status`
по `Approved` на `deploy` не исполняется. Фаза C (finalizer,
`finished_agent="deployer"`) не затронута — условие требует `finished_agent is
None`.
### 4. CTA Фазы A — `src/stage_engine._handle_self_deploy_phase_a`
Текст Plane-комментария и Telegram изменить: вместо «смените статус на Approved»
инструктировать перевести задачу в статус **«Confirm Deploy»** для запуска
прод-деплоя (TRZ-5/AC-6).
### Условность (как ORCH-35/36)
Вся ветка реальна только для `self_deploy.self_deploy_applies(repo)`
`orchestrator`. Прочие репо — прежний синхронный ssh-деплой агентом; статус
`Confirm Deploy` им не нужен и на них не влияет (AC-8).
## Альтернативы
- **A. Telegram inline-кнопка подтверждения** вместо нового статуса — отклонено:
кнопочная инфраструктура в коде отсутствует, заявлено вне scope (ORCH-036 п.
«inline-кнопка» не реализован); управление остаётся статусом Plane.
- **B. Добавить `confirm_deploy` в `_DEFAULT_STATES`** — отклонено: реального UUID
«Confirm Deploy» для enduro/fallback нет; пришлось бы подставить фиктивный или
дублирующий UUID, что ломает fail-closed (enduro «получил бы» триггер деплоя) и
смешивает семантику.
- **C. Отдельный публичный entrypoint `stage_engine.initiate_confirm_deploy()`**,
минующий `advance_stage` — отклонено: дублирует гарды
(`deploy_require_manual_approve`, `self_deploy_applies`, idempotency `initiated`),
и всё равно пришлось бы внутри `advance_stage` гасить `Approved`-на-`deploy` в
no-op. Параметр-сигнал проще и держит единую точку правды.
- **D. Сигнал через sentinel-маркер, записываемый webhookом** — отклонено: вызов
синхронный в пределах одного `advance_stage`, persistence не нужна; параметр
явнее и не плодит файловое состояние.
## Последствия
**Плюсы**
- Жест «запустить прод-деплой» отделён от «одобрить артефакт»; случайный approve
на доске больше не роняет прод (BG-1, BG-2).
- `Approved` на `deploy` детерминированно безопасен: no-op без отката/advance
(закрывает R-2).
- Fail-closed: нет статуса → нет деплоя, нет исключения (R-1, AC-7).
- Минимальный диффузный риск: контракты `STAGE_TRANSITIONS`/`QG_CHECKS`/
`check_deploy_status`/Фазы A/C/merge-gate/схема БД не тронуты (AC-9).
- Реконсилятор F-1 на `deploy` (finished_agent=None) теперь попадает в no-op-ветку
вместо прежнего неявного запуска Фазы B → прод-деплой невозможно инициировать
автоматически, только явным человеческим `Confirm Deploy` (усиление safety).
**Минусы / цена**
- Эксплуатационное предусловие: в Plane-проекте ORCH нужно создать статус доски
«Confirm Deploy» (точное имя, регистр) и сбросить кэш состояний — см.
`07-infra-requirements.md`. До создания статуса прод-деплой через approve не
запустится (это и есть желаемое fail-closed-поведение).
- Сигнатура `advance_stage` расширена одним kwarg (обратносовместимо).
**Хэндофф документации (golden source, в том же PR — стадия development).**
ADR (этот файл) — артефакт архитектора. Переписать `Approve = Approved`
`Confirm Deploy` в `docs/architecture/README.md` (секция ORCH-036), `CLAUDE.md`
(self-hosting/артефакты) и добавить запись в `CHANGELOG.md` обязан developer
одновременно с кодом (AC-10), чтобы доки не описывали ещё не существующее
поведение. В README на стадии architecture добавлена forward-looking пометка
ORCH-059 (design), как принято для незамёрженных доработок.
## Связанные ADR
- `adr-0007-executable-self-deploy.md` (ORCH-036) — задаёт Фазы A/B/C; ORCH-059
меняет **только триггер** Фазы B (`Approved``Confirm Deploy`) и делает
`Approved`-на-`deploy` no-op; Фазы внутренне не меняются.
- `adr-0003-staging-gate.md` (ORCH-35) — паттерн условности self-hosting.
- `adr-0007-reconciler.md` (ORCH-053) — реконсилятор F-1: поведение на `deploy`
становится no-op (см. Последствия).

View File

@@ -0,0 +1,44 @@
# 07 — Требования к инфраструктуре: ORCH-059
Work Item: **ORCH-059** · Repo: `orchestrator`
Связано: `06-adr/ADR-001-confirm-deploy-status.md`, `02-trz.md` §6.
> Топология контейнеров/портов/деплоя НЕ меняется (см. `docs/operations/INFRA.md`).
> Единственное инфра-требование ORCH-059 — конфигурация Plane-доски проекта ORCH.
## IR-1. Статус доски «Confirm Deploy» в проекте ORCH (предусловие эксплуатации)
- В Plane-проекте **ORCH** создать кастомный статус доски с **точным** именем
`Confirm Deploy` (case-sensitive, ровно один пробел) — должно посимвольно
совпасть с ключом `_PLANE_NAME_TO_KEY["Confirm Deploy"]`. Несовпадение →
fail-closed (деплой не запустится), не краш (R-9).
- UUID статуса генерирует Plane; код резолвит его через `get_project_states`
(`GET /workspaces/<ws>/projects/<orch>/states/`). Хардкодить UUID не нужно.
- **Размещение** на доске — рядом с approval-pending/`deploy` (рекомендация
эксплуатации, на поведение кода не влияет).
- **Только проект ORCH** (self-hosting). Для enduro и прочих проектов статус НЕ
создаётся и НЕ требуется — `self_deploy_applies` истинно лишь для `orchestrator`.
## IR-2. Сброс кэша состояний после создания статуса
`get_project_states` кэширует резолв per-project на время жизни процесса
(`_STATES_CACHE`). После создания статуса в Plane закэшированный словарь не
содержит `confirm_deploy` (R-5). Применить ОДНО из:
- вызвать `reload_project_states(<orch_project_id>)` (или полный сброс), либо
- штатно перезапустить прод по конвейеру `deploy-staging → deploy` (рестарт
процесса очищает кэш).
> Внеплановый ручной рестарт прод-контейнера для применения этой задачи **не
> требуется** и противопоказан (self-hosting групповой риск). Выкат — только через
> штатный staging→deploy.
## IR-3. Контрольная проверка готовности среды
После IR-1+IR-2:
1. `get_project_states(<orch>)` содержит `confirm_deploy` с непустым UUID,
отличным от `approved` (AC-1, TC-02).
2. Перевод тестовой задачи стадии `deploy` (sandbox) в `Confirm Deploy` запускает
Фазу B; перевод в `Approved` — нет (AC-2/AC-3).
## Что НЕ меняется
- Порты (8500 prod / 8501 staging), контейнеры, compose-профили, env-карта,
деплой-хук, схема БД, sentinel-каталоги ORCH-036 — без изменений.
- HTTP-эндпоинты (`POST /webhook/plane` тот же канал, событие
`work_item.updated`).

View File

@@ -0,0 +1,25 @@
# 10 — Технические риски: ORCH-059
Work Item: **ORCH-059** · Repo: `orchestrator` · ведёт: архитектор
Связано: `06-adr/ADR-001-confirm-deploy-status.md`.
| ID | Риск | Вероятн. | Влияние | Митигация | Проверка |
|----|------|----------|---------|-----------|----------|
| R-1 | Ключ `confirm_deploy` отсутствует в `_DEFAULT_STATES` / у проектов без статуса → `KeyError` в webhook-пути | Сред | Выс (краш обработчика) | Доступ ТОЛЬКО через `.get("confirm_deploy")`; `_DEFAULT_STATES` не содержит ключ намеренно; отсутствие → ветка не активируется (fail-closed) | TC-03, AC-7 |
| R-2 | `Approved` на `deploy` после правки вызывает `check_deploy_status` (вердикта нет) → ложный откат БАГ-8 / ложный advance | Выс | Выс (петля dev↔deploy, ложный rollback прода) | Блок Фазы B возвращается рано для `deploy + finished_agent is None` self-hosting в ОБОИХ случаях; `Approved``note=approved-on-deploy-noop`, QG не запускается | TC-05, TC-07, TC-11, AC-3 |
| R-3 | Самоправка прода требует внепланового рестарта прод-контейнера | Низ | Выс (встаёт конвейер всех проектов) | Изменение — чистая маршрутизация в коде; выкат через штатный `deploy-staging` (8501) → `deploy`; sentinel-состояние ORCH-036 не трогаем | AC-9, RG-01 |
| R-4 | `Confirm Deploy` прислан на не-`deploy` стадии (оператор ошибся) → срабатывает как обычный approve и продвигает чужой гейт | Низ | Сред | `handle_confirm_deploy` гардит `stage == "deploy"`; иначе no-op с логом | TC-04 (+ ручная верификация) |
| R-5 | Кэш `get_project_states` закэширован до создания статуса «Confirm Deploy» → ключ не виден после конфигурации Plane | Сред | Сред (деплой не запускается) | После создания статуса в Plane — `reload_project_states(orch)` или штатный рестарт по стадии `deploy`; зафиксировано в `07-infra-requirements.md` | ручная верификация |
| R-6 | Новый kwarg `confirm_deploy` ломает существующие вызовы `advance_stage` (launcher/reconciler/finalizer) | Низ | Выс | keyword-only с дефолтом `False`; все вызовы передают `finished_agent`; не-`deploy`/finished_agent≠None пути не затронуты | RG-01, AC-9 |
| R-7 | Регрессия идемпотентности Фазы B (двойной `Confirm Deploy`) | Низ | Сред | Внутренности `_handle_self_deploy_phase_b` (маркер `initiated`) не меняются; меняется только триггер | TC-08, AC-5 |
| R-8 | Реконсилятор F-1 на `deploy` (finished_agent=None) меняет поведение | Низ | Низ (улучшение) | Намеренно: раньше неявно мог войти в Фазу B, теперь → no-op. Прод-деплой инициируется только явным `Confirm Deploy`. Документировано в ADR/README | RG-01 |
| R-9 | Несовпадение имени статуса в Plane и `_PLANE_NAME_TO_KEY` (регистр/пробел) → ключ не резолвится | Сред | Сред (деплой не запускается, fail-closed) | Точное имя «Confirm Deploy» (case-sensitive) — требование среды в `07-infra-requirements.md`; маппинг ровно этой строкой | TC-01, TC-02 |
## Сводный вывод
Все риски — низкого/среднего остаточного уровня после митигаций. Доминирующий
класс — **fail-closed**: любая неполнота конфигурации (нет статуса, протухший кэш,
недоступный API) приводит к «деплой не запускается», а не к «деплой запускается
вслепую» или к крашу. Контракты конвейера (`STAGE_TRANSITIONS`, `QG_CHECKS`,
`check_deploy_status`, Фазы A/C, merge-gate, схема БД) не затрагиваются, поэтому
поверхность регрессии ограничена тремя модулями (`plane_sync.py`,
`webhooks/plane.py`, `stage_engine.py`).

View File

@@ -0,0 +1,59 @@
---
type: review
work_item_id: ORCH-059
verdict: APPROVED
version: 1
---
# Review ORCH-059
## Summary
Выделенный Plane-статус «Confirm Deploy» как единственный триггер Фазы B прод-деплоя
self-hosting; `Approved` на стадии `deploy` становится детерминированным no-op. Реализация
точно соответствует ТЗ (TRZ-1..6), ADR-001 и критериям приёмки (AC-1..10). Четыре точечные
правки в трёх модулях (`plane_sync.py`, `webhooks/plane.py`, `stage_engine.py`), без изменения
контрактов (`STAGE_TRANSITIONS`, `QG_CHECKS`, `check_deploy_status`, Фазы A/C, merge-gate, схема
БД). Документация обновлена в том же PR. `pytest tests/ -q` — 763 passed.
## Соответствие ТЗ и ADR
- **TRZ-1 / AC-1** — `"Confirm Deploy" → "confirm_deploy"` добавлен в `_PLANE_NAME_TO_KEY`;
намеренно отсутствует в `_DEFAULT_STATES` → fail-closed. Покрыто `test_tc01/tc02`.
- **TRZ-2 / AC-2** — `handle_confirm_deploy` (гард `stage=="deploy"`) →
`_try_advance_stage(..., confirm_deploy=True)` → Фаза B. Покрыто `test_tc04/tc07/tc10`.
- **TRZ-3 / AC-3** — `Approved` на `deploy`: ранний возврат ДО Quality Gate с
`note="approved-on-deploy-noop"`, без `initiate_deploy`, без ложного отката БАГ-8.
Покрыто `test_tc05/tc07_approved_without_confirm_is_noop/tc11`.
- **TRZ-4 / AC-4** — `handle_verdict(approved=True)` не тронут; approve на `analysis`
продвигает конвейер. Покрыто `test_tc06_approved_on_analysis_still_advances`.
- **AC-5** — идемпотентность повторного «Confirm Deploy» (`self-deploy-already-initiated`).
Покрыто `test_tc08`, `test_tc06_approved_calls_prod_hook_exactly_once`.
- **TRZ-5 / AC-6** — CTA Фазы A (Plane-коммент + Telegram) просит «Confirm Deploy» и явно
отмечает, что «Approved» прод-деплой не запускает. Покрыто `test_tc09`.
- **TRZ-1 / AC-7** — доступ через `.get("confirm_deploy")`, отсутствие статуса → ветка не
активируется, без `KeyError`. Покрыто `test_tc03` (API недоступен / статуса нет на доске).
- **TRZ-6 / AC-8** — условность через `self_deploy.self_deploy_applies`; не-self репо без
изменений. Покрыто `test_tc12`.
- **AC-9** — контракты и схема БД не изменены; 763 теста зелёные.
## Findings
### P0 — Blocker
- нет
### P1 — Must fix
- нет
### P2 — Should fix
- нет
## Документация
Обновлено в том же PR (AC-10 выполнен):
- `CLAUDE.md` — раздел self-hosting: прод-деплой только через «Confirm Deploy», `Approved` = no-op.
- `docs/architecture/README.md` — секция ORCH-036 уточнена + добавлена подсекция ORCH-059
(статус-триггер «Confirm Deploy»), запись в перечне статусов доработок.
- `CHANGELOG.md` — запись ORCH-059 в `[Unreleased] / Added`.
- ADR `docs/work-items/ORCH-059/06-adr/ADR-001-confirm-deploy-status.md` — заведён, отражает
реализацию (4 правки, fail-closed, рассмотренные альтернативы).
- `07-infra-requirements.md` — эксплуатационное предусловие (создать статус доски + сброс кэша).
Документация консистентна с кодом; golden-source инвариант соблюдён.

View File

@@ -0,0 +1,71 @@
---
type: test-report
work_item_id: ORCH-059
result: PASS
---
# Test Report — ORCH-059
Выделенный Plane-статус «Confirm Deploy» как единственный триггер Фазы B прод-деплоя
self-hosting; `Approved` на стадии `deploy` — детерминированный no-op.
## Окружение
- Python: 3.12.13
- pytest: 8.3.3
- Prod orchestrator (8500): `/health``{"status":"ok"}`
- Дата: 2026-06-07
## Результаты (контракт-тесты `04-test-plan.yaml`)
| TC ID | Описание | Тест | Результат |
|-------|----------|------|-----------|
| TC-01 | `_PLANE_NAME_TO_KEY`: `'Confirm Deploy' → 'confirm_deploy'` | test_tc01_confirm_deploy_name_to_key_mapping; test_tc01_confirm_deploy_not_in_default_states | PASS |
| TC-02 | `get_project_states` ORCH резолвит непустой UUID под `confirm_deploy`, ≠ `approved` | test_tc02_get_project_states_resolves_confirm_deploy | PASS |
| TC-03 | Fail-closed при отсутствии статуса (API недоступен / нет на доске) — без исключения | test_tc03_fail_closed_when_api_unreachable; test_tc03_fail_closed_when_status_not_on_board | PASS |
| TC-04 | `handle_issue_updated`: `Confirm Deploy` на `deploy` → путь Фазы B | test_tc04_confirm_deploy_routes_phase_b; test_tc04b_confirm_deploy_off_deploy_stage_is_noop | PASS |
| TC-05 | `Approved` на `deploy` НЕ вызывает `initiate_deploy` | test_tc05_approved_on_deploy_does_not_initiate | PASS |
| TC-06 | `Approved` на `analysis` по-прежнему продвигает → architecture | test_tc06_approved_on_analysis_still_advances | PASS |
| TC-07 | stage_engine: Фаза B только по confirm-deploy; `Approved` → no-op | test_tc07_confirm_deploy_initiates; test_tc07_approved_without_confirm_is_noop | PASS |
| TC-08 | Идемпотентность: повтор `Confirm Deploy` при маркере `initiated` → no-op | test_tc08_idempotent_repeat_confirm_deploy | PASS |
| TC-09 | CTA Фазы A содержит «Confirm Deploy», не предлагает «Approved» как триггер | test_tc09_phase_a_cta_requests_confirm_deploy | PASS |
| TC-10 | E2E: `Confirm Deploy``initiate_deploy` вызван, finalizer поставлен, маркер записан | test_tc10_confirm_deploy_e2e_initiates | PASS |
| TC-11 | E2E: `Approved` → деплой НЕ инициирован, задача остаётся на `deploy` | test_tc11_approved_e2e_noop | PASS |
| TC-12 | Условность: не-self репо verdict-статусы не меняют поведение деплоя | test_tc12_non_self_repo_unaffected | PASS |
| RG-01 | Полный регресс зелёный; STAGE_TRANSITIONS / QG_CHECKS без изменений | tests/ (763 passed) | PASS |
Все 16 целевых тестов ORCH-059 (TC-01..TC-12) — PASS.
## Сопоставление с критериями приёмки (`03-acceptance-criteria.md`)
| AC | Покрытие | Результат |
|----|----------|-----------|
| AC-1 Статус резолвится | TC-01, TC-02 | PASS |
| AC-2 Confirm Deploy на `deploy` → Фаза B | TC-04, TC-07, TC-10 | PASS |
| AC-3 Approved на `deploy` НЕ деплоит | TC-05, TC-07, TC-11 | PASS |
| AC-4 Approved на `analysis` без регрессии | TC-06 | PASS |
| AC-5 Идемпотентность Фазы B | TC-08 | PASS |
| AC-6 CTA Фазы A просит Confirm Deploy | TC-09 | PASS |
| AC-7 Fail-closed без статуса | TC-03 | PASS |
| AC-8 Условность для не-self | TC-12 | PASS |
| AC-9 Инварианты, pytest зелёный | RG-01 (763 passed) | PASS |
| AC-10 Документация обновлена | проверено reviewer (12-review.md, APPROVED) | PASS |
## Smoke test API (prod 8500)
- `GET /health``{"status":"ok","service":"orchestrator"}`
- `GET /status` → 200, активные задачи отдаются (вкл. ORCH-059 на `testing`)
- `GET /queue` → 200, counts + resilience + reconcile + reaper + post_deploy
## Вывод pytest
```
======================= 763 passed, 1 warning in 15.45s ========================
```
Целевой набор ORCH-059:
```
======================== 16 passed, 1 warning in 0.75s =========================
```
(1 warning — PydanticDeprecatedSince20 в `src/config.py`, не относится к ORCH-059.)
## Итог
**PASS** — все контракт-тесты (TC-01..TC-12) и регресс (763 passed) зелёные,
критерии приёмки AC-1..AC-10 покрыты, smoke API OK. Задача готова к стадии
deploy-staging.

View File

@@ -1,6 +1,6 @@
---
deploy_status: SUCCESS
work_item: ORCH-068
work_item: ORCH-059
hook_exit_code: 0
deployed_by: deploy-finalizer
---

View File

@@ -1,7 +1,7 @@
---
post_deploy_status: HEALTHY
action_taken: NONE
work_item: ORCH-068
work_item: ORCH-059
window_s: 900
checks_total: 30
checks_failed: 0

View File

@@ -1,39 +0,0 @@
---
staging_status: SUCCESS
timestamp: 2026-06-07T22:01:57Z
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed against the live `orchestrator-staging` instance (port 8501),
run canonically via `docker exec orchestrator-staging python3 /repos/orchestrator/scripts/staging_check.py --base-url http://localhost:8501 --mode stub`.
**Result: 8/10 checks PASS — exit code 0 (advance).**
All REAL (pipeline) checks are green. The two failing checks are the known
SANDBOX_INFRA-only checks C9a/C9b (sandbox branch / analyst-job — depend on
SANDBOX bot accounts being project members, not on the pipeline), which are
waived under ORCH-061 since every REAL check passed.
```
INFRA-WAIVED: C9a Branch appears in orchestrator-sandbox, C9b Analyst job enqueued in staging queue (known sandbox-infra; real checks green)
VERDICT: SUCCESS (exit 0) — SUCCESS (infra-waived): ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue'] are known sandbox-infra checks; all real checks green
```
## Check breakdown
| Block | Check | Result |
|-------|-------|--------|
| A SMOKE | A1 GET /health → 200 status=ok | PASS |
| A SMOKE | A2 GET /queue → 200 with counts/max_concurrency/resilience | PASS |
| A SMOKE | A3 ORCH_STAGING=true (not prod) | PASS |
| B ACCESS | B4 Plane: sandbox project accessible | PASS |
| B ACCESS | B5 Gitea: orchestrator-sandbox accessible, push=true | PASS |
| B ACCESS | B6 Registry: sandbox present, prod ET/ORCH absent | PASS |
| C E2E | C7 Create issue in Plane SANDBOX | PASS |
| C E2E | C8 Trigger pipeline via /webhook/plane | PASS |
| C E2E | C9a Branch appears in orchestrator-sandbox | FAIL (waived — sandbox-infra) |
| C E2E | C9b Analyst job enqueued in staging queue | FAIL (waived — sandbox-infra) |
CLEANUP completed: test Plane issue deleted (HTTP 204); no branch to delete.

View File

@@ -1,7 +0,0 @@
# Business Request: BUG: reconciler livelock — спам unblock done-задачи (ET-002)
Work Item ID: ORCH-068
## Description
TBD

View File

@@ -1,52 +0,0 @@
# BRD — ORCH-068: BUG reconciler livelock (спам unblock done-задачи)
## 1. Контекст и предыстория
Reconciler (`src/reconciler.py`, ORCH-053) — фоновый поток, который доигрывает пропущенные webhook-переходы. Ветвь **F-2 (plane-side)** опрашивает Plane per-project и реплеит In Progress / Approved / Rejected через штатные `handle_status_start` / `handle_verdict`. При фактической разблокировке вызывается `_note_unblock` → лог + Telegram.
ORCH-066 (мердж `feature/ORCH-066-plane`, прод 2026-06-07 ~22:16 UTC) ввела новую статусную модель Plane (маппинг стадия↔статус, новые имена `Done`, `Monitoring after Deploy` и т.п.). Это спровоцировало регрессию в F-2.
## 2. Проблема (бизнес-симптом)
С 22:17 UTC (рестарт прод-контейнера после деплоя ORCH-066) reconciler каждые ~120с шлёт в Telegram:
```
reconciler: ET-002 done разблокирована (потерян webhook)
```
для задачи **ET-002** (enduro-trails), которая в Done с 2026-05-21. На момент анализа — 191+ сообщений подряд, поток не прекращается (ночной спам, найден Славой 2026-06-08 01:22 UTC).
**Ключевой факт:** ET-002 полностью синхронизирована — БД `stage=done`, Plane `state=Done`. Reconciler обязан молчать (инвариант «silence when in sync», AC-9/AC-10 из ORCH-053), но шлёт уведомления вхолостую.
## 3. Диагностика (проведена, root cause найден)
1. **Деньги/токены НЕ тратятся:** `jobs` / `agent_runs` за 4ч пусты; `advance_stage` для done = no-op; `handle_verdict` для done-задачи ничего не меняет. Это «дешёвый», но шумный и подрывающий доверие баг (livelock + ложный alert-fatigue).
2. **Механизм:**
- `_reconcile_plane_project` (`src/reconciler.py` ~241) тянет `list_issues_by_state(pid, [in_progress, approved, rejected])`.
- На enduro-trails статусы «схлопнуты»: после ORCH-066 терминальный `Done` алиасится под UUID `approved` (см. ниже п.4) → ET-002 (Plane=Done) **попадает** в actionable-выборку.
- В `_reconcile_plane_issue` (~295) срабатывает ветка `new_state == approved and task is not None``handle_verdict(approved)` (no-op, задача уже done) **+ безусловный `_note_unblock`**.
- `_note_unblock` (~317) вызывается **сразу после `_dispatch`, не проверяя фактическое изменение состояния** — хотя его docstring обещает «fires only on an actual state change, never per idle tick». Инвариант нарушен.
3. **Два независимых дефекта складываются:**
- **D1 (выборка):** терминальные статусы (`Done`/`Cancelled`) не исключены из actionable-набора F-2; на «схлопывающих» проектах Done не отличается от approved по голому UUID.
- **D2 (нотификация):** `_note_unblock` срабатывает безусловно после no-op dispatch, а не только при подтверждённом state change.
4. **Почему `get_project_states` схлопывает:** функция строит маппинг по *именам* статусов из Plane API, затем недостающие ключи добивает из `_DEFAULT_STATES` (enduro-значения). После ORCH-066 набор статусов enduro изменился — голый UUID перестал однозначно различать `Done` (completed-группа) и `approved` (review). Группа состояния (`state.group`) при этом различает их корректно, но в коде не используется.
## 4. Связанный баг (BUG КЭША СТАТУСОВ, найден 2026-06-07 при деплое ORCH-066)
`_STATES_CACHE` (`src/plane_sync.py` ~134) кэширует статусы Plane на **весь lifetime процесса**. После создания нового Plane-статуса (напр. `Confirm Deploy`) боевой процесс держит устаревший набор → webhook на новый статус даёт «no pipeline action» (Phase B не триггерится). Лечилось только рестартом орка. Примитив сброса уже есть — `reload_project_states()` — но он нигде не вызывается автоматически.
Оба бага — следствие хрупкости статусной модели после ORCH-066. **Решение:** вести их в одном work item (см. scope ниже), окончательное разделение — на усмотрение архитектора.
## 5. Цели (Goals)
- G1. Reconciler НЕ шлёт «разблокирована» для синхронизированной done/cancelled задачи (восстановить инвариант silence-when-in-sync).
- G2. `_note_unblock` срабатывает **только** при реальном state change (соблюдён AC-9/AC-10).
- G3. Дедуп: нет повторного спама по той же задаче без изменения её состояния.
- G4. Корректное различение терминальных (`Done`/`Cancelled`) и review-статусов (`approved`/`rejected`) даже на проектах, «схлопывающих» их по UUID — на всех проектах (enduro И orchestrator).
- G5 (secondary). Устаревший `_STATES_CACHE` обновляется без рестарта процесса (TTL / flush-on-unknown / endpoint).
## 6. Не-цели (Out of scope)
- N1. Менять source-of-truth: ориентир F-2 на Plane **остаётся** корректным по дизайну (таблица `tasks` без status-колонки; статусы двигает человек в Plane). Идею F-2 НЕ переписываем — баг в маппинге/нотификации, не в концепции.
- N2. Менять реестры `STAGE_TRANSITIONS` / `QG_CHECKS`, схему БД, контракты гейтов.
- N3. Менять поведение F-1 (gate-side) и F-3.
- N4. Полный авто-approve деплоя (ORCH-54).
## 7. Затронутые стороны
- **Все проекты на одном инстансе** (enduro-trails + orchestrator, общая БД/очередь) — баг проявился на ET-002, но фикс выборки терминалов обязан быть проектно-независимым.
- **Self-hosting:** правка идёт в работающий прод-инструмент → обязательна страховка staging (8501), запрет на рестарт прод-контейнера в рамках задачи.
## 8. Критерий успеха (бизнес)
Тик reconciler для синхронизированной done/cancelled задачи = **0 уведомлений, 0 jobs, 0 токенов**. Telegram-спам прекращён. Легитимная разблокировка (реально потерянный approved/in_progress webhook) по-прежнему работает (нет регресса F-2).

View File

@@ -1,68 +0,0 @@
# ТЗ — ORCH-068: устранить livelock reconciler F-2 + спам unblock done-задачи
> Документ описывает ТРЕБОВАНИЯ к изменению поведения (что и где), а не выбор реализации. Конкретный механизм (state.group vs явный allowlist терминалов; TTL vs flush-on-unknown) — решение архитектора в ADR.
## 1. Затронутые модули `src/`
| Модуль | Роль в баге | Требуемое изменение |
|--------|-------------|---------------------|
| `src/reconciler.py` | F-2 `_reconcile_plane_project` / `_reconcile_plane_issue` / `_note_unblock` | Исключить терминальные статусы из actionable-выборки; `_note_unblock` только при подтверждённом state change; дедуп. |
| `src/plane_sync.py` | `get_project_states`, `list_issues_by_state`, `_STATES_CACHE` | Дать способ различать терминальные/review статусы (группа состояния); устранить вечно-устаревший кэш (TTL/flush). |
| `src/config.py` | флаги | (если нужны) новые kill-switch/настройки TTL — с дефолтами, не меняющими прод-инварианты. |
| `src/main.py` (`/queue`) | наблюдаемость | (опц.) отразить дедуп/skip-терминалов в снимке `reconcile`. |
**НЕ трогать:** `src/stages.py` (`STAGE_TRANSITIONS`), `src/qg/checks.py` (`QG_CHECKS`), схему БД, контракты `handle_status_start` / `handle_verdict`, F-1 (`reconcile_gate_once`), F-3.
## 2. Требования к F-2 (src/reconciler.py)
### TR-1. Исключить терминальные статусы из actionable-выборки
`_reconcile_plane_project` НЕ должен подавать задачи в терминальном Plane-статусе (`Done` и прочие completed-группы, `Cancelled`) ни в `list_issues_by_state`, ни в последующее сравнение веток.
- Требование проектно-независимое: работает на enduro И orchestrator, независимо от того, «схлопывает» ли проект статусы по UUID.
- Различение `Done`/`Cancelled` (completed) от `approved`/`rejected` (review) НЕ должно опираться только на голый UUID, если проект их алиасит. Допустимый ориентир — группа состояния Plane (`state.group`: `completed`/`started`/`unstarted`/`backlog`/`cancelled`) либо явный набор логических ключей терминалов. Выбор — за архитектором.
### TR-2. `_note_unblock` — только при реальном state change
`_note_unblock` (лог + Telegram + инкремент `unblocked_total`) ВЫЗЫВАЕТСЯ ТОЛЬКО когда диспетчеризованный обработчик фактически изменил состояние задачи (advance / replayed transition, реально сдвинувший стадию). No-op dispatch (задача уже в целевом состоянии) → нотификация НЕ отправляется.
- Сейчас `_dispatch` (`asyncio.run(coro_fn(...))`) отбрасывает результат, а `_note_unblock` зовётся безусловно. Требуется механизм подтверждения изменения (напр. сравнение стадии задачи до/после dispatch через `get_task_by_plane_id`, либо проброс сигнала из обработчика). Конкретику выбирает архитектор; контракт обработчиков `handle_*` менять НЕ обязательно (предпочтительно сравнение состояния до/после на стороне reconciler).
- Восстановить соответствие docstring `_note_unblock`: «Fires only on an actual state change … never per idle tick».
### TR-3. Дедуп / идемпотентность нотификаций
Reconciler НЕ должен слать повторное уведомление о той же задаче, если её состояние не менялось с прошлого тика. TR-1+TR-2 закрывают основной кейс (done более не входит в выборку и не нотифицируется); TR-3 — дополнительная страховка (best-effort), чтобы любой будущий no-op путь не дал повторного спама.
## 3. Требования к статус-кэшу (src/plane_sync.py) — secondary
### TR-4. Устаревший `_STATES_CACHE` обновляется без рестарта
После появления нового Plane-статуса процесс не должен бесконечно держать устаревший набор. Допустимые подходы (выбор архитектора, можно комбинировать):
- TTL на запись кэша (напр. `ORCH_PLANE_STATES_TTL_S`, дефолт разумный, 0/неуст. = прежнее поведение для совместимости);
- flush-on-unknown: при детекте неизвестного статуса в вебхуке/реконсилере — вызвать существующий `reload_project_states(pid)` и перезапросить;
- админ-эндпоинт/сигнал для ручного flush без рестарта.
`reload_project_states()` уже существует — переиспользовать как примитив сброса, новую логику сброса не дублировать.
## 4. Изменения API
- Новых обязательных endpoint'ов нет.
- Опционально (TR-4, на усмотрение архитектора): admin-эндпоинт сброса кэша статусов (напр. `POST /admin/plane-states/reload`) — если выбран этот вариант flush. Должен быть защищён/идемпотентен; документировать в README таблице API.
- Снимок `GET /queue` (блок `reconcile`) — без ломающих изменений; допустимо добавить поля наблюдаемости (skip-терминалов / dedup-счётчик).
## 5. Изменения схемы БД
**Нет.** Дедуп TR-3 реализуется in-memory (best-effort, как существующие счётчики `unblocked_total`/`last_unblocked`, AC-11 ORCH-053 допускает их сброс при рестарте) либо через сравнение живого состояния Plane/БД. Миграции не требуются.
## 6. Требования к новым QG checks
Нет. Реестр `QG_CHECKS` не меняется.
## 7. Инварианты (обязаны сохраниться)
- INV-1. Source of truth F-2 — Plane (НЕ меняем).
- INV-2. never-raise на единицу работы (per-issue / per-project / per-tick) сохранён.
- INV-3. Kill-switch `ORCH_RECONCILE_ENABLED` (+ `ORCH_RECONCILE_PLANE_ENABLED` гасит только F-2) — работают.
- INV-4. F-1 / F-3 поведение не изменено.
- INV-5. 0 jobs / 0 токенов для синхронизированных задач (как сейчас) сохранено.
- INV-6. Легитимная разблокировка реально-потерянного approved/in_progress webhook продолжает работать (нет регресса F-2).
- INV-7. Self-hosting: тик reconciler НИКОГДА не рестартит/не роняет прод-контейнер.
## 8. Артефакты pipeline, которые надо обновить в ТОМ ЖЕ PR
- `CLAUDE.md` — если меняется наблюдаемое поведение reconciler/кэша (раздел про reconciler/правила).
- `docs/architecture/README.md` — секция «Reconciler … (ORCH-053)»: уточнить исключение терминалов + дедуп; при TR-4 — секция «Plane Sync».
- `docs/architecture/adr/adr-0007-reconciler.md` (или новый per-WI ADR `docs/work-items/ORCH-068/06-adr/ADR-001-…`) — зафиксировать решение по терминалам/группе состояния и по кэшу.
- `CHANGELOG.md` — запись о фиксе (`fix:`).
- `.env.example` / `.env.staging` — если введён новый флаг (TTL/kill-switch).
## 9. Замечания по приёмке/тестированию
- Регресс-тест ОБЯЗАН покрывать: задача в `Done` (синхронизирована) → тик F-2 = 0 нотификаций, на enduro И orchestrator проектах (terminal не зависит от алиасинга).
- Тест НЕ должен делать реальных сетевых вызовов — мокать `list_issues_by_state` / `get_project_states` / `send_telegram` / `_dispatch` (handle_*).

View File

@@ -1,84 +0,0 @@
# Критерии приёмки — ORCH-068
Формат: каждый AC имеет чёткое условие PASS/FAIL. Задача принимается только при ВСЕХ PASS.
## Основное (P0) — livelock / спам
### AC-1. Синхронизированная done-задача → тишина
**Дано:** задача в Plane `state=Done`, БД `stage=done`, активных job нет.
**Когда:** выполняется один тик F-2 (`reconcile_plane_once` / `_reconcile_plane_project`).
- PASS: `_note_unblock` НЕ вызван; `send_telegram` НЕ вызван; `unblocked_total` не изменился; создано 0 jobs.
- FAIL: любое уведомление/лог «разблокирована»/инкремент счётчика для этой задачи.
### AC-2. Терминалы исключены из actionable-выборки
**Дано:** проект, где `Done` (и/или `Cancelled`) по UUID совпадает/«схлопнут» с `approved`/`rejected`.
**Когда:** `_reconcile_plane_project` формирует набор и обходит issues.
- PASS: issue в терминальном статусе (completed-группа / `Cancelled`) НЕ попадает ни в одну из веток `in_progress/approved/rejected`; для неё F-2 — no-op silence.
- FAIL: терминальная issue заходит в ветку approved/rejected/in_progress.
### AC-3. `_note_unblock` только при реальном state change
**Дано:** dispatch обработчика (`handle_verdict`/`handle_status_start`) фактически НЕ изменил стадию задачи (no-op, задача уже в целевом состоянии).
- PASS: `_note_unblock` НЕ вызван.
- FAIL: `_note_unblock` вызван после no-op dispatch.
### AC-4. Дедуп по неизменному состоянию
**Дано:** две последовательные итерации тика по одной и той же синхронизированной задаче, состояние между тиками не менялось.
- PASS: суммарно 0 повторных уведомлений по этой задаче.
- FAIL: повторное уведомление на втором тике без изменения состояния.
### AC-5. Нет регресса легитимной разблокировки F-2
**Дано:** задача, у которой Plane=`Approved`, а локальная стадия НЕ продвинулась (реально потерянный verdict-webhook), grace выдержан, активных job нет.
**Когда:** тик F-2.
- PASS: `handle_verdict(approved)` доигран; задача продвинута; `_note_unblock` вызван РОВНО один раз (реальный state change).
- FAIL: задача не продвинута ИЛИ нотификация не отправлена ИЛИ отправлена многократно.
### AC-6. Аналогично для in_progress-старта и rejected-отката
- PASS: потерянный `In Progress` (task is None) → старт пайплайна + 1 unblock; потерянный `Rejected` → откат + 1 unblock — оба только при реальном изменении.
- FAIL: ложный/повторный unblock или отсутствие легитимного.
## Инварианты (P0)
### AC-7. Деньги/ресурсы не тратятся на синхронизированные задачи
- PASS: 0 jobs, 0 agent_runs, 0 токенов для done/cancelled задач (как до бага).
- FAIL: любой созданный job/agent_run.
### AC-8. never-raise сохранён
**Дано:** `list_issues_by_state` / `get_project_states` / `_dispatch` / `send_telegram` бросают исключение.
- PASS: тик не падает; ошибка изолирована (per-issue / per-project), логируется; остальные задачи обрабатываются.
- FAIL: непойманное исключение роняет тик/поток.
### AC-9. Kill-switch'и работают
- PASS: `ORCH_RECONCILE_ENABLED=false` → F-1 и F-2 не выполняются; `ORCH_RECONCILE_PLANE_ENABLED=false` → F-2 не выполняется, F-1 работает.
- FAIL: любой свитч не гасит соответствующую ветку.
### AC-10. F-1 / F-3 / реестры / схема БД не изменены
- PASS: `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, контракты `handle_*`, поведение F-1/F-3 — без изменений (diff не затрагивает).
- FAIL: любое изменение перечисленного.
### AC-11. Self-hosting безопасность
- PASS: ни один путь reconciler не рестартит/не роняет прод-контейнер `orchestrator`.
- FAIL: обратное.
## Secondary (P1) — кэш статусов
### AC-12. Устаревший `_STATES_CACHE` обновляется без рестарта
**Дано:** после старта процесса в Plane появился новый статус (его UUID отсутствует в кэше).
**Когда:** срабатывает выбранный механизм (TTL истёк / flush-on-unknown / ручной flush).
- PASS: следующий `get_project_states` возвращает свежий набор, включающий новый статус; webhook на новый статус даёт корректное pipeline-действие БЕЗ рестарта.
- FAIL: процесс продолжает отдавать устаревший набор → «no pipeline action».
### AC-13. Совместимость кэша по умолчанию
- PASS: при дефолтных настройках (TTL не задан / flush не сработал) поведение `get_project_states` не регрессирует; enduro по-прежнему получает свои UUID, fallback на `_DEFAULT_STATES` при недоступности API сохранён.
- FAIL: регресс резолва статусов или потеря fallback.
## Документация (P0 по правилам проекта)
### AC-14. Документация обновлена в том же PR
- PASS: обновлены применимые из {`docs/architecture/README.md` (Reconciler/Plane Sync), ADR, `CHANGELOG.md`, `CLAUDE.md`, `.env.example`}; reviewer подтверждает.
- FAIL: поведение изменено, доки/ADR/CHANGELOG не обновлены.
## Тесты (P0)
### AC-15. `pytest tests/ -q` зелёный
- PASS: весь набор тестов проходит; добавлены регресс-тесты из `04-test-plan.yaml`, включая done→silence на enduro и orchestrator.
- FAIL: любой красный тест или отсутствие регресс-теста на основной баг.

View File

@@ -1,122 +0,0 @@
work_item: ORCH-068
description: >
Регрессионные и модульные тесты на устранение livelock reconciler F-2
(спам _note_unblock для синхронизированной done-задачи) и связанного бага
кэша статусов. Все тесты офлайн: Plane API / Telegram / dispatch мокаются.
Целевые модули: src/reconciler.py, src/plane_sync.py.
tests:
# ---------- P0: основной баг (livelock / спам) ----------
- id: TC-01
type: unit
description: >
Синхронизированная done-задача (Plane=Done, БД=done, нет активных job):
один тик F-2 -> _note_unblock НЕ вызван, send_telegram НЕ вызван,
unblocked_total не изменился, 0 jobs. (AC-1, AC-7)
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-02
type: unit
description: >
Терминал «схлопнут» с approved по UUID: issue в Done с тем же UUID, что и
approved-набор, НЕ заходит ни в одну ветку in_progress/approved/rejected
(silence). Проверка проектно-независимого исключения терминалов. (AC-2)
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-03
type: unit
description: >
Cancelled терминал также исключён из actionable-выборки -> тик = silence,
0 нотификаций. (AC-2)
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-04
type: unit
description: >
_note_unblock не вызывается после no-op dispatch: handle_verdict не сдвинул
стадию (задача уже в целевом состоянии) -> 0 нотификаций. (AC-3)
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-05
type: unit
description: >
Дедуп: два последовательных тика по одной синхронизированной задаче без
изменения состояния -> суммарно 0 повторных уведомлений. (AC-4)
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-06
type: unit
description: >
Нет регресса: Plane=Approved, локальная стадия не продвинута, grace выдержан,
нет активных job -> handle_verdict доигран, задача продвинута, _note_unblock
вызван РОВНО один раз. (AC-5)
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-07
type: unit
description: >
Нет регресса для in_progress (task is None -> старт пайплайна, 1 unblock) и
rejected (task существует -> откат, 1 unblock), оба только при реальном
изменении состояния. (AC-6)
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-08
type: unit
description: >
never-raise: list_issues_by_state / get_project_states / _dispatch /
send_telegram бросают исключение -> тик не падает, ошибка изолирована и
залогирована, прочие issues обработаны. (AC-8)
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-09
type: unit
description: >
Kill-switch: reconcile_enabled=False -> F-2 не выполняется;
reconcile_plane_enabled=False -> F-2 не выполняется, F-1 не затронут. (AC-9)
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-10
type: integration
description: >
End-to-end F-2 на двух проектах (enduro И orchestrator): задача в Done на
каждом -> тик reconcile_plane_once = 0 нотификаций / 0 jobs на обоих,
независимо от алиасинга статусов проекта. Главный регресс-тест бага. (AC-1, AC-2)
module: tests/test_reconciler_plane.py
expected: PASS
# ---------- P1: связанный баг кэша статусов ----------
- id: TC-11
type: unit
description: >
Устаревший _STATES_CACHE обновляется без рестарта: после появления нового
статуса срабатывает выбранный механизм (TTL/flush) -> следующий
get_project_states содержит новый статус. (AC-12)
module: tests/test_plane_states_cache.py
expected: PASS
- id: TC-12
type: unit
description: >
Совместимость по умолчанию: при дефолтных настройках get_project_states не
регрессирует — enduro отдаёт свои UUID, fallback на _DEFAULT_STATES при
недоступности API сохранён. (AC-13)
module: tests/test_plane_states_cache.py
expected: PASS
# ---------- P0: общий прогон ----------
- id: TC-13
type: integration
description: >
Полный набор pytest tests/ -q зелёный (нет регресса в reconciler/plane/qg/
stage_engine). (AC-15)
module: tests/
expected: PASS

View File

@@ -1,162 +0,0 @@
# ADR-001 (ORCH-068): Исключение терминалов из F-2 по группе состояния + подтверждённый unblock + TTL кэша статусов
- **Статус:** Accepted
- **Дата:** 2026-06-08
- **Задача:** ORCH-068 (BUG: reconciler livelock — спам «разблокирована» по синхронизированной done-задаче)
- **Сквозной ADR:** уточняет [adr-0007-reconciler.md](../../../architecture/adr/adr-0007-reconciler.md) (F-2) — реестры/схема НЕ меняются
- **Связанные:** ORCH-053 (reconciler F-2), ORCH-066 (новая статусная модель Plane — триггер регрессии), ORCH-060 (F-1 пред-гарды), ORCH-10 (`get_project_states`)
## Контекст
Reconciler F-2 (`src/reconciler.py`, `_reconcile_plane_project` / `_reconcile_plane_issue`)
опрашивает Plane per-project и доигрывает потерянные webhook-переходы через штатные
`handle_status_start` / `handle_verdict`. После мерджа ORCH-066 (новая статусная модель
Plane) на проде с 22:17 UTC reconciler каждые ~120с слал в Telegram
`reconciler: ET-002 done разблокирована (потерян webhook)` для задачи ET-002, которая
полностью синхронизирована (БД `stage=done`, Plane `state=Done` с 2026-05-21). 191+
сообщений за ночь — livelock без advance/jobs/токенов, но подрывающий доверие alert-fatigue.
Диагностика (BRD §3) выявила **два независимых, складывающихся дефекта**:
- **D1 (выборка):** F-2 различает actionable-статусы по **голому UUID**
(`in_progress`/`approved`/`rejected`). `get_project_states` строит маппинг по *именам*
статусов, недостающие ключи добивает из `_DEFAULT_STATES` (enduro-значения). После
ORCH-066 набор имён enduro изменился → терминальный `Done` перестал однозначно
отличаться от `approved` по UUID, и ET-002 (Plane=Done) **попала** в actionable-набор
ветки `approved`. Терминальные статусы (`Done`/`Cancelled`) нигде не исключаются из F-2.
- **D2 (нотификация):** `_note_unblock` вызывается **безусловно сразу после `_dispatch`**,
не проверяя, изменил ли обработчик реально состояние задачи. `handle_verdict(approved)`
для уже-`done` задачи — no-op, но нотификация всё равно уходит. Это прямо нарушает
собственный docstring `_note_unblock` («fires only on an actual state change, never per
idle tick») и инвариант silence-when-in-sync (AC-9/AC-10 ORCH-053).
Связанный secondary-баг (BRD §4): `_STATES_CACHE` (`src/plane_sync.py`) кэширует статусы
на **весь lifetime процесса**. После появления нового Plane-статуса боевой процесс держит
устаревший набор → webhook на новый статус даёт «no pipeline action», лечилось только
рестартом орка. Примитив сброса `reload_project_states()` уже есть, но автоматически не
вызывается.
Ограничения (из ТЗ, обязаны сохраниться): источник истины F-2 — Plane (не переписываем);
НЕ трогать `STAGE_TRANSITIONS` / `QG_CHECKS` / схему БД / контракты `handle_*` / F-1 / F-3;
never-raise per unit of work; kill-switch'и; 0 jobs/0 токенов для синхронизированных задач;
self-hosting — reconciler НИКОГДА не рестартит прод-контейнер.
## Решение
Чиним **оба** дефекта независимыми слоями (defense in depth, как принято в проекте —
ORCH-058) плюс TTL для кэша. Все правки локальны в `src/reconciler.py` и
`src/plane_sync.py`; реестры, схема БД и контракты обработчиков не меняются.
### Слой D1 — исключение терминалов по ГРУППЕ состояния (TR-1, AC-2)
Различаем терминальные (`completed`/`cancelled`) и review/work-статусы по **группе
состояния Plane** (`state.group ∈ {backlog, unstarted, started, completed, cancelled}`),
а НЕ по голому UUID. Группа — авторитетный, проектно-независимый дискриминатор: она
корректно различает `Done` (completed) и `approved` (started/review) даже когда проект
«схлопывает» их по UUID после переименований.
Механика (single API fetch, без новых сетевых вызовов):
- `/states/`-ответ Plane содержит для каждого статуса поле `group`. Расширяем кэш-запись
`_STATES_CACHE` так, чтобы из ОДНОГО запроса хранить и текущий `{logical_key → uuid}`,
и `{uuid → group}`. `get_project_states` сохраняет **прежнюю сигнатуру и форму возврата**
(`{logical_key: uuid}`) — обратная совместимость (AC-13). Добавляется sibling-аксессор
`get_project_state_groups(project_id) -> dict[uuid, group]` (или эквивалент), читающий ту
же кэш-запись.
- В `_reconcile_plane_issue` ДО выбора ветки: если группа `new_state`
{`completed`, `cancelled`} → **тишина** (return, no-op). Fallback, когда группа
недоступна (API не отдал `group` / fallback на `_DEFAULT_STATES`): исключать по логическим
ключам терминалов `{states.get("done"), states.get("cancelled")}`.
Терминал-исключение применяется **per-issue** (а не сужением `wanted`-набора
`list_issues_by_state`), потому что при UUID-алиасинге терминал может физически совпадать с
actionable-UUID в `wanted` — фильтрация по UUID его не отсечёт, а проверка группы отсечёт.
### Слой D2 — `_note_unblock` только при подтверждённом state change (TR-2, AC-3)
`_note_unblock` (лог + Telegram + `unblocked_total`) вызывается ТОЛЬКО когда диспетчеризованный
обработчик **фактически изменил состояние задачи**. Реализация — сравнение состояния
**до/после на стороне reconciler** (предпочтение ТЗ; контракты `handle_*` НЕ меняются):
- `approved`/`rejected` (task существует): захватить `stage_before` (из уже прочитанного
`task`), после `_dispatch` перечитать `get_task_by_plane_id(issue_id)``stage_after`;
`_note_unblock` только если `stage_after != stage_before`.
- `in_progress` + `task is None` (старт пайплайна): подтверждение = задача **появилась**
после dispatch (`get_task_by_plane_id` теперь не None).
No-op dispatch (задача уже в целевом состоянии) → 0 нотификаций. Восстанавливает соответствие
docstring и инвариант silence-when-in-sync.
### Слой TR-3 — дедуп нотификаций (страховка, AC-4)
In-memory best-effort guard: `{issue_id → last_unblocked_state_uuid}`. `_note_unblock` для
issue+state, уже отмеченного, подавляется. Сбрасывается при рестарте (допустимо — AC-11
ORCH-053, как `unblocked_total`/`last_unblocked`). D1+D2 закрывают основной кейс; TR-3 —
дополнительная сетка против любого будущего no-op-пути.
### Слой TR-4 — TTL кэша статусов (secondary, AC-12/AC-13)
Кэш-запись `_STATES_CACHE` хранит timestamp; `get_project_states` перезапрашивает API при
истечении `ORCH_PLANE_STATES_TTL_S`. Примитив инвалидации — существующий
`reload_project_states()` (не дублируем логику сброса). Новый флаг
`plane_states_ttl_s` (env `ORCH_PLANE_STATES_TTL_S`):
- дефолт **300** (5 мин) — устаревший набор самозалечивается без рестарта (G5);
- `0` — отключает TTL → строго прежний lifetime-кэш (escape hatch / strict back-compat).
Fallback на `_DEFAULT_STATES` при недоступности API сохранён без изменений; TTL-перезапрос
возвращает тот же корректный набор → не регресс (AC-13).
## Альтернативы
- **Явный allowlist логических ключей терминалов (`done`/`cancelled`) без группы** —
отклонён как primary: хрупок к будущим переименованиям/добавлению completed-статусов
(`Monitoring after Deploy` и т.п.) и к UUID-алиасингу. Оставлен как **fallback**, когда
`group` недоступен.
- **Сужение `wanted`-набора в `list_issues_by_state`** — недостаточно: при UUID-алиасинге
терминал совпадает с actionable-UUID и не отсекается фильтром по UUID. Нужна проверка
группы per-issue.
- **Проброс «changed»-сигнала из `handle_*`** — отклонён: меняет контракт обработчиков
(запрещено ТЗ N2). Выбрано сравнение до/после на стороне reconciler.
- **Флаг подавления нотификаций в `advance_stage`** — отклонён (как и в adr-0007):
трогает общий критический путь.
- **flush-on-unknown как primary для кэша** — допустимо ТЗ и дешевле, но недетерминирован
для юнит-теста (TC-11) и не лечит «тихий устаревший набор» без триггера-вебхука. Выбран
TTL (детерминированный, самозалечивающий); flush-on-unknown может быть добавлен позже как
комплемент, переиспользуя `reload_project_states`.
- **Admin-эндпоинт `POST /admin/plane-states/reload`** — отклонён в объёме (требует
ручного действия, не лечит автоматически); TTL покрывает G5 без нового API.
## Последствия
- **Плюсы:** livelock устранён двумя независимыми слоями; терминал-исключение
проектно-независимо (enduro И orchestrator), устойчиво к будущим переименованиям статусов;
`_note_unblock` снова соответствует своему контракту; устаревший кэш самозалечивается без
рестарта прода. Реестры/схема/контракты/F-1/F-3 не тронуты.
- **Минусы / плата:** один доп. accessor группы (тот же API-запрос, без новой сетевой
стоимости); TTL добавляет редкий перезапрос `/states/` (раз в 5 мин/проект); дедуп-словарь
— небольшая in-memory структура, неперсистентная (приемлемо).
- **Совместимость:** `get_project_states` форма возврата неизменна; `plane_states_ttl_s=0`
→ строго прежнее поведение кэша; `_DEFAULT_STATES`-fallback сохранён.
- **Self-hosting:** ни один путь не рестартит прод-контейнер (AC-11); правка
обязательно проходит staging-гейт (8501) перед прод-деплоем орка.
- **Наблюдаемость (опц.):** допустимо добавить в блок `reconcile` снимка `GET /queue`
счётчики `skipped_terminal` / `deduped` без ломающих изменений.
## Инварианты (подтверждение)
INV-1 источник истины F-2 = Plane — сохранён (правим маппинг/нотификацию, не концепцию).
INV-2 never-raise per-issue/-project/-tick — сохранён (новый guard в том же try-периметре).
INV-3 kill-switch'и `ORCH_RECONCILE_ENABLED` / `ORCH_RECONCILE_PLANE_ENABLED` — без изменений.
INV-4 F-1/F-3 — не тронуты. INV-5 0 jobs/0 токенов для done/cancelled — восстановлен.
INV-6 легитимная разблокировка реально-потерянного approved/in_progress — работает (D2
подтверждает реальный change, не подавляет его). INV-7 self-hosting — тик не рестартит прод.
## Объём изменений (для разработчика)
- `src/reconciler.py`: терминал-гард по группе + fallback в `_reconcile_plane_issue`;
before/after-сравнение стадии вокруг `_dispatch`; in-memory дедуп-словарь в `Reconciler`.
- `src/plane_sync.py`: кэш-запись с timestamp + `{uuid→group}`; `get_project_state_groups`;
TTL-логика в `get_project_states` (переиспользуя `reload_project_states`).
- `src/config.py`: флаг `plane_states_ttl_s` (env `ORCH_PLANE_STATES_TTL_S`, дефолт 300).
- `.env.example` / `.env.staging`: задокументировать `ORCH_PLANE_STATES_TTL_S`.
- Доки в ТОМ ЖЕ PR: `docs/architecture/README.md` (Reconciler/Plane Sync), `CHANGELOG.md`
(`fix:`), `CLAUDE.md` (при изменении наблюдаемого поведения), этот ADR.
- Тесты: `04-test-plan.yaml` (TC-01…TC-13), офлайн (мок Plane/Telegram/`_dispatch`).

View File

@@ -1,17 +0,0 @@
# Технические риски — ORCH-068
| ID | Риск | Вероятность | Влияние | Митигация |
|----|------|-------------|---------|-----------|
| R-1 | Plane `/states/` не отдаёт поле `group` (старая версия API / урезанный ответ) → терминал-исключение по группе не срабатывает | Низкая | Высокое (рецидив livelock) | Fallback на логические ключи терминалов `{done, cancelled}` при отсутствии `group`; never-raise → консервативная тишина при сбое резолва |
| R-2 | Over-exclusion: легитимная задача в started/review-группе ошибочно классифицирована как терминал → пропущена легитимная разблокировка (регресс INV-6) | Низкая | Среднее | Исключаем ТОЛЬКО группы `completed`/`cancelled`; `approved`/`rejected` относятся к started/unstarted → не задеты; регресс-тесты TC-06/TC-07 |
| R-3 | Гонка before/after: между `stage_before` и `stage_after` живой webhook двигает стадию → ложный `_note_unblock` | Очень низкая | Низкое | active-job guard + `max_concurrency=1` уже сериализуют; дедуп TR-3 подавляет повтор; ложный unblock безвреден (0 jobs/токенов) |
| R-4 | TTL `300s` провоцирует частые `/states/`-перезапросы при многих проектах | Низкая | Низкое | 1 запрос/проект/5 мин — пренебрежимо; `ORCH_PLANE_STATES_TTL_S=0` отключает TTL |
| R-5 | TTL-перезапрос в момент недоступности Plane → временный fallback на `_DEFAULT_STATES` (enduro) для не-enduro проекта | Низкая | Среднее | Поведение идентично текущему cold-cache fallback; самозалечивается следующим успешным запросом; не хуже статус-кво |
| R-6 | Дедуп-словарь растёт неограниченно (по issue_id) | Очень низкая | Низкое | Ключи — только реально разблокированные issue (редки); сбрасывается при рестарте; при необходимости — ограничить размер/LRU |
| R-7 | Изменение в `get_project_states` (кэш-запись) ломает прочих потребителей формы возврата | Низкая | Высокое | Внешняя сигнатура и форма `{logical_key: uuid}` сохранены; группа — отдельный accessor; покрыто TC-12 (совместимость по умолчанию) |
| R-8 | Self-hosting: правка в работающем прод-инструменте | — | Высокое | Обязательный staging-гейт (8501); запрет рестарта прод-контейнера в рамках задачи; INV-7 |
## Замечания
- Все правки локальны (`reconciler.py`, `plane_sync.py`, `config.py`); схема БД, реестры
`STAGE_TRANSITIONS`/`QG_CHECKS`, контракты `handle_*`, F-1/F-3 — не затронуты (AC-10).
- Тесты офлайн (мок Plane API / Telegram / `_dispatch`) — сетевых вызовов в CI нет.

View File

@@ -1,47 +0,0 @@
---
type: review
work_item_id: ORCH-068
verdict: APPROVED
version: 1
---
# Review ORCH-068
## Summary
Фикс livelock reconciler F-2 (спам `_note_unblock` по синхронизированной done-задаче после ORCH-066) реализован чисто и полностью по ТЗ/ADR. Два независимых слоя (D1 терминал-исключение по группе состояния + D2 подтверждённый state change) плюс TR-3 дедуп и TR-4 TTL кэша. Правки строго локальны в `src/reconciler.py` (F-2), `src/plane_sync.py`, `src/config.py`. Запрещённые ТЗ артефакты (`STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, контракты `handle_*`, F-1, F-3) не тронуты — diff не выходит за 3 файла `src/`. `pytest tests/ -q`**764 passed**.
## Соответствие ТЗ
- **TR-1** (исключить терминалы) ✅ — `_is_terminal_state` по `state.group ∈ {completed, cancelled}` с fallback на логические ключи `done`/`cancelled`; проверка per-issue (а не сужением `wanted`), что корректно для UUID-алиасинга.
- **TR-2** (`_note_unblock` только при реальном change) ✅ — `_stage_changed` (сравнение стадии до/после `_dispatch`), для in_progress-старта подтверждение = задача появилась; контракты `handle_*` не менялись.
- **TR-3** (дедуп) ✅ — in-memory guard `{issue_id → state_uuid}`, best-effort, сброс при рестарте (как `unblocked_total`).
- **TR-4** (TTL кэша) ✅ — `plane_states_ttl_s` (дефолт 300, `0`=lifetime), переиспользует `reload_project_states`; форма возврата `get_project_states` неизменна; при сбое перезапроса отдаётся stale-but-correct набор.
## Соответствие ADR
ADR-001 (terminal-exclusion-and-cache-ttl) реализован 1:1: группа как primary-дискриминатор, allowlist-fallback, before/after-сравнение на стороне reconciler, TTL с инвалидацией через существующий примитив. Сквозной adr-0007 дополнен корректной ссылкой. Все инварианты INV-1…INV-7 сохранены (источник истины Plane, never-raise per-issue, kill-switch'и, F-1/F-3 нетронуты, self-hosting не рестартит прод).
## Критерии приёмки
AC-1…AC-15 — все PASS. Покрытие тестами адресное: TC-01 (synced Done silence), TC-02 (aliased terminal по группе — ядро D1), TC-03 (Cancelled), TC-04 (no-op silence), TC-05 (дедуп), TC-06/TC-07 (легитимный unblock ×1), TC-08 (never-raise изоляция), TC-09 (kill-switch), TC-10 (enduro И orchestrator — headline-регресс), TC-11/TC-12 (TTL self-heal + back-compat + stale-on-failure).
## Findings
### P0 — Blocker
- нет
### P1 — Must fix
- нет
### P2 — Should fix
- нет
### P3 — Nice-to-have
- [ ] Дедуп-guard ключуется по `issue_id → state_uuid` без сброса при смене состояния. Теоретический edge-case: задача legitимно проходит `approved`(uuid_X)→…→снова `approved`(тот же uuid_X) — повторное (но легитимное) уведомление будет подавлено. Функционального ущерба нет (advance выполняется в `_dispatch` независимо от нотификации), это потеря только уведомления, и D2 — основной гард. Best-effort по контракту ТЗ. Можно при желании чистить запись при детекте смены состояния away-and-back. Не блокирует.
## Документация
Обновлена полно и в том же PR (AC-14 PASS):
- `docs/architecture/README.md` — компонент **Plane Sync** (TTL + `{uuid→group}`) и секция **Reconciler/F-2/F-4** (терминал-исключение, дедуп, счётчики `skipped_terminal_total`/`deduped_total`); футер «обновлять при изменении» расширен записью ORCH-068.
- `docs/architecture/adr/adr-0007-reconciler.md` — добавлена кросс-ссылка на per-WI ADR.
- `docs/work-items/ORCH-068/06-adr/ADR-001-…` — детальный ADR (Accepted).
- `CHANGELOG.md` — запись `### Fixed` (D1/D2/TR-3/TR-4, инварианты, тесты).
- `.env.example``ORCH_PLANE_STATES_TTL_S=300` с комментарием.
Изменение `src/` сопровождено соответствующим обновлением документации — требование golden-source выполнено.

View File

@@ -1,64 +0,0 @@
---
type: test-report
work_item_id: ORCH-068
result: PASS
---
# Test Report — ORCH-068
Фикс livelock reconciler F-2 (спам `_note_unblock` по синхронизированной
done-задаче) + связанный баг устаревшего `_STATES_CACHE`.
## Окружение
- Python: 3.12.13
- pytest: 8.3.3 (plugins: anyio-4.13.0, asyncio-0.23.8)
- Среда исполнения: worktree `feature/ORCH-068-bug-reconciler-livelock-unbloc`
- Prod health (8500): `{"status":"ok","service":"orchestrator"}` — OK (read-only smoke)
- Дата: 2026-06-08T05:13:59Z
## Smoke test API (read-only, прод 8500 не трогался деструктивно)
| Endpoint | Результат |
|----------|-----------|
| `GET /health` | `{"status":"ok",...}` — OK |
| `GET /status` | 200, активные задачи отданы — OK |
| `GET /queue` | 200, counts + блок `reconcile` (enabled/plane_enabled/unblocked_total) — OK |
## Результаты
| TC ID | Описание | Тест | Результат |
|-------|----------|------|-----------|
| TC-01 | Синхронизированная done → тишина (AC-1, AC-7) | `test_tc01_synced_done_is_silent` | PASS |
| TC-02 | Терминал «схлопнут» с approved по UUID исключён (AC-2) | `test_tc02_terminal_aliased_to_approved_excluded` | PASS |
| TC-03 | Cancelled терминал исключён (AC-2) | `test_tc03_cancelled_excluded` | PASS |
| TC-04 | `_note_unblock` не вызван после no-op dispatch (AC-3) | `test_tc04_noop_dispatch_no_unblock` | PASS |
| TC-05 | Дедуп: 0 повторных уведомлений (AC-4) | `test_tc05_dedup_no_repeat_notification` | PASS |
| TC-06 | Легитимный approved → unblock ровно один раз (AC-5) | `test_tc06_legit_approved_unblock_once` | PASS |
| TC-07 | in_progress-старт и rejected-откат, каждый 1 unblock (AC-6) | `test_tc07_in_progress_start_and_rejected_each_one_unblock` | PASS |
| TC-08 | never-raise: изоляция ошибок (AC-8) | `test_tc08_never_raise_isolation` | PASS |
| TC-09 | Kill-switch'и F-2 (AC-9) | `test_tc09_kill_switches` | PASS |
| TC-10 | done→silence на enduro И orchestrator (headline-регресс, AC-1/AC-2) | `test_tc10_done_silent_on_all_projects` | PASS |
| TC-11 | Устаревший `_STATES_CACHE` self-heal по TTL (AC-12) | `test_tc11_stale_cache_refreshes_after_ttl` + accessor/zero-ttl тесты | PASS |
| TC-12 | Совместимость кэша по умолчанию + fallback (AC-13) | `test_tc12_enduro_uuids_unchanged`, `test_tc12_api_error_falls_back_to_defaults`, `test_tc12_stale_served_when_refresh_fails` | PASS |
| TC-13 | Полный прогон `pytest tests/` зелёный (AC-15) | весь набор | PASS |
## Соответствие критериям приёмки
AC-1…AC-15 — все PASS. Целевые регресс-тесты (TC-01..TC-12) проходят;
полный набор без регрессий в reconciler / plane / qg / stage_engine / webhooks.
## Вывод pytest
### Целевые файлы (tests/test_reconciler_plane.py + tests/test_plane_states_cache.py)
```
collected 26 items
... (все 26 PASSED, включая tc01..tc17 reconciler + tc11/tc12 cache)
======================== 26 passed, 1 warning in 0.82s =========================
```
### Полный набор
```
======================= 764 passed, 1 warning in 13.66s ========================
```
(1 warning — PydanticDeprecatedSince20 в src/config.py, не относится к ORCH-068, предсуществующий.)
## Итог
PASS

View File

@@ -1,33 +0,0 @@
---
staging_status: SUCCESS
timestamp: 2026-06-08T05:17:46Z
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed. Exit code 0 → SUCCESS.
Executed canonically inside the `orchestrator-staging` container (ORCH-048, ADR-001),
so the B6 registry-isolation check read the staging instance's own process-env
(`.env.staging` → SANDBOX-only registry):
```
docker exec orchestrator-staging \
python3 /repos/orchestrator/scripts/staging_check.py \
--base-url http://localhost:8501 --mode stub
```
## Result: 8/10 checks PASS
- REAL failed: none
- All REAL checks green (Block A SMOKE, Block B ACCESS incl. B6 registry isolation,
C7 create issue, C8 trigger pipeline).
INFRA-WAIVED: C9a Branch appears in orchestrator-sandbox, C9b Analyst job enqueued in staging queue (known sandbox-infra; real checks green)
VERDICT: SUCCESS (exit 0) — SUCCESS (infra-waived): ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue'] are known sandbox-infra checks; all real checks green
Per ORCH-061, the two infra-only checks C9a/C9b (which depend on SANDBOX bot accounts
being project members, not on the pipeline) are tolerated when every REAL check is
green; the script prints `INFRA-WAIVED:`/`VERDICT:` lines and exits 0. Verdict trusts
the exit code.

View File

@@ -265,18 +265,6 @@ class Settings(BaseSettings):
reconcile_notify_unblock: bool = True
reconcile_skip_blocked_enabled: bool = True
# ORCH-068: TTL for the per-project Plane states cache (_STATES_CACHE in
# plane_sync). Historically the cache lived for the whole process lifetime,
# so a status added to Plane after start was never seen without a restart
# ("stale set -> no pipeline action"). With a TTL the entry self-heals by
# re-fetching /states/ after it expires (invalidation reuses the existing
# reload_project_states() primitive — no duplicated reset logic).
# plane_states_ttl_s (env ORCH_PLANE_STATES_TTL_S):
# >0 -> seconds before a cache entry is re-fetched (default 300 = 5 min);
# 0 -> disable TTL -> strictly the previous lifetime cache (back-compat
# escape hatch). get_project_states return shape is unchanged.
plane_states_ttl_s: int = 300
# ORCH-021: post-deploy production monitoring + degradation reaction. After
# the terminal deploy->done transition for an applicable repo, a reserved-agent
# `post-deploy-monitor` job (no LLM, modelled on deploy-finalizer) probes prod

View File

@@ -1,7 +1,6 @@
"""Plane API sync — update issue state and add comments."""
import logging
import time
import httpx
from .config import settings
@@ -129,44 +128,26 @@ _PLANE_NAME_TO_KEY: dict[str, str] = {
"Needs Input": "needs_input",
"In Review": "in_review",
"Blocked": "blocked",
# ORCH-059: dedicated prod-deploy trigger status, distinct from the
# human-gate "Approved". Resolved from the live Plane API for the ORCH
# project; intentionally ABSENT from _DEFAULT_STATES so environments without
# this board status (enduro / API fallback) fail-closed — no UUID, no
# confirm-deploy branch, no KeyError (accessed via .get).
"Confirm Deploy": "confirm_deploy",
}
# Per-project state cache (ORCH-10 + ORCH-068).
#
# Each entry is a RECORD, not a bare mapping:
# {"states": {logical_key: state_uuid}, # the ORCH-10 mapping (unchanged shape)
# "groups": {state_uuid: group}, # ORCH-068 D1: {uuid -> Plane state.group}
# "ts": monotonic timestamp} # ORCH-068 TR-4: for TTL self-heal
# get_project_states() still RETURNS the bare {logical_key: state_uuid} mapping
# (backward compatible — AC-13); the richer record is internal.
_STATES_CACHE: dict[str, dict] = {}
def _cache_record_fresh(record: dict) -> bool:
"""ORCH-068 (TR-4): is a cache record still within its TTL?
``plane_states_ttl_s <= 0`` disables the TTL -> a record never expires
(strictly the previous lifetime-cache behaviour, back-compat escape hatch).
"""
ttl = settings.plane_states_ttl_s
if ttl <= 0:
return True
ts = record.get("ts", 0.0)
return (time.monotonic() - ts) <= ttl
# Per-project state cache: {project_id: {logical_key: state_uuid}}
_STATES_CACHE: dict[str, dict[str, str]] = {}
def get_project_states(project_id: str) -> dict[str, str]:
"""ORCH-10: resolve {logical_key -> state_uuid} for a specific Plane project.
Source of truth: Plane API GET /projects/<project_id>/states/.
Results are cached per project_id. ORCH-068 (TR-4): a cached entry is
re-fetched once it is older than ``plane_states_ttl_s`` (default 300s) so a
status added to Plane after start self-heals without a process restart;
``plane_states_ttl_s = 0`` keeps the previous lifetime cache.
Results are cached per project_id for the lifetime of the process.
Falls back to _DEFAULT_STATES (enduro-trails values) if:
* project_id is empty/None,
* the API call fails (network error, non-2xx) AND nothing is cached,
* the API call fails (network error, non-2xx),
* the response contains no recognisable states.
The enduro-trails project therefore returns the same UUIDs as before
@@ -176,9 +157,8 @@ def get_project_states(project_id: str) -> dict[str, str]:
if not project_id:
return _DEFAULT_STATES
cached = _STATES_CACHE.get(project_id)
if cached is not None and _cache_record_fresh(cached):
return cached["states"]
if project_id in _STATES_CACHE:
return _STATES_CACHE[project_id]
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/states/"
try:
@@ -191,21 +171,12 @@ def get_project_states(project_id: str) -> dict[str, str]:
raise ValueError(f"unexpected states response shape: {type(items)}")
resolved: dict[str, str] = {}
groups: dict[str, str] = {}
for item in items:
name = item.get("name", "")
uid = item.get("id", "")
key = _PLANE_NAME_TO_KEY.get(name)
if key and uid:
resolved[key] = uid
# ORCH-068 D1: capture {uuid -> group} for terminal-state detection
# (a single API fetch — no extra network cost). The group is the
# authoritative, project-independent discriminator of terminal
# (completed/cancelled) vs review/work statuses, robust to UUID
# aliasing after status renames (ORCH-066).
grp = item.get("group", "")
if uid and grp:
groups[uid] = grp
if not resolved:
raise ValueError("no recognisable states in API response")
@@ -215,26 +186,13 @@ def get_project_states(project_id: str) -> dict[str, str]:
for k, v in _DEFAULT_STATES.items():
resolved.setdefault(k, v)
_STATES_CACHE[project_id] = {
"states": resolved,
"groups": groups,
"ts": time.monotonic(),
}
_STATES_CACHE[project_id] = resolved
logger.debug(
f"get_project_states: cached {len(resolved)} states / "
f"{len(groups)} groups for project {project_id[:8]}..."
f"get_project_states: cached {len(resolved)} states for project {project_id[:8]}..."
)
return resolved
except Exception as e:
# On a transient API failure keep serving the stale (but project-correct)
# set if we have one — far safer than reverting to enduro defaults.
if cached is not None:
logger.warning(
f"get_project_states: API refresh failed for project "
f"{project_id[:8]}..., serving stale cached set. Error: {e}"
)
return cached["states"]
logger.warning(
f"get_project_states: API failed for project {project_id[:8]}..., "
f"falling back to _DEFAULT_STATES. Error: {e}"
@@ -242,23 +200,6 @@ def get_project_states(project_id: str) -> dict[str, str]:
return _DEFAULT_STATES
def get_project_state_groups(project_id: str) -> dict[str, str]:
"""ORCH-068 (D1): return {state_uuid -> group} for a Plane project.
Reads the SAME cache record populated by ``get_project_states`` (no extra
network call). Call ``get_project_states(project_id)`` first to ensure the
record is fresh/populated. Returns ``{}`` when nothing is cached (e.g. the
API was unreachable and the caller fell back to ``_DEFAULT_STATES``); the
reconciler then falls back to logical terminal keys.
"""
record = _STATES_CACHE.get(project_id)
if isinstance(record, dict):
groups = record.get("groups")
if isinstance(groups, dict):
return groups
return {}
def reload_project_states(project_id: str = None) -> None:
"""ORCH-10: clear the per-project states cache.

View File

@@ -60,12 +60,7 @@ from .stage_engine import (
MAX_DEVELOPER_RETRIES,
)
from .stages import get_qg_for_stage
from .plane_sync import (
fetch_issue_state,
get_project_states,
get_project_state_groups,
list_issues_by_state,
)
from .plane_sync import fetch_issue_state, get_project_states, list_issues_by_state
from .webhooks.plane import handle_status_start, handle_verdict
from .notifications import send_telegram
from . import projects
@@ -144,13 +139,6 @@ class Reconciler:
self.last_run_ts: float | None = None
self.unblocked_total: int = 0
self.last_unblocked: str | None = None
# ORCH-068 observability: terminal-state skips and dedup suppressions.
self.skipped_terminal_total: int = 0
self.deduped_total: int = 0
# ORCH-068 (TR-3): in-memory dedup guard {issue_id -> last unblocked
# state uuid}. Best-effort (resets on restart, like unblocked_total);
# suppresses a repeat unblock notification for the same issue+state.
self._unblock_dedup: dict[str, str] = {}
# -- F-1: gate-side ----------------------------------------------------
def reconcile_gate_once(self) -> None:
@@ -254,9 +242,6 @@ class Reconciler:
pid = proj.plane_project_id
# Resolve the actionable state uuids per-project (never hardcode).
states = get_project_states(pid)
# ORCH-068 D1: {uuid -> group} from the SAME cache record (no extra
# fetch); empty when the API was unreachable -> per-issue fallback by key.
groups = get_project_state_groups(pid)
in_progress = states["in_progress"]
approved = states["approved"]
rejected = states["rejected"]
@@ -264,36 +249,16 @@ class Reconciler:
for issue in issues:
try:
self._reconcile_plane_issue(
issue, pid, in_progress, approved, rejected, states, groups
issue, pid, in_progress, approved, rejected
)
except Exception as e: # noqa: BLE001 - isolate one issue's failure
logger.error(
f"reconciler F-2: issue {issue.get('id')} failed: {e}"
)
def _is_terminal_state(
self, state_uuid: str, states: dict, groups: dict
) -> bool:
"""ORCH-068 D1: is ``state_uuid`` a terminal (completed/cancelled) state?
Primary discriminator is the Plane **state group** (project-independent,
robust to UUID aliasing after status renames): ``group`` in
``{completed, cancelled}`` -> terminal. When the group is unavailable
(API gave no ``group`` / we fell back to ``_DEFAULT_STATES``), fall back
to the logical terminal keys ``done`` / ``cancelled``.
"""
if not state_uuid:
return False
grp = groups.get(state_uuid)
if grp:
return grp in {"completed", "cancelled"}
# Fallback (group unknown): logical terminal keys for this project.
return state_uuid in {states.get("done"), states.get("cancelled")}
def _reconcile_plane_issue(
self, issue: dict, project_id: str,
in_progress: str, approved: str, rejected: str,
states: dict, groups: dict,
) -> None:
issue_id = str(issue.get("id") or "")
if not issue_id:
@@ -301,15 +266,6 @@ class Reconciler:
state = issue.get("state")
new_state = state.get("id") if isinstance(state, dict) else state
# ORCH-068 D1: a terminal issue (Done / Cancelled) is fully in sync by
# definition -> never actionable. Excluded per-issue (not by narrowing
# `wanted`) because UUID aliasing can make a terminal uuid collide with
# an actionable one — only the state GROUP disentangles them. Restores
# the silence-when-in-sync invariant (AC-1/AC-2).
if self._is_terminal_state(new_state, states, groups):
self.skipped_terminal_total += 1
return
# Grace ("lost, not merely delayed"): use the issue's own updated_at age.
# A missing/unparseable timestamp is treated as old enough (the active-job
# guard + atomic create-claim still prevent doubling).
@@ -334,41 +290,18 @@ class Reconciler:
if new_state == in_progress and task is None:
# In Progress without a task -> start the pipeline (lost start webhook).
# ORCH-068 D2: confirm a REAL change (the task now exists) before
# announcing — a no-op dispatch stays silent.
self._dispatch(handle_status_start, issue_data, project_id)
if get_task_by_plane_id(issue_id) is not None:
self._note_unblock(issue_id, "analysis", new_state)
self._note_unblock(issue_id, "analysis")
elif new_state == approved and task is not None:
# Approved but the stage never advanced -> replay the verdict.
stage_before = task["stage"]
self._dispatch(handle_verdict, issue_data, project_id, approved=True)
if self._stage_changed(issue_id, stage_before):
self._note_unblock(
task.get("work_item_id") or issue_id, stage_before, new_state
)
self._note_unblock(task.get("work_item_id") or issue_id, task["stage"])
elif new_state == rejected and task is not None:
# Rejected but never rolled back -> replay the verdict.
stage_before = task["stage"]
self._dispatch(handle_verdict, issue_data, project_id, approved=False)
if self._stage_changed(issue_id, stage_before):
self._note_unblock(
task.get("work_item_id") or issue_id, stage_before, new_state
)
self._note_unblock(task.get("work_item_id") or issue_id, task["stage"])
# else: everything is in sync -> silence (AC-10).
@staticmethod
def _stage_changed(issue_id: str, stage_before: str) -> bool:
"""ORCH-068 D2: did the dispatched handler actually move the stage?
Re-reads the task after ``_dispatch`` and compares to the captured
``stage_before``. A no-op replay (the task was already in the target
state) leaves the stage unchanged -> no unblock notification.
"""
after = get_task_by_plane_id(issue_id)
stage_after = after["stage"] if after else stage_before
return stage_after != stage_before
@staticmethod
def _dispatch(coro_fn, *args, **kwargs) -> None:
"""Run an async plane handler from this sync thread.
@@ -381,27 +314,12 @@ class Reconciler:
asyncio.run(coro_fn(*args, **kwargs))
# -- observability (F-4) ----------------------------------------------
def _note_unblock(
self, work_item_id: str, stage: str, state_uuid: str | None = None
) -> None:
def _note_unblock(self, work_item_id: str, stage: str) -> None:
"""Record + announce that a stuck task was unblocked (AC-12).
Fires only on an actual state change (an advance / replayed transition),
never per idle tick, so it does not conflict with AC-9 / AC-10.
ORCH-068 (TR-3): an in-memory dedup guard keyed by ``issue_id ->
state_uuid`` suppresses a repeat notification for the same issue+state
if a future no-op path ever reaches here. ``state_uuid`` is the issue's
Plane state; ``work_item_id`` doubles as the issue id for the
pipeline-start case (which has no work item yet).
"""
dedup_key = work_item_id
if state_uuid is not None and self._unblock_dedup.get(dedup_key) == state_uuid:
self.deduped_total += 1
return
if state_uuid is not None:
self._unblock_dedup[dedup_key] = state_uuid
self.unblocked_total += 1
self.last_unblocked = work_item_id
logger.info(
@@ -462,9 +380,6 @@ class Reconciler:
"last_run_ts": self.last_run_ts,
"unblocked_total": self.unblocked_total,
"last_unblocked": self.last_unblocked,
# ORCH-068 observability.
"skipped_terminal_total": self.skipped_terminal_total,
"deduped_total": self.deduped_total,
}

View File

@@ -171,6 +171,8 @@ def advance_stage(
work_item_id: str,
branch: str,
finished_agent: str | None = None,
*,
confirm_deploy: bool = False,
) -> AdvanceResult:
"""Run the current stage's quality gate and advance / roll back the pipeline.
@@ -187,6 +189,13 @@ def advance_stage(
approved/REQUEST_CHANGES/tester/architect branches. In the
plane webhook path it is None, so those agent-specific
branches simply do not trigger (matches old plane behavior).
confirm_deploy: ORCH-059 — keyword-only signal that the human flipped the
issue to the dedicated "Confirm Deploy" status. ONLY this
signal initiates Phase B of the self-hosting prod deploy on
the `deploy` stage. A plain `Approved` on `deploy`
(confirm_deploy=False) is a deliberate no-op (no prod
deploy, no false БАГ-8 rollback). All non-webhook callers
leave it at the default.
Returns AdvanceResult describing what happened.
"""
@@ -203,21 +212,32 @@ def advance_stage(
result.note = "terminal"
return result
# --- ORCH-036 Phase B: human Approved on `deploy` -> initiate deploy --
# A human flipping the Plane status to Approved on the `deploy` stage
# (finished_agent is None) is the prod-deploy trigger for the self-hosting
# repo. Initiate the DETACHED host deploy + enqueue the finalizer and
# return WITHOUT running check_deploy_status (the verdict does not exist
# yet — running the gate now would read a stale/absent log and falsely
# roll back, R-2). The finalizer (Phase C, finished_agent="deployer")
# records the verdict later; that path is NOT intercepted here.
# --- ORCH-036/059 Phase B: "Confirm Deploy" on `deploy` -> initiate ----
# ORCH-059: the prod-deploy trigger is now the DEDICATED "Confirm Deploy"
# status (confirm_deploy=True), NOT the overloaded "Approved". On the
# `deploy` stage (finished_agent is None) for the self-hosting repo we
# always return early WITHOUT running check_deploy_status (the verdict
# does not exist yet — running the gate now would read a stale/absent log
# and falsely roll back, R-2/БАГ-8), but we only initiate the DETACHED
# host deploy + enqueue the finalizer when confirm_deploy is set. A plain
# Approved (confirm_deploy=False) is a deliberate no-op — it neither
# deploys nor rolls back (TRZ-3/AC-3). The finalizer (Phase C,
# finished_agent="deployer") records the verdict later; that path is NOT
# intercepted here (it requires finished_agent set).
if (
current_stage == "deploy"
and finished_agent is None
and settings.deploy_require_manual_approve
and self_deploy.self_deploy_applies(repo)
):
_handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result)
if confirm_deploy:
_handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result)
else:
result.note = "approved-on-deploy-noop"
logger.info(
f"Task {task_id}: Approved on `deploy` without Confirm Deploy "
f"— no-op (prod deploy requires the 'Confirm Deploy' status)"
)
return result
# --- Quality gate ----------------------------------------------------
@@ -998,9 +1018,11 @@ def _handle_self_deploy_phase_a(
Staging is green and the branch is mergeable; for the self-hosting repo we do
NOT auto-deploy to prod. Move the task onto the `deploy` stage (so a later
human Approved lands there -> Phase B), set the issue approval-pending and ask
the human to flip the status to Approved. A restart-safe `approve-requested`
marker records that Phase A ran. The merge lease stays HELD.
human "Confirm Deploy" lands there -> Phase B), set the issue approval-pending
and ask the human to flip the status to "Confirm Deploy" (ORCH-059: the
dedicated prod-deploy trigger, distinct from the human-gate "Approved"). A
restart-safe `approve-requested` marker records that Phase A ran. The merge
lease stays HELD.
"""
update_task_stage(task_id, "deploy")
notify_stage_change(task_id, current_stage, "deploy")
@@ -1022,13 +1044,14 @@ def _handle_self_deploy_phase_a(
if work_item_id:
plane_add_comment(
work_item_id,
"\U0001f7e1 Staging зелёный. Требуется ручной approve для ПРОД-деплоя: "
"смените статус задачи на «Approved», чтобы запустить деплой в прод (8500).",
"\U0001f7e1 Staging зелёный. Требуется ручное подтверждение ПРОД-деплоя: "
"смените статус задачи на «Confirm Deploy», чтобы запустить деплой в прод "
"(8500). Статус «Approved» прод-деплой НЕ запускает.",
author="deployer",
)
send_telegram(
f"\U0001f7e1 {work_item_id}: staging OK. Ждёт approve на ПРОД-деплой "
f"(смените статус на Approved)."
f"\U0001f7e1 {work_item_id}: staging OK. Ждёт подтверждения ПРОД-деплоя "
f"(смените статус на «Confirm Deploy»)."
)
logger.info(
f"Task {task_id}: self-deploy Phase A — advanced to deploy, "

View File

@@ -150,8 +150,15 @@ async def handle_issue_updated(data: dict, project_id: str = ""):
# both enduro (b873d9eb) and orchestrator (e331bfb3) In Progress trigger the
# pipeline. Using PLANE_STATES["in_progress"] here was the root-cause blocker.
proj_states = get_project_states(project_id)
# ORCH-059: the dedicated "Confirm Deploy" status is the prod-deploy trigger.
# fail-closed via .get — environments without the status (enduro / API
# fallback) resolve to None, so the branch simply never activates (no KeyError,
# no blind deploy). Checked before `approved` so the two gestures never alias.
confirm_state = proj_states.get("confirm_deploy")
if new_state == proj_states["in_progress"]:
await handle_status_start(data, project_id)
elif confirm_state and new_state == confirm_state:
await handle_confirm_deploy(data, project_id)
elif new_state == proj_states["approved"]:
await handle_verdict(data, project_id, approved=True)
elif new_state == proj_states["rejected"]:
@@ -160,6 +167,45 @@ async def handle_issue_updated(data: dict, project_id: str = ""):
logger.info(f"issue {plane_id} updated to state {new_state[:8]}..., no pipeline action")
async def handle_confirm_deploy(data: dict, project_id: str = ""):
"""ORCH-059: a human flipped the issue to the dedicated "Confirm Deploy"
status — the explicit trigger for the self-hosting prod deploy (Phase B).
Guarded to the `deploy` stage: "Confirm Deploy" is only meaningful on the
approval-pending `deploy` stage (Phase A advanced the task there). On any
other stage it is a no-op-with-log, so a stray Confirm Deploy can never
perturb another gate.
Routes to the unified stage engine with ``confirm_deploy=True`` so ONLY this
path initiates Phase B; a plain Approved on `deploy` stays a no-op (TRZ-3).
"""
plane_id = str(data.get("id") or "")
task = get_task_by_plane_id(plane_id)
if not task:
logger.warning(f"Confirm Deploy for {plane_id} but no task found, ignoring")
return
task_id = task["id"]
current_stage = task["stage"]
repo = task["repo"]
work_item_id = task.get("work_item_id", "")
branch = task.get("branch", "")
if current_stage != "deploy":
logger.info(
f"Confirm Deploy for {plane_id} but stage is '{current_stage}' "
f"(not 'deploy'); no-op"
)
return
logger.info(
f"Task {task_id}: Confirm Deploy status on `deploy` -> initiate Phase B prod deploy"
)
await _try_advance_stage(
task_id, current_stage, repo, work_item_id, branch, confirm_deploy=True
)
async def handle_status_start(data: dict, project_id: str = ""):
"""An issue moved into In Progress.
@@ -633,7 +679,8 @@ async def _rollback_stage(
async def _try_advance_stage(
task_id: int, current_stage: str, repo: str, work_item_id: str, branch: str
task_id: int, current_stage: str, repo: str, work_item_id: str, branch: str,
confirm_deploy: bool = False,
):
"""Thin async wrapper over the unified stage engine (ORCH-4 / M-3).
@@ -642,10 +689,15 @@ async def _try_advance_stage(
is synchronous. We run it off the event loop via asyncio.to_thread so there
is exactly one implementation shared with the launcher.
finished_agent is None on this webhook path (a human Approved status change,
not a finished agent), so the agent-specific rollback branches inside the
engine intentionally do not trigger — the webhook path only runs the QG and
either advances or reports the failure.
finished_agent is None on this webhook path (a human status change, not a
finished agent), so the agent-specific rollback branches inside the engine
intentionally do not trigger — the webhook path only runs the QG and either
advances or reports the failure.
ORCH-059: ``confirm_deploy`` is threaded through (keyword-only on
advance_stage). It is True ONLY on the "Confirm Deploy" path
(handle_confirm_deploy) and gates Phase B of the self-hosting prod deploy; the
plain Approved path (handle_verdict) leaves it at the default False.
"""
import asyncio
from ..stage_engine import advance_stage
@@ -658,6 +710,7 @@ async def _try_advance_stage(
work_item_id,
branch,
None,
confirm_deploy=confirm_deploy,
)

View File

@@ -0,0 +1,171 @@
"""ORCH-059 TC-10/11/12: end-to-end routing from a Plane webhook payload through
handle_issue_updated into the stage engine, with the host deploy mocked.
Contract (AC-2, AC-3, AC-8):
* TC-10 — task on `deploy` + webhook "Confirm Deploy" -> initiate_deploy called,
`deploy-finalizer` enqueued, `initiated` marker written.
* TC-11 — task on `deploy` + webhook "Approved" -> NO prod deploy initiated, the
task stays on `deploy` (no rollback, no advance to done).
* TC-12 — non-self repo: verdict statuses on `deploy` do not change deploy
behaviour (self_deploy_applies == False; the confirm-deploy branch is inert).
"""
import os
import tempfile
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_orch_confirm_e2e.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
from unittest.mock import MagicMock # noqa: E402
import src.db as _db # noqa: E402
from src.db import init_db, get_db # noqa: E402
from src import stage_engine # noqa: E402
from src import self_deploy # noqa: E402
import src.plane_sync as plane_sync # noqa: E402
import src.webhooks.plane as wh # noqa: E402
IN_PROGRESS = "11111111-1111-1111-1111-111111111111"
APPROVED = "22222222-2222-2222-2222-222222222222"
REJECTED = "33333333-3333-3333-3333-333333333333"
CONFIRM = "44444444-4444-4444-4444-444444444444"
# ORCH project: Confirm Deploy resolved. enduro-like project: NO confirm_deploy key.
_STATES_SELF = {
"in_progress": IN_PROGRESS,
"approved": APPROVED,
"rejected": REJECTED,
"confirm_deploy": CONFIRM,
}
_STATES_NONSELF = {
"in_progress": IN_PROGRESS,
"approved": APPROVED,
"rejected": REJECTED,
}
@pytest.fixture(autouse=True)
def fresh_db(monkeypatch, tmp_path):
monkeypatch.setattr(_db.settings, "db_path", _test_db)
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path))
monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path))
monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True)
yield
@pytest.fixture(autouse=True)
def silence_engine(monkeypatch):
for name in (
"notify_stage_change", "notify_qg_failure", "send_telegram",
"plane_notify_stage", "plane_notify_qg", "plane_add_comment",
"set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress",
"set_issue_blocked", "set_issue_done",
):
monkeypatch.setattr(stage_engine, name, MagicMock(), raising=False)
def _make_task(stage, repo, branch, wi, plane_id):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
"VALUES (?, ?, ?, ?, ?)",
(plane_id, wi, repo, branch, stage),
)
task_id = cur.lastrowid
conn.commit()
conn.close()
return task_id
def _stage(task_id):
conn = get_db()
row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone()
conn.close()
return row[0]
def _jobs():
conn = get_db()
rows = conn.execute("SELECT agent FROM jobs ORDER BY id").fetchall()
conn.close()
return [r[0] for r in rows]
def _payload(state_uuid, plane_id):
return {"id": plane_id, "state": {"id": state_uuid}}
# ---------------------------------------------------------------------------
# TC-10: E2E Confirm Deploy -> prod deploy initiated
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_tc10_confirm_deploy_e2e_initiates(monkeypatch):
monkeypatch.setattr(plane_sync, "get_project_states", lambda pid: _STATES_SELF)
initiate = MagicMock(return_value=(True, "ok"))
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate)
task_id = _make_task("deploy", "orchestrator", "feature/ORCH-059-x",
"ORCH-059", "plane-ORCH-059")
await wh.handle_issue_updated(_payload(CONFIRM, "plane-ORCH-059"), "orch-proj")
initiate.assert_called_once()
assert "deploy-finalizer" in _jobs()
assert self_deploy.has_marker("orchestrator", "ORCH-059", self_deploy.INITIATED)
# Verdict comes later via the finalizer — still on `deploy`.
assert _stage(task_id) == "deploy"
# ---------------------------------------------------------------------------
# TC-11: E2E Approved -> no prod deploy, task stays on deploy
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_tc11_approved_e2e_noop(monkeypatch):
monkeypatch.setattr(plane_sync, "get_project_states", lambda pid: _STATES_SELF)
initiate = MagicMock(return_value=(True, "ok"))
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate)
task_id = _make_task("deploy", "orchestrator", "feature/ORCH-059-x",
"ORCH-059", "plane-ORCH-059")
await wh.handle_issue_updated(_payload(APPROVED, "plane-ORCH-059"), "orch-proj")
initiate.assert_not_called()
assert "deploy-finalizer" not in _jobs()
assert _stage(task_id) == "deploy" # no rollback, no advance to done
assert not self_deploy.has_marker("orchestrator", "ORCH-059", self_deploy.INITIATED)
# ---------------------------------------------------------------------------
# TC-12: non-self repo -> confirm-deploy branch inert (fail-closed, no key)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_tc12_non_self_repo_unaffected(monkeypatch):
# Non-self project has no confirm_deploy key at all -> the branch never fires.
monkeypatch.setattr(plane_sync, "get_project_states", lambda pid: _STATES_NONSELF)
initiate = MagicMock(return_value=(True, "ok"))
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate)
# Stub the deploy gate so the legacy non-self path stays deterministic (no
# real git/network); its verdict is irrelevant to this test's assertions.
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS, "check_deploy_status": lambda *a, **k: (True, "ok")},
)
task_id = _make_task("deploy", "enduro-trails", "feature/ET-009-x",
"ET-009", "plane-ET-009")
# An Approved on a non-self deploy task does not initiate self-deploy logic.
await wh.handle_issue_updated(_payload(APPROVED, "plane-ET-009"), "enduro-proj")
initiate.assert_not_called()
# The (absent) Confirm Deploy status simply maps to no pipeline action.
assert self_deploy.self_deploy_applies("enduro-trails") is False

View File

@@ -139,12 +139,14 @@ def test_tc06_approved_calls_prod_hook_exactly_once(monkeypatch):
ssh_run = MagicMock(return_value=MagicMock(returncode=0, stdout="", stderr=""))
monkeypatch.setattr(self_deploy.subprocess, "run", ssh_run)
task_id = _make_task("deploy") # already on deploy, awaiting Approved
task_id = _make_task("deploy") # already on deploy, awaiting Confirm Deploy
# 1st human Approved -> Phase B initiates the detached deploy.
# ORCH-059: Phase B is now triggered by the dedicated "Confirm Deploy" status
# (confirm_deploy=True), NOT by a plain Approved. 1st Confirm Deploy ->
# Phase B initiates the detached deploy.
res1 = advance_stage(
task_id, "deploy", "orchestrator", "ORCH-036",
"feature/ORCH-036-x", finished_agent=None,
"feature/ORCH-036-x", finished_agent=None, confirm_deploy=True,
)
assert res1.note == "self-deploy-initiated"
assert ssh_run.call_count == 1
@@ -152,10 +154,10 @@ def test_tc06_approved_calls_prod_hook_exactly_once(monkeypatch):
assert any(j["agent"] == "deploy-finalizer" for j in _jobs())
assert self_deploy.has_marker("orchestrator", "ORCH-036", self_deploy.INITIATED)
# 2nd (duplicate) Approved -> idempotent no-op, hook NOT called again.
# 2nd (duplicate) Confirm Deploy -> idempotent no-op, hook NOT called again.
res2 = advance_stage(
task_id, "deploy", "orchestrator", "ORCH-036",
"feature/ORCH-036-x", finished_agent=None,
"feature/ORCH-036-x", finished_agent=None, confirm_deploy=True,
)
assert res2.note == "self-deploy-already-initiated"
assert ssh_run.call_count == 1 # still exactly one prod deploy

View File

@@ -0,0 +1,152 @@
"""ORCH-059 TC-04/05/06: webhook routing for the dedicated "Confirm Deploy"
status vs. the overloaded "Approved".
Contract (AC-2, AC-3, AC-4):
* TC-04 — handle_issue_updated routes a "Confirm Deploy" status on a `deploy`
task to the Phase B path (handle_confirm_deploy -> advance_stage with
confirm_deploy=True), NOT the plain approve/advance path.
* TC-05 — an "Approved" status on a `deploy` task does NOT initiate the prod
deploy (self_deploy.initiate_deploy is never called).
* TC-06 — an "Approved" status on an `analysis` task still advances
analysis -> architecture (the approved-via-status human gate is intact).
"""
import os
import tempfile
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_orch_confirm_routing.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
from unittest.mock import AsyncMock, MagicMock # noqa: E402
import src.db as _db # noqa: E402
from src.db import init_db, get_db # noqa: E402
from src import stage_engine # noqa: E402
from src import self_deploy # noqa: E402
import src.plane_sync as plane_sync # noqa: E402
import src.webhooks.plane as wh # noqa: E402
IN_PROGRESS = "11111111-1111-1111-1111-111111111111"
APPROVED = "22222222-2222-2222-2222-222222222222"
REJECTED = "33333333-3333-3333-3333-333333333333"
CONFIRM = "44444444-4444-4444-4444-444444444444"
_STATES = {
"in_progress": IN_PROGRESS,
"approved": APPROVED,
"rejected": REJECTED,
"confirm_deploy": CONFIRM,
}
@pytest.fixture(autouse=True)
def fresh_db(monkeypatch, tmp_path):
monkeypatch.setattr(_db.settings, "db_path", _test_db)
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
# Deterministic per-project states (no network). handle_issue_updated imports
# get_project_states locally from ..plane_sync, so patch it at the source.
monkeypatch.setattr(plane_sync, "get_project_states", lambda pid: _STATES)
# Isolate sentinel dirs.
monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path))
monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path))
yield
@pytest.fixture(autouse=True)
def silence_engine(monkeypatch):
for name in (
"notify_stage_change", "notify_qg_failure", "send_telegram",
"plane_notify_stage", "plane_notify_qg", "plane_add_comment",
"set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress",
"set_issue_blocked", "set_issue_done",
):
monkeypatch.setattr(stage_engine, name, MagicMock(), raising=False)
def _make_task(stage, repo="orchestrator", branch="feature/ORCH-059-x",
wi="ORCH-059", plane_id="plane-ORCH-059"):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
"VALUES (?, ?, ?, ?, ?)",
(plane_id, wi, repo, branch, stage),
)
task_id = cur.lastrowid
conn.commit()
conn.close()
return task_id
def _payload(state_uuid, plane_id="plane-ORCH-059"):
return {"id": plane_id, "state": {"id": state_uuid}}
# ---------------------------------------------------------------------------
# TC-04: "Confirm Deploy" routes to the Phase B path with confirm_deploy=True
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_tc04_confirm_deploy_routes_phase_b(monkeypatch):
_make_task("deploy")
spy = AsyncMock()
monkeypatch.setattr(wh, "_try_advance_stage", spy)
# handle_verdict must NOT be taken for the confirm-deploy status.
verdict_spy = AsyncMock()
monkeypatch.setattr(wh, "handle_verdict", verdict_spy)
await wh.handle_issue_updated(_payload(CONFIRM), "proj")
spy.assert_awaited_once()
# confirm_deploy=True must be threaded through.
assert spy.await_args.kwargs.get("confirm_deploy") is True
verdict_spy.assert_not_awaited()
@pytest.mark.asyncio
async def test_tc04b_confirm_deploy_off_deploy_stage_is_noop(monkeypatch):
"""Guard: a stray "Confirm Deploy" on a non-deploy stage is a no-op (no advance)."""
_make_task("analysis")
spy = AsyncMock()
monkeypatch.setattr(wh, "_try_advance_stage", spy)
await wh.handle_confirm_deploy(_payload(CONFIRM), "proj")
spy.assert_not_awaited()
# ---------------------------------------------------------------------------
# TC-05: "Approved" on `deploy` does NOT initiate the prod deploy
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_tc05_approved_on_deploy_does_not_initiate(monkeypatch):
monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True)
_make_task("deploy")
initiate = MagicMock()
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate)
# Real routing: Approved -> handle_verdict -> _try_advance_stage(confirm_deploy=False)
# -> advance_stage -> the deploy block no-ops (does not initiate).
await wh.handle_issue_updated(_payload(APPROVED), "proj")
initiate.assert_not_called()
# ---------------------------------------------------------------------------
# TC-06: "Approved" on `analysis` still advances analysis -> architecture
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_tc06_approved_on_analysis_still_advances(monkeypatch):
task_id = _make_task("analysis")
await wh.handle_issue_updated(_payload(APPROVED), "proj")
conn = get_db()
stage = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone()[0]
conn.close()
assert stage == "architecture"

120
tests/test_plane_states.py Normal file
View File

@@ -0,0 +1,120 @@
"""ORCH-059 TC-01/02/03: resolver registration of the dedicated "Confirm Deploy"
status and its fail-closed absence in fallback environments.
Contract (AC-1, AC-7):
* TC-01 — _PLANE_NAME_TO_KEY maps the board name "Confirm Deploy" to the logical
key "confirm_deploy".
* TC-02 — get_project_states for an ORCH-like project (Plane API mocked to
include a "Confirm Deploy" state) returns a NON-empty uuid under
"confirm_deploy", distinct from "approved".
* TC-03 — fail-closed: when the status is absent (API fallback to
_DEFAULT_STATES / unreachable Plane), the key is simply missing and a .get
access yields None WITHOUT raising — the confirm-deploy branch never activates.
"""
import os
import tempfile
import pytest
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch_plane_states.db")
import src.plane_sync as plane_sync # noqa: E402
from src.plane_sync import ( # noqa: E402
_PLANE_NAME_TO_KEY,
_DEFAULT_STATES,
get_project_states,
reload_project_states,
)
@pytest.fixture(autouse=True)
def fresh_cache():
reload_project_states()
yield
reload_project_states()
# ---------------------------------------------------------------------------
# TC-01: name -> key mapping is registered
# ---------------------------------------------------------------------------
def test_tc01_confirm_deploy_name_to_key_mapping():
assert _PLANE_NAME_TO_KEY.get("Confirm Deploy") == "confirm_deploy"
def test_tc01_confirm_deploy_not_in_default_states():
"""Fail-closed by construction: NO fallback UUID exists for confirm_deploy, so
enduro / API-fallback environments never resolve a (wrong) deploy trigger."""
assert "confirm_deploy" not in _DEFAULT_STATES
# ---------------------------------------------------------------------------
# TC-02: live API resolves a real, distinct uuid for an ORCH-like project
# ---------------------------------------------------------------------------
def test_tc02_get_project_states_resolves_confirm_deploy(monkeypatch):
confirm_uuid = "cfd00000-0000-0000-0000-000000000059"
approved_uuid = "a519a341-dada-4a91-8910-7604f82b79c5"
class _Resp:
def raise_for_status(self):
pass
def json(self):
return {
"results": [
{"name": "In Progress", "id": "b873d9eb-993c-48cd-97ac-99a9b1623967"},
{"name": "Approved", "id": approved_uuid},
{"name": "Confirm Deploy", "id": confirm_uuid},
]
}
monkeypatch.setattr(plane_sync.httpx, "get", lambda *a, **k: _Resp())
states = get_project_states("orch-project-uuid")
assert states.get("confirm_deploy") == confirm_uuid
# Distinct gestures: confirm-deploy must NOT alias the human "Approved" gate.
assert states["confirm_deploy"] != states["approved"]
# ---------------------------------------------------------------------------
# TC-03: fail-closed when the status is absent (API fallback / unreachable)
# ---------------------------------------------------------------------------
def test_tc03_fail_closed_when_api_unreachable(monkeypatch):
"""A Plane outage -> get_project_states falls back to _DEFAULT_STATES, which
has no confirm_deploy key. .get must yield None, never raise."""
def _boom(*a, **k):
raise RuntimeError("plane down")
monkeypatch.setattr(plane_sync.httpx, "get", _boom)
states = get_project_states("any-project-uuid")
# No KeyError, branch never activates.
assert states.get("confirm_deploy") is None
# The human gate "Approved" still resolves (fallback is intact).
assert states.get("approved") == _DEFAULT_STATES["approved"]
def test_tc03_fail_closed_when_status_not_on_board(monkeypatch):
"""Project whose board lacks "Confirm Deploy": the key is filled by NEITHER the
API loop NOR the _DEFAULT_STATES backfill -> absent -> fail-closed."""
class _Resp:
def raise_for_status(self):
pass
def json(self):
return {
"results": [
{"name": "In Progress", "id": "b873d9eb-993c-48cd-97ac-99a9b1623967"},
{"name": "Approved", "id": "a519a341-dada-4a91-8910-7604f82b79c5"},
]
}
monkeypatch.setattr(plane_sync.httpx, "get", lambda *a, **k: _Resp())
states = get_project_states("board-without-confirm")
assert states.get("confirm_deploy") is None
assert states.get("approved") == "a519a341-dada-4a91-8910-7604f82b79c5"

View File

@@ -1,180 +0,0 @@
"""ORCH-068 (TR-4): tests for the Plane states cache TTL self-heal.
The per-project ``_STATES_CACHE`` used to live for the whole process lifetime,
so a status added to Plane after start was never seen without a restart
("stale set -> no pipeline action"). ORCH-068 adds a TTL: an entry is
re-fetched once it is older than ``plane_states_ttl_s`` (default 300s); ``0``
disables the TTL (strictly the previous lifetime cache).
All tests are offline: the Plane API (httpx) and the monotonic clock are mocked.
"""
import os
import tempfile
from unittest.mock import MagicMock, patch
import pytest
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")
_test_db = os.path.join(tempfile.gettempdir(), "test_plane_states_cache.db")
os.environ["ORCH_DB_PATH"] = _test_db
import src.plane_sync as ps # noqa: E402
_PROJECT = "proj-ttl"
_ET_PROJECT = "7a79f0a9-5278-49cd-9007-9a338f238f9c"
def _resp(data: dict, status: int = 200):
m = MagicMock()
m.status_code = status
m.json.return_value = data
if status >= 400:
from httpx import HTTPStatusError
m.raise_for_status.side_effect = HTTPStatusError(
"error", request=MagicMock(), response=MagicMock()
)
else:
m.raise_for_status.return_value = None
return m
def _states_response(in_progress_uuid: str) -> dict:
"""A minimal /states/ payload; In Progress carries the given UUID."""
return {
"results": [
{"id": in_progress_uuid, "name": "In Progress", "group": "started"},
{"id": "uuid-done", "name": "Done", "group": "completed"},
]
}
@pytest.fixture(autouse=True)
def reset_cache():
ps.reload_project_states()
yield
ps.reload_project_states()
# ---------------------------------------------------------------------------
# TC-11 (AC-12): a stale cache entry self-heals after the TTL — no restart.
# ---------------------------------------------------------------------------
def test_tc11_stale_cache_refreshes_after_ttl(monkeypatch):
monkeypatch.setattr(ps.settings, "plane_states_ttl_s", 300)
clock = {"t": 1000.0}
monkeypatch.setattr(ps.time, "monotonic", lambda: clock["t"])
responses = iter([
_resp(_states_response("uuid-A")), # first fetch: old set
_resp(_states_response("uuid-B")), # second fetch: new status appeared
])
mock_get = MagicMock(side_effect=lambda *a, **k: next(responses))
monkeypatch.setattr(ps.httpx, "get", mock_get)
# t=1000: first call -> fetch set A.
s1 = ps.get_project_states(_PROJECT)
assert s1["in_progress"] == "uuid-A"
assert mock_get.call_count == 1
# t=1100: within TTL -> served from cache, no new fetch.
clock["t"] = 1100.0
s2 = ps.get_project_states(_PROJECT)
assert s2["in_progress"] == "uuid-A"
assert mock_get.call_count == 1
# t=1400: TTL (300s) elapsed -> re-fetch -> fresh set B (self-heal).
clock["t"] = 1400.0
s3 = ps.get_project_states(_PROJECT)
assert s3["in_progress"] == "uuid-B"
assert mock_get.call_count == 2
def test_tc11_ttl_zero_keeps_lifetime_cache(monkeypatch):
"""plane_states_ttl_s=0 -> strictly the previous lifetime cache (back-compat)."""
monkeypatch.setattr(ps.settings, "plane_states_ttl_s", 0)
clock = {"t": 1000.0}
monkeypatch.setattr(ps.time, "monotonic", lambda: clock["t"])
responses = iter([
_resp(_states_response("uuid-A")),
_resp(_states_response("uuid-B")),
])
mock_get = MagicMock(side_effect=lambda *a, **k: next(responses))
monkeypatch.setattr(ps.httpx, "get", mock_get)
assert ps.get_project_states(_PROJECT)["in_progress"] == "uuid-A"
clock["t"] = 1_000_000.0 # far in the future
# TTL disabled -> still the cached A, never re-fetched.
assert ps.get_project_states(_PROJECT)["in_progress"] == "uuid-A"
assert mock_get.call_count == 1
def test_tc11_groups_exposed_via_accessor(monkeypatch):
"""get_project_state_groups returns {uuid -> group} from the same record."""
monkeypatch.setattr(ps.settings, "plane_states_ttl_s", 300)
monkeypatch.setattr(ps.httpx, "get", lambda *a, **k: _resp(_states_response("uuid-A")))
ps.get_project_states(_PROJECT)
groups = ps.get_project_state_groups(_PROJECT)
assert groups["uuid-A"] == "started"
assert groups["uuid-done"] == "completed"
def test_tc11_groups_empty_when_uncached(monkeypatch):
"""No cache record (e.g. API fell back to defaults) -> groups == {}."""
assert ps.get_project_state_groups("never-fetched") == {}
# ---------------------------------------------------------------------------
# TC-12 (AC-13): default-config compatibility — enduro UUIDs + API-error fallback.
# ---------------------------------------------------------------------------
def test_tc12_enduro_uuids_unchanged(monkeypatch):
"""enduro project still resolves its own UUIDs (return shape unchanged)."""
body = {
"results": [
{"id": "b873d9eb-993c-48cd-97ac-99a9b1623967",
"name": "In Progress", "group": "started"},
]
}
monkeypatch.setattr(ps.httpx, "get", lambda *a, **k: _resp(body))
states = ps.get_project_states(_ET_PROJECT)
assert states["in_progress"] == "b873d9eb-993c-48cd-97ac-99a9b1623967"
# Missing keys are still backfilled from _DEFAULT_STATES (complete mapping).
assert states["done"] == ps._DEFAULT_STATES["done"]
def test_tc12_api_error_falls_back_to_defaults(monkeypatch):
"""API failure with nothing cached -> _DEFAULT_STATES (fallback preserved)."""
monkeypatch.setattr(
ps.httpx, "get", MagicMock(side_effect=Exception("network error"))
)
states = ps.get_project_states(_PROJECT)
assert states is ps._DEFAULT_STATES
def test_tc12_stale_served_when_refresh_fails(monkeypatch):
"""TTL expiry + transient API failure -> serve the stale (project-correct)
set rather than reverting to enduro defaults."""
monkeypatch.setattr(ps.settings, "plane_states_ttl_s", 300)
clock = {"t": 1000.0}
monkeypatch.setattr(ps.time, "monotonic", lambda: clock["t"])
calls = {"n": 0}
def flaky_get(*a, **k):
calls["n"] += 1
if calls["n"] == 1:
return _resp(_states_response("uuid-A"))
raise Exception("transient outage")
monkeypatch.setattr(ps.httpx, "get", flaky_get)
assert ps.get_project_states(_PROJECT)["in_progress"] == "uuid-A"
clock["t"] = 2000.0 # past TTL -> refresh attempt fails
states = ps.get_project_states(_PROJECT)
assert states["in_progress"] == "uuid-A" # stale-but-correct, not defaults
assert states is not ps._DEFAULT_STATES

View File

@@ -295,342 +295,3 @@ def test_tc17_polls_all_projects_resolves_states_per_project(monkeypatch):
# state uuids are resolved per-project (not hardcoded): each call carries them.
for _pid, states in issues_calls:
assert set(states) == {_IN_PROGRESS, _APPROVED, _REJECTED}
# ===========================================================================
# ORCH-068: livelock-fix — terminal exclusion (D1) + confirmed-change unblock
# (D2) + dedup (TR-3). The old code spammed `_note_unblock` every ~120s for a
# fully synchronized Done task (incident: ET-002, 191+ Telegram messages/night).
# ===========================================================================
_DONE = "uuid-done"
_CANCELLED = "uuid-cancelled"
def _patch_states_with_terminals(monkeypatch, *, alias_done_to_approved=False):
"""Patch F-2 state resolution to include terminals + their groups.
``alias_done_to_approved`` models the regression trigger (ORCH-066): the
project "collapses" Done onto the approved UUID, so a genuinely-Done issue
would enter the ``approved`` branch by UUID. Only the state GROUP
(``completed``) disentangles it — the heart of D1.
"""
done_uuid = _APPROVED if alias_done_to_approved else _DONE
states = {
"in_progress": _IN_PROGRESS,
"approved": _APPROVED,
"rejected": _REJECTED,
"done": done_uuid,
"cancelled": _CANCELLED,
}
groups = {
_IN_PROGRESS: "started",
_APPROVED: "started",
_REJECTED: "started",
done_uuid: "completed", # genuinely-done issue -> completed group
_CANCELLED: "cancelled",
}
monkeypatch.setattr(reconciler_mod, "get_project_states", lambda pid: states)
monkeypatch.setattr(
reconciler_mod, "get_project_state_groups", lambda pid: groups
)
return states, groups
def _spy_telegram(monkeypatch):
sent = []
monkeypatch.setattr(reconciler_mod, "send_telegram", lambda msg: sent.append(msg))
return sent
def _job_count():
conn = get_db()
n = conn.execute("SELECT COUNT(*) FROM jobs").fetchone()[0]
conn.close()
return n
# ---------------------------------------------------------------------------
# TC-01 (AC-1, AC-7): synchronized Done task -> total silence, 0 jobs.
# ---------------------------------------------------------------------------
def test_tc01_synced_done_is_silent(monkeypatch, single_project):
start, verdict = _patch_handlers(monkeypatch)
_patch_states_with_terminals(monkeypatch)
sent = _spy_telegram(monkeypatch)
_make_task("iss-done", stage="done", wi="ET-002")
_patch_issues(monkeypatch, [
{"id": "iss-done", "state": {"id": _DONE}, "updated_at": _OLD_TS},
])
recon = Reconciler()
recon.reconcile_plane_once()
start.assert_not_called()
verdict.assert_not_called()
assert sent == []
assert recon.unblocked_total == 0
assert recon.skipped_terminal_total == 1
assert _job_count() == 0
# ---------------------------------------------------------------------------
# TC-02 (AC-2): Done UUID aliased onto approved -> still excluded by GROUP.
# ---------------------------------------------------------------------------
def test_tc02_terminal_aliased_to_approved_excluded(monkeypatch, single_project):
start, verdict = _patch_handlers(monkeypatch)
_patch_states_with_terminals(monkeypatch, alias_done_to_approved=True)
sent = _spy_telegram(monkeypatch)
# Task is Done; its Plane state UUID equals the approved UUID (aliasing).
_make_task("iss-alias", stage="done", wi="ET-002")
_patch_issues(monkeypatch, [
{"id": "iss-alias", "state": {"id": _APPROVED}, "updated_at": _OLD_TS},
])
recon = Reconciler()
recon.reconcile_plane_once()
# Without the group check this would enter the approved branch and notify.
start.assert_not_called()
verdict.assert_not_called()
assert sent == []
assert recon.unblocked_total == 0
assert recon.skipped_terminal_total == 1
# ---------------------------------------------------------------------------
# TC-03 (AC-2): Cancelled terminal is also excluded.
# ---------------------------------------------------------------------------
def test_tc03_cancelled_excluded(monkeypatch, single_project):
start, verdict = _patch_handlers(monkeypatch)
_patch_states_with_terminals(monkeypatch)
sent = _spy_telegram(monkeypatch)
_make_task("iss-cancel", stage="done", wi="ET-003")
_patch_issues(monkeypatch, [
{"id": "iss-cancel", "state": {"id": _CANCELLED}, "updated_at": _OLD_TS},
])
recon = Reconciler()
recon.reconcile_plane_once()
start.assert_not_called()
verdict.assert_not_called()
assert sent == []
assert recon.unblocked_total == 0
assert recon.skipped_terminal_total == 1
# ---------------------------------------------------------------------------
# TC-04 (AC-3): no-op dispatch (stage unchanged) -> no notification.
# ---------------------------------------------------------------------------
def test_tc04_noop_dispatch_no_unblock(monkeypatch, single_project):
# handle_verdict is a no-op AsyncMock -> the task stage never moves.
start, verdict = _patch_handlers(monkeypatch)
sent = _spy_telegram(monkeypatch)
_make_task("iss-noop", stage="review")
_patch_issues(monkeypatch, [
{"id": "iss-noop", "state": {"id": _APPROVED}, "updated_at": _OLD_TS},
])
recon = Reconciler()
recon.reconcile_plane_once()
# The handler was replayed (idempotent), but nothing changed -> silence.
assert verdict.call_count == 1
assert sent == []
assert recon.unblocked_total == 0
# ---------------------------------------------------------------------------
# TC-05 (AC-4): two consecutive ticks on a synced task -> 0 repeat unblocks;
# plus a direct check of the in-memory dedup guard.
# ---------------------------------------------------------------------------
def test_tc05_dedup_no_repeat_notification(monkeypatch, single_project):
start, verdict = _patch_handlers(monkeypatch)
_patch_states_with_terminals(monkeypatch)
sent = _spy_telegram(monkeypatch)
_make_task("iss-dedup", stage="done", wi="ET-004")
_patch_issues(monkeypatch, [
{"id": "iss-dedup", "state": {"id": _DONE}, "updated_at": _OLD_TS},
])
recon = Reconciler()
recon.reconcile_plane_once()
recon.reconcile_plane_once()
assert sent == []
assert recon.unblocked_total == 0
# Direct dedup-guard exercise: the same issue+state notifies at most once.
recon._note_unblock("ET-004", "review", "state-x")
recon._note_unblock("ET-004", "review", "state-x")
assert recon.unblocked_total == 1
assert recon.deduped_total == 1
# ---------------------------------------------------------------------------
# TC-06 (AC-5): legit lost Approved webhook -> replayed, advanced, ONE unblock.
# ---------------------------------------------------------------------------
def test_tc06_legit_approved_unblock_once(monkeypatch, single_project):
_patch_states_with_terminals(monkeypatch) # non-terminal approved -> actionable
sent = _spy_telegram(monkeypatch)
_make_task("iss-appr", stage="review", wi="ET-005")
async def fake_verdict(issue_data, project_id, approved=True):
# Simulate the real handler advancing the stage (review -> testing).
conn = get_db()
conn.execute(
"UPDATE tasks SET stage='testing' WHERE plane_id=?",
(issue_data["id"],),
)
conn.commit()
conn.close()
monkeypatch.setattr(reconciler_mod, "handle_verdict", fake_verdict)
monkeypatch.setattr(reconciler_mod, "handle_status_start", AsyncMock())
_patch_issues(monkeypatch, [
{"id": "iss-appr", "state": {"id": _APPROVED}, "updated_at": _OLD_TS},
])
recon = Reconciler()
recon.reconcile_plane_once()
assert recon.unblocked_total == 1
assert len(sent) == 1
assert "ET-005" in sent[0]
# ---------------------------------------------------------------------------
# TC-07 (AC-6): lost In Progress start (task appears) and lost Rejected
# rollback (stage moves) each fire exactly one unblock.
# ---------------------------------------------------------------------------
def test_tc07_in_progress_start_and_rejected_each_one_unblock(
monkeypatch, single_project
):
_patch_states_with_terminals(monkeypatch)
sent = _spy_telegram(monkeypatch)
async def fake_start(issue_data, project_id):
# Simulate the real start handler creating the task.
_make_task(issue_data["id"], stage="analysis", wi="ET-006")
async def fake_verdict(issue_data, project_id, approved=True):
conn = get_db()
conn.execute(
"UPDATE tasks SET stage='development' WHERE plane_id=?",
(issue_data["id"],),
)
conn.commit()
conn.close()
monkeypatch.setattr(reconciler_mod, "handle_status_start", fake_start)
monkeypatch.setattr(reconciler_mod, "handle_verdict", fake_verdict)
# Rejected task already exists at review; In Progress one has no task yet.
_make_task("iss-rej", stage="review", wi="ET-007")
_patch_issues(monkeypatch, [
{"id": "iss-start", "state": {"id": _IN_PROGRESS}, "updated_at": _OLD_TS},
{"id": "iss-rej", "state": {"id": _REJECTED}, "updated_at": _OLD_TS},
])
recon = Reconciler()
recon.reconcile_plane_once()
assert recon.unblocked_total == 2
assert len(sent) == 2
# ---------------------------------------------------------------------------
# TC-08 (AC-8): never-raise — a failing dependency isolates to its unit of work.
# ---------------------------------------------------------------------------
def test_tc08_never_raise_isolation(monkeypatch, single_project):
_patch_states_with_terminals(monkeypatch)
monkeypatch.setattr(reconciler_mod, "send_telegram", lambda msg: None)
# _dispatch blows up for one issue -> isolated; the tick must not crash.
def boom_dispatch(*a, **k):
raise RuntimeError("handler exploded")
monkeypatch.setattr(Reconciler, "_dispatch", staticmethod(boom_dispatch))
_make_task("iss-boom", stage="review", wi="ET-008")
_patch_issues(monkeypatch, [
{"id": "iss-boom", "state": {"id": _APPROVED}, "updated_at": _OLD_TS},
])
recon = Reconciler()
recon.reconcile_plane_once() # must NOT raise
assert recon.unblocked_total == 0
# list_issues_by_state raising -> per-project isolation, still no crash.
def boom_list(pid, states):
raise RuntimeError("plane down")
monkeypatch.setattr(reconciler_mod, "list_issues_by_state", boom_list)
recon.reconcile_plane_once() # must NOT raise
# ---------------------------------------------------------------------------
# TC-09 (AC-9): kill-switches mute F-2.
# ---------------------------------------------------------------------------
def test_tc09_kill_switches(monkeypatch, single_project):
start, verdict = _patch_handlers(monkeypatch)
_patch_states_with_terminals(monkeypatch)
called = {"list": 0}
def counting_list(pid, states):
called["list"] += 1
return [{"id": "iss-x", "state": {"id": _APPROVED}, "updated_at": _OLD_TS}]
monkeypatch.setattr(reconciler_mod, "list_issues_by_state", counting_list)
monkeypatch.setattr(reconciler_mod.settings, "reconcile_enabled", False)
Reconciler().reconcile_plane_once()
assert called["list"] == 0 # global switch off -> F-2 never runs
monkeypatch.setattr(reconciler_mod.settings, "reconcile_enabled", True)
monkeypatch.setattr(reconciler_mod.settings, "reconcile_plane_enabled", False)
Reconciler().reconcile_plane_once()
assert called["list"] == 0 # F-2 switch off -> still no poll
# ---------------------------------------------------------------------------
# TC-10 (AC-1, AC-2): end-to-end on BOTH registry projects (enduro AND
# orchestrator): a Done task on each -> 0 notifications / 0 jobs, regardless
# of per-project status aliasing. The headline regression test.
# ---------------------------------------------------------------------------
def test_tc10_done_silent_on_all_projects(monkeypatch):
from src import projects as projects_mod
projects_mod.reload_projects()
assert len({p.plane_project_id for p in projects_mod.PROJECTS}) >= 2
start, verdict = _patch_handlers(monkeypatch)
sent = _spy_telegram(monkeypatch)
states = {
"in_progress": _IN_PROGRESS,
"approved": _APPROVED,
"rejected": _REJECTED,
"done": _DONE,
"cancelled": _CANCELLED,
}
groups = {_DONE: "completed", _CANCELLED: "cancelled"}
monkeypatch.setattr(reconciler_mod, "get_project_states", lambda pid: states)
monkeypatch.setattr(
reconciler_mod, "get_project_state_groups", lambda pid: groups
)
# Each project returns a Done issue (unique id per project).
monkeypatch.setattr(
reconciler_mod, "list_issues_by_state",
lambda pid, st: [
{"id": f"done-{pid}", "state": {"id": _DONE}, "updated_at": _OLD_TS}
],
)
recon = Reconciler()
recon.reconcile_plane_once()
start.assert_not_called()
verdict.assert_not_called()
assert sent == []
assert recon.unblocked_total == 0
assert recon.skipped_terminal_total >= 2 # one per project
assert _job_count() == 0

View File

@@ -0,0 +1,101 @@
"""ORCH-059 TC-09: the Phase A CTA asks the operator for "Confirm Deploy".
Contract (AC-6): when Phase A advances `deploy-staging` -> `deploy` and requests
manual approval, both the Plane comment and the Telegram notification must
instruct the operator to flip the status to "Confirm Deploy" (the dedicated
prod-deploy trigger) — and must NOT present "Approved" as the deploy trigger.
"""
import os
import tempfile
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_orch_phase_a_cta.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from unittest.mock import MagicMock # noqa: E402
import src.db as _db # noqa: E402
from src.db import init_db, get_db # noqa: E402
from src import stage_engine # noqa: E402
from src import self_deploy # noqa: E402
from src.stage_engine import advance_stage # noqa: E402
def _pass(*a, **k):
return (True, "ok")
@pytest.fixture(autouse=True)
def fresh_db(monkeypatch, tmp_path):
monkeypatch.setattr(_db.settings, "db_path", _test_db)
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path))
monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path))
monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True)
# Pass the staging / merge / freshness sub-gates so the edge reaches Phase A.
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS,
"check_staging_status": _pass,
"check_branch_mergeable": _pass,
"check_staging_image_fresh": _pass},
)
yield
def _make_task(stage="deploy-staging", repo="orchestrator",
branch="feature/ORCH-059-x", wi="ORCH-059"):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
"VALUES (?, ?, ?, ?, ?)",
(f"plane-{wi}", wi, repo, branch, stage),
)
task_id = cur.lastrowid
conn.commit()
conn.close()
return task_id
def test_tc09_phase_a_cta_requests_confirm_deploy(monkeypatch):
# Silence everything EXCEPT the two CTA channels we want to inspect.
for name in (
"notify_stage_change", "notify_qg_failure", "plane_notify_stage",
"plane_notify_qg", "set_issue_in_review", "set_issue_needs_input",
"set_issue_in_progress", "set_issue_blocked", "set_issue_done",
):
monkeypatch.setattr(stage_engine, name, MagicMock(), raising=False)
plane_comment = MagicMock()
telegram = MagicMock()
monkeypatch.setattr(stage_engine, "plane_add_comment", plane_comment)
monkeypatch.setattr(stage_engine, "send_telegram", telegram)
task_id = _make_task()
res = advance_stage(
task_id, "deploy-staging", "orchestrator", "ORCH-059",
"feature/ORCH-059-x", finished_agent="deployer",
)
assert res.note == "self-deploy-approval-pending"
# The Plane comment CTA mentions "Confirm Deploy" as the trigger.
plane_comment.assert_called_once()
comment_text = plane_comment.call_args.args[1]
assert "Confirm Deploy" in comment_text
# The Telegram CTA mentions "Confirm Deploy" too.
telegram.assert_called_once()
tg_text = telegram.call_args.args[0]
assert "Confirm Deploy" in tg_text
# Neither CTA presents bare "Approved" as the deploy trigger. (The comment may
# mention Approved only to clarify it does NOT trigger; assert no instruction
# to "set status to Approved".)
assert "статус задачи на «Approved»" not in comment_text
assert "на Approved" not in tg_text

View File

@@ -0,0 +1,141 @@
"""ORCH-059 TC-07/08: the Phase B block in stage_engine.advance_stage initiates
the prod deploy ONLY on the confirm-deploy signal.
Contract (AC-2, AC-3, AC-5):
* TC-07 — on (current_stage=="deploy", finished_agent is None) for the
self-hosting repo: confirm_deploy=True -> Phase B initiates; confirm_deploy
omitted/False (a plain Approved) -> a no-op that neither initiates the deploy
nor runs check_deploy_status (no false БАГ-8 rollback).
* TC-08 — idempotency: with the `initiated` marker already present, a repeated
confirm-deploy does NOT initiate again.
"""
import os
import tempfile
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_orch_phase_b.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from unittest.mock import MagicMock # noqa: E402
import src.db as _db # noqa: E402
from src.db import init_db, get_db # noqa: E402
from src import stage_engine # noqa: E402
from src import self_deploy # noqa: E402
from src.stage_engine import advance_stage # noqa: E402
@pytest.fixture(autouse=True)
def fresh_db(monkeypatch, tmp_path):
monkeypatch.setattr(_db.settings, "db_path", _test_db)
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path))
monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path))
monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True)
yield
@pytest.fixture(autouse=True)
def silence_side_effects(monkeypatch):
for name in (
"notify_stage_change", "notify_qg_failure", "send_telegram",
"plane_notify_stage", "plane_notify_qg", "plane_add_comment",
"set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress",
"set_issue_blocked", "set_issue_done",
):
monkeypatch.setattr(stage_engine, name, MagicMock(), raising=False)
def _make_task(stage, repo="orchestrator", branch="feature/ORCH-059-x", wi="ORCH-059"):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
"VALUES (?, ?, ?, ?, ?)",
(f"plane-{wi}", wi, repo, branch, stage),
)
task_id = cur.lastrowid
conn.commit()
conn.close()
return task_id
def _stage(task_id):
conn = get_db()
row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone()
conn.close()
return row[0]
# ---------------------------------------------------------------------------
# TC-07: confirm-deploy initiates; plain Approved is a no-op
# ---------------------------------------------------------------------------
def test_tc07_confirm_deploy_initiates(monkeypatch):
initiate = MagicMock(return_value=(True, "ok"))
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate)
task_id = _make_task("deploy")
res = advance_stage(
task_id, "deploy", "orchestrator", "ORCH-059",
"feature/ORCH-059-x", finished_agent=None, confirm_deploy=True,
)
assert res.note == "self-deploy-initiated"
initiate.assert_called_once()
assert self_deploy.has_marker("orchestrator", "ORCH-059", self_deploy.INITIATED)
# Did NOT advance off deploy — the finalizer records the verdict later.
assert _stage(task_id) == "deploy"
def test_tc07_approved_without_confirm_is_noop(monkeypatch):
"""A plain Approved on `deploy` (confirm_deploy defaults to False): no
initiate_deploy, no rollback, no advance — a deterministic no-op (AC-3)."""
initiate = MagicMock(return_value=(True, "ok"))
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate)
# If check_deploy_status were (wrongly) run, it would intervene; spy to prove
# it is never invoked on this no-op path.
gate = MagicMock(return_value=(False, "FAILED"))
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS, "check_deploy_status": gate},
)
task_id = _make_task("deploy")
res = advance_stage(
task_id, "deploy", "orchestrator", "ORCH-059",
"feature/ORCH-059-x", finished_agent=None, # confirm_deploy omitted -> False
)
assert res.note == "approved-on-deploy-noop"
initiate.assert_not_called()
gate.assert_not_called() # check_deploy_status NOT run -> no false БАГ-8
assert res.advanced is False
assert res.rolled_back_to is None
assert _stage(task_id) == "deploy" # stays put, no rollback to development
assert not self_deploy.has_marker("orchestrator", "ORCH-059", self_deploy.INITIATED)
# ---------------------------------------------------------------------------
# TC-08: idempotency — existing `initiated` marker -> repeat is a no-op
# ---------------------------------------------------------------------------
def test_tc08_idempotent_repeat_confirm_deploy(monkeypatch):
initiate = MagicMock(return_value=(True, "ok"))
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate)
task_id = _make_task("deploy")
# Pre-seed the initiated marker (a deploy already in flight).
self_deploy.write_marker("orchestrator", "ORCH-059", self_deploy.INITIATED, content="1")
res = advance_stage(
task_id, "deploy", "orchestrator", "ORCH-059",
"feature/ORCH-059-x", finished_agent=None, confirm_deploy=True,
)
assert res.note == "self-deploy-already-initiated"
initiate.assert_not_called()