# ТЗ — 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-.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): живой долгий агент не реапится.