diff --git a/.env.example b/.env.example index 75e0ed6..40423d9 100644 --- a/.env.example +++ b/.env.example @@ -117,6 +117,15 @@ ORCH_RECONCILE_GRACE_OVERRIDES_JSON= ORCH_RECONCILE_NOTIFY_UNBLOCK=true ORCH_RECONCILE_SKIP_BLOCKED_ENABLED=true +# ORCH-068: TTL (seconds) for the per-project Plane states cache (plane_sync +# _STATES_CACHE). Historically the cache lived for the whole process lifetime, +# so a status added to Plane after start was invisible until a restart +# ("stale set -> no pipeline action"). With a TTL the entry self-heals by +# re-fetching /states/ once it expires (reuses reload_project_states()). +# >0 -> re-fetch after this many seconds (default 300 = 5 min); +# 0 -> disable TTL -> strictly the previous lifetime cache (back-compat). +ORCH_PLANE_STATES_TTL_S=300 + # ORCH-065: job-reaper + proactive merge-lease reclaim. A background daemon thread # (src/job_reaper.py, started LAST in main.lifespan after requeue_running_jobs) reaps # zombie 'running' jobs whose monitor/process died before writing the terminal status diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f21569..8a06421 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ - Цепочка стадий: `... testing → deploy-staging → deploy → done` (была без `deploy-staging`). ### Fixed +- **Reconciler (F-2) больше не зацикливается на спаме «разблокирована» по синхронизированной done-задаче** (ORCH-068): после мерджа новой статусной модели Plane (ORCH-066) sweeper потерянных webhook (ORCH-053) каждые ~120с слал в Telegram `reconciler: ET-002 done разблокирована (потерян webhook)` для полностью синхронизированной задачи (БД `stage=done`, Plane `state=Done`) — livelock без advance/jobs/токенов, но 191+ сообщений за ночь (alert-fatigue, подрыв доверия к нотификациям). Два независимых складывающихся дефекта (defense in depth, ADR-001): **D1 (выборка)** — F-2 различал actionable-статусы по голому UUID, а после ORCH-066 терминальный `Done` перестал однозначно отличаться от `approved` по UUID (UUID-алиасинг) и done-issue попадала в ветку `approved`; терминалы нигде не исключались. Решение: исключение терминалов по **группе состояния Plane** (`state.group ∈ {completed, cancelled}`) — проектно-независимый, устойчивый к переименованиям дискриминатор; проверка per-issue (а не сужением `wanted`-набора, т.к. при алиасинге терминал физически совпадает с actionable-UUID); fallback по логическим ключам `done`/`cancelled`, когда группа недоступна. `get_project_states` расширен записью `{uuid → group}` из ТОГО ЖЕ `/states/`-запроса (без новой сетевой стоимости) + sibling-аксессор `get_project_state_groups`. **D2 (нотификация)** — `_note_unblock` вызывался безусловно сразу после `_dispatch`, не проверяя, изменил ли обработчик реально состояние; `handle_verdict(approved)` для уже-`done` задачи — no-op, но нотификация всё равно уходила (нарушение собственного docstring и инварианта silence-when-in-sync). Решение: сравнение стадии задачи **до/после** `_dispatch` на стороне reconciler (контракты `handle_*` НЕ тронуты) — `_note_unblock` только при подтверждённом state change; для in_progress-старта подтверждение = задача появилась. Плюс **TR-3** — in-memory дедуп-guard `{issue_id → last_unblocked_state}` (страховка против любого будущего no-op-пути). Вторичный баг кэша (**TR-4**): `_STATES_CACHE` жил весь lifetime процесса → новый Plane-статус был невидим без рестарта («stale set → no pipeline action»); добавлен TTL `ORCH_PLANE_STATES_TTL_S` (дефолт 300с; `0` → прежний lifetime-кэш) — запись самозалечивается перезапросом `/states/` (примитив сброса — существующий `reload_project_states()`); при сбое перезапроса отдаётся stale-but-correct набор, а не enduro-дефолты. Форма возврата `get_project_states` неизменна (AC-13). `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, контракты `handle_status_start`/`handle_verdict`, F-1/F-3 — не тронуты; never-raise per-issue/-project сохранён; self-hosting — тик не рестартит прод. Наблюдаемость: счётчики `skipped_terminal_total`/`deduped_total` в блоке `reconcile` снимка `GET /queue`. ADR `docs/work-items/ORCH-068/06-adr/ADR-001-reconciler-terminal-exclusion-and-cache-ttl.md`. Тесты: `tests/test_reconciler_plane.py` (TC-01…TC-10), `tests/test_plane_states_cache.py` (TC-11/TC-12). - **Staging-rebuild больше не падает на `COPY data/` (worktree-контекст)** (ORCH-021): `check_staging_image_fresh` (ORCH-058, Strategy A) пересобирает staging-образ с **worktree задачи** в качестве docker build context (`docker build … "$BUILD_CONTEXT"`). Свежий git-worktree содержит только трекаемые файлы, а `Dockerfile` делал `COPY data/ ./data/` — но `data/` (директория SQLite) **gitignored** и в worktree-контексте отсутствует → `docker build` падал с `exit 1` («BUILD-STAGING: docker build failed - aborting»), задачу заворачивало с `deploy-staging` на `development` (петля, выжигание developer-ретраев, инцидент текущего прогона ORCH-021). При этом COPY был мёртвым грузом: `data/` всегда приходит рантайм-volume'ом (`./data:/app/data` / `./data/staging:/app/data` в `docker-compose.yml`), который затеняет всё, что было запечено в образ. Заменено на `RUN mkdir -p /app/data` (директория-mountpoint существует и без bind-mount, без зависимости от build-контекста). Контракты `STAGE_TRANSITIONS`/`QG_CHECKS`, штамп `LABEL org.opencontainers.image.revision=$GIT_SHA` (ORCH-058 Strategy B), exit-код-контракт хука — не тронуты. Регресс-гард: `tests/test_deploy_hook_provenance.py::test_tc08b_dockerfile_does_not_copy_gitignored_data_dir` (запрещает `COPY` любого gitignored-пути). - **`deploy-staging` больше не зацикливается на infra-only FAIL песочницы (C9a/C9b)** (ORCH-061): self-hosting `orchestrator` крутился в петле `deploy-staging → development` — `scripts/staging_check.py` давал `exit 1` при ЛЮБОМ упавшем чеке, поэтому две чисто инфраструктурные проверки **C9a** (ветка не появилась в `orchestrator-sandbox`) и **C9b** (job аналитика не встал в очередь staging) — вызванные тем, что SANDBOX-бот-аккаунты не состоят в sandbox-проекте Plane (шаги 6+ конвейера в песочнице недостижимы, это НЕ регресс конвейера) — приводили к `staging_status: FAILED` → откат → цикл (выжигание developer-ретраев, токенов, паразитная нагрузка общего инстанса). Решение (Direction «б», ADR-001): чеки классифицируются на `REAL` (все проверки конвейера A*/B*/C7/C8 — fail-closed) и `SANDBOX_INFRA` (строго allowlist `{C9a, C9b}` — waivable). Новый leaf-модуль `src/staging_verdict.py` (stdlib-only, контракт «never raise», по образцу `merge_gate`/`image_freshness`): `classify_check(label)` (allowlist по ведущему токену, всё неизвестное/малформенное → `REAL` fail-closed) и `compute_staging_verdict(items, infra_tolerant) -> StagingVerdict`: любой REAL-FAIL → `FAILED`/exit 1 (страховка при ЛЮБОМ значении флага); упали ТОЛЬКО C9a/C9b и толерантность включена → `SUCCESS`/exit 0 + упавшие метки в `waived` (наблюдаемость); только C9a/C9b и толерантность выключена → `FAILED`/exit 1 (legacy-строгий); любая внутренняя ошибка вердикта → `FAILED`/exit 1 (никогда не ложный green). `scripts/staging_check.py`: `Results` авто-классифицирует каждый чек (публичная 3-tuple форма `_items` сохранена — регрессия-гард ORCH-048 b6), `categorized_items()` отдаёт категорию, `summary()` печатает разбивку REAL/SANDBOX_INFRA; `main()` сворачивает прогон через `_verdict(...)`, печатает строки `INFRA-WAIVED:`/`VERDICT:` и делает `sys.exit(verdict.exit_code)`; новый флаг `--strict` форсит строгий режим для одного запуска. Глобальный kill-switch `ORCH_STAGING_INFRA_TOLERANCE_ENABLED` (`Settings.staging_infra_tolerance_enabled`, default `true`; `false` → строгий 1:1 до ORCH-061), живёт в `.env.staging`; `--strict` имеет приоритет над env. Наблюдаемость на стороне конвейера: `src/agents/launcher.py` получил `action_stage_no_changes_note(stage, repo)` — на action-стадиях (`deploy-staging`/`deploy`) self-hosting-репо «нет изменений для коммита» логируется как ожидаемое, а не трактуется как недопоставка. Контракты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, frontmatter `staging_status: SUCCESS|FAILED` / `deploy_status:` (толерантность применяется в скрипте ДО записи артефакта деплоером), exit-code-контракт хука (0/1/2), `check_staging_status`/`_parse_staging_status`; схема БД — без миграций. ADR `docs/work-items/ORCH-061/06-adr/ADR-001-staging-infra-tolerance.md`. Документация: `docs/architecture/README.md`, `docs/operations/STAGING_CHECK.md`, `.openclaw/agents/deployer.md`. Тесты: `tests/test_staging_check_b6.py`, `tests/test_qg_checks.py`, `tests/test_config.py`, `tests/test_launcher.py`, `tests/test_qg.py`, `tests/test_stage_engine.py::TestStagingInfraTolerance`. - **Reconciler (F-1) больше не разблокирует escalated / Blocked / Needs-Input задачи** (ORCH-060): sweeper потерянных webhook (ORCH-053) не отличал «застряла из-за потерянного события» от «исчерпала лимит developer-ретраев и ждёт человека» — если CI зелёный, а reviewer слал REQUEST_CHANGES до `MAX_DEVELOPER_RETRIES`, каждый тик F-1 видел зелёный `check_ci_green` и доигрывал `development → review` → reviewer снова REQUEST_CHANGES → откат (стадия не меняется, escalated в `gitea.py` лишь шлёт `notify_error`) → следующий тик снова разблокировал. Бесконечная петля (инцидент ET-013: 10 разблокировок за ночь, лишние запуски агентов/токены, спам в Telegram, паразитная нагрузка общего self-hosting-инстанса). В `Reconciler._reconcile_gate_task` (`src/reconciler.py`) ПОСЛЕ существующих гардов (`analysis` carve-out, нет гейта, активный job, grace) и ДО пред-оценки гейта добавлены два пред-гарда с ранним `return` (молчаливый skip — без `advance`, без инкремента `unblocked_total`, без нотификаций): **Guard 1 (escalated, детерминированный, без сети, проверяется первым)** — `developer_retry_count(task_id) >= MAX_DEVELOPER_RETRIES`; приватный `stage_engine._developer_retry_count` повышен до публичного `developer_retry_count` (единый источник истины по подсчёту ретраев `agent_runs`, приватное имя сохранено как алиас), граница берётся из `stage_engine.MAX_DEVELOPER_RETRIES` (не хардкод `3`). **Guard 2 (явный человеческий Plane-статус, Вариант A — без миграции БД)** — новый never-raise хелпер `plane_sync.fetch_issue_state(issue_id, project_id) -> str|None` (тот же endpoint/headers, что `fetch_issue_sequence_id`) + `Reconciler._is_blocked_or_needs_input(task)`: резолв проекта (`projects.get_project_by_repo`) → `get_project_states(pid)` → сверка текущего state issue с `blocked`/`needs_input`; любая ошибка/`None`/нерезолвленный проект → консервативный skip (`True`: не-разблокировать безопаснее). F-2 по существу не менялся: Blocked/Needs Input не входят в опрашиваемый набор `{in_progress, approved, rejected}` → не доигрываются (зафиксировано регресс-тестом). Новый под-флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED` (true) гасит ТОЛЬКО сетевой Guard 2 (escape hatch при Plane-outage); Guard 1 всегда активен. Схема БД, `STAGE_TRANSITIONS`, `QG_CHECKS`, never-raise на единицу работы, `analysis` carve-out и kill-switch'и (`reconcile_enabled`/`reconcile_plane_enabled`) не менялись. ADR `docs/work-items/ORCH-060/06-adr/ADR-001-reconciler-skip-escalated.md`. Тесты: `tests/test_reconciler.py` (TC-01…TC-11 + регресс ORCH-053). diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 60cc0c3..149199a 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 разблокирована (потерян webhook)` + Telegram (`reconcile_notify_unblock`); снимок - состояния в `GET /queue` (блок `reconcile`). + состояния в `GET /queue` (блок `reconcile`). **ORCH-068** добавляет в снимок + счётчики `skipped_terminal_total` (исключённые терминалы) и `deduped_total` + (подавленные повторные нотификации). Реализация: `src/reconciler.py` (daemon-поток по образцу `queue_worker`), стартует в `main.lifespan` **после** `worker.start()`, останавливается в `finally` **перед** @@ -410,3 +420,4 @@ Monitoring after Deploy → Done *Актуально на 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-022 (security-гейт: secret-scanning gitleaks + dependency audit pip-audit как под-гейт ребра `deploy-staging → deploy` ПЕРВЫМ, adr-0012, `docs/work-items/ORCH-022/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-022-security-secret-scanning (leaf src/security_gate.py never-raise + check_security_gate в src/qg/checks.py `QG_CHECKS` + врезка _handle_security_gate в src/stage_engine.py блок `current_stage == "deploy-staging"` ПЕРВОЙ; флаги `security_*` в src/config.py; gitleaks (pinned) в Dockerfile, pip-audit в requirements.txt, `.gitleaks.toml` в корне; артефакт 17-security-report.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`; обновлять также при изменении этих мест); ORCH-059 (выделенный статус-триггер прод-деплоя «Confirm Deploy», ADR `docs/work-items/ORCH-059/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-059 (маппинг `"Confirm Deploy"→"confirm_deploy"` в src/plane_sync.py `_PLANE_NAME_TO_KEY`, НЕ в `_DEFAULT_STATES` = fail-closed; ветка `handle_confirm_deploy` + fail-closed `.get("confirm_deploy")` в src/webhooks/plane.py `handle_issue_updated`; keyword-only `confirm_deploy` в src/stage_engine.py `advance_stage` — Фаза B деплоит ТОЛЬКО при `confirm_deploy=True`, иначе `Approved`-на-`deploy` = no-op; CTA Фазы A просит «Confirm Deploy»; эксплуатация — статус доски «Confirm Deploy» в Plane-проекте ORCH, `docs/work-items/ORCH-059/07-infra-requirements.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`; обновлять также при изменении этих мест); ORCH-066 (осмысленная статусная модель Plane — слой B, `docs/work-items/ORCH-066/06-adr/ADR-001-plane-status-model.md`) — реализовано в ветке feature/ORCH-066-plane (только Plane-индикация: новые ключи `to_analyse`/`analysis`/`code_review`/`awaiting_deploy`/`deploying`/`monitoring` в `_PLANE_NAME_TO_KEY`/`_DEFAULT_STATES` + project-relative `_STATE_ALIAS_FALLBACK` в get_project_states + `_STAGE_TO_STATE_KEY` analysis/review + 5 новых `set_issue_*` в src/plane_sync.py; триггер `in_progress`→`to_analyse` и `set_issue_analysis` в src/webhooks/plane.py; Phase A→Awaiting Deploy / Phase B→Deploying / terminal-sync split monitoring↔done / post-deploy monitor HEALTHY→Done DEGRADED→Blocked в src/stage_engine.py; F-2 триггер `to_analyse` + Guard 2 skip-set с вычитанием base_working в src/reconciler.py; `STAGE_TRANSITIONS`/QG/схема БД НЕ трогаются; без kill-switch — раскат гейтится созданием 6 Plane-статусов оператором, `docs/work-items/ORCH-066/07-infra-requirements.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`; обновлять также при изменении этих мест); ORCH-068 (livelock-fix reconciler F-2: терминал-исключение по группе состояния + `_note_unblock` только при подтверждённом state change + дедуп; TTL `_STATES_CACHE`, `docs/work-items/ORCH-068/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-068 (D1 терминал-гард по группе `_is_terminal_state` + `get_project_state_groups` в src/plane_sync.py; D2 сравнение стадии до/после `_dispatch` + дедуп-словарь в src/reconciler.py; TTL-запись `_STATES_CACHE` + флаг `plane_states_ttl_s` в src/config.py; счётчики `skipped_terminal_total`/`deduped_total` в `/queue`; обновлять также при изменении src/reconciler.py F-2, src/plane_sync.py `get_project_states`/`get_project_state_groups`/`_STATES_CACHE`).* 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/00-business-request.md b/docs/work-items/ORCH-068/00-business-request.md new file mode 100644 index 0000000..4a6434e --- /dev/null +++ b/docs/work-items/ORCH-068/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: BUG: reconciler livelock — спам unblock done-задачи (ET-002) + +Work Item ID: ORCH-068 + +## Description + +TBD diff --git a/docs/work-items/ORCH-068/01-brd.md b/docs/work-items/ORCH-068/01-brd.md new file mode 100644 index 0000000..f56dfb4 --- /dev/null +++ b/docs/work-items/ORCH-068/01-brd.md @@ -0,0 +1,52 @@ +# BRD — ORCH-068: BUG reconciler livelock (спам unblock done-задачи) + +## 1. Контекст и предыстория +Reconciler (`src/reconciler.py`, ORCH-053) — фоновый поток, который доигрывает пропущенные webhook-переходы. Ветвь **F-2 (plane-side)** опрашивает Plane per-project и реплеит In Progress / Approved / Rejected через штатные `handle_status_start` / `handle_verdict`. При фактической разблокировке вызывается `_note_unblock` → лог + Telegram. + +ORCH-066 (мердж `feature/ORCH-066-plane`, прод 2026-06-07 ~22:16 UTC) ввела новую статусную модель Plane (маппинг стадия↔статус, новые имена `Done`, `Monitoring after Deploy` и т.п.). Это спровоцировало регрессию в F-2. + +## 2. Проблема (бизнес-симптом) +С 22:17 UTC (рестарт прод-контейнера после деплоя ORCH-066) reconciler каждые ~120с шлёт в Telegram: +``` +reconciler: ET-002 done разблокирована (потерян webhook) +``` +для задачи **ET-002** (enduro-trails), которая в Done с 2026-05-21. На момент анализа — 191+ сообщений подряд, поток не прекращается (ночной спам, найден Славой 2026-06-08 01:22 UTC). + +**Ключевой факт:** ET-002 полностью синхронизирована — БД `stage=done`, Plane `state=Done`. Reconciler обязан молчать (инвариант «silence when in sync», AC-9/AC-10 из ORCH-053), но шлёт уведомления вхолостую. + +## 3. Диагностика (проведена, root cause найден) +1. **Деньги/токены НЕ тратятся:** `jobs` / `agent_runs` за 4ч пусты; `advance_stage` для done = no-op; `handle_verdict` для done-задачи ничего не меняет. Это «дешёвый», но шумный и подрывающий доверие баг (livelock + ложный alert-fatigue). +2. **Механизм:** + - `_reconcile_plane_project` (`src/reconciler.py` ~241) тянет `list_issues_by_state(pid, [in_progress, approved, rejected])`. + - На enduro-trails статусы «схлопнуты»: после ORCH-066 терминальный `Done` алиасится под UUID `approved` (см. ниже п.4) → ET-002 (Plane=Done) **попадает** в actionable-выборку. + - В `_reconcile_plane_issue` (~295) срабатывает ветка `new_state == approved and task is not None` → `handle_verdict(approved)` (no-op, задача уже done) **+ безусловный `_note_unblock`**. + - `_note_unblock` (~317) вызывается **сразу после `_dispatch`, не проверяя фактическое изменение состояния** — хотя его docstring обещает «fires only on an actual state change, never per idle tick». Инвариант нарушен. +3. **Два независимых дефекта складываются:** + - **D1 (выборка):** терминальные статусы (`Done`/`Cancelled`) не исключены из actionable-набора F-2; на «схлопывающих» проектах Done не отличается от approved по голому UUID. + - **D2 (нотификация):** `_note_unblock` срабатывает безусловно после no-op dispatch, а не только при подтверждённом state change. +4. **Почему `get_project_states` схлопывает:** функция строит маппинг по *именам* статусов из Plane API, затем недостающие ключи добивает из `_DEFAULT_STATES` (enduro-значения). После ORCH-066 набор статусов enduro изменился — голый UUID перестал однозначно различать `Done` (completed-группа) и `approved` (review). Группа состояния (`state.group`) при этом различает их корректно, но в коде не используется. + +## 4. Связанный баг (BUG КЭША СТАТУСОВ, найден 2026-06-07 при деплое ORCH-066) +`_STATES_CACHE` (`src/plane_sync.py` ~134) кэширует статусы Plane на **весь lifetime процесса**. После создания нового Plane-статуса (напр. `Confirm Deploy`) боевой процесс держит устаревший набор → webhook на новый статус даёт «no pipeline action» (Phase B не триггерится). Лечилось только рестартом орка. Примитив сброса уже есть — `reload_project_states()` — но он нигде не вызывается автоматически. + +Оба бага — следствие хрупкости статусной модели после ORCH-066. **Решение:** вести их в одном work item (см. scope ниже), окончательное разделение — на усмотрение архитектора. + +## 5. Цели (Goals) +- G1. Reconciler НЕ шлёт «разблокирована» для синхронизированной done/cancelled задачи (восстановить инвариант silence-when-in-sync). +- G2. `_note_unblock` срабатывает **только** при реальном state change (соблюдён AC-9/AC-10). +- G3. Дедуп: нет повторного спама по той же задаче без изменения её состояния. +- G4. Корректное различение терминальных (`Done`/`Cancelled`) и review-статусов (`approved`/`rejected`) даже на проектах, «схлопывающих» их по UUID — на всех проектах (enduro И orchestrator). +- G5 (secondary). Устаревший `_STATES_CACHE` обновляется без рестарта процесса (TTL / flush-on-unknown / endpoint). + +## 6. Не-цели (Out of scope) +- N1. Менять source-of-truth: ориентир F-2 на Plane **остаётся** корректным по дизайну (таблица `tasks` без status-колонки; статусы двигает человек в Plane). Идею F-2 НЕ переписываем — баг в маппинге/нотификации, не в концепции. +- N2. Менять реестры `STAGE_TRANSITIONS` / `QG_CHECKS`, схему БД, контракты гейтов. +- N3. Менять поведение F-1 (gate-side) и F-3. +- N4. Полный авто-approve деплоя (ORCH-54). + +## 7. Затронутые стороны +- **Все проекты на одном инстансе** (enduro-trails + orchestrator, общая БД/очередь) — баг проявился на ET-002, но фикс выборки терминалов обязан быть проектно-независимым. +- **Self-hosting:** правка идёт в работающий прод-инструмент → обязательна страховка staging (8501), запрет на рестарт прод-контейнера в рамках задачи. + +## 8. Критерий успеха (бизнес) +Тик reconciler для синхронизированной done/cancelled задачи = **0 уведомлений, 0 jobs, 0 токенов**. Telegram-спам прекращён. Легитимная разблокировка (реально потерянный approved/in_progress webhook) по-прежнему работает (нет регресса F-2). diff --git a/docs/work-items/ORCH-068/02-trz.md b/docs/work-items/ORCH-068/02-trz.md new file mode 100644 index 0000000..63d12e8 --- /dev/null +++ b/docs/work-items/ORCH-068/02-trz.md @@ -0,0 +1,68 @@ +# ТЗ — ORCH-068: устранить livelock reconciler F-2 + спам unblock done-задачи + +> Документ описывает ТРЕБОВАНИЯ к изменению поведения (что и где), а не выбор реализации. Конкретный механизм (state.group vs явный allowlist терминалов; TTL vs flush-on-unknown) — решение архитектора в ADR. + +## 1. Затронутые модули `src/` +| Модуль | Роль в баге | Требуемое изменение | +|--------|-------------|---------------------| +| `src/reconciler.py` | F-2 `_reconcile_plane_project` / `_reconcile_plane_issue` / `_note_unblock` | Исключить терминальные статусы из actionable-выборки; `_note_unblock` только при подтверждённом state change; дедуп. | +| `src/plane_sync.py` | `get_project_states`, `list_issues_by_state`, `_STATES_CACHE` | Дать способ различать терминальные/review статусы (группа состояния); устранить вечно-устаревший кэш (TTL/flush). | +| `src/config.py` | флаги | (если нужны) новые kill-switch/настройки TTL — с дефолтами, не меняющими прод-инварианты. | +| `src/main.py` (`/queue`) | наблюдаемость | (опц.) отразить дедуп/skip-терминалов в снимке `reconcile`. | + +**НЕ трогать:** `src/stages.py` (`STAGE_TRANSITIONS`), `src/qg/checks.py` (`QG_CHECKS`), схему БД, контракты `handle_status_start` / `handle_verdict`, F-1 (`reconcile_gate_once`), F-3. + +## 2. Требования к F-2 (src/reconciler.py) + +### TR-1. Исключить терминальные статусы из actionable-выборки +`_reconcile_plane_project` НЕ должен подавать задачи в терминальном Plane-статусе (`Done` и прочие completed-группы, `Cancelled`) ни в `list_issues_by_state`, ни в последующее сравнение веток. +- Требование проектно-независимое: работает на enduro И orchestrator, независимо от того, «схлопывает» ли проект статусы по UUID. +- Различение `Done`/`Cancelled` (completed) от `approved`/`rejected` (review) НЕ должно опираться только на голый UUID, если проект их алиасит. Допустимый ориентир — группа состояния Plane (`state.group`: `completed`/`started`/`unstarted`/`backlog`/`cancelled`) либо явный набор логических ключей терминалов. Выбор — за архитектором. + +### TR-2. `_note_unblock` — только при реальном state change +`_note_unblock` (лог + Telegram + инкремент `unblocked_total`) ВЫЗЫВАЕТСЯ ТОЛЬКО когда диспетчеризованный обработчик фактически изменил состояние задачи (advance / replayed transition, реально сдвинувший стадию). No-op dispatch (задача уже в целевом состоянии) → нотификация НЕ отправляется. +- Сейчас `_dispatch` (`asyncio.run(coro_fn(...))`) отбрасывает результат, а `_note_unblock` зовётся безусловно. Требуется механизм подтверждения изменения (напр. сравнение стадии задачи до/после dispatch через `get_task_by_plane_id`, либо проброс сигнала из обработчика). Конкретику выбирает архитектор; контракт обработчиков `handle_*` менять НЕ обязательно (предпочтительно сравнение состояния до/после на стороне reconciler). +- Восстановить соответствие docstring `_note_unblock`: «Fires only on an actual state change … never per idle tick». + +### TR-3. Дедуп / идемпотентность нотификаций +Reconciler НЕ должен слать повторное уведомление о той же задаче, если её состояние не менялось с прошлого тика. TR-1+TR-2 закрывают основной кейс (done более не входит в выборку и не нотифицируется); TR-3 — дополнительная страховка (best-effort), чтобы любой будущий no-op путь не дал повторного спама. + +## 3. Требования к статус-кэшу (src/plane_sync.py) — secondary + +### TR-4. Устаревший `_STATES_CACHE` обновляется без рестарта +После появления нового Plane-статуса процесс не должен бесконечно держать устаревший набор. Допустимые подходы (выбор архитектора, можно комбинировать): +- TTL на запись кэша (напр. `ORCH_PLANE_STATES_TTL_S`, дефолт разумный, 0/неуст. = прежнее поведение для совместимости); +- flush-on-unknown: при детекте неизвестного статуса в вебхуке/реконсилере — вызвать существующий `reload_project_states(pid)` и перезапросить; +- админ-эндпоинт/сигнал для ручного flush без рестарта. +`reload_project_states()` уже существует — переиспользовать как примитив сброса, новую логику сброса не дублировать. + +## 4. Изменения API +- Новых обязательных endpoint'ов нет. +- Опционально (TR-4, на усмотрение архитектора): admin-эндпоинт сброса кэша статусов (напр. `POST /admin/plane-states/reload`) — если выбран этот вариант flush. Должен быть защищён/идемпотентен; документировать в README таблице API. +- Снимок `GET /queue` (блок `reconcile`) — без ломающих изменений; допустимо добавить поля наблюдаемости (skip-терминалов / dedup-счётчик). + +## 5. Изменения схемы БД +**Нет.** Дедуп TR-3 реализуется in-memory (best-effort, как существующие счётчики `unblocked_total`/`last_unblocked`, AC-11 ORCH-053 допускает их сброс при рестарте) либо через сравнение живого состояния Plane/БД. Миграции не требуются. + +## 6. Требования к новым QG checks +Нет. Реестр `QG_CHECKS` не меняется. + +## 7. Инварианты (обязаны сохраниться) +- INV-1. Source of truth F-2 — Plane (НЕ меняем). +- INV-2. never-raise на единицу работы (per-issue / per-project / per-tick) сохранён. +- INV-3. Kill-switch `ORCH_RECONCILE_ENABLED` (+ `ORCH_RECONCILE_PLANE_ENABLED` гасит только F-2) — работают. +- INV-4. F-1 / F-3 поведение не изменено. +- INV-5. 0 jobs / 0 токенов для синхронизированных задач (как сейчас) сохранено. +- INV-6. Легитимная разблокировка реально-потерянного approved/in_progress webhook продолжает работать (нет регресса F-2). +- INV-7. Self-hosting: тик reconciler НИКОГДА не рестартит/не роняет прод-контейнер. + +## 8. Артефакты pipeline, которые надо обновить в ТОМ ЖЕ PR +- `CLAUDE.md` — если меняется наблюдаемое поведение reconciler/кэша (раздел про reconciler/правила). +- `docs/architecture/README.md` — секция «Reconciler … (ORCH-053)»: уточнить исключение терминалов + дедуп; при TR-4 — секция «Plane Sync». +- `docs/architecture/adr/adr-0007-reconciler.md` (или новый per-WI ADR `docs/work-items/ORCH-068/06-adr/ADR-001-…`) — зафиксировать решение по терминалам/группе состояния и по кэшу. +- `CHANGELOG.md` — запись о фиксе (`fix:`). +- `.env.example` / `.env.staging` — если введён новый флаг (TTL/kill-switch). + +## 9. Замечания по приёмке/тестированию +- Регресс-тест ОБЯЗАН покрывать: задача в `Done` (синхронизирована) → тик F-2 = 0 нотификаций, на enduro И orchestrator проектах (terminal не зависит от алиасинга). +- Тест НЕ должен делать реальных сетевых вызовов — мокать `list_issues_by_state` / `get_project_states` / `send_telegram` / `_dispatch` (handle_*). diff --git a/docs/work-items/ORCH-068/03-acceptance-criteria.md b/docs/work-items/ORCH-068/03-acceptance-criteria.md new file mode 100644 index 0000000..c3259b0 --- /dev/null +++ b/docs/work-items/ORCH-068/03-acceptance-criteria.md @@ -0,0 +1,84 @@ +# Критерии приёмки — ORCH-068 + +Формат: каждый AC имеет чёткое условие PASS/FAIL. Задача принимается только при ВСЕХ PASS. + +## Основное (P0) — livelock / спам + +### AC-1. Синхронизированная done-задача → тишина +**Дано:** задача в Plane `state=Done`, БД `stage=done`, активных job нет. +**Когда:** выполняется один тик F-2 (`reconcile_plane_once` / `_reconcile_plane_project`). +- PASS: `_note_unblock` НЕ вызван; `send_telegram` НЕ вызван; `unblocked_total` не изменился; создано 0 jobs. +- FAIL: любое уведомление/лог «разблокирована»/инкремент счётчика для этой задачи. + +### AC-2. Терминалы исключены из actionable-выборки +**Дано:** проект, где `Done` (и/или `Cancelled`) по UUID совпадает/«схлопнут» с `approved`/`rejected`. +**Когда:** `_reconcile_plane_project` формирует набор и обходит issues. +- PASS: issue в терминальном статусе (completed-группа / `Cancelled`) НЕ попадает ни в одну из веток `in_progress/approved/rejected`; для неё F-2 — no-op silence. +- FAIL: терминальная issue заходит в ветку approved/rejected/in_progress. + +### AC-3. `_note_unblock` только при реальном state change +**Дано:** dispatch обработчика (`handle_verdict`/`handle_status_start`) фактически НЕ изменил стадию задачи (no-op, задача уже в целевом состоянии). +- PASS: `_note_unblock` НЕ вызван. +- FAIL: `_note_unblock` вызван после no-op dispatch. + +### AC-4. Дедуп по неизменному состоянию +**Дано:** две последовательные итерации тика по одной и той же синхронизированной задаче, состояние между тиками не менялось. +- PASS: суммарно 0 повторных уведомлений по этой задаче. +- FAIL: повторное уведомление на втором тике без изменения состояния. + +### AC-5. Нет регресса легитимной разблокировки F-2 +**Дано:** задача, у которой Plane=`Approved`, а локальная стадия НЕ продвинулась (реально потерянный verdict-webhook), grace выдержан, активных job нет. +**Когда:** тик F-2. +- PASS: `handle_verdict(approved)` доигран; задача продвинута; `_note_unblock` вызван РОВНО один раз (реальный state change). +- FAIL: задача не продвинута ИЛИ нотификация не отправлена ИЛИ отправлена многократно. + +### AC-6. Аналогично для in_progress-старта и rejected-отката +- PASS: потерянный `In Progress` (task is None) → старт пайплайна + 1 unblock; потерянный `Rejected` → откат + 1 unblock — оба только при реальном изменении. +- FAIL: ложный/повторный unblock или отсутствие легитимного. + +## Инварианты (P0) + +### AC-7. Деньги/ресурсы не тратятся на синхронизированные задачи +- PASS: 0 jobs, 0 agent_runs, 0 токенов для done/cancelled задач (как до бага). +- FAIL: любой созданный job/agent_run. + +### AC-8. never-raise сохранён +**Дано:** `list_issues_by_state` / `get_project_states` / `_dispatch` / `send_telegram` бросают исключение. +- PASS: тик не падает; ошибка изолирована (per-issue / per-project), логируется; остальные задачи обрабатываются. +- FAIL: непойманное исключение роняет тик/поток. + +### AC-9. Kill-switch'и работают +- PASS: `ORCH_RECONCILE_ENABLED=false` → F-1 и F-2 не выполняются; `ORCH_RECONCILE_PLANE_ENABLED=false` → F-2 не выполняется, F-1 работает. +- FAIL: любой свитч не гасит соответствующую ветку. + +### AC-10. F-1 / F-3 / реестры / схема БД не изменены +- PASS: `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, контракты `handle_*`, поведение F-1/F-3 — без изменений (diff не затрагивает). +- FAIL: любое изменение перечисленного. + +### AC-11. Self-hosting безопасность +- PASS: ни один путь reconciler не рестартит/не роняет прод-контейнер `orchestrator`. +- FAIL: обратное. + +## Secondary (P1) — кэш статусов + +### AC-12. Устаревший `_STATES_CACHE` обновляется без рестарта +**Дано:** после старта процесса в Plane появился новый статус (его UUID отсутствует в кэше). +**Когда:** срабатывает выбранный механизм (TTL истёк / flush-on-unknown / ручной flush). +- PASS: следующий `get_project_states` возвращает свежий набор, включающий новый статус; webhook на новый статус даёт корректное pipeline-действие БЕЗ рестарта. +- FAIL: процесс продолжает отдавать устаревший набор → «no pipeline action». + +### AC-13. Совместимость кэша по умолчанию +- PASS: при дефолтных настройках (TTL не задан / flush не сработал) поведение `get_project_states` не регрессирует; enduro по-прежнему получает свои UUID, fallback на `_DEFAULT_STATES` при недоступности API сохранён. +- FAIL: регресс резолва статусов или потеря fallback. + +## Документация (P0 по правилам проекта) + +### AC-14. Документация обновлена в том же PR +- PASS: обновлены применимые из {`docs/architecture/README.md` (Reconciler/Plane Sync), ADR, `CHANGELOG.md`, `CLAUDE.md`, `.env.example`}; reviewer подтверждает. +- FAIL: поведение изменено, доки/ADR/CHANGELOG не обновлены. + +## Тесты (P0) + +### AC-15. `pytest tests/ -q` зелёный +- PASS: весь набор тестов проходит; добавлены регресс-тесты из `04-test-plan.yaml`, включая done→silence на enduro и orchestrator. +- FAIL: любой красный тест или отсутствие регресс-теста на основной баг. diff --git a/docs/work-items/ORCH-068/04-test-plan.yaml b/docs/work-items/ORCH-068/04-test-plan.yaml new file mode 100644 index 0000000..a76efc1 --- /dev/null +++ b/docs/work-items/ORCH-068/04-test-plan.yaml @@ -0,0 +1,122 @@ +work_item: ORCH-068 +description: > + Регрессионные и модульные тесты на устранение livelock reconciler F-2 + (спам _note_unblock для синхронизированной done-задачи) и связанного бага + кэша статусов. Все тесты офлайн: Plane API / Telegram / dispatch мокаются. + Целевые модули: src/reconciler.py, src/plane_sync.py. + +tests: + # ---------- P0: основной баг (livelock / спам) ---------- + - id: TC-01 + type: unit + description: > + Синхронизированная done-задача (Plane=Done, БД=done, нет активных job): + один тик F-2 -> _note_unblock НЕ вызван, send_telegram НЕ вызван, + unblocked_total не изменился, 0 jobs. (AC-1, AC-7) + module: tests/test_reconciler_plane.py + expected: PASS + + - id: TC-02 + type: unit + description: > + Терминал «схлопнут» с approved по UUID: issue в Done с тем же UUID, что и + approved-набор, НЕ заходит ни в одну ветку in_progress/approved/rejected + (silence). Проверка проектно-независимого исключения терминалов. (AC-2) + module: tests/test_reconciler_plane.py + expected: PASS + + - id: TC-03 + type: unit + description: > + Cancelled терминал также исключён из actionable-выборки -> тик = silence, + 0 нотификаций. (AC-2) + module: tests/test_reconciler_plane.py + expected: PASS + + - id: TC-04 + type: unit + description: > + _note_unblock не вызывается после no-op dispatch: handle_verdict не сдвинул + стадию (задача уже в целевом состоянии) -> 0 нотификаций. (AC-3) + module: tests/test_reconciler_plane.py + expected: PASS + + - id: TC-05 + type: unit + description: > + Дедуп: два последовательных тика по одной синхронизированной задаче без + изменения состояния -> суммарно 0 повторных уведомлений. (AC-4) + module: tests/test_reconciler_plane.py + expected: PASS + + - id: TC-06 + type: unit + description: > + Нет регресса: Plane=Approved, локальная стадия не продвинута, grace выдержан, + нет активных job -> handle_verdict доигран, задача продвинута, _note_unblock + вызван РОВНО один раз. (AC-5) + module: tests/test_reconciler_plane.py + expected: PASS + + - id: TC-07 + type: unit + description: > + Нет регресса для in_progress (task is None -> старт пайплайна, 1 unblock) и + rejected (task существует -> откат, 1 unblock), оба только при реальном + изменении состояния. (AC-6) + module: tests/test_reconciler_plane.py + expected: PASS + + - id: TC-08 + type: unit + description: > + never-raise: list_issues_by_state / get_project_states / _dispatch / + send_telegram бросают исключение -> тик не падает, ошибка изолирована и + залогирована, прочие issues обработаны. (AC-8) + module: tests/test_reconciler_plane.py + expected: PASS + + - id: TC-09 + type: unit + description: > + Kill-switch: reconcile_enabled=False -> F-2 не выполняется; + reconcile_plane_enabled=False -> F-2 не выполняется, F-1 не затронут. (AC-9) + module: tests/test_reconciler_plane.py + expected: PASS + + - id: TC-10 + type: integration + description: > + End-to-end F-2 на двух проектах (enduro И orchestrator): задача в Done на + каждом -> тик reconcile_plane_once = 0 нотификаций / 0 jobs на обоих, + независимо от алиасинга статусов проекта. Главный регресс-тест бага. (AC-1, AC-2) + module: tests/test_reconciler_plane.py + expected: PASS + + # ---------- P1: связанный баг кэша статусов ---------- + - id: TC-11 + type: unit + description: > + Устаревший _STATES_CACHE обновляется без рестарта: после появления нового + статуса срабатывает выбранный механизм (TTL/flush) -> следующий + get_project_states содержит новый статус. (AC-12) + module: tests/test_plane_states_cache.py + expected: PASS + + - id: TC-12 + type: unit + description: > + Совместимость по умолчанию: при дефолтных настройках get_project_states не + регрессирует — enduro отдаёт свои UUID, fallback на _DEFAULT_STATES при + недоступности API сохранён. (AC-13) + module: tests/test_plane_states_cache.py + expected: PASS + + # ---------- P0: общий прогон ---------- + - id: TC-13 + type: integration + description: > + Полный набор pytest tests/ -q зелёный (нет регресса в reconciler/plane/qg/ + stage_engine). (AC-15) + module: tests/ + expected: PASS 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 нет. diff --git a/docs/work-items/ORCH-068/12-review.md b/docs/work-items/ORCH-068/12-review.md new file mode 100644 index 0000000..314ba44 --- /dev/null +++ b/docs/work-items/ORCH-068/12-review.md @@ -0,0 +1,47 @@ +--- +type: review +work_item_id: ORCH-068 +verdict: APPROVED +version: 1 +--- + +# Review ORCH-068 + +## Summary +Фикс livelock reconciler F-2 (спам `_note_unblock` по синхронизированной done-задаче после ORCH-066) реализован чисто и полностью по ТЗ/ADR. Два независимых слоя (D1 терминал-исключение по группе состояния + D2 подтверждённый state change) плюс TR-3 дедуп и TR-4 TTL кэша. Правки строго локальны в `src/reconciler.py` (F-2), `src/plane_sync.py`, `src/config.py`. Запрещённые ТЗ артефакты (`STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, контракты `handle_*`, F-1, F-3) не тронуты — diff не выходит за 3 файла `src/`. `pytest tests/ -q` — **764 passed**. + +## Соответствие ТЗ +- **TR-1** (исключить терминалы) ✅ — `_is_terminal_state` по `state.group ∈ {completed, cancelled}` с fallback на логические ключи `done`/`cancelled`; проверка per-issue (а не сужением `wanted`), что корректно для UUID-алиасинга. +- **TR-2** (`_note_unblock` только при реальном change) ✅ — `_stage_changed` (сравнение стадии до/после `_dispatch`), для in_progress-старта подтверждение = задача появилась; контракты `handle_*` не менялись. +- **TR-3** (дедуп) ✅ — in-memory guard `{issue_id → state_uuid}`, best-effort, сброс при рестарте (как `unblocked_total`). +- **TR-4** (TTL кэша) ✅ — `plane_states_ttl_s` (дефолт 300, `0`=lifetime), переиспользует `reload_project_states`; форма возврата `get_project_states` неизменна; при сбое перезапроса отдаётся stale-but-correct набор. + +## Соответствие ADR +ADR-001 (terminal-exclusion-and-cache-ttl) реализован 1:1: группа как primary-дискриминатор, allowlist-fallback, before/after-сравнение на стороне reconciler, TTL с инвалидацией через существующий примитив. Сквозной adr-0007 дополнен корректной ссылкой. Все инварианты INV-1…INV-7 сохранены (источник истины Plane, never-raise per-issue, kill-switch'и, F-1/F-3 нетронуты, self-hosting не рестартит прод). + +## Критерии приёмки +AC-1…AC-15 — все PASS. Покрытие тестами адресное: TC-01 (synced Done silence), TC-02 (aliased terminal по группе — ядро D1), TC-03 (Cancelled), TC-04 (no-op silence), TC-05 (дедуп), TC-06/TC-07 (легитимный unblock ×1), TC-08 (never-raise изоляция), TC-09 (kill-switch), TC-10 (enduro И orchestrator — headline-регресс), TC-11/TC-12 (TTL self-heal + back-compat + stale-on-failure). + +## Findings + +### P0 — Blocker +- нет + +### P1 — Must fix +- нет + +### P2 — Should fix +- нет + +### P3 — Nice-to-have +- [ ] Дедуп-guard ключуется по `issue_id → state_uuid` без сброса при смене состояния. Теоретический edge-case: задача legitимно проходит `approved`(uuid_X)→…→снова `approved`(тот же uuid_X) — повторное (но легитимное) уведомление будет подавлено. Функционального ущерба нет (advance выполняется в `_dispatch` независимо от нотификации), это потеря только уведомления, и D2 — основной гард. Best-effort по контракту ТЗ. Можно при желании чистить запись при детекте смены состояния away-and-back. Не блокирует. + +## Документация +Обновлена полно и в том же PR (AC-14 PASS): +- `docs/architecture/README.md` — компонент **Plane Sync** (TTL + `{uuid→group}`) и секция **Reconciler/F-2/F-4** (терминал-исключение, дедуп, счётчики `skipped_terminal_total`/`deduped_total`); футер «обновлять при изменении» расширен записью ORCH-068. +- `docs/architecture/adr/adr-0007-reconciler.md` — добавлена кросс-ссылка на per-WI ADR. +- `docs/work-items/ORCH-068/06-adr/ADR-001-…` — детальный ADR (Accepted). +- `CHANGELOG.md` — запись `### Fixed` (D1/D2/TR-3/TR-4, инварианты, тесты). +- `.env.example` — `ORCH_PLANE_STATES_TTL_S=300` с комментарием. + +Изменение `src/` сопровождено соответствующим обновлением документации — требование golden-source выполнено. diff --git a/docs/work-items/ORCH-068/13-test-report.md b/docs/work-items/ORCH-068/13-test-report.md new file mode 100644 index 0000000..a576693 --- /dev/null +++ b/docs/work-items/ORCH-068/13-test-report.md @@ -0,0 +1,64 @@ +--- +type: test-report +work_item_id: ORCH-068 +result: PASS +--- + +# Test Report — ORCH-068 + +Фикс livelock reconciler F-2 (спам `_note_unblock` по синхронизированной +done-задаче) + связанный баг устаревшего `_STATES_CACHE`. + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 (plugins: anyio-4.13.0, asyncio-0.23.8) +- Среда исполнения: worktree `feature/ORCH-068-bug-reconciler-livelock-unbloc` +- Prod health (8500): `{"status":"ok","service":"orchestrator"}` — OK (read-only smoke) +- Дата: 2026-06-08T05:13:59Z + +## Smoke test API (read-only, прод 8500 не трогался деструктивно) +| Endpoint | Результат | +|----------|-----------| +| `GET /health` | `{"status":"ok",...}` — OK | +| `GET /status` | 200, активные задачи отданы — OK | +| `GET /queue` | 200, counts + блок `reconcile` (enabled/plane_enabled/unblocked_total) — OK | + +## Результаты + +| TC ID | Описание | Тест | Результат | +|-------|----------|------|-----------| +| TC-01 | Синхронизированная done → тишина (AC-1, AC-7) | `test_tc01_synced_done_is_silent` | PASS | +| TC-02 | Терминал «схлопнут» с approved по UUID исключён (AC-2) | `test_tc02_terminal_aliased_to_approved_excluded` | PASS | +| TC-03 | Cancelled терминал исключён (AC-2) | `test_tc03_cancelled_excluded` | PASS | +| TC-04 | `_note_unblock` не вызван после no-op dispatch (AC-3) | `test_tc04_noop_dispatch_no_unblock` | PASS | +| TC-05 | Дедуп: 0 повторных уведомлений (AC-4) | `test_tc05_dedup_no_repeat_notification` | PASS | +| TC-06 | Легитимный approved → unblock ровно один раз (AC-5) | `test_tc06_legit_approved_unblock_once` | PASS | +| TC-07 | in_progress-старт и rejected-откат, каждый 1 unblock (AC-6) | `test_tc07_in_progress_start_and_rejected_each_one_unblock` | PASS | +| TC-08 | never-raise: изоляция ошибок (AC-8) | `test_tc08_never_raise_isolation` | PASS | +| TC-09 | Kill-switch'и F-2 (AC-9) | `test_tc09_kill_switches` | PASS | +| TC-10 | done→silence на enduro И orchestrator (headline-регресс, AC-1/AC-2) | `test_tc10_done_silent_on_all_projects` | PASS | +| TC-11 | Устаревший `_STATES_CACHE` self-heal по TTL (AC-12) | `test_tc11_stale_cache_refreshes_after_ttl` + accessor/zero-ttl тесты | PASS | +| TC-12 | Совместимость кэша по умолчанию + fallback (AC-13) | `test_tc12_enduro_uuids_unchanged`, `test_tc12_api_error_falls_back_to_defaults`, `test_tc12_stale_served_when_refresh_fails` | PASS | +| TC-13 | Полный прогон `pytest tests/` зелёный (AC-15) | весь набор | PASS | + +## Соответствие критериям приёмки +AC-1…AC-15 — все PASS. Целевые регресс-тесты (TC-01..TC-12) проходят; +полный набор без регрессий в reconciler / plane / qg / stage_engine / webhooks. + +## Вывод pytest + +### Целевые файлы (tests/test_reconciler_plane.py + tests/test_plane_states_cache.py) +``` +collected 26 items +... (все 26 PASSED, включая tc01..tc17 reconciler + tc11/tc12 cache) +======================== 26 passed, 1 warning in 0.82s ========================= +``` + +### Полный набор +``` +======================= 764 passed, 1 warning in 13.66s ======================== +``` +(1 warning — PydanticDeprecatedSince20 в src/config.py, не относится к ORCH-068, предсуществующий.) + +## Итог +PASS diff --git a/docs/work-items/ORCH-068/14-deploy-log.md b/docs/work-items/ORCH-068/14-deploy-log.md new file mode 100644 index 0000000..6b188bd --- /dev/null +++ b/docs/work-items/ORCH-068/14-deploy-log.md @@ -0,0 +1,12 @@ +--- +deploy_status: SUCCESS +work_item: ORCH-068 +hook_exit_code: 0 +deployed_by: deploy-finalizer +--- + +# Deploy log — ORCH-036 executable self-deploy + +Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`. + +Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM. diff --git a/docs/work-items/ORCH-068/16-post-deploy-log.md b/docs/work-items/ORCH-068/16-post-deploy-log.md new file mode 100644 index 0000000..cec28da --- /dev/null +++ b/docs/work-items/ORCH-068/16-post-deploy-log.md @@ -0,0 +1,14 @@ +--- +post_deploy_status: HEALTHY +action_taken: NONE +work_item: ORCH-068 +window_s: 900 +checks_total: 30 +checks_failed: 0 +--- + +# Post-deploy log — ORCH-021 post-deploy monitor + +Наблюдение прода завершено: `post_deploy_status: HEALTHY`, `action_taken: NONE`. + +Окно наблюдения: 900s; опросов всего: 30, из них с провалом: 0. diff --git a/src/config.py b/src/config.py index bba6bc3..39f3d31 100644 --- a/src/config.py +++ b/src/config.py @@ -295,6 +295,18 @@ class Settings(BaseSettings): reconcile_notify_unblock: bool = True reconcile_skip_blocked_enabled: bool = True + # ORCH-068: TTL for the per-project Plane states cache (_STATES_CACHE in + # plane_sync). Historically the cache lived for the whole process lifetime, + # so a status added to Plane after start was never seen without a restart + # ("stale set -> no pipeline action"). With a TTL the entry self-heals by + # re-fetching /states/ after it expires (invalidation reuses the existing + # reload_project_states() primitive — no duplicated reset logic). + # plane_states_ttl_s (env ORCH_PLANE_STATES_TTL_S): + # >0 -> seconds before a cache entry is re-fetched (default 300 = 5 min); + # 0 -> disable TTL -> strictly the previous lifetime cache (back-compat + # escape hatch). get_project_states return shape is unchanged. + plane_states_ttl_s: int = 300 + # ORCH-021: post-deploy production monitoring + degradation reaction. After # the terminal deploy->done transition for an applicable repo, a reserved-agent # `post-deploy-monitor` job (no LLM, modelled on deploy-finalizer) probes prod diff --git a/src/plane_sync.py b/src/plane_sync.py index 20e76a9..399a9c7 100644 --- a/src/plane_sync.py +++ b/src/plane_sync.py @@ -1,6 +1,7 @@ """Plane API sync — update issue state and add comments.""" import logging +import time import httpx from .config import settings @@ -172,18 +173,42 @@ _STATE_ALIAS_FALLBACK: dict[str, str] = { "monitoring": "done", } -# Per-project state cache: {project_id: {logical_key: state_uuid}} -_STATES_CACHE: dict[str, dict[str, str]] = {} +# Per-project state cache (ORCH-10 + ORCH-068). +# +# Each entry is a RECORD, not a bare mapping: +# {"states": {logical_key: state_uuid}, # the ORCH-10 mapping (unchanged shape) +# "groups": {state_uuid: group}, # ORCH-068 D1: {uuid -> Plane state.group} +# "ts": monotonic timestamp} # ORCH-068 TR-4: for TTL self-heal +# get_project_states() still RETURNS the bare {logical_key: state_uuid} mapping +# (backward compatible — AC-13); the richer record is internal. +_STATES_CACHE: dict[str, dict] = {} + + +def _cache_record_fresh(record: dict) -> bool: + """ORCH-068 (TR-4): is a cache record still within its TTL? + + ``plane_states_ttl_s <= 0`` disables the TTL -> a record never expires + (strictly the previous lifetime-cache behaviour, back-compat escape hatch). + """ + ttl = settings.plane_states_ttl_s + if ttl <= 0: + return True + ts = record.get("ts", 0.0) + return (time.monotonic() - ts) <= ttl def get_project_states(project_id: str) -> dict[str, str]: """ORCH-10: resolve {logical_key -> state_uuid} for a specific Plane project. Source of truth: Plane API GET /projects//states/. - Results are cached per project_id for the lifetime of the process. + Results are cached per project_id. ORCH-068 (TR-4): a cached entry is + re-fetched once it is older than ``plane_states_ttl_s`` (default 300s) so a + status added to Plane after start self-heals without a process restart; + ``plane_states_ttl_s = 0`` keeps the previous lifetime cache. + Falls back to _DEFAULT_STATES (enduro-trails values) if: * project_id is empty/None, - * the API call fails (network error, non-2xx), + * the API call fails (network error, non-2xx) AND nothing is cached, * the response contains no recognisable states. The enduro-trails project therefore returns the same UUIDs as before @@ -193,8 +218,9 @@ def get_project_states(project_id: str) -> dict[str, str]: if not project_id: return _DEFAULT_STATES - if project_id in _STATES_CACHE: - return _STATES_CACHE[project_id] + cached = _STATES_CACHE.get(project_id) + if cached is not None and _cache_record_fresh(cached): + return cached["states"] url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/states/" try: @@ -207,12 +233,21 @@ def get_project_states(project_id: str) -> dict[str, str]: raise ValueError(f"unexpected states response shape: {type(items)}") resolved: dict[str, str] = {} + groups: dict[str, str] = {} for item in items: name = item.get("name", "") uid = item.get("id", "") key = _PLANE_NAME_TO_KEY.get(name) if key and uid: resolved[key] = uid + # ORCH-068 D1: capture {uuid -> group} for terminal-state detection + # (a single API fetch — no extra network cost). The group is the + # authoritative, project-independent discriminator of terminal + # (completed/cancelled) vs review/work statuses, robust to UUID + # aliasing after status renames (ORCH-066). + grp = item.get("group", "") + if uid and grp: + groups[uid] = grp if not resolved: raise ValueError("no recognisable states in API response") @@ -232,13 +267,26 @@ def get_project_states(project_id: str) -> dict[str, str]: for k, v in _DEFAULT_STATES.items(): resolved.setdefault(k, v) - _STATES_CACHE[project_id] = resolved + _STATES_CACHE[project_id] = { + "states": resolved, + "groups": groups, + "ts": time.monotonic(), + } logger.debug( - f"get_project_states: cached {len(resolved)} states for project {project_id[:8]}..." + f"get_project_states: cached {len(resolved)} states / " + f"{len(groups)} groups for project {project_id[:8]}..." ) return resolved except Exception as e: + # On a transient API failure keep serving the stale (but project-correct) + # set if we have one — far safer than reverting to enduro defaults. + if cached is not None: + logger.warning( + f"get_project_states: API refresh failed for project " + f"{project_id[:8]}..., serving stale cached set. Error: {e}" + ) + return cached["states"] logger.warning( f"get_project_states: API failed for project {project_id[:8]}..., " f"falling back to _DEFAULT_STATES. Error: {e}" @@ -246,6 +294,23 @@ def get_project_states(project_id: str) -> dict[str, str]: return _DEFAULT_STATES +def get_project_state_groups(project_id: str) -> dict[str, str]: + """ORCH-068 (D1): return {state_uuid -> group} for a Plane project. + + Reads the SAME cache record populated by ``get_project_states`` (no extra + network call). Call ``get_project_states(project_id)`` first to ensure the + record is fresh/populated. Returns ``{}`` when nothing is cached (e.g. the + API was unreachable and the caller fell back to ``_DEFAULT_STATES``); the + reconciler then falls back to logical terminal keys. + """ + record = _STATES_CACHE.get(project_id) + if isinstance(record, dict): + groups = record.get("groups") + if isinstance(groups, dict): + return groups + return {} + + def reload_project_states(project_id: str = None) -> None: """ORCH-10: clear the per-project states cache. diff --git a/src/reconciler.py b/src/reconciler.py index 4225703..d25b4a3 100644 --- a/src/reconciler.py +++ b/src/reconciler.py @@ -60,7 +60,12 @@ from .stage_engine import ( MAX_DEVELOPER_RETRIES, ) from .stages import get_qg_for_stage -from .plane_sync import fetch_issue_state, get_project_states, list_issues_by_state +from .plane_sync import ( + fetch_issue_state, + get_project_states, + get_project_state_groups, + list_issues_by_state, +) from .webhooks.plane import handle_status_start, handle_verdict from .notifications import send_telegram from . import projects @@ -139,6 +144,13 @@ class Reconciler: self.last_run_ts: float | None = None self.unblocked_total: int = 0 self.last_unblocked: str | None = None + # ORCH-068 observability: terminal-state skips and dedup suppressions. + self.skipped_terminal_total: int = 0 + self.deduped_total: int = 0 + # ORCH-068 (TR-3): in-memory dedup guard {issue_id -> last unblocked + # state uuid}. Best-effort (resets on restart, like unblocked_total); + # suppresses a repeat unblock notification for the same issue+state. + self._unblock_dedup: dict[str, str] = {} # -- F-1: gate-side ---------------------------------------------------- def reconcile_gate_once(self) -> None: @@ -271,23 +283,47 @@ class Reconciler: # the project's own `in_progress` UUID, so enduro behaviour is identical # (and `list_issues_by_state` deduplicates the uuid via its internal set). states = get_project_states(pid) + # ORCH-066 (AC-19): start/resume trigger is `To Analyse`. to_analyse = states["to_analyse"] + # ORCH-068 D1: {uuid -> group} from the SAME cache record (no extra + # fetch); empty when the API was unreachable -> per-issue fallback by key. + groups = get_project_state_groups(pid) approved = states["approved"] rejected = states["rejected"] issues = list_issues_by_state(pid, [to_analyse, approved, rejected]) for issue in issues: try: self._reconcile_plane_issue( - issue, pid, to_analyse, approved, rejected + issue, pid, to_analyse, approved, rejected, states, groups ) except Exception as e: # noqa: BLE001 - isolate one issue's failure logger.error( f"reconciler F-2: issue {issue.get('id')} failed: {e}" ) + def _is_terminal_state( + self, state_uuid: str, states: dict, groups: dict + ) -> bool: + """ORCH-068 D1: is ``state_uuid`` a terminal (completed/cancelled) state? + + Primary discriminator is the Plane **state group** (project-independent, + robust to UUID aliasing after status renames): ``group`` in + ``{completed, cancelled}`` -> terminal. When the group is unavailable + (API gave no ``group`` / we fell back to ``_DEFAULT_STATES``), fall back + to the logical terminal keys ``done`` / ``cancelled``. + """ + if not state_uuid: + return False + grp = groups.get(state_uuid) + if grp: + return grp in {"completed", "cancelled"} + # Fallback (group unknown): logical terminal keys for this project. + return state_uuid in {states.get("done"), states.get("cancelled")} + def _reconcile_plane_issue( self, issue: dict, project_id: str, to_analyse: str, approved: str, rejected: str, + states: dict, groups: dict, ) -> None: issue_id = str(issue.get("id") or "") if not issue_id: @@ -295,6 +331,15 @@ class Reconciler: state = issue.get("state") new_state = state.get("id") if isinstance(state, dict) else state + # ORCH-068 D1: a terminal issue (Done / Cancelled) is fully in sync by + # definition -> never actionable. Excluded per-issue (not by narrowing + # `wanted`) because UUID aliasing can make a terminal uuid collide with + # an actionable one — only the state GROUP disentangles them. Restores + # the silence-when-in-sync invariant (AC-1/AC-2). + if self._is_terminal_state(new_state, states, groups): + self.skipped_terminal_total += 1 + return + # Grace ("lost, not merely delayed"): use the issue's own updated_at age. # A missing/unparseable timestamp is treated as old enough (the active-job # guard + atomic create-claim still prevent doubling). @@ -319,24 +364,48 @@ class Reconciler: if new_state == to_analyse and task is None: # To Analyse without a task -> start the pipeline (lost start webhook). + # ORCH-068 D2: confirm a REAL change (the task now exists) before + # announcing — a no-op dispatch stays silent. self._dispatch(handle_status_start, issue_data, project_id) - self._note_unblock(issue_id, "analysis") + if get_task_by_plane_id(issue_id) is not None: + self._note_unblock(issue_id, "analysis", new_state) elif new_state == to_analyse and task is not None: # To Analyse with an existing (idle) task -> resume the analyst from # Needs Input (lost resume webhook). handle_status_start applies its # own busy-guard / start-vs-resume fork. self._dispatch(handle_status_start, issue_data, project_id) - self._note_unblock(task.get("work_item_id") or issue_id, task["stage"]) + self._note_unblock(task.get("work_item_id") or issue_id, task["stage"], new_state) + elif new_state == approved and task is not None: # Approved but the stage never advanced -> replay the verdict. + stage_before = task["stage"] self._dispatch(handle_verdict, issue_data, project_id, approved=True) - self._note_unblock(task.get("work_item_id") or issue_id, task["stage"]) + if self._stage_changed(issue_id, stage_before): + self._note_unblock( + task.get("work_item_id") or issue_id, stage_before, new_state + ) elif new_state == rejected and task is not None: # Rejected but never rolled back -> replay the verdict. + stage_before = task["stage"] self._dispatch(handle_verdict, issue_data, project_id, approved=False) - self._note_unblock(task.get("work_item_id") or issue_id, task["stage"]) + if self._stage_changed(issue_id, stage_before): + self._note_unblock( + task.get("work_item_id") or issue_id, stage_before, new_state + ) # else: everything is in sync -> silence (AC-10). + @staticmethod + def _stage_changed(issue_id: str, stage_before: str) -> bool: + """ORCH-068 D2: did the dispatched handler actually move the stage? + + Re-reads the task after ``_dispatch`` and compares to the captured + ``stage_before``. A no-op replay (the task was already in the target + state) leaves the stage unchanged -> no unblock notification. + """ + after = get_task_by_plane_id(issue_id) + stage_after = after["stage"] if after else stage_before + return stage_after != stage_before + @staticmethod def _dispatch(coro_fn, *args, **kwargs) -> None: """Run an async plane handler from this sync thread. @@ -349,12 +418,27 @@ class Reconciler: asyncio.run(coro_fn(*args, **kwargs)) # -- observability (F-4) ---------------------------------------------- - def _note_unblock(self, work_item_id: str, stage: str) -> None: + def _note_unblock( + self, work_item_id: str, stage: str, state_uuid: str | None = None + ) -> None: """Record + announce that a stuck task was unblocked (AC-12). Fires only on an actual state change (an advance / replayed transition), never per idle tick, so it does not conflict with AC-9 / AC-10. + + ORCH-068 (TR-3): an in-memory dedup guard keyed by ``issue_id -> + state_uuid`` suppresses a repeat notification for the same issue+state + if a future no-op path ever reaches here. ``state_uuid`` is the issue's + Plane state; ``work_item_id`` doubles as the issue id for the + pipeline-start case (which has no work item yet). """ + dedup_key = work_item_id + if state_uuid is not None and self._unblock_dedup.get(dedup_key) == state_uuid: + self.deduped_total += 1 + return + if state_uuid is not None: + self._unblock_dedup[dedup_key] = state_uuid + self.unblocked_total += 1 self.last_unblocked = work_item_id logger.info( @@ -415,6 +499,9 @@ class Reconciler: "last_run_ts": self.last_run_ts, "unblocked_total": self.unblocked_total, "last_unblocked": self.last_unblocked, + # ORCH-068 observability. + "skipped_terminal_total": self.skipped_terminal_total, + "deduped_total": self.deduped_total, } diff --git a/tests/test_plane_states_cache.py b/tests/test_plane_states_cache.py new file mode 100644 index 0000000..0b952f3 --- /dev/null +++ b/tests/test_plane_states_cache.py @@ -0,0 +1,180 @@ +"""ORCH-068 (TR-4): tests for the Plane states cache TTL self-heal. + +The per-project ``_STATES_CACHE`` used to live for the whole process lifetime, +so a status added to Plane after start was never seen without a restart +("stale set -> no pipeline action"). ORCH-068 adds a TTL: an entry is +re-fetched once it is older than ``plane_states_ttl_s`` (default 300s); ``0`` +disables the TTL (strictly the previous lifetime cache). + +All tests are offline: the Plane API (httpx) and the monotonic clock are mocked. +""" + +import os +import tempfile +from unittest.mock import MagicMock, patch + +import pytest + +os.environ.setdefault("ORCH_PLANE_API_URL", "http://plane.local") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_WORKSPACE_SLUG", "test-ws") +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") + +_test_db = os.path.join(tempfile.gettempdir(), "test_plane_states_cache.db") +os.environ["ORCH_DB_PATH"] = _test_db + +import src.plane_sync as ps # noqa: E402 + +_PROJECT = "proj-ttl" +_ET_PROJECT = "7a79f0a9-5278-49cd-9007-9a338f238f9c" + + +def _resp(data: dict, status: int = 200): + m = MagicMock() + m.status_code = status + m.json.return_value = data + if status >= 400: + from httpx import HTTPStatusError + m.raise_for_status.side_effect = HTTPStatusError( + "error", request=MagicMock(), response=MagicMock() + ) + else: + m.raise_for_status.return_value = None + return m + + +def _states_response(in_progress_uuid: str) -> dict: + """A minimal /states/ payload; In Progress carries the given UUID.""" + return { + "results": [ + {"id": in_progress_uuid, "name": "In Progress", "group": "started"}, + {"id": "uuid-done", "name": "Done", "group": "completed"}, + ] + } + + +@pytest.fixture(autouse=True) +def reset_cache(): + ps.reload_project_states() + yield + ps.reload_project_states() + + +# --------------------------------------------------------------------------- +# TC-11 (AC-12): a stale cache entry self-heals after the TTL — no restart. +# --------------------------------------------------------------------------- +def test_tc11_stale_cache_refreshes_after_ttl(monkeypatch): + monkeypatch.setattr(ps.settings, "plane_states_ttl_s", 300) + clock = {"t": 1000.0} + monkeypatch.setattr(ps.time, "monotonic", lambda: clock["t"]) + + responses = iter([ + _resp(_states_response("uuid-A")), # first fetch: old set + _resp(_states_response("uuid-B")), # second fetch: new status appeared + ]) + mock_get = MagicMock(side_effect=lambda *a, **k: next(responses)) + monkeypatch.setattr(ps.httpx, "get", mock_get) + + # t=1000: first call -> fetch set A. + s1 = ps.get_project_states(_PROJECT) + assert s1["in_progress"] == "uuid-A" + assert mock_get.call_count == 1 + + # t=1100: within TTL -> served from cache, no new fetch. + clock["t"] = 1100.0 + s2 = ps.get_project_states(_PROJECT) + assert s2["in_progress"] == "uuid-A" + assert mock_get.call_count == 1 + + # t=1400: TTL (300s) elapsed -> re-fetch -> fresh set B (self-heal). + clock["t"] = 1400.0 + s3 = ps.get_project_states(_PROJECT) + assert s3["in_progress"] == "uuid-B" + assert mock_get.call_count == 2 + + +def test_tc11_ttl_zero_keeps_lifetime_cache(monkeypatch): + """plane_states_ttl_s=0 -> strictly the previous lifetime cache (back-compat).""" + monkeypatch.setattr(ps.settings, "plane_states_ttl_s", 0) + clock = {"t": 1000.0} + monkeypatch.setattr(ps.time, "monotonic", lambda: clock["t"]) + + responses = iter([ + _resp(_states_response("uuid-A")), + _resp(_states_response("uuid-B")), + ]) + mock_get = MagicMock(side_effect=lambda *a, **k: next(responses)) + monkeypatch.setattr(ps.httpx, "get", mock_get) + + assert ps.get_project_states(_PROJECT)["in_progress"] == "uuid-A" + clock["t"] = 1_000_000.0 # far in the future + # TTL disabled -> still the cached A, never re-fetched. + assert ps.get_project_states(_PROJECT)["in_progress"] == "uuid-A" + assert mock_get.call_count == 1 + + +def test_tc11_groups_exposed_via_accessor(monkeypatch): + """get_project_state_groups returns {uuid -> group} from the same record.""" + monkeypatch.setattr(ps.settings, "plane_states_ttl_s", 300) + monkeypatch.setattr(ps.httpx, "get", lambda *a, **k: _resp(_states_response("uuid-A"))) + + ps.get_project_states(_PROJECT) + groups = ps.get_project_state_groups(_PROJECT) + assert groups["uuid-A"] == "started" + assert groups["uuid-done"] == "completed" + + +def test_tc11_groups_empty_when_uncached(monkeypatch): + """No cache record (e.g. API fell back to defaults) -> groups == {}.""" + assert ps.get_project_state_groups("never-fetched") == {} + + +# --------------------------------------------------------------------------- +# TC-12 (AC-13): default-config compatibility — enduro UUIDs + API-error fallback. +# --------------------------------------------------------------------------- +def test_tc12_enduro_uuids_unchanged(monkeypatch): + """enduro project still resolves its own UUIDs (return shape unchanged).""" + body = { + "results": [ + {"id": "b873d9eb-993c-48cd-97ac-99a9b1623967", + "name": "In Progress", "group": "started"}, + ] + } + monkeypatch.setattr(ps.httpx, "get", lambda *a, **k: _resp(body)) + states = ps.get_project_states(_ET_PROJECT) + assert states["in_progress"] == "b873d9eb-993c-48cd-97ac-99a9b1623967" + # Missing keys are still backfilled from _DEFAULT_STATES (complete mapping). + assert states["done"] == ps._DEFAULT_STATES["done"] + + +def test_tc12_api_error_falls_back_to_defaults(monkeypatch): + """API failure with nothing cached -> _DEFAULT_STATES (fallback preserved).""" + monkeypatch.setattr( + ps.httpx, "get", MagicMock(side_effect=Exception("network error")) + ) + states = ps.get_project_states(_PROJECT) + assert states is ps._DEFAULT_STATES + + +def test_tc12_stale_served_when_refresh_fails(monkeypatch): + """TTL expiry + transient API failure -> serve the stale (project-correct) + set rather than reverting to enduro defaults.""" + monkeypatch.setattr(ps.settings, "plane_states_ttl_s", 300) + clock = {"t": 1000.0} + monkeypatch.setattr(ps.time, "monotonic", lambda: clock["t"]) + + calls = {"n": 0} + + def flaky_get(*a, **k): + calls["n"] += 1 + if calls["n"] == 1: + return _resp(_states_response("uuid-A")) + raise Exception("transient outage") + + monkeypatch.setattr(ps.httpx, "get", flaky_get) + + assert ps.get_project_states(_PROJECT)["in_progress"] == "uuid-A" + clock["t"] = 2000.0 # past TTL -> refresh attempt fails + states = ps.get_project_states(_PROJECT) + assert states["in_progress"] == "uuid-A" # stale-but-correct, not defaults + assert states is not ps._DEFAULT_STATES diff --git a/tests/test_reconciler_plane.py b/tests/test_reconciler_plane.py index 8f03475..de9cf33 100644 --- a/tests/test_reconciler_plane.py +++ b/tests/test_reconciler_plane.py @@ -341,3 +341,342 @@ def test_tc17_polls_all_projects_resolves_states_per_project(monkeypatch): # state uuids are resolved per-project (not hardcoded): each call carries them. for _pid, states in issues_calls: assert set(states) == {_IN_PROGRESS, _APPROVED, _REJECTED} + + +# =========================================================================== +# ORCH-068: livelock-fix — terminal exclusion (D1) + confirmed-change unblock +# (D2) + dedup (TR-3). The old code spammed `_note_unblock` every ~120s for a +# fully synchronized Done task (incident: ET-002, 191+ Telegram messages/night). +# =========================================================================== + +_DONE = "uuid-done" +_CANCELLED = "uuid-cancelled" + + +def _patch_states_with_terminals(monkeypatch, *, alias_done_to_approved=False): + """Patch F-2 state resolution to include terminals + their groups. + + ``alias_done_to_approved`` models the regression trigger (ORCH-066): the + project "collapses" Done onto the approved UUID, so a genuinely-Done issue + would enter the ``approved`` branch by UUID. Only the state GROUP + (``completed``) disentangles it — the heart of D1. + """ + done_uuid = _APPROVED if alias_done_to_approved else _DONE + states = { + "in_progress": _IN_PROGRESS, + "approved": _APPROVED, + "rejected": _REJECTED, + "done": done_uuid, + "cancelled": _CANCELLED, + } + groups = { + _IN_PROGRESS: "started", + _APPROVED: "started", + _REJECTED: "started", + done_uuid: "completed", # genuinely-done issue -> completed group + _CANCELLED: "cancelled", + } + monkeypatch.setattr(reconciler_mod, "get_project_states", lambda pid: states) + monkeypatch.setattr( + reconciler_mod, "get_project_state_groups", lambda pid: groups + ) + return states, groups + + +def _spy_telegram(monkeypatch): + sent = [] + monkeypatch.setattr(reconciler_mod, "send_telegram", lambda msg: sent.append(msg)) + return sent + + +def _job_count(): + conn = get_db() + n = conn.execute("SELECT COUNT(*) FROM jobs").fetchone()[0] + conn.close() + return n + + +# --------------------------------------------------------------------------- +# TC-01 (AC-1, AC-7): synchronized Done task -> total silence, 0 jobs. +# --------------------------------------------------------------------------- +def test_tc01_synced_done_is_silent(monkeypatch, single_project): + start, verdict = _patch_handlers(monkeypatch) + _patch_states_with_terminals(monkeypatch) + sent = _spy_telegram(monkeypatch) + _make_task("iss-done", stage="done", wi="ET-002") + _patch_issues(monkeypatch, [ + {"id": "iss-done", "state": {"id": _DONE}, "updated_at": _OLD_TS}, + ]) + + recon = Reconciler() + recon.reconcile_plane_once() + + start.assert_not_called() + verdict.assert_not_called() + assert sent == [] + assert recon.unblocked_total == 0 + assert recon.skipped_terminal_total == 1 + assert _job_count() == 0 + + +# --------------------------------------------------------------------------- +# TC-02 (AC-2): Done UUID aliased onto approved -> still excluded by GROUP. +# --------------------------------------------------------------------------- +def test_tc02_terminal_aliased_to_approved_excluded(monkeypatch, single_project): + start, verdict = _patch_handlers(monkeypatch) + _patch_states_with_terminals(monkeypatch, alias_done_to_approved=True) + sent = _spy_telegram(monkeypatch) + # Task is Done; its Plane state UUID equals the approved UUID (aliasing). + _make_task("iss-alias", stage="done", wi="ET-002") + _patch_issues(monkeypatch, [ + {"id": "iss-alias", "state": {"id": _APPROVED}, "updated_at": _OLD_TS}, + ]) + + recon = Reconciler() + recon.reconcile_plane_once() + + # Without the group check this would enter the approved branch and notify. + start.assert_not_called() + verdict.assert_not_called() + assert sent == [] + assert recon.unblocked_total == 0 + assert recon.skipped_terminal_total == 1 + + +# --------------------------------------------------------------------------- +# TC-03 (AC-2): Cancelled terminal is also excluded. +# --------------------------------------------------------------------------- +def test_tc03_cancelled_excluded(monkeypatch, single_project): + start, verdict = _patch_handlers(monkeypatch) + _patch_states_with_terminals(monkeypatch) + sent = _spy_telegram(monkeypatch) + _make_task("iss-cancel", stage="done", wi="ET-003") + _patch_issues(monkeypatch, [ + {"id": "iss-cancel", "state": {"id": _CANCELLED}, "updated_at": _OLD_TS}, + ]) + + recon = Reconciler() + recon.reconcile_plane_once() + + start.assert_not_called() + verdict.assert_not_called() + assert sent == [] + assert recon.unblocked_total == 0 + assert recon.skipped_terminal_total == 1 + + +# --------------------------------------------------------------------------- +# TC-04 (AC-3): no-op dispatch (stage unchanged) -> no notification. +# --------------------------------------------------------------------------- +def test_tc04_noop_dispatch_no_unblock(monkeypatch, single_project): + # handle_verdict is a no-op AsyncMock -> the task stage never moves. + start, verdict = _patch_handlers(monkeypatch) + sent = _spy_telegram(monkeypatch) + _make_task("iss-noop", stage="review") + _patch_issues(monkeypatch, [ + {"id": "iss-noop", "state": {"id": _APPROVED}, "updated_at": _OLD_TS}, + ]) + + recon = Reconciler() + recon.reconcile_plane_once() + + # The handler was replayed (idempotent), but nothing changed -> silence. + assert verdict.call_count == 1 + assert sent == [] + assert recon.unblocked_total == 0 + + +# --------------------------------------------------------------------------- +# TC-05 (AC-4): two consecutive ticks on a synced task -> 0 repeat unblocks; +# plus a direct check of the in-memory dedup guard. +# --------------------------------------------------------------------------- +def test_tc05_dedup_no_repeat_notification(monkeypatch, single_project): + start, verdict = _patch_handlers(monkeypatch) + _patch_states_with_terminals(monkeypatch) + sent = _spy_telegram(monkeypatch) + _make_task("iss-dedup", stage="done", wi="ET-004") + _patch_issues(monkeypatch, [ + {"id": "iss-dedup", "state": {"id": _DONE}, "updated_at": _OLD_TS}, + ]) + + recon = Reconciler() + recon.reconcile_plane_once() + recon.reconcile_plane_once() + + assert sent == [] + assert recon.unblocked_total == 0 + + # Direct dedup-guard exercise: the same issue+state notifies at most once. + recon._note_unblock("ET-004", "review", "state-x") + recon._note_unblock("ET-004", "review", "state-x") + assert recon.unblocked_total == 1 + assert recon.deduped_total == 1 + + +# --------------------------------------------------------------------------- +# TC-06 (AC-5): legit lost Approved webhook -> replayed, advanced, ONE unblock. +# --------------------------------------------------------------------------- +def test_tc06_legit_approved_unblock_once(monkeypatch, single_project): + _patch_states_with_terminals(monkeypatch) # non-terminal approved -> actionable + sent = _spy_telegram(monkeypatch) + _make_task("iss-appr", stage="review", wi="ET-005") + + async def fake_verdict(issue_data, project_id, approved=True): + # Simulate the real handler advancing the stage (review -> testing). + conn = get_db() + conn.execute( + "UPDATE tasks SET stage='testing' WHERE plane_id=?", + (issue_data["id"],), + ) + conn.commit() + conn.close() + + monkeypatch.setattr(reconciler_mod, "handle_verdict", fake_verdict) + monkeypatch.setattr(reconciler_mod, "handle_status_start", AsyncMock()) + _patch_issues(monkeypatch, [ + {"id": "iss-appr", "state": {"id": _APPROVED}, "updated_at": _OLD_TS}, + ]) + + recon = Reconciler() + recon.reconcile_plane_once() + + assert recon.unblocked_total == 1 + assert len(sent) == 1 + assert "ET-005" in sent[0] + + +# --------------------------------------------------------------------------- +# TC-07 (AC-6): lost In Progress start (task appears) and lost Rejected +# rollback (stage moves) each fire exactly one unblock. +# --------------------------------------------------------------------------- +def test_tc07_in_progress_start_and_rejected_each_one_unblock( + monkeypatch, single_project +): + _patch_states_with_terminals(monkeypatch) + sent = _spy_telegram(monkeypatch) + + async def fake_start(issue_data, project_id): + # Simulate the real start handler creating the task. + _make_task(issue_data["id"], stage="analysis", wi="ET-006") + + async def fake_verdict(issue_data, project_id, approved=True): + conn = get_db() + conn.execute( + "UPDATE tasks SET stage='development' WHERE plane_id=?", + (issue_data["id"],), + ) + conn.commit() + conn.close() + + monkeypatch.setattr(reconciler_mod, "handle_status_start", fake_start) + monkeypatch.setattr(reconciler_mod, "handle_verdict", fake_verdict) + + # Rejected task already exists at review; In Progress one has no task yet. + _make_task("iss-rej", stage="review", wi="ET-007") + _patch_issues(monkeypatch, [ + {"id": "iss-start", "state": {"id": _IN_PROGRESS}, "updated_at": _OLD_TS}, + {"id": "iss-rej", "state": {"id": _REJECTED}, "updated_at": _OLD_TS}, + ]) + + recon = Reconciler() + recon.reconcile_plane_once() + + assert recon.unblocked_total == 2 + assert len(sent) == 2 + + +# --------------------------------------------------------------------------- +# TC-08 (AC-8): never-raise — a failing dependency isolates to its unit of work. +# --------------------------------------------------------------------------- +def test_tc08_never_raise_isolation(monkeypatch, single_project): + _patch_states_with_terminals(monkeypatch) + monkeypatch.setattr(reconciler_mod, "send_telegram", lambda msg: None) + + # _dispatch blows up for one issue -> isolated; the tick must not crash. + def boom_dispatch(*a, **k): + raise RuntimeError("handler exploded") + + monkeypatch.setattr(Reconciler, "_dispatch", staticmethod(boom_dispatch)) + _make_task("iss-boom", stage="review", wi="ET-008") + _patch_issues(monkeypatch, [ + {"id": "iss-boom", "state": {"id": _APPROVED}, "updated_at": _OLD_TS}, + ]) + + recon = Reconciler() + recon.reconcile_plane_once() # must NOT raise + assert recon.unblocked_total == 0 + + # list_issues_by_state raising -> per-project isolation, still no crash. + def boom_list(pid, states): + raise RuntimeError("plane down") + + monkeypatch.setattr(reconciler_mod, "list_issues_by_state", boom_list) + recon.reconcile_plane_once() # must NOT raise + + +# --------------------------------------------------------------------------- +# TC-09 (AC-9): kill-switches mute F-2. +# --------------------------------------------------------------------------- +def test_tc09_kill_switches(monkeypatch, single_project): + start, verdict = _patch_handlers(monkeypatch) + _patch_states_with_terminals(monkeypatch) + called = {"list": 0} + + def counting_list(pid, states): + called["list"] += 1 + return [{"id": "iss-x", "state": {"id": _APPROVED}, "updated_at": _OLD_TS}] + + monkeypatch.setattr(reconciler_mod, "list_issues_by_state", counting_list) + + monkeypatch.setattr(reconciler_mod.settings, "reconcile_enabled", False) + Reconciler().reconcile_plane_once() + assert called["list"] == 0 # global switch off -> F-2 never runs + + monkeypatch.setattr(reconciler_mod.settings, "reconcile_enabled", True) + monkeypatch.setattr(reconciler_mod.settings, "reconcile_plane_enabled", False) + Reconciler().reconcile_plane_once() + assert called["list"] == 0 # F-2 switch off -> still no poll + + +# --------------------------------------------------------------------------- +# TC-10 (AC-1, AC-2): end-to-end on BOTH registry projects (enduro AND +# orchestrator): a Done task on each -> 0 notifications / 0 jobs, regardless +# of per-project status aliasing. The headline regression test. +# --------------------------------------------------------------------------- +def test_tc10_done_silent_on_all_projects(monkeypatch): + from src import projects as projects_mod + projects_mod.reload_projects() + assert len({p.plane_project_id for p in projects_mod.PROJECTS}) >= 2 + + start, verdict = _patch_handlers(monkeypatch) + sent = _spy_telegram(monkeypatch) + + states = { + "in_progress": _IN_PROGRESS, + "approved": _APPROVED, + "rejected": _REJECTED, + "done": _DONE, + "cancelled": _CANCELLED, + } + groups = {_DONE: "completed", _CANCELLED: "cancelled"} + monkeypatch.setattr(reconciler_mod, "get_project_states", lambda pid: states) + monkeypatch.setattr( + reconciler_mod, "get_project_state_groups", lambda pid: groups + ) + # Each project returns a Done issue (unique id per project). + monkeypatch.setattr( + reconciler_mod, "list_issues_by_state", + lambda pid, st: [ + {"id": f"done-{pid}", "state": {"id": _DONE}, "updated_at": _OLD_TS} + ], + ) + + recon = Reconciler() + recon.reconcile_plane_once() + + start.assert_not_called() + verdict.assert_not_called() + assert sent == [] + assert recon.unblocked_total == 0 + assert recon.skipped_terminal_total >= 2 # one per project + assert _job_count() == 0