Compare commits

...

7 Commits

Author SHA1 Message Date
f08fcc4bc1 deployer(ET): auto-commit from deployer run_id=202
Some checks failed
CI / test (push) Has been cancelled
2026-06-06 20:55:24 +00:00
bcb57ed7da tester(ET): auto-commit from tester run_id=200
All checks were successful
CI / test (push) Successful in 16s
CI / test (pull_request) Successful in 21s
2026-06-06 20:51:18 +00:00
1f7a9d6cf3 reviewer(ET): auto-commit from reviewer run_id=198
All checks were successful
CI / test (push) Successful in 16s
CI / test (pull_request) Successful in 16s
2026-06-06 20:47:04 +00:00
5a7f8d4000 feat(reconciler): sweeper потерянных webhook (реконсиляция застрявших стадий)
All checks were successful
CI / test (push) Successful in 16s
CI / test (pull_request) Successful in 17s
Конвейер продвигается только входящими webhook; потерянное событие (502 на
ребилде, отсутствие ретраев у Plane/Gitea, неразрезолвленный sha→branch)
оставляет задачу молча застрявшей (класс инцидента ORCH-044). Новый фоновый
daemon-поток src/reconciler.py (паттерн queue_worker) доигрывает пропущенный
переход через те же штатные гейты/обработчики, что и webhook:

- F-1 gate-side: для задач stage≠done, без активного job и age(updated_at) ≥
  grace_for_stage(stage) — read-only пред-оценка канонического QG; зелёный →
  stage_engine.advance_stage(..., finished_agent=None); красный → тишина (спам
  нотификаций структурно невозможен). analysis F-1 не трогает (человеческий гейт).
- F-2 plane-side: опрос Plane API per-project (plane_sync.list_issues_by_state,
  курсорная пагинация, never-raise) → реплей In Progress/Approved/Rejected через
  существующие handle_status_start/handle_verdict (async из sync-потока, asyncio.run).
- F-3: усиление sha→branch в handle_ci_status — БД-fallback по единственной
  development-задаче repo (неоднозначность → не резолвим), debug→info.
- Анти-дубль на создании (db.create_task_atomic под process-wide Lock): гонка
  reconcile↔webhook не плодит второй task/branch/worktree/analyst-job (AC-4).
- F-4 observability: лог-строка разблокировки + Telegram + блок reconcile в /queue.

Старт/стоп в main.lifespan (после worker.start() / перед worker.stop()),
restart-safe, never-raise на единицу работы. Kill-switches ORCH_RECONCILE_ENABLED
/ ORCH_RECONCILE_PLANE_ENABLED + grace-настройки. Схема БД и реестры
STAGE_TRANSITIONS/QG_CHECKS не менялись.

Тесты: test_reconciler.py, test_reconciler_plane.py, test_gitea_sha_resolve.py,
test_config.py (33 новых, 563 всего зелёные). Документация обновлена (golden source):
architecture/README.md, INFRA.md, README.md, CHANGELOG.md, adr-0007 → accepted.

Refs: ORCH-053

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-06 20:37:01 +00:00
a3876dbf43 architect(ET): auto-commit from architect run_id=194
All checks were successful
CI / test (push) Successful in 16s
2026-06-06 20:15:09 +00:00
a991541e46 analyst(ET): auto-commit from analyst run_id=191
All checks were successful
CI / test (push) Successful in 15s
2026-06-06 19:31:04 +00:00
2a2e3b3795 docs: init ORCH-053 business request
All checks were successful
CI / test (push) Successful in 20s
2026-06-06 22:20:58 +03:00
32 changed files with 2778 additions and 26 deletions

View File

@@ -36,3 +36,20 @@ ORCH_MERGE_RETEST_TARGET=tests/
ORCH_MERGE_LOCK_TIMEOUT_S=300
ORCH_MERGE_DEFER_DELAY_S=60
ORCH_MERGE_DEFER_MAX_ATTEMPTS=5
# ORCH-053: stuck-task reconciler (sweeper for lost webhooks). A background daemon
# replays a missed stage transition through the SAME gates/handlers a webhook would,
# fixing tasks that got stuck on a dropped event (502 on rebuild, no Plane/Gitea
# retries, unresolved sha->branch).
# ENABLED -> global kill-switch (self-hosting safety / staged rollout).
# PLANE_ENABLED -> separate flag for the F-2 Plane-API poll (mute only F-2).
# INTERVAL_S -> background sweep period (seconds).
# GRACE_DEFAULT_S -> default "stuck" threshold on tasks.updated_at (seconds).
# GRACE_OVERRIDES_JSON -> per-stage thresholds, e.g. {"development":300}; bad JSON -> default.
# NOTIFY_UNBLOCK -> send a Telegram message when a stuck task is unblocked.
ORCH_RECONCILE_ENABLED=true
ORCH_RECONCILE_PLANE_ENABLED=true
ORCH_RECONCILE_INTERVAL_S=120
ORCH_RECONCILE_GRACE_DEFAULT_S=600
ORCH_RECONCILE_GRACE_OVERRIDES_JSON=
ORCH_RECONCILE_NOTIFY_UNBLOCK=true

View File

@@ -5,6 +5,7 @@
## [Unreleased]
### Added
- **Sweeper потерянных webhook (реконсиляция застрявших стадий)** (ORCH-053): фоновый daemon-поток `src/reconciler.py` (паттерн `queue_worker`), который устраняет тихое застревание задач, когда конвейер не двигается из-за потерянного события (502 на ребилде инстанса, отсутствие ретраев у Plane/Gitea, неразрезолвленный `sha→branch` — класс инцидента ORCH-044). Реконсилятор периодически (`reconcile_interval_s`) доигрывает пропущенный переход **через те же штатные гейты/обработчики**, что и webhook, не дублируя логику конвейера: **F-1 gate-side** (`reconcile_gate_once`) — для задач `stage≠done`, без активного job и `age(updated_at) ≥ grace_for_stage(stage)` делает read-only пред-оценку канонического QG стадии; зелёный → продвижение строго через неизменный `stage_engine.advance_stage(..., finished_agent=None)`; красный → тишина (спам нотификаций структурно невозможен — `advance_stage` на красном гейте не вызывается вовсе); `analysis` F-1 не трогает (человеческий гейт). **F-2 plane-side** (`reconcile_plane_once`) — опрос Plane API per-project (новый `plane_sync.list_issues_by_state`, курсорная пагинация, never-raise) и реплей In Progress / Approved / Rejected через существующие `webhooks.plane.handle_status_start` / `handle_verdict` (async-обработчики вызываются из sync-потока через `asyncio.run`). **F-3** — усиление `sha→branch` в `handle_ci_status`: при неразрезолвленном sha — БД-fallback по единственной development-задаче repo (`db.get_development_tasks_by_repo`; неоднозначность → не резолвим, ложного матча нет), `logger.debug``logger.info` для видимости потерянного CI-события. Анти-дубль на создании задачи (`db.create_task_atomic` под process-wide `threading.Lock`: SELECT-exists→INSERT, проигравший в гонке reconcile↔webhook не плодит второй task/branch/worktree/стартовый analyst-job). Старт/стоп в `main.lifespan` (после `worker.start()` / перед `worker.stop()`), restart-safe, never-raise на единицу работы. Наблюдаемость (F-4): при разблокировке — лог-строка `reconciler: <wi> <stage> разблокирована (потерян webhook)` + Telegram (`reconcile_notify_unblock`) и блок `reconcile` в `GET /queue`. Kill-switches: `ORCH_RECONCILE_ENABLED` (глобально), `ORCH_RECONCILE_PLANE_ENABLED` (гасит только F-2), `ORCH_RECONCILE_INTERVAL_S` (120), `ORCH_RECONCILE_GRACE_DEFAULT_S` (600), `ORCH_RECONCILE_GRACE_OVERRIDES_JSON` (per-stage), `ORCH_RECONCILE_NOTIFY_UNBLOCK` (true). Схема БД и реестры (`STAGE_TRANSITIONS`/`QG_CHECKS`) НЕ менялись. ADR `docs/work-items/ORCH-053/06-adr/ADR-001-stuck-task-reconciler.md`, глобальный `docs/architecture/adr/adr-0007-reconciler.md`. Тесты: `tests/test_reconciler.py`, `tests/test_reconciler_plane.py`, `tests/test_gitea_sha_resolve.py`, `tests/test_config.py`.
- **Merge-gate: авто-rebase на текущий `origin/main` + повторный прогон тестов + сериализация мержей** (ORCH-043): детерминированный (без LLM) суб-гейт на ребре `deploy-staging → deploy`, выполняемый ПЕРЕД мержем PR деплоером. Закрывает класс гонок «две зелёные ветки в одном репо ломают `main`»: пайплайн валидирует ветку против того `main`, от которого она ответвилась, а не против `main` в момент мержа — между «ветка зелёная» и «ветка смержена» параллельная задача может сдвинуть `main` (семантический конфликт: git мержит без текстового конфликта, но совмещённый `main` красный). Для self-hosting репозитория `orchestrator` это означало бы красный `main` инструмента, обслуживающего ВСЕ проекты. Новый модуль `src/merge_gate.py` (контракт «never raise», все git-операции — в per-branch worktree, ORCH-2/S-4): `branch_is_behind_main` (`git merge-base --is-ancestor origin/main HEAD`), `auto_rebase_onto_main` (rebase + `git push --force-with-lease` ТОЛЬКО ветки задачи — `main` НИКОГДА не пушится; текстовый конфликт → `rebase --abort` + чистый worktree), `retest_branch` (`python -m pytest <target>` в догнанном worktree, бюджет `merge_retest_timeout_s`), файловый merge-lease (`acquire_merge_lease`/`release_merge_lease`, атомарный `O_CREAT|O_EXCL`, holder-aware release, реклейм протухшего/битого лиза — без изменения схемы БД). Новый quality-gate `check_branch_mergeable` (`src/qg/checks.py`, зарегистрирован в `QG_CHECKS`) композирует примитивы под лизом: kill-switch/вне-области → no-op pass; lock занят → `(False, "merge-lock busy")` (сигнал DEFER, не код-фолт); ветка свежая → pass (лиз ДЕРЖИТСЯ до мержа); отстала → rebase → конфликт = fail+release, чисто → retest → зелёный = pass (лиз держится) / красный|timeout = fail+release. Интеграция в `src/stage_engine.py` (суб-гейт на `deploy-staging`, БЕЗ новой стадии в `STAGE_TRANSITIONS`): pass → advance на `deploy`; «merge-lock busy» → DEFER (повторная постановка деплоера на `deploy-staging` с задержкой `available_at`, анти-дедлок при `max_concurrency=1`, restart-safe счётчик по `task_content`, лимит `merge_defer_max_attempts` → block+Telegram); конфликт/красный retest → ROLLBACK на `development` + ретрай developer-а (кап `MAX_DEVELOPER_RETRIES`, без бесконечного баунса). Лиз освобождается на `deploy→done`, на rollback и по webhook смерженного PR (`src/webhooks/gitea.py`). Новый параметр `enqueue_job(..., available_at_delay_s=...)` (`src/db.py`) — отложенная постановка без изменения схемы. Условность раскатки (зеркало ORCH-35): `merge_gate_repos` (CSV) или по умолчанию только self-hosting `orchestrator`; глобальный kill-switch `merge_gate_enabled`. Новые настройки `ORCH_MERGE_GATE_ENABLED` (true), `ORCH_MERGE_GATE_REPOS` (""), `ORCH_MERGE_RETEST_TIMEOUT_S` (600), `ORCH_MERGE_RETEST_TARGET` (tests/), `ORCH_MERGE_LOCK_TIMEOUT_S` (300), `ORCH_MERGE_DEFER_DELAY_S` (60), `ORCH_MERGE_DEFER_MAX_ATTEMPTS` (5). ADR `docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md`, глобальный `docs/architecture/adr/adr-0006-merge-gate.md`. Тесты: `tests/test_merge_gate.py`, `tests/test_qg_merge_gate.py`, `tests/test_merge_gate_race.py`, `tests/test_stage_engine.py::TestMergeGate`, `tests/test_config.py`.
- **Режим `bump` live-трекера Telegram** (ORCH-042): новый `ORCH_TRACKER_MODE` (`Settings.tracker_mode`, дефолт `edit`) выбирает поведение карточки задачи. `edit` (как было) — карточка редактируется на месте (`editMessageText`). `bump` — на каждом обновлении старое сообщение удаляется и карточка отправляется заново вниз чата (best-effort `delete_telegram(старый_id)``send_telegram(text, disable_notification=True)``set_tracker_message_id(new_id)`), чтобы актуальный статус всегда был последним в чате при активной переписке. Инвариант «одна карточка на задачу» сохранён в обоих режимах: за один вызов `update_task_tracker` шлётся ≤1 нового сообщения; `set_tracker_message_id` вызывается ТОЛЬКО при успешном send (транзиентный `None` не затирает указатель); результат delete НЕ блокирует отправку новой карточки (delete-fail у сообщения >48ч → всё равно шлём новое). Резолюция режима в `notifications` (case-insensitive, trim): всё, что ≠ `"bump"` (включая пустое/мусор) → `edit` → нулевая регрессия и оркестратор не падает на любом значении флага. Новый low-level helper `delete_telegram(message_id) -> bool` (контракт «never raises», маркеры `_DELETE_GONE_MARKERS`): `ok:true` или «уже нет / нельзя удалить» → `True`; неизвестный `ok:false`/5xx/исключение → `False`; нет кредов → `False` без HTTP. Сигнатуры `send_telegram`/`edit_telegram`/`update_task_tracker` и схема БД (`tasks.tracker_message_id`) не менялись. ADR `docs/work-items/ORCH-042/06-adr/ADR-001-tracker-bump-mode.md`. Тесты: `tests/test_tracker_bump.py`, `tests/test_config.py`.
- **Дословный текст findings reviewer/tester встраивается в `task_desc` заворота** (ORCH-046): при откате на `development` строка `task_desc` (попадает в `.task-dev.md` developer-агента) теперь несёт суть претензий, а не только ссылку на файл — устраняет «испорченный телефон», из-за которого агент шёл «читать файл», терял ключевые P0/P1 / причину FAIL и заворачивался снова, выжигая `MAX_DEVELOPER_RETRIES` и токены. Новый defensive-модуль `src/review_parse.py` (контракт «never raise», как `src/frontmatter.py`): `extract_review_findings(path)` — дословные пункты P0/P1 из секции `## Findings` файла `12-review.md`; `extract_test_failures(path)` — релевантный фрагмент тела `13-test-report.md` (приоритет `## Вывод pytest` → FAIL-строки `## Результаты``## Итог`). Обе функции усекают результат до `MAX_FINDINGS_CHARS`/`MAX_FAILURES_CHARS` (≈2000) с маркером `…(truncated)`. Две rollback-ветки `src/stage_engine.py` (reviewer REQUEST_CHANGES, tester `check_tests_passed` FAIL) встраивают извлечённый текст и **сохраняют ссылку** на полный файл («Полный контекст»); при пустом/битом артефакте — graceful-фоллбэк на прежнюю ссылку-строку (никаких исключений в `advance_stage`). Tester-ветка дополнительно всегда включает `reason` гейта. Последовательность отката, `_developer_retry_count`, поля `AdvanceResult` и реестр `QG_CHECKS` не менялись. ADR `docs/work-items/ORCH-046/06-adr/ADR-001-embed-findings-in-task-desc.md`. Тесты: `tests/test_review_parse.py`, `tests/test_stage_engine.py::TestRollbackTaskDescEmbedding`.

View File

@@ -129,6 +129,12 @@ uvicorn src.main:app --reload --port 8500
| `ORCH_TRANSIENT_MAX_ATTEMPTS` | Ретраи для 429/недоступности | `5` |
| `ORCH_BREAKER_THRESHOLD` | transient подряд до открытия breaker | `3` |
| `ORCH_BREAKER_PAUSE_SECONDS` | Пауза при открытом breaker | `300` |
| `ORCH_RECONCILE_ENABLED` | Kill-switch sweeper потерянных webhook (ORCH-053) | `true` |
| `ORCH_RECONCILE_PLANE_ENABLED` | Отдельный флаг F-2 (опрос Plane API) | `true` |
| `ORCH_RECONCILE_INTERVAL_S` | Период фонового прохода reconciler, сек | `120` |
| `ORCH_RECONCILE_GRACE_DEFAULT_S` | Порог «застряла» по `tasks.updated_at`, сек | `600` |
| `ORCH_RECONCILE_GRACE_OVERRIDES_JSON` | Per-stage пороги, напр. `{"development":300}` | `""` |
| `ORCH_RECONCILE_NOTIFY_UNBLOCK` | Telegram при разблокировке застрявшей задачи | `true` |
## Очередь задач (ORCH-1 / F-2b)

View File

@@ -11,6 +11,7 @@
- **Quality Gates** (`src/qg/checks.py`) — проверки выхода со стадии, реестр `QG_CHECKS`.
- **Agent Launcher** (`src/agents/launcher.py`) — запуск Claude CLI агентов в изолированном git worktree, мониторинг, auto-advance.
- **Queue** (`src/queue_worker.py`, ORCH-1) — персистентная очередь задач (SQLite `jobs`), atomic claim, max_concurrency, ретраи, restart-safe.
- **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 не трогает (человеческий гейт). Наблюдаемость — блок `reconcile` в `GET /queue`.
- **Project Registry** (`src/projects.py`, ORCH-6) — Plane project id → repo + prefix; фильтрация вебхуков по проекту.
- **Plane Sync** (`src/plane_sync.py`) — синхронизация статусов/комментариев в Plane.
@@ -52,6 +53,35 @@ created → analysis → architecture → development → review → testing →
Подробнее: [adr-0006](adr/adr-0006-merge-gate.md), детально — `docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md`.
### Reconciler: реконсиляция потерянных webhook (ORCH-053 — реализовано)
Конвейер продвигается только входящими webhook; потерянное событие (502 на ребилде,
нет ретраев у Plane/Gitea, неразрезолвленный `sha→branch`) → задача застревает молча
(инцидент ORCH-044). Фоновый поток `reconciler` периодически (`reconcile_interval_s`)
находит застрявшие задачи и доигрывает пропущенный переход **через те же штатные
гейты/обработчики**, что и webhook:
- **F-1 gate-side:** для задач со `stage∉{done}`, без активного job и
`age(updated_at) ≥ grace_for_stage(stage)` — read-only пред-оценка канонического QG;
зелёный → `stage_engine.advance_stage(..., finished_agent=None)`; красный →
тишина (спам нотификаций структурно невозможен). `analysis` не реконсилируется.
- **F-2 plane-side:** опрос Plane API per-project → `handle_status_start` /
`handle_verdict` из `webhooks/plane.py` (логика не дублируется).
- **F-3:** усиление `sha→branch` в `handle_ci_status` (БД-fallback по единственной
development-задаче repo; неоднозначность → не резолвим).
- **F-4 observability:** при разблокировке — лог-строка `reconciler: <wi> <stage>
разблокирована (потерян webhook)` + Telegram (`reconcile_notify_unblock`); снимок
состояния в `GET /queue` (блок `reconcile`).
Реализация: `src/reconciler.py` (daemon-поток по образцу `queue_worker`), стартует в
`main.lifespan` **после** `worker.start()`, останавливается в `finally` **перед**
`worker.stop()`.
Инварианты: источник истины — гейт/Plane, не событие; идемпотентность (active-job
guard + atomic-claim на создании под process-wide Lock + grace + `max_concurrency=1`);
never-raise на единицу работы; тишина при синхронности; restart-safe; kill-switch
`ORCH_RECONCILE_ENABLED` (+ `ORCH_RECONCILE_PLANE_ENABLED` гасит только F-2). Схема БД
и реестры (`STAGE_TRANSITIONS`/`QG_CHECKS`) не меняются. Подробнее:
[adr-0007](adr/adr-0007-reconciler.md), детально — `docs/work-items/ORCH-053/06-adr/ADR-001-stuck-task-reconciler.md`.
## Откаты
- Reviewer REQUEST_CHANGES → откат на `development` + retry (`MAX_DEVELOPER_RETRIES = 3`).
- Tester `check_tests_passed` FAIL → откат на `development` + retry.
@@ -95,7 +125,7 @@ created → analysis → architecture → development → review → testing →
|--------|------|----------|
| GET | `/health` | health check |
| GET | `/status` | активные задачи (stage != done) |
| GET | `/queue` | очередь: counts + max_concurrency + последние jobs |
| GET | `/queue` | очередь: counts + max_concurrency + resilience + reconcile (ORCH-053) + последние jobs |
| POST | `/webhook/plane` | Plane webhook |
| POST | `/webhook/gitea` | Gitea webhook (push, PR, CI status) |
@@ -109,4 +139,4 @@ created → analysis → architecture → development → review → testing →
Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.md).
---
*Актуально на 2026-06-06. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. ORCH-043: merge-gate — design (см. adr-0006), реализация в ветке feature/ORCH-043.*
*Актуально на 2026-06-06. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. ORCH-043: merge-gate — design (см. adr-0006), реализация в ветке feature/ORCH-043. ORCH-053: reconciler — реализовано (см. adr-0007, src/reconciler.py).*

View File

@@ -11,6 +11,7 @@ Per-work-item решения живут в `docs/work-items/<id>/06-adr/ADR-NNN-
| adr-0004 | Поллинг с ретраем в check_ci_green (фикс CI-race) | accepted | 2026-06-05 | ORCH-045 |
| adr-0005 | Контейнеры бегут под uid:gid хоста (1000:1000) | accepted | 2026-06-06 | ORCH-040 |
| adr-0006 | Merge-gate (догон main + re-test + сериализация слияний) | proposed | 2026-06-06 | ORCH-043 |
| adr-0007 | Reconciler застрявших стадий (sweeper потерянных webhook) | accepted | 2026-06-06 | ORCH-053 |
## Формат
**Контекст → Решение → Альтернативы → Последствия → Связи.** Статус: proposed / accepted / superseded.

View File

@@ -0,0 +1,69 @@
# adr-0007: Reconciler застрявших стадий (sweeper потерянных webhook)
- **Статус:** accepted (реализовано в `src/reconciler.py`)
- **Дата:** 2026-06-06
- **Задача:** ORCH-053
- **Детальный ADR:** `docs/work-items/ORCH-053/06-adr/ADR-001-stuck-task-reconciler.md`
## Контекст
Конвейер продвигается **только** входящими webhook (Plane status / Gitea CI/PR).
Потерянное событие (502 на ребилде, отсутствие ретраев у Plane/Gitea,
неразрезолвленный `sha→branch`) → источник истины изменился, а стадия задачи —
нет; задача застревает молча (инцидент ORCH-044). Существующий resilience
(`requeue_running_jobs`, orphan-recovery, events de-dup ORCH-5, `ci_poll`
ORCH-045) работает на уровне jobs/agent_runs и **не реконсилирует**
рассинхрон «источник истины ≠ стадия задачи».
## Решение
Фоновый daemon-поток `src/reconciler.py` (паттерн `queue_worker`, module-singleton,
`threading.Event`), стартует в `main.lifespan` после `worker.start()`, стоп в
`finally` перед `worker.stop()`. Две взаимодополняющие ветки на каждом тике
(`reconcile_interval_s`, дефолт 120с):
- **F-1 gate-side** (локальная БД): для каждой `task` где `stage∉{done}`, **нет**
активного job, `age(updated_at) ≥ grace_for_stage(stage)` — read-only пред-оценка
канонического QG стадии; если зелёный → продвижение **штатным**
`stage_engine.advance_stage(..., finished_agent=None)` (тот же путь, что у Plane
Approved-webhook). Красный → **тишина** (нет advance, нет нотификаций — спам
структурно невозможен). `analysis` F-1 **не** реконсилирует (человеческий гейт →
отдан F-2).
- **F-2 plane-side** (опрос Plane API per-project через `list_issues_by_state`):
`In Progress`+нет задачи → `handle_status_start`; `Approved`+не сдвинута →
`handle_verdict(approved=True)`; `Rejected`+не откатана →
`handle_verdict(approved=False)`. Обработчики `webhooks/plane.py`
**переиспользуются** (async → `asyncio.run` из sync-потока), логика не дублируется.
- **F-3:** усиление `sha→branch` в `handle_ci_status` (БД-fallback по
`repo`+`stage='development'`, видимость на INFO) — defense-in-depth.
**Инварианты:** источник истины — гейт/Plane, не событие; продвижение только через
`advance_stage`; идемпотентность (active-job guard + atomic-claim на создании +
grace + `max_concurrency=1`); never-raise на единицу работы; тишина при
синхронности; restart-safe; kill-switch.
## Альтернативы
- **Флаг подавления нотификаций в `advance_stage`** — отклонён: меняет общий
критический путь. Вместо этого «не вызывать advance_stage на красном гейте».
- **UNIQUE-индекс `tasks.plane_id`** для анти-дубля — отклонён как primary: риск
падения миграции на проде; выбран process-wide `threading.Lock` (single-process
топология). Индекс — задокументированное будущее упрочнение для multi-process.
- **Отдельная стадия/QG реконсиляции** — вне объёма; нарушает «источник истины —
существующий гейт».
- **Реконсиляция analysis по локальным артефактам** — отклонена: автопродвижение
неодобренного человеком BRD.
## Последствия
- Потерянный webhook ≠ молча застрявшая задача; ручной heartbeat-watchdog не нужен;
резервная сетка к ORCH-51 (буфер недоставленных) и ORCH-36 (deploy).
- Плата: фоновый поток + опрос Plane API (митигируется интервалом/фильтром/
per-project); двойная оценка гейта на зелёной задаче; анти-дубль опирается на
single-process-допущение (как и очередь ORCH-1).
- Self-hosting: `reconcile_enabled` — обязательный kill-switch; поэтапный раскат
(`reconcile_plane_enabled` гасит только F-2); reconciler не рестартит/не роняет
прод-контейнер. БД-схема и реестры (`STAGE_TRANSITIONS`/`QG_CHECKS`) не меняются.
## Связи
adr-0002 (очередь / `available_at`, single-process-singleton), adr-0003 (условный
гейт — образец условности/флагов раската), adr-0006 (merge-gate как под-гейт ребра
внутри `advance_stage`), adr-0001 (реестр проектов для F-2 per-project), ORCH-5
(events de-dup — защита от дублей; reconciler — обратная защита от потерь),
ORCH-045 (`ci_poll`).

View File

@@ -75,6 +75,12 @@ ADR `docs/work-items/ORCH-040/06-adr/ADR-001-run-agents-as-host-uid.md` и гл
| `ORCH_AGENT_EFFORT_DEFAULT` | режим работы `--effort` по умолчанию (ORCH-41): low\|medium\|high\|xhigh\|max; дефолт `high` |
| `ORCH_AGENT_EFFORT_<AGENT>` | per-agent effort; дефолт: думающие → high, tester/deployer → medium |
| `ORCH_AGENT_FALLBACK_MODEL` | опц. фолбэк-модель при overloaded (`--fallback-model`); пусто → без флага |
| `ORCH_RECONCILE_ENABLED` | kill-switch sweeper потерянных webhook (ORCH-053); дефолт `true`. **При инциденте/раскатке**`false` глушит весь фоновый reconciler |
| `ORCH_RECONCILE_PLANE_ENABLED` | отдельный флаг F-2 (опрос Plane API); `false` гасит только plane-ветку, F-1 продолжает работать; дефолт `true` |
| `ORCH_RECONCILE_INTERVAL_S` | период фонового прохода reconciler, сек; дефолт `120` |
| `ORCH_RECONCILE_GRACE_DEFAULT_S` | порог «застряла» по `tasks.updated_at`, сек; дефолт `600` |
| `ORCH_RECONCILE_GRACE_OVERRIDES_JSON` | per-stage пороги, напр. `{"development":300}`; невалидный JSON → дефолт |
| `ORCH_RECONCILE_NOTIFY_UNBLOCK` | слать Telegram при разблокировке застрявшей задачи; дефолт `true` |
| `DEPLOY_SSH_USER` / `_HOST` / `DEPLOY_HOOK_SCRIPT` | параметры деплой-хука |
**Секреты — только в `.env` / `.env.staging` на хосте, в гит НЕ коммитятся.** Канон — `.env.example`, `.env.staging.example`.

View File

@@ -0,0 +1,7 @@
# Business Request: Sweeper потерянных webhook: реконсиляция застрявших стадий (stuck-task)
Work Item ID: ORCH-053
## Description
TBD

View File

@@ -0,0 +1,128 @@
# BRD — ORCH-053: Sweeper потерянных webhook (реконсиляция застрявших стадий)
Work Item ID: ORCH-053
Стадия: analysis → (architecture)
Тип: надёжность конвейера (проектирование + реализация). Self-hosting (ORCH).
## 1. Проблема (бизнес-контекст)
Продвижение задач между стадиями конвейера завязано **исключительно** на входящие
webhook-события:
- **Plane** (`work_item.updated` → статус In Progress / Approved / Rejected) — единственный
триггер старта задачи, advance и rollback (`src/webhooks/plane.py`).
- **Gitea** (CI-status `success`/`failure`, push, PR reviewed/merged) — триггер
development→review, architecture→development, review→testing, deploy→done
(`src/webhooks/gitea.py`).
Если входящее событие **потеряно** (502 на падающем/ребилдящемся инстансе, Plane/Gitea
не повторяют доставку, сетевой сбой, sha→branch не разрезолвился, вебхук был временно
выключен) — статус в источнике истины (Plane / зелёный CI) уже изменился, **а задача в
оркестраторе не сдвинулась**. Задача висит молча, без какого-либо механизма восстановления.
**Живой инцидент (ORCH-044, 06.06):** dev-агент отработал (exit 0, CI позеленел), но
Gitea webhook о CI-success не продвинул задачу (не дошёл / не сматчился sha→branch).
Задача висела бы на `development` молча навсегда — спасли только ручным дёрганьем гейта
`check_ci_green`. Это **системная дыра**, а не разовый сбой; сейчас её ловит ручной
heartbeat-watchdog Стрима (костыль).
### Что уже есть и почему недостаточно
| Механизм | Что покрывает | Почему не закрывает дыру |
|----------|---------------|--------------------------|
| `requeue_running_jobs()` (startup) | зависшие **jobs** при рестарте | про jobs, не про застрявший **stage-переход** |
| orphan-recovery (`main.py`) | `agent_runs` без `finished_at` | job-уровень, не stage |
| ORCH-5 events de-dup (`delivery_id`) | защита от **дублей** webhook | обратной защиты от **потери** нет |
| ORCH-045 `ci_poll` в `check_ci_green` | поллит CI 12×10с | только **если гейт уже вызван** webhook'ом; не пришёл webhook → гейт не вызывается |
Общий принцип всех существующих механизмов — restart-safe resilience на уровне jobs.
**Нет ни одного механизма, реконсилирующего рассинхрон «источник истины ≠ стадия задачи».**
## 2. Цель
Задача **не должна застревать молча** из-за потерянного входящего события. Ввести
фоновый периодический **sweeper / reconciler**, который сам находит «зависшие» задачи
и доигрывает пропущенный переход — через **те же штатные гейты и обработчики**, что и
webhook (никакой параллельной логики продвижения). Убрать необходимость в ручном
heartbeat-watchdog.
## 3. Заинтересованные стороны
- **Owner / Стрим (Слава)** — перестаёт ловить зависания вручную.
- **Все проекты на инстансе** (enduro-trails + orchestrator) — конвейер не встаёт молча.
- **Self-hosting (ORCH)** — особенно при ребилде прода (ORCH-51): вебхуки, прилетевшие
на падающий инстанс, подбираются реконсиляцией после старта.
## 4. Объём (Scope)
В объёме — **две взаимодополняющие ветки реконсиляции** (обе обязательны):
### F-1. Gate-side sweeper (реконсиляция застрявшей стадии по локальной БД)
Периодический проход по таблице `tasks`: найти задачи, у которых
(а) `stage != done`, (б) нет активных job'ов в очереди, (в) с момента `updated_at`
прошло больше **per-stage порога** → пере-проверить QG текущей стадии и, если passed —
продвинуть **штатным путём** (`stage_engine.advance_stage(..., finished_agent=None)`,
тот же путь, что использует webhook). Закрывает потерю Gitea CI/PR-вебхуков (ORCH-044).
### F-2. Plane-side reconciler (реконсиляция потерянного Plane status-webhook)
Периодический опрос Plane API по проектам реестра (`projects.py`): issues в статусах,
требующих действия (In Progress / Approved / Rejected). Сверить с локальной `tasks` и
доиграть **через существующие обработчики `webhooks/plane.py`**:
- **In Progress + нет задачи в БД** → создать+запустить (`handle_status_start`/`start_pipeline`);
- **Approved + стадия не сдвинута** → advance (`handle_verdict(approved=True)`);
- **Rejected + не откатана** → rollback (`handle_verdict(approved=False)`).
### F-3. Усиление sha→branch резолва в Gitea-вебхуке
В `handle_ci_status` добавить надёжный fallback (поиск task по БД), чтобы исходный
webhook реже терялся из-за неразрезолвленного branch. Sweeper работает от задачи
(repo+branch известны из БД) и обходит эту хрупкость по определению.
### F-4. Наблюдаемость
Лог (и опц. Telegram) каждый раз, когда sweeper **разблокировал** застрявшую задачу —
чтобы видеть частоту срабатывания дыры (метрика потерянных webhook). Опц. вывод
счётчика в `/queue` или `/reconcile`. Не спамить, когда всё синхронно.
### Вне объёма
- Буфер недоставленных webhook (это ORCH-51; sweeper — резервная сетка к нему).
- Изменение состава стадий/гейтов (`STAGE_TRANSITIONS`, `QG_CHECKS`).
- Изменение логики самих гейтов и обработчиков (только переиспользование).
- Новый исполняемый деплой (ORCH-36).
## 5. Ключевые требования (бизнес-уровень)
1. **Источник истины — гейт/Plane, а не событие.** Sweeper дёргает ровно те же функции
продвижения, что и webhook. Параллельной логики продвижения быть не должно.
2. **Идемпотентность (критично).** Задержавшийся или дублированный webhook + sweeper
НЕ создают двойную задачу / двойной запуск / двойной advance. Тот же guard, что у
webhook: нет активного job + стадия совпадает + atomic claim как в `queue_worker`.
3. **Безопасность активной работы.** Sweeper НЕ трогает задачи с активными
(`queued`/`running`) job'ами — они легитимно в работе, не потеряны.
4. **Per-stage grace.** Разные стадии имеют разное нормальное время (analysis ~815 мин
vs deploy). Порог застревания настраивается, чтобы не дёргать гейт у задачи, где агент
законно работает.
5. **Restart-safe.** Sweeper — фоновый поток, стартует с приложением, переживает рестарт
(как `queue_worker`). Без потери состояния.
6. **Self-hosting safety.** Sweeper не должен ронять/рестартить прод-контейнер; kill-switch
в конфиге для поэтапного раската и аварийного отключения.
7. **Без шума.** Когда всё синхронно — никаких действий и нотификаций.
8. **Документация = golden source.** README/architecture, ADR, CHANGELOG обновляются в
том же PR.
## 6. Эффект
- Потерянный webhook больше не = молча застрявшая задача.
- Ручной heartbeat-watchdog Стрима больше не нужен для ловли зависаний (AC-5 в эпике).
- Резервная сетка к ORCH-51 при ребилде прода.
## 7. Связи
- **Дополняет ORCH-51** (потеря webhook при рестарте — буфер; sweeper — реконсиляция).
- **Дополняет ORCH-36** (если deploy-webhook потеряется — sweeper добьёт deploy→done).
- **ORCH-1b** — та же философия resilience: транзиентный сбой не убивает задачу.
- Эпик: звено **ORCH-54** (автономное внедрение). Параллельна ORCH-36 (разные файлы),
но `max_concurrency=1` → встанет в очередь.
## 8. Риски (кратко; подробно — 10-tech-risks архитектора)
- **Гонка sweeper ↔ живой webhook** → двойной запуск. Митигируется atomic claim +
active-job guard + grace-период (не конкурировать с задержавшимся webhook).
- **Spam нотификаций** при персистентно красном гейте на каждом тике. Митигируется:
действие/нотификация только на изменении состояния (advance), не на каждый тик.
- **Нагрузка на Plane API** при опросе каждые N сек. Митигируется интервалом + фильтром
по статусам + per-project.
- **Self-hosting:** sweeper правит инструмент, обслуживающий и другие проекты. Kill-switch
обязателен.

View File

@@ -0,0 +1,170 @@
# ТЗ — ORCH-053: Sweeper потерянных webhook (реконсиляция застрявших стадий)
Work Item ID: ORCH-053
Базовая ветка: `feature/ORCH-053-sweeper-webhook-stuck-task`
> Это ТЗ фиксирует **конкретные изменения кода/конфига/доки**. Архитектурные развилки
> (потокобезопасность, точная схема дампинга нотификаций, способ вызова async-обработчиков
> из sync-потока) фиксирует архитектор в `06-adr/`. Если ТЗ окажется негодным — возврат в
> Анализ (не комментировать задним числом).
## 0. Живая разведка ПЕРЕД реализацией (обязательна)
Перед кодом разработчик обязан вживую проверить (как сейчас webhook продвигает стадию):
- `src/webhooks/gitea.py::handle_ci_status` (success-ветка ~стр.199217) и `handle_pr`;
- `src/webhooks/plane.py::handle_issue_updated / handle_status_start / handle_verdict / start_pipeline`;
- `src/stage_engine.py::advance_stage` (унифицированный путь, `finished_agent=None` = webhook-путь);
- `src/queue_worker.py` (образец фонового daemon-потока + `threading.Event` + atomic claim);
- `src/db.py::has_active_job_for_task / claim_next_job / update_task_stage` (`updated_at`).
## 1. Задействованные модули `src/`
| Модуль | Изменение |
|--------|-----------|
| `src/reconciler.py` | **НОВЫЙ.** Фоновый sweeper/reconciler (класс + module-singleton, паттерн `queue_worker`). Обе ветки F-1 (gate-side) и F-2 (plane-side). |
| `src/config.py` | Новые настройки `reconcile_*` (интервал, kill-switch, per-stage grace, plane-poll flag). |
| `src/main.py` | Старт/стоп reconciler в `lifespan` (после `worker.start()` / перед `worker.stop()`). |
| `src/stage_engine.py` | Тонкий хелпер `advance_if_gate_passed(...)` (или `reconcile_advance`) — обёртка над `advance_stage(..., finished_agent=None)`, **подавляющая повторный спам нотификаций** при провале гейта (продвижение — переиспользуется как есть). |
| `src/plane_sync.py` | НОВЫЙ хелпер `list_issues_by_state(project_id, state_uuids) -> list[dict]` (GET issues с пагинацией, фильтр по state). Используется F-2. |
| `src/webhooks/gitea.py` | F-3: усилить sha→branch резолв в `handle_ci_status` (fallback на БД-поиск task), логировать неразрезолв на уровне INFO (видимость). |
| `src/webhooks/plane.py` | F-2 переиспользует `handle_issue_updated` / `handle_status_start` / `handle_verdict` **без дублирования** логики (возможно, лёгкий рефактор для вызова из reconciler). |
| `src/main.py` (API) | F-4 (опц.): расширить `/queue` блоком reconcile-метрик или добавить `GET /reconcile`. |
## 2. F-1 — Gate-side sweeper (реконсиляция по локальной БД)
### Алгоритм одного прохода (`reconcile_gate_once()`)
```
для каждой task где stage NOT IN ('done',) :
если has_active_job_for_task(task.id): continue # в работе — не трогаем
если get_qg_for_stage(task.stage) is None: continue # created/done — нет гейта
grace = grace_for_stage(task.stage)
если age(task.updated_at) < grace: continue # ещё не «застряла»
# источник истины — гейт; путь продвижения — штатный
advance_if_gate_passed(task.id, task.stage, task.repo, task.work_item_id, task.branch)
```
- **Продвижение** идёт через `stage_engine.advance_stage(task_id, stage, repo, work_item_id,
branch, finished_agent=None)` — это **тот же** путь, которым пользуется Plane Approved-webhook
(`webhooks/plane._try_advance_stage`). Никакой параллельной логики advance.
- Для `development` → `advance_stage` прогонит `check_ci_green`; passed → `review` + enqueue
`reviewer`. Для `review` → `check_reviewer_verdict` (канонический гейт стадии из
`STAGE_TRANSITIONS`, читает `verdict:` из `12-review.md`). Для `testing` → `check_tests_passed`.
Для `deploy` → `check_deploy_status`. Для `deploy-staging` → `check_staging_status`
(+ merge-gate sub-gate отрабатывает внутри `advance_stage` как обычно).
- **Стадия `analysis`** (gQG `check_analysis_approved`): это **человеческий** гейт. В
`advance_stage` при `finished_agent=None` он трактуется как `approved-via-status` и
продвинет задачу — чего при потере именно **Approved**-webhka мы и хотим **только** если
Plane реально в статусе Approved. Поэтому **F-1 НЕ реконсилирует `analysis`** (advance
для analysis отдаётся F-2, которая сверяется с реальным статусом Plane). Архитектор
фиксирует это решение в ADR (защита от ложного продвижения неодобренного BRD).
### Подавление спама нотификаций (`advance_if_gate_passed`)
- Если гейт **passed** → `advance_stage` продвигает и шлёт штатные нотификации advance.
- Если гейт **failed** → НЕ повторять `notify_qg_failure`/`plane_notify_qg` на каждом тике.
Хелпер вызывает `advance_stage` так, чтобы при провале была **тишина** (лог `INFO`/`DEBUG`),
либо реализует продвижение, минуя ветку нотификации провала. Точную форму (флаг в
`advance_stage` vs отдельный путь оценки гейта) выбирает архитектор; контракт:
**на застрявшей-но-красной задаче sweeper не спамит**.
### Защита от гонки
- `has_active_job_for_task` + `update_task_stage` обновляет `updated_at` → следующий тик
увидит свежий `updated_at` и не сработает повторно.
- Если в момент тика прилетел живой webhook и поставил job — sweeper увидит активный job и
пропустит задачу.
- `max_concurrency=1`: новый enqueued job встанет в общую очередь (без двойного запуска).
## 3. F-2 — Plane-side reconciler (опрос Plane API)
### Алгоритм одного прохода (`reconcile_plane_once()`)
```
для каждого проекта p в projects.PROJECTS:
states = get_project_states(p.plane_project_id)
for issue in list_issues_by_state(p.plane_project_id,
[states['in_progress'], states['approved'], states['rejected']]):
task = get_task_by_plane_id(issue.id)
new_state = issue.state
# идемпотентность: пропускаем, если есть активный job (живой webhook вот-вот придёт/в работе)
если task and has_active_job_for_task(task.id): continue
# доигрываем потерянный переход ЧЕРЕЗ существующие обработчики plane.py
if new_state == in_progress and task is None: -> handle_status_start(issue_data, p.plane_project_id)
elif new_state == approved and task and stage не сдвинут: -> handle_verdict(issue_data, ..., approved=True)
elif new_state == rejected and task and не откатана: -> handle_verdict(issue_data, ..., approved=False)
else: continue # всё синхронно — тишина
```
- **Переиспользовать** `handle_issue_updated`/`handle_status_start`/`handle_verdict` из
`webhooks/plane.py`. Они `async` → reconciler (sync-поток) вызывает их через
`asyncio.run(...)` либо собственный event loop. Способ — на усмотрение архитектора;
**дублировать логику запрещено**.
- `issue_data` собирается в форму, ожидаемую обработчиками (`{"id", "state": {"id":...},
"project", "name", "description_stripped"}`). Недостающие поля (name/description)
обработчики сами дотягивают через `fetch_issue_fields` (как сейчас для status-only вебхука).
- **Grace для F-2:** не реагировать на issue, чей статус сменился совсем недавно (вебхук мог
просто задержаться). Источник «давности» — поле времени из Plane (`updated_at`) и/или
локальный grace по `tasks.updated_at`. Архитектор фиксирует точный критерий «потерян, а не
задержан».
- **Идемпотентность создания (In Progress без задачи):** `start_pipeline` уже защищён
(`handle_status_start` создаёт только если `get_task_by_plane_id` пуст). Гонка sweeper↔webhook
на создании: оба пройдут проверку «нет задачи» одновременно → возможен дубль. Требование:
использовать тот же claim-механизм / уникальность (как `ensure_unique_work_item_id` +
проверка существования под защитой). Архитектор обязан описать atomic-claim на создании в ADR.
### `list_issues_by_state` (новый в `plane_sync.py`)
- `GET {PLANE_BASE}/workspaces/{WORKSPACE}/projects/{pid}/issues/` с фильтром по state
(через query-параметр Plane, либо постфильтрация результата по `issue.state`).
- Пагинация (`results` + cursor/next) — обойти все страницы.
- Never-raise: при ошибке API/сети → `[]` + лог `warning` (Plane outage деградирует мягко,
не роняет тик).
## 4. F-3 — Усиление sha→branch резолва (`webhooks/gitea.py::handle_ci_status`)
- Текущая цепочка: `branches[0].name` → `git branch -r --contains <sha>`. Добавить
fallback **на БД**: если branch не определён, найти task по `repo` среди активных
(`stage='development'`) и, при однозначности, использовать её branch; иначе — оставить
неразрезолвленным.
- Заменить `logger.debug("could not determine branch...")` на `logger.info(...)` (видимость
потери). Sweeper (F-1) всё равно подберёт такую задачу — это defense-in-depth, не критпуть.
- **Не менять** success/failure-семантику гейта.
## 5. Конфигурация (`src/config.py`, env-prefix `ORCH_`)
| Поле | Дефолт | Назначение |
|------|--------|-----------|
| `reconcile_enabled` | `True` | глобальный kill-switch sweeper'а (self-hosting safety, поэтапный раскат). |
| `reconcile_interval_s` | `120` | период фонового прохода (сек). |
| `reconcile_plane_enabled` | `True` | отдельный флаг для F-2 (опрос Plane API), чтобы можно было гасить только plane-ветку. |
| `reconcile_grace_default_s` | `600` | дефолтный порог «застревания» по `tasks.updated_at`. |
| `reconcile_grace_overrides_json` | `""` | JSON-объект per-stage порогов, напр. `{"analysis": 1800, "development": 300, "deploy": 900}`. Невалидный JSON → дефолт (как `agent_timeout_overrides_json`). |
| `reconcile_notify_unblock` | `True` | слать Telegram при разблокировке (F-4). |
`grace_for_stage(stage)` = override из JSON, иначе `reconcile_grace_default_s`.
## 6. БД
- **Изменения схемы НЕ требуются** (предпочтительно, по образцу merge-gate ORCH-043).
Стуковость определяется по существующим `tasks.updated_at`, `tasks.stage` и таблице `jobs`
(`has_active_job_for_task`). `update_task_stage` уже обновляет `updated_at`.
- Если архитектор сочтёт необходимым анти-дребезг (`tasks.last_reconcile_at`) — допускается
идемпотентная миграция через `_ensure_column` (как остальные ALTER в `db.py`). По умолчанию
— **без новых колонок**.
## 7. API (опционально, F-4)
- Расширить `GET /queue` блоком `"reconcile": {...}` (enabled, interval, last_run_ts,
unblocked_total, last_unblocked) — по образцу `worker.status()`.
- ИЛИ добавить `GET /reconcile` с теми же метриками. Выбор — архитектор. Не обязательно для
прохождения AC, но крайне желательно для наблюдаемости.
## 8. Новые QG checks
- **Нет.** Sweeper переиспускает существующие гейты из `QG_CHECKS` через `advance_stage`.
Реестр `QG_CHECKS` и `STAGE_TRANSITIONS` не меняются.
## 9. Артефакты pipeline / документация (обязательно в ЭТОМ PR)
- `docs/architecture/README.md` — раздел про reconciler (компонент + место в resilience-слое).
- `docs/work-items/ORCH-053/06-adr/ADR-001-*.md` — архитектурное решение (потоки, гонки,
async-вызов обработчиков, подавление спама, grace-критерий, atomic-claim на создании).
- `CHANGELOG.md` — запись `feat: ORCH-053 stuck-task reconciler`.
- При желании архитектора — global ADR в `docs/architecture/adr/` (сквозной resilience).
- `docs/operations/INFRA.md` — упомянуть kill-switch `ORCH_RECONCILE_ENABLED` (self-hosting).
## 10. Нефункциональные требования
- **Never-raise в тике:** исключение в обработке одной задачи/issue не должно ронять весь
проход (изолировать try/except на единицу работы, как `queue_worker._drain_once`).
- **Идемпотентность** — см. §2/§3.
- **Restart-safe** — daemon-поток + `threading.Event`, чистый `stop()` в `lifespan.finally`.
- **Тишина при синхронности** — нет действий → нет логов уровня INFO/нотификаций.
- **Тесты** — см. `04-test-plan.yaml` (моки Plane/Gitea API и QG, без реальной сети).

View File

@@ -0,0 +1,116 @@
# Acceptance Criteria — ORCH-053
Work Item ID: ORCH-053
Формат: каждый критерий имеет явное условие PASS/FAIL. Критерий считается выполненным,
только если соответствующие тесты из `04-test-plan.yaml` зелёные.
## AC-1 — Реконсиляция застрявшей стадии (gate-side, F-1)
- **Дано:** task на стадии `development`, без активных job'ов, `updated_at` старше grace,
гейт `check_ci_green` для её branch — зелёный (CI прошёл, но webhook потерян, как ORCH-044).
- **Когда:** срабатывает фоновый проход `reconcile_gate_once()`.
- **PASS:** задача продвинута `development → review`, заenqueuen `reviewer` (через
`advance_stage(..., finished_agent=None)`), `tasks.updated_at` обновлён.
- **FAIL:** задача осталась на `development`, либо продвижение пошло параллельной логикой
(не через `advance_stage`).
## AC-2 — Источник истины — гейт, не событие
- **PASS:** продвижение в F-1 выполняется исключительно вызовом
`stage_engine.advance_stage(...)`; в `reconciler.py` НЕТ собственного
`update_task_stage`+`enqueue_job` для advance стадии (только переиспользование).
- **FAIL:** в reconciler продублирована логика advance/rollback.
## AC-3 — Идемпотентность: sweeper не трогает задачи с активным job
- **Дано:** task с `queued` или `running` job (`has_active_job_for_task == True`).
- **PASS:** sweeper пропускает задачу — ни advance, ни enqueue, ни нотификации.
- **FAIL:** sweeper дёргает гейт / создаёт второй job для такой задачи.
## AC-4 — Идемпотентность: задержавшийся/дублированный webhook + sweeper не двоят
- **Дано:** issue в Plane = In Progress, задержавшийся Plane-webhook ещё не обработан.
- **Когда:** F-2 реконсилирует И затем (или одновременно) приходит реальный webhook.
- **PASS:** создаётся **ровно одна** задача (один task row, один branch/worktree, один
стартовый analyst-job). Повторный путь видит существующую задачу/активный job и не двоит.
- **FAIL:** созданы две задачи / два стартовых job / два worktree на один `plane_id`.
## AC-5 — Per-stage grace соблюдается
- **Дано:** task на стадии, чей `updated_at` свежее grace этой стадии (агент легитимно
работает, напр. analysis 8 мин при grace 1800с).
- **PASS:** sweeper НЕ трогает задачу (не дёргает гейт).
- **PASS (граница):** как только `age(updated_at) >= grace_for_stage(stage)` и нет активного
job — задача становится кандидатом.
- **FAIL:** sweeper дёргает гейт у задачи в пределах grace.
## AC-6 — Plane In Progress без задачи → запуск (F-2)
- **Дано:** issue в Plane = In Progress (статус сменён руками, webhook потерян), в `tasks`
задачи нет, прошёл grace.
- **PASS:** sweeper вызывает `handle_status_start`/`start_pipeline` → задача создана,
заenqueuen analyst — как если бы пришёл webhook.
- **FAIL:** задача не создана; либо создана дублирующей логикой, минуя `handle_status_start`.
## AC-7 — Plane Approved без advance → advance (F-2)
- **Дано:** issue = Approved, task существует и стадия НЕ сдвинута, нет активного job, прошёл grace.
- **PASS:** sweeper вызывает `handle_verdict(approved=True)` → штатный advance.
- **FAIL:** нет advance, либо advance вне `handle_verdict`/`advance_stage`.
## AC-8 — Plane Rejected без rollback → rollback (F-2)
- **Дано:** issue = Rejected, task существует и не откатана, нет активного job, прошёл grace.
- **PASS:** sweeper вызывает `handle_verdict(approved=False)` → штатный rollback на предыдущую стадию.
- **FAIL:** нет rollback, либо rollback вне штатного пути.
## AC-9 — Нет спама нотификаций на красном гейте
- **Дано:** застрявшая задача, у которой гейт стабильно **красный** (напр. CI failure),
нет активного job, прошёл grace.
- **Когда:** sweeper проходит несколько тиков подряд.
- **PASS:** `notify_qg_failure`/Telegram НЕ вызывается на каждом тике (≤1 раз / без
повторов); задача не продвигается.
- **FAIL:** на каждом тике летит нотификация о провале гейта.
## AC-10 — Тишина при синхронности
- **Дано:** все задачи синхронны (нет застрявших; статусы Plane совпадают с локальными).
- **PASS:** проход не выполняет действий, не пишет INFO-логов о разблокировке, не шлёт нотификаций.
- **FAIL:** sweeper генерирует шум/действия при полностью синхронном состоянии.
## AC-11 — Restart-safe фоновый поток
- **PASS:** reconciler стартует в `main.lifespan` (daemon-поток), корректно
останавливается (`stop()`), переживает рестарт сервиса без потери (нет состояния в памяти,
критичного для корректности; всё перечитывается из БД/Plane).
- **FAIL:** reconciler не стартует автоматически, висит при shutdown, или дублирует действия
после рестарта.
## AC-12 — Наблюдаемость разблокировки (F-4)
- **Дано:** sweeper разблокировал застрявшую задачу.
- **PASS:** в лог пишется явная строка вида
`reconciler: <work_item_id> <stage> разблокирована (потерян webhook)`;
при `reconcile_notify_unblock=True` — Telegram-уведомление.
- **FAIL:** разблокировка происходит молча (невозможно измерить частоту дыры).
## AC-13 — Kill-switch
- **Дано:** `reconcile_enabled=False` (env `ORCH_RECONCILE_ENABLED=false`).
- **PASS:** фоновый поток reconciler не выполняет проходов (или не стартует); система
работает как до ORCH-053. `reconcile_plane_enabled=False` гасит только F-2, F-1 работает.
- **FAIL:** sweeper активен при выключенном флаге.
## AC-14 — Усиленный sha→branch резолв (F-3)
- **Дано:** Gitea CI-status webhook без `branches` и со `sha`, не разрезолвившимся
через `git branch -r --contains`.
- **PASS:** добавленный БД-fallback однозначно находит task (по repo + активной
development-стадии) и продвигает; неоднозначность логируется на уровне INFO; существующая
success/failure-семантика гейта не изменена.
- **FAIL:** регресс существующего резолва, либо ложный матч при неоднозначности.
## AC-15 — Never-raise в тике
- **Дано:** обработка одной задачи/issue кидает исключение (битые данные, ошибка API).
- **PASS:** исключение изолировано, проход продолжает остальные задачи; поток не падает.
- **FAIL:** одно исключение роняет весь проход / поток reconciler.
## AC-16 — F-1 не продвигает analysis по локальному состоянию
- **Дано:** task на `analysis`, артефакты на диске присутствуют, но Plane НЕ в статусе
Approved (BRD не одобрен человеком), нет активного job, прошёл grace.
- **PASS:** F-1 (gate-side) НЕ продвигает analysis→architecture (advance стадии analysis
отдан F-2, которая сверяется с реальным статусом Plane Approved).
- **FAIL:** sweeper автопродвинул неодобренный BRD.
## AC-17 — Документация обновлена (golden source)
- **PASS:** в PR обновлены `docs/architecture/README.md`, заведён
`docs/work-items/ORCH-053/06-adr/ADR-001-*.md`, обновлён `CHANGELOG.md`, упомянут
kill-switch в `docs/operations/INFRA.md`.
- **FAIL:** код изменён, документация — нет (Reviewer обязан вернуть REQUEST_CHANGES).

View File

@@ -0,0 +1,200 @@
work_item: ORCH-053
description: >
Тесты sweeper/reconciler потерянных webhook. Вся сеть (Plane API, Gitea API, QG)
мокируется (monkeypatch), как в существующих tests/. Telegram заглушён autouse-фикстурой
conftest. Используется временная SQLite БД (ORCH_DB_PATH / фикстура setup_db по образцу
test_webhooks.py / test_queue.py). Реальные агенты/CLI не запускаются.
tests:
# ---- F-1: gate-side sweeper -------------------------------------------------
- id: TC-01
type: unit
description: >
reconcile_gate_once продвигает застрявшую development-задачу: нет активных job,
updated_at старше grace, check_ci_green замокан в (True, "CI green") →
advance_stage вызван, стадия стала review, заenqueuen reviewer.
module: tests/test_reconciler.py
expected: PASS
- id: TC-02
type: unit
description: >
Источник истины — гейт: reconciler НЕ содержит собственного update_task_stage/
enqueue_job для advance — продвижение идёт строго через stage_engine.advance_stage
(проверка через мок/spy advance_stage, вызван с finished_agent=None).
module: tests/test_reconciler.py
expected: PASS
- id: TC-03
type: unit
description: >
Задача с активным job (has_active_job_for_task=True) пропускается: гейт не дёргается,
advance_stage не вызывается, нотификаций нет.
module: tests/test_reconciler.py
expected: PASS
- id: TC-04
type: unit
description: >
Per-stage grace: задача с updated_at свежее grace своей стадии не трогается;
ровно на границе age>=grace и без активного job — становится кандидатом.
module: tests/test_reconciler.py
expected: PASS
- id: TC-05
type: unit
description: >
grace_for_stage читает reconcile_grace_overrides_json (per-stage), при отсутствии
ключа — reconcile_grace_default_s; невалидный JSON → дефолт, не падает.
module: tests/test_reconciler.py
expected: PASS
- id: TC-06
type: unit
description: >
Нет спама: при стабильно красном гейте (check_ci_green=(False,...)) несколько проходов
подряд НЕ вызывают notify_qg_failure повторно на каждом тике; задача не продвигается.
module: tests/test_reconciler.py
expected: PASS
- id: TC-07
type: unit
description: >
Тишина при синхронности: когда все задачи done / имеют активный job / в пределах grace —
проход не вызывает advance_stage и не пишет INFO-логов о разблокировке.
module: tests/test_reconciler.py
expected: PASS
- id: TC-08
type: unit
description: >
AC-16: задача на analysis с артефактами на диске, но Plane НЕ Approved — F-1
(reconcile_gate_once) НЕ продвигает analysis→architecture.
module: tests/test_reconciler.py
expected: PASS
- id: TC-09
type: unit
description: >
Never-raise: если обработка одной задачи кидает исключение (advance_stage замокан на
raise), проход ловит его и продолжает обрабатывать остальные задачи; поток не падает.
module: tests/test_reconciler.py
expected: PASS
- id: TC-10
type: unit
description: >
Kill-switch: при reconcile_enabled=False reconcile_gate_once/plane_once не выполняют
действий (no-op); при reconcile_plane_enabled=False гасится только F-2.
module: tests/test_reconciler.py
expected: PASS
# ---- F-2: plane-side reconciler --------------------------------------------
- id: TC-11
type: unit
description: >
In Progress без задачи: list_issues_by_state возвращает issue в In Progress, в БД задачи
нет → reconcile_plane_once вызывает handle_status_start (мок) ровно один раз с корректным
issue_data (id/state/project).
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-12
type: unit
description: >
Approved без advance: issue=Approved, task существует, нет активного job → вызван
handle_verdict(approved=True) (мок) один раз.
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-13
type: unit
description: >
Rejected без rollback: issue=Rejected, task существует, нет активного job → вызван
handle_verdict(approved=False) (мок) один раз.
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-14
type: unit
description: >
Идемпотентность F-2: issue в требующем-действия статусе, но у task есть активный job →
handle_status_start/handle_verdict НЕ вызываются (живой webhook в работе).
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-15
type: integration
description: >
AC-4 анти-дубль на создании: одновременная реконсиляция + обработка реального In Progress
webhook для одного plane_id создают ровно ОДИН task row и один стартовый analyst-job
(реальная временная БД, мок Gitea/Plane сетевых вызовов).
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-16
type: unit
description: >
list_issues_by_state never-raise: при ошибке Plane API (httpx бросает / non-2xx) →
возвращает [], тик не падает; при успехе — обходит пагинацию и фильтрует по state.
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-17
type: unit
description: >
F-2 опрашивает все проекты реестра projects.PROJECTS и резолвит state-uuid через
get_project_states per-project (enduro + orchestrator), не хардкодит uuid.
module: tests/test_reconciler_plane.py
expected: PASS
# ---- F-3: sha→branch резолв -------------------------------------------------
- id: TC-18
type: unit
description: >
handle_ci_status: при отсутствии branches и неразрезолвленном sha срабатывает БД-fallback
и однозначно находит единственную development-задачу repo; продвижение идёт штатно.
module: tests/test_gitea_sha_resolve.py
expected: PASS
- id: TC-19
type: unit
description: >
handle_ci_status: при неоднозначности (несколько development-задач repo) БД-fallback не
делает ложный матч (branch остаётся неразрезолвленным, лог INFO), success/failure-семантика
гейта не изменена.
module: tests/test_gitea_sha_resolve.py
expected: PASS
# ---- F-4 / интеграция фонового потока --------------------------------------
- id: TC-20
type: unit
description: >
Наблюдаемость: при разблокировке reconciler пишет явную лог-строку с work_item_id и
stage; при reconcile_notify_unblock=True вызывается send_telegram (замокан).
module: tests/test_reconciler.py
expected: PASS
- id: TC-21
type: integration
description: >
Restart-safe поток: Reconciler.start() поднимает daemon-поток, stop() завершает его
в пределах таймаута; повторный start идемпотентен (не плодит второй поток).
module: tests/test_reconciler.py
expected: PASS
- id: TC-22
type: unit
description: >
Конфиг: новые поля reconcile_* присутствуют в Settings с заявленными дефолтами и
читаются из env с префиксом ORCH_ (по образцу tests/test_config.py).
module: tests/test_config.py
expected: PASS
- id: TC-23
type: unit
description: >
Регресс реестров: STAGE_TRANSITIONS и QG_CHECKS не изменены ORCH-053
(snapshot-тест проходит как раньше).
module: tests/test_qg_registry_snapshot.py
expected: PASS

View File

@@ -0,0 +1,221 @@
# ADR-001: Sweeper/reconciler потерянных webhook (реконсиляция застрявших стадий)
- **Статус:** Proposed
- **Дата:** 2026-06-06
- **Задача:** ORCH-053
- **Сквозной ADR:** `docs/architecture/adr/adr-0007-reconciler.md`
- **Связи:** adr-0001 (реестр проектов), adr-0002 (очередь / `available_at`),
adr-0003 (условный staging-гейт — образец условности), adr-0006 (merge-gate как
под-гейт ребра), ORCH-5 (events de-dup), ORCH-045 (`ci_poll`).
## Контекст
Продвижение задач по конвейеру завязано **исключительно** на входящие webhook
(Plane status / Gitea CI/PR). Потерянное событие (502 на ребилдящемся инстансе,
Plane/Gitea не ретраят, `sha→branch` не разрезолвился) → источник истины (Plane /
зелёный CI) изменился, а задача в оркестраторе застряла молча (живой инцидент
ORCH-044). Ни один существующий механизм resilience (`requeue_running_jobs`,
orphan-recovery, events de-dup, `ci_poll`) не реконсилирует рассинхрон
**«источник истины ≠ стадия задачи»** — все они работают на уровне jobs/agent_runs,
а не stage-перехода.
ТЗ (`02-trz.md`) фиксирует объём; данный ADR фиксирует архитектурные развилки,
явно отданные архитектору: (1) потокобезопасность и подавление спама нотификаций,
(2) способ вызова `async`-обработчиков `plane.py` из sync-потока, (3) atomic-claim
на создании задачи (анти-дубль), (4) критерий «потерян, а не задержан» (grace),
(5) отсутствие изменений схемы БД.
## Решение
### 1. Компонент: `src/reconciler.py` — фоновый daemon-поток
Новый модуль по образцу `queue_worker.py`: класс `Reconciler` + module-singleton
`reconciler`. Plain `threading.Thread(daemon=True)` + `threading.Event` для
остановки. Стартует в `main.lifespan` **после** `worker.start()`, останавливается в
`finally` **перед** `worker.stop()`. Цикл:
```
while not stop:
try:
if settings.reconcile_enabled:
reconcile_gate_once() # F-1
if settings.reconcile_plane_enabled:
reconcile_plane_once() # F-2
except Exception: log.error(...) # outer never-raise
stop.wait(settings.reconcile_interval_s)
```
`start()` идемпотентен (как `QueueWorker.start`: если поток жив — no-op), что
покрывает AC-11 (повторный start не плодит второй поток). Никакого критичного
состояния в памяти — всё перечитывается из БД/Plane на каждом тике; метрики
наблюдаемости (`last_run_ts`, `unblocked_total`) — best-effort, теряются при
рестарте (AC-11 это явно допускает).
### 2. Источник истины — гейт, не событие. Продвижение строго через `advance_stage`
F-1 НЕ дублирует логику advance. Вводится тонкий хелпер в `stage_engine.py`:
```python
def advance_if_gate_passed(task_id, stage, repo, work_item_id, branch) -> AdvanceResult | None
```
Алгоритм:
1. `stage == "analysis"` → немедленный возврат `None` (см. §6, AC-16).
2. `qg = get_qg_for_stage(stage)`; если `None` (created/done) → возврат `None`.
3. **Read-only пред-оценка гейта** тем же диспетчером, что использует webhook-путь:
`passed, reason = _run_qg(qg, repo, work_item_id, branch)`.
4. **passed** → вызвать `advance_stage(task_id, stage, repo, work_item_id, branch,
finished_agent=None)` — это **тот же** путь, которым продвигает Plane
Approved-webhook (`webhooks/plane._try_advance_stage`). Он повторно прогонит
гейт (гейты идемпотентны/read-only), продвинет стадию, отправит **штатные**
advance-нотификации и поставит следующего агента.
5. **not passed** → **тишина**: `logger.debug(...)`, возврат `None`. Никаких
`notify_qg_failure` / `plane_notify_qg`.
Это даёт оба контракта одновременно:
- **AC-2 / TC-02:** в `reconciler.py` нет собственного `update_task_stage` +
`enqueue_job` для advance — продвижение исключительно через `advance_stage(...,
finished_agent=None)`.
- **AC-9 / TC-06:** на застрявшей-но-красной задаче `advance_stage` **не
вызывается вовсе**, поэтому ветка нотификации провала (`agent is None` →
`notify_qg_failure`+`plane_notify_qg`, `stage_engine.py:228-230`) не
срабатывает ни на одном тике. Спам структурно невозможен.
**Подавление спама = «не вызывать advance_stage на красном гейте»**, а не флаг
внутри `advance_stage`. Это сохраняет унифицированный критический путь
(`advance_stage`) **без изменений** — минимальный blast-radius для self-hosting.
> **Цена (осознанная):** на «зелёной» задаче гейт оценивается дважды (пред-оценка
> в хелпере + повтор внутри `advance_stage`). Гейты — чистые read-only проверки
> (`check_ci_green`, `check_*_status` из `12/13/14/15`), на реально-застрявшей-но-
> готовой задаче (целевой кейс ORCH-044) возвращаются быстро (CI уже зелёный →
> `ci_poll` отдаёт результат на первой итерации). Двойная оценка приемлема ради
> неизменности `advance_stage`.
#### Отклонённая альтернатива: флаг `suppress_qg_failure_notify` в `advance_stage`
Однократная оценка гейта, но изменяет сигнатуру и поведение общего
критического пути (риск для self-hosting, обслуживающего все проекты). Отклонено
в пользу неизменности `advance_stage` (Option A выше).
### 3. F-2: вызов `async`-обработчиков `plane.py` из sync-потока
Reconciler — sync daemon-поток; `handle_status_start` / `handle_verdict` —
`async`. Решение: вызывать через **`asyncio.run(coro)`** на каждую единицу работы
внутри per-issue `try/except`. `asyncio.run` создаёт свежий event loop на вызов,
что необходимо, т.к. `handle_verdict → _try_advance_stage` использует
`asyncio.to_thread` (требует running loop). Логику **не дублировать** —
переиспользуются ровно `handle_status_start` / `handle_verdict` /
`list_issues_by_state`.
`issue_data` собирается в форму, ожидаемую обработчиками (`{"id", "state":{"id":..},
"project", "name", "description_stripped"}`); недостающие name/description
обработчики сами дотянут через `fetch_issue_fields` (как для status-only webhook).
### 4. Идемпотентность создания (анти-дубль, AC-4) — atomic-claim в БД
Гонка: F-2 видит `In Progress` + нет задачи; одновременно реальный webhook тоже
видит `In Progress` + нет задачи → оба проходят `get_task_by_plane_id() is None`
→ два `start_pipeline` → два task-row / branch / worktree / стартовых analyst-job
(events de-dup тут НЕ помогает: reconciler — не webhook-доставка).
Решение: **atomic-claim создания, защищённый process-wide `threading.Lock`**.
Новый хелпер `db.create_task_atomic(plane_id, ...)` выполняет
`SELECT-exists → INSERT` под module-level `Lock`, возвращая `(row, created: bool)`:
только победитель (`created=True`) продолжает branch/docs/analyst; проигравший
видит существующую задачу и выходит. `start_pipeline` рефакторится так, чтобы
**первым** DB-действием был этот claim; reconciler идёт тем же путём через
`handle_status_start` → `start_pipeline`.
**Обоснование выбора Lock, а не UNIQUE-индекса:**
- Прод — **один процесс** uvicorn на одну БД (staging/prod изолированы своими БД);
webhook исполняется в asyncio-треде uvicorn, reconciler — в своём треде того же
процесса → `threading.Lock` покрывает обе стороны гонки.
- **Без миграции схемы** (соответствует §6 ТЗ и образцу merge-gate ORCH-043).
`CREATE UNIQUE INDEX` на `tasks.plane_id` рискует упасть на проде, если там уже
существуют дубли `plane_id` (исторические) — а проверить это вживую нельзя.
- Дешёвый fast-path `get_task_by_plane_id` сохраняется до claim.
> **Граница применимости:** гарантия верна для single-process деплоя (текущая
> топология). Многопроцессный запуск (`uvicorn --workers N`) потребовал бы
> DB-native UNIQUE-индекса — задокументировано как будущее упрочнение в
> `08-data-requirements.md`. Очередь (`queue_worker`) уже опирается на ту же
> single-process-singleton модель, так что допущение не новое.
### 5. Анти-гонка с живым webhook (AC-3) — active-job guard + grace
- **Active-job guard:** `has_active_job_for_task(task.id) == True` → задача
легитимно в работе или живой webhook только что поставил job → **skip** (ни
пред-оценки гейта, ни advance, ни нотификаций). И в F-1, и в F-2.
- **Самозатухание повторов:** `advance_stage → update_task_stage` обновляет
`tasks.updated_at` → следующий тик увидит свежий `updated_at` и не сработает
повторно (grace).
- `max_concurrency=1`: новый enqueued job встаёт в общую очередь — двойного
запуска нет (atomic `claim_next_job`).
### 6. F-1 НЕ реконсилирует `analysis` (AC-16)
Гейт `check_analysis_approved` — **человеческий**. В `advance_stage` при
`finished_agent=None` он трактуется как `approved-via-status` и продвинул бы
задачу. Но при потере именно **Approved**-webhka продвигать analysis допустимо
**только** если Plane реально в статусе Approved — этого локальная БД не знает.
Поэтому advance стадии `analysis` отдан **F-2** (сверяется с реальным статусом
Plane). `advance_if_gate_passed` для `stage == "analysis"` — ранний возврат
`None`. Защита от автопродвижения неодобренного человеком BRD.
### 7. Grace: критерий «потерян, а не задержан»
- **F-1:** кандидат, если `has_active_job_for_task == False` **и**
`age(tasks.updated_at) >= grace_for_stage(stage)`.
`grace_for_stage(stage)` = per-stage override из `reconcile_grace_overrides_json`,
иначе `reconcile_grace_default_s`. Невалидный JSON → дефолт (паттерн
`agent_timeout_overrides_json`, never-raise).
- **F-2:** источник «давности» — поле `updated_at` **issue из Plane** (когда статус
реально сменился). Реагировать только если `age(issue.updated_at) >=
reconcile_grace_default_s` — отсекает просто задержавшийся webhook. Для
существующей задачи дополнительно требуется отсутствие активного job.
### 8. F-3: усиление `sha→branch` в `handle_ci_status`
При неразрезолвленном branch (нет `branches`, `git branch -r --contains` пуст) —
fallback на БД: найти task'и repo со `stage='development'`; при **однозначности**
(ровно одна) использовать её branch; при неоднозначности — оставить
неразрезолвленным + `logger.info`. `logger.debug → logger.info` для видимости.
Success/failure-семантика гейта не меняется. Defense-in-depth: F-1 всё равно
подберёт такую задачу.
### 9. БД и реестры — без изменений
- Схема **не меняется** (§6 ТЗ). Стуковость — по `tasks.updated_at`/`tasks.stage`
+ `has_active_job_for_task`. Анти-дребезг колонкой `last_reconcile_at` **не
нужен**: на красном гейте действий/нотификаций нет вовсе (§2), а после advance
`updated_at` обновляется → повтор невозможен.
- `STAGE_TRANSITIONS` и `QG_CHECKS` **не меняются** (AC / TC-23). Новых QG нет.
### 10. Наблюдаемость (F-4)
- При **разблокировке** (произошёл advance) — явная лог-строка
`reconciler: <work_item_id> <stage> разблокирована (потерян webhook)`; при
`reconcile_notify_unblock=True` — `send_telegram(...)`. Только на изменении
состояния, не на каждый тик (AC-12, не конфликтует с AC-9/AC-10).
- `/queue` расширяется блоком `"reconcile": {enabled, plane_enabled, interval,
last_run_ts, unblocked_total, last_unblocked}` по образцу `worker.status()`.
## Альтернативы (сводно)
- **Флаг подавления нотификаций в `advance_stage`** — отклонён (§2): изменяет общий
критический путь.
- **UNIQUE-индекс на `tasks.plane_id`** — отклонён как primary (§4): риск падения
миграции на проде; задокументирован как будущее упрочнение для multi-process.
- **Отдельная стадия/QG для реконсиляции** — вне объёма; нарушило бы «источник
истины — существующий гейт».
- **Реконсиляция analysis по локальным артефактам** — отклонена (§6): риск
автопродвижения неодобренного BRD.
## Последствия
- Потерянный webhook больше не = молча застрявшая задача; ручной heartbeat-watchdog
Стрима не нужен; резервная сетка к ORCH-51/ORCH-36.
- Плата: фоновый поток + периодический опрос Plane API (нагрузка — митигируется
интервалом + фильтром по статусам + per-project); двойная оценка гейта на зелёной
задаче; анти-дубль на создании опирается на single-process-допущение.
- Self-hosting: kill-switch `reconcile_enabled` обязателен; reconciler не
рестартит/не роняет прод-контейнер; раскат поэтапный (флаги).
- Сквозной resilience-механизм → сопровождается global `adr-0007`.

View File

@@ -0,0 +1,45 @@
# 07 — Требования к инфраструктуре — ORCH-053
Work Item ID: ORCH-053
## Топология
**Без изменений.** Новых контейнеров/портов/сервисов нет. Reconciler — фоновый
daemon-поток **внутри** существующего процесса orchestrator (как `queue_worker`).
Стартует/останавливается в `main.lifespan`. Деплой ORCH-053 — строго через
staging-гейт (8501) перед прод-деплоем (self-hosting, см. `docs/operations/INFRA.md`).
## Новые переменные окружения (`.env` / `.env.staging` на хосте, префикс `ORCH_`)
| Env | Поле `Settings` | Дефолт | Назначение |
|-----|-----------------|--------|-----------|
| `ORCH_RECONCILE_ENABLED` | `reconcile_enabled` | `true` | **Kill-switch** всего sweeper'а (self-hosting safety, поэтапный раскат, аварийное отключение). |
| `ORCH_RECONCILE_INTERVAL_S` | `reconcile_interval_s` | `120` | Период фонового прохода (сек). |
| `ORCH_RECONCILE_PLANE_ENABLED` | `reconcile_plane_enabled` | `true` | Отдельный флаг F-2 (опрос Plane API); `false` гасит только plane-ветку, F-1 работает. |
| `ORCH_RECONCILE_GRACE_DEFAULT_S` | `reconcile_grace_default_s` | `600` | Дефолтный порог «застревания» по `tasks.updated_at` / `issue.updated_at`. |
| `ORCH_RECONCILE_GRACE_OVERRIDES_JSON` | `reconcile_grace_overrides_json` | `""` | Per-stage пороги, напр. `{"analysis":1800,"development":300,"deploy":900}`. Невалидный JSON → дефолт (never-raise). |
| `ORCH_RECONCILE_NOTIFY_UNBLOCK` | `reconcile_notify_unblock` | `true` | Telegram при разблокировке (F-4). |
Секреты не добавляются. `.env.example` (канон) обновляется в PR реализации.
## Нагрузка / сеть
- **Plane API (F-2):** GET issues per-project каждые `reconcile_interval_s`, с
фильтром по статусам (In Progress / Approved / Rejected) и пагинацией. Митигация
нагрузки — интервал (120с), фильтр, per-project, never-raise (Plane outage →
`[]`, тик не падает). `get_project_states` уже кэширует state-uuid per-project.
- **Gitea API (F-1):** только косвенно — внутри переоценки гейтов (`check_ci_green`
и т.п.), которые и так вызываются webhook-путём. Дополнительных постоянных
вызовов reconciler не вносит сверх момента реальной разблокировки.
- **CPU/RAM:** один спящий daemon-поток; всплеск только при наличии застрявших
задач.
## Self-hosting
- Reconciler **не** рестартит/не роняет прод-контейнер `orchestrator` (8500),
обслуживающий все проекты с общей БД.
- `docs/operations/INFRA.md` дополняется упоминанием kill-switch
`ORCH_RECONCILE_ENABLED` (выполняется в PR реализации, §9 ТЗ).
- Раскат: при первом деплое допустимо стартовать с `ORCH_RECONCILE_PLANE_ENABLED=false`
(только F-1, минимальный риск), затем включить F-2.
## Конфиги/деплой
Дополнительных томов, портов, healthcheck'ов, изменений `docker-compose`/Dockerfile
**не требуется**.

View File

@@ -0,0 +1,38 @@
# 08 — Требования к данным / схеме БД — ORCH-053
Work Item ID: ORCH-053
## Изменения схемы: НЕТ
Реконсиляция строится исключительно на существующих структурах (по образцу
merge-gate ORCH-043 — «без новых колонок»):
| Структура | Использование reconciler |
|-----------|--------------------------|
| `tasks.stage` | Кандидаты F-1: `stage NOT IN ('done')`; `created`/`analysis` отфильтровываются (нет QG / человеческий гейт). |
| `tasks.updated_at` | Критерий «застряла»: `age(updated_at) ≥ grace_for_stage(stage)`. `update_task_stage` уже штампует `updated_at` → самозатухание повторов. |
| `tasks.repo`, `tasks.branch`, `tasks.work_item_id`, `tasks.plane_id` | Аргументы `advance_stage` / резолв задачи. |
| `jobs` (`has_active_job_for_task`) | Active-job guard (AC-3): задача с `queued`/`running` job не трогается. |
## Анти-дребезг (`last_reconcile_at`): НЕ вводится
На красном гейте reconciler не делает ни advance, ни нотификаций (см. ADR-001 §2),
поэтому спама нет структурно; после успешного advance обновляется `updated_at`
повтор невозможен. Дополнительная колонка для дебаунса не нужна.
## Идемпотентность создания (анти-дубль, AC-4)
Гонка reconciler↔webhook на создании задачи закрывается **process-wide
`threading.Lock`** вокруг `SELECT-exists → INSERT` (новый хелпер
`db.create_task_atomic`), **без** изменения схемы. Гарантия верна для текущей
**single-process** топологии (один uvicorn на одну БД; staging/prod изолированы) —
тот же допущение, что у очереди `queue_worker` (ORCH-1).
### Будущее упрочнение (вне объёма ORCH-053)
Для multi-process деплоя (`uvicorn --workers N`) потребуется DB-native гарантия:
частичный UNIQUE-индекс `CREATE UNIQUE INDEX ... ON tasks(plane_id) WHERE plane_id
IS NOT NULL` (паттерн `idx_events_delivery`) + `INSERT OR IGNORE` claim. Не вводим
сейчас: миграция может упасть на проде при наличии исторических дублей `plane_id`
(проверить вживую нельзя); требует отдельной задачи с аудитом данных.
## Миграции
Не требуются. Если в будущем понадобится колонка — только идемпотентный
`_ensure_column` (как все ALTER в `src/db.py`), безопасный на живой прод-БД.

View File

@@ -0,0 +1,27 @@
# 10 — Технические риски — ORCH-053
Work Item ID: ORCH-053
Severity: 🔴 high / 🟡 medium / 🟢 low
| # | Риск | Sev | Митигация (где зафиксировано) |
|---|------|-----|-------------------------------|
| R-1 | **Гонка reconciler↔живой webhook → двойная задача** (оба видят «нет задачи» на `In Progress`). | 🔴 | Atomic-claim `db.create_task_atomic` под process-wide `threading.Lock` (ADR-001 §4, 08-data). AC-4 / TC-15. |
| R-2 | **Двойной запуск агента** на стадии (reconciler дёргает гейт у задачи в работе). | 🔴 | `has_active_job_for_task` guard + `max_concurrency=1` + atomic `claim_next_job`; `update_task_stage` обновляет `updated_at` (ADR-001 §5). AC-3 / TC-03. |
| R-3 | **Спам нотификаций** на стабильно красном гейте каждый тик. | 🔴 | «Не вызывать `advance_stage` на красном» → ветка `notify_qg_failure` не достигается (ADR-001 §2). AC-9 / TC-06. |
| R-4 | **Автопродвижение неодобренного BRD** (F-1 продвинул `analysis` без Approved в Plane). | 🔴 | F-1 не реконсилирует `analysis`; advance стадии — только F-2 по реальному статусу Plane (ADR-001 §6). AC-16 / TC-08. |
| R-5 | **Дублирование логики advance/rollback** в reconciler (расхождение с webhook-путём со временем). | 🟡 | Продвижение строго через `advance_stage(..., finished_agent=None)`; F-2 — через `handle_*` из `plane.py`; своего `update_task_stage`/`enqueue_job` для advance нет (ADR-001 §2-3). AC-2 / TC-02. |
| R-6 | **Падение тика из-за одной битой задачи/issue** (битые данные, ошибка API). | 🟡 | Per-task / per-issue `try/except` + outer `try/except` в `_run` (паттерн `_drain_once`). AC-15 / TC-09. `list_issues_by_state` never-raise → `[]`. TC-16. |
| R-7 | **Нагрузка/недоступность Plane API** при опросе каждые N сек. | 🟡 | Интервал 120с + фильтр по статусам + per-project + кэш `get_project_states`; never-raise → мягкая деградация (ADR-001 §3, 07-infra). |
| R-8 | **`asyncio.run` из sync-потока** (event loop конфликты, зависание). | 🟡 | Свежий loop на единицу работы; внутри per-issue try/except; нет вложенного running loop (reconciler — не async). ADR-001 §3. |
| R-9 | **Self-hosting: reconciler меняет инструмент всех проектов** / нежелательное срабатывание на проде. | 🔴 | Kill-switch `reconcile_enabled`; раздельный `reconcile_plane_enabled`; деплой через staging-гейт; не рестартит прод. ADR-001 §1, 07-infra. AC-13 / TC-10. |
| R-10 | **Двойная оценка гейта** на зелёной задаче (пред-оценка + повтор в `advance_stage`); долгий `ci_poll` держит тик. | 🟢 | Гейты идемпотентны/read-only; на целевом кейсе (CI уже зелёный) возвращаются быстро; reconciler — отдельный daemon-поток. Осознанная цена за неизменность `advance_stage` (ADR-001 §2). |
| R-11 | **Ложный `sha→branch` матч** в F-3 при неоднозначности. | 🟡 | БД-fallback срабатывает только при ровно одной `development`-задаче repo; иначе — неразрезолвлено + INFO; success/failure-семантика гейта не тронута (ADR-001 §8). AC-14 / TC-18, TC-19. |
| R-12 | **Регресс реестров** (`STAGE_TRANSITIONS`/`QG_CHECKS`) или схемы. | 🟡 | Реестры/схема не меняются; snapshot-тест (ADR-001 §9). AC / TC-23. |
| R-13 | **Дубль на стадии deploy-staging↔merge-gate** (reconciler триггерит advance, конкурируя с merge-lease). | 🟢 | F-1 продвигает только через `advance_stage`, который штатно прогоняет merge-gate (defer/rollback владеет исходом); active-job guard + `updated_at` — без гонки на тике (ADR-001 §2). |
| R-14 | **Multi-process деплой ломает анти-дубль** (Lock — внутрипроцессный). | 🟢 | Текущая топология single-process (как очередь ORCH-1); ограничение задокументировано, DB UNIQUE-индекс — будущее упрочнение (08-data). |
## Сводно
Самые острые (🔴) — анти-дубль на создании (R-1), двойной запуск (R-2), спам (R-3),
автопродвижение analysis (R-4), self-hosting (R-9) — закрыты явными механизмами с
покрытием в `04-test-plan.yaml`. Остаточные допущения: single-process топология
(R-14) и осознанная двойная оценка гейта (R-10).

View File

@@ -0,0 +1,88 @@
---
type: review
work_item_id: ORCH-053
verdict: APPROVED
version: 1
---
# Review ORCH-053 — Sweeper потерянных webhook (реконсиляция застрявших стадий)
## Summary
PR реализует фоновый reconciler застрявших стадий ровно в объёме ТЗ (`02-trz.md`) и
ADR (`06-adr/ADR-001`, глобальный `adr-0007`). Все 17 acceptance-criteria покрыты
кодом и тестами; полный прогон `pytest`**563 passed**. Реализация строго следует
ключевым инвариантам: продвижение только через неизменный `advance_stage(...,
finished_agent=None)`, никакой дублирующей advance/rollback-логики в `reconciler.py`,
структурная невозможность спама нотификаций, never-raise на единицу работы,
restart-safe daemon-поток, kill-switch'и. Схема БД и реестры `STAGE_TRANSITIONS` /
`QG_CHECKS` не тронуты. Документация обновлена в этом же PR. Рекомендация: **APPROVED**.
## Соответствие ТЗ
- `src/reconciler.py` (НОВЫЙ): F-1 `reconcile_gate_once` + F-2 `reconcile_plane_once`, класс
`Reconciler` + module-singleton по образцу `queue_worker`. ✓
- `src/config.py`: все 6 `reconcile_*` настроек с дефолтами по таблице §5. ✓
- `src/main.py`: старт после `worker.start()`, стоп перед `worker.stop()`, блок `reconcile`
в `GET /queue`. ✓
- `src/stage_engine.py`: тонкий `advance_if_gate_passed` — read-only пред-оценка гейта,
advance только через `advance_stage`, на красном гейте `advance_stage` не вызывается
вовсе (подавление спама без изменения общего критпути). ✓
- `src/plane_sync.py`: `list_issues_by_state` с курсорной пагинацией и never-raise → `[]`. ✓
- `src/webhooks/gitea.py`: F-3 БД-fallback `sha→branch` (`_resolve_branch_via_db`),
однозначность обязательна, `debug→info`. ✓
- `src/webhooks/plane.py` + `src/db.py`: F-2 переиспользует `handle_status_start` /
`handle_verdict` без дублирования; анти-дубль `create_task_atomic` под process-wide Lock,
`start_pipeline` рефакторен на atomic-claim первым DB-действием. ✓
- Схема БД и реестры не менялись (§6/§8 ТЗ). ✓
## Соответствие ADR
- §2 (источник истины — гейт; продвижение только через `advance_stage`): соблюдено —
в `reconciler.py` нет собственного `update_task_stage`/`enqueue_job` для advance (AC-2).
- §3 (async-обработчики из sync-потока через `asyncio.run`): реализовано в `_dispatch`.
- §4 (atomic-claim под `threading.Lock`, без миграции): `db.create_task_atomic`.
- §6 (F-1 не трогает `analysis`): ранний возврат в `advance_if_gate_passed` и в
`_reconcile_gate_task` (AC-16).
- §7 (grace «потерян, а не задержан»): F-1 по `tasks.updated_at` (SQL `age_s`), F-2 по
`issue.updated_at` (`_age_seconds_iso`).
- Нарушений глобальных ADR нет; `adr-0007` заведён и внесён в `docs/architecture/adr/README.md`.
## Качество кода
- Контракт never-raise выдержан на всех уровнях: outer loop, per-task, per-project, per-issue,
`_parse_grace_overrides`, `list_issues_by_state`, `_resolve_branch_via_db`, телеграм-нотификация.
- Идемпотентность: active-job guard в F-1 и F-2; самозатухание через обновление `updated_at`
после advance; `max_concurrency=1`. Подтверждено анализом — F-2 на approved/rejected всегда
меняет состояние (analysis approved-via-status всегда проходит; rollback всегда срабатывает),
поэтому петли спама нотификаций структурно не возникает.
- Защита от ложного матча в F-3 (только при единственной development-задаче repo).
- Docstrings содержательные на всех публичных функциях; тесты не тривиальные (мапятся на
TC-01…TC-21 из `04-test-plan.yaml`).
## Документация
Обновлена в этом же PR (AC-17 выполнен):
- `docs/architecture/README.md` — компонент Reconciler, раздел resilience, строка в таблице API
(`/queue` … + reconcile), footer-пометка. ✓
- `docs/work-items/ORCH-053/06-adr/ADR-001-stuck-task-reconciler.md` — заведён. ✓
- `docs/architecture/adr/adr-0007-reconciler.md` + строка в `adr/README.md`. ✓
- `CHANGELOG.md` — запись в `[Unreleased]/Added`. ✓
- `docs/operations/INFRA.md` — kill-switch'и и env-карта (self-hosting). ✓
- `README.md` и `.env.example` — env-таблица `ORCH_RECONCILE_*`. ✓
## Findings
### P0 — Blocker
- Нет.
### P1 — Must fix
- Нет.
### P2 — Should fix
- Нет.
### P3 — Nice-to-have
- Несоответствие статуса ADR: `06-adr/ADR-001` помечен `Статус: Proposed`, тогда как
`docs/architecture/adr/README.md` указывает `adr-0007` как `accepted`. Косметика —
привести к одному значению при следующем касании.
- `get_project_states(pid)` теоретически может вернуть словарь без ключей
`approved`/`rejected` при частичном резолве состояний проекта → `KeyError` в
`_reconcile_plane_project`. Сейчас изолировано per-project `try/except` (never-raise
держится, эффект — пропуск F-2 для проекта). Можно усилить `.get(...)`-доступом ради
явности; не блокер.

View File

@@ -0,0 +1,74 @@
---
type: test-report
work_item_id: ORCH-053
result: PASS
---
# Test Report — ORCH-053 (Sweeper потерянных webhook / reconciler)
## Окружение
- Python: 3.12.13
- pytest: 8.3.3 (plugins: anyio-4.13.0, asyncio-0.23.8; asyncio mode=AUTO)
- Ветка: `feature/ORCH-053-sweeper-webhook-stuck-task`
- Дата: 2026-06-06
- Review verdict: APPROVED (`12-review.md`)
## Команда прогона
`python -m pytest tests/ -v --tb=short`**563 passed, 1 warning, 12.09s**
(warning — известный PydanticDeprecatedSince20 в `src/config.py`, не связан с ORCH-053).
## Результаты по тест-плану (`04-test-plan.yaml`)
| TC ID | Описание | Тест | Результат |
|-------|----------|------|-----------|
| TC-01 | F-1: продвижение застрявшей development-задачи | test_reconciler::test_tc01_advances_stuck_development_task | PASS |
| TC-02 | Источник истины — гейт, advance только через advance_stage(finished_agent=None) | test_reconciler::test_tc02_advances_via_advance_stage_finished_agent_none | PASS |
| TC-03 | Активный job → задача пропускается | test_reconciler::test_tc03_active_job_skipped | PASS |
| TC-04 | Per-stage grace, граница age>=grace | test_reconciler::test_tc04_grace_boundary | PASS |
| TC-05 | grace_for_stage: overrides + невалидный JSON → дефолт | test_reconciler::test_tc05_grace_for_stage_overrides / _invalid_json_falls_back | PASS |
| TC-06 | Нет спама нотификаций на красном гейте | test_reconciler::test_tc06_red_gate_no_spam | PASS |
| TC-07 | Тишина при синхронности | test_reconciler::test_tc07_silence_when_in_sync | PASS |
| TC-08 | AC-16: F-1 не продвигает analysis | test_reconciler::test_tc08_analysis_not_advanced_by_f1 | PASS |
| TC-09 | Never-raise изолирует сбой одной задачи | test_reconciler::test_tc09_never_raise_isolates_failure | PASS |
| TC-10 | Kill-switch (reconcile_enabled / reconcile_plane_enabled) | test_reconciler::test_tc10_kill_switch_disables_gate / _plane_switch_mutes_only_f2 | PASS |
| TC-11 | F-2: In Progress без задачи → handle_status_start | test_reconciler_plane::test_tc11_in_progress_without_task_starts_pipeline | PASS |
| TC-12 | F-2: Approved → handle_verdict(approved=True) | test_reconciler_plane::test_tc12_approved_replays_verdict | PASS |
| TC-13 | F-2: Rejected → handle_verdict(approved=False) | test_reconciler_plane::test_tc13_rejected_replays_verdict | PASS |
| TC-14 | Идемпотентность F-2: активный job / в пределах grace | test_reconciler_plane::test_tc14_active_job_skips / test_tc14b_within_grace_skipped | PASS |
| TC-15 | AC-4 анти-дубль на создании (create_task_atomic) | test_reconciler_plane::test_tc15_create_task_atomic_no_duplicate | PASS |
| TC-16 | list_issues_by_state never-raise + пагинация/фильтр | test_reconciler_plane::test_tc16_list_issues_never_raises_on_error / _paginates_and_filters | PASS |
| TC-17 | F-2 опрашивает все проекты, резолвит state per-project | test_reconciler_plane::test_tc17_polls_all_projects_resolves_states_per_project | PASS |
| TC-18 | F-3: sha→branch БД-fallback однозначный матч | test_gitea_sha_resolve::test_tc18_db_fallback_unique_match_advances | PASS |
| TC-19 | F-3: неоднозначность → нет ложного матча | test_gitea_sha_resolve::test_tc19_db_fallback_ambiguous_no_match | PASS |
| TC-20 | F-4: лог-строка разблокировки + Telegram (вкл/выкл) | test_reconciler::test_tc20_unblock_logs_and_notifies / _no_telegram_when_disabled | PASS |
| TC-21 | Restart-safe daemon-поток: start/stop/идемпотентный start | test_reconciler::test_tc21_daemon_thread_lifecycle | PASS |
| TC-22 | Конфиг reconcile_* дефолты + env ORCH_ | test_config::test_reconcile_settings_defaults / _env_override | PASS |
| TC-23 | Регресс реестров STAGE_TRANSITIONS / QG_CHECKS не изменены | test_qg_registry_snapshot::test_tc20_qg_registry_unchanged / _qg_callables_unchanged / _stage_transitions_unchanged | PASS |
Все 23 TC покрыты тестами и зелёные (целевые файлы: 36 passed).
## Smoke test API (прод-контейнер 8500, только read-only GET, без касания состояния)
- `GET /health` → 200 `{"status":"ok","service":"orchestrator"}`
- `GET /status` → 200 (active_tasks отдаётся; видна задача id=44 ORCH-053 на стадии testing)
- `GET /queue` → 200 (counts/max_concurrency/resilience отдаются)
- Блок `reconcile` в `/queue` на проде ОТСУТСТВУЕТ — ожидаемо: прод работает на старом коде,
ORCH-053 ещё не задеплоен. В коде ветки блок реализован (`src/main.py:131`
`"reconcile": reconciler.status()`). Появится после deploy-staging/deploy.
## Покрытие Acceptance Criteria (`03-acceptance-criteria.md`)
AC-1…AC-16 — покрыты соответствующими TC (см. таблицу) и зелёные.
AC-17 (документация — golden source) — подтверждён на стадии review (APPROVED, секция
«Документация»): README.md архитектуры, ADR-001, adr-0007, CHANGELOG.md, INFRA.md обновлены.
## Вывод pytest (хвост)
```
======================= 563 passed, 1 warning in 12.09s ========================
```
Целевые файлы ORCH-053:
```
======================== 36 passed, 1 warning in 1.20s =========================
```
## Итог
**PASS** — полный регресс зелёный (563 passed), все 23 TC из тест-плана выполнены,
acceptance-criteria покрыты, smoke прод-API здоров. Задача готова к стадии `deploy-staging`.

View File

@@ -0,0 +1,42 @@
---
staging_status: SUCCESS
timestamp: 2026-06-06T20:54:16Z
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed against the live `orchestrator-staging` instance (port 8501).
All checks passed — staging gate is GREEN.
## Run
- **Canonical execution:** inside container `orchestrator-staging` (ORCH-048, ADR-001).
The host environment has no `docker` CLI, so the `docker exec` was driven through the
Docker Engine API over the unix socket `/var/run/docker.sock` — functionally equivalent
to `docker exec orchestrator-staging python3 /repos/orchestrator/scripts/staging_check.py
--base-url http://localhost:8501 --mode stub`. B6 registry-isolation therefore reads the
running staging instance's own process-env (`.env.staging`), avoiding the false-FAIL of a
host-side run.
- **Mode:** `stub` (early-artifact verification: branch + QG-0 comment; no LLM credits).
- **Container:** `orchestrator-staging` (095be2c4ca3f)
- **Exit code:** 0
## Result: 10/10 checks PASS
| Block | Check | Verdict |
|-------|-------|---------|
| A SMOKE | A1 GET /health → 200 status=ok | PASS |
| A SMOKE | A2 GET /queue → 200 (counts/max_concurrency/resilience) | PASS |
| A SMOKE | A3 ORCH_STAGING=true (not prod) | PASS |
| B ACCESS | B4 Plane sandbox project accessible | PASS |
| B ACCESS | B5 Gitea orchestrator-sandbox accessible, push=true | PASS |
| B ACCESS | B6 Registry: sandbox present, prod ET/ORCH absent | PASS |
| C E2E | C7 Create issue in Plane SANDBOX | PASS |
| C E2E | C8 Trigger pipeline via /webhook/plane | PASS |
| C E2E | C9a Branch appears in orchestrator-sandbox | PASS |
| C E2E | C9b Analyst job enqueued in staging queue | PASS |
Cleanup completed (sandbox branch + Plane issue + DB rows removed). The `GET /queue`
response exposed the `resilience` block; the ORCH-053 reconciler runs in this staging
instance without destabilising the stand.

View File

@@ -152,6 +152,28 @@ class Settings(BaseSettings):
merge_defer_delay_s: int = 60
merge_defer_max_attempts: int = 5
# ORCH-053: stuck-task reconciler (sweeper for lost webhooks). A background
# daemon thread reconciles the "source of truth (gate / Plane) != task stage"
# drift left behind by a dropped webhook (502 on rebuild, no Plane/Gitea
# retries, unresolved sha->branch). See docs/architecture/adr/adr-0007-reconciler.md.
# reconcile_enabled -> global kill-switch (self-hosting safety,
# staged rollout, env ORCH_RECONCILE_ENABLED).
# reconcile_interval_s -> background sweep period (seconds).
# reconcile_plane_enabled -> separate flag for the F-2 Plane-API poll so
# only the plane branch can be muted.
# reconcile_grace_default_s -> default "stuck" threshold on tasks.updated_at.
# reconcile_grace_overrides_json -> JSON object of per-stage thresholds, e.g.
# {"analysis": 1800, "development": 300}. Invalid
# JSON -> default (mirrors agent_timeout_overrides_json).
# reconcile_notify_unblock -> send a Telegram message when a stuck task is
# unblocked (F-4 observability).
reconcile_enabled: bool = True
reconcile_interval_s: int = 120
reconcile_plane_enabled: bool = True
reconcile_grace_default_s: int = 600
reconcile_grace_overrides_json: str = ""
reconcile_notify_unblock: bool = True
# Telegram notifications
telegram_bot_token: str = ""
telegram_chat_id: str = ""

View File

@@ -1,6 +1,15 @@
import sqlite3
import threading
from .config import settings
# ORCH-053 (F-2 anti-dup): process-wide lock guarding the SELECT-exists -> INSERT
# task-creation claim. The prod topology is a single uvicorn process per DB
# (staging/prod isolated), with the webhook running in uvicorn's asyncio thread
# and the reconciler in its own thread of the SAME process -> a threading.Lock
# covers both sides of the create race without a schema migration. See
# docs/work-items/ORCH-053/06-adr/ADR-001-stuck-task-reconciler.md §4.
_CREATE_TASK_LOCK = threading.Lock()
def get_db() -> sqlite3.Connection:
conn = sqlite3.connect(settings.db_path)
@@ -145,6 +154,90 @@ def get_task_by_repo_branch(repo: str, branch: str) -> dict | None:
return None
def get_active_tasks_for_reconcile() -> list[dict]:
"""ORCH-053 (F-1): tasks eligible for the gate-side sweeper.
Returns every task whose stage is not terminal ('done'), each augmented with
``age_s`` = seconds since ``tasks.updated_at`` (computed in SQL against UTC
'now', matching how ``update_task_stage`` stamps ``updated_at``). The
reconciler applies the per-stage grace and active-job guard on top.
"""
conn = get_db()
try:
rows = conn.execute(
"SELECT *, "
"CAST(strftime('%s','now') - strftime('%s', updated_at) AS INTEGER) AS age_s "
"FROM tasks WHERE stage != 'done'"
).fetchall()
finally:
conn.close()
return [dict(r) for r in rows]
def get_development_tasks_by_repo(repo: str) -> list[dict]:
"""ORCH-053 (F-3): tasks of a repo currently on the 'development' stage.
Used as the sha->branch DB fallback in handle_ci_status: a CI-status webhook
whose branch could not be resolved (no branches[], empty
``git branch -r --contains``) is matched to the unique development task of
the repo (ambiguity -> caller leaves it unresolved).
"""
conn = get_db()
try:
rows = conn.execute(
"SELECT * FROM tasks WHERE repo = ? AND stage = 'development'", (repo,)
).fetchall()
finally:
conn.close()
return [dict(r) for r in rows]
def create_task_atomic(
plane_id: str,
work_item_id: str,
repo: str,
branch: str,
stage: str,
title: str,
) -> tuple[dict, bool]:
"""ORCH-053 (AC-4): atomically claim creation of a task for a plane_id.
Performs SELECT-exists -> INSERT under the process-wide ``_CREATE_TASK_LOCK``
so a race between the live Plane webhook and the F-2 reconciler (both seeing
"no task yet" for the same plane_id) cannot create two task rows / branches /
worktrees / starter analyst jobs.
Returns ``(row, created)``:
* ``created=True`` -> THIS caller inserted the row and owns the follow-up
work (branch / docs / analyst enqueue);
* ``created=False`` -> a task for this plane_id already existed (the other
racer won); ``row`` is the existing task and the caller must NOT duplicate
the follow-up work.
"""
with _CREATE_TASK_LOCK:
conn = get_db()
try:
existing = conn.execute(
"SELECT * FROM tasks WHERE plane_id = ? OR plane_issue_id = ?",
(plane_id, plane_id),
).fetchone()
if existing:
return dict(existing), False
cur = conn.execute(
"INSERT INTO tasks "
"(plane_id, work_item_id, repo, branch, stage, plane_issue_id, title) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(plane_id, work_item_id, repo, branch, stage, plane_id, title),
)
conn.commit()
row = conn.execute(
"SELECT * FROM tasks WHERE id = ?", (cur.lastrowid,)
).fetchone()
return dict(row), True
finally:
conn.close()
def update_task_stage(task_id: int, stage: str):
"""Update task stage and timestamp."""
conn = get_db()

View File

@@ -80,11 +80,19 @@ async def lifespan(app: FastAPI):
from .queue_worker import worker
worker.start()
# ORCH-053: start the stuck-task reconciler AFTER the worker so its active-job
# guard sees a fully-initialised queue. Kill-switch: ORCH_RECONCILE_ENABLED.
from .reconciler import reconciler
reconciler.start()
try:
yield
finally:
# Graceful shutdown of the worker (running agents keep going; their jobs
# are requeued on next start via queue-recovery if the process dies).
# Graceful shutdown order mirrors startup in reverse: stop the reconciler
# first (it must not enqueue new work while the worker is winding down),
# then the worker. Running agents keep going; their jobs are requeued on
# next start via queue-recovery if the process dies.
reconciler.stop()
worker.stop()
@@ -114,10 +122,12 @@ async def queue():
"""ORCH-1: job-queue observability — status counts + recent jobs."""
from .db import job_status_counts, recent_jobs
from .queue_worker import worker
from .reconciler import reconciler
return {
"counts": job_status_counts(),
"max_concurrency": worker.max_concurrency,
"poll_interval": worker.poll_interval,
"resilience": worker.status(),
"reconcile": reconciler.status(),
"recent": recent_jobs(10),
}

View File

@@ -356,6 +356,62 @@ def fetch_issue_fields(issue_id: str, project_id: str) -> tuple[str, str]:
return "", ""
def list_issues_by_state(project_id: str, state_uuids: list[str]) -> list[dict]:
"""ORCH-053 (F-2): list a project's issues whose state is in ``state_uuids``.
GETs ``/workspaces/{ws}/projects/{pid}/issues/`` and walks ALL pages
(Plane's cursor pagination: ``results`` + ``next_cursor`` /
``next_page_results``), keeping only issues whose state uuid is one of the
requested ones. The filter is applied client-side on ``issue.state`` (a dict
``{id,...}`` or a bare uuid string) so it works regardless of whether Plane's
query-param state filter is honoured.
Never raises: on any network / API / shape error it logs a warning and
returns ``[]`` so a Plane outage degrades the F-2 tick softly instead of
crashing it.
"""
if not project_id or not state_uuids:
return []
wanted = set(state_uuids)
out: list[dict] = []
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/issues/"
try:
cursor = None
pages = 0
while True:
params: dict = {"per_page": 100}
if cursor:
params["cursor"] = cursor
resp = httpx.get(url, headers=PLANE_HEADERS, params=params, timeout=10)
resp.raise_for_status()
body = resp.json()
if isinstance(body, dict):
items = body.get("results", [])
else:
items = body if isinstance(body, list) else []
for issue in items:
state = issue.get("state")
sid = state.get("id") if isinstance(state, dict) else state
if sid in wanted:
out.append(issue)
# Pagination: continue only while Plane reports more pages.
pages += 1
if not isinstance(body, dict):
break
has_more = bool(body.get("next_page_results"))
next_cursor = body.get("next_cursor")
if not has_more or not next_cursor or pages >= 100:
break
cursor = next_cursor
return out
except Exception as e:
logger.warning(
f"list_issues_by_state: API failed for project {project_id[:8]}..., "
f"returning []. Error: {e}"
)
return []
def find_issue_id(work_item_id: str, project_id: str = None) -> str | None:
"""Find Plane issue UUID by work_item_id (e.g. 'ET-002')."""
project_id = _resolve_project_id(work_item_id, project_id)

332
src/reconciler.py Normal file
View File

@@ -0,0 +1,332 @@
"""ORCH-053: stuck-task reconciler (sweeper for lost webhooks).
The pipeline advances ONLY on incoming webhooks (Plane status / Gitea CI/PR). A
dropped event (502 on a rebuilding instance, no Plane/Gitea retries, an
unresolved ``sha->branch``) leaves the source of truth (the gate / the Plane
status) changed while the task stays put — a silently stuck task (incident
ORCH-044). None of the existing resilience layers (``requeue_running_jobs``,
orphan-recovery, events de-dup, ``ci_poll``) reconcile this
"source-of-truth != task-stage" drift; they all work at the jobs/agent_runs
level, not the stage transition.
This module is a background daemon thread (modelled on ``queue_worker``) that
periodically replays the missed transition through the SAME standard gates /
handlers a webhook would use:
* **F-1 gate-side** (``reconcile_gate_once``): for each task with
``stage != 'done'``, no active job and ``age(updated_at) >=
grace_for_stage(stage)``, do a read-only pre-evaluation of the stage's
canonical quality gate; green -> advance through the unchanged
``stage_engine.advance_stage(..., finished_agent=None)``; red -> silence
(no advance, no notification). ``analysis`` is NOT reconciled here (human
gate; owned by F-2).
* **F-2 plane-side** (``reconcile_plane_once``): poll the Plane API per
project (``list_issues_by_state``) and replay In Progress / Approved /
Rejected through ``webhooks.plane.handle_status_start`` /
``handle_verdict`` (no logic duplicated).
Invariants: source of truth is the gate / Plane (not the event); advance only
via ``advance_stage``; idempotency (active-job guard + atomic create-claim +
grace + ``max_concurrency=1``); never-raise per unit of work; silence when in
sync; restart-safe; kill-switch ``ORCH_RECONCILE_ENABLED``
(+ ``ORCH_RECONCILE_PLANE_ENABLED`` mutes only F-2). The DB schema and the
registries (``STAGE_TRANSITIONS`` / ``QG_CHECKS``) are unchanged.
See docs/work-items/ORCH-053/06-adr/ADR-001-stuck-task-reconciler.md and the
cross-cutting docs/architecture/adr/adr-0007-reconciler.md.
"""
import asyncio
import json
import logging
import threading
from datetime import datetime, timezone
from .config import settings
from .db import (
get_active_tasks_for_reconcile,
get_task_by_plane_id,
has_active_job_for_task,
)
from .stage_engine import advance_if_gate_passed
from .stages import get_qg_for_stage
from .plane_sync import get_project_states, list_issues_by_state
from .webhooks.plane import handle_status_start, handle_verdict
from .notifications import send_telegram
from . import projects
logger = logging.getLogger("orchestrator.reconciler")
def _parse_grace_overrides(raw: str) -> dict[str, int]:
"""Parse ``reconcile_grace_overrides_json`` into {stage: seconds}.
Invalid / non-object JSON -> {} (caller falls back to the default grace),
mirroring the never-raise contract of ``agent_timeout_overrides_json``.
"""
if not raw or not raw.strip():
return {}
try:
data = json.loads(raw)
except (ValueError, TypeError) as e:
logger.warning(f"reconcile_grace_overrides_json is not valid JSON, ignoring: {e}")
return {}
if not isinstance(data, dict):
logger.warning("reconcile_grace_overrides_json must be a JSON object, ignoring")
return {}
out: dict[str, int] = {}
for k, v in data.items():
try:
out[str(k)] = int(v)
except (ValueError, TypeError):
logger.warning(f"reconcile_grace_overrides_json[{k}] is not an int, ignoring")
return out
def grace_for_stage(stage: str) -> int:
"""Per-stage "stuck" threshold (seconds): override from JSON, else default."""
overrides = _parse_grace_overrides(settings.reconcile_grace_overrides_json)
return overrides.get(stage, settings.reconcile_grace_default_s)
def _age_seconds_iso(ts: str) -> float | None:
"""Age in seconds of a Plane ISO-8601 timestamp (e.g. issue.updated_at).
Returns None when the value is missing / unparseable (caller decides the
fallback). Handles a trailing 'Z' and treats naive timestamps as UTC.
"""
if not ts:
return None
try:
text = ts.strip()
if text.endswith("Z"):
text = text[:-1] + "+00:00"
dt = datetime.fromisoformat(text)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return (datetime.now(timezone.utc) - dt).total_seconds()
except Exception:
return None
class Reconciler:
"""Background daemon that reconciles webhook-induced stage drift.
Modelled on ``QueueWorker``: a plain ``threading.Thread(daemon=True)`` +
``threading.Event`` for a clean stop. No correctness-critical state is held
in memory — every tick re-reads the DB / Plane; the observability counters
(``last_run_ts`` / ``unblocked_total`` / ``last_unblocked``) are best-effort
and may reset on restart (AC-11 allows this).
"""
def __init__(self, interval_s: float | None = None):
self.interval_s = (
interval_s if interval_s is not None else settings.reconcile_interval_s
)
self._stop = threading.Event()
self._thread: threading.Thread | None = None
# Best-effort observability (F-4).
self.last_run_ts: float | None = None
self.unblocked_total: int = 0
self.last_unblocked: str | None = None
# -- F-1: gate-side ----------------------------------------------------
def reconcile_gate_once(self) -> None:
"""One F-1 pass over all non-terminal tasks (per-task never-raise)."""
if not settings.reconcile_enabled:
return
for task in get_active_tasks_for_reconcile():
try:
self._reconcile_gate_task(task)
except Exception as e: # noqa: BLE001 - isolate one task's failure
logger.error(
f"reconciler F-1: task {task.get('id')} "
f"(stage={task.get('stage')}) failed: {e}"
)
def _reconcile_gate_task(self, task: dict) -> None:
task_id = task["id"]
stage = task["stage"]
# AC-16: analysis is a human gate -> owned by F-2, never F-1.
if stage == "analysis":
return
# created / done have no gate to evaluate.
if get_qg_for_stage(stage) is None:
return
# AC-3: a queued/running job means the task is legitimately in flight (or
# a live webhook just enqueued one) -> do not touch it.
if has_active_job_for_task(task_id):
return
# AC-5: respect the per-stage grace ("stuck", not just busy).
age_s = task.get("age_s") or 0
if age_s < grace_for_stage(stage):
return
result = advance_if_gate_passed(
task_id,
stage,
task["repo"],
task.get("work_item_id") or "",
task.get("branch") or "",
)
if result is not None and getattr(result, "advanced", False):
self._note_unblock(task.get("work_item_id") or str(task_id), stage)
# -- F-2: plane-side ---------------------------------------------------
def reconcile_plane_once(self) -> None:
"""One F-2 pass: poll Plane per project and replay missed transitions."""
if not settings.reconcile_enabled or not settings.reconcile_plane_enabled:
return
for proj in projects.PROJECTS:
try:
self._reconcile_plane_project(proj)
except Exception as e: # noqa: BLE001 - isolate one project's failure
logger.error(f"reconciler F-2: project {proj.repo} failed: {e}")
def _reconcile_plane_project(self, proj) -> None:
pid = proj.plane_project_id
# Resolve the actionable state uuids per-project (never hardcode).
states = get_project_states(pid)
in_progress = states["in_progress"]
approved = states["approved"]
rejected = states["rejected"]
issues = list_issues_by_state(pid, [in_progress, approved, rejected])
for issue in issues:
try:
self._reconcile_plane_issue(
issue, pid, in_progress, approved, rejected
)
except Exception as e: # noqa: BLE001 - isolate one issue's failure
logger.error(
f"reconciler F-2: issue {issue.get('id')} failed: {e}"
)
def _reconcile_plane_issue(
self, issue: dict, project_id: str,
in_progress: str, approved: str, rejected: str,
) -> None:
issue_id = str(issue.get("id") or "")
if not issue_id:
return
state = issue.get("state")
new_state = state.get("id") if isinstance(state, dict) else state
# 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).
age = _age_seconds_iso(issue.get("updated_at") or "")
if age is not None and age < settings.reconcile_grace_default_s:
return
task = get_task_by_plane_id(issue_id)
# AC-3/AC-4: a live webhook is in flight for this task -> skip.
if task is not None and has_active_job_for_task(task["id"]):
return
# issue_data in the shape the plane handlers expect; missing name /
# description are pulled by the handlers themselves (fetch_issue_fields).
issue_data = {
"id": issue_id,
"state": {"id": new_state},
"project": project_id,
"name": issue.get("name", ""),
"description_stripped": issue.get("description_stripped", ""),
}
if new_state == in_progress and task is None:
# In Progress without a task -> start the pipeline (lost start webhook).
self._dispatch(handle_status_start, issue_data, project_id)
self._note_unblock(issue_id, "analysis")
elif new_state == approved and task is not None:
# Approved but the stage never advanced -> replay the verdict.
self._dispatch(handle_verdict, issue_data, project_id, approved=True)
self._note_unblock(task.get("work_item_id") or issue_id, task["stage"])
elif new_state == rejected and task is not None:
# Rejected but never rolled back -> replay the verdict.
self._dispatch(handle_verdict, issue_data, project_id, approved=False)
self._note_unblock(task.get("work_item_id") or issue_id, task["stage"])
# else: everything is in sync -> silence (AC-10).
@staticmethod
def _dispatch(coro_fn, *args, **kwargs) -> None:
"""Run an async plane handler from this sync thread.
``asyncio.run`` spins a fresh event loop per call, which is required
because ``handle_verdict -> _try_advance_stage`` uses
``asyncio.to_thread`` (needs a running loop). The handlers are
REUSED verbatim — no pipeline logic is duplicated here.
"""
asyncio.run(coro_fn(*args, **kwargs))
# -- observability (F-4) ----------------------------------------------
def _note_unblock(self, work_item_id: str, stage: str) -> 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.
"""
self.unblocked_total += 1
self.last_unblocked = work_item_id
logger.info(
f"reconciler: {work_item_id} {stage} разблокирована (потерян webhook)"
)
if settings.reconcile_notify_unblock:
try:
send_telegram(
f"\U0001f527 reconciler: {work_item_id} {stage} "
f"разблокирована (потерян webhook)"
)
except Exception as e: # noqa: BLE001 - never break the tick
logger.warning(f"reconciler: unblock telegram failed: {e}")
# -- loop / lifecycle --------------------------------------------------
def _tick(self) -> None:
if settings.reconcile_enabled:
self.reconcile_gate_once() # F-1
if settings.reconcile_plane_enabled:
self.reconcile_plane_once() # F-2
self.last_run_ts = datetime.now(timezone.utc).timestamp()
def _run(self) -> None:
logger.info(
f"Reconciler started (interval={self.interval_s}s, "
f"enabled={settings.reconcile_enabled}, "
f"plane_enabled={settings.reconcile_plane_enabled})"
)
while not self._stop.is_set():
try:
self._tick()
except Exception as e: # noqa: BLE001 - outer never-raise
logger.error(f"Reconciler loop error: {e}")
self._stop.wait(self.interval_s)
logger.info("Reconciler stopped")
def start(self) -> None:
"""Start the daemon thread (idempotent: a live thread is a no-op)."""
if self._thread and self._thread.is_alive():
return
self._stop.clear()
self._thread = threading.Thread(
target=self._run, name="reconciler", daemon=True
)
self._thread.start()
def stop(self, timeout: float = 5.0) -> None:
self._stop.set()
if self._thread:
self._thread.join(timeout=timeout)
def status(self) -> dict:
"""Reconcile snapshot for /queue observability."""
return {
"enabled": settings.reconcile_enabled,
"plane_enabled": settings.reconcile_plane_enabled,
"interval": self.interval_s,
"last_run_ts": self.last_run_ts,
"unblocked_total": self.unblocked_total,
"last_unblocked": self.last_unblocked,
}
# Module-level singleton used by the FastAPI lifespan.
reconciler = Reconciler()

View File

@@ -318,6 +318,75 @@ def advance_stage(
return result
def advance_if_gate_passed(
task_id: int,
current_stage: str,
repo: str,
work_item_id: str,
branch: str,
) -> AdvanceResult | None:
"""ORCH-053 (F-1): reconcile a stuck stage by advancing it ONLY if its
quality gate is already green — without spamming failure notifications.
This is the thin wrapper the reconciler uses so that:
* The source of truth stays the GATE, and the advance path stays the
UNCHANGED unified ``advance_stage(..., finished_agent=None)`` (the same
path the Plane Approved-webhook uses). The reconciler never duplicates
``update_task_stage`` / ``enqueue_job`` (AC-2).
* On a stable-RED gate the sweeper is structurally silent: we do a cheap
read-only pre-evaluation of the gate and, if it fails, return ``None``
WITHOUT ever calling ``advance_stage`` — so the QG-failure notification
branch inside ``advance_stage`` (``agent is None`` ->
``notify_qg_failure`` + ``plane_notify_qg``) cannot fire on any tick
(AC-9). Spam is impossible by construction.
``analysis`` is intentionally NOT reconciled here: its gate
(``check_analysis_approved``) is a HUMAN gate; with ``finished_agent=None``
``advance_stage`` would treat it as approved-via-status and could advance an
unapproved BRD. The analysis advance is owned by the Plane-side reconciler
(F-2), which checks the real Plane status (AC-16).
Returns the ``AdvanceResult`` from ``advance_stage`` when the gate passed,
or ``None`` when the stage is not eligible / the gate is red / on any error
(never raises — the caller isolates per-task failures).
"""
try:
# AC-16: F-1 never reconciles the human analysis gate.
if current_stage == "analysis":
return None
qg_name = get_qg_for_stage(current_stage)
if not qg_name:
# created / done -> no gate to evaluate.
return None
# Read-only pre-evaluation with the SAME dispatcher the webhook path uses.
passed, reason = _run_qg(qg_name, repo, work_item_id, branch)
if not passed:
# Stable-red -> stay silent (no advance_stage call -> no QG-failure
# notification on this or any later tick).
logger.debug(
f"reconciler: task {task_id} gate '{qg_name}' still red "
f"({reason}); leaving on '{current_stage}'"
)
return None
# Gate is green: advance via the unchanged unified path. It re-runs the
# (idempotent, read-only) gate, advances the stage, sends the STANDARD
# advance notifications and enqueues the next agent.
return advance_stage(
task_id, current_stage, repo, work_item_id, branch, finished_agent=None
)
except Exception as e: # noqa: BLE001 - never-raise per ORCH-053 NFR
logger.error(
f"advance_if_gate_passed failed for task_id={task_id} "
f"stage={current_stage}: {e}"
)
return None
def _build_analyst_ready_comment(
repo: str, work_item_id: str, branch: str, task_id: int | None = None
) -> str:

View File

@@ -144,6 +144,36 @@ async def handle_push(payload: dict):
logger.info(f"Task {task_id}: source push detected on '{branch}', waiting for CI")
def _resolve_branch_via_db(repo_name: str) -> str:
"""ORCH-053 (F-3): resolve a CI-status SHA to a branch via the tasks DB.
Returns the branch of the SINGLE development-stage task for ``repo_name``.
If there are zero or several such tasks the match is ambiguous -> return ""
(the caller leaves the branch unresolved; never a false match). Logged at
INFO for visibility. Never raises.
"""
try:
from ..db import get_development_tasks_by_repo
devs = get_development_tasks_by_repo(repo_name)
except Exception as e: # noqa: BLE001 - defensive, never break the webhook
logger.info(f"CI status: sha->branch DB fallback errored for {repo_name}: {e}")
return ""
if len(devs) == 1:
branch = devs[0].get("branch") or ""
if branch:
logger.info(
f"CI status: sha->branch resolved via DB fallback to '{branch}' "
f"(unique development task in {repo_name})"
)
return branch
if len(devs) > 1:
logger.info(
f"CI status: sha->branch DB fallback ambiguous "
f"({len(devs)} development tasks in {repo_name}), leaving unresolved"
)
return ""
async def handle_ci_status(payload: dict):
"""
CI status update:
@@ -178,7 +208,15 @@ async def handle_ci_status(payload: dict):
except Exception:
pass
if not branch:
logger.debug(f"CI status event: could not determine branch for sha={sha}")
# ORCH-053 (F-3): DB fallback — when the SHA cannot be resolved to a
# branch (lost on a 502 rebuild, etc.), match it to the UNIQUE
# development-stage task of this repo. Ambiguity (more than one) is
# left unresolved to avoid a false match; the F-1 sweeper still picks
# such a task up later (defense-in-depth, not the critical path).
branch = _resolve_branch_via_db(repo_name)
if not branch:
# logger.info (was debug) so a lost CI event is VISIBLE in the logs.
logger.info(f"CI status event: could not determine branch for sha={sha}")
return
repo_name = payload.get("repository", {}).get("name", settings.default_repo)

View File

@@ -17,6 +17,7 @@ from ..db import (
update_task_stage,
enqueue_job,
insert_event_dedup,
create_task_atomic,
)
from ._dedup import plane_delivery_id
from ..stages import get_next_stage, get_agent_for_stage, get_qg_for_stage, get_previous_stage
@@ -496,15 +497,21 @@ async def start_pipeline(data: dict, project_id: str = ""):
f"branch collision for {repo}; disambiguated to unique branch {branch}"
)
# Insert task into DB
conn = get_db()
conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, plane_issue_id, title) "
"VALUES (?, ?, ?, ?, ?, ?, ?)",
(plane_id, work_item_id, repo, branch, "analysis", plane_id, name),
# Insert task into DB — ORCH-053 (AC-4): atomic anti-dup claim under a
# process-wide lock. If the F-2 reconciler and this live webhook race on the
# same plane_id, exactly one wins (created=True); the loser sees the existing
# task and returns WITHOUT creating a second branch / worktree / analyst job.
task_row, created = create_task_atomic(
plane_id, work_item_id, repo, branch, "analysis", name
)
conn.commit()
conn.close()
if not created:
logger.info(
f"start_pipeline: task for plane_id={plane_id} already exists "
f"(id={task_row['id']}, work_item_id={task_row.get('work_item_id')}), "
f"skipping duplicate creation"
)
return
task_id = task_row["id"]
# Create branch in Gitea
try:
@@ -523,20 +530,17 @@ async def start_pipeline(data: dict, project_id: str = ""):
logger.info(f"Task created: {work_item_id} ({name}), branch={branch}, stage=analysis")
# Launch analyst agent
# Launch analyst agent (task_id from the atomic create above).
try:
task_row = get_db().execute("SELECT id FROM tasks WHERE work_item_id=?", (work_item_id,)).fetchone()
if task_row:
task_id = task_row[0]
task_desc = (
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
f"Stage: analysis\nTitle: {name}\n\nDescription:\n{description}"
)
job_id = enqueue_job("analyst", repo, task_desc, task_id=task_id)
logger.info(f"Task {task_id}: enqueued analyst (job_id={job_id})")
# Post start comment to Plane
from ..plane_sync import add_comment as _add_comment
_add_comment(work_item_id, "\U0001f50d Analyst \u0437\u0430\u043f\u0443\u0449\u0435\u043d. BRD/\u0422\u0417/AC/TestPlan \u0432 \u0440\u0430\u0431\u043e\u0442\u0435 (\u043e\u0436\u0438\u0434\u0430\u0439\u0442\u0435 8-15 \u043c\u0438\u043d).", author="analyst")
task_desc = (
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
f"Stage: analysis\nTitle: {name}\n\nDescription:\n{description}"
)
job_id = enqueue_job("analyst", repo, task_desc, task_id=task_id)
logger.info(f"Task {task_id}: enqueued analyst (job_id={job_id})")
# Post start comment to Plane
from ..plane_sync import add_comment as _add_comment
_add_comment(work_item_id, "\U0001f50d Analyst \u0437\u0430\u043f\u0443\u0449\u0435\u043d. BRD/\u0422\u0417/AC/TestPlan \u0432 \u0440\u0430\u0431\u043e\u0442\u0435 (\u043e\u0436\u0438\u0434\u0430\u0439\u0442\u0435 8-15 \u043c\u0438\u043d).", author="analyst")
except Exception as e:
logger.error(f"Failed to launch analyst for {work_item_id}: {e}")

View File

@@ -37,6 +37,10 @@ def _no_telegram(monkeypatch):
monkeypatch.setattr("src.webhooks.plane.send_telegram", _noop, raising=False)
monkeypatch.setattr("src.agents.launcher.send_telegram", _noop, raising=False)
monkeypatch.setattr("src.queue_worker.send_telegram", _noop, raising=False)
# ORCH-053: the reconciler binds send_telegram as a MODULE-LEVEL name
# (from .notifications import send_telegram), so the source patch alone would
# not intercept its unblock notification — patch it here too.
monkeypatch.setattr("src.reconciler.send_telegram", _noop, raising=False)
yield

View File

@@ -72,3 +72,46 @@ def test_merge_gate_settings_env_override(monkeypatch):
assert s.merge_lock_timeout_s == 90
assert s.merge_defer_delay_s == 5
assert s.merge_defer_max_attempts == 9
# ---------------------------------------------------------------------------
# ORCH-053 / TC-22: reconcile_* settings defaults + env override.
# ---------------------------------------------------------------------------
_RECONCILE_ENV = (
"ORCH_RECONCILE_ENABLED",
"ORCH_RECONCILE_INTERVAL_S",
"ORCH_RECONCILE_PLANE_ENABLED",
"ORCH_RECONCILE_GRACE_DEFAULT_S",
"ORCH_RECONCILE_GRACE_OVERRIDES_JSON",
"ORCH_RECONCILE_NOTIFY_UNBLOCK",
)
def test_reconcile_settings_defaults(monkeypatch):
"""TC-22 / AC-13: documented defaults when no env is set."""
for name in _RECONCILE_ENV:
monkeypatch.delenv(name, raising=False)
s = Settings()
assert s.reconcile_enabled is True
assert s.reconcile_interval_s == 120
assert s.reconcile_plane_enabled is True
assert s.reconcile_grace_default_s == 600
assert s.reconcile_grace_overrides_json == ""
assert s.reconcile_notify_unblock is True
def test_reconcile_settings_env_override(monkeypatch):
"""TC-22 / AC-13: each field is read from its ORCH_* env var."""
monkeypatch.setenv("ORCH_RECONCILE_ENABLED", "false")
monkeypatch.setenv("ORCH_RECONCILE_INTERVAL_S", "300")
monkeypatch.setenv("ORCH_RECONCILE_PLANE_ENABLED", "false")
monkeypatch.setenv("ORCH_RECONCILE_GRACE_DEFAULT_S", "900")
monkeypatch.setenv("ORCH_RECONCILE_GRACE_OVERRIDES_JSON", '{"development": 300}')
monkeypatch.setenv("ORCH_RECONCILE_NOTIFY_UNBLOCK", "false")
s = Settings()
assert s.reconcile_enabled is False
assert s.reconcile_interval_s == 300
assert s.reconcile_plane_enabled is False
assert s.reconcile_grace_default_s == 900
assert s.reconcile_grace_overrides_json == '{"development": 300}'
assert s.reconcile_notify_unblock is False

View File

@@ -0,0 +1,119 @@
"""ORCH-053 (F-3): sha->branch resolution hardening in handle_ci_status.
When a CI-status webhook carries no ``branches[]`` and the SHA cannot be
resolved to a feature branch via ``git branch -r --contains`` (lost on a 502
rebuild, shallow clone, etc.), handle_ci_status now falls back to the tasks DB
and matches the UNIQUE development-stage task of the repo. Ambiguity (more than
one development task) is deliberately left unresolved so it can never make a
false match.
The git subprocess and the network QG / Plane / Telegram side effects are mocked
so the handler runs offline against a real isolated sqlite DB.
"""
import asyncio
import os
import tempfile
from types import SimpleNamespace
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_gitea_sha.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from unittest.mock import MagicMock # noqa: E402
import src.db as _db # noqa: E402
from src.db import init_db, get_db # noqa: E402
from src.webhooks import gitea as gitea_mod # noqa: E402
@pytest.fixture(autouse=True)
def fresh_db(monkeypatch):
monkeypatch.setattr(_db.settings, "db_path", _test_db)
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
yield
@pytest.fixture(autouse=True)
def silence_and_stub_git(monkeypatch):
# git branch -r --contains <sha> resolves to nothing (forces the DB fallback).
monkeypatch.setattr(
gitea_mod.subprocess, "run",
lambda *a, **k: SimpleNamespace(stdout="", returncode=0),
)
# Mute the network side effects bound module-level in gitea.
for name in ("notify_stage_change", "notify_qg_failure", "notify_error",
"plane_notify_stage"):
monkeypatch.setattr(gitea_mod, name, MagicMock(), raising=False)
def _make_dev_task(branch, wi, repo="enduro-trails"):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
"VALUES (?, ?, ?, ?, 'development')",
(f"plane-{wi}", wi, repo, branch),
)
tid = cur.lastrowid
conn.commit()
conn.close()
return tid
def _stage_of(task_id):
conn = get_db()
row = conn.execute("SELECT stage FROM tasks WHERE id = ?", (task_id,)).fetchone()
conn.close()
return row["stage"]
def _ci_payload(sha="deadbeef", repo="enduro-trails", state="success"):
return {
"state": state,
"sha": sha,
"branches": [], # no branch in the event -> forces resolution
"repository": {"name": repo},
}
# ---------------------------------------------------------------------------
# TC-18: unique development task -> DB fallback resolves the branch, advances.
# ---------------------------------------------------------------------------
def test_tc18_db_fallback_unique_match_advances(monkeypatch):
ci = MagicMock(return_value=(True, "CI green"))
monkeypatch.setattr(gitea_mod, "check_ci_green", ci)
tid = _make_dev_task("feature/ET-050-x", "ET-050")
asyncio.run(gitea_mod.handle_ci_status(_ci_payload()))
assert _stage_of(tid) == "review"
ci.assert_called_once()
# The fallback resolved to the unique dev task's branch.
assert ci.call_args.args[1] == "feature/ET-050-x"
# ---------------------------------------------------------------------------
# TC-19: several development tasks -> ambiguous -> no false match, no advance.
# ---------------------------------------------------------------------------
def test_tc19_db_fallback_ambiguous_no_match(monkeypatch, caplog):
ci = MagicMock(return_value=(True, "CI green"))
monkeypatch.setattr(gitea_mod, "check_ci_green", ci)
t1 = _make_dev_task("feature/ET-051-a", "ET-051")
t2 = _make_dev_task("feature/ET-052-b", "ET-052")
with caplog.at_level("INFO", logger="orchestrator.webhooks.gitea"):
asyncio.run(gitea_mod.handle_ci_status(_ci_payload()))
# Ambiguity -> branch unresolved -> handler returns before touching the gate.
assert _stage_of(t1) == "development"
assert _stage_of(t2) == "development"
ci.assert_not_called()
assert "could not determine branch" in caplog.text

379
tests/test_reconciler.py Normal file
View File

@@ -0,0 +1,379 @@
"""ORCH-053: tests for the gate-side stuck-task reconciler (F-1) + lifecycle.
These cover the F-1 sweeper (``Reconciler.reconcile_gate_once``), the per-stage
grace / config (``grace_for_stage``), the no-spam guarantee, the analysis carve-
out (AC-16), never-raise isolation, the kill-switch, the unblock observability
(AC-12 / F-4) and the restart-safe daemon thread (AC-11).
Everything that touches the network (the quality gate, Plane sync, Telegram) is
mocked at the src.stage_engine / src.reconciler level so the reconciler runs
against a real isolated sqlite DB (same convention as test_stage_engine.py).
"""
import os
import tempfile
import pytest
# Isolated test DB (set BEFORE importing src.* so settings picks it up).
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_reconciler.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from unittest.mock import MagicMock # noqa: E402
import src.db as _db # noqa: E402
from src.db import init_db, get_db, enqueue_job # noqa: E402
from src import stage_engine # noqa: E402
from src import reconciler as reconciler_mod # noqa: E402
from src.reconciler import Reconciler, grace_for_stage # noqa: E402
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def fresh_db(monkeypatch):
"""Fresh isolated DB per test."""
monkeypatch.setattr(_db.settings, "db_path", _test_db)
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
yield
@pytest.fixture(autouse=True)
def silence_side_effects(monkeypatch):
"""No-op every Plane/Telegram/notification side effect in the engine so the
real advance_stage runs deterministically and offline."""
for name in (
"notify_stage_change",
"notify_qg_failure",
"notify_approve_requested",
"notify_error",
"send_telegram",
"plane_notify_stage",
"plane_notify_qg",
"plane_add_comment",
"set_issue_in_review",
"set_issue_needs_input",
"set_issue_in_progress",
"set_issue_blocked",
"set_issue_done",
):
monkeypatch.setattr(stage_engine, name, MagicMock(), raising=False)
def _make_task(stage, *, repo="enduro-trails", branch="feature/ET-001-x",
wi="ET-001", age_s=None):
"""Insert a task; if age_s is given, backdate updated_at by that many secs."""
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
"VALUES (?, ?, ?, ?, ?)",
(f"plane-{wi}", wi, repo, branch, stage),
)
task_id = cur.lastrowid
if age_s is not None:
conn.execute(
"UPDATE tasks SET updated_at = datetime('now', ?) WHERE id = ?",
(f"-{int(age_s)} seconds", task_id),
)
conn.commit()
conn.close()
return task_id
def _stage_of(task_id):
conn = get_db()
row = conn.execute("SELECT stage FROM tasks WHERE id = ?", (task_id,)).fetchone()
conn.close()
return row["stage"]
def _jobs_for(task_id, agent=None):
conn = get_db()
if agent:
rows = conn.execute(
"SELECT * FROM jobs WHERE task_id = ? AND agent = ?", (task_id, agent)
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM jobs WHERE task_id = ?", (task_id,)
).fetchall()
conn.close()
return [dict(r) for r in rows]
def _green_ci(monkeypatch, value=(True, "CI green")):
"""Patch the check_ci_green entry in QG_CHECKS; return the mock."""
m = MagicMock(return_value=value)
monkeypatch.setitem(stage_engine.QG_CHECKS, "check_ci_green", m)
return m
# ---------------------------------------------------------------------------
# TC-01: happy path — stuck development task is advanced to review
# ---------------------------------------------------------------------------
def test_tc01_advances_stuck_development_task(monkeypatch):
_green_ci(monkeypatch)
task_id = _make_task("development", age_s=3600) # well past grace
Reconciler().reconcile_gate_once()
assert _stage_of(task_id) == "review"
reviewer_jobs = _jobs_for(task_id, "reviewer")
assert len(reviewer_jobs) == 1
# ---------------------------------------------------------------------------
# TC-02: source of truth is the gate — advance goes through advance_stage
# with finished_agent=None (no own update_task_stage/enqueue_job).
# ---------------------------------------------------------------------------
def test_tc02_advances_via_advance_stage_finished_agent_none(monkeypatch):
_green_ci(monkeypatch)
spy = MagicMock(wraps=stage_engine.advance_stage)
# advance_if_gate_passed resolves advance_stage as a module global.
monkeypatch.setattr(stage_engine, "advance_stage", spy)
task_id = _make_task("development", age_s=3600)
Reconciler().reconcile_gate_once()
assert spy.call_count == 1
# finished_agent must be None (the webhook path).
_args, kwargs = spy.call_args
assert kwargs.get("finished_agent", "MISSING") is None
assert spy.call_args.args[0] == task_id
# ---------------------------------------------------------------------------
# TC-03: task with an active job is skipped — gate not evaluated, no advance.
# ---------------------------------------------------------------------------
def test_tc03_active_job_skipped(monkeypatch):
ci = _green_ci(monkeypatch)
spy = MagicMock(wraps=stage_engine.advance_stage)
monkeypatch.setattr(stage_engine, "advance_stage", spy)
task_id = _make_task("development", age_s=3600)
enqueue_job("reviewer", "enduro-trails", task_id=task_id) # active (queued)
Reconciler().reconcile_gate_once()
assert _stage_of(task_id) == "development"
ci.assert_not_called()
spy.assert_not_called()
# ---------------------------------------------------------------------------
# TC-04: per-stage grace — fresh task untouched, at-threshold task eligible.
# ---------------------------------------------------------------------------
def test_tc04_grace_boundary(monkeypatch):
monkeypatch.setattr(reconciler_mod.settings, "reconcile_grace_default_s", 600)
_green_ci(monkeypatch)
fresh = _make_task("development", branch="feature/ET-002-fresh",
wi="ET-002", age_s=10) # < grace -> untouched
stuck = _make_task("development", branch="feature/ET-003-stuck",
wi="ET-003", age_s=3600) # >= grace -> advanced
Reconciler().reconcile_gate_once()
assert _stage_of(fresh) == "development"
assert _stage_of(stuck) == "review"
# ---------------------------------------------------------------------------
# TC-05: grace_for_stage reads overrides JSON; bad JSON -> default, no crash.
# ---------------------------------------------------------------------------
def test_tc05_grace_for_stage_overrides(monkeypatch):
monkeypatch.setattr(reconciler_mod.settings, "reconcile_grace_default_s", 600)
monkeypatch.setattr(
reconciler_mod.settings,
"reconcile_grace_overrides_json",
'{"development": 30, "review": 7200}',
)
assert grace_for_stage("development") == 30
assert grace_for_stage("review") == 7200
# missing key -> default
assert grace_for_stage("testing") == 600
def test_tc05_grace_for_stage_invalid_json_falls_back(monkeypatch):
monkeypatch.setattr(reconciler_mod.settings, "reconcile_grace_default_s", 600)
monkeypatch.setattr(
reconciler_mod.settings, "reconcile_grace_overrides_json", "{not valid json"
)
# Must not raise, must fall back to the default.
assert grace_for_stage("development") == 600
# ---------------------------------------------------------------------------
# TC-06: no spam — a stable-red gate never advances and never notifies, even
# across many ticks.
# ---------------------------------------------------------------------------
def test_tc06_red_gate_no_spam(monkeypatch):
_green_ci(monkeypatch, value=(False, "CI red"))
task_id = _make_task("development", age_s=3600)
rec = Reconciler()
for _ in range(5):
rec.reconcile_gate_once()
assert _stage_of(task_id) == "development"
# The QG-failure notification branch inside advance_stage must never fire,
# because advance_if_gate_passed returns None on a red gate (no advance call).
stage_engine.notify_qg_failure.assert_not_called()
stage_engine.plane_notify_qg.assert_not_called()
assert rec.unblocked_total == 0
# ---------------------------------------------------------------------------
# TC-07: silence when in sync — done / busy / within-grace tasks => no advance.
# ---------------------------------------------------------------------------
def test_tc07_silence_when_in_sync(monkeypatch):
_green_ci(monkeypatch)
spy = MagicMock(wraps=stage_engine.advance_stage)
monkeypatch.setattr(stage_engine, "advance_stage", spy)
_make_task("done", branch="feature/ET-010-done", wi="ET-010", age_s=3600)
fresh = _make_task("development", branch="feature/ET-011-fresh",
wi="ET-011", age_s=5)
busy = _make_task("development", branch="feature/ET-012-busy",
wi="ET-012", age_s=3600)
enqueue_job("reviewer", "enduro-trails", task_id=busy)
rec = Reconciler()
rec.reconcile_gate_once()
spy.assert_not_called()
assert rec.unblocked_total == 0
assert _stage_of(fresh) == "development"
# ---------------------------------------------------------------------------
# TC-08 (AC-16): F-1 never advances the human analysis gate.
# ---------------------------------------------------------------------------
def test_tc08_analysis_not_advanced_by_f1(monkeypatch):
# Even if the analysis gate would "pass", F-1 must not touch analysis.
monkeypatch.setitem(
stage_engine.QG_CHECKS, "check_analysis_approved",
MagicMock(return_value=(True, "approved")),
)
spy = MagicMock(wraps=stage_engine.advance_stage)
monkeypatch.setattr(stage_engine, "advance_stage", spy)
task_id = _make_task("analysis", age_s=3600)
Reconciler().reconcile_gate_once()
assert _stage_of(task_id) == "analysis"
spy.assert_not_called()
# ---------------------------------------------------------------------------
# TC-09: never-raise — one task blowing up does not stop the others.
# ---------------------------------------------------------------------------
def test_tc09_never_raise_isolates_failure(monkeypatch):
calls = []
def boom(task_id, stage, repo, wi, branch):
calls.append(task_id)
raise RuntimeError("boom")
monkeypatch.setattr(reconciler_mod, "advance_if_gate_passed", boom)
t1 = _make_task("development", branch="feature/ET-020-a", wi="ET-020", age_s=3600)
t2 = _make_task("development", branch="feature/ET-021-b", wi="ET-021", age_s=3600)
# Must not raise despite both tasks raising inside advance_if_gate_passed.
Reconciler().reconcile_gate_once()
assert set(calls) == {t1, t2} # both attempted
# ---------------------------------------------------------------------------
# TC-10: kill-switches.
# ---------------------------------------------------------------------------
def test_tc10_kill_switch_disables_gate(monkeypatch):
monkeypatch.setattr(reconciler_mod.settings, "reconcile_enabled", False)
spy = MagicMock(wraps=stage_engine.advance_stage)
monkeypatch.setattr(stage_engine, "advance_stage", spy)
_green_ci(monkeypatch)
task_id = _make_task("development", age_s=3600)
Reconciler().reconcile_gate_once()
assert _stage_of(task_id) == "development"
spy.assert_not_called()
def test_tc10_plane_switch_mutes_only_f2(monkeypatch):
monkeypatch.setattr(reconciler_mod.settings, "reconcile_enabled", True)
monkeypatch.setattr(reconciler_mod.settings, "reconcile_plane_enabled", False)
plane_pass = MagicMock()
monkeypatch.setattr(reconciler_mod.Reconciler, "_reconcile_plane_project", plane_pass)
# F-2 muted -> reconcile_plane_once is a no-op.
Reconciler().reconcile_plane_once()
plane_pass.assert_not_called()
# F-1 still runs.
_green_ci(monkeypatch)
task_id = _make_task("development", age_s=3600)
Reconciler().reconcile_gate_once()
assert _stage_of(task_id) == "review"
# ---------------------------------------------------------------------------
# TC-20: observability — explicit unblock log line + telegram (AC-12 / F-4).
# ---------------------------------------------------------------------------
def test_tc20_unblock_logs_and_notifies(monkeypatch, caplog):
_green_ci(monkeypatch)
monkeypatch.setattr(reconciler_mod.settings, "reconcile_notify_unblock", True)
tg = MagicMock()
monkeypatch.setattr(reconciler_mod, "send_telegram", tg)
_make_task("development", wi="ET-042", age_s=3600)
rec = Reconciler()
with caplog.at_level("INFO", logger="orchestrator.reconciler"):
rec.reconcile_gate_once()
# Exact AC-12 contract string.
assert "reconciler: ET-042 development разблокирована (потерян webhook)" in caplog.text
assert rec.unblocked_total == 1
assert rec.last_unblocked == "ET-042"
tg.assert_called_once()
def test_tc20_no_telegram_when_disabled(monkeypatch):
_green_ci(monkeypatch)
monkeypatch.setattr(reconciler_mod.settings, "reconcile_notify_unblock", False)
tg = MagicMock()
monkeypatch.setattr(reconciler_mod, "send_telegram", tg)
_make_task("development", wi="ET-043", age_s=3600)
Reconciler().reconcile_gate_once()
tg.assert_not_called()
# ---------------------------------------------------------------------------
# TC-21: restart-safe daemon thread — start/stop/idempotent start.
# ---------------------------------------------------------------------------
def test_tc21_daemon_thread_lifecycle(monkeypatch):
# Avoid any real work in the loop: disable both branches, big interval.
monkeypatch.setattr(reconciler_mod.settings, "reconcile_enabled", False)
rec = Reconciler(interval_s=60)
rec.start()
assert rec._thread is not None and rec._thread.is_alive()
first_thread = rec._thread
# Idempotent: a second start does not spawn a new thread.
rec.start()
assert rec._thread is first_thread
rec.stop(timeout=5.0)
assert not first_thread.is_alive()

View File

@@ -0,0 +1,297 @@
"""ORCH-053: tests for the Plane-side reconciler (F-2) + sha-resolve helpers.
F-2 polls the Plane API per project (``list_issues_by_state``) and REPLAYS a
missed In Progress / Approved / Rejected transition through the EXISTING
``webhooks.plane.handle_status_start`` / ``handle_verdict`` handlers — it never
duplicates pipeline logic. These tests mock those handlers (AsyncMock) and the
Plane API helpers, and verify the dispatch / idempotency / multi-project rules.
TC-15 is the AC-4 anti-dup integration test for ``create_task_atomic`` against a
real isolated sqlite DB under concurrency.
TC-16 exercises ``plane_sync.list_issues_by_state`` directly (pagination + the
never-raise contract).
"""
import os
import tempfile
import threading
from types import SimpleNamespace
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_reconciler_plane.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from unittest.mock import AsyncMock, MagicMock # noqa: E402
import src.db as _db # noqa: E402
from src.db import init_db, get_db, enqueue_job, create_task_atomic # noqa: E402
from src import reconciler as reconciler_mod # noqa: E402
from src import plane_sync # noqa: E402
from src.reconciler import Reconciler # noqa: E402
_IN_PROGRESS = "uuid-in-progress"
_APPROVED = "uuid-approved"
_REJECTED = "uuid-rejected"
_OLD_TS = "2020-01-01T00:00:00Z" # well past any grace
@pytest.fixture(autouse=True)
def fresh_db(monkeypatch):
monkeypatch.setattr(_db.settings, "db_path", _test_db)
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
yield
@pytest.fixture
def single_project(monkeypatch):
"""Restrict F-2 to a single fake project and stub its state resolution."""
proj = SimpleNamespace(
plane_project_id="proj-1", repo="enduro-trails", work_item_prefix="ET",
)
monkeypatch.setattr(reconciler_mod.projects, "PROJECTS", [proj])
monkeypatch.setattr(
reconciler_mod, "get_project_states",
lambda pid: {
"in_progress": _IN_PROGRESS,
"approved": _APPROVED,
"rejected": _REJECTED,
},
)
return proj
def _make_task(plane_id, stage="review", repo="enduro-trails",
branch="feature/ET-001-x", wi="ET-001"):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage, plane_issue_id) "
"VALUES (?, ?, ?, ?, ?, ?)",
(plane_id, wi, repo, branch, stage, plane_id),
)
tid = cur.lastrowid
conn.commit()
conn.close()
return tid
def _patch_handlers(monkeypatch):
start = AsyncMock()
verdict = AsyncMock()
monkeypatch.setattr(reconciler_mod, "handle_status_start", start)
monkeypatch.setattr(reconciler_mod, "handle_verdict", verdict)
return start, verdict
def _patch_issues(monkeypatch, issues):
monkeypatch.setattr(
reconciler_mod, "list_issues_by_state", lambda pid, states: list(issues)
)
# ---------------------------------------------------------------------------
# TC-11: In Progress without a task -> handle_status_start once.
# ---------------------------------------------------------------------------
def test_tc11_in_progress_without_task_starts_pipeline(monkeypatch, single_project):
start, verdict = _patch_handlers(monkeypatch)
_patch_issues(monkeypatch, [
{"id": "iss-1", "state": {"id": _IN_PROGRESS}, "updated_at": _OLD_TS,
"name": "Some issue"},
])
Reconciler().reconcile_plane_once()
assert start.call_count == 1
issue_data, project_id = start.call_args.args
assert issue_data["id"] == "iss-1"
assert issue_data["state"]["id"] == _IN_PROGRESS
assert project_id == "proj-1"
verdict.assert_not_called()
# ---------------------------------------------------------------------------
# TC-12: Approved with an existing task, no active job -> handle_verdict(True).
# ---------------------------------------------------------------------------
def test_tc12_approved_replays_verdict(monkeypatch, single_project):
start, verdict = _patch_handlers(monkeypatch)
_make_task("iss-2", stage="review")
_patch_issues(monkeypatch, [
{"id": "iss-2", "state": {"id": _APPROVED}, "updated_at": _OLD_TS},
])
Reconciler().reconcile_plane_once()
assert verdict.call_count == 1
assert verdict.call_args.kwargs.get("approved") is True
start.assert_not_called()
# ---------------------------------------------------------------------------
# TC-13: Rejected with an existing task -> handle_verdict(False).
# ---------------------------------------------------------------------------
def test_tc13_rejected_replays_verdict(monkeypatch, single_project):
start, verdict = _patch_handlers(monkeypatch)
_make_task("iss-3", stage="review")
_patch_issues(monkeypatch, [
{"id": "iss-3", "state": {"id": _REJECTED}, "updated_at": _OLD_TS},
])
Reconciler().reconcile_plane_once()
assert verdict.call_count == 1
assert verdict.call_args.kwargs.get("approved") is False
start.assert_not_called()
# ---------------------------------------------------------------------------
# TC-14: idempotency — an active job means a live webhook is in flight -> skip.
# ---------------------------------------------------------------------------
def test_tc14_active_job_skips(monkeypatch, single_project):
start, verdict = _patch_handlers(monkeypatch)
tid = _make_task("iss-4", stage="review")
enqueue_job("reviewer", "enduro-trails", task_id=tid) # active
_patch_issues(monkeypatch, [
{"id": "iss-4", "state": {"id": _APPROVED}, "updated_at": _OLD_TS},
])
Reconciler().reconcile_plane_once()
start.assert_not_called()
verdict.assert_not_called()
# ---------------------------------------------------------------------------
# TC-14b: within-grace issue is left alone (lost, not merely delayed).
# ---------------------------------------------------------------------------
def test_tc14b_within_grace_skipped(monkeypatch, single_project):
from datetime import datetime, timezone
start, verdict = _patch_handlers(monkeypatch)
_make_task("iss-5", stage="review")
fresh_ts = datetime.now(timezone.utc).isoformat()
_patch_issues(monkeypatch, [
{"id": "iss-5", "state": {"id": _APPROVED}, "updated_at": fresh_ts},
])
Reconciler().reconcile_plane_once()
start.assert_not_called()
verdict.assert_not_called()
# ---------------------------------------------------------------------------
# TC-15 (AC-4): atomic anti-dup — concurrent create_task_atomic for one
# plane_id yields exactly ONE row and ONE created=True.
# ---------------------------------------------------------------------------
def test_tc15_create_task_atomic_no_duplicate():
results = []
barrier = threading.Barrier(8)
def worker():
barrier.wait() # maximise the race
row, created = create_task_atomic(
"plane-dup", "ET-099", "enduro-trails",
"feature/ET-099-x", "analysis", "Dup race",
)
results.append((row["id"], created))
threads = [threading.Thread(target=worker) for _ in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
created_flags = [c for _, c in results]
assert created_flags.count(True) == 1 # exactly one winner
assert created_flags.count(False) == 7 # the rest see the existing row
conn = get_db()
n = conn.execute(
"SELECT COUNT(*) FROM tasks WHERE plane_id = 'plane-dup'"
).fetchone()[0]
conn.close()
assert n == 1 # only one task row ever created
# All callers see the same row id (the single task).
assert len({rid for rid, _ in results}) == 1
# ---------------------------------------------------------------------------
# TC-16: list_issues_by_state — never-raise on API error, filter+paginate on OK.
# ---------------------------------------------------------------------------
def test_tc16_list_issues_never_raises_on_error(monkeypatch):
def boom(*a, **k):
raise RuntimeError("plane down")
monkeypatch.setattr(plane_sync.httpx, "get", boom)
out = plane_sync.list_issues_by_state("proj-1", [_APPROVED])
assert out == []
def test_tc16_list_issues_paginates_and_filters(monkeypatch):
page1 = {
"results": [
{"id": "a", "state": {"id": _APPROVED}},
{"id": "b", "state": {"id": "other"}},
],
"next_page_results": True,
"next_cursor": "cur2",
}
page2 = {
"results": [
{"id": "c", "state": _APPROVED}, # bare-uuid state shape
{"id": "d", "state": {"id": _REJECTED}},
],
"next_page_results": False,
"next_cursor": None,
}
pages = iter([page1, page2])
def fake_get(url, headers=None, params=None, timeout=None):
resp = MagicMock()
resp.json.return_value = next(pages)
resp.raise_for_status.return_value = None
return resp
monkeypatch.setattr(plane_sync.httpx, "get", fake_get)
out = plane_sync.list_issues_by_state("proj-1", [_APPROVED, _REJECTED])
ids = {i["id"] for i in out}
assert ids == {"a", "c", "d"} # 'b' filtered out (state 'other')
# ---------------------------------------------------------------------------
# TC-17: F-2 polls EVERY registry project and resolves states per-project.
# ---------------------------------------------------------------------------
def test_tc17_polls_all_projects_resolves_states_per_project(monkeypatch):
_patch_handlers(monkeypatch)
from src import projects as projects_mod
projects_mod.reload_projects()
expected_ids = {p.plane_project_id for p in projects_mod.PROJECTS}
assert len(expected_ids) >= 2 # enduro + orchestrator in the default registry
states_calls = []
issues_calls = []
def fake_states(pid):
states_calls.append(pid)
return {"in_progress": _IN_PROGRESS, "approved": _APPROVED, "rejected": _REJECTED}
def fake_issues(pid, states):
issues_calls.append((pid, tuple(states)))
return []
monkeypatch.setattr(reconciler_mod, "get_project_states", fake_states)
monkeypatch.setattr(reconciler_mod, "list_issues_by_state", fake_issues)
Reconciler().reconcile_plane_once()
assert set(states_calls) == expected_ids
assert {pid for pid, _ in issues_calls} == expected_ids
# 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}