171 lines
16 KiB
Markdown
171 lines
16 KiB
Markdown
# ТЗ — 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-ветка ~стр.199–217) и `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, без реальной сети).
|