architect(ET): auto-commit from architect run_id=289
All checks were successful
CI / test (push) Successful in 16s

This commit is contained in:
2026-06-07 11:41:02 +00:00
parent 365c67f45d
commit efe437a4aa
4 changed files with 198 additions and 2 deletions

View File

@@ -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, 0607.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-вердикт).

View File

@@ -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-риск минимален.