# 01-BRD — ORCH-086: reconciler шлёт ложное «ET-002 done разблокирована»

Work Item: **ORCH-086**
Тип: **Багфикс** (шум уведомлений / остаток livelock)
Приоритет: **MEDIUM**
Зона: `src/reconciler.py`
Связано: продолжение **ORCH-068** (тот фикс задеплоен, но НЕ закрыл этот путь), наследует контракты **ORCH-053 / ORCH-060 / ORCH-066**.

## 1. Контекст / проблема
В Telegram периодически (а особенно сразу после рестарта оркестратора) прилетает уведомление:

> 🔧 reconciler: ET-002 done разблокирована (потерян webhook)

Это **ложный шум**: задача `ET-002` (проект enduro-trails) давно завершена, реально ничего не разблокируется. Уведомление вводит наблюдателя в заблуждение (создаёт впечатление, что конвейер чинит застрявшую задачу, хотя ничего не происходит).

ORCH-068 уже починил аналогичный livelock на **F-2 (plane-side)**: добавил per-issue терминал-исключение (`_is_terminal_state`, группа Plane `completed`/`cancelled`) и in-memory dedup-guard по `issue_id→state_uuid`. Однако эти две защиты **не покрывают путь F-1 (gate-side)**.

## 2. Диагностика (код-аудит, golden source — текущий `src/reconciler.py`)

Уведомление отправляет `Reconciler._note_unblock()` (`reconciler.py` ~стр.444) через `send_telegram()` при `settings.reconcile_notify_unblock=True`.

Два механизма ORCH-068, которые ДОЛЖНЫ были его подавить, на пути F-1 не работают:

1. **Dedup-guard не срабатывает.** Guard ключуется по `state_uuid` и активен только когда `state_uuid is not None` (`_note_unblock`, стр.459–463). Но вызов в F-1 (`_reconcile_gate_task`, стр.228):
   ```python
   self._note_unblock(task.get("work_item_id") or str(task_id), stage)
   ```
   передаёт **только 2 аргумента, БЕЗ `state_uuid`** → ветка dedup пропускается → уведомление шлётся при каждом релевантном тике/старте. (В отличие от F-2, где все 4 вызова `_note_unblock` передают `state_uuid` — стр.394/400/407/416.)

2. **Терминал-скип не ловит этот путь.** Терминал-исключение ORCH-068 (`_is_terminal_state`, стр.327–344) вызывается **только в F-2** (`_reconcile_plane_issue`, стр.362). В F-1 единственный «терминал-фильтр» — это `get_active_tasks_for_reconcile()` (`db.py` стр.193: `WHERE stage != 'done'`), который смотрит **только на стадию задачи в БД оркестратора** и НЕ знает о статусе задачи в Plane (группа `completed`/`cancelled`). Поэтому задача, которая в БД оркестратора стоит на НЕ-`done` стадии (дрейф), а в Plane уже `Done`, проходит фильтр.

### Почему `advance_if_gate_passed` считает ET-002 «продвинувшейся» (G1 — гипотеза, требует подтверждения в development)
Для enduro-trails (не self-hosting) условные гейты (`check_staging_status`, `check_deploy_status`, merge-gate, image-freshness, security-gate, merge-verify) — **no-op `(True, ...)`** (условность ORCH-35/43/58/71). Поэтому для enduro-задачи, чья стадия в БД оркестратора НЕ `done`, но застряла перед терминалом (например `deploy`), `advance_if_gate_passed` находит гейт зелёным (no-op) → вызывает `advance_stage(..., finished_agent=None)` → возвращает `result.advanced=True` (стр.227) → доходит до `_note_unblock`. Guard 2 (`_is_blocked_or_needs_input`, стр.230) задачу не спасает: его `skip_set` = `{blocked, needs_input, extra_waits}` и **НЕ содержит `done`/`cancelled`** → терминальная-в-Plane задача через него проходит. «Периодичность / при старте» объясняется отсутствием dedup (state_uuid не передан) + чистым in-memory состоянием нового процесса после рестарта (первый проход снова находит задачу).

> **Открытый вопрос для G1 (подтвердить в development по prod-БД/логам):** точная стадия `ET-002` в БД оркестратора в момент срабатывания (в quoted-сообщении фигурирует слово «done», но `get_active_tasks_for_reconcile` исключает `stage='done'` — значит стадия в БД иная либо аномальная). Фикс обязан быть **робастным независимо** от точной стадии: терминальность определяется по группе статуса Plane (как `_is_terminal_state`), а не по строковому совпадению стадии.

## 3. Бизнес-цели
- **G1.** Установить и задокументировать, почему F-1 (`advance_if_gate_passed`) доводит терминальную в Plane задачу (ET-002) до `_note_unblock` на каждом релевантном тике/старте.
- **G2.** Не слать unblock-уведомление для задач, УЖЕ терминальных (`done`/`cancelled`) в Plane (по группе статуса) и/или в оркестраторе — распространить терминал-скип ORCH-068 на путь F-1 (стр.228), а не только на F-2.
- **G3.** Передавать `state_uuid` в `_note_unblock` на **всех** путях (включая F-1) → in-memory dedup-guard работает везде (страховка от повтора, даже если терминал-скип когда-то не сработает).

## 4. Объём (Scope)
**В объёме:**
- Точечная правка `src/reconciler.py`: терминал-скип на пути F-1 + проброс `state_uuid` в `_note_unblock` из F-1.
- Сохранение/корректное инкрементирование наблюдаемости ORCH-068 (`skipped_terminal_total`, `deduped_total`, `unblocked_total`).
- Unit-тесты, покрывающие AC-1…AC-5.
- Обновление документации (`docs/architecture/README.md` блок Reconciler, `CHANGELOG.md`).

**Вне объёма (Не-цели):**
- НЕ ломать легитимный replay реально застрявшей задачи (когда реконсиляция её ДЕЙСТВИТЕЛЬНО двигает — уведомление полезно).
- НЕ трогать пайплайн / статусы enduro-trails.
- НЕ отключать `reconcile_notify_unblock` глобально (потеряем полезные алерты) — подавление **точечное**, только для терминальных.
- НЕ менять `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, схему БД, контракты `advance_stage` / `advance_if_gate_passed`.
- НЕ менять поведение F-2 (там ORCH-068 уже корректен) сверх необходимого переиспользования хелперов.

## 5. Заинтересованные лица
- **Owner / Слава** — наблюдатель Telegram-карточек и алертов; страдающая сторона (шум).
- **enduro-trails** — проект, чьи терминальные задачи генерируют ложные алерты; пайплайн не должен быть затронут.
- **orchestrator (self-hosting)** — терминал-детект должен корректно работать и для self (разные наборы Plane-статусов).

## 6. Риски и ограничения
- **R1 (грабли мультипроектности).** enduro-trails и orchestrator — разные проекты с разными наборами Plane-статусов. Терминал-детект ОБЯЗАН работать для обоих: первичный дискриминатор — группа статуса Plane (`completed`/`cancelled`, project-independent), fallback — логические ключи `done`/`cancelled` (как в существующем `_is_terminal_state`, стр.338–344).
- **R2 (наблюдаемость).** Нельзя сломать счётчики ORCH-068. При скипе терминальной задачи в F-1 — инкрементировать `skipped_terminal_total` (единая семантика с F-2). `deduped_total`/`unblocked_total` — без регрессии.
- **R3 (never-raise).** Тик реконсилятора обязан оставаться never-raise (сеть Plane может быть недоступна). Сбой терминал-проверки → консервативное поведение (как Guard 2: при ошибке скорее НЕ слать, чем слать ложно; но НЕ ценой подавления легитимного unblock — см. AC-4).
- **R4 (доп. сетевой вызов).** F-1 для проброса `state_uuid` и терминал-детекта должен знать текущий Plane-статус issue. Guard 2 (`_is_blocked_or_needs_input`) уже делает `fetch_issue_state`. Желательно переиспользовать один fetch, не удваивая обращения к Plane API на тик (производительность горячего цикла).
- **R5 (ложно-отрицательный риск).** Слишком агрессивное подавление может задушить полезный алерт о реально застрявшей задаче → обязателен регресс-тест AC-4.

## 7. Метрика успеха
- В Telegram больше нет периодического «ET-002 done разблокирована»; `skipped_terminal_total` растёт (наблюдаемо в `GET /queue`).
- `pytest tests/ -q` зелёный; новые тесты AC-1…AC-5 проходят.
