architect(ET): auto-commit from architect run_id=347
All checks were successful
CI / test (push) Successful in 17s
All checks were successful
CI / test (push) Successful in 17s
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
# ADR-001 (ORCH-068): Исключение терминалов из F-2 по группе состояния + подтверждённый unblock + TTL кэша статусов
|
||||
|
||||
- **Статус:** Accepted
|
||||
- **Дата:** 2026-06-08
|
||||
- **Задача:** ORCH-068 (BUG: reconciler livelock — спам «разблокирована» по синхронизированной done-задаче)
|
||||
- **Сквозной ADR:** уточняет [adr-0007-reconciler.md](../../../architecture/adr/adr-0007-reconciler.md) (F-2) — реестры/схема НЕ меняются
|
||||
- **Связанные:** ORCH-053 (reconciler F-2), ORCH-066 (новая статусная модель Plane — триггер регрессии), ORCH-060 (F-1 пред-гарды), ORCH-10 (`get_project_states`)
|
||||
|
||||
## Контекст
|
||||
|
||||
Reconciler F-2 (`src/reconciler.py`, `_reconcile_plane_project` / `_reconcile_plane_issue`)
|
||||
опрашивает Plane per-project и доигрывает потерянные webhook-переходы через штатные
|
||||
`handle_status_start` / `handle_verdict`. После мерджа ORCH-066 (новая статусная модель
|
||||
Plane) на проде с 22:17 UTC reconciler каждые ~120с слал в Telegram
|
||||
`reconciler: ET-002 done разблокирована (потерян webhook)` для задачи ET-002, которая
|
||||
полностью синхронизирована (БД `stage=done`, Plane `state=Done` с 2026-05-21). 191+
|
||||
сообщений за ночь — livelock без advance/jobs/токенов, но подрывающий доверие alert-fatigue.
|
||||
|
||||
Диагностика (BRD §3) выявила **два независимых, складывающихся дефекта**:
|
||||
|
||||
- **D1 (выборка):** F-2 различает actionable-статусы по **голому UUID**
|
||||
(`in_progress`/`approved`/`rejected`). `get_project_states` строит маппинг по *именам*
|
||||
статусов, недостающие ключи добивает из `_DEFAULT_STATES` (enduro-значения). После
|
||||
ORCH-066 набор имён enduro изменился → терминальный `Done` перестал однозначно
|
||||
отличаться от `approved` по UUID, и ET-002 (Plane=Done) **попала** в actionable-набор
|
||||
ветки `approved`. Терминальные статусы (`Done`/`Cancelled`) нигде не исключаются из F-2.
|
||||
- **D2 (нотификация):** `_note_unblock` вызывается **безусловно сразу после `_dispatch`**,
|
||||
не проверяя, изменил ли обработчик реально состояние задачи. `handle_verdict(approved)`
|
||||
для уже-`done` задачи — no-op, но нотификация всё равно уходит. Это прямо нарушает
|
||||
собственный docstring `_note_unblock` («fires only on an actual state change, never per
|
||||
idle tick») и инвариант silence-when-in-sync (AC-9/AC-10 ORCH-053).
|
||||
|
||||
Связанный secondary-баг (BRD §4): `_STATES_CACHE` (`src/plane_sync.py`) кэширует статусы
|
||||
на **весь lifetime процесса**. После появления нового Plane-статуса боевой процесс держит
|
||||
устаревший набор → webhook на новый статус даёт «no pipeline action», лечилось только
|
||||
рестартом орка. Примитив сброса `reload_project_states()` уже есть, но автоматически не
|
||||
вызывается.
|
||||
|
||||
Ограничения (из ТЗ, обязаны сохраниться): источник истины F-2 — Plane (не переписываем);
|
||||
НЕ трогать `STAGE_TRANSITIONS` / `QG_CHECKS` / схему БД / контракты `handle_*` / F-1 / F-3;
|
||||
never-raise per unit of work; kill-switch'и; 0 jobs/0 токенов для синхронизированных задач;
|
||||
self-hosting — reconciler НИКОГДА не рестартит прод-контейнер.
|
||||
|
||||
## Решение
|
||||
|
||||
Чиним **оба** дефекта независимыми слоями (defense in depth, как принято в проекте —
|
||||
ORCH-058) плюс TTL для кэша. Все правки локальны в `src/reconciler.py` и
|
||||
`src/plane_sync.py`; реестры, схема БД и контракты обработчиков не меняются.
|
||||
|
||||
### Слой D1 — исключение терминалов по ГРУППЕ состояния (TR-1, AC-2)
|
||||
|
||||
Различаем терминальные (`completed`/`cancelled`) и review/work-статусы по **группе
|
||||
состояния Plane** (`state.group ∈ {backlog, unstarted, started, completed, cancelled}`),
|
||||
а НЕ по голому UUID. Группа — авторитетный, проектно-независимый дискриминатор: она
|
||||
корректно различает `Done` (completed) и `approved` (started/review) даже когда проект
|
||||
«схлопывает» их по UUID после переименований.
|
||||
|
||||
Механика (single API fetch, без новых сетевых вызовов):
|
||||
- `/states/`-ответ Plane содержит для каждого статуса поле `group`. Расширяем кэш-запись
|
||||
`_STATES_CACHE` так, чтобы из ОДНОГО запроса хранить и текущий `{logical_key → uuid}`,
|
||||
и `{uuid → group}`. `get_project_states` сохраняет **прежнюю сигнатуру и форму возврата**
|
||||
(`{logical_key: uuid}`) — обратная совместимость (AC-13). Добавляется sibling-аксессор
|
||||
`get_project_state_groups(project_id) -> dict[uuid, group]` (или эквивалент), читающий ту
|
||||
же кэш-запись.
|
||||
- В `_reconcile_plane_issue` ДО выбора ветки: если группа `new_state` ∈
|
||||
{`completed`, `cancelled`} → **тишина** (return, no-op). Fallback, когда группа
|
||||
недоступна (API не отдал `group` / fallback на `_DEFAULT_STATES`): исключать по логическим
|
||||
ключам терминалов `{states.get("done"), states.get("cancelled")}`.
|
||||
|
||||
Терминал-исключение применяется **per-issue** (а не сужением `wanted`-набора
|
||||
`list_issues_by_state`), потому что при UUID-алиасинге терминал может физически совпадать с
|
||||
actionable-UUID в `wanted` — фильтрация по UUID его не отсечёт, а проверка группы отсечёт.
|
||||
|
||||
### Слой D2 — `_note_unblock` только при подтверждённом state change (TR-2, AC-3)
|
||||
|
||||
`_note_unblock` (лог + Telegram + `unblocked_total`) вызывается ТОЛЬКО когда диспетчеризованный
|
||||
обработчик **фактически изменил состояние задачи**. Реализация — сравнение состояния
|
||||
**до/после на стороне reconciler** (предпочтение ТЗ; контракты `handle_*` НЕ меняются):
|
||||
- `approved`/`rejected` (task существует): захватить `stage_before` (из уже прочитанного
|
||||
`task`), после `_dispatch` перечитать `get_task_by_plane_id(issue_id)` → `stage_after`;
|
||||
`_note_unblock` только если `stage_after != stage_before`.
|
||||
- `in_progress` + `task is None` (старт пайплайна): подтверждение = задача **появилась**
|
||||
после dispatch (`get_task_by_plane_id` теперь не None).
|
||||
|
||||
No-op dispatch (задача уже в целевом состоянии) → 0 нотификаций. Восстанавливает соответствие
|
||||
docstring и инвариант silence-when-in-sync.
|
||||
|
||||
### Слой TR-3 — дедуп нотификаций (страховка, AC-4)
|
||||
|
||||
In-memory best-effort guard: `{issue_id → last_unblocked_state_uuid}`. `_note_unblock` для
|
||||
issue+state, уже отмеченного, подавляется. Сбрасывается при рестарте (допустимо — AC-11
|
||||
ORCH-053, как `unblocked_total`/`last_unblocked`). D1+D2 закрывают основной кейс; TR-3 —
|
||||
дополнительная сетка против любого будущего no-op-пути.
|
||||
|
||||
### Слой TR-4 — TTL кэша статусов (secondary, AC-12/AC-13)
|
||||
|
||||
Кэш-запись `_STATES_CACHE` хранит timestamp; `get_project_states` перезапрашивает API при
|
||||
истечении `ORCH_PLANE_STATES_TTL_S`. Примитив инвалидации — существующий
|
||||
`reload_project_states()` (не дублируем логику сброса). Новый флаг
|
||||
`plane_states_ttl_s` (env `ORCH_PLANE_STATES_TTL_S`):
|
||||
- дефолт **300** (5 мин) — устаревший набор самозалечивается без рестарта (G5);
|
||||
- `0` — отключает TTL → строго прежний lifetime-кэш (escape hatch / strict back-compat).
|
||||
|
||||
Fallback на `_DEFAULT_STATES` при недоступности API сохранён без изменений; TTL-перезапрос
|
||||
возвращает тот же корректный набор → не регресс (AC-13).
|
||||
|
||||
## Альтернативы
|
||||
|
||||
- **Явный allowlist логических ключей терминалов (`done`/`cancelled`) без группы** —
|
||||
отклонён как primary: хрупок к будущим переименованиям/добавлению completed-статусов
|
||||
(`Monitoring after Deploy` и т.п.) и к UUID-алиасингу. Оставлен как **fallback**, когда
|
||||
`group` недоступен.
|
||||
- **Сужение `wanted`-набора в `list_issues_by_state`** — недостаточно: при UUID-алиасинге
|
||||
терминал совпадает с actionable-UUID и не отсекается фильтром по UUID. Нужна проверка
|
||||
группы per-issue.
|
||||
- **Проброс «changed»-сигнала из `handle_*`** — отклонён: меняет контракт обработчиков
|
||||
(запрещено ТЗ N2). Выбрано сравнение до/после на стороне reconciler.
|
||||
- **Флаг подавления нотификаций в `advance_stage`** — отклонён (как и в adr-0007):
|
||||
трогает общий критический путь.
|
||||
- **flush-on-unknown как primary для кэша** — допустимо ТЗ и дешевле, но недетерминирован
|
||||
для юнит-теста (TC-11) и не лечит «тихий устаревший набор» без триггера-вебхука. Выбран
|
||||
TTL (детерминированный, самозалечивающий); flush-on-unknown может быть добавлен позже как
|
||||
комплемент, переиспользуя `reload_project_states`.
|
||||
- **Admin-эндпоинт `POST /admin/plane-states/reload`** — отклонён в объёме (требует
|
||||
ручного действия, не лечит автоматически); TTL покрывает G5 без нового API.
|
||||
|
||||
## Последствия
|
||||
|
||||
- **Плюсы:** livelock устранён двумя независимыми слоями; терминал-исключение
|
||||
проектно-независимо (enduro И orchestrator), устойчиво к будущим переименованиям статусов;
|
||||
`_note_unblock` снова соответствует своему контракту; устаревший кэш самозалечивается без
|
||||
рестарта прода. Реестры/схема/контракты/F-1/F-3 не тронуты.
|
||||
- **Минусы / плата:** один доп. accessor группы (тот же API-запрос, без новой сетевой
|
||||
стоимости); TTL добавляет редкий перезапрос `/states/` (раз в 5 мин/проект); дедуп-словарь
|
||||
— небольшая in-memory структура, неперсистентная (приемлемо).
|
||||
- **Совместимость:** `get_project_states` форма возврата неизменна; `plane_states_ttl_s=0`
|
||||
→ строго прежнее поведение кэша; `_DEFAULT_STATES`-fallback сохранён.
|
||||
- **Self-hosting:** ни один путь не рестартит прод-контейнер (AC-11); правка
|
||||
обязательно проходит staging-гейт (8501) перед прод-деплоем орка.
|
||||
- **Наблюдаемость (опц.):** допустимо добавить в блок `reconcile` снимка `GET /queue`
|
||||
счётчики `skipped_terminal` / `deduped` без ломающих изменений.
|
||||
|
||||
## Инварианты (подтверждение)
|
||||
|
||||
INV-1 источник истины F-2 = Plane — сохранён (правим маппинг/нотификацию, не концепцию).
|
||||
INV-2 never-raise per-issue/-project/-tick — сохранён (новый guard в том же try-периметре).
|
||||
INV-3 kill-switch'и `ORCH_RECONCILE_ENABLED` / `ORCH_RECONCILE_PLANE_ENABLED` — без изменений.
|
||||
INV-4 F-1/F-3 — не тронуты. INV-5 0 jobs/0 токенов для done/cancelled — восстановлен.
|
||||
INV-6 легитимная разблокировка реально-потерянного approved/in_progress — работает (D2
|
||||
подтверждает реальный change, не подавляет его). INV-7 self-hosting — тик не рестартит прод.
|
||||
|
||||
## Объём изменений (для разработчика)
|
||||
|
||||
- `src/reconciler.py`: терминал-гард по группе + fallback в `_reconcile_plane_issue`;
|
||||
before/after-сравнение стадии вокруг `_dispatch`; in-memory дедуп-словарь в `Reconciler`.
|
||||
- `src/plane_sync.py`: кэш-запись с timestamp + `{uuid→group}`; `get_project_state_groups`;
|
||||
TTL-логика в `get_project_states` (переиспользуя `reload_project_states`).
|
||||
- `src/config.py`: флаг `plane_states_ttl_s` (env `ORCH_PLANE_STATES_TTL_S`, дефолт 300).
|
||||
- `.env.example` / `.env.staging`: задокументировать `ORCH_PLANE_STATES_TTL_S`.
|
||||
- Доки в ТОМ ЖЕ PR: `docs/architecture/README.md` (Reconciler/Plane Sync), `CHANGELOG.md`
|
||||
(`fix:`), `CLAUDE.md` (при изменении наблюдаемого поведения), этот ADR.
|
||||
- Тесты: `04-test-plan.yaml` (TC-01…TC-13), офлайн (мок Plane/Telegram/`_dispatch`).
|
||||
17
docs/work-items/ORCH-068/10-tech-risks.md
Normal file
17
docs/work-items/ORCH-068/10-tech-risks.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Технические риски — ORCH-068
|
||||
|
||||
| ID | Риск | Вероятность | Влияние | Митигация |
|
||||
|----|------|-------------|---------|-----------|
|
||||
| R-1 | Plane `/states/` не отдаёт поле `group` (старая версия API / урезанный ответ) → терминал-исключение по группе не срабатывает | Низкая | Высокое (рецидив livelock) | Fallback на логические ключи терминалов `{done, cancelled}` при отсутствии `group`; never-raise → консервативная тишина при сбое резолва |
|
||||
| R-2 | Over-exclusion: легитимная задача в started/review-группе ошибочно классифицирована как терминал → пропущена легитимная разблокировка (регресс INV-6) | Низкая | Среднее | Исключаем ТОЛЬКО группы `completed`/`cancelled`; `approved`/`rejected` относятся к started/unstarted → не задеты; регресс-тесты TC-06/TC-07 |
|
||||
| R-3 | Гонка before/after: между `stage_before` и `stage_after` живой webhook двигает стадию → ложный `_note_unblock` | Очень низкая | Низкое | active-job guard + `max_concurrency=1` уже сериализуют; дедуп TR-3 подавляет повтор; ложный unblock безвреден (0 jobs/токенов) |
|
||||
| R-4 | TTL `300s` провоцирует частые `/states/`-перезапросы при многих проектах | Низкая | Низкое | 1 запрос/проект/5 мин — пренебрежимо; `ORCH_PLANE_STATES_TTL_S=0` отключает TTL |
|
||||
| R-5 | TTL-перезапрос в момент недоступности Plane → временный fallback на `_DEFAULT_STATES` (enduro) для не-enduro проекта | Низкая | Среднее | Поведение идентично текущему cold-cache fallback; самозалечивается следующим успешным запросом; не хуже статус-кво |
|
||||
| R-6 | Дедуп-словарь растёт неограниченно (по issue_id) | Очень низкая | Низкое | Ключи — только реально разблокированные issue (редки); сбрасывается при рестарте; при необходимости — ограничить размер/LRU |
|
||||
| R-7 | Изменение в `get_project_states` (кэш-запись) ломает прочих потребителей формы возврата | Низкая | Высокое | Внешняя сигнатура и форма `{logical_key: uuid}` сохранены; группа — отдельный accessor; покрыто TC-12 (совместимость по умолчанию) |
|
||||
| R-8 | Self-hosting: правка в работающем прод-инструменте | — | Высокое | Обязательный staging-гейт (8501); запрет рестарта прод-контейнера в рамках задачи; INV-7 |
|
||||
|
||||
## Замечания
|
||||
- Все правки локальны (`reconciler.py`, `plane_sync.py`, `config.py`); схема БД, реестры
|
||||
`STAGE_TRANSITIONS`/`QG_CHECKS`, контракты `handle_*`, F-1/F-3 — не затронуты (AC-10).
|
||||
- Тесты офлайн (мок Plane API / Telegram / `_dispatch`) — сетевых вызовов в CI нет.
|
||||
Reference in New Issue
Block a user