architect(ET): auto-commit from architect run_id=421

This commit is contained in:
2026-06-09 02:08:23 +03:00
committed by stream
parent f2161451a0
commit aa488edddf
3 changed files with 230 additions and 0 deletions

View File

@@ -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

View File

@@ -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:459463`) пропускается → уведомление шлётся на каждом релевантном тике, а
после рестарта `_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:459463`) работает и на 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:162168`) → тик не падает.
## Последствия
### Плюсы
- Устраняется периодический ложный «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 — устойчивый дискриминатор.

View File

@@ -0,0 +1,21 @@
# 10-Tech Risks — ORCH-086
Технические риски выбранного решения (ADR-001). Бизнес-риски R1R5 — в `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.