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>
6.5 KiB
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.
Решение
- 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-2agent_runs.exit_codeзаписан, а job ещёrunning— но только после finalization-gracereaper_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, затем claimdoneПЕРВЫМ и только победитель claim выполняет_try_advance_stage(advance+enqueue) — проигравший не делает побочных эффектов (источник истины — QG, не «exit0»); гейт красный или exit≠0 / неизвестно →attempts<max→queued, иначеfailed+Telegram. Атомарный reap-claim (UPDATE ... WHERE id=? AND status='running'+rowcount, какclaim_next_job) исключает двойную обработку (совместимость со стартовымrequeue_running_jobs). - Проактивный реклейм stale/dead lease — функции в
merge_gate.py(pid_alive,reclaim_stale_lease), вызываемые на старте (рядом сrequeue_running_jobs) и периодически из тика reaper. Освобождение, если держатель мёртв (pid не жив) ИЛИ просрочен (TTL); живой держатель в пределах TTL — НЕ трогать. holder-aware, never-raise, условность как ORCH-43. - Идемпотентная финализация merge — без новой merge-логики: re-drive через
reaper→
queued→переисполнение стадии / reconciler; дорогие шаги не повторяются (branch_is_behind_main==False); добавлен детерминированный never-raise guardpr_already_merged(читает состояние PR), консультируемый перед повторным merge → уже слит = no-op. - Схема БД —
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.