integ: merge ORCH-068 reconciler livelock fix
# Conflicts: # docs/architecture/README.md # src/reconciler.py
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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<max`→`queued`, иначе `failed`+Telegram). Атомарный reap-claim (guard `status='running'`) совместим со стартовым `requeue_running_jobs`. Тот же поток периодически делает проактивный реклейм stale/dead merge-lease (см. ниже). never-raise; kill-switch `ORCH_REAPER_ENABLED`; снимок в `GET /queue` (блок `reaper`).
|
||||
- **Reconciler** (`src/reconciler.py`, ORCH-053 — реализовано, [adr-0007](adr/adr-0007-reconciler.md)) — фоновый daemon-поток (паттерн `queue_worker`), стартует/останавливается в `main.lifespan` (после `worker.start()` / перед `worker.stop()`). Реконсилирует рассинхрон «источник истины ≠ стадия задачи» при потерянном webhook. F-1 gate-side (продвигает застрявшую стадию по локальной БД через штатный `advance_stage(..., finished_agent=None)`), F-2 plane-side (опрос Plane API → `handle_*` из `plane.py`), F-3 (БД-fallback `sha→branch` в `handle_ci_status`). Источник истины — гейт/Plane, не событие; идемпотентность (active-job guard + atomic-claim + grace); kill-switch `ORCH_RECONCILE_ENABLED`. `analysis` F-1 не трогает (человеческий гейт). F-1 также пропускает escalated (retry≥лимита) и Blocked/Needs-Input задачи (ORCH-060). Наблюдаемость — блок `reconcile` в `GET /queue`.
|
||||
- **Project Registry** (`src/projects.py`, ORCH-6) — Plane project id → repo + prefix; фильтрация вебхуков по проекту.
|
||||
- **Plane Sync** (`src/plane_sync.py`) — синхронизация статусов/комментариев в Plane.
|
||||
- **Plane Sync** (`src/plane_sync.py`) — синхронизация статусов/комментариев в Plane. Резолв статусов проекта `get_project_states` (ORCH-10) кэширует `{logical_key→uuid}` per-project; **ORCH-068** добавляет в кэш-запись `{uuid→group}` (для терминал-исключения F-2) и **TTL** `ORCH_PLANE_STATES_TTL_S` (дефолт 300с; `0` → прежний lifetime-кэш) — устаревший набор статусов самозалечивается без рестарта процесса через существующий `reload_project_states()` (баг кэша после появления нового Plane-статуса). Форма возврата `get_project_states` неизменна (обратная совместимость).
|
||||
|
||||
## Конвейер и Quality Gates
|
||||
|
||||
@@ -235,11 +235,21 @@ helper `validated_revision` питает и штамп A, и `EXPECTED_REVISION`
|
||||
retry-count проверяется первым (дёшево, локальный SQL).
|
||||
- **F-2 plane-side:** опрос Plane API per-project → `handle_status_start` /
|
||||
`handle_verdict` из `webhooks/plane.py` (логика не дублируется).
|
||||
**ORCH-068 (livelock-fix):** (1) задачи в **терминальной группе** Plane
|
||||
(`state.group ∈ {completed, cancelled}`, fallback — логические ключи
|
||||
`done`/`cancelled`) исключаются из actionable-выборки per-issue — проектно-независимо,
|
||||
устойчиво к UUID-алиасингу после переименований статусов (ORCH-066); (2) `_note_unblock`
|
||||
(лог + Telegram + `unblocked_total`) вызывается ТОЛЬКО при **подтверждённом state change**
|
||||
(сравнение стадии задачи до/после `_dispatch`; no-op dispatch → тишина), плюс in-memory
|
||||
дедуп по `issue_id→state`. Восстанавливает инвариант silence-when-in-sync (AC-9/AC-10).
|
||||
Детали — `docs/work-items/ORCH-068/06-adr/ADR-001-reconciler-terminal-exclusion-and-cache-ttl.md`.
|
||||
- **F-3:** усиление `sha→branch` в `handle_ci_status` (БД-fallback по единственной
|
||||
development-задаче repo; неоднозначность → не резолвим).
|
||||
- **F-4 observability:** при разблокировке — лог-строка `reconciler: <wi> <stage>
|
||||
разблокирована (потерян 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`).*
|
||||
|
||||
@@ -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 как под-гейт ребра
|
||||
|
||||
7
docs/work-items/ORCH-068/00-business-request.md
Normal file
7
docs/work-items/ORCH-068/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: BUG: reconciler livelock — спам unblock done-задачи (ET-002)
|
||||
|
||||
Work Item ID: ORCH-068
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
52
docs/work-items/ORCH-068/01-brd.md
Normal file
52
docs/work-items/ORCH-068/01-brd.md
Normal file
@@ -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).
|
||||
68
docs/work-items/ORCH-068/02-trz.md
Normal file
68
docs/work-items/ORCH-068/02-trz.md
Normal file
@@ -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_*).
|
||||
84
docs/work-items/ORCH-068/03-acceptance-criteria.md
Normal file
84
docs/work-items/ORCH-068/03-acceptance-criteria.md
Normal file
@@ -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: любой красный тест или отсутствие регресс-теста на основной баг.
|
||||
122
docs/work-items/ORCH-068/04-test-plan.yaml
Normal file
122
docs/work-items/ORCH-068/04-test-plan.yaml
Normal file
@@ -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
|
||||
@@ -0,0 +1,162 @@
|
||||
# ADR-001 (ORCH-068): Исключение терминалов из F-2 по группе состояния + подтверждённый unblock + TTL кэша статусов
|
||||
|
||||
- **Статус:** Accepted
|
||||
- **Дата:** 2026-06-08
|
||||
- **Задача:** ORCH-068 (BUG: reconciler livelock — спам «разблокирована» по синхронизированной done-задаче)
|
||||
- **Сквозной ADR:** уточняет [adr-0007-reconciler.md](../../../architecture/adr/adr-0007-reconciler.md) (F-2) — реестры/схема НЕ меняются
|
||||
- **Связанные:** ORCH-053 (reconciler F-2), ORCH-066 (новая статусная модель Plane — триггер регрессии), ORCH-060 (F-1 пред-гарды), ORCH-10 (`get_project_states`)
|
||||
|
||||
## Контекст
|
||||
|
||||
Reconciler F-2 (`src/reconciler.py`, `_reconcile_plane_project` / `_reconcile_plane_issue`)
|
||||
опрашивает Plane per-project и доигрывает потерянные webhook-переходы через штатные
|
||||
`handle_status_start` / `handle_verdict`. После мерджа ORCH-066 (новая статусная модель
|
||||
Plane) на проде с 22:17 UTC reconciler каждые ~120с слал в Telegram
|
||||
`reconciler: ET-002 done разблокирована (потерян webhook)` для задачи ET-002, которая
|
||||
полностью синхронизирована (БД `stage=done`, Plane `state=Done` с 2026-05-21). 191+
|
||||
сообщений за ночь — livelock без advance/jobs/токенов, но подрывающий доверие alert-fatigue.
|
||||
|
||||
Диагностика (BRD §3) выявила **два независимых, складывающихся дефекта**:
|
||||
|
||||
- **D1 (выборка):** F-2 различает actionable-статусы по **голому UUID**
|
||||
(`in_progress`/`approved`/`rejected`). `get_project_states` строит маппинг по *именам*
|
||||
статусов, недостающие ключи добивает из `_DEFAULT_STATES` (enduro-значения). После
|
||||
ORCH-066 набор имён enduro изменился → терминальный `Done` перестал однозначно
|
||||
отличаться от `approved` по UUID, и ET-002 (Plane=Done) **попала** в actionable-набор
|
||||
ветки `approved`. Терминальные статусы (`Done`/`Cancelled`) нигде не исключаются из F-2.
|
||||
- **D2 (нотификация):** `_note_unblock` вызывается **безусловно сразу после `_dispatch`**,
|
||||
не проверяя, изменил ли обработчик реально состояние задачи. `handle_verdict(approved)`
|
||||
для уже-`done` задачи — no-op, но нотификация всё равно уходит. Это прямо нарушает
|
||||
собственный docstring `_note_unblock` («fires only on an actual state change, never per
|
||||
idle tick») и инвариант silence-when-in-sync (AC-9/AC-10 ORCH-053).
|
||||
|
||||
Связанный secondary-баг (BRD §4): `_STATES_CACHE` (`src/plane_sync.py`) кэширует статусы
|
||||
на **весь lifetime процесса**. После появления нового Plane-статуса боевой процесс держит
|
||||
устаревший набор → webhook на новый статус даёт «no pipeline action», лечилось только
|
||||
рестартом орка. Примитив сброса `reload_project_states()` уже есть, но автоматически не
|
||||
вызывается.
|
||||
|
||||
Ограничения (из ТЗ, обязаны сохраниться): источник истины F-2 — Plane (не переписываем);
|
||||
НЕ трогать `STAGE_TRANSITIONS` / `QG_CHECKS` / схему БД / контракты `handle_*` / F-1 / F-3;
|
||||
never-raise per unit of work; kill-switch'и; 0 jobs/0 токенов для синхронизированных задач;
|
||||
self-hosting — reconciler НИКОГДА не рестартит прод-контейнер.
|
||||
|
||||
## Решение
|
||||
|
||||
Чиним **оба** дефекта независимыми слоями (defense in depth, как принято в проекте —
|
||||
ORCH-058) плюс TTL для кэша. Все правки локальны в `src/reconciler.py` и
|
||||
`src/plane_sync.py`; реестры, схема БД и контракты обработчиков не меняются.
|
||||
|
||||
### Слой D1 — исключение терминалов по ГРУППЕ состояния (TR-1, AC-2)
|
||||
|
||||
Различаем терминальные (`completed`/`cancelled`) и review/work-статусы по **группе
|
||||
состояния Plane** (`state.group ∈ {backlog, unstarted, started, completed, cancelled}`),
|
||||
а НЕ по голому UUID. Группа — авторитетный, проектно-независимый дискриминатор: она
|
||||
корректно различает `Done` (completed) и `approved` (started/review) даже когда проект
|
||||
«схлопывает» их по UUID после переименований.
|
||||
|
||||
Механика (single API fetch, без новых сетевых вызовов):
|
||||
- `/states/`-ответ Plane содержит для каждого статуса поле `group`. Расширяем кэш-запись
|
||||
`_STATES_CACHE` так, чтобы из ОДНОГО запроса хранить и текущий `{logical_key → uuid}`,
|
||||
и `{uuid → group}`. `get_project_states` сохраняет **прежнюю сигнатуру и форму возврата**
|
||||
(`{logical_key: uuid}`) — обратная совместимость (AC-13). Добавляется sibling-аксессор
|
||||
`get_project_state_groups(project_id) -> dict[uuid, group]` (или эквивалент), читающий ту
|
||||
же кэш-запись.
|
||||
- В `_reconcile_plane_issue` ДО выбора ветки: если группа `new_state` ∈
|
||||
{`completed`, `cancelled`} → **тишина** (return, no-op). Fallback, когда группа
|
||||
недоступна (API не отдал `group` / fallback на `_DEFAULT_STATES`): исключать по логическим
|
||||
ключам терминалов `{states.get("done"), states.get("cancelled")}`.
|
||||
|
||||
Терминал-исключение применяется **per-issue** (а не сужением `wanted`-набора
|
||||
`list_issues_by_state`), потому что при UUID-алиасинге терминал может физически совпадать с
|
||||
actionable-UUID в `wanted` — фильтрация по UUID его не отсечёт, а проверка группы отсечёт.
|
||||
|
||||
### Слой D2 — `_note_unblock` только при подтверждённом state change (TR-2, AC-3)
|
||||
|
||||
`_note_unblock` (лог + Telegram + `unblocked_total`) вызывается ТОЛЬКО когда диспетчеризованный
|
||||
обработчик **фактически изменил состояние задачи**. Реализация — сравнение состояния
|
||||
**до/после на стороне reconciler** (предпочтение ТЗ; контракты `handle_*` НЕ меняются):
|
||||
- `approved`/`rejected` (task существует): захватить `stage_before` (из уже прочитанного
|
||||
`task`), после `_dispatch` перечитать `get_task_by_plane_id(issue_id)` → `stage_after`;
|
||||
`_note_unblock` только если `stage_after != stage_before`.
|
||||
- `in_progress` + `task is None` (старт пайплайна): подтверждение = задача **появилась**
|
||||
после dispatch (`get_task_by_plane_id` теперь не None).
|
||||
|
||||
No-op dispatch (задача уже в целевом состоянии) → 0 нотификаций. Восстанавливает соответствие
|
||||
docstring и инвариант silence-when-in-sync.
|
||||
|
||||
### Слой TR-3 — дедуп нотификаций (страховка, AC-4)
|
||||
|
||||
In-memory best-effort guard: `{issue_id → last_unblocked_state_uuid}`. `_note_unblock` для
|
||||
issue+state, уже отмеченного, подавляется. Сбрасывается при рестарте (допустимо — AC-11
|
||||
ORCH-053, как `unblocked_total`/`last_unblocked`). D1+D2 закрывают основной кейс; TR-3 —
|
||||
дополнительная сетка против любого будущего no-op-пути.
|
||||
|
||||
### Слой TR-4 — TTL кэша статусов (secondary, AC-12/AC-13)
|
||||
|
||||
Кэш-запись `_STATES_CACHE` хранит timestamp; `get_project_states` перезапрашивает API при
|
||||
истечении `ORCH_PLANE_STATES_TTL_S`. Примитив инвалидации — существующий
|
||||
`reload_project_states()` (не дублируем логику сброса). Новый флаг
|
||||
`plane_states_ttl_s` (env `ORCH_PLANE_STATES_TTL_S`):
|
||||
- дефолт **300** (5 мин) — устаревший набор самозалечивается без рестарта (G5);
|
||||
- `0` — отключает TTL → строго прежний lifetime-кэш (escape hatch / strict back-compat).
|
||||
|
||||
Fallback на `_DEFAULT_STATES` при недоступности API сохранён без изменений; TTL-перезапрос
|
||||
возвращает тот же корректный набор → не регресс (AC-13).
|
||||
|
||||
## Альтернативы
|
||||
|
||||
- **Явный allowlist логических ключей терминалов (`done`/`cancelled`) без группы** —
|
||||
отклонён как primary: хрупок к будущим переименованиям/добавлению completed-статусов
|
||||
(`Monitoring after Deploy` и т.п.) и к UUID-алиасингу. Оставлен как **fallback**, когда
|
||||
`group` недоступен.
|
||||
- **Сужение `wanted`-набора в `list_issues_by_state`** — недостаточно: при UUID-алиасинге
|
||||
терминал совпадает с actionable-UUID и не отсекается фильтром по UUID. Нужна проверка
|
||||
группы per-issue.
|
||||
- **Проброс «changed»-сигнала из `handle_*`** — отклонён: меняет контракт обработчиков
|
||||
(запрещено ТЗ N2). Выбрано сравнение до/после на стороне reconciler.
|
||||
- **Флаг подавления нотификаций в `advance_stage`** — отклонён (как и в adr-0007):
|
||||
трогает общий критический путь.
|
||||
- **flush-on-unknown как primary для кэша** — допустимо ТЗ и дешевле, но недетерминирован
|
||||
для юнит-теста (TC-11) и не лечит «тихий устаревший набор» без триггера-вебхука. Выбран
|
||||
TTL (детерминированный, самозалечивающий); flush-on-unknown может быть добавлен позже как
|
||||
комплемент, переиспользуя `reload_project_states`.
|
||||
- **Admin-эндпоинт `POST /admin/plane-states/reload`** — отклонён в объёме (требует
|
||||
ручного действия, не лечит автоматически); TTL покрывает G5 без нового API.
|
||||
|
||||
## Последствия
|
||||
|
||||
- **Плюсы:** livelock устранён двумя независимыми слоями; терминал-исключение
|
||||
проектно-независимо (enduro И orchestrator), устойчиво к будущим переименованиям статусов;
|
||||
`_note_unblock` снова соответствует своему контракту; устаревший кэш самозалечивается без
|
||||
рестарта прода. Реестры/схема/контракты/F-1/F-3 не тронуты.
|
||||
- **Минусы / плата:** один доп. accessor группы (тот же API-запрос, без новой сетевой
|
||||
стоимости); TTL добавляет редкий перезапрос `/states/` (раз в 5 мин/проект); дедуп-словарь
|
||||
— небольшая in-memory структура, неперсистентная (приемлемо).
|
||||
- **Совместимость:** `get_project_states` форма возврата неизменна; `plane_states_ttl_s=0`
|
||||
→ строго прежнее поведение кэша; `_DEFAULT_STATES`-fallback сохранён.
|
||||
- **Self-hosting:** ни один путь не рестартит прод-контейнер (AC-11); правка
|
||||
обязательно проходит staging-гейт (8501) перед прод-деплоем орка.
|
||||
- **Наблюдаемость (опц.):** допустимо добавить в блок `reconcile` снимка `GET /queue`
|
||||
счётчики `skipped_terminal` / `deduped` без ломающих изменений.
|
||||
|
||||
## Инварианты (подтверждение)
|
||||
|
||||
INV-1 источник истины F-2 = Plane — сохранён (правим маппинг/нотификацию, не концепцию).
|
||||
INV-2 never-raise per-issue/-project/-tick — сохранён (новый guard в том же try-периметре).
|
||||
INV-3 kill-switch'и `ORCH_RECONCILE_ENABLED` / `ORCH_RECONCILE_PLANE_ENABLED` — без изменений.
|
||||
INV-4 F-1/F-3 — не тронуты. INV-5 0 jobs/0 токенов для done/cancelled — восстановлен.
|
||||
INV-6 легитимная разблокировка реально-потерянного approved/in_progress — работает (D2
|
||||
подтверждает реальный change, не подавляет его). INV-7 self-hosting — тик не рестартит прод.
|
||||
|
||||
## Объём изменений (для разработчика)
|
||||
|
||||
- `src/reconciler.py`: терминал-гард по группе + fallback в `_reconcile_plane_issue`;
|
||||
before/after-сравнение стадии вокруг `_dispatch`; in-memory дедуп-словарь в `Reconciler`.
|
||||
- `src/plane_sync.py`: кэш-запись с timestamp + `{uuid→group}`; `get_project_state_groups`;
|
||||
TTL-логика в `get_project_states` (переиспользуя `reload_project_states`).
|
||||
- `src/config.py`: флаг `plane_states_ttl_s` (env `ORCH_PLANE_STATES_TTL_S`, дефолт 300).
|
||||
- `.env.example` / `.env.staging`: задокументировать `ORCH_PLANE_STATES_TTL_S`.
|
||||
- Доки в ТОМ ЖЕ PR: `docs/architecture/README.md` (Reconciler/Plane Sync), `CHANGELOG.md`
|
||||
(`fix:`), `CLAUDE.md` (при изменении наблюдаемого поведения), этот ADR.
|
||||
- Тесты: `04-test-plan.yaml` (TC-01…TC-13), офлайн (мок Plane/Telegram/`_dispatch`).
|
||||
17
docs/work-items/ORCH-068/10-tech-risks.md
Normal file
17
docs/work-items/ORCH-068/10-tech-risks.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Технические риски — ORCH-068
|
||||
|
||||
| ID | Риск | Вероятность | Влияние | Митигация |
|
||||
|----|------|-------------|---------|-----------|
|
||||
| R-1 | Plane `/states/` не отдаёт поле `group` (старая версия API / урезанный ответ) → терминал-исключение по группе не срабатывает | Низкая | Высокое (рецидив livelock) | Fallback на логические ключи терминалов `{done, cancelled}` при отсутствии `group`; never-raise → консервативная тишина при сбое резолва |
|
||||
| R-2 | Over-exclusion: легитимная задача в started/review-группе ошибочно классифицирована как терминал → пропущена легитимная разблокировка (регресс INV-6) | Низкая | Среднее | Исключаем ТОЛЬКО группы `completed`/`cancelled`; `approved`/`rejected` относятся к started/unstarted → не задеты; регресс-тесты TC-06/TC-07 |
|
||||
| R-3 | Гонка before/after: между `stage_before` и `stage_after` живой webhook двигает стадию → ложный `_note_unblock` | Очень низкая | Низкое | active-job guard + `max_concurrency=1` уже сериализуют; дедуп TR-3 подавляет повтор; ложный unblock безвреден (0 jobs/токенов) |
|
||||
| R-4 | TTL `300s` провоцирует частые `/states/`-перезапросы при многих проектах | Низкая | Низкое | 1 запрос/проект/5 мин — пренебрежимо; `ORCH_PLANE_STATES_TTL_S=0` отключает TTL |
|
||||
| R-5 | TTL-перезапрос в момент недоступности Plane → временный fallback на `_DEFAULT_STATES` (enduro) для не-enduro проекта | Низкая | Среднее | Поведение идентично текущему cold-cache fallback; самозалечивается следующим успешным запросом; не хуже статус-кво |
|
||||
| R-6 | Дедуп-словарь растёт неограниченно (по issue_id) | Очень низкая | Низкое | Ключи — только реально разблокированные issue (редки); сбрасывается при рестарте; при необходимости — ограничить размер/LRU |
|
||||
| R-7 | Изменение в `get_project_states` (кэш-запись) ломает прочих потребителей формы возврата | Низкая | Высокое | Внешняя сигнатура и форма `{logical_key: uuid}` сохранены; группа — отдельный accessor; покрыто TC-12 (совместимость по умолчанию) |
|
||||
| R-8 | Self-hosting: правка в работающем прод-инструменте | — | Высокое | Обязательный staging-гейт (8501); запрет рестарта прод-контейнера в рамках задачи; INV-7 |
|
||||
|
||||
## Замечания
|
||||
- Все правки локальны (`reconciler.py`, `plane_sync.py`, `config.py`); схема БД, реестры
|
||||
`STAGE_TRANSITIONS`/`QG_CHECKS`, контракты `handle_*`, F-1/F-3 — не затронуты (AC-10).
|
||||
- Тесты офлайн (мок Plane API / Telegram / `_dispatch`) — сетевых вызовов в CI нет.
|
||||
47
docs/work-items/ORCH-068/12-review.md
Normal file
47
docs/work-items/ORCH-068/12-review.md
Normal file
@@ -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 выполнено.
|
||||
64
docs/work-items/ORCH-068/13-test-report.md
Normal file
64
docs/work-items/ORCH-068/13-test-report.md
Normal file
@@ -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
|
||||
12
docs/work-items/ORCH-068/14-deploy-log.md
Normal file
12
docs/work-items/ORCH-068/14-deploy-log.md
Normal file
@@ -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.
|
||||
14
docs/work-items/ORCH-068/16-post-deploy-log.md
Normal file
14
docs/work-items/ORCH-068/16-post-deploy-log.md
Normal file
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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/<project_id>/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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
180
tests/test_plane_states_cache.py
Normal file
180
tests/test_plane_states_cache.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user