diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 0b1d743..02baba8 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -14,7 +14,7 @@ - **Job-reaper** (`src/job_reaper.py`, ORCH-065 — [adr-0011](adr/adr-0011-job-reaper-lease-reclaim.md)) — фоновый daemon-поток (каркас `reconciler`), стартует/останавливается в `main.lifespan` (после `reconciler.start()` / перед `worker.stop()`). Детектирует «мёртвый» `running`-job **без рестарта** процесса (Tier-1 мёртвый `jobs.pid` после `reaper_dead_ticks` тиков; Tier-2 `agent_runs.exit_code` записан, а job ещё `running`; Tier-3 backstop `reaper_max_running_s`) и приводит строку к корректному статусу через те же контракты (`_try_advance_stage`/`_finalize_job`, gate-driven; exit≠0/неизвестно → `attempts @@ -306,4 +314,4 @@ ORCH-065 вводит фоновый watchdog, чтобы смерть проц Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.md). --- -*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест).* +*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест); ORCH-068 (livelock-fix reconciler F-2: терминал-исключение по группе состояния + `_note_unblock` только при подтверждённом state change + дедуп; TTL `_STATES_CACHE`, `docs/work-items/ORCH-068/06-adr/ADR-001`) — design, ветка feature/ORCH-068 (обновлять также при изменении src/reconciler.py F-2, src/plane_sync.py `get_project_states`/`_STATES_CACHE`, флаг `plane_states_ttl_s` в src/config.py).* diff --git a/docs/architecture/adr/adr-0007-reconciler.md b/docs/architecture/adr/adr-0007-reconciler.md index e0dbd38..21e818f 100644 --- a/docs/architecture/adr/adr-0007-reconciler.md +++ b/docs/architecture/adr/adr-0007-reconciler.md @@ -69,6 +69,15 @@ grace + `max_concurrency=1`); never-raise на единицу работы; ти задачи. Инварианты adr-0007 сохранены (схема/реестры не меняются, never-raise, тишина при пропуске). +- **ORCH-068** (`docs/work-items/ORCH-068/06-adr/ADR-001-reconciler-terminal-exclusion-and-cache-ttl.md`): + фикс livelock F-2 (спам `_note_unblock` по синхронизированной done-задаче после + ORCH-066). F-2 исключает терминалы по **группе состояния** (`completed`/`cancelled`, + fallback — ключи `done`/`cancelled`) проектно-независимо; `_note_unblock` — только при + подтверждённом state change (сравнение стадии до/после `_dispatch`) + in-memory дедуп; + `_STATES_CACHE` получает TTL (`ORCH_PLANE_STATES_TTL_S`, дефолт 300с, `0`=lifetime). + Инварианты adr-0007 сохранены (источник истины — Plane; реестры/схема/`handle_*`/F-1/F-3 + не меняются; never-raise; kill-switch'и). + ## Связи adr-0002 (очередь / `available_at`, single-process-singleton), adr-0003 (условный гейт — образец условности/флагов раската), adr-0006 (merge-gate как под-гейт ребра diff --git a/docs/work-items/ORCH-068/06-adr/ADR-001-reconciler-terminal-exclusion-and-cache-ttl.md b/docs/work-items/ORCH-068/06-adr/ADR-001-reconciler-terminal-exclusion-and-cache-ttl.md new file mode 100644 index 0000000..6d51de1 --- /dev/null +++ b/docs/work-items/ORCH-068/06-adr/ADR-001-reconciler-terminal-exclusion-and-cache-ttl.md @@ -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`). diff --git a/docs/work-items/ORCH-068/10-tech-risks.md b/docs/work-items/ORCH-068/10-tech-risks.md new file mode 100644 index 0000000..7a3b68e --- /dev/null +++ b/docs/work-items/ORCH-068/10-tech-risks.md @@ -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 нет.