architect(ET): auto-commit from architect run_id=289
All checks were successful
CI / test (push) Successful in 16s
All checks were successful
CI / test (push) Successful in 16s
This commit is contained in:
@@ -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-вердикт).
|
||||
20
docs/work-items/ORCH-060/10-tech-risks.md
Normal file
20
docs/work-items/ORCH-060/10-tech-risks.md
Normal 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-риск минимален.
|
||||
Reference in New Issue
Block a user