# 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 — устойчивый дискриминатор.
