Files
orchestrator/docs/architecture/adr/adr-0011-job-reaper-lease-reclaim.md
claude-bot 720c31393a fix(reaper): Tier-2 finalization grace + claim-before-act (no dup advance)
Tier-2 reaped a LIVE, still-finalizing monitor: _monitor_agent writes
agent_runs.exit_code FIRST, then does git push / PR / Plane comments before
_finalize_job, and the agent pid is already dead in that window — so the old
"exit_code recorded -> reap now" had no grace and could race a healthy job.
Worse, _reap_known_outcome ran the advance (advance_stage -> enqueue_job)
BEFORE the atomic claim, so a reaper that lost the race had already enqueued
the next stage (dup advance / dup enqueue), violating ADR-001 Р-1.

Fix:
- Tier-2 grace: reap only once agent_runs.exit_code has been recorded for
  >= reaper_finalize_grace_s (new setting, default 300s; > max finalization
  window). A live finalizing monitor is never reaped (FR-1.3/AC-3). New
  finished_age_s column computed in get_running_jobs.
- claim-before-act for exit0: evaluate the canonical QG READ-ONLY (the
  reconciler pattern) to choose the terminal status, then atomically claim
  'done' FIRST; only the claim winner runs the advance. A loser performs no
  side effects -> no dup advance / dup enqueue.

Docs (golden source) updated in the same change: ADR-001, global adr-0011,
README, internals, .env.example, CHANGELOG (also fixes the P3 broken adr-0011
link). New tests cover the grace window, lost-claim no-side-effects, and the
already-advanced idempotent path.

Refs: ORCH-065

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 16:14:45 +00:00

6.5 KiB
Raw Permalink Blame History

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<maxqueued, иначе 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.