# adr-0011: Job-reaper + проактивный реклейм merge-lease

| | |
|---|---|
| Статус | accepted |
| Дата | 2026-06-07 |
| Источник | ORCH-065 (BUG P0, блокер ORCH-54) |
| Детально | `docs/work-items/ORCH-065/06-adr/ADR-001-job-reaper-and-lease-reclaim.md` |

## Контекст

Единый инстанс с общей БД и очередью (`jobs`, `max_concurrency=1` для
self-hosting). Финализация статуса job (`done`/`queued`/`failed`) происходит
ТОЛЬКО в `launcher._monitor_agent → _finalize_job` внутри живого процесса. Смерть
monitor-потока/процесса между `proc.wait()` и `_finalize_job` (краш, OOM,
self-restart во время deploy) оставляет строку `jobs` навсегда `running`. При
`max_concurrency=1` одна такая зомби-строка блокирует claim всех job →
**встаёт конвейер всех проектов**. Единственная защита — `requeue_running_jobs()`
— работает ТОЛЬКО на старте процесса. Симметрично: merge-lease (ORCH-043,
файл `.merge-lease-<repo>.json`) реклеймится лишь лениво по TTL при чужом
`acquire`; liveness держателя по pid не проверяется → залипший lease блокирует
чужие merge. Это последняя ручная точка автономного self-deploy (блокер ORCH-54);
доказанные инциденты 07.06 — jobs 236/239/242/254.

## Решение

1. **Job-reaper** — новый daemon-поток `src/job_reaper.py` (каркас `reconciler`:
   never-raise, `_stop`-Event, старт/стоп в `lifespan`, снимок в `/queue`,
   kill-switch). Работает **без рестарта** процесса. Liveness — трёхуровневая:
   Tier-1 мёртвый `jobs.pid` (новая колонка) после `reaper_dead_ticks` подряд
   тиков; Tier-2 `agent_runs.exit_code` записан, а job ещё `running` — но только
   после finalization-grace `reaper_finalize_grace_s` (окно неоднозначно: живой
   monitor пишет exit_code ПЕРВЫМ, затем git push/PR/Plane-комментарии, поэтому
   живой финализирующий monitor НЕ реапится); Tier-3 backstop по потолку
   `reaper_max_running_s`. Действие — **claim-before-act**: для exit0 канонический
   QG оценивается read-only ПЕРЕД атомарным claim, затем claim `done` ПЕРВЫМ и
   только победитель claim выполняет `_try_advance_stage` (advance+enqueue) —
   проигравший не делает побочных эффектов (источник истины — QG, не «exit0»);
   гейт красный или exit≠0 / неизвестно → `attempts<max` → `queued`, иначе
   `failed`+Telegram. Атомарный reap-claim (`UPDATE ... WHERE id=? AND
   status='running'` + `rowcount`, как `claim_next_job`) исключает двойную
   обработку (совместимость со стартовым `requeue_running_jobs`).
2. **Проактивный реклейм stale/dead lease** — функции в `merge_gate.py`
   (`pid_alive`, `reclaim_stale_lease`), вызываемые на старте (рядом с
   `requeue_running_jobs`) и периодически из тика reaper. Освобождение, если
   держатель **мёртв** (pid не жив) ИЛИ **просрочен** (TTL); живой держатель в
   пределах TTL — НЕ трогать. holder-aware, never-raise, условность как ORCH-43.
3. **Идемпотентная финализация merge** — без новой merge-логики: re-drive через
   reaper→`queued`→переисполнение стадии / reconciler; дорогие шаги не
   повторяются (`branch_is_behind_main==False`); добавлен детерминированный
   never-raise guard `pr_already_merged` (читает состояние PR), консультируемый
   перед повторным merge → уже слит = no-op.
4. **Схема БД** — `jobs.pid INTEGER` через идемпотентный `_ensure_column`
   (паттерн live-safe миграции). Больше ничего не меняется.

Kill-switch'и (`ORCH_*`): `reaper_enabled`, `reaper_interval_s`,
`reaper_dead_ticks`, `reaper_max_running_s`, `reaper_finalize_grace_s`,
`lease_reclaim_enabled`; переиспользуются `merge_lock_timeout_s`,
`merge_gate_repos`. `false` → строго прежнее поведение.

## Альтернативы
- Reaper внутри reconciler — отвергнуто (смешение stage- и jobs-уровней, общий
  kill-switch, хуже изоляция).
- Только эвристика `agent_runs` без `jobs.pid` — отвергнуто как основной механизм
  (не ловит зомби, чей monitor умер до записи exit_code); оставлена как Tier-2/3.
- БД-lock / внешний брокер очередей — вне объёма (single-node SQLite).
- Форс `done` по факту exit0 — отвергнуто; выбран gate-driven advance.

## Последствия
- (+) Зомби-job и залипший lease самовосстанавливаются без рестарта и без
  оператора; очередь общего инстанса не встаёт; снят технический блокер ORCH-54.
- (+) Контракты неизменны (`STAGE_TRANSITIONS`, `QG_CHECKS`, `check_*`, БАГ-8,
  exit-коды хука); одна колонка через проверенный idempotent-паттерн.
- (−) pid-liveness валиден в предположении одного pid-namespace (агент —
  дочерний процесс оркестратора); закрыто backstop'ом по времени и TTL.
- (−) streak-счётчик in-memory (сброс на рестарте; рестарт покрыт
  `requeue_running_jobs`).

## Связи
- Базируется: adr-0002 (очередь), adr-0006 (merge-gate), adr-0007 (reconciler /
  self-deploy).
- Разблокирует: ORCH-54.
