Merge pull request 'fix(reconciler): terminal-skip + state_uuid dedup on F-1 path (ORCH-086)' (#86) from feature/ORCH-086-orch-86-reconciler-telegram-et into main
Some checks failed
CI / test (push) Has been cancelled
Some checks failed
CI / test (push) Has been cancelled
This commit was merged in pull request #86.
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу.
|
||||
|
||||
## [Unreleased]
|
||||
- **Терминал-скип и `state_uuid`-dedup на пути F-1 реконсилятора** (ORCH-086, `fix`): в Telegram периодически (особенно после рестарта орка) прилетало ложное `🔧 reconciler: ET-002 done разблокирована (потерян webhook)` — задача давно завершена, ничего не разблокируется, это шум. **Корень:** ORCH-068 закрыл livelock только на F-2 (plane-side); путь F-1 (gate-side) остался непокрытым по двум причинам — (A) вызов `_note_unblock(work_item_id, stage)` шёл без `state_uuid`, поэтому in-memory dedup пропускался; (B) единственным «терминал-фильтром» F-1 была выборка `get_active_tasks_for_reconcile` (`WHERE stage != 'done'`), не знающая о статусе issue в Plane — задача с дрейфом «БД орка не-`done`, а Plane уже `Done`» проходила фильтр, no-op условные гейты (enduro) давали зелёный → `advance` → ложное уведомление. **Фикс (ADR-001, локализован в `src/reconciler.py`):** (D1) новый `_resolve_issue_status(task)` делает **один** сетевой резолв Plane-статуса задачи за тик `(states, groups, state_uuid)` после дешёвых локальных гардов (busy/young/escalated в Plane не ходят), never-raise → `({}, {}, None)` при сбое; (D2) безусловный терминал-скип ДО Guard 2 — терминальная задача (группа Plane `completed`/`cancelled`, fallback на логические ключи `done`/`cancelled`, ЛИБО стадия в БД орка ∈ `{done, cancelled}`, т.к. `cancelled` не отсекается выборкой) → ранний `return` + `skipped_terminal_total++`, не подчинён `reconcile_skip_blocked_enabled` (тот гейтит только Guard 2); (D3) `_is_blocked_or_needs_input` переиспользует резолв D1 (3-й/4-й опц. аргументы; при `_UNSET` — самостоятельный резолв для прямых/легаси-вызовов, поведение 1:1); (D4) вызов `_note_unblock` на F-1 теперь передаёт `state_uuid` → dedup работает и на F-1 (повтор того же `issue_id`+`state_uuid` → `deduped_total++`, без второго Telegram). Терминальность — тот же `_is_terminal_state`, что и в F-2 (первичный дискриминатор — группа Plane, устойчив к UUID-алиасингу/мультипроектности; покрывает enduro и orchestrator). Анти-регресс (AC-4): легитимный unblock реально застрявшей не-терминальной задачи по-прежнему `advance` + ровно один Telegram (`unblocked_total++`). `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, сигнатуры `advance_stage`/`advance_if_gate_passed`/`_note_unblock`, форма `status()`/`GET /queue`, новые config-флаги — без изменений; never-raise сохранён. ADR `docs/work-items/ORCH-086/06-adr/ADR-001-reconciler-f1-terminal-skip-and-dedup.md`. Тесты: `tests/test_reconciler.py` (TC-86-01..09/11: терминал по группе completed/cancelled, fallback по логическому ключу, DB-side cancelled, проброс/dedup `state_uuid`, анти-регресс, never-raise, независимость от Guard-2-флага), `tests/test_reconciler_plane.py` (TC-86-10: форма `status()` неизменна). Документация: `docs/architecture/README.md` (раздел Reconciler F-1).
|
||||
- **Подавление Telegram link-preview в карточке трекера / уведомлениях** (ORCH-080): под каждой карточкой трекера (`bump` и `edit`) и под notify/alert-сообщениями Telegram разворачивал баннер «Plane — Modern project management» для кликабельной ссылки `ORCH-NNN` на issue. В дефолтном `bump`-режиме (ORCH-067) карточка пересоздаётся на каждом переходе → баннер дублировался и засорял ленту (жалоба Owner, 08.06). **Корень:** JSON-payload обоих низкоуровневых примитивов `notifications.send_telegram` (`POST /sendMessage`) и `notifications.edit_telegram` (`POST /editMessageText`) не содержал ключ `disable_web_page_preview`. **Фикс (ADR-001, минимальная аддитивная правка на уровне примитива):** добавлен `"disable_web_page_preview": True` в payload обоих методов — гасит баннер у ВСЕХ потребителей (`update_task_tracker` в обоих режимах, `notify_approve_requested`, `notify_error`, alert'ы стадий из `launcher`/`stage_engine`) без изменения их кода. Безусловно, без kill-switch (превью трекера не нужно никому, риск нулевой). `parse_mode: "HTML"` сохранён в обоих payload → ссылка `ORCH-NNN` остаётся кликабельной; `disable_notification` (карточка тихая), bump/edit-логика, инвариант «одна карточка на задачу», контракты возврата (`send_telegram → message_id|None`, `edit_telegram → EDIT_*`) и never-raise — не затронуты. `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД — без изменений. ADR `docs/work-items/ORCH-080/06-adr/ADR-001-disable-telegram-link-preview.md`. Тесты: `tests/test_link_preview_disabled.py` (TC-01..06: флаг в обоих payload, регрессия `parse_mode`/полей, контракты возврата, never-raise). Документация: `CLAUDE.md` + `docs/architecture/README.md` (компонент Notifications).
|
||||
- **Гарантированный идемпотентный код-PR перед merge-verify (фикс ложного HOLD «no open PR»)** (ORCH-082/ORCH-81): закрыт отсутствующий инвариант «к моменту merge-verify у ветки есть открытый код-PR». **Корень (ORCH-074, 08.06):** PR создавался единственной `launcher._ensure_pr` ТОЛЬКО на developer-пути и ТОЛЬКО при свежем worktree-коммите (`exit==0 → git status непуст → commit → push → agent=="developer"`); после ручных восстановлений `main` у ветки ORCH-074 не оказалось открытого код-PR → детерминированный `merge_gate.merge_pr` вернул `("False", "no open PR")` → защита ORCH-073 верно удержала задачу (HOLD, не ложный `done`), но лечила следствие. **Фикс (ADR-001, аддитивно, внутри того же под-гейта merge-verify, машина стадий не тронута):** (1) новый идемпотентный leaf-актор `merge_gate.ensure_open_pr(repo, branch) -> (status, detail)` (never-raise): `GET …/pulls?state=open` с фильтром **`head.ref==branch` И `base.ref=="main"`** (идентичен `merge_pr`/ORCH-073 FR-3 — авто-docs-PR `base!=main` НЕ код-PR) → `("existed", N)`; иначе `POST …/pulls` → `("created", N)`; гонка `409/422` «PR exists» → повторный GET → `existed` (без дублей); любая иная HTTP/parse/сетевая ошибка → `("failed", reason)`. (2) Врезка в `stage_engine._handle_merge_verify` ПОСЛЕ резолва `validated_revision` и ПЕРЕД `merge_pr`: при `merge_verify_autocreate_pr_enabled` → `ensure_open_pr`; `created|existed` → штатно к `merge_pr` → `verify_merged_to_main`; `failed` → честный HOLD через новый helper `_hold_pr_create_failed` (текст «PR создать не удалось», `result.note="pr-create-failed-hold"` — текстуально отличим от not-merged HOLD; задача остаётся на `deploy`, НЕ `done`, БЕЗ отката на development). (3) `launcher._ensure_pr` делегирован в `merge_gate.ensure_open_pr` (единый код создания PR, общий фильтр `head==branch & base==main`); триггер «создавать только на developer-пути со свежим коммитом» НЕ ужесточён — менялась только реализация под капотом. **Защита ORCH-073 неприкосновенна и приоритетна:** подтверждение merge остаётся ТОЛЬКО `verify_merged_to_main` (SHA-в-main) + `check_main_regression`; `ensure_open_pr` устраняет лишь ЛОЖНЫЙ HOLD «no open PR», реально невлитый код → HOLD как прежде. Kill-switch `ORCH_MERGE_VERIFY_AUTOCREATE_PR_ENABLED` (дефолт `true`); область — `merge_verify_applies(repo)` (self-hosting / `merge_verify_repos`), non-self → no-op; `false` → поведение ORCH-074 1:1. Идемпотентность из Gitea (наличие открытого PR), без миграции БД (restart-safe); `main` не push/force-push. Инварианты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS` (под-гейт — врезка в `advance_stage`, не новый QG), схема БД, `check_deploy_status`/`_parse_deploy_status`, exit-коды хука, merge-gate (ORCH-043), image-freshness (ORCH-058), внешние HTTP-эндпоинты. ADR `docs/work-items/ORCH-082/06-adr/ADR-001-ensure-open-pr-before-merge-verify.md` (+ сквозной `adr-0016`). Документация: `docs/architecture/README.md` (блок ORCH-082 в merge-verify). Тесты: `tests/test_orch082_ensure_pr.py` (TC-01..05: идемпотентный актор, фильтр base==main, гонка 409/422, never-raise), `tests/test_orch082_merge_verify_autocreate.py` (TC-06..12: врезка, регресс ORCH-073, kill-switch, условность, наблюдаемость).
|
||||
- **Устойчивость резолва `--effort` к пустому env + developer → `xhigh`** (ORCH-081/ORCH-52h): фикс конфигурационного бага, из-за которого в проде `resolve_agent_effort()` возвращал `''` для всех 6 агентов и `--effort` не передавался в Claude CLI (каждый агент бежал на встроенном CLI-дефолте вместо заявленного уровня — прямой удар по предсказуемости качества всего конвейера, включая enduro-trails из общего инстанса). **Корень:** pydantic Settings трактует ПРИСУТСТВУЮЩУЮ env-переменную, даже пустую (`ORCH_AGENT_EFFORT_*=` без значения), как явное `''` и перебивает class-default; в проде пусты И per-agent, И `agent_effort_default`, поэтому у цепочки резолва (`_resolve_agent_attr`: project-override → per-agent env → default → `''`) не остаётся непустого «пола» для отката. **Фикс (вариант c, ADR-001):** в `resolve_agent_effort` (`src/agents/launcher.py`) добавлен уровень 4 — непустой **per-role floor** ниже `default`: новый чистый helper `_agent_effort_floor(agent)` возвращает декларированный class-default поля `agent_effort_<agent>` через `type(settings).model_fields[...].default` (значение, которое пустой env перебить НЕ может). Floor срабатывает ТОЛЬКО когда уровни 1–3 пусты и применяется ДО валидации, поэтому: (а) при пустом прод-`.env` каждая роль получает СВОЙ канонический уровень (developer=`xhigh`, tester/deployer=`medium`, analyst/architect/reviewer=`high`), а не общий default; (б) явная опечатка (`turbo`/`ultra`) непуста → floor НЕ применяется → значение штатно дропается валидацией `VALID_EFFORTS` в `''` (never-break ORCH-41 не регрессирует, floor не маскирует мусор); (в) непустой явный env/project-override/`default` по-прежнему ПОБЕЖДАЕТ floor (приоритет резолва сохранён 1:1). Unknown-agent (имя вне 6 ролей) деградирует на class-default `agent_effort_default` (`high`) — безопасный непустой пол. **`config.py`:** `agent_effort_developer` `high → xhigh` (канон Opus 4.8: coding/agentic роль) — единственное изменение значений; floor подтягивает его автоматически (единый источник правды, ноль риска дрейфа floor-карты). Инварианты НЕ менялись: приоритеты/сигнатуры резолва ORCH-41, `_resolve_agent_attr` (общий с model-резолвом, не тронут), `resolve_agent_model` (ORCH-074), путь проброса `--effort` в `_spawn`, `VALID_EFFORTS`, API, схема БД (без миграций). ADR `docs/work-items/ORCH-081/06-adr/ADR-001-effort-resolution-floor.md`. Документация: `docs/architecture/README.md` (таблица «модель/эффорт по ролям»: developer `xhigh` + ремарка про floor), `.env.example` (`ORCH_AGENT_EFFORT_DEVELOPER=xhigh` + комментарий split/floor). Тесты: `tests/test_resolve_agent_effort.py` (TC-01..08: канон-дефолты, floor при пустом env per-role, floor-не-маскирует-typo, приоритет, `xhigh∈VALID_EFFORTS`, сборка флага `--effort xhigh`/`--effort medium`).
|
||||
|
||||
@@ -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
|
||||
|
||||
7
docs/work-items/ORCH-086/00-business-request.md
Normal file
7
docs/work-items/ORCH-086/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: ORCH-86: reconciler шлёт в Telegram «ET-002 done разблокирована (потерян webhook)» периодически
|
||||
|
||||
Work Item ID: ORCH-086
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
70
docs/work-items/ORCH-086/01-brd.md
Normal file
70
docs/work-items/ORCH-086/01-brd.md
Normal file
@@ -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 проходят.
|
||||
68
docs/work-items/ORCH-086/02-trz.md
Normal file
68
docs/work-items/ORCH-086/02-trz.md
Normal file
@@ -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` зелёный.
|
||||
32
docs/work-items/ORCH-086/03-acceptance-criteria.md
Normal file
32
docs/work-items/ORCH-086/03-acceptance-criteria.md
Normal file
@@ -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:** любое из перечисленного нарушено.
|
||||
110
docs/work-items/ORCH-086/04-test-plan.yaml
Normal file
110
docs/work-items/ORCH-086/04-test-plan.yaml
Normal file
@@ -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
|
||||
@@ -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 — устойчивый дискриминатор.
|
||||
21
docs/work-items/ORCH-086/10-tech-risks.md
Normal file
21
docs/work-items/ORCH-086/10-tech-risks.md
Normal file
@@ -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.
|
||||
51
docs/work-items/ORCH-086/12-review.md
Normal file
51
docs/work-items/ORCH-086/12-review.md
Normal file
@@ -0,0 +1,51 @@
|
||||
---
|
||||
type: review
|
||||
work_item_id: ORCH-086
|
||||
verdict: APPROVED
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ORCH-086
|
||||
|
||||
## Summary
|
||||
Терминал-скип и `state_uuid`-dedup распространены на путь F-1 реконсилятора, закрывая F-1-пробел
|
||||
ORCH-068 (ложное «ET-002 done разблокирована (потерян webhook)»). Изменение полностью локализовано
|
||||
в `src/reconciler.py` (новый `_resolve_issue_status` + врезка ранних гардов в `_reconcile_gate_task`
|
||||
+ переиспользование резолва в `_is_blocked_or_needs_input` через опц. аргументы с `_UNSET`-sentinel
|
||||
для обратной совместимости). Реализация 1:1 соответствует ТЗ (TR-1/TR-2/TR-3) и ADR-001 (D1–D4).
|
||||
`STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, сигнатуры `advance_stage`/`advance_if_gate_passed`/
|
||||
`_note_unblock`, форма `status()`/`GET /queue`, config-флаги — без изменений. Контракт never-raise
|
||||
сохранён на всех новых путях. Полный прогон `pytest tests/ -q` зелёный — 1069 passed.
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- (нет)
|
||||
|
||||
### P1 — Must fix
|
||||
- (нет)
|
||||
|
||||
### P2 — Should fix
|
||||
- (нет)
|
||||
|
||||
## Документация
|
||||
Обновлена в том же PR, соответствует требованию «golden source наравне с кодом» (CLAUDE.md §2,
|
||||
TRZ §8):
|
||||
- `docs/architecture/README.md` — раздел Reconciler F-1 дополнен блоком ORCH-086 (терминал-скип +
|
||||
dedup на F-1, единый fetch на тик, безусловность относительно `reconcile_skip_blocked_enabled`).
|
||||
- `CHANGELOG.md` — запись `fix:` ORCH-086 с описанием корня (причины A/B) и фикса (D1–D4).
|
||||
- `docs/work-items/ORCH-086/06-adr/ADR-001-reconciler-f1-terminal-skip-and-dedup.md` — присутствует,
|
||||
Accepted, описывает решение, порядок гардов, семантику ошибок и отклонённые альтернативы.
|
||||
- API не менялось → обновление таблицы API не требуется. Per-work-item ADR достаточно (точечный фикс
|
||||
существующего компонента, не сквозное решение — обосновано в §«Область и масштаб»).
|
||||
|
||||
## Контроль качества
|
||||
- Тесты содержательные, не тривиальные: TC-86-01..09/11 (`tests/test_reconciler.py`) покрывают
|
||||
терминал по группе `completed`/`cancelled`, fallback по логическому ключу при пустых `groups`,
|
||||
DB-side `cancelled` без обращения к Plane, проброс/dedup `state_uuid`, анти-регресс легитимного
|
||||
unblock, never-raise без ложного уведомления, независимость терминал-скипа от Guard-2-флага;
|
||||
TC-86-10 (`tests/test_reconciler_plane.py`) — неизменность формы `status()`; TC-86-12 — зелёный
|
||||
регресс-прогон. Сопутствующая правка `tests/test_orch026_task_deps.py` корректно адаптирует мок
|
||||
Guard 2 под новую сигнатуру и держит резолв offline.
|
||||
- `task.get("plane_id") or task.get("plane_issue_id")` в `_resolve_issue_status` — дословный перенос
|
||||
ранее протестированной логики Guard 2 (ORCH-060), регрессии нет.
|
||||
67
docs/work-items/ORCH-086/13-test-report.md
Normal file
67
docs/work-items/ORCH-086/13-test-report.md
Normal file
@@ -0,0 +1,67 @@
|
||||
---
|
||||
type: test-report
|
||||
work_item_id: ORCH-086
|
||||
result: PASS
|
||||
---
|
||||
|
||||
# Test Report — ORCH-086
|
||||
|
||||
Терминал-скип и проброс/dedup `state_uuid` на пути F-1 реконсилятора (закрытие F-1-пробела
|
||||
ORCH-068: ложное «ET-002 done разблокирована (потерян webhook)»).
|
||||
|
||||
## Окружение
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3
|
||||
- Repo / ветка: orchestrator @ `feature/ORCH-086-orch-86-reconciler-telegram-et` (worktree)
|
||||
- Prod health (8500): `{"status":"ok","service":"orchestrator"}` — OK
|
||||
- Дата: 2026-06-09
|
||||
|
||||
## Предусловия
|
||||
- Review-вердикт (`12-review.md`): **APPROVED** (P0/P1/P2 — нет).
|
||||
|
||||
## Результаты
|
||||
|
||||
| TC ID | Описание | Тест | Результат |
|
||||
|-------|----------|------|-----------|
|
||||
| TC-86-01 | AC-1 — терминальная enduro-задача (group=completed), зелёный гейт: нет `_note_unblock`/Telegram | `test_tc86_01_terminal_in_plane_not_unblocked` | PASS |
|
||||
| TC-86-02 | AC-2 — терминал-скип `++skipped_terminal_total`, нет `advance_if_gate_passed` | `test_tc86_02_terminal_skip_counter_no_advance` | PASS |
|
||||
| TC-86-03 | AC-2/R1 — терминал по ГРУППЕ (cancelled), независимо от проекта | `test_tc86_03_terminal_by_group_cancelled` | PASS |
|
||||
| TC-86-04 | AC-2/R1 — fallback по логическому ключу done/cancelled при пустых groups | `test_tc86_04_terminal_fallback_logical_key_empty_groups` | PASS |
|
||||
| TC-86-05 | AC-2 — терминальность по стадии БД (`stage='cancelled'`) | `test_tc86_05_terminal_by_db_stage_cancelled` | PASS |
|
||||
| TC-86-06 | AC-3 — легитимный unblock зовёт `_note_unblock` с непустым `state_uuid` | `test_tc86_06_legit_unblock_passes_state_uuid` | PASS |
|
||||
| TC-86-07 | AC-3 — повторный тик для того же issue+state_uuid подавлен dedup (`++deduped_total`) | `test_tc86_07_repeat_tick_deduped` | PASS |
|
||||
| TC-86-08 | AC-4 (анти-регресс) — реально застрявшая задача продвигается, ровно один Telegram, `++unblocked_total` | `test_tc86_08_legit_unblock_still_notifies` | PASS |
|
||||
| TC-86-09 | AC-5 — never-raise: исключение в детекте не роняет тик и не шлёт ложного Telegram | `test_tc86_09_never_raise_no_false_notify` | PASS |
|
||||
| TC-86-10 | AC-6 — форма `status()`/`GET /queue` неизменна (счётчики на месте) | `test_tc86_10_status_shape_unchanged` (test_reconciler_plane.py) | PASS |
|
||||
| TC-86-11 | AC-6 — `reconcile_skip_blocked_enabled=False` НЕ отключает терминал-скип | `test_tc86_11_terminal_skip_independent_of_guard2_flag` | PASS |
|
||||
| TC-86-12 | Полный регресс пакета reconciler/config зелёный | `pytest tests/test_reconciler.py tests/test_reconciler_plane.py tests/test_config.py` | PASS |
|
||||
|
||||
## Smoke test API (prod 8500)
|
||||
- `GET /health` → `{"status":"ok","service":"orchestrator"}` — OK
|
||||
- `GET /status` → 200, валидный JSON (`active_tasks` присутствует) — OK
|
||||
- `GET /queue` → 200, блок `reconcile` присутствует (`enabled`, `unblocked_total`, `last_unblocked`, `interval`) — OK
|
||||
|
||||
## Вывод pytest
|
||||
|
||||
Полный прогон:
|
||||
```
|
||||
1069 passed, 1 warning in 26.16s
|
||||
```
|
||||
|
||||
Целевой регресс-пакет (TC-86-12):
|
||||
```
|
||||
78 passed, 1 warning in 2.38s
|
||||
```
|
||||
(единственный warning — PydanticDeprecatedSince20 в `src/config.py:5`, не связан с задачей.)
|
||||
|
||||
## Покрытие критериев приёмки
|
||||
- AC-1 — TC-86-01 ✓
|
||||
- AC-2 — TC-86-02/03/04/05 ✓
|
||||
- AC-3 — TC-86-06/07 ✓
|
||||
- AC-4 — TC-86-08 ✓
|
||||
- AC-5 — TC-86-09 + зелёный полный прогон ✓
|
||||
- AC-6 — TC-86-10/11 + контракты (STAGE_TRANSITIONS/QG_CHECKS/схема БД/сигнатуры не тронуты) ✓
|
||||
|
||||
## Итог
|
||||
**PASS** — все 12 тест-кейсов PASS, полный регресс `pytest tests/` зелёный (1069 passed),
|
||||
smoke API OK. Задача готова к переходу на стадию `deploy-staging`.
|
||||
12
docs/work-items/ORCH-086/14-deploy-log.md
Normal file
12
docs/work-items/ORCH-086/14-deploy-log.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
deploy_status: SUCCESS
|
||||
work_item: ORCH-086
|
||||
hook_exit_code: 0
|
||||
deployed_by: deploy-finalizer
|
||||
---
|
||||
|
||||
# Deploy log — ORCH-036 executable self-deploy
|
||||
|
||||
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
|
||||
|
||||
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.
|
||||
@@ -73,6 +73,11 @@ from . import task_deps
|
||||
|
||||
logger = logging.getLogger("orchestrator.reconciler")
|
||||
|
||||
# ORCH-086 (D3): sentinel distinguishing "caller did not pass a pre-resolved
|
||||
# state_uuid" (Guard 2 self-resolves, backward-compatible 1-arg call) from an
|
||||
# explicit ``None`` (Plane unreachable -> conservative skip).
|
||||
_UNSET = object()
|
||||
|
||||
|
||||
def _parse_grace_overrides(raw: str) -> dict[str, int]:
|
||||
"""Parse ``reconcile_grace_overrides_json`` into {stage: seconds}.
|
||||
@@ -183,6 +188,14 @@ class Reconciler:
|
||||
# AC-16: analysis is a human gate -> owned by F-2, never F-1.
|
||||
if stage == "analysis":
|
||||
return
|
||||
# ORCH-086 D2 (DB-side terminal drift): ``get_active_tasks_for_reconcile``
|
||||
# filters ``stage != 'done'`` but NOT ``cancelled``. A task already
|
||||
# terminal in the orchestrator DB is fully in sync by definition -> skip
|
||||
# before any gate/network work, mirroring the F-2 terminal-skip counter
|
||||
# (single semantics with ``_reconcile_plane_issue``). Local, no network.
|
||||
if stage in ("done", "cancelled"):
|
||||
self.skipped_terminal_total += 1
|
||||
return
|
||||
# created / done have no gate to evaluate.
|
||||
if get_qg_for_stage(stage) is None:
|
||||
return
|
||||
@@ -201,9 +214,25 @@ class Reconciler:
|
||||
# Deterministic, local SQL, no network — and checked FIRST (cheapest).
|
||||
if developer_retry_count(task_id) >= MAX_DEVELOPER_RETRIES:
|
||||
return
|
||||
# ORCH-086 D1: single networked resolve per task per tick, AFTER the cheap
|
||||
# local guards (so busy/young/escalated tasks never hit Plane). Feeds the
|
||||
# Plane-side terminal-skip (D2), Guard 2 (D3) and the state_uuid handed to
|
||||
# _note_unblock (D4) — no duplicate fetch.
|
||||
states, groups, state_uuid = self._resolve_issue_status(task)
|
||||
# ORCH-086 D2 (Plane-side terminal-skip), UNCONDITIONAL (not gated by
|
||||
# reconcile_skip_blocked_enabled, which gates ONLY Guard 2). A task whose
|
||||
# Plane status is terminal (group completed/cancelled, or the logical
|
||||
# done/cancelled fallback) is fully in sync -> never a real unblock.
|
||||
# Runs BEFORE Guard 2 so terminal tasks correctly bump skipped_terminal_total
|
||||
# instead of being swallowed by Guard 2's conservative path. Closes the F-1
|
||||
# gap of ORCH-068 (which only covered F-2); fixes the spurious
|
||||
# "ET-002 ... разблокирована" notification.
|
||||
if self._is_terminal_state(state_uuid, states, groups):
|
||||
self.skipped_terminal_total += 1
|
||||
return
|
||||
# ORCH-060 Guard 2: respect an explicit human gate (Blocked / Needs Input).
|
||||
# Networked; runs after Guard 1 so escalated tasks never hit Plane.
|
||||
if self._is_blocked_or_needs_input(task):
|
||||
# Reuses the D1 resolve (ORCH-086 D3) so the tick makes a single fetch.
|
||||
if self._is_blocked_or_needs_input(task, states, state_uuid):
|
||||
return
|
||||
# ORCH-026 Guard 3 (B-5): a task blocked by an unfinished declared
|
||||
# dependency is legitimately waiting, NOT stuck -> F-1 must not advance it
|
||||
@@ -225,9 +254,48 @@ class Reconciler:
|
||||
task.get("branch") or "",
|
||||
)
|
||||
if result is not None and getattr(result, "advanced", False):
|
||||
self._note_unblock(task.get("work_item_id") or str(task_id), stage)
|
||||
# ORCH-086 D4: pass state_uuid so the in-memory dedup guard covers F-1
|
||||
# too (a repeat tick for the same issue+state is suppressed; survives
|
||||
# the "first pass after restart" symptom together with the D2 skip).
|
||||
self._note_unblock(
|
||||
task.get("work_item_id") or str(task_id), stage, state_uuid
|
||||
)
|
||||
|
||||
def _is_blocked_or_needs_input(self, task: dict) -> bool:
|
||||
def _resolve_issue_status(
|
||||
self, task: dict
|
||||
) -> tuple[dict, dict, str | None]:
|
||||
"""ORCH-086 D1: one networked resolve per task per tick.
|
||||
|
||||
Returns ``(states, groups, current_state_uuid)``. A single
|
||||
``fetch_issue_state`` plus the cached (ORCH-068 TTL)
|
||||
``get_project_states`` / ``get_project_state_groups``. The result feeds
|
||||
the terminal-skip (D2), Guard 2 (D3) and the ``state_uuid`` handed to
|
||||
``_note_unblock`` (D4), so the tick never fetches the same issue twice.
|
||||
|
||||
**never-raise.** On any failure / unresolved project / missing state ->
|
||||
``({} or states, {} or groups, None)`` so callers apply their
|
||||
conservative fallback (terminal-skip = not terminal; Guard 2 = skip).
|
||||
"""
|
||||
try:
|
||||
proj = projects.get_project_by_repo(task.get("repo") or "")
|
||||
if proj is None:
|
||||
return {}, {}, None
|
||||
pid = proj.plane_project_id
|
||||
states = get_project_states(pid)
|
||||
groups = get_project_state_groups(pid)
|
||||
issue_id = task.get("plane_id") or task.get("plane_issue_id") or ""
|
||||
state_uuid = fetch_issue_state(issue_id, pid)
|
||||
return states or {}, groups or {}, state_uuid
|
||||
except Exception as e: # noqa: BLE001 - never break the tick
|
||||
logger.warning(
|
||||
f"reconciler D1: status resolve failed for task "
|
||||
f"{task.get('id')}, treating as unresolved: {e}"
|
||||
)
|
||||
return {}, {}, None
|
||||
|
||||
def _is_blocked_or_needs_input(
|
||||
self, task: dict, states: dict | None = None, state_uuid=_UNSET
|
||||
) -> bool:
|
||||
"""Guard 2 (ORCH-060 + ORCH-066): is this issue waiting for a human OR in
|
||||
an active orchestrator wait that F-1 must not "revive"?
|
||||
|
||||
@@ -251,19 +319,22 @@ class Reconciler:
|
||||
human-gated task re-introduces the bounce we are trying to kill. The
|
||||
sub-flag ``reconcile_skip_blocked_enabled`` disables ONLY this networked
|
||||
guard (escape hatch for a Plane outage); Guard 1 stays active.
|
||||
|
||||
**ORCH-086 D3:** the production caller (``_reconcile_gate_task``) passes
|
||||
the already-resolved ``(states, state_uuid)`` from the single D1 fetch, so
|
||||
the tick does not hit Plane twice. When ``state_uuid`` is left ``_UNSET``
|
||||
(direct/legacy 1-arg call) Guard 2 self-resolves via ``_resolve_issue_status``
|
||||
— behaviour identical to the pre-ORCH-086 code.
|
||||
"""
|
||||
if not settings.reconcile_skip_blocked_enabled:
|
||||
return False
|
||||
try:
|
||||
proj = projects.get_project_by_repo(task.get("repo") or "")
|
||||
if proj is None:
|
||||
return True # cannot resolve the project -> conservative skip
|
||||
pid = proj.plane_project_id
|
||||
states = get_project_states(pid)
|
||||
issue_id = task.get("plane_id") or task.get("plane_issue_id") or ""
|
||||
cur = fetch_issue_state(issue_id, pid)
|
||||
if cur is None:
|
||||
return True # Plane unreachable / no state -> conservative skip
|
||||
if state_uuid is _UNSET:
|
||||
# Backward-compatible self-resolve (direct callers / tests).
|
||||
states, _groups, state_uuid = self._resolve_issue_status(task)
|
||||
if not states or state_uuid is None:
|
||||
return True # unresolved project / Plane unreachable -> conservative skip
|
||||
cur = state_uuid
|
||||
# ORCH-066 BR-13: active orchestrator waits, minus base working
|
||||
# statuses so aliased (enduro) keys never widen the skip-set.
|
||||
base_working = {
|
||||
|
||||
@@ -148,8 +148,12 @@ def test_reconciler_skip_helper_honours_block(monkeypatch):
|
||||
monkeypatch.setattr(rec.settings, "reconcile_grace_default_s", 0, raising=False)
|
||||
|
||||
r = rec.Reconciler()
|
||||
# Bypass Guard 2 (networked) so we isolate Guard 3.
|
||||
monkeypatch.setattr(r, "_is_blocked_or_needs_input", lambda task: False)
|
||||
# Bypass Guard 2 (networked) so we isolate Guard 3. ORCH-086: the production
|
||||
# call now passes the resolved (states, state_uuid), so accept extra args.
|
||||
monkeypatch.setattr(r, "_is_blocked_or_needs_input", lambda *a, **k: False)
|
||||
# ORCH-086: the D1 resolve now runs before Guard 2 (for the terminal-skip) —
|
||||
# keep it offline so this Guard-3 test stays deterministic.
|
||||
monkeypatch.setattr(r, "_resolve_issue_status", lambda task: ({}, {}, None))
|
||||
|
||||
task_row = {"id": b, "stage": "development", "repo": "orchestrator",
|
||||
"work_item_id": "ORCH-51", "branch": "feature/ORCH-51", "age_s": 9999}
|
||||
|
||||
@@ -744,3 +744,233 @@ def test_tc21_guard2_aliased_waits_do_not_widen_skipset(monkeypatch):
|
||||
assert _guard2(monkeypatch, aliased, "done-u") is False
|
||||
# The explicit human gates still skip.
|
||||
assert _guard2(monkeypatch, aliased, "blocked-u") is True
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# ORCH-086: terminal-skip + state_uuid dedup on the F-1 (gate-side) path.
|
||||
# Closes the gap of ORCH-068 (which covered only F-2). The spurious
|
||||
# "ET-002 ... разблокирована (потерян webhook)" notification for a task that is
|
||||
# already terminal in Plane (but drifted in the orchestrator DB) is suppressed.
|
||||
# ===========================================================================
|
||||
def _plane_terminal(monkeypatch, *, state_uuid="done-uuid",
|
||||
states=None, groups=None):
|
||||
"""Make Plane report ``state_uuid`` as the issue's current state, with the
|
||||
given {key->uuid} states and {uuid->group} groups maps."""
|
||||
monkeypatch.setattr(reconciler_mod, "fetch_issue_state",
|
||||
MagicMock(return_value=state_uuid))
|
||||
monkeypatch.setattr(reconciler_mod, "get_project_states",
|
||||
MagicMock(return_value=states if states is not None
|
||||
else {"done": "done-uuid"}))
|
||||
monkeypatch.setattr(reconciler_mod, "get_project_state_groups",
|
||||
MagicMock(return_value=groups if groups is not None
|
||||
else {"done-uuid": "completed"}))
|
||||
|
||||
|
||||
# --- TC-86-01 (AC-1) -------------------------------------------------------
|
||||
def test_tc86_01_terminal_in_plane_not_unblocked(monkeypatch):
|
||||
"""enduro task NOT-done in the DB but terminal in Plane (group=completed),
|
||||
green gate: F-1 must NOT call _note_unblock / send_telegram — neither on a
|
||||
normal tick nor on the first pass of a fresh Reconciler (clean dedup)."""
|
||||
_green_ci(monkeypatch)
|
||||
monkeypatch.setattr(reconciler_mod.settings, "reconcile_notify_unblock", True)
|
||||
tg = MagicMock()
|
||||
monkeypatch.setattr(reconciler_mod, "send_telegram", tg)
|
||||
note = MagicMock()
|
||||
monkeypatch.setattr(reconciler_mod.Reconciler, "_note_unblock", note)
|
||||
_plane_terminal(monkeypatch) # Plane says Done (group=completed)
|
||||
|
||||
task_id = _make_task("development", wi="ET-002", age_s=3600)
|
||||
|
||||
# Fresh Reconciler -> empty _unblock_dedup -> the "first pass after restart"
|
||||
# symptom is exercised; the terminal-skip must fire regardless of dedup.
|
||||
rec = Reconciler()
|
||||
rec.reconcile_gate_once()
|
||||
rec.reconcile_gate_once()
|
||||
|
||||
assert _stage_of(task_id) == "development" # never advanced
|
||||
note.assert_not_called()
|
||||
tg.assert_not_called()
|
||||
assert rec.unblocked_total == 0
|
||||
assert rec.skipped_terminal_total >= 1
|
||||
|
||||
|
||||
# --- TC-86-02 (AC-2) -------------------------------------------------------
|
||||
def test_tc86_02_terminal_skip_counter_no_advance(monkeypatch):
|
||||
"""Terminal-skip bumps skipped_terminal_total and never reaches
|
||||
advance_if_gate_passed."""
|
||||
spy = MagicMock()
|
||||
monkeypatch.setattr(reconciler_mod, "advance_if_gate_passed", spy)
|
||||
_plane_terminal(monkeypatch)
|
||||
|
||||
_make_task("development", wi="ET-002", age_s=3600)
|
||||
rec = Reconciler()
|
||||
rec.reconcile_gate_once()
|
||||
|
||||
assert rec.skipped_terminal_total == 1
|
||||
spy.assert_not_called()
|
||||
|
||||
|
||||
# --- TC-86-03 (AC-2 / R1) --------------------------------------------------
|
||||
def test_tc86_03_terminal_by_group_cancelled(monkeypatch):
|
||||
"""Terminal detection by Plane state GROUP works for cancelled too, and is
|
||||
project-independent (group discriminator, not a per-project key)."""
|
||||
spy = MagicMock()
|
||||
monkeypatch.setattr(reconciler_mod, "advance_if_gate_passed", spy)
|
||||
_plane_terminal(
|
||||
monkeypatch, state_uuid="cancel-uuid",
|
||||
states={"done": "done-uuid", "cancelled": "cancel-uuid"},
|
||||
groups={"cancel-uuid": "cancelled"},
|
||||
)
|
||||
|
||||
_make_task("development", wi="ET-002", age_s=3600)
|
||||
rec = Reconciler()
|
||||
rec.reconcile_gate_once()
|
||||
|
||||
assert rec.skipped_terminal_total == 1
|
||||
spy.assert_not_called()
|
||||
|
||||
|
||||
# --- TC-86-04 (AC-2 / R1) --------------------------------------------------
|
||||
def test_tc86_04_terminal_fallback_logical_key_empty_groups(monkeypatch):
|
||||
"""Fallback when groups are unavailable ({}): terminality by the project's
|
||||
logical done/cancelled key."""
|
||||
spy = MagicMock()
|
||||
monkeypatch.setattr(reconciler_mod, "advance_if_gate_passed", spy)
|
||||
_plane_terminal(
|
||||
monkeypatch, state_uuid="done-key-uuid",
|
||||
states={"done": "done-key-uuid", "cancelled": "cancel-key-uuid"},
|
||||
groups={}, # group unknown -> logical-key fallback
|
||||
)
|
||||
|
||||
_make_task("development", wi="ET-002", age_s=3600)
|
||||
rec = Reconciler()
|
||||
rec.reconcile_gate_once()
|
||||
|
||||
assert rec.skipped_terminal_total == 1
|
||||
spy.assert_not_called()
|
||||
|
||||
|
||||
# --- TC-86-05 (AC-2) -------------------------------------------------------
|
||||
def test_tc86_05_terminal_by_db_stage_cancelled(monkeypatch):
|
||||
"""DB-side terminal drift: a task with stage='cancelled' (NOT filtered by
|
||||
get_active_tasks_for_reconcile, which only drops 'done') is skipped locally
|
||||
without reaching _note_unblock / advance — and bumps skipped_terminal_total."""
|
||||
spy = MagicMock()
|
||||
monkeypatch.setattr(reconciler_mod, "advance_if_gate_passed", spy)
|
||||
note = MagicMock()
|
||||
monkeypatch.setattr(reconciler_mod.Reconciler, "_note_unblock", note)
|
||||
# A networked resolve must not even be needed for the DB-side guard.
|
||||
monkeypatch.setattr(
|
||||
reconciler_mod, "fetch_issue_state",
|
||||
MagicMock(side_effect=AssertionError("must not hit Plane for DB-cancelled")),
|
||||
)
|
||||
|
||||
_make_task("cancelled", wi="ET-002", age_s=3600)
|
||||
rec = Reconciler()
|
||||
rec.reconcile_gate_once()
|
||||
|
||||
assert rec.skipped_terminal_total == 1
|
||||
spy.assert_not_called()
|
||||
note.assert_not_called()
|
||||
|
||||
|
||||
# --- TC-86-06 (AC-3) -------------------------------------------------------
|
||||
def test_tc86_06_legit_unblock_passes_state_uuid(monkeypatch):
|
||||
"""A legitimate unblock calls _note_unblock with a non-empty state_uuid; the
|
||||
dedup guard stores issue_id -> state_uuid."""
|
||||
_green_ci(monkeypatch)
|
||||
# Default fixture: fetch_issue_state -> 'some-non-gated-state', groups {} ->
|
||||
# not terminal, not blocked -> the task advances.
|
||||
task_id = _make_task("development", wi="ET-300", age_s=3600)
|
||||
|
||||
rec = Reconciler()
|
||||
rec.reconcile_gate_once()
|
||||
|
||||
assert _stage_of(task_id) == "review"
|
||||
assert rec.unblocked_total == 1
|
||||
assert rec._unblock_dedup.get("ET-300") == "some-non-gated-state"
|
||||
|
||||
|
||||
# --- TC-86-07 (AC-3) -------------------------------------------------------
|
||||
def test_tc86_07_repeat_tick_deduped(monkeypatch):
|
||||
"""A repeat F-1 tick for the same issue+state_uuid is suppressed by the dedup
|
||||
guard: deduped_total += 1 and no second send_telegram."""
|
||||
monkeypatch.setattr(reconciler_mod.settings, "reconcile_notify_unblock", True)
|
||||
tg = MagicMock()
|
||||
monkeypatch.setattr(reconciler_mod, "send_telegram", tg)
|
||||
# advance "succeeds" but leaves the stage put, so each tick reaches
|
||||
# _note_unblock again with the SAME resolved state_uuid.
|
||||
monkeypatch.setattr(
|
||||
reconciler_mod, "advance_if_gate_passed",
|
||||
MagicMock(return_value=MagicMock(advanced=True)),
|
||||
)
|
||||
|
||||
_make_task("development", wi="ET-301", age_s=3600)
|
||||
rec = Reconciler()
|
||||
rec.reconcile_gate_once() # first: notifies
|
||||
rec.reconcile_gate_once() # second: same issue+state -> deduped
|
||||
|
||||
assert tg.call_count == 1
|
||||
assert rec.unblocked_total == 1
|
||||
assert rec.deduped_total == 1
|
||||
|
||||
|
||||
# --- TC-86-08 (AC-4, anti-regress) -----------------------------------------
|
||||
def test_tc86_08_legit_unblock_still_notifies(monkeypatch):
|
||||
"""A NON-terminal genuinely stuck task (working Plane status, past grace, no
|
||||
active job, green gate) is STILL advanced and notifies exactly once."""
|
||||
_green_ci(monkeypatch)
|
||||
monkeypatch.setattr(reconciler_mod.settings, "reconcile_notify_unblock", True)
|
||||
tg = MagicMock()
|
||||
monkeypatch.setattr(reconciler_mod, "send_telegram", tg)
|
||||
|
||||
task_id = _make_task("development", wi="ET-302", age_s=3600)
|
||||
rec = Reconciler()
|
||||
rec.reconcile_gate_once()
|
||||
|
||||
assert _stage_of(task_id) == "review"
|
||||
tg.assert_called_once()
|
||||
assert rec.unblocked_total == 1
|
||||
assert rec.skipped_terminal_total == 0
|
||||
|
||||
|
||||
# --- TC-86-09 (AC-5, never-raise) ------------------------------------------
|
||||
def test_tc86_09_never_raise_no_false_notify(monkeypatch):
|
||||
"""An exception in the terminal-detect / fetch_issue_state path does not blow
|
||||
up the tick AND does not produce a false unblock (conservative)."""
|
||||
_green_ci(monkeypatch)
|
||||
monkeypatch.setattr(reconciler_mod.settings, "reconcile_notify_unblock", True)
|
||||
tg = MagicMock()
|
||||
monkeypatch.setattr(reconciler_mod, "send_telegram", tg)
|
||||
monkeypatch.setattr(
|
||||
reconciler_mod, "fetch_issue_state",
|
||||
MagicMock(side_effect=RuntimeError("plane boom")),
|
||||
)
|
||||
|
||||
task_id = _make_task("development", wi="ET-303", age_s=3600)
|
||||
rec = Reconciler()
|
||||
rec.reconcile_gate_once() # must not raise
|
||||
|
||||
# resolve failed -> state_uuid None -> not terminal, Guard 2 conservative skip.
|
||||
assert _stage_of(task_id) == "development"
|
||||
tg.assert_not_called()
|
||||
assert rec.unblocked_total == 0
|
||||
|
||||
|
||||
# --- TC-86-11 (AC-6) -------------------------------------------------------
|
||||
def test_tc86_11_terminal_skip_independent_of_guard2_flag(monkeypatch):
|
||||
"""reconcile_skip_blocked_enabled=False (Guard 2 escape hatch) does NOT
|
||||
disable the unconditional terminal-skip: a terminal task is still skipped."""
|
||||
monkeypatch.setattr(
|
||||
reconciler_mod.settings, "reconcile_skip_blocked_enabled", False
|
||||
)
|
||||
spy = MagicMock()
|
||||
monkeypatch.setattr(reconciler_mod, "advance_if_gate_passed", spy)
|
||||
_plane_terminal(monkeypatch) # group=completed
|
||||
|
||||
_make_task("development", wi="ET-304", age_s=3600)
|
||||
rec = Reconciler()
|
||||
rec.reconcile_gate_once()
|
||||
|
||||
assert rec.skipped_terminal_total == 1
|
||||
spy.assert_not_called()
|
||||
|
||||
@@ -684,3 +684,18 @@ def test_tc10_done_silent_on_all_projects(monkeypatch):
|
||||
assert recon.unblocked_total == 0
|
||||
assert recon.skipped_terminal_total >= 2 # one per project
|
||||
assert _job_count() == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TC-86-10 (AC-6): the status()/GET-queue observability shape is unchanged by
|
||||
# ORCH-086 — the ORCH-068 counters (skipped_terminal_total / deduped_total /
|
||||
# unblocked_total) are still present, so the F-2 regression contract holds.
|
||||
# ---------------------------------------------------------------------------
|
||||
def test_tc86_10_status_shape_unchanged():
|
||||
snap = Reconciler().status()
|
||||
for key in (
|
||||
"enabled", "plane_enabled", "interval", "last_run_ts",
|
||||
"unblocked_total", "last_unblocked",
|
||||
"skipped_terminal_total", "deduped_total",
|
||||
):
|
||||
assert key in snap, f"status() missing observability key: {key}"
|
||||
|
||||
Reference in New Issue
Block a user