From f2161451a0f8bbf6a629ee25e1f8dc5ab780088f Mon Sep 17 00:00:00 2001 From: claude-bot Date: Tue, 9 Jun 2026 01:50:19 +0300 Subject: [PATCH] analyst(ET): auto-commit from analyst run_id=419 --- docs/work-items/ORCH-086/01-brd.md | 70 +++++++++++ docs/work-items/ORCH-086/02-trz.md | 68 +++++++++++ .../ORCH-086/03-acceptance-criteria.md | 32 +++++ docs/work-items/ORCH-086/04-test-plan.yaml | 110 ++++++++++++++++++ 4 files changed, 280 insertions(+) create mode 100644 docs/work-items/ORCH-086/01-brd.md create mode 100644 docs/work-items/ORCH-086/02-trz.md create mode 100644 docs/work-items/ORCH-086/03-acceptance-criteria.md create mode 100644 docs/work-items/ORCH-086/04-test-plan.yaml diff --git a/docs/work-items/ORCH-086/01-brd.md b/docs/work-items/ORCH-086/01-brd.md new file mode 100644 index 0000000..a928e70 --- /dev/null +++ b/docs/work-items/ORCH-086/01-brd.md @@ -0,0 +1,70 @@ +# 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 проходят. diff --git a/docs/work-items/ORCH-086/02-trz.md b/docs/work-items/ORCH-086/02-trz.md new file mode 100644 index 0000000..122a9a8 --- /dev/null +++ b/docs/work-items/ORCH-086/02-trz.md @@ -0,0 +1,68 @@ +# 02-TRZ — ORCH-086: терминал-скип и dedup на пути F-1 реконсилятора + +> Техническое задание. Архитектурное решение (КАК именно) — за архитектором (ADR). Здесь — ЧТО должно измениться и инварианты. + +## 1. Задействованные модули `src/` +- **`src/reconciler.py`** — основной (и, как ожидается, единственный) изменяемый модуль: + - `Reconciler._reconcile_gate_task` (стр.180–228) — путь F-1, где находится баг. + - `Reconciler._note_unblock` (стр.444–477) — точка отправки уведомления + dedup-guard. + - `Reconciler._is_terminal_state` (стр.327–344) — существующий терминал-детект (сейчас зовётся только из F-2); переиспользуется в F-1. + - `Reconciler._is_blocked_or_needs_input` (стр.230–288) — уже делает `fetch_issue_state`; желательно переиспользовать его результат, чтобы не удваивать сетевой вызов. +- **Возможно затрагиваемые (read-only переиспользование, без изменения контракта):** `src/plane_sync.py` (`fetch_issue_state`, `get_project_states`, `get_project_state_groups`), `src/projects.py` (`get_project_by_repo`). Изменять их не требуется. +- **НЕ затрагиваются:** `src/stages.py` (`STAGE_TRANSITIONS`), `src/qg/checks.py` (`QG_CHECKS`), `src/stage_engine.py` (`advance_stage`/`advance_if_gate_passed`), `src/db.py` (схема), `src/config.py` (новые флаги не вводятся). + +## 2. Требуемые изменения (функциональные) + +### TR-1 (G2): терминал-скип на пути F-1 +В `_reconcile_gate_task` ДО вызова `_note_unblock` (а лучше — до/вместо доведения терминальной задачи до `advance_if_gate_passed`) добавить проверку: **является ли задача терминальной**. +- Терминальность определяется тем же способом, что и в F-2 (`_is_terminal_state`): первичный дискриминатор — **группа статуса Plane** issue ∈ `{completed, cancelled}`; fallback (группа недоступна) — логические ключи `done`/`cancelled` проекта. Это покрывает грабли R1 (enduro vs orchestrator). +- Дополнительно: терминальной считается и задача, чья **стадия в БД оркестратора** ∈ `{done, cancelled}` (на случай дрейфа Plane↔БД; `get_active_tasks_for_reconcile` уже отсекает `done`, но `cancelled` — нет). +- Терминальная задача → **return без advance и без `_note_unblock`**; инкремент `self.skipped_terminal_total` (единая семантика с F-2, стр.363). +- Скип **безусловный** (как терминал-скип F-2 — без отдельного kill-switch). Это НЕ маскирует легитимный replay: реально застрявшая задача терминальной в Plane не бывает. + +> **Где именно** ставить проверку (до `advance_if_gate_passed` или внутри/перед `_note_unblock`) — решает архитектор. Рекомендация: ставить как ранний guard в `_reconcile_gate_task` рядом с Guard 1/Guard 2 (чтобы терминальная задача даже не запускала `advance_if_gate_passed`/гейт). Если терминал-детект требует Plane-статус, он логично переиспользует fetch из Guard 2. + +### TR-2 (G3): проброс `state_uuid` в `_note_unblock` из F-1 +Вызов на стр.228 должен передавать `state_uuid` (текущий Plane-state issue), чтобы in-memory dedup-guard (`_unblock_dedup`, стр.459–463) работал и на пути F-1: +```python +# было: +self._note_unblock(task.get("work_item_id") or str(task_id), stage) +# должно (концептуально): +self._note_unblock(task.get("work_item_id") or str(task_id), stage, state_uuid) +``` +- `state_uuid` — текущий uuid статуса issue в Plane (тот же, что используется для терминал-детекта TR-1). +- Если Plane недоступен и `state_uuid` достоверно получить нельзя → допустимо передать `None` (dedup деградирует в no-op, как сегодня), НО приоритетно сначала отрабатывает терминал-скип TR-1; never-raise сохраняется. +- **Сигнатуру `_note_unblock` не менять** (3-й параметр `state_uuid` уже опциональный, стр.445). + +### TR-3: переиспользование сетевого вызова (R4, нефункц., желательно) +F-1 не должен делать > 1 обращения к Plane API на задачу за тик ради статуса. `_is_blocked_or_needs_input` уже вызывает `fetch_issue_state`. Архитектор решает форму переиспользования (например, вынести резолв `(project_states, groups, current_state_uuid)` в один helper, питающий Guard 2 + терминал-скип TR-1 + dedup TR-2). Допустимо и без рефакторинга, если число вызовов на тик не растёт значимо. + +## 3. Контракты и инварианты (НЕ нарушать) +- **never-raise:** каждая единица работы F-1 изолирована (`_reconcile_gate_task` уже под `try/except` в `reconcile_gate_once`, стр.162–168). Любая ошибка терминал-детекта/fetch → не падает тик; консервативное поведение (R3): при невозможности достоверно определить терминальность — НЕ слать ложно, но и не глушить легитимный (см. AC-4: легитимный unblock — это реальная смена стадии не-терминальной задачи; терминал-неопределённость к нему не относится). +- **silence-when-in-sync:** терминальная (= полностью синхронизированная) задача → тишина (инвариант ORCH-068 AC-1/AC-2, теперь и для F-1). +- **Легитимный unblock сохраняется:** не-терминальная реально застрявшая задача с зелёным гейтом по-прежнему `advance` + уведомление (AC-4). +- **Наблюдаемость ORCH-068:** `skipped_terminal_total` инкрементируется при терминал-скипе F-1; `deduped_total` — при подавлении повтора dedup'ом; `unblocked_total`/`last_unblocked` — только при реальной отправке. Снимок `status()` (стр.516–528) и блок `reconcile` в `GET /queue` — без структурных изменений. +- **Условность мультипроекта:** терминал-детект работает и для enduro, и для orchestrator (по группе статуса + fallback). Пайплайн/статусы enduro не трогаются. + +## 4. Изменения API +Нет. HTTP-эндпоинты не меняются. `GET /queue` блок `reconcile` сохраняет форму (значения счётчиков — наблюдаемое поведение). + +## 5. Изменения схемы БД +Нет. Миграции нет. (Терминальность Plane резолвится онлайн, как в ORCH-068 / Guard 2 — Вариант A без колонки статуса в `tasks`.) + +## 6. Новые/изменённые QG checks +Нет. Реестр `QG_CHECKS` и `STAGE_TRANSITIONS` не меняются. + +## 7. Конфигурация +Новые флаги НЕ вводятся. Терминал-скип безусловен (как у F-2). Существующие `reconcile_enabled`, `reconcile_notify_unblock`, `reconcile_skip_blocked_enabled`, `reconcile_plane_enabled` — без изменений семантики. (`reconcile_skip_blocked_enabled` гейтит ТОЛЬКО Guard 2; терминал-скип TR-1 ему НЕ подчиняется.) + +## 8. Артефакты pipeline, подлежащие обновлению (документация = golden source) +- `docs/architecture/README.md` — раздел «Reconciler … ORCH-068»: дописать, что терминал-исключение и dedup теперь покрывают и F-1 (gate-side), не только F-2. +- `CHANGELOG.md` — запись `fix:` про ORCH-086. +- `docs/work-items/ORCH-086/06-adr/ADR-NNN-*.md` — ADR (создаёт архитектор). +- (Опционально) краткая ссылка в ADR ORCH-068, что F-1-пробел закрыт ORCH-086. + +## 9. Готовность к development (Definition of Ready) +- G1 подтверждён по prod-логам/БД (точная стадия ET-002 и путь срабатывания задокументированы в ADR/12-review). +- Тест-план `04-test-plan.yaml` реализован в `tests/test_reconciler.py`. +- `pytest tests/ -q` зелёный. diff --git a/docs/work-items/ORCH-086/03-acceptance-criteria.md b/docs/work-items/ORCH-086/03-acceptance-criteria.md new file mode 100644 index 0000000..a4441b9 --- /dev/null +++ b/docs/work-items/ORCH-086/03-acceptance-criteria.md @@ -0,0 +1,32 @@ +# 03-Acceptance Criteria — ORCH-086 + +Каждый критерий формулирует чёткое условие PASS/FAIL. Проверяется автотестами (`tests/test_reconciler.py`) и код-ревью. + +## AC-1 — ET-002 (терминальная) больше не генерирует «разблокирована» +**Дано:** F-1 (`reconcile_gate_once`) обрабатывает задачу enduro, чья стадия в БД оркестратора НЕ-`done` (дрейф), а текущий статус в Plane — терминальный (`Done`, группа `completed`); гейт стадии зелёный (для enduro — no-op `True`). +- **PASS:** `_note_unblock` НЕ вызывается → `send_telegram` НЕ вызывается ни при обычном тике, ни при первом проходе после старта (свежий процесс/чистый `_unblock_dedup`). +- **FAIL:** уведомление «… разблокирована (потерян webhook)» отправлено хотя бы раз. + +## AC-2 — терминальные задачи (done/cancelled) не доходят до `_note_unblock` +**Дано:** задача терминальна в Plane (группа `completed` или `cancelled`) ИЛИ её стадия в БД ∈ `{done, cancelled}`. +- **PASS:** F-1 делает ранний скип (нет `advance` / нет `_note_unblock`); `skipped_terminal_total` увеличен на 1 на каждую такую задачу за тик. +- **FAIL:** терминальная задача доходит до `advance_if_gate_passed`→`_note_unblock`, либо `skipped_terminal_total` не растёт. +- **Грабли (R1):** условие должно срабатывать для ОБОИХ проектов — enduro (терминал по группе `completed`/`cancelled`, либо fallback-ключ `done`/`cancelled`) и orchestrator (свой набор статусов). Тест покрывает оба пути терминал-детекта: (а) по группе, (б) fallback по логическому ключу при пустых `groups`. + +## AC-3 — `_note_unblock` на всех путях получает `state_uuid` → dedup покрывает все вызовы +**Дано:** легитимный unblock реально застрявшей НЕ-терминальной задачи на пути F-1 (гейт зелёный, стадия сменилась). +- **PASS:** `_note_unblock` вызван с непустым `state_uuid`; повторный вызов для того же `issue_id`+`state_uuid` (например на следующем тике до фактической смены статуса) подавляется dedup-guard'ом → `deduped_total` растёт, второго `send_telegram` нет. +- **FAIL:** F-1 зовёт `_note_unblock` без `state_uuid` (2 аргумента) → dedup не работает → повторные уведомления. + +## AC-4 — легитимный unblock реально застрявшей задачи ПО-ПРЕЖНЕМУ уведомляет (анти-регресс) +**Дано:** НЕ-терминальная задача (Plane-статус рабочий, не `done`/`cancelled`/`blocked`/`needs_input`), реально застрявшая (прошла grace, нет active-job), гейт зелёный → F-1 её продвигает (`result.advanced=True`). +- **PASS:** `_note_unblock` вызван ОДИН раз; при `reconcile_notify_unblock=True` отправлен ровно один Telegram; `unblocked_total` += 1. +- **FAIL:** уведомление подавлено (полезный алерт задушен) ИЛИ отправлено более одного раза за одну смену стадии. + +## AC-5 — pytest зелёный; never-raise в тике сохранён +- **PASS:** `pytest tests/ -q` зелёный; при исключении внутри терминал-детекта/`fetch_issue_state`/`_reconcile_gate_task` тик НЕ падает (изоляция per-task), и ложное уведомление при ошибке НЕ отправляется (консервативно). +- **FAIL:** падение тика, незелёный pytest, либо исключение терминал-детекта приводит к ложной отправке. + +## AC-6 — без регрессий смежного поведения (контрактный) +- **PASS:** F-2 (plane-side) терминал-скип/dedup/счётчики работают как в ORCH-068; `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, сигнатуры `advance_stage`/`advance_if_gate_passed`/`_note_unblock`, форма `status()`/`GET /queue` — не изменены; новые config-флаги не введены; `reconcile_skip_blocked_enabled` по-прежнему гейтит только Guard 2 (терминал-скип ему не подчинён). Документация (`README.md`, `CHANGELOG.md`) обновлена в том же PR. +- **FAIL:** любое из перечисленного нарушено. diff --git a/docs/work-items/ORCH-086/04-test-plan.yaml b/docs/work-items/ORCH-086/04-test-plan.yaml new file mode 100644 index 0000000..9d861fc --- /dev/null +++ b/docs/work-items/ORCH-086/04-test-plan.yaml @@ -0,0 +1,110 @@ +work_item: ORCH-086 +description: > + Терминал-скип и проброс state_uuid на пути F-1 реконсилятора. + Тесты добавляются в tests/test_reconciler.py (рядом с существующими TC-01..TC-21), + переиспользуя фикстуры fresh_db / silence_side_effects / _green_ci / + plane_state_not_blocked и спай send_telegram. Все тесты — pytest, оффлайн + (Plane/Telegram мокаются), детерминированные. +tests: + - id: TC-86-01 + type: unit + description: > + AC-1 — задача enduro НЕ-done в БД, но терминальная в Plane (group=completed), + гейт зелёный: F-1 НЕ вызывает _note_unblock и НЕ шлёт Telegram (ни при тике, + ни на первом проходе свежего Reconciler). + module: tests/test_reconciler.py + expected: PASS + + - id: TC-86-02 + type: unit + description: > + AC-2 — терминал-скип инкрементирует skipped_terminal_total и НЕ вызывает + advance_if_gate_passed для терминальной задачи (advance_stage-спай не дёрнут). + module: tests/test_reconciler.py + expected: PASS + + - id: TC-86-03 + type: unit + description: > + AC-2/R1 — терминал-детект по ГРУППЕ статуса Plane (completed/cancelled) + срабатывает независимо от проекта (enduro и orchestrator): задача в группе + cancelled тоже скипается. + module: tests/test_reconciler.py + expected: PASS + + - id: TC-86-04 + type: unit + description: > + AC-2/R1 — fallback терминал-детекта при пустых groups: терминальность по + логическому ключу done/cancelled проекта. Пустой groups + state_uuid == + states['done'] -> скип. + module: tests/test_reconciler.py + expected: PASS + + - id: TC-86-05 + type: unit + description: > + AC-2 — терминальность по стадии БД оркестратора: задача со stage='cancelled' + (не отсекается get_active_tasks_for_reconcile, которое фильтрует только 'done') + скипается, не доходит до _note_unblock. + module: tests/test_reconciler.py + expected: PASS + + - id: TC-86-06 + type: unit + description: > + AC-3 — F-1 вызывает _note_unblock С непустым state_uuid (3 аргумента) на + легитимном unblock; проверяется, что dedup сохраняет ключ issue_id->state_uuid. + module: tests/test_reconciler.py + expected: PASS + + - id: TC-86-07 + type: unit + description: > + AC-3 — повторный F-1-тик для того же issue+state_uuid подавляется dedup-guard: + deduped_total += 1, второго send_telegram нет. + module: tests/test_reconciler.py + expected: PASS + + - id: TC-86-08 + type: unit + description: > + AC-4 (анти-регресс) — НЕ-терминальная реально застрявшая задача (рабочий + Plane-статус, прошла grace, нет active-job, гейт зелёный) ПО-ПРЕЖНЕМУ + продвигается и шлёт РОВНО один Telegram; unblocked_total += 1. + module: tests/test_reconciler.py + expected: PASS + + - id: TC-86-09 + type: unit + description: > + AC-5 — never-raise: исключение в терминал-детекте / fetch_issue_state не + роняет тик (reconcile_gate_once завершается) и НЕ приводит к ложной отправке + Telegram (консервативно: при неопределённости терминальности не уведомляем). + module: tests/test_reconciler.py + expected: PASS + + - id: TC-86-10 + type: unit + description: > + AC-6 — регресс F-2: существующие TC F-2 (терминал-скип/dedup/счётчики + ORCH-068) остаются зелёными; форма status()/GET-queue не изменилась + (skipped_terminal_total, deduped_total, unblocked_total присутствуют). + module: tests/test_reconciler_plane.py + expected: PASS + + - id: TC-86-11 + type: unit + description: > + AC-6 — reconcile_skip_blocked_enabled=False (escape hatch Guard 2) НЕ + отключает терминал-скип TR-1: терминальная задача всё равно скипается. + module: tests/test_reconciler.py + expected: PASS + + - id: TC-86-12 + type: unit + description: > + Полный прогон регрессии пакета reconciler: pytest tests/test_reconciler.py + tests/test_reconciler_plane.py tests/test_config.py -q зелёный. + module: tests/test_reconciler.py + expected: PASS