diff --git a/.env.example b/.env.example index a7ef50c..40423d9 100644 --- a/.env.example +++ b/.env.example @@ -117,6 +117,15 @@ 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 @@ -146,6 +155,27 @@ ORCH_REAPER_MAX_RUNNING_S=3600 ORCH_REAPER_FINALIZE_GRACE_S=300 ORCH_LEASE_RECLAIM_ENABLED=true +# ORCH-022: security-gate (secret-scanning + dependency audit) on the +# deploy-staging -> deploy edge, run FIRST among the edge sub-gates. Deterministic +# (no LLM): gitleaks (offline secret-scan, pinned Go binary in the image) + pip-audit +# (OSV/PyPI CVE audit). Verdict in the versioned 17-security-report.md frontmatter; +# FAIL -> rollback to development + developer-retry (cap 3). See ADR-001. +# GATE_ENABLED -> global kill-switch; false -> pipeline 1:1 as before ORCH-022. +# GATE_REPOS -> CSV of repos where the gate is REAL; empty -> only self-hosting. +# DEP_BLOCK_SEVERITY -> CVE severity that BLOCKS (CRITICAL>HIGH>MEDIUM>LOW); below / +# UNKNOWN -> warning only (anti-loop). +# SCAN_TIMEOUT_S -> per external scanner call timeout. +# DEP_AUDIT_FAIL_CLOSED -> strict mode: unreachable CVE feed -> FAIL instead of the +# default fail-open + warning (anti-loop). Default false. +# SECRETS_BLOCK -> a found secret blocks (always true by default; the offline +# secrets guarantee is unconditional). +ORCH_SECURITY_GATE_ENABLED=true +ORCH_SECURITY_GATE_REPOS= +ORCH_SECURITY_DEP_BLOCK_SEVERITY=HIGH +ORCH_SECURITY_SCAN_TIMEOUT_S=300 +ORCH_SECURITY_DEP_AUDIT_FAIL_CLOSED=false +ORCH_SECURITY_SECRETS_BLOCK=true + # ORCH-021: post-deploy production monitoring + degradation reaction. After the # terminal deploy->done transition for an applicable repo, a reserved-agent job # `post-deploy-monitor` (no LLM, modelled on deploy-finalizer) probes prod over a diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..e14a5d7 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,38 @@ +# gitleaks config — ORCH-022 security-gate (secret-scanning). +# +# Versioned in the repo root (07-infra I-4 / BR-13): rules + an allowlist of +# known-safe matches are reviewed as code. The security-gate (src/security_gate.py) +# passes this file via `--config` when present. gitleaks runs OFFLINE (local rules) +# so the "a secret always blocks" guarantee (BR-2) never depends on the network. +# +# Strategy: extend the built-in ruleset (broad coverage, maintained upstream) and +# only ADD a narrow allowlist for placeholders / fixtures that are intentionally +# fake (e.g. .env.example dummy values, test fixtures). Keep the allowlist tight — +# an over-broad allowlist silently re-opens the leak it was meant to bless. + +title = "orchestrator gitleaks config" + +[extend] +# Start from gitleaks' maintained default ruleset. +useDefault = true + +[allowlist] +description = "Known-safe, intentionally non-secret matches (placeholders + fixtures)." + +# Files that legitimately contain placeholder/dummy secret-shaped values: +# * .env.example — the committed canon of env vars with DUMMY values (CLAUDE.md §8; +# real secrets live only in the host .env / .env.staging, never in git). +# * tests/ — fixtures may embed fake tokens to exercise the scanner itself (TC-03). +# * .gitleaks.toml — this file (avoid self-matching example patterns below). +paths = [ + '''(^|/)\.env\.example$''', + '''(^|/)tests/''', + '''(^|/)\.gitleaks\.toml$''', +] + +# Generic placeholder tokens used in docs / examples that are NOT real secrets. +regexes = [ + '''(?i)(your[-_]?(token|key|secret|password)[-_]?here)''', + '''(?i)(changeme|dummy|example|placeholder|xxxxx+)''', + '''(?i)<[a-z0-9_-]+>''', +] diff --git a/CHANGELOG.md b/CHANGELOG.md index 09dfba8..8a06421 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ ## [Unreleased] ### Added +- **Security-гейт: secret-scanning (gitleaks) + dependency audit (pip-audit) перед мержем** (ORCH-022): автономный конвейер вливал ветку в `main` без проверки на утёкший секрет (ключ/токен/пароль/приватный ключ) и уязвимую зависимость (известный CVE) — для self-hosting `orchestrator` это особенно остро: один общий прод-инстанс обслуживает все проекты из общей БД, поэтому секрет/CVE, проскочивший через одну задачу, уезжает в прод всех проектов (CLAUDE.md §self-hosting, §8). ORCH-022 вводит детерминированный (без LLM) **security-гейт как под-гейт ребра `deploy-staging → deploy`**, исполняемый **ПЕРВЫМ** среди edge-под-гейтов (ДО merge-gate ORCH-043 и image-freshness ORCH-058) — дёшево фейлить до дорогих rebase/rebuild, а скан ветки ДО rebase не «обвиняет» задачу в CVE из обновившегося `main`. Паттерн соседей: новый leaf-модуль `src/security_gate.py` (контракт «never-raise», по образцу `merge_gate`/`image_freshness`/`staging_verdict`) + тонкая обёртка `check_security_gate` в реестре `QG_CHECKS` (`src/qg/checks.py`, lazy-import → нет цикла) + врезка `_handle_security_gate` в `src/stage_engine.py` в блок `current_stage == "deploy-staging"` ПЕРВОЙ. `STAGE_TRANSITIONS` и схема БД — **без изменений**. **Secret-scanning (`gitleaks`, offline):** скан диапазона `origin/main..HEAD` (ровно коммиты задачи); любой секрет вне аллоулиста версионируемого `.gitleaks.toml` → вклад в FAIL. Полностью оффлайн (локальные правила) → гарантия «секрет всегда блокирует» (BR-2) безусловна, не зависит от сети; **fail-closed** при ошибке инструмента/отсутствии бинаря/таймауте (нельзя доказать «секретов нет» → FAIL). Контракт exit-кодов: 0=чисто, 1=найдено, ≥2=ошибка. **Dependency audit (`pip-audit`, OSV/PyPI):** аудит `requirements.txt`; severity ≥ `security_dep_block_severity` (дефолт `HIGH`, порядок CRITICAL>HIGH>MEDIUM>LOW) → вклад в FAIL (`deps_blocking`); ниже порога / UNKNOWN → warning (`deps_warning`, анти-петля Р-4, не авто-блок). Источник advisory требует сети → недоступность фида **fail-open + громкий warning** по умолчанию (`deps_audit_degraded: true` + Telegram + лог; прецедент анти-петли ORCH-061), флаг `security_dep_audit_fail_closed` переводит в строгий режим без редеплоя кода. **Артефакт `17-security-report.md`** (YAML-frontmatter `security_status`/`secrets_found`/`deps_blocking`/`deps_warning`/`deps_audit_degraded` + тело-списки находок); машинный вердикт читается ТОЛЬКО из frontmatter (гейт пишет → читает обратно через `parse_security_status` → возвращает ровно то, что записал: единый источник истины, AC-8), negative-токен (FAIL) авторитетен, нет frontmatter/битый YAML/нет поля → **fail-closed** на чтении; значения секретов в артефакте маскируются (не ре-лик). **FAIL → откат на `development`** + developer-retry (общий `_developer_retry_count`, cap `MAX_DEVELOPER_RETRIES`=3, затем `set_issue_blocked` + Telegram, без бесконечного баунса); `task_desc` перезапущенного developer'а несёт дословные находки (`extract_security_findings`, паттерн ORCH-046) + ссылку на артефакт. **Self-hosting safety:** гейт только читает/сканирует/пишет артефакт — не вызывает деплой-хук, не рестартит прод-контейнер (под-гейт исполняется ДО захвата merge-lease → при FAIL lease освобождать не нужно). **Условность как ORCH-35/43/58:** `security_gate_enabled` (kill-switch) + `security_gate_repos` (CSV; пусто → только self-hosting `orchestrator`); таймаут `security_scan_timeout_s`; never-raise. v1 — Python-only стек; SAST/мульти-стек — follow-up (BR-14). Инфраструктура: pinned `gitleaks` (статический Go-бинарь) в `Dockerfile` (+ `curl`/`ca-certificates`), `pip-audit` (pinned) в `requirements.txt`, `.gitleaks.toml` в корне репо. Новые настройки: `ORCH_SECURITY_GATE_ENABLED` (true), `ORCH_SECURITY_GATE_REPOS` (""), `ORCH_SECURITY_DEP_BLOCK_SEVERITY` (HIGH), `ORCH_SECURITY_SCAN_TIMEOUT_S` (300), `ORCH_SECURITY_DEP_AUDIT_FAIL_CLOSED` (false), `ORCH_SECURITY_SECRETS_BLOCK` (true). Инварианты НЕ менялись: `STAGE_TRANSITIONS` (9 стадий), `check_branch_mergeable`/`check_staging_image_fresh` и их под-гейты, БАГ-8 откат, terminal-sync, схема БД (без миграций). ADR `docs/work-items/ORCH-022/06-adr/ADR-001-security-gate.md`, глобальный `docs/architecture/adr/adr-0012-security-gate.md`. Документация: `docs/architecture/README.md`, `CLAUDE.md`, `.env.example`. Тесты: `tests/test_security_gate.py`, `tests/test_qg_security.py`, `tests/test_stage_engine_security_gate.py`, `tests/test_qg_registry_snapshot.py`, `tests/test_config.py`. +- **Выделенный статус-триггер прод-деплоя «Confirm Deploy»** (ORCH-059): жест запуска прод-деплоя отделён от человеческого гейта одобрения. Раньше один Plane-статус `Approved` был перегружен: на `analysis` он работал как человеческий гейт BRD (`check_analysis_approved`), а на `deploy` — молча триггерил Фазу B прод-деплоя ORCH-036 (`advance_stage(deploy, finished_agent=None) → _handle_self_deploy_phase_b → detached host-рестарт прод-контейнера 8500`). Привычный жест approve = групповой self-hosting риск (прод обслуживает ВСЕ проекты из одного инстанса). ORCH-059 вводит отдельный логический статус `confirm_deploy` («Confirm Deploy»), который триггерит **ТОЛЬКО** Фазу B на `deploy`; `Approved` остаётся исключительно гейтом конвейера. Четыре точечные правки в трёх модулях: (1) `src/plane_sync.py` — маппинг `"Confirm Deploy" → "confirm_deploy"` в `_PLANE_NAME_TO_KEY`; ключ намеренно НЕ добавлен в `_DEFAULT_STATES` (нет UUID для enduro/fallback) → **fail-closed**: для проекта ORCH резолвится из живого Plane API (`get_project_states(orch)["confirm_deploy"]` → реальный UUID), для сред без статуса (enduro / недоступный API / доска без статуса) ключ просто отсутствует, доступ через `.get("confirm_deploy")` → `None`, без `KeyError`. (2) `src/webhooks/plane.py` — `handle_issue_updated` ДО ветки `approved` добавляет fail-closed-ветку `confirm_state = proj_states.get("confirm_deploy"); if confirm_state and new_state == confirm_state: handle_confirm_deploy(...)`; новый `handle_confirm_deploy` резолвит задачу, гард `stage == "deploy"` (иначе no-op с логом — защищает прочие гейты от случайного триггера), иначе → `_try_advance_stage(..., confirm_deploy=True)`. `handle_verdict(approved=True)` не изменён (продолжает звать `_try_advance_stage` с дефолтным `confirm_deploy=False`). (3) `src/stage_engine.py` — `advance_stage` получил keyword-only параметр `confirm_deploy: bool = False` (обратносовместимо: все существующие вызовы из launcher/reconciler/finalizer передают `finished_agent`); блок Фазы B теперь **всегда возвращается рано** для `deploy + finished_agent is None` self-hosting, но `_handle_self_deploy_phase_b` вызывается ТОЛЬКО при `confirm_deploy=True`, иначе (обычный `Approved`) — детерминированный **no-op** (`result.note = "approved-on-deploy-noop"`): возврат ДО блока Quality Gate → `check_deploy_status` не запускается → нет ложного отката БАГ-8 (вердикта ещё нет, R-2). (4) CTA Фазы A (`_handle_self_deploy_phase_a`) — Plane-коммент и Telegram просят перевести задачу в статус «Confirm Deploy» (а не «Approved»). Следствие для reconciler F-1 на `deploy` (ORCH-053): попадает в no-op-ветку вместо неявного запуска Фазы B → прод-деплой нельзя инициировать автоматически, только явным человеческим «Confirm Deploy» (усиление safety). Условность как ORCH-35/36 (реально только для `self_deploy.self_deploy_applies("orchestrator")`; прочие репо — прежний синхронный ssh-деплой агентом, статус не нужен и не влияет). Контракты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status`, exit-код-контракт хука (0/1/2), Фазы A/C, merge-gate, terminal-sync, схема БД (статусы — на стороне Plane; restart-safe состояние деплоя — существующие sentinel-файлы ORCH-036). Эксплуатационное предусловие: в Plane-проекте ORCH создать статус доски «Confirm Deploy» (точное имя, регистр) + сброс кэша состояний — `docs/work-items/ORCH-059/07-infra-requirements.md`. До создания статуса прод-деплой через approve не запустится (желаемое fail-closed-поведение). ADR `docs/work-items/ORCH-059/06-adr/ADR-001-confirm-deploy-status.md` (уточняет триггер Фазы B относительно adr-0007). Тесты: `tests/test_plane_states.py`, `tests/test_plane_confirm_deploy.py`, `tests/test_stage_engine_phase_b.py`, `tests/test_stage_engine_phase_a_cta.py`, `tests/test_confirm_deploy_integration.py`, `tests/test_deploy_approve.py` (обновлён под новый триггер). +- **Осмысленная статусная модель Plane (слой B — индикация)** (ORCH-066): Plane больше не показывает наблюдателю огрублённую/вводящую в заблуждение картину — статусы доски приведены к смыслу стадий конвейера, при этом статус остаётся **индикацией, а не управлением**. Архитектурный инвариант (ADR-001): меняется ТОЛЬКО слой B (отображение в Plane — `src/plane_sync.py` и точки выставления статуса в `stage_engine.py`/`webhooks/plane.py`/`reconciler.py`), слой A (машина стадий `src/stages.py::STAGE_TRANSITIONS`) остаётся **байт-в-байт неизменным** (AC-21, регресс-тест TC-22 сверяет полный литерал словаря). Целевая модель: `Backlog → Todo → [To Analyse] → Analysis → [In Review → Approved] → Architecture → Development → Code-Review → Testing → Awaiting Deploy → [Confirm Deploy] → Deploying → Monitoring after Deploy → Done`. Добавлены **6 новых логических ключей статуса** (`to_analyse`, `analysis`, `code_review`, `awaiting_deploy`, `deploying`, `monitoring`) в `_DEFAULT_STATES`/`_PLANE_NAME_TO_KEY` плюс `STAGE_VISIBILITY_STATE` (`analysis→analysis`, `review→code_review`) и `_STAGE_TO_STATE_KEY`; новые сеттеры `set_issue_analysis/code_review/awaiting_deploy/deploying/monitoring` + диспетчер `set_issue_stage_state`. **Project-relative alias-fallback (BR-12):** если оператор ещё не создал новый статус в конкретном Plane-проекте, ключ деградирует на базовый UUID **ТОГО ЖЕ** проекта (`_STATE_ALIAS_FALLBACK`: `analysis→in_progress`, `code_review→review`, `awaiting_deploy→in_review`, `deploying→in_progress`, `monitoring→done`, `to_analyse→in_progress`), поэтому PATCH остаётся валидным на частичных конфигах, а enduro-trails схлопывает новые ключи на старые базовые статусы → **нулевая регрессия**. **Самодеплой (ORCH-036) теперь индицирует фазы:** Phase A → `Awaiting Deploy` (ожидание ручного approve), Phase B → `Deploying`, terminal-sync `deploy→done` ветвится — для self-hosting (`post_deploy.post_deploy_applies(repo)`) issue входит в окно `Monitoring after Deploy` (НЕ терминальный Done), для прочих репо — прежний терминальный `Done` (нулевая регрессия, TC-08/TC-09). **Post-deploy монитор (ORCH-021)** на закрытии окна: HEALTHY → `set_issue_done`, DEGRADED → `set_issue_blocked` (только индикация; self-hosting остаётся ALERT_ONLY, прод НИКОГДА не рестартится/не откатывается — BR-5, TC-10/11/12). **Reconciler:** F-2 триггер старта/резюма расширен на `To Analyse` (TC-20), Guard 2 `_is_blocked_or_needs_input` учитывает новые активные ожидания (`awaiting_deploy/deploying/monitoring`) с вычитанием базовых рабочих статусов, чтобы алиасинг на частичных проектах не расширял skip-set (анти-регресс, TC-21). Контракт **never-raise** на всех сеттерах и резолвере состояний сохранён (API Plane недоступен → identity-фоллбэк, сеттеры не бросают — TC-16/17/18). **Раскатка** управляется оператором (создание 6 статусов в Plane), отдельного kill-switch не вводится — на «голом» Plane всё деградирует на прежнее поведение. Инварианты НЕ менялись (TC-22/TC-23): `STAGE_TRANSITIONS` (9 стадий), реестр `QG_CHECKS` (12 чеков), сигнатура `check_deploy_status(repo, work_item_id, branch)`, exit-код-контракт хука, merge-gate, схема БД (без миграций). ADR `docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md`. Тесты: `tests/test_plane_status_model.py`, `tests/test_plane_to_analyse_resume.py`, `tests/test_plane_status_failclosed.py`, `tests/test_plane_webhook.py` (TC-15), `tests/test_deploy_terminal_sync.py` (TC-08/09), `tests/test_post_deploy_integration.py` (TC-10/11/12), `tests/test_orch10_states.py` (TC-19), `tests/test_reconciler.py` (TC-21), `tests/test_reconciler_plane.py` (TC-20). - **Job-reaper + проактивный реклейм протухшего merge-lease + идемпотентная финализация merge** (ORCH-065): закрыт класс инцидентов «zombie jobs» — статус job выставлялся ТОЛЬКО в живом процессе launcher'а, поэтому гибель процесса (OOM/рестарт инстанса/segfault Claude-CLI) оставляла строку `jobs.status='running'` навсегда; при `max_concurrency=1` один такой зомби намертво блокировал очередь ВСЕХ проектов (self-hosting: enduro-trails встаёт из-за зомби ORCH-задачи). Плюс два смежных дефекта: застрявший merge-lease (`.merge-lease-.json` реклеймился лишь лениво по TTL при чужом acquire, живость pid-holder'а не проверялась) и неидемпотентная финализация merge (rebase+re-test зелёные, но процесс умер до самого merge → нет повторного проигрывания). Решение — новый фоновый daemon-поток **`src/job_reaper.py`** (контракт «never-raise на единицу работы», паттерн `reconciler`/`queue_worker`): периодический тик (`reaper_interval_s`) сканирует `running`-jobs трёхуровневой проверкой живости (ADR Р-1): **Tier-1** мёртвый pid (`os.kill(pid, 0)` → `ProcessLookupError`) с анти-false-positive порогом `reaper_dead_ticks` подряд-мёртвых тиков (стрик в памяти); **Tier-2** `agent_runs.exit_code` записан, но job всё ещё `running` — но только после finalization-grace `reaper_finalize_grace_s` (окно неоднозначно: живой monitor пишет exit_code ПЕРВЫМ, затем git push/PR/Plane-комментарии и лишь потом `_finalize_job`, а pid агента к этому моменту мёртв в обоих случаях — живой финализирующий monitor НЕ реапится); **Tier-3** backstop-потолок `reaper_max_running_s`. Единственная мутирующая запись reaper'а — атомарный терминальный флип через `db.reap_running_job(... WHERE status='running')` (rowcount==1 у победителя, проигравший в гонке с `requeue_running_jobs`/launcher видит rowcount==0 — без двойной обработки, TC-06). Для Tier-2 exit0 действие построено по принципу **claim-before-act** (ADR Р-1): источник истины — канонический QG (не «exit0»), он оценивается read-only (`_gate_is_green` → `stage_engine._run_qg`, как у reconciler) ПЕРЕД claim, затем атомарный claim `done` ПЕРВЫМ и только победитель claim делает gate-driven advance (`_gate_driven_advance` → штатный `launcher._try_advance_stage`, кандидат-стадии агента из `STAGE_TRANSITIONS`) — проигравший claim не выполняет НИКАКИХ побочных эффектов (нет дубль-advance / дубль-enqueue следующей стадии); зелёный гейт → `done`+advance, красный → путь неуспеха (requeue в пределах `attempts post_deploy_5xx_threshold`; иначе `HEALTHY` — одиночный глюк не откатывает), `decide_action` (self-hosting → ВСЕГДА `ALERT_ONLY`; не-self + `post_deploy_auto_rollback=true` → `ROLLBACK`; иначе `ALERT_ONLY`), `map_rollback_exit_code` (`0→ROLLBACK_OK`, иначе `ROLLBACK_FAILED`), sentinel-state хелперы (`armed`/`series`/`done` под `/.post-deploy-state-//`, restart-safe счётчики), `build_rollback_command`/`run_rollback` (ssh-хук `--rollback` с прод-env, синхронно — только для не-self), `build/write_post_deploy_log` (артефакт `16-post-deploy-log.md`), `arm_monitor` (идемпотентный арм + первый отложенный job), `status` (снимок для `/queue`). **Механизм наблюдения — reserved-agent job `post-deploy-monitor`** (детерминированный, no-LLM, калька `deploy-finalizer`, НЕ стадия и НЕ daemon): арм в `stage_engine.advance_stage` в блоке `next_stage == "done"` ПОСЛЕ terminal-sync/release-lease (`post_deploy.arm_monitor`, sentinel `armed` = идемпотентность при двойном webhook/reconciler/finalizer); один тик = один job — перехват в `agents/launcher.launch_job` ДО `_spawn` → `stage_engine.run_post_deploy_monitor` (один опрос → append в `series` → `classify` → перепостановка с задержкой `available_at_delay_s` ИЛИ реакция+артефакт+`mark_done`); бюджет тиков `window_s/interval_s` (анти-livelock). **Self-hosting safety (BR-5):** для `orchestrator` тик НИКОГДА не откатывает/рестартит прод-контейнер — реакция всегда `ALERT_ONLY` (громкий Telegram + Plane-коммент с запросом ручного approve); авто-rollback хуком `--rollback` — только для не-self репо при `post_deploy_auto_rollback=true` (целевой контейнер ≠ orchestrator). Наблюдаемость — блок `post_deploy` в `GET /queue` (enabled/window/interval/активные наблюдения). Артефакт `16-post-deploy-log.md` (YAML-frontmatter `post_deploy_status`/`action_taken`/`window_s`/`checks_total`/`checks_failed`) — машиночитаемо для петли уроков ORCH-8; best-effort. Новые настройки: `ORCH_POST_DEPLOY_MONITOR_ENABLED` (true, kill-switch), `ORCH_POST_DEPLOY_REPOS` (CSV; пусто → только self-hosting), `ORCH_POST_DEPLOY_WINDOW_S` (900), `ORCH_POST_DEPLOY_INTERVAL_S` (30), `ORCH_POST_DEPLOY_FAIL_THRESHOLD` (3), `ORCH_POST_DEPLOY_5XX_THRESHOLD` (0.5), `ORCH_POST_DEPLOY_AUTO_ROLLBACK` (false), `ORCH_POST_DEPLOY_BASE_URL` (http://localhost:8500); параметры отката переиспользуют `deploy_prod_*`. Инварианты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status`, terminal-sync `deploy→done`, merge-gate, exit-код-контракт хука (0/1/2), схема БД (без миграций; состояние — sentinel-файлы). Условность как ORCH-35/36/43/58. ADR `docs/work-items/ORCH-021/06-adr/ADR-001-post-deploy-monitor.md`, глобальный `docs/architecture/adr/adr-0010-post-deploy-monitor.md`. Тесты: `tests/test_post_deploy.py`, `tests/test_post_deploy_integration.py`. - **Провенанс staging-образа перед BUILD-ONCE retag в прод (свежесть артефакта, INV-FRESH)** (ORCH-058): BUILD-ONCE retag (ORCH-036) промоутит staging-образ (`orchestrator-orchestrator-staging`) в прод **без rebuild**, полагаясь на «образ свеж и провалидирован» — гарантии не было: конвейер нигде не пересобирал staging-образ из провалидированного коммита, поэтому retag мог тихо промоутнуть УСТАРЕВШИЙ образ (инцидент LESSONS_ORCH-036 п.4 — зелёный деплой молча откатывал прод). Закрыто **двумя слоями (defense in depth), только для self-hosting**. Новый модуль `src/image_freshness.py` (контракт «never raise», по образцу `merge_gate`): `provenance_verdict` (чистая функция вердикта match/mismatch/fail-closed), `validated_revision` (`git rev-parse HEAD` в worktree валидированного коммита — единый якорь и для штампа A, и для `EXPECTED_REVISION` B), `image_revision` (OCI-лейбл `org.opencontainers.image.revision` через `docker image inspect`, ``/ошибка → пусто), `rebuild_staging_image` (ssh-хук `--build-staging`), `image_freshness_applies` (условность), `check_staging_image_fresh` (композитный QG). **Strategy A (liveness):** новый детерминированный QG-под-чек `check_staging_image_fresh` (зарегистрирован в `QG_CHECKS`, `src/qg/checks.py`) на ребре `deploy-staging → deploy` ПОСЛЕ merge-gate и ДО Phase A — пересобирает staging-образ из worktree валидированного коммита (хук `--build-staging`, `--build-arg GIT_SHA=`), пересоздаёт 8501 и прогоняет `staging_check.py --mode stub` против свежего 8501 (health + e2e, внутри staging-контейнера через `docker exec` — канон ORCH-048) → валидируем РОВНО тот артефакт (build + e2e), что промоутится в прод (AC-4); FAIL/не-ноль staging_check → откат на `development` (как merge-gate, кап `MAX_DEVELOPER_RETRIES`). `rebuild_staging_image` пробрасывает в хук **явный** staging-таргет (service/port/profile/container), исключая дрейф на прод 8500. Сборки/recreate/validate — **только staging (8501)**, прод (8500) не трогается. **Strategy B (safety):** `Dockerfile` штампует `LABEL org.opencontainers.image.revision=$GIT_SHA` (`ARG GIT_SHA`); `build_deploy_command` (`src/self_deploy.py`) пробрасывает `EXPECTED_REVISION`; хост-хук шагом 2b ПЕРЕД `docker tag` fail-closed сверяет лейбл `revision` у `SOURCE_IMAGE` с `EXPECTED_REVISION` — несовпадение / пустой лейбл / ошибка inspect → `exit 1` (FAILED → БАГ-8 откат), делает тихий промоут устаревшего образа структурно невозможным даже при проигравшей гонку/отключённой A. Хост-хук `scripts/orchestrator-deploy-hook.sh` расширен **обратно-совместимым** режимом `--build-staging` (пересборка+recreate staging, exit 0/1) и fail-closed guard'ом (активен только при заданном `EXPECTED_REVISION`). Единый kill-switch `ORCH_IMAGE_FRESHNESS_ENABLED` (true) включает A+B **как целое** (нет «B без A» = вечного fail-fast); область — `ORCH_IMAGE_FRESHNESS_REPOS` (CSV; пусто → только self-hosting `orchestrator`). Контракты НЕ менялись: `STAGE_TRANSITIONS` (под-гейт ребра, не стадия), exit-code-контракт хука (0/1/2), `map_exit_code_to_status`, `check_deploy_status`/`_parse_deploy_status`, БАГ-8, terminal-sync, merge-gate; схема БД — без миграций. ADR `docs/work-items/ORCH-058/06-adr/ADR-001-staging-image-provenance.md`, глобальный `docs/architecture/adr/adr-0008-staging-image-provenance.md`. Документация: `docs/architecture/README.md`, `docs/operations/DEPLOY_HOOK.md`, `docs/operations/STAGING.md`, `docs/operations/INFRA.md`, `.env.example`. Тесты: `tests/test_image_freshness.py`, `tests/test_deploy_hook_provenance.py`, `tests/test_deploy_build_once.py` (TC-06), `tests/test_deploy_hook_mapping.py` (TC-09), `tests/test_stage_engine.py::TestImageFreshnessGate`, `tests/test_qg_registry_snapshot.py`, `tests/test_config.py`. @@ -31,6 +34,7 @@ - Цепочка стадий: `... testing → deploy-staging → deploy → done` (была без `deploy-staging`). ### Fixed +- **Reconciler (F-2) больше не зацикливается на спаме «разблокирована» по синхронизированной done-задаче** (ORCH-068): после мерджа новой статусной модели Plane (ORCH-066) sweeper потерянных webhook (ORCH-053) каждые ~120с слал в Telegram `reconciler: ET-002 done разблокирована (потерян webhook)` для полностью синхронизированной задачи (БД `stage=done`, Plane `state=Done`) — livelock без advance/jobs/токенов, но 191+ сообщений за ночь (alert-fatigue, подрыв доверия к нотификациям). Два независимых складывающихся дефекта (defense in depth, ADR-001): **D1 (выборка)** — F-2 различал actionable-статусы по голому UUID, а после ORCH-066 терминальный `Done` перестал однозначно отличаться от `approved` по UUID (UUID-алиасинг) и done-issue попадала в ветку `approved`; терминалы нигде не исключались. Решение: исключение терминалов по **группе состояния Plane** (`state.group ∈ {completed, cancelled}`) — проектно-независимый, устойчивый к переименованиям дискриминатор; проверка per-issue (а не сужением `wanted`-набора, т.к. при алиасинге терминал физически совпадает с actionable-UUID); fallback по логическим ключам `done`/`cancelled`, когда группа недоступна. `get_project_states` расширен записью `{uuid → group}` из ТОГО ЖЕ `/states/`-запроса (без новой сетевой стоимости) + sibling-аксессор `get_project_state_groups`. **D2 (нотификация)** — `_note_unblock` вызывался безусловно сразу после `_dispatch`, не проверяя, изменил ли обработчик реально состояние; `handle_verdict(approved)` для уже-`done` задачи — no-op, но нотификация всё равно уходила (нарушение собственного docstring и инварианта silence-when-in-sync). Решение: сравнение стадии задачи **до/после** `_dispatch` на стороне reconciler (контракты `handle_*` НЕ тронуты) — `_note_unblock` только при подтверждённом state change; для in_progress-старта подтверждение = задача появилась. Плюс **TR-3** — in-memory дедуп-guard `{issue_id → last_unblocked_state}` (страховка против любого будущего no-op-пути). Вторичный баг кэша (**TR-4**): `_STATES_CACHE` жил весь lifetime процесса → новый Plane-статус был невидим без рестарта («stale set → no pipeline action»); добавлен TTL `ORCH_PLANE_STATES_TTL_S` (дефолт 300с; `0` → прежний lifetime-кэш) — запись самозалечивается перезапросом `/states/` (примитив сброса — существующий `reload_project_states()`); при сбое перезапроса отдаётся stale-but-correct набор, а не enduro-дефолты. Форма возврата `get_project_states` неизменна (AC-13). `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, контракты `handle_status_start`/`handle_verdict`, F-1/F-3 — не тронуты; never-raise per-issue/-project сохранён; self-hosting — тик не рестартит прод. Наблюдаемость: счётчики `skipped_terminal_total`/`deduped_total` в блоке `reconcile` снимка `GET /queue`. ADR `docs/work-items/ORCH-068/06-adr/ADR-001-reconciler-terminal-exclusion-and-cache-ttl.md`. Тесты: `tests/test_reconciler_plane.py` (TC-01…TC-10), `tests/test_plane_states_cache.py` (TC-11/TC-12). - **Staging-rebuild больше не падает на `COPY data/` (worktree-контекст)** (ORCH-021): `check_staging_image_fresh` (ORCH-058, Strategy A) пересобирает staging-образ с **worktree задачи** в качестве docker build context (`docker build … "$BUILD_CONTEXT"`). Свежий git-worktree содержит только трекаемые файлы, а `Dockerfile` делал `COPY data/ ./data/` — но `data/` (директория SQLite) **gitignored** и в worktree-контексте отсутствует → `docker build` падал с `exit 1` («BUILD-STAGING: docker build failed - aborting»), задачу заворачивало с `deploy-staging` на `development` (петля, выжигание developer-ретраев, инцидент текущего прогона ORCH-021). При этом COPY был мёртвым грузом: `data/` всегда приходит рантайм-volume'ом (`./data:/app/data` / `./data/staging:/app/data` в `docker-compose.yml`), который затеняет всё, что было запечено в образ. Заменено на `RUN mkdir -p /app/data` (директория-mountpoint существует и без bind-mount, без зависимости от build-контекста). Контракты `STAGE_TRANSITIONS`/`QG_CHECKS`, штамп `LABEL org.opencontainers.image.revision=$GIT_SHA` (ORCH-058 Strategy B), exit-код-контракт хука — не тронуты. Регресс-гард: `tests/test_deploy_hook_provenance.py::test_tc08b_dockerfile_does_not_copy_gitignored_data_dir` (запрещает `COPY` любого gitignored-пути). - **`deploy-staging` больше не зацикливается на infra-only FAIL песочницы (C9a/C9b)** (ORCH-061): self-hosting `orchestrator` крутился в петле `deploy-staging → development` — `scripts/staging_check.py` давал `exit 1` при ЛЮБОМ упавшем чеке, поэтому две чисто инфраструктурные проверки **C9a** (ветка не появилась в `orchestrator-sandbox`) и **C9b** (job аналитика не встал в очередь staging) — вызванные тем, что SANDBOX-бот-аккаунты не состоят в sandbox-проекте Plane (шаги 6+ конвейера в песочнице недостижимы, это НЕ регресс конвейера) — приводили к `staging_status: FAILED` → откат → цикл (выжигание developer-ретраев, токенов, паразитная нагрузка общего инстанса). Решение (Direction «б», ADR-001): чеки классифицируются на `REAL` (все проверки конвейера A*/B*/C7/C8 — fail-closed) и `SANDBOX_INFRA` (строго allowlist `{C9a, C9b}` — waivable). Новый leaf-модуль `src/staging_verdict.py` (stdlib-only, контракт «never raise», по образцу `merge_gate`/`image_freshness`): `classify_check(label)` (allowlist по ведущему токену, всё неизвестное/малформенное → `REAL` fail-closed) и `compute_staging_verdict(items, infra_tolerant) -> StagingVerdict`: любой REAL-FAIL → `FAILED`/exit 1 (страховка при ЛЮБОМ значении флага); упали ТОЛЬКО C9a/C9b и толерантность включена → `SUCCESS`/exit 0 + упавшие метки в `waived` (наблюдаемость); только C9a/C9b и толерантность выключена → `FAILED`/exit 1 (legacy-строгий); любая внутренняя ошибка вердикта → `FAILED`/exit 1 (никогда не ложный green). `scripts/staging_check.py`: `Results` авто-классифицирует каждый чек (публичная 3-tuple форма `_items` сохранена — регрессия-гард ORCH-048 b6), `categorized_items()` отдаёт категорию, `summary()` печатает разбивку REAL/SANDBOX_INFRA; `main()` сворачивает прогон через `_verdict(...)`, печатает строки `INFRA-WAIVED:`/`VERDICT:` и делает `sys.exit(verdict.exit_code)`; новый флаг `--strict` форсит строгий режим для одного запуска. Глобальный kill-switch `ORCH_STAGING_INFRA_TOLERANCE_ENABLED` (`Settings.staging_infra_tolerance_enabled`, default `true`; `false` → строгий 1:1 до ORCH-061), живёт в `.env.staging`; `--strict` имеет приоритет над env. Наблюдаемость на стороне конвейера: `src/agents/launcher.py` получил `action_stage_no_changes_note(stage, repo)` — на action-стадиях (`deploy-staging`/`deploy`) self-hosting-репо «нет изменений для коммита» логируется как ожидаемое, а не трактуется как недопоставка. Контракты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, frontmatter `staging_status: SUCCESS|FAILED` / `deploy_status:` (толерантность применяется в скрипте ДО записи артефакта деплоером), exit-code-контракт хука (0/1/2), `check_staging_status`/`_parse_staging_status`; схема БД — без миграций. ADR `docs/work-items/ORCH-061/06-adr/ADR-001-staging-infra-tolerance.md`. Документация: `docs/architecture/README.md`, `docs/operations/STAGING_CHECK.md`, `.openclaw/agents/deployer.md`. Тесты: `tests/test_staging_check_b6.py`, `tests/test_qg_checks.py`, `tests/test_config.py`, `tests/test_launcher.py`, `tests/test_qg.py`, `tests/test_stage_engine.py::TestStagingInfraTolerance`. - **Reconciler (F-1) больше не разблокирует escalated / Blocked / Needs-Input задачи** (ORCH-060): sweeper потерянных webhook (ORCH-053) не отличал «застряла из-за потерянного события» от «исчерпала лимит developer-ретраев и ждёт человека» — если CI зелёный, а reviewer слал REQUEST_CHANGES до `MAX_DEVELOPER_RETRIES`, каждый тик F-1 видел зелёный `check_ci_green` и доигрывал `development → review` → reviewer снова REQUEST_CHANGES → откат (стадия не меняется, escalated в `gitea.py` лишь шлёт `notify_error`) → следующий тик снова разблокировал. Бесконечная петля (инцидент ET-013: 10 разблокировок за ночь, лишние запуски агентов/токены, спам в Telegram, паразитная нагрузка общего self-hosting-инстанса). В `Reconciler._reconcile_gate_task` (`src/reconciler.py`) ПОСЛЕ существующих гардов (`analysis` carve-out, нет гейта, активный job, grace) и ДО пред-оценки гейта добавлены два пред-гарда с ранним `return` (молчаливый skip — без `advance`, без инкремента `unblocked_total`, без нотификаций): **Guard 1 (escalated, детерминированный, без сети, проверяется первым)** — `developer_retry_count(task_id) >= MAX_DEVELOPER_RETRIES`; приватный `stage_engine._developer_retry_count` повышен до публичного `developer_retry_count` (единый источник истины по подсчёту ретраев `agent_runs`, приватное имя сохранено как алиас), граница берётся из `stage_engine.MAX_DEVELOPER_RETRIES` (не хардкод `3`). **Guard 2 (явный человеческий Plane-статус, Вариант A — без миграции БД)** — новый never-raise хелпер `plane_sync.fetch_issue_state(issue_id, project_id) -> str|None` (тот же endpoint/headers, что `fetch_issue_sequence_id`) + `Reconciler._is_blocked_or_needs_input(task)`: резолв проекта (`projects.get_project_by_repo`) → `get_project_states(pid)` → сверка текущего state issue с `blocked`/`needs_input`; любая ошибка/`None`/нерезолвленный проект → консервативный skip (`True`: не-разблокировать безопаснее). F-2 по существу не менялся: Blocked/Needs Input не входят в опрашиваемый набор `{in_progress, approved, rejected}` → не доигрываются (зафиксировано регресс-тестом). Новый под-флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED` (true) гасит ТОЛЬКО сетевой Guard 2 (escape hatch при Plane-outage); Guard 1 всегда активен. Схема БД, `STAGE_TRANSITIONS`, `QG_CHECKS`, never-raise на единицу работы, `analysis` carve-out и kill-switch'и (`reconcile_enabled`/`reconcile_plane_enabled`) не менялись. ADR `docs/work-items/ORCH-060/06-adr/ADR-001-reconciler-skip-escalated.md`. Тесты: `tests/test_reconciler.py` (TC-01…TC-11 + регресс ORCH-053). diff --git a/CLAUDE.md b/CLAUDE.md index 63cf19e..4b633ab 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,16 +38,19 @@ created → analysis → architecture → development → review → testing → └──── REQUEST_CHANGES ──────┘ (откат на development, max 3) ``` +## Статусная модель Plane (ORCH-066) — индикация ≠ управление +Статусы Plane — это **слой B (индикация)**, отдельный от **слоя A (машина стадий)** `src/stages.py::STAGE_TRANSITIONS`. Plane показывает наблюдателю осмысленную картину (`Backlog → Todo → Analysis → Architecture → Development → Code-Review → Testing → Awaiting Deploy → Deploying → Monitoring after Deploy → Done` + человеческие гейты `In Review/Approved`, `Confirm Deploy`), но НИКОГДА не управляет конвейером. Маппинг и сеттеры — `src/plane_sync.py` (6 новых ключей: `to_analyse/analysis/code_review/awaiting_deploy/deploying/monitoring`), с project-relative alias-fallback: на частично сконфигурированном проекте новый ключ деградирует на базовый UUID ТОГО ЖЕ проекта (нулевая регрессия для enduro-trails). Детали — `docs/architecture/README.md`. + ## Конвенции - Conventional Commits (`feat:`, `fix:`, `docs:`, `refactor:`, `test:`) - Ветки: `feature/ORCH-NNN-slug`, `fix/ORCH-NNN-slug` - ADR per work-item: `docs/work-items//06-adr/ADR-NNN-slug.md` - Global ADR (сквозные решения): `docs/architecture/adr/adr-NNNN-slug.md` - Work items: `docs/work-items//` -- Машинные вердикты Quality Gate — строго YAML-frontmatter (`verdict:`, `deploy_status:`, `staging_status:`), никогда проза +- Машинные вердикты Quality Gate — строго YAML-frontmatter (`verdict:`, `deploy_status:`, `staging_status:`, `security_status:`), никогда проза ## Артефакты задачи (`docs/work-items//`) -`00-business-request.md`, `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`, `04-test-plan.yaml`, `06-adr/ADR-NNN-slug.md`, `07-infra-requirements.md`, `08-data-requirements.md`, `10-tech-risks.md`, `12-review.md`, `13-test-report.md`, `14-deploy-log.md`, `15-staging-log.md`, `16-post-deploy-log.md` (post-deploy наблюдение, ORCH-021). +`00-business-request.md`, `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`, `04-test-plan.yaml`, `06-adr/ADR-NNN-slug.md`, `07-infra-requirements.md`, `08-data-requirements.md`, `10-tech-risks.md`, `12-review.md`, `13-test-report.md`, `14-deploy-log.md`, `15-staging-log.md`, `16-post-deploy-log.md` (post-deploy наблюдение, ORCH-021), `17-security-report.md` (security-гейт: `security_status:`/secrets/deps, ORCH-022). ## Правила для агентов 1. Перед любым действием прочесть этот файл и `docs/architecture/README.md`. @@ -64,6 +67,10 @@ created → analysis → architecture → development → review → testing → - **НЕ перезапускать / не ронять прод-контейнер** `orchestrator` в рамках задачи — встанет конвейер всех проектов. - Любой деплой/рестарт self = групповой риск. Детали и топология — `docs/operations/INFRA.md`. - Стадия `deploy-staging` (порт 8501) — обязательная страховка перед прод-деплоем орка. +- Прод-деплой орка запускается ТОЛЬКО переводом задачи на стадии `deploy` в выделенный + Plane-статус **«Confirm Deploy»** (ORCH-059). Статус `Approved` — человеческий гейт + конвейера и прод-деплой НЕ запускает (на `deploy` — no-op). Это разделяет «одобрить + артефакт» и «выкатить в прод», чтобы привычный approve не ронял прод случайным кликом. --- *Паспорт проекта orchestrator. Поддерживается агентами при каждой доработке. Изолирован: описывает только этот проект (канон per-repo, см. ORCH-9).* diff --git a/Dockerfile b/Dockerfile index 890aef5..8ed2471 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,9 +8,28 @@ FROM python:3.12-slim ARG GIT_SHA="" LABEL org.opencontainers.image.revision=$GIT_SHA WORKDIR /app -RUN apt-get update -qq && apt-get install -y -qq openssh-client git && rm -rf /var/lib/apt/lists/* +RUN apt-get update -qq && apt-get install -y -qq openssh-client git curl ca-certificates && rm -rf /var/lib/apt/lists/* # git operations run as root over bind-mounted /repos (may be owned by host uid) -> trust it. RUN git config --system --add safe.directory '*' +# ORCH-022: pinned gitleaks static Go binary for the offline secret-scan sub-gate +# (07-infra I-1). Baked into the image (NOT a pip package): the gate runs INSIDE the +# orchestrator container over a per-task worktree. Pinned release => deterministic +# rules; gitleaks needs no network so the "a secret always blocks" guarantee (BR-2) +# is independent of internet access. Multi-arch aware (amd64/arm64). +ARG GITLEAKS_VERSION=8.18.4 +RUN set -eux; \ + arch="$(dpkg --print-architecture)"; \ + case "$arch" in \ + amd64) gl_arch="x64" ;; \ + arm64) gl_arch="arm64" ;; \ + *) echo "unsupported arch: $arch" >&2; exit 1 ;; \ + esac; \ + curl -fsSL -o /tmp/gitleaks.tar.gz \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_${gl_arch}.tar.gz"; \ + tar -xzf /tmp/gitleaks.tar.gz -C /usr/local/bin gitleaks; \ + chmod +x /usr/local/bin/gitleaks; \ + rm -f /tmp/gitleaks.tar.gz; \ + gitleaks version # ORCH-58: compose runs the container as uid:gid 1000:1000 (ORCH-40), but the base # image has no passwd entry for uid 1000 -> ssh/whoami fail with # "No user exists for uid 1000" (rc=255), breaking the detached self-deploy ssh diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 0b1d743..149199a 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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/.deploy-state-//`), без мигр Подробнее: [adr-0007](adr/adr-0007-executable-self-deploy.md), детально — `docs/work-items/ORCH-036/06-adr/ADR-001-executable-self-deploy.md`. +#### Выделенный статус-триггер прод-деплоя «Confirm Deploy» (ORCH-059 — реализовано) +Перегрузка: один Plane-статус `Approved` служил И человеческим гейтом BRD на +`analysis` (`check_analysis_approved`), И триггером Фазы B прод-деплоя на `deploy` +— привычный жест approve молча запускал прод-рестарт (групповой self-hosting +риск). ORCH-059 разделяет жесты: вводится отдельный логический статус +`confirm_deploy` («Confirm Deploy»), который триггерит **ТОЛЬКО** Фазу B на +`deploy`; `Approved` остаётся исключительно гейтом конвейера. +- `_PLANE_NAME_TO_KEY` += `"Confirm Deploy" → "confirm_deploy"`; в + `_DEFAULT_STATES` ключ НЕ добавляется (нет UUID для enduro/fallback) → + **fail-closed**: нет статуса → нет деплоя, без `KeyError` (доступ через `.get`). +- `handle_issue_updated` маршрутизирует `Confirm Deploy` → `handle_confirm_deploy` + (гард `stage=="deploy"`) → `_try_advance_stage(..., confirm_deploy=True)`. +- `advance_stage` получает kwarg `confirm_deploy: bool=False`; блок Фазы B + (`deploy`+`finished_agent is None`+self-hosting) деплоит ТОЛЬКО при + `confirm_deploy=True`, иначе (обычный `Approved`) — **no-op** (`check_deploy_status` + не запускается → нет ложного отката БАГ-8). +- CTA Фазы A (`_handle_self_deploy_phase_a`) просит «Confirm Deploy», не «Approved». +- Условность как ORCH-35/36 (только `orchestrator`); Фазы A/C, `STAGE_TRANSITIONS`, + `QG_CHECKS`, `check_deploy_status`, merge-gate, схема БД — без изменений. +- Эксплуатация: в Plane-проекте ORCH создать статус «Confirm Deploy» + сброс кэша + состояний (`docs/work-items/ORCH-059/07-infra-requirements.md`). + +Детально — `docs/work-items/ORCH-059/06-adr/ADR-001-confirm-deploy-status.md` +(уточняет/триггер Фазы B относительно adr-0007). + ### Post-deploy наблюдение прода + реакция на деградацию (ORCH-021 — реализовано) Конвейер заканчивался на `deploy → done` и **забывал про прод**: «успех» = health-check в момент рестарта (~60с). Класс «зелёный деплой, красный прод» (прецедент ET-8 — @@ -155,6 +184,38 @@ helper `validated_revision` питает и штамп A, и `EXPECTED_REVISION` образа, без миграций). Подробнее: [adr-0008](adr/adr-0008-staging-image-provenance.md), детально — `docs/work-items/ORCH-058/06-adr/ADR-001-staging-image-provenance.md`. +### Security-гейт: secret-scanning + dependency audit перед мержем (ORCH-022 — реализовано) +Автономный конвейер вливал ветку в `main` без проверки на утёкший секрет (ключ/токен/пароль/ +приватный ключ) и уязвимую зависимость (CVE); для self-hosting один секрет/CVE через одну +задачу уезжал в общий прод всех проектов (CLAUDE.md §8). ORCH-022 вводит детерминированный +(без LLM) **security-гейт как под-гейт ребра `deploy-staging → deploy`**, рядом с merge-gate +(ORCH-043) и image-freshness (ORCH-058), исполняемый **ПЕРВЫМ** среди edge-под-гейтов +(ДО merge-gate). Паттерн соседей: leaf `src/security_gate.py` (never-raise) + тонкая обёртка +`check_security_gate` в `QG_CHECKS` + врезка `_handle_security_gate` в `advance_stage`. +`STAGE_TRANSITIONS` и схема БД — **без изменений**. +- **Secret-scanning (`gitleaks`, offline):** скан `origin/main..HEAD`; любой секрет вне + аллоулиста `.gitleaks.toml` → вклад в FAIL. Offline → гарантия «секрет всегда блокирует» + не зависит от сети (безусловна). +- **Dependency audit (`pip-audit`, OSV/PyPI):** severity ≥ `security_dep_block_severity` + (дефолт `HIGH`) → FAIL; ниже / UNKNOWN → warning. Недоступность фида → **fail-open + + громкий warning** (анти-петля ORCH-061; флаг `security_dep_audit_fail_closed` для строгого + режима). best-effort при доступности фида. +- **ПЕРВЫМ, ДО merge-gate:** дёшево фейлить до дорогих rebase/rebuild; скан ветки ДО rebase + не «обвиняет» задачу в CVE из обновившегося `main`; до захвата merge-lease → при FAIL lease + освобождать не нужно. +- **Артефакт `17-security-report.md`** (YAML-frontmatter `security_status`/`secrets_found`/ + `deps_blocking`/`deps_warning`/`deps_audit_degraded`); вердикт читается ТОЛЬКО из + frontmatter (гейт пишет → читает обратно через `parse_security_status` → возвращает: единый + источник истины), negative-токен авторитетен, битый/нет → fail-closed. +- **FAIL → откат на `development`** + developer-retry (общий `_developer_retry_count`, cap 3, + затем `set_issue_blocked` + Telegram); `task_desc` несёт дословные находки (ORCH-046). +- **Условность как ORCH-35/43/58:** `security_gate_enabled` + `security_gate_repos` (пусто → + только self-hosting); never-raise; таймаут `security_scan_timeout_s`; гейт не деплоит/не + рестартит прод. v1 — Python-only; SAST/мульти-стек — follow-up (BR-14). + +Подробнее: [adr-0012](adr/adr-0012-security-gate.md), детально — +`docs/work-items/ORCH-022/06-adr/ADR-001-security-gate.md`. + ### Reconciler: реконсиляция потерянных webhook (ORCH-053 — реализовано) Конвейер продвигается только входящими webhook; потерянное событие (502 на ребилде, нет ретраев у Plane/Gitea, неразрезолвленный `sha→branch`) → задача застревает молча @@ -174,11 +235,21 @@ 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: разблокирована (потерян webhook)` + Telegram (`reconcile_notify_unblock`); снимок - состояния в `GET /queue` (блок `reconcile`). + состояния в `GET /queue` (блок `reconcile`). **ORCH-068** добавляет в снимок + счётчики `skipped_terminal_total` (исключённые терминалы) и `deduped_total` + (подавленные повторные нотификации). Реализация: `src/reconciler.py` (daemon-поток по образцу `queue_worker`), стартует в `main.lifespan` **после** `worker.start()`, останавливается в `finally` **перед** @@ -249,6 +320,46 @@ ORCH-065 вводит фоновый watchdog, чтобы смерть проц Подробнее: [adr-0011](adr/adr-0011-job-reaper-lease-reclaim.md), детально — `docs/work-items/ORCH-065/06-adr/ADR-001-job-reaper-and-lease-reclaim.md`. +### Осмысленная статусная модель Plane (ORCH-066 — реализовано) +Plane-доска была семантически перегружена: `In Progress` означал «человек запускает +конвейер», «идёт анализ», «идёт прод-деплой» и «возврат из Needs Input» одновременно. +ORCH-066 наводит порядок по утверждённой Owner модели, меняя **только слой B** +(Plane-индикация: `src/plane_sync.py` + точки простановки в `src/stage_engine.py`/ +`src/webhooks/plane.py`/`src/reconciler.py`) и **не трогая слой A** (`STAGE_TRANSITIONS`, +инвариант). Статус — индикация, не управление (вердикты по-прежнему из YAML-frontmatter): +``` +Backlog → Todo → [To Analyse] → Analysis → [In Review → Approved] → Architecture → +Development → Code-Review → Testing → Awaiting Deploy → [Confirm Deploy] → Deploying → +Monitoring after Deploy → Done +``` +`[...]` = человеческий вход-триггер; остальное ставит орк. +- **6 новых логических ключей** (`to_analyse`/`analysis`/`code_review`/`awaiting_deploy`/ + `deploying`/`monitoring`) в `_PLANE_NAME_TO_KEY` (резолв по имени) + `_DEFAULT_STATES`. + `To Analyse` заменяет `In Progress` как вход-триггер (старт + resume аналитика из Needs + Input; fork «старт vs resume» по `get_task_by_plane_id`+`has_active_job_for_task` — + сохранён). Стадии: analysis→`Analysis`, review→`Code-Review` (`_STAGE_TO_STATE_KEY`). +- **Self-deploy фазы:** Phase A → `Awaiting Deploy` (разгружает `In Review`), Phase B → + `Deploying`, Phase C/terminal-sync (self) → `Monitoring after Deploy` (НЕ `Done` сразу); + post-deploy monitor (ORCH-021): HEALTHY-окно → `Done`, DEGRADED → `Blocked` (тик + по-прежнему НИКОГДА не рестартит прод — ALERT_ONLY). Не-self репо: `deploy → Done` как + сейчас (terminal-sync разводится по `post_deploy.post_deploy_applies`). +- **Fail-closed (project-relative alias-fallback):** отсутствующий новый статус в проекте + деградирует на **собственный базовый UUID того же проекта** (`to_analyse/analysis→in_progress`, + `code_review→review`, `awaiting_deploy→in_review`, `deploying→in_progress`, + `monitoring→done`) — индикация откатывается к текущей, конвейер не ломается, PATCH валиден + даже при частичной конфигурации. Enduro (статусы не создаются) → строго прежнее поведение. + Усиленный паттерн ORCH-059 AC-7. +- **Reconciler:** F-2 триггер `in_progress`→`to_analyse`; Guard 2 skip-set расширен + активными ожиданиями (`awaiting_deploy`/`deploying`/`monitoring`) с **вычитанием базовых + рабочих статусов** — на enduro (алиасы схлопнуты) нулевой регресс, на orchestrator skip + реальных ожиданий (BR-13). +- **Инварианты:** `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_deploy_status`, exit-коды хука, + merge-gate, `Confirm Deploy`, механизм `Needs Input` (analyst-only), схема БД — без + изменений. Без нового kill-switch (раскат гейтится созданием Plane-статусов оператором). + Инфра-предусловие — `docs/work-items/ORCH-066/07-infra-requirements.md`. + +Подробнее: `docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md`. + ## Откаты - Reviewer REQUEST_CHANGES → откат на `development` + retry (`MAX_DEVELOPER_RETRIES = 3`). - Tester `check_tests_passed` FAIL → откат на `development` + retry. @@ -306,4 +417,7 @@ ORCH-065 вводит фоновый watchdog, чтобы смерть проц Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.md). --- -*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест).* +*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест); ORCH-022 (security-гейт: secret-scanning gitleaks + dependency audit pip-audit как под-гейт ребра `deploy-staging → deploy` ПЕРВЫМ, adr-0012, `docs/work-items/ORCH-022/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-022-security-secret-scanning (leaf src/security_gate.py never-raise + check_security_gate в src/qg/checks.py `QG_CHECKS` + врезка _handle_security_gate в src/stage_engine.py блок `current_stage == "deploy-staging"` ПЕРВОЙ; флаги `security_*` в src/config.py; gitleaks (pinned) в Dockerfile, pip-audit в requirements.txt, `.gitleaks.toml` в корне; артефакт 17-security-report.md; обновлять также при изменении этих мест).* +*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест); ORCH-059 (выделенный статус-триггер прод-деплоя «Confirm Deploy», ADR `docs/work-items/ORCH-059/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-059 (маппинг `"Confirm Deploy"→"confirm_deploy"` в src/plane_sync.py `_PLANE_NAME_TO_KEY`, НЕ в `_DEFAULT_STATES` = fail-closed; ветка `handle_confirm_deploy` + fail-closed `.get("confirm_deploy")` в src/webhooks/plane.py `handle_issue_updated`; keyword-only `confirm_deploy` в src/stage_engine.py `advance_stage` — Фаза B деплоит ТОЛЬКО при `confirm_deploy=True`, иначе `Approved`-на-`deploy` = no-op; CTA Фазы A просит «Confirm Deploy»; эксплуатация — статус доски «Confirm Deploy» в Plane-проекте ORCH, `docs/work-items/ORCH-059/07-infra-requirements.md`).* +*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест); ORCH-066 (осмысленная статусная модель Plane — слой B, `docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md`) — реализовано в ветке feature/ORCH-066-plane (только Plane-индикация: новые ключи `to_analyse`/`analysis`/`code_review`/`awaiting_deploy`/`deploying`/`monitoring` в `_PLANE_NAME_TO_KEY`/`_DEFAULT_STATES` + project-relative `_STATE_ALIAS_FALLBACK` в get_project_states + `_STAGE_TO_STATE_KEY` analysis/review + 5 новых `set_issue_*` в src/plane_sync.py; триггер `in_progress`→`to_analyse` и `set_issue_analysis` в src/webhooks/plane.py; Phase A→Awaiting Deploy / Phase B→Deploying / terminal-sync split monitoring↔done / post-deploy monitor HEALTHY→Done DEGRADED→Blocked в src/stage_engine.py; F-2 триггер `to_analyse` + Guard 2 skip-set с вычитанием base_working в src/reconciler.py; `STAGE_TRANSITIONS`/QG/схема БД НЕ трогаются; без kill-switch — раскат гейтится созданием 6 Plane-статусов оператором, `docs/work-items/ORCH-066/07-infra-requirements.md`; обновлять при изменении этих мест).* +*Актуально на 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`).* diff --git a/docs/architecture/adr/adr-0007-reconciler.md b/docs/architecture/adr/adr-0007-reconciler.md index e0dbd38..21e818f 100644 --- a/docs/architecture/adr/adr-0007-reconciler.md +++ b/docs/architecture/adr/adr-0007-reconciler.md @@ -69,6 +69,15 @@ 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 как под-гейт ребра diff --git a/docs/architecture/adr/adr-0012-security-gate.md b/docs/architecture/adr/adr-0012-security-gate.md new file mode 100644 index 0000000..048ad32 --- /dev/null +++ b/docs/architecture/adr/adr-0012-security-gate.md @@ -0,0 +1,63 @@ +# adr-0012: Security-гейт — secret-scanning + dependency audit перед мержем + +- **Статус:** proposed +- **Дата:** 2026-06-07 +- **Задача:** ORCH-022 +- **Детальный ADR:** `docs/work-items/ORCH-022/06-adr/ADR-001-security-gate.md` + +## Контекст +Оркестратор автономен: `developer` пишет код без человека-фильтра. Перед слиянием ветки в +`main` нет проверки на утёкший секрет (ключ/токен/пароль/приватный ключ) и уязвимую +зависимость (CVE). Для self-hosting один общий прод-инстанс обслуживает все проекты с общей +БД — секрет/CVE через одну задачу попадает в прод всех (CLAUDE.md §self-hosting, §8). Фактический +мерж PR в `main` делает `deployer` в начале стадии `deploy`. + +## Решение +Детерминированный (без LLM) **security-гейт как под-гейт ребра `deploy-staging → deploy`**, +рядом с merge-gate (ORCH-043) и image-freshness (ORCH-058), исполняемый **ПЕРВЫМ** среди +edge-под-гейтов (ДО merge-gate). `STAGE_TRANSITIONS` не меняется; в `QG_CHECKS` добавлен +`check_security_gate`. Паттерн — как у соседей: leaf-модуль `src/security_gate.py` +(never-raise) + тонкая обёртка в `QG_CHECKS` + врезка `_handle_security_gate` в `advance_stage`. + +- **Secret-scanning (`gitleaks`, offline):** скан `origin/main..HEAD`; любой секрет вне + аллоулиста (`.gitleaks.toml`) → вклад в FAIL. Offline → гарантия «секрет всегда блокирует» + не зависит от сети. +- **Dependency audit (`pip-audit`, OSV/PyPI):** severity ≥ `security_dep_block_severity` + (дефолт `HIGH`) → FAIL; ниже / UNKNOWN → warning. Недоступность фида → **fail-open + + громкий warning** (анти-петля; флаг `security_dep_audit_fail_closed` для строгого режима). +- **ПЕРВЫМ на ребре, ДО merge-gate:** дёшево фейлить до дорогих rebase/rebuild; скан ветки + ДО rebase не «обвиняет» задачу в CVE, притащенной обновившимся `main` (анти-петля + ORCH-061); до захвата merge-lease → при FAIL lease освобождать не нужно. +- **Артефакт `17-security-report.md`** с YAML-frontmatter (`security_status`, + `secrets_found`, `deps_blocking`, `deps_warning`, `deps_audit_degraded`); вердикт читается + ТОЛЬКО из frontmatter (канон), negative-токен авторитетен; битый/нет → fail-closed. +- **FAIL → откат на `development`** + developer-retry (общий `_developer_retry_count`, cap 3, + затем `set_issue_blocked` + Telegram); `task_desc` несёт дословные находки (ORCH-046). +- **Условность (как ORCH-35/43/58):** `security_gate_enabled` + `security_gate_repos`; пусто + → реально только self-hosting (`orchestrator`), прочие репо — no-op pass. +- **never-raise**, таймаут `security_scan_timeout_s`, гейт не деплоит/не рестартит прод. + +## Альтернативы +- **Вариант R (review-стадия):** diff может разойтись с мержем в `main`; merge-edge — последняя + страховка. Отклонено. +- **Вариант C (CI-job через `check_ci_green`):** пороги/severity/аллоулист/артефакт плохо + выражаются статусом коммита; коуплинг с раннером. Отклонено для v1 (точка расширения). +- **Новая стадия `security`:** «пустая» стадия без агента не имеет триггера (как в ORCH-043). + Отклонено. +- **fail-closed dep-audit / аудит после rebase:** ложные откаты → петля. Отклонено. +- **Новая колонка retry в БД:** не нужна (переиспользуем `_developer_retry_count`). + +## Последствия +- Класс «тихо влитый секрет/CVE» закрыт: секреты — безусловно (offline), CVE — best-effort при + доступности фида. Самоприменение CLAUDE.md §8 без человека. +- Плата: ещё один «скрытый» под-гейт ребра (нет в `STAGE_TRANSITIONS`); внешние инструменты + (gitleaks в образе, pip-audit в зависимостях); время скана на каждом прогоне (ограничено + таймаутом); v1 — Python-only (SAST/мульти-стек — follow-up WI). +- Сквозное изменение (новый QG + edge-под-гейт) → `arch:major-change`; прод-деплой ORCH-022 — + строго через staging-гейт (8501), без рестарта прод-контейнера. + +## Связи +adr-0006 (merge-gate — паттерн edge-под-гейта/отката), adr-0008 (image-freshness — +условность/never-raise/fail-closed), adr-0003 (условный гейт / `is_self_hosting_repo`), +adr-0009 (анти-петля ложных FAIL, ORCH-061), ORCH-046 (дословный reason в `task_desc`), +ORCH-9/15 (мульти-стек — будущая зависимость), ORCH-2 (worktree-изоляция). diff --git a/docs/work-items/ORCH-022/00-business-request.md b/docs/work-items/ORCH-022/00-business-request.md new file mode 100644 index 0000000..eb3d55e --- /dev/null +++ b/docs/work-items/ORCH-022/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: [★ высокий] Security-гейт: secret-scanning + аудит зависимостей перед мержем + +Work Item ID: ORCH-022 + +## Description + +TBD diff --git a/docs/work-items/ORCH-022/01-brd.md b/docs/work-items/ORCH-022/01-brd.md new file mode 100644 index 0000000..62b4d54 --- /dev/null +++ b/docs/work-items/ORCH-022/01-brd.md @@ -0,0 +1,150 @@ +# 01 — BRD: Security-гейт (secret-scanning + аудит зависимостей перед мержем) + +Work Item: **ORCH-022** +Приоритет: **★ высокий** +Источник: предложение Стрим, одобрено Славой (2026-06-04). +Стадия: analysis. + +--- + +## 1. Бизнес-проблема + +Оркестратор — автономная мульти-агентная система: агенты (`developer`) пишут код +**без человека-фильтра по умолчанию**. Перед мержем в `main` сейчас нет проверки на: + +- **утёкший секрет** — закоммиченный API-ключ / токен / пароль / приватный ключ; +- **дырявую зависимость** — пакет с известной CVE; +- (опционально) **базовую уязвимость кода** — типовой SAST-паттерн. + +Для автономной системы это критично: ошибку, которую в обычной команде «выловили бы +глазами на ревью», здесь поймать некому. Утёкший в `git`-историю ключ или уязвимая +зависимость может уехать в прод и обслуживать **все** проекты (общий инстанс, +self-hosting). + +### Прецеденты / связки +- **PR #18** (`check_ci_green`: красный CI → возврат на `development`) — задаёт целевой + паттерн поведения красного гейта. Security-гейт должен вести себя так же. +- **Управление секретами** (CLAUDE.md §8): секреты живут только в `.env`/`.env.staging` + на хосте, канон — `.env.example`. Гейт — это автоматический страж этого правила. + +--- + +## 2. Цель + +Ввести **security-гейт перед слиянием ветки задачи в `main`**, который детерминированно +(без LLM) проверяет diff/ветку на секреты и уязвимые зависимости и **блокирует +продвижение** при нарушении порогов: красный security-гейт → **возврат на `development`** +(developer-retry, как красный CI / merge-gate), задача **не уезжает в прод**. + +### Бизнес-ценность +- Структурно невозможно «тихо» влить секрет или известную CVE в прод автономной системы. +- Самоприменение правила CLAUDE.md §8 (секреты не в гит) без участия человека. +- Расширяет уже выстроенную линию автономных страховок (CI-гейт, merge-gate ORCH-043, + staging-провенанс ORCH-058, post-deploy ORCH-021). + +--- + +## 3. Объём (Scope) + +### 3.1 В объёме (v1) — **предположение по умолчанию (A1)** +1. **Secret-scanning** — обязательный минимум гейта. Поиск закоммиченных секретов + в ветке задачи / её diff относительно `main`. +2. **Dependency audit** — аудит зависимостей проекта на известные CVE. +3. **Машиночитаемый артефакт-вердикт** security-гейта (YAML-frontmatter — канон гейтов). +4. **Поведение красного гейта** = откат на `development` + developer-retry (cap + `MAX_DEVELOPER_RETRIES = 3`), наблюдаемость (Telegram + Plane-коммент). +5. **Условный раскат** (kill-switch + scope репозиториев), **never-raise**, + self-hosting (`orchestrator`) — первым. + +### 3.2 Вне объёма (v1) — **предположение (A2), отдельные WI** +- **SAST (semgrep)** — вынесен в follow-up WI: шумнее, требует policy-тюнинга правил; + гейт проектируется с точкой расширения под него, но в v1 не включается. +- **Полноценный мульти-стек** (JS/npm, Android) — см. A3 ниже; в v1 целевой стек — + Python (сам оркестратор). Связь с ORCH-9/15 фиксируется как зависимость на будущее. +- Ретроспективное сканирование уже существующей истории `main` (гейт смотрит вперёд — + ветку перед мержем, не чистит прошлое). +- Управление аллоулистом ложных срабатываний через UI/Plane (в v1 — файл в репозитории). + +### 3.3 Зафиксированные предположения по умолчанию +> ⚠️ Интерактивный опрос Owner на стадии анализа не дал ответа; ниже — +> **дефолты по конвенциям проекта**. Любой из них Owner/архитектор может переопределить +> (для A4 предусмотрены конфиг-флаги порогов). + +- **A1 (объём сканеров v1):** secret-scanning + dependency-audit. SAST отложен. +- **A2 (SAST):** отложен в отдельный WI; гейт оставляет точку расширения. +- **A3 (стек):** **Python-only сначала**, реально только для self-hosting + (`is_self_hosting_repo` / scope-CSV), как ORCH-35/43/58. Прочие репо — no-op pass. + Мульти-стек (детект стека по репо) — отдельный WI. +- **A4 (пороги):** **секреты — всегда блок**; **зависимости — блок на HIGH/CRITICAL, + warning на MEDIUM/LOW**. Пороги вынесены в конфиг (переопределяемы без редеплоя кода). + +--- + +## 4. Заинтересованные стороны +| Роль | Интерес | +|------|---------| +| Owner (Слава) | Прод-безопасность автономного конвейера; контроль порогов и раската. | +| Стрим | Инициатор; снижение риска утечки/уязвимости в автономном режиме. | +| Агент `developer` | Получает понятную причину красного гейта → быстрый фикс. | +| Агент `reviewer` | Гейт снимает с него непосильную задачу «глазами ловить ключи». | +| Все проекты на инстансе | Общий прод не должен получить секрет/CVE через одну задачу. | + +--- + +## 5. Бизнес-требования + +| ID | Требование | Приоритет | +|----|-----------|-----------| +| BR-1 | Перед слиянием ветки задачи в `main` обязателен security-гейт (секреты + аудит зависимостей). | MUST | +| BR-2 | Найден секрет (порог A4) → гейт **красный** → откат на `development`, в прод не уходит. | MUST | +| BR-3 | Уязвимость зависимости уровня блокировки (порог A4) → гейт **красный** → откат на `development`. | MUST | +| BR-4 | Уязвимость ниже порога блокировки → **warning**, продвижение не блокируется, но фиксируется в артефакте. | MUST | +| BR-5 | Красный гейт ведёт себя как красный CI / merge-gate: откат на `development` + developer-retry (cap 3), затем эскалация (Telegram + Plane Blocked). | MUST | +| BR-6 | Вердикт гейта — **машиночитаемый** (YAML-frontmatter артефакта), читается гейтом ТОЛЬКО из frontmatter (канон проекта), не из прозы. | MUST | +| BR-7 | Гейт **детерминированный, без LLM** в критическом пути (как merge-gate / image-freshness). | MUST | +| BR-8 | Гейт **never-raise**: внутренняя ошибка не роняет `advance_stage` и не вешает конвейер всех проектов. | MUST | +| BR-9 | Условный раскат: глобальный kill-switch + scope-CSV репозиториев; пусто → реально только self-hosting (`orchestrator`), прочие репо — no-op pass. | MUST | +| BR-10 | Пороги блокировки конфигурируемы (env-флаги, без редеплоя кода). | SHOULD | +| BR-11 | Наблюдаемость: причина блокировки видна (Telegram + Plane-коммент + артефакт); проход — без шума. | MUST | +| BR-12 | Документация (CLAUDE.md «Артефакты задачи», `docs/architecture/README.md` таблица гейтов, CHANGELOG, ADR) обновлена в том же PR. | MUST | +| BR-13 | Аллоулист ложных срабатываний (заведомо-безопасные совпадения, напр. в `.env.example`, фикстуры тестов) поддерживается версионируемым файлом в репозитории. | SHOULD | +| BR-14 | Точка расширения под SAST и мульти-стек заложена, но в v1 не активна (A2/A3). | SHOULD | + +--- + +## 6. Ограничения и риски (бизнес-уровень) +- **Self-hosting:** гейт исполняется внутри инстанса, который правит сам себя. Запрет на + рестарт/падение прод-контейнера в рамках задачи (CLAUDE.md §self-hosting) сохраняется — + гейт ничего не деплоит и не рестартит, только читает/сканирует. +- **Ложные срабатывания** (false positives) могут зациклить откат `→ development` + (прецедент ORCH-061 со staging-петлёй). Митигировано: cap retry=3 + аллоулист (BR-13) + + конфигурируемые пороги (BR-10) + kill-switch (BR-9). +- **Внешние БД уязвимостей** (CVE-фиды) — сетевая зависимость; недоступность фида не + должна давать ложный красный (см. AC: degrade-поведение при недоступности фида — + решение порога «fail-open vs fail-closed для аудита» закрепляется в acceptance + ADR). +- **Стоимость/время** сканирования добавляется к каждому прогону задачи — должно быть + ограничено таймаутом (как merge-retest). + +--- + +## 7. Критерий успеха (бизнес) +Ветка с подсаженным тестовым секретом и/или зависимостью с известной CRITICAL-CVE +**не может** дойти до `main`/прода: гейт краснеет, задача откатывается на `development` +с понятной причиной. Чистая ветка проходит гейт без задержек и без шума. Для не-self +репозиториев конвейер не меняется (no-op). Прод-контейнер не рестартится гейтом. + +--- + +## 8. Открытые вопросы (для архитектора / Owner) +1. **Размещение гейта** (решение архитектора): (а) на стадии `review`, либо (б) отдельный + под-гейт перед мержем на ребре `deploy-staging → deploy` (где уже живёт merge-gate + ORCH-043 / image-freshness ORCH-058). Требование BRD — «перед слиянием в `main`»; + обе опции его удовлетворяют. См. 02-trz §4. +2. **Где запускается сканер**: новый job в `.gitea/workflows/ci.yml` (тогда вердикт может + течь через существующий `check_ci_green`) **или** отдельный QG-чек/под-гейт в `src/qg`. + Решение — архитектор (02-trz фиксирует требования к обоим путям). +3. **Аудит зависимостей при недоступном CVE-фиде:** fail-open (warning) или fail-closed + (блок)? Дефолт-предложение — **fail-open с громким warning** (не плодить ложные + завороты), закрепить в ADR. +4. **Выбор конкретных инструментов** (gitleaks vs trufflehog; pip-audit vs trivy) — + технологическое решение архитектора; BRD фиксирует только функцию. diff --git a/docs/work-items/ORCH-022/02-trz.md b/docs/work-items/ORCH-022/02-trz.md new file mode 100644 index 0000000..0d95796 --- /dev/null +++ b/docs/work-items/ORCH-022/02-trz.md @@ -0,0 +1,175 @@ +# 02 — ТЗ: Security-гейт (secret-scanning + dependency audit) + +Work Item: **ORCH-022** · Стадия: analysis · См. `01-brd.md`, `03-acceptance-criteria.md`. + +> **Граница ответственности аналитика.** Ниже — *функциональные требования и точки +> касания* кода. Выбор размещения гейта в пайплайне, конкретных инструментов и схемы +> модулей — **решение архитектора** (см. §4 и `01-brd.md` §8). ТЗ фиксирует требования к +> любому из допустимых вариантов и инварианты, которые нельзя нарушать. + +--- + +## 1. Контекст кода (как есть) + +- **Стадии:** `src/stages.py::STAGE_TRANSITIONS` — линейный конвейер + `… review → testing → deploy-staging → deploy → done`. Фактический merge ветки в + `main` делает агент `deployer` **в начале стадии `deploy`** (CLAUDE/README). +- **Quality Gates:** `src/qg/checks.py` — реестр `QG_CHECKS` (имя → функция), сигнатуры + диспетчеризуются в `src/stage_engine.py::_run_qg`. +- **Существующий паттерн «красный гейт → возврат developer»:** + `check_ci_green` (PR #18) и rollback-ветки в + `stage_engine._handle_qg_failure_rollbacks` (откат на `development`, developer-retry, + cap `MAX_DEVELOPER_RETRIES = 3`, затем `set_issue_blocked` + Telegram). +- **Эталонный паттерн детерминированного под-гейта на ребре** (без LLM, never-raise, + условный раскат, откат на `development`): + - merge-gate **ORCH-043** — `src/merge_gate.py` + `check_branch_mergeable` + + `stage_engine._handle_merge_gate` (ребро `deploy-staging → deploy`); + - image-freshness **ORCH-058** — `src/image_freshness.py` + `_check_staging_image_fresh` + + `stage_engine._handle_image_freshness` (то же ребро). + Оба: leaf-модуль с чистой логикой (never-raise) + тонкая обёртка в `QG_CHECKS` + + врезка-обработчик в `advance_stage`, kill-switch `*_enabled` + scope `*_repos`, + реально только для self-hosting при пустом scope. +- **CI:** `.gitea/workflows/ci.yml` — один job `test` (pytest) на `self-hosted` раннере, + push в `feature/**` и PR в `main`. `check_ci_green` читает комбинированный статус + коммита из Gitea API. +- **Артефакты задачи** нумерованы до `16-post-deploy-log.md`. +- **Зависимости Python:** `requirements.txt` (корень репо). + +--- + +## 2. Функциональные требования к реализации + +### FR-1. Secret-scanning ветки перед мержем +- Сканировать ветку задачи / её diff относительно `origin/main` на секреты + (ключи, токены, пароли, приватные ключи). +- **Любой** подтверждённый секрет (не из аллоулиста) → вердикт **FAIL** (порог A4: секреты + всегда блокируют). +- Инструмент (gitleaks / trufflehog) — выбор архитектора. Должен запускаться offline-/ + детерминированно (без LLM) и иметь конфиг правил/аллоулиста в репозитории. + +### FR-2. Dependency audit +- Аудит зависимостей целевого стека на известные CVE. Для Python — манифест + `requirements.txt` (инструмент pip-audit / trivy — выбор архитектора). +- Классификация по severity. **Порог блокировки (A4, конфигурируемо BR-10):** + - `CRITICAL`, `HIGH` → вклад в **FAIL**; + - `MEDIUM`, `LOW` → **warning** (фиксируется в артефакте, не блокирует). +- Недоступность CVE-фида: degrade-поведение по решению ADR (дефолт-предложение — + fail-open + громкий warning, чтобы не плодить ложные завороты). Поведение должно быть + детерминированным и протестированным. + +### FR-3. Машиночитаемый артефакт-вердикт +- Гейт порождает артефакт security-отчёта с **YAML-frontmatter**, напр.: + ``` + --- + security_status: PASS # PASS | FAIL + secrets_found: 0 + deps_blocking: 0 # число уязвимостей уровня блокировки + deps_warning: 2 + --- + ``` + Имя артефакта — предложение: **`17-security-report.md`** (следующий свободный номер; + финализирует архитектор). Тело — человекочитаемый список находок. +- Вердикт читается гейтом **ТОЛЬКО из frontmatter** (канон проекта: «машинные вердикты — + строго YAML-frontmatter, никогда проза»), по образцу `_parse_deploy_status` / + `_parse_staging_status` / `check_reviewer_verdict`. Negative-токен (FAIL) авторитетен. +- Отсутствие/битый frontmatter → `(False, reason)` (fail-closed на чтении вердикта, + как у существующих парсеров). + +### FR-4. Поведение красного гейта (откат) +- `security_status: FAIL` → откат на `development` + enqueue `developer`, по образцу + `_handle_qg_failure_rollbacks` (merge-gate-ветка — точный шаблон): + - cap `MAX_DEVELOPER_RETRIES` (3); при исчерпании — `set_issue_blocked` + Telegram-алерт; + - `task_desc` для developer несёт **дословную причину** (какие секреты/CVE), по образцу + ORCH-046 (встраивание must-fix в `task_desc`), а не только ссылку на артефакт; + - Plane-коммент + `notify_qg_failure` (наблюдаемость BR-11). + +### FR-5. Условный раскат (как ORCH-35/43/58) +- Глобальный kill-switch `security_gate_enabled` (env `ORCH_SECURITY_GATE_ENABLED`, + дефолт по согласованию; рекомендуется `true` с safety-net, как у соседних фич). +- Scope `security_gate_repos` (CSV); пусто → реально только `is_self_hosting_repo(repo)` + (`orchestrator`). Прочие репо → `(True, "security-gate N/A for ")` (мгновенный pass). +- Отдельные пороги-флаги (A4/BR-10): напр. `security_dep_block_severity` + (`HIGH` по умолчанию), при желании `security_secrets_block` (`true`). + +### FR-6. never-raise +- Любая внутренняя ошибка гейта (сбой сканера, отсутствие бинаря, таймаут) → + `(False, "")` **без** проброса исключения в `advance_stage`. Контракт — + как у `check_branch_mergeable` (внешний + внутренний guard). +- Таймаут сканирования ограничен (по образцу `merge_retest_timeout_s`). + +### FR-7. Наблюдаемость +- Блокировка → Telegram + Plane-коммент (BR-11). Проход → лог-строка, без шумных + нотификаций (по образцу merge-gate pass). +- Желательно: краткий снимок в `GET /queue` (опционально, по образцу блоков `reconcile`/ + `reaper`/`post_deploy`) — на усмотрение архитектора. + +--- + +## 3. Задействованные модули `src/` (точки касания) + +| Модуль | Изменение | +|--------|-----------| +| `src/security_gate.py` (**новый leaf-модуль**) | Чистая логика гейта: запуск сканеров, классификация по severity, применение порогов/аллоулиста, формирование вердикта + парсер frontmatter. **never-raise.** По образцу `src/merge_gate.py` / `src/image_freshness.py` / `src/post_deploy.py`. | +| `src/qg/checks.py` | Новый чек `check_security_gate` (тонкая обёртка над `security_gate`, ленивый импорт во избежание циклов) + регистрация в `QG_CHECKS`. Условность (kill-switch/scope/self-hosting) — как `check_branch_mergeable` / `_check_staging_image_fresh`. | +| `src/stage_engine.py` | Врезка-обработчик `_handle_security_gate(...)` по образцу `_handle_merge_gate` / `_handle_image_freshness`: вызов в `advance_stage` на выбранном архитектором ребре; FAIL → откат на `development` (FR-4); never-raise. **`STAGE_TRANSITIONS` НЕ меняется**, если выбран вариант «под-гейт ребра». | +| `src/config.py` | Новые настройки: `security_gate_enabled`, `security_gate_repos`, `security_dep_block_severity`, `security_scan_timeout_s` (+ при необходимости пути к бинарям/конфигам сканеров). С docstring-комментариями по образцу ORCH-043/058. | +| `.gitea/workflows/ci.yml` | **Если** архитектор выберет CI-путь: новый job `security` (secret-scan + dep-audit), влияющий на комбинированный статус коммита (тогда срабатывает `check_ci_green`-паттерн PR #18). Иначе — не трогается. | +| `requirements.txt` / Dockerfile | Установка выбранных сканеров (если они Python-пакеты — в `requirements.txt`; если бинари — в Dockerfile/раннер). | +| Конфиг сканера + аллоулист | Версионируемые файлы в репозитории (напр. `.gitleaks.toml` / аллоулист) — BR-13. | +| `.openclaw/agents/developer.md` | (Если нужно) краткая инструкция developer'у про устранение security-находок при заворотах. | + +> Если выбран вариант «гейт на стадии `review`» — врезка делается в соответствующую +> ветку `advance_stage`/обработчик ревью вместо ребра `deploy-staging → deploy`. + +--- + +## 4. Размещение в пайплайне — варианты для архитектора + +Требование BRD: **«перед слиянием ветки в `main`»**. Допустимы (выбор + обоснование — в ADR): + +- **Вариант R (review):** security-проверка на стадии `review` (раньше отлов, дешевле + откат — задача ещё близко к development). Минус: дальше по конвейеру `main` может уйти + вперёд (но это закрывает merge-gate). +- **Вариант M (merge-edge, рекомендуемый к рассмотрению):** под-гейт на ребре + `deploy-staging → deploy`, рядом с merge-gate (ORCH-043) и image-freshness (ORCH-058) — + непосредственно перед фактическим мержем `deployer`'ом. Плюс: единое место «последней + страховки перед main», переиспользование готового паттерна врезки/отката/lease. +- **Вариант C (CI-job):** добавить job в `ci.yml`; вердикт течёт через `check_ci_green`. + Плюс: меньше нового кода в движке. Минус: пороги/severity-логика и артефакт-вердикт + сложнее выразить только статусом коммита. + +ТЗ не предписывает вариант; реализация обязана сохранить инварианты §6. + +--- + +## 5. Изменения API +- Новых HTTP-endpoint'ов **не требуется**. +- Допустимо (опционально, FR-7): расширить ответ `GET /queue` блоком `security` + (counts/last_run) — по образцу блоков `reconcile`/`reaper`/`post_deploy`. Не обязательно. + +## 6. Изменения схемы БД +- **Не требуется.** Состояние гейта — артефакт-файл + (при необходимости) sentinel-файлы, + по образцу merge-lease / deploy-state / post-deploy-state. Миграций БД нет. +- Если архитектор сочтёт нужным считать security-retry отдельно от developer-retry — + предпочесть подсчёт по `jobs`/`agent_runs` (как `_developer_retry_count` / + `_merge_defer_count`), без новых колонок. + +## 7. Инварианты (НЕ нарушать) +1. `STAGE_TRANSITIONS` и реестр `QG_CHECKS` остаются консистентными; при варианте + «под-гейт ребра» — `STAGE_TRANSITIONS` не меняется (триггер — то же событие стадии). +2. Машинный вердикт — только из YAML-frontmatter, не из прозы. +3. never-raise: гейт никогда не пробрасывает исключение в `advance_stage`. +4. Условность как ORCH-35/43/58: не-self репо при пустом scope не затрагиваются (no-op). +5. Гейт **не деплоит и не рестартит** прод-контейнер (self-hosting safety). +6. Откат и retry-счётчик developer не ломаются (cap=3, затем эскалация). +7. Документация (CLAUDE.md, README, CHANGELOG, ADR) обновлена в том же PR (BR-12). + +## 8. Артефакты pipeline, создаваемые/обновляемые +- **Новый:** `docs/work-items/ORCH-022/17-security-report.md` (имя финализирует архитектор) + с `security_status:`-frontmatter (FR-3) — порождается гейтом per-task. +- **ADR:** `docs/work-items/ORCH-022/06-adr/ADR-001-.md` (решение: размещение, + инструменты, degrade-поведение фида, пороги). При сквозном влиянии — global ADR в + `docs/architecture/adr/`. +- **Обновить:** `CLAUDE.md` (раздел «Артефакты задачи» — добавить 17-…), + `docs/architecture/README.md` (таблица гейтов + реестр `QG_CHECKS` + новый раздел), + `CHANGELOG.md`, `.env.example` (новые `ORCH_SECURITY_*`). diff --git a/docs/work-items/ORCH-022/03-acceptance-criteria.md b/docs/work-items/ORCH-022/03-acceptance-criteria.md new file mode 100644 index 0000000..65695ea --- /dev/null +++ b/docs/work-items/ORCH-022/03-acceptance-criteria.md @@ -0,0 +1,140 @@ +# 03 — Критерии приёмки: Security-гейт (ORCH-022) + +Формат: каждый критерий имеет чёткое условие **PASS/FAIL**. Привязка к +`01-brd.md` (BR-*) и `02-trz.md` (FR-*). + +--- + +## A. Secret-scanning (FR-1, BR-1/BR-2) + +### AC-1 — Подсаженный секрет блокирует гейт +- **PASS:** ветка с тестовым секретом (напр. фиктивный AWS-ключ формата `AKIA…` вне + аллоулиста) → `security_status: FAIL`; гейт возвращает `(False, reason)`, причина + называет секрет/файл. +- **FAIL:** секрет не обнаружен ИЛИ гейт зелёный при наличии секрета. + +### AC-2 — Чистая ветка проходит +- **PASS:** ветка без секретов → `security_status: PASS`; `secrets_found: 0`; + гейт возвращает `(True, …)`. +- **FAIL:** ложное срабатывание (FAIL на чистой ветке). + +### AC-3 — Аллоулист подавляет заведомо-безопасное (BR-13) +- **PASS:** совпадение, явно занесённое в версионируемый аллоулист (напр. плейсхолдер в + `.env.example` / фикстура теста), **не** даёт FAIL. +- **FAIL:** аллоулист игнорируется и даёт ложный FAIL. + +--- + +## B. Dependency audit (FR-2, BR-3/BR-4) + +### AC-4 — CVE уровня блокировки краснит гейт +- **PASS:** зависимость с известной `CRITICAL`/`HIGH` CVE (при пороге по умолчанию + `HIGH`) → вклад в `security_status: FAIL`; `deps_blocking >= 1`. +- **FAIL:** блокирующая уязвимость не приводит к FAIL. + +### AC-5 — Низкая severity = warning, не блок +- **PASS:** только `MEDIUM`/`LOW` уязвимости → `security_status: PASS`, при этом + `deps_warning >= 1` и находки перечислены в теле артефакта. +- **FAIL:** `MEDIUM`/`LOW` блокирует продвижение. + +### AC-6 — Порог блокировки конфигурируем (BR-10) +- **PASS:** при `ORCH_SECURITY_DEP_BLOCK_SEVERITY=CRITICAL` та же `HIGH`-уязвимость + становится warning (не блок); при `=HIGH` — блок. Поведение детерминированно + определяется флагом. +- **FAIL:** флаг не влияет на классификацию. + +### AC-7 — Degrade при недоступном CVE-фиде +- **PASS:** недоступность фида обрабатывается по решению ADR детерминированно и + протестированно (дефолт: fail-open + громкий warning, гейт не краснеет ложно). +- **FAIL:** недоступность фида даёт неконтролируемый красный/исключение. + +--- + +## C. Вердикт и артефакт (FR-3, BR-6) + +### AC-8 — Машинный вердикт только из frontmatter +- **PASS:** вердикт читается ТОЛЬКО из YAML-frontmatter `17-security-report.md`; проза с + «PASS»/«FAIL» в теле не влияет на решение. Negative-токен (FAIL) авторитетен. +- **FAIL:** вердикт извлекается из тела/прозы. + +### AC-9 — Битый/отсутствующий frontmatter → fail-closed на чтении +- **PASS:** нет frontmatter / битый YAML / нет поля `security_status` → `(False, reason)` + (как `_parse_deploy_status`/`check_reviewer_verdict`). +- **FAIL:** битый артефакт трактуется как PASS. + +### AC-10 — Артефакт создаётся с корректными полями +- **PASS:** после прогона существует `17-security-report.md` с валидным frontmatter + (`security_status`, `secrets_found`, `deps_blocking`, `deps_warning`) и телом-списком. +- **FAIL:** артефакт не создан/без машинных полей. + +--- + +## D. Откат и retry (FR-4, BR-5) + +### AC-11 — Красный гейт → откат на development + developer-retry +- **PASS:** `FAIL` → стадия задачи становится `development`, enqueue `developer`, + Plane-коммент + `notify_qg_failure`; счётчик developer-retry растёт. +- **FAIL:** при FAIL задача продвигается дальше / не откатывается. + +### AC-12 — task_desc несёт дословную причину (ORCH-046-паттерн) +- **PASS:** `task_desc` для перезапущенного developer содержит конкретику находок + (какие секреты/CVE), а не только ссылку на артефакт. +- **FAIL:** developer получает только ссылку без сути. + +### AC-13 — Cap retry и эскалация +- **PASS:** после `MAX_DEVELOPER_RETRIES` (3) безуспешных фиксов — `set_issue_blocked` + + Telegram-алерт; бесконечного отскока нет. +- **FAIL:** откат зацикливается без cap/эскалации. + +--- + +## E. Условный раскат и устойчивость (FR-5/FR-6, BR-8/BR-9) + +### AC-14 — Не-self репозиторий = no-op pass +- **PASS:** для repo, не входящего в scope и не self-hosting → гейт возвращает + `(True, "security-gate N/A for ")` мгновенно, конвейер такого репо не меняется. +- **FAIL:** гейт реально запускается/блокирует чужой репо при пустом scope. + +### AC-15 — Kill-switch отключает гейт +- **PASS:** `ORCH_SECURITY_GATE_ENABLED=false` → гейт — no-op pass (`(True, …)`), + поведение конвейера 1:1 как до ORCH-022. +- **FAIL:** при выключенном флаге гейт всё ещё блокирует. + +### AC-16 — never-raise +- **PASS:** искусственный сбой (нет бинаря сканера / таймаут / исключение внутри) → + `(False, reason)` без проброса исключения; `advance_stage` не падает, конвейер других + задач/проектов не встаёт. +- **FAIL:** внутренняя ошибка пробрасывается/вешает движок. + +### AC-17 — Таймаут ограничен +- **PASS:** сканирование, превысившее `ORCH_SECURITY_SCAN_TIMEOUT_S`, корректно + прерывается → детерминированный вердикт (по политике degrade), без зависания. +- **FAIL:** сканер висит без таймаута. + +--- + +## F. Инварианты и интеграция (BR-7/BR-12, TRZ §7) + +### AC-18 — STAGE_TRANSITIONS/QG_CHECKS консистентны +- **PASS:** при варианте «под-гейт ребра» `STAGE_TRANSITIONS` не изменён; новый чек + зарегистрирован в `QG_CHECKS`; `_run_qg` корректно его диспетчеризует. Все + существующие тесты гейтов/стадий зелёные. +- **FAIL:** сломан реестр/переходы/существующие тесты. + +### AC-19 — Гейт не деплоит/не рестартит прод +- **PASS:** код гейта не вызывает деплой-хук/рестарт прод-контейнера; только + чтение/сканирование. +- **FAIL:** гейт инициирует рестарт/деплой. + +### AC-20 — Документация обновлена в том же PR (BR-12) +- **PASS:** обновлены `CLAUDE.md` (артефакт 17-…), `docs/architecture/README.md` + (таблица гейтов + реестр QG + раздел ORCH-022), `CHANGELOG.md`, `.env.example` + (`ORCH_SECURITY_*`); заведён ADR `06-adr/ADR-001-*`. +- **FAIL:** функционал есть, документация/ADR не обновлены → reviewer обязан + REQUEST_CHANGES (CLAUDE.md §6). + +### AC-21 — End-to-end на тестовой задаче +- **PASS:** прогон на self-hosting-репо: грязная ветка (секрет/CVE) → откат на + `development`; после фикса чистая ветка → гейт зелёный → конвейер идёт дальше; прод не + затронут в процессе. +- **FAIL:** любой шаг E2E не воспроизводится. diff --git a/docs/work-items/ORCH-022/04-test-plan.yaml b/docs/work-items/ORCH-022/04-test-plan.yaml new file mode 100644 index 0000000..694ebce --- /dev/null +++ b/docs/work-items/ORCH-022/04-test-plan.yaml @@ -0,0 +1,126 @@ +work_item: ORCH-022 +title: "Security-гейт: secret-scanning + dependency audit перед мержем" +notes: > + План тестов для security-гейта. Чистая логика выносится в leaf-модуль + src/security_gate.py (never-raise) — основной предмет unit-тестов (по образцу + tests для merge_gate / image_freshness / post_deploy / staging_verdict). + Интеграция врезки в advance_stage и условный раскат — integration-тесты. + Имена модулей тестов финализирует разработчик/архитектор по факту реализации. + +tests: + # --- Secret-scanning (FR-1 / AC-1..AC-3) --- + - id: TC-01 + type: unit + description: "Подсаженный тестовый секрет в diff -> вердикт FAIL, secrets_found>=1, причина называет находку." + module: tests/test_security_gate.py + expected: PASS + - id: TC-02 + type: unit + description: "Чистая ветка без секретов -> вердикт PASS, secrets_found=0." + module: tests/test_security_gate.py + expected: PASS + - id: TC-03 + type: unit + description: "Совпадение из аллоулиста (плейсхолдер .env.example / фикстура) НЕ даёт FAIL." + module: tests/test_security_gate.py + expected: PASS + + # --- Dependency audit + пороги (FR-2 / AC-4..AC-7) --- + - id: TC-04 + type: unit + description: "CVE уровня HIGH/CRITICAL при пороге HIGH -> вклад в FAIL, deps_blocking>=1." + module: tests/test_security_gate.py + expected: PASS + - id: TC-05 + type: unit + description: "Только MEDIUM/LOW уязвимости -> PASS, deps_warning>=1, находки в теле артефакта." + module: tests/test_security_gate.py + expected: PASS + - id: TC-06 + type: unit + description: "Конфиг порога: severity=CRITICAL делает HIGH-CVE warning; severity=HIGH делает её блоком." + module: tests/test_security_gate.py + expected: PASS + - id: TC-07 + type: unit + description: "Недоступный CVE-фид -> детерминированный degrade по политике ADR (дефолт fail-open + warning), без исключения и без ложного FAIL." + module: tests/test_security_gate.py + expected: PASS + + # --- Вердикт / парсер frontmatter (FR-3 / AC-8..AC-10) --- + - id: TC-08 + type: unit + description: "Вердикт читается ТОЛЬКО из YAML-frontmatter; проза PASS/FAIL в теле не влияет; negative-токен авторитетен." + module: tests/test_security_gate.py + expected: PASS + - id: TC-09 + type: unit + description: "Нет frontmatter / битый YAML / нет поля security_status -> (False, reason) (fail-closed на чтении)." + module: tests/test_security_gate.py + expected: PASS + - id: TC-10 + type: unit + description: "Артефакт 17-security-report.md создаётся с валидным frontmatter (security_status, secrets_found, deps_blocking, deps_warning) и телом-списком." + module: tests/test_security_gate.py + expected: PASS + + # --- never-raise / таймаут / условность (FR-5/FR-6 / AC-14..AC-17) --- + - id: TC-11 + type: unit + description: "Отсутствие бинаря сканера / внутреннее исключение -> (False, reason), исключение не пробрасывается (never-raise)." + module: tests/test_security_gate.py + expected: PASS + - id: TC-12 + type: unit + description: "Превышение ORCH_SECURITY_SCAN_TIMEOUT_S -> корректное прерывание и детерминированный вердикт, без зависания." + module: tests/test_security_gate.py + expected: PASS + - id: TC-13 + type: unit + description: "check_security_gate: не-self репо при пустом scope -> (True, 'security-gate N/A for ') мгновенно." + module: tests/test_qg_security.py + expected: PASS + - id: TC-14 + type: unit + description: "check_security_gate: ORCH_SECURITY_GATE_ENABLED=false -> no-op pass (True)." + module: tests/test_qg_security.py + expected: PASS + - id: TC-15 + type: unit + description: "Новый чек зарегистрирован в QG_CHECKS и корректно диспетчеризуется _run_qg." + module: tests/test_qg_security.py + expected: PASS + + # --- Откат / retry в stage_engine (FR-4 / AC-11..AC-13) --- + - id: TC-16 + type: integration + description: "security_status FAIL -> advance_stage откатывает на development, enqueue developer, Plane-коммент + notify_qg_failure." + module: tests/test_stage_engine_security_gate.py + expected: PASS + - id: TC-17 + type: integration + description: "task_desc перезапущенного developer содержит дословную причину находок (ORCH-046-паттерн), не только ссылку." + module: tests/test_stage_engine_security_gate.py + expected: PASS + - id: TC-18 + type: integration + description: "После MAX_DEVELOPER_RETRIES (3) -> set_issue_blocked + Telegram-алерт; бесконечного отскока нет." + module: tests/test_stage_engine_security_gate.py + expected: PASS + - id: TC-19 + type: integration + description: "security_status PASS -> advance_stage продвигает конвейер штатно (без отката, без шумных нотификаций)." + module: tests/test_stage_engine_security_gate.py + expected: PASS + + # --- Инварианты / интеграция (BR-7/BR-12 / AC-18..AC-19) --- + - id: TC-20 + type: integration + description: "При варианте 'под-гейт ребра' STAGE_TRANSITIONS не изменён; существующие тесты стадий/гейтов остаются зелёными." + module: tests/test_stages.py + expected: PASS + - id: TC-21 + type: integration + description: "Гейт не вызывает деплой-хук/рестарт прод-контейнера (self-hosting safety)." + module: tests/test_stage_engine_security_gate.py + expected: PASS diff --git a/docs/work-items/ORCH-022/06-adr/ADR-001-security-gate.md b/docs/work-items/ORCH-022/06-adr/ADR-001-security-gate.md new file mode 100644 index 0000000..d38dfff --- /dev/null +++ b/docs/work-items/ORCH-022/06-adr/ADR-001-security-gate.md @@ -0,0 +1,235 @@ +# ADR-001: Security-гейт — secret-scanning + dependency audit перед мержем + +- **Статус:** Accepted (proposed → принято архитектором ORCH-022) +- **Дата:** 2026-06-07 +- **Задача:** ORCH-022 +- **Связанный global ADR:** `docs/architecture/adr/adr-0012-security-gate.md` +- **Источники:** `01-brd.md` (BR-1..BR-14), `02-trz.md` (FR-1..FR-7, §4 варианты, §7 инварианты), + `03-acceptance-criteria.md` (AC-1..AC-21). + +--- + +## Контекст + +Оркестратор автономен: `developer`-агент пишет код без человека-фильтра. Перед слиянием +ветки задачи в `main` нет автоматической проверки на утёкший секрет (ключ/токен/пароль/ +приватный ключ) и на уязвимую зависимость (известная CVE). Для self-hosting это особенно +опасно: один общий прод-инстанс обслуживает все проекты с общей БД — секрет или CVE, +просочившийся через одну задачу, попадает в прод всех проектов (CLAUDE.md §self-hosting, §8). + +Конвейер уже содержит линию детерминированных страховок на ребре `deploy-staging → deploy` +(непосредственно перед фактическим мержем PR в `main`, который делает `deployer` в начале +стадии `deploy`): + +- **merge-gate** (ORCH-043, `check_branch_mergeable`) — догон `main` + re-test + сериализация; +- **image-freshness** (ORCH-058, `check_staging_image_fresh`) — провенанс staging-образа. + +Оба построены по одному паттерну: **leaf-модуль чистой логики (never-raise) + тонкая обёртка +в `QG_CHECKS` + врезка-обработчик `_handle_*` в `advance_stage`**, с условным раскатом +(`*_enabled` + `*_repos`, реально только для self-hosting при пустом scope) и откатом на +`development` с developer-retry (cap `MAX_DEVELOPER_RETRIES = 3`). + +Открытые вопросы BRD §8 / TRZ §4, требующие решения архитектора: +1. Размещение гейта в пайплайне (review / merge-edge / CI-job). +2. Где запускается сканер (CI-job через `check_ci_green` / отдельный QG-чек). +3. Degrade при недоступном CVE-фиде (fail-open / fail-closed). +4. Выбор инструментов (gitleaks/trufflehog; pip-audit/trivy). + +--- + +## Решение + +### Р-1. Размещение — Вариант M (под-гейт ребра `deploy-staging → deploy`), ПЕРВЫМ среди edge-под-гейтов + +Security-гейт реализуется как **детерминированный под-гейт того же ребра** +`deploy-staging → deploy`, что merge-gate и image-freshness, и исполняется **ПЕРВЫМ** — +**ДО** merge-gate. `STAGE_TRANSITIONS` **не меняется** (триггер — то же событие «staging- +deployer завершился»; инвариант TRZ §7.1). + +Порядок врезок в `advance_stage` (блок `current_stage == "deploy-staging"`): + +``` +check_staging_status (PASS, существующий QG стадии) + → security-gate (НОВЫЙ, _handle_security_gate) ← первым + → merge-gate (_handle_merge_gate) + → image-freshness (_handle_image_freshness) + → Phase A (self-deploy approve) +``` + +**Почему merge-edge, а не review (Вариант R):** +- BRD-требование «перед слиянием в `main`» удовлетворяют оба, но на review-стадии diff + может разойтись с тем, что реально вольётся в `main` (параллельная задача двигает `main` + вперёд между review и merge). Merge-edge — последняя точка перед фактическим мержем. +- Переиспользуется готовая машинерия отката/retry/нотификаций edge-под-гейтов + (минимальный blast-radius, инвариант TRZ §7). + +**Почему ПЕРВЫМ (до merge-gate), а не после image-freshness:** +- **Дёшево фейлить.** merge-gate (rebase + re-test, минуты) и image-freshness (docker + rebuild, до 1200с) — дорогие. Нет смысла гонять их на ветке с секретом/CVE. +- **Корректность для секретов.** Секрет живёт в собственных коммитах ветки; + rebase онто `main` его не добавляет и не убирает → скан диапазона `origin/main..HEAD` + до rebase ловит ровно те коммиты, что попадут в `main`. +- **Анти-петля для зависимостей.** Аудит ветки **до** rebase оценивает то, что вносит + ИМЕННО эта задача (её `requirements.txt`/diff), а не уязвимость, которую притащил в + ветку обновившийся `main`. Аудит после rebase «обвинял» бы задачу в чужой (main'овой) + CVE → ложный откат `→ development` → петля (прецедент ORCH-061). Скан до rebase этого + избегает. +- **Проще, чем image-freshness.** Гейт исполняется ДО захвата merge-lease → при FAIL + **lease освобождать не нужно** (в отличие от `_handle_image_freshness`). Чистый откат. + +**Почему не CI-job (Вариант C):** пороги severity, warning-vs-block, аллоулист и +машиночитаемый артефакт-вердикт плохо выражаются одним статусом коммита Gitea; путь +коуплится с CI-раннером. Отклонено для v1; оставлено как точка расширения (BR-14). + +### Р-2. Инструменты + +- **Secret-scanning — `gitleaks`.** Полностью **offline** (без сетевого фида → гарантия + «секрет всегда блокирует» не зависит от сети, BR-2), один статический бинарь, + детерминированный, конфиг + аллоулист в репо (`.gitleaks.toml`, BR-13), поддержка + `--log-opts="origin/main..HEAD"` (скан диапазона), JSON-отчёт, exit-code контракт + (0 = чисто, 1 = найдены секреты, ≥2 = ошибка инструмента). Бинарь устанавливается в + `Dockerfile` (Go-бинарь, не pip-пакет) — см. `07-infra-requirements.md`. +- **Dependency audit — `pip-audit`.** Python-native (v1-стек — сам оркестратор, Python), + читает `requirements.txt`, источник advisory — OSV/PyPI, JSON-выход, ставится через + `requirements.txt`. trivy/trufflehog отклонены как тяжелее/контейнер-ориентированные для + v1-цели «Python-only» (A3). + +Конкретные инструменты — деталь реализации; контракт гейта (вход: repo/branch/wi, +выход: `(bool, reason)` + артефакт) от них не зависит, заменяемы за leaf-модулем. + +### Р-3. Degrade при недоступном CVE-фиде — **fail-open + громкий warning** (дефолт) + +`pip-audit` требует сети (OSV/PyPI advisory DB). Недоступность фида **по умолчанию**: +- **fail-open**: dep-audit не даёт FAIL по причине недоступности фида (иначе — ложные + откаты `→ development` → петля при сетевых проблемах прод-инстанса, прецедент ORCH-061); +- **громко**: в артефакте `deps_audit_degraded: true`, лог `logger.warning`, Telegram-алерт. +- **Секреты не деградируют:** gitleaks offline → гарантия BR-2 безусловна даже при + отсутствии сети. Деградирует ТОЛЬКО dep-audit. +- **Конфигурируемо:** флаг `security_dep_audit_fail_closed` (дефолт `false`) позволяет + Owner'у переключить на fail-closed (недоступность фида → FAIL) без редеплоя кода. + +Это разделяет две гарантии: «нет секрета в прод» — **безусловная**; «нет известной CVE» — +**best-effort при доступности фида**. Закреплено в acceptance (AC-7). + +### Р-4. Пороги классификации (A4, BR-10) + +- **Секреты:** любой подтверждённый (не из аллоулиста) секрет → **вклад в FAIL** (всегда + блок; флаг `security_secrets_block`, дефолт `true`). +- **Зависимости:** severity ≥ `security_dep_block_severity` (дефолт `HIGH`) → **вклад в + FAIL** (`deps_blocking`); ниже порога (`MEDIUM`/`LOW`) → **warning** (`deps_warning`, + не блокирует, фиксируется в теле). +- **Severity = UNKNOWN** (OSV/advisory без CVSS — частый случай pip-audit): трактуется как + **ниже порога → warning**, никогда не авто-блок (анти-петля). Логируется. + +### Р-5. Артефакт и вердикт (FR-3, BR-6, канон проекта) + +- Новый артефакт **`17-security-report.md`** (следующий свободный номер; финализировано). +- YAML-frontmatter: + ``` + --- + security_status: PASS # PASS | FAIL + secrets_found: 0 + deps_blocking: 0 + deps_warning: 2 + deps_audit_degraded: false + --- + ``` + Тело — человекочитаемый список находок (секреты: файл/правило/маскированное совпадение; + CVE: пакет/версия/идентификатор/severity). +- **Единый источник истины:** гейт вычисляет находки → пишет артефакт → **читает вердикт + обратно через `parse_security_status(content)`** (frontmatter-парсер по образцу + `_parse_deploy_status`/`_parse_staging_status`) → возвращает этот вердикт. Так возвращаемый + `(bool, reason)` гарантированно == frontmatter артефакта (канон «машинный вердикт — только + из YAML-frontmatter, никогда из прозы», AC-8). Negative-токен (`FAIL`) авторитетен. +- Битый/отсутствующий frontmatter / нет поля `security_status` → `(False, reason)` — + fail-closed на чтении вердикта (AC-9). + +### Р-6. Поведение красного гейта (FR-4, BR-5) + +`security_status: FAIL` → врезка `_handle_security_gate` (по образцу +`_handle_image_freshness`, но БЕЗ работы с lease — гейт до его захвата): +- `update_task_stage(development)` + `enqueue_job("developer", …)`; +- retry-счётчик — **существующий** `_developer_retry_count` (общий с merge/freshness; + без новой колонки, TRZ §6); cap `MAX_DEVELOPER_RETRIES = 3` → при исчерпании + `set_issue_blocked` + Telegram; +- `task_desc` несёт **дословную причину** (какие секреты/файлы, какие пакеты/CVE/severity) + по образцу ORCH-046 — не только ссылку на артефакт (AC-12); +- `notify_qg_failure` + Plane-коммент (наблюдаемость BR-11). + +PASS → `return False` из обработчика → `advance_stage` идёт к merge-gate (тишина, без шума). + +### Р-7. Условный раскат и устойчивость (FR-5/FR-6) + +- `check_security_gate(repo, work_item_id, branch)` в `QG_CHECKS`; обёртка делегирует в + `src/security_gate.py` (ленивый импорт во избежание цикла — по образцу + `_check_staging_image_fresh`). +- Условность: `security_gate_enabled=False` → `(True, "security-gate disabled")`; + `security_gate_repos` (CSV) пусто → реально только `is_self_hosting_repo` → прочие репо + `(True, "security-gate N/A for ")` (AC-14/AC-15). +- **never-raise** (двойной guard как `check_branch_mergeable`): любая ошибка (нет бинаря, + таймаут, исключение) → `(False, reason)`, исключение не уходит в `advance_stage` (AC-16). +- Таймаут сканирования `security_scan_timeout_s` (дефолт 300) на каждый внешний вызов + (`subprocess … timeout=`) — превышение → детерминированный degrade-вердикт (AC-17). + +### Р-8. Self-hosting safety (инвариант TRZ §7.5, AC-19) + +Гейт **только читает/сканирует** (git, gitleaks, pip-audit, запись артефакта). Не вызывает +деплой-хук, не рестартит и не трогает прод-контейнер (8500/8501). + +--- + +## Точки касания (для developer; reviewer проверяет полноту — AC-20) + +| Модуль | Изменение | +|--------|-----------| +| `src/security_gate.py` (**новый leaf**) | `security_gate_applies`, `scan_secrets`, `audit_dependencies`, `classify_severity`, `compute_verdict`, `write_security_report`, `parse_security_status`, `check_security_gate`. never-raise, fail-closed на чтении вердикта. По образцу `image_freshness.py`. | +| `src/qg/checks.py` | `check_security_gate` (тонкая обёртка, ленивый импорт) + регистрация в `QG_CHECKS`. | +| `src/stage_engine.py` | `_handle_security_gate(...)` + врезка ПЕРВОЙ в блоке `current_stage == "deploy-staging"` (до `_handle_merge_gate`). FAIL → откат на `development`. never-raise. **`STAGE_TRANSITIONS` НЕ меняется.** | +| `src/config.py` | `security_gate_enabled` (True), `security_gate_repos` (""), `security_dep_block_severity` ("HIGH"), `security_scan_timeout_s` (300), `security_dep_audit_fail_closed` (False), `security_secrets_block` (True) — с docstring по образцу ORCH-043/058. | +| `Dockerfile` | Установка `gitleaks` (release-бинарь). | +| `requirements.txt` | `pip-audit`. | +| `.gitleaks.toml` (**новый, корень репо**) | Конфиг правил + аллоулист (`.env.example`-плейсхолдеры, тест-фикстуры) — BR-13. | +| `.openclaw/agents/developer.md` | (Опц.) краткая инструкция про устранение security-находок при заворотах. | +| `tests/` | `test_security_gate.py`, `test_qg_security.py`, `test_stage_engine_security_gate.py` (см. `04-test-plan.yaml`). | +| **Документация** | `CLAUDE.md` (артефакт 17-…), `docs/architecture/README.md` (таблица гейтов + реестр QG + раздел), `CHANGELOG.md`, `.env.example` (`ORCH_SECURITY_*`), global `adr-0012`. | + +--- + +## Альтернативы (отклонены) + +- **Вариант R (review-стадия):** раньше/дешевле, но diff может разойтись с тем, что + вольётся в `main`; merge-edge уже закрывает «последнюю страховку». +- **Вариант C (CI-job через `check_ci_green`):** пороги/severity/аллоулист/артефакт плохо + выражаются статусом коммита; коуплинг с CI-раннером. → точка расширения BR-14. +- **fail-closed dep-audit по умолчанию:** ложные откаты при сетевых сбоях → петля. → + только опционально через флаг. +- **Аудит после rebase (как анкер image-freshness):** обвиняет задачу в CVE из `main` → + петля. → скан ветки ДО merge-gate. +- **Новая стадия `security`:** «пустая» стадия без агента не имеет триггера (как + отклонено в ORCH-043). → под-гейт ребра. +- **Новая колонка retry в БД:** не нужна — переиспользуем `_developer_retry_count`. + +--- + +## Последствия + +**Плюсы.** Структурно невозможно тихо влить секрет (безусловно) или известную CVE +(best-effort) в `main`/прод автономной системы. Самоприменение CLAUDE.md §8. Минимальный +blast-radius: `STAGE_TRANSITIONS`/схема БД не меняются, переиспользован готовый паттерн. + +**Минусы / плата.** Ещё один «скрытый» под-гейт ребра (нет в `STAGE_TRANSITIONS`). +Добавлены внешние инструменты (gitleaks-бинарь в образ, pip-audit в зависимости). Время +сканирования добавляется к каждому прогону (ограничено таймаутом). Dep-audit best-effort +при сетевых сбоях (осознанный компромисс против петли). v1 — Python-only (A3); мульти-стек +и SAST — follow-up WI (BR-14). + +**Раскат.** Сквозное изменение конвейера (новый QG + новый edge-под-гейт) → лейбл +`arch:major-change`. Прод-деплой ORCH-022 — строго через staging-гейт (8501), без рестарта +прод-контейнера в рамках задачи (self-hosting safety). + +## Связи + +adr-0006 (merge-gate — паттерн edge-под-гейта/отката), adr-0008 (image-freshness — +условность/never-raise/fail-closed), adr-0003 (`is_self_hosting_repo` — образец условности), +adr-0009/ORCH-061 (анти-петля ложных FAIL), ORCH-046 (дословный reason в `task_desc`), +ORCH-9/15 (мульти-стек — будущая зависимость). diff --git a/docs/work-items/ORCH-022/07-infra-requirements.md b/docs/work-items/ORCH-022/07-infra-requirements.md new file mode 100644 index 0000000..d48f588 --- /dev/null +++ b/docs/work-items/ORCH-022/07-infra-requirements.md @@ -0,0 +1,56 @@ +# 07 — Инфраструктурные требования: Security-гейт (ORCH-022) + +См. `06-adr/ADR-001-security-gate.md` (Р-2, Р-3, Р-8). Топология не меняется (один сервер +mva154, Docker Compose). Новые требования — только инструменты сканирования и сетевой доступ +к CVE-фиду. + +## I-1. Бинарь `gitleaks` в образе +- **Что:** статический Go-бинарь `gitleaks` (secret-scanning), устанавливается в `Dockerfile` + (НЕ pip-пакет). Зафиксировать версию (pinned release) для детерминизма. +- **Почему в образе, а не на хосте:** гейт исполняется внутри контейнера оркестратора + (`advance_stage`); сканируется per-task worktree, смонтированный в контейнер. +- **Оффлайн:** gitleaks не требует сети (правила локальны) → гарантия «секрет всегда + блокирует» (BR-2) не зависит от доступности интернета. +- **Контракт exit-кодов:** 0 = чисто, 1 = найдены секреты, ≥2 = ошибка инструмента + (≥2 → never-raise degrade-вердикт гейта). + +## I-2. `pip-audit` в зависимостях +- **Что:** Python-пакет `pip-audit` (dependency audit), добавляется в `requirements.txt` + (pinned-версия). +- **Источник advisory:** OSV / PyPI advisory DB — **требует сетевого доступа** (исходящий + HTTPS к OSV/PyPI). +- **Цель v1:** аудит `requirements.txt` корня репо (Python-стек, A3). Мульти-стек — follow-up. + +## I-3. Сетевой доступ к CVE-фиду (degrade-политика) +- **Требование:** исходящий HTTPS из прод-контейнера к OSV/PyPI advisory. +- **При недоступности (Р-3):** **fail-open + громкий warning** по умолчанию — dep-audit не + краснит гейт из-за сетевого сбоя (анти-петля ORCH-061); фиксируется + `deps_audit_degraded: true` + Telegram + лог. Флаг `security_dep_audit_fail_closed` + (дефолт `false`) — для перевода в строгий режим без редеплоя кода. +- **Секреты не зависят от сети** (I-1) — критическая гарантия безусловна. + +## I-4. Конфиг-файлы в репозитории (версионируемые, BR-13) +- `.gitleaks.toml` (корень репо): правила + аллоулист заведомо-безопасных совпадений + (плейсхолдеры `.env.example`, тест-фикстуры). Версионируется, ревьюится как код. + +## I-5. Env-флаги (`.env.example` + хост `.env`/`.env.staging`) +| Переменная | Дефолт | Назначение | +|------------|--------|-----------| +| `ORCH_SECURITY_GATE_ENABLED` | `true` | глобальный kill-switch | +| `ORCH_SECURITY_GATE_REPOS` | `` (пусто) | CSV scope; пусто → только self-hosting | +| `ORCH_SECURITY_DEP_BLOCK_SEVERITY` | `HIGH` | порог блокировки зависимостей | +| `ORCH_SECURITY_SCAN_TIMEOUT_S` | `300` | таймаут каждого внешнего вызова сканера | +| `ORCH_SECURITY_DEP_AUDIT_FAIL_CLOSED` | `false` | строгий режим при недоступном фиде | +| `ORCH_SECURITY_SECRETS_BLOCK` | `true` | секреты блокируют (всегда по дефолту) | + +Секреты-значения в гит НЕ коммитятся (CLAUDE.md §8) — только дефолты в `.env.example`. + +## I-6. Ресурсы и тайминги +- Время сканирования добавляется к каждому прогону задачи на ребре `deploy-staging → deploy`, + ограничено `ORCH_SECURITY_SCAN_TIMEOUT_S` (по образцу `merge_retest_timeout_s`). +- Гейт исполняется ДО merge-gate/image-freshness (дёшево фейлить до дорогих rebase/rebuild). + +## I-7. Self-hosting safety (инвариант) +Гейт **только читает/сканирует** (git, gitleaks, pip-audit, запись артефакта). Не вызывает +деплой-хук, не рестартит/не трогает прод-контейнер (8500/8501). Прод-деплой ORCH-022 — строго +через staging-гейт (8501). diff --git a/docs/work-items/ORCH-022/08-data-requirements.md b/docs/work-items/ORCH-022/08-data-requirements.md new file mode 100644 index 0000000..9445746 --- /dev/null +++ b/docs/work-items/ORCH-022/08-data-requirements.md @@ -0,0 +1,26 @@ +# 08 — Требования к схеме БД: Security-гейт (ORCH-022) + +## Решение: схема БД НЕ меняется + +Миграций нет. Обоснование (соответствует TRZ §6 и паттерну edge-под-гейтов ORCH-043/058): + +1. **Вердикт гейта — артефакт-файл** `17-security-report.md` (YAML-frontmatter), как + `14-deploy-log.md` / `15-staging-log.md`. Не хранится в БД. +2. **Состояние/идемпотентность** — детерминированная пересборка вердикта при каждом тике + (гейт чистый, без долгоживущего состояния между прогонами); sentinel-файлы НЕ требуются + (в отличие от deploy-state/post-deploy-state — там асинхронный self-restart). +3. **Retry-счётчик** — переиспользуется существующий `_developer_retry_count(task_id)` + (подсчёт по `jobs`/`agent_runs`), общий с merge-gate/image-freshness. **Новой колонки + `security_retry` НЕ вводим** (TRZ §6: предпочесть подсчёт по `jobs`/`agent_runs`). Это + корректно: security-FAIL, как merge/freshness-FAIL, откатывает на `development` и + запускает developer — он и есть единица retry; общий cap=3 защищает от петли. + +## Используемые существующие таблицы (без изменений) +- `tasks` — стадия задачи (`update_task_stage` при откате на `development`). +- `jobs` — enqueue `developer` при FAIL; основа `_developer_retry_count`. +- `agent_runs` — usage/duration; основа подсчёта retry. + +## Что НЕ делаем +- Не добавляем таблицу findings/CVE-журнала (история находок — в артефактах per-task; петля + уроков ORCH-8 читает артефакт). +- Не добавляем колонок в `tasks`/`jobs`. diff --git a/docs/work-items/ORCH-022/10-tech-risks.md b/docs/work-items/ORCH-022/10-tech-risks.md new file mode 100644 index 0000000..47a76c3 --- /dev/null +++ b/docs/work-items/ORCH-022/10-tech-risks.md @@ -0,0 +1,16 @@ +# 10 — Технические риски: Security-гейт (ORCH-022) + +| ID | Риск | Вероятность / Влияние | Митигация (заложена в ADR-001) | +|----|------|----------------------|-------------------------------| +| R-1 | **Ложные срабатывания → петля отката** `→ development` (прецедент ORCH-061 staging-loop). | Средн. / Выс. | Аллоулист `.gitleaks.toml` (BR-13); cap `MAX_DEVELOPER_RETRIES=3` → эскалация (`set_issue_blocked`+Telegram); конфигурируемый порог severity; kill-switch; UNKNOWN-severity → warning, не блок. | +| R-2 | **Недоступность CVE-фида** даёт ложный красный/исключение. | Средн. / Выс. | fail-open + громкий warning по умолчанию (Р-3); `deps_audit_degraded:true`; флаг `security_dep_audit_fail_closed` для строгого режима. Секреты offline → не затронуты. | +| R-3 | **Скан вешает worker-слот** (зависший gitleaks/pip-audit) → стоит конвейер всех проектов (общий инстанс, `max_concurrency`). | Низк. / Выс. | `security_scan_timeout_s` (300) на каждый внешний вызов; never-raise degrade-вердикт; гейт ПЕРВЫМ на ребре (фейлит до дорогих rebase/rebuild). | +| R-4 | **Исключение гейта роняет `advance_stage`** → встаёт движок. | Низк. / Выс. | Двойной never-raise guard (внешний+внутренний) как `check_branch_mergeable`; AC-16/TC-11. | +| R-5 | **Скан после rebase обвиняет задачу в CVE из `main`** → петля. | — (устранён дизайном) | Гейт исполняется ДО merge-gate (скан ветки до rebase); Р-1. | +| R-6 | **Отсутствие бинаря `gitleaks` в образе** (забыт в Dockerfile) → гейт всегда degrade. | Низк. / Средн. | Установка в Dockerfile (I-1), pinned-версия; TC-11 (нет бинаря → `(False,reason)`, never-raise); проверяется на staging (8501) до прода. | +| R-7 | **pip-audit без severity (UNKNOWN)** → либо ложный блок, либо пропуск. | Средн. / Средн. | UNKNOWN → warning (не блок), логируется; осознанный анти-петля компромисс; ужесточение — follow-up. | +| R-8 | **Self-hosting: гейт трогает прод** (рестарт/деплой). | — (запрещено дизайном) | Гейт только читает/сканирует; AC-19/TC-21; прод-деплой ORCH-022 — через staging-гейт. | +| R-9 | **Drift вердикта vs артефакта** (возврат ≠ frontmatter). | Низк. / Средн. | Единый источник: гейт пишет артефакт → читает обратно через `parse_security_status` → возвращает (Р-5); AC-8. | +| R-10 | **Регресс существующих гейтов/стадий** (сломан `QG_CHECKS`/`STAGE_TRANSITIONS`). | Низк. / Выс. | `STAGE_TRANSITIONS` не меняется; новый чек — аддитивно в реестр; полный прогон `tests/` (TC-20); staging-гейт перед прод. | +| R-11 | **v1 Python-only** — секреты/CVE в не-Python стеке (JS/Android) не ловятся. | — (вне scope v1, A3) | Условность scope; точка расширения мульти-стек/SAST (BR-14); зависимость ORCH-9/15 зафиксирована. | +| R-12 | **Стоимость времени** на каждом прогоне задачи. | Низк. / Низк. | Таймаут; гейт первым (ранний выход); только self-hosting по умолчанию. | diff --git a/docs/work-items/ORCH-022/12-review.md b/docs/work-items/ORCH-022/12-review.md new file mode 100644 index 0000000..b8875df --- /dev/null +++ b/docs/work-items/ORCH-022/12-review.md @@ -0,0 +1,74 @@ +--- +type: review +work_item_id: ORCH-022 +verdict: APPROVED +version: 1 +--- + +# Review ORCH-022 + +## Summary +Security-гейт (secret-scanning `gitleaks` + dependency audit `pip-audit`) реализован как +детерминированный под-гейт ребра `deploy-staging → deploy`, исполняемый ПЕРВЫМ среди +edge-под-гейтов — в точности по ADR-001 (Вариант M) и эталонному паттерну соседей +(merge-gate ORCH-043 / image-freshness ORCH-058): leaf-модуль `src/security_gate.py` +(never-raise) + тонкая обёртка `check_security_gate` в `QG_CHECKS` (lazy-import, нет цикла) ++ врезка `_handle_security_gate` ПЕРВОЙ в блоке `current_stage == "deploy-staging"`. +`STAGE_TRANSITIONS` и схема БД не тронуты. Все 772 теста зелёные (25 из них — +security-специфичные: `test_security_gate.py`, `test_qg_security.py`, +`test_stage_engine_security_gate.py`). Документация обновлена полностью и в этом же PR. + +### Соответствие ТЗ (02-trz) +- FR-1 secret-scan offline `origin/main..HEAD`, любой секрет вне аллоулиста → FAIL ✓ +- FR-2 dep-audit по severity (`HIGH` дефолт), MEDIUM/LOW/UNKNOWN → warning ✓ +- FR-3 машинный вердикт ТОЛЬКО из frontmatter `17-security-report.md`, negative-токен + авторитетен, write→read-back (единый источник истины) ✓ +- FR-4 FAIL → откат на `development` + developer-retry (cap 3) + `task_desc` с дословными + находками (ORCH-046) ✓ +- FR-5 условность `security_gate_enabled` / `security_gate_repos` (пусто → self-hosting) ✓ +- FR-6 never-raise + таймаут `security_scan_timeout_s` ✓ +- FR-7 наблюдаемость (Telegram при degraded/FAIL, лог при PASS) ✓ +- §6 без миграций БД, §7 инварианты соблюдены (STAGE_TRANSITIONS/QG_CHECKS консистентны, + gate не деплоит/не рестартит прод) ✓ + +### Соответствие ADR (06-adr/ADR-001 + global adr-0012) +Р-1 (размещение ПЕРВЫМ, до merge-gate, до захвата merge-lease → lease не освобождается), +Р-2 (gitleaks pinned Go-бинарь в Dockerfile, pip-audit в requirements), Р-3 (fail-open +degrade + флаг `security_dep_audit_fail_closed`), Р-4 (пороги, UNKNOWN→warning), Р-5 +(артефакт + read-back), Р-6 (откат/cap/эскалация), Р-7 (lazy-import, double-guard +never-raise), Р-8 (self-hosting safety) — все реализованы как описано. + +### Критерии приёмки (03) +AC-1..AC-21 покрыты тестами TC-01..TC-21 (incl. rollback TC-16, verbatim task_desc TC-17, +cap+blocked TC-18, PASS-advance TC-19, no-deploy-on-FAIL TC-21). AC-20 (документация) — +подтверждён ниже. + +## Findings + +### P0 — Blocker +- нет + +### P1 — Must fix +- нет + +### P2 — Should fix +- нет + +### P3 — Nice-to-have +- Глобальный `docs/architecture/adr/adr-0012-security-gate.md` помечен `Статус: proposed`, + тогда как per-WI `06-adr/ADR-001` — `Accepted`. Косметическая рассинхронизация статуса, + на функциональность/гейты не влияет. + +## Документация +Обновлена в том же PR (AC-20, CLAUDE.md §6 соблюдён): +- `CLAUDE.md` — раздел «Артефакты задачи» (добавлен `17-security-report.md`) + строка о + машинных вердиктах (`security_status:`). +- `docs/architecture/README.md` — реестр `QG_CHECKS` (`check_security_gate (ORCH-022)`), + новый раздел «Security-гейт …», статусная сноска внизу. +- `docs/architecture/adr/adr-0012-security-gate.md` — новый global ADR (+ per-WI ADR-001). +- `CHANGELOG.md` — подробная запись в `[Unreleased] / Added`. +- `.env.example` — все шесть `ORCH_SECURITY_*` с комментариями. +- `Dockerfile` (pinned gitleaks), `requirements.txt` (pip-audit), `.gitleaks.toml` (корень, + правила + аллоулист) — инфраструктура версионирована. + +Статус: документация = golden source — синхронна с кодом. Замечаний нет. diff --git a/docs/work-items/ORCH-022/13-test-report.md b/docs/work-items/ORCH-022/13-test-report.md new file mode 100644 index 0000000..8977d5c --- /dev/null +++ b/docs/work-items/ORCH-022/13-test-report.md @@ -0,0 +1,76 @@ +--- +type: test-report +work_item_id: ORCH-022 +result: PASS +--- + +# Test Report — ORCH-022 + +Security-гейт: secret-scanning (gitleaks) + dependency audit (pip-audit) как под-гейт +ребра `deploy-staging → deploy`. + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 +- Дата: 2026-06-07 +- Ветка: `feature/ORCH-022-security-secret-scanning` +- Review verdict: APPROVED (`12-review.md`) + +## Smoke test API (prod 8500, self-hosting — не трогаем контейнер) +| Endpoint | Результат | +|----------|-----------| +| `GET /health` | `{"status":"ok","service":"orchestrator"}` — OK | +| `GET /status` | OK (active task ORCH-022 в stage=testing виден) | +| `GET /queue` | OK (counts/resilience/reconcile/reaper/post_deploy присутствуют) | + +## Результаты (привязка к 04-test-plan.yaml) + +| TC ID | Описание | Тест | Результат | +|-------|----------|------|-----------| +| TC-01 | Секрет в diff → FAIL, secrets_found>=1, причина называет находку | test_security_gate.py::test_tc01_secret_in_diff_fails | PASS | +| TC-02 | Чистая ветка → PASS, secrets_found=0 | test_tc02_clean_branch_passes | PASS | +| TC-03 | Аллоулист подавляет заведомо-безопасное | test_tc03_allowlisted_match_does_not_fail | PASS | +| TC-04 | HIGH/CRITICAL CVE при пороге HIGH → FAIL, deps_blocking>=1 | test_tc04_high_cve_at_high_threshold_blocks | PASS | +| TC-05 | Только MEDIUM/LOW → PASS, deps_warning>=1 | test_tc05_only_medium_low_warns_passes | PASS | +| TC-06 | Конфиг порога severity влияет на классификацию | test_tc06_threshold_config_changes_classification | PASS | +| TC-07 | Недоступный фид → детерминированный degrade (fail-open default / fail-closed strict) | test_tc07_degraded_feed_failopen_default_failclosed_strict | PASS | +| TC-08 | Вердикт ТОЛЬКО из frontmatter; negative-токен авторитетен | test_tc08_verdict_only_from_frontmatter | PASS | +| TC-09 | Нет/битый frontmatter → (False, reason) fail-closed | test_tc09_missing_or_broken_frontmatter_failclosed | PASS | +| TC-10 | Артефакт 17-security-report.md с валидным frontmatter + телом | test_tc10_artifact_has_valid_frontmatter_and_body | PASS | +| TC-11 | Нет бинаря / исключение → (False, reason), never-raise | test_tc11_missing_binary_failclosed_never_raises | PASS | +| TC-12 | Таймаут → детерминированный fail-closed, без зависания | test_tc12_timeout_is_deterministic_failclosed | PASS | +| TC-13 | Не-self репо при пустом scope → (True, N/A) мгновенно | test_qg_security.py::test_tc13_non_self_repo_empty_scope_is_na | PASS | +| TC-14 | ORCH_SECURITY_GATE_ENABLED=false → no-op pass | test_tc14_disabled_is_noop_pass | PASS | +| TC-15 | Зарегистрирован в QG_CHECKS и диспетчеризуется _run_qg | test_tc15_registered_in_qg_checks / test_tc15_dispatched_by_run_qg | PASS | +| TC-16 | FAIL → откат на development, enqueue developer, notify_qg_failure | test_stage_engine_security_gate.py::test_tc16_fail_rolls_back_and_enqueues_developer | PASS | +| TC-17 | task_desc несёт дословную причину (ORCH-046) | test_tc17_task_desc_has_verbatim_findings | PASS | +| TC-18 | После MAX_DEVELOPER_RETRIES (3) → set_issue_blocked + Telegram | test_tc18_retry_cap_blocks_and_alerts | PASS | +| TC-19 | PASS → штатное продвижение конвейера | test_tc19_pass_advances_normally | PASS | +| TC-20 | STAGE_TRANSITIONS не изменён; тесты стадий зелёные | tests/test_stages.py (полный прогон) | PASS | +| TC-21 | Гейт не вызывает деплой-хук/рестарт прод (self-hosting safety) | test_tc21_fail_never_triggers_deploy | PASS | + +Все 21 TC покрыты и зелёные. Соответствие критериям приёмки (03-acceptance-criteria): +AC-1..AC-21 закрыты соответствующими TC (AC-N ↔ TC-N для N=1..21; AC-20 «документация» +подтверждён в review 12-review.md). + +## Вывод pytest + +### Security-специфичные тесты (25 шт.) +``` +tests/test_security_gate.py ............... (15) +tests/test_qg_security.py ...... (6) +tests/test_stage_engine_security_gate.py ..... (5) +======================== 25 passed, 1 warning in 0.49s ========================= +``` + +### Полный регресс +``` +======================= 772 passed, 1 warning in 14.70s ======================== +``` +(1 warning — PydanticDeprecatedSince20 в src/config.py, не связан с ORCH-022, +существовал до задачи.) + +## Итог +**PASS** — полный регресс 772/772 зелёный, 25 security-тестов покрывают все 21 TC +плана и AC-1..AC-21, smoke-тесты API прод-инстанса OK. Прод-контейнер в процессе +тестирования не затронут (тесты офлайн/изолированы). Задача готова к стадии deploy-staging. diff --git a/docs/work-items/ORCH-022/14-deploy-log.md b/docs/work-items/ORCH-022/14-deploy-log.md new file mode 100644 index 0000000..6edaa73 --- /dev/null +++ b/docs/work-items/ORCH-022/14-deploy-log.md @@ -0,0 +1,12 @@ +--- +deploy_status: SUCCESS +work_item: ORCH-022 +hook_exit_code: 0 +deployed_by: deploy-finalizer +--- + +# Deploy log — ORCH-036 executable self-deploy + +Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`. + +Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM. diff --git a/docs/work-items/ORCH-022/16-post-deploy-log.md b/docs/work-items/ORCH-022/16-post-deploy-log.md new file mode 100644 index 0000000..1fe4449 --- /dev/null +++ b/docs/work-items/ORCH-022/16-post-deploy-log.md @@ -0,0 +1,14 @@ +--- +post_deploy_status: HEALTHY +action_taken: NONE +work_item: ORCH-022 +window_s: 900 +checks_total: 30 +checks_failed: 0 +--- + +# Post-deploy log — ORCH-021 post-deploy monitor + +Наблюдение прода завершено: `post_deploy_status: HEALTHY`, `action_taken: NONE`. + +Окно наблюдения: 900s; опросов всего: 30, из них с провалом: 0. diff --git a/docs/work-items/ORCH-059/00-business-request.md b/docs/work-items/ORCH-059/00-business-request.md new file mode 100644 index 0000000..3e790a5 --- /dev/null +++ b/docs/work-items/ORCH-059/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: Approve деплоя через статус Confirm Deploy (вместо перегруженного Approved) + +Work Item ID: ORCH-059 + +## Description + +TBD diff --git a/docs/work-items/ORCH-059/01-brd.md b/docs/work-items/ORCH-059/01-brd.md new file mode 100644 index 0000000..67dc05f --- /dev/null +++ b/docs/work-items/ORCH-059/01-brd.md @@ -0,0 +1,115 @@ +# 01 — BRD: Approve прод-деплоя через выделенный статус «Confirm Deploy» + +Work Item: **ORCH-059** +Repo: `orchestrator` +Stage: analysis +Тип: enhancement / risk-reduction (self-hosting) + +## 1. Контекст и проблема + +В ORCH-036 («исполняемый самодеплой стадии `deploy`») прод-деплой self-hosting +инстанса (контейнер `orchestrator`, порт 8500) запускается **Фазой B**: человек +переводит issue в Plane-статус **`Approved`**, webhook +`work_item.updated` → `handle_issue_updated` → `handle_verdict(approved=True)` +→ `_try_advance_stage` → `advance_stage(finished_agent=None)`, и в +`stage_engine.advance_stage` срабатывает блок +`current_stage == "deploy" and finished_agent is None` → +`_handle_self_deploy_phase_b` → detached host-деплой прода. + +**Перегрузка статуса.** Тот же самый Plane-статус `Approved` (UUID +`a519a341-…`) используется как **человеческий гейт одобрения BRD** на ранней +стадии `analysis` (`check_analysis_approved`: analysis → architecture) и в общем +verdict-роутинге `handle_verdict`. Один и тот же визуальный «Approved» на доске +означает две принципиально разные вещи: + +- на `analysis` — «BRD/ТЗ/AC приняты, продолжай конвейер» (дёшево, обратимо); +- на `deploy` — «**ВЫКАТИ В ПРОД** инструмент, который прямо сейчас обслуживает + все проекты из одного инстанса с общей БД» (дорого, групповой риск, см. + раздел Self-hosting в `CLAUDE.md`). + +### Последствия (Pain) +- **Двусмысленность семантики.** Один статус — два смысла; оператор не видит из + названия, что клик на `deploy` запускает реальный прод-рестарт. +- **Риск случайного клика.** Привычный жест «Approved» (которым оператор + штатно одобряет BRD десятки раз) на стадии `deploy` молча триггерит + прод-деплой. Цена ошибки — незапланированный рестарт прод-инстанса, + встающий конвейер всех проектов. +- **Несоответствие ожиданиям ORCH-036.** В scope ORCH-36 заявлялась Telegram + inline-кнопка подтверждения; в коде её **нет** — developer реализовал approve + исключительно через Plane-статус. Отдельного «осознанного» жеста подтверждения + деплоя в системе сейчас не существует. + +## 2. Решение Owner + +Ввести **отдельный Plane-статус `Confirm Deploy`** в проекте ORCH, который +триггерит **ТОЛЬКО** Фазу B self-deploy на стадии `deploy`. Статус `Approved` +перестаёт запускать прод-деплой и сохраняет единственный смысл — человеческое +одобрение на гейтах конвейера (прежде всего BRD на `analysis`). + +Минимальная правка: `handle_verdict` в `src/webhooks/plane.py` + регистрация +нового состояния в проекте ORCH (Plane + резолвер состояний). + +## 3. Бизнес-цели +- **BG-1.** Убрать двусмысленность: жест «запустить прод-деплой» отделён от жеста + «одобрить артефакт». +- **BG-2.** Снизить риск случайного прод-деплоя: запуск прода требует явного, + редко используемого статуса `Confirm Deploy`, а не привычного `Approved`. +- **BG-3.** Не сломать работающий self-hosting конвейер при доработке самого + инструмента (нулевая регрессия `analysis`-гейта и не-self репозиториев). + +## 4. Объём (Scope) + +### В объёме +- Новый логический статус `confirm_deploy` («Confirm Deploy») в резолвере + состояний Plane (`src/plane_sync.py`). +- Маршрутизация нового статуса в `src/webhooks/plane.py` + (`handle_issue_updated` / `handle_verdict`) на путь Фазы B прод-деплоя. +- Прекращение триггера Фазы B по статусу `Approved` на стадии `deploy`. +- Обновление текста CTA Фазы A (Plane-комментарий + Telegram в + `stage_engine._handle_self_deploy_phase_a`): инструктировать оператора + переводить задачу в `Confirm Deploy`, а не в `Approved`. +- Конфигурация Plane: создание статуса «Confirm Deploy» в проекте ORCH + (предусловие эксплуатации — фиксируется в TRZ/AC как требование среды). +- Обновление документации (`CLAUDE.md`, `docs/architecture/README.md` секция + ORCH-036, `CHANGELOG.md`) и ADR per-work-item. + +### Вне объёма +- Telegram inline-кнопки подтверждения деплоя (отдельная задача; здесь не + реализуем — управление по-прежнему статусом Plane). +- Полностью автоматический approve деплоя (ORCH-54). +- Изменение Фаз A/C, exit-кодов хука, merge-gate, `check_deploy_status`, + схемы БД, реестров `STAGE_TRANSITIONS` / `QG_CHECKS`. +- Поведение прод-деплоя для не-self репозиториев (остаётся прежним). +- Post-deploy наблюдение (ORCH-021) — не затрагивается. + +## 5. Заинтересованные стороны +- **Owner/оператор** — переводит задачи по статусам; главный выгодоприобретатель + снижения риска. +- **Self-hosting конвейер** — все проекты на общем инстансе; косвенно зависят от + безопасности прод-деплоя орка. + +## 6. Допущения +- A-1. Plane позволяет добавить кастомный статус «Confirm Deploy» в проект ORCH; + его UUID резолвится через `get_project_states` (API `/states/`). +- A-2. Статус `Confirm Deploy` нужен только проекту ORCH (self-hosting). Прочие + проекты прод-деплой через Plane-approve не используют + (`self_deploy_applies` → только `orchestrator`). +- A-3. Оператор переводит задачу в `Confirm Deploy` только когда она реально + находится на стадии `deploy` (approval-pending после Фазы A). + +## 7. Риски (детально — 10-tech-risks.md, ведёт архитектор) +- R-1. Новый логический ключ `confirm_deploy` отсутствует в fallback + `_DEFAULT_STATES` и в проектах без этого статуса → обращение к ключу должно + быть безопасным (fail-closed: нет статуса → нет деплоя, не падение). +- R-2. Регрессия: `Approved` на `deploy` после правки не должен НИ + запускать деплой, НИ вызывать ложный откат/advance. +- R-3. Самоправка прода: правка не должна потребовать ручного рестарта прод- + контейнера вне штатной стадии deploy-staging → deploy. + +## 8. Definition of Done (бизнес-уровень) +- Перевод задачи стадии `deploy` в `Confirm Deploy` запускает прод-деплой + (Фаза B) ровно так же, как раньше делал `Approved`. +- Перевод задачи стадии `deploy` в `Approved` прод-деплой НЕ запускает. +- `Approved` на `analysis` (и прочих человеческих гейтах) работает без изменений. +- CTA Фазы A просит `Confirm Deploy`. +- Документация и ADR обновлены в том же PR. diff --git a/docs/work-items/ORCH-059/02-trz.md b/docs/work-items/ORCH-059/02-trz.md new file mode 100644 index 0000000..2b4f6cb --- /dev/null +++ b/docs/work-items/ORCH-059/02-trz.md @@ -0,0 +1,103 @@ +# 02 — ТЗ: выделенный статус «Confirm Deploy» как триггер прод-деплоя + +Work Item: **ORCH-059** · Repo: `orchestrator` · Stage: analysis + +> ТЗ описывает **что** должно измениться и **поведенческий контракт**. Конкретный +> дизайн (сигнатуры, способ проброса признака «confirm-deploy» из webhook в +> `stage_engine`, sentinel-обработка) — за архитектором (ADR per-work-item). +> Точки касания ниже заданы бизнес-запросом Owner и текущей реализацией ORCH-036. + +## 1. Задействованные модули `src/` + +| Модуль | Роль в задаче | +|--------|---------------| +| `src/plane_sync.py` | Резолвер состояний Plane. Добавить логический ключ `confirm_deploy` ↔ имя статуса «Confirm Deploy»; обеспечить безопасный доступ при отсутствии статуса (fallback/неполный конфиг). | +| `src/webhooks/plane.py` | `handle_issue_updated` — маршрутизация нового статуса; `handle_verdict` — отделить «подтверждение деплоя» от обычного approve; снять триггер Фазы B со статуса `Approved` на `deploy`. | +| `src/stage_engine.py` | Блок Фазы B (`current_stage == "deploy" and finished_agent is None`) должен срабатывать ТОЛЬКО по сигналу confirm-deploy, не по обычному Approved. Обновить CTA-текст Фазы A (`_handle_self_deploy_phase_a`). | +| `src/config.py` | (опционально, на усмотрение архитектора) флаг/имя статуса, если потребуется конфигурируемость. По умолчанию — не требуется. | + +## 2. Поведенческий контракт (требования) + +### TRZ-1. Регистрация статуса «Confirm Deploy» +Резолвер состояний (`get_project_states`) обязан возвращать UUID статуса +«Confirm Deploy» под логическим ключом `confirm_deploy` для проекта ORCH. +Маппинг имени `"Confirm Deploy" → "confirm_deploy"` добавляется в +`_PLANE_NAME_TO_KEY`. Для проектов/сред, где статус отсутствует (enduro, +fallback `_DEFAULT_STATES`, недоступный API), ключ может отсутствовать — +обращение к нему должно быть **fail-closed**: «нет статуса → ветка confirm-deploy +не активируется», без `KeyError`/исключения. + +### TRZ-2. Триггер прод-деплоя по «Confirm Deploy» +Когда задача находится на стадии `deploy` и issue переводится в статус +`Confirm Deploy`, система обязана инициировать **Фазу B** прод-деплоя +(эквивалент текущего `_handle_self_deploy_phase_b`: idempotency-guard `initiated`, +`self_deploy.initiate_deploy`, постановка `deploy-finalizer`, комментарии/Telegram). +Поведение, идемпотентность и Фаза C — **без изменений** относительно ORCH-036; +меняется только **что именно является триггером**. + +### TRZ-3. `Approved` больше не запускает прод-деплой +Перевод задачи стадии `deploy` в статус `Approved` **не должен** инициировать +Фазу B. Он не должен также вызывать ложный откат (БАГ-8) или ложный advance +по `check_deploy_status` (вердикта ещё нет). Допустимое поведение — **no-op с +логированием** (issue остаётся на `deploy`/approval-pending). Конкретный способ +(игнор на уровне webhook-роутинга или на уровне `stage_engine`) — за архитектором. + +### TRZ-4. Сохранность гейта `Approved` на остальных стадиях +Статус `Approved` обязан продолжать работать как человеческий гейт: +- `analysis` → `architecture` (`check_analysis_approved`, approved-via-status); +- любой иной человеческий approve-advance, существующий сегодня. +Регрессия `handle_verdict(approved=True)` для НЕ-`deploy` стадий недопустима. + +### TRZ-5. CTA Фазы A +Текст запроса approve в `_handle_self_deploy_phase_a` (Plane-комментарий + Telegram) +обязан инструктировать оператора переводить задачу в статус **`Confirm Deploy`** +(а не `Approved`) для запуска прод-деплоя. + +### TRZ-6. Условность (как ORCH-35/36) +Ветка confirm-deploy реальна только для self-hosting +(`self_deploy.self_deploy_applies(repo)` → `orchestrator`). Для прочих репо — +прежнее поведение (синхронный деплой агентом), статус `Confirm Deploy` не +требуется и не влияет. + +## 3. Изменения API +Изменений HTTP-эндпоинтов **нет**. Канал — существующий `POST /webhook/plane` +(событие `work_item.updated`). Внешнее изменение: в проекте ORCH появляется +дополнительный статус доски «Confirm Deploy» (Plane-конфигурация, не код-API). + +## 4. Изменения схемы БД +**Нет.** `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, таблицы `tasks`/`jobs`/ +`agent_runs`/`events` — без изменений. Статусы — на стороне Plane; restart-safe +состояние деплоя — существующие sentinel-файлы ORCH-036 (без миграций). + +## 5. Требования к новым QG checks +**Нет.** Новый Quality Gate не вводится. `check_deploy_status` / +`_parse_deploy_status` и контракт exit-кодов хука (0/1/2) — без изменений. + +## 6. Конфигурация среды (предусловие эксплуатации) +- В проекте ORCH в Plane создаётся статус доски **«Confirm Deploy»** (точное имя, + чувствительно к регистру — должно совпасть с ключом `_PLANE_NAME_TO_KEY`). +- Размещение статуса на доске — рядом со стадией deploy/approval-pending + (рекомендация эксплуатации, не код). +- Кэш состояний (`get_project_states` / `reload_project_states`): после создания + статуса может потребоваться сброс кэша или рестарт по штатной стадии deploy. + +## 7. Артефакты, создаваемые/обновляемые по pipeline +- `docs/work-items/ORCH-059/06-adr/ADR-001-confirm-deploy-status.md` — решение + (как отличается триггер; где разрезается перегрузка `Approved`; fail-closed + при отсутствии статуса) — **ведёт архитектор**. +- `CLAUDE.md` — упоминание выделенного статуса approve прод-деплоя (раздел + self-hosting / артефакты). +- `docs/architecture/README.md` — секция ORCH-036: уточнить, что Фаза B + триггерится статусом `Confirm Deploy`, а не `Approved`. +- `CHANGELOG.md` — запись ORCH-059. +- `12-review.md`, `13-test-report.md`, `14-deploy-log.md`, `15-staging-log.md` — + штатно по стадиям конвейера. + +## 8. Совместимость и инварианты +- Не меняются: `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_deploy_status`, + БАГ-8 (FAILED → откат на development), merge-gate, exit-коды хука, Фазы A/C, + схема БД, post-deploy (ORCH-021). +- Self-hosting safety: правка НЕ требует внепланового рестарта прод-контейнера; + выкат — через штатный deploy-staging (8501) → deploy. +- Never-crash: отсутствие статуса `Confirm Deploy` в резолвере не приводит к + исключению в webhook-пути. diff --git a/docs/work-items/ORCH-059/03-acceptance-criteria.md b/docs/work-items/ORCH-059/03-acceptance-criteria.md new file mode 100644 index 0000000..fdd1fb4 --- /dev/null +++ b/docs/work-items/ORCH-059/03-acceptance-criteria.md @@ -0,0 +1,76 @@ +# 03 — Критерии приёмки: ORCH-059 + +Repo: `orchestrator` · Stage: analysis +Каждый критерий — однозначный PASS/FAIL. Проверка: unit/integration (см. +`04-test-plan.yaml`) + ручная верификация для инфра-предусловий. + +## AC-1 — Статус «Confirm Deploy» резолвится +**Given** проект ORCH со статусом доски «Confirm Deploy» +**When** вызывается резолвер состояний для проекта ORCH +**Then** возвращается логический ключ `confirm_deploy` с непустым UUID, +а маппинг `"Confirm Deploy" → "confirm_deploy"` присутствует в `_PLANE_NAME_TO_KEY`. +**FAIL:** ключ отсутствует или указывает на UUID статуса `Approved`. + +## AC-2 — «Confirm Deploy» на стадии `deploy` запускает Фазу B +**Given** задача self-hosting (`orchestrator`) на стадии `deploy`, +`deploy_require_manual_approve=true`, маркер `initiated` отсутствует +**When** приходит `work_item.updated` со статусом `Confirm Deploy` +**Then** инициируется Фаза B: вызывается `self_deploy.initiate_deploy`, +ставится job `deploy-finalizer`, пишется маркер `initiated`. +**FAIL:** прод-деплой не инициирован, либо finalizer не поставлен. + +## AC-3 — «Approved» на стадии `deploy` НЕ запускает прод-деплой +**Given** та же задача на стадии `deploy` +**When** приходит `work_item.updated` со статусом `Approved` +**Then** `self_deploy.initiate_deploy` **НЕ** вызывается; Фаза B не стартует; +задача не откатывается (БАГ-8 не срабатывает) и не «доходит» по +`check_deploy_status` (вердикта нет); событие залогировано как no-op. +**FAIL:** вызван `initiate_deploy`, либо произошёл откат/ложный advance. + +## AC-4 — «Approved» на `analysis` работает без регрессии +**Given** задача на стадии `analysis` (BRD готов, approval-pending) +**When** issue переводится в `Approved` +**Then** срабатывает approved-via-status и задача продвигается +`analysis → architecture` (как до правки). +**FAIL:** approve на analysis перестал продвигать конвейер. + +## AC-5 — Идемпотентность Фазы B по «Confirm Deploy» +**Given** задача на `deploy`, маркер `initiated` уже существует +**When** повторно приходит статус `Confirm Deploy` (двойной клик / дубль webhook) +**Then** повторного `initiate_deploy` не происходит (no-op, +`self-deploy-already-initiated`). +**FAIL:** прод-деплой запускается повторно. + +## AC-6 — CTA Фазы A просит «Confirm Deploy» +**Given** Фаза A (`deploy-staging → deploy`, approval-pending) +**When** формируются Plane-комментарий и Telegram-уведомление запроса approve +**Then** текст инструктирует перевести задачу в статус **`Confirm Deploy`** +(а не «Approved») для запуска прод-деплоя. +**FAIL:** CTA по-прежнему упоминает только «Approved». + +## AC-7 — Fail-closed при отсутствии статуса +**Given** среда без статуса «Confirm Deploy» (enduro / fallback `_DEFAULT_STATES` +/ недоступный Plane API) +**When** обрабатывается `work_item.updated` +**Then** webhook-путь не выбрасывает исключение; ветка confirm-deploy не +активируется (прод-деплой не запускается «вслепую»). +**FAIL:** `KeyError`/исключение в обработчике, либо ложный запуск Фазы B. + +## AC-8 — Условность для не-self репозиториев +**Given** не-self репозиторий (`self_deploy_applies(repo) == False`) +**When** приходит любой verdict-статус на стадии `deploy` +**Then** поведение прод-деплоя не меняется относительно текущего (синхронный +деплой агентом); статус `Confirm Deploy` не требуется. +**FAIL:** изменилось поведение деплоя не-self проекта. + +## AC-9 — Инварианты не нарушены +**Then** `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_deploy_status`/ +`_parse_deploy_status`, контракт exit-кодов хука (0/1/2), Фазы A/C, merge-gate, +схема БД — без изменений; `pytest tests/ -q` зелёный. +**FAIL:** изменён любой из перечисленных контрактов или красные тесты. + +## AC-10 — Документация обновлена (golden source) +**Then** в том же PR обновлены `CLAUDE.md`, секция ORCH-036 в +`docs/architecture/README.md`, `CHANGELOG.md`; заведён +`06-adr/ADR-001-confirm-deploy-status.md`. +**FAIL:** функционал изменён, документация — нет (Reviewer → REQUEST_CHANGES). diff --git a/docs/work-items/ORCH-059/04-test-plan.yaml b/docs/work-items/ORCH-059/04-test-plan.yaml new file mode 100644 index 0000000..97e8daf --- /dev/null +++ b/docs/work-items/ORCH-059/04-test-plan.yaml @@ -0,0 +1,109 @@ +work_item: ORCH-059 +title: Approve прод-деплоя через выделенный статус «Confirm Deploy» +repo: orchestrator +stage: analysis + +# Контракт-тесты: триггер прод-деплоя смещается с перегруженного `Approved` +# на выделенный статус `Confirm Deploy`. Деплой и сетевые вызовы мокаются. +tests: + - id: TC-01 + type: unit + description: "_PLANE_NAME_TO_KEY содержит маппинг 'Confirm Deploy' -> 'confirm_deploy'" + module: tests/test_plane_states.py + expected: PASS + + - id: TC-02 + type: unit + description: >- + get_project_states для проекта ORCH (мок API со статусом 'Confirm Deploy') + возвращает непустой UUID под ключом 'confirm_deploy', отличный от 'approved' + module: tests/test_plane_states.py + expected: PASS + + - id: TC-03 + type: unit + description: >- + Fail-closed: при отсутствии статуса 'Confirm Deploy' (fallback _DEFAULT_STATES / + недоступный API) доступ к ключу confirm_deploy не выбрасывает исключение + и не активирует ветку confirm-deploy + module: tests/test_plane_states.py + expected: PASS + + - id: TC-04 + type: unit + description: >- + handle_issue_updated: статус 'Confirm Deploy' на задаче стадии deploy + маршрутизируется на путь Фазы B (а не на обычный approve/advance) + module: tests/test_plane_confirm_deploy.py + expected: PASS + + - id: TC-05 + type: unit + description: >- + handle_verdict/Approved на стадии deploy НЕ вызывает self_deploy.initiate_deploy + (initiate_deploy замокан и не должен быть вызван) + module: tests/test_plane_confirm_deploy.py + expected: PASS + + - id: TC-06 + type: unit + description: >- + Approved на стадии analysis по-прежнему продвигает analysis -> architecture + (approved-via-status, регрессия гейта check_analysis_approved) + module: tests/test_plane_confirm_deploy.py + expected: PASS + + - id: TC-07 + type: unit + description: >- + stage_engine: блок Фазы B (current_stage==deploy, finished_agent is None) + инициирует deploy ТОЛЬКО по сигналу confirm-deploy; Approved-сигнал -> no-op + module: tests/test_stage_engine_phase_b.py + expected: PASS + + - id: TC-08 + type: unit + description: >- + Идемпотентность: при существующем маркере 'initiated' повторный + Confirm Deploy не вызывает initiate_deploy (self-deploy-already-initiated) + module: tests/test_stage_engine_phase_b.py + expected: PASS + + - id: TC-09 + type: unit + description: >- + CTA Фазы A (_handle_self_deploy_phase_a): текст Plane-комментария и Telegram + содержат 'Confirm Deploy' и не предлагают 'Approved' как триггер деплоя + module: tests/test_stage_engine_phase_a_cta.py + expected: PASS + + - id: TC-10 + type: integration + description: >- + E2E (мок Plane API + self_deploy): задача на deploy -> webhook Confirm Deploy + -> initiate_deploy вызван, deploy-finalizer поставлен, маркер initiated записан + module: tests/test_confirm_deploy_integration.py + expected: PASS + + - id: TC-11 + type: integration + description: >- + E2E: задача на deploy -> webhook Approved -> прод-деплой НЕ инициирован, + задача остаётся на deploy (нет отката, нет advance в done) + module: tests/test_confirm_deploy_integration.py + expected: PASS + + - id: TC-12 + type: integration + description: >- + Условность: для не-self репозитория verdict-статусы на deploy не меняют + поведение деплоя (self_deploy_applies == False) + module: tests/test_confirm_deploy_integration.py + expected: PASS + +regression: + - id: RG-01 + type: integration + description: "pytest tests/ -q зелёный; STAGE_TRANSITIONS и QG_CHECKS без изменений" + module: tests/ + expected: PASS diff --git a/docs/work-items/ORCH-059/06-adr/ADR-001-confirm-deploy-status.md b/docs/work-items/ORCH-059/06-adr/ADR-001-confirm-deploy-status.md new file mode 100644 index 0000000..3077474 --- /dev/null +++ b/docs/work-items/ORCH-059/06-adr/ADR-001-confirm-deploy-status.md @@ -0,0 +1,156 @@ +# ADR-001 (ORCH-059): Выделенный статус «Confirm Deploy» как триггер прод-деплоя + +## Статус +Accepted (design) — реализация в ветке `feature/ORCH-059-approve-confirm-deploy-approve`. + +## Контекст +ORCH-036 (исполняемый самодеплой стадии `deploy`) запускает прод-деплой +self-hosting инстанса **Фазой B**: человек переводит issue в Plane-статус +`Approved` → webhook `work_item.updated` → `handle_issue_updated` → +`handle_verdict(approved=True)` → `_try_advance_stage` → +`advance_stage(finished_agent=None)`; в `stage_engine.advance_stage` блок +`current_stage == "deploy" and finished_agent is None` → +`_handle_self_deploy_phase_b` → detached host-деплой прода (8500). + +Тот же UUID `Approved` (`a519a341-…`, `_DEFAULT_STATES["approved"]`) — это +**человеческий гейт одобрения** на стадии `analysis` +(`check_analysis_approved`, путь `approved-via-status`) и общий verdict-роутинг +в `handle_verdict`. Один визуальный «Approved» на доске значит две принципиально +разные вещи: «принять BRD» (дёшево, обратимо) и «**ВЫКАТИТЬ В ПРОД** инструмент, +обслуживающий все проекты из одного инстанса с общей БД» (дорого, групповой +риск). Привычный жест approve на стадии `deploy` молча триггерит прод-рестарт — +цена случайного клика высока (см. self-hosting в `CLAUDE.md`). + +Ограничения, формирующие дизайн (см. `02-trz.md`, `03-acceptance-criteria.md`): +1. **Нулевая регрессия** гейта `Approved` на `analysis` и прочих стадиях (TRZ-4). +2. **Fail-closed**: среды без статуса (enduro, fallback `_DEFAULT_STATES`, + недоступный API) не должны падать и не должны «вслепую» деплоить (TRZ-1, R-1). +3. **`Approved` на `deploy` не должен** запускать Фазу B И не должен вызывать + ложный откат (БАГ-8) или ложный advance по `check_deploy_status` — вердикта + ещё нет (TRZ-3, R-2). +4. **Без правки контрактов**: `STAGE_TRANSITIONS`, `QG_CHECKS`, + `check_deploy_status`, Фазы A/C, merge-gate, exit-коды хука, схема БД (TRZ-8). +5. **Self-hosting safety**: правка — чистая маршрутизация, не требует внепланового + рестарта прода; выкат через штатный `deploy-staging` (8501) → `deploy` (R-3). + +## Решение +Ввести отдельный логический статус `confirm_deploy` («Confirm Deploy»), который +триггерит **ТОЛЬКО** Фазу B на стадии `deploy`. `Approved` теряет смысл «запусти +прод-деплой» и остаётся исключительно человеческим гейтом конвейера. + +Четыре точечные правки в трёх модулях: + +### 1. Резолвер состояний — `src/plane_sync.py` +- В `_PLANE_NAME_TO_KEY` добавить маппинг `"Confirm Deploy" → "confirm_deploy"`. +- В `_DEFAULT_STATES` ключ `confirm_deploy` **НЕ добавлять** (реального UUID для + enduro/fallback нет; отсутствие ключа = fail-closed). Для проекта ORCH ключ + резолвится `get_project_states` из живого Plane API; для проектов без статуса и + на fallback-пути ключ просто отсутствует в результирующем словаре. +- Следствие: `get_project_states(orch)["confirm_deploy"]` → реальный UUID; + `get_project_states(enduro).get("confirm_deploy")` → `None`. + +### 2. Маршрутизация webhook — `src/webhooks/plane.py` +В `handle_issue_updated`, **до** ветки `approved`, добавить fail-closed-ветку: +```python +confirm_state = proj_states.get("confirm_deploy") # .get -> AC-7/R-1 +if confirm_state and new_state == confirm_state: + await handle_confirm_deploy(data, project_id) +elif new_state == proj_states["in_progress"]: + ... +elif new_state == proj_states["approved"]: + await handle_verdict(data, project_id, approved=True) +``` +Новый `handle_confirm_deploy(data, project_id)`: +- резолвит задачу по `plane_id`; +- если `stage != "deploy"` → **no-op с логом** (Confirm Deploy осмыслен только на + approval-pending стадии `deploy`; защищает прочие гейты от случайного approve); +- иначе → `_try_advance_stage(..., confirm_deploy=True)`. + +`handle_verdict(approved=True)` не меняется — продолжает звать `_try_advance_stage` +с `confirm_deploy=False` (дефолт). + +### 3. Сигнал в движок — `src/stage_engine.advance_stage(...)` +Добавить keyword-only параметр `confirm_deploy: bool = False` (back-compat: все +существующие вызовы из launcher/reconciler/finalizer/webhook передают +`finished_agent`, новый kwarg дефолтный). Блок Фазы B переписать так, чтобы он +**всегда возвращался рано** для `deploy + finished_agent is None` self-hosting, +но деплоил только по сигналу: +```python +if (current_stage == "deploy" and finished_agent is None + and settings.deploy_require_manual_approve + and self_deploy.self_deploy_applies(repo)): + if confirm_deploy: + _handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result) + else: + # TRZ-3/R-2: обычный Approved на deploy — no-op; НЕ запускаем + # check_deploy_status (вердикта ещё нет -> ложный откат БАГ-8). + result.note = "approved-on-deploy-noop" + return result +``` +Ключевое: возврат **до** блока Quality Gate в обоих случаях → `check_deploy_status` +по `Approved` на `deploy` не исполняется. Фаза C (finalizer, +`finished_agent="deployer"`) не затронута — условие требует `finished_agent is +None`. + +### 4. CTA Фазы A — `src/stage_engine._handle_self_deploy_phase_a` +Текст Plane-комментария и Telegram изменить: вместо «смените статус на Approved» +инструктировать перевести задачу в статус **«Confirm Deploy»** для запуска +прод-деплоя (TRZ-5/AC-6). + +### Условность (как ORCH-35/36) +Вся ветка реальна только для `self_deploy.self_deploy_applies(repo)` → +`orchestrator`. Прочие репо — прежний синхронный ssh-деплой агентом; статус +`Confirm Deploy` им не нужен и на них не влияет (AC-8). + +## Альтернативы +- **A. Telegram inline-кнопка подтверждения** вместо нового статуса — отклонено: + кнопочная инфраструктура в коде отсутствует, заявлено вне scope (ORCH-036 п. + «inline-кнопка» не реализован); управление остаётся статусом Plane. +- **B. Добавить `confirm_deploy` в `_DEFAULT_STATES`** — отклонено: реального UUID + «Confirm Deploy» для enduro/fallback нет; пришлось бы подставить фиктивный или + дублирующий UUID, что ломает fail-closed (enduro «получил бы» триггер деплоя) и + смешивает семантику. +- **C. Отдельный публичный entrypoint `stage_engine.initiate_confirm_deploy()`**, + минующий `advance_stage` — отклонено: дублирует гарды + (`deploy_require_manual_approve`, `self_deploy_applies`, idempotency `initiated`), + и всё равно пришлось бы внутри `advance_stage` гасить `Approved`-на-`deploy` в + no-op. Параметр-сигнал проще и держит единую точку правды. +- **D. Сигнал через sentinel-маркер, записываемый webhook’ом** — отклонено: вызов + синхронный в пределах одного `advance_stage`, persistence не нужна; параметр + явнее и не плодит файловое состояние. + +## Последствия +**Плюсы** +- Жест «запустить прод-деплой» отделён от «одобрить артефакт»; случайный approve + на доске больше не роняет прод (BG-1, BG-2). +- `Approved` на `deploy` детерминированно безопасен: no-op без отката/advance + (закрывает R-2). +- Fail-closed: нет статуса → нет деплоя, нет исключения (R-1, AC-7). +- Минимальный диффузный риск: контракты `STAGE_TRANSITIONS`/`QG_CHECKS`/ + `check_deploy_status`/Фазы A/C/merge-gate/схема БД не тронуты (AC-9). +- Реконсилятор F-1 на `deploy` (finished_agent=None) теперь попадает в no-op-ветку + вместо прежнего неявного запуска Фазы B → прод-деплой невозможно инициировать + автоматически, только явным человеческим `Confirm Deploy` (усиление safety). + +**Минусы / цена** +- Эксплуатационное предусловие: в Plane-проекте ORCH нужно создать статус доски + «Confirm Deploy» (точное имя, регистр) и сбросить кэш состояний — см. + `07-infra-requirements.md`. До создания статуса прод-деплой через approve не + запустится (это и есть желаемое fail-closed-поведение). +- Сигнатура `advance_stage` расширена одним kwarg (обратносовместимо). + +**Хэндофф документации (golden source, в том же PR — стадия development).** +ADR (этот файл) — артефакт архитектора. Переписать `Approve = Approved` → +`Confirm Deploy` в `docs/architecture/README.md` (секция ORCH-036), `CLAUDE.md` +(self-hosting/артефакты) и добавить запись в `CHANGELOG.md` обязан developer +одновременно с кодом (AC-10), чтобы доки не описывали ещё не существующее +поведение. В README на стадии architecture добавлена forward-looking пометка +ORCH-059 (design), как принято для незамёрженных доработок. + +## Связанные ADR +- `adr-0007-executable-self-deploy.md` (ORCH-036) — задаёт Фазы A/B/C; ORCH-059 + меняет **только триггер** Фазы B (`Approved` → `Confirm Deploy`) и делает + `Approved`-на-`deploy` no-op; Фазы внутренне не меняются. +- `adr-0003-staging-gate.md` (ORCH-35) — паттерн условности self-hosting. +- `adr-0007-reconciler.md` (ORCH-053) — реконсилятор F-1: поведение на `deploy` + становится no-op (см. Последствия). diff --git a/docs/work-items/ORCH-059/07-infra-requirements.md b/docs/work-items/ORCH-059/07-infra-requirements.md new file mode 100644 index 0000000..7435c50 --- /dev/null +++ b/docs/work-items/ORCH-059/07-infra-requirements.md @@ -0,0 +1,44 @@ +# 07 — Требования к инфраструктуре: ORCH-059 + +Work Item: **ORCH-059** · Repo: `orchestrator` +Связано: `06-adr/ADR-001-confirm-deploy-status.md`, `02-trz.md` §6. + +> Топология контейнеров/портов/деплоя НЕ меняется (см. `docs/operations/INFRA.md`). +> Единственное инфра-требование ORCH-059 — конфигурация Plane-доски проекта ORCH. + +## IR-1. Статус доски «Confirm Deploy» в проекте ORCH (предусловие эксплуатации) +- В Plane-проекте **ORCH** создать кастомный статус доски с **точным** именем + `Confirm Deploy` (case-sensitive, ровно один пробел) — должно посимвольно + совпасть с ключом `_PLANE_NAME_TO_KEY["Confirm Deploy"]`. Несовпадение → + fail-closed (деплой не запустится), не краш (R-9). +- UUID статуса генерирует Plane; код резолвит его через `get_project_states` + (`GET /workspaces//projects//states/`). Хардкодить UUID не нужно. +- **Размещение** на доске — рядом с approval-pending/`deploy` (рекомендация + эксплуатации, на поведение кода не влияет). +- **Только проект ORCH** (self-hosting). Для enduro и прочих проектов статус НЕ + создаётся и НЕ требуется — `self_deploy_applies` истинно лишь для `orchestrator`. + +## IR-2. Сброс кэша состояний после создания статуса +`get_project_states` кэширует резолв per-project на время жизни процесса +(`_STATES_CACHE`). После создания статуса в Plane закэшированный словарь не +содержит `confirm_deploy` (R-5). Применить ОДНО из: +- вызвать `reload_project_states()` (или полный сброс), либо +- штатно перезапустить прод по конвейеру `deploy-staging → deploy` (рестарт + процесса очищает кэш). + +> Внеплановый ручной рестарт прод-контейнера для применения этой задачи **не +> требуется** и противопоказан (self-hosting групповой риск). Выкат — только через +> штатный staging→deploy. + +## IR-3. Контрольная проверка готовности среды +После IR-1+IR-2: +1. `get_project_states()` содержит `confirm_deploy` с непустым UUID, + отличным от `approved` (AC-1, TC-02). +2. Перевод тестовой задачи стадии `deploy` (sandbox) в `Confirm Deploy` запускает + Фазу B; перевод в `Approved` — нет (AC-2/AC-3). + +## Что НЕ меняется +- Порты (8500 prod / 8501 staging), контейнеры, compose-профили, env-карта, + деплой-хук, схема БД, sentinel-каталоги ORCH-036 — без изменений. +- HTTP-эндпоинты (`POST /webhook/plane` тот же канал, событие + `work_item.updated`). diff --git a/docs/work-items/ORCH-059/10-tech-risks.md b/docs/work-items/ORCH-059/10-tech-risks.md new file mode 100644 index 0000000..eb2b72f --- /dev/null +++ b/docs/work-items/ORCH-059/10-tech-risks.md @@ -0,0 +1,25 @@ +# 10 — Технические риски: ORCH-059 + +Work Item: **ORCH-059** · Repo: `orchestrator` · ведёт: архитектор +Связано: `06-adr/ADR-001-confirm-deploy-status.md`. + +| ID | Риск | Вероятн. | Влияние | Митигация | Проверка | +|----|------|----------|---------|-----------|----------| +| R-1 | Ключ `confirm_deploy` отсутствует в `_DEFAULT_STATES` / у проектов без статуса → `KeyError` в webhook-пути | Сред | Выс (краш обработчика) | Доступ ТОЛЬКО через `.get("confirm_deploy")`; `_DEFAULT_STATES` не содержит ключ намеренно; отсутствие → ветка не активируется (fail-closed) | TC-03, AC-7 | +| R-2 | `Approved` на `deploy` после правки вызывает `check_deploy_status` (вердикта нет) → ложный откат БАГ-8 / ложный advance | Выс | Выс (петля dev↔deploy, ложный rollback прода) | Блок Фазы B возвращается рано для `deploy + finished_agent is None` self-hosting в ОБОИХ случаях; `Approved` → `note=approved-on-deploy-noop`, QG не запускается | TC-05, TC-07, TC-11, AC-3 | +| R-3 | Самоправка прода требует внепланового рестарта прод-контейнера | Низ | Выс (встаёт конвейер всех проектов) | Изменение — чистая маршрутизация в коде; выкат через штатный `deploy-staging` (8501) → `deploy`; sentinel-состояние ORCH-036 не трогаем | AC-9, RG-01 | +| R-4 | `Confirm Deploy` прислан на не-`deploy` стадии (оператор ошибся) → срабатывает как обычный approve и продвигает чужой гейт | Низ | Сред | `handle_confirm_deploy` гардит `stage == "deploy"`; иначе no-op с логом | TC-04 (+ ручная верификация) | +| R-5 | Кэш `get_project_states` закэширован до создания статуса «Confirm Deploy» → ключ не виден после конфигурации Plane | Сред | Сред (деплой не запускается) | После создания статуса в Plane — `reload_project_states(orch)` или штатный рестарт по стадии `deploy`; зафиксировано в `07-infra-requirements.md` | ручная верификация | +| R-6 | Новый kwarg `confirm_deploy` ломает существующие вызовы `advance_stage` (launcher/reconciler/finalizer) | Низ | Выс | keyword-only с дефолтом `False`; все вызовы передают `finished_agent`; не-`deploy`/finished_agent≠None пути не затронуты | RG-01, AC-9 | +| R-7 | Регрессия идемпотентности Фазы B (двойной `Confirm Deploy`) | Низ | Сред | Внутренности `_handle_self_deploy_phase_b` (маркер `initiated`) не меняются; меняется только триггер | TC-08, AC-5 | +| R-8 | Реконсилятор F-1 на `deploy` (finished_agent=None) меняет поведение | Низ | Низ (улучшение) | Намеренно: раньше неявно мог войти в Фазу B, теперь → no-op. Прод-деплой инициируется только явным `Confirm Deploy`. Документировано в ADR/README | RG-01 | +| R-9 | Несовпадение имени статуса в Plane и `_PLANE_NAME_TO_KEY` (регистр/пробел) → ключ не резолвится | Сред | Сред (деплой не запускается, fail-closed) | Точное имя «Confirm Deploy» (case-sensitive) — требование среды в `07-infra-requirements.md`; маппинг ровно этой строкой | TC-01, TC-02 | + +## Сводный вывод +Все риски — низкого/среднего остаточного уровня после митигаций. Доминирующий +класс — **fail-closed**: любая неполнота конфигурации (нет статуса, протухший кэш, +недоступный API) приводит к «деплой не запускается», а не к «деплой запускается +вслепую» или к крашу. Контракты конвейера (`STAGE_TRANSITIONS`, `QG_CHECKS`, +`check_deploy_status`, Фазы A/C, merge-gate, схема БД) не затрагиваются, поэтому +поверхность регрессии ограничена тремя модулями (`plane_sync.py`, +`webhooks/plane.py`, `stage_engine.py`). diff --git a/docs/work-items/ORCH-059/12-review.md b/docs/work-items/ORCH-059/12-review.md new file mode 100644 index 0000000..d4b4add --- /dev/null +++ b/docs/work-items/ORCH-059/12-review.md @@ -0,0 +1,59 @@ +--- +type: review +work_item_id: ORCH-059 +verdict: APPROVED +version: 1 +--- + +# Review ORCH-059 + +## Summary +Выделенный Plane-статус «Confirm Deploy» как единственный триггер Фазы B прод-деплоя +self-hosting; `Approved` на стадии `deploy` становится детерминированным no-op. Реализация +точно соответствует ТЗ (TRZ-1..6), ADR-001 и критериям приёмки (AC-1..10). Четыре точечные +правки в трёх модулях (`plane_sync.py`, `webhooks/plane.py`, `stage_engine.py`), без изменения +контрактов (`STAGE_TRANSITIONS`, `QG_CHECKS`, `check_deploy_status`, Фазы A/C, merge-gate, схема +БД). Документация обновлена в том же PR. `pytest tests/ -q` — 763 passed. + +## Соответствие ТЗ и ADR +- **TRZ-1 / AC-1** — `"Confirm Deploy" → "confirm_deploy"` добавлен в `_PLANE_NAME_TO_KEY`; + намеренно отсутствует в `_DEFAULT_STATES` → fail-closed. Покрыто `test_tc01/tc02`. +- **TRZ-2 / AC-2** — `handle_confirm_deploy` (гард `stage=="deploy"`) → + `_try_advance_stage(..., confirm_deploy=True)` → Фаза B. Покрыто `test_tc04/tc07/tc10`. +- **TRZ-3 / AC-3** — `Approved` на `deploy`: ранний возврат ДО Quality Gate с + `note="approved-on-deploy-noop"`, без `initiate_deploy`, без ложного отката БАГ-8. + Покрыто `test_tc05/tc07_approved_without_confirm_is_noop/tc11`. +- **TRZ-4 / AC-4** — `handle_verdict(approved=True)` не тронут; approve на `analysis` + продвигает конвейер. Покрыто `test_tc06_approved_on_analysis_still_advances`. +- **AC-5** — идемпотентность повторного «Confirm Deploy» (`self-deploy-already-initiated`). + Покрыто `test_tc08`, `test_tc06_approved_calls_prod_hook_exactly_once`. +- **TRZ-5 / AC-6** — CTA Фазы A (Plane-коммент + Telegram) просит «Confirm Deploy» и явно + отмечает, что «Approved» прод-деплой не запускает. Покрыто `test_tc09`. +- **TRZ-1 / AC-7** — доступ через `.get("confirm_deploy")`, отсутствие статуса → ветка не + активируется, без `KeyError`. Покрыто `test_tc03` (API недоступен / статуса нет на доске). +- **TRZ-6 / AC-8** — условность через `self_deploy.self_deploy_applies`; не-self репо без + изменений. Покрыто `test_tc12`. +- **AC-9** — контракты и схема БД не изменены; 763 теста зелёные. + +## Findings + +### P0 — Blocker +- нет + +### P1 — Must fix +- нет + +### P2 — Should fix +- нет + +## Документация +Обновлено в том же PR (AC-10 выполнен): +- `CLAUDE.md` — раздел self-hosting: прод-деплой только через «Confirm Deploy», `Approved` = no-op. +- `docs/architecture/README.md` — секция ORCH-036 уточнена + добавлена подсекция ORCH-059 + (статус-триггер «Confirm Deploy»), запись в перечне статусов доработок. +- `CHANGELOG.md` — запись ORCH-059 в `[Unreleased] / Added`. +- ADR `docs/work-items/ORCH-059/06-adr/ADR-001-confirm-deploy-status.md` — заведён, отражает + реализацию (4 правки, fail-closed, рассмотренные альтернативы). +- `07-infra-requirements.md` — эксплуатационное предусловие (создать статус доски + сброс кэша). + +Документация консистентна с кодом; golden-source инвариант соблюдён. diff --git a/docs/work-items/ORCH-059/13-test-report.md b/docs/work-items/ORCH-059/13-test-report.md new file mode 100644 index 0000000..d5ea52d --- /dev/null +++ b/docs/work-items/ORCH-059/13-test-report.md @@ -0,0 +1,71 @@ +--- +type: test-report +work_item_id: ORCH-059 +result: PASS +--- + +# Test Report — ORCH-059 + +Выделенный Plane-статус «Confirm Deploy» как единственный триггер Фазы B прод-деплоя +self-hosting; `Approved` на стадии `deploy` — детерминированный no-op. + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 +- Prod orchestrator (8500): `/health` → `{"status":"ok"}` +- Дата: 2026-06-07 + +## Результаты (контракт-тесты `04-test-plan.yaml`) + +| TC ID | Описание | Тест | Результат | +|-------|----------|------|-----------| +| TC-01 | `_PLANE_NAME_TO_KEY`: `'Confirm Deploy' → 'confirm_deploy'` | test_tc01_confirm_deploy_name_to_key_mapping; test_tc01_confirm_deploy_not_in_default_states | PASS | +| TC-02 | `get_project_states` ORCH резолвит непустой UUID под `confirm_deploy`, ≠ `approved` | test_tc02_get_project_states_resolves_confirm_deploy | PASS | +| TC-03 | Fail-closed при отсутствии статуса (API недоступен / нет на доске) — без исключения | test_tc03_fail_closed_when_api_unreachable; test_tc03_fail_closed_when_status_not_on_board | PASS | +| TC-04 | `handle_issue_updated`: `Confirm Deploy` на `deploy` → путь Фазы B | test_tc04_confirm_deploy_routes_phase_b; test_tc04b_confirm_deploy_off_deploy_stage_is_noop | PASS | +| TC-05 | `Approved` на `deploy` НЕ вызывает `initiate_deploy` | test_tc05_approved_on_deploy_does_not_initiate | PASS | +| TC-06 | `Approved` на `analysis` по-прежнему продвигает → architecture | test_tc06_approved_on_analysis_still_advances | PASS | +| TC-07 | stage_engine: Фаза B только по confirm-deploy; `Approved` → no-op | test_tc07_confirm_deploy_initiates; test_tc07_approved_without_confirm_is_noop | PASS | +| TC-08 | Идемпотентность: повтор `Confirm Deploy` при маркере `initiated` → no-op | test_tc08_idempotent_repeat_confirm_deploy | PASS | +| TC-09 | CTA Фазы A содержит «Confirm Deploy», не предлагает «Approved» как триггер | test_tc09_phase_a_cta_requests_confirm_deploy | PASS | +| TC-10 | E2E: `Confirm Deploy` → `initiate_deploy` вызван, finalizer поставлен, маркер записан | test_tc10_confirm_deploy_e2e_initiates | PASS | +| TC-11 | E2E: `Approved` → деплой НЕ инициирован, задача остаётся на `deploy` | test_tc11_approved_e2e_noop | PASS | +| TC-12 | Условность: не-self репо verdict-статусы не меняют поведение деплоя | test_tc12_non_self_repo_unaffected | PASS | +| RG-01 | Полный регресс зелёный; STAGE_TRANSITIONS / QG_CHECKS без изменений | tests/ (763 passed) | PASS | + +Все 16 целевых тестов ORCH-059 (TC-01..TC-12) — PASS. + +## Сопоставление с критериями приёмки (`03-acceptance-criteria.md`) + +| AC | Покрытие | Результат | +|----|----------|-----------| +| AC-1 Статус резолвится | TC-01, TC-02 | PASS | +| AC-2 Confirm Deploy на `deploy` → Фаза B | TC-04, TC-07, TC-10 | PASS | +| AC-3 Approved на `deploy` НЕ деплоит | TC-05, TC-07, TC-11 | PASS | +| AC-4 Approved на `analysis` без регрессии | TC-06 | PASS | +| AC-5 Идемпотентность Фазы B | TC-08 | PASS | +| AC-6 CTA Фазы A просит Confirm Deploy | TC-09 | PASS | +| AC-7 Fail-closed без статуса | TC-03 | PASS | +| AC-8 Условность для не-self | TC-12 | PASS | +| AC-9 Инварианты, pytest зелёный | RG-01 (763 passed) | PASS | +| AC-10 Документация обновлена | проверено reviewer (12-review.md, APPROVED) | PASS | + +## Smoke test API (prod 8500) +- `GET /health` → `{"status":"ok","service":"orchestrator"}` +- `GET /status` → 200, активные задачи отдаются (вкл. ORCH-059 на `testing`) +- `GET /queue` → 200, counts + resilience + reconcile + reaper + post_deploy + +## Вывод pytest +``` +======================= 763 passed, 1 warning in 15.45s ======================== +``` +Целевой набор ORCH-059: +``` +======================== 16 passed, 1 warning in 0.75s ========================= +``` +(1 warning — PydanticDeprecatedSince20 в `src/config.py`, не относится к ORCH-059.) + +## Итог +**PASS** — все контракт-тесты (TC-01..TC-12) и регресс (763 passed) зелёные, +критерии приёмки AC-1..AC-10 покрыты, smoke API OK. Задача готова к стадии +deploy-staging. diff --git a/docs/work-items/ORCH-059/14-deploy-log.md b/docs/work-items/ORCH-059/14-deploy-log.md new file mode 100644 index 0000000..c323abb --- /dev/null +++ b/docs/work-items/ORCH-059/14-deploy-log.md @@ -0,0 +1,12 @@ +--- +deploy_status: SUCCESS +work_item: ORCH-059 +hook_exit_code: 0 +deployed_by: deploy-finalizer +--- + +# Deploy log — ORCH-036 executable self-deploy + +Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`. + +Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM. diff --git a/docs/work-items/ORCH-059/16-post-deploy-log.md b/docs/work-items/ORCH-059/16-post-deploy-log.md new file mode 100644 index 0000000..5d96374 --- /dev/null +++ b/docs/work-items/ORCH-059/16-post-deploy-log.md @@ -0,0 +1,14 @@ +--- +post_deploy_status: HEALTHY +action_taken: NONE +work_item: ORCH-059 +window_s: 900 +checks_total: 30 +checks_failed: 0 +--- + +# Post-deploy log — ORCH-021 post-deploy monitor + +Наблюдение прода завершено: `post_deploy_status: HEALTHY`, `action_taken: NONE`. + +Окно наблюдения: 900s; опросов всего: 30, из них с провалом: 0. diff --git a/docs/work-items/ORCH-066/00-business-request.md b/docs/work-items/ORCH-066/00-business-request.md new file mode 100644 index 0000000..bf2dac6 --- /dev/null +++ b/docs/work-items/ORCH-066/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: [высокий] Статусная модель Plane: осмысленные статусы этапов + +Work Item ID: ORCH-066 + +## Description + +TBD diff --git a/docs/work-items/ORCH-066/01-brd.md b/docs/work-items/ORCH-066/01-brd.md new file mode 100644 index 0000000..42e382b --- /dev/null +++ b/docs/work-items/ORCH-066/01-brd.md @@ -0,0 +1,110 @@ +# 01 — Business Requirements Document (BRD) + +**Work Item:** ORCH-066 +**Заголовок:** [высокий] Статусная модель Plane: осмысленные статусы этапов +**Стадия:** analysis +**Автор:** Analyst +**Дата:** 2026-06-07 + +--- + +## 1. Контекст и проблема + +Статусная модель Plane оркестратора имеет **семантические перегрузки**: один и тот +же Plane-статус используется для несовместимых смыслов, из-за чего: + +- оператор не понимает, на каком реально этапе стоит задача (доска нечитаема); +- повышается риск ошибки оператора (например, неверный ручной перевод статуса); +- `In Progress` одновременно означает «человек запускает конвейер», «идёт анализ», + «идёт прод-деплой» и «возврат из Needs Input» — четыре разных смысла на одном статусе. + +Уже частично исправлено: ORCH-059 ввёл отдельный статус для подтверждения деплоя +(`Confirm Deploy`), разгрузив перегруженный `Approved`. ORCH-066 завершает наведение +порядка по **утверждённой Owner** статусной модели. + +### Два слоя (критично различать) + +| Слой | Что это | Источник | Трогаем? | +|------|---------|----------|----------| +| **A** | `STAGE_TRANSITIONS` — внутренняя машина стадий (`created→analysis→…→done`) | `src/stages.py` | **НЕТ (инвариант)** | +| **B** | Plane-статусы — индикация на доске | `src/plane_sync.py` + точки в `src/stage_engine.py` / `src/webhooks/plane.py` | **ДА** | + +ORCH-066 меняет **только слой B** и точки, где код вручную проставляет Plane-статусы. + +--- + +## 2. Целевая статусная модель (решение Owner) + +``` +Backlog → Todo → [To Analyse] → Analysis → [In Review → Approved] → Architecture → +Development → Code-Review → Testing → Awaiting Deploy → [Confirm Deploy] → Deploying → +Monitoring after Deploy → Done +``` + +- `[...]` = **действие человека** (вход-триггер). +- Остальное ставит **орк** (индикация). + +### Ветки (нелинейные исходы) +- **Rejected** — откат на предыдущую стадию (человек). +- **Needs Input** — ТОЛЬКО аналитик (НЕ расширять на других агентов). +- **Blocked** — затык / фейл деплоя / деградация прода. +- **Cancelled** — человек решил не делать задачу (валидный выход из In Review). + +--- + +## 3. Бизнес-требования + +| ID | Требование | Приоритет | +|----|------------|-----------| +| **BR-1** | Каждый этап конвейера показывается на доске Plane осмысленным статусом (To Analyse / Analysis / Code-Review / Awaiting Deploy / Deploying / Monitoring after Deploy). | Must | +| **BR-2** | `To Analyse` — единый человеческий вход: (а) старт нового конвейера, (б) resume/relaunch аналитика при возврате из Needs Input. Заменяет роль `In Progress` как входа-триггера. | Must | +| **BR-3** | Стадия `analysis` индицируется отдельным статусом `Analysis` (орк ставит при старте/relaunch аналитика), а не `In Progress`. | Must | +| **BR-4** | Стадия `review` индицируется Plane-статусом `Code-Review` (переименование `Review`). | Must | +| **BR-5** | Self-deploy Phase A (approval-pending) ставит `Awaiting Deploy` вместо `In Review`. | Must | +| **BR-6** | Self-deploy Phase B (старт прод-деплоя) ставит `Deploying`. | Must | +| **BR-7** | Self-deploy Phase C (health-OK финализация) ставит `Monitoring after Deploy` (НЕ `Done` сразу). | Must | +| **BR-8** | Post-deploy monitor (ORCH-021): чистое закрытие окна (HEALTHY) → `Done`; UNHEALTHY/деградация → `Blocked`. | Must | +| **BR-9** | `In Review` разгрузить: оставить ТОЛЬКО за approve-pending артефактов конвейера (BRD/ревью). Выходы: `Approved` (вперёд), `Rejected` (откат), `Cancelled` (человек отменил). | Must | +| **BR-10** | `Needs Input` — БЕЗ ИЗМЕНЕНИЙ. Остаётся только у аналитика (`01-questions.md` → `set_issue_needs_input`). Механизм не трогать. | Must | +| **BR-11** | Возврат аналитика из Needs Input выполняется через `To Analyse` (а НЕ через `In Progress`). Логика fork «старт vs resume» (по наличию task + active-job) сохраняется. | Must (грабли R1) | +| **BR-12** | **Fail-closed:** отсутствие нового статуса в проекте (enduro / Plane API down / fallback `_DEFAULT_STATES`) НЕ приводит к падению; поведение остаётся backward-compatible (паттерн ORCH-059 AC-7). | Must | +| **BR-13** | Reconciler не «оживляет» активные ожидания (`Awaiting Deploy` / `Deploying` / `Monitoring after Deploy`) как зависшие задачи (Guard 2 skip-list). | Must | +| **BR-14** | Документация (golden source) обновлена в том же PR: `CLAUDE.md`, `docs/architecture/README.md`, `CHANGELOG.md`, ADR per-work-item. | Must | + +--- + +## 4. Границы (Out of Scope / НЕ трогать) + +- `STAGE_TRANSITIONS` (`src/stages.py`) — машина стадий, инвариант. +- `QG_CHECKS`, `check_deploy_status`, exit-коды хука (0/1/2), merge-gate, схема БД. +- `Confirm Deploy` (уже работает, ORCH-059). +- Механизм `Needs Input` (analyst-only) — не расширять, не менять. +- Поведение прод-деплоя **не-self** репозиториев (enduro-trails): для них терминальный + переход остаётся `deploy → Done` как сейчас (Monitoring after Deploy не применяется — + post-deploy monitor армится только для self-hosting). +- Автоматический approve / авто-rollback self-hosting (ORCH-54 / ORCH-021 политика + ALERT_ONLY) — не меняется. + +--- + +## 5. Инфра-предусловие (вне кода, делает оператор) + +Новые Plane-статусы в проекте **ORCH** создаёт оператор через Plane API **ДО** эксплуатации: +`To Analyse`, `Analysis`, `Code-Review`, `Awaiting Deploy`, `Deploying`, +`Monitoring after Deploy` (`Confirm Deploy` уже есть). + +Резолвер (`_PLANE_NAME_TO_KEY` + `get_project_states`) подхватывает их **по имени** с +**fail-closed fallback** на `_DEFAULT_STATES` (см. BR-12). Документируется в +`07-infra-requirements.md` (создаёт архитектор) и в `docs/operations/`. + +--- + +## 6. Definition of Done + +- Plane показывает осмысленные статусы на каждом этапе. +- Возврат аналитика из Needs Input работает через `To Analyse`. +- Phase A → `Awaiting Deploy`, Phase B → `Deploying`, Phase C → `Monitoring after Deploy`, + окно HEALTHY → `Done`, фейл → `Blocked`. +- `STAGE_TRANSITIONS` не изменён. +- `pytest tests/ -q` — зелёный. Fail-closed покрыт тестами. +- Документация обновлена. diff --git a/docs/work-items/ORCH-066/02-trz.md b/docs/work-items/ORCH-066/02-trz.md new file mode 100644 index 0000000..a38860c --- /dev/null +++ b/docs/work-items/ORCH-066/02-trz.md @@ -0,0 +1,178 @@ +# 02 — Техническое задание (ТЗ) + +**Work Item:** ORCH-066 +**Стадия:** analysis → (вход для architecture) +**Автор:** Analyst + +> ТЗ фиксирует ТРЕБУЕМОЕ ПОВЕДЕНИЕ и затронутые точки кода. Конкретную архитектуру +> резолвера (точные имена ключей/функций) финализирует архитектор в ADR. Ниже — +> опорный контракт, согласованный с бизнес-запросом Owner. + +--- + +## 1. Задействованные модули `src/` + +| Модуль | Роль в задаче | +|--------|---------------| +| `src/plane_sync.py` | **Ядро изменений (слой B):** реестр логических статусов (`_DEFAULT_STATES`), `_PLANE_NAME_TO_KEY`, маппинг стадия→статус (`_STAGE_TO_STATE_KEY`, `STAGE_VISIBILITY_STATE`), хелперы `set_issue_*`. | +| `src/webhooks/plane.py` | Маршрутизация входящего статуса (`handle_issue_updated`): `To Analyse` → `handle_status_start` (старт **или** resume). | +| `src/stage_engine.py` | Точки ручной простановки статуса: analyst-flow (`Analysis`/`Needs Input`/`In Review`), Phase A (`Awaiting Deploy`), Phase B (`Deploying`), Phase C → `Monitoring after Deploy`, post-deploy monitor → `Done`/`Blocked`. | +| `src/reconciler.py` | F-2 запрос статусов (`To Analyse` в список), Guard 2 skip-list (активные ожидания). | +| `src/stages.py` | **НЕ менять** (инвариант слоя A). Используется только для чтения переходов. | +| `src/config.py` | (При необходимости) kill-switch для новой статусной модели — на усмотрение архитектора (см. §6). | + +--- + +## 2. Изменения статусной модели (слой B) + +### 2.1. Реестр логических статусов (`src/plane_sync.py`) + +Ввести новые **логические ключи** и их имена в `_PLANE_NAME_TO_KEY`: + +| Логический ключ | Plane name | Назначение | +|-----------------|-----------|------------| +| `to_analyse` | `To Analyse` | Вход-триггер (старт + resume аналитика). | +| `analysis` | `Analysis` | Индикация стадии analysis (орк). | +| `code_review` | `Code-Review` | Индикация стадии review (орк). Заменяет `review`. | +| `awaiting_deploy` | `Awaiting Deploy` | Phase A approval-pending (орк). | +| `deploying` | `Deploying` | Phase B прод-деплой идёт (орк). | +| `monitoring` | `Monitoring after Deploy` | Phase C / post-deploy окно (орк). | + +Сохранить существующие: `backlog`, `todo`, `in_progress` (backward-compat), `needs_input`, +`in_review`, `blocked`, `done`, `cancelled`, `architecture`, `development`, `testing`, +`approved`, `rejected`. `Cancelled` уже присутствует в `_PLANE_NAME_TO_KEY`. + +### 2.2. Fail-closed резолюция (КРИТИЧНО — BR-12) + +`get_project_states()` после резолва по API делает `setdefault(k, v)` из `_DEFAULT_STATES`. +Чтобы отсутствие нового статуса в проекте (enduro / Plane down / частичная конфигурация) +**не ломало** конвейер, новые логические ключи в `_DEFAULT_STATES` должны +**алиаситься на существующие UUID** (degrade-to-current): + +| Новый ключ | Default-алиас (UUID) | Деградированное поведение | +|------------|----------------------|---------------------------| +| `to_analyse` | = `in_progress` | enduro/старый проект: `In Progress` по-прежнему триггерит старт/resume. | +| `analysis` | = `in_progress` | analysis показывается как `In Progress` (как сейчас). | +| `code_review` | = `review` | review показывается как `Review` (как сейчас). | +| `awaiting_deploy` | = `in_review` | Phase A показывается как `In Review` (как сейчас). | +| `deploying` | = `in_progress` | Phase B показывается как `In Progress` (как сейчас). | +| `monitoring` | = `done` | Phase C показывается как `Done` (как сейчас); монитор затем держит Done / флипает Blocked. | + +> Эффект: если оператор НЕ создал новый статус — система работает строго как до ORCH-066 +> (никаких падений, никаких 404 от Plane PATCH). Если создал — резолвится по имени и +> используется новый UUID. Это ровно паттерн ORCH-059 AC-7. + +### 2.3. Маппинг стадия → статус + +`src/plane_sync.py`: +- `_STAGE_TO_STATE_KEY`: `analysis` → `analysis` (было `in_progress`); `review` → `code_review` + (было `review`). `deploy` остаётся (управляется Phase A/B/C напрямую, не через + `update_issue_state`). `created`/`architecture`/`development`/`testing`/`done` — без изменений. +- `STAGE_VISIBILITY_STATE`: `review` → `code_review` (было `review`). Добавить + `analysis` → `analysis`, если индикация analysis ставится через `set_issue_stage_state` + (решает архитектор; альтернатива — отдельный хелпер `set_issue_analysis`). +- Сохранить совместимость `STAGE_TO_STATE` / `PLANE_STATES` алиасов (импортируются тестами). + +### 2.4. Точки простановки статуса + +| Место (файл:симв.) | Сейчас | Должно стать | +|--------------------|--------|--------------| +| `webhooks/plane.py` `handle_issue_updated` | `new_state == in_progress` → `handle_status_start` | `new_state == to_analyse` (с fail-closed: при алиасе совпадает с `in_progress`) → `handle_status_start` | +| `webhooks/plane.py` `start_pipeline` (старт) | статус остаётся `In Progress` | при старте/enqueue analyst орк ставит `Analysis` | +| `webhooks/plane.py` `handle_status_start` (resume из Needs Input) | relaunch на `In Progress`-триггере | relaunch на `To Analyse`-триггере; при relaunch орк ставит `Analysis`. Fork «старт vs resume» (по `get_task_by_plane_id` + `has_active_job_for_task`) — **сохранить как есть.** | +| `stage_engine.py` `_handle_analysis_approved_flow` (artifacts ready) | `set_issue_in_review` | оставить `In Review` (BR-9: In Review только за approve-pending конвейера) ✔ без изменений | +| `stage_engine.py` `_handle_analysis_approved_flow` (questions) | `set_issue_needs_input` | **без изменений** (BR-10) | +| `stage_engine.py` `_handle_self_deploy_phase_a` | `set_issue_in_review` | `Awaiting Deploy` (`set_issue_awaiting_deploy` или аналог) | +| `stage_engine.py` `_handle_self_deploy_phase_b` | (статус не меняет) | `Deploying` | +| `stage_engine.py` advance `deploy → done` (terminal-sync, строка ~338) | `set_issue_done` для всех | **self-hosting:** `Monitoring after Deploy` (перед/вместо арма монитора); **не-self:** `Done` как сейчас | +| `stage_engine.py` `run_post_deploy_monitor` (HEALTHY, окно закрыто) | пишет лог + коммент, статус Plane НЕ трогает (остаётся Done) | `Done` (явно) | +| `stage_engine.py` `run_post_deploy_monitor` (DEGRADED) | пишет лог + alert | `Blocked` | + +> **Замечание по terminal-sync (важно для архитектора):** сейчас `advance_stage` на +> `next_stage == "done"` вызывает `set_issue_done` безусловно (строка ~338), затем армит +> post-deploy monitor для self-hosting (~361). Нужно развести: для репо, где +> `post_deploy.post_deploy_applies(repo)` истинно (self-hosting) — ставить `Monitoring +> after Deploy` вместо `Done`, и переложить простановку `Done`/`Blocked` на финал +> монитора (`run_post_deploy_monitor`). Для прочих репо — `Done` как сейчас. + +### 2.5. Новые хелперы `src/plane_sync.py` + +Добавить тонкие обёртки по образцу `set_issue_in_review` (резолв per-project UUID + +`_set_issue_state_direct`), never-raise при отсутствии issue: +- `set_issue_analysis(work_item_id, project_id=None)` +- `set_issue_code_review(...)` (или через `set_issue_stage_state("review")`) +- `set_issue_awaiting_deploy(...)` +- `set_issue_deploying(...)` +- `set_issue_monitoring(...)` + +(Точный набор/именование — на усмотрение архитектора; контракт: per-project резолв + +fail-closed.) + +--- + +## 3. Изменения reconciler (`src/reconciler.py`) + +- **F-2** `_reconcile_plane_project`: добавить `to_analyse` в список запрашиваемых + статусов (`list_issues_by_state([... , to_analyse])`) и в `_reconcile_plane_issue` + маршрутизировать `new_state == to_analyse` → `handle_status_start` (старт при `task is + None`, resume при существующем task без active-job — логика уже в `handle_status_start`). + Сохранить обработку `approved`/`rejected`. При fail-closed алиасе `to_analyse==in_progress` + поведение не дублируется (один и тот же UUID). +- **Guard 2** `_is_blocked_or_needs_input` (F-1 skip): расширить skip-множество активными + ожиданиями — `awaiting_deploy`, `deploying`, `monitoring` — чтобы реконсилер НЕ + «оживлял» их как зависшие (BR-13). Имя метода/семантику можно обобщить + («human-or-active-wait»), флаг `reconcile_skip_blocked_enabled` продолжает управлять + этим networked-чеком. + +> Примечание: F-1 и так не тронет Phase A (`check_deploy_status` red → silent), +> Deploying (active finalizer job), Monitoring (стадия `done`). Guard 2 — явная +> defense-in-depth по требованию Owner. + +--- + +## 4. Изменения API / эндпоинтов + +**Нет** новых HTTP-эндпоинтов. `GET /queue` / `GET /status` — без изменений контракта +(статусы Plane там не отражаются). Изменения только во внешней индикации Plane (PATCH +issue state — существующий механизм). + +--- + +## 5. Изменения схемы БД + +**Нет.** `tasks` не хранит Plane-статус (источник истины — стадия в БД + Plane API). +Миграции не требуются. + +--- + +## 6. Требования к новым QG checks + +**Нет.** `QG_CHECKS` не расширяется. Статусы — индикация, не управление (канон: +машинные вердикты читаются из YAML-frontmatter артефактов, не из Plane-статуса). + +Опционально (на усмотрение архитектора): единый kill-switch новой статусной модели +(env-флаг) для безопасного раската, по образцу `staging_infra_tolerance_enabled` / +`reconcile_skip_blocked_enabled`. Не обязателен, т.к. fail-closed алиасинг (§2.2) уже даёт +backward-compatible деградацию. + +--- + +## 7. Артефакты pipeline, создаваемые/обновляемые + +- `06-adr/ADR-001-plane-status-model.md` — архитектор (решение по резолверу, + алиасингу, разводке terminal-sync). +- `07-infra-requirements.md` — архитектор (список Plane-статусов для ручного создания + оператором + Plane API инструкция). +- Документация (golden source, тот же PR): `CLAUDE.md` (секция статусной модели), + `docs/architecture/README.md` (секция статусов рядом с ORCH-036/ORCH-021), + `CHANGELOG.md`. + +--- + +## 8. Инварианты (проверяемые) + +- `src/stages.py` `STAGE_TRANSITIONS` — байт-в-байт без изменений. +- `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status`, exit-коды хука, merge-gate, + схема БД, `Confirm Deploy`, механизм `Needs Input` — без изменений. +- Все новые `set_issue_*` / резолв — never-raise (Plane down ⇒ degrade, не crash). +- Поведение enduro (не-self) и его терминальный `Done` — без регресса. diff --git a/docs/work-items/ORCH-066/03-acceptance-criteria.md b/docs/work-items/ORCH-066/03-acceptance-criteria.md new file mode 100644 index 0000000..73252f3 --- /dev/null +++ b/docs/work-items/ORCH-066/03-acceptance-criteria.md @@ -0,0 +1,71 @@ +# 03 — Критерии приёмки (Acceptance Criteria) + +**Work Item:** ORCH-066 + +Каждый критерий — чёткое условие PASS/FAIL. Покрытие тестами — см. `04-test-plan.yaml`. + +--- + +## Группа A — Вход и стадия анализа + +| ID | Критерий | PASS | FAIL | +|----|----------|------|------| +| **AC-1** | `To Analyse` запускает конвейер | Перевод issue без task в `To Analyse` → `handle_status_start` → `start_pipeline` (создаётся task, ветка, enqueue analyst). | Не запускается / запускается на другом статусе. | +| **AC-2** | `To Analyse` делает resume аналитика из Needs Input | Существующий task без active-job + перевод в `To Analyse` → relaunch агента текущей стадии (analyst читает свежие комменты). Fork «старт vs resume» определяется по `get_task_by_plane_id` + `has_active_job_for_task` (как раньше). | Создаётся второй task / двойной запуск / resume не происходит. | +| **AC-3** | Стадия `analysis` индицируется статусом `Analysis` | При старте/relaunch аналитика орк ставит `Analysis`. | Остаётся `In Progress` (при наличии статуса `Analysis` в проекте). | +| **AC-4** | Busy-guard сохранён | `To Analyse` при существующем active-job для task → НЕ relaunch (no double launch). | Двойной запуск агента. | + +## Группа B — Code-Review + +| ID | Критерий | PASS | FAIL | +|----|----------|------|------| +| **AC-5** | Стадия `review` индицируется `Code-Review` | Вход в стадию `review` → Plane-статус `Code-Review`. | Остаётся `Review` (при наличии нового статуса). | + +## Группа C — Self-deploy фазы + +| ID | Критерий | PASS | FAIL | +|----|----------|------|------| +| **AC-6** | Phase A → `Awaiting Deploy` | `_handle_self_deploy_phase_a` ставит `Awaiting Deploy` (не `In Review`). | Ставит `In Review` (при наличии нового статуса). | +| **AC-7** | Phase B → `Deploying` | `_handle_self_deploy_phase_b` при успешном `initiate_deploy` ставит `Deploying`. | Статус не меняется / иной. | +| **AC-8** | Phase C → `Monitoring after Deploy` (self) | Финализатор SUCCESS для self-hosting → статус `Monitoring after Deploy`, НЕ `Done` сразу. | Ставит `Done` немедленно (для self-hosting). | +| **AC-9** | Не-self deploy → `Done` без регресса | Для не-self репо (`post_deploy_applies==False`) терминальный `deploy → done` ставит `Done` как сейчас. | Не-self репо получает `Monitoring after Deploy` / иной регресс. | + +## Группа D — Post-deploy monitor + +| ID | Критерий | PASS | FAIL | +|----|----------|------|------| +| **AC-10** | Чистое окно → `Done` | `run_post_deploy_monitor` HEALTHY + окно исчерпано → статус `Done`. | Остаётся `Monitoring after Deploy` / иной. | +| **AC-11** | Деградация → `Blocked` | `run_post_deploy_monitor` DEGRADED → статус `Blocked` (+ существующий ALERT_ONLY для self). | Остаётся в Monitoring / ставит Done. | +| **AC-12** | Self-hosting монитор не рестартит прод | Тик НИКОГДА не рестартит/откатывает прод-контейнер (ORCH-021 BR-5 сохранён). | Тик трогает прод-контейнер. | + +## Группа E — In Review / Needs Input / ветки + +| ID | Критерий | PASS | FAIL | +|----|----------|------|------| +| **AC-13** | `In Review` только за approve-pending конвейера | `In Review` ставится лишь для approve артефактов (analyst BRD/ревью), не для Phase A. | Phase A / иные стадии ставят `In Review`. | +| **AC-14** | `Needs Input` без изменений | Поведение `set_issue_needs_input` (analyst `01-questions.md`) идентично прежнему; не расширено на других агентов. | Механизм изменён / расширен. | +| **AC-15** | `Cancelled` — валидный выход из In Review без действий конвейера | Перевод в `Cancelled` → орк не выполняет advance/rollback (индикация, не управление). | Орк совершает действие конвейера на `Cancelled`. | + +## Группа F — Fail-closed (КРИТИЧНО) + +| ID | Критерий | PASS | FAIL | +|----|----------|------|------| +| **AC-16** | Отсутствие нового статуса не ломает конвейер | Проект без новых статусов (enduro/частичный/Plane down) → `get_project_states` отдаёт default-алиасы; все `set_issue_*`/триггеры работают backward-compatible, без исключений и без 404 PATCH. | Падение / необработанное исключение / зависание задачи. | +| **AC-17** | enduro `In Progress` по-прежнему стартует конвейер | Через `to_analyse`-алиас (= `in_progress` UUID) перевод enduro-issue в `In Progress` запускает старт/resume. | enduro-старт сломан. | +| **AC-18** | Резолв по имени | При наличии статуса в проекте по `name` (`_PLANE_NAME_TO_KEY`) используется его UUID, а не default-алиас. | Используется неверный UUID. | + +## Группа G — Reconciler + +| ID | Критерий | PASS | FAIL | +|----|----------|------|------| +| **AC-19** | F-2 реконсилирует `To Analyse` | `_reconcile_plane_project` запрашивает `to_analyse` и маршрутизирует к `handle_status_start` (старт/resume при потерянном webhook). | `To Analyse`-старты не реконсилируются. | +| **AC-20** | Guard 2 skip активных ожиданий | Задачи в `Awaiting Deploy` / `Deploying` / `Monitoring after Deploy` НЕ «оживляются» F-1 как зависшие. | Реконсилер advance'ит активное ожидание. | + +## Группа H — Инварианты и документация + +| ID | Критерий | PASS | FAIL | +|----|----------|------|------| +| **AC-21** | `STAGE_TRANSITIONS` не изменён | `src/stages.py` `STAGE_TRANSITIONS` идентичен (diff пуст). | Любое изменение слоя A. | +| **AC-22** | Реестры/контракты не изменены | `QG_CHECKS`, `check_deploy_status`, exit-коды хука, merge-gate, схема БД, `Confirm Deploy` — без изменений. | Любое изменение перечисленного. | +| **AC-23** | Тесты зелёные | `pytest tests/ -q` проходит полностью; новые fail-closed тесты присутствуют и зелёные. | Любой красный тест. | +| **AC-24** | Документация обновлена (golden source) | `CLAUDE.md`, `docs/architecture/README.md`, `CHANGELOG.md` обновлены; заведён `06-adr/ADR-001-*`. | Любой из артефактов не обновлён. | diff --git a/docs/work-items/ORCH-066/04-test-plan.yaml b/docs/work-items/ORCH-066/04-test-plan.yaml new file mode 100644 index 0000000..4355822 --- /dev/null +++ b/docs/work-items/ORCH-066/04-test-plan.yaml @@ -0,0 +1,184 @@ +work_item: ORCH-066 +description: > + Тест-план статусной модели Plane (слой B). Покрывает осмысленные статусы этапов, + возврат аналитика через To Analyse, фазы self-deploy, post-deploy monitor, + fail-closed деградацию и reconciler. Слой A (STAGE_TRANSITIONS) проверяется на + неизменность. Все тесты — pytest; Plane API мокается (httpx), как в существующих + tests/test_plane_*.py / tests/test_orch10_states.py. + +tests: + # --- Группа A: вход и стадия анализа --- + - id: TC-01 + type: unit + description: "To Analyse без существующего task -> handle_status_start -> start_pipeline (старт конвейера)." + module: tests/test_status_trigger.py + covers: [AC-1] + expected: PASS + + - id: TC-02 + type: integration + description: "To Analyse при существующем task без active-job -> relaunch агента стадии (resume из Needs Input), новый task НЕ создаётся." + module: tests/test_plane_to_analyse_resume.py + covers: [AC-2, BR-11] + expected: PASS + + - id: TC-03 + type: unit + description: "Старт/relaunch аналитика ставит Plane-статус Analysis (а не In Progress) при наличии статуса в проекте." + module: tests/test_plane_status_model.py + covers: [AC-3] + expected: PASS + + - id: TC-04 + type: unit + description: "To Analyse при существующем task с active-job -> НЕ relaunch (busy-guard)." + module: tests/test_plane_to_analyse_resume.py + covers: [AC-4] + expected: PASS + + # --- Группа B: Code-Review --- + - id: TC-05 + type: unit + description: "Вход в стадию review -> Plane-статус Code-Review (маппинг _STAGE_TO_STATE_KEY / STAGE_VISIBILITY_STATE)." + module: tests/test_plane_status_model.py + covers: [AC-5] + expected: PASS + + # --- Группа C: self-deploy фазы --- + - id: TC-06 + type: unit + description: "_handle_self_deploy_phase_a ставит Awaiting Deploy (не In Review)." + module: tests/test_deploy_approve.py + covers: [AC-6, AC-13] + expected: PASS + + - id: TC-07 + type: unit + description: "_handle_self_deploy_phase_b при успешном initiate_deploy ставит Deploying." + module: tests/test_deploy_approve.py + covers: [AC-7] + expected: PASS + + - id: TC-08 + type: integration + description: "Phase C (finalizer SUCCESS) для self-hosting ставит Monitoring after Deploy, НЕ Done; армит post-deploy monitor." + module: tests/test_deploy_terminal_sync.py + covers: [AC-8] + expected: PASS + + - id: TC-09 + type: integration + description: "Не-self репо: deploy->done ставит Done (без регресса, Monitoring не применяется)." + module: tests/test_deploy_terminal_sync.py + covers: [AC-9] + expected: PASS + + # --- Группа D: post-deploy monitor --- + - id: TC-10 + type: unit + description: "run_post_deploy_monitor HEALTHY + окно исчерпано -> Plane-статус Done." + module: tests/test_post_deploy.py + covers: [AC-10] + expected: PASS + + - id: TC-11 + type: unit + description: "run_post_deploy_monitor DEGRADED -> Plane-статус Blocked (+ ALERT_ONLY для self)." + module: tests/test_post_deploy.py + covers: [AC-11] + expected: PASS + + - id: TC-12 + type: unit + description: "Self-hosting тик НЕ рестартит/не откатывает прод-контейнер (ORCH-021 BR-5 сохранён)." + module: tests/test_post_deploy.py + covers: [AC-12] + expected: PASS + + # --- Группа E: In Review / Needs Input / Cancelled --- + - id: TC-13 + type: unit + description: "In Review ставится только за approve-pending конвейера (analyst BRD ready), не Phase A." + module: tests/test_analyst_status_only_regression.py + covers: [AC-13] + expected: PASS + + - id: TC-14 + type: unit + description: "set_issue_needs_input (analyst 01-questions.md) поведение идентично прежнему; не расширено на других агентов." + module: tests/test_plane_status_model.py + covers: [AC-14, BR-10] + expected: PASS + + - id: TC-15 + type: unit + description: "Перевод в Cancelled -> handle_issue_updated не выполняет advance/rollback (индикация, не управление)." + module: tests/test_plane_webhook.py + covers: [AC-15] + expected: PASS + + # --- Группа F: fail-closed (критично) --- + - id: TC-16 + type: unit + description: "Проект без новых статусов: get_project_states отдаёт default-алиасы (to_analyse=in_progress, code_review=review, awaiting_deploy=in_review, monitoring=done); исключений нет." + module: tests/test_plane_status_failclosed.py + covers: [AC-16, BR-12] + expected: PASS + + - id: TC-17 + type: unit + description: "Plane API down -> get_project_states fallback на _DEFAULT_STATES; set_issue_* never-raise." + module: tests/test_plane_status_failclosed.py + covers: [AC-16] + expected: PASS + + - id: TC-18 + type: integration + description: "enduro In Progress по-прежнему стартует конвейер через to_analyse-алиас." + module: tests/test_plane_status_failclosed.py + covers: [AC-17] + expected: PASS + + - id: TC-19 + type: unit + description: "Резолв по имени: при наличии статуса в проекте используется его UUID, а не default-алиас." + module: tests/test_orch10_states.py + covers: [AC-18] + expected: PASS + + # --- Группа G: reconciler --- + - id: TC-20 + type: integration + description: "F-2 _reconcile_plane_project запрашивает to_analyse и маршрутизирует к handle_status_start (потерянный webhook старта/resume)." + module: tests/test_reconciler_plane.py + covers: [AC-19] + expected: PASS + + - id: TC-21 + type: unit + description: "Guard 2: задачи в Awaiting Deploy / Deploying / Monitoring after Deploy НЕ оживляются F-1 как зависшие." + module: tests/test_reconciler.py + covers: [AC-20, BR-13] + expected: PASS + + # --- Группа H: инварианты --- + - id: TC-22 + type: unit + description: "STAGE_TRANSITIONS не изменён (явная проверка ключей/значений слоя A)." + module: tests/test_plane_status_model.py + covers: [AC-21] + expected: PASS + + - id: TC-23 + type: unit + description: "QG_CHECKS реестр и check_deploy_status контракты не изменены." + module: tests/test_plane_status_model.py + covers: [AC-22] + expected: PASS + + - id: TC-24 + type: integration + description: "Полный прогон pytest tests/ -q зелёный (регрессия)." + module: tests/ + covers: [AC-23] + expected: PASS diff --git a/docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md b/docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md new file mode 100644 index 0000000..bc25cb4 --- /dev/null +++ b/docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md @@ -0,0 +1,287 @@ +# ADR-001: Осмысленная статусная модель Plane (слой B) + +**Work Item:** ORCH-066 +**Стадия:** architecture +**Автор:** Architect +**Дата:** 2026-06-07 +**Статус:** Accepted + +> Контракт резолвера, алиасинга и разводки точек простановки статуса. Опирается на +> BRD (`01-brd.md`), ТЗ (`02-trz.md`), критерии приёмки (`03-acceptance-criteria.md`). +> Инфра-предусловие (статусы, создаваемые оператором) — `07-infra-requirements.md`, +> риски — `10-tech-risks.md`. + +--- + +## 1. Контекст + +Plane-доска оркестратора семантически перегружена: `In Progress` одновременно +означает «человек запускает конвейер», «идёт анализ», «идёт прод-деплой» и «возврат +из Needs Input». Оператор не различает реальный этап задачи → риск ошибочного ручного +перевода статуса. ORCH-059 уже разгрузил `Approved` отдельным `Confirm Deploy`; +ORCH-066 завершает наведение порядка по утверждённой Owner модели. + +**Жёсткое разделение двух слоёв (инвариант проекта):** + +| Слой | Что | Источник | ORCH-066 | +|------|-----|----------|----------| +| **A** | `STAGE_TRANSITIONS` — машина стадий | `src/stages.py` | **НЕ трогаем** | +| **B** | Plane-статусы — индикация на доске | `src/plane_sync.py` + точки простановки | **меняем только это** | + +Статус — **индикация, не управление**. Машинные вердикты по-прежнему читаются только +из YAML-frontmatter артефактов (канон гейтов). Конвейер движут гейты слоя A; смена +Plane-статуса не может продвинуть/откатить задачу (кроме существующих человеческих +триггеров `To Analyse`/`Approved`/`Rejected`, которые и раньше были входами). + +Целевая модель Owner: + +``` +Backlog → Todo → [To Analyse] → Analysis → [In Review → Approved] → Architecture → +Development → Code-Review → Testing → Awaiting Deploy → [Confirm Deploy] → Deploying → +Monitoring after Deploy → Done +``` +`[...]` = действие человека (вход-триггер); остальное ставит орк (индикация). +Ветки: **Rejected** (откат), **Needs Input** (только аналитик), **Blocked** (затык/фейл +деплоя/деградация), **Cancelled** (человек отменил задачу). + +--- + +## 2. Решение + +### 2.1. Реестр логических статусов (`src/plane_sync.py`) + +Вводим 6 новых **логических ключей**. Имена в `_PLANE_NAME_TO_KEY` (резолв по имени из +Plane API): + +| Логический ключ | Plane name | Назначение | +|-----------------|-----------|------------| +| `to_analyse` | `To Analyse` | Вход-триггер: старт нового конвейера **и** resume аналитика из Needs Input. | +| `analysis` | `Analysis` | Индикация стадии analysis (орк). | +| `code_review` | `Code-Review` | Индикация стадии review (орк). Заменяет `review` как видимый статус. | +| `awaiting_deploy` | `Awaiting Deploy` | Phase A approval-pending (орк). | +| `deploying` | `Deploying` | Phase B прод-деплой идёт (орк). | +| `monitoring` | `Monitoring after Deploy` | Phase C / post-deploy окно (орк). | + +Существующие ключи сохраняются: `backlog`, `todo`, `in_progress`, `needs_input`, +`in_review`, `blocked`, `done`, `cancelled`, `architecture`, `development`, `review`, +`testing`, `approved`, `rejected`. `Cancelled` уже присутствует. + +### 2.2. Fail-closed резолюция — **project-relative alias-fallback** (КРИТИЧНО, BR-12) + +ТЗ §2.2 предложил статические алиасы на enduro-UUID в `_DEFAULT_STATES`. Архитектурное +уточнение: для **частично сконфигурированного** проекта (оператор создал не все новые +статусы) статический enduro-UUID в orchestrator-проекте даст невалидный `state` → PATCH +422/404. Поэтому деградация делается **относительно того же проекта**, а не на чужой +UUID. + +**Два уровня fallback в `get_project_states()` (success-path), строго в порядке:** + +1. Резолв по имени из Plane API (как сейчас). +2. **Alias-fallback (новый):** для каждого отсутствующего нового ключа — UUID его + **базового ключа из этого же проекта**: + + ```python + _STATE_ALIAS_FALLBACK = { + "to_analyse": "in_progress", + "analysis": "in_progress", + "code_review": "review", + "awaiting_deploy": "in_review", + "deploying": "in_progress", + "monitoring": "done", + } + # после резолва по имени, ДО _DEFAULT_STATES.setdefault: + for new_key, base_key in _STATE_ALIAS_FALLBACK.items(): + if new_key not in resolved and resolved.get(base_key): + resolved[new_key] = resolved[base_key] + ``` +3. `_DEFAULT_STATES.setdefault(...)` (как сейчас) — последний резерв для путей, где + API недоступен целиком (`if not project_id: return _DEFAULT_STATES`, полный провал + запроса). В `_DEFAULT_STATES` новые ключи ТОЖЕ добавляются (= enduro-UUID базового + ключа), чтобы любой caller всегда получал полный словарь и `states[key]` не кидал + `KeyError`. + +**Эффект деградации:** + +| Сценарий | Поведение | +|----------|-----------| +| Orchestrator: все новые статусы созданы | резолв по имени → новые UUID (целевая модель). | +| Orchestrator: создана ЧАСТЬ новых статусов | отсутствующие → **собственный** базовый UUID проекта → индикация деградирует до текущего статуса, PATCH валиден. | +| Enduro (новые статусы не создаются никогда) | alias-fallback → собственные enduro базовые UUID → строго прежнее поведение (`In Progress`/`Review`/`Done`). | +| Plane API down целиком | `_DEFAULT_STATES` (enduro-UUID) — без регресса относительно сегодняшнего поведения. | + +Это паттерн ORCH-059 AC-7, усиленный project-relative разрешением. Все `set_issue_*` и +`_set_issue_state_direct` остаются **never-raise** (PATCH-исключение логируется, не +пробрасывается) — индикация деградирует, слой A не затрагивается. + +### 2.3. Маппинг стадия → статус + +- `_STAGE_TO_STATE_KEY` (живой путь `update_issue_state`→`stage_to_state`): + `analysis` → `analysis` (было `in_progress`); `review` → `code_review` (было `review`). + `deploy` остаётся `in_progress` (управляется Phase A/B/C напрямую). Остальные — без + изменений. +- `STAGE_VISIBILITY_STATE`: `review` → `code_review`; добавить `analysis` → `analysis` + (для консистентности; `set_issue_stage_state` сейчас dormant, но карта обновляется). +- `STAGE_TO_STATE` (legacy/test-only) — обновить `analysis`→`_DEFAULT_STATES["analysis"]`, + `review`→`_DEFAULT_STATES["code_review"]`. UUID-значения **байт-в-байт прежние** (это + алиасы на те же in_progress/review UUID) → тесты на конкретные UUID не краснеют. + +### 2.4. Новые хелперы `src/plane_sync.py` + +Тонкие обёртки по образцу `set_issue_in_review` (per-project резолв + `_set_issue_state_direct`, +never-raise): + +- `set_issue_analysis(work_item_id, project_id=None)` +- `set_issue_code_review(work_item_id, project_id=None)` +- `set_issue_awaiting_deploy(work_item_id, project_id=None)` +- `set_issue_deploying(work_item_id, project_id=None)` +- `set_issue_monitoring(work_item_id, project_id=None)` + +`get_project_states` всегда возвращает полный словарь (см. §2.2), поэтому `[key]` не +кидает `KeyError`. + +### 2.5. Точки простановки статуса (разводка) + +| Файл:место | Сейчас | Должно стать | AC | +|------------|--------|--------------|----| +| `webhooks/plane.py` `handle_issue_updated` | `new_state == in_progress` → `handle_status_start` | `new_state == to_analyse` → `handle_status_start` (при алиасе совпадает с `in_progress`) | AC-1, AC-17 | +| `webhooks/plane.py` `start_pipeline` (успешный старт) | статус остаётся `In Progress` | в конце старта орк ставит `set_issue_analysis` | AC-3 | +| `webhooks/plane.py` `handle_status_start` (resume-ветка) | relaunch агента стадии | при relaunch орк ставит `set_issue_analysis`; fork «старт vs resume» (`get_task_by_plane_id` + `has_active_job_for_task`) — **без изменений** | AC-2, AC-4 | +| `webhooks/plane.py` `_rollback_stage` (reject@analysis, ~583) | `set_issue_in_progress` | `set_issue_analysis` | AC-3 | +| `stage_engine.py` `_handle_analysis_approved_flow` (artifacts ready) | `set_issue_in_review` | **без изменений** (BR-9) | AC-13 | +| `stage_engine.py` `_handle_analysis_approved_flow` (questions) | `set_issue_needs_input` | **без изменений** (BR-10) | AC-14 | +| `stage_engine.py` rollback@analysis (architect conflict, ~669) | `set_issue_in_progress` | `set_issue_analysis` | AC-3 | +| `stage_engine.py` `_handle_self_deploy_phase_a` (~1012) | `set_issue_in_review` | `set_issue_awaiting_deploy` | AC-6, AC-13 | +| `stage_engine.py` `_handle_self_deploy_phase_b` (после `INITIATED` marker) | статус не меняет | `set_issue_deploying` | AC-7 | +| `stage_engine.py` terminal-sync `deploy → done` (~338) | `set_issue_done` для всех | **self (`post_deploy_applies`):** `set_issue_monitoring`; **не-self:** `set_issue_done` как сейчас | AC-8, AC-9 | +| `stage_engine.py` `run_post_deploy_monitor` HEALTHY+окно закрыто (~1260) | статус не трогает | `set_issue_done` (явно) | AC-10 | +| `stage_engine.py` `run_post_deploy_monitor` DEGRADED (~1273) | alert/log | `set_issue_blocked` (+ существующий ALERT_ONLY) | AC-11 | + +**Разводка terminal-sync (детально, AC-8/AC-9).** Текущий код безусловно зовёт +`set_issue_done` на `next_stage == "done"`, затем (для self) армит post-deploy monitor. +Разводим по `post_deploy.post_deploy_applies(repo)`: + +```python +if next_stage == "done" and work_item_id: + if post_deploy.post_deploy_applies(repo): + set_issue_monitoring(work_item_id) # self: окно наблюдения, НЕ Done сразу + else: + set_issue_done(work_item_id) # не-self: терминальный Done как сейчас +# арм монитора (существующий блок ~361) — без изменений +``` +Финальный `Done`/`Blocked` для self-hosting перекладывается на `run_post_deploy_monitor`. +При деградированном алиасе `monitoring==done` self-hosting показывает `Done` и затем +монитор держит `Done`/флипает `Blocked` — поведение идентично сегодняшнему. + +**AC-12 (инвариант ORCH-021):** добавление `set_issue_blocked` в DEGRADED-ветку — +**только индикация**; тик по-прежнему НИКОГДА не рестартит/откатывает прод-контейнер +(self-hosting остаётся `ALERT_ONLY`). `set_issue_blocked` — Plane-PATCH, не действие над +контейнером. + +**Cancelled (AC-15):** изменений кода НЕ требует. `handle_issue_updated` реагирует только +на `to_analyse`/`approved`/`rejected`; `Cancelled` падает в `else` → «no pipeline action». +Орк не делает advance/rollback — индикация, не управление. Критерий выполнен существующим +кодом. + +### 2.6. Reconciler (`src/reconciler.py`) + +- **F-2 `_reconcile_plane_project`:** заменить триггер `in_progress` → `to_analyse` в + списке запрашиваемых статусов (`list_issues_by_state([to_analyse, approved, rejected])`) + и в `_reconcile_plane_issue` маршрутизировать `new_state == to_analyse` → + `handle_status_start`. При алиасе `to_analyse == in_progress` (enduro) поведение + идентично текущему (один UUID; `list_issues_by_state` дедуплицирует через `set`). AC-19. +- **Guard 2 `_is_blocked_or_needs_input`:** расширить skip-множество активными ожиданиями + `awaiting_deploy`/`deploying`/`monitoring` (BR-13, AC-20). **Анти-регресс enduro + (КРИТИЧНО):** новые ключи алиасятся на `in_review`/`in_progress`/`done`; добавить их в + skip «как есть» → на enduro `In Progress`/`Done`-задачи начнут ошибочно пропускаться + F-1 (регресс ORCH-053/060). Поэтому активные ожидания включаются в skip **только когда + они РАЗЛИЧНЫ от базовых рабочих статусов проекта** (т.е. реально созданы): + + ```python + base_working = {states.get(k) for k in ( + "backlog","todo","in_progress","in_review","review", + "architecture","development","testing","approved","rejected","done")} + extra_waits = {states.get("awaiting_deploy"), + states.get("deploying"), + states.get("monitoring")} - base_working - {None} + skip_set = {states.get("blocked"), states.get("needs_input")} | extra_waits + return cur in skip_set + ``` + Enduro (алиасы схлопываются в base) → `extra_waits == {}` → нулевой регресс. Orchestrator + (отдельные UUID) → три реальных статуса в skip → BR-13. Семантику метода обобщаем до + «human-or-active-wait»; флаг `reconcile_skip_blocked_enabled` продолжает гасить этот + networked-чек. F-1 и так структурно не оживляет эти состояния (Phase A: `check_deploy_status` + red → silent; Deploying: active finalizer job → active-job guard; Monitoring: стадия + `done` → не итерируется) — Guard 2 это defense-in-depth по требованию Owner. + +### 2.7. Без kill-switch + +Отдельный env-флаг новой модели **не вводится**. Раскат естественно гейтится +**инфра-предусловием**: пока оператор не создал новые статусы — alias-fallback (§2.2) +держит строго прежнее поведение; создал — резолв по имени включает новую модель. Это +проще отдельного флага и соответствует принципу «минимум зависимостей». (ТЗ §6 допускает +флаг как опциональный — сознательно отказываемся.) + +--- + +## 3. Затронутые модули (карта изменений) + +| Модуль | Изменение | +|--------|-----------| +| `src/plane_sync.py` | `_PLANE_NAME_TO_KEY` +6; `_DEFAULT_STATES` +6 (enduro-alias UUID); `_STATE_ALIAS_FALLBACK` (новое) + применение в `get_project_states`; `_STAGE_TO_STATE_KEY` (analysis/review); `STAGE_VISIBILITY_STATE`; `STAGE_TO_STATE` (legacy); 5 новых `set_issue_*`. | +| `src/webhooks/plane.py` | триггер `in_progress`→`to_analyse` в `handle_issue_updated`; `set_issue_analysis` в `start_pipeline` и resume-ветке `handle_status_start`; `_rollback_stage` reject@analysis → `set_issue_analysis`. | +| `src/stage_engine.py` | Phase A → `set_issue_awaiting_deploy`; Phase B → `set_issue_deploying`; terminal-sync split (`monitoring` vs `done`); post-deploy monitor HEALTHY→`set_issue_done`, DEGRADED→`set_issue_blocked`; rollback@analysis (architect conflict) `set_issue_in_progress`→`set_issue_analysis`. | +| `src/reconciler.py` | F-2 триггер `to_analyse`; Guard 2 skip-set + анти-регресс subtraction. | +| `src/stages.py` | **НЕ трогаем** (инвариант слоя A). | +| `src/config.py` | Без изменений (kill-switch не вводится). | + +--- + +## 4. Инварианты (проверяемые, AC-21/AC-22) + +- `src/stages.py` `STAGE_TRANSITIONS` — diff пуст (байт-в-байт). +- `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status`, exit-коды хука (0/1/2), + merge-gate, `check_branch_mergeable`/`check_staging_image_fresh`, схема БД — без изменений. +- `Confirm Deploy` (ORCH-059), механизм `Needs Input` (analyst-only) — без изменений. +- Новых HTTP-эндпоинтов нет; `GET /queue`/`GET /status` контракт без изменений. +- Миграций БД нет (`tasks` не хранит Plane-статус; источник истины — стадия в БД + Plane API). +- Все новые `set_issue_*` / резолв — never-raise. +- Не-self (enduro) терминальный `deploy → Done` — без регресса. + +--- + +## 5. Последствия + +**Плюсы** +- Доска читаема: каждый этап = осмысленный статус; человеческие входы визуально отделены + от индикации. +- `In Progress` разгружен: больше не «всё подряд». +- Fail-closed усилен (project-relative): частичная конфигурация не ломает ни индикацию, + ни конвейер. +- Слой A нетронут → нулевой риск для машины стадий и гейтов всех проектов (self-hosting). +- Нет нового флага/таблицы → меньше движущихся частей. + +**Минусы / ограничения** +- Требуется ручное инфра-действие оператора (создать 6 статусов в проекте ORCH) — до + этого orchestrator деградирует до старой индикации (см. `07-infra-requirements.md`). +- Статусы кэшируются per-process (`_STATES_CACHE`): после создания статусов нужен + `reload_project_states()` или рестарт **staging** (не прод — см. self-hosting риск). +- Guard-2 subtraction добавляет немного логики; покрывается тестами (enduro-алиас → пустой + extra; orchestrator → три статуса). + +**Self-hosting (⚠️):** изменения — слой B (Plane-индикация) + reconciler-гварды; машина +стадий и контракты деплоя нетронуты. Выкладка ОБЯЗАТЕЛЬНО через `deploy-staging` (8501) +до прод-деплоя орка. Прод-контейнер не рестартить в рамках задачи вне штатного staging-гейта. + +--- + +## 6. Альтернативы (отклонены) + +- **Статический enduro-UUID алиас (ТЗ §2.2 буквально):** ломается на частичной + конфигурации orchestrator-проекта (чужой UUID → PATCH 422). Заменён project-relative + alias-fallback (§2.2). +- **Глобальный env kill-switch новой модели:** избыточен — инфра-предусловие уже даёт + естественный гейт раската (§2.7). +- **Хранить Plane-статус в `tasks` (миграция БД):** не нужно; источник истины — стадия + + живой Plane API. Нарушило бы инвариант «без лишних зависимостей». +- **Менять `STAGE_TRANSITIONS` ради новых статусов:** запрещено (инвариант слоя A); + статусы — индикация, отделены от машины стадий. diff --git a/docs/work-items/ORCH-066/07-infra-requirements.md b/docs/work-items/ORCH-066/07-infra-requirements.md new file mode 100644 index 0000000..214d3a2 --- /dev/null +++ b/docs/work-items/ORCH-066/07-infra-requirements.md @@ -0,0 +1,96 @@ +# 07 — Требования к инфраструктуре + +**Work Item:** ORCH-066 +**Автор:** Architect +**Дата:** 2026-06-07 + +> ORCH-066 не меняет топологию (контейнеры/порты/сеть — без изменений, см. +> `docs/operations/INFRA.md`). Единственное инфра-действие — создание новых +> Plane-статусов в проекте **ORCH** руками оператора через Plane API. Это +> **предусловие эксплуатации**, не часть кодового PR. + +--- + +## 1. Что нужно сделать оператору (ДО эксплуатации новой модели) + +Создать в Plane-проекте **ORCH** следующие статусы (states) с точными именами — +резолвер сопоставляет их по `name` (`_PLANE_NAME_TO_KEY`): + +| Plane name (точно) | Логический ключ | Группа Plane (рекомендуемая) | Назначение | +|--------------------|-----------------|------------------------------|------------| +| `To Analyse` | `to_analyse` | unstarted / started | Человеческий вход: старт конвейера + resume аналитика из Needs Input. | +| `Analysis` | `analysis` | started | Индикация стадии анализа. | +| `Code-Review` | `code_review` | started | Индикация стадии review. | +| `Awaiting Deploy` | `awaiting_deploy` | started | Phase A: ожидание ручного approve на прод-деплой. | +| `Deploying` | `deploying` | started | Phase B: идёт прод-деплой. | +| `Monitoring after Deploy` | `monitoring` | started | Phase C / окно пост-деплой наблюдения. | + +`Confirm Deploy` (ORCH-059) и базовые статусы (`Backlog`, `Todo`, `In Progress`, +`Architecture`, `Development`, `Review`, `Testing`, `Approved`, `Rejected`, `Done`, +`Cancelled`, `Needs Input`, `In Review`, `Blocked`) уже существуют — **не трогать**. + +> ⚠️ **Точность имён критична.** Резолв идёт по строковому `name`. Опечатка/иной регистр +> → статус не сопоставится → ключ деградирует на собственный базовый UUID проекта +> (alias-fallback, ADR §2.2): индикация откатится к старому статусу, но конвейер +> продолжит работать. Дефис в `Code-Review` — обязателен. + +--- + +## 2. Plane API — как создать статус + +Эндпоинт (как в `src/plane_sync.py`, `PLANE_BASE = {plane_api_url}/api/v1`): + +``` +POST {PLANE_BASE}/workspaces/{WORKSPACE}/projects/{ORCH_PROJECT_ID}/states/ +Headers: X-API-Key: (или соответствующий бот-токен с правами) +Body (JSON): + { "name": "To Analyse", "group": "started", "color": "#3f76ff" } +``` + +Повторить для каждого имени из таблицы §1. `group` влияет только на колонку доски; +оркестратор `group` не читает (резолв строго по `name`). `color` — на вкус оператора. + +Проверка после создания: + +``` +GET {PLANE_BASE}/workspaces/{WORKSPACE}/projects/{ORCH_PROJECT_ID}/states/ +``` +В ответе должны присутствовать все 6 имён. + +--- + +## 3. Сброс кэша статусов (важно) + +`get_project_states` кэширует резолв per-process (`_STATES_CACHE`). После создания +статусов оркестратор подхватит их **только** после сброса кэша: + +- штатно — `plane_sync.reload_project_states(project_id)` (или рестарт процесса); +- на **staging** (8501) — безопасный рестарт песочницы; +- на **прод** (8500) — **НЕ рестартить контейнер ради этого** в рамках задачи + (self-hosting: общий контейнер всех проектов). Кэш заполняется при первом обращении к + проекту; если статусы созданы ДО первого PATCH в цикле новой версии — отдельный сброс не + нужен. Если созданы позже — дождаться штатного цикла обновления/деплоя орка. + +--- + +## 4. Порядок раската (рекомендация) + +1. Слить кодовый PR ORCH-066 через `deploy-staging` (8501). +2. Создать 6 статусов в проекте ORCH (§1–§2). +3. Сбросить кэш / поднять staging, прогнать sandbox-задачу — убедиться, что доска + показывает `Analysis` / `Code-Review` / `Awaiting Deploy` / `Deploying` / + `Monitoring after Deploy` / `Done` на соответствующих этапах. +4. Прод-деплой орка штатным self-deploy (Phase A → approve → Phase B/C). + +**До шага 2** система работает строго как до ORCH-066 (alias-fallback) — раскат +безопасно обратим: не создавать/удалить статусы = откат индикации к старой модели, +без изменения кода. + +--- + +## 5. Что НЕ требуется + +- Никаких изменений docker-compose, портов, сети, томов, `.env`/`.env.staging`. +- Никаких миграций БД (`tasks` не хранит Plane-статус). +- Никаких изменений в проекте **enduro-trails** — там новые статусы не создаются; + alias-fallback держит прежнюю индикацию (`In Progress`/`Review`/`Done`). diff --git a/docs/work-items/ORCH-066/10-tech-risks.md b/docs/work-items/ORCH-066/10-tech-risks.md new file mode 100644 index 0000000..1735d9b --- /dev/null +++ b/docs/work-items/ORCH-066/10-tech-risks.md @@ -0,0 +1,31 @@ +# 10 — Технические риски + +**Work Item:** ORCH-066 +**Автор:** Architect +**Дата:** 2026-06-07 + +Риски слоя B (Plane-индикация). Слой A (`STAGE_TRANSITIONS`/гейты) не затрагивается, поэтому +класс «сломали конвейер» структурно исключён — худший исход любого риска ниже = неверная +**индикация**, не остановка конвейера. + +| ID | Риск | Вероятность | Влияние | Митигация | +|----|------|-------------|---------|-----------| +| **R1** | Частичная конфигурация: оператор создал не все 6 статусов в ORCH → отсутствующий ключ деградирует. Наивный статический enduro-UUID дал бы невалидный `state` (PATCH 422) на orchestrator-issue. | Средняя | Средн. | **Project-relative alias-fallback** (ADR §2.2): отсутствующий ключ → собственный базовый UUID проекта → PATCH валиден, индикация откатывается к текущему статусу. Покрыть тестом partial-config. | +| **R2** | Enduro-регресс через Guard 2: новые ключи алиасятся на `in_progress`/`in_review`/`done`; наивное добавление в skip-set заставит F-1 пропускать enduro `In Progress`/`Done` → сломанная реконсиляция (ORCH-053/060). | Средняя | Высок. | **Subtraction базовых рабочих статусов** (ADR §2.6): `extra_waits -= base_working`. На enduro (алиасы схлопнуты) `extra_waits == {}` → нулевой регресс. Тест: enduro-алиас не добавляет skip, orchestrator-distinct добавляет. | +| **R3** | Двойной триггер старта: F-2 reconciler и webhook оба маршрутизируют `to_analyse`; при алиасе `to_analyse == in_progress` возможен повтор. | Низкая | Низк. | `list_issues_by_state` дедуплицирует UUID через `set`; active-job guard + atomic create-claim в `handle_status_start` (`get_task_by_plane_id` + `has_active_job_for_task`) — без двойного старта (AC-4). Сохранить fork как есть. | +| **R4** | Кэш статусов: после создания статусов `_STATES_CACHE` отдаёт старый резолв до сброса → доска не обновляется. | Средняя | Низк. | `reload_project_states()` / рестарт **staging**. Документировано в `07-infra-requirements.md §3`. Прод-рестарт ради кэша — запрещён (self-hosting). | +| **R5** | Опечатка в имени статуса оператором (`Code Review` без дефиса и т.п.) → ключ не резолвится. | Средняя | Низк. | Резолв по точному `name`; при промахе — alias-fallback (деградация, не падение). Точные имена и проверка в `07-infra-requirements.md §1–2`. | +| **R6** | Terminal-sync split: ошибка ветвления `post_deploy_applies` → enduro получает `Monitoring after Deploy` вместо `Done` (регресс AC-9) или self уходит в `Done` минуя окно (AC-8). | Низкая | Средн. | Единый источник условности — `post_deploy.post_deploy_applies(repo)` (та же функция, что армит монитор). Тесты AC-8 (self→monitoring) и AC-9 (не-self→done). | +| **R7** | Phase B: `set_issue_deploying` поставлен до фактического старта детача → ложная индикация при провале `initiate_deploy`. | Низкая | Низк. | Ставить `set_issue_deploying` **после** успешного `initiate_deploy` и записи `INITIATED` marker (ADR §2.5); провал `initiate_deploy` оставляет `Awaiting Deploy` + просьбу повторить approve. | +| **R8** | Post-deploy DEGRADED → `set_issue_blocked` ошибочно трактуется как «действие над продом». | Низкая | Высок.(если) | `set_issue_blocked` — только Plane-PATCH. Тик остаётся `ALERT_ONLY`, НИКОГДА не рестартит/откатывает прод-контейнер (AC-12, ORCH-021 BR-5). Явный тест: self DEGRADED не трогает контейнер. | +| **R9** | Plane API недоступен в момент простановки статуса → PATCH падает. | Низкая | Низк. | Все `set_issue_*`/`_set_issue_state_direct` — never-raise (логируют, не пробрасывают). Индикация пропускается, слой A не затронут. | +| **R10** | Регресс на тестах, читающих `STAGE_TO_STATE`/`PLANE_STATES` конкретные UUID. | Низкая | Низк. | Новые ключи в `_DEFAULT_STATES` = алиасы на те же in_progress/review/done UUID → значения байт-в-байт; `STAGE_TO_STATE` analysis/review остаются прежними UUID (ADR §2.3). | +| **R11** | Self-hosting: выкладка орка минуя staging. | Низкая | Высок. | Обязательный `deploy-staging` гейт (8501); прод не рестартить вне штатного self-deploy. Раскат обратим (не создавать статусы = старое поведение). | + +## Сводный вывод + +Все риски снижаемы в рамках принятой архитектуры; ни один не способен остановить конвейер +(слой A инвариантен). Два ключевых требуют аккуратной реализации и обязательных тестов: +**R1** (project-relative alias-fallback) и **R2** (Guard-2 anti-regress subtraction) — +оба зафиксированы в ADR §2.2 и §2.6 как явные контракты. Эскалации `arch:major-change` не +требуется: изменение локализовано в слое B, без новых компонентов/стадий/QG/миграций. diff --git a/docs/work-items/ORCH-066/12-review.md b/docs/work-items/ORCH-066/12-review.md new file mode 100644 index 0000000..92186ec --- /dev/null +++ b/docs/work-items/ORCH-066/12-review.md @@ -0,0 +1,89 @@ +--- +type: review +work_item_id: ORCH-066 +verdict: APPROVED +version: 1 +--- + +# Review ORCH-066 + +## Summary +Осмысленная статусная модель Plane (слой B — индикация). Реализация затрагивает +строго слой B (`src/plane_sync.py`, точки простановки в `src/stage_engine.py` / +`src/webhooks/plane.py` / `src/reconciler.py`) и **не трогает слой A** +(`src/stages.py::STAGE_TRANSITIONS` — diff пуст). Все 4 оси проверки (ТЗ, ADR, +качество кода, тесты) и проверка документации — пройдены. `pytest tests/ -q`: +**774 passed**. Вердикт — **APPROVED**. + +## Соответствие ТЗ (02-trz.md) +- §2.1 — 6 новых логических ключей в `_PLANE_NAME_TO_KEY` + `_DEFAULT_STATES`. ✔ +- §2.2 — fail-closed резолюция (BR-12). ✔ (реализована усиленная project-relative + версия — см. ADR ниже). +- §2.3 — `_STAGE_TO_STATE_KEY` (analysis→analysis, review→code_review), + `STAGE_VISIBILITY_STATE`, legacy `STAGE_TO_STATE` (UUID байт-в-байт прежние). ✔ +- §2.4 — точки простановки разведены (handle_issue_updated триггер `to_analyse`, + start_pipeline/resume → Analysis, Phase A → Awaiting Deploy, Phase B → Deploying, + terminal-sync split, post-deploy HEALTHY→Done / DEGRADED→Blocked, + rollback@analysis → Analysis). ✔ +- §2.5 — 5 новых never-raise хелперов `set_issue_*`. ✔ +- §3 — reconciler F-2 триггер `to_analyse` (+ resume-ветка), Guard 2 skip-set с + вычитанием base_working. ✔ +- §4/§5/§6 — нет новых эндпоинтов, нет миграций БД, `QG_CHECKS` не расширен. ✔ + +## Соответствие ADR (06-adr/ADR-001) +- §2.2 project-relative alias-fallback (`_STATE_ALIAS_FALLBACK`, применён ДО + `_DEFAULT_STATES.setdefault`) — реализован точно по контракту, деградация на + собственный базовый UUID проекта, PATCH остаётся валидным на частичной + конфигурации. ✔ +- §2.5 terminal-sync split по `post_deploy.post_deploy_applies(repo)` — реализован + как в ADR (self → Monitoring, не-self → Done). ✔ +- §2.6 Guard 2 анти-регресс (extra_waits − base_working − {None}) — реализован + дословно, enduro-алиасы схлопываются → нулевой регресс. ✔ +- §2.7 без kill-switch — config.py не изменён (diff пуст). ✔ + +## Качество кода +- Все новые `set_issue_*` следуют образцу `set_issue_in_review` (per-project резолв + + `_set_issue_state_direct`), контракт never-raise сохранён, есть docstrings. ✔ +- Post-deploy/terminal-sync простановки обёрнуты в try/except с warning-логом + (never break the tick). ✔ +- Переменные в scope корректны (`work_item_id` определён до всех новых вызовов в + `start_pipeline`/`handle_status_start`/stage_engine). ✔ +- AC-12 соблюдён: `set_issue_blocked` в DEGRADED-ветке — только индикация, тик + прод-контейнер не трогает. ✔ + +## Качество тестов +- Содержательные, не тривиальные: `test_plane_status_failclosed.py` + (TC-16/17/18 — partial project, API down, never-raise сеттеров, enduro alias + старт), `test_plane_to_analyse_resume.py`, `test_plane_status_model.py`, + `test_deploy_terminal_sync.py` (self/не-self split), `test_post_deploy_integration.py`, + `test_reconciler*.py` (F-2 to_analyse + Guard 2). ✔ + +## Инварианты (AC-21/AC-22) +- `src/stages.py` — diff 0 строк (STAGE_TRANSITIONS байт-в-байт). ✔ +- `src/qg/checks.py` — diff 0 строк (QG_CHECKS, check_deploy_status). ✔ +- `src/config.py` — diff 0 строк. ✔ +- Схема БД — без миграций. ✔ + +## Findings + +### P0 — Blocker +- нет + +### P1 — Must fix +- нет + +### P2 — Should fix +- нет + +## Документация +Обновлена в том же PR (golden source соблюдён): +- `CLAUDE.md` — добавлена секция «Статусная модель Plane (ORCH-066)». ✔ +- `docs/architecture/README.md` — секция «Осмысленная статусная модель Plane + (ORCH-066)» + обновлён статусный footer. ✔ +- `CHANGELOG.md` — подробная запись в [Unreleased]/Added. ✔ +- `06-adr/ADR-001-plane-status-model.md` — заведён. ✔ +- `07-infra-requirements.md` — присутствует (инфра-предусловие: 6 Plane-статусов + создаёт оператор). ✔ + +Изменения `src/` полностью отражены в документации → требование +«документация обновлена при изменении src/» выполнено. diff --git a/docs/work-items/ORCH-066/13-test-report.md b/docs/work-items/ORCH-066/13-test-report.md new file mode 100644 index 0000000..9eb8b83 --- /dev/null +++ b/docs/work-items/ORCH-066/13-test-report.md @@ -0,0 +1,77 @@ +--- +type: test-report +work_item_id: ORCH-066 +result: PASS +--- + +# Test Report — ORCH-066 + +Осмысленная статусная модель Plane (слой B — индикация). Прогон полного регресса + +покрытие тест-плана `04-test-plan.yaml` + проверка инвариантов слоя A. + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 +- Ветка: feature/ORCH-066-plane +- Дата: 2026-06-07 + +## Результаты по тест-плану (04-test-plan.yaml) + +| TC ID | Покрывает | Описание | Модуль | Результат | +|-------|-----------|----------|--------|-----------| +| TC-01 | AC-1 | To Analyse без task → start_pipeline | test_status_trigger.py | PASS | +| TC-02 | AC-2,BR-11 | To Analyse resume аналитика, без двойного task | test_plane_to_analyse_resume.py | PASS | +| TC-03 | AC-3 | Старт/relaunch → статус Analysis | test_plane_status_model.py | PASS | +| TC-04 | AC-4 | Busy-guard: active-job → не relaunch | test_plane_to_analyse_resume.py | PASS | +| TC-05 | AC-5 | review → статус Code-Review | test_plane_status_model.py | PASS | +| TC-06 | AC-6,AC-13 | Phase A → Awaiting Deploy (не In Review) | test_deploy_approve.py | PASS | +| TC-07 | AC-7 | Phase B → Deploying | test_deploy_approve.py | PASS | +| TC-08 | AC-8 | Phase C self → Monitoring after Deploy | test_deploy_terminal_sync.py | PASS | +| TC-09 | AC-9 | Не-self deploy→done → Done (без регресса) | test_deploy_terminal_sync.py | PASS | +| TC-10 | AC-10 | Post-deploy HEALTHY → Done | test_post_deploy.py | PASS | +| TC-11 | AC-11 | Post-deploy DEGRADED → Blocked | test_post_deploy.py | PASS | +| TC-12 | AC-12 | Self-тик не рестартит прод | test_post_deploy.py | PASS | +| TC-13 | AC-13 | In Review только за approve-pending | test_analyst_status_only_regression.py | PASS | +| TC-14 | AC-14,BR-10 | Needs Input без изменений | test_plane_status_model.py | PASS | +| TC-15 | AC-15 | Cancelled → нет действий конвейера | test_plane_webhook.py | PASS | +| TC-16 | AC-16,BR-12 | Fail-closed default-алиасы, нет исключений | test_plane_status_failclosed.py | PASS | +| TC-17 | AC-16 | Plane API down → fallback, never-raise | test_plane_status_failclosed.py | PASS | +| TC-18 | AC-17 | enduro In Progress стартует через алиас | test_plane_status_failclosed.py | PASS | +| TC-19 | AC-18 | Резолв по имени → корректный UUID | test_orch10_states.py | PASS | +| TC-20 | AC-19 | F-2 реконсилирует To Analyse | test_reconciler_plane.py | PASS | +| TC-21 | AC-20,BR-13 | Guard 2 skip активных ожиданий | test_reconciler.py | PASS | +| TC-22 | AC-21 | STAGE_TRANSITIONS не изменён | test_plane_status_model.py | PASS | +| TC-23 | AC-22 | QG_CHECKS/check_deploy_status не изменены | test_plane_status_model.py | PASS | +| TC-24 | AC-23 | Полный регресс pytest зелёный | tests/ | PASS | + +Все 24 тест-кейса — PASS. + +## Инварианты слоя A (AC-21 / AC-22) +Diff против `origin/main` (merge-base `4815e378`): +- `src/stages.py` (STAGE_TRANSITIONS) — diff пуст ✔ +- `src/qg/checks.py` (QG_CHECKS, check_deploy_status) — diff пуст ✔ +- `src/config.py` (без kill-switch) — diff пуст ✔ + +## Smoke test API (TestClient — прод-контейнер 8500 не трогался) +> `curl` в окружении недоступен; smoke прогнан через FastAPI TestClient (lifespan), +> без рестарта/обращения к прод-контейнеру (self-hosting safety). + +| Endpoint | Статус | Тело (фрагмент) | +|----------|--------|-----------------| +| GET /health | 200 | `{"status":"ok","service":"orchestrator"}` | +| GET /status | 200 | `{"active_tasks":[...]}` | +| GET /queue | 200 | `{"counts":{...},"max_concurrency":1,...}` | + +## Вывод pytest +``` +======================= 774 passed, 1 warning in 17.68s ======================== +``` +(единственный warning — PydanticDeprecatedSince20 в src/config.py, предсуществующий, +не связан с ORCH-066) + +Прогон по модулям тест-плана: `117 passed` (ORCH-066-специфичные файлы). + +## Итог +PASS — все тесты зелёные (774 passed), все 24 TC покрыты, инварианты слоя A +сохранены (diff пуст), smoke-эндпоинты отвечают 200. Review-вердикт APPROVED. +Задача готова к переходу на стадию deploy-staging. diff --git a/docs/work-items/ORCH-066/14-deploy-log.md b/docs/work-items/ORCH-066/14-deploy-log.md new file mode 100644 index 0000000..d72b4a7 --- /dev/null +++ b/docs/work-items/ORCH-066/14-deploy-log.md @@ -0,0 +1,12 @@ +--- +deploy_status: SUCCESS +work_item: ORCH-066 +hook_exit_code: 0 +deployed_by: deploy-finalizer +--- + +# Deploy log — ORCH-036 executable self-deploy + +Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`. + +Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM. diff --git a/docs/work-items/ORCH-066/16-post-deploy-log.md b/docs/work-items/ORCH-066/16-post-deploy-log.md new file mode 100644 index 0000000..569090f --- /dev/null +++ b/docs/work-items/ORCH-066/16-post-deploy-log.md @@ -0,0 +1,14 @@ +--- +post_deploy_status: HEALTHY +action_taken: NONE +work_item: ORCH-066 +window_s: 900 +checks_total: 30 +checks_failed: 0 +--- + +# Post-deploy log — ORCH-021 post-deploy monitor + +Наблюдение прода завершено: `post_deploy_status: HEALTHY`, `action_taken: NONE`. + +Окно наблюдения: 900s; опросов всего: 30, из них с провалом: 0. diff --git a/docs/work-items/ORCH-068/00-business-request.md b/docs/work-items/ORCH-068/00-business-request.md new file mode 100644 index 0000000..4a6434e --- /dev/null +++ b/docs/work-items/ORCH-068/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: BUG: reconciler livelock — спам unblock done-задачи (ET-002) + +Work Item ID: ORCH-068 + +## Description + +TBD diff --git a/docs/work-items/ORCH-068/01-brd.md b/docs/work-items/ORCH-068/01-brd.md new file mode 100644 index 0000000..f56dfb4 --- /dev/null +++ b/docs/work-items/ORCH-068/01-brd.md @@ -0,0 +1,52 @@ +# 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). diff --git a/docs/work-items/ORCH-068/02-trz.md b/docs/work-items/ORCH-068/02-trz.md new file mode 100644 index 0000000..63d12e8 --- /dev/null +++ b/docs/work-items/ORCH-068/02-trz.md @@ -0,0 +1,68 @@ +# ТЗ — 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_*). diff --git a/docs/work-items/ORCH-068/03-acceptance-criteria.md b/docs/work-items/ORCH-068/03-acceptance-criteria.md new file mode 100644 index 0000000..c3259b0 --- /dev/null +++ b/docs/work-items/ORCH-068/03-acceptance-criteria.md @@ -0,0 +1,84 @@ +# Критерии приёмки — 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: любой красный тест или отсутствие регресс-теста на основной баг. diff --git a/docs/work-items/ORCH-068/04-test-plan.yaml b/docs/work-items/ORCH-068/04-test-plan.yaml new file mode 100644 index 0000000..a76efc1 --- /dev/null +++ b/docs/work-items/ORCH-068/04-test-plan.yaml @@ -0,0 +1,122 @@ +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 diff --git a/docs/work-items/ORCH-068/06-adr/ADR-001-reconciler-terminal-exclusion-and-cache-ttl.md b/docs/work-items/ORCH-068/06-adr/ADR-001-reconciler-terminal-exclusion-and-cache-ttl.md new file mode 100644 index 0000000..6d51de1 --- /dev/null +++ b/docs/work-items/ORCH-068/06-adr/ADR-001-reconciler-terminal-exclusion-and-cache-ttl.md @@ -0,0 +1,162 @@ +# 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`). diff --git a/docs/work-items/ORCH-068/10-tech-risks.md b/docs/work-items/ORCH-068/10-tech-risks.md new file mode 100644 index 0000000..7a3b68e --- /dev/null +++ b/docs/work-items/ORCH-068/10-tech-risks.md @@ -0,0 +1,17 @@ +# Технические риски — 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 нет. diff --git a/docs/work-items/ORCH-068/12-review.md b/docs/work-items/ORCH-068/12-review.md new file mode 100644 index 0000000..314ba44 --- /dev/null +++ b/docs/work-items/ORCH-068/12-review.md @@ -0,0 +1,47 @@ +--- +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 выполнено. diff --git a/docs/work-items/ORCH-068/13-test-report.md b/docs/work-items/ORCH-068/13-test-report.md new file mode 100644 index 0000000..a576693 --- /dev/null +++ b/docs/work-items/ORCH-068/13-test-report.md @@ -0,0 +1,64 @@ +--- +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 diff --git a/docs/work-items/ORCH-068/14-deploy-log.md b/docs/work-items/ORCH-068/14-deploy-log.md new file mode 100644 index 0000000..6b188bd --- /dev/null +++ b/docs/work-items/ORCH-068/14-deploy-log.md @@ -0,0 +1,12 @@ +--- +deploy_status: SUCCESS +work_item: ORCH-068 +hook_exit_code: 0 +deployed_by: deploy-finalizer +--- + +# Deploy log — ORCH-036 executable self-deploy + +Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`. + +Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM. diff --git a/docs/work-items/ORCH-068/16-post-deploy-log.md b/docs/work-items/ORCH-068/16-post-deploy-log.md new file mode 100644 index 0000000..cec28da --- /dev/null +++ b/docs/work-items/ORCH-068/16-post-deploy-log.md @@ -0,0 +1,14 @@ +--- +post_deploy_status: HEALTHY +action_taken: NONE +work_item: ORCH-068 +window_s: 900 +checks_total: 30 +checks_failed: 0 +--- + +# Post-deploy log — ORCH-021 post-deploy monitor + +Наблюдение прода завершено: `post_deploy_status: HEALTHY`, `action_taken: NONE`. + +Окно наблюдения: 900s; опросов всего: 30, из них с провалом: 0. diff --git a/requirements.txt b/requirements.txt index 55490e7..9aed60e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,8 @@ pydantic-settings==2.5.0 httpx==0.27.0 pytest==8.3.3 pytest-asyncio==0.23.8 +# ORCH-022: dependency audit (OSV/PyPI advisory) for the security-gate. Needs the +# network at scan time -> an unreachable feed degrades fail-open + warning by +# default (ADR-001 Р-3 / 07-infra I-2). gitleaks (secret-scan) is a pinned Go +# binary baked into the Dockerfile, NOT a pip package. +pip-audit==2.7.3 diff --git a/src/config.py b/src/config.py index a35aafa..39f3d31 100644 --- a/src/config.py +++ b/src/config.py @@ -219,6 +219,36 @@ class Settings(BaseSettings): image_freshness_enabled: bool = True image_freshness_repos: str = "" + # ORCH-022: security-gate (secret-scanning + dependency audit) on the + # deploy-staging -> deploy edge, run FIRST among the edge sub-gates (cheap to + # fail before the expensive rebase/rebuild). Deterministic (no LLM): gitleaks + # (offline secret-scan) + pip-audit (OSV/PyPI dependency audit), verdict in the + # versioned 17-security-report.md frontmatter; FAIL -> rollback to development + + # developer-retry (cap MAX_DEVELOPER_RETRIES). See ADR-001-security-gate.md. + # security_gate_enabled -> SINGLE kill-switch; False -> pipeline 1:1 as + # before ORCH-022 for everyone. Env + # ORCH_SECURITY_GATE_ENABLED. + # security_gate_repos -> CSV of repos where the gate is REAL; empty -> + # only the self-hosting repo (orchestrator). + # Mirrors merge_gate_repos / image_freshness_repos. + # security_dep_block_severity -> CVE severity threshold that BLOCKS (CRITICAL > + # HIGH > MEDIUM > LOW); below it / UNKNOWN -> a + # warning only (anti-loop ADR-001 Р-4). + # security_scan_timeout_s -> per external scanner call timeout (mirrors + # merge_retest_timeout_s). + # security_dep_audit_fail_closed -> strict mode: an unreachable CVE feed -> FAIL + # instead of the default fail-open + warning + # (Р-3). Default False (anti-loop ORCH-061). + # security_secrets_block -> a found secret blocks (always True by default; + # the offline secrets guarantee is unconditional, + # BR-2). + security_gate_enabled: bool = True + security_gate_repos: str = "" + security_dep_block_severity: str = "HIGH" + security_scan_timeout_s: int = 300 + security_dep_audit_fail_closed: bool = False + security_secrets_block: bool = True + # ORCH-061: tolerate KNOWN sandbox-infra FAILs (C9a/C9b) in the staging suite. # The self-hosting deploy-staging stage looped because scripts/staging_check.py # exited non-zero on ANY failed check, so two infra-only failures (sandbox bot @@ -265,6 +295,18 @@ 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 diff --git a/src/plane_sync.py b/src/plane_sync.py index f6ed56f..399a9c7 100644 --- a/src/plane_sync.py +++ b/src/plane_sync.py @@ -1,6 +1,7 @@ """Plane API sync — update issue state and add comments.""" import logging +import time import httpx from .config import settings @@ -107,6 +108,19 @@ _DEFAULT_STATES = { # Feature 2 (verdict statuses) — Approved / Rejected. "approved": "a519a341-dada-4a91-8910-7604f82b79c5", "rejected": "ba958f3c-5db5-461d-8f82-89425e413b97", + # ORCH-066 (meaningful Plane status model, layer B): six new logical keys. + # Their _DEFAULT_STATES values alias the enduro-trails UUID of their BASE key + # (see _STATE_ALIAS_FALLBACK) so a project without these statuses created + # (enduro / Plane down / partial config) degrades to the current behaviour + # instead of producing an invalid PATCH state. The project-relative + # alias-fallback in get_project_states() overrides these with the *project's + # own* base UUID on the success path; these defaults are the last resort. + "to_analyse": "b873d9eb-993c-48cd-97ac-99a9b1623967", # = in_progress + "analysis": "b873d9eb-993c-48cd-97ac-99a9b1623967", # = in_progress + "code_review": "ba0d802c-5218-41d4-ab43-978b0ea123ed", # = review + "awaiting_deploy": "38fb1f64-aa1e-48a3-92e0-0b109679046b", # = in_review + "deploying": "b873d9eb-993c-48cd-97ac-99a9b1623967", # = in_progress + "monitoring": "381a2833-3c4e-4be5-bd0f-be84cb946ad8", # = done } # Backward-compat alias — do NOT remove (tests + webhooks/plane.py import it). @@ -128,20 +142,73 @@ _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", + # ORCH-066: meaningful per-stage / human-input statuses (layer B). + "To Analyse": "to_analyse", + "Analysis": "analysis", + "Code-Review": "code_review", + "Awaiting Deploy": "awaiting_deploy", + "Deploying": "deploying", + "Monitoring after Deploy": "monitoring", } -# Per-project state cache: {project_id: {logical_key: state_uuid}} -_STATES_CACHE: dict[str, dict[str, str]] = {} +# ORCH-066 (BR-12): project-relative alias-fallback for the new logical keys. +# After resolving states by name from the Plane API, any NEW key the project did +# not define degrades to the UUID of its BASE key **from the same project** — so +# the indication falls back to the current status and the PATCH stays valid even +# for a partially-configured project. Enduro (none of the new statuses created) +# collapses every new key onto its base, i.e. strictly the pre-ORCH-066 +# behaviour. Strengthened ORCH-059 AC-7 pattern. +_STATE_ALIAS_FALLBACK: dict[str, str] = { + "to_analyse": "in_progress", + "analysis": "in_progress", + "code_review": "review", + "awaiting_deploy": "in_review", + "deploying": "in_progress", + "monitoring": "done", +} + +# Per-project state cache (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 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//states/. - Results are cached per project_id for the lifetime of the process. + 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. + Falls back to _DEFAULT_STATES (enduro-trails values) if: * project_id is empty/None, - * the API call fails (network error, non-2xx), + * the API call fails (network error, non-2xx) AND nothing is cached, * the response contains no recognisable states. The enduro-trails project therefore returns the same UUIDs as before @@ -151,8 +218,9 @@ def get_project_states(project_id: str) -> dict[str, str]: if not project_id: return _DEFAULT_STATES - if project_id in _STATES_CACHE: - return _STATES_CACHE[project_id] + cached = _STATES_CACHE.get(project_id) + if cached is not None and _cache_record_fresh(cached): + return cached["states"] url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/states/" try: @@ -165,28 +233,60 @@ 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") + # ORCH-066 (BR-12): project-relative alias-fallback. For each NEW key the + # project did not define, reuse the UUID of its BASE key FROM THIS SAME + # PROJECT (never a foreign/enduro UUID — that would yield an invalid PATCH + # state on a partially-configured orchestrator project). Runs BEFORE the + # _DEFAULT_STATES.setdefault below so a project's own base UUID wins over + # the static enduro default. + for new_key, base_key in _STATE_ALIAS_FALLBACK.items(): + if new_key not in resolved and resolved.get(base_key): + resolved[new_key] = resolved[base_key] + # Fill any missing keys from _DEFAULT_STATES so callers always get a # complete mapping (defensive against partial Plane configs). for k, v in _DEFAULT_STATES.items(): resolved.setdefault(k, v) - _STATES_CACHE[project_id] = resolved + _STATES_CACHE[project_id] = { + "states": resolved, + "groups": groups, + "ts": time.monotonic(), + } logger.debug( - f"get_project_states: cached {len(resolved)} states for project {project_id[:8]}..." + f"get_project_states: cached {len(resolved)} states / " + f"{len(groups)} groups 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}" @@ -194,6 +294,23 @@ 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. @@ -210,14 +327,16 @@ def reload_project_states(project_id: str = None) -> None: # Feature 3: map an orchestrator stage -> the Plane status to show on the board -# when the pipeline ENTERS that stage. analysis stays driven by the existing -# in_progress/in_review/needs_input logic (no dedicated status). deploy keeps -# in_progress until done. Needs Input / In Review / Blocked remain higher -# priority and are set explicitly elsewhere — do NOT override them from here. +# when the pipeline ENTERS that stage. ORCH-066: analysis -> Analysis and +# review -> Code-Review now have dedicated statuses. deploy keeps in_progress +# until its own Phase A/B/C statuses drive it. Needs Input / In Review / Blocked +# remain higher priority and are set explicitly elsewhere — do NOT override them +# from here. STAGE_VISIBILITY_STATE = { + "analysis": "analysis", # ORCH-066: analysis stage -> Analysis status "architecture": "architecture", "development": "development", - "review": "review", + "review": "code_review", # ORCH-066: review stage -> Code-Review status "testing": "testing", } @@ -225,22 +344,27 @@ STAGE_VISIBILITY_STATE = { # update_issue_state now calls stage_to_state() instead of looking up here. STAGE_TO_STATE = { "created": _DEFAULT_STATES["todo"], - "analysis": _DEFAULT_STATES["in_progress"], + # ORCH-066: analysis -> Analysis, review -> Code-Review. The new keys alias + # the same in_progress / review UUIDs in _DEFAULT_STATES, so legacy callers / + # tests that compare against concrete UUIDs see byte-identical values. + "analysis": _DEFAULT_STATES["analysis"], "architecture": _DEFAULT_STATES["architecture"], "development": _DEFAULT_STATES["development"], - "review": _DEFAULT_STATES["review"], + "review": _DEFAULT_STATES["code_review"], "testing": _DEFAULT_STATES["testing"], "deploy": _DEFAULT_STATES["in_progress"], "done": _DEFAULT_STATES["done"], } # Map orchestrator stage -> logical state key (project-independent). +# ORCH-066: analysis -> analysis, review -> code_review (was in_progress/review). +# deploy stays in_progress (Phase A/B/C drive it directly, not update_issue_state). _STAGE_TO_STATE_KEY = { "created": "todo", - "analysis": "in_progress", + "analysis": "analysis", "architecture": "architecture", "development": "development", - "review": "review", + "review": "code_review", "testing": "testing", "deploy": "in_progress", "done": "done", @@ -575,6 +699,58 @@ def set_issue_in_progress(work_item_id: str, project_id: str = None): _set_issue_state_direct(work_item_id, state_id, project_id) +def set_issue_analysis(work_item_id: str, project_id: str = None): + """ORCH-066: set issue to 'Analysis' — analyst is working (start / resume). + + Degrades to the project's In Progress UUID when the 'Analysis' status is not + created (alias-fallback). never-raise (via _set_issue_state_direct). + """ + project_id = _resolve_project_id(work_item_id, project_id) + state_id = get_project_states(project_id)["analysis"] + _set_issue_state_direct(work_item_id, state_id, project_id) + + +def set_issue_code_review(work_item_id: str, project_id: str = None): + """ORCH-066: set issue to 'Code-Review' — review stage indication. + + Degrades to the project's Review UUID when 'Code-Review' is not created. + """ + project_id = _resolve_project_id(work_item_id, project_id) + state_id = get_project_states(project_id)["code_review"] + _set_issue_state_direct(work_item_id, state_id, project_id) + + +def set_issue_awaiting_deploy(work_item_id: str, project_id: str = None): + """ORCH-066: set issue to 'Awaiting Deploy' — self-deploy Phase A approval-pending. + + Degrades to the project's In Review UUID when 'Awaiting Deploy' is not created. + """ + project_id = _resolve_project_id(work_item_id, project_id) + state_id = get_project_states(project_id)["awaiting_deploy"] + _set_issue_state_direct(work_item_id, state_id, project_id) + + +def set_issue_deploying(work_item_id: str, project_id: str = None): + """ORCH-066: set issue to 'Deploying' — self-deploy Phase B prod deploy in flight. + + Degrades to the project's In Progress UUID when 'Deploying' is not created. + """ + project_id = _resolve_project_id(work_item_id, project_id) + state_id = get_project_states(project_id)["deploying"] + _set_issue_state_direct(work_item_id, state_id, project_id) + + +def set_issue_monitoring(work_item_id: str, project_id: str = None): + """ORCH-066: set issue to 'Monitoring after Deploy' — post-deploy window open. + + Degrades to the project's Done UUID when 'Monitoring after Deploy' is not + created (so the board shows Done, exactly as before ORCH-066). + """ + project_id = _resolve_project_id(work_item_id, project_id) + state_id = get_project_states(project_id)["monitoring"] + _set_issue_state_direct(work_item_id, state_id, project_id) + + def set_issue_stage_state(work_item_id: str, stage: str, project_id: str = None): """Feature 3: move the issue to the board status for a pipeline stage. diff --git a/src/qg/checks.py b/src/qg/checks.py index ead2b95..2c95d84 100644 --- a/src/qg/checks.py +++ b/src/qg/checks.py @@ -716,6 +716,23 @@ def _check_staging_image_fresh(repo: str, work_item_id: str, branch: str) -> tup return check_staging_image_fresh(repo, work_item_id, branch) +def check_security_gate(repo: str, work_item_id: str, branch: str) -> tuple[bool, str]: + """ORCH-022 security sub-gate (secret-scan + dependency audit) on the + deploy-staging -> deploy edge, run FIRST (before merge-gate / image-freshness). + + Thin registry wrapper that delegates to ``security_gate.check_security_gate`` + (gitleaks offline + pip-audit, write/read-back ``17-security-report.md``). The + real logic lives in ``src/security_gate.py`` (leaf module, never-raise, + fail-closed on secrets, fail-open degrade for the dep-audit feed); importing it + lazily here avoids an import cycle (security_gate imports is_self_hosting_repo + from this module). For non-self repos with an empty scope it returns + ``(True, "security-gate N/A for ")`` so the deploy edge is unchanged for + them (AC-13/TC-13). + """ + from ..security_gate import check_security_gate as _impl + return _impl(repo, work_item_id, branch) + + # Registry for dynamic lookup by name QG_CHECKS = { "check_analysis_approved": check_analysis_approved, @@ -730,4 +747,5 @@ QG_CHECKS = { "check_staging_status": check_staging_status, "check_branch_mergeable": check_branch_mergeable, "check_staging_image_fresh": _check_staging_image_fresh, + "check_security_gate": check_security_gate, } diff --git a/src/reconciler.py b/src/reconciler.py index 6d65baa..d25b4a3 100644 --- a/src/reconciler.py +++ b/src/reconciler.py @@ -60,7 +60,12 @@ from .stage_engine import ( MAX_DEVELOPER_RETRIES, ) from .stages import get_qg_for_stage -from .plane_sync import fetch_issue_state, get_project_states, list_issues_by_state +from .plane_sync import ( + fetch_issue_state, + get_project_states, + get_project_state_groups, + list_issues_by_state, +) from .webhooks.plane import handle_status_start, handle_verdict from .notifications import send_telegram from . import projects @@ -139,6 +144,13 @@ 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: @@ -193,12 +205,22 @@ class Reconciler: self._note_unblock(task.get("work_item_id") or str(task_id), stage) def _is_blocked_or_needs_input(self, task: dict) -> bool: - """ORCH-060 Guard 2: is this issue in an explicit human Plane gate? + """Guard 2 (ORCH-060 + ORCH-066): is this issue waiting for a human OR in + an active orchestrator wait that F-1 must not "revive"? Variant A (no schema migration): resolve the task's Plane project, fetch - the issue's current state uuid and compare against the project's - ``blocked`` / ``needs_input`` states. ``tasks`` has no status column, so - the live Plane state is the source of truth. + the issue's current state uuid and compare against a skip-set. ``tasks`` + has no status column, so the live Plane state is the source of truth. + + Skip-set = explicit human gates (``blocked`` / ``needs_input``) PLUS the + ORCH-066 active waits (``awaiting_deploy`` / ``deploying`` / ``monitoring``, + BR-13). **Anti-regress (CRITICAL):** the active-wait keys alias onto + ``in_review`` / ``in_progress`` / ``done`` on a project that did not create + them. Adding them verbatim would make F-1 wrongly skip enduro + In Progress / Done tasks (regression of ORCH-053/060). So they are + included ONLY when DISTINCT from the project's base working statuses + (i.e. actually created as separate statuses): enduro collapses them to {} + -> zero regress; orchestrator keeps three real statuses -> BR-13. **Never-raise, conservative fallback.** Any error / unresolved project / missing state -> return ``True`` (treat as "possibly blocked" -> skip): @@ -219,7 +241,22 @@ class Reconciler: cur = fetch_issue_state(issue_id, pid) if cur is None: return True # Plane unreachable / no state -> conservative skip - return cur in {states.get("blocked"), states.get("needs_input")} + # ORCH-066 BR-13: active orchestrator waits, minus base working + # statuses so aliased (enduro) keys never widen the skip-set. + base_working = { + states.get(k) for k in ( + "backlog", "todo", "in_progress", "in_review", "review", + "architecture", "development", "testing", + "approved", "rejected", "done", + ) + } + extra_waits = { + states.get("awaiting_deploy"), + states.get("deploying"), + states.get("monitoring"), + } - base_working - {None} + skip_set = {states.get("blocked"), states.get("needs_input")} | extra_waits + return cur in skip_set except Exception as e: # noqa: BLE001 - never break the tick logger.warning( f"reconciler Guard 2: blocked-check failed for task " @@ -241,24 +278,52 @@ class Reconciler: def _reconcile_plane_project(self, proj) -> None: pid = proj.plane_project_id # Resolve the actionable state uuids per-project (never hardcode). + # ORCH-066 (AC-19): the start/resume trigger is `To Analyse` (was + # In Progress). On a project without that status, `to_analyse` aliases to + # the project's own `in_progress` UUID, so enduro behaviour is identical + # (and `list_issues_by_state` deduplicates the uuid via its internal set). states = get_project_states(pid) - in_progress = states["in_progress"] + # ORCH-066 (AC-19): start/resume trigger is `To Analyse`. + to_analyse = states["to_analyse"] + # 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) approved = states["approved"] rejected = states["rejected"] - issues = list_issues_by_state(pid, [in_progress, approved, rejected]) + issues = list_issues_by_state(pid, [to_analyse, approved, rejected]) for issue in issues: try: self._reconcile_plane_issue( - issue, pid, in_progress, approved, rejected + issue, pid, to_analyse, approved, rejected, states, groups ) 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, + to_analyse: str, approved: str, rejected: str, + states: dict, groups: dict, ) -> None: issue_id = str(issue.get("id") or "") if not issue_id: @@ -266,6 +331,15 @@ 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). @@ -288,20 +362,50 @@ class Reconciler: "description_stripped": issue.get("description_stripped", ""), } - if new_state == in_progress and task is None: - # In Progress without a task -> start the pipeline (lost start webhook). + if new_state == to_analyse and task is None: + # To Analyse without a task -> start the pipeline (lost start webhook). + # 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) - self._note_unblock(issue_id, "analysis") + if get_task_by_plane_id(issue_id) is not None: + self._note_unblock(issue_id, "analysis", new_state) + elif new_state == to_analyse and task is not None: + # To Analyse with an existing (idle) task -> resume the analyst from + # Needs Input (lost resume webhook). handle_status_start applies its + # own busy-guard / start-vs-resume fork. + self._dispatch(handle_status_start, issue_data, project_id) + self._note_unblock(task.get("work_item_id") or issue_id, task["stage"], new_state) + 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) - self._note_unblock(task.get("work_item_id") or issue_id, task["stage"]) + if self._stage_changed(issue_id, stage_before): + self._note_unblock( + task.get("work_item_id") or issue_id, stage_before, new_state + ) 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) - self._note_unblock(task.get("work_item_id") or issue_id, task["stage"]) + if self._stage_changed(issue_id, stage_before): + self._note_unblock( + task.get("work_item_id") or issue_id, stage_before, new_state + ) # 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. @@ -314,12 +418,27 @@ class Reconciler: asyncio.run(coro_fn(*args, **kwargs)) # -- observability (F-4) ---------------------------------------------- - def _note_unblock(self, work_item_id: str, stage: str) -> None: + def _note_unblock( + self, work_item_id: str, stage: str, state_uuid: str | None = None + ) -> 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( @@ -380,6 +499,9 @@ 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, } diff --git a/src/security_gate.py b/src/security_gate.py new file mode 100644 index 0000000..05a33dc --- /dev/null +++ b/src/security_gate.py @@ -0,0 +1,689 @@ +"""Security-gate core (ORCH-022): secret-scanning + dependency audit before merge. + +Background +---------- +The orchestrator is autonomous: the ``developer`` agent writes code with no human +filter. Before a task branch merges into ``main`` there was no automatic check for a +leaked secret (key / token / password / private key) or a vulnerable dependency +(known CVE). For the self-hosting ``orchestrator`` repo this is acute: one shared +prod instance serves every project from a shared DB, so a secret or CVE that slips +through one task lands in the prod of all projects (CLAUDE.md §self-hosting, §8). + +This module provides the deterministic (no-LLM) primitives that the quality-gate +``check_security_gate`` (src/qg/checks.py) composes on the ``deploy-staging -> +deploy`` edge, **FIRST** among the edge sub-gates (BEFORE the merge-gate and +image-freshness), immediately before the deployer merges the PR (ADR-001 Р-1): + + * ``scan_secrets`` -> run ``gitleaks`` over ``origin/main..HEAD`` (offline). + * ``audit_dependencies`` -> run ``pip-audit`` over ``requirements.txt`` (OSV/PyPI). + * ``classify_severity`` -> pure: map a CVE severity to block / warning. + * ``compute_verdict`` -> pure: combine findings + thresholds -> the artefact + frontmatter fields + a human-readable reason. + * ``write_security_report`` / ``parse_security_status`` -> write the + ``17-security-report.md`` artefact and read its machine verdict back (single + source of truth: the gate returns exactly the frontmatter it wrote, AC-8). + * ``check_security_gate`` -> the orchestrating entry the QG wrapper delegates to. + +Invariants (ADR-001 §7, never broken): + * **Secrets are unconditional** (BR-2): gitleaks is fully offline, so the "a + secret always blocks" guarantee does not depend on the network. A secret-scan + TOOL error is **fail-closed** (we cannot prove "no secret" -> FAIL). + * **Dependency audit is best-effort** (Р-3): an unreachable CVE feed degrades + **fail-open + a loud warning** by default (anti-loop, precedent ORCH-061); + ``security_dep_audit_fail_closed`` flips it to strict. + * **never-raise**: any internal error -> ``(False, "")``; an exception + never escapes into ``advance_stage`` (AC-16). + * **Self-hosting safety** (AC-19): the gate only reads / scans / writes the + artefact. It never calls the deploy hook and never restarts the prod container. + +This module is a **leaf**: it imports only ``config`` / ``git_worktree`` and lazily +``qg.checks.is_self_hosting_repo`` / ``notifications``; it never imports +``stage_engine``. +""" + +import json +import logging +import os +import subprocess +from dataclasses import dataclass, field + +from .config import settings +from .git_worktree import ensure_worktree, get_worktree_path + +logger = logging.getLogger("orchestrator.security_gate") + +# Bounded git timeout so a hung fetch never wedges the monitor-thread running the +# gate (the scan timeout itself comes from settings.security_scan_timeout_s). +_GIT_TIMEOUT = 60 + +# Severity ranking for the dependency block threshold. UNKNOWN / unrecognised is +# intentionally absent -> classified as "warning" (anti-loop, ADR-001 Р-4). +_SEVERITY_ORDER = {"LOW": 1, "MEDIUM": 2, "HIGH": 3, "CRITICAL": 4} + + +# --------------------------------------------------------------------------- +# Result containers (plain dataclasses, easy to build in tests) +# --------------------------------------------------------------------------- +@dataclass +class SecretScanResult: + """Outcome of :func:`scan_secrets`. + + status: + * ``"clean"`` -> no secret found. + * ``"found"`` -> ``findings`` lists the confirmed (non-allowlisted) secrets. + * ``"error"`` -> the scanner could not run (missing binary / timeout / rc>=2); + treated as **fail-closed** by :func:`compute_verdict` (BR-2). + """ + + status: str = "clean" + findings: list = field(default_factory=list) + detail: str = "" + + +@dataclass +class DepAuditResult: + """Outcome of :func:`audit_dependencies`. + + status: + * ``"ok"`` -> the audit ran; ``findings`` may be empty or non-empty. + * ``"degraded"`` -> the CVE feed was unreachable / the tool failed; **fail-open** + by default (ADR-001 Р-3), surfaced as ``deps_audit_degraded: true``. + """ + + status: str = "ok" + findings: list = field(default_factory=list) + detail: str = "" + + +# --------------------------------------------------------------------------- +# Conditionality (mirrors _merge_gate_applies / image_freshness_applies) +# --------------------------------------------------------------------------- +def security_gate_applies(repo: str) -> bool: + """Whether the security-gate is REAL for this repo (conditional rollout). + + Mirrors the ORCH-35 / ORCH-43 / ORCH-58 pattern: + * ``security_gate_enabled=False`` -> always False (kill-switch; pipeline is + 1:1 as before ORCH-022 for everyone). + * ``security_gate_repos`` (CSV) non-empty -> real only for the listed repos. + * empty CSV -> real ONLY for the self-hosting repo (``orchestrator``). + Never raises (AC-16): any error -> False (the safe no-op default). + """ + try: + if not settings.security_gate_enabled: + return False + raw = (settings.security_gate_repos or "").strip() + if raw: + allowed = {r.strip().lower() for r in raw.split(",") if r.strip()} + return (repo or "").strip().lower() in allowed + # Lazy import keeps this module a leaf (no qg import at module load). + from .qg.checks import is_self_hosting_repo + return is_self_hosting_repo(repo) + except Exception as e: # noqa: BLE001 - never-raise contract + logger.warning("security_gate_applies error for %s: %s", repo, e) + return False + + +# --------------------------------------------------------------------------- +# Secret-scanning (gitleaks, offline) — FR-1 / AC-1..AC-3 +# --------------------------------------------------------------------------- +def _gitleaks_config_path(worktree: str) -> str | None: + """Versioned ``.gitleaks.toml`` at the repo root (BR-13), or None if absent.""" + cfg = os.path.join(worktree, ".gitleaks.toml") + return cfg if os.path.isfile(cfg) else None + + +def _mask(secret: str) -> str: + """Mask a matched secret so the artefact never re-leaks it verbatim.""" + s = (secret or "").strip() + if len(s) <= 8: + return "****" + return f"{s[:4]}…{s[-2:]}" + + +def parse_gitleaks_report(text: str) -> list: + """Pure parser for the gitleaks JSON report -> a list of finding dicts. + + Each finding: ``{"file", "rule", "line", "match"}`` (the match is MASKED). + Tolerates an empty / non-JSON / non-list body (returns ``[]``); never raises. + """ + try: + data = json.loads(text or "[]") + except (ValueError, TypeError): + return [] + if not isinstance(data, list): + return [] + out = [] + for item in data: + if not isinstance(item, dict): + continue + out.append( + { + "file": item.get("File") or item.get("file") or "?", + "rule": item.get("RuleID") or item.get("Description") or "secret", + "line": item.get("StartLine") or item.get("startLine") or 0, + "match": _mask(item.get("Secret") or item.get("Match") or ""), + } + ) + return out + + +def scan_secrets(repo: str, branch: str) -> SecretScanResult: + """Scan ``origin/main..HEAD`` of the task branch for secrets with ``gitleaks``. + + Offline (BR-2): gitleaks rules are local, so the "a secret always blocks" + guarantee never depends on the network. Scanning the ``origin/main..HEAD`` + range covers exactly the commits this task adds (and that will land in + ``main``), and — because it runs BEFORE the merge-gate rebase — does not blame + the task for a secret introduced by a parallel update of ``main`` (ADR-001 Р-1). + + Exit-code contract (07-infra-requirements.md I-1): 0 = clean, 1 = secrets + found, >=2 = tool error. A tool error / missing binary / timeout -> ``"error"`` + (fail-closed downstream). Never raises (AC-16). + """ + try: + wt = ensure_worktree(repo, branch) + except Exception as e: # noqa: BLE001 - never-raise contract + return SecretScanResult(status="error", detail=f"worktree error: {e}") + + # Refresh origin/main so the origin/main..HEAD range is meaningful. Best-effort: + # a fetch failure does not abort the scan (gitleaks still scans whatever range + # it can resolve); the scan itself is the security-critical step. + try: + subprocess.run( + ["git", "-C", wt, "fetch", "origin", "main"], + capture_output=True, timeout=_GIT_TIMEOUT, + ) + except (subprocess.SubprocessError, OSError) as e: + logger.warning("scan_secrets: fetch origin/main failed for %s/%s: %s", repo, branch, e) + + report_path = os.path.join(wt, ".gitleaks-report.json") + cmd = [ + "gitleaks", "detect", + "--source", wt, + "--log-opts", "origin/main..HEAD", + "--report-format", "json", + "--report-path", report_path, + "--exit-code", "1", + "--no-banner", + ] + cfg = _gitleaks_config_path(wt) + if cfg: + cmd += ["--config", cfg] + + timeout = settings.security_scan_timeout_s + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + except subprocess.TimeoutExpired: + return SecretScanResult(status="error", detail=f"gitleaks timeout after {timeout}s") + except FileNotFoundError: + # Missing binary -> fail-closed (we cannot prove the branch is secret-free). + return SecretScanResult(status="error", detail="gitleaks binary not found") + except (subprocess.SubprocessError, OSError) as e: + return SecretScanResult(status="error", detail=f"gitleaks error: {e}") + finally: + # The report file is transient scratch inside the worktree; remove it after + # reading so it is never committed/scanned on a later pass. + report_text = "" + try: + if os.path.isfile(report_path): + with open(report_path, "r", encoding="utf-8") as f: + report_text = f.read() + os.remove(report_path) + except OSError: + report_text = "" + + if r.returncode == 0: + return SecretScanResult(status="clean", detail="no secrets found") + if r.returncode == 1: + findings = parse_gitleaks_report(report_text) or parse_gitleaks_report(r.stdout) + if not findings: + # rc=1 with no parseable findings -> still treat as found (fail-closed). + findings = [{"file": "?", "rule": "secret", "line": 0, "match": "****"}] + return SecretScanResult( + status="found", findings=findings, detail=f"{len(findings)} secret(s) found" + ) + # rc >= 2 (or any other) -> tool error -> fail-closed. + tail = ((r.stderr or "") + (r.stdout or "")).strip()[-200:] + return SecretScanResult(status="error", detail=f"gitleaks rc={r.returncode}: {tail}") + + +# --------------------------------------------------------------------------- +# Dependency audit (pip-audit, OSV/PyPI) — FR-2 / AC-4..AC-7 +# --------------------------------------------------------------------------- +def parse_pip_audit_report(text: str) -> list: + """Pure parser for the ``pip-audit -f json`` report -> a list of finding dicts. + + Each finding: ``{"package", "version", "id", "severity", "fix"}``. pip-audit's + default JSON rarely carries a CVSS severity (OSV advisories often omit it), so a + missing severity is reported as ``"UNKNOWN"`` (classified as a warning, never an + auto-block — ADR-001 Р-4 anti-loop). Tolerates both the modern + ``{"dependencies": [...]}`` shape and a bare list; never raises. + """ + try: + data = json.loads(text or "{}") + except (ValueError, TypeError): + return [] + if isinstance(data, dict): + deps = data.get("dependencies", data.get("vulnerabilities", [])) + elif isinstance(data, list): + deps = data + else: + return [] + out = [] + for dep in deps or []: + if not isinstance(dep, dict): + continue + name = dep.get("name") or dep.get("package") or "?" + version = dep.get("version") or "?" + for v in dep.get("vulns", dep.get("vulnerabilities", [])) or []: + if not isinstance(v, dict): + continue + sev = _extract_severity(v) + fix = v.get("fix_versions") or v.get("fixed_in") or [] + aliases = v.get("aliases") or [] + vuln_id = v.get("id") or (aliases[0] if aliases else "?") + out.append( + { + "package": name, + "version": version, + "id": vuln_id, + "severity": sev, + "fix": ", ".join(fix) if isinstance(fix, list) else str(fix), + } + ) + return out + + +def _extract_severity(vuln: dict) -> str: + """Best-effort severity extraction from a pip-audit vuln record -> UPPER token. + + pip-audit JSON may carry severity in different shapes depending on the advisory + source; when none is present we return ``"UNKNOWN"`` (warning, never a block). + """ + raw = vuln.get("severity") + if isinstance(raw, str) and raw.strip(): + return raw.strip().upper() + if isinstance(raw, list) and raw: + first = raw[0] + if isinstance(first, dict): + val = first.get("severity") or first.get("score") or first.get("type") + if val: + return str(val).strip().upper() + elif first: + return str(first).strip().upper() + return "UNKNOWN" + + +def audit_dependencies(repo: str, branch: str) -> DepAuditResult: + """Audit the branch's ``requirements.txt`` for known CVEs with ``pip-audit``. + + The advisory source is OSV/PyPI -> it needs the network. Per ADR-001 Р-3 an + unreachable feed / tool failure degrades **fail-open** by default (status + ``"degraded"``), so a transient network problem on the prod instance never + produces a false rollback loop (precedent ORCH-061). The ``"degraded"`` state + is surfaced loudly (``deps_audit_degraded: true`` + warning log + Telegram). + + Returns a :class:`DepAuditResult`. Never raises (AC-16). + """ + try: + wt = get_worktree_path(repo, branch) + if not os.path.isdir(wt): + wt = ensure_worktree(repo, branch) + except Exception as e: # noqa: BLE001 - never-raise contract + return DepAuditResult(status="degraded", detail=f"worktree error: {e}") + + req = os.path.join(wt, "requirements.txt") + if not os.path.isfile(req): + # Python-only v1 (A3): no manifest -> nothing to audit (not a degrade). + return DepAuditResult(status="ok", detail="no requirements.txt to audit") + + cmd = ["pip-audit", "-r", req, "-f", "json", "--progress-spinner", "off"] + timeout = settings.security_scan_timeout_s + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + except subprocess.TimeoutExpired: + return DepAuditResult(status="degraded", detail=f"pip-audit timeout after {timeout}s") + except FileNotFoundError: + # Missing binary -> degrade (dep-audit is best-effort, not unconditional). + return DepAuditResult(status="degraded", detail="pip-audit binary not found") + except (subprocess.SubprocessError, OSError) as e: + return DepAuditResult(status="degraded", detail=f"pip-audit error: {e}") + + # pip-audit exits 0 (no vulns) or 1 (vulns found) with valid JSON on stdout. A + # network/feed error produces non-JSON output (and often a non-zero rc) -> if + # we cannot parse the JSON we degrade fail-open rather than block falsely. + out = (r.stdout or "").strip() + if not out: + if r.returncode == 0: + return DepAuditResult(status="ok", detail="no vulnerabilities") + tail = (r.stderr or "").strip()[-200:] + return DepAuditResult(status="degraded", detail=f"pip-audit no output (rc={r.returncode}): {tail}") + try: + json.loads(out) + except ValueError: + tail = (r.stderr or "").strip()[-200:] + return DepAuditResult(status="degraded", detail=f"pip-audit feed unavailable: {tail}") + + findings = parse_pip_audit_report(out) + return DepAuditResult(status="ok", findings=findings, detail=f"{len(findings)} vuln(s)") + + +# --------------------------------------------------------------------------- +# Pure classification + verdict (FR-2/FR-3/Р-4) — the core of the unit tests +# --------------------------------------------------------------------------- +def classify_severity(severity: str, block_threshold: str) -> str: + """Pure: classify a CVE severity against the block threshold -> token. + + Returns ``"block"`` when ``severity >= block_threshold`` in CRITICAL > HIGH > + MEDIUM > LOW order, else ``"warning"``. An UNKNOWN / unrecognised severity is + ALWAYS ``"warning"`` (never an auto-block — anti-loop, ADR-001 Р-4). Never + raises. + """ + sev = (severity or "").upper().strip() + thr = (block_threshold or "HIGH").upper().strip() + sev_rank = _SEVERITY_ORDER.get(sev) + thr_rank = _SEVERITY_ORDER.get(thr, _SEVERITY_ORDER["HIGH"]) + if sev_rank is None: + return "warning" + return "block" if sev_rank >= thr_rank else "warning" + + +def compute_verdict( + secret_result: SecretScanResult, + dep_result: DepAuditResult, + *, + secrets_block: bool, + dep_block_severity: str, + dep_fail_closed: bool, +) -> dict: + """Pure: combine scan results + thresholds into the artefact's machine fields. + + Returns a dict with the frontmatter fields (``security_status``, + ``secrets_found``, ``deps_blocking``, ``deps_warning``, ``deps_audit_degraded``), + a one-line ``reason`` summary, and the categorised finding lists for the body. + + Decision (ADR-001 Р-4): + * secret-scan ERROR -> FAIL (fail-closed; BR-2 secrets guarantee is unconditional). + * any secret found AND ``secrets_block`` -> FAIL. + * any dependency at/over ``dep_block_severity`` -> FAIL (``deps_blocking``). + * MEDIUM/LOW/UNKNOWN deps -> warning only (``deps_warning``), never block. + * feed degraded -> warning by default; FAIL only when ``dep_fail_closed``. + Never raises. + """ + secret_scan_error = secret_result.status == "error" + secret_findings = list(secret_result.findings) if secret_result.status == "found" else [] + secrets_found = len(secret_findings) + + deps_audit_degraded = dep_result.status == "degraded" + blocking_findings = [] + warning_findings = [] + for f in dep_result.findings or []: + if classify_severity(f.get("severity", "UNKNOWN"), dep_block_severity) == "block": + blocking_findings.append(f) + else: + warning_findings.append(f) + + reasons = [] + fail = False + if secret_scan_error: + fail = True + reasons.append(f"secret scan error (fail-closed): {secret_result.detail}") + if secrets_block and secrets_found > 0: + fail = True + names = ", ".join( + f"{x.get('rule')} in {x.get('file')}:{x.get('line')}" for x in secret_findings + ) + reasons.append(f"{secrets_found} secret(s): {names}") + if blocking_findings: + fail = True + names = ", ".join( + f"{x.get('package')} {x.get('version')} {x.get('id')} ({x.get('severity')})" + for x in blocking_findings + ) + reasons.append(f"{len(blocking_findings)} blocking CVE(s): {names}") + if deps_audit_degraded and dep_fail_closed: + fail = True + reasons.append(f"dep-audit feed unavailable (fail-closed): {dep_result.detail}") + + status = "FAIL" if fail else "PASS" + if reasons: + reason = "; ".join(reasons) + else: + extra = " (dep-audit degraded — warning only)" if deps_audit_degraded else "" + reason = f"clean: {secrets_found} secrets, {len(blocking_findings)} blocking CVE(s){extra}" + + return { + "security_status": status, + "secrets_found": secrets_found, + "secret_scan_error": secret_scan_error, + "deps_blocking": len(blocking_findings), + "deps_warning": len(warning_findings), + "deps_audit_degraded": deps_audit_degraded, + "reason": reason, + "secret_findings": secret_findings, + "blocking_findings": blocking_findings, + "warning_findings": warning_findings, + } + + +# --------------------------------------------------------------------------- +# Artefact: write the report, read the machine verdict back (FR-3 / AC-8..AC-10) +# --------------------------------------------------------------------------- +def _report_rel(work_item_id: str) -> str: + return f"docs/work-items/{work_item_id}/17-security-report.md" + + +def _report_path(repo: str, work_item_id: str, branch: str) -> str: + """Absolute path of 17-security-report.md inside the task worktree.""" + try: + wt = get_worktree_path(repo, branch) + if not os.path.isdir(wt): + wt = ensure_worktree(repo, branch) + except Exception: # noqa: BLE001 - never-raise; fall back to shared clone + wt = os.path.join(settings.repos_dir, repo) + return os.path.join(wt, _report_rel(work_item_id)) + + +def _bool_yaml(v: bool) -> str: + return "true" if v else "false" + + +def render_security_report(work_item_id: str, fields: dict) -> str: + """Pure: render the 17-security-report.md content (frontmatter + body) from the + fields produced by :func:`compute_verdict`. Never raises.""" + def _secret_lines(): + items = fields.get("secret_findings") or [] + if not items: + return "- None" + return "\n".join( + f"- `{x.get('file')}:{x.get('line')}` — {x.get('rule')} (match `{x.get('match')}`)" + for x in items + ) + + def _dep_lines(key): + items = fields.get(key) or [] + if not items: + return "- None" + return "\n".join( + f"- `{x.get('package')}=={x.get('version')}` — {x.get('id')} " + f"severity={x.get('severity')} fix={x.get('fix') or 'n/a'}" + for x in items + ) + + return ( + "---\n" + f"security_status: {fields.get('security_status', 'FAIL')}\n" + f"secrets_found: {int(fields.get('secrets_found', 0))}\n" + f"deps_blocking: {int(fields.get('deps_blocking', 0))}\n" + f"deps_warning: {int(fields.get('deps_warning', 0))}\n" + f"deps_audit_degraded: {_bool_yaml(bool(fields.get('deps_audit_degraded', False)))}\n" + "---\n" + f"# Security Report — {work_item_id}\n\n" + "Детерминированный security-гейт (ORCH-022): secret-scanning (gitleaks, offline) + " + "dependency audit (pip-audit). Машинный вердикт читается ТОЛЬКО из frontmatter выше.\n\n" + "## Verdict\n" + f"{fields.get('reason', '')}\n\n" + "## Secrets\n" + f"{_secret_lines()}\n\n" + "## Dependencies (blocking)\n" + f"{_dep_lines('blocking_findings')}\n\n" + "## Dependencies (warning)\n" + f"{_dep_lines('warning_findings')}\n" + ) + + +def write_security_report(repo: str, work_item_id: str, branch: str, fields: dict) -> str: + """Write 17-security-report.md into the task worktree; return its path. + + Best-effort/never-raise: a write error is logged and the path is still returned + (the caller's read-back then fails closed). The artefact body is human-readable; + the machine verdict lives ONLY in the YAML frontmatter (canon).""" + path = _report_path(repo, work_item_id, branch) + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + f.write(render_security_report(work_item_id, fields)) + except OSError as e: + logger.error("write_security_report error for %s/%s: %s", repo, work_item_id, e) + return path + + +def parse_security_status(content: str) -> tuple[bool, str]: + """Map a 17-security-report.md body to a quality-gate verdict by reading ONLY + the machine-readable ``security_status:`` YAML frontmatter — never the prose. + + Mirrors ``_parse_deploy_status`` / ``_parse_staging_status`` (canon: machine + verdict only from frontmatter, AC-8). The negative token (FAIL) is authoritative + (checked first). Returns: + * ``security_status: PASS`` -> ``(True, "Security status: PASS")`` + * ``security_status: FAIL`` -> ``(False, "Security status: FAIL")`` + * missing field / no frontmatter / bad YAML -> ``(False, )`` (fail-closed + on the verdict read, AC-9). + """ + import yaml + + status = None + if content.startswith("---"): + parts = content.split("---", 2) + if len(parts) >= 3: + try: + fm = yaml.safe_load(parts[1]) or {} + except yaml.YAMLError as e: + return False, f"Invalid YAML frontmatter in security report: {e}" + if isinstance(fm, dict): + status = str(fm.get("security_status", "")).upper().strip() + if status == "FAIL": + return False, "Security status: FAIL" + if status == "PASS": + return True, "Security status: PASS" + return False, f"No machine-readable security_status in frontmatter (got: {status!r})" + + +def extract_security_findings(report_path: str) -> str: + """ORCH-046: best-effort verbatim excerpt of the report's finding sections for + embedding into the developer's ``task_desc`` on a rollback. + + Pulls the ``## Verdict`` + ``## Secrets`` + ``## Dependencies (blocking)`` + sections so the developer sees the must-fix substance directly (not just a + link). Contract «never raise»: any error / missing file -> ``""`` (the caller + then falls back to the reason + link). Mirrors ``review_parse`` defensiveness. + """ + try: + if not os.path.isfile(report_path): + return "" + with open(report_path, "r", encoding="utf-8") as f: + content = f.read() + # Drop the frontmatter; keep the human body. + if content.startswith("---"): + parts = content.split("---", 2) + if len(parts) >= 3: + content = parts[2] + wanted = ("## Verdict", "## Secrets", "## Dependencies (blocking)") + lines = content.splitlines() + out = [] + keep = False + for ln in lines: + if ln.startswith("## "): + keep = any(ln.startswith(w) for w in wanted) + if keep: + out.append(ln) + excerpt = "\n".join(out).strip() + return excerpt[:1500] + except Exception as e: # noqa: BLE001 - never-raise (ORCH-046 defensive) + logger.warning("extract_security_findings error for %s: %s", report_path, e) + return "" + + +# --------------------------------------------------------------------------- +# Orchestrating entry — delegated to by qg.checks.check_security_gate +# --------------------------------------------------------------------------- +def check_security_gate(repo: str, work_item_id: str, branch: str) -> tuple[bool, str]: + """ORCH-022 security-gate on the deploy-staging -> deploy edge, run FIRST. + + Deterministic, no LLM. Algorithm (ADR-001 Р-1/Р-5): + 1. Conditionality: ``security_gate_enabled=False`` -> ``(True, "...disabled")``; + a repo the gate is not real for -> ``(True, "security-gate N/A for ")``. + 2. ``scan_secrets`` (offline) + ``audit_dependencies`` (best-effort). + 3. ``compute_verdict`` -> write ``17-security-report.md`` -> read the verdict + BACK via ``parse_security_status`` (single source of truth: the returned + verdict == the artefact frontmatter, AC-8). + 4. FAIL -> ``(False, reason)`` (engine rolls back to ``development``); PASS -> + ``(True, reason)`` (engine proceeds to the merge-gate). + + A degraded dep-audit on a PASS is surfaced loudly (Telegram + log) without + failing the gate (ADR-001 Р-3). Never-raise (AC-16): any internal error -> + ``(False, "")``; an exception never escapes into ``advance_stage``. + """ + try: + if not settings.security_gate_enabled: + return True, "security-gate disabled" + if not security_gate_applies(repo): + return True, f"security-gate N/A for {repo}" + + secret_result = scan_secrets(repo, branch) + dep_result = audit_dependencies(repo, branch) + fields = compute_verdict( + secret_result, + dep_result, + secrets_block=settings.security_secrets_block, + dep_block_severity=settings.security_dep_block_severity, + dep_fail_closed=settings.security_dep_audit_fail_closed, + ) + + path = write_security_report(repo, work_item_id, branch, fields) + + # Read the machine verdict back from the artefact we just wrote — so the + # returned (bool, reason) is guaranteed == the YAML frontmatter (AC-8). + try: + with open(path, "r", encoding="utf-8") as f: + content = f.read() + except OSError as e: + return False, f"cannot read security report (fail-closed): {e}" + ok, _verdict = parse_security_status(content) + + # Surface a degraded dep-audit loudly even when the gate passes (Р-3 / BR-11). + if fields.get("deps_audit_degraded"): + logger.warning( + "security-gate %s/%s: dep-audit DEGRADED (fail-%s): %s", + repo, work_item_id, + "closed" if settings.security_dep_audit_fail_closed else "open", + dep_result.detail, + ) + try: + from .notifications import send_telegram + send_telegram( + f"⚠️ {work_item_id}: dep-audit недоступен фид CVE " + f"({dep_result.detail}). " + + ("Гейт fail-closed → FAIL." if settings.security_dep_audit_fail_closed + else "Гейт fail-open → warning (секреты проверены оффлайн).") + ) + except Exception as e: # noqa: BLE001 - telegram best-effort + logger.warning("security-gate degraded telegram failed: %s", e) + + if ok: + logger.info("security-gate passed for %s/%s: %s", repo, work_item_id, fields["reason"]) + return True, f"security clean ({fields['reason']})" + return False, fields["reason"] + except Exception as e: # noqa: BLE001 - never-raise contract (AC-16) + logger.error("check_security_gate error for %s/%s: %s", repo, branch, e) + return False, f"security-gate error: {e}" diff --git a/src/stage_engine.py b/src/stage_engine.py index df84ca5..36de7a7 100644 --- a/src/stage_engine.py +++ b/src/stage_engine.py @@ -34,6 +34,7 @@ from .db import get_db, update_task_stage, enqueue_job from .stages import get_next_stage, get_qg_for_stage, get_agent_for_stage from .git_worktree import get_worktree_path from .review_parse import extract_review_findings, extract_test_failures +from .security_gate import extract_security_findings from .qg.checks import QG_CHECKS from . import merge_gate from . import self_deploy @@ -53,6 +54,10 @@ from .plane_sync import ( set_issue_in_progress, set_issue_blocked, set_issue_done, + set_issue_analysis, + set_issue_awaiting_deploy, + set_issue_deploying, + set_issue_monitoring, ) from .config import settings @@ -171,6 +176,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 +194,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 +217,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 ---------------------------------------------------- @@ -277,6 +302,18 @@ def advance_stage( # event. If it intervenes (defer on busy-lock, or rollback on conflict / # red re-test) it owns the outcome and we return without advancing. if current_stage == "deploy-staging": + # --- ORCH-022 security sub-gate (deploy-staging -> deploy edge) ----- + # Run FIRST among the edge sub-gates (BEFORE the merge-gate and the + # image-freshness rebuild): it is cheap (read-only scan) and we want to + # fail BEFORE the expensive rebase/rebuild (07-infra I-6). Deterministic: + # gitleaks (offline secret-scan) + pip-audit (CVE audit). FAIL -> rollback + # to development + developer-retry (cap MAX_DEVELOPER_RETRIES). It owns + # the outcome on intervention (mirrors the merge-gate / image-freshness). + if _handle_security_gate( + task_id, current_stage, repo, work_item_id, branch, agent, result + ): + return result + if _handle_merge_gate( task_id, current_stage, repo, work_item_id, branch, agent, result ): @@ -335,14 +372,28 @@ def advance_stage( # here, so explicitly drive the Plane issue into the terminal Done state # (PLANE_STATES['done'] — mapping unchanged) in addition to the # stage-change comment above. + # ORCH-066 (AC-8/AC-9): split terminal-sync by whether post-deploy + # monitoring applies. For self-hosting (post_deploy_applies==True) the + # task enters a `Monitoring after Deploy` window, NOT terminal Done yet — + # the monitor finalises Done/Blocked (run_post_deploy_monitor). For + # non-self repos the behaviour is unchanged: terminal Done immediately. + # Where the `Monitoring after Deploy` status is absent, set_issue_monitoring + # degrades to the project's Done UUID -> identical to today. if next_stage == "done" and work_item_id: try: - set_issue_done(work_item_id) - logger.info( - f"Task {task_id}: deploy->done, Plane state forced to Done" - ) + if post_deploy.post_deploy_applies(repo): + set_issue_monitoring(work_item_id) + logger.info( + f"Task {task_id}: deploy->done (self), Plane state -> " + f"Monitoring after Deploy (post-deploy window)" + ) + else: + set_issue_done(work_item_id) + logger.info( + f"Task {task_id}: deploy->done, Plane state forced to Done" + ) except Exception as e: - logger.error(f"Task {task_id}: failed to set Plane Done: {e}") + logger.error(f"Task {task_id}: failed to set Plane terminal state: {e}") # ORCH-043: the merge has landed (deploy->done). Release the merge lease as # a backstop in case the PR-merged webhook was lost (holder-aware no-op if a @@ -666,7 +717,9 @@ def _handle_qg_failure_rollbacks( notify_stage_change(task_id, current_stage, "analysis") plane_notify_stage(work_item_id, current_stage, "analysis") result.rolled_back_to = "analysis" - set_issue_in_progress(work_item_id) + # ORCH-066 (AC-3): rolled back to analysis -> indicate `Analysis` + # (degrades to In Progress where the status is not created). + set_issue_analysis(work_item_id) with open(conflict_path, "r") as cf: conflict_text = cf.read()[:500] plane_add_comment( @@ -911,6 +964,93 @@ def _handle_merge_gate_rollback( ) +# --------------------------------------------------------------------------- +# ORCH-022: security sub-gate (secret-scan + dependency audit) on the +# deploy-staging -> deploy edge +# --------------------------------------------------------------------------- +def _handle_security_gate( + task_id, current_stage, repo, work_item_id, branch, agent, result: AdvanceResult +) -> bool: + """Run check_security_gate on the deploy-staging -> deploy edge (ORCH-022). + + Runs FIRST among the edge sub-gates — BEFORE the merge-gate and the + image-freshness rebuild — because it is a cheap read-only scan and we want to + fail BEFORE the expensive rebase/rebuild (07-infra I-6). Deterministic (no LLM): + gitleaks (offline secret-scan, fail-closed) + pip-audit (CVE audit, fail-open + degrade). The machine verdict lives in 17-security-report.md frontmatter. + + Returns True if the gate INTERVENED (the caller must return without advancing): + * FAIL (secret found / blocking CVE / fail-closed) -> ROLLBACK to development + (+ developer retry, capped by MAX_DEVELOPER_RETRIES). No merge-lease release + here: the security-gate runs BEFORE the merge-gate, so the lease is not held + yet (distinct from the image-freshness rollback). The verbatim findings are + embedded into the developer's task_desc (ORCH-046 pattern, TC-17). + Returns False when the gate PASSED (clean, or N/A for a non-self repo with an + empty scope) so advance_stage proceeds to the merge-gate. + """ + passed, reason = _run_qg("check_security_gate", repo, work_item_id, branch) + if passed: + logger.info(f"Task {task_id}: security-gate passed ({reason})") + return False + + result.qg_name = "check_security_gate" + result.qg_passed = False + result.qg_reason = reason + + update_task_stage(task_id, "development") + notify_stage_change(task_id, current_stage, "development") + plane_notify_stage(work_item_id, current_stage, "development") + result.rolled_back_to = "development" + set_issue_in_progress(work_item_id) + notify_qg_failure(task_id, current_stage, "check_security_gate", reason) + plane_add_comment( + work_item_id, + f"❌ Security-гейт провален ({reason}). Откат на development. " + f"Developer нужен для фикса (секреты/уязвимые зависимости).", + author="deployer", + ) + retry_count = _developer_retry_count(task_id) + if retry_count < MAX_DEVELOPER_RETRIES: + # ORCH-046: embed the verbatim findings into task_desc so the developer + # agent sees the must-fix substance directly (not just a link). + # extract_security_findings never raises; "" -> graceful link-only fallback. + report_ref = f"docs/work-items/{work_item_id}/17-security-report.md" + report_path = os.path.join(get_worktree_path(repo, branch), report_ref) + findings = extract_security_findings(report_path) + head = ( + f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n" + f"Stage: development\nNote: Security-гейт провален " + f"(attempt {retry_count + 1}/{MAX_DEVELOPER_RETRIES}). " + f"Причина: {reason}." + ) + if findings: + task_desc = ( + f"{head}\nFindings:\n{findings}\n" + f"Полный контекст: {report_ref}" + ) + else: + task_desc = f"{head} Fix findings in {report_ref}" + new_job = enqueue_job("developer", repo, task_desc, task_id=task_id) + result.enqueued_agent = "developer" + result.enqueued_job_id = new_job + logger.info( + f"Task {task_id}: security-gate FAILED, enqueued developer (job_id={new_job})" + ) + else: + set_issue_blocked(work_item_id) + send_telegram( + f"\U0001f6a8 {work_item_id}: Security-гейт still failing after " + f"{MAX_DEVELOPER_RETRIES} developer retries ({reason}). " + f"Manual intervention needed." + ) + result.alerted = True + logger.error( + f"Task {task_id}: security-gate FAILED, rolled back deploy-staging -> " + f"development ({reason})" + ) + return True + + # --------------------------------------------------------------------------- # ORCH-058: staging-image freshness sub-gate on the deploy-staging -> deploy edge # --------------------------------------------------------------------------- @@ -998,9 +1138,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") @@ -1009,7 +1151,11 @@ def _handle_self_deploy_phase_a( result.note = "self-deploy-approval-pending" if work_item_id: - set_issue_in_review(work_item_id) + # ORCH-066 (AC-6/AC-13): Phase A approval-pending is now `Awaiting Deploy`, + # which discharges `In Review` of the deploy-approval meaning (In Review + # stays for analyst BRD/review approve-pending only). Degrades to In Review + # where the status is not created. + set_issue_awaiting_deploy(work_item_id) # ORCH-036: belt-and-suspenders — wipe any STALE deploy-state markers before # arming a fresh approve. A prior FAILED pass clears on rollback, but clearing # here too guarantees the entry to every new prod-deploy pass starts clean @@ -1022,13 +1168,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, " @@ -1069,6 +1216,10 @@ def _handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result: Adv self_deploy.write_marker( repo, work_item_id, self_deploy.INITIATED, content=str(time.time()) ) + # ORCH-066 (AC-7): the prod deploy is now in flight -> indicate `Deploying` + # (degrades to In Progress where the status is not created). + if work_item_id: + set_issue_deploying(work_item_id) task_desc = ( f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n" f"Stage: deploy\nNote: deploy-finalize poll (prod self-deploy initiated)." @@ -1263,6 +1414,12 @@ def run_post_deploy_monitor(job: dict): settings.post_deploy_window_s, checks_total, checks_failed, ) post_deploy.mark_done(repo, work_item_id) + # ORCH-066 (AC-10): the post-deploy window closed clean -> terminal Done. + if work_item_id: + try: + set_issue_done(work_item_id) + except Exception as e: # noqa: BLE001 - never break the tick + logger.warning(f"post-deploy: set Done failed for {work_item_id}: {e}") _notify_post_deploy( work_item_id, f"✅ {work_item_id}: пост-деплой окно завершено чисто " @@ -1303,6 +1460,15 @@ def run_post_deploy_monitor(job: dict): f"self-hosting запрещён (BR-5).", ) + # ORCH-066 (AC-11/AC-12): a confirmed degradation -> indicate `Blocked` for + # manual intervention. This is INDICATION ONLY — the tick NEVER restarts / + # rolls back the prod container (self-hosting stays ALERT_ONLY, BR-5). + if work_item_id: + try: + set_issue_blocked(work_item_id) + except Exception as e: # noqa: BLE001 - never break the tick + logger.warning(f"post-deploy: set Blocked failed for {work_item_id}: {e}") + post_deploy.write_post_deploy_log( repo, work_item_id, branch, post_deploy.DEGRADED, action_taken, settings.post_deploy_window_s, checks_total, checks_failed, diff --git a/src/webhooks/plane.py b/src/webhooks/plane.py index b14ab3b..875f54a 100644 --- a/src/webhooks/plane.py +++ b/src/webhooks/plane.py @@ -147,11 +147,24 @@ async def handle_issue_updated(data: dict, project_id: str = ""): return # ORCH-10: resolve expected state UUIDs per the incoming issue's project so - # both enduro (b873d9eb) and orchestrator (e331bfb3) In Progress trigger the + # both enduro (b873d9eb) and orchestrator (e331bfb3) statuses trigger the # pipeline. Using PLANE_STATES["in_progress"] here was the root-cause blocker. + # ORCH-066: the start/resume trigger is now `To Analyse` (human entry-point), + # which discharges `In Progress` of its overloaded "start the pipeline" + # meaning. Fail-closed: on a project without the `To Analyse` status, + # `to_analyse` aliases to the project's own `in_progress` UUID, so moving an + # enduro issue to In Progress still triggers start/resume (AC-17). proj_states = get_project_states(project_id) - if new_state == proj_states["in_progress"]: + # 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") + # ORCH-066: start/resume trigger is `To Analyse` (human entry-point). + if new_state == proj_states["to_analyse"]: 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 +173,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. @@ -235,9 +287,14 @@ async def handle_status_start(data: dict, project_id: str = ""): ) job_id = enqueue_job(stage_agent, repo, task_desc, task_id=task_id) logger.info( - f"Task {task_id}: returned to In Progress (Needs Input answered), " + f"Task {task_id}: returned to To Analyse (Needs Input answered), " f"relaunched {stage_agent} for stage {current_stage} (job_id={job_id})" ) + # ORCH-066 (AC-3): a resume of the analyst (the only Needs-Input owner) is + # re-indicated as `Analysis`; other stages keep their own indication. + if current_stage == "analysis": + from ..plane_sync import set_issue_analysis as _set_analysis + _set_analysis(work_item_id) try: _add_comment( work_item_id, @@ -538,6 +595,10 @@ async def start_pipeline(data: dict, project_id: str = ""): ) job_id = enqueue_job("analyst", repo, task_desc, task_id=task_id) logger.info(f"Task {task_id}: enqueued analyst (job_id={job_id})") + # ORCH-066 (AC-3): indicate the analysis stage with the dedicated + # `Analysis` status (degrades to In Progress where it is not created). + from ..plane_sync import set_issue_analysis as _set_analysis + _set_analysis(work_item_id, plane_project_id) # Post start comment to Plane from ..plane_sync import add_comment as _add_comment _add_comment(work_item_id, "\U0001f50d Analyst \u0437\u0430\u043f\u0443\u0449\u0435\u043d. BRD/\u0422\u0417/AC/TestPlan \u0432 \u0440\u0430\u0431\u043e\u0442\u0435 (\u043e\u0436\u0438\u0434\u0430\u0439\u0442\u0435 8-15 \u043c\u0438\u043d).", author="analyst") @@ -579,9 +640,11 @@ async def _rollback_stage( (via the existing rollback notify + an enqueue of the prev-stage agent). """ if current_stage == "analysis": - # Already in analysis — just relaunch analyst with rejection reason - from ..plane_sync import set_issue_in_progress - set_issue_in_progress(work_item_id) + # Already in analysis — just relaunch analyst with rejection reason. + # ORCH-066 (AC-3): indicate `Analysis` (degrades to In Progress where the + # status is not created). + from ..plane_sync import set_issue_analysis + set_issue_analysis(work_item_id) task_desc = ( f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n" f"Stage: analysis\nNote: Stakeholder REJECTED your artifacts. " @@ -633,7 +696,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 +706,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 +727,7 @@ async def _try_advance_stage( work_item_id, branch, None, + confirm_deploy=confirm_deploy, ) diff --git a/tests/test_config.py b/tests/test_config.py index 6957461..092395b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -235,6 +235,7 @@ def test_tc19_qg_checks_registry_unchanged(): "check_staging_status", "check_branch_mergeable", "check_staging_image_fresh", + "check_security_gate", } diff --git a/tests/test_confirm_deploy_integration.py b/tests/test_confirm_deploy_integration.py new file mode 100644 index 0000000..92f268b --- /dev/null +++ b/tests/test_confirm_deploy_integration.py @@ -0,0 +1,187 @@ +"""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, + "to_analyse": IN_PROGRESS, # ORCH-066 integ: always present (alias) + # ORCH-066 integ: full status-model keys alias to base UUIDs, + # mirroring get_project_states() _STATE_ALIAS_FALLBACK in production. + "analysis": IN_PROGRESS, + "code_review": APPROVED, + "awaiting_deploy": IN_PROGRESS, + "deploying": IN_PROGRESS, + "monitoring": APPROVED, + "approved": APPROVED, + "rejected": REJECTED, + "confirm_deploy": CONFIRM, +} +_STATES_NONSELF = { + "in_progress": IN_PROGRESS, + "to_analyse": IN_PROGRESS, # ORCH-066 integ: always present (alias) + # ORCH-066 integ: full status-model keys alias to base UUIDs, + # mirroring get_project_states() _STATE_ALIAS_FALLBACK in production. + "analysis": IN_PROGRESS, + "code_review": APPROVED, + "awaiting_deploy": IN_PROGRESS, + "deploying": IN_PROGRESS, + "monitoring": APPROVED, + "approved": APPROVED, + "rejected": REJECTED, +} + + +@pytest.fixture(autouse=True) +def fresh_db(monkeypatch, tmp_path): + monkeypatch.setattr(_db.settings, "db_path", _test_db) + if os.path.exists(_test_db): + os.unlink(_test_db) + init_db() + monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path)) + monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path)) + monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True) + yield + + +@pytest.fixture(autouse=True) +def silence_engine(monkeypatch): + for name in ( + "notify_stage_change", "notify_qg_failure", "send_telegram", + "plane_notify_stage", "plane_notify_qg", "plane_add_comment", + "set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress", + "set_issue_blocked", "set_issue_done", + ): + monkeypatch.setattr(stage_engine, name, MagicMock(), raising=False) + + +def _make_task(stage, repo, branch, wi, plane_id): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) " + "VALUES (?, ?, ?, ?, ?)", + (plane_id, wi, repo, branch, stage), + ) + task_id = cur.lastrowid + conn.commit() + conn.close() + return task_id + + +def _stage(task_id): + conn = get_db() + row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone() + conn.close() + return row[0] + + +def _jobs(): + conn = get_db() + rows = conn.execute("SELECT agent FROM jobs ORDER BY id").fetchall() + conn.close() + return [r[0] for r in rows] + + +def _payload(state_uuid, plane_id): + return {"id": plane_id, "state": {"id": state_uuid}} + + +# --------------------------------------------------------------------------- +# TC-10: E2E Confirm Deploy -> prod deploy initiated +# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_tc10_confirm_deploy_e2e_initiates(monkeypatch): + monkeypatch.setattr(plane_sync, "get_project_states", lambda pid: _STATES_SELF) + initiate = MagicMock(return_value=(True, "ok")) + monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate) + + task_id = _make_task("deploy", "orchestrator", "feature/ORCH-059-x", + "ORCH-059", "plane-ORCH-059") + + await wh.handle_issue_updated(_payload(CONFIRM, "plane-ORCH-059"), "orch-proj") + + initiate.assert_called_once() + assert "deploy-finalizer" in _jobs() + assert self_deploy.has_marker("orchestrator", "ORCH-059", self_deploy.INITIATED) + # Verdict comes later via the finalizer — still on `deploy`. + assert _stage(task_id) == "deploy" + + +# --------------------------------------------------------------------------- +# TC-11: E2E Approved -> no prod deploy, task stays on deploy +# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_tc11_approved_e2e_noop(monkeypatch): + monkeypatch.setattr(plane_sync, "get_project_states", lambda pid: _STATES_SELF) + initiate = MagicMock(return_value=(True, "ok")) + monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate) + + task_id = _make_task("deploy", "orchestrator", "feature/ORCH-059-x", + "ORCH-059", "plane-ORCH-059") + + await wh.handle_issue_updated(_payload(APPROVED, "plane-ORCH-059"), "orch-proj") + + initiate.assert_not_called() + assert "deploy-finalizer" not in _jobs() + assert _stage(task_id) == "deploy" # no rollback, no advance to done + assert not self_deploy.has_marker("orchestrator", "ORCH-059", self_deploy.INITIATED) + + +# --------------------------------------------------------------------------- +# TC-12: non-self repo -> confirm-deploy branch inert (fail-closed, no key) +# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_tc12_non_self_repo_unaffected(monkeypatch): + # Non-self project has no confirm_deploy key at all -> the branch never fires. + monkeypatch.setattr(plane_sync, "get_project_states", lambda pid: _STATES_NONSELF) + initiate = MagicMock(return_value=(True, "ok")) + monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate) + # Stub the deploy gate so the legacy non-self path stays deterministic (no + # real git/network); its verdict is irrelevant to this test's assertions. + monkeypatch.setattr( + stage_engine, "QG_CHECKS", + {**stage_engine.QG_CHECKS, "check_deploy_status": lambda *a, **k: (True, "ok")}, + ) + + task_id = _make_task("deploy", "enduro-trails", "feature/ET-009-x", + "ET-009", "plane-ET-009") + + # An Approved on a non-self deploy task does not initiate self-deploy logic. + await wh.handle_issue_updated(_payload(APPROVED, "plane-ET-009"), "enduro-proj") + + initiate.assert_not_called() + # The (absent) Confirm Deploy status simply maps to no pipeline action. + assert self_deploy.self_deploy_applies("enduro-trails") is False diff --git a/tests/test_deploy_approve.py b/tests/test_deploy_approve.py index 146a8e4..256c876 100644 --- a/tests/test_deploy_approve.py +++ b/tests/test_deploy_approve.py @@ -48,6 +48,9 @@ def silence_side_effects(monkeypatch): "send_telegram", "plane_notify_stage", "plane_notify_qg", "plane_add_comment", "set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress", "set_issue_blocked", "set_issue_done", + # ORCH-066 status setters. + "set_issue_analysis", "set_issue_awaiting_deploy", "set_issue_deploying", + "set_issue_monitoring", ): monkeypatch.setattr(stage_engine, name, MagicMock()) @@ -101,6 +104,7 @@ def test_tc05_no_approve_does_not_call_prod_hook(monkeypatch): stage_engine, "QG_CHECKS", {**stage_engine.QG_CHECKS, "check_staging_status": _pass, + "check_security_gate": _pass, "check_branch_mergeable": _pass, "check_staging_image_fresh": _pass}, ) @@ -127,6 +131,9 @@ def test_tc05_no_approve_does_not_call_prod_hook(monkeypatch): assert _jobs() == [] # The restart-safe approve-requested marker was written. assert self_deploy.has_marker("orchestrator", "ORCH-036", self_deploy.APPROVE_REQUESTED) + # ORCH-066 AC-6/AC-13: Phase A indicates `Awaiting Deploy`, NOT `In Review`. + stage_engine.set_issue_awaiting_deploy.assert_called_once_with("ORCH-036") + stage_engine.set_issue_in_review.assert_not_called() # --------------------------------------------------------------------------- @@ -139,23 +146,27 @@ 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 # The finalizer was enqueued. assert any(j["agent"] == "deploy-finalizer" for j in _jobs()) assert self_deploy.has_marker("orchestrator", "ORCH-036", self_deploy.INITIATED) + # ORCH-066 AC-7: Phase B indicates `Deploying` on a successful initiate. + stage_engine.set_issue_deploying.assert_called_once_with("ORCH-036") - # 2nd (duplicate) Approved -> idempotent no-op, hook NOT called again. + # 2nd (duplicate) Confirm Deploy -> idempotent no-op, hook NOT called again. res2 = advance_stage( task_id, "deploy", "orchestrator", "ORCH-036", - "feature/ORCH-036-x", finished_agent=None, + "feature/ORCH-036-x", finished_agent=None, confirm_deploy=True, ) assert res2.note == "self-deploy-already-initiated" assert ssh_run.call_count == 1 # still exactly one prod deploy diff --git a/tests/test_deploy_terminal_sync.py b/tests/test_deploy_terminal_sync.py index d7b9b5e..c417cef 100644 --- a/tests/test_deploy_terminal_sync.py +++ b/tests/test_deploy_terminal_sync.py @@ -45,6 +45,9 @@ def silence_side_effects(monkeypatch): "send_telegram", "plane_notify_stage", "plane_notify_qg", "plane_add_comment", "set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress", "set_issue_blocked", "set_issue_done", + # ORCH-066 status setters. + "set_issue_analysis", "set_issue_awaiting_deploy", "set_issue_deploying", + "set_issue_monitoring", ): monkeypatch.setattr(stage_engine, name, MagicMock()) @@ -106,3 +109,56 @@ def test_tc17_success_deploy_syncs_terminal_done(monkeypatch): release.assert_called_once_with("orchestrator", "feature/ORCH-036-x") # No agent is launched leaving deploy (terminal). assert _jobs() == [] + + +# --------------------------------------------------------------------------- +# ORCH-066 TC-08 (AC-8): self-hosting deploy->done -> Monitoring after Deploy, +# NOT terminal Done. The post-deploy monitor finalises. +# --------------------------------------------------------------------------- +def test_tc08_self_deploy_done_sets_monitoring_not_done(monkeypatch): + self_deploy.write_marker("orchestrator", "ORCH-036", self_deploy.RESULT, "0") + monkeypatch.setattr( + stage_engine, "QG_CHECKS", + {**stage_engine.QG_CHECKS, "check_deploy_status": _pass}, + ) + monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", MagicMock()) + # post_deploy applies for the self-hosting repo with the monitor enabled. + monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_monitor_enabled", True) + monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_repos", "") + # arm_monitor is orthogonal; stub it so this test stays on the status contract. + monkeypatch.setattr(stage_engine.post_deploy, "arm_monitor", MagicMock(return_value=True)) + + task_id = _make_task("deploy") + stage_engine.run_deploy_finalizer( + {"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "deploy-finalizer"} + ) + + assert _stage(task_id) == "done" + # Self-hosting: the issue enters the Monitoring window, NOT terminal Done yet. + stage_engine.set_issue_monitoring.assert_called_once_with("ORCH-036") + stage_engine.set_issue_done.assert_not_called() + + +# --------------------------------------------------------------------------- +# ORCH-066 TC-09 (AC-9): non-self repo deploy->done -> terminal Done (no regress). +# --------------------------------------------------------------------------- +def test_tc09_non_self_deploy_done_sets_done(monkeypatch): + self_deploy.write_marker("enduro-trails", "ET-042", self_deploy.RESULT, "0") + monkeypatch.setattr( + stage_engine, "QG_CHECKS", + {**stage_engine.QG_CHECKS, "check_deploy_status": _pass}, + ) + monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", MagicMock()) + # Monitor enabled, but the empty CSV means it applies ONLY to the self repo; + # a non-self repo therefore takes the unchanged terminal-Done path. + monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_monitor_enabled", True) + monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_repos", "") + + task_id = _make_task("deploy", repo="enduro-trails", branch="feature/ET-042-x", wi="ET-042") + stage_engine.run_deploy_finalizer( + {"task_id": task_id, "repo": "enduro-trails", "id": 1, "agent": "deploy-finalizer"} + ) + + assert _stage(task_id) == "done" + stage_engine.set_issue_done.assert_called_once_with("ET-042") + stage_engine.set_issue_monitoring.assert_not_called() diff --git a/tests/test_m6_sequence.py b/tests/test_m6_sequence.py index 733a267..13b80cb 100644 --- a/tests/test_m6_sequence.py +++ b/tests/test_m6_sequence.py @@ -40,11 +40,15 @@ ENDURO_PLANE_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c" _PROJECT_STATES = { ENDURO_PLANE_ID: { "in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967", + # ORCH-066: To Analyse is the start trigger; with the status absent it + # aliases to in_progress (the real get_project_states fallback). + "to_analyse": "b873d9eb-993c-48cd-97ac-99a9b1623967", "approved": "a519a341-dada-4a91-8910-7604f82b79c5", "rejected": "ba958f3c-5db5-461d-8f82-89425e413b97", }, ORCH_PLANE_ID: { "in_progress": "e331bfb3-e17e-4699-ba48-4abb89c21b7b", + "to_analyse": "e331bfb3-e17e-4699-ba48-4abb89c21b7b", "approved": "63f2c8fe-dcda-4ace-952f-dd88bd0118ff", "rejected": "4c769e90-bf80-4a52-b97a-e1c84904bfc3", }, diff --git a/tests/test_orch10_states.py b/tests/test_orch10_states.py index c753fd5..292e48a 100644 --- a/tests/test_orch10_states.py +++ b/tests/test_orch10_states.py @@ -460,3 +460,59 @@ def test_default_states_et_values(): assert ps._DEFAULT_STATES[key] == expected, ( f"_DEFAULT_STATES['{key}']: expected {expected}, got {ps._DEFAULT_STATES.get(key)}" ) + + +# --------------------------------------------------------------------------- +# ORCH-066 TC-19 (AC-18): resolve-by-name — when a project DEFINES one of the +# new statuses, get_project_states must use its OWN UUID, not the default alias. +# --------------------------------------------------------------------------- +def test_orch066_tc19_name_resolution_beats_alias(): + """A project that created 'Analysis' / 'Code-Review' / 'Awaiting Deploy' / + 'Deploying' / 'Monitoring after Deploy' resolves each to its own project + UUID (via _PLANE_NAME_TO_KEY), NOT the aliased base-key UUID.""" + import src.plane_sync as ps + + new_uuids = { + "Analysis": "11111111-0000-0000-0000-000000000001", + "Code-Review": "11111111-0000-0000-0000-000000000002", + "Awaiting Deploy": "11111111-0000-0000-0000-000000000003", + "Deploying": "11111111-0000-0000-0000-000000000004", + "Monitoring after Deploy": "11111111-0000-0000-0000-000000000005", + "To Analyse": "11111111-0000-0000-0000-000000000006", + } + # Start from the full ORCH base set, then add the dedicated new statuses. + results = _make_states_response(ORCH_STATES)["results"] + results += [{"id": uid, "name": name} for name, uid in new_uuids.items()] + + with patch("src.plane_sync.httpx.get") as mock_get: + mock_get.return_value = _fake_response({"results": results}) + states = ps.get_project_states(ORCH_PROJECT_ID) + + # Each new key resolved to the project's OWN UUID, not the base-key alias. + assert states["analysis"] == new_uuids["Analysis"] + assert states["code_review"] == new_uuids["Code-Review"] + assert states["awaiting_deploy"] == new_uuids["Awaiting Deploy"] + assert states["deploying"] == new_uuids["Deploying"] + assert states["monitoring"] == new_uuids["Monitoring after Deploy"] + assert states["to_analyse"] == new_uuids["To Analyse"] + # Sanity: they are NOT the aliased base UUIDs. + assert states["analysis"] != states["in_progress"] + assert states["code_review"] != states["review"] + assert states["awaiting_deploy"] != states["in_review"] + + +def test_orch066_tc19_missing_new_status_aliases_to_project_base(): + """BR-12: a project WITHOUT the new statuses degrades each new key to its OWN + base UUID (not a foreign enduro UUID) — keeping the PATCH state valid.""" + import src.plane_sync as ps + with patch("src.plane_sync.httpx.get") as mock_get: + mock_get.return_value = _fake_response(_make_states_response(ORCH_STATES)) + states = ps.get_project_states(ORCH_PROJECT_ID) + + # No dedicated new statuses -> alias to THIS project's base UUIDs. + assert states["analysis"] == ORCH_STATES["in_progress"] + assert states["to_analyse"] == ORCH_STATES["in_progress"] + assert states["code_review"] == ORCH_STATES["review"] + assert states["awaiting_deploy"] == ORCH_STATES["in_review"] + assert states["deploying"] == ORCH_STATES["in_progress"] + assert states["monitoring"] == ORCH_STATES["done"] diff --git a/tests/test_plane_confirm_deploy.py b/tests/test_plane_confirm_deploy.py new file mode 100644 index 0000000..4bdc919 --- /dev/null +++ b/tests/test_plane_confirm_deploy.py @@ -0,0 +1,162 @@ +"""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, + # ORCH-066 integ: real get_project_states always exposes to_analyse + # (aliased to in_progress on projects without the dedicated status). + "to_analyse": IN_PROGRESS, + # ORCH-066 integ: full status-model keys alias to base UUIDs, + # mirroring get_project_states() _STATE_ALIAS_FALLBACK in production. + "analysis": IN_PROGRESS, + "code_review": APPROVED, + "awaiting_deploy": IN_PROGRESS, + "deploying": IN_PROGRESS, + "monitoring": APPROVED, + "approved": APPROVED, + "rejected": REJECTED, + "confirm_deploy": CONFIRM, +} + + +@pytest.fixture(autouse=True) +def fresh_db(monkeypatch, tmp_path): + monkeypatch.setattr(_db.settings, "db_path", _test_db) + if os.path.exists(_test_db): + os.unlink(_test_db) + init_db() + # Deterministic per-project states (no network). handle_issue_updated imports + # get_project_states locally from ..plane_sync, so patch it at the source. + monkeypatch.setattr(plane_sync, "get_project_states", lambda pid: _STATES) + # Isolate sentinel dirs. + monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path)) + monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path)) + yield + + +@pytest.fixture(autouse=True) +def silence_engine(monkeypatch): + for name in ( + "notify_stage_change", "notify_qg_failure", "send_telegram", + "plane_notify_stage", "plane_notify_qg", "plane_add_comment", + "set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress", + "set_issue_blocked", "set_issue_done", + ): + monkeypatch.setattr(stage_engine, name, MagicMock(), raising=False) + + +def _make_task(stage, repo="orchestrator", branch="feature/ORCH-059-x", + wi="ORCH-059", plane_id="plane-ORCH-059"): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) " + "VALUES (?, ?, ?, ?, ?)", + (plane_id, wi, repo, branch, stage), + ) + task_id = cur.lastrowid + conn.commit() + conn.close() + return task_id + + +def _payload(state_uuid, plane_id="plane-ORCH-059"): + return {"id": plane_id, "state": {"id": state_uuid}} + + +# --------------------------------------------------------------------------- +# TC-04: "Confirm Deploy" routes to the Phase B path with confirm_deploy=True +# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_tc04_confirm_deploy_routes_phase_b(monkeypatch): + _make_task("deploy") + spy = AsyncMock() + monkeypatch.setattr(wh, "_try_advance_stage", spy) + # handle_verdict must NOT be taken for the confirm-deploy status. + verdict_spy = AsyncMock() + monkeypatch.setattr(wh, "handle_verdict", verdict_spy) + + await wh.handle_issue_updated(_payload(CONFIRM), "proj") + + spy.assert_awaited_once() + # confirm_deploy=True must be threaded through. + assert spy.await_args.kwargs.get("confirm_deploy") is True + verdict_spy.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_tc04b_confirm_deploy_off_deploy_stage_is_noop(monkeypatch): + """Guard: a stray "Confirm Deploy" on a non-deploy stage is a no-op (no advance).""" + _make_task("analysis") + spy = AsyncMock() + monkeypatch.setattr(wh, "_try_advance_stage", spy) + + await wh.handle_confirm_deploy(_payload(CONFIRM), "proj") + + spy.assert_not_awaited() + + +# --------------------------------------------------------------------------- +# TC-05: "Approved" on `deploy` does NOT initiate the prod deploy +# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_tc05_approved_on_deploy_does_not_initiate(monkeypatch): + monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True) + _make_task("deploy") + initiate = MagicMock() + monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate) + + # Real routing: Approved -> handle_verdict -> _try_advance_stage(confirm_deploy=False) + # -> advance_stage -> the deploy block no-ops (does not initiate). + await wh.handle_issue_updated(_payload(APPROVED), "proj") + + initiate.assert_not_called() + + +# --------------------------------------------------------------------------- +# TC-06: "Approved" on `analysis` still advances analysis -> architecture +# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_tc06_approved_on_analysis_still_advances(monkeypatch): + task_id = _make_task("analysis") + + await wh.handle_issue_updated(_payload(APPROVED), "proj") + + conn = get_db() + stage = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone()[0] + conn.close() + assert stage == "architecture" diff --git a/tests/test_plane_states.py b/tests/test_plane_states.py new file mode 100644 index 0000000..868e54f --- /dev/null +++ b/tests/test_plane_states.py @@ -0,0 +1,120 @@ +"""ORCH-059 TC-01/02/03: resolver registration of the dedicated "Confirm Deploy" +status and its fail-closed absence in fallback environments. + +Contract (AC-1, AC-7): + * TC-01 — _PLANE_NAME_TO_KEY maps the board name "Confirm Deploy" to the logical + key "confirm_deploy". + * TC-02 — get_project_states for an ORCH-like project (Plane API mocked to + include a "Confirm Deploy" state) returns a NON-empty uuid under + "confirm_deploy", distinct from "approved". + * TC-03 — fail-closed: when the status is absent (API fallback to + _DEFAULT_STATES / unreachable Plane), the key is simply missing and a .get + access yields None WITHOUT raising — the confirm-deploy branch never activates. +""" + +import os +import tempfile + +import pytest + +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch_plane_states.db") + +import src.plane_sync as plane_sync # noqa: E402 +from src.plane_sync import ( # noqa: E402 + _PLANE_NAME_TO_KEY, + _DEFAULT_STATES, + get_project_states, + reload_project_states, +) + + +@pytest.fixture(autouse=True) +def fresh_cache(): + reload_project_states() + yield + reload_project_states() + + +# --------------------------------------------------------------------------- +# TC-01: name -> key mapping is registered +# --------------------------------------------------------------------------- +def test_tc01_confirm_deploy_name_to_key_mapping(): + assert _PLANE_NAME_TO_KEY.get("Confirm Deploy") == "confirm_deploy" + + +def test_tc01_confirm_deploy_not_in_default_states(): + """Fail-closed by construction: NO fallback UUID exists for confirm_deploy, so + enduro / API-fallback environments never resolve a (wrong) deploy trigger.""" + assert "confirm_deploy" not in _DEFAULT_STATES + + +# --------------------------------------------------------------------------- +# TC-02: live API resolves a real, distinct uuid for an ORCH-like project +# --------------------------------------------------------------------------- +def test_tc02_get_project_states_resolves_confirm_deploy(monkeypatch): + confirm_uuid = "cfd00000-0000-0000-0000-000000000059" + approved_uuid = "a519a341-dada-4a91-8910-7604f82b79c5" + + class _Resp: + def raise_for_status(self): + pass + + def json(self): + return { + "results": [ + {"name": "In Progress", "id": "b873d9eb-993c-48cd-97ac-99a9b1623967"}, + {"name": "Approved", "id": approved_uuid}, + {"name": "Confirm Deploy", "id": confirm_uuid}, + ] + } + + monkeypatch.setattr(plane_sync.httpx, "get", lambda *a, **k: _Resp()) + + states = get_project_states("orch-project-uuid") + assert states.get("confirm_deploy") == confirm_uuid + # Distinct gestures: confirm-deploy must NOT alias the human "Approved" gate. + assert states["confirm_deploy"] != states["approved"] + + +# --------------------------------------------------------------------------- +# TC-03: fail-closed when the status is absent (API fallback / unreachable) +# --------------------------------------------------------------------------- +def test_tc03_fail_closed_when_api_unreachable(monkeypatch): + """A Plane outage -> get_project_states falls back to _DEFAULT_STATES, which + has no confirm_deploy key. .get must yield None, never raise.""" + + def _boom(*a, **k): + raise RuntimeError("plane down") + + monkeypatch.setattr(plane_sync.httpx, "get", _boom) + + states = get_project_states("any-project-uuid") + # No KeyError, branch never activates. + assert states.get("confirm_deploy") is None + # The human gate "Approved" still resolves (fallback is intact). + assert states.get("approved") == _DEFAULT_STATES["approved"] + + +def test_tc03_fail_closed_when_status_not_on_board(monkeypatch): + """Project whose board lacks "Confirm Deploy": the key is filled by NEITHER the + API loop NOR the _DEFAULT_STATES backfill -> absent -> fail-closed.""" + + class _Resp: + def raise_for_status(self): + pass + + def json(self): + return { + "results": [ + {"name": "In Progress", "id": "b873d9eb-993c-48cd-97ac-99a9b1623967"}, + {"name": "Approved", "id": "a519a341-dada-4a91-8910-7604f82b79c5"}, + ] + } + + monkeypatch.setattr(plane_sync.httpx, "get", lambda *a, **k: _Resp()) + + states = get_project_states("board-without-confirm") + assert states.get("confirm_deploy") is None + assert states.get("approved") == "a519a341-dada-4a91-8910-7604f82b79c5" diff --git a/tests/test_plane_states_cache.py b/tests/test_plane_states_cache.py new file mode 100644 index 0000000..0b952f3 --- /dev/null +++ b/tests/test_plane_states_cache.py @@ -0,0 +1,180 @@ +"""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 diff --git a/tests/test_plane_status_failclosed.py b/tests/test_plane_status_failclosed.py new file mode 100644 index 0000000..14ad2e3 --- /dev/null +++ b/tests/test_plane_status_failclosed.py @@ -0,0 +1,131 @@ +"""ORCH-066 fail-closed (CRITICAL) — the new status model must never wedge the +pipeline when the 6 Plane statuses are absent or Plane is unreachable. + + * TC-16 (AC-16, BR-12) — a project WITHOUT the new statuses resolves each new + logical key to its OWN base UUID (to_analyse=in_progress, code_review=review, + awaiting_deploy=in_review, monitoring=done); no exception. + * TC-17 (AC-16) — Plane API down -> get_project_states falls back to + _DEFAULT_STATES; every set_issue_* helper is never-raise. + * TC-18 (AC-17) — enduro In Progress STILL starts the pipeline through + the to_analyse alias (= in_progress UUID). + +httpx is mocked; no network. +""" + +import os + +os.environ.setdefault("ORCH_PLANE_API_URL", "http://plane.local") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_WORKSPACE_SLUG", "test-ws") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") + +from unittest.mock import patch, MagicMock, AsyncMock # noqa: E402 + +import pytest # noqa: E402 + +from src import plane_sync as PS # noqa: E402 + +ENDURO_PROJECT_ID = "7a79f0a9-5278-49cd-9007-9a338f238f9c" + +# An enduro-style states response: the 6 ORCH-066 statuses are NOT created. +_ENDURO_BASE = { + "Backlog": "backlog-u", "Todo": "todo-u", "In Progress": "ip-u", + "Review": "review-u", "In Review": "inrev-u", "Approved": "appr-u", + "Rejected": "rej-u", "Done": "done-u", "Needs Input": "ni-u", + "Blocked": "blk-u", +} + + +def _states_response(name_to_uuid): + return {"results": [{"id": uid, "name": name} for name, uid in name_to_uuid.items()]} + + +def _fake_resp(data, status=200): + m = MagicMock() + m.status_code = status + m.json.return_value = data + m.raise_for_status.return_value = None + return m + + +@pytest.fixture(autouse=True) +def _reset_cache(): + PS.reload_project_states() + yield + PS.reload_project_states() + + +# --------------------------------------------------------------------------- +# TC-16 (AC-16 / BR-12): partial project -> alias to its own base UUIDs, no raise. +# --------------------------------------------------------------------------- +def test_tc16_partial_project_aliases_to_base_uuids(): + with patch("src.plane_sync.httpx.get") as mock_get: + mock_get.return_value = _fake_resp(_states_response(_ENDURO_BASE)) + states = PS.get_project_states(ENDURO_PROJECT_ID) + + # The new keys degrade to THIS project's base UUIDs (not foreign defaults). + assert states["to_analyse"] == states["in_progress"] == "ip-u" + assert states["analysis"] == "ip-u" + assert states["code_review"] == states["review"] == "review-u" + assert states["awaiting_deploy"] == states["in_review"] == "inrev-u" + assert states["deploying"] == "ip-u" + assert states["monitoring"] == states["done"] == "done-u" + + +# --------------------------------------------------------------------------- +# TC-17 (AC-16): Plane API down -> _DEFAULT_STATES; set_issue_* never-raise. +# --------------------------------------------------------------------------- +def test_tc17_api_down_falls_back_to_defaults(): + with patch("src.plane_sync.httpx.get", side_effect=Exception("plane down")): + states = PS.get_project_states(ENDURO_PROJECT_ID) + assert states is PS._DEFAULT_STATES + # All new keys exist in the defaults (so callers never KeyError). + for k in ("to_analyse", "analysis", "code_review", "awaiting_deploy", + "deploying", "monitoring"): + assert k in states + + +def test_tc17_set_issue_helpers_never_raise_when_issue_missing(): + # find_issue_id returns None (issue not in Plane) -> helpers log + return, + # they must NOT raise. Covers every ORCH-066 setter. + setters = [ + PS.set_issue_analysis, PS.set_issue_code_review, + PS.set_issue_awaiting_deploy, PS.set_issue_deploying, + PS.set_issue_monitoring, + ] + with patch("src.plane_sync._resolve_project_id", return_value="proj-1"), \ + patch("src.plane_sync.get_project_states", return_value=PS._DEFAULT_STATES), \ + patch("src.plane_sync.find_issue_id", return_value=None), \ + patch("src.plane_sync.httpx.patch") as mock_patch: + for setter in setters: + setter("ET-1") # must not raise + # No PATCH issued because the issue could not be resolved. + mock_patch.assert_not_called() + + +def test_tc17_set_issue_helpers_never_raise_when_patch_errors(): + # The PATCH itself blows up -> _set_issue_state_direct swallows it. + with patch("src.plane_sync._resolve_project_id", return_value="proj-1"), \ + patch("src.plane_sync.get_project_states", return_value=PS._DEFAULT_STATES), \ + patch("src.plane_sync.find_issue_id", return_value="issue-uuid"), \ + patch("src.plane_sync.httpx.patch", side_effect=Exception("boom")): + PS.set_issue_monitoring("ET-1") # must not raise + + +# --------------------------------------------------------------------------- +# TC-18 (AC-17): enduro In Progress still starts the pipeline via to_analyse alias. +# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_tc18_enduro_in_progress_still_starts_via_alias(): + from src.webhooks.plane import handle_issue_updated + + with patch("src.plane_sync.httpx.get") as mock_get, \ + patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) as mock_start, \ + patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) as mock_verdict: + mock_get.return_value = _fake_resp(_states_response(_ENDURO_BASE)) + # enduro never created 'To Analyse' -> to_analyse aliases In Progress (ip-u). + data = {"id": "et-issue", "state": {"id": "ip-u", "name": "In Progress"}} + await handle_issue_updated(data, ENDURO_PROJECT_ID) + + mock_start.assert_called_once() + mock_verdict.assert_not_called() diff --git a/tests/test_plane_status_model.py b/tests/test_plane_status_model.py new file mode 100644 index 0000000..268dbf1 --- /dev/null +++ b/tests/test_plane_status_model.py @@ -0,0 +1,152 @@ +"""ORCH-066: the meaningful Plane status model (layer B) — unit coverage. + +These tests pin the layer-B behaviour WITHOUT touching layer A (the stage +machine). httpx is mocked; no network. + + * TC-03 (AC-3) — the analyst start/resume indicates `Analysis`, not In Progress. + * TC-05 (AC-5) — entering the `review` stage indicates `Code-Review`. + * TC-14 (AC-14) — set_issue_needs_input is unchanged (still PATCHes Needs Input). + * TC-22 (AC-21) — STAGE_TRANSITIONS (layer A) is byte-identical (explicit pin). + * TC-23 (AC-22) — QG_CHECKS registry + check_deploy_status contract unchanged. +""" + +import os + +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") + +from unittest.mock import patch, MagicMock # noqa: E402 + +from src import plane_sync as PS # noqa: E402 + + +# A per-project state map that DEFINES the new ORCH-066 statuses with distinct +# UUIDs, so we can prove the dedicated status (not the base alias) is used. +_STATES_WITH_NEW = { + "in_progress": "ip-uuid", + "review": "review-uuid", + "in_review": "inrev-uuid", + "needs_input": "ni-uuid", + "done": "done-uuid", + "analysis": "analysis-uuid", + "code_review": "codereview-uuid", + "awaiting_deploy": "awaiting-uuid", + "deploying": "deploying-uuid", + "monitoring": "monitoring-uuid", +} + + +def _patch_resolve(states): + """Patch find_issue_id + _resolve_project_id + get_project_states so a + set_issue_* helper reaches the PATCH with a known per-project state map.""" + return ( + patch("src.plane_sync.httpx.patch"), + patch("src.plane_sync.find_issue_id", return_value="issue-uuid"), + patch("src.plane_sync._resolve_project_id", return_value="proj-1"), + patch("src.plane_sync.get_project_states", return_value=states), + ) + + +def _run_setter(setter, states): + p_patch, p_find, p_res, p_states = _patch_resolve(states) + with p_patch as mock_patch, p_find, p_res, p_states: + resp = MagicMock() + resp.raise_for_status.return_value = None + mock_patch.return_value = resp + setter("ET-1") + return mock_patch + + +# --------------------------------------------------------------------------- +# TC-03 (AC-3): analyst start/resume indicates Analysis. +# --------------------------------------------------------------------------- +def test_tc03_set_issue_analysis_patches_analysis_uuid(): + mock_patch = _run_setter(PS.set_issue_analysis, _STATES_WITH_NEW) + # The dedicated Analysis UUID is used (NOT the in_progress base alias). + assert mock_patch.call_args.kwargs["json"]["state"] == "analysis-uuid" + assert mock_patch.call_args.kwargs["json"]["state"] != _STATES_WITH_NEW["in_progress"] + + +def test_tc03_analysis_aliases_in_progress_when_absent(): + # A project without the Analysis status -> get_project_states already aliased + # 'analysis' onto its in_progress UUID, so the PATCH degrades gracefully. + aliased = dict(_STATES_WITH_NEW) + aliased["analysis"] = aliased["in_progress"] + mock_patch = _run_setter(PS.set_issue_analysis, aliased) + assert mock_patch.call_args.kwargs["json"]["state"] == aliased["in_progress"] + + +# --------------------------------------------------------------------------- +# TC-05 (AC-5): the review stage indicates Code-Review. +# --------------------------------------------------------------------------- +def test_tc05_review_stage_maps_to_code_review(): + # Both the stage->state-key map and the stage-visibility map point review at + # the new code_review logical key (layer B only). + assert PS._STAGE_TO_STATE_KEY["review"] == "code_review" + assert PS.STAGE_VISIBILITY_STATE["review"] == "code_review" + + +def test_tc05_set_issue_stage_state_review_patches_code_review_uuid(): + p_patch, p_find, p_res, p_states = _patch_resolve(_STATES_WITH_NEW) + with p_patch as mock_patch, p_find, p_res, p_states: + resp = MagicMock() + resp.raise_for_status.return_value = None + mock_patch.return_value = resp + PS.set_issue_stage_state("ET-1", "review") + assert mock_patch.call_args.kwargs["json"]["state"] == "codereview-uuid" + + +def test_tc05_set_issue_code_review_helper_patches_code_review_uuid(): + mock_patch = _run_setter(PS.set_issue_code_review, _STATES_WITH_NEW) + assert mock_patch.call_args.kwargs["json"]["state"] == "codereview-uuid" + + +# --------------------------------------------------------------------------- +# TC-14 (AC-14): Needs Input behaviour unchanged. +# --------------------------------------------------------------------------- +def test_tc14_needs_input_unchanged(): + mock_patch = _run_setter(PS.set_issue_needs_input, _STATES_WITH_NEW) + assert mock_patch.call_args.kwargs["json"]["state"] == "ni-uuid" + + +# --------------------------------------------------------------------------- +# TC-22 (AC-21): STAGE_TRANSITIONS (layer A) is byte-identical. ORCH-066 changes +# ONLY layer B — the machine must not move. +# --------------------------------------------------------------------------- +def test_tc22_stage_transitions_unchanged(): + from src.stages import STAGE_TRANSITIONS + assert STAGE_TRANSITIONS == { + "created": {"next": "analysis", "agent": "analyst", "qg": None}, + "analysis": {"next": "architecture", "agent": "architect", "qg": "check_analysis_approved"}, + "architecture": {"next": "development", "agent": "developer", "qg": "check_architecture_done"}, + "development": {"next": "review", "agent": "reviewer", "qg": "check_ci_green"}, + "review": {"next": "testing", "agent": "tester", "qg": "check_reviewer_verdict"}, + "testing": {"next": "deploy-staging", "agent": "deployer", "qg": "check_tests_passed"}, + "deploy-staging": {"next": "deploy", "agent": "deployer", "qg": "check_staging_status"}, + "deploy": {"next": "done", "agent": None, "qg": "check_deploy_status"}, + "done": {"next": None, "agent": None, "qg": None}, + } + + +# --------------------------------------------------------------------------- +# TC-23 (AC-22): QG_CHECKS registry + check_deploy_status contract unchanged. +# --------------------------------------------------------------------------- +def test_tc23_qg_checks_registry_unchanged(): + from src.qg.checks import QG_CHECKS + assert set(QG_CHECKS.keys()) == { + "check_analysis_approved", "check_analysis_complete", "check_architecture_done", + "check_ci_green", "check_review_approved", "check_tests_passed", + "check_reviewer_verdict", "check_tests_local", "check_deploy_status", + "check_staging_status", "check_branch_mergeable", "check_staging_image_fresh", + "check_security_gate", # ORCH-022 integ: security-gate registered + } + + +def test_tc23_check_deploy_status_signature_unchanged(): + import inspect + from src.qg.checks import check_deploy_status, QG_CHECKS + # Registry still points at the same callable. + assert QG_CHECKS["check_deploy_status"] is check_deploy_status + # (repo, work_item_id, branch=None) -> tuple[bool, str] contract intact. + params = list(inspect.signature(check_deploy_status).parameters) + assert params == ["repo", "work_item_id", "branch"] diff --git a/tests/test_plane_to_analyse_resume.py b/tests/test_plane_to_analyse_resume.py new file mode 100644 index 0000000..8394c9d --- /dev/null +++ b/tests/test_plane_to_analyse_resume.py @@ -0,0 +1,114 @@ +"""ORCH-066: To Analyse resume semantics (F-1 status-only model). + +`handle_status_start` forks on (existing task?) + (active job?): + + * TC-02 (AC-2, BR-11) — an EXISTING task with NO active job + To Analyse -> + RELAUNCH the current stage's agent (the analyst resumes from Needs Input); + NO second task is created; the issue is re-indicated `Analysis`. + * TC-04 (AC-4) — an EXISTING task WITH an active job + To Analyse -> + busy-guard: NO relaunch (no double launch). + +handle_status_start is exercised directly; enqueue_job + Plane side-effects are +mocked. A real isolated sqlite DB backs get_task_by_plane_id / the job guard. +""" + +import os +import tempfile + +import pytest + +_test_db = os.path.join(tempfile.gettempdir(), "test_orch066_to_analyse_resume.db") +os.environ["ORCH_DB_PATH"] = _test_db +os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir() +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") + +from unittest.mock import patch, AsyncMock, MagicMock # noqa: E402 + +import src.db as _db # noqa: E402 +from src.db import init_db, get_db # noqa: E402 +from src.webhooks.plane import handle_status_start # noqa: E402 + + +@pytest.fixture(autouse=True) +def fresh_db(monkeypatch): + monkeypatch.setattr(_db.settings, "db_path", _test_db) + if os.path.exists(_test_db): + os.unlink(_test_db) + init_db() + yield + if os.path.exists(_test_db): + os.unlink(_test_db) + + +def _make_task(plane_id="resume-1", stage="analysis", repo="enduro-trails", + branch="feature/ET-001-x", wi="ET-001"): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) " + "VALUES (?, ?, ?, ?, ?)", + (plane_id, wi, repo, branch, stage), + ) + tid = cur.lastrowid + conn.commit() + conn.close() + return tid + + +def _count(plane_id): + conn = get_db() + n = conn.execute("SELECT COUNT(*) FROM tasks WHERE plane_id=?", (plane_id,)).fetchone()[0] + conn.close() + return n + + +# --------------------------------------------------------------------------- +# TC-02 (AC-2 / BR-11): existing task, no active job -> RELAUNCH (resume), no dup. +# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_tc02_to_analyse_resume_relaunches_analyst_no_duplicate(): + _make_task("resume-1", stage="analysis") + data = {"id": "resume-1", "state": {"id": "ip-uuid", "name": "To Analyse"}} + + with patch("src.webhooks.plane.enqueue_job", return_value=7) as mock_enqueue, \ + patch("src.webhooks.plane.start_pipeline", new_callable=AsyncMock) as mock_start, \ + patch("src.plane_sync.add_comment", MagicMock()), \ + patch("src.plane_sync.set_issue_analysis") as mock_analysis: + await handle_status_start(data, "proj-1") + + # No new pipeline start (it is a resume, not a fresh task). + mock_start.assert_not_called() + assert _count("resume-1") == 1 # NO duplicate task + # The current stage's agent (analyst) was relaunched exactly once. + assert mock_enqueue.call_count == 1 + assert mock_enqueue.call_args.args[0] == "analyst" + # AC-3: the resumed analysis stage is re-indicated as Analysis. + mock_analysis.assert_called_once_with("ET-001") + + +# --------------------------------------------------------------------------- +# TC-04 (AC-4): existing task WITH active job -> busy-guard, NO relaunch. +# --------------------------------------------------------------------------- +@pytest.mark.asyncio +async def test_tc04_to_analyse_with_active_job_does_not_relaunch(): + tid = _make_task("resume-2", stage="analysis") + # Seed an active (queued) job so has_active_job_for_task reports busy. + conn = get_db() + conn.execute( + "INSERT INTO jobs (agent, repo, task_id, status) VALUES (?, ?, ?, 'queued')", + ("analyst", "enduro-trails", tid), + ) + conn.commit() + conn.close() + + data = {"id": "resume-2", "state": {"id": "ip-uuid", "name": "To Analyse"}} + with patch("src.webhooks.plane.enqueue_job", return_value=9) as mock_enqueue, \ + patch("src.webhooks.plane.start_pipeline", new_callable=AsyncMock) as mock_start, \ + patch("src.plane_sync.add_comment", MagicMock()), \ + patch("src.plane_sync.set_issue_analysis") as mock_analysis: + await handle_status_start(data, "proj-1") + + mock_start.assert_not_called() + mock_enqueue.assert_not_called() # busy-guard held: NO double launch + mock_analysis.assert_not_called() + assert _count("resume-2") == 1 diff --git a/tests/test_plane_webhook.py b/tests/test_plane_webhook.py index 961001f..ec73c30 100644 --- a/tests/test_plane_webhook.py +++ b/tests/test_plane_webhook.py @@ -47,13 +47,18 @@ UNKNOWN_PLANE_ID = "deadbeef-0000-0000-0000-000000000000" _PROJECT_STATES = { ENDURO_PLANE_ID: { "in_progress": "b873d9eb-993c-48cd-97ac-99a9b1623967", + # ORCH-066: To Analyse is the start trigger; absent -> aliases in_progress. + "to_analyse": "b873d9eb-993c-48cd-97ac-99a9b1623967", "approved": "a519a341-dada-4a91-8910-7604f82b79c5", "rejected": "ba958f3c-5db5-461d-8f82-89425e413b97", + "cancelled": "b1cae7f9-961d-4889-a179-f3acea697d17", }, ORCH_PLANE_ID: { "in_progress": "e331bfb3-e17e-4699-ba48-4abb89c21b7b", + "to_analyse": "e331bfb3-e17e-4699-ba48-4abb89c21b7b", "approved": "63f2c8fe-dcda-4ace-952f-dd88bd0118ff", "rejected": "4c769e90-bf80-4a52-b97a-e1c84904bfc3", + "cancelled": "59d1d210-8e3a-4a83-930a-cbc5dbf6ad85", }, } @@ -219,3 +224,38 @@ def test_prefixes_independent_per_project(mock_branch, mock_docs, mock_launcher) assert rows["o1"] == "ORCH-001" assert rows["o2"] == "ORCH-002" assert rows["e1"] == "ET-001" + + +# --------------------------------------------------------------------------- +# ORCH-066 TC-15 (AC-15): Cancelled is a valid human exit — the orchestrator +# performs NO advance/rollback (indication, not control). +# --------------------------------------------------------------------------- +@patch("src.webhooks.plane.handle_verdict", new_callable=AsyncMock) +@patch("src.webhooks.plane.handle_status_start", new_callable=AsyncMock) +@patch("src.webhooks.plane.launcher") +def test_cancelled_state_does_no_pipeline_action(mock_launcher, mock_start, mock_verdict): + cancelled = _PROJECT_STATES[ORCH_PLANE_ID]["cancelled"] + resp = client.post( + "/webhook/plane", + json={ + "event": "issue", + "action": "updated", + "data": { + "id": "cancel-1", + "name": "A cancelled work item", + "description_stripped": "This is a sufficiently long description.", + "project": ORCH_PLANE_ID, + "state": {"id": cancelled, "name": "Cancelled", "group": "cancelled"}, + }, + }, + ) + assert resp.status_code == 200 + # Neither the start nor the verdict (advance/rollback) handler ran. + mock_start.assert_not_called() + mock_verdict.assert_not_called() + mock_launcher.launch.assert_not_called() + # No task created off a Cancelled transition. + conn = get_db() + task = conn.execute("SELECT * FROM tasks WHERE plane_id='cancel-1'").fetchone() + conn.close() + assert task is None diff --git a/tests/test_post_deploy_integration.py b/tests/test_post_deploy_integration.py index 7e1e8f6..beecc26 100644 --- a/tests/test_post_deploy_integration.py +++ b/tests/test_post_deploy_integration.py @@ -47,6 +47,9 @@ def silence_side_effects(monkeypatch): "send_telegram", "plane_notify_stage", "plane_notify_qg", "plane_add_comment", "set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress", "set_issue_blocked", "set_issue_done", + # ORCH-066 status setters. + "set_issue_analysis", "set_issue_awaiting_deploy", "set_issue_deploying", + "set_issue_monitoring", ): monkeypatch.setattr(stage_engine, name, MagicMock()) @@ -242,6 +245,81 @@ def test_finished_window_tick_is_noop(monkeypatch): probe.assert_not_called() +# --------------------------------------------------------------------------- +# ORCH-066 TC-10 (AC-10): HEALTHY + window exhausted -> Plane state Done. +# --------------------------------------------------------------------------- +def test_orch066_tc10_clean_window_close_sets_done(monkeypatch): + monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True) + monkeypatch.setattr(post_deploy.settings, "post_deploy_window_s", 30) + monkeypatch.setattr(post_deploy.settings, "post_deploy_interval_s", 30) # budget=1 + monkeypatch.setattr( + post_deploy, "probe_signals", + lambda url: post_deploy.ProbeResult(True, 2, 0, "ok"), + ) + task_id = _make_task("done") + post_deploy.write_marker("orchestrator", "ORCH-021", post_deploy.ARMED, "armed") + stage_engine.run_post_deploy_monitor( + {"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "post-deploy-monitor"} + ) + # Clean window close -> terminal Done indicated on Plane; window marked done. + stage_engine.set_issue_done.assert_called_once_with("ORCH-021") + stage_engine.set_issue_blocked.assert_not_called() + assert post_deploy.has_marker("orchestrator", "ORCH-021", post_deploy.DONE) + # No follow-up tick once the window closed. + assert _jobs("post-deploy-monitor") == [] + + +# --------------------------------------------------------------------------- +# ORCH-066 TC-11 (AC-11): DEGRADED -> Plane state Blocked (self-hosting alert). +# --------------------------------------------------------------------------- +def test_orch066_tc11_degraded_sets_blocked(monkeypatch): + monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True) + monkeypatch.setattr(post_deploy.settings, "post_deploy_fail_threshold", 1) + monkeypatch.setattr(post_deploy.settings, "post_deploy_window_s", 30) + monkeypatch.setattr(post_deploy.settings, "post_deploy_interval_s", 30) + monkeypatch.setattr( + post_deploy, "probe_signals", + lambda url: post_deploy.ProbeResult(False, 2, 2, "down"), + ) + monkeypatch.setattr(stage_engine, "_notify_post_deploy", MagicMock()) + task_id = _make_task("done") + post_deploy.write_marker("orchestrator", "ORCH-021", post_deploy.ARMED, "armed") + stage_engine.run_post_deploy_monitor( + {"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "post-deploy-monitor"} + ) + # DEGRADED -> Blocked indication (NOT Done); window finalised. + stage_engine.set_issue_blocked.assert_called_once_with("ORCH-021") + stage_engine.set_issue_done.assert_not_called() + assert post_deploy.has_marker("orchestrator", "ORCH-021", post_deploy.DONE) + + +# --------------------------------------------------------------------------- +# ORCH-066 TC-12 (AC-12): a self-hosting tick NEVER restarts/rolls back prod — +# the Blocked indication is the ONLY mutation (ORCH-021 BR-5 preserved). +# --------------------------------------------------------------------------- +def test_orch066_tc12_self_tick_never_restarts_prod(monkeypatch): + monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True) + monkeypatch.setattr(post_deploy.settings, "post_deploy_auto_rollback", True) + monkeypatch.setattr(post_deploy.settings, "post_deploy_fail_threshold", 1) + monkeypatch.setattr(post_deploy.settings, "post_deploy_window_s", 30) + monkeypatch.setattr(post_deploy.settings, "post_deploy_interval_s", 30) + monkeypatch.setattr( + post_deploy, "probe_signals", + lambda url: post_deploy.ProbeResult(False, 2, 2, "down"), + ) + monkeypatch.setattr(stage_engine, "_notify_post_deploy", MagicMock()) + # The rollback hook (the only restart-capable path) MUST stay untouched for self. + rollback = MagicMock(return_value=(0, "ok")) + monkeypatch.setattr(post_deploy, "run_rollback", rollback) + task_id = _make_task("done") + post_deploy.write_marker("orchestrator", "ORCH-021", post_deploy.ARMED, "armed") + stage_engine.run_post_deploy_monitor( + {"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "post-deploy-monitor"} + ) + rollback.assert_not_called() # never restarts/rolls back the prod self-container + stage_engine.set_issue_blocked.assert_called_once_with("ORCH-021") # indication only + + # --------------------------------------------------------------------------- # TC-20 — /queue observability block # --------------------------------------------------------------------------- diff --git a/tests/test_qg_registry_snapshot.py b/tests/test_qg_registry_snapshot.py index 5270bbc..0067f7b 100644 --- a/tests/test_qg_registry_snapshot.py +++ b/tests/test_qg_registry_snapshot.py @@ -30,6 +30,7 @@ _EXPECTED_QGS = { "check_staging_status", "check_branch_mergeable", # ORCH-043 merge-gate (deploy-staging -> deploy edge) "check_staging_image_fresh", # ORCH-058 image-freshness sub-gate (same edge) + "check_security_gate", # ORCH-022 security sub-gate (same edge, run FIRST) } diff --git a/tests/test_qg_security.py b/tests/test_qg_security.py new file mode 100644 index 0000000..408c6e5 --- /dev/null +++ b/tests/test_qg_security.py @@ -0,0 +1,113 @@ +"""ORCH-022 / TC-13..TC-15: the security-gate QG wrapper + registry wiring. + +Covers the thin ``check_security_gate`` registry wrapper in src/qg/checks.py (its +conditionality fast-paths) and that the new check is registered + dispatched by +``_run_qg``. The deterministic core (scan / verdict / frontmatter) is covered in +tests/test_security_gate.py. +""" + +import os + +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") + +from src import security_gate as sg # noqa: E402 +from src.qg import checks as qg # noqa: E402 +from src.qg.checks import QG_CHECKS, check_security_gate # noqa: E402 + +_WI = "ORCH-022" +_BRANCH = "feature/ORCH-022-x" + + +# --------------------------------------------------------------------------- +# TC-13 — non-self repo with empty scope -> N/A fast pass (no scanner run). +# --------------------------------------------------------------------------- +def test_tc13_non_self_repo_empty_scope_is_na(monkeypatch): + """TC-13: a non-self repo with an empty scope -> (True, 'security-gate N/A + for ') immediately, WITHOUT invoking the scanners.""" + monkeypatch.setattr(sg.settings, "security_gate_enabled", True) + monkeypatch.setattr(sg.settings, "security_gate_repos", "") + + called = {"scan": False} + + def _should_not_run(*a, **k): + called["scan"] = True + raise AssertionError("scanner must not run for an N/A repo") + + monkeypatch.setattr(sg, "scan_secrets", _should_not_run) + monkeypatch.setattr(sg, "audit_dependencies", _should_not_run) + + ok, reason = check_security_gate("enduro-trails", _WI, _BRANCH) + assert ok is True + assert "N/A" in reason + assert "enduro-trails" in reason + assert called["scan"] is False + + +# --------------------------------------------------------------------------- +# TC-14 — kill-switch disabled -> no-op pass. +# --------------------------------------------------------------------------- +def test_tc14_disabled_is_noop_pass(monkeypatch): + """TC-14: ORCH_SECURITY_GATE_ENABLED=false -> no-op pass (True), scanners untouched.""" + monkeypatch.setattr(sg.settings, "security_gate_enabled", False) + + def _should_not_run(*a, **k): + raise AssertionError("scanner must not run when the gate is disabled") + + monkeypatch.setattr(sg, "scan_secrets", _should_not_run) + monkeypatch.setattr(sg, "audit_dependencies", _should_not_run) + + ok, reason = check_security_gate("orchestrator", _WI, _BRANCH) + assert ok is True + assert "disabled" in reason.lower() + + +# --------------------------------------------------------------------------- +# TC-15 — registered in QG_CHECKS + dispatched by _run_qg. +# --------------------------------------------------------------------------- +def test_tc15_registered_in_qg_checks(): + """TC-15a: the new check is registered and callable.""" + assert "check_security_gate" in QG_CHECKS + assert QG_CHECKS["check_security_gate"] is check_security_gate + assert callable(QG_CHECKS["check_security_gate"]) + + +def test_tc15_dispatched_by_run_qg(monkeypatch): + """TC-15b: _run_qg routes 'check_security_gate' with the (repo, work_item_id, + branch) signature to the registered wrapper.""" + from src import stage_engine + + captured = {} + + def _fake(repo, work_item_id, branch): + captured["args"] = (repo, work_item_id, branch) + return True, "ok" + + monkeypatch.setitem(stage_engine.QG_CHECKS, "check_security_gate", _fake) + passed, reason = stage_engine._run_qg("check_security_gate", "orchestrator", _WI, _BRANCH) + assert passed is True + assert captured["args"] == ("orchestrator", _WI, _BRANCH) + + +def test_security_gate_applies_scope(monkeypatch): + """Conditionality matrix mirrors merge_gate_applies / image_freshness_applies.""" + monkeypatch.setattr(sg.settings, "security_gate_enabled", True) + # Empty scope -> only the self-hosting repo. + monkeypatch.setattr(sg.settings, "security_gate_repos", "") + assert sg.security_gate_applies("orchestrator") is True + assert sg.security_gate_applies("enduro-trails") is False + # Explicit CSV scope -> only the listed repos (case-insensitive). + monkeypatch.setattr(sg.settings, "security_gate_repos", "enduro-trails, foo") + assert sg.security_gate_applies("enduro-trails") is True + assert sg.security_gate_applies("orchestrator") is False + # Kill-switch wins over everything. + monkeypatch.setattr(sg.settings, "security_gate_enabled", False) + assert sg.security_gate_applies("orchestrator") is False + + +def test_qg_wrapper_delegates(monkeypatch): + """The QG wrapper delegates to security_gate.check_security_gate verbatim.""" + monkeypatch.setattr(sg, "check_security_gate", lambda r, w, b: (False, "delegated FAIL")) + ok, reason = check_security_gate("orchestrator", _WI, _BRANCH) + assert ok is False + assert reason == "delegated FAIL" diff --git a/tests/test_reconciler.py b/tests/test_reconciler.py index 8e47314..f28489a 100644 --- a/tests/test_reconciler.py +++ b/tests/test_reconciler.py @@ -572,7 +572,7 @@ def test_tc060_08_no_gate_call_on_escalated(monkeypatch): # --------------------------------------------------------------------------- def test_tc060_09_f2_does_not_replay_blocked(monkeypatch): states = { - "in_progress": "IP", "approved": "AP", "rejected": "RJ", + "in_progress": "IP", "to_analyse": "IP", "approved": "AP", "rejected": "RJ", "blocked": "BL", "needs_input": "NI", } monkeypatch.setattr( @@ -680,3 +680,67 @@ def test_tc060_subflag_disables_only_guard2(monkeypatch): assert _stage_of(blocked) == "review" # Guard 2 muted assert _stage_of(escalated) == "development" # Guard 1 still skips + + +# --------------------------------------------------------------------------- +# ORCH-066 TC-21 (AC-20 / BR-13): Guard 2 skips the active orchestrator waits +# (Awaiting Deploy / Deploying / Monitoring after Deploy) ONLY when they are +# DISTINCT statuses — an aliased (enduro) project must NOT widen the skip-set. +# --------------------------------------------------------------------------- +def _guard2(monkeypatch, states, cur_state): + """Drive _is_blocked_or_needs_input with a chosen project state map + the + issue's current Plane state uuid.""" + monkeypatch.setattr(reconciler_mod, "get_project_states", + MagicMock(return_value=states)) + monkeypatch.setattr(reconciler_mod, "fetch_issue_state", + MagicMock(return_value=cur_state)) + monkeypatch.setattr( + reconciler_mod.projects, "get_project_by_repo", + MagicMock(return_value=MagicMock(plane_project_id="proj-test")), + ) + monkeypatch.setattr( + reconciler_mod.settings, "reconcile_skip_blocked_enabled", True + ) + task = {"id": 1, "repo": "orchestrator", "plane_id": "iss-1"} + return Reconciler()._is_blocked_or_needs_input(task) + + +# orchestrator has the three new statuses as DISTINCT UUIDs. +_DISTINCT_STATES = { + "backlog": "bl-u", "todo": "td-u", "in_progress": "ip-u", "in_review": "inrev-u", + "review": "rev-u", "architecture": "arch-u", "development": "dev-u", + "testing": "test-u", "approved": "appr-u", "rejected": "rej-u", "done": "done-u", + "blocked": "blocked-u", "needs_input": "ni-u", + "awaiting_deploy": "await-u", "deploying": "deploying-u", "monitoring": "monitor-u", +} + + +def test_tc21_guard2_skips_distinct_active_waits(monkeypatch): + # Each active-wait status (distinct UUID) -> skipped (not revived). + assert _guard2(monkeypatch, _DISTINCT_STATES, "await-u") is True + assert _guard2(monkeypatch, _DISTINCT_STATES, "deploying-u") is True + assert _guard2(monkeypatch, _DISTINCT_STATES, "monitor-u") is True + # Explicit human gates still skip. + assert _guard2(monkeypatch, _DISTINCT_STATES, "blocked-u") is True + assert _guard2(monkeypatch, _DISTINCT_STATES, "ni-u") is True + # A normal working state is NOT skipped (gets reconciled). + assert _guard2(monkeypatch, _DISTINCT_STATES, "ip-u") is False + + +def test_tc21_guard2_aliased_waits_do_not_widen_skipset(monkeypatch): + # enduro: the new keys alias onto base working statuses -> they must NOT make + # F-1 skip a genuinely In Progress / In Review / Done task (anti-regress). + aliased = { + "backlog": "bl-u", "todo": "td-u", "in_progress": "ip-u", "in_review": "inrev-u", + "review": "rev-u", "architecture": "arch-u", "development": "dev-u", + "testing": "test-u", "approved": "appr-u", "rejected": "rej-u", "done": "done-u", + "blocked": "blocked-u", "needs_input": "ni-u", + # aliased onto base UUIDs (project did not create dedicated statuses). + "awaiting_deploy": "inrev-u", "deploying": "ip-u", "monitoring": "done-u", + } + # In Progress / In Review / Done are base working states -> NOT skipped. + assert _guard2(monkeypatch, aliased, "ip-u") is False + assert _guard2(monkeypatch, aliased, "inrev-u") is False + assert _guard2(monkeypatch, aliased, "done-u") is False + # The explicit human gates still skip. + assert _guard2(monkeypatch, aliased, "blocked-u") is True diff --git a/tests/test_reconciler_plane.py b/tests/test_reconciler_plane.py index e68d498..51c96c7 100644 --- a/tests/test_reconciler_plane.py +++ b/tests/test_reconciler_plane.py @@ -59,6 +59,9 @@ def single_project(monkeypatch): reconciler_mod, "get_project_states", lambda pid: { "in_progress": _IN_PROGRESS, + # ORCH-066: To Analyse is the F-2 start/resume trigger; absent in this + # project -> aliases in_progress (real get_project_states fallback). + "to_analyse": _IN_PROGRESS, "approved": _APPROVED, "rejected": _REJECTED, }, @@ -114,6 +117,46 @@ def test_tc11_in_progress_without_task_starts_pipeline(monkeypatch, single_proje verdict.assert_not_called() +# --------------------------------------------------------------------------- +# ORCH-066 TC-20 (AC-19): F-2 polls the DISTINCT To Analyse status and routes it +# to handle_status_start (a lost start/resume webhook is recovered). +# --------------------------------------------------------------------------- +def test_tc20_distinct_to_analyse_polled_and_routed(monkeypatch): + _TO_ANALYSE = "uuid-to-analyse" # distinct from in_progress + monkeypatch.setattr( + reconciler_mod, "get_project_states", + lambda pid: { + "in_progress": _IN_PROGRESS, + "to_analyse": _TO_ANALYSE, # dedicated status created + "approved": _APPROVED, + "rejected": _REJECTED, + }, + ) + monkeypatch.setattr( + reconciler_mod.projects, "PROJECTS", + [SimpleNamespace(plane_project_id="proj-1", repo="enduro-trails", + work_item_prefix="ET")], + ) + start, verdict = _patch_handlers(monkeypatch) + + polled = {} + + def fake_list(pid, states): + polled["states"] = list(states) + return [{"id": "iss-ta", "state": {"id": _TO_ANALYSE}, "updated_at": _OLD_TS, + "name": "Lost start"}] + + monkeypatch.setattr(reconciler_mod, "list_issues_by_state", fake_list) + + Reconciler().reconcile_plane_once() + + # The To Analyse UUID is in the polled set and routed to start (not verdict). + assert _TO_ANALYSE in polled["states"] + assert start.call_count == 1 + assert start.call_args.args[0]["id"] == "iss-ta" + verdict.assert_not_called() + + # --------------------------------------------------------------------------- # TC-12: Approved with an existing task, no active job -> handle_verdict(True). # --------------------------------------------------------------------------- @@ -279,7 +322,10 @@ def test_tc17_polls_all_projects_resolves_states_per_project(monkeypatch): def fake_states(pid): states_calls.append(pid) - return {"in_progress": _IN_PROGRESS, "approved": _APPROVED, "rejected": _REJECTED} + return { + "in_progress": _IN_PROGRESS, "to_analyse": _IN_PROGRESS, + "approved": _APPROVED, "rejected": _REJECTED, + } def fake_issues(pid, states): issues_calls.append((pid, tuple(states))) @@ -295,3 +341,346 @@ 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, + # ORCH-066 integ: real get_project_states() always exposes to_analyse, + # aliased to in_progress on projects without the dedicated status. + "to_analyse": _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, + "to_analyse": _IN_PROGRESS, # ORCH-066 integ: always present + "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 diff --git a/tests/test_security_gate.py b/tests/test_security_gate.py new file mode 100644 index 0000000..499ca3b --- /dev/null +++ b/tests/test_security_gate.py @@ -0,0 +1,324 @@ +"""ORCH-022 / TC-01..TC-12: the security-gate leaf module (src/security_gate.py). + +These exercise the DETERMINISTIC core: the pure classifier / verdict / frontmatter +helpers (no binaries needed) plus scan_secrets / audit_dependencies with the +external scanners (gitleaks / pip-audit) mocked at subprocess.run. The integration +of the gate into advance_stage is covered in tests/test_stage_engine_security_gate.py; +the QG registry wiring in tests/test_qg_security.py. + +Contract under test (ADR-001 §7): + * secrets are UNCONDITIONAL + offline -> a found secret blocks; a tool error is + fail-closed (FAIL); + * dependency audit is best-effort -> blocking only at/over the severity threshold; + UNKNOWN / below-threshold -> warning; an unreachable feed degrades fail-open + + warning by default, fail-closed only when configured; + * the machine verdict lives ONLY in the YAML frontmatter (read-back == written); + * never-raise: any internal error -> (False, reason), no exception escapes. +""" + +import os +import subprocess + +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") + +import pytest # noqa: E402 + +from src import security_gate as sg # noqa: E402 + +_REPO = "orchestrator" +_BRANCH = "feature/ORCH-022-x" +_WI = "ORCH-022" + + +# --------------------------------------------------------------------------- +# Builders for the result containers (no binaries needed). +# --------------------------------------------------------------------------- +def _clean_secret(): + return sg.SecretScanResult(status="clean", detail="no secrets found") + + +def _found_secret(n=1): + findings = [ + {"file": "src/config.py", "rule": "generic-api-key", "line": 12 + i, "match": "abcd…yz"} + for i in range(n) + ] + return sg.SecretScanResult(status="found", findings=findings, detail=f"{n} secret(s)") + + +def _ok_deps(findings=None): + return sg.DepAuditResult(status="ok", findings=findings or [], detail="ok") + + +def _degraded_deps(): + return sg.DepAuditResult(status="degraded", detail="pip-audit feed unavailable") + + +def _verdict(secret, dep, *, secrets_block=True, dep_block_severity="HIGH", dep_fail_closed=False): + return sg.compute_verdict( + secret, dep, + secrets_block=secrets_block, + dep_block_severity=dep_block_severity, + dep_fail_closed=dep_fail_closed, + ) + + +# --------------------------------------------------------------------------- +# TC-01 / TC-02 / TC-03 — secret-scanning (FR-1 / AC-1..AC-3) +# --------------------------------------------------------------------------- +def test_tc01_secret_in_diff_fails(): + """TC-01: a planted secret -> FAIL, secrets_found>=1, reason names the finding.""" + fields = _verdict(_found_secret(1), _ok_deps()) + assert fields["security_status"] == "FAIL" + assert fields["secrets_found"] >= 1 + # The reason must name the finding substance (rule + file), not just "FAIL". + assert "generic-api-key" in fields["reason"] + assert "src/config.py" in fields["reason"] + + +def test_tc02_clean_branch_passes(): + """TC-02: a clean branch -> PASS, secrets_found=0.""" + fields = _verdict(_clean_secret(), _ok_deps()) + assert fields["security_status"] == "PASS" + assert fields["secrets_found"] == 0 + assert fields["deps_blocking"] == 0 + + +def test_tc03_allowlisted_match_does_not_fail(monkeypatch, tmp_path): + """TC-03: an allowlisted match (placeholder / fixture) is filtered by gitleaks + (rc=0) -> scan_secrets reports clean -> PASS. The allowlist lives in the + versioned .gitleaks.toml; here we assert the gate honours gitleaks' rc=0.""" + wt = tmp_path / "wt" + wt.mkdir() + monkeypatch.setattr(sg, "ensure_worktree", lambda r, b: str(wt)) + + def _fake_run(cmd, **kwargs): + # `git fetch` and `gitleaks detect` both routed here; both "succeed clean". + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(sg.subprocess, "run", _fake_run) + res = sg.scan_secrets(_REPO, _BRANCH) + assert res.status == "clean" + fields = _verdict(res, _ok_deps()) + assert fields["security_status"] == "PASS" + + +# --------------------------------------------------------------------------- +# TC-04..TC-07 — dependency audit + thresholds (FR-2 / AC-4..AC-7) +# --------------------------------------------------------------------------- +def test_tc04_high_cve_at_high_threshold_blocks(): + """TC-04: a HIGH/CRITICAL CVE at threshold HIGH -> FAIL, deps_blocking>=1.""" + deps = _ok_deps([ + {"package": "requests", "version": "2.0.0", "id": "CVE-1", "severity": "HIGH", "fix": "2.1"}, + {"package": "urllib3", "version": "1.0.0", "id": "CVE-2", "severity": "CRITICAL", "fix": "1.1"}, + ]) + fields = _verdict(_clean_secret(), deps, dep_block_severity="HIGH") + assert fields["security_status"] == "FAIL" + assert fields["deps_blocking"] >= 1 + assert "CVE-1" in fields["reason"] or "CVE-2" in fields["reason"] + + +def test_tc05_only_medium_low_warns_passes(): + """TC-05: only MEDIUM/LOW vulns -> PASS, deps_warning>=1, findings in the body.""" + deps = _ok_deps([ + {"package": "jinja2", "version": "2.0", "id": "CVE-M", "severity": "MEDIUM", "fix": "2.1"}, + {"package": "click", "version": "7.0", "id": "CVE-L", "severity": "LOW", "fix": ""}, + ]) + fields = _verdict(_clean_secret(), deps, dep_block_severity="HIGH") + assert fields["security_status"] == "PASS" + assert fields["deps_warning"] >= 1 + assert fields["deps_blocking"] == 0 + body = sg.render_security_report(_WI, fields) + assert "CVE-M" in body and "CVE-L" in body + + +def test_tc06_threshold_config_changes_classification(): + """TC-06: severity=CRITICAL makes a HIGH CVE a warning; severity=HIGH blocks it.""" + assert sg.classify_severity("HIGH", "CRITICAL") == "warning" + assert sg.classify_severity("HIGH", "HIGH") == "block" + assert sg.classify_severity("CRITICAL", "CRITICAL") == "block" + # UNKNOWN is ALWAYS a warning, never an auto-block (anti-loop, Р-4). + assert sg.classify_severity("UNKNOWN", "LOW") == "warning" + assert sg.classify_severity("", "HIGH") == "warning" + + deps = _ok_deps([ + {"package": "x", "version": "1", "id": "CVE-H", "severity": "HIGH", "fix": ""}, + ]) + at_critical = _verdict(_clean_secret(), deps, dep_block_severity="CRITICAL") + at_high = _verdict(_clean_secret(), deps, dep_block_severity="HIGH") + assert at_critical["security_status"] == "PASS" + assert at_critical["deps_warning"] == 1 + assert at_high["security_status"] == "FAIL" + assert at_high["deps_blocking"] == 1 + + +def test_tc07_degraded_feed_failopen_default_failclosed_strict(): + """TC-07: an unreachable CVE feed degrades fail-open + warning by default (no + exception, no false FAIL); fail-closed -> FAIL only when configured.""" + default = _verdict(_clean_secret(), _degraded_deps(), dep_fail_closed=False) + assert default["security_status"] == "PASS" + assert default["deps_audit_degraded"] is True + + strict = _verdict(_clean_secret(), _degraded_deps(), dep_fail_closed=True) + assert strict["security_status"] == "FAIL" + assert strict["deps_audit_degraded"] is True + assert "fail-closed" in strict["reason"] + + +# --------------------------------------------------------------------------- +# TC-08..TC-10 — verdict / frontmatter parser + artefact (FR-3 / AC-8..AC-10) +# --------------------------------------------------------------------------- +def test_tc08_verdict_only_from_frontmatter(): + """TC-08: the verdict is read ONLY from the YAML frontmatter; prose in the body + does not influence it; the negative (FAIL) token is authoritative.""" + # Frontmatter PASS but body screams FAIL -> still PASS (prose ignored). + pass_fm = ( + "---\nsecurity_status: PASS\nsecrets_found: 0\n---\n" + "# Report\nThis build totally FAILED everything, FAIL FAIL.\n" + ) + ok, reason = sg.parse_security_status(pass_fm) + assert ok is True + assert "PASS" in reason + + # Frontmatter FAIL but body says PASS -> FAIL (negative token authoritative). + fail_fm = "---\nsecurity_status: FAIL\n---\nEverything PASS, looks great!\n" + ok, reason = sg.parse_security_status(fail_fm) + assert ok is False + assert "FAIL" in reason + + +def test_tc09_missing_or_broken_frontmatter_failclosed(): + """TC-09: no frontmatter / broken YAML / missing field -> (False, reason).""" + # No frontmatter at all. + ok, reason = sg.parse_security_status("# Just a body, no frontmatter\nPASS\n") + assert ok is False and reason + + # Frontmatter present but no security_status field. + ok, reason = sg.parse_security_status("---\nother: 1\n---\nbody\n") + assert ok is False + + # Broken YAML in the frontmatter. + ok, reason = sg.parse_security_status("---\nsecurity_status: : : [bad\n---\nbody\n") + assert ok is False + + +def test_tc10_artifact_has_valid_frontmatter_and_body(tmp_path, monkeypatch): + """TC-10: 17-security-report.md is written with valid frontmatter (all machine + fields) and a body listing the findings; read-back == the written verdict.""" + wt = tmp_path / "wt" + wt.mkdir() + monkeypatch.setattr(sg, "get_worktree_path", lambda r, b: str(wt)) + monkeypatch.setattr(sg, "ensure_worktree", lambda r, b: str(wt)) + + deps = _ok_deps([ + {"package": "requests", "version": "2.0", "id": "CVE-X", "severity": "HIGH", "fix": "2.1"}, + {"package": "click", "version": "7.0", "id": "CVE-L", "severity": "LOW", "fix": ""}, + ]) + fields = _verdict(_found_secret(1), deps, dep_block_severity="HIGH") + path = sg.write_security_report(_REPO, _WI, _BRANCH, fields) + assert os.path.isfile(path) + with open(path, encoding="utf-8") as f: + content = f.read() + + # Frontmatter carries every machine field. + for key in ("security_status", "secrets_found", "deps_blocking", "deps_warning", + "deps_audit_degraded"): + assert f"{key}:" in content + # Body lists findings. + assert "CVE-X" in content and "CVE-L" in content + # Read-back agrees with the computed status (single source of truth, AC-8). + ok, _ = sg.parse_security_status(content) + assert ok is (fields["security_status"] == "PASS") + assert ok is False # this fixture is a FAIL (secret + HIGH CVE) + + +# --------------------------------------------------------------------------- +# TC-11 / TC-12 — never-raise / timeout (FR-5/FR-6 / AC-14..AC-17) +# --------------------------------------------------------------------------- +def test_tc11_missing_binary_failclosed_never_raises(monkeypatch, tmp_path): + """TC-11: a missing scanner binary / internal exception -> error -> FAIL + (fail-closed for secrets), and the exception never propagates.""" + wt = tmp_path / "wt" + wt.mkdir() + monkeypatch.setattr(sg, "ensure_worktree", lambda r, b: str(wt)) + + def _raise_fnf(cmd, **kwargs): + # git fetch ok, gitleaks missing. + if cmd[:1] == ["git"]: + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + raise FileNotFoundError("gitleaks") + + monkeypatch.setattr(sg.subprocess, "run", _raise_fnf) + res = sg.scan_secrets(_REPO, _BRANCH) + assert res.status == "error" + fields = _verdict(res, _ok_deps()) + assert fields["security_status"] == "FAIL" # fail-closed, BR-2 + assert "fail-closed" in fields["reason"] + + # check_security_gate as a whole never raises even if everything explodes. + monkeypatch.setattr(sg, "security_gate_applies", lambda r: True) + + def _boom(*a, **k): + raise RuntimeError("kaboom") + + monkeypatch.setattr(sg, "scan_secrets", _boom) + ok, reason = sg.check_security_gate(_REPO, _WI, _BRANCH) + assert ok is False + assert "error" in reason.lower() + + +def test_tc12_timeout_is_deterministic_failclosed(monkeypatch, tmp_path): + """TC-12: exceeding the scan timeout -> a deterministic error verdict, no hang.""" + wt = tmp_path / "wt" + wt.mkdir() + monkeypatch.setattr(sg, "ensure_worktree", lambda r, b: str(wt)) + + def _timeout(cmd, **kwargs): + if cmd[:1] == ["git"]: + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + raise subprocess.TimeoutExpired(cmd, kwargs.get("timeout", 1)) + + monkeypatch.setattr(sg.subprocess, "run", _timeout) + res = sg.scan_secrets(_REPO, _BRANCH) + assert res.status == "error" + assert "timeout" in res.detail.lower() + fields = _verdict(res, _ok_deps()) + assert fields["security_status"] == "FAIL" + + # pip-audit timeout -> degrade (best-effort), not a hard error. + monkeypatch.setattr(sg, "get_worktree_path", lambda r, b: str(wt)) + (wt / "requirements.txt").write_text("requests==2.0\n") + dep = sg.audit_dependencies(_REPO, _BRANCH) + assert dep.status == "degraded" + assert "timeout" in dep.detail.lower() + + +# --------------------------------------------------------------------------- +# Parser robustness (supports the above; pure, never raises) +# --------------------------------------------------------------------------- +def test_parse_gitleaks_report_tolerant(): + assert sg.parse_gitleaks_report("") == [] + assert sg.parse_gitleaks_report("not json") == [] + assert sg.parse_gitleaks_report("{}") == [] + parsed = sg.parse_gitleaks_report( + '[{"File":"a.py","RuleID":"key","StartLine":3,"Secret":"supersecretvalue"}]' + ) + assert parsed[0]["file"] == "a.py" + assert parsed[0]["rule"] == "key" + # The secret value is masked, never re-leaked verbatim. + assert "supersecretvalue" not in parsed[0]["match"] + + +def test_parse_pip_audit_report_tolerant(): + assert sg.parse_pip_audit_report("") == [] + assert sg.parse_pip_audit_report("garbage") == [] + doc = ( + '{"dependencies":[{"name":"requests","version":"2.0",' + '"vulns":[{"id":"CVE-1","severity":"HIGH","fix_versions":["2.1"]}]}]}' + ) + parsed = sg.parse_pip_audit_report(doc) + assert parsed[0]["package"] == "requests" + assert parsed[0]["severity"] == "HIGH" + # Missing severity -> UNKNOWN. + doc2 = '{"dependencies":[{"name":"x","version":"1","vulns":[{"id":"CVE-2"}]}]}' + assert sg.parse_pip_audit_report(doc2)[0]["severity"] == "UNKNOWN" diff --git a/tests/test_stage_engine.py b/tests/test_stage_engine.py index ca3dab6..66ced68 100644 --- a/tests/test_stage_engine.py +++ b/tests/test_stage_engine.py @@ -832,6 +832,7 @@ class TestMergeGate: stage_engine, "QG_CHECKS", {**stage_engine.QG_CHECKS, "check_staging_status": _pass, + "check_security_gate": _pass, "check_branch_mergeable": _pass, "check_staging_image_fresh": _pass}, ) @@ -856,6 +857,7 @@ class TestMergeGate: stage_engine, "QG_CHECKS", {**stage_engine.QG_CHECKS, "check_staging_status": _pass, + "check_security_gate": _pass, "check_branch_mergeable": _fail("merge-lock busy")}, ) monkeypatch.setattr(stage_engine.settings, "merge_defer_delay_s", 30) @@ -883,6 +885,7 @@ class TestMergeGate: stage_engine, "QG_CHECKS", {**stage_engine.QG_CHECKS, "check_staging_status": _pass, + "check_security_gate": _pass, "check_branch_mergeable": _fail("merge-lock busy")}, ) monkeypatch.setattr(stage_engine.settings, "merge_defer_max_attempts", 3) @@ -916,6 +919,7 @@ class TestMergeGate: stage_engine, "QG_CHECKS", {**stage_engine.QG_CHECKS, "check_staging_status": _pass, + "check_security_gate": _pass, "check_branch_mergeable": _fail("rebase conflict: src/db.py")}, ) task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043", @@ -939,6 +943,7 @@ class TestMergeGate: stage_engine, "QG_CHECKS", {**stage_engine.QG_CHECKS, "check_staging_status": _pass, + "check_security_gate": _pass, "check_branch_mergeable": _fail("re-test failed after rebase: 1 failed")}, ) task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043", @@ -962,6 +967,7 @@ class TestMergeGate: stage_engine, "QG_CHECKS", {**stage_engine.QG_CHECKS, "check_staging_status": _pass, + "check_security_gate": _pass, "check_branch_mergeable": _fail("rebase conflict: src/db.py")}, ) task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043", @@ -1014,6 +1020,7 @@ class TestImageFreshnessGate: stage_engine, "QG_CHECKS", {**stage_engine.QG_CHECKS, "check_staging_status": _pass, + "check_security_gate": _pass, "check_branch_mergeable": _pass, "check_staging_image_fresh": _fail( "staging rebuild failed: health FAILED")}, @@ -1041,6 +1048,7 @@ class TestImageFreshnessGate: stage_engine, "QG_CHECKS", {**stage_engine.QG_CHECKS, "check_staging_status": _pass, + "check_security_gate": _pass, "check_branch_mergeable": _pass, "check_staging_image_fresh": _fail("provenance mismatch")}, ) @@ -1064,6 +1072,7 @@ class TestImageFreshnessGate: stage_engine, "QG_CHECKS", {**stage_engine.QG_CHECKS, "check_staging_status": _pass, + "check_security_gate": _pass, "check_branch_mergeable": _pass, "check_staging_image_fresh": _pass}, ) @@ -1089,6 +1098,7 @@ class TestImageFreshnessGate: stage_engine, "QG_CHECKS", {**stage_engine.QG_CHECKS, "check_staging_status": _pass, + "check_security_gate": _pass, "check_branch_mergeable": _pass}, ) # check_staging_image_fresh left REAL -> N/A for enduro-trails task_id = _make_task("deploy-staging", repo="enduro-trails", wi="ET-099", @@ -1160,6 +1170,7 @@ class TestStagingInfraTolerance: stage_engine, "QG_CHECKS", {**stage_engine.QG_CHECKS, "check_staging_status": _pass, + "check_security_gate": _pass, "check_branch_mergeable": _pass, "check_staging_image_fresh": _pass}, ) @@ -1232,6 +1243,7 @@ class TestStagingInfraTolerance: stage_engine, "QG_CHECKS", {**stage_engine.QG_CHECKS, "check_staging_status": _pass, + "check_security_gate": _pass, "check_branch_mergeable": _pass, "check_staging_image_fresh": _pass, "check_deploy_status": _pass}, diff --git a/tests/test_stage_engine_phase_a_cta.py b/tests/test_stage_engine_phase_a_cta.py new file mode 100644 index 0000000..b86c391 --- /dev/null +++ b/tests/test_stage_engine_phase_a_cta.py @@ -0,0 +1,105 @@ +"""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, + # ORCH-022 integ: security-gate now runs on the deploy-staging->deploy + # edge; this 059 test exercises the CTA, not the scanner (022 has its + # own tests), so pass it like the other deploy sub-gates. + "check_security_gate": _pass, + "check_staging_image_fresh": _pass}, + ) + yield + + +def _make_task(stage="deploy-staging", repo="orchestrator", + branch="feature/ORCH-059-x", wi="ORCH-059"): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) " + "VALUES (?, ?, ?, ?, ?)", + (f"plane-{wi}", wi, repo, branch, stage), + ) + task_id = cur.lastrowid + conn.commit() + conn.close() + return task_id + + +def test_tc09_phase_a_cta_requests_confirm_deploy(monkeypatch): + # Silence everything EXCEPT the two CTA channels we want to inspect. + for name in ( + "notify_stage_change", "notify_qg_failure", "plane_notify_stage", + "plane_notify_qg", "set_issue_in_review", "set_issue_needs_input", + "set_issue_in_progress", "set_issue_blocked", "set_issue_done", + ): + monkeypatch.setattr(stage_engine, name, MagicMock(), raising=False) + plane_comment = MagicMock() + telegram = MagicMock() + monkeypatch.setattr(stage_engine, "plane_add_comment", plane_comment) + monkeypatch.setattr(stage_engine, "send_telegram", telegram) + + task_id = _make_task() + res = advance_stage( + task_id, "deploy-staging", "orchestrator", "ORCH-059", + "feature/ORCH-059-x", finished_agent="deployer", + ) + + assert res.note == "self-deploy-approval-pending" + + # The Plane comment CTA mentions "Confirm Deploy" as the trigger. + plane_comment.assert_called_once() + comment_text = plane_comment.call_args.args[1] + assert "Confirm Deploy" in comment_text + # The Telegram CTA mentions "Confirm Deploy" too. + telegram.assert_called_once() + tg_text = telegram.call_args.args[0] + assert "Confirm Deploy" in tg_text + + # Neither CTA presents bare "Approved" as the deploy trigger. (The comment may + # mention Approved only to clarify it does NOT trigger; assert no instruction + # to "set status to Approved".) + assert "статус задачи на «Approved»" not in comment_text + assert "на Approved" not in tg_text diff --git a/tests/test_stage_engine_phase_b.py b/tests/test_stage_engine_phase_b.py new file mode 100644 index 0000000..2765536 --- /dev/null +++ b/tests/test_stage_engine_phase_b.py @@ -0,0 +1,141 @@ +"""ORCH-059 TC-07/08: the Phase B block in stage_engine.advance_stage initiates +the prod deploy ONLY on the confirm-deploy signal. + +Contract (AC-2, AC-3, AC-5): + * TC-07 — on (current_stage=="deploy", finished_agent is None) for the + self-hosting repo: confirm_deploy=True -> Phase B initiates; confirm_deploy + omitted/False (a plain Approved) -> a no-op that neither initiates the deploy + nor runs check_deploy_status (no false БАГ-8 rollback). + * TC-08 — idempotency: with the `initiated` marker already present, a repeated + confirm-deploy does NOT initiate again. +""" + +import os +import tempfile + +import pytest + +_test_db = os.path.join(tempfile.gettempdir(), "test_orch_phase_b.db") +os.environ["ORCH_DB_PATH"] = _test_db +os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir() +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") + +from unittest.mock import MagicMock # noqa: E402 + +import src.db as _db # noqa: E402 +from src.db import init_db, get_db # noqa: E402 +from src import stage_engine # noqa: E402 +from src import self_deploy # noqa: E402 +from src.stage_engine import advance_stage # noqa: E402 + + +@pytest.fixture(autouse=True) +def fresh_db(monkeypatch, tmp_path): + monkeypatch.setattr(_db.settings, "db_path", _test_db) + if os.path.exists(_test_db): + os.unlink(_test_db) + init_db() + monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path)) + monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path)) + monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True) + yield + + +@pytest.fixture(autouse=True) +def silence_side_effects(monkeypatch): + for name in ( + "notify_stage_change", "notify_qg_failure", "send_telegram", + "plane_notify_stage", "plane_notify_qg", "plane_add_comment", + "set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress", + "set_issue_blocked", "set_issue_done", + ): + monkeypatch.setattr(stage_engine, name, MagicMock(), raising=False) + + +def _make_task(stage, repo="orchestrator", branch="feature/ORCH-059-x", wi="ORCH-059"): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) " + "VALUES (?, ?, ?, ?, ?)", + (f"plane-{wi}", wi, repo, branch, stage), + ) + task_id = cur.lastrowid + conn.commit() + conn.close() + return task_id + + +def _stage(task_id): + conn = get_db() + row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone() + conn.close() + return row[0] + + +# --------------------------------------------------------------------------- +# TC-07: confirm-deploy initiates; plain Approved is a no-op +# --------------------------------------------------------------------------- +def test_tc07_confirm_deploy_initiates(monkeypatch): + initiate = MagicMock(return_value=(True, "ok")) + monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate) + + task_id = _make_task("deploy") + res = advance_stage( + task_id, "deploy", "orchestrator", "ORCH-059", + "feature/ORCH-059-x", finished_agent=None, confirm_deploy=True, + ) + + assert res.note == "self-deploy-initiated" + initiate.assert_called_once() + assert self_deploy.has_marker("orchestrator", "ORCH-059", self_deploy.INITIATED) + # Did NOT advance off deploy — the finalizer records the verdict later. + assert _stage(task_id) == "deploy" + + +def test_tc07_approved_without_confirm_is_noop(monkeypatch): + """A plain Approved on `deploy` (confirm_deploy defaults to False): no + initiate_deploy, no rollback, no advance — a deterministic no-op (AC-3).""" + initiate = MagicMock(return_value=(True, "ok")) + monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate) + # If check_deploy_status were (wrongly) run, it would intervene; spy to prove + # it is never invoked on this no-op path. + gate = MagicMock(return_value=(False, "FAILED")) + monkeypatch.setattr( + stage_engine, "QG_CHECKS", + {**stage_engine.QG_CHECKS, "check_deploy_status": gate}, + ) + + task_id = _make_task("deploy") + res = advance_stage( + task_id, "deploy", "orchestrator", "ORCH-059", + "feature/ORCH-059-x", finished_agent=None, # confirm_deploy omitted -> False + ) + + assert res.note == "approved-on-deploy-noop" + initiate.assert_not_called() + gate.assert_not_called() # check_deploy_status NOT run -> no false БАГ-8 + assert res.advanced is False + assert res.rolled_back_to is None + assert _stage(task_id) == "deploy" # stays put, no rollback to development + assert not self_deploy.has_marker("orchestrator", "ORCH-059", self_deploy.INITIATED) + + +# --------------------------------------------------------------------------- +# TC-08: idempotency — existing `initiated` marker -> repeat is a no-op +# --------------------------------------------------------------------------- +def test_tc08_idempotent_repeat_confirm_deploy(monkeypatch): + initiate = MagicMock(return_value=(True, "ok")) + monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate) + + task_id = _make_task("deploy") + # Pre-seed the initiated marker (a deploy already in flight). + self_deploy.write_marker("orchestrator", "ORCH-059", self_deploy.INITIATED, content="1") + + res = advance_stage( + task_id, "deploy", "orchestrator", "ORCH-059", + "feature/ORCH-059-x", finished_agent=None, confirm_deploy=True, + ) + + assert res.note == "self-deploy-already-initiated" + initiate.assert_not_called() diff --git a/tests/test_stage_engine_security_gate.py b/tests/test_stage_engine_security_gate.py new file mode 100644 index 0000000..72fd8d7 --- /dev/null +++ b/tests/test_stage_engine_security_gate.py @@ -0,0 +1,270 @@ +"""ORCH-022 / TC-16..TC-19, TC-21: the security sub-gate wired into advance_stage. + +These are integration tests over src.stage_engine.advance_stage on the +deploy-staging -> deploy edge. The security verdict is injected by patching the +QG_CHECKS registry entry (the leaf scanner logic is unit-tested in +tests/test_security_gate.py), so we exercise the ENGINE behaviour: + * FAIL -> rollback to development + enqueue developer + Plane comment + notify; + * the rollback task_desc carries the verbatim findings (ORCH-046 pattern); + * after MAX_DEVELOPER_RETRIES -> set_issue_blocked + Telegram, no bounce; + * PASS -> the pipeline advances normally (no rollback, no noisy notify); + * self-hosting safety: a FAIL never calls the deploy hook / restarts prod. + +Network/Plane/Telegram side effects are mocked at the src.stage_engine level. +""" + +import os +import tempfile + +import pytest + +_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_security_gate.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.stage_engine import advance_stage # noqa: E402 + +_BRANCH = "feature/ORCH-022-x" + + +# --------------------------------------------------------------------------- +# Fixtures (mirror tests/test_stage_engine.py) +# --------------------------------------------------------------------------- +@pytest.fixture(autouse=True) +def fresh_db(monkeypatch): + monkeypatch.setattr(_db.settings, "db_path", _test_db) + if os.path.exists(_test_db): + os.unlink(_test_db) + init_db() + yield + + +@pytest.fixture(autouse=True) +def silence_side_effects(monkeypatch): + for name in ( + "notify_stage_change", + "notify_qg_failure", + "notify_approve_requested", + "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()) + + +def _make_task(stage, repo, branch=_BRANCH, wi="ORCH-022"): + 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] + + +def _jobs(): + conn = get_db() + rows = conn.execute("SELECT agent, repo, task_id FROM jobs ORDER BY id").fetchall() + conn.close() + return [dict(r) for r in rows] + + +def _job_contents(): + conn = get_db() + rows = conn.execute("SELECT task_content FROM jobs ORDER BY id").fetchall() + conn.close() + return [r[0] for r in rows] + + +def _add_developer_runs(task_id, n): + conn = get_db() + for _ in range(n): + conn.execute( + "INSERT INTO agent_runs (task_id, agent) VALUES (?, 'developer')", + (task_id,), + ) + conn.commit() + conn.close() + + +def _pass(*a, **k): + return (True, "ok") + + +def _fail(reason): + def _f(*a, **k): + return (False, reason) + return _f + + +def _qg_with_security(monkeypatch, security_result): + """Patch QG_CHECKS so every gate passes EXCEPT the security gate, which returns + ``security_result``. Keeps the deploy-staging edge reachable (check_staging_status + passes) and isolates the security verdict under test.""" + patched = {k: _pass for k in stage_engine.QG_CHECKS} + patched["check_security_gate"] = security_result + monkeypatch.setattr(stage_engine, "QG_CHECKS", patched) + + +# --------------------------------------------------------------------------- +# TC-16 — FAIL -> rollback to development + enqueue developer + notify. +# --------------------------------------------------------------------------- +def test_tc16_fail_rolls_back_and_enqueues_developer(monkeypatch): + """TC-16: security_status FAIL -> rollback deploy-staging -> development, + enqueue developer, Plane comment + notify_qg_failure.""" + _qg_with_security(monkeypatch, _fail("2 secret(s): aws-key in src/x.py:3")) + task_id = _make_task("deploy-staging", repo="enduro-trails") + + res = advance_stage( + task_id, "deploy-staging", "enduro-trails", "ORCH-022", _BRANCH, + finished_agent="deployer", + ) + + assert res.advanced is False + assert res.rolled_back_to == "development" + assert _stage(task_id) == "development" + jobs = _jobs() + assert len(jobs) == 1 + assert jobs[0]["agent"] == "developer" + assert res.qg_name == "check_security_gate" + # The deployer-authored Plane comment + the QG-failure notification fired. + assert stage_engine.plane_add_comment.called + assert stage_engine.notify_qg_failure.called + + +# --------------------------------------------------------------------------- +# TC-17 — the rollback task_desc carries the verbatim findings (ORCH-046). +# --------------------------------------------------------------------------- +def test_tc17_task_desc_has_verbatim_findings(monkeypatch, tmp_path): + """TC-17: the re-launched developer's task_desc embeds the verbatim finding + substance (not just a link), following the ORCH-046 pattern.""" + reason = "2 secret(s): aws-access-key in src/config.py:12" + _qg_with_security(monkeypatch, _fail(reason)) + task_id = _make_task("deploy-staging", repo="enduro-trails") + + # Isolate the worktree base under tmp_path so this test never touches the real + # shared /repos/_wt host path (PermissionError in CI). Mirrors the pattern in + # tests/test_git_worktree.py / test_merge_gate.py. + from src import git_worktree + monkeypatch.setattr(git_worktree.settings, "worktrees_dir", str(tmp_path / "_wt")) + + # Seed a real 17-security-report.md in the worktree so extract_security_findings + # has a verbatim body to excerpt. + wt = stage_engine.get_worktree_path("enduro-trails", _BRANCH) + report_dir = os.path.join(wt, "docs", "work-items", "ORCH-022") + os.makedirs(report_dir, exist_ok=True) + with open(os.path.join(report_dir, "17-security-report.md"), "w", encoding="utf-8") as f: + f.write( + "---\nsecurity_status: FAIL\nsecrets_found: 1\n---\n" + "# Security Report — ORCH-022\n\n" + "## Verdict\n1 secret(s): aws-access-key in src/config.py:12\n\n" + "## Secrets\n- `src/config.py:12` — aws-access-key (match `AKIA…YZ`)\n\n" + "## Dependencies (blocking)\n- None\n" + ) + + advance_stage( + task_id, "deploy-staging", "enduro-trails", "ORCH-022", _BRANCH, + finished_agent="deployer", + ) + + contents = _job_contents() + assert len(contents) == 1 + desc = contents[0] + # The verbatim reason AND the excerpted finding line are present. + assert "aws-access-key in src/config.py:12" in desc + assert "src/config.py:12" in desc + # Plus the link to the full artefact. + assert "17-security-report.md" in desc + + +# --------------------------------------------------------------------------- +# TC-18 — after MAX_DEVELOPER_RETRIES -> block + Telegram, no bounce. +# --------------------------------------------------------------------------- +def test_tc18_retry_cap_blocks_and_alerts(monkeypatch): + """TC-18: after MAX_DEVELOPER_RETRIES developer attempts -> set_issue_blocked + + Telegram alert; no infinite bounce (no new developer job).""" + _qg_with_security(monkeypatch, _fail("blocking CVE")) + task_id = _make_task("deploy-staging", repo="enduro-trails") + _add_developer_runs(task_id, stage_engine.MAX_DEVELOPER_RETRIES) + + res = advance_stage( + task_id, "deploy-staging", "enduro-trails", "ORCH-022", _BRANCH, + finished_agent="deployer", + ) + + assert res.rolled_back_to == "development" + assert res.alerted is True + assert stage_engine.set_issue_blocked.called + assert stage_engine.send_telegram.called + # No further developer job past the cap. + assert _jobs() == [] + + +# --------------------------------------------------------------------------- +# TC-19 — PASS -> the pipeline advances normally. +# --------------------------------------------------------------------------- +def test_tc19_pass_advances_normally(monkeypatch): + """TC-19: security_status PASS -> advance deploy-staging -> deploy with the + deployer launched, no rollback, no QG-failure notification.""" + _qg_with_security(monkeypatch, lambda *a, **k: (True, "security clean")) + task_id = _make_task("deploy-staging", repo="enduro-trails") + + res = advance_stage( + task_id, "deploy-staging", "enduro-trails", "ORCH-022", _BRANCH, + finished_agent="deployer", + ) + + assert res.advanced is True + assert res.to_stage == "deploy" + assert _stage(task_id) == "deploy" + assert res.rolled_back_to is None + # No noisy QG-failure notification on the happy path. + assert not stage_engine.notify_qg_failure.called + + +# --------------------------------------------------------------------------- +# TC-21 — self-hosting safety: a FAIL never deploys / restarts prod. +# --------------------------------------------------------------------------- +def test_tc21_fail_never_triggers_deploy(monkeypatch): + """TC-21: on a security FAIL the gate only rolls back + enqueues developer; it + never calls the deploy hook / restarts the prod container (self-hosting safety).""" + _qg_with_security(monkeypatch, _fail("secret found")) + # Spy on the self-deploy entrypoints — none must be invoked on a FAIL. + monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", MagicMock()) + monkeypatch.setattr(stage_engine.self_deploy, "self_deploy_applies", MagicMock(return_value=True)) + task_id = _make_task("deploy-staging", repo="orchestrator") + + res = advance_stage( + task_id, "deploy-staging", "orchestrator", "ORCH-022", _BRANCH, + finished_agent="deployer", + ) + + assert res.rolled_back_to == "development" + # The security FAIL returns BEFORE the self-deploy block -> no deploy initiated. + assert not stage_engine.self_deploy.initiate_deploy.called + # Only the developer is re-enqueued; no deployer job. + jobs = _jobs() + assert all(j["agent"] == "developer" for j in jobs) diff --git a/tests/test_stage_visibility.py b/tests/test_stage_visibility.py index a41f5c7..d7be813 100644 --- a/tests/test_stage_visibility.py +++ b/tests/test_stage_visibility.py @@ -68,10 +68,18 @@ def test_set_issue_stage_state_patches_correct_uuid(mock_proj, mock_find, mock_p @patch("src.plane_sync.httpx.patch") @patch("src.plane_sync.find_issue_id", return_value="issue-uuid") @patch("src.plane_sync._resolve_project_id", return_value="proj-1") -def test_set_issue_stage_state_noop_for_analysis(mock_proj, mock_find, mock_patch): - # analysis has no dedicated board status -> no PATCH at all. +def test_set_issue_stage_state_noop_for_deploy(mock_proj, mock_find, mock_patch): + # ORCH-066: analysis now HAS a dedicated status (Analysis) -> it PATCHes. + # deploy still has no board status here (driven by Phase A/B/C) -> no-op. + resp = MagicMock() + resp.raise_for_status.return_value = None + mock_patch.return_value = resp + PS.set_issue_stage_state("ET-1", "analysis") - mock_patch.assert_not_called() + # analysis aliases in_progress when the Analysis status is absent. + assert mock_patch.call_args.kwargs["json"]["state"] == PS.PLANE_STATES["analysis"] + + mock_patch.reset_mock() PS.set_issue_stage_state("ET-1", "deploy") mock_patch.assert_not_called()