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