From 50bcae765a780e3ca5d49c1e729b8a4d729a1f14 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Wed, 10 Jun 2026 03:47:49 +0300 Subject: [PATCH] feat(bug-fast-track): cheaper/shorter pipeline route for bug-fix tasks (ORCH-019) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A task carrying the Plane `Bug` label takes a shortened route that skips the `architecture` stage (one opus architect run + ADR + check_architecture_done), replacing heavy analysis with a lite package (bug-report + mandatory regression test plan). EVERY Quality Gate / sub-gate runs UNCHANGED — the route is a scheduler property, not a gate (root invariant NFR-1): STAGE_TRANSITIONS / QG_CHECKS / check_* / machine-verdict keys are byte-for-byte preserved. - src/bug_fast_track.py: new leaf (never-raise) — bug_fast_track_applies (local, network-free, checked first), is_bug_task (labels.has_label, Plane API source), skips_architecture (pure DB-backed routing predicate), snapshot. - src/db.py: additive idempotent tasks.track column (TEXT DEFAULT 'full') + set_task_track / get_task_track helpers (missing/NULL -> 'full', fail-safe). - src/stage_engine.py: routing-override on the analysis-exit edge (track='bug' -> development/developer, skipping architect); brd-review-clock stamp extended to analysis->development. get_next_stage/get_agent_for_stage stay pure. - src/webhooks/plane.py: classify task as bug in start_pipeline (applies-first short-circuit; never-raise -> full cycle on any error). - src/main.py: additive bug_fast_track block in GET /queue + POST /bug-fast-track/escalate (reset 'bug'->'full' to return to the full cycle). - src/config.py: bug_fast_track_enabled / _label / _repos flags (empty CSV -> self-hosting only). - src/notifications.py: optional 🐞 marker on the bug-track card (never-raise). - Prompts: analyst.md (lite bug package + escalation), reviewer.md (regression- test axis) — 52d canon preserved. - Docs: CLAUDE.md, README.md (env + API + section), docs/architecture/README.md, CHANGELOG.md, .env.example. - Tests: tests/test_bug_fast_track*.py + test_db_migrations.py + queue block (TC-01..TC-15). Full regression green (1551 passed). Kill-switch ORCH_BUG_FAST_TRACK_ENABLED=false -> 1:1 pre-ORCH-019 (zero regression; residual track column harmless). Refs: ORCH-019 Co-Authored-By: Claude Opus 4.8 --- .env.example | 12 ++ .openclaw/agents/analyst.md | 11 ++ .openclaw/agents/reviewer.md | 5 + CHANGELOG.md | 9 ++ CLAUDE.md | 37 +++++ README.md | 34 +++++ docs/architecture/README.md | 2 +- src/bug_fast_track.py | 166 ++++++++++++++++++++ src/config.py | 28 ++++ src/db.py | 49 ++++++ src/main.py | 47 ++++++ src/notifications.py | 12 +- src/stage_engine.py | 34 ++++- src/webhooks/plane.py | 37 +++++ tests/test_bug_fast_track.py | 168 +++++++++++++++++++++ tests/test_bug_fast_track_composition.py | 87 +++++++++++ tests/test_bug_fast_track_e2e.py | 184 +++++++++++++++++++++++ tests/test_bug_fast_track_escalation.py | 105 +++++++++++++ tests/test_bug_fast_track_gates.py | 97 ++++++++++++ tests/test_bug_fast_track_routing.py | 147 ++++++++++++++++++ tests/test_db_migrations.py | 79 ++++++++++ tests/test_queue_endpoint.py | 47 ++++++ 22 files changed, 1392 insertions(+), 5 deletions(-) create mode 100644 src/bug_fast_track.py create mode 100644 tests/test_bug_fast_track.py create mode 100644 tests/test_bug_fast_track_composition.py create mode 100644 tests/test_bug_fast_track_e2e.py create mode 100644 tests/test_bug_fast_track_escalation.py create mode 100644 tests/test_bug_fast_track_gates.py create mode 100644 tests/test_bug_fast_track_routing.py create mode 100644 tests/test_db_migrations.py diff --git a/.env.example b/.env.example index 39bfb51..ddf731b 100644 --- a/.env.example +++ b/.env.example @@ -139,6 +139,18 @@ ORCH_SERIAL_GATE_FREEZE_ENABLED=true # for enduro too). ORCH_STOP_STATUS_ENABLED=true ORCH_STOP_STATUS_REPOS= +# ORCH-019: bug-fast-track — a cheaper/shorter pipeline route for bug-fix tasks. +# A task carrying the Plane `Bug` label skips the whole `architecture` stage; EVERY +# Quality Gate / sub-gate runs UNCHANGED (route is a scheduler property, not a gate). +# Additive, never-raise, fail-safe -> full cycle. Infra precondition: create a `Bug` +# label on the ORCH board (its absence = full cycle, fail-safe). Leaf src/bug_fast_track.py. +# BUG_FAST_TRACK_ENABLED=false -> start_pipeline AND advance_stage are 1:1 as before +# ORCH-019 (zero regression). +# BUG_FAST_TRACK_LABEL -> Plane label that activates the track (default `Bug`). +# BUG_FAST_TRACK_REPOS (CSV) -> scope; EMPTY = self-hosting only (orchestrator). +ORCH_BUG_FAST_TRACK_ENABLED=true +ORCH_BUG_FAST_TRACK_LABEL=Bug +ORCH_BUG_FAST_TRACK_REPOS= # ORCH-094: terminal-window-aware guard for the three deploy-phase Plane status # setters (set_issue_awaiting_deploy / set_issue_deploying / set_issue_monitoring). # A DB stage=done task converges to Done idempotently instead of flapping diff --git a/.openclaw/agents/analyst.md b/.openclaw/agents/analyst.md index d08e03c..ca277e8 100644 --- a/.openclaw/agents/analyst.md +++ b/.openclaw/agents/analyst.md @@ -29,6 +29,17 @@ FastAPI + SQLite, конвейер стадий через Quality Gates, аге Стандарт структуры документов — `docs/_standards/PIPELINE_DOCS.md`; копируй скелеты из `docs/_templates/` (`01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`, `04-test-plan.yaml`). + +**Багфикс-трек (ORCH-019).** Если задача помечена меткой Plane `Bug` (укороченный маршрут — +пропуск стадии `architecture`), выпускай **облегчённый** пакет, но **всё равно все 4 файла** +(гейт `check_analysis_complete` требует `01/02/03/04` — не меняется): `01-brd.md` = короткий +bug-report (симптом / шаги воспроизведения / локализация / причина), `02-trz.md` + +`03-acceptance-criteria.md` = краткие bug-shaped заглушки, `04-test-plan.yaml` = план +**обязательного регресс-теста** (красный до фикса, зелёный после). Экономия — в пропуске целой +стадии `architecture` (отдельный прогон архитектора + ADR), не в числе файлов. Если баг оказался +**сложным/архитектурным/визуальным** (нужен ADR или макет) — выпусти **полный** analysis-пакет и +помечай в bug-report `escalate: full-cycle` (эскалация в полный цикл, ADR-001 D5 ORCH-019); оператор +снимает багфикс-трек эндпоинтом `POST /bug-fast-track/escalate`. diff --git a/.openclaw/agents/reviewer.md b/.openclaw/agents/reviewer.md index ae77544..b17c22b 100644 --- a/.openclaw/agents/reviewer.md +++ b/.openclaw/agents/reviewer.md @@ -42,6 +42,11 @@ tools: (слом критического инварианта конвейера может быть P0). Это усиление оси, а не отдельная ось. 3. **Качество кода** — нет явных ошибок/утечек/security-дыр? Есть docstrings на публичных функциях? Тесты содержательные (не тривиальные)? + - **Багфикс-трек: регресс-тест (ORCH-019, BR-4).** Если задача — багфикс (метка `Bug` / + укороченный маршрут с пропуском `architecture`), исправление кода **обязано** нести + новый/изменённый тест-фиксатор дефекта (красный до фикса, зелёный после). Фикс кода без + теста-фиксатора → **finding ≥ P1 / REQUEST_CHANGES**. Это усиление оси «качество», а не + отдельная ось (структурно дублируется coverage-гейтом ORCH-027). 4. **Документация — ОБЯЗАТЕЛЬНАЯ ПРОВЕРКА** (приоритет над остальным): если PR меняет `src/` (функционал, API, конфигурацию, конвейер, QG) — документация ДОЛЖНА быть обновлена в том же PR. Проверь: API → `docs/architecture/README.md` (таблица API)? стадии/QG → diff --git a/CHANGELOG.md b/CHANGELOG.md index 74ac986..ceb3005 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,15 @@ Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу. ## [Unreleased] +- **Багфикс-трек: упрощённый/дешёвый маршрут конвейера для багов** (ORCH-019, `feat`): задача с меткой Plane `Bug` идёт **укороченным маршрутом** — пропускается стадия `architecture` (отдельный прогон opus-агента `architect` + ADR + exit-гейт `check_architecture_done`), тяжёлая аналитика заменяется облегчённым пакетом (короткий bug-report + обязательный план регресс-теста). **Все Quality Gate'ы исполняются без изменений** (корневой инвариант NFR-1): `STAGE_TRANSITIONS` / реестр `QG_CHECKS` / сигнатуры `check_*` / machine-verdict ключи (`verdict:`/`result:`/`deploy_status:`/`staging_status:`/`security_status:`/`coverage_status:`) — байт-в-байт прежние; маршрутизация багфикса — свойство планировщика, **не** гейт. Аддитивно, под kill-switch, с областью репо, never-raise, fail-safe → полный цикл. ADR: `docs/work-items/ORCH-019/06-adr/ADR-001-bug-fast-track.md`, сквозной `docs/architecture/adr/adr-0032-bug-fast-track.md`. + - **Классификация (D1, FR-1):** новый leaf `src/bug_fast_track.py` (never-raise, паттерн `labels`/`serial_gate`). `bug_fast_track_applies(repo)` (локально, без сети) проверяется ПЕРВЫМ → выключенный флаг = нулевой сетевой оверхед; `is_bug_task(work_item_id, project_id)` делегирует в проверенный `labels.has_label` (ORCH-089: `fetch_issue_labels`+`get_project_labels`, нормализация, TTL-кэш). **Источник истины — Plane API**, не payload вебхука. Чтение метки — только в `start_pipeline`, **никогда** в горячем `claim_next_job` (NFR-4). + - **Хранение типа (D2):** аддитивная идемпотентная колонка `tasks.track TEXT DEFAULT 'full'` (`_ensure_column`, паттерн `tasks.cancelled_at` ORCH-090); значения `'full'` (дефолт, ВСЕ существующие и не-баг задачи) | `'bug'`. Хелперы `db.set_task_track`/`db.get_task_track` (отсутствие/NULL → `'full'`, fail-safe). Сигнатура `create_task_atomic` не меняется. + - **Routing-override (D3, FR-2):** врезка в `advance_stage` на ребре выхода из `analysis`: при `track='bug'` (через чистый предикат `bug_fast_track.skips_architecture`) `next_stage` → `development`, `next_agent` → `developer` (минуя `architect`). `get_next_stage`/`get_agent_for_stage`/`STAGE_TRANSITIONS` — чистые, 1:1; тип читается из БД (без сети, NFR-4). Для не-баг задач (`track='full'`) маршрут байт-в-байт прежний. Сопутствующе: стамп `mark_brd_review_ended` расширен на `analysis → development` (честная метрика ORCH-087 на багфикс-треке). + - **Гейт `analysis` не тронут (D4, FR-6):** `check_analysis_complete`/`check_analysis_approved` байт-в-байт прежние; багфикс-аналитик всё равно эмитит все 4 файла (облегчённые) — сильнейшая позиция NFR-1 (нулевая поверхность правок гейта). + - **Эскалация (D5, FR-5):** админ-эндпоинт `POST /bug-fast-track/escalate?work_item=` (по образцу `POST /serial-gate/unfreeze`) сбрасывает `track` `'bug'→'full'` → следующий переход уходит в `architecture` (полный цикл). Плюс решение мини-аналитика «баг сложный → полный пакет + `escalate: full-cycle`». + - **Область / флаги (D6):** `bug_fast_track_enabled` (kill-switch, env `ORCH_BUG_FAST_TRACK_ENABLED`), `bug_fast_track_label` (дефолт `Bug`), `bug_fast_track_repos` (CSV; **пусто → self-hosting only** — enduro подключается явным CSV). `False` → старт и маршрут 1:1 как до ORCH-019 (нулевая регрессия, AC-6). + - **Наблюдаемость (D7, FR-7):** аддитивный read-only блок `bug_fast_track` в `GET /queue` (флаг/метка/область + счётчик багфикс-задач + метрика сэкономленных стадий `architecture`); лог-строка на решение о маршруте; отметка `🐞` в Telegram-карточке (never-raise). Композиция (D8, AC-9): багфикс-задача — обычная задача репо для serial-gate (ORCH-088, не обходит его); `autoApprove`/`autoDeploy` (ORCH-089), coverage-gate (ORCH-027, союзник BR-4), merge-gate (ORCH-043) — штатно. + - **Промпты:** `analyst.md` (облегчённый багфикс-пакет + путь эскалации), `reviewer.md` (ось «багфикс без регресс-теста → finding ≥P1 / REQUEST_CHANGES») — канон 52d не нарушен. **Инфра-предусловие:** создать метку `Bug` в Plane-проекте ORCH (её отсутствие = fail-safe полный цикл). Тесты: `tests/test_bug_fast_track*.py` + `tests/test_db_migrations.py` + блок в `tests/test_queue_endpoint.py` (TC-01…TC-15). Полный регресс `tests/ -q` зелёный. Откат: `ORCH_BUG_FAST_TRACK_ENABLED=false` (мгновенный; остаточная колонка `track` безвредна). - **Детект legacy root-owned файлов + внятная ошибка worktree при миграции на uid 1000** (ORCH-057, follow-up ORCH-040, `feat`): закрыт недоделанный AC ORCH-040 — legacy `root:root` файлы в `/repos` (после перевода контейнеров на `user: "1000:1000"`) ломали создание worktree под uid 1000 (`ensure_worktree` → сырой `fatal: … Permission denied`, агент не стартовал, диагноза не было). Три аддитивных, обратимых kill-switch'ем слоя; **`STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict-ключи / схема БД — байт-в-байт прежние**. ADR: `docs/work-items/ORCH-057/06-adr/ADR-001-legacy-ownership-normalization.md`, сквозной `docs/architecture/adr/adr-0031-legacy-ownership-normalization.md`. - **D1 — actionable-ошибка `ensure_worktree`:** класс «нет прав» (`Permission denied` / `could not create leading directories` / `insufficient permission for adding an object` / `PermissionError`/`EACCES`/`EPERM`) оборачивается в `RuntimeError` с **причиной** (legacy root-файлы в `/repos/_wt`/`.git` после миграции uid), **лечащей командой** (`chown -R : …`) и ссылкой на `INFRA.md` — вместо сырого git stderr. Ошибки, **не** связанные с правами, сохраняют прежний контракт (меняется только формулировка, не факт сбоя; чистый классификатор `fs_normalize.classify_worktree_error`). Под выключенным kill-switch контракт ошибки 1:1 как до ORCH-057. - **D2 — детект-леаф `src/fs_normalize.py`** (never-raise, паттерн `serial_gate`/`coverage_gate`): `scan_ownership(roots, target_uid=os.getuid())` обходит `/repos/_wt`, `/.git/{objects,worktrees}`, `data/runs` с ранним выходом при первом `st_uid != target_uid`, TTL-кэшем (`fs_scan_cache_ttl_s`, по образцу `preflight._cache`) и `applies(repo)` first (пустой CSV → self-hosting only → enduro-trails не сканируется). Опц. `normalize()` chown'ит **только** при `geteuid()==0` (под uid 1000 — no-op + честный лог «нужна операторская процедура», НЕ ошибка). diff --git a/CLAUDE.md b/CLAUDE.md index 6181f95..e8426bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -153,6 +153,43 @@ created → analysis → architecture → development → review → testing → `docs/work-items/ORCH-090/06-adr/ADR-001-stop-cancel-task.md`, `docs/architecture/adr/adr-0026-stop-cancel-task.md`. +## Багфикс-трек: дешёвый маршрут для багов (ORCH-019) +Задача с меткой Plane `Bug` идёт **укороченным маршрутом** — пропускается стадия `architecture` +(отдельный прогон opus-агента `architect` + ADR + exit-гейт `check_architecture_done`); тяжёлая +аналитика заменяется облегчённым пакетом (короткий bug-report + обязательный план регресс-теста, +но всё равно все 4 файла analysis — гейт `check_analysis_complete` не меняется). **Корневой +инвариант (NFR-1):** срезается ТОЛЬКО аналитика/архитектура — **все Quality Gate'ы и под-гейты +исполняются без изменений** (`STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict ключи — +байт-в-байт прежние); маршрутизация багфикса — свойство планировщика, **не** гейт. Аддитивно, под +kill-switch, never-raise, fail-safe → полный цикл. +- **Классификация (D1):** leaf `src/bug_fast_track.py` (never-raise, образец `labels`/`serial_gate`). + `bug_fast_track_applies(repo)` (локально, без сети) ПЕРВЫМ → выключенный флаг = нулевой сетевой + оверхед; `is_bug_task` делегирует в `labels.has_label` (ORCH-089-аппарат, источник истины — Plane + API, не payload). Чтение метки — только в `start_pipeline`, **никогда** в горячем `claim_next_job` + (NFR-4). +- **Хранение типа (D2):** аддитивная идемпотентная колонка `tasks.track TEXT DEFAULT 'full'` + (`_ensure_column`, паттерн `tasks.cancelled_at`); значения `'full'` (дефолт, ВСЕ существующие и + не-баг задачи) | `'bug'`. Хелперы `db.set_task_track`/`get_task_track` (отсутствие/NULL → `'full'`, + fail-safe). Читается в `advance_stage` из БД, не из сети. +- **Routing-override (D3):** врезка в `advance_stage` на ребре выхода из `analysis`: при `track='bug'` + (чистый предикат `bug_fast_track.skips_architecture`) `next_stage` → `development`, `next_agent` → + `developer` (минуя `architect`). `STAGE_TRANSITIONS`/`get_next_stage`/`get_agent_for_stage` — чистые, + 1:1. Стамп `mark_brd_review_ended` расширен на `analysis → development` (честная метрика ORCH-087). +- **Эскалация (D5):** `POST /bug-fast-track/escalate?work_item=` сбрасывает `track` `'bug'→'full'` + → следующий переход уходит в `architecture` (полный цикл). Плюс self-escalate мини-аналитика + («баг сложный → полный пакет + `escalate: full-cycle`»). +- **Флаги** (`config.py`): `bug_fast_track_enabled` (kill-switch, env `ORCH_BUG_FAST_TRACK_ENABLED`), + `bug_fast_track_label` (дефолт `Bug`), `bug_fast_track_repos` (CSV; **пусто → self-hosting only**). + `False`/неприменимый репо → старт и маршрут байт-в-байт прежние (нулевая регрессия для enduro и + orchestrator). Наблюдаемость — read-only блок `bug_fast_track` в `GET /queue` (флаг/метка/область + + счётчик багфикс-задач + метрика пропущенных стадий `architecture`) + отметка `🐞` в Telegram-карточке + (never-raise). Композиция: багфикс-задача — обычная задача репо для serial-gate (ORCH-088, не + обходит его); `autoApprove`/`autoDeploy` (ORCH-089), coverage-gate (ORCH-027, союзник BR-4), + merge-gate (ORCH-043) — штатно. **Инфра-предусловие:** создать метку **`Bug`** в Plane-проекте ORCH + (её отсутствие = fail-safe полный цикл). Детали — + `docs/work-items/ORCH-019/06-adr/ADR-001-bug-fast-track.md`, + `docs/architecture/adr/adr-0032-bug-fast-track.md`. + ## Гейт покрытия тестами (ORCH-027) Существующие тестовые гейты (`check_ci_green`, `check_tests_passed`, merge-gate re-test) судят только по **факту** прохождения, не по **полноте** — ни один не замечает «300 строк кода, 0 diff --git a/README.md b/README.md index 780c276..d6092a2 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ created → analysis → architecture → development → review → testing → | GET | `/queue` | Очередь задач (ORCH-1): counts по статусам + max_concurrency + последние 10 jobs | | POST | `/webhook/plane` | Plane webhook receiver | | POST | `/webhook/gitea` | Gitea webhook receiver | +| POST | `/bug-fast-track/escalate?work_item=` | Эскалация багфикс-задачи в полный цикл (ORCH-019): сброс `track` `'bug'→'full'` → следующий переход уходит в `architecture` | ## Структура проекта @@ -140,6 +141,9 @@ uvicorn src.main:app --reload --port 8500 | `ORCH_QG0_TITLE_MAX` | Верхний лимит длины заголовка QG-0 (вход `_qg0_errors`); невалидное/пустое значение → дефолт (ORCH-069) | `200` | | `ORCH_STOP_STATUS_ENABLED` | Kill-switch отмены задачи по Plane-статусу **STOP** + закрытия дыры релонча (ORCH-090); `false` → поведение 1:1 как до ORCH-090 | `true` | | `ORCH_STOP_STATUS_REPOS` | CSV область репо для STOP-отмены; пусто = все репо (ORCH-090) | `""` | +| `ORCH_BUG_FAST_TRACK_ENABLED` | Kill-switch багфикс-трека (ORCH-019): задача с меткой Plane `Bug` пропускает стадию `architecture`; `false` → старт и маршрут 1:1 как до ORCH-019 (нулевая регрессия) | `true` | +| `ORCH_BUG_FAST_TRACK_LABEL` | Имя метки Plane, активирующей багфикс-трек (ORCH-019) | `Bug` | +| `ORCH_BUG_FAST_TRACK_REPOS` | CSV область репо для багфикс-трека; **пусто → self-hosting only** (`orchestrator`) — enduro подключается явным CSV (ORCH-019) | `""` | ## Очередь задач (ORCH-1 / F-2b) @@ -181,6 +185,36 @@ ORCH-090/06-adr/ADR-001-stop-cancel-task.md` + сквозной > группой `cancelled`. До создания статуса фича в fail-safe (нет UUID → ветка STOP > не активируется). +## Багфикс-трек: дешёвый маршрут для багов (ORCH-019) + +Задача с меткой Plane `Bug` (имя метки — `ORCH_BUG_FAST_TRACK_LABEL`, дефолт `Bug`) +идёт **укороченным маршрутом** конвейера: `analysis(lite) → development → review → +testing → deploy-staging → deploy → done`, т.е. **пропускается стадия `architecture`** +(отдельный прогон opus-агента `architect` + ADR + exit-гейт `check_architecture_done`). +Мини-аналитик выдаёт облегчённый пакет (короткий bug-report + обязательный план +регресс-теста), но всё равно все 4 файла analysis — гейт `check_analysis_complete` +не меняется. + +**Корневой инвариант:** упрощается только аналитика/архитектура — **все Quality +Gate'ы и под-гейты исполняются без изменений** (`STAGE_TRANSITIONS` / `QG_CHECKS` / +`check_*` / machine-verdict ключи — байт-в-байт прежние). Маршрутизация багфикса — +свойство планировщика (routing-override в `advance_stage` по `tasks.track='bug'`), +**не** Quality Gate. + +Классификация (`src/bug_fast_track.py`, never-raise): локальный `bug_fast_track_applies(repo)` +ПЕРВЫМ (выключенный флаг = нулевой сетевой оверхед), затем `is_bug_task` через +`labels.has_label` (источник истины — Plane API). Тип хранится в аддитивной колонке +`tasks.track` (`'full'` | `'bug'`), читается в горячем пути из БД (не из сети). +**Эскалация** сложного/архитектурного бага в полный цикл — `POST /bug-fast-track/escalate?work_item=` +(сброс `'bug'→'full'`). Всё под kill-switch `ORCH_BUG_FAST_TRACK_ENABLED`, область — +`ORCH_BUG_FAST_TRACK_REPOS` (пусто → self-hosting only), fail-safe → полный цикл. +Наблюдаемость — блок `bug_fast_track` в `GET /queue` + отметка `🐞` в Telegram-карточке. +Деталь — `docs/work-items/ORCH-019/06-adr/ADR-001-bug-fast-track.md` + сквозной +`docs/architecture/adr/adr-0032-bug-fast-track.md`. + +> **Инфра-предусловие:** на доске Plane проекта ORCH создать метку **`Bug`**. До её +> создания фича в fail-safe (нет метки → задача идёт полным циклом). + **Resilience-слой:** дешёвый preflight (CLI/net, кэш, без токенов) гейтит claim; 429/overload детектится по логу (transient vs permanent), transient ретраится с exp-backoff (`available_at`, Retry-After); circuit breaker паузит воркер после N diff --git a/docs/architecture/README.md b/docs/architecture/README.md index c3d4d73..3ef112d 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -373,7 +373,7 @@ Phase A ждёт ручного `Confirm Deploy`, ORCH-059). ORCH-089 снима `docs/work-items/ORCH-089/06-adr/ADR-001-auto-label-gates.md`, `docs/work-items/ORCH-089/07-infra-requirements.md`. -### Багфикс-трек: укороченный маршрут для багов (ORCH-019 — design) +### Багфикс-трек: укороченный маршрут для багов (ORCH-019 — реализовано) Задача с меткой Plane `Bug` идёт по **укороченному** маршруту `analysis(lite) → development → review → testing → deploy-staging → deploy → done`, **минуя стадию `architecture`** (отдельный прогон opus-агента `architect` + ADR + exit-гейт `check_architecture_done`). **Корневой инвариант diff --git a/src/bug_fast_track.py b/src/bug_fast_track.py new file mode 100644 index 0000000..e005bb3 --- /dev/null +++ b/src/bug_fast_track.py @@ -0,0 +1,166 @@ +"""ORCH-019: bug-fast-track — a cheaper/shorter pipeline route for bug-fix tasks. + +Leaf module — pure, unit-testable logic over the config flags + the proven Plane +label apparatus (``labels.has_label`` -> ``plane_sync``, ORCH-089). Mirrors the +leaf pattern of ``src/labels.py`` / ``src/serial_gate.py``: imports only +``config`` (and lazily ``labels`` / ``db`` / ``qg.checks``), never +``stage_engine`` / ``launcher``. + +What it decides (ADR-001): + * Whether the bug-fast-track is in scope for a repo (``bug_fast_track_applies``) + — a LOCAL, network-free check evaluated FIRST. + * Whether a given Plane issue carries the ``Bug`` label (``is_bug_task``) — the + only network call, made ONLY after ``applies()`` is True, so a disabled + kill-switch costs zero network and yields zero regression (AC-6). + * Whether a task's stored track skips the ``architecture`` stage + (``skips_architecture``) — a pure predicate over the DB-stored ``track``, + read in the hot ``advance_stage`` path WITHOUT any network call (NFR-4). + +never-raise contract (BR-6/AC-6, fail-safe to the FULL cycle): every public +function degrades to "full cycle" on ANY error / ambiguity / Plane +unavailability / disabled flag. There is NO fail-open here — the conservative +default is always the full pipeline (with ``architecture``), so an error can +never silently skip a stage. +""" +from __future__ import annotations + +import logging + +from .config import settings + +logger = logging.getLogger("orchestrator.bug_fast_track") + + +# --------------------------------------------------------------------------- +# Scope / kill-switch (mirrors _auto_label_applies / serial_gate_applies) +# --------------------------------------------------------------------------- +def bug_fast_track_applies(repo: str) -> bool: + """Whether the bug-fast-track is REAL for ``repo`` (ADR-001 D6 / AC-6). + + * ``bug_fast_track_enabled=False`` -> always False (kill-switch; start and + routing are 1:1 as before ORCH-019, and — crucially — ``has_label`` is + never consulted, so no new network call on start, AC-6). + * ``bug_fast_track_repos`` (CSV) non-empty -> real only for the listed repos. + * empty CSV -> self-hosting only (``orchestrator``) — the safe default (the + track is first burnt in on the orchestrator itself, where the `Bug` label + is guaranteed to exist; enduro opts in via an explicit CSV entry). + Checked FIRST (local, network-free); never raises -> False on error (degrade + to "full cycle", which matches the kill-switch-off behaviour). + """ + try: + if not getattr(settings, "bug_fast_track_enabled", False): + return False + raw = (getattr(settings, "bug_fast_track_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 (avoids importing qg at load). + from .qg.checks import is_self_hosting_repo + return is_self_hosting_repo(repo) + except Exception as e: # noqa: BLE001 - never-raise -> full cycle + logger.warning("bug_fast_track_applies error for %s: %s", repo, e) + return False + + +# --------------------------------------------------------------------------- +# Classification (the ONLY network call; ADR-001 D1) +# --------------------------------------------------------------------------- +def is_bug_task(work_item_id: str, project_id: str | None = None) -> bool: + """True iff the issue carries the configured ``Bug`` label (Plane API source). + + ``bug_fast_track_applies`` is assumed already True (checked by the caller — + the gate idiom ``applies(repo) and is_bug_task(...)`` short-circuits before any + network call when the kill-switch is off). Delegates to the proven + ``labels.has_label`` (fetch_issue_labels + get_project_labels, normalization, + TTL-cache, source-of-truth = Plane API, not the webhook payload). + + Any error / ambiguity / Plane unavailability -> **False** (fail-safe -> full + cycle, never silently fast-track on doubt). + """ + try: + label = (getattr(settings, "bug_fast_track_label", "") or "").strip() + if not label: + return False + from . import labels + return bool(labels.has_label(work_item_id, label, project_id)) + except Exception as e: # noqa: BLE001 - never-raise -> full cycle + logger.warning( + "is_bug_task error for %s -> fail-safe (full cycle): %s", work_item_id, e + ) + return False + + +# --------------------------------------------------------------------------- +# Routing predicate (pure, DB-backed; hot path — NO network, NFR-4) — ADR-001 D3 +# --------------------------------------------------------------------------- +def skips_architecture(track: str | None) -> bool: + """Whether a task with stored ``track`` skips the ``architecture`` stage. + + Pure predicate (no I/O): True iff the kill-switch is on AND ``track == 'bug'``. + Used by ``advance_stage`` on the analysis-exit edge to map + ``analysis -> architecture`` to ``analysis -> development`` for a bug task. + A disabled flag -> always False (1:1 prior routing); any error -> False + (fail-safe -> full cycle). + """ + try: + if not getattr(settings, "bug_fast_track_enabled", False): + return False + return (track or "").strip().lower() == "bug" + except Exception as e: # noqa: BLE001 - never-raise -> full cycle + logger.warning("skips_architecture error for track=%r: %s", track, e) + return False + + +# --------------------------------------------------------------------------- +# Observability snapshot for GET /queue (ADR-001 D7) +# --------------------------------------------------------------------------- +def snapshot() -> dict: + """Read-only bug-fast-track summary for GET /queue (additive block). never-raise. + + Surfaces the flags + a savings metric derived from the existing telemetry: the + count of tasks on the bug track and the number of ``architecture`` agent runs + those tasks structurally skipped (one per bug task = ``est_saved_architecture_runs``). + Any error -> a minimal dict with the flags (never crashes the endpoint). + """ + try: + enabled = bool(getattr(settings, "bug_fast_track_enabled", False)) + except Exception: # noqa: BLE001 + enabled = False + try: + label = getattr(settings, "bug_fast_track_label", "Bug") or "Bug" + except Exception: # noqa: BLE001 + label = "Bug" + try: + repos_cfg = getattr(settings, "bug_fast_track_repos", "") or "" + except Exception: # noqa: BLE001 + repos_cfg = "" + active_bug_tasks = 0 + total_bug_tasks = 0 + try: + from . import db + conn = db.get_db() + try: + # ORCH-090 terminal set {done,cancelled}: "active" = not terminal. + row = conn.execute( + "SELECT " + " COUNT(*) AS total, " + " SUM(CASE WHEN stage NOT IN ('done','cancelled') THEN 1 ELSE 0 END) AS active " + "FROM tasks WHERE track = 'bug'" + ).fetchone() + if row: + total_bug_tasks = int(row["total"] or 0) + active_bug_tasks = int(row["active"] or 0) + finally: + conn.close() + except Exception as e: # noqa: BLE001 + logger.warning("bug_fast_track snapshot count error: %s", e) + return { + "enabled": enabled, + "label": label, + "repos": repos_cfg, + "active_bug_tasks": active_bug_tasks, + "total_bug_tasks": total_bug_tasks, + # Each bug task skips exactly one `architecture` stage (one architect agent + # run + ADR). This is the structural savings the track buys (FR-7 / AC-7). + "est_saved_architecture_runs": total_bug_tasks, + } diff --git a/src/config.py b/src/config.py index 4c7fc52..199c3d8 100644 --- a/src/config.py +++ b/src/config.py @@ -794,6 +794,34 @@ class Settings(BaseSettings): auto_label_repos: str = "" auto_label_states_ttl_s: int = 300 + # ORCH-019: bug-fast-track — a cheaper/shorter pipeline route for bug-fix tasks. + # A task carrying the Plane label `bug_fast_track_label` (default `Bug`) skips + # the whole `architecture` stage (one opus `architect` run + ADR + the + # check_architecture_done exit-gate): the routing-override in advance_stage maps + # the analysis -> architecture edge to analysis -> development for a task whose + # tasks.track == 'bug'. EVERY Quality Gate / sub-gate (CI/review/tester/staging/ + # deploy + security/merge/coverage/image-freshness/merge-verify) runs UNCHANGED + # — the route is a scheduler property, NOT a gate (root invariant NFR-1). + # Recognition reuses the proven ORCH-089 label apparatus (labels.has_label -> + # plane_sync), read ONLY in start_pipeline (never in the hot claim_next_job). + # Additive leaf (src/bug_fast_track.py, never-raise) + an additive idempotent + # tasks.track column; STAGE_TRANSITIONS / QG_CHECKS / check_* / verdict-keys are + # NOT touched. fail-safe -> full cycle on any error/ambiguity/disabled flag. See + # docs/work-items/ORCH-019/06-adr/ADR-001-bug-fast-track.md and the cross-cutting + # docs/architecture/adr/adr-0032-bug-fast-track.md. + # bug_fast_track_enabled -> kill-switch (env ORCH_BUG_FAST_TRACK_ENABLED). + # False -> start_pipeline AND advance_stage are 1:1 as + # before ORCH-019 (skips_architecture always False, + # has_label never consulted) — zero regression (AC-6). + # bug_fast_track_label -> Plane label name that activates the track (env + # ORCH_BUG_FAST_TRACK_LABEL; default `Bug`). + # bug_fast_track_repos -> CSV scope (env ORCH_BUG_FAST_TRACK_REPOS). Empty -> + # self-hosting only (orchestrator), the safe default + # (D6); non-empty -> only the listed repos. + bug_fast_track_enabled: bool = True + bug_fast_track_label: str = "Bug" + bug_fast_track_repos: str = "" + # Telegram notifications telegram_bot_token: str = "" telegram_chat_id: str = "" diff --git a/src/db.py b/src/db.py index 1190ea5..6aca2e0 100644 --- a/src/db.py +++ b/src/db.py @@ -140,6 +140,13 @@ def init_db(): # irreversible step finishes honestly, then applied. _ensure_column(conn, "tasks", "cancelled_at", "TEXT") _ensure_column(conn, "tasks", "cancel_requested_at", "TEXT") + # ORCH-019 (08-data-requirements.md): bug-fast-track task type. Additive, + # idempotent (_ensure_column is a no-op once present) -> safe on the live shared + # prod DB (enduro untouched). Values: 'full' (DEFAULT — ALL existing and non-bug + # tasks) | 'bug' (a task carrying the Plane `Bug` label, set in start_pipeline + # after a successful atomic create). Read in advance_stage for the routing-override + # (skips architecture) — from the DB, NEVER from the network (NFR-4). + _ensure_column(conn, "tasks", "track", "TEXT DEFAULT 'full'") # ORCH-026 (Level B): declarative task dependencies. job_deps stores the # directed edge "task_id (B) is blocked-by depends_on_task_id (A)". The # scheduler gate in claim_next_job keeps B queued until every A reaches @@ -487,6 +494,48 @@ def update_task_stage(task_id: int, stage: str): conn.close() +# --------------------------------------------------------------------------- +# ORCH-019: bug-fast-track task type (tasks.track) helpers +# --------------------------------------------------------------------------- +def set_task_track(task_id: int, track: str) -> None: + """ORCH-019: persist the task's pipeline track ('full' | 'bug'). + + Idempotent overwrite. Called from start_pipeline (after a successful atomic + create, when the issue carries the `Bug` label) and from the escalate endpoint + (reset 'bug' -> 'full' to return a complex bug to the full cycle). + """ + conn = get_db() + try: + conn.execute( + "UPDATE tasks SET track = ? WHERE id = ?", (track, task_id) + ) + conn.commit() + finally: + conn.close() + + +def get_task_track(task_id: int) -> str: + """ORCH-019: read the task's pipeline track; missing/NULL -> 'full' (fail-safe). + + Read in the hot advance_stage path for the routing-override (skips architecture). + A non-existent row, a NULL value, or any read error degrades to 'full' so a bug + can never be created by accident (fail-safe -> full cycle). + """ + try: + conn = get_db() + try: + row = conn.execute( + "SELECT track FROM tasks WHERE id = ?", (task_id,) + ).fetchone() + finally: + conn.close() + if not row: + return "full" + return row["track"] or "full" + except Exception: # noqa: BLE001 - fail-safe -> full cycle + return "full" + + # --------------------------------------------------------------------------- # Telegram live tracker helpers (feat/telegram-live-tracker) # --------------------------------------------------------------------------- diff --git a/src/main.py b/src/main.py index 4c5d5fc..2ca7d28 100644 --- a/src/main.py +++ b/src/main.py @@ -212,6 +212,7 @@ async def queue(): from . import fs_normalize from . import labels from . import cancel + from . import bug_fast_track from .disk_watchdog import disk_watchdog from .build_cache_pruner import build_cache_pruner return { @@ -243,6 +244,10 @@ async def queue(): # repo scope, cancelled/deferred counts, recent cancellations. Additive block; # never-raise. "stop": cancel.snapshot(), + # ORCH-019 (FR-7 / AC-7): bug-fast-track observability (read-only) — + # kill-switch, label, scope, bug-task counts + the structural savings metric + # (architecture stages skipped). Additive block; never-raise. + "bug_fast_track": bug_fast_track.snapshot(), # ORCH-063 (FR-6 / AC-7): disk-watchdog observability (read-only) — # enabled, threshold, interval, last measurement per host-path. Additive # block; never-raise (status() returns {"enabled": ...} minimum on error). @@ -343,3 +348,45 @@ async def coverage_set_baseline(repo: str = "", value: float | None = None): repo = repo.strip() ok = db.set_coverage_baseline(repo, value, sha="manual-override") return {"ok": ok, "repo": repo, "baseline": db.get_coverage_baseline(repo)} + + +@app.post("/bug-fast-track/escalate") +async def bug_fast_track_escalate(work_item: str = ""): + """ORCH-019 (FR-5 / AC-5, ADR-001 D5): escalate a bug-fast-track task to the + full cycle (return it to the route WITH `architecture`). + + Operator path for a bug that turned out to be complex / architectural / visual + (needs an ADR or a mock): reset ``tasks.track`` 'bug' -> 'full'. Apply while the + task is still in `analysis` (before its exit) — the next advance_stage then routes + analysis -> architecture normally. By образцу ``POST /serial-gate/unfreeze`` / + ``POST /coverage/baseline``. never-raise. + """ + from . import db + if not work_item or not work_item.strip(): + return {"ok": False, "error": "missing 'work_item'", "work_item": work_item} + work_item = work_item.strip() + task = db.get_task_by_work_item_id(work_item) + if not task: + return {"ok": False, "error": "unknown work_item", "work_item": work_item} + prev_track = task.get("track") or "full" + db.set_task_track(task["id"], "full") + if prev_track == "bug": + try: + from .notifications import send_telegram + send_telegram( + f"🐞➡️ {work_item}: эскалация в ПОЛНЫЙ цикл " + f"(багфикс-трек снят, стадия architecture восстановлена)." + ) + except Exception: + pass + try: + from .plane_sync import add_comment + add_comment( + work_item, + "🐞➡️ Эскалация: задача возвращена в полный цикл " + "(багфикс-трек снят, стадия architecture восстановлена).", + author="analyst", + ) + except Exception: + pass + return {"ok": True, "work_item": work_item, "track": "full", "was": prev_track} diff --git a/src/notifications.py b/src/notifications.py index bc82174..9c2761a 100644 --- a/src/notifications.py +++ b/src/notifications.py @@ -452,10 +452,18 @@ def render_task_tracker(task_id: int) -> str: task_repo = _row_get(task, "repo") task_issue_id = _row_get(task, "plane_issue_id") num_html = plane_issue_link(work_item_id, plane_issue_id=task_issue_id, repo=task_repo) + # ORCH-019 (D7): mark a bug-fast-track task with a \ud83d\udc1e in the header. Optional, + # never-raise \u2014 any error simply omits the marker (the card always renders). + bug_marker = "" + try: + if (_row_get(task, "track") or "").strip().lower() == "bug": + bug_marker = "\U0001f41e " + except Exception: + bug_marker = "" header = ( - f"\U0001f389 {num_html} \u00b7 {esc_title} \u2014 \u0413\u041e\u0422\u041e\u0412\u041e" + f"\U0001f389 {bug_marker}{num_html} \u00b7 {esc_title} \u2014 \u0413\u041e\u0422\u041e\u0412\u041e" if done - else f"\U0001f6e0\ufe0f {num_html} \u00b7 {esc_title}" + else f"\U0001f6e0\ufe0f {bug_marker}{num_html} \u00b7 {esc_title}" ) bar = "\u2501" * 22 # ORCH-067 (req 2): a Plane-status line (model ORCH-066) under the header. diff --git a/src/stage_engine.py b/src/stage_engine.py index 1eb9a33..3d4bbbb 100644 --- a/src/stage_engine.py +++ b/src/stage_engine.py @@ -30,7 +30,7 @@ import os import time from dataclasses import dataclass, field -from .db import get_db, update_task_stage, enqueue_job +from .db import get_db, update_task_stage, enqueue_job, get_task_track 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 @@ -40,6 +40,7 @@ from . import merge_gate from . import self_deploy from . import post_deploy from . import labels +from . import bug_fast_track from .notifications import ( notify_stage_change, notify_qg_failure, @@ -212,6 +213,25 @@ def advance_stage( try: qg_name = get_qg_for_stage(current_stage) next_stage = get_next_stage(current_stage) + + # --- ORCH-019 bug-fast-track routing-override (ADR-001 D3) ------------ + # A task carrying the Plane `Bug` label is stored as tasks.track='bug' in + # start_pipeline. On the analysis-EXIT edge we map analysis -> architecture + # to analysis -> development, so a bug skips the whole `architecture` stage + # (one opus architect run + ADR + check_architecture_done). This is a pure + # routing-override: STAGE_TRANSITIONS / get_next_stage / get_agent_for_stage + # stay 1:1, and the track is read from the DB (no network in this hot path, + # NFR-4). For a non-bug task (track='full', the DEFAULT) the route is + # byte-for-byte unchanged. The `track` is reused below for the next-agent + # override and the brd-review-clock stamp. + track = get_task_track(task_id) + if current_stage == "analysis" and bug_fast_track.skips_architecture(track): + next_stage = "development" + logger.info( + f"Task {task_id}: bug-fast-track -> analysis -> development " + f"(skipping architecture, ORCH-019)" + ) + result.qg_name = qg_name result.to_stage = next_stage @@ -383,7 +403,11 @@ def advance_stage( # Telegram live tracker: the analysis->architecture advance is the human # Approved gate clearing -> stamp the END of "Ревью БРД" (the only # human time). Idempotent: only the first stamp counts. - if current_stage == "analysis" and next_stage == "architecture": + # ORCH-019 (ADR-001 D3): for a bug-fast-track task the analysis-exit edge + # lands on `development` (not `architecture`), so the brd-review-clock end + # stamp must trigger on BOTH targets — otherwise "твоё время" (ORCH-087) + # would never close on the bug track. This does not touch any gate. + if current_stage == "analysis" and next_stage in ("architecture", "development"): try: from .db import mark_brd_review_ended mark_brd_review_ended(task_id) @@ -462,6 +486,12 @@ def advance_stage( # --- Launch the next agent (ORCH-4 fix: current_stage, not next) ----- next_agent = get_agent_for_stage(current_stage) + # ORCH-019 (ADR-001 D3): get_agent_for_stage('analysis') is 'architect'; for a + # bug-fast-track task we skip the architect run entirely and launch the + # developer directly (mirrors the next_stage override above). get_agent_for_stage + # stays pure (1:1) — the override lives here, NOT in stages.py. + if current_stage == "analysis" and next_stage == "development": + next_agent = "developer" if next_agent: task_desc = ( f"Work item: {work_item_id}\nRepo: {repo}\n" diff --git a/src/webhooks/plane.py b/src/webhooks/plane.py index c632678..b69b826 100644 --- a/src/webhooks/plane.py +++ b/src/webhooks/plane.py @@ -18,6 +18,7 @@ from ..db import ( enqueue_job, insert_event_dedup, create_task_atomic, + set_task_track, ) from ._dedup import plane_delivery_id from ..stages import get_next_stage, get_agent_for_stage, get_qg_for_stage, get_previous_stage @@ -648,6 +649,42 @@ async def start_pipeline(data: dict, project_id: str = ""): return task_id = task_row["id"] + # ORCH-019 (FR-1/FR-2, ADR-001 D1/D2): classify the task as a bug-fix and put it + # on the cheaper bug-fast-track (skips the `architecture` stage downstream). The + # gate idiom is `applies(repo) and is_bug_task(...)`: the LOCAL, network-free + # `bug_fast_track_applies` is checked FIRST so a disabled kill-switch / out-of-scope + # repo costs ZERO network (no has_label call). The Plane `Bug` label is the source + # of truth (read here at start, NEVER in the hot claim_next_job — NFR-4); the type + # is persisted in tasks.track so advance_stage routes off the DB, not the network. + # never-raise / fail-safe: ANY error -> task stays track='full' (full cycle, AC-6). + try: + from .. import bug_fast_track + if bug_fast_track.bug_fast_track_applies(repo) and bug_fast_track.is_bug_task( + work_item_id, plane_project_id + ): + set_task_track(task_id, "bug") + logger.info( + f"Task {work_item_id}: classified as BUG -> bug-fast-track " + f"(architecture stage will be skipped, ORCH-019)" + ) + try: + from ..plane_sync import add_comment as _bug_comment + _bug_comment( + work_item_id, + "\U0001f41e Багфикс-трек: " + "упрощённый маршрут " + "(пропуск стадии architecture). " + "Все Quality Gate исполняются.", + author="analyst", + ) + except Exception: + pass + except Exception as e: + logger.warning( + f"Task {work_item_id}: bug-fast-track classification skipped " + f"(fail-safe -> full cycle): {e}" + ) + # ORCH-088 (FR-1/AC-6, ADR-001 D1): DEFER the branch cut for an applicable repo. # Creating the Gitea branch here (T0, issue -> analysis) would cut it from `main` # BEFORE the predecessor is merged -> stale base. When the serial gate applies we diff --git a/tests/test_bug_fast_track.py b/tests/test_bug_fast_track.py new file mode 100644 index 0000000..cf3c9a6 --- /dev/null +++ b/tests/test_bug_fast_track.py @@ -0,0 +1,168 @@ +"""ORCH-019 — src/bug_fast_track.py: bug-fast-track pure logic (never-raise, fail-safe). + +Covers (04-test-plan.yaml): + TC-01 is_bug_task() True for an issue carrying the `Bug` label (label read from + the Plane API via labels.has_label, NOT the webhook payload). + TC-02 is_bug_task() False on missing/ambiguous label or labels=None (fail-safe). + TC-03 bug_fast_track_applies(): the LOCAL scope (enabled + CSV repos) is checked + FIRST, before any network; disabled flag -> False without has_label. + TC-04 never-raise: an exception in the label apparatus degrades is_bug_task to + False (full cycle), never propagates. +""" +import os +import tempfile + +import pytest + +os.environ.setdefault( + "ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_bug_fast_track.db") +) +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") + +from src import bug_fast_track # noqa: E402 +from src import plane_sync # noqa: E402 +from src import config as cfg # noqa: E402 + + +@pytest.fixture(autouse=True) +def enabled_self_hosting(monkeypatch): + monkeypatch.setattr(cfg.settings, "bug_fast_track_enabled", True, raising=False) + monkeypatch.setattr(cfg.settings, "bug_fast_track_label", "Bug", raising=False) + monkeypatch.setattr(cfg.settings, "bug_fast_track_repos", "", raising=False) + # Keep _resolve_project_id offline-deterministic (mirrors test_labels.py). + monkeypatch.setattr(plane_sync, "_resolve_project_id", lambda w=None, p=None: "proj-1") + yield + + +# --- TC-01: classification True -------------------------------------------- +def test_tc01_is_bug_task_true(monkeypatch): + monkeypatch.setattr(plane_sync, "fetch_issue_labels", lambda w, p=None: ["uuid-BUG"]) + monkeypatch.setattr(plane_sync, "get_project_labels", lambda pid: {"bug": "uuid-BUG"}) + assert bug_fast_track.is_bug_task("ORCH-1", "proj-1") is True + + +def test_tc01_label_from_plane_api_not_payload(monkeypatch): + """The decision comes from labels.has_label (Plane API), independent of any + webhook payload field — a payload `type` is irrelevant.""" + seen = {"fetch": 0} + + def fetch(w, p=None): + seen["fetch"] += 1 + return ["uuid-BUG"] + monkeypatch.setattr(plane_sync, "fetch_issue_labels", fetch) + monkeypatch.setattr(plane_sync, "get_project_labels", lambda pid: {"bug": "uuid-BUG"}) + assert bug_fast_track.is_bug_task("ORCH-1", "proj-1") is True + assert seen["fetch"] == 1 # the Plane API WAS consulted + + +# --- TC-02: fail-safe on absent / ambiguous / None ------------------------- +def test_tc02_label_absent(monkeypatch): + monkeypatch.setattr(plane_sync, "fetch_issue_labels", lambda w, p=None: ["uuid-OTHER"]) + monkeypatch.setattr(plane_sync, "get_project_labels", lambda pid: {"bug": "uuid-BUG"}) + assert bug_fast_track.is_bug_task("ORCH-1", "proj-1") is False + + +def test_tc02_labels_none(monkeypatch): + monkeypatch.setattr(plane_sync, "fetch_issue_labels", lambda w, p=None: None) + monkeypatch.setattr(plane_sync, "get_project_labels", lambda pid: {"bug": "uuid-BUG"}) + assert bug_fast_track.is_bug_task("ORCH-1", "proj-1") is False + + +def test_tc02_label_ambiguous(monkeypatch): + monkeypatch.setattr(plane_sync, "fetch_issue_labels", lambda w, p=None: ["uuid-BUG"]) + monkeypatch.setattr( + plane_sync, "get_project_labels", lambda pid: {"bug": "__AMBIGUOUS__"} + ) + assert bug_fast_track.is_bug_task("ORCH-1", "proj-1") is False + + +def test_tc02_empty_label_config(monkeypatch): + monkeypatch.setattr(cfg.settings, "bug_fast_track_label", "", raising=False) + monkeypatch.setattr(plane_sync, "fetch_issue_labels", lambda w, p=None: ["uuid-BUG"]) + monkeypatch.setattr(plane_sync, "get_project_labels", lambda pid: {"bug": "uuid-BUG"}) + assert bug_fast_track.is_bug_task("ORCH-1", "proj-1") is False + + +# --- TC-03: local scope first (CSV + self-hosting + kill-switch) ------------ +def test_tc03_empty_csv_self_hosting_only(monkeypatch): + monkeypatch.setattr(cfg.settings, "bug_fast_track_repos", "", raising=False) + assert bug_fast_track.bug_fast_track_applies("orchestrator") is True + assert bug_fast_track.bug_fast_track_applies("enduro-trails") is False + + +def test_tc03_csv_membership(monkeypatch): + monkeypatch.setattr(cfg.settings, "bug_fast_track_repos", "enduro-trails, foo", raising=False) + assert bug_fast_track.bug_fast_track_applies("enduro-trails") is True + assert bug_fast_track.bug_fast_track_applies("foo") is True + # orchestrator is NOT in the explicit CSV -> out of scope. + assert bug_fast_track.bug_fast_track_applies("orchestrator") is False + + +def test_tc03_killswitch_off_no_network(monkeypatch): + """The gate idiom `applies(repo) and is_bug_task(...)` short-circuits before any + network call when the kill-switch is off (AC-6).""" + monkeypatch.setattr(cfg.settings, "bug_fast_track_enabled", False, raising=False) + called = {"fetch": 0} + + def spy(*a, **k): + called["fetch"] += 1 + return ["uuid-BUG"] + monkeypatch.setattr(plane_sync, "fetch_issue_labels", spy) + + repo = "orchestrator" + fired = bug_fast_track.bug_fast_track_applies(repo) and bug_fast_track.is_bug_task( + "ORCH-1", "proj-1" + ) + assert fired is False + assert called["fetch"] == 0 # is_bug_task never reached -> zero network + + +# --- TC-04: never-raise ----------------------------------------------------- +def test_tc04_is_bug_task_never_raises(monkeypatch): + def boom(*a, **k): + raise RuntimeError("plane down") + monkeypatch.setattr(plane_sync, "fetch_issue_labels", boom) + monkeypatch.setattr(plane_sync, "get_project_labels", lambda pid: {"bug": "uuid-BUG"}) + # Degrades to False (full cycle), no exception. + assert bug_fast_track.is_bug_task("ORCH-1", "proj-1") is False + + +def test_tc04_applies_never_raises(monkeypatch): + # A repos config whose access explodes still yields False, not a crash. + class _Poisoned: + bug_fast_track_enabled = True + + @property + def bug_fast_track_repos(self): + raise RuntimeError("boom") + + monkeypatch.setattr(bug_fast_track, "settings", _Poisoned(), raising=False) + assert bug_fast_track.bug_fast_track_applies("orchestrator") is False + + +# --- skips_architecture predicate ------------------------------------------ +def test_skips_architecture_bug(monkeypatch): + assert bug_fast_track.skips_architecture("bug") is True + assert bug_fast_track.skips_architecture("BUG") is True + + +def test_skips_architecture_full(monkeypatch): + assert bug_fast_track.skips_architecture("full") is False + assert bug_fast_track.skips_architecture(None) is False + assert bug_fast_track.skips_architecture("") is False + + +def test_skips_architecture_killswitch_off(monkeypatch): + monkeypatch.setattr(cfg.settings, "bug_fast_track_enabled", False, raising=False) + # Even a stored 'bug' track is inert when the kill-switch is off (1:1 routing). + assert bug_fast_track.skips_architecture("bug") is False + + +# --- snapshot --------------------------------------------------------------- +def test_snapshot_never_raises(): + snap = bug_fast_track.snapshot() + assert set(snap) >= { + "enabled", "label", "repos", + "active_bug_tasks", "total_bug_tasks", "est_saved_architecture_runs", + } diff --git a/tests/test_bug_fast_track_composition.py b/tests/test_bug_fast_track_composition.py new file mode 100644 index 0000000..d3d5233 --- /dev/null +++ b/tests/test_bug_fast_track_composition.py @@ -0,0 +1,87 @@ +"""ORCH-019 — composition with ORCH-088 serial-gate / ORCH-089 auto-label (AC-9). + +Covers (04-test-plan.yaml): + TC-14 A bug-fast-track task is an ORDINARY repo task for the serial gate + (ORCH-088): it counts as an active task and is gated like any other — it + does NOT bypass serialisation. autoApprove/autoDeploy (ORCH-089) apply on + the bug track (scope is repo-based, track-agnostic). +""" +import os +import tempfile + +import pytest + +os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_bft_composition.db") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") + +import src.db as db # noqa: E402 +from src.db import init_db, get_db, enqueue_job, claim_next_job # noqa: E402 +from src import serial_gate, labels, config as cfg # noqa: E402 + + +@pytest.fixture(autouse=True) +def fresh_db(tmp_path, monkeypatch): + dbfile = tmp_path / "comp.db" + monkeypatch.setattr(db.settings, "db_path", str(dbfile)) + monkeypatch.setattr(cfg.settings, "serial_gate_enabled", True, raising=False) + monkeypatch.setattr(cfg.settings, "serial_gate_repos", "", raising=False) + monkeypatch.setattr(cfg.settings, "serial_gate_freeze_enabled", False, raising=False) + monkeypatch.setattr(cfg.settings, "task_deps_enabled", False, raising=False) + monkeypatch.setattr(cfg.settings, "bug_fast_track_enabled", True, raising=False) + monkeypatch.setattr(cfg.settings, "auto_label_enabled", True, raising=False) + init_db() + yield + + +def _make_task(work_item_id, stage="analysis", repo="orchestrator", track="full"): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title, track) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (work_item_id, work_item_id, repo, f"feature/{work_item_id}", stage, work_item_id, track), + ) + tid = cur.lastrowid + conn.commit() + conn.close() + return tid + + +def test_tc14_bug_task_counts_as_active_in_serial_gate(): + # An EARLIER bug task A (unfinished) must gate a later task B's analyst-job — + # a bug task does NOT bypass the serial gate. + _make_task("ORCH-301", stage="development", track="bug") # active bug predecessor + b = _make_task("ORCH-302", stage="analysis", track="full") # new task + enqueue_job("analyst", "orchestrator", "B", task_id=b) + assert claim_next_job() is None, "a bug task must gate a later analyst-job (no bypass)" + # The bug task is the active task in the snapshot. + per = serial_gate.snapshot()["per_repo"]["orchestrator"] + assert per["active_task"]["work_item_id"] == "ORCH-301" + + +def test_tc14_bug_task_itself_gated_behind_predecessor(): + # The bug task is also HELD behind an earlier non-bug task (symmetry). + _make_task("ORCH-310", stage="development", track="full") # active predecessor + b = _make_task("ORCH-311", stage="analysis", track="bug") # new BUG task + enqueue_job("analyst", "orchestrator", "bug-B", task_id=b) + assert claim_next_job() is None, "a bug task is itself serialised behind the predecessor" + + +def test_tc14_bug_task_claimable_once_predecessor_done(): + a = _make_task("ORCH-320", stage="development", track="full") + b = _make_task("ORCH-321", stage="analysis", track="bug") + jid = enqueue_job("analyst", "orchestrator", "bug-B", task_id=b) + assert claim_next_job() is None + # Finish A -> the bug task's analyst-job is now claimable. + conn = get_db() + conn.execute("UPDATE tasks SET stage='done' WHERE id=?", (a,)) + conn.commit() + conn.close() + claimed = claim_next_job() + assert claimed is not None and claimed["id"] == jid + + +def test_tc14_auto_label_applies_track_agnostic(monkeypatch): + # autoApprove/autoDeploy scope is repo-based, independent of the bug track. + assert labels.auto_approve_applies("orchestrator") is True + assert labels.auto_deploy_applies("orchestrator") is True diff --git a/tests/test_bug_fast_track_e2e.py b/tests/test_bug_fast_track_e2e.py new file mode 100644 index 0000000..0dd9be5 --- /dev/null +++ b/tests/test_bug_fast_track_e2e.py @@ -0,0 +1,184 @@ +"""ORCH-019 — bug-fast-track end-to-end / start_pipeline integration. + +Covers (04-test-plan.yaml): + TC-08 E2E: a bug task walks development -> review -> testing -> deploy-staging -> + deploy -> done with EVERY edge gate executed, NEVER entering architecture. + TC-09 start_pipeline: an issue with the `Bug` label (flag on, repo in scope) is + created on the bug-fast-track (tasks.track='bug'); an issue without it is + created on the full cycle (track='full'). + TC-10 Fail-safe: with bug_fast_track_enabled=False a `Bug`-labelled issue is + created on the full cycle (track='full'), is_bug_task never consulted. +""" +import os +import tempfile + +import pytest + +_test_db = os.path.join(tempfile.gettempdir(), "test_bug_fast_track_e2e.db") +os.environ["ORCH_DB_PATH"] = _test_db +os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir() +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") + +import src.db as db # noqa: E402 +from src.db import init_db, get_db # noqa: E402 +from src import stage_engine, config as cfg # noqa: E402 +from src.stage_engine import advance_stage # noqa: E402 + + +@pytest.fixture(autouse=True) +def fresh_db(monkeypatch, tmp_path): + dbfile = tmp_path / "e2e.db" + monkeypatch.setattr(db.settings, "db_path", str(dbfile)) + monkeypatch.setattr(cfg.settings, "bug_fast_track_enabled", True, raising=False) + monkeypatch.setattr(cfg.settings, "bug_fast_track_repos", "", raising=False) + # Keep the edge sub-gates + self-deploy + serial gate inert so the PLAIN advance + # path runs deterministically and offline (we assert routing + gate execution, + # not the self-hosting deploy mechanics — those have their own suites). + for flag in ( + "self_deploy_enabled", "security_gate_enabled", "merge_gate_enabled", + "coverage_gate_enabled", "image_freshness_enabled", + "post_deploy_monitor_enabled", "serial_gate_enabled", + ): + monkeypatch.setattr(cfg.settings, flag, False, raising=False) + 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", "set_issue_analysis", + "set_issue_awaiting_deploy", "set_issue_deploying", "set_issue_monitoring", + "set_issue_approved", + ): + monkeypatch.setattr(stage_engine, name, lambda *a, **k: None, raising=False) + yield + + +def _make_task(work_item_id, stage="analysis", repo="orchestrator", track="full"): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title, track) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (work_item_id, work_item_id, repo, f"feature/{work_item_id}", stage, work_item_id, track), + ) + tid = cur.lastrowid + conn.commit() + conn.close() + return tid + + +# --- TC-08: E2E walk, architecture skipped, every gate executed ------------ +def test_tc08_bug_task_full_walk_skips_architecture(monkeypatch): + tid = _make_task("ORCH-e2e", stage="analysis", track="bug") + invoked = [] + + # Record + pass every registered edge gate. check_analysis_approved is NOT in + # this map: with finished_agent=None it is satisfied as approved-via-status + # (no call). check_architecture_done MUST never be invoked. + def _passing(name): + def _fn(*a, **k): + invoked.append(name) + return (True, f"{name} ok") + return _fn + + for gate in ( + "check_ci_green", "check_reviewer_verdict", "check_tests_passed", + "check_staging_status", "check_deploy_status", "check_architecture_done", + ): + monkeypatch.setitem(stage_engine.QG_CHECKS, gate, _passing(gate)) + + visited = ["analysis"] + wi, repo, branch = "ORCH-e2e", "orchestrator", "feature/ORCH-e2e" + for _ in range(10): + row = db.get_task_by_work_item_id(wi) + cur = row["stage"] + if cur in ("done", "cancelled"): + break + res = advance_stage(tid, cur, repo, wi, branch, finished_agent=None) + if not res.advanced: + break + visited.append(res.to_stage) + + assert "architecture" not in visited, f"bug task must skip architecture: {visited}" + assert visited[:3] == ["analysis", "development", "review"] + assert visited[-1] == "done", f"task should reach done: {visited}" + # Every downstream edge gate ran; the architecture gate never did. + for gate in ("check_ci_green", "check_reviewer_verdict", "check_tests_passed", + "check_staging_status", "check_deploy_status"): + assert gate in invoked, f"gate {gate} must execute on the bug track" + assert "check_architecture_done" not in invoked + + +# --- TC-09 / TC-10: start_pipeline classification -------------------------- +async def _drive_start_pipeline(monkeypatch, *, is_bug: bool, enabled: bool): + from src.webhooks import plane + from src import plane_sync, bug_fast_track + from src.projects import ProjectConfig + + monkeypatch.setattr(cfg.settings, "bug_fast_track_enabled", enabled, raising=False) + + proj = ProjectConfig( + plane_project_id="proj-uuid", repo="orchestrator", + work_item_prefix="ORCH", name="orch", + ) + monkeypatch.setattr(plane, "get_project_by_plane_id", lambda pid: proj) + monkeypatch.setattr(plane, "_qg0_errors", lambda name, desc: []) + monkeypatch.setattr(plane, "ensure_unique_work_item_id", lambda wid, repo: wid) + monkeypatch.setattr(plane_sync, "fetch_issue_sequence_id", lambda *a, **k: 777) + monkeypatch.setattr(plane_sync, "set_issue_analysis", lambda *a, **k: None) + monkeypatch.setattr(plane_sync, "add_comment", lambda *a, **k: None) + monkeypatch.setattr(plane, "enqueue_job", lambda *a, **k: 1) + + async def _noop(*a, **k): + return None + monkeypatch.setattr(plane, "_create_gitea_branch", _noop) + monkeypatch.setattr(plane, "_create_initial_docs", _noop) + + # Spy is_bug_task so we can assert it is/ isn't consulted; applies() stays REAL + # (flag + self-hosting scope), so TC-10 proves the local short-circuit. + seen = {"is_bug_task": 0} + + def _is_bug(wi, pid=None): + seen["is_bug_task"] += 1 + return is_bug + monkeypatch.setattr(bug_fast_track, "is_bug_task", _is_bug) + + data = { + "id": "issue-uuid-1", + "name": "Fix the crash on submit", + "description_stripped": "A sufficiently long description for QG-0 to pass.", + "project": "proj-uuid", + } + await plane.start_pipeline(data, project_id="proj-uuid") + return seen + + +def test_tc09_bug_label_creates_bug_track(monkeypatch): + import asyncio + seen = asyncio.run(_drive_start_pipeline(monkeypatch, is_bug=True, enabled=True)) + assert seen["is_bug_task"] == 1 # applies() True -> classification consulted + row = db.get_task_by_work_item_id("ORCH-777") + assert row is not None + assert row["track"] == "bug" + + +def test_tc09_no_label_creates_full_track(monkeypatch): + import asyncio + seen = asyncio.run(_drive_start_pipeline(monkeypatch, is_bug=False, enabled=True)) + assert seen["is_bug_task"] == 1 + row = db.get_task_by_work_item_id("ORCH-777") + assert row["track"] == "full" + + +def test_tc10_killswitch_off_bug_label_full_cycle(monkeypatch): + import asyncio + seen = asyncio.run(_drive_start_pipeline(monkeypatch, is_bug=True, enabled=False)) + # applies() is False (kill-switch) -> is_bug_task short-circuited (zero network). + assert seen["is_bug_task"] == 0 + row = db.get_task_by_work_item_id("ORCH-777") + assert row["track"] == "full" diff --git a/tests/test_bug_fast_track_escalation.py b/tests/test_bug_fast_track_escalation.py new file mode 100644 index 0000000..03ac696 --- /dev/null +++ b/tests/test_bug_fast_track_escalation.py @@ -0,0 +1,105 @@ +"""ORCH-019 — escalation of a complex bug to the full cycle (FR-5 / AC-5, D5). + +Covers (04-test-plan.yaml): + TC-11 After the escalate endpoint resets track 'bug' -> 'full' (while the task + is still in `analysis`), the next advance routes analysis -> architecture + (return to the full cycle with the architect run). +""" +import os +import tempfile + +import pytest + +_test_db = os.path.join(tempfile.gettempdir(), "test_bug_fast_track_escalation.db") +os.environ["ORCH_DB_PATH"] = _test_db +os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir() +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") + +import src.db as db # noqa: E402 +from src.db import init_db, get_db # noqa: E402 +from src import stage_engine, config as cfg # noqa: E402 +from src.stage_engine import advance_stage # noqa: E402 + + +@pytest.fixture(autouse=True) +def fresh_db(monkeypatch, tmp_path): + dbfile = tmp_path / "esc.db" + monkeypatch.setattr(db.settings, "db_path", str(dbfile)) + monkeypatch.setattr(cfg.settings, "bug_fast_track_enabled", True, raising=False) + 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", "set_issue_analysis", + "set_issue_awaiting_deploy", "set_issue_deploying", "set_issue_monitoring", + "set_issue_approved", + ): + monkeypatch.setattr(stage_engine, name, lambda *a, **k: None, raising=False) + yield + + +def _make_task(work_item_id, stage="analysis", track="bug"): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title, track) " + "VALUES (?, ?, ?, ?, ?, ?, ?)", + (work_item_id, work_item_id, "orchestrator", f"feature/{work_item_id}", + stage, work_item_id, track), + ) + tid = cur.lastrowid + conn.commit() + conn.close() + return tid + + +def test_tc11_escalate_returns_to_full_cycle(monkeypatch): + import asyncio + from src import main + + tid = _make_task("ORCH-cmplx", stage="analysis", track="bug") + + # Operator escalates while the task is still in analysis. + out = asyncio.run(main.bug_fast_track_escalate(work_item="ORCH-cmplx")) + assert out["ok"] is True + assert out["track"] == "full" + assert out["was"] == "bug" + assert db.get_task_track(tid) == "full" + + # The next advance now routes back through architecture (full cycle). + res = advance_stage( + tid, "analysis", "orchestrator", "ORCH-cmplx", "feature/ORCH-cmplx", + finished_agent=None, + ) + assert res.to_stage == "architecture" + assert res.enqueued_agent == "architect" + + +def test_tc11_escalate_unknown_work_item(): + import asyncio + from src import main + out = asyncio.run(main.bug_fast_track_escalate(work_item="ORCH-nope")) + assert out["ok"] is False + + +def test_tc11_escalate_missing_arg(): + import asyncio + from src import main + out = asyncio.run(main.bug_fast_track_escalate(work_item="")) + assert out["ok"] is False + + +def test_tc11_escalate_idempotent_on_full(monkeypatch): + import asyncio + from src import main + tid = _make_task("ORCH-already", stage="analysis", track="full") + out = asyncio.run(main.bug_fast_track_escalate(work_item="ORCH-already")) + assert out["ok"] is True + assert out["was"] == "full" + assert db.get_task_track(tid) == "full" diff --git a/tests/test_bug_fast_track_gates.py b/tests/test_bug_fast_track_gates.py new file mode 100644 index 0000000..1f4ef51 --- /dev/null +++ b/tests/test_bug_fast_track_gates.py @@ -0,0 +1,97 @@ +"""ORCH-019 — Quality-Gate invariants on the bug-fast-track (root invariant NFR-1). + +Covers (04-test-plan.yaml): + TC-07 The QG_CHECKS registry + the check_* signatures are NOT changed by the + bug-fast-track; the machine verdict-keys (verdict / result / deploy_status / + staging_status / security_status / coverage_status) are preserved by name + and case. + TC-12 check_analysis_complete does NOT special-case the bug track (ADR-001 D4): + a bug lite-package that still emits all 4 analysis files passes; the same + requirement holds for a non-bug task (no false block, no weakening). +""" +import os +import tempfile + +os.environ.setdefault( + "ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_bft_gates.db") +) +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") + +from src.qg.checks import QG_CHECKS, check_analysis_complete # noqa: E402 + + +# --- TC-07: registry + verdict-keys unchanged ------------------------------ +def test_tc07_qg_checks_registry_unchanged(): + # The exact registered gate set — a bug-fast-track must add/remove NOTHING. + expected = { + "check_analysis_complete", + "check_analysis_approved", + "check_architecture_done", + "check_ci_green", + "check_review_approved", + "check_reviewer_verdict", + "check_tests_local", + "check_tests_passed", + "check_staging_status", + "check_staging_image_fresh", + "check_deploy_status", + "check_branch_mergeable", + "check_security_gate", + "check_coverage_gate", + } + assert set(QG_CHECKS.keys()) == expected + + +def test_tc07_verdict_keys_preserved(): + """The frontmatter machine verdict-keys are parsed by exact name/case. ORCH-019 + touches none of the parsers, so the literal keys must still be present.""" + import inspect + from src.qg import checks as checks_mod + src = inspect.getsource(checks_mod) + for key in ("verdict:", "result:", "deploy_status:", "staging_status:"): + assert key in src, f"verdict key '{key}' must be preserved in qg.checks" + # security_status / coverage_status live in their own leaves but are read via + # the same unified frontmatter contract — assert they survive there. + import inspect as _i + from src import security_gate, coverage_gate + assert "security_status" in _i.getsource(security_gate) + assert "coverage_status" in _i.getsource(coverage_gate) + + +# --- TC-12: analysis gate not weakened, no false block --------------------- +def _seed_analysis_docs(repo_root, work_item_id, files): + d = os.path.join(repo_root, "docs", "work-items", work_item_id) + os.makedirs(d, exist_ok=True) + for fn in files: + with open(os.path.join(d, fn), "w") as fh: + fh.write("stub\n") + + +def test_tc12_bug_lite_package_with_all_four_passes(monkeypatch, tmp_path): + from src.qg import checks as checks_mod + monkeypatch.setattr(checks_mod, "_repo_path", lambda repo, branch=None: str(tmp_path)) + _seed_analysis_docs( + str(tmp_path), "ORCH-bug", + ["01-brd.md", "02-trz.md", "03-acceptance-criteria.md", "04-test-plan.yaml"], + ) + ok, reason = check_analysis_complete("orchestrator", "ORCH-bug", "feature/x") + assert ok is True, reason + + +def test_tc12_missing_file_still_fails_for_any_track(monkeypatch, tmp_path): + """The gate is NOT weakened for bugs: a package missing 02/03 still fails — + exactly as for a non-bug task (the gate never reads tasks.track).""" + from src.qg import checks as checks_mod + monkeypatch.setattr(checks_mod, "_repo_path", lambda repo, branch=None: str(tmp_path)) + _seed_analysis_docs(str(tmp_path), "ORCH-bug", ["01-brd.md", "04-test-plan.yaml"]) + ok, reason = check_analysis_complete("orchestrator", "ORCH-bug", "feature/x") + assert ok is False + assert "02-trz.md" in reason and "03-acceptance-criteria.md" in reason + + +def test_tc12_signature_has_no_track_param(): + import inspect + params = list(inspect.signature(check_analysis_complete).parameters) + # byte-for-byte signature: (repo, work_item_id, branch=None) — no track-awareness. + assert params == ["repo", "work_item_id", "branch"] diff --git a/tests/test_bug_fast_track_routing.py b/tests/test_bug_fast_track_routing.py new file mode 100644 index 0000000..6ad951b --- /dev/null +++ b/tests/test_bug_fast_track_routing.py @@ -0,0 +1,147 @@ +"""ORCH-019 — advance_stage routing-override (ADR-001 D3). + +Covers (04-test-plan.yaml): + TC-05 bug task: analysis -> development (architecture skipped, developer + enqueued); non-bug task: analysis -> architecture (architect enqueued). + TC-06 STAGE_TRANSITIONS is structurally unchanged (set of stages + edges + + agents + qg byte-for-byte) — the override does NOT mutate the table. +""" +import os +import tempfile + +import pytest + +_test_db = os.path.join(tempfile.gettempdir(), "test_bug_fast_track_routing.db") +os.environ["ORCH_DB_PATH"] = _test_db +os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir() +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") + +import src.db as db # noqa: E402 +from src.db import init_db, get_db, set_task_track # noqa: E402 +from src import stage_engine # noqa: E402 +from src import config as cfg # noqa: E402 +from src.stage_engine import advance_stage # noqa: E402 + + +@pytest.fixture(autouse=True) +def fresh_db(monkeypatch, tmp_path): + dbfile = tmp_path / "r.db" + monkeypatch.setattr(db.settings, "db_path", str(dbfile)) + monkeypatch.setattr(cfg.settings, "bug_fast_track_enabled", True, raising=False) + 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", "set_issue_analysis", + "set_issue_awaiting_deploy", "set_issue_deploying", "set_issue_monitoring", + "set_issue_approved", + ): + monkeypatch.setattr(stage_engine, name, lambda *a, **k: None, raising=False) + yield + + +def _make_task(work_item_id, stage="analysis", repo="orchestrator"): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title) " + "VALUES (?, ?, ?, ?, ?, ?)", + (work_item_id, work_item_id, repo, f"feature/{work_item_id}", stage, work_item_id), + ) + tid = cur.lastrowid + conn.commit() + conn.close() + return tid + + +# --- TC-05 ----------------------------------------------------------------- +def test_tc05_bug_task_skips_architecture(): + tid = _make_task("ORCH-bug", stage="analysis") + set_task_track(tid, "bug") + # agent=None -> the webhook Approved-via-status path (gate satisfied, advance). + res = advance_stage( + tid, "analysis", "orchestrator", "ORCH-bug", "feature/ORCH-bug", + finished_agent=None, + ) + assert res.advanced is True + assert res.to_stage == "development" + assert res.enqueued_agent == "developer" + # DB stage actually advanced past architecture. + row = db.get_task_by_work_item_id("ORCH-bug") + assert row["stage"] == "development" + + +def test_tc05_full_task_keeps_architecture(): + tid = _make_task("ORCH-full", stage="analysis") + # track defaults to 'full' (no set_task_track call). + res = advance_stage( + tid, "analysis", "orchestrator", "ORCH-full", "feature/ORCH-full", + finished_agent=None, + ) + assert res.advanced is True + assert res.to_stage == "architecture" + assert res.enqueued_agent == "architect" + + +def test_tc05_killswitch_off_bug_keeps_architecture(monkeypatch): + monkeypatch.setattr(cfg.settings, "bug_fast_track_enabled", False, raising=False) + tid = _make_task("ORCH-bugoff", stage="analysis") + set_task_track(tid, "bug") # stored, but the flag is off -> inert + res = advance_stage( + tid, "analysis", "orchestrator", "ORCH-bugoff", "feature/ORCH-bugoff", + finished_agent=None, + ) + assert res.to_stage == "architecture" + assert res.enqueued_agent == "architect" + + +def test_tc05_bug_only_affects_analysis_edge(): + """The override is scoped to the analysis-exit edge only — a bug task on + `development` still routes development -> review (no spurious skips).""" + tid = _make_task("ORCH-bugdev", stage="development") + set_task_track(tid, "bug") + # Make check_ci_green pass deterministically (we only assert routing, not CI). + import src.stage_engine as se + orig = se.QG_CHECKS.get("check_ci_green") + se.QG_CHECKS["check_ci_green"] = lambda *a, **k: (True, "ok") + try: + res = advance_stage( + tid, "development", "orchestrator", "ORCH-bugdev", "feature/ORCH-bugdev", + finished_agent=None, + ) + finally: + if orig is not None: + se.QG_CHECKS["check_ci_green"] = orig + assert res.to_stage == "review" + + +# --- TC-06: STAGE_TRANSITIONS structurally unchanged ----------------------- +def test_tc06_stage_transitions_unchanged(): + from src.stages import STAGE_TRANSITIONS + expected = { + "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}, + "cancelled": {"next": None, "agent": None, "qg": None}, + } + assert STAGE_TRANSITIONS == expected + + +def test_tc06_get_next_stage_pure(): + """get_next_stage / get_agent_for_stage stay PURE (no track arg) — the override + lives in advance_stage, not in stages.py.""" + from src.stages import get_next_stage, get_agent_for_stage + assert get_next_stage("analysis") == "architecture" + assert get_agent_for_stage("analysis") == "architect" diff --git a/tests/test_db_migrations.py b/tests/test_db_migrations.py new file mode 100644 index 0000000..4bdac37 --- /dev/null +++ b/tests/test_db_migrations.py @@ -0,0 +1,79 @@ +"""ORCH-019 (TC-15) — additive, idempotent tasks.track migration. + +The bug-fast-track stores the task type in an additive ``tasks.track`` column +(``TEXT DEFAULT 'full'``) created via ``_ensure_column`` (idempotent). A repeated +``init_db`` must not crash, existing rows must default to ``'full'``, and the +helpers must round-trip. +""" +import os +import tempfile + +import pytest + +os.environ.setdefault( + "ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_db_migrations.db") +) +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") + +import src.db as db # noqa: E402 +from src.db import init_db, get_db, set_task_track, get_task_track # noqa: E402 + + +@pytest.fixture(autouse=True) +def fresh_db(tmp_path, monkeypatch): + dbfile = tmp_path / "m.db" + monkeypatch.setattr(db.settings, "db_path", str(dbfile)) + init_db() + yield + + +def _columns(table): + conn = get_db() + try: + return [r[1] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()] + finally: + conn.close() + + +def test_tc15_track_column_present_with_default(): + assert "track" in _columns("tasks") + # A row inserted WITHOUT track gets the DEFAULT 'full'. + conn = get_db() + conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title) " + "VALUES ('p','ORCH-1','orchestrator','feature/x','analysis','t')" + ) + conn.commit() + row = conn.execute("SELECT track FROM tasks WHERE work_item_id='ORCH-1'").fetchone() + conn.close() + assert row["track"] == "full" + + +def test_tc15_init_db_idempotent(): + # Running init_db again is a no-op on the existing column (no crash). + init_db() + init_db() + assert "track" in _columns("tasks") + + +def test_tc15_helpers_round_trip(): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title) " + "VALUES ('p2','ORCH-2','orchestrator','feature/y','analysis','t')" + ) + tid = cur.lastrowid + conn.commit() + conn.close() + + assert get_task_track(tid) == "full" # default + set_task_track(tid, "bug") + assert get_task_track(tid) == "bug" + set_task_track(tid, "full") + assert get_task_track(tid) == "full" + + +def test_tc15_get_task_track_missing_row_failsafe(): + # Unknown task id -> 'full' (fail-safe -> full cycle), never raises. + assert get_task_track(999999) == "full" diff --git a/tests/test_queue_endpoint.py b/tests/test_queue_endpoint.py index 9e51d74..de31da0 100644 --- a/tests/test_queue_endpoint.py +++ b/tests/test_queue_endpoint.py @@ -59,3 +59,50 @@ def test_queue_serial_gate_reflects_freeze(): assert "orchestrator" in per assert per["orchestrator"]["frozen"] is True assert per["orchestrator"]["frozen_reason"] == "DEGRADED" + + +# --- ORCH-019 (TC-13): additive bug_fast_track block ----------------------- +def test_queue_has_bug_fast_track_block_and_keeps_existing_keys(monkeypatch): + import asyncio + from src import main + + monkeypatch.setattr(cfg.settings, "bug_fast_track_enabled", True, raising=False) + payload = asyncio.run(main.queue()) + + # Pre-existing keys are all still present (no contract break). + for key in ("counts", "serial_gate", "coverage", "auto_labels", "stop", "recent"): + assert key in payload, f"existing /queue key '{key}' must be preserved" + + assert "bug_fast_track" in payload + bft = payload["bug_fast_track"] + assert bft["enabled"] is True + assert set(bft) >= { + "enabled", "label", "repos", + "active_bug_tasks", "total_bug_tasks", "est_saved_architecture_runs", + } + + +def test_queue_bug_fast_track_counts_bug_tasks(): + import asyncio + from src import main + + conn = db.get_db() + conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title, track) " + "VALUES ('p1','ORCH-401','orchestrator','feature/x','development','t','bug')" + ) + conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title, track) " + "VALUES ('p2','ORCH-402','orchestrator','feature/y','done','t','bug')" + ) + conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, title, track) " + "VALUES ('p3','ORCH-403','orchestrator','feature/z','development','t','full')" + ) + conn.commit() + conn.close() + + bft = asyncio.run(main.queue())["bug_fast_track"] + assert bft["total_bug_tasks"] == 2 # two bug tasks total + assert bft["active_bug_tasks"] == 1 # one non-terminal bug task + assert bft["est_saved_architecture_runs"] == 2