diff --git a/docs/work-items/ORCH-068/01-brd.md b/docs/work-items/ORCH-068/01-brd.md new file mode 100644 index 0000000..f56dfb4 --- /dev/null +++ b/docs/work-items/ORCH-068/01-brd.md @@ -0,0 +1,52 @@ +# BRD — ORCH-068: BUG reconciler livelock (спам unblock done-задачи) + +## 1. Контекст и предыстория +Reconciler (`src/reconciler.py`, ORCH-053) — фоновый поток, который доигрывает пропущенные webhook-переходы. Ветвь **F-2 (plane-side)** опрашивает Plane per-project и реплеит In Progress / Approved / Rejected через штатные `handle_status_start` / `handle_verdict`. При фактической разблокировке вызывается `_note_unblock` → лог + Telegram. + +ORCH-066 (мердж `feature/ORCH-066-plane`, прод 2026-06-07 ~22:16 UTC) ввела новую статусную модель Plane (маппинг стадия↔статус, новые имена `Done`, `Monitoring after Deploy` и т.п.). Это спровоцировало регрессию в F-2. + +## 2. Проблема (бизнес-симптом) +С 22:17 UTC (рестарт прод-контейнера после деплоя ORCH-066) reconciler каждые ~120с шлёт в Telegram: +``` +reconciler: ET-002 done разблокирована (потерян webhook) +``` +для задачи **ET-002** (enduro-trails), которая в Done с 2026-05-21. На момент анализа — 191+ сообщений подряд, поток не прекращается (ночной спам, найден Славой 2026-06-08 01:22 UTC). + +**Ключевой факт:** ET-002 полностью синхронизирована — БД `stage=done`, Plane `state=Done`. Reconciler обязан молчать (инвариант «silence when in sync», AC-9/AC-10 из ORCH-053), но шлёт уведомления вхолостую. + +## 3. Диагностика (проведена, root cause найден) +1. **Деньги/токены НЕ тратятся:** `jobs` / `agent_runs` за 4ч пусты; `advance_stage` для done = no-op; `handle_verdict` для done-задачи ничего не меняет. Это «дешёвый», но шумный и подрывающий доверие баг (livelock + ложный alert-fatigue). +2. **Механизм:** + - `_reconcile_plane_project` (`src/reconciler.py` ~241) тянет `list_issues_by_state(pid, [in_progress, approved, rejected])`. + - На enduro-trails статусы «схлопнуты»: после ORCH-066 терминальный `Done` алиасится под UUID `approved` (см. ниже п.4) → ET-002 (Plane=Done) **попадает** в actionable-выборку. + - В `_reconcile_plane_issue` (~295) срабатывает ветка `new_state == approved and task is not None` → `handle_verdict(approved)` (no-op, задача уже done) **+ безусловный `_note_unblock`**. + - `_note_unblock` (~317) вызывается **сразу после `_dispatch`, не проверяя фактическое изменение состояния** — хотя его docstring обещает «fires only on an actual state change, never per idle tick». Инвариант нарушен. +3. **Два независимых дефекта складываются:** + - **D1 (выборка):** терминальные статусы (`Done`/`Cancelled`) не исключены из actionable-набора F-2; на «схлопывающих» проектах Done не отличается от approved по голому UUID. + - **D2 (нотификация):** `_note_unblock` срабатывает безусловно после no-op dispatch, а не только при подтверждённом state change. +4. **Почему `get_project_states` схлопывает:** функция строит маппинг по *именам* статусов из Plane API, затем недостающие ключи добивает из `_DEFAULT_STATES` (enduro-значения). После ORCH-066 набор статусов enduro изменился — голый UUID перестал однозначно различать `Done` (completed-группа) и `approved` (review). Группа состояния (`state.group`) при этом различает их корректно, но в коде не используется. + +## 4. Связанный баг (BUG КЭША СТАТУСОВ, найден 2026-06-07 при деплое ORCH-066) +`_STATES_CACHE` (`src/plane_sync.py` ~134) кэширует статусы Plane на **весь lifetime процесса**. После создания нового Plane-статуса (напр. `Confirm Deploy`) боевой процесс держит устаревший набор → webhook на новый статус даёт «no pipeline action» (Phase B не триггерится). Лечилось только рестартом орка. Примитив сброса уже есть — `reload_project_states()` — но он нигде не вызывается автоматически. + +Оба бага — следствие хрупкости статусной модели после ORCH-066. **Решение:** вести их в одном work item (см. scope ниже), окончательное разделение — на усмотрение архитектора. + +## 5. Цели (Goals) +- G1. Reconciler НЕ шлёт «разблокирована» для синхронизированной done/cancelled задачи (восстановить инвариант silence-when-in-sync). +- G2. `_note_unblock` срабатывает **только** при реальном state change (соблюдён AC-9/AC-10). +- G3. Дедуп: нет повторного спама по той же задаче без изменения её состояния. +- G4. Корректное различение терминальных (`Done`/`Cancelled`) и review-статусов (`approved`/`rejected`) даже на проектах, «схлопывающих» их по UUID — на всех проектах (enduro И orchestrator). +- G5 (secondary). Устаревший `_STATES_CACHE` обновляется без рестарта процесса (TTL / flush-on-unknown / endpoint). + +## 6. Не-цели (Out of scope) +- N1. Менять source-of-truth: ориентир F-2 на Plane **остаётся** корректным по дизайну (таблица `tasks` без status-колонки; статусы двигает человек в Plane). Идею F-2 НЕ переписываем — баг в маппинге/нотификации, не в концепции. +- N2. Менять реестры `STAGE_TRANSITIONS` / `QG_CHECKS`, схему БД, контракты гейтов. +- N3. Менять поведение F-1 (gate-side) и F-3. +- N4. Полный авто-approve деплоя (ORCH-54). + +## 7. Затронутые стороны +- **Все проекты на одном инстансе** (enduro-trails + orchestrator, общая БД/очередь) — баг проявился на ET-002, но фикс выборки терминалов обязан быть проектно-независимым. +- **Self-hosting:** правка идёт в работающий прод-инструмент → обязательна страховка staging (8501), запрет на рестарт прод-контейнера в рамках задачи. + +## 8. Критерий успеха (бизнес) +Тик reconciler для синхронизированной done/cancelled задачи = **0 уведомлений, 0 jobs, 0 токенов**. Telegram-спам прекращён. Легитимная разблокировка (реально потерянный approved/in_progress webhook) по-прежнему работает (нет регресса F-2). diff --git a/docs/work-items/ORCH-068/02-trz.md b/docs/work-items/ORCH-068/02-trz.md new file mode 100644 index 0000000..63d12e8 --- /dev/null +++ b/docs/work-items/ORCH-068/02-trz.md @@ -0,0 +1,68 @@ +# ТЗ — ORCH-068: устранить livelock reconciler F-2 + спам unblock done-задачи + +> Документ описывает ТРЕБОВАНИЯ к изменению поведения (что и где), а не выбор реализации. Конкретный механизм (state.group vs явный allowlist терминалов; TTL vs flush-on-unknown) — решение архитектора в ADR. + +## 1. Затронутые модули `src/` +| Модуль | Роль в баге | Требуемое изменение | +|--------|-------------|---------------------| +| `src/reconciler.py` | F-2 `_reconcile_plane_project` / `_reconcile_plane_issue` / `_note_unblock` | Исключить терминальные статусы из actionable-выборки; `_note_unblock` только при подтверждённом state change; дедуп. | +| `src/plane_sync.py` | `get_project_states`, `list_issues_by_state`, `_STATES_CACHE` | Дать способ различать терминальные/review статусы (группа состояния); устранить вечно-устаревший кэш (TTL/flush). | +| `src/config.py` | флаги | (если нужны) новые kill-switch/настройки TTL — с дефолтами, не меняющими прод-инварианты. | +| `src/main.py` (`/queue`) | наблюдаемость | (опц.) отразить дедуп/skip-терминалов в снимке `reconcile`. | + +**НЕ трогать:** `src/stages.py` (`STAGE_TRANSITIONS`), `src/qg/checks.py` (`QG_CHECKS`), схему БД, контракты `handle_status_start` / `handle_verdict`, F-1 (`reconcile_gate_once`), F-3. + +## 2. Требования к F-2 (src/reconciler.py) + +### TR-1. Исключить терминальные статусы из actionable-выборки +`_reconcile_plane_project` НЕ должен подавать задачи в терминальном Plane-статусе (`Done` и прочие completed-группы, `Cancelled`) ни в `list_issues_by_state`, ни в последующее сравнение веток. +- Требование проектно-независимое: работает на enduro И orchestrator, независимо от того, «схлопывает» ли проект статусы по UUID. +- Различение `Done`/`Cancelled` (completed) от `approved`/`rejected` (review) НЕ должно опираться только на голый UUID, если проект их алиасит. Допустимый ориентир — группа состояния Plane (`state.group`: `completed`/`started`/`unstarted`/`backlog`/`cancelled`) либо явный набор логических ключей терминалов. Выбор — за архитектором. + +### TR-2. `_note_unblock` — только при реальном state change +`_note_unblock` (лог + Telegram + инкремент `unblocked_total`) ВЫЗЫВАЕТСЯ ТОЛЬКО когда диспетчеризованный обработчик фактически изменил состояние задачи (advance / replayed transition, реально сдвинувший стадию). No-op dispatch (задача уже в целевом состоянии) → нотификация НЕ отправляется. +- Сейчас `_dispatch` (`asyncio.run(coro_fn(...))`) отбрасывает результат, а `_note_unblock` зовётся безусловно. Требуется механизм подтверждения изменения (напр. сравнение стадии задачи до/после dispatch через `get_task_by_plane_id`, либо проброс сигнала из обработчика). Конкретику выбирает архитектор; контракт обработчиков `handle_*` менять НЕ обязательно (предпочтительно сравнение состояния до/после на стороне reconciler). +- Восстановить соответствие docstring `_note_unblock`: «Fires only on an actual state change … never per idle tick». + +### TR-3. Дедуп / идемпотентность нотификаций +Reconciler НЕ должен слать повторное уведомление о той же задаче, если её состояние не менялось с прошлого тика. TR-1+TR-2 закрывают основной кейс (done более не входит в выборку и не нотифицируется); TR-3 — дополнительная страховка (best-effort), чтобы любой будущий no-op путь не дал повторного спама. + +## 3. Требования к статус-кэшу (src/plane_sync.py) — secondary + +### TR-4. Устаревший `_STATES_CACHE` обновляется без рестарта +После появления нового Plane-статуса процесс не должен бесконечно держать устаревший набор. Допустимые подходы (выбор архитектора, можно комбинировать): +- TTL на запись кэша (напр. `ORCH_PLANE_STATES_TTL_S`, дефолт разумный, 0/неуст. = прежнее поведение для совместимости); +- flush-on-unknown: при детекте неизвестного статуса в вебхуке/реконсилере — вызвать существующий `reload_project_states(pid)` и перезапросить; +- админ-эндпоинт/сигнал для ручного flush без рестарта. +`reload_project_states()` уже существует — переиспользовать как примитив сброса, новую логику сброса не дублировать. + +## 4. Изменения API +- Новых обязательных endpoint'ов нет. +- Опционально (TR-4, на усмотрение архитектора): admin-эндпоинт сброса кэша статусов (напр. `POST /admin/plane-states/reload`) — если выбран этот вариант flush. Должен быть защищён/идемпотентен; документировать в README таблице API. +- Снимок `GET /queue` (блок `reconcile`) — без ломающих изменений; допустимо добавить поля наблюдаемости (skip-терминалов / dedup-счётчик). + +## 5. Изменения схемы БД +**Нет.** Дедуп TR-3 реализуется in-memory (best-effort, как существующие счётчики `unblocked_total`/`last_unblocked`, AC-11 ORCH-053 допускает их сброс при рестарте) либо через сравнение живого состояния Plane/БД. Миграции не требуются. + +## 6. Требования к новым QG checks +Нет. Реестр `QG_CHECKS` не меняется. + +## 7. Инварианты (обязаны сохраниться) +- INV-1. Source of truth F-2 — Plane (НЕ меняем). +- INV-2. never-raise на единицу работы (per-issue / per-project / per-tick) сохранён. +- INV-3. Kill-switch `ORCH_RECONCILE_ENABLED` (+ `ORCH_RECONCILE_PLANE_ENABLED` гасит только F-2) — работают. +- INV-4. F-1 / F-3 поведение не изменено. +- INV-5. 0 jobs / 0 токенов для синхронизированных задач (как сейчас) сохранено. +- INV-6. Легитимная разблокировка реально-потерянного approved/in_progress webhook продолжает работать (нет регресса F-2). +- INV-7. Self-hosting: тик reconciler НИКОГДА не рестартит/не роняет прод-контейнер. + +## 8. Артефакты pipeline, которые надо обновить в ТОМ ЖЕ PR +- `CLAUDE.md` — если меняется наблюдаемое поведение reconciler/кэша (раздел про reconciler/правила). +- `docs/architecture/README.md` — секция «Reconciler … (ORCH-053)»: уточнить исключение терминалов + дедуп; при TR-4 — секция «Plane Sync». +- `docs/architecture/adr/adr-0007-reconciler.md` (или новый per-WI ADR `docs/work-items/ORCH-068/06-adr/ADR-001-…`) — зафиксировать решение по терминалам/группе состояния и по кэшу. +- `CHANGELOG.md` — запись о фиксе (`fix:`). +- `.env.example` / `.env.staging` — если введён новый флаг (TTL/kill-switch). + +## 9. Замечания по приёмке/тестированию +- Регресс-тест ОБЯЗАН покрывать: задача в `Done` (синхронизирована) → тик F-2 = 0 нотификаций, на enduro И orchestrator проектах (terminal не зависит от алиасинга). +- Тест НЕ должен делать реальных сетевых вызовов — мокать `list_issues_by_state` / `get_project_states` / `send_telegram` / `_dispatch` (handle_*). diff --git a/docs/work-items/ORCH-068/03-acceptance-criteria.md b/docs/work-items/ORCH-068/03-acceptance-criteria.md new file mode 100644 index 0000000..c3259b0 --- /dev/null +++ b/docs/work-items/ORCH-068/03-acceptance-criteria.md @@ -0,0 +1,84 @@ +# Критерии приёмки — ORCH-068 + +Формат: каждый AC имеет чёткое условие PASS/FAIL. Задача принимается только при ВСЕХ PASS. + +## Основное (P0) — livelock / спам + +### AC-1. Синхронизированная done-задача → тишина +**Дано:** задача в Plane `state=Done`, БД `stage=done`, активных job нет. +**Когда:** выполняется один тик F-2 (`reconcile_plane_once` / `_reconcile_plane_project`). +- PASS: `_note_unblock` НЕ вызван; `send_telegram` НЕ вызван; `unblocked_total` не изменился; создано 0 jobs. +- FAIL: любое уведомление/лог «разблокирована»/инкремент счётчика для этой задачи. + +### AC-2. Терминалы исключены из actionable-выборки +**Дано:** проект, где `Done` (и/или `Cancelled`) по UUID совпадает/«схлопнут» с `approved`/`rejected`. +**Когда:** `_reconcile_plane_project` формирует набор и обходит issues. +- PASS: issue в терминальном статусе (completed-группа / `Cancelled`) НЕ попадает ни в одну из веток `in_progress/approved/rejected`; для неё F-2 — no-op silence. +- FAIL: терминальная issue заходит в ветку approved/rejected/in_progress. + +### AC-3. `_note_unblock` только при реальном state change +**Дано:** dispatch обработчика (`handle_verdict`/`handle_status_start`) фактически НЕ изменил стадию задачи (no-op, задача уже в целевом состоянии). +- PASS: `_note_unblock` НЕ вызван. +- FAIL: `_note_unblock` вызван после no-op dispatch. + +### AC-4. Дедуп по неизменному состоянию +**Дано:** две последовательные итерации тика по одной и той же синхронизированной задаче, состояние между тиками не менялось. +- PASS: суммарно 0 повторных уведомлений по этой задаче. +- FAIL: повторное уведомление на втором тике без изменения состояния. + +### AC-5. Нет регресса легитимной разблокировки F-2 +**Дано:** задача, у которой Plane=`Approved`, а локальная стадия НЕ продвинулась (реально потерянный verdict-webhook), grace выдержан, активных job нет. +**Когда:** тик F-2. +- PASS: `handle_verdict(approved)` доигран; задача продвинута; `_note_unblock` вызван РОВНО один раз (реальный state change). +- FAIL: задача не продвинута ИЛИ нотификация не отправлена ИЛИ отправлена многократно. + +### AC-6. Аналогично для in_progress-старта и rejected-отката +- PASS: потерянный `In Progress` (task is None) → старт пайплайна + 1 unblock; потерянный `Rejected` → откат + 1 unblock — оба только при реальном изменении. +- FAIL: ложный/повторный unblock или отсутствие легитимного. + +## Инварианты (P0) + +### AC-7. Деньги/ресурсы не тратятся на синхронизированные задачи +- PASS: 0 jobs, 0 agent_runs, 0 токенов для done/cancelled задач (как до бага). +- FAIL: любой созданный job/agent_run. + +### AC-8. never-raise сохранён +**Дано:** `list_issues_by_state` / `get_project_states` / `_dispatch` / `send_telegram` бросают исключение. +- PASS: тик не падает; ошибка изолирована (per-issue / per-project), логируется; остальные задачи обрабатываются. +- FAIL: непойманное исключение роняет тик/поток. + +### AC-9. Kill-switch'и работают +- PASS: `ORCH_RECONCILE_ENABLED=false` → F-1 и F-2 не выполняются; `ORCH_RECONCILE_PLANE_ENABLED=false` → F-2 не выполняется, F-1 работает. +- FAIL: любой свитч не гасит соответствующую ветку. + +### AC-10. F-1 / F-3 / реестры / схема БД не изменены +- PASS: `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, контракты `handle_*`, поведение F-1/F-3 — без изменений (diff не затрагивает). +- FAIL: любое изменение перечисленного. + +### AC-11. Self-hosting безопасность +- PASS: ни один путь reconciler не рестартит/не роняет прод-контейнер `orchestrator`. +- FAIL: обратное. + +## Secondary (P1) — кэш статусов + +### AC-12. Устаревший `_STATES_CACHE` обновляется без рестарта +**Дано:** после старта процесса в Plane появился новый статус (его UUID отсутствует в кэше). +**Когда:** срабатывает выбранный механизм (TTL истёк / flush-on-unknown / ручной flush). +- PASS: следующий `get_project_states` возвращает свежий набор, включающий новый статус; webhook на новый статус даёт корректное pipeline-действие БЕЗ рестарта. +- FAIL: процесс продолжает отдавать устаревший набор → «no pipeline action». + +### AC-13. Совместимость кэша по умолчанию +- PASS: при дефолтных настройках (TTL не задан / flush не сработал) поведение `get_project_states` не регрессирует; enduro по-прежнему получает свои UUID, fallback на `_DEFAULT_STATES` при недоступности API сохранён. +- FAIL: регресс резолва статусов или потеря fallback. + +## Документация (P0 по правилам проекта) + +### AC-14. Документация обновлена в том же PR +- PASS: обновлены применимые из {`docs/architecture/README.md` (Reconciler/Plane Sync), ADR, `CHANGELOG.md`, `CLAUDE.md`, `.env.example`}; reviewer подтверждает. +- FAIL: поведение изменено, доки/ADR/CHANGELOG не обновлены. + +## Тесты (P0) + +### AC-15. `pytest tests/ -q` зелёный +- PASS: весь набор тестов проходит; добавлены регресс-тесты из `04-test-plan.yaml`, включая done→silence на enduro и orchestrator. +- FAIL: любой красный тест или отсутствие регресс-теста на основной баг. diff --git a/docs/work-items/ORCH-068/04-test-plan.yaml b/docs/work-items/ORCH-068/04-test-plan.yaml new file mode 100644 index 0000000..a76efc1 --- /dev/null +++ b/docs/work-items/ORCH-068/04-test-plan.yaml @@ -0,0 +1,122 @@ +work_item: ORCH-068 +description: > + Регрессионные и модульные тесты на устранение livelock reconciler F-2 + (спам _note_unblock для синхронизированной done-задачи) и связанного бага + кэша статусов. Все тесты офлайн: Plane API / Telegram / dispatch мокаются. + Целевые модули: src/reconciler.py, src/plane_sync.py. + +tests: + # ---------- P0: основной баг (livelock / спам) ---------- + - id: TC-01 + type: unit + description: > + Синхронизированная done-задача (Plane=Done, БД=done, нет активных job): + один тик F-2 -> _note_unblock НЕ вызван, send_telegram НЕ вызван, + unblocked_total не изменился, 0 jobs. (AC-1, AC-7) + module: tests/test_reconciler_plane.py + expected: PASS + + - id: TC-02 + type: unit + description: > + Терминал «схлопнут» с approved по UUID: issue в Done с тем же UUID, что и + approved-набор, НЕ заходит ни в одну ветку in_progress/approved/rejected + (silence). Проверка проектно-независимого исключения терминалов. (AC-2) + module: tests/test_reconciler_plane.py + expected: PASS + + - id: TC-03 + type: unit + description: > + Cancelled терминал также исключён из actionable-выборки -> тик = silence, + 0 нотификаций. (AC-2) + module: tests/test_reconciler_plane.py + expected: PASS + + - id: TC-04 + type: unit + description: > + _note_unblock не вызывается после no-op dispatch: handle_verdict не сдвинул + стадию (задача уже в целевом состоянии) -> 0 нотификаций. (AC-3) + module: tests/test_reconciler_plane.py + expected: PASS + + - id: TC-05 + type: unit + description: > + Дедуп: два последовательных тика по одной синхронизированной задаче без + изменения состояния -> суммарно 0 повторных уведомлений. (AC-4) + module: tests/test_reconciler_plane.py + expected: PASS + + - id: TC-06 + type: unit + description: > + Нет регресса: Plane=Approved, локальная стадия не продвинута, grace выдержан, + нет активных job -> handle_verdict доигран, задача продвинута, _note_unblock + вызван РОВНО один раз. (AC-5) + module: tests/test_reconciler_plane.py + expected: PASS + + - id: TC-07 + type: unit + description: > + Нет регресса для in_progress (task is None -> старт пайплайна, 1 unblock) и + rejected (task существует -> откат, 1 unblock), оба только при реальном + изменении состояния. (AC-6) + module: tests/test_reconciler_plane.py + expected: PASS + + - id: TC-08 + type: unit + description: > + never-raise: list_issues_by_state / get_project_states / _dispatch / + send_telegram бросают исключение -> тик не падает, ошибка изолирована и + залогирована, прочие issues обработаны. (AC-8) + module: tests/test_reconciler_plane.py + expected: PASS + + - id: TC-09 + type: unit + description: > + Kill-switch: reconcile_enabled=False -> F-2 не выполняется; + reconcile_plane_enabled=False -> F-2 не выполняется, F-1 не затронут. (AC-9) + module: tests/test_reconciler_plane.py + expected: PASS + + - id: TC-10 + type: integration + description: > + End-to-end F-2 на двух проектах (enduro И orchestrator): задача в Done на + каждом -> тик reconcile_plane_once = 0 нотификаций / 0 jobs на обоих, + независимо от алиасинга статусов проекта. Главный регресс-тест бага. (AC-1, AC-2) + module: tests/test_reconciler_plane.py + expected: PASS + + # ---------- P1: связанный баг кэша статусов ---------- + - id: TC-11 + type: unit + description: > + Устаревший _STATES_CACHE обновляется без рестарта: после появления нового + статуса срабатывает выбранный механизм (TTL/flush) -> следующий + get_project_states содержит новый статус. (AC-12) + module: tests/test_plane_states_cache.py + expected: PASS + + - id: TC-12 + type: unit + description: > + Совместимость по умолчанию: при дефолтных настройках get_project_states не + регрессирует — enduro отдаёт свои UUID, fallback на _DEFAULT_STATES при + недоступности API сохранён. (AC-13) + module: tests/test_plane_states_cache.py + expected: PASS + + # ---------- P0: общий прогон ---------- + - id: TC-13 + type: integration + description: > + Полный набор pytest tests/ -q зелёный (нет регресса в reconciler/plane/qg/ + stage_engine). (AC-15) + module: tests/ + expected: PASS