---
work_item: ORCH-113
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-15
model_used: claude-opus-4-8
---

# ADR-001: Reaper Tier-2 — in-memory ownership-маркер финализации `deploy-staging` (живой finalizer не реапится)

Work Item: **ORCH-113** — BUG: job-reaper повторно запускает финализацию `deploy-staging`, пока жив исходный finalizer
Стадия: **architecture**
Сквозная регистрация: **`docs/architecture/adr/adr-0043-reaper-finalizer-liveness-ownership.md`** (решение кросс-каттинговое — уточняет контракт reaper ORCH-065/adr-0011).

## Статус
Proposed

## Контекст

Оркестратор self-hosting: один инстанс, общая БД/очередь, `max_concurrency=1`. Финальный статус job
(`done`/`queued`/`failed`/`cancelled`) пишется **только** в живом процессе
(`launcher._monitor_agent → _finalize_job`). Сверено по коду:

- `_monitor_agent` штампит `agent_runs.finished_at`/`exit_code` **ПЕРВЫМ** (`launcher.py:861`), затем
  делает git commit/push (+PR), usage-комментарии Plane (секунды…десятки секунд), затем
  `_try_advance_stage` (`launcher.py:998`) и лишь потом `_finalize_job` (`launcher.py:1003`).
- На ребре `deploy-staging → deploy` `_try_advance_stage → advance_stage` синхронно, **в потоке
  монитора**, прогоняет тяжёлый набор edge-под-гейтов (`stage_engine.py:327–368`):
  `security` → `merge-gate` (полный локальный re-test, бюджет `merge_retest_timeout_s=900`) →
  `coverage` (`pytest --cov`) → `image-freshness` (docker-rebuild + пересоздание staging) — это
  **минуты**, и весь объём идёт **после** штампа `finished_at` и **до** `_finalize_job`.
- Reaper Tier-2 (`job_reaper._reap_job`, `job_reaper.py:197–209`) меряет `finished_age_s` от
  `agent_runs.finished_at` (`db.get_running_jobs`, `db.py:1360`) = **от начала** финализации. По
  истечении `reaper_finalize_grace_s=300` он трактует живого, долго финализирующего монитора как
  мёртвого и независимо запускает тот же тяжёлый advance (`_reap_exit0 → _gate_driven_advance →
  _try_advance_stage → advance_stage`).

Дешёвая read-only пред-проверка `_gate_is_green('deploy-staging')` читает лишь `check_staging_status`
(frontmatter `15-staging-log.md` = `SUCCESS`) → reaper уверенно идёт в тяжёлый advance. Атомарный
claim-before-act (`reap_running_job ... WHERE status='running'`) защищает **флип строки** job, но **не
side-effectful исполнение edge-гейтов**: монитор не claim'ит строку перед `advance_stage`, поэтому
монитор и reaper выполняют `advance_stage` **параллельно**.

**Инцидент ORCH-111 (deployer job 1914, run_id 683):** финализация `deploy-staging` заняла >300с;
reaper повторил edge-гейты; один повторный re-test стал красным (`3 failed … 14 errors in 444.79s`);
задача ложно откатана `deploy-staging → development` (+ ложный developer-retry), **параллельно**
исходный finalizer довёл deploy до `SUCCESS` и смержил PR #130 (`deploy → done`). Состояние раздвоилось.

Источника истины «жив ли finalizer» сегодня нет: pid агента в Tier-2 уже мёртв в **обоих** случаях
(`proc.wait()` вернулся), а живость **потока-монитора** система не наблюдает. Per-stage grace,
покрывающая Σ финализации (`Σ ≈ 4160с`), невозможна без нарушения сквозного бюджета ORCH-065/109/110
`reaper_max_running_s (5400) > Σ(deploy-staging gate-work) + grace (≈4460)`.

**Решающий факт (проверен):** монитор и reaper — daemon-**потоки одного** uvicorn-процесса (CMD без
`--workers`; `_monitor_agent` стартует `threading.Thread`, `launcher.py:661`; reaper — daemon-поток,
`main.py:144`), разделяющие одну SQLite-БД. Значит, живость finalizer'а можно определить **in-memory**.
Рестарт покрыт существующим `requeue_running_jobs()` (`running → queued`), который в `main.lifespan`
вызывается (`main.py:59`) **до** старта reaper (`main.py:144`).

## Решение

### Сводка
Ввести **процесс-локальный реестр владения финализацией**: живой монитор регистрирует «я финализирую
job X», а reaper в Tier-2 на стадии `deploy-staging` **не реапит** job, чьё владение активно, и
переходит к Tier-3 backstop. Реестр in-memory — авторитетен в рамках одного процесса/БД; рестарт
покрыт `requeue_running_jobs`. Grace и `reaper_max_running_s` не меняются → сквозной бюджет цел. Под
глобальным kill-switch; **нулевое** изменение схемы БД и контрактов.

### D1 — Leaf `src/finalizer_liveness.py` (владение, FR-2)
Новый чистый процесс-локальный модуль (паттерн `serial_gate`/`coverage_gate`: never-raise, без сети/БД):
- `mark(job_id, run_id, stage)` — зарегистрировать активную финализацию;
- `clear(job_id)` — снять;
- `is_active(job_id) -> bool` — есть ли живое владение;
- `snapshot() -> dict` — read-only для наблюдаемости.

Состояние — `{job_id: {"run_id", "stage", "started_ts"}}` + `threading.Lock`. Собственного TTL нет —
ограничение по времени даёт Tier-3 (см. D3). Все функции изолированы `try/except` → дефолт
(`is_active` при ошибке → `False`, консервативно: не блокировать добивание).

### D2 — Эмиссия владения в `launcher._monitor_agent` (FR-1)
`mark(job_id, run_id, stage)` вызывается **сразу после** штампа `exit_code` (`launcher.py:864`, самый
ранний момент, когда reaper переходит в Tier-2; до этого pid агента жив → Tier-1 защищает). Хвост
финализации (git push … `_try_advance_stage` … `_finalize_job`) оборачивается в `try/finally`, в
`finally` — `clear(job_id)`. Так исключение **в потоке монитора** гарантированно снимает владение →
reaper добивает (FR-4). Только при `job_id is not None` (legacy `launch()` с `job_id=None` не в
`get_running_jobs`). Гибель **всего процесса** → рестарт → `requeue_running_jobs` → реестр пуст
(restart-safe без durable, NFR-5).

### D3 — Консультация reaper, scoped + Tier-3 backstop (FR-3, FR-4)
В `job_reaper._reap_job`, Tier-2-ветка (`exit_code` записан, `finished_age >= grace`): **перед**
`_reap_known_outcome` — если `settings.reaper_finalizer_liveness_enabled` **И** стадия задачи
(`_task_meta`) `== "deploy-staging"` **И** `finalizer_liveness.is_active(job_id)` → **defer** (лог +
счётчик), **не** реапить через Tier-2, провалиться к Tier-3. Иначе — прежний путь
(`_reap_known_outcome; return`), байт-в-байт. **Tier-3** (`age >= reaper_max_running_s`) маркер
**игнорирует** — добивает всегда (ограниченное время; бюджет `5400 > Σ+grace ≈ 4460` гарантирует, что
легитимная финализация завершится до 5400 → ложного Tier-3-реапа живого finalizer'а нет).

### D4 — Скоуп и kill-switch (NFR-4)
Только глобальный `reaper_finalizer_liveness_enabled` (`config.py`, env
`ORCH_REAPER_FINALIZER_LIVENESS_ENABLED`, дефолт `True`). **Без** per-repo разреза: баг общий для
любого репо со стадией `deploy-staging` (enduro тоже); per-repo оставил бы баг активным для части
репо. Это сознательный отход от leaf-паттерна `*_repos` (он для **гейтов, действующих на репо**; здесь
— наблюдатель-безопасность глобального демона). `False` → reaper никогда не консультирует маркер →
поведение байт-в-байт прежнее; стадии `!= deploy-staging` не консультируются → не тронуты.

### D5 — Наблюдаемость (TZ §4)
Счётчик `finalizer_defers_total` + размер `finalizer_liveness.snapshot()` в блоке `reaper`
`GET /queue`. Существующие ключи ответа не меняются; новых эндпоинтов нет.

### Инварианты
`STAGE_TRANSITIONS` / `QG_CHECKS` / каждый `check_*` / machine-verdict ключи / схема существующих
таблиц — **байт-в-байт**; **нулевое** изменение схемы БД; reaper остаётся never-raise per-unit;
`reaper_finalize_grace_s` и `reaper_max_running_s` **не меняются** (NFR-6 цел); фикс не рестартит прод
и не пушит `main` (NFR-3). Merge-verify (`deploy → done`, ORCH-071) — единственный choke-point в
`done`, не ослабляется (FR-5).

## Альтернативы
- **Per-stage grace, покрывающая Σ** — отвергнуто: нарушает бюджет `5400 > Σ+grace` (Σ≈4160 ⇒ grace
  пришлось бы <1240, не покрывает Σ); таймер — это и есть источник бага.
- **Durable-колонка (finalizing-heartbeat / owner-токен)** — отвергнуто: один процесс/одна БД →
  in-memory авторитетно; рестарт покрыт requeue; блокирующий re-test (900с) не может бить периодический
  heartbeat из того же потока; durable добавляет миграцию и запись ради нулевой выгоды.
- **Sub-state `finalizing` в `jobs.status`** — отвергнуто: меняет семантику статуса, который читают
  `claim_next_job`/`requeue_running_jobs`/`reconciler`/`reaper` (`WHERE status='running'`) — нарушение
  NFR-2 и высокий радиус поражения.
- **Lease-файл на `(job, stage)` (как merge-lease)** — отвергнуто: тяжелее (файловый I/O, TTL,
  reclaim), дублирует merge-lease; in-memory достаточно при одном процессе; TTL возвращает таймер-проблему.
- **Флип job из `running` до тяжёлых гейтов** — отвергнуто: ломает `get_running_jobs`/метрики и
  restart-requeue (краш мид-гейт оставит job non-running и нереквью'имым).

## Последствия
- **+** Устранены повторный прогон edge-гейтов, ложный откат `deploy-staging → development` и
  расхождение состояния при живом долгом finalizer'е; идемпотентность edge-гейтов через владение
  (AC-1/AC-2/AC-4).
- **+** Реально мёртвый/застрявший finalizer добивается (finally-clear → Tier-2; иначе Tier-3);
  основная функция reaper ORCH-065 сохранена (AC-3).
- **+** Нулевое изменение схемы и контрактов; сквозной бюджет ORCH-065/109/110 не тронут (AC-5).
- **−** Гарантия владения валидна при **одном процессе/одной БД** (проверено: один uvicorn-воркер);
  ввод `--workers>1` потребует durable-сигнала — зафиксировано в `10-tech-risks.md` (TR-3).
- **−** Окно «штамп `finished_at` → `mark()`» (git push) маркером не покрыто — закрыто прежним
  grace=300 (окно ≪ grace), TR-2.
- **Откат:** `ORCH_REAPER_FINALIZER_LIVENESS_ENABLED=false` → reaper байт-в-байт прежний (маркер
  по-прежнему пишется монитором, но не консультируется — инертен). Полный откат — удаление leaf +
  двух врезок.

## Ссылки
- BRD: `docs/work-items/ORCH-113/01-brd.md`
- TRZ: `docs/work-items/ORCH-113/02-trz.md`
- Acceptance: `docs/work-items/ORCH-113/03-acceptance-criteria.md`
- Test-plan: `docs/work-items/ORCH-113/04-test-plan.yaml`
- Сквозной ADR: `docs/architecture/adr/adr-0043-reaper-finalizer-liveness-ownership.md`
- Базовые ADR: `adr-0011` (reaper/lease ORCH-065), `adr-0040` (timeout-бюджеты ORCH-109),
  `adr-0042` (merge-gate re-test ORCH-110)
- Сверено по коду: `src/job_reaper.py`, `src/agents/launcher.py` (`_monitor_agent`/`_try_advance_stage`),
  `src/db.py` (`get_running_jobs`/`requeue_running_jobs`), `src/stage_engine.py` (`advance_stage` ребро
  `deploy-staging`), `src/config.py` (`reaper_*`), `src/main.py` (`lifespan`)
</content>
