# ADR-001 (ORCH-065): Job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge

## Статус
Accepted — 2026-06-07

Связь со сквозным ADR: [docs/architecture/adr/adr-0011-job-reaper-lease-reclaim.md](../../../architecture/adr/adr-0011-job-reaper-lease-reclaim.md).

## Контекст

Оркестратор — единый инстанс с **общей БД и общей очередью** (`jobs`,
`max_concurrency=1` для self-hosting). BRD/ТЗ фиксируют три связанных класса
отказов «процесс/поток умер, а состояние осталось захваченным навсегда»:

- **A — zombie jobs.** Статус job (`done`/`queued`/`failed`) выставляется ТОЛЬКО
  в `launcher._monitor_agent` → `_finalize_job` внутри того же процесса. Смерть
  monitor-потока/процесса между `proc.wait()` и `_finalize_job` (краш, OOM,
  self-restart во время deploy) оставляет строку `jobs` навсегда `running`.
  Единственная защита — `requeue_running_jobs()`, срабатывает ТОЛЬКО на старте
  процесса. Зомби без рестарта не реанимируется никогда. При `max_concurrency=1`
  одна зомби-строка блокирует claim всех job (`count_running_jobs() >=
  max_concurrency`) → встаёт конвейер ВСЕХ проектов. Доказано: jobs 236/239/242/254
  (07.06).
- **B — залипший merge-lease.** Файловый lease `.merge-lease-<repo>.json`
  (ORCH-043) реклеймится **лениво и только по возрасту** (`age >=
  merge_lock_timeout_s`) и **только** в момент `acquire_merge_lease` другой
  задачей. Liveness держателя по pid не проверяется, хотя pid в lease пишется.
  Смерть с зажатым lease блокирует чужие merge до истечения TTL.
- **C — неидемпотентная финализация merge.** Если rebase+re-test зелёные, но
  процесс умер до фактического merge PR — повторного докатывания нет; дорогая
  работа (rebase+re-test) сделана, а задача висит.

Факты кода, на которых строится решение:
- `_spawn` (launcher.py:401) создаёт `subprocess.Popen(["bash","-c",cmd])`;
  `proc.pid` — pid агентского процесса, дочернего к процессу оркестратора в ОДНОМ
  pid-namespace контейнера. Сейчас `jobs.pid` НЕ хранится.
- `_monitor_agent` (launcher.py:541) порядок: `proc.wait()` → запись
  `agent_runs.finished_at/exit_code` → git commit/push (+PR) → БАГ-8 deployer
  rollback → usage-комменты → `_try_advance_stage` (exit0, gate-driven advance)
  → `_finalize_job` (драйв статуса job по контракту attempts/transient).
- `claim_next_job` (db.py:454) — атомарный claim через `UPDATE ... WHERE id=? AND
  status='queued'` + `rowcount` (образец атомарности).
- `reconciler.py` — образец фонового daemon-потока (never-raise, `_stop`-Event,
  старт/стоп в `main.lifespan`, снимок в `/queue`, kill-switch).
- `merge_gate.py`: lease пишет `pid=os.getpid()` (pid процесса оркестратора, НЕ
  агента), `acquired_at`; `release_merge_lease` уже holder-aware (по `branch`) и
  идемпотентен; `acquire_merge_lease` идемпотентен для «held by self» (по branch).
- `is_self_hosting_repo` / `merge_gate_repos` — образец условности (ORCH-35/43).

## Решение

### Р-1. Job-reaper — отдельный daemon-поток `src/job_reaper.py`

Reaper — **новый модуль и отдельный daemon-поток** (НЕ расширение reconciler).
Обоснование: reconciler работает на уровне **stage-transition** (источник истины —
гейт/Plane); reaper работает на уровне **jobs/agent_runs** (источник истины —
liveness процесса). Это разные never-raise-домены и разные kill-switch'и; слияние
в один тик смешало бы ответственности. Reaper копирует проверенный каркас
`Reconciler`: `threading.Thread(daemon=True)` + `threading.Event`, старт/стоп в
`main.lifespan`, снимок в `/queue`, per-job изоляция исключений.

**Liveness — трёхуровневая (defense in depth):**

1. **Tier-1 (liveness, основной): мёртвый pid.** Добавляем колонку `jobs.pid`
   (см. Р-4). В `_spawn` рядом с `run_id`/`started_at` пишем `proc.pid`. Reaper:
   `pid_alive(pid)` = `os.kill(pid, 0)` с обработкой `ProcessLookupError` (мёртв)
   / `PermissionError` (жив, чужой) — единственный сигнал, ловящий «monitor умер
   ДО записи `finished_at`».
2. **Tier-2 (completion race): exit_code записан, job ещё `running`.** Окно
   **неоднозначно**: это И «monitor умер между записью exit_code и
   `_finalize_job`», И «живой monitor ещё финализирует» — `_monitor_agent`
   пишет `exit_code` ПЕРВЫМ, затем git commit/push (+PR), БАГ-8-проверку и
   сетевые usage-комментарии в Plane (секунды-десятки секунд), и лишь потом
   `_try_advance_stage` → `_finalize_job`. pid агента к этому моменту уже мёртв в
   ОБОИХ случаях, поэтому по pid их не различить. **Анти-ложноположительность
   Tier-2 (FR-1.3, AC-3): finalization-grace.** Job реапится по Tier-2 только
   когда `exit_code` записан не меньше `reaper_finalize_grace_s` назад (потолок
   заведомо > максимального окна финализации). В пределах grace строка не
   трогается (живой финализирующий monitor НИКОГДА не реапится; нет дубль-advance
   / дубль-enqueue). После grace monitor заведомо мёртв → исход **известен**.
3. **Tier-3 (backstop по потолку):** job висит `running` дольше
   `reaper_max_running_s` (заведомо > max `agent_timeout`+grace). Реап даже когда
   liveness определить нельзя (pid переиспользован/неизвестен).

**Анти-ложноположительность (FR-1.3, AC-3):** по Tier-1 job реапится только после
`reaper_dead_ticks` (≥2) ПОДРЯД тиков мёртвого pid — in-memory streak-счётчик по
`job_id` (best-effort, сбрасывается на рестарте — но рестарт покрыт стартовым
`requeue_running_jobs`). Tier-3 — одношаговый (порог времени, streak не нужен).
Живой агент в пределах своего `agent_timeout` НЕ реапится никогда (pid жив + не
превышен потолок).

**Действие при подтверждённой смерти (FR-1.2, AC-4) — переиспользование
существующих контрактов, без дублирования:**

- **Атомарный reap-claim.** Перед любым действием с побочными эффектами reaper
  атомарно «застолбляет» строку тем же приёмом, что `claim_next_job`: терминальный
  flip несёт guard `WHERE id=? AND status='running'` и проверяет `rowcount`. При
  гонке (поздно доехавший monitor, стартовый `requeue_running_jobs`) проигравший
  видит `rowcount==0` и НЕ обрабатывает строку повторно (AC-5).
- **Исход известен (Tier-2, exit_code в `agent_runs`, grace прошёл):**
  - `exit==0`: **claim-BEFORE-act, gate-driven idempotent advance.** Порядок
    критичен (см. «Атомарный reap-claim» выше): атомарный claim ОБЯЗАН
    предшествовать любому `advance_stage`/`enqueue_job`. Поскольку claim
    переводит строку ИЗ `running`, прогнать advance ДО claim, чтобы узнать цвет
    гейта, нельзя — поэтому канонический QG оценивается **read-only, без
    побочных эффектов** (тот же `_run_qg`, что у reconciler) ПЕРЕД claim:
    - стадия уже продвинута мимо этого агента → атомарный `done` без advance
      (идемпотентная уборка);
    - гейт зелёный → атомарный claim `done` ПЕРВЫМ, и только победитель claim
      выполняет `_try_advance_stage` (advance + `enqueue_job` следующей стадии)
      РОВНО один раз; проигравший claim (поздний monitor / стартовый
      `requeue_running_jobs`) НЕ делает побочных эффектов (нет дубль-advance /
      дубль-enqueue);
    - гейт красный (monitor умер ДО git-push, артефакта нет) → НЕ выдумываем
      `done`, уходим в ветку «исход неуспешен» ниже.
    **Источник истины — гейт, не «exit0».**
  - `exit!=0`: ровно существующий контракт `_finalize_job` (классификация
    transient/permanent, `attempts<max` → `queued`, иначе `failed`+Telegram).
- **Исход неизвестен (Tier-1 мёртвый pid без exit_code, или Tier-3 backstop):**
  не выдумываем `exit0`. Если `attempts < max_attempts` → `queued` (как
  `requeue_running_jobs`); если бюджет исчерпан → `failed` + Telegram-алерт.

**Restart-safe (FR-1.5, AC-5):** reaper стартует в `lifespan` ПОСЛЕ
`requeue_running_jobs()`, поэтому при рестарте сначала отрабатывает стартовый
requeue, а периодический reaper лишь добивает то, что возникнет позже; атомарный
guard `status='running'` исключает двойную обработку.

### Р-2. Проактивный реклейм stale/dead merge-lease — функции в `merge_gate.py`

Логика lease живёт в одном месте (`merge_gate.py`). Добавляем:
- `pid_alive(pid) -> bool` (never-raise; ошибка/`PermissionError` → считаем
  «жив», т.е. консервативно НЕ реклеймим — безопаснее не трогать).
- `reclaim_stale_lease(repo) -> bool` — для репо из области (см. ниже): прочитать
  lease; освободить (`release_merge_lease(repo, branch=holder_branch)` —
  holder-aware), если держатель **мёртв** (`pid` из lease не жив) ИЛИ **просрочен**
  (`age >= merge_lock_timeout_s`). Живой держатель в пределах TTL — НЕ трогать
  (AC-8, защита легитимного merge). Каждый реклейм → `logger.warning` +
  Telegram.

**Точки вызова (FR-2.1):**
- на старте — в `lifespan` рядом с `requeue_running_jobs()`;
- периодически — из тика reaper (один общий фоновый поток на оба механизма A и B).

**Условность (FR-2.4, AC-9):** реально только для `merge_gate_repos`/self-hosting
(тот же предикат, что merge-gate); прочие репо — no-op. Kill-switch
`lease_reclaim_enabled` (=false → остаётся лишь прежний ленивый TTL-реклейм в
`acquire_merge_lease`). Контракт **never-raise**: ошибка реклейма логируется и не
валит поток.

**pid-семантика lease:** lease пишет pid процесса ОРКЕСТРАТОРА (`os.getpid()`).
После рестарта контейнера старый pid мёртв → детектируется. Риск pid-reuse
(контейнер мог переиспользовать номер pid) закрыт тем, что реклейм срабатывает по
**ИЛИ** (pid мёртв **ИЛИ** TTL истёк): даже при ложном «жив» TTL добьёт lease
(контракт ORCH-043 сохранён). См. 10-tech-risks.

### Р-3. Идемпотентная финализация merge (Проблема C) — re-drive + guard, без новой merge-логики

Per FR-3.3 — НЕ создаём дублирующую merge-логику. Восстановление обеспечивается
**штатными путями**:
- reaper доводит зомби-job до `queued` → стадия `deploy-staging`/`deploy`
  переисполняется и снова проходит `check_branch_mergeable` (merge-gate), ЛИБО
  reconciler доигрывает переход по зелёному гейту;
- дорогие шаги не повторяются «вхолостую»: `branch_is_behind_main == False` → этап
  rebase+re-test пропускается (ветка уже догнана);
- lease при повторе берётся заново — `acquire_merge_lease` уже идемпотентен для
  «held by self» по branch (FR-3.4).

**Идемпотентность у самого merge (FR-3.2, AC-11):** добавляем детерминированный
never-raise guard `pr_already_merged(repo, branch) -> bool` (переиспользует
существующий Gitea-клиент; запрос состояния PR). Путь слияния (deployer/merge)
консультируется с этим guard ПЕРЕД повторным merge: PR уже слит → no-op (без
второго merge и без ошибки). Это единственная новая «merge-related» функция — она
не сливает, а лишь читает состояние, поэтому не нарушает «no duplicate merge
logic».

### Р-4. Изменение схемы БД — `jobs.pid INTEGER` (lightweight migration)

Колонка добавляется идемпотентно через существующий `_ensure_column(conn, "jobs",
"pid", "INTEGER")` в `init_db` (паттерн уже применяется к `jobs.transient_attempts`
/ `jobs.available_at` — безопасно на live prod DB). `pid` проставляется в `_spawn`
рядом с `run_id`/`started_at`. **Альтернатива без миграции отвергнута** (см.
Альтернативы): только по `agent_runs.finished_at/exit_code` нельзя поймать
зомби, у которого monitor умер ДО записи exit_code — а это и есть основной класс
инцидента. `STAGE_TRANSITIONS`, `QG_CHECKS`, схема `agent_runs`, файл-схема lease —
без изменений.

### Р-5. Конфигурация (`src/config.py`, env `ORCH_*`)

| Настройка | Назначение | Дефолт |
|-----------|-----------|--------|
| `reaper_enabled` | глобальный kill-switch job-reaper | `True` |
| `reaper_interval_s` | период сканирования | `60` |
| `reaper_dead_ticks` | подряд тиков мёртвого pid перед реапом (Tier-1) | `2` |
| `reaper_max_running_s` | потолок `running` (Tier-3 backstop), > max agent_timeout+grace | `3600` |
| `reaper_finalize_grace_s` | Tier-2 grace: сколько `exit_code` должен быть записан до реапа (> max окна финализации) | `300` |
| `lease_reclaim_enabled` | kill-switch проактивного реклейма lease | `True` |
| (reuse) `merge_lock_timeout_s` | TTL lease | `300` |
| (reuse) `merge_gate_repos` | область применения lease-reclaim | как есть |

`false` → строго прежнее поведение (AC-14).

### Р-6. Наблюдаемость (`GET /queue`)

Блок `reaper` (образец `reconcile`/`post_deploy`): `enabled`, `interval`,
`last_run_ts`, `reaped_total`, `last_reaped` (`{job_id, agent, outcome}`),
`lease_reclaimed_total`. Каждый reap и lease-reclaim → `logger.warning` с
идентификаторами (`job_id`, `run_id`, `pid`, `repo`, `branch`). reap→`failed` и
lease-reclaim → Telegram (AC-16).

### Р-7. Lifespan (`src/main.py`)

Старт (после существующего `requeue_running_jobs()`):
```
init_db()                       # + _ensure_column(jobs, pid)
... orphan-recovery (M-1) ...
requeue_running_jobs()
+ startup lease-reclaim         # reclaim_stale_lease для merge_gate_repos
worker.start()
reconciler.start()
+ reaper.start()                # НОВЫЙ daemon-поток (A + периодический B)
```
Стоп (reverse): `reaper.stop()` → `reconciler.stop()` → `worker.stop()`.

## Альтернативы

- **Reaper как часть reconciler** — отвергнуто: смешивает stage-уровень и
  jobs-уровень, два разных kill-switch'а в одном тике, хуже изоляция отказов.
- **Без `jobs.pid`, только эвристика `agent_runs` + потолок** — отвергнуто как
  основной механизм: не ловит зомби, чей monitor умер ДО записи `exit_code`
  (главный класс инцидента). Эвристика оставлена как Tier-2/Tier-3 поверх pid.
- **БД-lock вместо файлового lease / внешний брокер очередей** — вне объёма
  (BRD §4), несоразмерно для single-node SQLite.
- **Реаниматор фабрикует `exit0` и форсит `done`** — отвергнуто: ложный `done`
  без реальной работы (если git-push не случился). Выбран gate-driven advance:
  источник истины — канонический QG, не предположение об успехе.
- **Новый статус `reaping` в enum `jobs.status`** — отвергнуто: меняет контракт
  статусов; атомарного guard `WHERE status='running'` достаточно.

## Последствия

**Плюсы:**
- Зомби-job самовосстанавливается БЕЗ рестарта процесса → очередь не встаёт
  (групповой риск снят для всех проектов общего инстанса).
- Залипший lease освобождается проактивно (старт + период), не дожидаясь TTL и
  чужого acquire.
- Незавершённый merge докатывается штатным путём, идемпотентно; ручные шаги
  оператора устранены → снят технический блокер ORCH-54.
- Контракты неизменны (`STAGE_TRANSITIONS`, `QG_CHECKS`, `check_*`, БАГ-8,
  exit-коды хука); один новый столбец через проверенный idempotent-паттерн.

**Минусы / ограничения:**
- pid-liveness валиден в предположении ОДНОГО pid-namespace (агент — дочерний
  процесс оркестратора в том же контейнере). Multi-container/namespaced pid →
  pid-liveness ненадёжен; закрыто backstop'ом по времени и TTL. См. 10-tech-risks.
- streak-счётчик in-memory best-effort: после рестарта он сбрасывается, но
  стартовый `requeue_running_jobs` покрывает рестарт-сценарий.
- Tier-3 backstop консервативен (потолок > max timeout); очень долгий легитимный
  агент (близко к потолку) теоретически может быть реапнут — потолок выбран с
  большим запасом, чтобы этого не случалось (AC-3).

## Инварианты (НЕ нарушать)
- Reaper/lease-reclaim НЕ рестартят/не роняют прод-контейнер `orchestrator` и НЕ
  инициируют git-push в `main` (AC-12). Реклейм lease — только удаление
  файла-lease, без git-операций.
- `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, сигнатуры/поведение `check_*` (в т.ч.
  `check_branch_mergeable`), БАГ-8 откат, exit-коды deploy-хука — без изменений;
  новых QG checks/стадий нет (AC-13).
- never-raise на единицу фоновой работы; идемпотентность (атомарный guard +
  gate-driven advance); restart-safe; тишина при отсутствии аномалий.
- Анти-ложноположительность (FR-1.3): живой долгий агент не реапится.

## Связи
- Базируется на: ORCH-1 (очередь, adr-0002), ORCH-043 (merge-gate, adr-0006),
  ORCH-053 (reconciler-паттерн, adr-0007), ORCH-36 (self-deploy, adr-0007).
- Сквозной ADR: adr-0011.
- Разблокирует: ORCH-54 (полностью автономный self-deploy).
