From efe437a4aa9df666f3930bf7aa96b288a5f7c4fd Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 11:41:02 +0000 Subject: [PATCH] architect(ET): auto-commit from architect run_id=289 --- docs/architecture/README.md | 11 +- docs/architecture/adr/adr-0007-reconciler.md | 8 + .../ADR-001-reconciler-skip-escalated.md | 161 ++++++++++++++++++ docs/work-items/ORCH-060/10-tech-risks.md | 20 +++ 4 files changed, 198 insertions(+), 2 deletions(-) create mode 100644 docs/work-items/ORCH-060/06-adr/ADR-001-reconciler-skip-escalated.md create mode 100644 docs/work-items/ORCH-060/10-tech-risks.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index c20ab5d..91da3e3 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -11,7 +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`. +- **Reconciler** (`src/reconciler.py`, ORCH-053 — реализовано, [adr-0007](adr/adr-0007-reconciler.md)) — фоновый daemon-поток (паттерн `queue_worker`), стартует/останавливается в `main.lifespan` (после `worker.start()` / перед `worker.stop()`). Реконсилирует рассинхрон «источник истины ≠ стадия задачи» при потерянном webhook. F-1 gate-side (продвигает застрявшую стадию по локальной БД через штатный `advance_stage(..., finished_agent=None)`), F-2 plane-side (опрос Plane API → `handle_*` из `plane.py`), F-3 (БД-fallback `sha→branch` в `handle_ci_status`). Источник истины — гейт/Plane, не событие; идемпотентность (active-job guard + atomic-claim + grace); kill-switch `ORCH_RECONCILE_ENABLED`. `analysis` F-1 не трогает (человеческий гейт). F-1 также пропускает escalated (retry≥лимита) и Blocked/Needs-Input задачи (ORCH-060). Наблюдаемость — блок `reconcile` в `GET /queue`. - **Project Registry** (`src/projects.py`, ORCH-6) — Plane project id → repo + prefix; фильтрация вебхуков по проекту. - **Plane Sync** (`src/plane_sync.py`) — синхронизация статусов/комментариев в Plane. @@ -118,6 +118,13 @@ helper `validated_revision` питает и штамп A, и `EXPECTED_REVISION` `age(updated_at) ≥ grace_for_stage(stage)` — read-only пред-оценка канонического QG; зелёный → `stage_engine.advance_stage(..., finished_agent=None)`; красный → тишина (спам нотификаций структурно невозможен). `analysis` не реконсилируется. + **Skip escalated / Blocked / Needs-Input (ORCH-060):** ДО оценки гейта F-1 + пропускает (молча, без advance/нотификаций) задачи, которые ждут человека — + (1) исчерпавшие лимит developer-ретраев (`developer_retry_count(task_id) >= + MAX_DEVELOPER_RETRIES`, детерминированно, без сети — закрывает bounce-петлю + ET-013) и (2) в явном Plane-статусе **Blocked** / **Needs Input** (Вариант A — + запрос Plane API, без миграции БД; never-raise → консервативный skip). Гард + retry-count проверяется первым (дёшево, локальный SQL). - **F-2 plane-side:** опрос Plane API per-project → `handle_status_start` / `handle_verdict` из `webhooks/plane.py` (логика не дублируется). - **F-3:** усиление `sha→branch` в `handle_ci_status` (БД-fallback по единственной @@ -194,4 +201,4 @@ never-raise на единицу работы; тишина при синхрон Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.md). --- -*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile).* +*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — architecture, ветка feature/ORCH-060; ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile).* diff --git a/docs/architecture/adr/adr-0007-reconciler.md b/docs/architecture/adr/adr-0007-reconciler.md index f9466ea..e0dbd38 100644 --- a/docs/architecture/adr/adr-0007-reconciler.md +++ b/docs/architecture/adr/adr-0007-reconciler.md @@ -61,6 +61,14 @@ grace + `max_concurrency=1`); never-raise на единицу работы; ти (`reconcile_plane_enabled` гасит только F-2); reconciler не рестартит/не роняет прод-контейнер. БД-схема и реестры (`STAGE_TRANSITIONS`/`QG_CHECKS`) не меняются. +## Уточнения +- **ORCH-060** (`docs/work-items/ORCH-060/06-adr/ADR-001-reconciler-skip-escalated.md`): + F-1 (`_reconcile_gate_task`) приобретает два пред-гарда ДО оценки гейта — + пропускает escalated (`developer_retry_count ≥ MAX_DEVELOPER_RETRIES`, + детерминированно) и Blocked/Needs-Input (Вариант A, Plane API, без миграции) + задачи. Инварианты adr-0007 сохранены (схема/реестры не меняются, never-raise, + тишина при пропуске). + ## Связи adr-0002 (очередь / `available_at`, single-process-singleton), adr-0003 (условный гейт — образец условности/флагов раската), adr-0006 (merge-gate как под-гейт ребра diff --git a/docs/work-items/ORCH-060/06-adr/ADR-001-reconciler-skip-escalated.md b/docs/work-items/ORCH-060/06-adr/ADR-001-reconciler-skip-escalated.md new file mode 100644 index 0000000..3394958 --- /dev/null +++ b/docs/work-items/ORCH-060/06-adr/ADR-001-reconciler-skip-escalated.md @@ -0,0 +1,161 @@ +# ADR-001: Reconciler (F-1) пропускает escalated / Blocked / Needs-Input задачи + +- **Статус:** Accepted +- **Дата:** 2026-06-07 +- **Задача:** ORCH-060 +- **Стадия:** architecture +- **Связано:** adr-0007 (reconciler, ORCH-053) — уточняет контракт F-1; + ORCH-046 (retry-счётчик), ORCH-047 (BLOCKED-вердикт) + +## Контекст + +ORCH-053 ввёл F-1 (`Reconciler._reconcile_gate_task`): для каждой не-терминальной +задачи без активного job и старше grace делается read-only пред-оценка +канонического QG; зелёный → `advance_if_gate_passed` → +`advance_stage(..., finished_agent=None)`. + +**Дефект (инцидент ET-013, 06–07.06.2026).** Задача, исчерпавшая лимит +developer-ретраев (`_developer_retry_count(task_id) >= MAX_DEVELOPER_RETRIES = 3`), +**escalated** в обработчиках `gitea.py` (`:280` CI-failure, `:371` review +REQUEST_CHANGES) выполняет ТОЛЬКО `notify_error(...)`: + +- стадия НЕ меняется (остаётся `development`); +- терминального маркера в БД нет (нет столбца статуса в `tasks`); +- активного job нет. + +Для F-1 такая задача **неотличима** от «застрявшей из-за потерянного webhook». +Если CI зелёный (типовой кейс: dev починил CI, но reviewer слал REQUEST_CHANGES +до лимита), каждые `reconcile_interval_s` (120с) F-1 видит зелёный `check_ci_green` +и разблокирует `development → review` → reviewer снова REQUEST_CHANGES → откат → +снова эскалация (стадия не меняется) → следующий тик снова разблокирует. +**Бесконечный цикл:** ET-013 разблокирована 10 раз за ночь, лишние запуски агентов +(токены/деньги), спам в Telegram, паразитная нагрузка общего self-hosting-инстанса. + +Симметричный риск: задачу, которую человек явно перевёл в Plane-статус **Blocked** +/ **Needs Input** (ручной гейт), sweeper не должен авторазблокировать до +вмешательства человека. + +## Решение + +В `_reconcile_gate_task` ПОСЛЕ существующих гардов (`stage=='analysis'` carve-out, +`get_qg_for_stage is None`, `has_active_job_for_task`, grace) и ДО +`advance_if_gate_passed` добавляются два пред-гарда. Любой срабатывает → ранний +`return`: задача пропущена, гейт НЕ оценивается, `unblocked_total` не растёт, +нотификаций нет. + +### Гард 1 — escalated по ретраям (детерминированный, без сети) — **обязателен** + +```python +# ORCH-060: escalated tasks (max developer retries reached) are terminal — +# they wait for a human, not the sweeper. Deterministic, no network. +if developer_retry_count(task_id) >= MAX_DEVELOPER_RETRIES: + return +``` + +- Источник истины по retry — `agent_runs` (как у `_developer_retry_count`): + `SELECT COUNT(*) FROM agent_runs WHERE task_id=? AND agent='developer'`. +- `MAX_DEVELOPER_RETRIES` импортируется из `stage_engine` — **не хардкодить `3`** + (AC-11). +- Граница `>=` (на лимите — skip, на `лимит−1` — advance; AC-4). + +**Промоут хелпера.** `stage_engine._developer_retry_count` повышается до публичного +`developer_retry_count` (приватное имя сохраняется как алиас для существующих +внутренних call-sites). Reconciler импортирует +`MAX_DEVELOPER_RETRIES, developer_retry_count` из `stage_engine`. SQL **не +дублируется** в `db.py` — единый источник истины по подсчёту ретраев. + +### Гард 2 — явный человеческий Plane-статус (Blocked / Needs Input) — **Вариант A** + +```python +# ORCH-060: respect an explicit human gate (Blocked / Needs Input). +if self._is_blocked_or_needs_input(task): + return +``` + +Механика — **Вариант A (запрос Plane API, без миграции схемы):** + +1. Новый never-raise хелпер `plane_sync.fetch_issue_state(issue_id, project_id) + -> str | None` — GET issue-detail (тот же endpoint/headers, что + `fetch_issue_sequence_id` / `fetch_issue_fields`), возвращает uuid текущего + `state`; любая ошибка/отсутствие поля → `None`. +2. `Reconciler._is_blocked_or_needs_input(task)`: + - `repo → ProjectConfig` через `projects.get_project_by_repo(task['repo'])`; + - `pid = proj.plane_project_id`; `states = get_project_states(pid)` (кэш per-project); + - `cur = fetch_issue_state(task['plane_id' | 'plane_issue_id'], pid)`; + - вернуть `cur in {states['blocked'], states['needs_input']}`. + - **Never-raise → консервативный фоллбэк:** любая ошибка/`None`/нерезолвленный + проект → трактуем как «возможно заблокировано» → возвращаем `True` (skip). + Не-разблокировать безопаснее, чем разблокировать (AC-10). + +**Порядок гардов:** Гард 1 (локальный SQL, дёшево) — ПЕРВЫМ; Гард 2 (сеть) — +вторым. Для зафиксированного инцидента (ET-013 = escalated) Гард 1 закрывает кейс +**без единого сетевого вызова**. + +### Что НЕ меняется (инварианты ORCH-053) + +- Схема БД — **без миграции** (Вариант A). `STAGE_TRANSITIONS` / `QG_CHECKS` — + без изменений. Гард — ВНЕ гейта: решает, ЗАПУСКАТЬ ли пред-оценку, а не меняет + вердикт. +- Never-raise на единицу работы (`reconcile_gate_once` per-task `try/except` + сохраняется; новая логика не бросает наружу). +- `analysis` carve-out, kill-switch'и (`reconcile_enabled`, + `reconcile_plane_enabled`) — как прежде. +- F-2 по существу не меняется: Blocked/Needs Input не входят в + `{in_progress, approved, rejected}` → не доигрываются (фиксируется + регресс-тестом AC-9). + +### Опционально (вне scope AC, рекомендации) + +- Под-флаг `reconcile_skip_blocked_enabled` (default `true`) для независимого + отключения только Гарда 2 (сетевого), по аналогии с `reconcile_plane_enabled`. + Гард 1 (локальный, безопасный) — всегда активен. +- Best-effort счётчик `skipped_escalated` в снимке `GET /queue` (наблюдаемость). + +## Альтернативы + +- **Вариант B — локальный терминальный маркер в БД** (`tasks.blocked` / + `tasks.reconcile_skip`, идемпотентный ALTER, выставляется в `set_issue_blocked` + / `set_issue_needs_input` и точках эскалации `gitea.py`). **Отклонён как + primary:** + - нарушает инвариант ORCH-053 «схема reconciler не меняется» (миграция на живой + прод-БД = self-hosting-риск); + - затрагивает больше точек записи (4+: две эскалации gitea + два set_issue_*) — + выше риск рассинхрона маркера и факта; + - для зафиксированного инцидента **не нужен**: Гард 1 (retry-count) закрывает + ET-013 детерминированно и без сети. + Вариант B остаётся задокументированным будущим упрочнением, если Plane-coupling + Гарда 2 окажется болезненным (см. Последствия). +- **Подавление в самом `advance_stage` / новый терминальный вердикт гейта** — + отклонён: меняет общий критический путь; ORCH-053 уже постановил «не вызывать + advance на красном», тот же принцип «не вызывать advance на escalated». +- **Гард только по retry (без Гарда 2)** — недостаточно: не покрывает ручной + Blocked при retry<лимита; AC-5/AC-6 требуют пропуск. + +## Последствия + +- **Плюсы:** ET-013-петля устранена детерминированно; 0 фантомных разблокировок, + 0 лишних запусков агентов, 0 спама по escalated-задачам; ручной Blocked/Needs + Input уважается; без миграции БД и без изменения реестров → минимальный + self-hosting-риск; единый источник истины по retry (промоут хелпера). +- **Минусы / плата:** + - Гард 2 вводит **per-candidate сетевой вызов** Plane на тике. Митигировано: + кандидатов после grace+no-active-job немного; `get_project_states` кэшируется; + Гард 1 отсекает escalated до сети. + - **Plane-coupling F-1:** при недоступности Plane Гард 2 фоллбэкает в skip → + F-1 во время Plane-outage не доигрывает кандидатов с retry<лимита (консерва- + тивно «не навреди»). Приемлемо: outage редок/транзиентен; escalated-кейс + (Гард 1) от Plane не зависит и продолжает работать; альтернатива + (proceed-on-error) рискует вернуть bounce при реальном Blocked. Под-флаг + `reconcile_skip_blocked_enabled` даёт ручной обход на время инцидента. +- **Self-hosting:** изменение — чистая логика sweeper'а; прод-контейнер не + рестартится/не роняется; деплой через staging (8501) по канону. + +## Связи + +- **adr-0007 (reconciler, ORCH-053)** — данный ADR уточняет контракт F-1 + (`_reconcile_gate_task` приобретает два пред-гарда; инварианты сохранены). +- **adr-0003 (условный staging-гейт)** — образец never-raise + флага раската + (Гард 2 / `reconcile_skip_blocked_enabled`). +- **adr-0001 (реестр проектов)** — `get_project_by_repo` → `plane_project_id` + для резолва per-project статусов (Вариант A). +- ORCH-046 (retry-счётчик `agent_runs`), ORCH-047 (BLOCKED-вердикт). diff --git a/docs/work-items/ORCH-060/10-tech-risks.md b/docs/work-items/ORCH-060/10-tech-risks.md new file mode 100644 index 0000000..886f4bf --- /dev/null +++ b/docs/work-items/ORCH-060/10-tech-risks.md @@ -0,0 +1,20 @@ +# Технические риски: ORCH-060 + +Work Item ID: ORCH-060 +Стадия: architecture + +| # | Риск | Вероятность | Влияние | Митигация | +|---|------|-------------|---------|-----------| +| R-1 | **Plane-coupling F-1.** Гард 2 (Вариант A) делает сетевой вызов на тике; при недоступности Plane все кандидаты с retry<лимита фоллбэкают в skip → F-1 временно не доигрывает. | Низкая (outage редок) | Среднее | Консервативный фоллбэк («не навреди»); escalated-кейс закрыт Гардом 1 без сети; под-флаг `reconcile_skip_blocked_enabled` для ручного обхода; `get_project_states` кэшируется. | +| R-2 | **Стоимость поллинга.** Per-candidate GET issue-detail каждые 120с при большом числе stuck-задач. | Низкая | Низкое | Кандидатов после grace+no-active-job мало; Гард 1 (локальный SQL) отсекает escalated до сети; вызов только для переживших Гард 1. | +| R-3 | **Промоут хелпера ломает call-sites.** `_developer_retry_count → developer_retry_count`. | Низкая | Среднее | Сохранить приватный алиас `_developer_retry_count = developer_retry_count`; grep всех вызовов перед мержем; покрыто существующими тестами stage_engine. | +| R-4 | **Неверный фоллбэк-знак Гарда 2.** Если ошибку трактовать как «не заблокировано» → возврат ET-013-bounce при реальном Blocked. | Средняя (ошибка реализации) | Высокое | ADR явно фиксирует: ошибка/None/нерезолвленный проект → `True` (skip); AC-10 проверяет never-raise+skip. | +| R-5 | **Резолв plane-issue-id из task.** В `tasks` два поля (`plane_id` / `plane_issue_id`); неверный выбор → пустой запрос. | Низкая | Низкое | Использовать тот же приоритет, что `get_task_by_plane_id` (оба поля); пустой id → фоллбэк skip. | +| R-6 | **Регресс happy-path.** Слишком широкий гард пропустит честно-застрявшие задачи (retry<лимита, не Blocked). | Низкая | Высокое | AC-3/AC-4 (граница ровно на лимите); регресс существующих тестов AC-13. | +| R-7 | **Self-hosting деплой.** Изменение работающего в проде sweeper'а. | Низкая | Высокое | Чистая логика, без миграции/рестарт-контрактов; обязательный прогон через staging (8501) перед прод-деплоем; kill-switch `reconcile_enabled`. | + +## Вывод +Все риски — низкие/средние по вероятности и митигируемы в рамках выбранной +архитектуры (Вариант A, без миграции). Критичен корректный знак never-raise +фоллбэка Гарда 2 (R-4) — выделен в AC-10. Схема БД и реестры не меняются → +self-hosting-риск минимален.