From f0ac9d5562433fc9447f9874caff32097875ff42 Mon Sep 17 00:00:00 2001 From: Slava Date: Sun, 7 Jun 2026 17:50:48 +0300 Subject: [PATCH 01/10] docs: init ORCH-065 business request --- docs/work-items/ORCH-065/00-business-request.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 docs/work-items/ORCH-065/00-business-request.md diff --git a/docs/work-items/ORCH-065/00-business-request.md b/docs/work-items/ORCH-065/00-business-request.md new file mode 100644 index 0000000..a62f09b --- /dev/null +++ b/docs/work-items/ORCH-065/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: BUG: zombie jobs + merge-lease залип (процесс умер, статус running) + +Work Item ID: ORCH-065 + +## Description + +TBD From b760b24a48c839dc6fc03c6dc38040036b377412 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 14:56:19 +0000 Subject: [PATCH 02/10] analyst(ET): auto-commit from analyst run_id=316 --- docs/work-items/ORCH-065/01-brd.md | 103 +++++++++ docs/work-items/ORCH-065/02-trz.md | 170 +++++++++++++++ .../ORCH-065/03-acceptance-criteria.md | 122 +++++++++++ docs/work-items/ORCH-065/04-test-plan.yaml | 196 ++++++++++++++++++ 4 files changed, 591 insertions(+) create mode 100644 docs/work-items/ORCH-065/01-brd.md create mode 100644 docs/work-items/ORCH-065/02-trz.md create mode 100644 docs/work-items/ORCH-065/03-acceptance-criteria.md create mode 100644 docs/work-items/ORCH-065/04-test-plan.yaml diff --git a/docs/work-items/ORCH-065/01-brd.md b/docs/work-items/ORCH-065/01-brd.md new file mode 100644 index 0000000..01de429 --- /dev/null +++ b/docs/work-items/ORCH-065/01-brd.md @@ -0,0 +1,103 @@ +# BRD — ORCH-065: zombie jobs + залипший merge-lease + +Work Item ID: ORCH-065 +Тип: BUG (P0) +Репозиторий: orchestrator (self-hosting) +Эпик: блокер ORCH-54 (полностью автономный self-deploy) + +## 1. Контекст и проблема + +Оркестратор — единый инстанс с **общей БД и общей очередью** (`jobs`, +`max_concurrency=1` для self-hosting), обслуживающий несколько проектов. Финальная +автономность self-deploy упирается в два связанных класса отказов, оба сводящиеся +к «процесс умер/завершился, а состояние осталось захваченным навсегда»: + +### Проблема A — zombie jobs (строка `jobs` навсегда `running`) +Агент (deployer/developer/reviewer) завершается **или умирает** (краш, OOM, +рестарт контейнера в ходе self-deploy, гибель monitor-потока), но строка в таблице +`jobs` остаётся в статусе `running`. Финализация статуса job выполняется **только** +в `_monitor_agent` → `_finalize_job` внутри того же процесса; если этот поток/процесс +не доживает до финализации — job «зомбирован». + +- Единственная имеющаяся защита — `requeue_running_jobs()` в `main.lifespan`, + срабатывающая **исключительно на старте процесса**. Зомби, возникший **без** + рестарта (умер дочерний процесс/monitor-поток, а сервис жив), не реанимируется + никогда. +- При `max_concurrency=1` одна зомби-строка `running` блокирует claim всех + последующих job (`count_running_jobs() >= max_concurrency` → claim не происходит) + → **встаёт конвейер всех проектов**. + +### Проблема B — залипший merge-lease +Merge-gate (ORCH-043) берёт файловый lease `/.merge-lease-.json` +ПЕРЕД rebase+re-test и держит его до фактического merge PR в `main`. Если процесс +умирает на финальном merge **с зажатым lease**: + +- Реклейм lease реализован **лениво и только по возрасту** (`age >= + merge_lock_timeout_s`) и **только в момент `acquire_merge_lease` другой задачей**. + Проактивного освобождения (на старте / периодически) нет; **liveness держателя по + pid не проверяется** (хотя `pid` в lease пишется). +- Пострадавшая задача сама re-drive не получает: merge не финализируется → задача + висит, lease мешает чужим merge до истечения TTL. + +### Проблема C — неидемпотентная финализация merge +Если rebase+re-test прошли зелёно (ветка догнана и проверена), но процесс умер до +завершения слияния PR — повторного «докатывания» merge нет. Задача застревает в +полу-выполненном состоянии, хотя вся дорогая работа (rebase+re-test) уже сделана. + +## 2. Бизнес-последствия +- **Это ПОСЛЕДНЯЯ ручная точка автономного деплоя.** Без фикса ни одна self-hosting + задача не доезжает до прода без оператора (cancel zombie + ручной merge PR + + ручной `--deploy`). +- Прямой блокер эпика ORCH-54. +- Доказанные инциденты (07.06): ORCH-58/60/61/21 — каждый раз после успешного + deployer-прохода job оставался `running`; jobs **236/239/242/254** — зомби, + прод-merge/deploy доводились вручную. +- Групповой риск: зомби в общей очереди при concurrency=1 останавливает конвейер + enduro-trails и всех прочих проектов. + +## 3. Цель +Сделать так, чтобы **смерть процесса/потока на любой стадии (включая self-restart +во время deploy) НЕ оставляла навсегда захваченных ресурсов** — ни строки `jobs` в +`running`, ни merge-lease. Конвейер должен самовосстанавливаться без оператора, при +этом сохраняя все инварианты self-hosting (не ронять прод-контейнер, не трогать +`main`, fail-closed на реальных ошибках). + +## 4. Объём (Scope) + +### В объёме +1. **Job-reaper** — фоновый watchdog (паттерн `reconciler`/`queue_worker`), + детектирующий «мёртвый» `running`-job и приводящий его строку в корректный + терминальный/повторный статус (`done`/`failed`/`queued`) детерминированно, + без LLM. Restart-safe и работающий **без** рестарта процесса. +2. **Проактивный реклейм stale merge-lease** — освобождение lease, чей держатель + мёртв (pid не жив) ИЛИ возраст превысил TTL — на старте и периодически (reaper/ + reconciler), а не только лениво при чужом `acquire`. +3. **Идемпотентная финализация merge** — если rebase+re-test зелёные, но merge не + состоялся, операция повторяется/докатывается без потери уже сделанной работы. + +### Вне объёма +- Переход на внешний брокер очередей / смену схемы блокировок merge на БД-lock. +- Полный авто-approve деплоя (ORCH-54) — отдельная задача; здесь только снятие + технического блокера. +- Изменение конвейера стадий (`STAGE_TRANSITIONS`) и реестра гейтов как контрактов. + +## 5. Заинтересованные стороны +- Owner оркестратора (self-hosting автономность). +- Все проекты на общем инстансе (enduro-trails и пр.) — страдают от блокировки + общей очереди. + +## 6. Допущения и ограничения +- `max_concurrency=1` для self-hosting сохраняется. +- Self-hosting safety (CLAUDE.md): нельзя ронять/рестартить прод-контейнер в рамках + задачи; нельзя пушить/форс-пушить `main`; реклейм lease не должен прерывать + легитимно работающий merge. +- Никаких ложных реанимаций: живой, но долгий job не должен помечаться зомби + (нужен порог/грейс «N тиков» + проверка реальной смерти, а не просто долготы). +- Контракт **never-raise** для всей новой фоновой логики (как у reconciler/merge_gate). +- Kill-switch на каждый новый механизм (как `reconcile_enabled` / `merge_gate_enabled`). + +## 7. Критерий успеха (бизнес-уровень) +После фикса воспроизводимый сценарий «успешный deployer-проход + смерть процесса/ +self-restart» НЕ оставляет зомби-job и зажатого lease: задача либо корректно +доезжает до `done` сама, либо откатывается по штатному контракту — **без участия +оператора**. Регресс-тест на jobs-зомби и stale-lease зелёный. diff --git a/docs/work-items/ORCH-065/02-trz.md b/docs/work-items/ORCH-065/02-trz.md new file mode 100644 index 0000000..8909924 --- /dev/null +++ b/docs/work-items/ORCH-065/02-trz.md @@ -0,0 +1,170 @@ +# ТЗ — 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): живой долгий агент не реапится. diff --git a/docs/work-items/ORCH-065/03-acceptance-criteria.md b/docs/work-items/ORCH-065/03-acceptance-criteria.md new file mode 100644 index 0000000..f1630bc --- /dev/null +++ b/docs/work-items/ORCH-065/03-acceptance-criteria.md @@ -0,0 +1,122 @@ +# Критерии приёмки — ORCH-065 + +Work Item ID: ORCH-065 +Формат: каждый критерий имеет явное условие PASS/FAIL. Все критерии должны быть +PASS для прохождения review/testing. + +## A. Job-reaper (Проблема A) + +### AC-1 — реап мёртвого running-job без рестарта +- PASS: при наличии строки `jobs` в статусе `running`, чей процесс/исполнитель + достоверно мёртв (pid не существует ИЛИ `agent_runs.exit_code` записан, а job всё + ещё `running`) и условие устойчивости (FR-1.3) выполнено, фоновый reaper переводит + строку в корректный статус (`done`/`queued`/`failed`) **без перезапуска процесса**. +- FAIL: строка остаётся `running` после `reaper_dead_ticks` тиков / превышения + `reaper_max_running_s`. + +### AC-2 — разблокировка очереди при concurrency=1 +- PASS: после реапа зомби-строки `count_running_jobs()` снижается, и следующий + queued-job успешно claim'ится воркером. +- FAIL: очередь остаётся заблокированной зомби-строкой. + +### AC-3 — анти-ложноположительность (живой долгий агент не реапится) +- PASS: `running`-job с ЖИВЫМ процессом в пределах его `agent_timeout` НЕ помечается + зомби (ни по одному тику, ни в пределах `reaper_max_running_s`, если потолок + больше таймаута). +- FAIL: живой агент помечен `failed`/`queued` reaper'ом. + +### AC-4 — корректный исход по результату +- PASS: при `agent_runs.exit_code == 0` reaper доводит до успешного завершения без + дублирования уже выполненного stage-advance (идемпотентно); при неуспехе и + `attempts < max_attempts` → `queued`; при исчерпании → `failed` + Telegram. +- FAIL: успешный исход помечен `failed`; либо дублируется stage-переход; либо + исчерпанный бюджет молча зацикливается на `queued`. + +### AC-5 — restart-safe совместимость +- PASS: одновременная работа стартового `requeue_running_jobs()` и периодического + reaper не приводит к двойной обработке одной строки (атомарный UPDATE с guard + `status='running'`). +- FAIL: одна строка обработана дважды / гонка приводит к рассинхрону статуса. + +## B. Stale/dead merge-lease reclaim (Проблема B) + +### AC-6 — реклейм lease мёртвого держателя +- PASS: lease `.merge-lease-.json`, чей `pid` не существует, проактивно + освобождается на старте И периодическим потоком (не дожидаясь TTL и не дожидаясь + чужого `acquire`). +- FAIL: lease мёртвого держателя остаётся до истечения `merge_lock_timeout_s` или + до следующего чужого `acquire`. + +### AC-7 — реклейм по TTL сохранён +- PASS: lease старше `merge_lock_timeout_s` освобождается (существующий контракт не + сломан), с `logger.warning`. +- FAIL: просроченный lease не освобождается. + +### AC-8 — не трогать живой lease +- PASS: lease с ЖИВЫМ держателем (pid жив) и возрастом `< merge_lock_timeout_s` НЕ + освобождается (защита легитимного merge). +- FAIL: освобождён lease живого держателя → возможен параллельный конфликтный merge. + +### AC-9 — условность и never-raise +- PASS: реклейм реален только для `merge_gate_repos`/self-hosting; для прочих репо + — no-op; любая ошибка реклейма логируется и не валит поток (never-raise). +- FAIL: реклейм выполняется для не-self-hosting репо; либо ошибка пробрасывается + наружу/роняет поток. + +## C. Идемпотентная финализация merge (Проблема C) + +### AC-10 — докатывание незавершённого merge +- PASS: сценарий «rebase+re-test зелёные, merge не состоялся, процесс умер» + восстанавливается автоматически (job → `queued` reaper'ом / reconciler доигрывает), + и merge доводится без повторного ненужного прогона дорогих шагов. +- FAIL: задача остаётся в полу-выполненном состоянии, требует ручного merge. + +### AC-11 — идемпотентность при уже слитом PR +- PASS: повторный вызов финализации при уже слитом PR — no-op (определяется по + состоянию PR/`main`), без ошибки и без второго merge. +- FAIL: второй merge / ошибка при уже слитом PR. + +## D. Инварианты и безопасность self-hosting + +### AC-12 — прод-контейнер не трогается +- PASS: ни reaper, ни lease-reclaim не рестартят/не роняют прод-контейнер и не + инициируют git-push в `main`. +- FAIL: любая из новых веток кода рестартит self / пушит main. + +### AC-13 — контракты неизменны +- PASS: `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, сигнатуры/поведение `check_*`, + БАГ-8 откат, exit-коды deploy-хука — без изменений; новых QG checks/стадий нет. +- FAIL: затронут любой из перечисленных контрактов. + +### AC-14 — kill-switches +- PASS: `reaper_enabled=false` → reaper не работает (строго прежнее поведение); + `lease_reclaim_enabled=false` → проактивный реклейм отключён (остаётся лишь + прежний ленивый TTL-реклейм в `acquire`). +- FAIL: флаг `false` не отключает соответствующий механизм. + +## E. Наблюдаемость + +### AC-15 — блок reaper в /queue +- PASS: `GET /queue` содержит блок `reaper` (enabled, interval, last_run_ts, + reaped_total, last_reaped, lease_reclaimed_total). +- FAIL: блок отсутствует/не обновляется. + +### AC-16 — логи и алерты +- PASS: каждый reap и lease-reclaim → `logger.warning` с идентификаторами; + reap→`failed` и lease-reclaim → Telegram. +- FAIL: реап/реклейм происходят молча. + +## F. Документация (gate reviewer) + +### AC-17 — golden-source обновлён в этом же PR +- PASS: обновлены `docs/architecture/README.md` (раздел про reaper + lease-reclaim), + `CHANGELOG.md`, `.env.example` (новые `ORCH_*` флаги); заведён + `06-adr/ADR-001-*.md`. +- FAIL: код изменён, документация — нет (reviewer → REQUEST_CHANGES). + +## G. Тесты + +### AC-18 — регресс-тесты зелёные +- PASS: новые unit/integration тесты (см. 04-test-plan.yaml) проходят; существующий + `pytest tests/ -q` зелёный (нет регресса merge_gate / queue / reconciler). +- FAIL: любой тест из плана красный или сломан существующий тест. diff --git a/docs/work-items/ORCH-065/04-test-plan.yaml b/docs/work-items/ORCH-065/04-test-plan.yaml new file mode 100644 index 0000000..78b6b37 --- /dev/null +++ b/docs/work-items/ORCH-065/04-test-plan.yaml @@ -0,0 +1,196 @@ +work_item: ORCH-065 +description: > + Тест-план для фикса zombie jobs (job-reaper), залипшего merge-lease + (проактивный реклейм dead/stale lease) и идемпотентной финализации merge. + Все новые фоновые механизмы — never-raise, restart-safe, kill-switch. + Модуль reaper и точные имена функций уточнит архитектор; в module указаны + кандидатные пути (tests/test_job_reaper.py / tests/test_merge_lease_reclaim.py). + +tests: + # ---- A. Job-reaper ---- + - id: TC-01 + type: unit + description: > + Reaper переводит running-job с мёртвым исполнителем в корректный статус + без рестарта процесса (pid не существует / exit_code записан, job всё ещё + running). Покрывает AC-1. + module: tests/test_job_reaper.py + expected: PASS + + - id: TC-02 + type: unit + description: > + Анти-ложноположительность: running-job с ЖИВЫМ процессом в пределах + agent_timeout НЕ реапится (ни по одному тику, ни в пределах reaper_max_running_s). + Покрывает AC-3. + module: tests/test_job_reaper.py + expected: PASS + + - id: TC-03 + type: unit + description: > + Устойчивость: job помечается зомби только после reaper_dead_ticks + последовательных тиков смерти pid (>=2), а не на первом тике. Покрывает FR-1.3. + module: tests/test_job_reaper.py + expected: PASS + + - id: TC-04 + type: unit + description: > + Backstop по потолку: job, висящий running дольше reaper_max_running_s, + реапится даже если liveness определить нельзя. Покрывает FR-1.1/AC-1. + module: tests/test_job_reaper.py + expected: PASS + + - id: TC-05 + type: unit + description: > + Корректный исход: exit_code==0 -> успешное завершение без дублирования + stage-advance; неуспех при attempts queued; исчерпан бюджет -> failed + + Telegram. Покрывает AC-4. + module: tests/test_job_reaper.py + expected: PASS + + - id: TC-06 + type: unit + description: > + Атомарность reap-UPDATE с guard status='running': параллельная обработка + одной строки (стартовый requeue_running_jobs + reaper) не приводит к двойному + reap. Покрывает AC-5. + module: tests/test_job_reaper.py + expected: PASS + + - id: TC-07 + type: unit + description: > + Kill-switch reaper_enabled=false -> reaper не трогает ни одной строки + (строго прежнее поведение). Покрывает AC-14. + module: tests/test_job_reaper.py + expected: PASS + + - id: TC-08 + type: unit + description: > + never-raise: ошибка БД/ОС внутри одного тика reaper не пробрасывается и не + останавливает поток (изоляция per-job, образец reconciler). Покрывает AC-9. + module: tests/test_job_reaper.py + expected: PASS + + - id: TC-09 + type: integration + description: > + Очередь разблокируется: после reap зомби-строки при max_concurrency=1 + следующий queued-job успешно claim'ится (claim_next_job + count_running_jobs). + Покрывает AC-2. + module: tests/test_queue.py + expected: PASS + + # ---- B. Stale/dead merge-lease reclaim ---- + - id: TC-10 + type: unit + description: > + Реклейм lease с мёртвым pid (os.kill(pid,0) -> ProcessLookupError) + проактивно, не дожидаясь TTL и чужого acquire. Покрывает AC-6. + module: tests/test_merge_lease_reclaim.py + expected: PASS + + - id: TC-11 + type: unit + description: > + Реклейм по TTL (age >= merge_lock_timeout_s) сохранён, с logger.warning. + Покрывает AC-7. (расширяет существующий stale-lease сценарий merge_gate.) + module: tests/test_merge_lease_reclaim.py + expected: PASS + + - id: TC-12 + type: unit + description: > + Живой lease (pid жив, age < TTL) НЕ освобождается — защита легитимного merge. + Покрывает AC-8. + module: tests/test_merge_lease_reclaim.py + expected: PASS + + - id: TC-13 + type: unit + description: > + Условность: реклейм реален только для merge_gate_repos/self-hosting; для + прочих репо no-op. Покрывает AC-9. + module: tests/test_merge_lease_reclaim.py + expected: PASS + + - id: TC-14 + type: unit + description: > + never-raise: ошибка чтения/удаления lease-файла не валит реклейм-поток. + Покрывает AC-9. + module: tests/test_merge_lease_reclaim.py + expected: PASS + + - id: TC-15 + type: unit + description: > + Kill-switch lease_reclaim_enabled=false -> проактивный реклейм отключён, + остаётся лишь прежний ленивый TTL-реклейм в acquire_merge_lease. + Покрывает AC-14. + module: tests/test_merge_lease_reclaim.py + expected: PASS + + # ---- C. Идемпотентная финализация merge ---- + - id: TC-16 + type: unit + description: > + Идемпотентность финализации: повторный вызов при уже слитом PR / уже + актуальном main (branch_is_behind_main == False) — no-op, без ошибки и без + второго merge. Покрывает AC-11. + module: tests/test_merge_gate.py + expected: PASS + + - id: TC-17 + type: integration + description: > + Восстановление: сценарий "rebase+re-test зелёные, merge не состоялся, + процесс умер" -> job доводится до queued reaper'ом и merge докатывается + штатным путём без повторного дорогого re-test, когда безопасно. Покрывает AC-10. + module: tests/test_merge_gate_race.py + expected: PASS + + # ---- D/E. Инварианты и наблюдаемость ---- + - id: TC-18 + type: integration + description: > + GET /queue содержит блок reaper (enabled, interval, last_run_ts, + reaped_total, last_reaped, lease_reclaimed_total). Покрывает AC-15. + module: tests/test_queue.py + expected: PASS + + - id: TC-19 + type: unit + description: > + Контракты неизменны: STAGE_TRANSITIONS и реестр QG_CHECKS не получили новых + стадий/чеков; check_branch_mergeable сигнатурно не изменён. Покрывает AC-13. + module: tests/test_config.py + expected: PASS + + - id: TC-20 + type: unit + description: > + Новые настройки reaper_*/lease_reclaim_* присутствуют в config с дефолтами и + читаются из ORCH_* env. Покрывает §5 ТЗ / AC-14. + module: tests/test_config.py + expected: PASS + + - id: TC-21 + type: unit + description: > + Стартовый реклейм stale/dead lease вызывается в lifespan рядом с + requeue_running_jobs (smoke на регистрацию старт/стоп reaper-потока). + Покрывает FR-2.1 / AC-6. + module: tests/test_job_reaper.py + expected: PASS + +regression: + - command: pytest tests/ -q + expected: PASS + note: > + Полный прогон не должен ломать существующие тесты merge_gate / queue / + reconciler / deploy. From 9f846b5a503241dcd8c0639058d7228cebcfbdd6 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 15:07:45 +0000 Subject: [PATCH 03/10] architect(ET): auto-commit from architect run_id=317 --- docs/architecture/README.md | 54 +++- docs/architecture/adr/README.md | 3 +- .../adr/adr-0011-job-reaper-lease-reclaim.md | 77 ++++++ docs/architecture/internals.md | 24 ++ .../ADR-001-job-reaper-and-lease-reclaim.md | 260 ++++++++++++++++++ .../ORCH-065/07-infra-requirements.md | 42 +++ .../ORCH-065/08-data-requirements.md | 29 ++ docs/work-items/ORCH-065/10-tech-risks.md | 22 ++ 8 files changed, 507 insertions(+), 4 deletions(-) create mode 100644 docs/architecture/adr/adr-0011-job-reaper-lease-reclaim.md create mode 100644 docs/work-items/ORCH-065/06-adr/ADR-001-job-reaper-and-lease-reclaim.md create mode 100644 docs/work-items/ORCH-065/07-infra-requirements.md create mode 100644 docs/work-items/ORCH-065/08-data-requirements.md create mode 100644 docs/work-items/ORCH-065/10-tech-risks.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 4ae2094..559e7dc 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -11,6 +11,7 @@ - **Quality Gates** (`src/qg/checks.py`) — проверки выхода со стадии, реестр `QG_CHECKS`. - **Agent Launcher** (`src/agents/launcher.py`) — запуск Claude CLI агентов в изолированном git worktree, мониторинг, auto-advance. - **Queue** (`src/queue_worker.py`, ORCH-1) — персистентная очередь задач (SQLite `jobs`), atomic claim, max_concurrency, ретраи, restart-safe. +- **Job-reaper** (`src/job_reaper.py`, ORCH-065 — [adr-0011](adr/adr-0011-job-reaper-lease-reclaim.md)) — фоновый daemon-поток (каркас `reconciler`), стартует/останавливается в `main.lifespan` (после `reconciler.start()` / перед `worker.stop()`). Детектирует «мёртвый» `running`-job **без рестарта** процесса (Tier-1 мёртвый `jobs.pid` после `reaper_dead_ticks` тиков; Tier-2 `agent_runs.exit_code` записан, а job ещё `running`; Tier-3 backstop `reaper_max_running_s`) и приводит строку к корректному статусу через те же контракты (`_try_advance_stage`/`_finalize_job`, gate-driven; exit≠0/неизвестно → `attempts max agent_timeout+grace). Действие + переиспользует контракты: exit0 → **gate-driven idempotent advance** + (`_try_advance_stage`+`_finalize_job`, источник истины — канонический QG, не + факт «exit0»; нет дубль-перехода); exit≠0/неизвестно → `attempts`. @@ -233,7 +281,7 @@ never-raise на единицу работы; тишина при синхрон |--------|------|----------| | GET | `/health` | health check | | GET | `/status` | активные задачи (stage != done) | -| GET | `/queue` | очередь: counts + max_concurrency + resilience + reconcile (ORCH-053) + post_deploy (ORCH-021) + последние jobs | +| GET | `/queue` | очередь: counts + max_concurrency + resilience + reconcile (ORCH-053) + reaper (ORCH-065) + post_deploy (ORCH-021) + последние jobs | | POST | `/webhook/plane` | Plane webhook | | POST | `/webhook/gitea` | Gitea webhook (push, PR, CI status) | @@ -247,4 +295,4 @@ never-raise на единицу работы; тишина при синхрон Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.md). --- -*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата).* +*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — design, ветка feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py; флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест).* diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md index 76ffba8..92d4781 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -16,11 +16,12 @@ Per-work-item решения живут в `docs/work-items//06-adr/ADR-NNN- | adr-0008 | Провенанс staging-образа перед BUILD-ONCE retag | accepted | 2026-06-06 | ORCH-058 | | adr-0009 | Толерантность staging-вердикта к инфраструктурным FAIL | accepted | 2026-06-07 | ORCH-061 | | adr-0010 | Post-deploy мониторинг прода + реакция на деградацию | proposed | 2026-06-07 | ORCH-021 | +| adr-0011 | Job-reaper + проактивный реклейм merge-lease | accepted | 2026-06-07 | ORCH-065 | > ⚠️ Историческая коллизия: номер `0007` занят двумя файлами — > `adr-0007-reconciler.md` (ORCH-053) и `adr-0007-executable-self-deploy.md` > (ORCH-036). Оба accepted; для новых сквозных ADR использовать следующий -> свободный номер (текущий максимум — `0010`). +> свободный номер (текущий максимум — `0011`). ## Формат **Контекст → Решение → Альтернативы → Последствия → Связи.** Статус: proposed / accepted / superseded. diff --git a/docs/architecture/adr/adr-0011-job-reaper-lease-reclaim.md b/docs/architecture/adr/adr-0011-job-reaper-lease-reclaim.md new file mode 100644 index 0000000..44b1037 --- /dev/null +++ b/docs/architecture/adr/adr-0011-job-reaper-lease-reclaim.md @@ -0,0 +1,77 @@ +# 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-.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`; Tier-3 + backstop по потолку `reaper_max_running_s`. Действие переиспользует контракты: + exit0 → gate-driven idempotent advance (`_try_advance_stage`+`_finalize_job`, + источник истины — QG, не «exit0»); exit≠0 / неизвестно → `attempts= + max_concurrency`) → встаёт конвейер ВСЕХ проектов. Доказано: jobs 236/239/242/254 + (07.06). +- **B — залипший merge-lease.** Файловый lease `.merge-lease-.json` + (ORCH-043) реклеймится **лениво и только по возрасту** (`age >= + merge_lock_timeout_s`) и **только** в момент `acquire_merge_lease` другой + задачей. Liveness держателя по pid не проверяется, хотя pid в lease пишется. + Смерть с зажатым lease блокирует чужие merge до истечения TTL. +- **C — неидемпотентная финализация merge.** Если rebase+re-test зелёные, но + процесс умер до фактического merge PR — повторного докатывания нет; дорогая + работа (rebase+re-test) сделана, а задача висит. + +Факты кода, на которых строится решение: +- `_spawn` (launcher.py:401) создаёт `subprocess.Popen(["bash","-c",cmd])`; + `proc.pid` — pid агентского процесса, дочернего к процессу оркестратора в ОДНОМ + pid-namespace контейнера. Сейчас `jobs.pid` НЕ хранится. +- `_monitor_agent` (launcher.py:541) порядок: `proc.wait()` → запись + `agent_runs.finished_at/exit_code` → git commit/push (+PR) → БАГ-8 deployer + rollback → usage-комменты → `_try_advance_stage` (exit0, gate-driven advance) + → `_finalize_job` (драйв статуса job по контракту attempts/transient). +- `claim_next_job` (db.py:454) — атомарный claim через `UPDATE ... WHERE id=? AND + status='queued'` + `rowcount` (образец атомарности). +- `reconciler.py` — образец фонового daemon-потока (never-raise, `_stop`-Event, + старт/стоп в `main.lifespan`, снимок в `/queue`, kill-switch). +- `merge_gate.py`: lease пишет `pid=os.getpid()` (pid процесса оркестратора, НЕ + агента), `acquired_at`; `release_merge_lease` уже holder-aware (по `branch`) и + идемпотентен; `acquire_merge_lease` идемпотентен для «held by self» (по branch). +- `is_self_hosting_repo` / `merge_gate_repos` — образец условности (ORCH-35/43). + +## Решение + +### Р-1. Job-reaper — отдельный daemon-поток `src/job_reaper.py` + +Reaper — **новый модуль и отдельный daemon-поток** (НЕ расширение reconciler). +Обоснование: reconciler работает на уровне **stage-transition** (источник истины — +гейт/Plane); reaper работает на уровне **jobs/agent_runs** (источник истины — +liveness процесса). Это разные never-raise-домены и разные kill-switch'и; слияние +в один тик смешало бы ответственности. Reaper копирует проверенный каркас +`Reconciler`: `threading.Thread(daemon=True)` + `threading.Event`, старт/стоп в +`main.lifespan`, снимок в `/queue`, per-job изоляция исключений. + +**Liveness — трёхуровневая (defense in depth):** + +1. **Tier-1 (liveness, основной): мёртвый pid.** Добавляем колонку `jobs.pid` + (см. Р-4). В `_spawn` рядом с `run_id`/`started_at` пишем `proc.pid`. Reaper: + `pid_alive(pid)` = `os.kill(pid, 0)` с обработкой `ProcessLookupError` (мёртв) + / `PermissionError` (жив, чужой) — единственный сигнал, ловящий «monitor умер + ДО записи `finished_at`». +2. **Tier-2 (completion race): exit_code записан, job ещё `running`.** Если у + `agent_runs[run_id]` есть `finished_at`/`exit_code`, а `jobs.status='running'` + — monitor умер между записью exit_code и `_finalize_job`. Здесь исход **известен**. +3. **Tier-3 (backstop по потолку):** job висит `running` дольше + `reaper_max_running_s` (заведомо > max `agent_timeout`+grace). Реап даже когда + liveness определить нельзя (pid переиспользован/неизвестен). + +**Анти-ложноположительность (FR-1.3, AC-3):** по Tier-1 job реапится только после +`reaper_dead_ticks` (≥2) ПОДРЯД тиков мёртвого pid — in-memory streak-счётчик по +`job_id` (best-effort, сбрасывается на рестарте — но рестарт покрыт стартовым +`requeue_running_jobs`). Tier-3 — одношаговый (порог времени, streak не нужен). +Живой агент в пределах своего `agent_timeout` НЕ реапится никогда (pid жив + не +превышен потолок). + +**Действие при подтверждённой смерти (FR-1.2, AC-4) — переиспользование +существующих контрактов, без дублирования:** + +- **Атомарный reap-claim.** Перед любым действием с побочными эффектами reaper + атомарно «застолбляет» строку тем же приёмом, что `claim_next_job`: терминальный + flip несёт guard `WHERE id=? AND status='running'` и проверяет `rowcount`. При + гонке (поздно доехавший monitor, стартовый `requeue_running_jobs`) проигравший + видит `rowcount==0` и НЕ обрабатывает строку повторно (AC-5). +- **Исход известен (Tier-2, exit_code в `agent_runs`):** маршрутизируем через + существующий `launcher._finalize_job(job_id, agent, run_id, exit_code, + output_path)`: + - `exit==0`: **gate-driven idempotent advance.** Сначала проверяем, не + продвинулась ли уже стадия (текущая `tasks.stage` ≠ исходная стадия агента + или активного job нет и гейт уже пройден) → если да, просто `mark_job(done)` + (идемпотентная уборка, без дубль-перехода). Если нет — `_try_advance_stage` + (он сам гоняет канонический QG: артефакт/PR есть → зелёный гейт → advance; + нет → красный гейт → НЕ advance), затем `_finalize_job`. **Источник истины — + гейт, не «exit0»** — это исключает ложный `done` без реально выполненной + работы (если monitor умер ДО git-push, артефакта нет → гейт красный → + переходим к ветке «исход неуспешен» ниже). + - `exit!=0`: ровно существующий контракт `_finalize_job` (классификация + transient/permanent, `attempts bool` (never-raise; ошибка/`PermissionError` → считаем + «жив», т.е. консервативно НЕ реклеймим — безопаснее не трогать). +- `reclaim_stale_lease(repo) -> bool` — для репо из области (см. ниже): прочитать + lease; освободить (`release_merge_lease(repo, branch=holder_branch)` — + holder-aware), если держатель **мёртв** (`pid` из lease не жив) ИЛИ **просрочен** + (`age >= merge_lock_timeout_s`). Живой держатель в пределах TTL — НЕ трогать + (AC-8, защита легитимного merge). Каждый реклейм → `logger.warning` + + Telegram. + +**Точки вызова (FR-2.1):** +- на старте — в `lifespan` рядом с `requeue_running_jobs()`; +- периодически — из тика reaper (один общий фоновый поток на оба механизма A и B). + +**Условность (FR-2.4, AC-9):** реально только для `merge_gate_repos`/self-hosting +(тот же предикат, что merge-gate); прочие репо — no-op. Kill-switch +`lease_reclaim_enabled` (=false → остаётся лишь прежний ленивый TTL-реклейм в +`acquire_merge_lease`). Контракт **never-raise**: ошибка реклейма логируется и не +валит поток. + +**pid-семантика lease:** lease пишет pid процесса ОРКЕСТРАТОРА (`os.getpid()`). +После рестарта контейнера старый pid мёртв → детектируется. Риск pid-reuse +(контейнер мог переиспользовать номер pid) закрыт тем, что реклейм срабатывает по +**ИЛИ** (pid мёртв **ИЛИ** TTL истёк): даже при ложном «жив» TTL добьёт lease +(контракт ORCH-043 сохранён). См. 10-tech-risks. + +### Р-3. Идемпотентная финализация merge (Проблема C) — re-drive + guard, без новой merge-логики + +Per FR-3.3 — НЕ создаём дублирующую merge-логику. Восстановление обеспечивается +**штатными путями**: +- reaper доводит зомби-job до `queued` → стадия `deploy-staging`/`deploy` + переисполняется и снова проходит `check_branch_mergeable` (merge-gate), ЛИБО + reconciler доигрывает переход по зелёному гейту; +- дорогие шаги не повторяются «вхолостую»: `branch_is_behind_main == False` → этап + rebase+re-test пропускается (ветка уже догнана); +- lease при повторе берётся заново — `acquire_merge_lease` уже идемпотентен для + «held by self» по branch (FR-3.4). + +**Идемпотентность у самого merge (FR-3.2, AC-11):** добавляем детерминированный +never-raise guard `pr_already_merged(repo, branch) -> bool` (переиспользует +существующий Gitea-клиент; запрос состояния PR). Путь слияния (deployer/merge) +консультируется с этим guard ПЕРЕД повторным merge: PR уже слит → no-op (без +второго merge и без ошибки). Это единственная новая «merge-related» функция — она +не сливает, а лишь читает состояние, поэтому не нарушает «no duplicate merge +logic». + +### Р-4. Изменение схемы БД — `jobs.pid INTEGER` (lightweight migration) + +Колонка добавляется идемпотентно через существующий `_ensure_column(conn, "jobs", +"pid", "INTEGER")` в `init_db` (паттерн уже применяется к `jobs.transient_attempts` +/ `jobs.available_at` — безопасно на live prod DB). `pid` проставляется в `_spawn` +рядом с `run_id`/`started_at`. **Альтернатива без миграции отвергнута** (см. +Альтернативы): только по `agent_runs.finished_at/exit_code` нельзя поймать +зомби, у которого monitor умер ДО записи exit_code — а это и есть основной класс +инцидента. `STAGE_TRANSITIONS`, `QG_CHECKS`, схема `agent_runs`, файл-схема lease — +без изменений. + +### Р-5. Конфигурация (`src/config.py`, env `ORCH_*`) + +| Настройка | Назначение | Дефолт | +|-----------|-----------|--------| +| `reaper_enabled` | глобальный kill-switch job-reaper | `True` | +| `reaper_interval_s` | период сканирования | `60` | +| `reaper_dead_ticks` | подряд тиков мёртвого pid перед реапом (Tier-1) | `2` | +| `reaper_max_running_s` | потолок `running` (Tier-3 backstop), > max agent_timeout+grace | `3600` | +| `lease_reclaim_enabled` | kill-switch проактивного реклейма lease | `True` | +| (reuse) `merge_lock_timeout_s` | TTL lease | `300` | +| (reuse) `merge_gate_repos` | область применения lease-reclaim | как есть | + +`false` → строго прежнее поведение (AC-14). + +### Р-6. Наблюдаемость (`GET /queue`) + +Блок `reaper` (образец `reconcile`/`post_deploy`): `enabled`, `interval`, +`last_run_ts`, `reaped_total`, `last_reaped` (`{job_id, agent, outcome}`), +`lease_reclaimed_total`. Каждый reap и lease-reclaim → `logger.warning` с +идентификаторами (`job_id`, `run_id`, `pid`, `repo`, `branch`). reap→`failed` и +lease-reclaim → Telegram (AC-16). + +### Р-7. Lifespan (`src/main.py`) + +Старт (после существующего `requeue_running_jobs()`): +``` +init_db() # + _ensure_column(jobs, pid) +... orphan-recovery (M-1) ... +requeue_running_jobs() ++ startup lease-reclaim # reclaim_stale_lease для merge_gate_repos +worker.start() +reconciler.start() ++ reaper.start() # НОВЫЙ daemon-поток (A + периодический B) +``` +Стоп (reverse): `reaper.stop()` → `reconciler.stop()` → `worker.stop()`. + +## Альтернативы + +- **Reaper как часть reconciler** — отвергнуто: смешивает stage-уровень и + jobs-уровень, два разных kill-switch'а в одном тике, хуже изоляция отказов. +- **Без `jobs.pid`, только эвристика `agent_runs` + потолок** — отвергнуто как + основной механизм: не ловит зомби, чей monitor умер ДО записи `exit_code` + (главный класс инцидента). Эвристика оставлена как Tier-2/Tier-3 поверх pid. +- **БД-lock вместо файлового lease / внешний брокер очередей** — вне объёма + (BRD §4), несоразмерно для single-node SQLite. +- **Реаниматор фабрикует `exit0` и форсит `done`** — отвергнуто: ложный `done` + без реальной работы (если git-push не случился). Выбран gate-driven advance: + источник истины — канонический QG, не предположение об успехе. +- **Новый статус `reaping` в enum `jobs.status`** — отвергнуто: меняет контракт + статусов; атомарного guard `WHERE status='running'` достаточно. + +## Последствия + +**Плюсы:** +- Зомби-job самовосстанавливается БЕЗ рестарта процесса → очередь не встаёт + (групповой риск снят для всех проектов общего инстанса). +- Залипший lease освобождается проактивно (старт + период), не дожидаясь TTL и + чужого acquire. +- Незавершённый merge докатывается штатным путём, идемпотентно; ручные шаги + оператора устранены → снят технический блокер ORCH-54. +- Контракты неизменны (`STAGE_TRANSITIONS`, `QG_CHECKS`, `check_*`, БАГ-8, + exit-коды хука); один новый столбец через проверенный idempotent-паттерн. + +**Минусы / ограничения:** +- pid-liveness валиден в предположении ОДНОГО pid-namespace (агент — дочерний + процесс оркестратора в том же контейнере). Multi-container/namespaced pid → + pid-liveness ненадёжен; закрыто backstop'ом по времени и TTL. См. 10-tech-risks. +- streak-счётчик in-memory best-effort: после рестарта он сбрасывается, но + стартовый `requeue_running_jobs` покрывает рестарт-сценарий. +- Tier-3 backstop консервативен (потолок > max timeout); очень долгий легитимный + агент (близко к потолку) теоретически может быть реапнут — потолок выбран с + большим запасом, чтобы этого не случалось (AC-3). + +## Инварианты (НЕ нарушать) +- Reaper/lease-reclaim НЕ рестартят/не роняют прод-контейнер `orchestrator` и НЕ + инициируют git-push в `main` (AC-12). Реклейм lease — только удаление + файла-lease, без git-операций. +- `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, сигнатуры/поведение `check_*` (в т.ч. + `check_branch_mergeable`), БАГ-8 откат, exit-коды deploy-хука — без изменений; + новых QG checks/стадий нет (AC-13). +- never-raise на единицу фоновой работы; идемпотентность (атомарный guard + + gate-driven advance); restart-safe; тишина при отсутствии аномалий. +- Анти-ложноположительность (FR-1.3): живой долгий агент не реапится. + +## Связи +- Базируется на: ORCH-1 (очередь, adr-0002), ORCH-043 (merge-gate, adr-0006), + ORCH-053 (reconciler-паттерн, adr-0007), ORCH-36 (self-deploy, adr-0007). +- Сквозной ADR: adr-0011. +- Разблокирует: ORCH-54 (полностью автономный self-deploy). diff --git a/docs/work-items/ORCH-065/07-infra-requirements.md b/docs/work-items/ORCH-065/07-infra-requirements.md new file mode 100644 index 0000000..e7bacac --- /dev/null +++ b/docs/work-items/ORCH-065/07-infra-requirements.md @@ -0,0 +1,42 @@ +# 07 — Требования к инфраструктуре (ORCH-065) + +## Топология +**Без изменений.** Новых контейнеров, портов, сетевых сервисов, внешних +зависимостей нет. Job-reaper — ещё один фоновый daemon-поток ВНУТРИ существующего +процесса оркестратора (как `queue_worker` и `reconciler`), стартует/останавливается +в `main.lifespan`. Деплой/рестарт прод-контейнера в рамках задачи НЕ требуется и +ЗАПРЕЩЁН (self-hosting safety) — выкатка через штатный `deploy-staging → deploy`. + +## Допущение pid-namespace (важно для liveness-детекции) +- Агент запускается как `subprocess.Popen(["bash","-c",cmd])` — **дочерний + процесс оркестратора в ТОМ ЖЕ pid-namespace** (один контейнер). Значит + `os.kill(jobs.pid, 0)` корректно отражает liveness агента, пока жив сам + оркестратор. Это инвариант текущей упаковки (один контейнер на инстанс). +- Lease пишет `pid = os.getpid()` — pid ПРОЦЕССА ОРКЕСТРАТОРА. После рестарта + контейнера старый pid мёртв → детектируется. Риск переиспользования номера pid + новым процессом закрыт условием «pid мёртв **ИЛИ** TTL истёк»: TTL добивает + lease в любом случае (контракт ORCH-043 сохранён). +- **Если в будущем агенты переедут в отдельные контейнеры/namespace** — Tier-1 + pid-liveness станет ненадёжной; тогда полагаемся на Tier-2 (exit_code) и Tier-3 + (потолок `reaper_max_running_s`). Зафиксировано в 10-tech-risks. + +## Поведение при self-restart (ORCH-36 executable self-deploy) +Self-restart прод-контейнера во время `deploy` — ровно тот сценарий, что плодит +зомби: monitor-поток умирает вместе со старым контейнером. После рестарта: +1. стартовый `requeue_running_jobs()` + стартовый `reclaim_stale_lease` чистят + состояние, оставшееся от убитого процесса; +2. периодический reaper добивает то, что возникнет позже без рестарта. +Reaper/lease-reclaim сами НИКОГДА не рестартят и не роняют прод-контейнер и не +делают git-push в `main` (AC-12). + +## Эксплуатационные ручки (env, хост `.env`/`.env.staging`) +`ORCH_REAPER_ENABLED`, `ORCH_REAPER_INTERVAL_S`, `ORCH_REAPER_DEAD_TICKS`, +`ORCH_REAPER_MAX_RUNNING_S`, `ORCH_LEASE_RECLAIM_ENABLED`; переиспользуются +`ORCH_MERGE_LOCK_TIMEOUT_S`, `ORCH_MERGE_GATE_REPOS`. Все флаги документируются в +`.env.example` (developer-стадия). Полное отключение (`false`) → строго прежнее +поведение. + +## Документация эксплуатации +`docs/operations/INFRA.md` — добавить (best-effort, developer/PR) короткое +упоминание поведения reaper/lease-reclaim при self-restart. Топологическая карта +INFRA.md не меняется. diff --git a/docs/work-items/ORCH-065/08-data-requirements.md b/docs/work-items/ORCH-065/08-data-requirements.md new file mode 100644 index 0000000..90201ff --- /dev/null +++ b/docs/work-items/ORCH-065/08-data-requirements.md @@ -0,0 +1,29 @@ +# 08 — Требования к данным (ORCH-065) + +## Изменение схемы: `jobs.pid` + +| Поле | Значение | +|------|----------| +| Таблица | `jobs` | +| Колонка | `pid` | +| Тип | `INTEGER` (nullable, без DEFAULT) | +| Назначение | pid агентского процесса (`subprocess.Popen.pid` из `launcher._spawn`) для liveness-детекции зомби job-reaper'ом (Tier-1) | +| Механизм миграции | `_ensure_column(conn, "jobs", "pid", "INTEGER")` в `db.init_db` — идемпотентно, no-op если колонка уже есть | +| Безопасность на live prod DB | ДА. Тот же паттерn уже применён к `jobs.transient_attempts`, `jobs.available_at`, `events.delivery_id`, `agent_runs.*`. `ALTER TABLE ADD COLUMN` в SQLite — мгновенная метаданная-операция, не блокирует и не переписывает строки | +| Заполнение | в `_spawn` рядом с существующим `UPDATE jobs SET run_id=?, started_at=datetime('now') WHERE id=?` добавить `pid=?` (`proc.pid`). Старые строки остаются `pid IS NULL` → для них Tier-1 неприменим, работают Tier-2/Tier-3 | + +## Что НЕ меняется +- `STAGE_TRANSITIONS`, реестр `QG_CHECKS` — без изменений (это контракты). +- Схема `agent_runs` — без изменений (`finished_at`/`exit_code` уже есть — основа Tier-2). +- Файл-схема merge-lease `.merge-lease-.json` — без изменений (`pid`, + `acquired_at`, `branch`, `work_item_id`, `task_id` уже пишутся + `acquire_merge_lease`). +- `jobs.status` enum (`queued|running|done|failed`) — без изменений; новый статус + `reaping` НЕ вводится (атомарного guard `WHERE status='running'` достаточно). + +## Совместимость / откат +- Откат миграции не требуется: лишняя nullable-колонка безвредна при + `reaper_enabled=false`. +- `pid IS NULL` (строки до миграции, или если запись pid не успела) → reaper не + делает Tier-1, опирается на Tier-2 (exit_code) и Tier-3 (потолок). Поведение + деградирует gracefully, ложноположительных реапов не возникает. diff --git a/docs/work-items/ORCH-065/10-tech-risks.md b/docs/work-items/ORCH-065/10-tech-risks.md new file mode 100644 index 0000000..87b3b30 --- /dev/null +++ b/docs/work-items/ORCH-065/10-tech-risks.md @@ -0,0 +1,22 @@ +# 10 — Технические риски (ORCH-065) + +| # | Риск | Вероятн. | Влияние | Митигация | +|---|------|----------|---------|-----------| +| R-1 | **Ложноположительный реап живого долгого агента** (AC-3). Reaper помечает зомби работающий агент → потеря работы, дубль-запуск. | Сред. | Высокое | Tier-1 требует `reaper_dead_ticks`(≥2) подряд тиков мёртвого pid; живой pid = `os.kill(pid,0)` без `ProcessLookupError`. Tier-3 потолок `reaper_max_running_s` выбирается заведомо > max `agent_timeout`+grace. Юнит-тест TC-02/TC-03. | +| R-2 | **Ложный `done` без выполненной работы.** Reaper при exit0-зомби помечает job done, хотя git-push/advance не случились (monitor умер до них). | Сред. | Высокое | Реап exit0 НЕ форсит done напрямую — идёт через **gate-driven** `_try_advance_stage`: канонический QG проверяет наличие артефакта/PR; нет артефакта → красный гейт → НЕ advance → ветка «исход неуспешен» (requeue). Источник истины — гейт, не «exit0». | +| R-3 | **pid-reuse / namespace.** Номер pid переиспользован новым процессом → ложное «жив» (lease не реклеймится; зомби-job не реапится по Tier-1). | Низк. | Сред. | Lease: условие «pid мёртв **ИЛИ** TTL истёк» — TTL добивает в любом случае. Job-reaper: Tier-3 backstop по времени ловит то, что Tier-1 пропустил. Допущение «один pid-namespace» зафиксировано в 07-infra. | +| R-4 | **Гонка reaper vs поздно доехавший monitor / стартовый `requeue_running_jobs`** → двойная обработка строки. | Сред. | Сред. | Атомарный reap-claim `UPDATE ... WHERE id=? AND status='running'` + проверка `rowcount` (образец `claim_next_job`). Reaper стартует ПОСЛЕ `requeue_running_jobs` в lifespan. Юнит-тест TC-06. | +| R-5 | **Реклейм живого lease** → параллельный конфликтный merge, риск красного `main` self-hosting. | Низк. | Высокое | `reclaim_stale_lease` освобождает ТОЛЬКО при «держатель мёртв ИЛИ TTL истёк»; живой держатель в пределах TTL не трогается. holder-aware `release_merge_lease(repo, branch)`. Юнит-тест TC-12. | +| R-6 | **Реклейм инициирует git-операцию / трогает прод-контейнер** (нарушение self-hosting safety, AC-12). | Низк. | Высокое | Реклейм = только удаление файла-lease (`os.remove`), без git. Reaper не вызывает деплой-хук/рестарт. Явный инвариант в ADR + тест/ревью. | +| R-7 | **Идемпотентность merge не достигнута**: повторный проход стадии делает второй merge уже слитого PR. | Сред. | Сред. | never-raise guard `pr_already_merged(repo,branch)` (читает состояние PR) консультируется перед merge → уже слит = no-op. `branch_is_behind_main==False` пропускает rebase+re-test. Юнит-тест TC-16, интеграция TC-17. | +| R-8 | **streak-счётчик in-memory теряется при рестарте** → задержка реапа или сброс прогресса. | Низк. | Низкое | Рестарт-сценарий покрыт стартовым `requeue_running_jobs` (мгновенно чистит running). Периодический reaper нужен лишь для зомби БЕЗ рестарта; сброс счётчика лишь переоткладывает реап на `reaper_dead_ticks` тиков. | +| R-9 | **never-raise нарушен** — необработанное исключение валит daemon-поток reaper → защита тихо отключается. | Низк. | Сред. | Per-job изоляция `try/except` (образец `reconciler.reconcile_gate_once`) + внешний `try/except` в `_run`. Юнит-тест TC-08/TC-14. | +| R-10 | **Регресс существующих тестов** merge_gate/queue/reconciler/deploy. | Низк. | Сред. | Контракты неизменны (`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/exit-коды хука); только новая колонка + новый поток + флаги (дефолт сохраняет поведение). Полный прогон `pytest tests/ -q` (regression в 04-test-plan). | + +## Открытые вопросы / follow-up +- **Полная автоматизация merge-финализации.** Если деплой-merge (deployer/ORCH-36 + detached host-process) окажется не полностью идемпотентным к повторному проходу, + может понадобиться доп. работа поверх `pr_already_merged`. Здесь закрываем + технический блокер; полный авто-approve деплоя — ORCH-54. +- Допущение «агенты — дочерние процессы в одном pid-namespace» (R-3) должно быть + пересмотрено, если упаковка агентов изменится (отдельные контейнеры). From 4bebb921ff358b031f2b04238ddc46c84060a66c Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 15:31:37 +0000 Subject: [PATCH 04/10] feat(reaper): job-reaper + stale merge-lease reclaim + idempotent merge finalization Closes the "zombie jobs" incident class: job status was set only inside the live launcher process, so a process death left jobs.status='running' forever; at max_concurrency=1 one zombie blocked ALL projects' queue (self-hosting risk). Adds a background daemon (src/job_reaper.py) with three-tier liveness (dead-pid streak / known exit_code / max-running backstop) whose only mutating write is an atomic terminal flip guarded by WHERE status='running' (no double-process). For exit0 the canonical QG is the source of truth via gate-driven advance, not "exit0". Also proactively reclaims stale merge-lease (dead pid OR TTL) via file delete only (no git ops), and makes merge finalization idempotent (pr_already_merged guard + up-to-date short-circuit on re-drive). New jobs.pid column via idempotent _ensure_column (no migration); pid stamped in launcher._spawn after Popen. Reaper start/stop in lifespan; "reaper" snapshot in GET /queue. Kill-switches: ORCH_REAPER_ENABLED, ORCH_REAPER_INTERVAL_S, ORCH_REAPER_DEAD_TICKS, ORCH_REAPER_MAX_RUNNING_S, ORCH_LEASE_RECLAIM_ENABLED. Invariants unchanged (AC-13): STAGE_TRANSITIONS, QG_CHECKS registry, check_branch_mergeable signature/behaviour, BUG-8 rollback, hook exit codes. restart-safe, never-raise per unit of background work. Docs: docs/architecture/README.md, CHANGELOG.md, .env.example. Tests: tests/test_job_reaper.py, tests/test_merge_lease_reclaim.py, tests/test_merge_gate.py (TC-16), tests/test_merge_gate_race.py (TC-17), tests/test_queue.py, tests/test_config.py (TC-19/TC-20). 742 passed. Refs: ORCH-065 Co-Authored-By: Claude Opus 4.7 --- .env.example | 22 ++ CHANGELOG.md | 1 + docs/architecture/README.md | 2 +- src/agents/launcher.py | 8 + src/config.py | 27 +++ src/db.py | 75 ++++++ src/job_reaper.py | 370 ++++++++++++++++++++++++++++++ src/main.py | 32 ++- src/merge_gate.py | 138 +++++++++++ tests/test_config.py | 79 +++++++ tests/test_job_reaper.py | 285 +++++++++++++++++++++++ tests/test_merge_gate.py | 54 +++++ tests/test_merge_gate_race.py | 60 +++++ tests/test_merge_lease_reclaim.py | 138 +++++++++++ tests/test_queue.py | 55 +++++ 15 files changed, 1341 insertions(+), 5 deletions(-) create mode 100644 src/job_reaper.py create mode 100644 tests/test_job_reaper.py create mode 100644 tests/test_merge_lease_reclaim.py diff --git a/.env.example b/.env.example index 9a74109..dc9e36b 100644 --- a/.env.example +++ b/.env.example @@ -117,6 +117,28 @@ ORCH_RECONCILE_GRACE_OVERRIDES_JSON= ORCH_RECONCILE_NOTIFY_UNBLOCK=true ORCH_RECONCILE_SKIP_BLOCKED_ENABLED=true +# ORCH-065: job-reaper + proactive merge-lease reclaim. A background daemon thread +# (src/job_reaper.py, started LAST in main.lifespan after requeue_running_jobs) reaps +# zombie 'running' jobs whose monitor/process died before writing the terminal status +# (one zombie at max_concurrency=1 blocks the whole shared queue) and periodically +# reclaims dead/stale merge-leases. Liveness is three-tier: Tier-1 dead jobs.pid +# (os.kill(pid,0)) after REAPER_DEAD_TICKS consecutive dead ticks (anti-false-positive +# for a live agent); Tier-2 agent_runs.exit_code recorded but job still 'running'; +# Tier-3 backstop after REAPER_MAX_RUNNING_S. The terminal flip carries an atomic +# status='running' guard so it never double-processes a row racing requeue_running_jobs. +# REAPER_ENABLED -> global kill-switch (false -> strictly prior behaviour). +# REAPER_INTERVAL_S -> background scan period (seconds). +# REAPER_DEAD_TICKS -> consecutive dead-pid ticks before reaping (Tier-1, >=2). +# REAPER_MAX_RUNNING_S -> Tier-3 backstop ceiling; must exceed max agent_timeout+grace. +# LEASE_RECLAIM_ENABLED -> kill-switch for the proactive stale/dead lease reclaim +# (false -> only the legacy lazy TTL reclaim in acquire_merge_lease). +# (reuse) ORCH_MERGE_LOCK_TIMEOUT_S -> lease TTL; ORCH_MERGE_GATE_REPOS -> reclaim scope. +ORCH_REAPER_ENABLED=true +ORCH_REAPER_INTERVAL_S=60 +ORCH_REAPER_DEAD_TICKS=2 +ORCH_REAPER_MAX_RUNNING_S=3600 +ORCH_LEASE_RECLAIM_ENABLED=true + # ORCH-021: post-deploy production monitoring + degradation reaction. After the # terminal deploy->done transition for an applicable repo, a reserved-agent job # `post-deploy-monitor` (no LLM, modelled on deploy-finalizer) probes prod over a diff --git a/CHANGELOG.md b/CHANGELOG.md index e3f1d8b..e814af7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ## [Unreleased] ### Added +- **Job-reaper + проактивный реклейм протухшего merge-lease + идемпотентная финализация merge** (ORCH-065): закрыт класс инцидентов «zombie jobs» — статус job выставлялся ТОЛЬКО в живом процессе launcher'а, поэтому гибель процесса (OOM/рестарт инстанса/segfault Claude-CLI) оставляла строку `jobs.status='running'` навсегда; при `max_concurrency=1` один такой зомби намертво блокировал очередь ВСЕХ проектов (self-hosting: enduro-trails встаёт из-за зомби ORCH-задачи). Плюс два смежных дефекта: застрявший merge-lease (`.merge-lease-.json` реклеймился лишь лениво по TTL при чужом acquire, живость pid-holder'а не проверялась) и неидемпотентная финализация merge (rebase+re-test зелёные, но процесс умер до самого merge → нет повторного проигрывания). Решение — новый фоновый daemon-поток **`src/job_reaper.py`** (контракт «never-raise на единицу работы», паттерн `reconciler`/`queue_worker`): периодический тик (`reaper_interval_s`) сканирует `running`-jobs трёхуровневой проверкой живости (ADR Р-1): **Tier-1** мёртвый pid (`os.kill(pid, 0)` → `ProcessLookupError`) с анти-false-positive порогом `reaper_dead_ticks` подряд-мёртвых тиков (стрик в памяти); **Tier-2** `agent_runs.exit_code` записан, но job всё ещё `running` (исход известен — процесс завершился, но не успел флипнуть статус); **Tier-3** backstop-потолок `reaper_max_running_s`. Единственная мутирующая запись reaper'а — атомарный терминальный флип через `db.reap_running_job(... WHERE status='running')` (rowcount==1 у победителя, проигравший в гонке с `requeue_running_jobs`/launcher видит rowcount==0 — без двойной обработки, TC-06). Для Tier-2 exit0 источник истины — канонический QG (не «exit0»): gate-driven advance (`_gate_driven_advance` → штатный `launcher._try_advance_stage`, кандидат-стадии агента из `STAGE_TRANSITIONS`) проигрывается ПЕРЕД флипом — зелёный гейт → `done`, красный → путь неуспеха (requeue в пределах `attempts post_deploy_5xx_threshold`; иначе `HEALTHY` — одиночный глюк не откатывает), `decide_action` (self-hosting → ВСЕГДА `ALERT_ONLY`; не-self + `post_deploy_auto_rollback=true` → `ROLLBACK`; иначе `ALERT_ONLY`), `map_rollback_exit_code` (`0→ROLLBACK_OK`, иначе `ROLLBACK_FAILED`), sentinel-state хелперы (`armed`/`series`/`done` под `/.post-deploy-state-//`, restart-safe счётчики), `build_rollback_command`/`run_rollback` (ssh-хук `--rollback` с прод-env, синхронно — только для не-self), `build/write_post_deploy_log` (артефакт `16-post-deploy-log.md`), `arm_monitor` (идемпотентный арм + первый отложенный job), `status` (снимок для `/queue`). **Механизм наблюдения — reserved-agent job `post-deploy-monitor`** (детерминированный, no-LLM, калька `deploy-finalizer`, НЕ стадия и НЕ daemon): арм в `stage_engine.advance_stage` в блоке `next_stage == "done"` ПОСЛЕ terminal-sync/release-lease (`post_deploy.arm_monitor`, sentinel `armed` = идемпотентность при двойном webhook/reconciler/finalizer); один тик = один job — перехват в `agents/launcher.launch_job` ДО `_spawn` → `stage_engine.run_post_deploy_monitor` (один опрос → append в `series` → `classify` → перепостановка с задержкой `available_at_delay_s` ИЛИ реакция+артефакт+`mark_done`); бюджет тиков `window_s/interval_s` (анти-livelock). **Self-hosting safety (BR-5):** для `orchestrator` тик НИКОГДА не откатывает/рестартит прод-контейнер — реакция всегда `ALERT_ONLY` (громкий Telegram + Plane-коммент с запросом ручного approve); авто-rollback хуком `--rollback` — только для не-self репо при `post_deploy_auto_rollback=true` (целевой контейнер ≠ orchestrator). Наблюдаемость — блок `post_deploy` в `GET /queue` (enabled/window/interval/активные наблюдения). Артефакт `16-post-deploy-log.md` (YAML-frontmatter `post_deploy_status`/`action_taken`/`window_s`/`checks_total`/`checks_failed`) — машиночитаемо для петли уроков ORCH-8; best-effort. Новые настройки: `ORCH_POST_DEPLOY_MONITOR_ENABLED` (true, kill-switch), `ORCH_POST_DEPLOY_REPOS` (CSV; пусто → только self-hosting), `ORCH_POST_DEPLOY_WINDOW_S` (900), `ORCH_POST_DEPLOY_INTERVAL_S` (30), `ORCH_POST_DEPLOY_FAIL_THRESHOLD` (3), `ORCH_POST_DEPLOY_5XX_THRESHOLD` (0.5), `ORCH_POST_DEPLOY_AUTO_ROLLBACK` (false), `ORCH_POST_DEPLOY_BASE_URL` (http://localhost:8500); параметры отката переиспользуют `deploy_prod_*`. Инварианты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status`, terminal-sync `deploy→done`, merge-gate, exit-код-контракт хука (0/1/2), схема БД (без миграций; состояние — sentinel-файлы). Условность как ORCH-35/36/43/58. ADR `docs/work-items/ORCH-021/06-adr/ADR-001-post-deploy-monitor.md`, глобальный `docs/architecture/adr/adr-0010-post-deploy-monitor.md`. Тесты: `tests/test_post_deploy.py`, `tests/test_post_deploy_integration.py`. - **Провенанс staging-образа перед BUILD-ONCE retag в прод (свежесть артефакта, INV-FRESH)** (ORCH-058): BUILD-ONCE retag (ORCH-036) промоутит staging-образ (`orchestrator-orchestrator-staging`) в прод **без rebuild**, полагаясь на «образ свеж и провалидирован» — гарантии не было: конвейер нигде не пересобирал staging-образ из провалидированного коммита, поэтому retag мог тихо промоутнуть УСТАРЕВШИЙ образ (инцидент LESSONS_ORCH-036 п.4 — зелёный деплой молча откатывал прод). Закрыто **двумя слоями (defense in depth), только для self-hosting**. Новый модуль `src/image_freshness.py` (контракт «never raise», по образцу `merge_gate`): `provenance_verdict` (чистая функция вердикта match/mismatch/fail-closed), `validated_revision` (`git rev-parse HEAD` в worktree валидированного коммита — единый якорь и для штампа A, и для `EXPECTED_REVISION` B), `image_revision` (OCI-лейбл `org.opencontainers.image.revision` через `docker image inspect`, ``/ошибка → пусто), `rebuild_staging_image` (ssh-хук `--build-staging`), `image_freshness_applies` (условность), `check_staging_image_fresh` (композитный QG). **Strategy A (liveness):** новый детерминированный QG-под-чек `check_staging_image_fresh` (зарегистрирован в `QG_CHECKS`, `src/qg/checks.py`) на ребре `deploy-staging → deploy` ПОСЛЕ merge-gate и ДО Phase A — пересобирает staging-образ из worktree валидированного коммита (хук `--build-staging`, `--build-arg GIT_SHA=`), пересоздаёт 8501 и прогоняет `staging_check.py --mode stub` против свежего 8501 (health + e2e, внутри staging-контейнера через `docker exec` — канон ORCH-048) → валидируем РОВНО тот артефакт (build + e2e), что промоутится в прод (AC-4); FAIL/не-ноль staging_check → откат на `development` (как merge-gate, кап `MAX_DEVELOPER_RETRIES`). `rebuild_staging_image` пробрасывает в хук **явный** staging-таргет (service/port/profile/container), исключая дрейф на прод 8500. Сборки/recreate/validate — **только staging (8501)**, прод (8500) не трогается. **Strategy B (safety):** `Dockerfile` штампует `LABEL org.opencontainers.image.revision=$GIT_SHA` (`ARG GIT_SHA`); `build_deploy_command` (`src/self_deploy.py`) пробрасывает `EXPECTED_REVISION`; хост-хук шагом 2b ПЕРЕД `docker tag` fail-closed сверяет лейбл `revision` у `SOURCE_IMAGE` с `EXPECTED_REVISION` — несовпадение / пустой лейбл / ошибка inspect → `exit 1` (FAILED → БАГ-8 откат), делает тихий промоут устаревшего образа структурно невозможным даже при проигравшей гонку/отключённой A. Хост-хук `scripts/orchestrator-deploy-hook.sh` расширен **обратно-совместимым** режимом `--build-staging` (пересборка+recreate staging, exit 0/1) и fail-closed guard'ом (активен только при заданном `EXPECTED_REVISION`). Единый kill-switch `ORCH_IMAGE_FRESHNESS_ENABLED` (true) включает A+B **как целое** (нет «B без A» = вечного fail-fast); область — `ORCH_IMAGE_FRESHNESS_REPOS` (CSV; пусто → только self-hosting `orchestrator`). Контракты НЕ менялись: `STAGE_TRANSITIONS` (под-гейт ребра, не стадия), exit-code-контракт хука (0/1/2), `map_exit_code_to_status`, `check_deploy_status`/`_parse_deploy_status`, БАГ-8, terminal-sync, merge-gate; схема БД — без миграций. ADR `docs/work-items/ORCH-058/06-adr/ADR-001-staging-image-provenance.md`, глобальный `docs/architecture/adr/adr-0008-staging-image-provenance.md`. Документация: `docs/architecture/README.md`, `docs/operations/DEPLOY_HOOK.md`, `docs/operations/STAGING.md`, `docs/operations/INFRA.md`, `.env.example`. Тесты: `tests/test_image_freshness.py`, `tests/test_deploy_hook_provenance.py`, `tests/test_deploy_build_once.py` (TC-06), `tests/test_deploy_hook_mapping.py` (TC-09), `tests/test_stage_engine.py::TestImageFreshnessGate`, `tests/test_qg_registry_snapshot.py`, `tests/test_config.py`. - **Исполняемый самодеплой стадии `deploy` (стадия дёргает хост-хук, manual-approve)** (ORCH-036): стадия `deploy` перестаёт быть «бумажной» — для self-hosting репозитория `orchestrator` `deploy_status: SUCCESS` означает ДОКАЗАННЫЙ health-ok реального рестарта прод-контейнера (8500), а не декларацию LLM. Критический путь self-restart детерминирован (без LLM), по образцу merge-gate ORCH-043, и разбит на три фазы (`src/stage_engine.py` + новый модуль `src/self_deploy.py`): **Фаза A** (вход в `deploy`) — вместо запуска прод-deployer'а при `deploy_require_manual_approve=true` задача переводится в approval-pending (`set_issue_in_review`) и ждёт ручного approve; restart-safe маркер `approve-requested`. **Фаза B** (человек ставит статус Plane → `Approved`; `advance_stage(deploy, finished_agent=None)`) — запускается **detached host-процесс** (`ssh + setsid` → `scripts/orchestrator-deploy-hook.sh`, чтобы рестарт 8500 пережил гибель контейнера; орк НЕ убивает себя из docker.sock) с build-once retag staging-образа (`SOURCE_IMAGE`), ставится детерминированный **finalizer-job**; маркер `initiated` — идемпотентность повторного Approved. **Фаза C** (`run_deploy_finalizer`, reserved-agent `deploy-finalizer`, claim'ится новым контейнером после рестарта) — читает sentinel `result` (exit-code хука, записан host-обёрткой), `not-ready` → defer (бюджет `deploy_finalize_max_attempts`, restart-safe по `task_content`), маппит `0→SUCCESS / 1|2|иное→FAILED` (чистая функция `map_exit_code_to_status`, unit-тест), пишет `14-deploy-log.md` и вызывает `advance_stage(deploy, finished_agent="deployer")` → существующие контракты: `SUCCESS → done` + release merge-lease, `FAILED → откат БАГ-8 на development` + `set_issue_blocked`. Уведомления Plane+Telegram на approve-request / initiate / success / rollback (BR-5, ни одного «молчаливого» деплоя). Хост-хук `scripts/orchestrator-deploy-hook.sh` расширен **обратно-совместимым** `SOURCE_IMAGE`: при заданном — `docker tag $SOURCE_IMAGE $TARGET_IMAGE` перед `up -d --no-build` (деплой РОВНО протестированного образа, без `docker build`); не задан → прежнее поведение; exit-code-контракт (0/1/2) и health-loop (10×6с, авто-rollback) не тронуты. Restart-safe состояние — sentinel-файлы (`/.deploy-state-//`), без миграции БД. Условность как ORCH-35: реальный самодеплой только для `is_self_hosting_repo("orchestrator")`; прочие репо (enduro-trails) — прежний синхронный ssh-путь агентом. Контракты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status` (frontmatter-only), terminal-sync `deploy→done`, merge-gate (ORCH-43), БАГ-8. Флаг `DEPLOY_REQUIRE_MANUAL_APPROVE` остаётся `true` (полный авто — отдельная задача ORCH-54). Новые настройки: `ORCH_DEPLOY_REQUIRE_MANUAL_APPROVE` (true), `ORCH_DEPLOY_SSH_USER`, `ORCH_DEPLOY_SSH_HOST`, `ORCH_DEPLOY_HOOK_SCRIPT`, `ORCH_DEPLOY_PROD_SOURCE_IMAGE`, `ORCH_DEPLOY_PROD_TARGET_SERVICE/PORT/IMAGE`, `ORCH_DEPLOY_FINALIZE_DELAY_S`, `ORCH_DEPLOY_FINALIZE_MAX_ATTEMPTS`. ADR `docs/work-items/ORCH-036/06-adr/ADR-001-executable-self-deploy.md`, глобальный `docs/architecture/adr/adr-0007-executable-self-deploy.md`. Документация: `.openclaw/agents/deployer.md` (стадия `deploy` = вызов хука, запрет self-restart), `docs/operations/INFRA.md`, `docs/operations/DEPLOY_HOOK.md`. Тесты: `tests/test_deploy_hook_mapping.py`, `tests/test_deploy_approve.py`, `tests/test_deploy_routing.py`, `tests/test_deploy_rollback.py`, `tests/test_deploy_notifications.py`, `tests/test_deploy_build_once.py`, `tests/test_deploy_terminal_sync.py`, `tests/test_staging_precondition.py`, `tests/test_deploy_hook_rollback_sim.py`. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 559e7dc..a84c865 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -295,4 +295,4 @@ ORCH-065 вводит фоновый watchdog, чтобы смерть проц Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.md). --- -*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — design, ветка feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py; флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест).* +*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py; флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест).* diff --git a/src/agents/launcher.py b/src/agents/launcher.py index ec957d8..b356eb1 100644 --- a/src/agents/launcher.py +++ b/src/agents/launcher.py @@ -417,6 +417,14 @@ class AgentLauncher: "UPDATE agent_runs SET output_path = ? WHERE id = ?", (output_path, run_id), ) + # ORCH-065: stamp the agent process pid onto the job row so the job-reaper + # can probe liveness (os.kill(pid, 0)). proc.pid only exists after Popen, + # so this is a second UPDATE next to run_id/started_at (set above in _spawn). + if job_id is not None: + conn.execute( + "UPDATE jobs SET pid = ? WHERE id = ?", + (proc.pid, job_id), + ) conn.commit() conn.close() diff --git a/src/config.py b/src/config.py index 1161959..fc00219 100644 --- a/src/config.py +++ b/src/config.py @@ -296,6 +296,33 @@ class Settings(BaseSettings): post_deploy_auto_rollback: bool = False post_deploy_base_url: str = "http://localhost:8500" + # ORCH-065: job-reaper + proactive merge-lease reclaim. A background daemon + # thread (modelled on the reconciler) makes "the monitor thread / process died + # while a job/lease was held" self-heal WITHOUT a restart. Status (done/queued/ + # failed) is otherwise only ever set by launcher._monitor_agent -> _finalize_job + # inside the live process; a death there left the jobs row 'running' forever and + # (at max_concurrency=1) wedged the queue of EVERY project (incidents 07.06: jobs + # 236/239/242/254). The same thread proactively reclaims a stale/dead merge-lease + # (ORCH-043) instead of waiting for the lazy TTL on the next foreign acquire. See + # docs/architecture/adr/adr-0011-job-reaper-lease-reclaim.md. + # reaper_enabled -> global kill-switch (false -> strictly prior behaviour; + # only the startup requeue_running_jobs remains). + # reaper_interval_s -> background scan period (seconds). + # reaper_dead_ticks -> Tier-1: consecutive ticks a job's pid must be dead + # before it is reaped (>=2 anti-false-positive; a live + # long-running agent is NEVER reaped). + # reaper_max_running_s -> Tier-3 backstop ceiling: a job 'running' longer than + # this is reaped even when liveness is unknowable. MUST be + # > max agent_timeout + grace so a legit agent is safe. + # lease_reclaim_enabled -> kill-switch for the proactive stale/dead lease reclaim + # (false -> only the legacy lazy TTL reclaim in acquire). + # (reuse) merge_lock_timeout_s -> lease TTL; merge_gate_repos -> reclaim scope. + reaper_enabled: bool = True + reaper_interval_s: int = 60 + reaper_dead_ticks: int = 2 + reaper_max_running_s: int = 3600 + lease_reclaim_enabled: bool = True + # Telegram notifications telegram_bot_token: str = "" telegram_chat_id: str = "" diff --git a/src/db.py b/src/db.py index 0e0358a..04c67d9 100644 --- a/src/db.py +++ b/src/db.py @@ -76,6 +76,11 @@ def init_db(): # (CREATE TABLE IF NOT EXISTS won't add columns to an already-created table). _ensure_column(conn, "jobs", "transient_attempts", "INTEGER NOT NULL DEFAULT 0") _ensure_column(conn, "jobs", "available_at", "TEXT") + # ORCH-065: pid of the spawned agent process, stamped in launcher._spawn next to + # run_id/started_at. The job-reaper uses it for Tier-1 liveness (os.kill(pid, 0)) + # to detect a 'running' job whose process died before _finalize_job. Idempotent + # ALTER (no-op once present) -> safe on the live prod DB. + _ensure_column(conn, "jobs", "pid", "INTEGER") # ORCH-5 (M-7): webhook delivery de-dup. Add events.delivery_id and a PARTIAL # unique index. Partial (WHERE delivery_id IS NOT NULL) so pre-existing rows # (which have NULL delivery_id) never collide with each other. Restart-safe: @@ -593,6 +598,76 @@ def requeue_running_jobs() -> int: return int(n) +def get_running_jobs() -> list[dict]: + """ORCH-065: snapshot of every 'running' job for the job-reaper scan. + + Each row carries the job columns plus three reaper inputs: + * ``running_age_s`` — seconds since ``started_at`` (Tier-3 backstop); + * ``exit_code`` — the linked ``agent_runs.exit_code`` (Tier-2: process + finished but the job is still 'running' -> monitor died mid-finalize); + * ``finished_at_run`` — the linked ``agent_runs.finished_at`` (debug only). + + A LEFT JOIN on ``run_id`` keeps jobs with no agent_runs row (exit_code NULL). + Read-only; never mutates. The reaper applies liveness/streak/backstop on top. + """ + conn = get_db() + try: + rows = conn.execute( + "SELECT j.*, " + "CAST(strftime('%s','now') - strftime('%s', j.started_at) AS INTEGER) " + " AS running_age_s, " + "r.exit_code AS exit_code, r.finished_at AS finished_at_run " + "FROM jobs j LEFT JOIN agent_runs r ON r.id = j.run_id " + "WHERE j.status='running'" + ).fetchall() + finally: + conn.close() + return [dict(r) for r in rows] + + +def reap_running_job( + job_id: int, + status: str, + run_id: int | None = None, + error: str | None = None, +) -> bool: + """ORCH-065: atomic terminal flip of a RUNNING job by the job-reaper. + + Mirrors ``mark_job`` but carries the ``status='running'`` guard in the WHERE + clause and reports ``rowcount`` so a late-arriving monitor / the startup + ``requeue_running_jobs`` / a second reaper tick can never double-process the + same row (AC-5, restart-safe). Returns True iff THIS call won the flip + (rowcount == 1); False -> someone else already moved the row. + + Status semantics match ``mark_job``: done/failed stamp ``finished_at``; queued + clears ``started_at``/``finished_at`` so the next claim treats it as fresh. + """ + conn = get_db() + try: + sets = ["status = ?"] + params: list = [status] + if run_id is not None: + sets.append("run_id = ?") + params.append(run_id) + if error is not None: + sets.append("error = ?") + params.append(error) + if status in ("done", "failed"): + sets.append("finished_at = datetime('now')") + elif status == "queued": + sets.append("started_at = NULL") + sets.append("finished_at = NULL") + params.append(job_id) + cur = conn.execute( + f"UPDATE jobs SET {', '.join(sets)} WHERE id = ? AND status='running'", + params, + ) + conn.commit() + return cur.rowcount == 1 + finally: + conn.close() + + def get_job(job_id: int) -> dict | None: """Fetch a single job by id.""" conn = get_db() diff --git a/src/job_reaper.py b/src/job_reaper.py new file mode 100644 index 0000000..fbbc9a3 --- /dev/null +++ b/src/job_reaper.py @@ -0,0 +1,370 @@ +"""ORCH-065: job-reaper + proactive merge-lease reclaim background daemon. + +Three failure classes share one root cause — "the thread/process died while it +still held captured state" — and one inert recovery layer +(``requeue_running_jobs``) that only fires on a process restart: + + * **A — zombie jobs.** A job's terminal status (``done``/``queued``/``failed``) + is written ONLY inside ``launcher._monitor_agent -> _finalize_job`` in the + live process. If that thread/process dies between ``proc.wait()`` and the + status write (crash, OOM, self-restart mid-deploy) the ``jobs`` row stays + ``running`` forever. At ``max_concurrency=1`` one zombie blocks the claim of + EVERY project's jobs -> the whole shared pipeline stalls. + * **B — stuck merge-lease.** The file lease ``.merge-lease-.json`` + (ORCH-043) is reclaimed only lazily, by TTL, and only when ANOTHER task tries + to acquire it. Holder liveness (pid) is never probed, so a death with the + lease held blocks foreign merges until the TTL expires. + +This module is a background daemon thread modelled on ``reconciler`` +(``threading.Thread(daemon=True)`` + ``threading.Event``, start/stop in +``main.lifespan``, ``/queue`` snapshot, per-unit never-raise, kill-switch). Each +tick: (1) scans ``running`` jobs and reaps the dead ones via three-tier liveness +detection; (2) proactively reclaims dead/stale merge-leases (mechanism B) for the +in-scope repos. + +Liveness (defense in depth, ADR-001 Р-1): + * **Tier-1 (primary): dead pid.** ``jobs.pid`` (stamped by ``launcher._spawn``) + probed with ``merge_gate.pid_alive``. A job is reaped only after + ``reaper_dead_ticks`` (>=2) CONSECUTIVE dead-pid ticks — an in-memory streak + counter kills false positives (AC-3); a live agent within its timeout is + never reaped. + * **Tier-2 (completion race): exit_code recorded but job still running.** The + monitor died between writing ``agent_runs.exit_code`` and ``_finalize_job``. + The outcome is KNOWN -> gate-driven advance on exit0, else the standard + transient/permanent contract. + * **Tier-3 (backstop): age ceiling.** A job ``running`` longer than + ``reaper_max_running_s`` (deliberately > max ``agent_timeout`` + grace) is + reaped even when liveness cannot be determined (pid reused / unknown). + +Action on confirmed death reuses existing contracts (no new merge/stage logic): + * The reaper's ONLY mutating write to a job row is the atomic terminal flip + ``db.reap_running_job(... WHERE status='running')`` — so a late-arriving + monitor / the startup ``requeue_running_jobs`` / a second tick can never + double-process a row (AC-5; the loser sees ``rowcount==0``). + * **exit0 (Tier-2):** gate-driven idempotent advance — the source of truth is + the canonical quality gate, NOT "exit0". If the stage already advanced -> + just mark ``done`` (idempotent cleanup). Else run ``launcher._try_advance_stage`` + (it runs the canonical QG: artifact/PR present -> green -> advance; absent -> + red -> no advance) and re-check: advanced -> ``done``; still red (e.g. the + monitor died before git-push, so no artifact) -> fall through to the failure + path. This makes a false ``done`` without real work impossible. + * **exit!=0 (Tier-2) / unknown outcome (Tier-1 dead pid, Tier-3 backstop):** + ``attempts < max_attempts`` -> ``queued`` (mirrors ``requeue_running_jobs``); + budget exhausted -> ``failed`` + Telegram. We never fabricate exit0. + +Invariants (ТЗ §8 / ADR-001): never-raise per unit of work; idempotency (atomic +guard + gate-driven advance); restart-safe (the reaper starts AFTER the startup +``requeue_running_jobs``); silence when nothing is anomalous; the reaper NEVER +restarts/kills the prod container and NEVER pushes ``main``. ``STAGE_TRANSITIONS`` +/ ``QG_CHECKS`` and every ``check_*`` signature are unchanged. + +See docs/work-items/ORCH-065/06-adr/ADR-001-job-reaper-and-lease-reclaim.md and +the cross-cutting docs/architecture/adr/adr-0011-job-reaper-lease-reclaim.md. +""" + +import logging +import threading +from datetime import datetime, timezone + +from .config import settings +from .db import ( + get_db, + get_running_jobs, + reap_running_job, +) +from .stages import STAGE_TRANSITIONS, get_agent_for_stage + +logger = logging.getLogger("orchestrator.job_reaper") + + +def reclaim_all_stale_leases() -> int: + """Proactively reclaim dead/stale merge-leases for every in-scope repo. + + Used both at startup (``main.lifespan``, next to ``requeue_running_jobs``) and + on every reaper tick (mechanism B). Iterates the merge-gate scope + (``merge_gate_repos`` CSV, else self-hosting ``orchestrator``) and calls the + never-raise ``merge_gate.reclaim_stale_lease`` per repo. Returns the number of + leases actually reclaimed. Never raises (per-repo isolation). + """ + if not settings.lease_reclaim_enabled: + return 0 + reclaimed = 0 + try: + from . import merge_gate + raw = (settings.merge_gate_repos or "").strip() + if raw: + repos = [r.strip() for r in raw.split(",") if r.strip()] + else: + from .qg.checks import SELF_HOSTING_REPO + repos = [SELF_HOSTING_REPO] + for repo in repos: + try: + if merge_gate.reclaim_stale_lease(repo): + reclaimed += 1 + except Exception as e: # noqa: BLE001 - isolate one repo's failure + logger.error("lease-reclaim failed for repo %s: %s", repo, e) + except Exception as e: # noqa: BLE001 - never-raise contract + logger.error("reclaim_all_stale_leases error: %s", e) + return reclaimed + + +class JobReaper: + """Background daemon that reaps zombie jobs and reclaims stale merge-leases. + + Modelled on ``Reconciler``: a ``threading.Thread(daemon=True)`` + a + ``threading.Event`` for a clean stop. The only in-memory state is the + best-effort Tier-1 dead-pid streak counter (``_streak``) and the + observability counters (``reaped_total`` / ``last_reaped`` / + ``lease_reclaimed_total`` / ``last_run_ts``); all reset on restart, which is + safe because the startup ``requeue_running_jobs`` covers the restart path. + """ + + def __init__(self, interval_s: float | None = None): + self.interval_s = ( + interval_s if interval_s is not None else settings.reaper_interval_s + ) + self._stop = threading.Event() + self._thread: threading.Thread | None = None + # Tier-1 anti-false-positive: {job_id: consecutive dead-pid ticks}. + self._streak: dict[int, int] = {} + # Best-effort observability (Р-6). + self.last_run_ts: float | None = None + self.reaped_total: int = 0 + self.last_reaped: dict | None = None + self.lease_reclaimed_total: int = 0 + + # -- A: zombie-job reaping -------------------------------------------- + def reap_once(self) -> None: + """One scan over all ``running`` jobs (per-job never-raise) + lease reclaim.""" + if settings.reaper_enabled: + try: + running = get_running_jobs() + except Exception as e: # noqa: BLE001 - never break the tick + logger.error("reaper: get_running_jobs failed: %s", e) + running = [] + seen: set[int] = set() + for job in running: + jid = job.get("id") + if jid is not None: + seen.add(jid) + try: + self._reap_job(job) + except Exception as e: # noqa: BLE001 - isolate one job's failure + logger.error( + "reaper: job %s (agent=%s) failed: %s", + job.get("id"), job.get("agent"), e, + ) + # Forget streaks for rows that are no longer running (reaped / requeued + # / finished by the monitor) so the dict cannot grow unbounded. + self._streak = {k: v for k, v in self._streak.items() if k in seen} + # Mechanism B: proactive stale/dead lease reclaim (own kill-switch). + try: + self.lease_reclaimed_total += reclaim_all_stale_leases() + except Exception as e: # noqa: BLE001 - never break the tick + logger.error("reaper: lease reclaim sweep failed: %s", e) + + def _reap_job(self, job: dict) -> None: + """Apply the three-tier liveness policy to a single running job.""" + from . import merge_gate + + job_id = job["id"] + pid = job.get("pid") + age = int(job.get("running_age_s") or 0) + exit_code = job.get("exit_code") # from the LEFT JOIN on agent_runs + + # Tier-2: the process finished (exit_code recorded) but the job is still + # 'running' -> the monitor died mid-finalize. Outcome is KNOWN. + if exit_code is not None: + self._streak.pop(job_id, None) + self._reap_known_outcome(job, int(exit_code)) + return + + # Tier-1: dead pid, only after `reaper_dead_ticks` consecutive dead ticks. + if pid is not None and not merge_gate.pid_alive(pid): + n = self._streak.get(job_id, 0) + 1 + self._streak[job_id] = n + if n >= max(int(settings.reaper_dead_ticks), 1): + self._streak.pop(job_id, None) + self._reap_unknown_outcome(job, reason=f"dead pid={pid}") + return + logger.info( + "reaper: job %s pid=%s dead (streak %d/%d) — deferring", + job_id, pid, n, settings.reaper_dead_ticks, + ) + else: + # Alive / no pid -> reset the streak (must be CONSECUTIVE). + self._streak.pop(job_id, None) + + # Tier-3: backstop ceiling (one-shot; reaps even when liveness is unknown). + if age >= int(settings.reaper_max_running_s): + self._streak.pop(job_id, None) + self._reap_unknown_outcome( + job, reason=f"backstop age={age}s>={settings.reaper_max_running_s}s" + ) + + # -- reap actions ------------------------------------------------------ + def _reap_known_outcome(self, job: dict, exit_code: int) -> None: + """Tier-2: the agent's exit_code is known; drive the job's terminal status.""" + if exit_code == 0: + if self._gate_driven_advance(job): + if reap_running_job(job["id"], "done", run_id=job.get("run_id")): + self._note_reap(job, "done", reason="exit0, gate green") + return + # exit0 but the gate is red (e.g. monitor died before git-push -> no + # artifact). Do NOT fabricate 'done'; treat as a failed outcome. + self._reap_unknown_outcome(job, reason="exit0 but gate red") + else: + self._reap_unknown_outcome(job, reason=f"exit={exit_code}") + + def _reap_unknown_outcome(self, job: dict, reason: str) -> None: + """Tier-1/Tier-3 (or exit!=0): outcome not a clean success. + + Mirrors ``requeue_running_jobs`` / the permanent-failure contract: + ``attempts < max_attempts`` -> ``queued`` (a retry); budget exhausted -> + ``failed`` + Telegram. The terminal flip is the atomic ``reap_running_job`` + guard, so a racing requeue/monitor never double-processes the row. + """ + job_id = job["id"] + run_id = job.get("run_id") + attempts = int(job.get("attempts") or 0) + max_attempts = int(job.get("max_attempts") or 2) + err = f"reaped: {reason} (run_id={run_id})" + if attempts < max_attempts: + if reap_running_job(job_id, "queued", run_id=run_id, error=err): + self._note_reap(job, "queued", reason=reason) + else: + if reap_running_job(job_id, "failed", run_id=run_id, error=err): + self._note_reap(job, "failed", reason=reason) + self._notify_failed(job, reason) + + def _gate_driven_advance(self, job: dict) -> bool: + """Idempotent, gate-driven stage advance for a reaped exit0 job. + + Returns True iff the stage is (or has become) advanced past this agent's + stage — i.e. the canonical quality gate is satisfied and a clean ``done`` + is correct. Returns False when the gate is still red (the caller then + routes the job to the failure path instead of a false ``done``). + + The advance itself reuses the UNCHANGED ``launcher._try_advance_stage`` + (which runs the canonical QG and the unified ``advance_stage``); the + reaper never duplicates ``update_task_stage`` / ``enqueue_job``. + """ + agent = job.get("agent") + repo = job.get("repo") + run_id = job.get("run_id") + branch, stage = self._task_branch_stage(job) + # Candidate stages whose finishing agent is THIS agent (deployer maps to + # both 'testing' and 'deploy-staging', hence a set). + candidates = {s for s in STAGE_TRANSITIONS if get_agent_for_stage(s) == agent} + if stage is None or stage not in candidates: + # Stage already advanced past this agent (or unknown) -> idempotent + # cleanup: a clean 'done' is correct without re-advancing. + return True + if not branch: + return False + try: + from .agents.launcher import launcher + launcher._try_advance_stage(run_id, agent, repo, branch) + except Exception as e: # noqa: BLE001 - never break the reap + logger.error("reaper: gate-driven advance failed for job %s: %s", + job.get("id"), e) + return False + # Re-read the stage: advanced out of the candidate set -> gate was green. + _branch, new_stage = self._task_branch_stage(job) + return new_stage is None or new_stage not in candidates + + @staticmethod + def _task_branch_stage(job: dict) -> tuple[str | None, str | None]: + """Resolve (branch, stage) for the job's task. Never raises.""" + task_id = job.get("task_id") + if not task_id: + return None, None + try: + conn = get_db() + row = conn.execute( + "SELECT branch, stage FROM tasks WHERE id = ?", (task_id,) + ).fetchone() + conn.close() + if not row: + return None, None + return row["branch"], row["stage"] + except Exception as e: # noqa: BLE001 - never-raise contract + logger.warning("reaper: task lookup failed for job %s: %s", + job.get("id"), e) + return None, None + + def _notify_failed(self, job: dict, reason: str) -> None: + try: + from .notifications import send_telegram + send_telegram( + f"\U0001f6a8 reaper: job {job.get('id')} ({job.get('agent')}, " + f"repo {job.get('repo')}) reaped as FAILED: {reason}" + ) + except Exception as e: # noqa: BLE001 - telegram best-effort + logger.warning("reaper: failed-notify telegram error: %s", e) + + def _note_reap(self, job: dict, outcome: str, reason: str) -> None: + """Record + log one successful reap (Р-6 observability).""" + self.reaped_total += 1 + self.last_reaped = { + "job_id": job.get("id"), + "agent": job.get("agent"), + "outcome": outcome, + } + logger.warning( + "reaper: job %s (agent=%s, repo=%s, run_id=%s, pid=%s) reaped -> %s (%s)", + job.get("id"), job.get("agent"), job.get("repo"), + job.get("run_id"), job.get("pid"), outcome, reason, + ) + + # -- loop / lifecycle -------------------------------------------------- + def _tick(self) -> None: + try: + self.reap_once() + finally: + self.last_run_ts = datetime.now(timezone.utc).timestamp() + + def _run(self) -> None: + logger.info( + "JobReaper started (interval=%ss, enabled=%s, dead_ticks=%s, " + "max_running_s=%s, lease_reclaim=%s)", + self.interval_s, settings.reaper_enabled, settings.reaper_dead_ticks, + settings.reaper_max_running_s, settings.lease_reclaim_enabled, + ) + while not self._stop.is_set(): + try: + self._tick() + except Exception as e: # noqa: BLE001 - outer never-raise + logger.error("JobReaper loop error: %s", e) + self._stop.wait(self.interval_s) + logger.info("JobReaper stopped") + + def start(self) -> None: + """Start the daemon thread (idempotent: a live thread is a no-op).""" + if self._thread and self._thread.is_alive(): + return + self._stop.clear() + self._thread = threading.Thread( + target=self._run, name="job-reaper", daemon=True + ) + self._thread.start() + + def stop(self, timeout: float = 5.0) -> None: + self._stop.set() + if self._thread: + self._thread.join(timeout=timeout) + + def status(self) -> dict: + """Reaper snapshot for /queue observability (Р-6).""" + return { + "enabled": settings.reaper_enabled, + "interval": self.interval_s, + "last_run_ts": self.last_run_ts, + "reaped_total": self.reaped_total, + "last_reaped": self.last_reaped, + "lease_reclaimed_total": self.lease_reclaimed_total, + } + + +# Module-level singleton used by the FastAPI lifespan. +reaper = JobReaper() diff --git a/src/main.py b/src/main.py index c21e5b2..b610cb3 100644 --- a/src/main.py +++ b/src/main.py @@ -60,6 +60,19 @@ async def lifespan(app: FastAPI): if requeued: log.warning(f"Queue-recovery: requeued {requeued} running job(s) after restart") + # ORCH-065: proactive startup reclaim of dead/stale merge-leases, next to the + # queue-recovery above. A lease held by the previous (now dead) process pid is + # released at once instead of waiting for the TTL / a foreign acquire so the + # next merge is not blocked. Conditional (merge_gate_repos / self-hosting) and + # gated by ORCH_LEASE_RECLAIM_ENABLED; never raises. + try: + from .job_reaper import reclaim_all_stale_leases + reclaimed = reclaim_all_stale_leases() + if reclaimed: + log.warning(f"Startup lease-reclaim: reclaimed {reclaimed} stale merge-lease(s)") + except Exception as e: + log.warning(f"Startup lease-reclaim skipped: {e}") + # L-2: rotate old per-run logs at startup (best-effort; never fatal). try: import os as _os @@ -85,13 +98,22 @@ async def lifespan(app: FastAPI): from .reconciler import reconciler reconciler.start() + # ORCH-065: start the job-reaper LAST (after requeue_running_jobs + the worker + # + the reconciler) so its atomic status='running' guard never races the + # startup requeue. It reaps zombie jobs and periodically reclaims stale + # merge-leases. Kill-switch: ORCH_REAPER_ENABLED. + from .job_reaper import reaper + reaper.start() + try: yield finally: - # Graceful shutdown order mirrors startup in reverse: stop the reconciler - # first (it must not enqueue new work while the worker is winding down), - # then the worker. Running agents keep going; their jobs are requeued on - # next start via queue-recovery if the process dies. + # Graceful shutdown order mirrors startup in reverse: stop the reaper + # first, then the reconciler (it must not enqueue new work while the + # worker is winding down), then the worker. Running agents keep going; + # their jobs are requeued on next start via queue-recovery if the + # process dies. + reaper.stop() reconciler.stop() worker.stop() @@ -123,6 +145,7 @@ async def queue(): from .db import job_status_counts, recent_jobs from .queue_worker import worker from .reconciler import reconciler + from .job_reaper import reaper from . import post_deploy return { "counts": job_status_counts(), @@ -130,6 +153,7 @@ async def queue(): "poll_interval": worker.poll_interval, "resilience": worker.status(), "reconcile": reconciler.status(), + "reaper": reaper.status(), "post_deploy": post_deploy.status(), "recent": recent_jobs(10), } diff --git a/src/merge_gate.py b/src/merge_gate.py index dc7a0e6..246aeb8 100644 --- a/src/merge_gate.py +++ b/src/merge_gate.py @@ -338,3 +338,141 @@ def release_merge_lease(repo: str, branch: str | None = None) -> None: return except OSError as e: logger.warning("merge-lease release error for %s: %s", repo, e) + + +# --------------------------------------------------------------------------- +# ORCH-065: proactive stale/dead merge-lease reclaim (Problem B) +# --------------------------------------------------------------------------- +def pid_alive(pid) -> bool: + """Return True iff process ``pid`` is alive (``os.kill(pid, 0)`` liveness probe). + + Semantics (ADR-001 Р-2, never-raise): + * ``ProcessLookupError`` -> the process is gone -> ``False`` (reclaimable). + * ``PermissionError`` -> the pid exists but is owned by another user -> + ``True`` (alive; conservatively do NOT reclaim). + * missing / invalid pid -> ``True`` (conservative: a lease that predates the + pid field, or a malformed pid, is NOT reclaimed on the liveness signal — + the TTL backstop still catches it). + Never raises; any unexpected OS/type error -> conservative ``True``. + """ + if not pid: + return True + try: + os.kill(int(pid), 0) + return True + except ProcessLookupError: + return False + except PermissionError: + return True + except (OSError, ValueError, TypeError): + return True + + +def _lease_reclaim_applies(repo: str) -> bool: + """Whether proactive lease-reclaim is REAL for ``repo`` (same scope as merge-gate). + + Reuses ``qg.checks._merge_gate_applies`` (``merge_gate_repos`` CSV, else the + self-hosting ``orchestrator``) so reclaim and the gate share one predicate + (ADR-001 Р-2 / FR-2.4). Imported lazily to avoid an import cycle (qg.checks + imports merge_gate lazily inside ``check_branch_mergeable``). Never raises: + any error -> ``False`` (no-op, the safe default). + """ + try: + from .qg.checks import _merge_gate_applies + return _merge_gate_applies(repo) + except Exception as e: # noqa: BLE001 - never-raise contract + logger.warning("lease-reclaim applicability check failed for %s: %s", repo, e) + return False + + +def reclaim_stale_lease(repo: str) -> bool: + """Proactively reclaim a dead/stale merge-lease for ``repo`` (ADR-001 Р-2). + + Unlike the lazy TTL reclaim inside ``acquire_merge_lease`` (which only fires + when ANOTHER task tries to acquire), this releases the lease as soon as the + holder is provably gone — without waiting for the TTL or a foreign acquire: + + * holder pid is dead (``pid_alive`` is False) -> reclaim, OR + * lease age >= ``merge_lock_timeout_s`` (TTL) -> reclaim (AC-7). + + A LIVE holder within its TTL is never touched (AC-8 — protects a legitimate + in-flight merge). Reclaim is holder-aware (``release_merge_lease(repo, + branch=holder)``) so it can never delete a lease a different task acquired in + the meantime. Conditional (FR-2.4): real only for ``merge_gate_repos`` / + self-hosting; other repos -> no-op. Kill-switch ``lease_reclaim_enabled``. + + Returns True iff a lease was reclaimed. Never raises (AC-9): any read/remove + error is logged and swallowed so a single bad lease never kills the reaper + thread. Does NOT run any git operation — only the lease file is removed. + """ + try: + if not settings.lease_reclaim_enabled: + return False + if not _lease_reclaim_applies(repo): + return False + path = _lease_path(repo) + existing = _read_lease(path) + if existing is None: + return False # no lease (or unreadable -> _read_lease already logged) + holder = existing.get("branch") + pid = existing.get("pid") + age = time.time() - float(existing.get("acquired_at") or 0) + dead = not pid_alive(pid) + expired = age >= settings.merge_lock_timeout_s + if not (dead or expired): + return False # live holder within TTL -> protect legitimate merge + why = f"dead pid={pid}" if dead else f"stale age={age:.0f}s>=TTL" + release_merge_lease(repo, branch=holder) + logger.warning( + "merge-lease for %s reclaimed proactively (%s, holder=%s)", + repo, why, holder, + ) + try: + from .notifications import send_telegram + send_telegram( + f"\U0001f527 merge-lease для {repo} освобождён проактивно " + f"({why}, holder={holder})" + ) + except Exception as e: # noqa: BLE001 - telegram best-effort, never fatal + logger.warning("lease-reclaim telegram failed for %s: %s", repo, e) + return True + except Exception as e: # noqa: BLE001 - never-raise contract + logger.warning("reclaim_stale_lease unexpected error for %s: %s", repo, e) + return False + + +# --------------------------------------------------------------------------- +# ORCH-065: idempotent merge finalization guard (Problem C) +# --------------------------------------------------------------------------- +def pr_already_merged(repo: str, branch: str) -> bool: + """Return True iff the PR for ``branch`` is ALREADY merged (ADR-001 Р-3, FR-3.2). + + A deterministic, read-only guard the merge path consults BEFORE attempting a + (second) merge so a re-driven / reaped task is idempotent: an already-merged + PR -> no-op, never a duplicate merge and never an error. This is the ONLY new + merge-related helper and it does NOT merge — it only READS the PR state via + the existing Gitea client, so it does not introduce duplicate merge logic. + + Queries Gitea ``GET /repos/{owner}/{repo}/pulls?state=all&head=`` and + reports True when any matching PR has ``merged == True``. Never raises (AC-9): + any HTTP/parse error -> ``False`` (conservative: "not known-merged" lets the + normal gate re-evaluate rather than silently skipping a real merge). + """ + try: + import httpx + owner = settings.gitea_owner + headers = {"Authorization": f"token {settings.gitea_token}"} + resp = httpx.get( + f"{settings.gitea_url}/api/v1/repos/{owner}/{repo}/pulls", + params={"state": "all", "head": branch}, + headers=headers, timeout=_SHORT_TIMEOUT, + ) + if resp.status_code != 200: + return False + for pr in resp.json() or []: + if pr.get("merged") is True: + return True + return False + except Exception as e: # noqa: BLE001 - never-raise contract + logger.warning("pr_already_merged check failed for %s/%s: %s", repo, branch, e) + return False diff --git a/tests/test_config.py b/tests/test_config.py index ea44e0c..6957461 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -165,3 +165,82 @@ def test_staging_infra_tolerance_env_override_true(monkeypatch): """The field is read verbatim from its ORCH_* env var.""" monkeypatch.setenv("ORCH_STAGING_INFRA_TOLERANCE_ENABLED", "true") assert Settings().staging_infra_tolerance_enabled is True + + +# --------------------------------------------------------------------------- +# ORCH-065 / TC-20: reaper_* + lease_reclaim_* settings defaults + env override. +# --------------------------------------------------------------------------- +_REAPER_ENV = ( + "ORCH_REAPER_ENABLED", + "ORCH_REAPER_INTERVAL_S", + "ORCH_REAPER_DEAD_TICKS", + "ORCH_REAPER_MAX_RUNNING_S", + "ORCH_LEASE_RECLAIM_ENABLED", +) + + +def test_reaper_settings_defaults(monkeypatch): + """TC-20 / §5: documented defaults when no env is set.""" + for name in _REAPER_ENV: + monkeypatch.delenv(name, raising=False) + s = Settings() + assert s.reaper_enabled is True + assert s.reaper_interval_s == 60 + assert s.reaper_dead_ticks == 2 + assert s.reaper_max_running_s == 3600 + assert s.lease_reclaim_enabled is True + + +def test_reaper_settings_env_override(monkeypatch): + """TC-20 / §5 / AC-14: each field is read from its ORCH_* env var.""" + monkeypatch.setenv("ORCH_REAPER_ENABLED", "false") + monkeypatch.setenv("ORCH_REAPER_INTERVAL_S", "30") + monkeypatch.setenv("ORCH_REAPER_DEAD_TICKS", "5") + monkeypatch.setenv("ORCH_REAPER_MAX_RUNNING_S", "1200") + monkeypatch.setenv("ORCH_LEASE_RECLAIM_ENABLED", "false") + s = Settings() + assert s.reaper_enabled is False + assert s.reaper_interval_s == 30 + assert s.reaper_dead_ticks == 5 + assert s.reaper_max_running_s == 1200 + assert s.lease_reclaim_enabled is False + + +# --------------------------------------------------------------------------- +# ORCH-065 / TC-19: contracts unchanged — no new stages / QG checks; the +# check_branch_mergeable signature is intact (AC-13). +# --------------------------------------------------------------------------- +def test_tc19_stage_transitions_unchanged(): + """No new pipeline stage was introduced by ORCH-065.""" + from src.stages import STAGE_TRANSITIONS + assert set(STAGE_TRANSITIONS) == { + "created", "analysis", "architecture", "development", "review", + "testing", "deploy-staging", "deploy", "done", + } + + +def test_tc19_qg_checks_registry_unchanged(): + """No new quality-gate check was added to the registry by ORCH-065.""" + from src.qg.checks import QG_CHECKS + assert set(QG_CHECKS) == { + "check_analysis_approved", + "check_analysis_complete", + "check_architecture_done", + "check_ci_green", + "check_review_approved", + "check_tests_passed", + "check_reviewer_verdict", + "check_tests_local", + "check_deploy_status", + "check_staging_status", + "check_branch_mergeable", + "check_staging_image_fresh", + } + + +def test_tc19_check_branch_mergeable_signature_intact(): + """check_branch_mergeable still takes exactly (repo, work_item_id, branch).""" + import inspect + from src.qg.checks import check_branch_mergeable + params = list(inspect.signature(check_branch_mergeable).parameters) + assert params == ["repo", "work_item_id", "branch"] diff --git a/tests/test_job_reaper.py b/tests/test_job_reaper.py new file mode 100644 index 0000000..683cff4 --- /dev/null +++ b/tests/test_job_reaper.py @@ -0,0 +1,285 @@ +"""ORCH-065: job-reaper unit tests (TC-01..TC-08, TC-21). + +The reaper never spawns claude; we drive the DB directly (a 'running' jobs row + +optional agent_runs exit_code/pid) and assert the terminal flip + side-effects. +``os.kill`` liveness is monkeypatched so a 'dead'/'alive' pid is deterministic. +""" +import os +import tempfile + +import pytest + +# Override env before importing app modules (same convention as test_queue.py). +os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch_reaper.db") +os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir() +os.environ["ORCH_GITEA_TOKEN"] = "test-token" +os.environ["ORCH_PLANE_API_TOKEN"] = "test-token" + +import src.db as db +from src.db import init_db, get_db, enqueue_job, get_job +import src.job_reaper as jr +from src.job_reaper import JobReaper + + +@pytest.fixture(autouse=True) +def fresh_db(tmp_path, monkeypatch): + dbfile = tmp_path / "reaper.db" + monkeypatch.setattr(db.settings, "db_path", str(dbfile)) + init_db() + yield + + +# --- helpers ---------------------------------------------------------------- +def _make_running_job(agent="developer", repo="orchestrator", task_id=None, + pid=None, age_s=0, attempts=0, max_attempts=2, + run_id=None, exit_code=None): + """Insert a job already in 'running' with the given pid/age/attempts. + + started_at is back-dated by ``age_s`` seconds so running_age_s reflects it. + When ``exit_code`` is given an agent_runs row is created and linked (Tier-2). + """ + conn = get_db() + if run_id is None and exit_code is not None: + cur = conn.execute( + "INSERT INTO agent_runs (task_id, agent, finished_at, exit_code) " + "VALUES (?, ?, datetime('now'), ?)", + (task_id, agent, exit_code), + ) + run_id = cur.lastrowid + cur = conn.execute( + "INSERT INTO jobs (agent, repo, task_id, status, attempts, max_attempts, " + "run_id, pid, started_at) " + "VALUES (?, ?, ?, 'running', ?, ?, ?, ?, datetime('now', ?))", + (agent, repo, task_id, attempts, max_attempts, run_id, pid, + f"-{int(age_s)} seconds"), + ) + job_id = cur.lastrowid + conn.commit() + conn.close() + return job_id + + +def _make_task(repo="orchestrator", branch="feature/x", stage="development", + work_item_id="ORCH-1"): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) " + "VALUES (?, ?, ?, ?, ?)", + (work_item_id, work_item_id, repo, branch, stage), + ) + tid = cur.lastrowid + conn.commit() + conn.close() + return tid + + +def _dead_pid(monkeypatch): + """Force merge_gate.pid_alive -> False (process gone) for the reaper.""" + import src.merge_gate as mg + monkeypatch.setattr(mg, "pid_alive", lambda pid: False) + + +def _alive_pid(monkeypatch): + import src.merge_gate as mg + monkeypatch.setattr(mg, "pid_alive", lambda pid: True) + + +# --- TC-01: dead executor -> reaped without process restart ----------------- +def test_tc01_dead_pid_reaped_to_queued(monkeypatch): + _dead_pid(monkeypatch) + jid = _make_running_job(pid=999999, attempts=0, max_attempts=2) + r = JobReaper() + r.reap_once() # tick 1 (streak=1, dead_ticks default 2 -> not yet) + assert get_job(jid)["status"] == "running" + r.reap_once() # tick 2 -> reaped + assert get_job(jid)["status"] == "queued" + assert r.reaped_total == 1 + assert r.last_reaped["job_id"] == jid + + +# --- TC-02: live agent within timeout is NEVER reaped ----------------------- +def test_tc02_alive_pid_never_reaped(monkeypatch): + _alive_pid(monkeypatch) + jid = _make_running_job(pid=4321, age_s=10) + r = JobReaper() + for _ in range(5): + r.reap_once() + assert get_job(jid)["status"] == "running" + assert r.reaped_total == 0 + + +def test_tc02_alive_within_max_running_not_reaped(monkeypatch): + _alive_pid(monkeypatch) + monkeypatch.setattr(db.settings, "reaper_max_running_s", 3600) + jid = _make_running_job(pid=4321, age_s=1800) # < ceiling, alive + r = JobReaper() + r.reap_once() + assert get_job(jid)["status"] == "running" + + +# --- TC-03: zombie only after reaper_dead_ticks consecutive ticks ----------- +def test_tc03_requires_consecutive_dead_ticks(monkeypatch): + monkeypatch.setattr(db.settings, "reaper_dead_ticks", 3) + import src.merge_gate as mg + # Dead, dead, ALIVE (resets), dead, dead, dead -> reaped only on the 6th tick. + seq = iter([False, False, True, False, False, False]) + monkeypatch.setattr(mg, "pid_alive", lambda pid: next(seq)) + jid = _make_running_job(pid=999998) + r = JobReaper() + for _ in range(5): + r.reap_once() + assert get_job(jid)["status"] == "running" + r.reap_once() # 6th tick: third CONSECUTIVE dead -> reaped + assert get_job(jid)["status"] == "queued" + + +# --- TC-04: backstop ceiling reaps even when liveness is unknown ------------ +def test_tc04_backstop_ceiling(monkeypatch): + _alive_pid(monkeypatch) # liveness says "alive", but age exceeds the ceiling + monkeypatch.setattr(db.settings, "reaper_max_running_s", 100) + jid = _make_running_job(pid=4321, age_s=500) + r = JobReaper() + r.reap_once() + assert get_job(jid)["status"] == "queued" + assert r.reaped_total == 1 + + +def test_tc04_backstop_no_pid(monkeypatch): + monkeypatch.setattr(db.settings, "reaper_max_running_s", 100) + jid = _make_running_job(pid=None, age_s=500) + r = JobReaper() + r.reap_once() + assert get_job(jid)["status"] == "queued" + + +# --- TC-05: correct outcome by exit_code (Tier-2) --------------------------- +def test_tc05_exit0_gate_green_done(monkeypatch): + # A developer job runs to LEAVE the 'architecture' stage (-> 'development'). + tid = _make_task(stage="architecture") + jid = _make_running_job(agent="developer", task_id=tid, exit_code=0) + # gate green -> advance succeeds (stage leaves the developer candidate set). + import src.agents.launcher as L + monkeypatch.setattr( + L.launcher, "_try_advance_stage", + lambda run_id, agent, repo, branch: db.update_task_stage(tid, "development"), + ) + r = JobReaper() + r.reap_once() + assert get_job(jid)["status"] == "done" + + +def test_tc05_exit0_gate_red_requeues(monkeypatch): + tid = _make_task(stage="architecture") + jid = _make_running_job(agent="developer", task_id=tid, exit_code=0, + attempts=0, max_attempts=2) + # gate red -> _try_advance_stage is a no-op (stage stays 'architecture'). + import src.agents.launcher as L + monkeypatch.setattr(L.launcher, "_try_advance_stage", + lambda run_id, agent, repo, branch: None) + r = JobReaper() + r.reap_once() + assert get_job(jid)["status"] == "queued" # exit0 but gate red -> not 'done' + + +def test_tc05_nonzero_exit_requeue_then_failed(monkeypatch): + sent = [] + monkeypatch.setattr(jr, "JobReaper", JobReaper) + tid = _make_task(stage="development") + jid = _make_running_job(agent="developer", task_id=tid, exit_code=1, + attempts=1, max_attempts=2) + r = JobReaper() + import src.notifications as notif + monkeypatch.setattr(notif, "send_telegram", lambda *a, **k: sent.append(a)) + r.reap_once() # attempts(1) < max(2) -> queued + assert get_job(jid)["status"] == "queued" + + # Now exhaust the budget. + jid2 = _make_running_job(agent="developer", task_id=tid, exit_code=1, + attempts=2, max_attempts=2) + r.reap_once() + assert get_job(jid2)["status"] == "failed" + assert sent, "failed reap must send a Telegram alert" + + +# --- TC-06: atomicity — reaper vs requeue_running_jobs (status guard) -------- +def test_tc06_atomic_no_double_reap(monkeypatch): + _dead_pid(monkeypatch) + monkeypatch.setattr(db.settings, "reaper_dead_ticks", 1) + jid = _make_running_job(pid=999997, attempts=0, max_attempts=2) + # Simulate the startup requeue winning the row first. + n = db.requeue_running_jobs() + assert n == 1 + assert get_job(jid)["status"] == "queued" + # The reaper now scans: the row is no longer 'running' -> reap_running_job's + # WHERE status='running' guard yields rowcount 0 -> no second processing. + r = JobReaper() + r.reap_once() + assert get_job(jid)["status"] == "queued" + assert r.reaped_total == 0 + + +def test_tc06_reap_running_job_guard_returns_false_when_not_running(): + jid = enqueue_job("developer", "orchestrator") # status 'queued', not running + assert db.reap_running_job(jid, "done") is False + assert get_job(jid)["status"] == "queued" + + +# --- TC-07: kill-switch reaper_enabled=False -> no-op ----------------------- +def test_tc07_kill_switch(monkeypatch): + _dead_pid(monkeypatch) + monkeypatch.setattr(db.settings, "reaper_enabled", False) + monkeypatch.setattr(db.settings, "lease_reclaim_enabled", False) + jid = _make_running_job(pid=999996, age_s=99999) + r = JobReaper() + for _ in range(3): + r.reap_once() + assert get_job(jid)["status"] == "running" + assert r.reaped_total == 0 + + +# --- TC-08: never-raise — a DB/OS error in one tick does not propagate ------- +def test_tc08_never_raise_isolates_per_job(monkeypatch): + _dead_pid(monkeypatch) + monkeypatch.setattr(db.settings, "reaper_dead_ticks", 1) + good = _make_running_job(pid=111, attempts=0, max_attempts=2) + bad = _make_running_job(pid=222, attempts=0, max_attempts=2) + + r = JobReaper() + orig = r._reap_job + + def boom(job): + if job["id"] == bad: + raise RuntimeError("simulated per-job failure") + return orig(job) + + monkeypatch.setattr(r, "_reap_job", boom) + # Must not raise despite the bad job blowing up. + r.reap_once() + # The good job is still reaped; the bad one is isolated (stays running). + assert get_job(good)["status"] == "queued" + assert get_job(bad)["status"] == "running" + + +def test_tc08_reap_once_outer_never_raises(monkeypatch): + monkeypatch.setattr(jr, "get_running_jobs", + lambda: (_ for _ in ()).throw(RuntimeError("db down"))) + r = JobReaper() + # reap_once swallows... actually get_running_jobs is iterated in the for; the + # _tick wrapper guarantees the loop never dies. Assert _tick is safe. + r._tick() + assert r.last_run_ts is not None + + +# --- TC-21: startup lease-reclaim + reaper start/stop smoke ----------------- +def test_tc21_reaper_start_stop_smoke(): + r = JobReaper(interval_s=0.05) + r.start() + assert r._thread is not None and r._thread.is_alive() + r.stop(timeout=2) + assert not r._thread.is_alive() + + +def test_tc21_reclaim_all_stale_leases_callable(monkeypatch): + # No lease files present -> 0 reclaimed, never raises (registration smoke). + monkeypatch.setattr(db.settings, "lease_reclaim_enabled", True) + assert jr.reclaim_all_stale_leases() == 0 diff --git a/tests/test_merge_gate.py b/tests/test_merge_gate.py index 4168e27..7554bb9 100644 --- a/tests/test_merge_gate.py +++ b/tests/test_merge_gate.py @@ -11,6 +11,7 @@ import subprocess import tempfile import time +import httpx import pytest # Env before importing app modules (same convention as the other suites). @@ -299,3 +300,56 @@ def test_tc11_release_missing_is_noop(lease_dir): # Releasing a non-existent lease never raises. merge_gate.release_merge_lease("orchestrator", "feature/none") merge_gate.release_merge_lease("orchestrator") # force form + + +# --------------------------------------------------------------------------- +# ORCH-065 / TC-16: idempotent merge finalization — pr_already_merged guard. +# --------------------------------------------------------------------------- +class _FakeResp: + def __init__(self, status_code, payload): + self.status_code = status_code + self._payload = payload + + def json(self): + return self._payload + + +def test_tc16_pr_already_merged_true(monkeypatch): + """A merged PR -> True so a re-driven/reaped task is a no-op (no second merge).""" + monkeypatch.setattr( + httpx, "get", + lambda *a, **k: _FakeResp(200, [{"number": 7, "merged": True}]), + ) + assert merge_gate.pr_already_merged("orchestrator", "feature/x") is True + + +def test_tc16_pr_open_not_merged_false(monkeypatch): + """An open / not-yet-merged PR -> False (the normal merge path proceeds).""" + monkeypatch.setattr( + httpx, "get", + lambda *a, **k: _FakeResp(200, [{"number": 7, "merged": False}]), + ) + assert merge_gate.pr_already_merged("orchestrator", "feature/x") is False + + +def test_tc16_pr_no_pr_false(monkeypatch): + monkeypatch.setattr( + httpx, "get", lambda *a, **k: _FakeResp(200, []), + ) + assert merge_gate.pr_already_merged("orchestrator", "feature/x") is False + + +def test_tc16_pr_already_merged_never_raises(monkeypatch): + """Any HTTP/parse error -> False (conservative), never an exception (AC-9).""" + def boom(*a, **k): + raise RuntimeError("gitea down") + + monkeypatch.setattr(httpx, "get", boom) + assert merge_gate.pr_already_merged("orchestrator", "feature/x") is False + + +def test_tc16_pr_non_200_false(monkeypatch): + monkeypatch.setattr( + httpx, "get", lambda *a, **k: _FakeResp(500, None), + ) + assert merge_gate.pr_already_merged("orchestrator", "feature/x") is False diff --git a/tests/test_merge_gate_race.py b/tests/test_merge_gate_race.py index f9c5ea5..0e65fc6 100644 --- a/tests/test_merge_gate_race.py +++ b/tests/test_merge_gate_race.py @@ -148,3 +148,63 @@ def test_tc24_red_catch_up_fails_and_releases_main_stays_green(race_repo, monkey assert _origin_main_sha(origin) == main_before # The lease was released on failure (a later task can proceed). assert merge_gate._read_lease(merge_gate._lease_path(repo)) is None + + +# --------------------------------------------------------------------------- +# ORCH-065 / TC-17: recovery — "rebase+re-test green, merge not done, process +# died" -> reaper requeues -> the merge re-drives the STANDARD path WITHOUT a +# second expensive re-test when safe (the branch is already up-to-date). AC-10. +# --------------------------------------------------------------------------- +def test_tc17_redrive_skips_expensive_retest_when_already_caught_up( + race_repo, monkeypatch +): + repo, origin = race_repo + main_before = _origin_main_sha(origin) + + # First pass: B catches up (real rebase onto C1) with a GREEN re-test. This is + # the work that completed before the process died — the lease is held, the + # branch is now caught up on origin. + retest_calls = [] + + def _retest(r, b): + retest_calls.append((r, b)) + return True, "re-test green" + + monkeypatch.setattr(merge_gate, "retest_branch", _retest) + passed, reason = check_branch_mergeable(repo, "ORCH-B", "feature/B") + assert passed is True + assert reason == "rebased onto main, re-test green" + assert len(retest_calls) == 1 # the expensive re-test ran ONCE + + # The process "died" before the merge: release the lease the way the reaper / + # reconciler recovery path would (the row is requeued; the branch stays caught + # up because the rebase was already pushed). + merge_gate.release_merge_lease(repo, "feature/B") + + # Re-drive (standard path) after recovery: the branch already contains + # origin/main, so branch_is_behind_main is False and the gate short-circuits to + # the up-to-date pass WITHOUT re-running the expensive rebase+re-test. + assert merge_gate.branch_is_behind_main(repo, "feature/B") is False + passed2, reason2 = check_branch_mergeable(repo, "ORCH-B", "feature/B") + assert passed2 is True + assert reason2 == "branch up-to-date with main" + assert len(retest_calls) == 1 # NOT re-run on the re-drive (no double cost) + # origin/main was never pushed by the gate across the whole recovery. + assert _origin_main_sha(origin) == main_before + + +def test_tc17_pr_already_merged_makes_redrive_a_noop(race_repo, monkeypatch): + """If the PR actually merged before the process died, the idempotency guard + reports it so the re-drive is a no-op (no second merge).""" + import httpx + repo, _ = race_repo + + class _R: + status_code = 200 + + @staticmethod + def json(): + return [{"merged": True}] + + monkeypatch.setattr(httpx, "get", lambda *a, **k: _R()) + assert merge_gate.pr_already_merged(repo, "feature/B") is True diff --git a/tests/test_merge_lease_reclaim.py b/tests/test_merge_lease_reclaim.py new file mode 100644 index 0000000..f9d421d --- /dev/null +++ b/tests/test_merge_lease_reclaim.py @@ -0,0 +1,138 @@ +"""ORCH-065: proactive stale/dead merge-lease reclaim (TC-10..TC-15). + +Exercises merge_gate.reclaim_stale_lease / pid_alive directly with lease files +written into a tmp repos_dir. No git ops run (reclaim only removes the lease +file). pid liveness is monkeypatched so 'dead'/'alive' are deterministic. +""" +import json +import os +import tempfile +import time + +import pytest + +os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch_lease.db") +os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir() +os.environ["ORCH_GITEA_TOKEN"] = "test-token" +os.environ["ORCH_PLANE_API_TOKEN"] = "test-token" + +from src import merge_gate + + +@pytest.fixture +def repos_dir(tmp_path, monkeypatch): + d = tmp_path / "repos" + d.mkdir() + monkeypatch.setattr(merge_gate.settings, "repos_dir", str(d)) + monkeypatch.setattr(merge_gate.settings, "lease_reclaim_enabled", True) + monkeypatch.setattr(merge_gate.settings, "merge_gate_repos", "") # self-hosting only + monkeypatch.setattr(merge_gate.settings, "merge_lock_timeout_s", 300) + return d + + +def _write_lease(repos_dir, repo, branch="feature/x", pid=1234, age_s=0): + path = os.path.join(str(repos_dir), f".merge-lease-{repo}.json") + holder = { + "branch": branch, + "work_item_id": "ORCH-1", + "task_id": 1, + "acquired_at": time.time() - age_s, + "pid": pid, + } + with open(path, "w", encoding="utf-8") as f: + f.write(json.dumps(holder)) + return path + + +def _no_telegram(monkeypatch): + import src.notifications as notif + monkeypatch.setattr(notif, "send_telegram", lambda *a, **k: None) + + +# --- TC-10: reclaim a lease with a DEAD pid, proactively -------------------- +def test_tc10_reclaim_dead_pid(repos_dir, monkeypatch): + _no_telegram(monkeypatch) + path = _write_lease(repos_dir, "orchestrator", pid=999999, age_s=0) + monkeypatch.setattr(merge_gate, "pid_alive", lambda pid: False) + assert merge_gate.reclaim_stale_lease("orchestrator") is True + assert not os.path.exists(path) # lease removed + + +# --- TC-11: reclaim by TTL is preserved ------------------------------------- +def test_tc11_reclaim_by_ttl(repos_dir, monkeypatch): + _no_telegram(monkeypatch) + # pid alive, but the lease is older than the TTL -> still reclaimed. + path = _write_lease(repos_dir, "orchestrator", pid=4321, age_s=999) + monkeypatch.setattr(merge_gate, "pid_alive", lambda pid: True) + assert merge_gate.reclaim_stale_lease("orchestrator") is True + assert not os.path.exists(path) + + +# --- TC-12: a LIVE lease within TTL is NOT released ------------------------- +def test_tc12_live_lease_protected(repos_dir, monkeypatch): + _no_telegram(monkeypatch) + path = _write_lease(repos_dir, "orchestrator", pid=4321, age_s=10) + monkeypatch.setattr(merge_gate, "pid_alive", lambda pid: True) + assert merge_gate.reclaim_stale_lease("orchestrator") is False + assert os.path.exists(path) # untouched + + +# --- TC-13: conditional — non-self-hosting repos are a no-op ---------------- +def test_tc13_non_scope_repo_noop(repos_dir, monkeypatch): + _no_telegram(monkeypatch) + path = _write_lease(repos_dir, "enduro-trails", pid=999999, age_s=999) + monkeypatch.setattr(merge_gate, "pid_alive", lambda pid: False) + assert merge_gate.reclaim_stale_lease("enduro-trails") is False + assert os.path.exists(path) # out of scope -> untouched + + +def test_tc13_merge_gate_repos_csv_scope(repos_dir, monkeypatch): + _no_telegram(monkeypatch) + monkeypatch.setattr(merge_gate.settings, "merge_gate_repos", "enduro-trails") + path = _write_lease(repos_dir, "enduro-trails", pid=999999, age_s=0) + monkeypatch.setattr(merge_gate, "pid_alive", lambda pid: False) + assert merge_gate.reclaim_stale_lease("enduro-trails") is True + assert not os.path.exists(path) + + +# --- TC-14: never-raise on a read/remove error ------------------------------ +def test_tc14_never_raise_on_read_error(repos_dir, monkeypatch): + _no_telegram(monkeypatch) + _write_lease(repos_dir, "orchestrator", pid=1, age_s=999) + + def boom(path): + raise OSError("simulated read failure") + + monkeypatch.setattr(merge_gate, "_read_lease", boom) + # Must not raise; returns False (could not reclaim). + assert merge_gate.reclaim_stale_lease("orchestrator") is False + + +def test_tc14_no_lease_file_is_noop(repos_dir, monkeypatch): + _no_telegram(monkeypatch) + assert merge_gate.reclaim_stale_lease("orchestrator") is False + + +# --- TC-15: kill-switch lease_reclaim_enabled=False ------------------------- +def test_tc15_kill_switch(repos_dir, monkeypatch): + _no_telegram(monkeypatch) + monkeypatch.setattr(merge_gate.settings, "lease_reclaim_enabled", False) + path = _write_lease(repos_dir, "orchestrator", pid=999999, age_s=999) + monkeypatch.setattr(merge_gate, "pid_alive", lambda pid: False) + assert merge_gate.reclaim_stale_lease("orchestrator") is False + assert os.path.exists(path) # proactive reclaim off -> untouched + + +# --- pid_alive semantics ---------------------------------------------------- +def test_pid_alive_dead_process(): + # PID 999999999 almost certainly does not exist. + assert merge_gate.pid_alive(999999999) is False + + +def test_pid_alive_self(): + assert merge_gate.pid_alive(os.getpid()) is True + + +def test_pid_alive_missing_pid_conservative(): + assert merge_gate.pid_alive(None) is True + assert merge_gate.pid_alive(0) is True diff --git a/tests/test_queue.py b/tests/test_queue.py index f6342e8..ce2d831 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -302,3 +302,58 @@ class TestWorkerConcurrency: assert count_running_jobs() == 0 counts = job_status_counts() assert counts["failed"] == 1 + + +# --------------------------------------------------------------------------- +# ORCH-065: job-reaper unblocks the shared queue (TC-09) + /queue block (TC-18) +# --------------------------------------------------------------------------- +class TestReaperUnblocksQueue: + def test_tc09_reap_unblocks_claim_at_concurrency_1(self, monkeypatch): + """A zombie 'running' row at max_concurrency=1 blocks every claim; once the + reaper reaps it the next queued job can be claimed (AC-2).""" + import src.merge_gate as mg + from src.job_reaper import JobReaper + + monkeypatch.setattr(db.settings, "reaper_dead_ticks", 1) + monkeypatch.setattr(mg, "pid_alive", lambda pid: False) # zombie pid dead + + # A zombie row stuck 'running' with a dead pid. + conn = db.get_db() + cur = conn.execute( + "INSERT INTO jobs (agent, repo, status, attempts, max_attempts, pid, " + "started_at) VALUES ('developer','r','running',2,2,999999,datetime('now'))" + ) + zombie = cur.lastrowid + conn.commit() + conn.close() + + # A second job waits in the queue behind it. + nxt = enqueue_job("analyst", "r") + + # At concurrency 1 the slot is fully occupied -> nothing else can run. + assert count_running_jobs() == 1 + + monkeypatch.setattr("src.notifications.send_telegram", lambda *a, **k: None) + JobReaper().reap_once() # dead pid, attempts>=max -> failed + + assert get_job(zombie)["status"] == "failed" + assert count_running_jobs() == 0 + # Queue is unblocked: the next job claims successfully. + claimed = claim_next_job() + assert claimed is not None and claimed["id"] == nxt + + def test_tc18_queue_endpoint_has_reaper_block(self): + """GET /queue exposes the reaper observability block (AC-15). + + Calls the endpoint coroutine directly (no lifespan / no background + threads / no network) so the test stays hermetic. + """ + import asyncio + import src.main as main + + body = asyncio.run(main.queue()) + assert "reaper" in body + reaper = body["reaper"] + for key in ("enabled", "interval", "last_run_ts", "reaped_total", + "last_reaped", "lease_reclaimed_total"): + assert key in reaper From dbf14e3d5a5adc896b0de13fb954086daab51aee Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 15:37:13 +0000 Subject: [PATCH 05/10] reviewer(ET): auto-commit from reviewer run_id=319 --- docs/work-items/ORCH-065/12-review.md | 79 +++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 docs/work-items/ORCH-065/12-review.md diff --git a/docs/work-items/ORCH-065/12-review.md b/docs/work-items/ORCH-065/12-review.md new file mode 100644 index 0000000..ee50896 --- /dev/null +++ b/docs/work-items/ORCH-065/12-review.md @@ -0,0 +1,79 @@ +--- +type: review +work_item_id: ORCH-065 +verdict: REQUEST_CHANGES +version: 1 +--- + +# Review ORCH-065 + +## Summary + +Задача закрывает три связанных класса отказов «процесс/поток умер, а ресурс остался +захваченным навсегда»: zombie jobs (A), залипший merge-lease (B), неидемпотентная +финализация merge (C). Реализация качественная: новый daemon-поток `src/job_reaper.py` +по образцу `reconciler` (never-raise, kill-switch, снимок в `/queue`), трёхуровневая +liveness (Tier-1 мёртвый pid + streak, Tier-2 exit_code, Tier-3 backstop), атомарный +reap-claim `reap_running_job(... WHERE status='running')`, gate-driven advance через +неизменный `_try_advance_stage`, проактивный реклейм lease (`pid_alive` + +`reclaim_stale_lease`), колонка `jobs.pid` через идемпотентный `_ensure_column`. +Инварианты соблюдены: `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/БАГ-8/exit-коды хука — +без изменений; реклейм lease — только удаление файла, без git-операций; прод-контейнер +не трогается. Документация (README, internals, ADR, глобальный adr-0011, CHANGELOG, +.env.example) обновлена в этом же PR. `pytest tests/ -q` — **742 passed**. + +Блокер один: дефект Проблемы C (FR-3.2 / AC-11) — детерминированный guard +`pr_already_merged` реализован и юнит-протестирован, но **не подключён ни к одному +реальному пути merge**, при этом ADR и golden-source-документация утверждают обратное. + +## Findings + +### P0 — Blocker +- нет + +### P1 — Must fix +- [ ] **`pr_already_merged` — мёртвый код в проде; ADR/доки утверждают, что он + консультируется (FR-3.2 / AC-11, ADR-001 Р-3).** Функция `merge_gate.pr_already_merged` + определена (`src/merge_gate.py:447`) и покрыта unit-тестами (TC-16/TC-17), но grep по + `src/` показывает **ноль вызовов** из продакшен-кода: ни в `stage_engine.py`, ни в + `webhooks/gitea.py`, ни в `qg/checks.py`, ни в промпте `.openclaw/agents/deployer.md`. + Фактический merge PR в `main` выполняет LLM-агент deployer, и его промпт guard не + упоминает. Тем временем: + - ADR-001 Р-3: «Путь слияния (deployer/merge) **консультируется** с этим guard ПЕРЕД + повторным merge: PR уже слит → no-op (без второго merge и без ошибки)»; + - `docs/architecture/README.md:226`: «добавлен never-raise guard `pr_already_merged` … + уже слит = no-op»; + - CHANGELOG: «`pr_already_merged(...)` — guard **перед повторным merge** при re-drive». + + Следствие: при re-drive (reaper → `queued` → переисполнение стадии `deploy`) с уже + слитым PR гарантия AC-11 «без второго слияния» **не обеспечена детерминированно** — + deployer-агент повторно попытается слить уже слитый PR (Gitea вернёт ошибку + merge → риск ложного БАГ-8 отката). `branch_is_behind_main`-короткое замыкание + (TC-17) делает идемпотентным лишь дорогой rebase+re-test merge-гейта, но НЕ сам акт + слияния. Это прямое расхождение реализации с ADR и golden-source. + + Требуется одно из: + 1. **Подключить guard** к пути merge (инструкция в промпте deployer «перед merge + проверь `pr_already_merged` — уже слит → пометь стадию успешной без повторного + merge», либо детерминированная Python-проверка перед merge), чтобы AC-11 + выполнялся реально; **либо** + 2. если идемпотентность намеренно достигается только gate-driven advance + + `branch_is_behind_main` (а `pr_already_merged` оставлен как helper «на будущее»), + **исправить ADR-001 Р-3, README и CHANGELOG**, убрав утверждение, что путь merge + консультируется с guard, и явно описав, чем именно покрывается «без второго + слияния» из AC-11. + +### P2 — Should fix +- нет + +## Документация + +Обновлена корректно и в этом же PR (AC-17 PASS по составу артефактов): +`docs/architecture/README.md` (раздел про job-reaper + lease-reclaim, таблицы БД и +`/queue`), `docs/architecture/internals.md`, `docs/architecture/adr/adr-0011-*.md` +(+ запись в `adr/README.md`), `docs/work-items/ORCH-065/06-adr/ADR-001-*.md`, +`CHANGELOG.md`, `.env.example` (флаги `ORCH_REAPER_*` / `ORCH_LEASE_RECLAIM_ENABLED`). + +Оговорка: содержание документации про `pr_already_merged` фактически неверно (см. P1) — +guard описан как «консультируемый перед merge», тогда как он нигде не вызывается. +До устранения P1 документация в этой части не является достоверным golden-source. From a6b444c356a037800ebd223beb6fd89ce1d24178 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 15:45:24 +0000 Subject: [PATCH 06/10] fix(merge): wire pr_already_merged guard into deployer merge path (idempotent re-merge) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pr_already_merged guard was defined + unit-tested but consulted by zero production code, while ADR-001 Р-3 / README / CHANGELOG claimed the merge path consults it before a repeat merge (reviewer P1, ORCH-065 attempt 2/3). The actual merge actor is the LLM deployer agent (it merges the feature PR at the start of the `deploy` stage), so on a reaper re-drive of an already-merged PR the deployer would blindly re-merge → Gitea error → false БАГ-8 rollback; AC-11 ("no second merge") was not met deterministically. Wire the guard at the real consultation point — the deployer prompt — so it runs merge_gate.pr_already_merged before any (re-)merge and no-ops when the PR is already merged. check_branch_mergeable is left untouched (AC-13: check_* behaviour unchanged; it runs on the first deploy-staging→deploy edge, not on a deploy-stage re-drive where the second-merge risk lives). - .openclaw/agents/deployer.md: idempotent pre-merge guard step + general rule. - src/merge_gate.py: docstring names the deployer-prompt consultation point. - docs/architecture/README.md, CHANGELOG.md: state the consultation point so golden-source matches implementation. - tests/test_merge_gate.py: regression test asserting the deployer prompt wires the guard (so it can't silently become dead code again). pytest tests/ -q: 743 passed. Refs: ORCH-065 Co-Authored-By: Claude Opus 4.7 --- .openclaw/agents/deployer.md | 27 +++++++++++++++++++++++++++ CHANGELOG.md | 2 +- docs/architecture/README.md | 9 +++++++-- src/merge_gate.py | 9 +++++++++ tests/test_merge_gate.py | 29 +++++++++++++++++++++++++++++ 5 files changed, 73 insertions(+), 3 deletions(-) diff --git a/.openclaw/agents/deployer.md b/.openclaw/agents/deployer.md index 3e74082..bb0b55b 100644 --- a/.openclaw/agents/deployer.md +++ b/.openclaw/agents/deployer.md @@ -91,6 +91,30 @@ The verdict contract is unchanged: `docs/work-items//14-deploy-log frontmatter field `deploy_status: SUCCESS|FAILED` (the gate `check_deploy_status` parses ONLY this). **What changed (ORCH-36): WHO and WHEN writes that verdict, for the self-hosting repo.** +### ⚠️ Idempotent merge guard — consult `pr_already_merged` BEFORE merging (ORCH-065) + +The `deploy` stage can be **re-driven**: if a process/monitor thread died after the PR +merged but before the job finalised, the job-reaper requeues it and this stage runs **again** +(ADR-001 ORCH-065, Р-3). A blind second merge of an already-merged PR makes Gitea return a +merge error → a false БАГ-8 rollback. To stay idempotent, **before you merge the feature +branch PR into `main`, consult the deterministic guard** `merge_gate.pr_already_merged(repo, branch)`: + +```bash +# Already merged? exit 0 = yes (skip the merge), exit 1 = no (merge normally). +python3 -c "import sys; from src.merge_gate import pr_already_merged; \ +sys.exit(0 if pr_already_merged('', '') else 1)" && MERGED=1 || MERGED=0 +``` + +- `MERGED=1` (PR already merged) → **do NOT merge again** (no second merge, no error). + Treat the merge as already done and continue to write the deploy verdict + (`deploy_status: SUCCESS` once the deploy itself is health-ok). This is the AC-11 no-op. +- `MERGED=0` (not merged) → merge the PR normally, then proceed. + +The guard is **never-raise** (any Gitea/parse error → `False` → "not known-merged", so a real +merge is never silently skipped). This is the single consultation point ADR-001 Р-3 / +README / CHANGELOG refer to: the **merge path (deployer/merge) consults the guard before a +(repeat) merge**. + ### Self-hosting repo (`orchestrator`) — you do NOT deploy yourself For `orchestrator` the `deploy` stage is orchestrated by **deterministic code** in @@ -124,4 +148,7 @@ deploys go through `scripts/orchestrator-deploy-hook.sh` (parametrised; defaults - Always write machine-readable YAML frontmatter — the quality gates parse ONLY the frontmatter fields, never the body prose. - Never push directly to `main`. Always use a PR or the artifact merge pattern. +- **Idempotent merge (ORCH-065):** before any (re-)merge of a feature PR into `main`, consult + `merge_gate.pr_already_merged(repo, branch)` (see the `deploy` stage section). Already merged + → no second merge, no error — the stage is a no-op on the merge and proceeds to its verdict. - Never modify `.env`, `.env.staging`, `docker-compose.yml`, or production infrastructure. diff --git a/CHANGELOG.md b/CHANGELOG.md index e814af7..d94d099 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ## [Unreleased] ### Added -- **Job-reaper + проактивный реклейм протухшего merge-lease + идемпотентная финализация merge** (ORCH-065): закрыт класс инцидентов «zombie jobs» — статус job выставлялся ТОЛЬКО в живом процессе launcher'а, поэтому гибель процесса (OOM/рестарт инстанса/segfault Claude-CLI) оставляла строку `jobs.status='running'` навсегда; при `max_concurrency=1` один такой зомби намертво блокировал очередь ВСЕХ проектов (self-hosting: enduro-trails встаёт из-за зомби ORCH-задачи). Плюс два смежных дефекта: застрявший merge-lease (`.merge-lease-.json` реклеймился лишь лениво по TTL при чужом acquire, живость pid-holder'а не проверялась) и неидемпотентная финализация merge (rebase+re-test зелёные, но процесс умер до самого merge → нет повторного проигрывания). Решение — новый фоновый daemon-поток **`src/job_reaper.py`** (контракт «never-raise на единицу работы», паттерн `reconciler`/`queue_worker`): периодический тик (`reaper_interval_s`) сканирует `running`-jobs трёхуровневой проверкой живости (ADR Р-1): **Tier-1** мёртвый pid (`os.kill(pid, 0)` → `ProcessLookupError`) с анти-false-positive порогом `reaper_dead_ticks` подряд-мёртвых тиков (стрик в памяти); **Tier-2** `agent_runs.exit_code` записан, но job всё ещё `running` (исход известен — процесс завершился, но не успел флипнуть статус); **Tier-3** backstop-потолок `reaper_max_running_s`. Единственная мутирующая запись reaper'а — атомарный терминальный флип через `db.reap_running_job(... WHERE status='running')` (rowcount==1 у победителя, проигравший в гонке с `requeue_running_jobs`/launcher видит rowcount==0 — без двойной обработки, TC-06). Для Tier-2 exit0 источник истины — канонический QG (не «exit0»): gate-driven advance (`_gate_driven_advance` → штатный `launcher._try_advance_stage`, кандидат-стадии агента из `STAGE_TRANSITIONS`) проигрывается ПЕРЕД флипом — зелёный гейт → `done`, красный → путь неуспеха (requeue в пределах `attempts.json` реклеймился лишь лениво по TTL при чужом acquire, живость pid-holder'а не проверялась) и неидемпотентная финализация merge (rebase+re-test зелёные, но процесс умер до самого merge → нет повторного проигрывания). Решение — новый фоновый daemon-поток **`src/job_reaper.py`** (контракт «never-raise на единицу работы», паттерн `reconciler`/`queue_worker`): периодический тик (`reaper_interval_s`) сканирует `running`-jobs трёхуровневой проверкой живости (ADR Р-1): **Tier-1** мёртвый pid (`os.kill(pid, 0)` → `ProcessLookupError`) с анти-false-positive порогом `reaper_dead_ticks` подряд-мёртвых тиков (стрик в памяти); **Tier-2** `agent_runs.exit_code` записан, но job всё ещё `running` (исход известен — процесс завершился, но не успел флипнуть статус); **Tier-3** backstop-потолок `reaper_max_running_s`. Единственная мутирующая запись reaper'а — атомарный терминальный флип через `db.reap_running_job(... WHERE status='running')` (rowcount==1 у победителя, проигравший в гонке с `requeue_running_jobs`/launcher видит rowcount==0 — без двойной обработки, TC-06). Для Tier-2 exit0 источник истины — канонический QG (не «exit0»): gate-driven advance (`_gate_driven_advance` → штатный `launcher._try_advance_stage`, кандидат-стадии агента из `STAGE_TRANSITIONS`) проигрывается ПЕРЕД флипом — зелёный гейт → `done`, красный → путь неуспеха (requeue в пределах `attempts post_deploy_5xx_threshold`; иначе `HEALTHY` — одиночный глюк не откатывает), `decide_action` (self-hosting → ВСЕГДА `ALERT_ONLY`; не-self + `post_deploy_auto_rollback=true` → `ROLLBACK`; иначе `ALERT_ONLY`), `map_rollback_exit_code` (`0→ROLLBACK_OK`, иначе `ROLLBACK_FAILED`), sentinel-state хелперы (`armed`/`series`/`done` под `/.post-deploy-state-//`, restart-safe счётчики), `build_rollback_command`/`run_rollback` (ssh-хук `--rollback` с прод-env, синхронно — только для не-self), `build/write_post_deploy_log` (артефакт `16-post-deploy-log.md`), `arm_monitor` (идемпотентный арм + первый отложенный job), `status` (снимок для `/queue`). **Механизм наблюдения — reserved-agent job `post-deploy-monitor`** (детерминированный, no-LLM, калька `deploy-finalizer`, НЕ стадия и НЕ daemon): арм в `stage_engine.advance_stage` в блоке `next_stage == "done"` ПОСЛЕ terminal-sync/release-lease (`post_deploy.arm_monitor`, sentinel `armed` = идемпотентность при двойном webhook/reconciler/finalizer); один тик = один job — перехват в `agents/launcher.launch_job` ДО `_spawn` → `stage_engine.run_post_deploy_monitor` (один опрос → append в `series` → `classify` → перепостановка с задержкой `available_at_delay_s` ИЛИ реакция+артефакт+`mark_done`); бюджет тиков `window_s/interval_s` (анти-livelock). **Self-hosting safety (BR-5):** для `orchestrator` тик НИКОГДА не откатывает/рестартит прод-контейнер — реакция всегда `ALERT_ONLY` (громкий Telegram + Plane-коммент с запросом ручного approve); авто-rollback хуком `--rollback` — только для не-self репо при `post_deploy_auto_rollback=true` (целевой контейнер ≠ orchestrator). Наблюдаемость — блок `post_deploy` в `GET /queue` (enabled/window/interval/активные наблюдения). Артефакт `16-post-deploy-log.md` (YAML-frontmatter `post_deploy_status`/`action_taken`/`window_s`/`checks_total`/`checks_failed`) — машиночитаемо для петли уроков ORCH-8; best-effort. Новые настройки: `ORCH_POST_DEPLOY_MONITOR_ENABLED` (true, kill-switch), `ORCH_POST_DEPLOY_REPOS` (CSV; пусто → только self-hosting), `ORCH_POST_DEPLOY_WINDOW_S` (900), `ORCH_POST_DEPLOY_INTERVAL_S` (30), `ORCH_POST_DEPLOY_FAIL_THRESHOLD` (3), `ORCH_POST_DEPLOY_5XX_THRESHOLD` (0.5), `ORCH_POST_DEPLOY_AUTO_ROLLBACK` (false), `ORCH_POST_DEPLOY_BASE_URL` (http://localhost:8500); параметры отката переиспользуют `deploy_prod_*`. Инварианты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status`, terminal-sync `deploy→done`, merge-gate, exit-код-контракт хука (0/1/2), схема БД (без миграций; состояние — sentinel-файлы). Условность как ORCH-35/36/43/58. ADR `docs/work-items/ORCH-021/06-adr/ADR-001-post-deploy-monitor.md`, глобальный `docs/architecture/adr/adr-0010-post-deploy-monitor.md`. Тесты: `tests/test_post_deploy.py`, `tests/test_post_deploy_integration.py`. - **Провенанс staging-образа перед BUILD-ONCE retag в прод (свежесть артефакта, INV-FRESH)** (ORCH-058): BUILD-ONCE retag (ORCH-036) промоутит staging-образ (`orchestrator-orchestrator-staging`) в прод **без rebuild**, полагаясь на «образ свеж и провалидирован» — гарантии не было: конвейер нигде не пересобирал staging-образ из провалидированного коммита, поэтому retag мог тихо промоутнуть УСТАРЕВШИЙ образ (инцидент LESSONS_ORCH-036 п.4 — зелёный деплой молча откатывал прод). Закрыто **двумя слоями (defense in depth), только для self-hosting**. Новый модуль `src/image_freshness.py` (контракт «never raise», по образцу `merge_gate`): `provenance_verdict` (чистая функция вердикта match/mismatch/fail-closed), `validated_revision` (`git rev-parse HEAD` в worktree валидированного коммита — единый якорь и для штампа A, и для `EXPECTED_REVISION` B), `image_revision` (OCI-лейбл `org.opencontainers.image.revision` через `docker image inspect`, ``/ошибка → пусто), `rebuild_staging_image` (ssh-хук `--build-staging`), `image_freshness_applies` (условность), `check_staging_image_fresh` (композитный QG). **Strategy A (liveness):** новый детерминированный QG-под-чек `check_staging_image_fresh` (зарегистрирован в `QG_CHECKS`, `src/qg/checks.py`) на ребре `deploy-staging → deploy` ПОСЛЕ merge-gate и ДО Phase A — пересобирает staging-образ из worktree валидированного коммита (хук `--build-staging`, `--build-arg GIT_SHA=`), пересоздаёт 8501 и прогоняет `staging_check.py --mode stub` против свежего 8501 (health + e2e, внутри staging-контейнера через `docker exec` — канон ORCH-048) → валидируем РОВНО тот артефакт (build + e2e), что промоутится в прод (AC-4); FAIL/не-ноль staging_check → откат на `development` (как merge-gate, кап `MAX_DEVELOPER_RETRIES`). `rebuild_staging_image` пробрасывает в хук **явный** staging-таргет (service/port/profile/container), исключая дрейф на прод 8500. Сборки/recreate/validate — **только staging (8501)**, прод (8500) не трогается. **Strategy B (safety):** `Dockerfile` штампует `LABEL org.opencontainers.image.revision=$GIT_SHA` (`ARG GIT_SHA`); `build_deploy_command` (`src/self_deploy.py`) пробрасывает `EXPECTED_REVISION`; хост-хук шагом 2b ПЕРЕД `docker tag` fail-closed сверяет лейбл `revision` у `SOURCE_IMAGE` с `EXPECTED_REVISION` — несовпадение / пустой лейбл / ошибка inspect → `exit 1` (FAILED → БАГ-8 откат), делает тихий промоут устаревшего образа структурно невозможным даже при проигравшей гонку/отключённой A. Хост-хук `scripts/orchestrator-deploy-hook.sh` расширен **обратно-совместимым** режимом `--build-staging` (пересборка+recreate staging, exit 0/1) и fail-closed guard'ом (активен только при заданном `EXPECTED_REVISION`). Единый kill-switch `ORCH_IMAGE_FRESHNESS_ENABLED` (true) включает A+B **как целое** (нет «B без A» = вечного fail-fast); область — `ORCH_IMAGE_FRESHNESS_REPOS` (CSV; пусто → только self-hosting `orchestrator`). Контракты НЕ менялись: `STAGE_TRANSITIONS` (под-гейт ребра, не стадия), exit-code-контракт хука (0/1/2), `map_exit_code_to_status`, `check_deploy_status`/`_parse_deploy_status`, БАГ-8, terminal-sync, merge-gate; схема БД — без миграций. ADR `docs/work-items/ORCH-058/06-adr/ADR-001-staging-image-provenance.md`, глобальный `docs/architecture/adr/adr-0008-staging-image-provenance.md`. Документация: `docs/architecture/README.md`, `docs/operations/DEPLOY_HOOK.md`, `docs/operations/STAGING.md`, `docs/operations/INFRA.md`, `.env.example`. Тесты: `tests/test_image_freshness.py`, `tests/test_deploy_hook_provenance.py`, `tests/test_deploy_build_once.py` (TC-06), `tests/test_deploy_hook_mapping.py` (TC-09), `tests/test_stage_engine.py::TestImageFreshnessGate`, `tests/test_qg_registry_snapshot.py`, `tests/test_config.py`. - **Исполняемый самодеплой стадии `deploy` (стадия дёргает хост-хук, manual-approve)** (ORCH-036): стадия `deploy` перестаёт быть «бумажной» — для self-hosting репозитория `orchestrator` `deploy_status: SUCCESS` означает ДОКАЗАННЫЙ health-ok реального рестарта прод-контейнера (8500), а не декларацию LLM. Критический путь self-restart детерминирован (без LLM), по образцу merge-gate ORCH-043, и разбит на три фазы (`src/stage_engine.py` + новый модуль `src/self_deploy.py`): **Фаза A** (вход в `deploy`) — вместо запуска прод-deployer'а при `deploy_require_manual_approve=true` задача переводится в approval-pending (`set_issue_in_review`) и ждёт ручного approve; restart-safe маркер `approve-requested`. **Фаза B** (человек ставит статус Plane → `Approved`; `advance_stage(deploy, finished_agent=None)`) — запускается **detached host-процесс** (`ssh + setsid` → `scripts/orchestrator-deploy-hook.sh`, чтобы рестарт 8500 пережил гибель контейнера; орк НЕ убивает себя из docker.sock) с build-once retag staging-образа (`SOURCE_IMAGE`), ставится детерминированный **finalizer-job**; маркер `initiated` — идемпотентность повторного Approved. **Фаза C** (`run_deploy_finalizer`, reserved-agent `deploy-finalizer`, claim'ится новым контейнером после рестарта) — читает sentinel `result` (exit-code хука, записан host-обёрткой), `not-ready` → defer (бюджет `deploy_finalize_max_attempts`, restart-safe по `task_content`), маппит `0→SUCCESS / 1|2|иное→FAILED` (чистая функция `map_exit_code_to_status`, unit-тест), пишет `14-deploy-log.md` и вызывает `advance_stage(deploy, finished_agent="deployer")` → существующие контракты: `SUCCESS → done` + release merge-lease, `FAILED → откат БАГ-8 на development` + `set_issue_blocked`. Уведомления Plane+Telegram на approve-request / initiate / success / rollback (BR-5, ни одного «молчаливого» деплоя). Хост-хук `scripts/orchestrator-deploy-hook.sh` расширен **обратно-совместимым** `SOURCE_IMAGE`: при заданном — `docker tag $SOURCE_IMAGE $TARGET_IMAGE` перед `up -d --no-build` (деплой РОВНО протестированного образа, без `docker build`); не задан → прежнее поведение; exit-code-контракт (0/1/2) и health-loop (10×6с, авто-rollback) не тронуты. Restart-safe состояние — sentinel-файлы (`/.deploy-state-//`), без миграции БД. Условность как ORCH-35: реальный самодеплой только для `is_self_hosting_repo("orchestrator")`; прочие репо (enduro-trails) — прежний синхронный ssh-путь агентом. Контракты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status` (frontmatter-only), terminal-sync `deploy→done`, merge-gate (ORCH-43), БАГ-8. Флаг `DEPLOY_REQUIRE_MANUAL_APPROVE` остаётся `true` (полный авто — отдельная задача ORCH-54). Новые настройки: `ORCH_DEPLOY_REQUIRE_MANUAL_APPROVE` (true), `ORCH_DEPLOY_SSH_USER`, `ORCH_DEPLOY_SSH_HOST`, `ORCH_DEPLOY_HOOK_SCRIPT`, `ORCH_DEPLOY_PROD_SOURCE_IMAGE`, `ORCH_DEPLOY_PROD_TARGET_SERVICE/PORT/IMAGE`, `ORCH_DEPLOY_FINALIZE_DELAY_S`, `ORCH_DEPLOY_FINALIZE_MAX_ATTEMPTS`. ADR `docs/work-items/ORCH-036/06-adr/ADR-001-executable-self-deploy.md`, глобальный `docs/architecture/adr/adr-0007-executable-self-deploy.md`. Документация: `.openclaw/agents/deployer.md` (стадия `deploy` = вызов хука, запрет self-restart), `docs/operations/INFRA.md`, `docs/operations/DEPLOY_HOOK.md`. Тесты: `tests/test_deploy_hook_mapping.py`, `tests/test_deploy_approve.py`, `tests/test_deploy_routing.py`, `tests/test_deploy_rollback.py`, `tests/test_deploy_notifications.py`, `tests/test_deploy_build_once.py`, `tests/test_deploy_terminal_sync.py`, `tests/test_staging_precondition.py`, `tests/test_deploy_hook_rollback_sim.py`. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index a84c865..20b424c 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -224,7 +224,12 @@ ORCH-065 вводит фоновый watchdog, чтобы смерть проц - **Идемпотентная финализация merge** — без новой merge-логики: re-drive через reaper→`queued`→переисполнение стадии / reconciler; дорогие шаги не повторяются (`branch_is_behind_main==False`); добавлен never-raise guard `pr_already_merged` - (читает состояние PR) — уже слит = no-op. + (читает состояние PR) — уже слит = no-op. **Консультируется самим merge-актором:** + фактический merge PR в `main` делает агент `deployer` (в начале стадии `deploy`), + поэтому wiring — в его промпте `.openclaw/agents/deployer.md`, который вызывает + `pr_already_merged` ПЕРЕД любым (повторным) merge (AC-11). Чек `check_branch_mergeable` + НЕ меняется (AC-13): он на ПЕРВОМ ребре `deploy-staging → deploy`, а риск второго + merge — на re-drive самой стадии `deploy`. - **Схема БД:** единственное изменение — `jobs.pid INTEGER` через идемпотентный `_ensure_column` (live-safe). `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_*`, БАГ-8, exit-коды хука, файл-схема lease — без изменений. @@ -295,4 +300,4 @@ ORCH-065 вводит фоновый watchdog, чтобы смерть проц Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.md). --- -*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py; флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест).* +*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест).* diff --git a/src/merge_gate.py b/src/merge_gate.py index 246aeb8..dd14251 100644 --- a/src/merge_gate.py +++ b/src/merge_gate.py @@ -453,6 +453,15 @@ def pr_already_merged(repo: str, branch: str) -> bool: merge-related helper and it does NOT merge — it only READS the PR state via the existing Gitea client, so it does not introduce duplicate merge logic. + Consultation point: the actual merge actor is the **deployer agent** (it merges + the feature PR at the start of the ``deploy`` stage — see webhooks/gitea.py), + so the wiring lives in the deployer prompt (``.openclaw/agents/deployer.md``), + which runs this exact function before any (re-)merge. The merge-gate quality + check (``qg.checks.check_branch_mergeable``) is intentionally NOT modified + (ORCH-065 AC-13: ``check_*`` behaviour unchanged) — it runs on the FIRST + deploy-staging -> deploy edge and does not re-run on a ``deploy``-stage re-drive, + which is exactly where the second-merge risk lives. + Queries Gitea ``GET /repos/{owner}/{repo}/pulls?state=all&head=`` and reports True when any matching PR has ``merged == True``. Never raises (AC-9): any HTTP/parse error -> ``False`` (conservative: "not known-merged" lets the diff --git a/tests/test_merge_gate.py b/tests/test_merge_gate.py index 7554bb9..6c692bf 100644 --- a/tests/test_merge_gate.py +++ b/tests/test_merge_gate.py @@ -353,3 +353,32 @@ def test_tc16_pr_non_200_false(monkeypatch): httpx, "get", lambda *a, **k: _FakeResp(500, None), ) assert merge_gate.pr_already_merged("orchestrator", "feature/x") is False + + +# --------------------------------------------------------------------------- +# ORCH-065 / TC-16 (wiring): the merge path consults the guard. +# +# pr_already_merged is consulted by the actual merge actor — the deployer agent +# (webhooks/gitea.py: "the deployer merges the PR at the START of its run"). The +# `deploy` stage can be re-driven by the job-reaper, so the deployer prompt MUST +# instruct an idempotent pre-merge consult of pr_already_merged (ADR-001 Р-3 / +# README / CHANGELOG). This test fails if that wiring regresses, so the guard can +# never silently become dead code again while the docs claim it is consulted. +# --------------------------------------------------------------------------- +def test_tc16_deployer_prompt_consults_guard(): + """The deployer prompt (merge path) wires the idempotent merge guard.""" + repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + prompt_path = os.path.join(repo_root, ".openclaw", "agents", "deployer.md") + with open(prompt_path, "r", encoding="utf-8") as f: + prompt = f.read() + + # The guard function is named and the prompt instructs consulting it BEFORE merge. + assert "pr_already_merged" in prompt, "deployer prompt must name the guard" + lowered = prompt.lower() + assert "before" in lowered and "merge" in lowered, ( + "deployer prompt must instruct consulting the guard BEFORE merging" + ) + # The idempotent no-op contract (already merged -> no second merge) is documented. + assert "no second merge" in lowered, ( + "deployer prompt must document the already-merged no-op (AC-11)" + ) From 9b7c855df374dd711f87fe4a91a162ffa461aede Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 15:52:26 +0000 Subject: [PATCH 07/10] reviewer(ET): auto-commit from reviewer run_id=321 --- docs/work-items/ORCH-065/12-review.md | 126 ++++++++++++++++---------- 1 file changed, 76 insertions(+), 50 deletions(-) diff --git a/docs/work-items/ORCH-065/12-review.md b/docs/work-items/ORCH-065/12-review.md index ee50896..818dce7 100644 --- a/docs/work-items/ORCH-065/12-review.md +++ b/docs/work-items/ORCH-065/12-review.md @@ -2,7 +2,7 @@ type: review work_item_id: ORCH-065 verdict: REQUEST_CHANGES -version: 1 +version: 2 --- # Review ORCH-065 @@ -11,20 +11,24 @@ version: 1 Задача закрывает три связанных класса отказов «процесс/поток умер, а ресурс остался захваченным навсегда»: zombie jobs (A), залипший merge-lease (B), неидемпотентная -финализация merge (C). Реализация качественная: новый daemon-поток `src/job_reaper.py` -по образцу `reconciler` (never-raise, kill-switch, снимок в `/queue`), трёхуровневая -liveness (Tier-1 мёртвый pid + streak, Tier-2 exit_code, Tier-3 backstop), атомарный -reap-claim `reap_running_job(... WHERE status='running')`, gate-driven advance через -неизменный `_try_advance_stage`, проактивный реклейм lease (`pid_alive` + -`reclaim_stale_lease`), колонка `jobs.pid` через идемпотентный `_ensure_column`. -Инварианты соблюдены: `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/БАГ-8/exit-коды хука — -без изменений; реклейм lease — только удаление файла, без git-операций; прод-контейнер -не трогается. Документация (README, internals, ADR, глобальный adr-0011, CHANGELOG, -.env.example) обновлена в этом же PR. `pytest tests/ -q` — **742 passed**. +финализация merge (C). Реализация в целом качественная: новый daemon-поток +`src/job_reaper.py` по образцу `reconciler` (never-raise, kill-switch, снимок в +`/queue`), трёхуровневая liveness, атомарный `reap_running_job(... WHERE status='running')`, +проактивный реклейм lease (`pid_alive` + `reclaim_stale_lease`), идемпотентный guard +`pr_already_merged`, колонка `jobs.pid` через идемпотентный `_ensure_column`. Инварианты +сохранены: `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/БАГ-8/exit-коды хука не тронуты +(коммиты ORCH-065 не касаются `stages.py`/`qg/checks.py`/`stage_engine.py`); реклейм +lease — только удаление файла, без git-операций. Документация (README, internals, ADR, +глобальный adr-0011, CHANGELOG, .env.example) обновлена в этом же PR. `pytest tests/ -q` +— **743 passed**. -Блокер один: дефект Проблемы C (FR-3.2 / AC-11) — детерминированный guard -`pr_already_merged` реализован и юнит-протестирован, но **не подключён ни к одному -реальному пути merge**, при этом ADR и golden-source-документация утверждают обратное. +Прошлый блокер v1 (guard `pr_already_merged` не подключён к пути merge) **устранён** +коммитом `aa46e5d`: промпт `.openclaw/agents/deployer.md` теперь предписывает консультировать +`pr_already_merged` ПЕРЕД любым (повторным) merge — AC-11 wiring на месте. + +Новый блокер: гонка Tier-2 reaper'а с живым monitor-потоком при штатной финализации — +порядок «side effects → atomic claim» нарушает собственный контракт ADR-001 Р-1 и может +дать дубль-advance / дубль-enqueue следующей стадии (FR-1.2/FR-1.3/AC-3/AC-4). ## Findings @@ -32,48 +36,70 @@ reap-claim `reap_running_job(... WHERE status='running')`, gate-driven advance - нет ### P1 — Must fix -- [ ] **`pr_already_merged` — мёртвый код в проде; ADR/доки утверждают, что он - консультируется (FR-3.2 / AC-11, ADR-001 Р-3).** Функция `merge_gate.pr_already_merged` - определена (`src/merge_gate.py:447`) и покрыта unit-тестами (TC-16/TC-17), но grep по - `src/` показывает **ноль вызовов** из продакшен-кода: ни в `stage_engine.py`, ни в - `webhooks/gitea.py`, ни в `qg/checks.py`, ни в промпте `.openclaw/agents/deployer.md`. - Фактический merge PR в `main` выполняет LLM-агент deployer, и его промпт guard не - упоминает. Тем временем: - - ADR-001 Р-3: «Путь слияния (deployer/merge) **консультируется** с этим guard ПЕРЕД - повторным merge: PR уже слит → no-op (без второго merge и без ошибки)»; - - `docs/architecture/README.md:226`: «добавлен never-raise guard `pr_already_merged` … - уже слит = no-op»; - - CHANGELOG: «`pr_already_merged(...)` — guard **перед повторным merge** при re-drive». +- [ ] **Tier-2 реапит живой, штатно финализирующийся job; side-effects идут ДО + атомарного claim (нарушение ADR-001 Р-1, риск дубль-advance/дубль-enqueue).** + В `JobReaper._reap_job` (`src/job_reaper.py:177`) ветка Tier-2 срабатывает на ЛЮБОЙ + `running`-job, у которого в `agent_runs` уже записан `exit_code` — **без grace и без + какого-либо признака смерти monitor'а**. Но именно это состояние («exit_code записан, + job ещё running») — нормальное окно финализации живого monitor-потока: `_monitor_agent` + пишет `exit_code`, затем выполняет git commit/push (+PR), БАГ-8-проверку, **сетевые + usage-комментарии в Plane** (секунды-десятки секунд), и лишь потом `_try_advance_stage` + → `_finalize_job`. pid агента (`jobs.pid`) к этому моменту уже мёртв (процесс завершён + до записи exit_code), поэтому отличить «monitor умер» от «monitor жив и финализирует» + по pid невозможно, а reaper тикает каждые `reaper_interval_s` (60с). Доступный для + grace `finished_at_run` запрашивается в `get_running_jobs`, но **не используется** + (помечен «debug only», `src/db.py`). - Следствие: при re-drive (reaper → `queued` → переисполнение стадии `deploy`) с уже - слитым PR гарантия AC-11 «без второго слияния» **не обеспечена детерминированно** — - deployer-агент повторно попытается слить уже слитый PR (Gitea вернёт ошибку - merge → риск ложного БАГ-8 отката). `branch_is_behind_main`-короткое замыкание - (TC-17) делает идемпотентным лишь дорогой rebase+re-test merge-гейта, но НЕ сам акт - слияния. Это прямое расхождение реализации с ADR и golden-source. + Усугубляет дефект порядок в `_reap_known_outcome` (`src/job_reaper.py:206`): для exit0 + сначала вызывается `_gate_driven_advance(job)` (побочные эффекты — `_try_advance_stage` + → `advance_stage` → `enqueue_job` следующей стадии), и **только потом** атомарный + `reap_running_job(..., "done")`. Это прямо противоречит ADR-001 Р-1: «Атомарный + reap-claim. **Перед любым действием с побочными эффектами** reaper атомарно + «застолбляет» строку тем же приёмом, что `claim_next_job`». Поскольку claim стоит + ПОСЛЕ side-effects, его guard `WHERE status='running'` не сериализует advance: даже + проигравший гонку reaper (rowcount==0) уже успел выполнить `advance_stage`. - Требуется одно из: - 1. **Подключить guard** к пути merge (инструкция в промпте deployer «перед merge - проверь `pr_already_merged` — уже слит → пометь стадию успешной без повторного - merge», либо детерминированная Python-проверка перед merge), чтобы AC-11 - выполнялся реально; **либо** - 2. если идемпотентность намеренно достигается только gate-driven advance + - `branch_is_behind_main` (а `pr_already_merged` оставлен как helper «на будущее»), - **исправить ADR-001 Р-3, README и CHANGELOG**, убрав утверждение, что путь merge - консультируется с guard, и явно описав, чем именно покрывается «без второго - слияния» из AC-11. + Последствия в окне гонки (reaper-тик попал в финализацию живого monitor'а): + - `enqueue_job` (`src/db.py:419`) — обычный INSERT **без дедупликации**, поэтому + параллельные `advance_stage` от reaper и monitor дают **две `queued`-строки + следующего агента** → дублирующий запуск следующей стадии (двойной commit/PR/ + комментарии, лишние токены); + - либо (если гейт следующего ребра ещё красный, напр. CI не позеленел) reaper уходит + в `_reap_unknown_outcome` → `reap_running_job(..., "queued")` и **спихивает в queued + job, который вот-вот успешно завершится** monitor'ом; при `max_concurrency=1` воркер + может повторно заклеймить и **перезапустить тот же агент**. + + Это противоречит FR-1.3/AC-3 (живой агент НЕ должен реапиться) и FR-1.2/AC-4 + (никакого дубль-advance). Требуется одно из: + 1. ввести **grace для Tier-2** — реапить только если `finished_at_run` старше + заведомо-большего, чем максимальное окно финализации (использовать уже + запрашиваемый `finished_at_run`), и/или + 2. **claim-before-act**: атомарно «застолбить» строку (как требует ADR Р-1) ДО любого + `advance_stage`/`enqueue_job`, чтобы проигравший гонку reaper не выполнял побочных + эффектов; advance — только после выигранного claim. + + (Tier-1 этим не страдает: streak `reaper_dead_ticks` + мёртвый pid; ветка + `_reap_unknown_outcome` без exit0 тоже безопасна — единственное действие там и есть + атомарный flip.) ### P2 — Should fix - нет +### P3 — Nice to have +- [ ] **Битая ссылка в CHANGELOG на глобальный ADR.** В записи ORCH-065 указан + `docs/architecture/adr/adr-0011-job-reaper-and-lease-reclaim.md`, фактический файл — + `adr-0011-job-reaper-lease-reclaim.md` (без `-and-`). README и `src/job_reaper.py` + ссылаются корректно. Поправить путь в `CHANGELOG.md`. + ## Документация -Обновлена корректно и в этом же PR (AC-17 PASS по составу артефактов): -`docs/architecture/README.md` (раздел про job-reaper + lease-reclaim, таблицы БД и -`/queue`), `docs/architecture/internals.md`, `docs/architecture/adr/adr-0011-*.md` -(+ запись в `adr/README.md`), `docs/work-items/ORCH-065/06-adr/ADR-001-*.md`, -`CHANGELOG.md`, `.env.example` (флаги `ORCH_REAPER_*` / `ORCH_LEASE_RECLAIM_ENABLED`). +Обновлена корректно и в этом же PR (AC-17 PASS по составу): `docs/architecture/README.md` +(раздел про job-reaper + lease-reclaim, таблицы БД и `/queue`), `docs/architecture/internals.md`, +`docs/architecture/adr/adr-0011-job-reaper-lease-reclaim.md` (+ запись в `adr/README.md`), +`docs/work-items/ORCH-065/06-adr/ADR-001-job-reaper-and-lease-reclaim.md`, `CHANGELOG.md`, +`.env.example` (флаги `ORCH_REAPER_*` / `ORCH_LEASE_RECLAIM_ENABLED`). -Оговорка: содержание документации про `pr_already_merged` фактически неверно (см. P1) — -guard описан как «консультируемый перед merge», тогда как он нигде не вызывается. -До устранения P1 документация в этой части не является достоверным golden-source. +Оговорка: ADR-001 Р-1 описывает «claim перед побочными эффектами», но реализация exit0-пути +этому не следует (см. P1) — при исправлении кода либо привести код в соответствие с ADR, +либо синхронизировать формулировку ADR с фактическим порядком. Битая ссылка на adr-0011 в +CHANGELOG — P3. From 720c31393a4e413044be96cc3624d5fda33dc835 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 16:06:27 +0000 Subject: [PATCH 08/10] fix(reaper): Tier-2 finalization grace + claim-before-act (no dup advance) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 25 ++- CHANGELOG.md | 2 +- docs/architecture/README.md | 24 ++- .../adr/adr-0011-job-reaper-lease-reclaim.md | 25 ++- docs/architecture/internals.md | 13 +- .../ADR-001-job-reaper-and-lease-reclaim.md | 45 +++-- src/config.py | 9 + src/db.py | 12 +- src/job_reaper.py | 181 ++++++++++++++---- tests/test_job_reaper.py | 117 ++++++++++- 10 files changed, 354 insertions(+), 99 deletions(-) diff --git a/.env.example b/.env.example index dc9e36b..a7ef50c 100644 --- a/.env.example +++ b/.env.example @@ -123,20 +123,27 @@ ORCH_RECONCILE_SKIP_BLOCKED_ENABLED=true # (one zombie at max_concurrency=1 blocks the whole shared queue) and periodically # reclaims dead/stale merge-leases. Liveness is three-tier: Tier-1 dead jobs.pid # (os.kill(pid,0)) after REAPER_DEAD_TICKS consecutive dead ticks (anti-false-positive -# for a live agent); Tier-2 agent_runs.exit_code recorded but job still 'running'; -# Tier-3 backstop after REAPER_MAX_RUNNING_S. The terminal flip carries an atomic -# status='running' guard so it never double-processes a row racing requeue_running_jobs. -# REAPER_ENABLED -> global kill-switch (false -> strictly prior behaviour). -# REAPER_INTERVAL_S -> background scan period (seconds). -# REAPER_DEAD_TICKS -> consecutive dead-pid ticks before reaping (Tier-1, >=2). -# REAPER_MAX_RUNNING_S -> Tier-3 backstop ceiling; must exceed max agent_timeout+grace. -# LEASE_RECLAIM_ENABLED -> kill-switch for the proactive stale/dead lease reclaim -# (false -> only the legacy lazy TTL reclaim in acquire_merge_lease). +# for a live agent); Tier-2 agent_runs.exit_code recorded but job still 'running' +# (only after a REAPER_FINALIZE_GRACE_S finalization grace, so a live monitor still +# doing git push / PR / Plane comments is never reaped); Tier-3 backstop after +# REAPER_MAX_RUNNING_S. The terminal flip carries an atomic status='running' guard and +# precedes any advance/enqueue (claim-before-act) so it never double-processes/-advances +# a row racing a late monitor or requeue_running_jobs. +# REAPER_ENABLED -> global kill-switch (false -> strictly prior behaviour). +# REAPER_INTERVAL_S -> background scan period (seconds). +# REAPER_DEAD_TICKS -> consecutive dead-pid ticks before reaping (Tier-1, >=2). +# REAPER_MAX_RUNNING_S -> Tier-3 backstop ceiling; must exceed max agent_timeout+grace. +# REAPER_FINALIZE_GRACE_S -> Tier-2 grace: how long agent_runs.exit_code must have been +# recorded before a still-'running' job is reaped; MUST exceed +# the max finalization window (git push + PR + Plane comments). +# LEASE_RECLAIM_ENABLED -> kill-switch for the proactive stale/dead lease reclaim +# (false -> only the legacy lazy TTL reclaim in acquire_merge_lease). # (reuse) ORCH_MERGE_LOCK_TIMEOUT_S -> lease TTL; ORCH_MERGE_GATE_REPOS -> reclaim scope. ORCH_REAPER_ENABLED=true ORCH_REAPER_INTERVAL_S=60 ORCH_REAPER_DEAD_TICKS=2 ORCH_REAPER_MAX_RUNNING_S=3600 +ORCH_REAPER_FINALIZE_GRACE_S=300 ORCH_LEASE_RECLAIM_ENABLED=true # ORCH-021: post-deploy production monitoring + degradation reaction. After the diff --git a/CHANGELOG.md b/CHANGELOG.md index d94d099..09dfba8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ## [Unreleased] ### Added -- **Job-reaper + проактивный реклейм протухшего merge-lease + идемпотентная финализация merge** (ORCH-065): закрыт класс инцидентов «zombie jobs» — статус job выставлялся ТОЛЬКО в живом процессе launcher'а, поэтому гибель процесса (OOM/рестарт инстанса/segfault Claude-CLI) оставляла строку `jobs.status='running'` навсегда; при `max_concurrency=1` один такой зомби намертво блокировал очередь ВСЕХ проектов (self-hosting: enduro-trails встаёт из-за зомби ORCH-задачи). Плюс два смежных дефекта: застрявший merge-lease (`.merge-lease-.json` реклеймился лишь лениво по TTL при чужом acquire, живость pid-holder'а не проверялась) и неидемпотентная финализация merge (rebase+re-test зелёные, но процесс умер до самого merge → нет повторного проигрывания). Решение — новый фоновый daemon-поток **`src/job_reaper.py`** (контракт «never-raise на единицу работы», паттерн `reconciler`/`queue_worker`): периодический тик (`reaper_interval_s`) сканирует `running`-jobs трёхуровневой проверкой живости (ADR Р-1): **Tier-1** мёртвый pid (`os.kill(pid, 0)` → `ProcessLookupError`) с анти-false-positive порогом `reaper_dead_ticks` подряд-мёртвых тиков (стрик в памяти); **Tier-2** `agent_runs.exit_code` записан, но job всё ещё `running` (исход известен — процесс завершился, но не успел флипнуть статус); **Tier-3** backstop-потолок `reaper_max_running_s`. Единственная мутирующая запись reaper'а — атомарный терминальный флип через `db.reap_running_job(... WHERE status='running')` (rowcount==1 у победителя, проигравший в гонке с `requeue_running_jobs`/launcher видит rowcount==0 — без двойной обработки, TC-06). Для Tier-2 exit0 источник истины — канонический QG (не «exit0»): gate-driven advance (`_gate_driven_advance` → штатный `launcher._try_advance_stage`, кандидат-стадии агента из `STAGE_TRANSITIONS`) проигрывается ПЕРЕД флипом — зелёный гейт → `done`, красный → путь неуспеха (requeue в пределах `attempts.json` реклеймился лишь лениво по TTL при чужом acquire, живость pid-holder'а не проверялась) и неидемпотентная финализация merge (rebase+re-test зелёные, но процесс умер до самого merge → нет повторного проигрывания). Решение — новый фоновый daemon-поток **`src/job_reaper.py`** (контракт «never-raise на единицу работы», паттерн `reconciler`/`queue_worker`): периодический тик (`reaper_interval_s`) сканирует `running`-jobs трёхуровневой проверкой живости (ADR Р-1): **Tier-1** мёртвый pid (`os.kill(pid, 0)` → `ProcessLookupError`) с анти-false-positive порогом `reaper_dead_ticks` подряд-мёртвых тиков (стрик в памяти); **Tier-2** `agent_runs.exit_code` записан, но job всё ещё `running` — но только после finalization-grace `reaper_finalize_grace_s` (окно неоднозначно: живой monitor пишет exit_code ПЕРВЫМ, затем git push/PR/Plane-комментарии и лишь потом `_finalize_job`, а pid агента к этому моменту мёртв в обоих случаях — живой финализирующий monitor НЕ реапится); **Tier-3** backstop-потолок `reaper_max_running_s`. Единственная мутирующая запись reaper'а — атомарный терминальный флип через `db.reap_running_job(... WHERE status='running')` (rowcount==1 у победителя, проигравший в гонке с `requeue_running_jobs`/launcher видит rowcount==0 — без двойной обработки, TC-06). Для Tier-2 exit0 действие построено по принципу **claim-before-act** (ADR Р-1): источник истины — канонический QG (не «exit0»), он оценивается read-only (`_gate_is_green` → `stage_engine._run_qg`, как у reconciler) ПЕРЕД claim, затем атомарный claim `done` ПЕРВЫМ и только победитель claim делает gate-driven advance (`_gate_driven_advance` → штатный `launcher._try_advance_stage`, кандидат-стадии агента из `STAGE_TRANSITIONS`) — проигравший claim не выполняет НИКАКИХ побочных эффектов (нет дубль-advance / дубль-enqueue следующей стадии); зелёный гейт → `done`+advance, красный → путь неуспеха (requeue в пределах `attempts post_deploy_5xx_threshold`; иначе `HEALTHY` — одиночный глюк не откатывает), `decide_action` (self-hosting → ВСЕГДА `ALERT_ONLY`; не-self + `post_deploy_auto_rollback=true` → `ROLLBACK`; иначе `ALERT_ONLY`), `map_rollback_exit_code` (`0→ROLLBACK_OK`, иначе `ROLLBACK_FAILED`), sentinel-state хелперы (`armed`/`series`/`done` под `/.post-deploy-state-//`, restart-safe счётчики), `build_rollback_command`/`run_rollback` (ssh-хук `--rollback` с прод-env, синхронно — только для не-self), `build/write_post_deploy_log` (артефакт `16-post-deploy-log.md`), `arm_monitor` (идемпотентный арм + первый отложенный job), `status` (снимок для `/queue`). **Механизм наблюдения — reserved-agent job `post-deploy-monitor`** (детерминированный, no-LLM, калька `deploy-finalizer`, НЕ стадия и НЕ daemon): арм в `stage_engine.advance_stage` в блоке `next_stage == "done"` ПОСЛЕ terminal-sync/release-lease (`post_deploy.arm_monitor`, sentinel `armed` = идемпотентность при двойном webhook/reconciler/finalizer); один тик = один job — перехват в `agents/launcher.launch_job` ДО `_spawn` → `stage_engine.run_post_deploy_monitor` (один опрос → append в `series` → `classify` → перепостановка с задержкой `available_at_delay_s` ИЛИ реакция+артефакт+`mark_done`); бюджет тиков `window_s/interval_s` (анти-livelock). **Self-hosting safety (BR-5):** для `orchestrator` тик НИКОГДА не откатывает/рестартит прод-контейнер — реакция всегда `ALERT_ONLY` (громкий Telegram + Plane-коммент с запросом ручного approve); авто-rollback хуком `--rollback` — только для не-self репо при `post_deploy_auto_rollback=true` (целевой контейнер ≠ orchestrator). Наблюдаемость — блок `post_deploy` в `GET /queue` (enabled/window/interval/активные наблюдения). Артефакт `16-post-deploy-log.md` (YAML-frontmatter `post_deploy_status`/`action_taken`/`window_s`/`checks_total`/`checks_failed`) — машиночитаемо для петли уроков ORCH-8; best-effort. Новые настройки: `ORCH_POST_DEPLOY_MONITOR_ENABLED` (true, kill-switch), `ORCH_POST_DEPLOY_REPOS` (CSV; пусто → только self-hosting), `ORCH_POST_DEPLOY_WINDOW_S` (900), `ORCH_POST_DEPLOY_INTERVAL_S` (30), `ORCH_POST_DEPLOY_FAIL_THRESHOLD` (3), `ORCH_POST_DEPLOY_5XX_THRESHOLD` (0.5), `ORCH_POST_DEPLOY_AUTO_ROLLBACK` (false), `ORCH_POST_DEPLOY_BASE_URL` (http://localhost:8500); параметры отката переиспользуют `deploy_prod_*`. Инварианты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status`, terminal-sync `deploy→done`, merge-gate, exit-код-контракт хука (0/1/2), схема БД (без миграций; состояние — sentinel-файлы). Условность как ORCH-35/36/43/58. ADR `docs/work-items/ORCH-021/06-adr/ADR-001-post-deploy-monitor.md`, глобальный `docs/architecture/adr/adr-0010-post-deploy-monitor.md`. Тесты: `tests/test_post_deploy.py`, `tests/test_post_deploy_integration.py`. - **Провенанс staging-образа перед BUILD-ONCE retag в прод (свежесть артефакта, INV-FRESH)** (ORCH-058): BUILD-ONCE retag (ORCH-036) промоутит staging-образ (`orchestrator-orchestrator-staging`) в прод **без rebuild**, полагаясь на «образ свеж и провалидирован» — гарантии не было: конвейер нигде не пересобирал staging-образ из провалидированного коммита, поэтому retag мог тихо промоутнуть УСТАРЕВШИЙ образ (инцидент LESSONS_ORCH-036 п.4 — зелёный деплой молча откатывал прод). Закрыто **двумя слоями (defense in depth), только для self-hosting**. Новый модуль `src/image_freshness.py` (контракт «never raise», по образцу `merge_gate`): `provenance_verdict` (чистая функция вердикта match/mismatch/fail-closed), `validated_revision` (`git rev-parse HEAD` в worktree валидированного коммита — единый якорь и для штампа A, и для `EXPECTED_REVISION` B), `image_revision` (OCI-лейбл `org.opencontainers.image.revision` через `docker image inspect`, ``/ошибка → пусто), `rebuild_staging_image` (ssh-хук `--build-staging`), `image_freshness_applies` (условность), `check_staging_image_fresh` (композитный QG). **Strategy A (liveness):** новый детерминированный QG-под-чек `check_staging_image_fresh` (зарегистрирован в `QG_CHECKS`, `src/qg/checks.py`) на ребре `deploy-staging → deploy` ПОСЛЕ merge-gate и ДО Phase A — пересобирает staging-образ из worktree валидированного коммита (хук `--build-staging`, `--build-arg GIT_SHA=`), пересоздаёт 8501 и прогоняет `staging_check.py --mode stub` против свежего 8501 (health + e2e, внутри staging-контейнера через `docker exec` — канон ORCH-048) → валидируем РОВНО тот артефакт (build + e2e), что промоутится в прод (AC-4); FAIL/не-ноль staging_check → откат на `development` (как merge-gate, кап `MAX_DEVELOPER_RETRIES`). `rebuild_staging_image` пробрасывает в хук **явный** staging-таргет (service/port/profile/container), исключая дрейф на прод 8500. Сборки/recreate/validate — **только staging (8501)**, прод (8500) не трогается. **Strategy B (safety):** `Dockerfile` штампует `LABEL org.opencontainers.image.revision=$GIT_SHA` (`ARG GIT_SHA`); `build_deploy_command` (`src/self_deploy.py`) пробрасывает `EXPECTED_REVISION`; хост-хук шагом 2b ПЕРЕД `docker tag` fail-closed сверяет лейбл `revision` у `SOURCE_IMAGE` с `EXPECTED_REVISION` — несовпадение / пустой лейбл / ошибка inspect → `exit 1` (FAILED → БАГ-8 откат), делает тихий промоут устаревшего образа структурно невозможным даже при проигравшей гонку/отключённой A. Хост-хук `scripts/orchestrator-deploy-hook.sh` расширен **обратно-совместимым** режимом `--build-staging` (пересборка+recreate staging, exit 0/1) и fail-closed guard'ом (активен только при заданном `EXPECTED_REVISION`). Единый kill-switch `ORCH_IMAGE_FRESHNESS_ENABLED` (true) включает A+B **как целое** (нет «B без A» = вечного fail-fast); область — `ORCH_IMAGE_FRESHNESS_REPOS` (CSV; пусто → только self-hosting `orchestrator`). Контракты НЕ менялись: `STAGE_TRANSITIONS` (под-гейт ребра, не стадия), exit-code-контракт хука (0/1/2), `map_exit_code_to_status`, `check_deploy_status`/`_parse_deploy_status`, БАГ-8, terminal-sync, merge-gate; схема БД — без миграций. ADR `docs/work-items/ORCH-058/06-adr/ADR-001-staging-image-provenance.md`, глобальный `docs/architecture/adr/adr-0008-staging-image-provenance.md`. Документация: `docs/architecture/README.md`, `docs/operations/DEPLOY_HOOK.md`, `docs/operations/STAGING.md`, `docs/operations/INFRA.md`, `.env.example`. Тесты: `tests/test_image_freshness.py`, `tests/test_deploy_hook_provenance.py`, `tests/test_deploy_build_once.py` (TC-06), `tests/test_deploy_hook_mapping.py` (TC-09), `tests/test_stage_engine.py::TestImageFreshnessGate`, `tests/test_qg_registry_snapshot.py`, `tests/test_config.py`. - **Исполняемый самодеплой стадии `deploy` (стадия дёргает хост-хук, manual-approve)** (ORCH-036): стадия `deploy` перестаёт быть «бумажной» — для self-hosting репозитория `orchestrator` `deploy_status: SUCCESS` означает ДОКАЗАННЫЙ health-ok реального рестарта прод-контейнера (8500), а не декларацию LLM. Критический путь self-restart детерминирован (без LLM), по образцу merge-gate ORCH-043, и разбит на три фазы (`src/stage_engine.py` + новый модуль `src/self_deploy.py`): **Фаза A** (вход в `deploy`) — вместо запуска прод-deployer'а при `deploy_require_manual_approve=true` задача переводится в approval-pending (`set_issue_in_review`) и ждёт ручного approve; restart-safe маркер `approve-requested`. **Фаза B** (человек ставит статус Plane → `Approved`; `advance_stage(deploy, finished_agent=None)`) — запускается **detached host-процесс** (`ssh + setsid` → `scripts/orchestrator-deploy-hook.sh`, чтобы рестарт 8500 пережил гибель контейнера; орк НЕ убивает себя из docker.sock) с build-once retag staging-образа (`SOURCE_IMAGE`), ставится детерминированный **finalizer-job**; маркер `initiated` — идемпотентность повторного Approved. **Фаза C** (`run_deploy_finalizer`, reserved-agent `deploy-finalizer`, claim'ится новым контейнером после рестарта) — читает sentinel `result` (exit-code хука, записан host-обёрткой), `not-ready` → defer (бюджет `deploy_finalize_max_attempts`, restart-safe по `task_content`), маппит `0→SUCCESS / 1|2|иное→FAILED` (чистая функция `map_exit_code_to_status`, unit-тест), пишет `14-deploy-log.md` и вызывает `advance_stage(deploy, finished_agent="deployer")` → существующие контракты: `SUCCESS → done` + release merge-lease, `FAILED → откат БАГ-8 на development` + `set_issue_blocked`. Уведомления Plane+Telegram на approve-request / initiate / success / rollback (BR-5, ни одного «молчаливого» деплоя). Хост-хук `scripts/orchestrator-deploy-hook.sh` расширен **обратно-совместимым** `SOURCE_IMAGE`: при заданном — `docker tag $SOURCE_IMAGE $TARGET_IMAGE` перед `up -d --no-build` (деплой РОВНО протестированного образа, без `docker build`); не задан → прежнее поведение; exit-code-контракт (0/1/2) и health-loop (10×6с, авто-rollback) не тронуты. Restart-safe состояние — sentinel-файлы (`/.deploy-state-//`), без миграции БД. Условность как ORCH-35: реальный самодеплой только для `is_self_hosting_repo("orchestrator")`; прочие репо (enduro-trails) — прежний синхронный ssh-путь агентом. Контракты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status` (frontmatter-only), terminal-sync `deploy→done`, merge-gate (ORCH-43), БАГ-8. Флаг `DEPLOY_REQUIRE_MANUAL_APPROVE` остаётся `true` (полный авто — отдельная задача ORCH-54). Новые настройки: `ORCH_DEPLOY_REQUIRE_MANUAL_APPROVE` (true), `ORCH_DEPLOY_SSH_USER`, `ORCH_DEPLOY_SSH_HOST`, `ORCH_DEPLOY_HOOK_SCRIPT`, `ORCH_DEPLOY_PROD_SOURCE_IMAGE`, `ORCH_DEPLOY_PROD_TARGET_SERVICE/PORT/IMAGE`, `ORCH_DEPLOY_FINALIZE_DELAY_S`, `ORCH_DEPLOY_FINALIZE_MAX_ATTEMPTS`. ADR `docs/work-items/ORCH-036/06-adr/ADR-001-executable-self-deploy.md`, глобальный `docs/architecture/adr/adr-0007-executable-self-deploy.md`. Документация: `.openclaw/agents/deployer.md` (стадия `deploy` = вызов хука, запрет self-restart), `docs/operations/INFRA.md`, `docs/operations/DEPLOY_HOOK.md`. Тесты: `tests/test_deploy_hook_mapping.py`, `tests/test_deploy_approve.py`, `tests/test_deploy_routing.py`, `tests/test_deploy_rollback.py`, `tests/test_deploy_notifications.py`, `tests/test_deploy_build_once.py`, `tests/test_deploy_terminal_sync.py`, `tests/test_staging_precondition.py`, `tests/test_deploy_hook_rollback_sim.py`. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 20b424c..0b1d743 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -207,14 +207,19 @@ ORCH-065 вводит фоновый watchdog, чтобы смерть проц работает **без рестарта**. Трёхуровневая liveness: Tier-1 мёртвый `jobs.pid` (новая колонка) после `reaper_dead_ticks` подряд тиков (анти-ложноположительность — живой долгий агент не реапится); Tier-2 `agent_runs.exit_code` записан, а job - ещё `running` (monitor умер между записью exit_code и финализацией); Tier-3 - backstop по потолку `reaper_max_running_s` (> max agent_timeout+grace). Действие - переиспользует контракты: exit0 → **gate-driven idempotent advance** - (`_try_advance_stage`+`_finalize_job`, источник истины — канонический QG, не - факт «exit0»; нет дубль-перехода); exit≠0/неизвестно → `attempts max + agent_timeout+grace). Действие переиспользует контракты по принципу + **claim-before-act**: для exit0 канонический QG оценивается read-only ПЕРЕД + атомарным claim, затем claim `done` ПЕРВЫМ и только победитель claim делает + `_try_advance_stage` (advance+enqueue) — проигравший claim (поздний monitor / + стартовый requeue) не выполняет побочных эффектов (нет дубль-advance/-enqueue); + источник истины — канонический QG, не факт «exit0»; гейт красный или exit≠0/ + неизвестно → `attempts= grace`) — живой финализирующий monitor НЕ реапится; - **Tier-3** — backstop: job висит `running` дольше `reaper_max_running_s`. Реап атомарен (`UPDATE jobs SET ... WHERE id=? AND status='running'` + `rowcount`, как `claim_next_job`) → совместим со стартовым `requeue_running_jobs` без двойной -обработки. Действие переиспользует контракты: exit0 → gate-driven -`_try_advance_stage`+`_finalize_job` (источник истины — QG); exit≠0/неизвестно → +обработки. Действие — **claim-before-act**: для exit0 канонический QG оценивается +read-only ПЕРЕД атомарным claim, затем claim `done` ПЕРВЫМ и только победитель +claim делает `_try_advance_stage` (advance+enqueue) — проигравший (поздний monitor +/ стартовый requeue) не выполняет побочных эффектов (нет дубль-advance/-enqueue); +источник истины — QG, не «exit0»; гейт красный или exit≠0/неизвестно → `attempts максимального окна финализации). В пределах grace строка не + трогается (живой финализирующий monitor НИКОГДА не реапится; нет дубль-advance + / дубль-enqueue). После grace monitor заведомо мёртв → исход **известен**. 3. **Tier-3 (backstop по потолку):** job висит `running` дольше `reaper_max_running_s` (заведомо > max `agent_timeout`+grace). Реап даже когда liveness определить нельзя (pid переиспользован/неизвестен). @@ -87,18 +96,23 @@ liveness процесса). Это разные never-raise-домены и ра flip несёт guard `WHERE id=? AND status='running'` и проверяет `rowcount`. При гонке (поздно доехавший monitor, стартовый `requeue_running_jobs`) проигравший видит `rowcount==0` и НЕ обрабатывает строку повторно (AC-5). -- **Исход известен (Tier-2, exit_code в `agent_runs`):** маршрутизируем через - существующий `launcher._finalize_job(job_id, agent, run_id, exit_code, - output_path)`: - - `exit==0`: **gate-driven idempotent advance.** Сначала проверяем, не - продвинулась ли уже стадия (текущая `tasks.stage` ≠ исходная стадия агента - или активного job нет и гейт уже пройден) → если да, просто `mark_job(done)` - (идемпотентная уборка, без дубль-перехода). Если нет — `_try_advance_stage` - (он сам гоняет канонический QG: артефакт/PR есть → зелёный гейт → advance; - нет → красный гейт → НЕ advance), затем `_finalize_job`. **Источник истины — - гейт, не «exit0»** — это исключает ложный `done` без реально выполненной - работы (если monitor умер ДО git-push, артефакта нет → гейт красный → - переходим к ветке «исход неуспешен» ниже). +- **Исход известен (Tier-2, exit_code в `agent_runs`, grace прошёл):** + - `exit==0`: **claim-BEFORE-act, gate-driven idempotent advance.** Порядок + критичен (см. «Атомарный reap-claim» выше): атомарный claim ОБЯЗАН + предшествовать любому `advance_stage`/`enqueue_job`. Поскольку claim + переводит строку ИЗ `running`, прогнать advance ДО claim, чтобы узнать цвет + гейта, нельзя — поэтому канонический QG оценивается **read-only, без + побочных эффектов** (тот же `_run_qg`, что у reconciler) ПЕРЕД claim: + - стадия уже продвинута мимо этого агента → атомарный `done` без advance + (идемпотентная уборка); + - гейт зелёный → атомарный claim `done` ПЕРВЫМ, и только победитель claim + выполняет `_try_advance_stage` (advance + `enqueue_job` следующей стадии) + РОВНО один раз; проигравший claim (поздний monitor / стартовый + `requeue_running_jobs`) НЕ делает побочных эффектов (нет дубль-advance / + дубль-enqueue); + - гейт красный (monitor умер ДО git-push, артефакта нет) → НЕ выдумываем + `done`, уходим в ветку «исход неуспешен» ниже. + **Источник истины — гейт, не «exit0».** - `exit!=0`: ровно существующий контракт `_finalize_job` (классификация transient/permanent, `attempts max agent_timeout+grace | `3600` | +| `reaper_finalize_grace_s` | Tier-2 grace: сколько `exit_code` должен быть записан до реапа (> max окна финализации) | `300` | | `lease_reclaim_enabled` | kill-switch проактивного реклейма lease | `True` | | (reuse) `merge_lock_timeout_s` | TTL lease | `300` | | (reuse) `merge_gate_repos` | область применения lease-reclaim | как есть | diff --git a/src/config.py b/src/config.py index fc00219..a35aafa 100644 --- a/src/config.py +++ b/src/config.py @@ -314,6 +314,14 @@ class Settings(BaseSettings): # reaper_max_running_s -> Tier-3 backstop ceiling: a job 'running' longer than # this is reaped even when liveness is unknowable. MUST be # > max agent_timeout + grace so a legit agent is safe. + # reaper_finalize_grace_s -> Tier-2 anti-false-positive: a LIVE monitor writes + # agent_runs.exit_code FIRST, THEN does git commit/push + + # PR + Plane usage comments (seconds..minutes) and only + # then _finalize_job. The agent pid is already dead in + # that window, so pid cannot tell "monitor died" from + # "monitor still finalizing". A job is reaped via Tier-2 + # only once exit_code has been recorded for at least this + # many seconds (MUST be > the max finalization window). # lease_reclaim_enabled -> kill-switch for the proactive stale/dead lease reclaim # (false -> only the legacy lazy TTL reclaim in acquire). # (reuse) merge_lock_timeout_s -> lease TTL; merge_gate_repos -> reclaim scope. @@ -321,6 +329,7 @@ class Settings(BaseSettings): reaper_interval_s: int = 60 reaper_dead_ticks: int = 2 reaper_max_running_s: int = 3600 + reaper_finalize_grace_s: int = 300 lease_reclaim_enabled: bool = True # Telegram notifications diff --git a/src/db.py b/src/db.py index 04c67d9..bbe0e5b 100644 --- a/src/db.py +++ b/src/db.py @@ -601,11 +601,15 @@ def requeue_running_jobs() -> int: def get_running_jobs() -> list[dict]: """ORCH-065: snapshot of every 'running' job for the job-reaper scan. - Each row carries the job columns plus three reaper inputs: + Each row carries the job columns plus four reaper inputs: * ``running_age_s`` — seconds since ``started_at`` (Tier-3 backstop); * ``exit_code`` — the linked ``agent_runs.exit_code`` (Tier-2: process finished but the job is still 'running' -> monitor died mid-finalize); - * ``finished_at_run`` — the linked ``agent_runs.finished_at`` (debug only). + * ``finished_at_run`` — the linked ``agent_runs.finished_at``; + * ``finished_age_s`` — seconds since ``agent_runs.finished_at`` (Tier-2 + finalization grace: a LIVE monitor writes exit_code, THEN does git + push / PR / Plane comments before _finalize_job, so a freshly-finished + run is NOT yet a zombie — the reaper waits ``reaper_finalize_grace_s``). A LEFT JOIN on ``run_id`` keeps jobs with no agent_runs row (exit_code NULL). Read-only; never mutates. The reaper applies liveness/streak/backstop on top. @@ -616,7 +620,9 @@ def get_running_jobs() -> list[dict]: "SELECT j.*, " "CAST(strftime('%s','now') - strftime('%s', j.started_at) AS INTEGER) " " AS running_age_s, " - "r.exit_code AS exit_code, r.finished_at AS finished_at_run " + "r.exit_code AS exit_code, r.finished_at AS finished_at_run, " + "CAST(strftime('%s','now') - strftime('%s', r.finished_at) AS INTEGER) " + " AS finished_age_s " "FROM jobs j LEFT JOIN agent_runs r ON r.id = j.run_id " "WHERE j.status='running'" ).fetchall() diff --git a/src/job_reaper.py b/src/job_reaper.py index fbbc9a3..f71928c 100644 --- a/src/job_reaper.py +++ b/src/job_reaper.py @@ -28,10 +28,17 @@ Liveness (defense in depth, ADR-001 Р-1): ``reaper_dead_ticks`` (>=2) CONSECUTIVE dead-pid ticks — an in-memory streak counter kills false positives (AC-3); a live agent within its timeout is never reaped. - * **Tier-2 (completion race): exit_code recorded but job still running.** The - monitor died between writing ``agent_runs.exit_code`` and ``_finalize_job``. - The outcome is KNOWN -> gate-driven advance on exit0, else the standard - transient/permanent contract. + * **Tier-2 (completion race): exit_code recorded but job still running.** This + window is AMBIGUOUS — it is both "the monitor died between writing + ``agent_runs.exit_code`` and ``_finalize_job``" AND "a LIVE monitor is still + finalizing" (``_monitor_agent`` writes ``exit_code`` FIRST, then git + commit/push (+PR), the БАГ-8 check and network Plane usage comments — seconds + to tens of seconds — and ONLY THEN ``_try_advance_stage`` -> ``_finalize_job``). + The agent pid is already dead in BOTH cases, so it cannot disambiguate. The + reaper therefore treats it as a dead monitor (KNOWN outcome) only after a + finalization grace: ``exit_code`` recorded for >= ``reaper_finalize_grace_s`` + (a live finalizing monitor is NEVER reaped, FR-1.3/AC-3). Within the grace the + row is left untouched. * **Tier-3 (backstop): age ceiling.** A job ``running`` longer than ``reaper_max_running_s`` (deliberately > max ``agent_timeout`` + grace) is reaped even when liveness cannot be determined (pid reused / unknown). @@ -41,13 +48,16 @@ Action on confirmed death reuses existing contracts (no new merge/stage logic): ``db.reap_running_job(... WHERE status='running')`` — so a late-arriving monitor / the startup ``requeue_running_jobs`` / a second tick can never double-process a row (AC-5; the loser sees ``rowcount==0``). - * **exit0 (Tier-2):** gate-driven idempotent advance — the source of truth is - the canonical quality gate, NOT "exit0". If the stage already advanced -> - just mark ``done`` (idempotent cleanup). Else run ``launcher._try_advance_stage`` - (it runs the canonical QG: artifact/PR present -> green -> advance; absent -> - red -> no advance) and re-check: advanced -> ``done``; still red (e.g. the - monitor died before git-push, so no artifact) -> fall through to the failure - path. This makes a false ``done`` without real work impossible. + * **exit0 (Tier-2): claim-BEFORE-act (ADR-001 Р-1).** The source of truth is the + canonical quality gate, NOT "exit0". If the stage already advanced -> atomic + ``done`` claim only (idempotent cleanup). Else evaluate the canonical QG + READ-ONLY (no side effects, the reconciler pattern): red (e.g. the monitor died + before git-push, so no artifact) -> failure path (no false ``done``); green -> + atomically claim ``done`` FIRST, and only the claim winner then runs + ``launcher._try_advance_stage`` (advance + ``enqueue_job`` of the next stage). + A tick that loses the claim performs NO side effects, so a late-finalizing + monitor / the startup ``requeue_running_jobs`` can never be double-advanced or + double-enqueued. * **exit!=0 (Tier-2) / unknown outcome (Tier-1 dead pid, Tier-3 backstop):** ``attempts < max_attempts`` -> ``queued`` (mirrors ``requeue_running_jobs``); budget exhausted -> ``failed`` + Telegram. We never fabricate exit0. @@ -173,27 +183,46 @@ class JobReaper: exit_code = job.get("exit_code") # from the LEFT JOIN on agent_runs # Tier-2: the process finished (exit_code recorded) but the job is still - # 'running' -> the monitor died mid-finalize. Outcome is KNOWN. + # 'running'. This is AMBIGUOUS: it is BOTH "the monitor died mid-finalize" + # AND "a LIVE monitor is still finalizing" — _monitor_agent writes exit_code + # FIRST, then does git commit/push (+PR), the БАГ-8 check, network Plane + # usage comments (seconds..tens of seconds), and ONLY THEN _try_advance_stage + # -> _finalize_job. The agent pid is already dead in BOTH cases, so pid can + # NOT disambiguate. We treat it as a dead monitor (KNOWN outcome) only after + # a finalization grace: exit_code must have been recorded for at least + # `reaper_finalize_grace_s` (FR-1.3/AC-3 — a live finalizing monitor is never + # reaped). Within the grace window we leave the row alone (and fall through to + # the Tier-3 backstop only, which never trips before the grace given a sane + # config where reaper_max_running_s > reaper_finalize_grace_s). if exit_code is not None: self._streak.pop(job_id, None) - self._reap_known_outcome(job, int(exit_code)) - return - - # Tier-1: dead pid, only after `reaper_dead_ticks` consecutive dead ticks. - if pid is not None and not merge_gate.pid_alive(pid): - n = self._streak.get(job_id, 0) + 1 - self._streak[job_id] = n - if n >= max(int(settings.reaper_dead_ticks), 1): - self._streak.pop(job_id, None) - self._reap_unknown_outcome(job, reason=f"dead pid={pid}") + finished_age = job.get("finished_age_s") + grace = int(settings.reaper_finalize_grace_s) + if finished_age is not None and int(finished_age) >= grace: + self._reap_known_outcome(job, int(exit_code)) return logger.info( - "reaper: job %s pid=%s dead (streak %d/%d) — deferring", - job_id, pid, n, settings.reaper_dead_ticks, + "reaper: job %s exit_code=%s recorded %ss ago (< grace %ss) — " + "deferring (monitor may still be finalizing)", + job_id, exit_code, finished_age, grace, ) + # fall through to the Tier-3 backstop guard below. else: - # Alive / no pid -> reset the streak (must be CONSECUTIVE). - self._streak.pop(job_id, None) + # Tier-1: dead pid, only after `reaper_dead_ticks` consecutive dead ticks. + if pid is not None and not merge_gate.pid_alive(pid): + n = self._streak.get(job_id, 0) + 1 + self._streak[job_id] = n + if n >= max(int(settings.reaper_dead_ticks), 1): + self._streak.pop(job_id, None) + self._reap_unknown_outcome(job, reason=f"dead pid={pid}") + return + logger.info( + "reaper: job %s pid=%s dead (streak %d/%d) — deferring", + job_id, pid, n, settings.reaper_dead_ticks, + ) + else: + # Alive / no pid -> reset the streak (must be CONSECUTIVE). + self._streak.pop(job_id, None) # Tier-3: backstop ceiling (one-shot; reaps even when liveness is unknown). if age >= int(settings.reaper_max_running_s): @@ -206,16 +235,83 @@ class JobReaper: def _reap_known_outcome(self, job: dict, exit_code: int) -> None: """Tier-2: the agent's exit_code is known; drive the job's terminal status.""" if exit_code == 0: - if self._gate_driven_advance(job): - if reap_running_job(job["id"], "done", run_id=job.get("run_id")): - self._note_reap(job, "done", reason="exit0, gate green") - return - # exit0 but the gate is red (e.g. monitor died before git-push -> no - # artifact). Do NOT fabricate 'done'; treat as a failed outcome. - self._reap_unknown_outcome(job, reason="exit0 but gate red") + self._reap_exit0(job) else: self._reap_unknown_outcome(job, reason=f"exit={exit_code}") + def _reap_exit0(self, job: dict) -> None: + """Reap an exit0 Tier-2 job with claim-BEFORE-act (ADR-001 Р-1). + + The atomic ``reap_running_job`` claim (guard ``WHERE status='running'``) MUST + precede any ``advance_stage`` / ``enqueue_job`` side effect, so a reaper tick + that LOSES the row (to a late-finalizing monitor or the startup + ``requeue_running_jobs``) performs NO side effects — no duplicate advance, no + duplicate ``enqueue_job`` of the next stage (FR-1.2/AC-4). + + Because the claim flips the row OUT of 'running', we cannot run the advance + first to learn the gate colour. Instead we evaluate the canonical quality gate + READ-ONLY (no side effects — the pattern the reconciler uses) to choose the + terminal status BEFORE claiming: + * already advanced past this agent -> idempotent clean ``done`` (no advance); + * gate green -> claim ``done`` first, THEN advance exactly once; + * gate red (e.g. monitor died before git-push -> no artifact) -> NOT a real + success: route to the retry/fail contract (never a false ``done``). + """ + job_id = job["id"] + run_id = job.get("run_id") + agent = job.get("agent") + branch, stage, work_item_id = self._task_meta(job) + candidates = {s for s in STAGE_TRANSITIONS if get_agent_for_stage(s) == agent} + + if stage is None or stage not in candidates: + # Stage already advanced past this agent (or unknown) -> a clean 'done' + # is correct WITHOUT re-advancing. Atomic claim only (idempotent cleanup). + if reap_running_job(job_id, "done", run_id=run_id): + self._note_reap(job, "done", reason="exit0, already advanced") + return + + if not branch or not self._gate_is_green(stage, job, branch, work_item_id): + # exit0 but the gate is red -> do NOT fabricate 'done'; treat as failure + # (retry within budget, else failed + Telegram). + self._reap_unknown_outcome(job, reason="exit0 but gate red") + return + + # Gate green. CLAIM-BEFORE-ACT: own the row atomically FIRST. + if not reap_running_job(job_id, "done", run_id=run_id): + # Lost the race -> the winner (late monitor / startup requeue) owns the + # advance; we do NOTHING (no duplicate side effects). + return + # We exclusively own the row now -> drive the gate-based advance exactly once. + self._gate_driven_advance(job) + self._note_reap(job, "done", reason="exit0, gate green") + + def _gate_is_green( + self, stage: str, job: dict, branch: str, work_item_id: str | None + ) -> bool: + """Read-only canonical-QG evaluation for a reaped exit0 job (no side effects). + + Mirrors the reconciler's cheap pre-evaluation: dispatch the stage's QG via + the SAME ``_run_qg`` the webhook path uses, returning its pass/fail WITHOUT + running ``advance_stage`` (so no stage move / enqueue / notification happens + here). A stage with no registered gate is treated as green (nothing blocks a + clean 'done'). Never raises -> any error returns False (conservative: route + to retry, never a false 'done'). + """ + try: + from .stages import get_qg_for_stage + from .stage_engine import _run_qg + qg_name = get_qg_for_stage(stage) + if not qg_name: + return True + passed, _reason = _run_qg(qg_name, job.get("repo"), work_item_id, branch) + return bool(passed) + except Exception as e: # noqa: BLE001 - never break the reap + logger.warning( + "reaper: gate pre-eval failed for job %s (stage=%s): %s", + job.get("id"), stage, e, + ) + return False + def _reap_unknown_outcome(self, job: dict, reason: str) -> None: """Tier-1/Tier-3 (or exit!=0): outcome not a clean success. @@ -252,7 +348,7 @@ class JobReaper: agent = job.get("agent") repo = job.get("repo") run_id = job.get("run_id") - branch, stage = self._task_branch_stage(job) + branch, stage, _wid = self._task_meta(job) # Candidate stages whose finishing agent is THIS agent (deployer maps to # both 'testing' and 'deploy-staging', hence a set). candidates = {s for s in STAGE_TRANSITIONS if get_agent_for_stage(s) == agent} @@ -270,28 +366,29 @@ class JobReaper: job.get("id"), e) return False # Re-read the stage: advanced out of the candidate set -> gate was green. - _branch, new_stage = self._task_branch_stage(job) + _branch, new_stage, _wid2 = self._task_meta(job) return new_stage is None or new_stage not in candidates @staticmethod - def _task_branch_stage(job: dict) -> tuple[str | None, str | None]: - """Resolve (branch, stage) for the job's task. Never raises.""" + def _task_meta(job: dict) -> tuple[str | None, str | None, str | None]: + """Resolve (branch, stage, work_item_id) for the job's task. Never raises.""" task_id = job.get("task_id") if not task_id: - return None, None + return None, None, None try: conn = get_db() row = conn.execute( - "SELECT branch, stage FROM tasks WHERE id = ?", (task_id,) + "SELECT branch, stage, work_item_id FROM tasks WHERE id = ?", + (task_id,), ).fetchone() conn.close() if not row: - return None, None - return row["branch"], row["stage"] + return None, None, None + return row["branch"], row["stage"], row["work_item_id"] except Exception as e: # noqa: BLE001 - never-raise contract logger.warning("reaper: task lookup failed for job %s: %s", job.get("id"), e) - return None, None + return None, None, None def _notify_failed(self, job: dict, reason: str) -> None: try: diff --git a/tests/test_job_reaper.py b/tests/test_job_reaper.py index 683cff4..a17747f 100644 --- a/tests/test_job_reaper.py +++ b/tests/test_job_reaper.py @@ -32,18 +32,22 @@ def fresh_db(tmp_path, monkeypatch): # --- helpers ---------------------------------------------------------------- def _make_running_job(agent="developer", repo="orchestrator", task_id=None, pid=None, age_s=0, attempts=0, max_attempts=2, - run_id=None, exit_code=None): + run_id=None, exit_code=None, finished_age_s=600): """Insert a job already in 'running' with the given pid/age/attempts. started_at is back-dated by ``age_s`` seconds so running_age_s reflects it. - When ``exit_code`` is given an agent_runs row is created and linked (Tier-2). + When ``exit_code`` is given an agent_runs row is created and linked (Tier-2); + its ``finished_at`` is back-dated by ``finished_age_s`` seconds so the + Tier-2 finalization grace (``reaper_finalize_grace_s``, default 300) is + satisfied by default — pass a small ``finished_age_s`` to exercise the + "monitor may still be finalizing" deferral. """ conn = get_db() if run_id is None and exit_code is not None: cur = conn.execute( "INSERT INTO agent_runs (task_id, agent, finished_at, exit_code) " - "VALUES (?, ?, datetime('now'), ?)", - (task_id, agent, exit_code), + "VALUES (?, ?, datetime('now', ?), ?)", + (task_id, agent, f"-{int(finished_age_s)} seconds", exit_code), ) run_id = cur.lastrowid cur = conn.execute( @@ -153,11 +157,20 @@ def test_tc04_backstop_no_pid(monkeypatch): # --- TC-05: correct outcome by exit_code (Tier-2) --------------------------- +def _gate(monkeypatch, green: bool): + """Force the reaper's READ-ONLY gate pre-evaluation to green/red.""" + monkeypatch.setattr( + JobReaper, "_gate_is_green", + lambda self, stage, job, branch, wid: green, + ) + + def test_tc05_exit0_gate_green_done(monkeypatch): # A developer job runs to LEAVE the 'architecture' stage (-> 'development'). tid = _make_task(stage="architecture") jid = _make_running_job(agent="developer", task_id=tid, exit_code=0) - # gate green -> advance succeeds (stage leaves the developer candidate set). + _gate(monkeypatch, green=True) + # gate green -> the claim flips 'done' FIRST, then the advance runs. import src.agents.launcher as L monkeypatch.setattr( L.launcher, "_try_advance_stage", @@ -172,13 +185,31 @@ def test_tc05_exit0_gate_red_requeues(monkeypatch): tid = _make_task(stage="architecture") jid = _make_running_job(agent="developer", task_id=tid, exit_code=0, attempts=0, max_attempts=2) - # gate red -> _try_advance_stage is a no-op (stage stays 'architecture'). + _gate(monkeypatch, green=False) # read-only pre-eval says red + # The advance path must NEVER run when the gate is red (claim-before-act). import src.agents.launcher as L + called = [] monkeypatch.setattr(L.launcher, "_try_advance_stage", - lambda run_id, agent, repo, branch: None) + lambda run_id, agent, repo, branch: called.append(1)) r = JobReaper() r.reap_once() assert get_job(jid)["status"] == "queued" # exit0 but gate red -> not 'done' + assert not called, "no advance/side-effects on a red gate" + + +def test_tc05_exit0_already_advanced_done_no_side_effects(monkeypatch): + # Stage already past the developer candidate set -> idempotent clean 'done' + # with NO advance call (the monitor already advanced before dying). + tid = _make_task(stage="development") # developer's candidate is 'architecture' + jid = _make_running_job(agent="developer", task_id=tid, exit_code=0) + import src.agents.launcher as L + called = [] + monkeypatch.setattr(L.launcher, "_try_advance_stage", + lambda run_id, agent, repo, branch: called.append(1)) + r = JobReaper() + r.reap_once() + assert get_job(jid)["status"] == "done" + assert not called, "already-advanced reap must not re-advance" def test_tc05_nonzero_exit_requeue_then_failed(monkeypatch): @@ -201,6 +232,78 @@ def test_tc05_nonzero_exit_requeue_then_failed(monkeypatch): assert sent, "failed reap must send a Telegram alert" +# --- TC-05b: Tier-2 finalization grace (live monitor still finalizing) ------- +def test_tc05_tier2_within_grace_not_reaped(monkeypatch): + """exit_code freshly recorded -> a LIVE monitor may still be finalizing. + + The reaper must NOT reap it within ``reaper_finalize_grace_s`` (FR-1.3/AC-3: + a live finalizing monitor — git push / PR / Plane comments — is never reaped, + no dup advance / enqueue). + """ + monkeypatch.setattr(db.settings, "reaper_finalize_grace_s", 300) + tid = _make_task(stage="architecture") + # exit_code recorded only 5s ago -> still inside the finalization grace. + jid = _make_running_job(agent="developer", task_id=tid, exit_code=0, + finished_age_s=5) + import src.agents.launcher as L + called = [] + monkeypatch.setattr(L.launcher, "_try_advance_stage", + lambda run_id, agent, repo, branch: called.append(1)) + r = JobReaper() + r.reap_once() + assert get_job(jid)["status"] == "running" # deferred, NOT reaped + assert r.reaped_total == 0 + assert not called, "a live finalizing monitor must not be advanced by the reaper" + + +def test_tc05_tier2_after_grace_reaped(monkeypatch): + """Once exit_code has been recorded longer than the grace, the monitor is + genuinely dead and the Tier-2 reap proceeds.""" + monkeypatch.setattr(db.settings, "reaper_finalize_grace_s", 300) + tid = _make_task(stage="architecture") + jid = _make_running_job(agent="developer", task_id=tid, exit_code=0, + finished_age_s=600) # well past the grace + _gate(monkeypatch, green=True) + import src.agents.launcher as L + monkeypatch.setattr( + L.launcher, "_try_advance_stage", + lambda run_id, agent, repo, branch: db.update_task_stage(tid, "development"), + ) + r = JobReaper() + r.reap_once() + assert get_job(jid)["status"] == "done" + + +def test_tc05_tier2_lost_claim_no_side_effects(monkeypatch): + """claim-BEFORE-act: when another actor (a late monitor / startup requeue) + moves the row out of 'running' AFTER the reaper read it but BEFORE the atomic + claim, the reaper's claim loses (rowcount==0) and it performs NO advance side + effects (no dup advance / dup enqueue) — ADR-001 Р-1.""" + monkeypatch.setattr(db.settings, "reaper_finalize_grace_s", 0) + tid = _make_task(stage="architecture") + jid = _make_running_job(agent="developer", task_id=tid, exit_code=0, + finished_age_s=10) + import src.agents.launcher as L + called = [] + monkeypatch.setattr(L.launcher, "_try_advance_stage", + lambda run_id, agent, repo, branch: called.append(1)) + + # The read-only gate pre-eval reports green, but the row is concurrently + # claimed by someone else right before the reaper's atomic claim runs. + def green_then_steal(self, stage, job, branch, wid): + db.requeue_running_jobs() # another actor wins the 'running' row first + return True + + monkeypatch.setattr(JobReaper, "_gate_is_green", green_then_steal) + r = JobReaper() + r.reap_once() + # Reaper lost the atomic claim -> no advance, no double work. The row stays + # where the winner left it ('queued'), not flipped to 'done' by the reaper. + assert not called, "reaper that lost the claim must not advance/enqueue" + assert get_job(jid)["status"] == "queued" + assert r.reaped_total == 0 + + # --- TC-06: atomicity — reaper vs requeue_running_jobs (status guard) -------- def test_tc06_atomic_no_double_reap(monkeypatch): _dead_pid(monkeypatch) From cba67a4270c0e061362ec6b67116a8553909f00b Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 16:09:30 +0000 Subject: [PATCH 09/10] reviewer(ET): auto-commit from reviewer run_id=323 --- docs/work-items/ORCH-065/12-review.md | 119 +++++++++----------------- 1 file changed, 42 insertions(+), 77 deletions(-) diff --git a/docs/work-items/ORCH-065/12-review.md b/docs/work-items/ORCH-065/12-review.md index 818dce7..0366741 100644 --- a/docs/work-items/ORCH-065/12-review.md +++ b/docs/work-items/ORCH-065/12-review.md @@ -1,8 +1,8 @@ --- type: review work_item_id: ORCH-065 -verdict: REQUEST_CHANGES -version: 2 +verdict: APPROVED +version: 3 --- # Review ORCH-065 @@ -11,24 +11,38 @@ version: 2 Задача закрывает три связанных класса отказов «процесс/поток умер, а ресурс остался захваченным навсегда»: zombie jobs (A), залипший merge-lease (B), неидемпотентная -финализация merge (C). Реализация в целом качественная: новый daemon-поток -`src/job_reaper.py` по образцу `reconciler` (never-raise, kill-switch, снимок в -`/queue`), трёхуровневая liveness, атомарный `reap_running_job(... WHERE status='running')`, -проактивный реклейм lease (`pid_alive` + `reclaim_stale_lease`), идемпотентный guard -`pr_already_merged`, колонка `jobs.pid` через идемпотентный `_ensure_column`. Инварианты -сохранены: `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/БАГ-8/exit-коды хука не тронуты -(коммиты ORCH-065 не касаются `stages.py`/`qg/checks.py`/`stage_engine.py`); реклейм -lease — только удаление файла, без git-операций. Документация (README, internals, ADR, -глобальный adr-0011, CHANGELOG, .env.example) обновлена в этом же PR. `pytest tests/ -q` -— **743 passed**. +финализация merge (C). Реализация качественная: новый daemon-поток `src/job_reaper.py` +по образцу `reconciler` (never-raise, kill-switch, снимок в `/queue`), трёхуровневая +liveness, атомарный `reap_running_job(... WHERE status='running')`, проактивный реклейм +lease (`pid_alive` + `reclaim_stale_lease`), идемпотентный guard `pr_already_merged`, +колонка `jobs.pid` через идемпотентный `_ensure_column`. -Прошлый блокер v1 (guard `pr_already_merged` не подключён к пути merge) **устранён** -коммитом `aa46e5d`: промпт `.openclaw/agents/deployer.md` теперь предписывает консультировать -`pr_already_merged` ПЕРЕД любым (повторным) merge — AC-11 wiring на месте. +**Все блокеры предыдущих ревью устранены:** +- v1 P0 (guard `pr_already_merged` не подключён к merge-пути) — устранён `aa46e5d`: + промпт `.openclaw/agents/deployer.md` консультирует `pr_already_merged` ПЕРЕД любым + (повторным) merge (AC-11 wiring на месте, подтверждено строками 94–105/152). +- v2 P1 (Tier-2 реапит живой финализирующий monitor; side-effects ДО атомарного claim, + нарушение ADR-001 Р-1) — устранён `3e2eb27` двумя мерами: + 1. **Tier-2 finalization grace** — новая колонка `finished_age_s` в `get_running_jobs` + (`src/db.py:609`) + настройка `reaper_finalize_grace_s` (дефолт 300с); Tier-2 + реапит только при `finished_age >= grace`, иначе строка не трогается + (`src/job_reaper.py:197-209`). Живой финализирующий monitor больше не реапится + (FR-1.3/AC-3). + 2. **claim-before-act** — `_reap_exit0` (`src/job_reaper.py:242-286`) сначала оценивает + канонический QG read-only (`_gate_is_green` → `_run_qg`, без побочных эффектов), + затем атомарно claim `done` ПЕРВЫМ, и только победитель claim выполняет + `_gate_driven_advance`. Проигравший гонку (поздний monitor / стартовый requeue) не + делает НИКАКИХ побочных эффектов → нет дубль-advance/дубль-enqueue (FR-1.2/AC-4). +- v2 P3 (битая ссылка на adr-0011 в CHANGELOG) — исправлена в `3e2eb27` + (`adr-0011-job-reaper-lease-reclaim.md`). -Новый блокер: гонка Tier-2 reaper'а с живым monitor-потоком при штатной финализации — -порядок «side effects → atomic claim» нарушает собственный контракт ADR-001 Р-1 и может -дать дубль-advance / дубль-enqueue следующей стадии (FR-1.2/FR-1.3/AC-3/AC-4). +Инварианты сохранены (AC-13): ORCH-065-коммиты (`1a2e881`/`aa46e5d`/`3e2eb27`) НЕ касаются +`src/stages.py` и `src/qg/checks.py` — `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/БАГ-8/ +exit-коды хука не тронуты; реклейм lease — только удаление файла, без git-операций +(AC-12). Документация (README, internals, ADR-001, глобальный adr-0011, CHANGELOG, +.env.example) обновлена в этом же PR (AC-17). Новые тесты покрывают grace-окно, +lost-claim-no-side-effects, already-advanced-идемпотентность. `pytest tests/ -q` — +**747 passed**. ## Findings @@ -36,70 +50,21 @@ lease — только удаление файла, без git-операций. - нет ### P1 — Must fix -- [ ] **Tier-2 реапит живой, штатно финализирующийся job; side-effects идут ДО - атомарного claim (нарушение ADR-001 Р-1, риск дубль-advance/дубль-enqueue).** - В `JobReaper._reap_job` (`src/job_reaper.py:177`) ветка Tier-2 срабатывает на ЛЮБОЙ - `running`-job, у которого в `agent_runs` уже записан `exit_code` — **без grace и без - какого-либо признака смерти monitor'а**. Но именно это состояние («exit_code записан, - job ещё running») — нормальное окно финализации живого monitor-потока: `_monitor_agent` - пишет `exit_code`, затем выполняет git commit/push (+PR), БАГ-8-проверку, **сетевые - usage-комментарии в Plane** (секунды-десятки секунд), и лишь потом `_try_advance_stage` - → `_finalize_job`. pid агента (`jobs.pid`) к этому моменту уже мёртв (процесс завершён - до записи exit_code), поэтому отличить «monitor умер» от «monitor жив и финализирует» - по pid невозможно, а reaper тикает каждые `reaper_interval_s` (60с). Доступный для - grace `finished_at_run` запрашивается в `get_running_jobs`, но **не используется** - (помечен «debug only», `src/db.py`). - - Усугубляет дефект порядок в `_reap_known_outcome` (`src/job_reaper.py:206`): для exit0 - сначала вызывается `_gate_driven_advance(job)` (побочные эффекты — `_try_advance_stage` - → `advance_stage` → `enqueue_job` следующей стадии), и **только потом** атомарный - `reap_running_job(..., "done")`. Это прямо противоречит ADR-001 Р-1: «Атомарный - reap-claim. **Перед любым действием с побочными эффектами** reaper атомарно - «застолбляет» строку тем же приёмом, что `claim_next_job`». Поскольку claim стоит - ПОСЛЕ side-effects, его guard `WHERE status='running'` не сериализует advance: даже - проигравший гонку reaper (rowcount==0) уже успел выполнить `advance_stage`. - - Последствия в окне гонки (reaper-тик попал в финализацию живого monitor'а): - - `enqueue_job` (`src/db.py:419`) — обычный INSERT **без дедупликации**, поэтому - параллельные `advance_stage` от reaper и monitor дают **две `queued`-строки - следующего агента** → дублирующий запуск следующей стадии (двойной commit/PR/ - комментарии, лишние токены); - - либо (если гейт следующего ребра ещё красный, напр. CI не позеленел) reaper уходит - в `_reap_unknown_outcome` → `reap_running_job(..., "queued")` и **спихивает в queued - job, который вот-вот успешно завершится** monitor'ом; при `max_concurrency=1` воркер - может повторно заклеймить и **перезапустить тот же агент**. - - Это противоречит FR-1.3/AC-3 (живой агент НЕ должен реапиться) и FR-1.2/AC-4 - (никакого дубль-advance). Требуется одно из: - 1. ввести **grace для Tier-2** — реапить только если `finished_at_run` старше - заведомо-большего, чем максимальное окно финализации (использовать уже - запрашиваемый `finished_at_run`), и/или - 2. **claim-before-act**: атомарно «застолбить» строку (как требует ADR Р-1) ДО любого - `advance_stage`/`enqueue_job`, чтобы проигравший гонку reaper не выполнял побочных - эффектов; advance — только после выигранного claim. - - (Tier-1 этим не страдает: streak `reaper_dead_ticks` + мёртвый pid; ветка - `_reap_unknown_outcome` без exit0 тоже безопасна — единственное действие там и есть - атомарный flip.) +- нет ### P2 — Should fix - нет ### P3 — Nice to have -- [ ] **Битая ссылка в CHANGELOG на глобальный ADR.** В записи ORCH-065 указан - `docs/architecture/adr/adr-0011-job-reaper-and-lease-reclaim.md`, фактический файл — - `adr-0011-job-reaper-lease-reclaim.md` (без `-and-`). README и `src/job_reaper.py` - ссылаются корректно. Поправить путь в `CHANGELOG.md`. +- нет ## Документация -Обновлена корректно и в этом же PR (AC-17 PASS по составу): `docs/architecture/README.md` -(раздел про job-reaper + lease-reclaim, таблицы БД и `/queue`), `docs/architecture/internals.md`, -`docs/architecture/adr/adr-0011-job-reaper-lease-reclaim.md` (+ запись в `adr/README.md`), -`docs/work-items/ORCH-065/06-adr/ADR-001-job-reaper-and-lease-reclaim.md`, `CHANGELOG.md`, -`.env.example` (флаги `ORCH_REAPER_*` / `ORCH_LEASE_RECLAIM_ENABLED`). - -Оговорка: ADR-001 Р-1 описывает «claim перед побочными эффектами», но реализация exit0-пути -этому не следует (см. P1) — при исправлении кода либо привести код в соответствие с ADR, -либо синхронизировать формулировку ADR с фактическим порядком. Битая ссылка на adr-0011 в -CHANGELOG — P3. +Обновлена корректно и в этом же PR (AC-17 PASS): `docs/architecture/README.md` +(раздел про job-reaper + lease-reclaim, таблицы БД и `/queue`), +`docs/architecture/internals.md`, `docs/architecture/adr/adr-0011-job-reaper-lease-reclaim.md` +(+ запись в `adr/README.md`), +`docs/work-items/ORCH-065/06-adr/ADR-001-job-reaper-and-lease-reclaim.md`, `CHANGELOG.md` +(ссылка на adr-0011 исправлена), `.env.example` (флаги `ORCH_REAPER_*` / +`ORCH_REAPER_FINALIZE_GRACE_S` / `ORCH_LEASE_RECLAIM_ENABLED`). ADR-001 Р-1 и реализация +exit0-пути теперь согласованы (claim-before-act). From 930e65298c33ccc67654316959b5cadf3ed0687f Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 16:11:12 +0000 Subject: [PATCH 10/10] tester(ET): auto-commit from tester run_id=324 --- docs/work-items/ORCH-065/13-test-report.md | 92 ++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 docs/work-items/ORCH-065/13-test-report.md diff --git a/docs/work-items/ORCH-065/13-test-report.md b/docs/work-items/ORCH-065/13-test-report.md new file mode 100644 index 0000000..5a649df --- /dev/null +++ b/docs/work-items/ORCH-065/13-test-report.md @@ -0,0 +1,92 @@ +--- +type: test-report +work_item_id: ORCH-065 +result: PASS +--- + +# Test Report — ORCH-065 + +Тема: job-reaper + проактивный реклейм stale/dead merge-lease + идемпотентная +финализация merge. Прогон полного регресса в ветке +`feature/ORCH-065-bug-zombie-jobs-merge-lease-ru`. Review-вердикт — APPROVED (v3). + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 +- Ветка: feature/ORCH-065-bug-zombie-jobs-merge-lease-ru (worktree) +- Прод (8500): health `200 {"status":"ok"}` — НЕ перезапускался (self-hosting инвариант соблюдён) +- Дата: 2026-06-07 + +## Smoke API (прод 8500, read-only) +| Endpoint | Результат | +|----------|-----------| +| `GET /health` | 200 `{"status":"ok","service":"orchestrator"}` | +| `GET /status` | 200, активные задачи отдаются (ORCH-065 в `testing`, ET-013 в `development`) | +| `GET /queue` | 200, counts/resilience/reconcile/post_deploy присутствуют | + +Примечание: блок `reaper` в `/queue` прода (8500) ОТСУТСТВУЕТ — ожидаемо, т.к. прод +исполняет ещё не задеплоенный (до-ORCH-065) код. Контракт блока `reaper` проверен +тестом TC-18 (`tests/test_queue.py::test_tc18_queue_endpoint_has_reaper_block`) +против кода ветки — PASS. Curl недоступен в окружении, smoke выполнен через +`urllib.request` (read-only, без побочных эффектов на прод). + +## Результаты по тест-плану (04-test-plan.yaml) + +| TC ID | Тип | Модуль | Покрывает | Результат | +|-------|-----|--------|-----------|-----------| +| TC-01 | unit | test_job_reaper.py | AC-1 (реап мёртвого job без рестарта) | PASS | +| TC-02 | unit | test_job_reaper.py | AC-3 (живой агент не реапится) | PASS | +| TC-03 | unit | test_job_reaper.py | FR-1.3 (устойчивость reaper_dead_ticks) | PASS | +| TC-04 | unit | test_job_reaper.py | FR-1.1/AC-1 (backstop reaper_max_running_s) | PASS | +| TC-05 | unit | test_job_reaper.py | AC-4 (исход по результату: done/queued/failed) | PASS | +| TC-06 | unit | test_job_reaper.py | AC-5 (атомарность reap-UPDATE guard) | PASS | +| TC-07 | unit | test_job_reaper.py | AC-14 (kill-switch reaper_enabled=false) | PASS | +| TC-08 | unit | test_job_reaper.py | AC-9 (never-raise per-job) | PASS | +| TC-09 | integration | test_queue.py | AC-2 (разблокировка очереди concurrency=1) | PASS | +| TC-10 | unit | test_merge_lease_reclaim.py | AC-6 (реклейм lease мёртвого pid) | PASS | +| TC-11 | unit | test_merge_lease_reclaim.py | AC-7 (реклейм по TTL сохранён) | PASS | +| TC-12 | unit | test_merge_lease_reclaim.py | AC-8 (живой lease не трогается) | PASS | +| TC-13 | unit | test_merge_lease_reclaim.py | AC-9 (условность self-hosting/no-op) | PASS | +| TC-14 | unit | test_merge_lease_reclaim.py | AC-9 (never-raise при ошибке lease-файла) | PASS | +| TC-15 | unit | test_merge_lease_reclaim.py | AC-14 (kill-switch lease_reclaim_enabled=false) | PASS | +| TC-16 | unit | test_merge_gate.py | AC-11 (идемпотентность при уже слитом PR) | PASS | +| TC-17 | integration | test_merge_gate_race.py | AC-10 (докатывание незавершённого merge) | PASS | +| TC-18 | integration | test_queue.py | AC-15 (блок reaper в /queue) | PASS | +| TC-19 | unit | test_config.py | AC-13 (контракты STAGE_TRANSITIONS/QG_CHECKS неизменны) | PASS | +| TC-20 | unit | test_config.py | §5/AC-14 (новые настройки reaper_*/lease_reclaim_*) | PASS | +| TC-21 | unit | test_job_reaper.py | FR-2.1/AC-6 (стартовый реклейм в lifespan) | PASS | + +Все 21 TC из плана — PASS. + +## Сопоставление с критериями приёмки (03-acceptance-criteria.md) +- A (AC-1…AC-5): job-reaper — покрыты TC-01..TC-06, TC-09 → PASS +- B (AC-6…AC-9): lease-reclaim — покрыты TC-10..TC-15 → PASS +- C (AC-10, AC-11): идемпотентная финализация — TC-16, TC-17 → PASS +- D (AC-12 прод не трогается, AC-13 контракты, AC-14 kill-switches): TC-07, TC-15, TC-19, TC-20 + smoke прода без рестарта → PASS +- E (AC-15 /queue, AC-16 логи/алерты): TC-18 → PASS +- F (AC-17 документация): review подтвердил обновление README/internals/ADR-001/adr-0011/CHANGELOG/.env.example (APPROVED) → PASS +- G (AC-18 регресс зелёный): `pytest tests/` 747 passed → PASS + +## Вывод pytest + +### Целевые модули плана +``` +$ python -m pytest tests/test_job_reaper.py tests/test_merge_lease_reclaim.py \ + tests/test_merge_gate.py tests/test_merge_gate_race.py \ + tests/test_queue.py tests/test_config.py -q +92 passed, 1 warning in 3.40s +``` + +### Полный регресс +``` +$ python -m pytest tests/ -v --tb=short +======================= 747 passed, 1 warning in 15.47s ======================== +``` +(1 warning — PydanticDeprecatedSince20 в src/config.py, не связан с ORCH-065, +предсуществующий.) + +## Итог +**PASS.** Полный регресс — 747 passed, 0 failed. Все 21 TC тест-плана зелёные, +все критерии приёмки (AC-1…AC-18) подтверждены. Smoke прода — health/status/queue +200 OK, прод-контейнер не перезапускался (self-hosting инвариант соблюдён). +Задача готова к переходу на стадию `deploy-staging`.