diff --git a/docs/work-items/ORCH-060/01-brd.md b/docs/work-items/ORCH-060/01-brd.md new file mode 100644 index 0000000..0193f35 --- /dev/null +++ b/docs/work-items/ORCH-060/01-brd.md @@ -0,0 +1,90 @@ +# BRD: Reconciler не должен трогать escalated / max-retries задачи + +Work Item ID: ORCH-060 +Стадия: analysis → architecture +Связано: ORCH-053 (reconciler), ORCH-046 (retry-счётчик), ORCH-047 (BLOCKED-вердикт) + +## 1. Контекст и проблема + +ORCH-053 ввёл фоновый reconciler (`src/reconciler.py`) — sweeper, доигрывающий +пропущенные webhook-переходы. Слой F-1 (`reconcile_gate_once` → +`_reconcile_gate_task`) для каждой не-терминальной задачи (`stage != 'done'`) без +активного job и старше grace делает read-only пред-оценку канонического QG; если +гейт зелёный → `advance_if_gate_passed` → `advance_stage(..., finished_agent=None)`. + +**Дефект.** Задача, исчерпавшая лимит developer-ретраев +(`_developer_retry_count(task_id) >= MAX_DEVELOPER_RETRIES = 3`), **escalated** — +но эскалация в обработчиках Gitea (`src/webhooks/gitea.py:280` для CI-failure, +`:371` для review REQUEST_CHANGES) выполняет ТОЛЬКО `notify_error(...)`: + +- стадия НЕ меняется (остаётся `development`); +- терминального маркера в БД нет (нет `blocked`-флага в таблице `tasks`); +- активного job нет. + +Для reconciler такая задача неотличима от «застрявшей из-за потерянного webhook». +Если CI к этому моменту зелёный (типичный кейс: разработчик починил CI, но reviewer +продолжал слать REQUEST_CHANGES → ушли в лимит), F-1 каждые `reconcile_interval_s` +(120 с) видит зелёный `check_ci_green` и **разблокирует** задачу `development → review`. +Reviewer снова REQUEST_CHANGES → откат на `development` → снова эскалация (стадия +не меняется). Следующий тик — снова разблокировка. Бесконечный цикл. + +**Реальный инцидент (наблюдение 06–07.06.2026).** ET-013 разблокирована +reconciler'ом **10 раз за ночь**, в итоге всё равно escalated — бесполезный поллинг +каждые 2 минуты, лишние запуски агентов (токены, деньги), шум в Telegram +(`reconcile_notify_unblock`), нагрузка на конвейер общего инстанса (self-hosting: +один инстанс обслуживает ORCH + enduro-trails). + +Симметричный риск: задача, которую человек/агент явно перевёл в Plane-статус +**Blocked** или **Needs Input** (ручной гейт), не должна автоматически +разблокироваться reconciler'ом до вмешательства человека. + +## 2. Бизнес-цель + +Reconciler (F-1) обязан **пропускать** (не трогать) задачи, которые: +1. исчерпали лимит developer-ретраев (`_developer_retry_count >= MAX_DEVELOPER_RETRIES`), и/или +2. находятся в явном «человеческом»/терминальном Plane-статусе **Blocked** / **Needs Input**. + +Такие задачи ждут ручного вмешательства; автоматический sweeper их игнорирует. + +## 3. Заинтересованные стороны + +- **Owner проекта** — прекращение «фантомной» активности и шума по escalated-задачам. +- **Другие проекты на инстансе (enduro-trails)** — снижение паразитной нагрузки общей очереди. +- **Агенты-разработчики оркестратора** — корректная семантика терминального состояния. + +## 4. Объём (Scope) + +### Входит +- Гард в F-1 (`_reconcile_gate_task` / `advance_if_gate_passed`), который ДО + оценки гейта и вызова `advance_stage` пропускает escalated-задачи + (retry-count >= лимит) — детерминированно, без сети. +- Гард, пропускающий задачи в Plane-статусе Blocked / Needs Input. +- Тесты (unit) на оба условия + регресс happy-path и отсутствия спама/нотификаций. +- Обновление документации: `docs/architecture/README.md` (описание F-1), + per-work-item ADR, `CHANGELOG.md`. + +### Не входит +- Изменение порога `MAX_DEVELOPER_RETRIES` или логики самой эскалации в `gitea.py`. +- Изменение F-2 plane-side по существу (F-2 уже реагирует только на + in_progress/approved/rejected, то есть Blocked/Needs Input им не доигрываются — + достаточно регресс-теста, фиксирующего это поведение). +- Реестры `STAGE_TRANSITIONS` / `QG_CHECKS`, схема прочих стадий. + +## 5. Допущения и ограничения + +- **Инвариант reconciler (ORCH-053):** схема БД и реестры не меняются. Решение + должно либо обойтись без миграции, либо архитектор обязан явно обосновать + необходимость нового столбца как терминального маркера. +- **Never-raise:** гард не должен ломать тик; любая ошибка вычисления условия → + безопасный фоллбэк (не трогать задачу — консервативно). +- **self-hosting:** нельзя ронять/рестартить прод-контейнер; изменение — чисто + логика sweeper'а, деплой через staging (8501) по канону. +- Источник истины по retry — `agent_runs` (как у `_developer_retry_count`). + +## 6. Критерий успеха (бизнес) + +После выката на конкретной escalated-задаче (как ET-013): за ночь — **0** +строк `reconciler: ... разблокирована`, **0** повторных запусков агентов, +**0** Telegram-нотификаций разблокировки; задача спокойно ждёт человека в +`development`/Blocked. При этом штатные «честно застрявшие» задачи +(retry < лимита, не Blocked) reconciler по-прежнему доигрывает. diff --git a/docs/work-items/ORCH-060/02-trz.md b/docs/work-items/ORCH-060/02-trz.md new file mode 100644 index 0000000..dee520e --- /dev/null +++ b/docs/work-items/ORCH-060/02-trz.md @@ -0,0 +1,113 @@ +# ТЗ: Reconciler пропускает escalated / max-retries / blocked-needs-input задачи + +Work Item ID: ORCH-060 +Стадия: analysis → architecture (архитектор фиксирует механику в ADR) + +## 1. Задействованные модули `src/` + +| Модуль | Роль в задаче | +|--------|---------------| +| `src/reconciler.py` | **Основное изменение.** F-1: `Reconciler._reconcile_gate_task` — добавить пред-проверки (escalated / blocked / needs-input) ДО `advance_if_gate_passed`. | +| `src/stage_engine.py` | Источник `MAX_DEVELOPER_RETRIES` (=3) и `_developer_retry_count(task_id)`. Кандидат на промоут приватного хелпера в переиспользуемый (решает архитектор). | +| `src/db.py` | Чтение состояния задачи (`get_active_tasks_for_reconcile` уже отдаёт строки `tasks`); возможный новый read-helper для retry-count, если решено не импортировать приватный из stage_engine. | +| `src/plane_sync.py` | Маппинг Plane-статусов (`PLANE_STATES`, `get_project_states`): `blocked`, `needs_input`. Источник для проверки «человеческого» статуса, если архитектор выберет проверку через Plane API. | +| `src/webhooks/gitea.py` | НЕ меняется (только справочно: точки эскалации `:280`, `:371`). | + +## 2. Требуемое поведение (контракт F-1) + +`Reconciler._reconcile_gate_task(task)` ДО вызова `advance_if_gate_passed(...)` +обязан вернуться (пропустить задачу, ничего не делая, не инкрементируя +`unblocked_total`, не слать нотификации), если выполнено ЛЮБОЕ из условий: + +1. **Escalated по ретраям (обязательно, детерминированно, без сети):** + `developer_retry_count(task_id) >= MAX_DEVELOPER_RETRIES`. + - `MAX_DEVELOPER_RETRIES` импортируется из `stage_engine` (НЕ хардкодить число). + - Источник счётчика — тот же запрос, что в `_developer_retry_count`: + `SELECT COUNT(*) FROM agent_runs WHERE task_id=? AND agent='developer'`. + +2. **Явный человеческий/терминальный Plane-статус:** issue в состоянии + **Blocked** или **Needs Input**. + +Порядок: проверки добавляются в `_reconcile_gate_task` ПОСЛЕ существующих гардов +(`stage=='analysis'` carve-out, `get_qg_for_stage is None`, `has_active_job_for_task`, +grace) и ДО `advance_if_gate_passed`. Условие (1) — дешёвое (локальный SQL) — +проверять раньше условия (2), если (2) требует сети. + +## 3. Механика проверки blocked/needs-input (выбор — за архитектором, ADR) + +В таблице `tasks` НЕТ столбца статуса (`stage` всегда `development` у escalated). +Архитектор выбирает и обосновывает один из вариантов; требования к каждому: + +- **Вариант A — проверка через Plane API (без миграции, предпочтительно по + инварианту ORCH-053 «схема не меняется»):** для кандидата F-1 запросить текущее + состояние issue (per-project `get_project_states` → сверка с `blocked`/`needs_input`). + Допустимо, т.к. F-1 уже делает сетевой вызов в гейте (`check_ci_green`), а + кандидатов после grace+no-active-job немного. Обязателен never-raise: ошибка + запроса → консервативно НЕ трогать задачу (skip), либо явно обоснованный фоллбэк. +- **Вариант B — локальный терминальный маркер в БД:** идемпотентная миграция + (`tasks.blocked`/`tasks.reconcile_skip`), выставляется в точках `set_issue_blocked`/ + `set_issue_needs_input` и в точках эскалации `gitea.py`. Требует обоснования + нарушения инварианта «схема reconciler не меняется» и затрагивает больше точек. + +> Рекомендация аналитика: условие (1) полностью закрывает зафиксированный инцидент +> (ET-013 = escalated = max retries) детерминированно и без сети — оно +> обязательно к реализации. Условие (2) — защита от автоперекрытия ручного гейта; +> минимально-инвазивный путь — Вариант A. Архитектор вправе ограничить (2) +> Вариантом A либо обосновать B. + +## 4. Изменения API + +Нет. Эндпоинты не добавляются и не меняются. Снимок `GET /queue` (блок `reconcile`) +по содержимому не меняется; опционально архитектор может добавить best-effort +счётчик `skipped_escalated` (необязательно, вне scope AC). + +## 5. Изменения схемы БД + +По умолчанию — **нет** (Вариант A). При выборе Варианта B — идемпотентная +ALTER-миграция через `_ensure_column` (как остальные в `db.init_db`), +restart-safe, безопасная на живой прод-БД; обязательна явная мотивация в ADR. + +## 6. Требования к QG checks + +Нет новых QG. Реестр `QG_CHECKS` и `STAGE_TRANSITIONS` не меняются. Гард — +ВНЕ гейта: он решает, ЗАПУСКАТЬ ли пред-оценку гейта вообще, а не меняет вердикт +гейта. + +## 7. Инварианты, которые нельзя нарушить + +- **Never-raise** на единицу работы (per-task `try/except` в `reconcile_gate_once` + сохраняется; новая логика не должна бросать наружу). +- **Тишина при пропуске:** пропущенная задача не инкрементирует `unblocked_total`, + не пишет лог `разблокирована`, не шлёт Telegram. +- **Регресс F-1 happy-path:** задача с retry < лимита и не-Blocked/Needs-Input при + зелёном гейте по-прежнему доигрывается (`advance_stage` вызывается). +- **F-2** по существу не меняется: Blocked/Needs Input не входят в + {in_progress, approved, rejected} → не доигрываются (зафиксировать регресс-тестом). +- `analysis` carve-out F-1 сохраняется. +- Kill-switch'и (`reconcile_enabled`, `reconcile_plane_enabled`) работают как прежде. + +## 8. Артефакты pipeline, которые должны быть созданы/обновлены + +- `docs/work-items/ORCH-060/06-adr/ADR-001-*.md` — решение по механике (2) (A vs B). +- `docs/architecture/README.md` — дополнить описание F-1 («skip escalated / + blocked / needs-input»). +- `CHANGELOG.md` — запись `fix(reconciler): ...`. +- Тесты — `tests/test_reconciler.py` (расширение). +- Обновить footer `docs/architecture/README.md` (статус ORCH-060). + +## 9. Точки изменения кода (конкретно) + +1. `src/reconciler.py`, `_reconcile_gate_task`: после grace-проверки и до + `advance_if_gate_passed` вставить: + ```python + # ORCH-060: escalated tasks (max developer retries reached) are terminal — + # they wait for a human, not the sweeper. Skip deterministically (no network). + if developer_retry_count(task_id) >= MAX_DEVELOPER_RETRIES: + return + # ORCH-060: respect an explicit human gate (Blocked / Needs Input). + if self._is_blocked_or_needs_input(task): # mechanism per ADR (Variant A/B) + return + ``` +2. `src/reconciler.py`: импорт `MAX_DEVELOPER_RETRIES` (и retry-count хелпера) из + `stage_engine` (или новый read-helper в `db.py`). +3. Хелпер проверки Plane-статуса (`_is_blocked_or_needs_input`) — never-raise. diff --git a/docs/work-items/ORCH-060/03-acceptance-criteria.md b/docs/work-items/ORCH-060/03-acceptance-criteria.md new file mode 100644 index 0000000..1580a7e --- /dev/null +++ b/docs/work-items/ORCH-060/03-acceptance-criteria.md @@ -0,0 +1,124 @@ +# Критерии приёмки: ORCH-060 + +Work Item ID: ORCH-060 + +Формат: каждый критерий — Дано / Когда / Тогда, с однозначным PASS/FAIL. + +--- + +## AC-1 — Escalated-задача (retry == лимит) не разблокируется (главный кейс ET-013) + +- **Дано:** задача на `stage='development'`, без активного job, `age >= grace`, + `check_ci_green` зелёный; в `agent_runs` ровно `MAX_DEVELOPER_RETRIES` (=3) + записей `agent='developer'`. +- **Когда:** выполняется `Reconciler.reconcile_gate_once()`. +- **Тогда:** стадия остаётся `development`; `advance_stage`/`advance_if_gate_passed` + не приводит к смене стадии; `unblocked_total == 0`; новый developer/reviewer job + не создаётся. +- **PASS:** стадия не изменилась И `unblocked_total == 0` И нет новых job. +- **FAIL:** стадия стала `review` / появился новый job / `unblocked_total > 0`. + +## AC-2 — Граница: retry > лимита тоже пропускается + +- **Дано:** то же, но developer-записей `> MAX_DEVELOPER_RETRIES` (например 4–5). +- **Когда:** `reconcile_gate_once()`. +- **Тогда:** задача пропущена (как AC-1). +- **PASS / FAIL:** как AC-1. + +## AC-3 — Регресс happy-path: retry < лимита по-прежнему доигрывается + +- **Дано:** `development`, без активного job, `age >= grace`, `check_ci_green` + зелёный; developer-записей `< MAX_DEVELOPER_RETRIES` (например 0, 1 или 2). +- **Когда:** `reconcile_gate_once()`. +- **Тогда:** задача доигрывается `development → review`; `unblocked_total == 1`; + enqueue следующего агента происходит как раньше. +- **PASS:** стадия стала `review` И `unblocked_total == 1`. +- **FAIL:** задача пропущена / стадия не изменилась. + +## AC-4 — Граница ровно на лимите (==3) → skip, на (лимит−1) → advance + +- **Дано:** две задачи-близнеца, идентичные кроме числа developer-записей: + одна с `MAX_DEVELOPER_RETRIES`, другая с `MAX_DEVELOPER_RETRIES − 1`. +- **Когда:** `reconcile_gate_once()`. +- **Тогда:** первая пропущена (skip), вторая доиграна (advance). +- **PASS:** ровно одна из двух доиграна (та, что `−1`). +- **FAIL:** обе доиграны / обе пропущены / доиграна задача на лимите. + +## AC-5 — Plane-статус Blocked → пропуск + +- **Дано:** задача-кандидат F-1 (stage не-терминальный, без активного job, + `age >= grace`, гейт зелёный), у которой текущий Plane-статус issue = **Blocked**; + retry < лимита (чтобы изолировать именно этот гард). +- **Когда:** `reconcile_gate_once()`. +- **Тогда:** задача пропущена; стадия не меняется; `unblocked_total == 0`. +- **PASS:** стадия не изменилась И `unblocked_total == 0`. +- **FAIL:** задача доиграна. + +## AC-6 — Plane-статус Needs Input → пропуск + +- **Дано:** как AC-5, но Plane-статус = **Needs Input**. +- **Когда:** `reconcile_gate_once()`. +- **Тогда:** задача пропущена (как AC-5). +- **PASS / FAIL:** как AC-5. + +## AC-7 — Тишина при пропуске (no spam) + +- **Дано:** escalated-задача (как AC-1). +- **Когда:** `reconcile_gate_once()` (один или несколько тиков). +- **Тогда:** НЕ вызывается `_note_unblock`; нет лог-строки `... разблокирована`; + нет `send_telegram`; нет `notify_qg_failure` (пропуск — раньше оценки гейта). +- **PASS:** ни одна из перечисленных нотификаций не вызвана. +- **FAIL:** вызвана любая нотификация. + +## AC-8 — Никакого сетевого вызова гейта на escalated-задаче + +- **Дано:** escalated-задача (как AC-1) с замоканным `check_ci_green`. +- **Когда:** `reconcile_gate_once()`. +- **Тогда:** `check_ci_green` (через `advance_if_gate_passed`/`_run_qg`) НЕ + вызывается для этой задачи — пропуск происходит раньше. +- **PASS:** мок гейта не вызван. +- **FAIL:** мок гейта вызван. + +## AC-9 — F-2 не доигрывает Blocked/Needs Input (регресс) + +- **Дано:** issue в Plane-статусе Blocked или Needs Input (не входит в + {in_progress, approved, rejected}). +- **Когда:** `reconcile_plane_once()`. +- **Тогда:** ни `handle_status_start`, ни `handle_verdict` не вызываются для + этого issue; `unblocked_total == 0`. +- **PASS:** обработчики не вызваны. +- **FAIL:** вызван любой обработчик. + +## AC-10 — Never-raise: ошибка проверки статуса не ломает тик + +- **Дано:** проверка blocked/needs-input (Plane API в Варианте A) бросает + исключение для одной задачи; в выборке есть ещё одна валидная задача. +- **Когда:** `reconcile_gate_once()`. +- **Тогда:** тик не падает; сбойная задача консервативно НЕ трогается (skip); + остальные обрабатываются. +- **PASS:** исключение изолировано, остальные задачи обработаны. +- **FAIL:** исключение всплыло из `reconcile_gate_once`. + +## AC-11 — Лимит не хардкодится + +- **Дано:** код F-1-гарда. +- **Тогда:** используется `stage_engine.MAX_DEVELOPER_RETRIES`, а не литерал `3`. +- **PASS:** граница берётся из константы. +- **FAIL:** в reconciler.py появился магический `3`. + +## AC-12 — Документация обновлена (golden source) + +- **Дано:** PR задачи. +- **Тогда:** обновлены `docs/architecture/README.md` (описание F-1 с новым skip), + `CHANGELOG.md`, создан `06-adr/ADR-001-*.md`. +- **PASS:** все три артефакта обновлены/созданы в этом же PR. +- **FAIL:** любой отсутствует (reviewer → REQUEST_CHANGES). + +## AC-13 — Регресс существующих тестов reconciler + +- **Дано:** существующий `tests/test_reconciler.py` (ORCH-053). +- **Когда:** `pytest tests/test_reconciler.py -q`. +- **Тогда:** все прежние тесты зелёные (поведение happy-path/analysis/kill-switch + не сломано). +- **PASS:** 0 регрессий. +- **FAIL:** любой ранее зелёный тест упал. diff --git a/docs/work-items/ORCH-060/04-test-plan.yaml b/docs/work-items/ORCH-060/04-test-plan.yaml new file mode 100644 index 0000000..14b8c7c --- /dev/null +++ b/docs/work-items/ORCH-060/04-test-plan.yaml @@ -0,0 +1,82 @@ +work_item: ORCH-060 +description: > + Reconciler F-1 пропускает escalated (retry >= MAX_DEVELOPER_RETRIES) и + явно-blocked / needs-input задачи; happy-path и no-spam сохранены. + Конвенции test-фикстур — как в существующем tests/test_reconciler.py + (изолированная sqlite-БД, моки Plane/Telegram/gate). Хелпер _make_task + вставляет задачу; developer-ретраи моделируются вставкой N строк в agent_runs + (agent='developer'); зелёный CI — через _green_ci(monkeypatch). + +tests: + - id: TC-01 + type: unit + description: "AC-1: escalated dev-задача (ровно MAX_DEVELOPER_RETRIES developer-ранов) при зелёном CI НЕ разблокируется — стадия остаётся development, unblocked_total==0, новых job нет" + module: tests/test_reconciler.py + setup: "_make_task('development', age_s=grace+60); insert MAX_DEVELOPER_RETRIES rows agent_runs(agent='developer'); _green_ci()" + expected: PASS + + - id: TC-02 + type: unit + description: "AC-2: developer-ранов > MAX_DEVELOPER_RETRIES (4–5) → также skip" + module: tests/test_reconciler.py + expected: PASS + + - id: TC-03 + type: unit + description: "AC-3 (регресс happy-path): developer-ранов < MAX (0/1/2) при зелёном CI → задача доигрывается development->review, unblocked_total==1" + module: tests/test_reconciler.py + expected: PASS + + - id: TC-04 + type: unit + description: "AC-4: граница — задача с ровно MAX пропущена, задача с MAX-1 доиграна (ровно одна advance)" + module: tests/test_reconciler.py + expected: PASS + + - id: TC-05 + type: unit + description: "AC-5: задача в Plane-статусе Blocked (retry<лимита) пропущена — стадия не меняется, unblocked_total==0 (мок проверки статуса возвращает Blocked)" + module: tests/test_reconciler.py + expected: PASS + + - id: TC-06 + type: unit + description: "AC-6: задача в Plane-статусе Needs Input (retry<лимита) пропущена" + module: tests/test_reconciler.py + expected: PASS + + - id: TC-07 + type: unit + description: "AC-7 (no spam): на escalated-задаче не вызваны _note_unblock / send_telegram / notify_qg_failure; нет лог-строки 'разблокирована'" + module: tests/test_reconciler.py + expected: PASS + + - id: TC-08 + type: unit + description: "AC-8: на escalated-задаче мок check_ci_green НЕ вызван (skip раньше пред-оценки гейта)" + module: tests/test_reconciler.py + expected: PASS + + - id: TC-09 + type: unit + description: "AC-9 (регресс F-2): issue в Blocked/Needs Input не передаётся ни в handle_status_start, ни в handle_verdict при reconcile_plane_once; unblocked_total==0" + module: tests/test_reconciler.py + expected: PASS + + - id: TC-10 + type: unit + description: "AC-10 (never-raise): проверка blocked/needs-input бросает исключение на одной задаче → тик не падает, сбойная skip, валидная соседняя обработана" + module: tests/test_reconciler.py + expected: PASS + + - id: TC-11 + type: unit + description: "AC-11: граница берётся из stage_engine.MAX_DEVELOPER_RETRIES — тест с monkeypatch значения константы меняет точку отсечения (нет хардкода 3)" + module: tests/test_reconciler.py + expected: PASS + + - id: TC-12 + type: integration + description: "AC-13 (регресс): полный прогон tests/test_reconciler.py (ORCH-053 кейсы) — все прежние тесты зелёные" + module: tests/test_reconciler.py + expected: PASS