# ADR-001 (ORCH-068): Исключение терминалов из F-2 по группе состояния + подтверждённый unblock + TTL кэша статусов

- **Статус:** Accepted
- **Дата:** 2026-06-08
- **Задача:** ORCH-068 (BUG: reconciler livelock — спам «разблокирована» по синхронизированной done-задаче)
- **Сквозной ADR:** уточняет [adr-0007-reconciler.md](../../../architecture/adr/adr-0007-reconciler.md) (F-2) — реестры/схема НЕ меняются
- **Связанные:** ORCH-053 (reconciler F-2), ORCH-066 (новая статусная модель Plane — триггер регрессии), ORCH-060 (F-1 пред-гарды), ORCH-10 (`get_project_states`)

## Контекст

Reconciler F-2 (`src/reconciler.py`, `_reconcile_plane_project` / `_reconcile_plane_issue`)
опрашивает Plane per-project и доигрывает потерянные webhook-переходы через штатные
`handle_status_start` / `handle_verdict`. После мерджа ORCH-066 (новая статусная модель
Plane) на проде с 22:17 UTC reconciler каждые ~120с слал в Telegram
`reconciler: ET-002 done разблокирована (потерян webhook)` для задачи ET-002, которая
полностью синхронизирована (БД `stage=done`, Plane `state=Done` с 2026-05-21). 191+
сообщений за ночь — livelock без advance/jobs/токенов, но подрывающий доверие alert-fatigue.

Диагностика (BRD §3) выявила **два независимых, складывающихся дефекта**:

- **D1 (выборка):** F-2 различает actionable-статусы по **голому UUID**
  (`in_progress`/`approved`/`rejected`). `get_project_states` строит маппинг по *именам*
  статусов, недостающие ключи добивает из `_DEFAULT_STATES` (enduro-значения). После
  ORCH-066 набор имён enduro изменился → терминальный `Done` перестал однозначно
  отличаться от `approved` по UUID, и ET-002 (Plane=Done) **попала** в actionable-набор
  ветки `approved`. Терминальные статусы (`Done`/`Cancelled`) нигде не исключаются из F-2.
- **D2 (нотификация):** `_note_unblock` вызывается **безусловно сразу после `_dispatch`**,
  не проверяя, изменил ли обработчик реально состояние задачи. `handle_verdict(approved)`
  для уже-`done` задачи — no-op, но нотификация всё равно уходит. Это прямо нарушает
  собственный docstring `_note_unblock` («fires only on an actual state change, never per
  idle tick») и инвариант silence-when-in-sync (AC-9/AC-10 ORCH-053).

Связанный secondary-баг (BRD §4): `_STATES_CACHE` (`src/plane_sync.py`) кэширует статусы
на **весь lifetime процесса**. После появления нового Plane-статуса боевой процесс держит
устаревший набор → webhook на новый статус даёт «no pipeline action», лечилось только
рестартом орка. Примитив сброса `reload_project_states()` уже есть, но автоматически не
вызывается.

Ограничения (из ТЗ, обязаны сохраниться): источник истины F-2 — Plane (не переписываем);
НЕ трогать `STAGE_TRANSITIONS` / `QG_CHECKS` / схему БД / контракты `handle_*` / F-1 / F-3;
never-raise per unit of work; kill-switch'и; 0 jobs/0 токенов для синхронизированных задач;
self-hosting — reconciler НИКОГДА не рестартит прод-контейнер.

## Решение

Чиним **оба** дефекта независимыми слоями (defense in depth, как принято в проекте —
ORCH-058) плюс TTL для кэша. Все правки локальны в `src/reconciler.py` и
`src/plane_sync.py`; реестры, схема БД и контракты обработчиков не меняются.

### Слой D1 — исключение терминалов по ГРУППЕ состояния (TR-1, AC-2)

Различаем терминальные (`completed`/`cancelled`) и review/work-статусы по **группе
состояния Plane** (`state.group ∈ {backlog, unstarted, started, completed, cancelled}`),
а НЕ по голому UUID. Группа — авторитетный, проектно-независимый дискриминатор: она
корректно различает `Done` (completed) и `approved` (started/review) даже когда проект
«схлопывает» их по UUID после переименований.

Механика (single API fetch, без новых сетевых вызовов):
- `/states/`-ответ Plane содержит для каждого статуса поле `group`. Расширяем кэш-запись
  `_STATES_CACHE` так, чтобы из ОДНОГО запроса хранить и текущий `{logical_key → uuid}`,
  и `{uuid → group}`. `get_project_states` сохраняет **прежнюю сигнатуру и форму возврата**
  (`{logical_key: uuid}`) — обратная совместимость (AC-13). Добавляется sibling-аксессор
  `get_project_state_groups(project_id) -> dict[uuid, group]` (или эквивалент), читающий ту
  же кэш-запись.
- В `_reconcile_plane_issue` ДО выбора ветки: если группа `new_state` ∈
  {`completed`, `cancelled`} → **тишина** (return, no-op). Fallback, когда группа
  недоступна (API не отдал `group` / fallback на `_DEFAULT_STATES`): исключать по логическим
  ключам терминалов `{states.get("done"), states.get("cancelled")}`.

Терминал-исключение применяется **per-issue** (а не сужением `wanted`-набора
`list_issues_by_state`), потому что при UUID-алиасинге терминал может физически совпадать с
actionable-UUID в `wanted` — фильтрация по UUID его не отсечёт, а проверка группы отсечёт.

### Слой D2 — `_note_unblock` только при подтверждённом state change (TR-2, AC-3)

`_note_unblock` (лог + Telegram + `unblocked_total`) вызывается ТОЛЬКО когда диспетчеризованный
обработчик **фактически изменил состояние задачи**. Реализация — сравнение состояния
**до/после на стороне reconciler** (предпочтение ТЗ; контракты `handle_*` НЕ меняются):
- `approved`/`rejected` (task существует): захватить `stage_before` (из уже прочитанного
  `task`), после `_dispatch` перечитать `get_task_by_plane_id(issue_id)` → `stage_after`;
  `_note_unblock` только если `stage_after != stage_before`.
- `in_progress` + `task is None` (старт пайплайна): подтверждение = задача **появилась**
  после dispatch (`get_task_by_plane_id` теперь не None).

No-op dispatch (задача уже в целевом состоянии) → 0 нотификаций. Восстанавливает соответствие
docstring и инвариант silence-when-in-sync.

### Слой TR-3 — дедуп нотификаций (страховка, AC-4)

In-memory best-effort guard: `{issue_id → last_unblocked_state_uuid}`. `_note_unblock` для
issue+state, уже отмеченного, подавляется. Сбрасывается при рестарте (допустимо — AC-11
ORCH-053, как `unblocked_total`/`last_unblocked`). D1+D2 закрывают основной кейс; TR-3 —
дополнительная сетка против любого будущего no-op-пути.

### Слой TR-4 — TTL кэша статусов (secondary, AC-12/AC-13)

Кэш-запись `_STATES_CACHE` хранит timestamp; `get_project_states` перезапрашивает API при
истечении `ORCH_PLANE_STATES_TTL_S`. Примитив инвалидации — существующий
`reload_project_states()` (не дублируем логику сброса). Новый флаг
`plane_states_ttl_s` (env `ORCH_PLANE_STATES_TTL_S`):
- дефолт **300** (5 мин) — устаревший набор самозалечивается без рестарта (G5);
- `0` — отключает TTL → строго прежний lifetime-кэш (escape hatch / strict back-compat).

Fallback на `_DEFAULT_STATES` при недоступности API сохранён без изменений; TTL-перезапрос
возвращает тот же корректный набор → не регресс (AC-13).

## Альтернативы

- **Явный allowlist логических ключей терминалов (`done`/`cancelled`) без группы** —
  отклонён как primary: хрупок к будущим переименованиям/добавлению completed-статусов
  (`Monitoring after Deploy` и т.п.) и к UUID-алиасингу. Оставлен как **fallback**, когда
  `group` недоступен.
- **Сужение `wanted`-набора в `list_issues_by_state`** — недостаточно: при UUID-алиасинге
  терминал совпадает с actionable-UUID и не отсекается фильтром по UUID. Нужна проверка
  группы per-issue.
- **Проброс «changed»-сигнала из `handle_*`** — отклонён: меняет контракт обработчиков
  (запрещено ТЗ N2). Выбрано сравнение до/после на стороне reconciler.
- **Флаг подавления нотификаций в `advance_stage`** — отклонён (как и в adr-0007):
  трогает общий критический путь.
- **flush-on-unknown как primary для кэша** — допустимо ТЗ и дешевле, но недетерминирован
  для юнит-теста (TC-11) и не лечит «тихий устаревший набор» без триггера-вебхука. Выбран
  TTL (детерминированный, самозалечивающий); flush-on-unknown может быть добавлен позже как
  комплемент, переиспользуя `reload_project_states`.
- **Admin-эндпоинт `POST /admin/plane-states/reload`** — отклонён в объёме (требует
  ручного действия, не лечит автоматически); TTL покрывает G5 без нового API.

## Последствия

- **Плюсы:** livelock устранён двумя независимыми слоями; терминал-исключение
  проектно-независимо (enduro И orchestrator), устойчиво к будущим переименованиям статусов;
  `_note_unblock` снова соответствует своему контракту; устаревший кэш самозалечивается без
  рестарта прода. Реестры/схема/контракты/F-1/F-3 не тронуты.
- **Минусы / плата:** один доп. accessor группы (тот же API-запрос, без новой сетевой
  стоимости); TTL добавляет редкий перезапрос `/states/` (раз в 5 мин/проект); дедуп-словарь
  — небольшая in-memory структура, неперсистентная (приемлемо).
- **Совместимость:** `get_project_states` форма возврата неизменна; `plane_states_ttl_s=0`
  → строго прежнее поведение кэша; `_DEFAULT_STATES`-fallback сохранён.
- **Self-hosting:** ни один путь не рестартит прод-контейнер (AC-11); правка
  обязательно проходит staging-гейт (8501) перед прод-деплоем орка.
- **Наблюдаемость (опц.):** допустимо добавить в блок `reconcile` снимка `GET /queue`
  счётчики `skipped_terminal` / `deduped` без ломающих изменений.

## Инварианты (подтверждение)

INV-1 источник истины F-2 = Plane — сохранён (правим маппинг/нотификацию, не концепцию).
INV-2 never-raise per-issue/-project/-tick — сохранён (новый guard в том же try-периметре).
INV-3 kill-switch'и `ORCH_RECONCILE_ENABLED` / `ORCH_RECONCILE_PLANE_ENABLED` — без изменений.
INV-4 F-1/F-3 — не тронуты. INV-5 0 jobs/0 токенов для done/cancelled — восстановлен.
INV-6 легитимная разблокировка реально-потерянного approved/in_progress — работает (D2
подтверждает реальный change, не подавляет его). INV-7 self-hosting — тик не рестартит прод.

## Объём изменений (для разработчика)

- `src/reconciler.py`: терминал-гард по группе + fallback в `_reconcile_plane_issue`;
  before/after-сравнение стадии вокруг `_dispatch`; in-memory дедуп-словарь в `Reconciler`.
- `src/plane_sync.py`: кэш-запись с timestamp + `{uuid→group}`; `get_project_state_groups`;
  TTL-логика в `get_project_states` (переиспользуя `reload_project_states`).
- `src/config.py`: флаг `plane_states_ttl_s` (env `ORCH_PLANE_STATES_TTL_S`, дефолт 300).
- `.env.example` / `.env.staging`: задокументировать `ORCH_PLANE_STATES_TTL_S`.
- Доки в ТОМ ЖЕ PR: `docs/architecture/README.md` (Reconciler/Plane Sync), `CHANGELOG.md`
  (`fix:`), `CLAUDE.md` (при изменении наблюдаемого поведения), этот ADR.
- Тесты: `04-test-plan.yaml` (TC-01…TC-13), офлайн (мок Plane/Telegram/`_dispatch`).
