diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 32e8d60..924de80 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -351,6 +351,18 @@ helper `validated_revision` питает и штамп A, и `EXPECTED_REVISION` ET-013) и (2) в явном Plane-статусе **Blocked** / **Needs Input** (Вариант A — запрос Plane API, без миграции БД; never-raise → консервативный skip). Гард retry-count проверяется первым (дёшево, локальный SQL). + **ORCH-086 (закрытие F-1-пробела ORCH-068):** терминал-исключение и `state_uuid`-dedup + (изначально только F-2) распространены на F-1. После дешёвых локальных гардов F-1 делает + **один** резолв Plane-статуса задачи на тик (общий fetch для Guard 2 + терминал-скипа + + `_note_unblock`); терминальная задача (группа Plane `completed`/`cancelled`, fallback — + логические ключи `done`/`cancelled`, ЛИБО стадия в БД орка ∈ `{done, cancelled}`) → + **безусловный** ранний скип (`skipped_terminal_total++`, без `advance`/уведомления; не подчинён + `reconcile_skip_blocked_enabled`). Вызов `_note_unblock` на F-1 теперь передаёт `state_uuid` → + in-memory dedup работает на обоих путях (страховка от повтора после рестарта). Лечит + периодическое ложное «ET-002 done разблокирована (потерян webhook)» для терминальных в Plane + задач (enduro/orchestrator), сохраняя легитимный unblock реально застрявшей не-терминальной + задачи. `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД/сигнатуры/новые флаги — без изменений. Детали — + `docs/work-items/ORCH-086/06-adr/ADR-001-reconciler-f1-terminal-skip-and-dedup.md`. - **F-2 plane-side:** опрос Plane API per-project → `handle_status_start` / `handle_verdict` из `webhooks/plane.py` (логика не дублируется). **ORCH-068 (livelock-fix):** (1) задачи в **терминальной группе** Plane diff --git a/docs/work-items/ORCH-086/06-adr/ADR-001-reconciler-f1-terminal-skip-and-dedup.md b/docs/work-items/ORCH-086/06-adr/ADR-001-reconciler-f1-terminal-skip-and-dedup.md new file mode 100644 index 0000000..fc72df5 --- /dev/null +++ b/docs/work-items/ORCH-086/06-adr/ADR-001-reconciler-f1-terminal-skip-and-dedup.md @@ -0,0 +1,197 @@ +# ADR-001: Терминал-скип и `state_uuid`-dedup на пути F-1 реконсилятора (одиночный fetch) + +## Статус +Accepted + +Связано: продолжение **ORCH-068** (терминал-исключение + dedup для F-2), наследует контракты +**ORCH-053** (`adr-0007-reconciler.md`), **ORCH-060** (Guard 1/Guard 2), **ORCH-066** (статусная +модель Plane). Не вводит сквозного решения — точечный фикс существующего компонента +`src/reconciler.py`; глобальный `adr-00NN` НЕ заводится (см. §«Область и масштаб»). + +## Контекст + +В Telegram периодически (особенно сразу после рестарта орка) прилетает ложное +`🔧 reconciler: ET-002 done разблокирована (потерян webhook)`. Задача `ET-002` +(enduro-trails) давно завершена; реально ничего не разблокируется — это шум, вводящий +наблюдателя в заблуждение. + +ORCH-068 закрыл аналогичный livelock **только на F-2 (plane-side)** двумя механизмами: +1. `_is_terminal_state(state_uuid, states, groups)` — терминал-исключение по **группе статуса + Plane** (`completed`/`cancelled`, project-independent) с fallback на логические ключи + `done`/`cancelled`. Вызывается **только** из `_reconcile_plane_issue` (F-2, `reconciler.py:362`). +2. In-memory dedup-guard `_unblock_dedup` (`issue_id → state_uuid`) внутри `_note_unblock` + (`reconciler.py:459`), активный **только когда `state_uuid is not None`**. + +Оба механизма **не покрывают путь F-1 (gate-side)**. Код-аудит (golden source — текущий +`src/reconciler.py`) подтверждает две независимые причины: + +- **Причина A — dedup не срабатывает.** Вызов F-1 (`_reconcile_gate_task`, `reconciler.py:228`) + передаёт `_note_unblock(work_item_id, stage)` — **только 2 аргумента, без `state_uuid`**. Ветка + dedup (`reconciler.py:459–463`) пропускается → уведомление шлётся на каждом релевантном тике, а + после рестарта `_unblock_dedup` пуст → первый проход снова шлёт. + +- **Причина B — нет терминал-скипа.** Единственный «терминал-фильтр» F-1 — + `get_active_tasks_for_reconcile()` (`db.py`, `WHERE stage != 'done'`), который смотрит **только + на стадию задачи в БД орка** и не знает о статусе issue в Plane. Для enduro (не self-hosting) + условные гейты (`check_staging_status`/`check_deploy_status`/merge-gate/…) — no-op `(True, …)` + (условность ORCH-35/43/58/71). Поэтому задача, чья стадия в БД орка ∈ не-`done` (дрейф), но в + Plane уже `Done` (группа `completed`), проходит фильтр → `advance_if_gate_passed` находит гейт + зелёным (no-op) → `result.advanced=True` (`reconciler.py:227`) → доходит до `_note_unblock`. + Guard 2 (`_is_blocked_or_needs_input`) её не спасает: его `skip_set` = `{blocked, needs_input, + extra_waits}` и **не содержит `done`/`cancelled`**. + +> **G1 (открытый вопрос BRD):** точная стадия `ET-002` в БД орка в момент срабатывания подлежит +> подтверждению в development по prod-логам/БД. Настоящее решение **робастно независимо** от точной +> стадии: терминальность определяется по группе статуса Plane (как F-2), а не по строковому +> совпадению стадии. Документирование точной стадии — в `12-review.md` (DoR TRZ §9). + +## Решение + +Распространить **оба** механизма ORCH-068 на путь F-1, переиспользовав один сетевой вызов на +задачу за тик. Все изменения локализованы в `src/reconciler.py` (`_reconcile_gate_task` + один +новый helper). `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, сигнатуры `advance_stage`/ +`advance_if_gate_passed`/`_note_unblock`, форма `status()`/`GET /queue` — **не меняются**. Новых +config-флагов нет. + +### D1 — Одиночный резолв Plane-статуса задачи (TR-3, R4) + +Ввести приватный helper, например: + +```python +def _resolve_issue_status(self, task: dict) -> tuple[dict, dict, str | None]: + """One networked resolve per task per tick: (states, groups, current_state_uuid). + + never-raise; on any failure / unresolved project / missing state -> + (states_or_{}, groups_or_{}, None). The single fetch feeds the terminal-skip + (D2), Guard 2 (D3) and the state_uuid handed to _note_unblock (D4). + """ +``` + +Внутри — **один** `fetch_issue_state(issue_id, pid)` плюс кэшируемые (ORCH-068 TTL) +`get_project_states(pid)` / `get_project_state_groups(pid)`. Это устраняет удвоение сетевого вызова +(сегодня `_is_blocked_or_needs_input` делает свой `fetch_issue_state` и **выбрасывает** uuid). + +### D2 — Терминал-скип на F-1 (TR-1, G2), безусловный + +В `_reconcile_gate_task`, **после** дешёвых локальных гардов (active-job, grace, Guard 1 +retry-count — все без сети) и **до** Guard 2 / `advance_if_gate_passed`, вставить ранний guard: + +```python +states, groups, state_uuid = self._resolve_issue_status(task) +# DB-side drift: cancelled is not filtered by get_active_tasks_for_reconcile (only done is). +if stage in ("done", "cancelled") or self._is_terminal_state(state_uuid, states, groups): + self.skipped_terminal_total += 1 + return +``` + +- Терминальность — тот же `_is_terminal_state` (переиспользование, **не** дублирование): первичный + дискриминатор — группа Plane ∈ `{completed, cancelled}`; fallback при пустых `groups` — логические + ключи `done`/`cancelled`. Покрывает R1 (enduro и orchestrator с разными наборами статусов). +- Дополнительно терминальной считается задача, чья **стадия в БД** ∈ `{done, cancelled}` (дрейф + Plane↔БД; `cancelled` сейчас не отсекается на уровне выборки). +- **Безусловный** — не подчинён `reconcile_skip_blocked_enabled` (тот гейтит **только** Guard 2). + Это не маскирует легитимный replay: реально застрявшая задача терминальной в Plane не бывает. +- Инкремент `skipped_terminal_total` — единая семантика с F-2 (`reconciler.py:363`). + +### D3 — Guard 2 переиспользует резолв (рефактор, без смены контракта) + +`_is_blocked_or_needs_input` принимает уже резолвнутые `(states, state_uuid)` вместо собственного +`fetch_issue_state`. Поведение и kill-switch `reconcile_skip_blocked_enabled` сохранены 1:1 +(флаг off → ранний `return False` без использования резолва; ошибка/`state_uuid is None` → +консервативный `return True` — skip). Допустима форма с дефолтными параметрами для обратной +совместимости вызова, но единственный продакшен-вызов — из `_reconcile_gate_task` с общим резолвом. + +### D4 — Проброс `state_uuid` в `_note_unblock` (TR-2, G3) + +Вызов на `reconciler.py:228` передаёт третий аргумент: + +```python +self._note_unblock(task.get("work_item_id") or str(task_id), stage, state_uuid) +``` + +`state_uuid` — тот же, что резолвнут в D1. Сигнатура `_note_unblock` **не меняется** (3-й параметр +уже опциональный). Теперь in-memory dedup (`reconciler.py:459–463`) работает и на F-1: +повторный вызов для того же `issue_id`+`state_uuid` (следующий тик до фактической смены статуса) → +`deduped_total += 1`, второго Telegram нет. Если Plane недоступен и `state_uuid` достоверно +получить нельзя → `None` (dedup деградирует в no-op, как сегодня) — но первым отрабатывает +терминал-скип D2 и/или консервативный Guard 2 D3. + +### Порядок гардов в `_reconcile_gate_task` (итог) + +``` +analysis-skip → qg-none-skip → active-job-skip → grace-skip + → Guard 1 (retry-count, local SQL, no network) + → [D1] resolve (states, groups, state_uuid) # единственный сетевой fetch + → [D2] terminal-skip (unconditional) # skipped_terminal_total++ + → Guard 2 (_is_blocked_or_needs_input, reuse) # gated by reconcile_skip_blocked_enabled + → Guard 3 (task_deps) + → advance_if_gate_passed → [D4] _note_unblock(..., state_uuid) +``` + +Терминал-скип **до** Guard 2, чтобы терминальные задачи корректно увеличивали +`skipped_terminal_total` (а не молчаливо проглатывались консервативным Guard 2). Резолв D1 — после +дешёвых локальных гардов, чтобы busy/молодые задачи не порождали сетевых вызовов. + +### Семантика ошибок (never-raise, R3, AC-5) + +- `_resolve_issue_status` never-raise → при сбое `state_uuid=None`, `groups={}`. +- `state_uuid=None` → `_is_terminal_state` возвращает `False` (нельзя подтвердить терминал по + Plane), но DB-side `stage ∈ {done, cancelled}` всё ещё ловит дрейф. +- При дефолтной конфигурации (`reconcile_skip_blocked_enabled=True`) недостижимый Plane → + Guard 2 консервативно `True` → **skip**, ложное уведомление не уходит (AC-5). +- Любое исключение в резолве/детекте изолировано `try/except` уровня + `reconcile_gate_once` (`reconciler.py:162–168`) → тик не падает. + +## Последствия + +### Плюсы +- Устраняется периодический ложный «ET-002 … разблокирована»; наблюдаемо ростом + `skipped_terminal_total` в `GET /queue` (метрика успеха BRD §7). +- Робастно для обоих проектов: первичный дискриминатор — группа статуса Plane (R1). +- Один сетевой вызов на задачу за тик (не растёт нагрузка горячего цикла, R4) — резолв заодно + питает Guard 2, ранее делавший отдельный fetch. +- Dedup-страховка теперь покрывает F-1: даже если терминал-скип однажды не сработает, повтор + подавляется (`deduped_total`). +- Симметрия F-1 ↔ F-2: единая семантика терминал-исключения и счётчиков; легче сопровождать. +- Нулевой контрактный след: ни стадий, ни QG, ни схемы БД, ни новых флагов, ни смены сигнатур. + +### Минусы / ограничения +- **Доп. fetch при `reconcile_skip_blocked_enabled=False`.** Раньше при выключенном Guard 2 F-1 не + ходил в Plane вовсе. Теперь терминал-скип (безусловный, по требованию TR-1) делает резолв даже + при выключенном escape-hatch. Вызов never-raise и быстро деградирует в `None`, но это новая + сетевая операция в этом режиме. **Принято** как цена корректности (TRZ §7 явно: терминал-скип не + подчинён этому флагу). +- **Угол «escape-hatch off + Plane недоступен».** При `reconcile_skip_blocked_enabled=False` И + недостижимом Plane Guard 2 не защищает, терминал-скип не подтверждает терминал (`state_uuid=None`), + и не-`cancelled` дрейф-задача может быть продвинута + уведомлена с `state_uuid=None`. Это **тот же + деградированный режим, что и сегодня** (новой гарантии под выключенный escape-hatch не даётся; + и регрессии нет). Дефолтная конфигурация полностью консервативна. +- Терминал-скип считает `skipped_terminal_total` только для задач, прошедших active-job/grace гарды + (как и F-2 считает только среди actionable issue). Это намеренно — счётчик отражает «дошло бы до + ложного unblock, но подавлено», а не «всего терминальных в системе». + +### Анти-регресс (AC-4) +Легитимный unblock реально застрявшей **не-терминальной** задачи (рабочий Plane-статус, гейт +зелёный, стадия реально сменилась) по-прежнему уведомляет ровно один раз с непустым `state_uuid` +(`unblocked_total += 1`). Терминал-скип к нему не применяется (такая задача не терминальна), Guard 2 +её не глушит (статус рабочий). F-2 не затронут. + +## Область и масштаб (почему нет глобального ADR) +Изменение **не сквозное**: не вводит новой стадии, QG, компонента или среды; это точечное +расширение уже существующего поведения реконсилятора (ORCH-053/`adr-0007`, доработка ORCH-068). +По конвенции глобальные `adr-00NN` заводятся для сквозных решений — здесь достаточно per-work-item +ADR + обновления раздела «Reconciler» в `docs/architecture/README.md` (golden source) и +`CHANGELOG.md`. Лейбл `arch:major-change` НЕ выставляется. + +## Альтернативы (отклонены) +- **Глобально выключить `reconcile_notify_unblock`** — теряем полезные алерты о реально застрявших + задачах (BRD не-цель). Подавление должно быть точечным (только терминальные). +- **Сужать выборку `get_active_tasks_for_reconcile` по статусу Plane** — потребовало бы сети в SQL- + выборке горячего цикла очереди всех проектов (анти-паттерн ORCH-026: claim/sweep offline-устойчивы) + и/или колонку статуса в `tasks` (миграция БД). Отклонено: терминальность резолвится онлайн + per-task (Вариант A, как ORCH-068 / Guard 2). +- **Только проброс `state_uuid` (D4) без терминал-скипа (D2)** — dedup подавил бы повтор в пределах + жизни процесса, но после рестарта (`_unblock_dedup` пуст) первый проход снова бы слал ложное + уведомление (ровно симптом BRD «особенно после рестарта»). Нужны оба механизма. +- **Терминал-детект по строке стадии** — хрупко при дрейфе Plane↔БД и мультипроектности (R1). + Группа статуса Plane — устойчивый дискриминатор. diff --git a/docs/work-items/ORCH-086/10-tech-risks.md b/docs/work-items/ORCH-086/10-tech-risks.md new file mode 100644 index 0000000..3c08b85 --- /dev/null +++ b/docs/work-items/ORCH-086/10-tech-risks.md @@ -0,0 +1,21 @@ +# 10-Tech Risks — ORCH-086 + +Технические риски выбранного решения (ADR-001). Бизнес-риски R1–R5 — в `01-brd.md`; здесь — +реализационные риски конкретного дизайна (одиночный fetch + терминал-скип на F-1). + +| # | Риск | Вероятность / Влияние | Митигация (как проверяется) | +|---|------|----------------------|------------------------------| +| TR-A | **Регрессия Guard 2 при рефакторе.** Перевод `_is_blocked_or_needs_input` на внешний резолв `(states, state_uuid)` может незаметно изменить семантику kill-switch `reconcile_skip_blocked_enabled` или консервативный fallback (`return True` при ошибке). | Низкая / Высокая | Поведение флага и fallback сохранить 1:1; контрактный тест AC-6 + регресс-тест Guard 2 (flag off → `False`; ошибка/`state_uuid=None` → `True`). | +| TR-B | **Угол «escape-hatch off + Plane недоступен».** При `reconcile_skip_blocked_enabled=False` и недостижимом Plane не-`cancelled` дрейф-задача может быть продвинута + ложно уведомлена (`state_uuid=None`). | Низкая / Средняя | Принятый деградированный режим (== сегодняшнее поведение, без новой гарантии). Дефолт (`flag=True`) полностью консервативен — основной тест AC-5 идёт под дефолтом. Задокументировано в ADR «Минусы». | +| TR-C | **Двойной сетевой вызов на тик.** Если резолв D1 и Guard 2 случайно оба сделают `fetch_issue_state`, нагрузка горячего цикла вырастет (R4). | Средняя / Средняя | Ровно один `fetch_issue_state` на задачу за тик; тест считает число вызовов `fetch_issue_state` (mock call_count == 1) на пути F-1. | +| TR-D | **Счётчик `skipped_terminal_total` расходится с семантикой F-2.** Двойной инкремент или инкремент не на ту задачу ломает наблюдаемость ORCH-068 (R2). | Низкая / Средняя | Инкремент ровно один раз на терминальную задачу за тик, перед `return`; тест AC-2 проверяет `+1` на задачу и отсутствие `advance`/`_note_unblock`. | +| TR-E | **Терминал-детект ломается на пустых `groups` (fallback).** При недоступности `get_project_state_groups` (пустой dict) `_is_terminal_state` должен корректно падать на логические ключи `done`/`cancelled`, иначе терминал enduro не распознается. | Низкая / Высокая | Переиспользуется существующий `_is_terminal_state` (уже покрыт для F-2); тест AC-2 покрывает обе ветви — (а) по группе, (б) fallback по ключу при пустых `groups`. | +| TR-F | **Порядок гардов.** Если терминал-скип поставить после Guard 2, терминальная задача молча проглатывается консервативным Guard 2 и `skipped_terminal_total` не растёт (теряем метрику успеха). | Низкая / Средняя | Терминал-скип строго ДО Guard 2 (ADR порядок гардов); тест проверяет инкремент счётчика именно при терминале. | +| TR-G | **never-raise в новом helper.** Исключение в `_resolve_issue_status`/`_is_terminal_state` не должно ронять тик и не должно приводить к ложной отправке. | Низкая / Высокая | helper под `try/except` → `(…, None)`; тик уже изолирован `reconcile_gate_once` (`reconciler.py:162`). Тест AC-5: исключение в fetch → тик жив, `send_telegram` не вызван. | +| TR-H | **Анти-регресс легитимного unblock (AC-4).** Слишком широкий терминал/skip-set может задушить полезный алерт о реально застрявшей не-терминальной задаче. | Низкая / Высокая | Терминал-детект строго по `{completed, cancelled}` (+ DB `done`/`cancelled`); регресс-тест AC-4 — не-терминальная задача с зелёным гейтом уведомляет ровно один раз. | + +## Зависимости / предпосылки +- `fetch_issue_state`, `get_project_states`, `get_project_state_groups`, `get_project_by_repo` — + переиспользуются read-only, без изменения контракта (TRZ §1). +- G1 (точная стадия `ET-002`) подтверждается в development по prod-логам/БД и фиксируется в + `12-review.md` (DoR TRZ §9). Решение робастно независимо от исхода G1.