# ТЗ — ORCH-065: job-reaper + stale merge-lease reclaim + идемпотентный merge

Work Item ID: ORCH-065
Базируется на: 01-brd.md
Примечание архитектору: ТЗ фиксирует ТРЕБОВАНИЯ и кандидатные модули. Выбор
конкретной реализации (новый модуль vs расширение reconciler; колонка `jobs.pid`
vs эвристика на `agent_runs`) — за стадией architecture (ADR). Если какая-либо
часть ТЗ окажется нереализуемой/спорной — вернуть в Анализ, не комментировать
задним числом.

## 0. Текущее состояние (факты из кода)

| Место | Факт |
|-------|------|
| `src/queue_worker.py` `_drain_once` | claim не происходит, пока `count_running_jobs() >= max_concurrency`. Одна зомби-строка `running` при concurrency=1 блокирует всю очередь. |
| `src/agents/launcher.py` `_monitor_agent` → `_finalize_job` | статус job (`done`/`queued`/`failed`) выставляется ТОЛЬКО в этом monitor-потоке. Смерть потока/процесса до финализации ⇒ job навсегда `running`. |
| `src/main.py` (lifespan, строки ~55-61) | `requeue_running_jobs()` вызывается ТОЛЬКО при старте процесса. |
| `src/db.py` `requeue_running_jobs` | flip всех `running`→`queued`. Без рестарта не запускается. |
| `src/db.py` таблица `jobs` | колонки `pid`/`heartbeat` НЕТ; есть `run_id`, `started_at`, `attempts`, `max_attempts`. |
| `src/merge_gate.py` `acquire_merge_lease` | реклейм stale lease (age `>= merge_lock_timeout_s`) и corrupt — ТОЛЬКО лениво в момент `acquire`. Lease пишет `pid`, но liveness по pid нигде не проверяется. |
| `src/merge_gate.py` `release_merge_lease` | holder-aware (по `branch`), идемпотентен. Вызовы: `webhooks/gitea.py:380` (PR-merged), `stage_engine.py:352/740/876/952`, `qg/checks.py:683/691/697`. |
| `src/qg/checks.py` `check_branch_mergeable` | при SUCCESS lease ДЕРЖИТСЯ до фактического merge PR. Если процесс умрёт после этого — lease зажат. |
| `src/reconciler.py` | паттерн-образец фонового daemon-потока (never-raise, kill-switch, observability в `/queue`). |

## 1. Задействованные модули `src/`

- `src/db.py` — новые job-запросы для reaper (выборка stale running-job, атомарный
  reap). Возможна lightweight-миграция (см. §3).
- **Job-reaper** — НОВЫЙ модуль (кандидат `src/job_reaper.py`) ИЛИ расширение
  `src/reconciler.py`. Решение — за архитектором; ТЗ требует daemon-поток по образцу
  `reconciler` (never-raise, `_stop`-Event, старт/стоп в `main.lifespan`, снимок в
  `/queue`).
- `src/merge_gate.py` — функция проактивного реклейма stale/dead lease (по pid-
  liveness + по TTL); helper проверки liveness pid; helper идемпотентной
  финализации merge.
- `src/main.py` — старт/стоп нового daemon-потока в `lifespan` (после `worker.start()`
  / `reconciler.start()`, симметрично остановка перед `worker.stop()`); вызов
  стартового реклейма stale-lease рядом с `requeue_running_jobs()`.
- `src/config.py` — новые настройки/флаги (см. §5).
- `src/main.py` `GET /queue` — блок наблюдаемости reaper (образец `reconcile`/
  `post_deploy`).

## 2. Функциональные требования

### FR-1. Job-reaper (Проблема A)
- Фоновый поток периодически (`reaper_interval_s`) сканирует строки `jobs` в статусе
  `running`.
- Для каждого `running`-job определяет, **жив ли его исполнитель**. «Мёртвым» job
  считается, когда выполнено и устойчиво (см. FR-1.3) хотя бы одно из:
  - процесс агента (по pid/run_id) не существует, а финализация не произошла;
  - `agent_runs` строки run_id имеет `finished_at`/`exit_code` (процесс реально
    завершился), но `jobs.status` всё ещё `running` (monitor умер между записью
    exit_code и `_finalize_job`);
  - job висит `running` дольше предохранительного потолка
    `reaper_max_running_s` (заведомо больше любого легитимного `agent_timeout` +
    grace) — backstop на случай, когда liveness определить нельзя.
- FR-1.2 Действие при подтверждённой смерти:
  - если есть достоверный успешный исход (`agent_runs.exit_code == 0`) — довести job
    к корректному завершению **через тот же контракт**, что `_finalize_job`
    (включая, при необходимости, повторную попытку auto-advance) — НЕ дублировать
    переход, если он уже произошёл (идемпотентность через `has_active_job_for_task`
    / проверку стадии);
  - если исход неуспешный/неизвестен и бюджет попыток не исчерпан
    (`attempts < max_attempts`) — `queued` (повторная постановка), как делает
    `requeue_running_jobs`;
  - если бюджет исчерпан — `failed` + Telegram-алерт.
- FR-1.3 **Анти-ложноположительность.** Job помечается зомби только после
  устойчивого подтверждения смерти: процесс мёртв на протяжении `reaper_dead_ticks`
  последовательных тиков (≥2) ИЛИ превышен `reaper_max_running_s`. Живой долгий
  агент (в пределах своего `agent_timeout`) НИКОГДА не реапится.
- FR-1.4 Работает **без рестарта** процесса (главное отличие от существующего
  `requeue_running_jobs`).
- FR-1.5 Restart-safe: после рестарта поведение корректно совмещается со стартовым
  `requeue_running_jobs()` (нет двойной обработки одной строки; атомарность reap-
  UPDATE с guard по `status='running'`, как в `claim_next_job`).

### FR-2. Проактивный реклейм stale/dead merge-lease (Проблема B)
- FR-2.1 На старте процесса (рядом с `requeue_running_jobs()` в `lifespan`) и
  периодически в фоновом потоке: для каждого репо с merge-gate проверить lease и
  освободить его, если держатель **мёртв** или lease **просрочен**.
- FR-2.2 «Держатель мёртв» = pid из lease не существует в системе (liveness-проба,
  напр. `os.kill(pid, 0)` с обработкой `ProcessLookupError`/`PermissionError`),
  при условии что pid принадлежит этому хосту/неймспейсу. «Просрочен» = `age >=
  merge_lock_timeout_s` (существующий TTL-контракт сохраняется).
- FR-2.3 Реклейм **holder-aware и безопасен**: НЕ освобождать lease, чей держатель
  жив и в пределах TTL (защита легитимного merge). Логировать `warning` при каждом
  реклейме (наблюдаемость, как сейчас в `acquire_merge_lease`).
- FR-2.4 Условность как ORCH-35/43: реально только для self-hosting/`merge_gate_repos`;
  прочие репо — no-op.
- FR-2.5 Контракт **never-raise**; любая ошибка реклейма не должна валить поток.

### FR-3. Идемпотентная финализация merge (Проблема C)
- FR-3.1 Если ветка прошла rebase+re-test (догнана до `origin/main` и зелёная), но
  merge PR не состоялся из-за смерти процесса — система должна **докатить/повторить**
  merge без повторного прогона дорогих шагов, когда это безопасно.
- FR-3.2 Финализация merge должна быть **идемпотентной**: повторный вызов при уже
  слитом PR — no-op (определять по состоянию PR в Gitea и/или по
  `branch_is_behind_main`/состоянию `main`), без ошибки и без второго слияния.
- FR-3.3 Восстановление re-drive обеспечивается штатными механизмами (reaper
  довёл job до `queued` → повторный проход стадии `deploy`/merge-gate; либо
  reconciler доигрывает переход). Дублирующая логика merge НЕ создаётся — переиспользуются
  существующие пути (`check_branch_mergeable` / deployer-merge).
- FR-3.4 При повторе lease берётся заново (идемпотентный re-acquire «held by self»
  по branch уже поддержан в `acquire_merge_lease`).

### FR-4. Наблюдаемость
- FR-4.1 Блок `reaper` в `GET /queue`: enabled, interval, last_run_ts, reaped_total,
  last_reaped (job_id/agent), lease_reclaimed_total (best-effort, как у reconciler).
- FR-4.2 Каждый reap и каждый lease-reclaim — `logger.warning` с идентификаторами
  (job_id, run_id, pid, repo, branch).
- FR-4.3 При reap→`failed` и при lease-reclaim — Telegram (как существующие алерты).

## 3. Изменения схемы БД
- Текущая `jobs` НЕ содержит `pid`. Для надёжной pid-liveness job-reaper'у, скорее
  всего, потребуется **lightweight-миграция**: добавить `jobs.pid INTEGER` (через
  `_ensure_column`, идемпотентно, безопасно на live prod DB — паттерн уже
  применяется в `db.py`). pid проставляется в `_spawn` рядом с `run_id`/`started_at`.
- **Альтернатива без миграции** (на усмотрение архитектора): определять смерть по
  `agent_runs.finished_at/exit_code` + потолку `reaper_max_running_s`, без хранения
  pid в `jobs`. ADR должен зафиксировать выбор и обоснование.
- Реестры `STAGE_TRANSITIONS` и `QG_CHECKS` — **без изменений** (новых стадий/гейтов
  не вводим; reaper и lease-reclaim — фоновые механизмы, не стадии).
- Merge-lease остаётся файловым (`.merge-lease-<repo>.json`); схема файла lease
  не меняется (pid и acquired_at уже есть).

## 4. Изменения API
- `GET /queue` — добавить блок `reaper` (read-only наблюдаемость). Прочие endpoints
  без изменений. Новых webhook-роутов нет.

## 5. Конфигурация / kill-switches (`src/config.py`)
Именование — по образцу `reconcile_*` / `merge_*`. Кандидаты (точные имена/дефолты
уточняет архитектор):

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

Все флаги — пробрасываются из env (`ORCH_*`), `false` → строго прежнее поведение.

## 6. Требования к QG checks
- Новых QG checks НЕ вводить (это фоновые resilience-механизмы, не гейты выхода со
  стадии). `check_branch_mergeable` остаётся контрактно неизменным; допускается лишь
  переиспользование его как идемпотентного пути финализации merge (FR-3.3).

## 7. Артефакты pipeline, создаваемые/обновляемые в ЭТОМ PR
- Код: см. §1.
- `06-adr/ADR-001-*.md` — архитектурное решение (где живёт reaper; pid-колонка vs
  эвристика; механизм идемпотентного merge) — создаёт architect.
- `docs/architecture/README.md` — новый раздел про job-reaper + lease-reclaim
  (golden-source, в этом же PR).
- `docs/architecture/internals.md` — детали (если затрагивается схема БД / потоки).
- `CHANGELOG.md` — запись ORCH-065.
- `.env.example` — новые `ORCH_*` флаги (канон секретов/настроек).
- `docs/operations/INFRA.md` — упоминание поведения при self-restart, если
  затрагивается (best-effort).

## 8. Инварианты (НЕ нарушать)
- Не ронять/не рестартить прод-контейнер `orchestrator` в рамках задачи.
- Никогда не пушить/форс-пушить `main`; реклейм lease не инициирует git-операций.
- `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, контракты `check_*`, БАГ-8 откат,
  exit-коды deploy-хука — без изменений.
- never-raise на единицу фоновой работы; идемпотентность; restart-safe; тишина при
  отсутствии аномалий (как reconciler).
- Анти-ложноположительность (FR-1.3): живой долгий агент не реапится.
