From a083ed849512d4e947b0d820308e0f486f7f7a61 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sat, 6 Jun 2026 19:31:04 +0000 Subject: [PATCH] analyst(ET): auto-commit from analyst run_id=191 --- docs/work-items/ORCH-053/01-brd.md | 128 +++++++++++ docs/work-items/ORCH-053/02-trz.md | 170 +++++++++++++++ .../ORCH-053/03-acceptance-criteria.md | 116 ++++++++++ docs/work-items/ORCH-053/04-test-plan.yaml | 200 ++++++++++++++++++ 4 files changed, 614 insertions(+) create mode 100644 docs/work-items/ORCH-053/01-brd.md create mode 100644 docs/work-items/ORCH-053/02-trz.md create mode 100644 docs/work-items/ORCH-053/03-acceptance-criteria.md create mode 100644 docs/work-items/ORCH-053/04-test-plan.yaml diff --git a/docs/work-items/ORCH-053/01-brd.md b/docs/work-items/ORCH-053/01-brd.md new file mode 100644 index 0000000..41444e1 --- /dev/null +++ b/docs/work-items/ORCH-053/01-brd.md @@ -0,0 +1,128 @@ +# BRD — ORCH-053: Sweeper потерянных webhook (реконсиляция застрявших стадий) + +Work Item ID: ORCH-053 +Стадия: analysis → (architecture) +Тип: надёжность конвейера (проектирование + реализация). Self-hosting (ORCH). + +## 1. Проблема (бизнес-контекст) + +Продвижение задач между стадиями конвейера завязано **исключительно** на входящие +webhook-события: +- **Plane** (`work_item.updated` → статус In Progress / Approved / Rejected) — единственный + триггер старта задачи, advance и rollback (`src/webhooks/plane.py`). +- **Gitea** (CI-status `success`/`failure`, push, PR reviewed/merged) — триггер + development→review, architecture→development, review→testing, deploy→done + (`src/webhooks/gitea.py`). + +Если входящее событие **потеряно** (502 на падающем/ребилдящемся инстансе, Plane/Gitea +не повторяют доставку, сетевой сбой, sha→branch не разрезолвился, вебхук был временно +выключен) — статус в источнике истины (Plane / зелёный CI) уже изменился, **а задача в +оркестраторе не сдвинулась**. Задача висит молча, без какого-либо механизма восстановления. + +**Живой инцидент (ORCH-044, 06.06):** dev-агент отработал (exit 0, CI позеленел), но +Gitea webhook о CI-success не продвинул задачу (не дошёл / не сматчился sha→branch). +Задача висела бы на `development` молча навсегда — спасли только ручным дёрганьем гейта +`check_ci_green`. Это **системная дыра**, а не разовый сбой; сейчас её ловит ручной +heartbeat-watchdog Стрима (костыль). + +### Что уже есть и почему недостаточно +| Механизм | Что покрывает | Почему не закрывает дыру | +|----------|---------------|--------------------------| +| `requeue_running_jobs()` (startup) | зависшие **jobs** при рестарте | про jobs, не про застрявший **stage-переход** | +| orphan-recovery (`main.py`) | `agent_runs` без `finished_at` | job-уровень, не stage | +| ORCH-5 events de-dup (`delivery_id`) | защита от **дублей** webhook | обратной защиты от **потери** нет | +| ORCH-045 `ci_poll` в `check_ci_green` | поллит CI 12×10с | только **если гейт уже вызван** webhook'ом; не пришёл webhook → гейт не вызывается | + +Общий принцип всех существующих механизмов — restart-safe resilience на уровне jobs. +**Нет ни одного механизма, реконсилирующего рассинхрон «источник истины ≠ стадия задачи».** + +## 2. Цель + +Задача **не должна застревать молча** из-за потерянного входящего события. Ввести +фоновый периодический **sweeper / reconciler**, который сам находит «зависшие» задачи +и доигрывает пропущенный переход — через **те же штатные гейты и обработчики**, что и +webhook (никакой параллельной логики продвижения). Убрать необходимость в ручном +heartbeat-watchdog. + +## 3. Заинтересованные стороны +- **Owner / Стрим (Слава)** — перестаёт ловить зависания вручную. +- **Все проекты на инстансе** (enduro-trails + orchestrator) — конвейер не встаёт молча. +- **Self-hosting (ORCH)** — особенно при ребилде прода (ORCH-51): вебхуки, прилетевшие + на падающий инстанс, подбираются реконсиляцией после старта. + +## 4. Объём (Scope) + +В объёме — **две взаимодополняющие ветки реконсиляции** (обе обязательны): + +### F-1. Gate-side sweeper (реконсиляция застрявшей стадии по локальной БД) +Периодический проход по таблице `tasks`: найти задачи, у которых +(а) `stage != done`, (б) нет активных job'ов в очереди, (в) с момента `updated_at` +прошло больше **per-stage порога** → пере-проверить QG текущей стадии и, если passed — +продвинуть **штатным путём** (`stage_engine.advance_stage(..., finished_agent=None)`, +тот же путь, что использует webhook). Закрывает потерю Gitea CI/PR-вебхуков (ORCH-044). + +### F-2. Plane-side reconciler (реконсиляция потерянного Plane status-webhook) +Периодический опрос Plane API по проектам реестра (`projects.py`): issues в статусах, +требующих действия (In Progress / Approved / Rejected). Сверить с локальной `tasks` и +доиграть **через существующие обработчики `webhooks/plane.py`**: +- **In Progress + нет задачи в БД** → создать+запустить (`handle_status_start`/`start_pipeline`); +- **Approved + стадия не сдвинута** → advance (`handle_verdict(approved=True)`); +- **Rejected + не откатана** → rollback (`handle_verdict(approved=False)`). + +### F-3. Усиление sha→branch резолва в Gitea-вебхуке +В `handle_ci_status` добавить надёжный fallback (поиск task по БД), чтобы исходный +webhook реже терялся из-за неразрезолвленного branch. Sweeper работает от задачи +(repo+branch известны из БД) и обходит эту хрупкость по определению. + +### F-4. Наблюдаемость +Лог (и опц. Telegram) каждый раз, когда sweeper **разблокировал** застрявшую задачу — +чтобы видеть частоту срабатывания дыры (метрика потерянных webhook). Опц. вывод +счётчика в `/queue` или `/reconcile`. Не спамить, когда всё синхронно. + +### Вне объёма +- Буфер недоставленных webhook (это ORCH-51; sweeper — резервная сетка к нему). +- Изменение состава стадий/гейтов (`STAGE_TRANSITIONS`, `QG_CHECKS`). +- Изменение логики самих гейтов и обработчиков (только переиспользование). +- Новый исполняемый деплой (ORCH-36). + +## 5. Ключевые требования (бизнес-уровень) + +1. **Источник истины — гейт/Plane, а не событие.** Sweeper дёргает ровно те же функции + продвижения, что и webhook. Параллельной логики продвижения быть не должно. +2. **Идемпотентность (критично).** Задержавшийся или дублированный webhook + sweeper + НЕ создают двойную задачу / двойной запуск / двойной advance. Тот же guard, что у + webhook: нет активного job + стадия совпадает + atomic claim как в `queue_worker`. +3. **Безопасность активной работы.** Sweeper НЕ трогает задачи с активными + (`queued`/`running`) job'ами — они легитимно в работе, не потеряны. +4. **Per-stage grace.** Разные стадии имеют разное нормальное время (analysis ~8–15 мин + vs deploy). Порог застревания настраивается, чтобы не дёргать гейт у задачи, где агент + законно работает. +5. **Restart-safe.** Sweeper — фоновый поток, стартует с приложением, переживает рестарт + (как `queue_worker`). Без потери состояния. +6. **Self-hosting safety.** Sweeper не должен ронять/рестартить прод-контейнер; kill-switch + в конфиге для поэтапного раската и аварийного отключения. +7. **Без шума.** Когда всё синхронно — никаких действий и нотификаций. +8. **Документация = golden source.** README/architecture, ADR, CHANGELOG обновляются в + том же PR. + +## 6. Эффект +- Потерянный webhook больше не = молча застрявшая задача. +- Ручной heartbeat-watchdog Стрима больше не нужен для ловли зависаний (AC-5 в эпике). +- Резервная сетка к ORCH-51 при ребилде прода. + +## 7. Связи +- **Дополняет ORCH-51** (потеря webhook при рестарте — буфер; sweeper — реконсиляция). +- **Дополняет ORCH-36** (если deploy-webhook потеряется — sweeper добьёт deploy→done). +- **ORCH-1b** — та же философия resilience: транзиентный сбой не убивает задачу. +- Эпик: звено **ORCH-54** (автономное внедрение). Параллельна ORCH-36 (разные файлы), + но `max_concurrency=1` → встанет в очередь. + +## 8. Риски (кратко; подробно — 10-tech-risks архитектора) +- **Гонка sweeper ↔ живой webhook** → двойной запуск. Митигируется atomic claim + + active-job guard + grace-период (не конкурировать с задержавшимся webhook). +- **Spam нотификаций** при персистентно красном гейте на каждом тике. Митигируется: + действие/нотификация только на изменении состояния (advance), не на каждый тик. +- **Нагрузка на Plane API** при опросе каждые N сек. Митигируется интервалом + фильтром + по статусам + per-project. +- **Self-hosting:** sweeper правит инструмент, обслуживающий и другие проекты. Kill-switch + обязателен. diff --git a/docs/work-items/ORCH-053/02-trz.md b/docs/work-items/ORCH-053/02-trz.md new file mode 100644 index 0000000..6913914 --- /dev/null +++ b/docs/work-items/ORCH-053/02-trz.md @@ -0,0 +1,170 @@ +# ТЗ — ORCH-053: Sweeper потерянных webhook (реконсиляция застрявших стадий) + +Work Item ID: ORCH-053 +Базовая ветка: `feature/ORCH-053-sweeper-webhook-stuck-task` + +> Это ТЗ фиксирует **конкретные изменения кода/конфига/доки**. Архитектурные развилки +> (потокобезопасность, точная схема дампинга нотификаций, способ вызова async-обработчиков +> из sync-потока) фиксирует архитектор в `06-adr/`. Если ТЗ окажется негодным — возврат в +> Анализ (не комментировать задним числом). + +## 0. Живая разведка ПЕРЕД реализацией (обязательна) +Перед кодом разработчик обязан вживую проверить (как сейчас webhook продвигает стадию): +- `src/webhooks/gitea.py::handle_ci_status` (success-ветка ~стр.199–217) и `handle_pr`; +- `src/webhooks/plane.py::handle_issue_updated / handle_status_start / handle_verdict / start_pipeline`; +- `src/stage_engine.py::advance_stage` (унифицированный путь, `finished_agent=None` = webhook-путь); +- `src/queue_worker.py` (образец фонового daemon-потока + `threading.Event` + atomic claim); +- `src/db.py::has_active_job_for_task / claim_next_job / update_task_stage` (`updated_at`). + +## 1. Задействованные модули `src/` + +| Модуль | Изменение | +|--------|-----------| +| `src/reconciler.py` | **НОВЫЙ.** Фоновый sweeper/reconciler (класс + module-singleton, паттерн `queue_worker`). Обе ветки F-1 (gate-side) и F-2 (plane-side). | +| `src/config.py` | Новые настройки `reconcile_*` (интервал, kill-switch, per-stage grace, plane-poll flag). | +| `src/main.py` | Старт/стоп reconciler в `lifespan` (после `worker.start()` / перед `worker.stop()`). | +| `src/stage_engine.py` | Тонкий хелпер `advance_if_gate_passed(...)` (или `reconcile_advance`) — обёртка над `advance_stage(..., finished_agent=None)`, **подавляющая повторный спам нотификаций** при провале гейта (продвижение — переиспользуется как есть). | +| `src/plane_sync.py` | НОВЫЙ хелпер `list_issues_by_state(project_id, state_uuids) -> list[dict]` (GET issues с пагинацией, фильтр по state). Используется F-2. | +| `src/webhooks/gitea.py` | F-3: усилить sha→branch резолв в `handle_ci_status` (fallback на БД-поиск task), логировать неразрезолв на уровне INFO (видимость). | +| `src/webhooks/plane.py` | F-2 переиспользует `handle_issue_updated` / `handle_status_start` / `handle_verdict` **без дублирования** логики (возможно, лёгкий рефактор для вызова из reconciler). | +| `src/main.py` (API) | F-4 (опц.): расширить `/queue` блоком reconcile-метрик или добавить `GET /reconcile`. | + +## 2. F-1 — Gate-side sweeper (реконсиляция по локальной БД) + +### Алгоритм одного прохода (`reconcile_gate_once()`) +``` +для каждой task где stage NOT IN ('done',) : + если has_active_job_for_task(task.id): continue # в работе — не трогаем + если get_qg_for_stage(task.stage) is None: continue # created/done — нет гейта + grace = grace_for_stage(task.stage) + если age(task.updated_at) < grace: continue # ещё не «застряла» + # источник истины — гейт; путь продвижения — штатный + advance_if_gate_passed(task.id, task.stage, task.repo, task.work_item_id, task.branch) +``` +- **Продвижение** идёт через `stage_engine.advance_stage(task_id, stage, repo, work_item_id, + branch, finished_agent=None)` — это **тот же** путь, которым пользуется Plane Approved-webhook + (`webhooks/plane._try_advance_stage`). Никакой параллельной логики advance. +- Для `development` → `advance_stage` прогонит `check_ci_green`; passed → `review` + enqueue + `reviewer`. Для `review` → `check_reviewer_verdict` (канонический гейт стадии из + `STAGE_TRANSITIONS`, читает `verdict:` из `12-review.md`). Для `testing` → `check_tests_passed`. + Для `deploy` → `check_deploy_status`. Для `deploy-staging` → `check_staging_status` + (+ merge-gate sub-gate отрабатывает внутри `advance_stage` как обычно). +- **Стадия `analysis`** (gQG `check_analysis_approved`): это **человеческий** гейт. В + `advance_stage` при `finished_agent=None` он трактуется как `approved-via-status` и + продвинет задачу — чего при потере именно **Approved**-webhka мы и хотим **только** если + Plane реально в статусе Approved. Поэтому **F-1 НЕ реконсилирует `analysis`** (advance + для analysis отдаётся F-2, которая сверяется с реальным статусом Plane). Архитектор + фиксирует это решение в ADR (защита от ложного продвижения неодобренного BRD). + +### Подавление спама нотификаций (`advance_if_gate_passed`) +- Если гейт **passed** → `advance_stage` продвигает и шлёт штатные нотификации advance. +- Если гейт **failed** → НЕ повторять `notify_qg_failure`/`plane_notify_qg` на каждом тике. + Хелпер вызывает `advance_stage` так, чтобы при провале была **тишина** (лог `INFO`/`DEBUG`), + либо реализует продвижение, минуя ветку нотификации провала. Точную форму (флаг в + `advance_stage` vs отдельный путь оценки гейта) выбирает архитектор; контракт: + **на застрявшей-но-красной задаче sweeper не спамит**. + +### Защита от гонки +- `has_active_job_for_task` + `update_task_stage` обновляет `updated_at` → следующий тик + увидит свежий `updated_at` и не сработает повторно. +- Если в момент тика прилетел живой webhook и поставил job — sweeper увидит активный job и + пропустит задачу. +- `max_concurrency=1`: новый enqueued job встанет в общую очередь (без двойного запуска). + +## 3. F-2 — Plane-side reconciler (опрос Plane API) + +### Алгоритм одного прохода (`reconcile_plane_once()`) +``` +для каждого проекта p в projects.PROJECTS: + states = get_project_states(p.plane_project_id) + for issue in list_issues_by_state(p.plane_project_id, + [states['in_progress'], states['approved'], states['rejected']]): + task = get_task_by_plane_id(issue.id) + new_state = issue.state + # идемпотентность: пропускаем, если есть активный job (живой webhook вот-вот придёт/в работе) + если task and has_active_job_for_task(task.id): continue + # доигрываем потерянный переход ЧЕРЕЗ существующие обработчики plane.py + if new_state == in_progress and task is None: -> handle_status_start(issue_data, p.plane_project_id) + elif new_state == approved and task and stage не сдвинут: -> handle_verdict(issue_data, ..., approved=True) + elif new_state == rejected and task and не откатана: -> handle_verdict(issue_data, ..., approved=False) + else: continue # всё синхронно — тишина +``` +- **Переиспользовать** `handle_issue_updated`/`handle_status_start`/`handle_verdict` из + `webhooks/plane.py`. Они `async` → reconciler (sync-поток) вызывает их через + `asyncio.run(...)` либо собственный event loop. Способ — на усмотрение архитектора; + **дублировать логику запрещено**. +- `issue_data` собирается в форму, ожидаемую обработчиками (`{"id", "state": {"id":...}, + "project", "name", "description_stripped"}`). Недостающие поля (name/description) + обработчики сами дотягивают через `fetch_issue_fields` (как сейчас для status-only вебхука). +- **Grace для F-2:** не реагировать на issue, чей статус сменился совсем недавно (вебхук мог + просто задержаться). Источник «давности» — поле времени из Plane (`updated_at`) и/или + локальный grace по `tasks.updated_at`. Архитектор фиксирует точный критерий «потерян, а не + задержан». +- **Идемпотентность создания (In Progress без задачи):** `start_pipeline` уже защищён + (`handle_status_start` создаёт только если `get_task_by_plane_id` пуст). Гонка sweeper↔webhook + на создании: оба пройдут проверку «нет задачи» одновременно → возможен дубль. Требование: + использовать тот же claim-механизм / уникальность (как `ensure_unique_work_item_id` + + проверка существования под защитой). Архитектор обязан описать atomic-claim на создании в ADR. + +### `list_issues_by_state` (новый в `plane_sync.py`) +- `GET {PLANE_BASE}/workspaces/{WORKSPACE}/projects/{pid}/issues/` с фильтром по state + (через query-параметр Plane, либо постфильтрация результата по `issue.state`). +- Пагинация (`results` + cursor/next) — обойти все страницы. +- Never-raise: при ошибке API/сети → `[]` + лог `warning` (Plane outage деградирует мягко, + не роняет тик). + +## 4. F-3 — Усиление sha→branch резолва (`webhooks/gitea.py::handle_ci_status`) +- Текущая цепочка: `branches[0].name` → `git branch -r --contains `. Добавить + fallback **на БД**: если branch не определён, найти task по `repo` среди активных + (`stage='development'`) и, при однозначности, использовать её branch; иначе — оставить + неразрезолвленным. +- Заменить `logger.debug("could not determine branch...")` на `logger.info(...)` (видимость + потери). Sweeper (F-1) всё равно подберёт такую задачу — это defense-in-depth, не критпуть. +- **Не менять** success/failure-семантику гейта. + +## 5. Конфигурация (`src/config.py`, env-prefix `ORCH_`) + +| Поле | Дефолт | Назначение | +|------|--------|-----------| +| `reconcile_enabled` | `True` | глобальный kill-switch sweeper'а (self-hosting safety, поэтапный раскат). | +| `reconcile_interval_s` | `120` | период фонового прохода (сек). | +| `reconcile_plane_enabled` | `True` | отдельный флаг для F-2 (опрос Plane API), чтобы можно было гасить только plane-ветку. | +| `reconcile_grace_default_s` | `600` | дефолтный порог «застревания» по `tasks.updated_at`. | +| `reconcile_grace_overrides_json` | `""` | JSON-объект per-stage порогов, напр. `{"analysis": 1800, "development": 300, "deploy": 900}`. Невалидный JSON → дефолт (как `agent_timeout_overrides_json`). | +| `reconcile_notify_unblock` | `True` | слать Telegram при разблокировке (F-4). | + +`grace_for_stage(stage)` = override из JSON, иначе `reconcile_grace_default_s`. + +## 6. БД +- **Изменения схемы НЕ требуются** (предпочтительно, по образцу merge-gate ORCH-043). + Стуковость определяется по существующим `tasks.updated_at`, `tasks.stage` и таблице `jobs` + (`has_active_job_for_task`). `update_task_stage` уже обновляет `updated_at`. +- Если архитектор сочтёт необходимым анти-дребезг (`tasks.last_reconcile_at`) — допускается + идемпотентная миграция через `_ensure_column` (как остальные ALTER в `db.py`). По умолчанию + — **без новых колонок**. + +## 7. API (опционально, F-4) +- Расширить `GET /queue` блоком `"reconcile": {...}` (enabled, interval, last_run_ts, + unblocked_total, last_unblocked) — по образцу `worker.status()`. +- ИЛИ добавить `GET /reconcile` с теми же метриками. Выбор — архитектор. Не обязательно для + прохождения AC, но крайне желательно для наблюдаемости. + +## 8. Новые QG checks +- **Нет.** Sweeper переиспускает существующие гейты из `QG_CHECKS` через `advance_stage`. + Реестр `QG_CHECKS` и `STAGE_TRANSITIONS` не меняются. + +## 9. Артефакты pipeline / документация (обязательно в ЭТОМ PR) +- `docs/architecture/README.md` — раздел про reconciler (компонент + место в resilience-слое). +- `docs/work-items/ORCH-053/06-adr/ADR-001-*.md` — архитектурное решение (потоки, гонки, + async-вызов обработчиков, подавление спама, grace-критерий, atomic-claim на создании). +- `CHANGELOG.md` — запись `feat: ORCH-053 stuck-task reconciler`. +- При желании архитектора — global ADR в `docs/architecture/adr/` (сквозной resilience). +- `docs/operations/INFRA.md` — упомянуть kill-switch `ORCH_RECONCILE_ENABLED` (self-hosting). + +## 10. Нефункциональные требования +- **Never-raise в тике:** исключение в обработке одной задачи/issue не должно ронять весь + проход (изолировать try/except на единицу работы, как `queue_worker._drain_once`). +- **Идемпотентность** — см. §2/§3. +- **Restart-safe** — daemon-поток + `threading.Event`, чистый `stop()` в `lifespan.finally`. +- **Тишина при синхронности** — нет действий → нет логов уровня INFO/нотификаций. +- **Тесты** — см. `04-test-plan.yaml` (моки Plane/Gitea API и QG, без реальной сети). diff --git a/docs/work-items/ORCH-053/03-acceptance-criteria.md b/docs/work-items/ORCH-053/03-acceptance-criteria.md new file mode 100644 index 0000000..5f7ea68 --- /dev/null +++ b/docs/work-items/ORCH-053/03-acceptance-criteria.md @@ -0,0 +1,116 @@ +# Acceptance Criteria — ORCH-053 + +Work Item ID: ORCH-053 +Формат: каждый критерий имеет явное условие PASS/FAIL. Критерий считается выполненным, +только если соответствующие тесты из `04-test-plan.yaml` зелёные. + +## AC-1 — Реконсиляция застрявшей стадии (gate-side, F-1) +- **Дано:** task на стадии `development`, без активных job'ов, `updated_at` старше grace, + гейт `check_ci_green` для её branch — зелёный (CI прошёл, но webhook потерян, как ORCH-044). +- **Когда:** срабатывает фоновый проход `reconcile_gate_once()`. +- **PASS:** задача продвинута `development → review`, заenqueuen `reviewer` (через + `advance_stage(..., finished_agent=None)`), `tasks.updated_at` обновлён. +- **FAIL:** задача осталась на `development`, либо продвижение пошло параллельной логикой + (не через `advance_stage`). + +## AC-2 — Источник истины — гейт, не событие +- **PASS:** продвижение в F-1 выполняется исключительно вызовом + `stage_engine.advance_stage(...)`; в `reconciler.py` НЕТ собственного + `update_task_stage`+`enqueue_job` для advance стадии (только переиспользование). +- **FAIL:** в reconciler продублирована логика advance/rollback. + +## AC-3 — Идемпотентность: sweeper не трогает задачи с активным job +- **Дано:** task с `queued` или `running` job (`has_active_job_for_task == True`). +- **PASS:** sweeper пропускает задачу — ни advance, ни enqueue, ни нотификации. +- **FAIL:** sweeper дёргает гейт / создаёт второй job для такой задачи. + +## AC-4 — Идемпотентность: задержавшийся/дублированный webhook + sweeper не двоят +- **Дано:** issue в Plane = In Progress, задержавшийся Plane-webhook ещё не обработан. +- **Когда:** F-2 реконсилирует И затем (или одновременно) приходит реальный webhook. +- **PASS:** создаётся **ровно одна** задача (один task row, один branch/worktree, один + стартовый analyst-job). Повторный путь видит существующую задачу/активный job и не двоит. +- **FAIL:** созданы две задачи / два стартовых job / два worktree на один `plane_id`. + +## AC-5 — Per-stage grace соблюдается +- **Дано:** task на стадии, чей `updated_at` свежее grace этой стадии (агент легитимно + работает, напр. analysis 8 мин при grace 1800с). +- **PASS:** sweeper НЕ трогает задачу (не дёргает гейт). +- **PASS (граница):** как только `age(updated_at) >= grace_for_stage(stage)` и нет активного + job — задача становится кандидатом. +- **FAIL:** sweeper дёргает гейт у задачи в пределах grace. + +## AC-6 — Plane In Progress без задачи → запуск (F-2) +- **Дано:** issue в Plane = In Progress (статус сменён руками, webhook потерян), в `tasks` + задачи нет, прошёл grace. +- **PASS:** sweeper вызывает `handle_status_start`/`start_pipeline` → задача создана, + заenqueuen analyst — как если бы пришёл webhook. +- **FAIL:** задача не создана; либо создана дублирующей логикой, минуя `handle_status_start`. + +## AC-7 — Plane Approved без advance → advance (F-2) +- **Дано:** issue = Approved, task существует и стадия НЕ сдвинута, нет активного job, прошёл grace. +- **PASS:** sweeper вызывает `handle_verdict(approved=True)` → штатный advance. +- **FAIL:** нет advance, либо advance вне `handle_verdict`/`advance_stage`. + +## AC-8 — Plane Rejected без rollback → rollback (F-2) +- **Дано:** issue = Rejected, task существует и не откатана, нет активного job, прошёл grace. +- **PASS:** sweeper вызывает `handle_verdict(approved=False)` → штатный rollback на предыдущую стадию. +- **FAIL:** нет rollback, либо rollback вне штатного пути. + +## AC-9 — Нет спама нотификаций на красном гейте +- **Дано:** застрявшая задача, у которой гейт стабильно **красный** (напр. CI failure), + нет активного job, прошёл grace. +- **Когда:** sweeper проходит несколько тиков подряд. +- **PASS:** `notify_qg_failure`/Telegram НЕ вызывается на каждом тике (≤1 раз / без + повторов); задача не продвигается. +- **FAIL:** на каждом тике летит нотификация о провале гейта. + +## AC-10 — Тишина при синхронности +- **Дано:** все задачи синхронны (нет застрявших; статусы Plane совпадают с локальными). +- **PASS:** проход не выполняет действий, не пишет INFO-логов о разблокировке, не шлёт нотификаций. +- **FAIL:** sweeper генерирует шум/действия при полностью синхронном состоянии. + +## AC-11 — Restart-safe фоновый поток +- **PASS:** reconciler стартует в `main.lifespan` (daemon-поток), корректно + останавливается (`stop()`), переживает рестарт сервиса без потери (нет состояния в памяти, + критичного для корректности; всё перечитывается из БД/Plane). +- **FAIL:** reconciler не стартует автоматически, висит при shutdown, или дублирует действия + после рестарта. + +## AC-12 — Наблюдаемость разблокировки (F-4) +- **Дано:** sweeper разблокировал застрявшую задачу. +- **PASS:** в лог пишется явная строка вида + `reconciler: разблокирована (потерян webhook)`; + при `reconcile_notify_unblock=True` — Telegram-уведомление. +- **FAIL:** разблокировка происходит молча (невозможно измерить частоту дыры). + +## AC-13 — Kill-switch +- **Дано:** `reconcile_enabled=False` (env `ORCH_RECONCILE_ENABLED=false`). +- **PASS:** фоновый поток reconciler не выполняет проходов (или не стартует); система + работает как до ORCH-053. `reconcile_plane_enabled=False` гасит только F-2, F-1 работает. +- **FAIL:** sweeper активен при выключенном флаге. + +## AC-14 — Усиленный sha→branch резолв (F-3) +- **Дано:** Gitea CI-status webhook без `branches` и со `sha`, не разрезолвившимся + через `git branch -r --contains`. +- **PASS:** добавленный БД-fallback однозначно находит task (по repo + активной + development-стадии) и продвигает; неоднозначность логируется на уровне INFO; существующая + success/failure-семантика гейта не изменена. +- **FAIL:** регресс существующего резолва, либо ложный матч при неоднозначности. + +## AC-15 — Never-raise в тике +- **Дано:** обработка одной задачи/issue кидает исключение (битые данные, ошибка API). +- **PASS:** исключение изолировано, проход продолжает остальные задачи; поток не падает. +- **FAIL:** одно исключение роняет весь проход / поток reconciler. + +## AC-16 — F-1 не продвигает analysis по локальному состоянию +- **Дано:** task на `analysis`, артефакты на диске присутствуют, но Plane НЕ в статусе + Approved (BRD не одобрен человеком), нет активного job, прошёл grace. +- **PASS:** F-1 (gate-side) НЕ продвигает analysis→architecture (advance стадии analysis + отдан F-2, которая сверяется с реальным статусом Plane Approved). +- **FAIL:** sweeper автопродвинул неодобренный BRD. + +## AC-17 — Документация обновлена (golden source) +- **PASS:** в PR обновлены `docs/architecture/README.md`, заведён + `docs/work-items/ORCH-053/06-adr/ADR-001-*.md`, обновлён `CHANGELOG.md`, упомянут + kill-switch в `docs/operations/INFRA.md`. +- **FAIL:** код изменён, документация — нет (Reviewer обязан вернуть REQUEST_CHANGES). diff --git a/docs/work-items/ORCH-053/04-test-plan.yaml b/docs/work-items/ORCH-053/04-test-plan.yaml new file mode 100644 index 0000000..227007d --- /dev/null +++ b/docs/work-items/ORCH-053/04-test-plan.yaml @@ -0,0 +1,200 @@ +work_item: ORCH-053 +description: > + Тесты sweeper/reconciler потерянных webhook. Вся сеть (Plane API, Gitea API, QG) + мокируется (monkeypatch), как в существующих tests/. Telegram заглушён autouse-фикстурой + conftest. Используется временная SQLite БД (ORCH_DB_PATH / фикстура setup_db по образцу + test_webhooks.py / test_queue.py). Реальные агенты/CLI не запускаются. + +tests: + # ---- F-1: gate-side sweeper ------------------------------------------------- + - id: TC-01 + type: unit + description: > + reconcile_gate_once продвигает застрявшую development-задачу: нет активных job, + updated_at старше grace, check_ci_green замокан в (True, "CI green") → + advance_stage вызван, стадия стала review, заenqueuen reviewer. + module: tests/test_reconciler.py + expected: PASS + + - id: TC-02 + type: unit + description: > + Источник истины — гейт: reconciler НЕ содержит собственного update_task_stage/ + enqueue_job для advance — продвижение идёт строго через stage_engine.advance_stage + (проверка через мок/spy advance_stage, вызван с finished_agent=None). + module: tests/test_reconciler.py + expected: PASS + + - id: TC-03 + type: unit + description: > + Задача с активным job (has_active_job_for_task=True) пропускается: гейт не дёргается, + advance_stage не вызывается, нотификаций нет. + module: tests/test_reconciler.py + expected: PASS + + - id: TC-04 + type: unit + description: > + Per-stage grace: задача с updated_at свежее grace своей стадии не трогается; + ровно на границе age>=grace и без активного job — становится кандидатом. + module: tests/test_reconciler.py + expected: PASS + + - id: TC-05 + type: unit + description: > + grace_for_stage читает reconcile_grace_overrides_json (per-stage), при отсутствии + ключа — reconcile_grace_default_s; невалидный JSON → дефолт, не падает. + module: tests/test_reconciler.py + expected: PASS + + - id: TC-06 + type: unit + description: > + Нет спама: при стабильно красном гейте (check_ci_green=(False,...)) несколько проходов + подряд НЕ вызывают notify_qg_failure повторно на каждом тике; задача не продвигается. + module: tests/test_reconciler.py + expected: PASS + + - id: TC-07 + type: unit + description: > + Тишина при синхронности: когда все задачи done / имеют активный job / в пределах grace — + проход не вызывает advance_stage и не пишет INFO-логов о разблокировке. + module: tests/test_reconciler.py + expected: PASS + + - id: TC-08 + type: unit + description: > + AC-16: задача на analysis с артефактами на диске, но Plane НЕ Approved — F-1 + (reconcile_gate_once) НЕ продвигает analysis→architecture. + module: tests/test_reconciler.py + expected: PASS + + - id: TC-09 + type: unit + description: > + Never-raise: если обработка одной задачи кидает исключение (advance_stage замокан на + raise), проход ловит его и продолжает обрабатывать остальные задачи; поток не падает. + module: tests/test_reconciler.py + expected: PASS + + - id: TC-10 + type: unit + description: > + Kill-switch: при reconcile_enabled=False reconcile_gate_once/plane_once не выполняют + действий (no-op); при reconcile_plane_enabled=False гасится только F-2. + module: tests/test_reconciler.py + expected: PASS + + # ---- F-2: plane-side reconciler -------------------------------------------- + - id: TC-11 + type: unit + description: > + In Progress без задачи: list_issues_by_state возвращает issue в In Progress, в БД задачи + нет → reconcile_plane_once вызывает handle_status_start (мок) ровно один раз с корректным + issue_data (id/state/project). + module: tests/test_reconciler_plane.py + expected: PASS + + - id: TC-12 + type: unit + description: > + Approved без advance: issue=Approved, task существует, нет активного job → вызван + handle_verdict(approved=True) (мок) один раз. + module: tests/test_reconciler_plane.py + expected: PASS + + - id: TC-13 + type: unit + description: > + Rejected без rollback: issue=Rejected, task существует, нет активного job → вызван + handle_verdict(approved=False) (мок) один раз. + module: tests/test_reconciler_plane.py + expected: PASS + + - id: TC-14 + type: unit + description: > + Идемпотентность F-2: issue в требующем-действия статусе, но у task есть активный job → + handle_status_start/handle_verdict НЕ вызываются (живой webhook в работе). + module: tests/test_reconciler_plane.py + expected: PASS + + - id: TC-15 + type: integration + description: > + AC-4 анти-дубль на создании: одновременная реконсиляция + обработка реального In Progress + webhook для одного plane_id создают ровно ОДИН task row и один стартовый analyst-job + (реальная временная БД, мок Gitea/Plane сетевых вызовов). + module: tests/test_reconciler_plane.py + expected: PASS + + - id: TC-16 + type: unit + description: > + list_issues_by_state never-raise: при ошибке Plane API (httpx бросает / non-2xx) → + возвращает [], тик не падает; при успехе — обходит пагинацию и фильтрует по state. + module: tests/test_reconciler_plane.py + expected: PASS + + - id: TC-17 + type: unit + description: > + F-2 опрашивает все проекты реестра projects.PROJECTS и резолвит state-uuid через + get_project_states per-project (enduro + orchestrator), не хардкодит uuid. + module: tests/test_reconciler_plane.py + expected: PASS + + # ---- F-3: sha→branch резолв ------------------------------------------------- + - id: TC-18 + type: unit + description: > + handle_ci_status: при отсутствии branches и неразрезолвленном sha срабатывает БД-fallback + и однозначно находит единственную development-задачу repo; продвижение идёт штатно. + module: tests/test_gitea_sha_resolve.py + expected: PASS + + - id: TC-19 + type: unit + description: > + handle_ci_status: при неоднозначности (несколько development-задач repo) БД-fallback не + делает ложный матч (branch остаётся неразрезолвленным, лог INFO), success/failure-семантика + гейта не изменена. + module: tests/test_gitea_sha_resolve.py + expected: PASS + + # ---- F-4 / интеграция фонового потока -------------------------------------- + - id: TC-20 + type: unit + description: > + Наблюдаемость: при разблокировке reconciler пишет явную лог-строку с work_item_id и + stage; при reconcile_notify_unblock=True вызывается send_telegram (замокан). + module: tests/test_reconciler.py + expected: PASS + + - id: TC-21 + type: integration + description: > + Restart-safe поток: Reconciler.start() поднимает daemon-поток, stop() завершает его + в пределах таймаута; повторный start идемпотентен (не плодит второй поток). + module: tests/test_reconciler.py + expected: PASS + + - id: TC-22 + type: unit + description: > + Конфиг: новые поля reconcile_* присутствуют в Settings с заявленными дефолтами и + читаются из env с префиксом ORCH_ (по образцу tests/test_config.py). + module: tests/test_config.py + expected: PASS + + - id: TC-23 + type: unit + description: > + Регресс реестров: STAGE_TRANSITIONS и QG_CHECKS не изменены ORCH-053 + (snapshot-тест проходит как раньше). + module: tests/test_qg_registry_snapshot.py + expected: PASS