Merge pull request 'ORCH-113: reaper must not re-run deploy-staging finalization while the finalizer is alive' (#134) from feature/ORCH-113-bug-job-reaper-must-not-re-run into main
Some checks failed
CI / test (push) Has been cancelled
Some checks failed
CI / test (push) Has been cancelled
This commit was merged in pull request #134.
This commit is contained in:
@@ -3,6 +3,11 @@
|
||||
Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу.
|
||||
|
||||
## [Unreleased]
|
||||
- **Job-reaper не реапит живой долго финализирующий монитор `deploy-staging`** (ORCH-113, `fix`, bug→escalate full-cycle): устранено расхождение состояния из инцидента ORCH-111 (deployer job 1914 / run_id 683). На ребре `deploy-staging → deploy` живой монитор (`launcher._monitor_agent`) штампит `agent_runs.finished_at`/`exit_code` **первым**, затем синхронно в своём потоке прогоняет тяжёлые edge-под-гейты (`security → merge-gate re-test → coverage → image-freshness`) — **минуты** — и лишь потом `_finalize_job`. Reaper Tier-2 меряет `finished_age_s` от `finished_at` (= начала финализации), поэтому по истечении `reaper_finalize_grace_s=300` трактовал живого долго финализирующего монитора как мёртвого и **независимо** повторял тот же тяжёлый advance: повторный re-test стал красным → ложный откат `deploy-staging → development` (+ ложный developer-retry) **параллельно** с тем, что исходный finalizer довёл deploy до SUCCESS и смержил PR — состояние раздвоилось. Аддитивно, под глобальным kill-switch, never-raise; `STAGE_TRANSITIONS`/`QG_CHECKS`/каждый `check_*`/machine-verdict ключи/схема БД — **байт-в-байт не тронуты**; `reaper_finalize_grace_s`/`reaper_max_running_s` и сквозной бюджет ORCH-065/109/110 (`5400 > Σ(gate-work)+grace`) сохранены; фикс не рестартит прод и не пушит `main`. ADR: `docs/work-items/ORCH-113/06-adr/ADR-001-reaper-finalizer-liveness-ownership.md`, сквозной `docs/architecture/adr/adr-0043-reaper-finalizer-liveness-ownership.md`.
|
||||
- **Leaf `src/finalizer_liveness.py` (новый, процесс-локальный реестр владения):** чистый never-raise модуль (паттерн `serial_gate`/`coverage_gate`, без сети/БД) — `mark(job_id, run_id, stage)` / `clear(job_id)` / `is_active(job_id)` / `snapshot()`; состояние `{job_id: {...}}` под `threading.Lock`. Авторитетно in-memory, т.к. монитор и reaper — daemon-**потоки одного** uvicorn-процесса (CMD без `--workers`) с общей SQLite-БД. Собственного TTL нет — ограничение по времени даёт Tier-3 backstop. `is_active` при ошибке → `False` (консервативно: не блокировать добивание).
|
||||
- **Эмиссия владения (`launcher._monitor_agent`):** `mark()` вызывается **сразу после** штампа `exit_code` (самый ранний момент Tier-2), хвост финализации вынесен в `_run_monitor_finalization` и обёрнут в `try/finally` с `clear()` в `finally` → исключение в потоке монитора гарантированно снимает владение, и реально мёртвый finalizer добивается. Маркер пишется **безусловно** (kill-switch гейтит только консультацию reaper, поэтому выключенный путь — байт-в-байт прежний). Хвост перенесён **дословно** (проверяется `git diff -w`: +49/−0, нулевое изменение логики).
|
||||
- **Консультация reaper (`job_reaper._reap_job` Tier-2):** при `reaper_finalizer_liveness_enabled` **И** стадии задачи `== "deploy-staging"` **И** активном владении → **defer** (счётчик + лог, не повторять advance), провал к Tier-3. **Tier-3 (`age >= reaper_max_running_s`) маркер игнорирует** — застрявший/мёртвый finalizer добивается в ограниченное время. Скоуп — только глобальный kill-switch `reaper_finalizer_liveness_enabled` (env `ORCH_REAPER_FINALIZER_LIVENESS_ENABLED`, дефолт `True`; `False` → reaper байт-в-байт прежний), **без** per-repo разреза (баг общий для всех репо со стадией `deploy-staging`).
|
||||
- **Наблюдаемость:** аддитивные ключи `finalizer_liveness_enabled`/`finalizer_defers_total`/`finalizer_owned` в блоке `reaper` ответа `GET /queue` (существующие ключи не тронуты). Покрытие — `tests/test_orch113_reaper_finalizer_liveness.py` (TC-01…TC-08, включая обязательный регресс ORCH-111: КРАСНЫЙ до фикса, ЗЕЛЁНЫЙ после).
|
||||
- **Merge-gate re-test: толерантность к инфра-таймауту + tree-kill спавненных pytest + контракт необходимости re-test** (ORCH-110, `fix`, bug→escalate full-cycle): устранён ложный откат `deploy-staging → development`, возникавший когда локальный re-test merge-gate падал по **таймауту** (инфра/ресурс) при зелёных CI + tester + staging (инцидент ORCH-109/PR #129: сюит 516.7s упёрся в бюджет 600s под CPU-голоданием от осиротевших pytest-процессов → `(False, "re-test timeout after 600s")` → `_handle_merge_gate_rollback` → каждый из 3 developer-retry падал так же → «Merge-gate still failing after 3 developer retries» → ручное вмешательство). Аддитивно, под 5 независимыми kill-switch, never-raise, скоуп self-hosting; `STAGE_TRANSITIONS`/реестр `QG_CHECKS`/семантика `check_*`/machine-verdict-ключи/схема БД — **байт-в-байт не тронуты**; INV-4 (никогда push/force-push `main`) и запрет рестарта прод-контейнера — соблюдены. ADR: `docs/work-items/ORCH-110/06-adr/ADR-001-merge-gate-retest-infra-tolerance-and-tree-kill.md`, сквозной `docs/architecture/adr/adr-0042-merge-gate-retest-infra-tolerance-and-tree-kill.md`.
|
||||
- **D1 — process-group tree-kill (`src/proc_group.py`, новый stdlib-only leaf):** `merge_gate.retest_branch` и `coverage_gate.measure_coverage` теперь спавнят pytest в **отдельной группе процессов** (`start_new_session`) и при таймауте убивают **всё дерево** (`os.killpg`, каскад SIGTERM→grace→SIGKILL по образцу `launcher.stop_process`), а не только прямого потомка — осиротевшие внуки-pytest больше не переживают бюджет и не грузят CPU. Контракты возврата сохранены (меняется лишь побочный эффект — нет утечки). Грейс реюзит `agent_kill_grace_seconds`. Fallback never-break: `subprocess_tree_kill_enabled=False` или не-POSIX → прежний `subprocess.run(timeout=)`.
|
||||
- **D2/D3 — классификация + маршрутизация инфра-таймаута:** чистый предикат `merge_gate.classify_retest_failure(reason)` различает `timeout`/`red`/`lock-busy`/`other` (scope-guard: `auto_rebase_onto_main`'s «rebase timeout» — НЕ инфра-таймаут re-test, остаётся на rollback-пути). Инфра-таймаут → новый `_handle_merge_gate_infra_retry` (ограниченный повтор по образцу `_handle_merge_gate_defer`: задача **остаётся на deploy-staging**, staging-deployer перезапускается с задержкой, **БЕЗ** отката на `development` и **БЕЗ** расхода developer-retry). Анти-над-толерантность (BR-6): детерминированно **красный** re-test / конфликт по-прежнему → `_handle_merge_gate_rollback`. Anti-loop: исчерпание бюджета → один **инфра-alert** (явно инфраструктурная формулировка «НЕ дефект кода» с кликабельным номером), задача НЕ уходит в `development`.
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
- **Quality Gates** (`src/qg/checks.py`) — проверки выхода со стадии, реестр `QG_CHECKS`.
|
||||
- **Agent Launcher** (`src/agents/launcher.py`) — запуск Claude CLI агентов в изолированном git worktree, мониторинг, auto-advance. Модель/эффорт каждого агента резолвятся из config (`resolve_agent_model`/`resolve_agent_effort`, ORCH-41), а не из frontmatter промпта. **ORCH-74:** имя модели валидируется форматом `^claude-…$` (`is_valid_model`) перед `--model`; невалидное → лог + откат на следующий уровень/CLI-дефолт (never-break, как `VALID_EFFORTS` для эффорта). Тот же предикат гардит inline-чтение `--fallback-model`. **ORCH-109 ([adr-0040](adr/adr-0040-agent-timeout-budgets-and-launch-model-stamp.md)):** (1) резолвенная **модель стампится в `agent_runs.model` в момент launch** (`_spawn`, объединённый `UPDATE … SET model=?, effort=?` рядом со стампом эффорта ORCH-087; пустой резолв → `NULL`; never-raise) → модель видна не-`null` при любом исходе прогона, включая timeout-kill (`exit_code=-9`), и in-flight в `GET /metrics`/`GET /queue` (`get_running_agents` уже отдаёт `model`); постфактум `record_usage` (`model=COALESCE(?, model)`) остаётся **обогащением**, не единственным источником истины. (2) **Per-role wall-clock бюджеты** через выделенные ключи `agent_timeout_developer_s=3600`/`agent_timeout_reviewer_s=3000` (лестница `_resolve_timeout`: `agent_timeout_overrides_json` → выделенный ключ роли → `agent_timeout_seconds=1800`; прочие роли — байт-в-байт; малформный/вне-диапазонный конфиг → дефолт + WARNING). Инвариант reaper ORCH-065 сохранён синхронным поднятием `reaper_max_running_s` 3600→**5400** (`5400 > max(timeout)3600 + grace20`). FR-5 анти-salvage — структурно: продвижение гейтится `if exit_code==0`, timeout-kill → `_finalize_job` (retry/fail), не advance. `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД не тронуты.
|
||||
- **Queue** (`src/queue_worker.py`, ORCH-1) — персистентная очередь задач (SQLite `jobs`), atomic claim, max_concurrency, ретраи, restart-safe. **ORCH-026:** `claim_next_job` гейтит задачи с незавершёнными зависимостями (`job_deps`, `NOT EXISTS`) без занятия слота; декларации/циклы — leaf `src/task_deps.py`.
|
||||
- **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`→`queued`, иначе `failed`+Telegram). Атомарный reap-claim (guard `status='running'`) совместим со стартовым `requeue_running_jobs`. Тот же поток периодически делает проактивный реклейм stale/dead merge-lease (см. ниже). never-raise; kill-switch `ORCH_REAPER_ENABLED`; снимок в `GET /queue` (блок `reaper`).
|
||||
- **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`→`queued`, иначе `failed`+Telegram). Атомарный reap-claim (guard `status='running'`) совместим со стартовым `requeue_running_jobs`. Тот же поток периодически делает проактивный реклейм stale/dead merge-lease (см. ниже). never-raise; kill-switch `ORCH_REAPER_ENABLED`; снимок в `GET /queue` (блок `reaper`). **ORCH-113 ([adr-0043](adr/adr-0043-reaper-finalizer-liveness-ownership.md)):** на ребре `deploy-staging → deploy` тяжёлые edge-под-гейты (security/merge-gate re-test/coverage/image-freshness) исполняются в потоке монитора **после** штампа `finished_at` и **до** `_finalize_job` — минуты, а Tier-2 `finished_age_s` меряется от `finished_at`, поэтому живой долго финализирующий монитор ошибочно реапился (инцидент ORCH-111: повторный re-test → ложный откат `deploy-staging → development` параллельно успешному deploy). Фикс — процесс-локальный реестр владения финализацией (leaf `src/finalizer_liveness.py`, never-raise): монитор `mark()`/`clear()` (try/finally), reaper в Tier-2 при `stage=="deploy-staging"` И активном владении делает **defer** (не повторяет advance); Tier-3 backstop маркер игнорирует (мёртвый/застрявший finalizer добивается в ограниченное время). In-memory restart-safe через `requeue_running_jobs` (вызов до старта reaper); схема БД, `reaper_finalize_grace_s`/`reaper_max_running_s` и сквозной бюджет не тронуты. Kill-switch `ORCH_REAPER_FINALIZER_LIVENESS_ENABLED`.
|
||||
- **Reconciler** (`src/reconciler.py`, ORCH-053 — реализовано, [adr-0007](adr/adr-0007-reconciler.md)) — фоновый daemon-поток (паттерн `queue_worker`), стартует/останавливается в `main.lifespan` (после `worker.start()` / перед `worker.stop()`). Реконсилирует рассинхрон «источник истины ≠ стадия задачи» при потерянном webhook. F-1 gate-side (продвигает застрявшую стадию по локальной БД через штатный `advance_stage(..., finished_agent=None)`), F-2 plane-side (опрос Plane API → `handle_*` из `plane.py`), F-3 (БД-fallback `sha→branch` в `handle_ci_status`). Источник истины — гейт/Plane, не событие; идемпотентность (active-job guard + atomic-claim + grace); kill-switch `ORCH_RECONCILE_ENABLED`. `analysis` F-1 не трогает (человеческий гейт). F-1 также пропускает escalated (retry≥лимита) и Blocked/Needs-Input задачи (ORCH-060). Наблюдаемость — блок `reconcile` в `GET /queue`.
|
||||
- **Disk-watchdog** (`src/disk_watchdog.py`, ORCH-063 — [adr-0024](adr/adr-0024-disk-watchdog.md)) — фоновый daemon-поток (каркас `reconciler`/`job_reaper`), стартует/останавливается в `main.lifespan` (старт последним — после `reaper.start()`; стоп первым в reverse-порядке; гард `disk_monitor_enabled`). Каждые `disk_monitor_interval_s` (дефолт 300с) меряет заполнение **хост-ФС** по смонтированным bind-путям (`/repos`, `/app/data`) через stdlib `shutil.disk_usage` (не overlay `/` контейнера, не субпроцесс `df`; дедуп путей по `st_dev`). Решение об алерте — pure-функция `decide_action(used_pct, threshold, prev_state, now, realert_s)`: алерт на пересечении порога (дефолт **85%**), cooldown-повтор `disk_monitor_realert_s` (анти-спам, не на каждом тике), однократный recovery при возврате ниже порога. Алерт — `send_telegram` (notifying, best-effort). Состояние анти-спама — in-memory (без миграции БД). never-raise (per-path/per-tick/per-send); только читает и уведомляет — не трогает диск/контейнер, не рестартит прод (self-hosting безопасность). Kill-switch `ORCH_DISK_MONITOR_ENABLED`; снимок — блок `disk_monitor` в `GET /queue` (`enabled`/`threshold_pct`/`interval_s`/`realert_s`/`paths`[`used_pct`/`free_gb`/`alerting`/`last_alert_at`]). `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД — не тронуты. Детали — `docs/work-items/ORCH-063/06-adr/ADR-001-disk-watchdog.md`.
|
||||
- **Build-cache-pruner** (`src/build_cache_pruner.py`, ORCH-062 — [adr-0025](adr/adr-0025-build-cache-pruner.md)) — фоновый daemon-поток (каркас `disk_watchdog`), стартует/останавливается в `main.lifespan` (старт последним — после `disk_watchdog.start()`; стоп первым в reverse; гард `build_cache_prune_enabled`). «Вторая половина» disk-watchdog: **watchdog сигналит — pruner убирает**. Каждые `build_cache_prune_interval_s` (дефолт 21600с = 6ч) выполняет **строго `docker builder prune -f --filter until=<until>`** (BuildKit GC; дефолт `until=24h` — удаляет build cache старше суток, тёплый кэш сохраняет; `-a` опционально, только в паре с фильтром). Затрагивает **только** build cache — НЕ образы/контейнеры; рестарт docker daemon/прода не выполняется (self-hosting безопасность). В контейнере нет `docker` CLI (`Dockerfile:11`), поэтому уборка идёт **на хосте через ssh** каналом `deploy_ssh_user@deploy_ssh_host` (как `image_freshness`/`self_deploy`); пустой `deploy_ssh_host` → тик no-op (скоуп на self-host). never-raise (per-команда/per-tick); учёт результата in-memory (без миграции БД). Kill-switch `ORCH_BUILD_CACHE_PRUNE_ENABLED`; снимок — блок `build_cache_prune` в `GET /queue` (`enabled`/`interval_s`/`until`/`last_run_ts`/`last_reclaimed`/`last_error`). `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД — не тронуты. Детали — `docs/work-items/ORCH-062/06-adr/ADR-001-build-cache-pruner.md`.
|
||||
@@ -1171,7 +1171,15 @@ ORCH-065 вводит фоновый watchdog, чтобы смерть проц
|
||||
затем git push/PR/Plane-комментарии), поэтому Tier-2 реапит только после
|
||||
finalization-grace `reaper_finalize_grace_s` (живой финализирующий monitor НЕ
|
||||
реапится); Tier-3 backstop по потолку `reaper_max_running_s` (> max
|
||||
agent_timeout+grace). Действие переиспользует контракты по принципу
|
||||
agent_timeout+grace). **ORCH-113 (adr-0043):** на ребре `deploy-staging → deploy`
|
||||
финализация в потоке монитора длится **минуты** (тяжёлые edge-под-гейты
|
||||
security/merge-gate re-test/coverage/image-freshness идут после штампа
|
||||
`finished_at` и до `_finalize_job`), а grace=300 это не покрывал → живой долгий
|
||||
finalizer ошибочно реапился и независимо повторял advance (ложный откат,
|
||||
инцидент ORCH-111). Tier-2 теперь консультирует процесс-локальный реестр владения
|
||||
(`src/finalizer_liveness.py`): при `stage=="deploy-staging"` И активном владении —
|
||||
**defer** (не повторяет advance), Tier-3 backstop маркер игнорирует. In-memory,
|
||||
restart-safe через `requeue_running_jobs`; grace/потолок и бюджет не меняются. Действие переиспользует контракты по принципу
|
||||
**claim-before-act**: для exit0 канонический QG оценивается read-only ПЕРЕД
|
||||
атомарным claim, затем claim `done` ПЕРВЫМ и только победитель claim делает
|
||||
`_try_advance_stage` (advance+enqueue) — проигравший claim (поздний monitor /
|
||||
@@ -1203,11 +1211,15 @@ ORCH-065 вводит фоновый watchdog, чтобы смерть проц
|
||||
`logger.warning`; reap→`failed` и lease-reclaim → Telegram.
|
||||
- **Kill-switch'и:** `ORCH_REAPER_ENABLED`, `ORCH_REAPER_INTERVAL_S`,
|
||||
`ORCH_REAPER_DEAD_TICKS`, `ORCH_REAPER_MAX_RUNNING_S`,
|
||||
`ORCH_REAPER_FINALIZE_GRACE_S`, `ORCH_LEASE_RECLAIM_ENABLED`; `false` → строго
|
||||
прежнее поведение.
|
||||
`ORCH_REAPER_FINALIZE_GRACE_S`, `ORCH_LEASE_RECLAIM_ENABLED`,
|
||||
`ORCH_REAPER_FINALIZER_LIVENESS_ENABLED` (ORCH-113); `false` → строго прежнее
|
||||
поведение.
|
||||
|
||||
Подробнее: [adr-0011](adr/adr-0011-job-reaper-lease-reclaim.md), детально —
|
||||
`docs/work-items/ORCH-065/06-adr/ADR-001-job-reaper-and-lease-reclaim.md`.
|
||||
Подробнее: [adr-0011](adr/adr-0011-job-reaper-lease-reclaim.md) +
|
||||
[adr-0043](adr/adr-0043-reaper-finalizer-liveness-ownership.md) (ORCH-113,
|
||||
finalizer-liveness ownership); детально —
|
||||
`docs/work-items/ORCH-065/06-adr/ADR-001-job-reaper-and-lease-reclaim.md`,
|
||||
`docs/work-items/ORCH-113/06-adr/ADR-001-reaper-finalizer-liveness-ownership.md`.
|
||||
|
||||
### Осмысленная статусная модель Plane (ORCH-066 — реализовано)
|
||||
Plane-доска была семантически перегружена: `In Progress` означал «человек запускает
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
---
|
||||
work_item: ORCH-113
|
||||
stage: architecture
|
||||
author_agent: architect
|
||||
status: proposed
|
||||
created_at: 2026-06-15
|
||||
model_used: claude-opus-4-8
|
||||
---
|
||||
|
||||
# adr-0043: Reaper Tier-2 — in-memory ownership-маркер финализации `deploy-staging` (живой finalizer не реапится)
|
||||
|
||||
- **Статус:** proposed
|
||||
- **Дата:** 2026-06-15
|
||||
- **Задача:** ORCH-113 (bug → escalate full-cycle; кластер инцидента ORCH-111)
|
||||
- **Детальный ADR:** `docs/work-items/ORCH-113/06-adr/ADR-001-reaper-finalizer-liveness-ownership.md`
|
||||
- **Уточняет:** `adr-0011` (job-reaper/lease-reclaim ORCH-065), `adr-0040` (timeout-бюджеты ORCH-109),
|
||||
`adr-0042` (merge-gate re-test infra-tolerance + tree-kill ORCH-110), `adr-0041`
|
||||
(ORCH-111 `proc_blocking` — комплементарный наблюдатель того же инцидента)
|
||||
|
||||
## Контекст
|
||||
|
||||
На ребре `deploy-staging → deploy` живой монитор (`launcher._monitor_agent`) штампит
|
||||
`agent_runs.finished_at`/`exit_code` **первым**, затем синхронно, в своём потоке, прогоняет тяжёлый
|
||||
набор edge-под-гейтов через `_try_advance_stage → advance_stage` (`stage_engine.py:327–368`):
|
||||
`security` → `merge-gate` (полный локальный re-test, `merge_retest_timeout_s=900`) → `coverage`
|
||||
(`pytest --cov`) → `image-freshness` (docker-rebuild + пересоздание staging) — **минуты**, — и лишь
|
||||
потом `_finalize_job`. Reaper Tier-2 (`job_reaper.py:197–209`) меряет `finished_age_s` от
|
||||
`finished_at` = **начала** финализации и по `reaper_finalize_grace_s=300` считает живого, долго
|
||||
финализирующего монитора мёртвым → независимо повторяет тот же тяжёлый advance. Атомарный
|
||||
claim-before-act защищает лишь **флип строки** job, но не **side-effectful исполнение edge-гейтов**
|
||||
(монитор не claim'ит строку перед `advance_stage`) → две `advance_stage` параллельно.
|
||||
|
||||
Инцидент ORCH-111 (job 1914): повторный re-test красный, ложный откат `deploy-staging → development`
|
||||
(+ ложный developer-retry), **параллельно** исходный finalizer довёл deploy до SUCCESS и смержил
|
||||
PR #130 — состояние раздвоилось. Реального сигнала «жив ли finalizer» нет (pid агента в Tier-2 мёртв в
|
||||
обоих случаях). Per-stage grace, покрывающая Σ финализации (≈4160с), невозможна без нарушения сквозного
|
||||
бюджета ORCH-065/109/110 `reaper_max_running_s (5400) > Σ(deploy-staging gate-work) + grace (≈4460)`.
|
||||
|
||||
**Решающий факт (проверен):** монитор и reaper — daemon-**потоки одного** uvicorn-процесса (CMD без
|
||||
`--workers`), общая SQLite-БД → живость finalizer'а определяется **in-memory**. Рестарт покрыт
|
||||
существующим `requeue_running_jobs()` (running→queued), вызываемым в `main.lifespan` **до** старта reaper.
|
||||
|
||||
## Решение
|
||||
|
||||
1. **Leaf `src/finalizer_liveness.py`** — чистый процесс-локальный реестр владения финализацией
|
||||
(паттерн `serial_gate`/`coverage_gate`: never-raise, без сети/БД): `mark(job_id, run_id, stage)` /
|
||||
`clear(job_id)` / `is_active(job_id) -> bool` / `snapshot()`; `{job_id: {...}}` + `threading.Lock`;
|
||||
собственного TTL нет (ограничение по времени даёт Tier-3).
|
||||
2. **Эмиссия владения** — `launcher._monitor_agent`: `mark(...)` сразу после штампа `exit_code`
|
||||
(самый ранний момент Tier-2), `clear(...)` в `try/finally` вокруг хвоста финализации → исключение
|
||||
в потоке монитора гарантированно снимает владение (reaper добивает). Гибель процесса → рестарт →
|
||||
`requeue_running_jobs` → реестр пуст (restart-safe без durable-хранения).
|
||||
3. **Консультация reaper** — `_reap_job` Tier-2 (`exit_code` записан, `finished_age >= grace`): если
|
||||
`reaper_finalizer_liveness_enabled` **И** стадия `== "deploy-staging"` **И** `is_active(job_id)` →
|
||||
**defer** (лог + счётчик), не реапить через Tier-2, провалиться к Tier-3. Иначе — прежний путь.
|
||||
**Tier-3 (`age >= reaper_max_running_s`) маркер игнорирует** — добивает всегда в ограниченное время.
|
||||
4. **Скоуп/флаг** — только глобальный kill-switch `reaper_finalizer_liveness_enabled`
|
||||
(env `ORCH_REAPER_FINALIZER_LIVENESS_ENABLED`, дефолт `True`); **без** per-repo разреза (баг общий
|
||||
для всех репо со стадией `deploy-staging`; per-repo оставил бы баг активным для части репо).
|
||||
`False` → reaper байт-в-байт прежний; стадии `!= deploy-staging` не консультируются.
|
||||
5. **Наблюдаемость** — счётчик `finalizer_defers_total` + размер `snapshot()` в блоке `reaper`
|
||||
`GET /queue`; существующие ключи ответа не меняются; новых эндпоинтов нет.
|
||||
|
||||
**Инварианты:** `STAGE_TRANSITIONS` / `QG_CHECKS` / каждый `check_*` / machine-verdict ключи / схема
|
||||
существующих таблиц — **байт-в-байт**; **нулевое** изменение схемы БД; reaper остаётся never-raise
|
||||
наблюдателем; `reaper_finalize_grace_s` и `reaper_max_running_s` **не меняются** (сквозной бюджет цел);
|
||||
фикс не рестартит прод и не пушит `main`.
|
||||
|
||||
## Альтернативы
|
||||
- Per-stage grace, покрывающая Σ — отвергнуто (нарушает бюджет `5400 > Σ+grace`; таймер = источник бага).
|
||||
- Durable-колонка (heartbeat/owner-токен) — отвергнуто (один процесс → in-memory авторитетно; рестарт
|
||||
покрыт requeue; блокирующий re-test не может бить heartbeat).
|
||||
- Sub-state `finalizing` в `jobs.status` — отвергнуто (меняет семантику статуса для
|
||||
claim/requeue/reconciler/reaper — нарушение NFR-2).
|
||||
- Lease-файл на `(job, stage)` — отвергнуто (тяжелее, дублирует merge-lease, TTL = таймер-проблема).
|
||||
- Флип job из `running` до тяжёлых гейтов — отвергнуто (ломает `get_running_jobs`/метрики и
|
||||
restart-requeue).
|
||||
|
||||
## Последствия
|
||||
- (+) Устранены повторный прогон edge-гейтов, ложный откат и расхождение состояния при живом долгом
|
||||
finalizer'е `deploy-staging`; идемпотентность исполнения edge-гейтов через владение.
|
||||
- (+) Реально мёртвый/застрявший finalizer добивается (finally-clear → Tier-2; иначе Tier-3); функция
|
||||
reaper ORCH-065 сохранена.
|
||||
- (+) Нулевое изменение схемы и контрактов; сквозной бюджет ORCH-065/109/110 не тронут; откат — один
|
||||
env-флаг.
|
||||
- (−) Гарантия владения валидна при **одном процессе/одной БД** (проверено: один uvicorn-воркер); ввод
|
||||
`--workers>1` потребует durable-сигнала (риск в work-item 10-tech-risks).
|
||||
- (−) Окно «штамп `finished_at` → `mark()`» (git push) маркером не покрыто — закрыто прежним grace=300.
|
||||
|
||||
## Связи
|
||||
- Базируется/уточняет: `adr-0011`, `adr-0040`, `adr-0042`, `adr-0041`.
|
||||
- Союзные задачи кластера инцидента ORCH-111: `ORCH-110` (инфра-толерантность merge-gate — отдельный
|
||||
объём, не дублировать), `ORCH-109` (бюджеты).
|
||||
- Детально: `docs/work-items/ORCH-113/06-adr/ADR-001-reaper-finalizer-liveness-ownership.md`.
|
||||
</content>
|
||||
@@ -385,7 +385,16 @@ daemon-поток `src/job_reaper.py` (каркас `reconciler`) периоди
|
||||
git push/PR/Plane-комментарии (секунды-десятки секунд) и лишь потом
|
||||
`_finalize_job`; pid агента к этому моменту мёртв в обоих случаях. Поэтому
|
||||
Tier-2 реапит только после finalization-grace `reaper_finalize_grace_s`
|
||||
(`finished_age_s >= grace`) — живой финализирующий monitor НЕ реапится;
|
||||
(`finished_age_s >= grace`) — живой финализирующий monitor НЕ реапится.
|
||||
**ORCH-113 (adr-0043):** на ребре `deploy-staging → deploy` финализация длится
|
||||
**минуты** (тяжёлые edge-под-гейты после штампа `finished_at`, до `_finalize_job`),
|
||||
grace=300 это не покрывал → живой долгий finalizer ошибочно реапился и повторял
|
||||
advance (ложный откат, инцидент ORCH-111). Tier-2 консультирует процесс-локальный
|
||||
реестр владения `src/finalizer_liveness.py` (`mark`/`clear` в потоке монитора через
|
||||
try/finally): при `stage=="deploy-staging"` И активном владении → **defer**;
|
||||
Tier-3 backstop маркер игнорирует (мёртвый/застрявший finalizer добивается).
|
||||
Kill-switch `ORCH_REAPER_FINALIZER_LIVENESS_ENABLED`; in-memory, restart-safe через
|
||||
`requeue_running_jobs` (до старта reaper); схема БД и сквозной бюджет не тронуты;
|
||||
- **Tier-3** — backstop: job висит `running` дольше `reaper_max_running_s`.
|
||||
|
||||
Реап атомарен (`UPDATE jobs SET ... WHERE id=? AND status='running'` + `rowcount`,
|
||||
|
||||
7
docs/work-items/ORCH-113/00-business-request.md
Normal file
7
docs/work-items/ORCH-113/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# Business Request: BUG: job-reaper must not re-run deploy-staging finalization while original finalizer is alive
|
||||
|
||||
Work Item ID: ORCH-113
|
||||
|
||||
## Description
|
||||
|
||||
TBD
|
||||
167
docs/work-items/ORCH-113/01-brd.md
Normal file
167
docs/work-items/ORCH-113/01-brd.md
Normal file
@@ -0,0 +1,167 @@
|
||||
---
|
||||
work_item: ORCH-113
|
||||
stage: analysis
|
||||
author_agent: analyst
|
||||
status: ready-for-review
|
||||
created_at: 2026-06-15
|
||||
model_used: claude-opus-4-8
|
||||
escalate: full-cycle
|
||||
---
|
||||
|
||||
# 01 — BRD (бизнес-требования): ORCH-113 — BUG: job-reaper не должен повторно запускать финализацию `deploy-staging`, пока жив исходный finalizer
|
||||
|
||||
Work Item: **ORCH-113** · Repo: **orchestrator** · Стадия: analysis
|
||||
|
||||
> **Багфикс-трек → эскалация в полный цикл (`escalate: full-cycle`).** Задача помечена `Bug`, но
|
||||
> сама баг-карточка явно требует «анализ контракта reaper, статуса `running/finalizing`, длительности
|
||||
> grace и идемпотентности edge-гейтов» (см. «Ограничение» в бизнес-запросе) — это решение с
|
||||
> несколькими проектными альтернативами (liveness-heartbeat finalizer'а / явный sub-state
|
||||
> `finalizing` / per-stage grace / ownership-lease на edge-гейты) и нетривиальными инвариантами
|
||||
> self-hosting, затрагивающее **задокументированный сквозной инвариант ORCH-065** (контракт
|
||||
> живости reaper, `adr-0011`). По правилу ORCH-019 (ADR-001 D5) выпускается **полный** analysis-пакет,
|
||||
> а трек эскалируется (`POST /bug-fast-track/escalate?work_item=ORCH-113`) → задача проходит стадию
|
||||
> `architecture`. Прецедент — родственные задачи того же инцидент-кластера: ORCH-110 / ORCH-111
|
||||
> («bug → escalate full-cycle»).
|
||||
|
||||
---
|
||||
|
||||
## 1. Бизнес-контекст и проблема
|
||||
|
||||
Оркестратор — self-hosting инструмент: его прод-контейнер обслуживает конвейер **всех** проектов из
|
||||
одного инстанса с общей БД и общей очередью и дорабатывает сам себя. Фоновый демон **job-reaper**
|
||||
(`src/job_reaper.py`, ORCH-065) — страховочный слой: он добивает «зомби»-job'ы, чей монитор умер,
|
||||
не записав терминальный статус. Его Tier-2-ветка (процесс агента завершился — `agent_runs.exit_code`
|
||||
записан, — но job всё ещё `running`) **неоднозначна**: это одновременно «монитор умер посреди
|
||||
финализации» И «живой монитор ещё финализирует». Reaper разрешает неоднозначность таймером —
|
||||
**finalization grace** `reaper_finalize_grace_s = 300` (db.py:1345-1348, job_reaper.py:36-44): если
|
||||
`exit_code` записан дольше grace — трактует ситуацию как **мёртвый монитор** и сам до-водит стадию.
|
||||
|
||||
**Корневая ошибка контракта:** grace=300с построен на задокументированном допущении, что после записи
|
||||
`finished_at` монитор делает лишь «git commit/push (+PR), БАГ-8-проверку и сетевые Plane-комментарии —
|
||||
**секунды…десятки секунд**, и ТОЛЬКО ПОТОМ `_try_advance_stage`». Для ребра `deploy-staging → deploy`
|
||||
это **неверно**: `_try_advance_stage` (`launcher._monitor_agent`, строка 998) синхронно, в потоке
|
||||
монитора, прогоняет **весь набор тяжёлых детерминированных edge-под-гейтов** —
|
||||
`security` → `merge-gate` (полный локальный re-test, бюджет `merge_retest_timeout_s = 900`) →
|
||||
`coverage` (ещё один полный `pytest --cov`) → `image-freshness` (docker-rebuild + пересоздание
|
||||
staging). Это **минуты**, и весь этот объём идёт **ПОСЛЕ** штампа `finished_at` (launcher:861) и
|
||||
**ДО** `_finalize_job` (launcher:1003). `finished_age_s` reaper меряет именно от `finished_at`
|
||||
(db.py:1360) — то есть от **начала** финализации. Итог: пока живой монитор ещё внутри edge-гейтов,
|
||||
reaper по истечении 300с считает его мёртвым и **независимо запускает тот же тяжёлый набор edge-гейтов
|
||||
повторно**.
|
||||
|
||||
**Установленные факты инцидента (ORCH-111, deployer job `1914`, run_id `683`):**
|
||||
- deployer завершился `exit_code=0` и записал `15-staging-log.md` со `staging_status: SUCCESS`;
|
||||
- после выхода агента job `1914` оставалась `running`, т.к. монитор ещё финализировал стадию;
|
||||
- финализация `deploy-staging` заняла **дольше 300с** (полный локальный re-test + coverage);
|
||||
- reaper после `reaper_finalize_grace_s=300` трактовал это как умерший monitor и повторно вызвал
|
||||
gate-driven advance (`_reap_exit0` → `_gate_driven_advance` → `_try_advance_stage` → `advance_stage`);
|
||||
- edge-гейты `deploy-staging` исполнились **повторно**: `security-gate`, `merge-gate`, rebase, полный
|
||||
локальный re-test;
|
||||
- один из повторных re-test стал **красным**: `3 failed, 1916 passed, 1 warning, 14 errors in 444.79s`;
|
||||
- задача была откатана `deploy-staging → development` (+ ложный developer-retry), хотя **параллельно**
|
||||
исходный prod-deploy/finalizer дошёл до `deploy_status: SUCCESS`, **PR #130 был смержен**, задача
|
||||
перешла `deploy → done`.
|
||||
|
||||
**Симптом:** две ветки системы расходятся по состоянию **одной** задачи — одна повторно откатывает
|
||||
`deploy-staging`, другая успешно завершает deploy. Гонка + ложный rollback + ложный developer-retry +
|
||||
шумные алерты + несогласованное состояние Plane/БД.
|
||||
|
||||
**Почему существующие гарды reaper не спасли:** атомарный claim-before-act
|
||||
(`reap_running_job(... WHERE status='running')`, job_reaper.py:280) защищает **строку job** от
|
||||
двойного терминального флипа, но **не защищает побочное исполнение edge-гейтов**: reaper вызывает
|
||||
`_gate_driven_advance → advance_stage`, который и прогоняет тяжёлые под-гейты, **до/независимо** от
|
||||
монитора. Гонка — в **side-effectful исполнении edge-гейтов**, а не в флипе строки. Дешёвая
|
||||
read-only пред-проверка `_gate_is_green('deploy-staging')` читает лишь `check_staging_status`
|
||||
(frontmatter `15-staging-log.md` = `SUCCESS`, зелёный) → reaper уверенно идёт в тяжёлый advance.
|
||||
Tier-3 backstop (`reaper_max_running_s = 5400`) при этом не срабатывает — баг чисто в Tier-2 grace.
|
||||
|
||||
## 2. Объём (scope)
|
||||
|
||||
### В объёме
|
||||
- Reaper **не должен** повторно исполнять тяжёлую финализацию `deploy-staging`/merge-gate (security /
|
||||
merge-gate / локальный re-test / coverage / image-freshness), пока исходный monitor/finalizer ещё
|
||||
**жив** или пока edge-гейты для этого job/stage **уже исполняются**.
|
||||
- Повторная обработка завершившегося-но-ещё-`running` job на `deploy-staging` должна быть
|
||||
**идемпотентной**: без второго локального re-test/merge-gate для того же job/stage без **строгого
|
||||
владения состоянием**.
|
||||
- Согласование Tier-2 grace (`reaper_finalize_grace_s`) с **фактической** wall-clock-длительностью
|
||||
финализации `deploy-staging` ИЛИ замена таймерного критерия живости на сигнал, переживающий
|
||||
«долгую, но живую» финализацию.
|
||||
- Сохранение основной функции reaper (ORCH-065): реально **мёртвый** finalizer на `deploy-staging`
|
||||
по-прежнему добивается за ограниченное время.
|
||||
|
||||
### Вне объёма
|
||||
- Изменение `STAGE_TRANSITIONS` / `QG_CHECKS` / семантики любого `check_*` / machine-verdict ключей /
|
||||
схемы существующих таблиц (правки — только аддитивные).
|
||||
- Инфра-толерантность merge-gate к таймауту re-test и tree-kill осиротевших pytest-процессов — это
|
||||
**ORCH-110** (союзная задача того же инцидента; не дублировать).
|
||||
- Починка конкретных «мигающих» тестов, давших `3 failed … 14 errors`.
|
||||
- Полный редизайн reaper или модели финализации монитора.
|
||||
- **Выбор механизма** решения (heartbeat / sub-state `finalizing` / per-stage grace / ownership-lease)
|
||||
— это **архитектурное решение** (06-adr), не зона аналитика.
|
||||
|
||||
## 3. Заинтересованные стороны
|
||||
- **Owner / Слава** — заказчик исправления, держатель инвариантов self-hosting.
|
||||
- **Конвейер всех проектов** (orchestrator self-hosting + enduro-trails) — общий инстанс/БД/очередь:
|
||||
ложный rollback и гонка состояния касаются стабильности платформы в целом.
|
||||
- **Операторы** — получатели алертов; именно их будят ложные «merge-gate FAILED / rolled back».
|
||||
- **Архитектор** — принимает решение по механизму владения/живости (06-adr) после эскалации.
|
||||
|
||||
## 4. Бизнес-требования (BR)
|
||||
- **BR-1** — Reaper **не должен** запускать второй прогон edge-гейтов ребра `deploy-staging → deploy`
|
||||
(security / merge-gate / re-test / coverage / image-freshness) для job, чей исходный
|
||||
monitor/finalizer **ещё жив**.
|
||||
- **BR-2** — Повторная обработка завершившегося-но-`running` job на `deploy-staging` **идемпотентна**:
|
||||
не более **одного** локального re-test/merge-gate на пару (job, stage) без строгого владения
|
||||
состоянием; второй актор, не владеющий состоянием, **не исполняет** побочных шагов.
|
||||
- **BR-3** — Критерий живости Tier-2 должен учитывать **реальную** wall-clock-длительность
|
||||
финализации `deploy-staging` (включающую полный набор edge-гейтов), ИЛИ живость должна определяться
|
||||
сигналом, который **переживает** долгую-но-живую финализацию (не одним `finished_age_s`).
|
||||
- **BR-4** — Реально **мёртвый** монитор (краш посреди финализации `deploy-staging`) по-прежнему
|
||||
должен добиваться reaper'ом за ограниченное время — основная функция ORCH-065 **сохраняется**;
|
||||
фикс не превращает reaper в no-op для `deploy-staging`.
|
||||
- **BR-5** — После согласования у задачи — **единственное** консистентное состояние: **никакого**
|
||||
ложного отката `deploy-staging → development` и **никакого** ложного developer-retry после
|
||||
фактически успешного deploy; ветки системы сходятся, не расходятся.
|
||||
|
||||
## 5. Нефункциональные требования (NFR)
|
||||
- **NFR-1** — Контракт reaper сохранён: **never-raise** на единицу работы, **kill-switch**,
|
||||
fail-safe; reaper остаётся наблюдателем-страховкой, не Quality Gate'ом.
|
||||
- **NFR-2** — `STAGE_TRANSITIONS` / `QG_CHECKS` / каждый `check_*` / machine-verdict ключи / схема
|
||||
существующих таблиц — **байт-в-байт**; любые БД-правки — только **аддитивные** (`_ensure_column` /
|
||||
`CREATE TABLE IF NOT EXISTS`).
|
||||
- **NFR-3** — Self-hosting-безопасно: фикс **никогда** не рестартит/не роняет прод-контейнер и
|
||||
**никогда** не пушит/force-push'ит `main`.
|
||||
- **NFR-4** — Обратная совместимость и обратимость: поведение reaper для **не-`deploy-staging`** стадий
|
||||
и путь добивания **мёртвого** монитора сохранены; выключенный kill-switch → строго прежнее
|
||||
поведение; раскат обратим.
|
||||
- **NFR-5** — Restart-safe: in-memory состояние reaper сбрасывается при рестарте (это покрыто
|
||||
стартовым `requeue_running_jobs`); любой **новый** маркер владения/живости должен быть либо durable,
|
||||
либо безопасно восстановимым после рестарта.
|
||||
- **NFR-6** — Сквозной инвариант ORCH-065/109/110 сохранён: `reaper_max_running_s (5400) >
|
||||
Σ(deploy-staging gate-work) + grace` (Tier-3 backstop). Любая правка grace/таймаутов не должна его
|
||||
нарушить.
|
||||
|
||||
## 6. Допущения и ограничения
|
||||
- Задача помечена `Bug`; ввиду архитектурной природы — **эскалация в полный цикл** (нужен ADR +
|
||||
анализ тех-рисков архитектором: 06-adr / 07 / 08 / 10).
|
||||
- Инстанс общий для всех проектов (общая БД/очередь) — фикс не должен вносить регрессию для
|
||||
enduro-trails и не-self репо.
|
||||
- Выбор конкретного механизма владения/живости — за архитектором; настоящий BRD фиксирует **требования
|
||||
и инварианты**, а не реализацию.
|
||||
- Источник истины о «жив ли finalizer» сегодня отсутствует: pid агента в Tier-2 **уже мёртв** в обоих
|
||||
случаях (`proc.wait()` вернулся), а живости **потока-монитора/финализатора** система не наблюдает —
|
||||
это и есть пробел, который закрывает фикс.
|
||||
|
||||
## 7. Критерии успеха
|
||||
Reaper при живом finalizer'е `deploy-staging` не запускает второй прогон edge-гейтов и не откатывает
|
||||
задачу; повторная обработка идемпотентна; мёртвый finalizer по-прежнему добивается; после фикса нет
|
||||
ложного rollback/developer-retry и расхождения состояния; инварианты ORCH-065/NFR-2 целы; полный
|
||||
регресс `tests/` зелёный. Детальные PASS/FAIL — `03-acceptance-criteria.md`.
|
||||
|
||||
## 8. Риски
|
||||
- **Гонка/расхождение состояния** (наблюдалось): повторный откат после успешного deploy. **Высокий.**
|
||||
- **Над-толерантность**: слишком «доверять живости» → реально мёртвый finalizer не добивается (регресс
|
||||
ORCH-065). Сдерживается BR-4 + Tier-3 backstop.
|
||||
- **Нарушение сквозного бюджета** при правке grace/таймаутов (NFR-6).
|
||||
Детальная проработка и контрмеры — `10-tech-risks.md` (заполняет архитектор).
|
||||
106
docs/work-items/ORCH-113/02-trz.md
Normal file
106
docs/work-items/ORCH-113/02-trz.md
Normal file
@@ -0,0 +1,106 @@
|
||||
---
|
||||
work_item: ORCH-113
|
||||
stage: analysis
|
||||
author_agent: analyst
|
||||
status: ready-for-review
|
||||
created_at: 2026-06-15
|
||||
model_used: claude-opus-4-8
|
||||
escalate: full-cycle
|
||||
---
|
||||
|
||||
# 02 — ТЗ (TRZ): ORCH-113 — BUG: job-reaper не должен повторно запускать финализацию `deploy-staging`, пока жив исходный finalizer
|
||||
|
||||
Work Item: **ORCH-113** · Repo: **orchestrator** · Стадия: analysis
|
||||
|
||||
> ТЗ описывает **требования к изменению**, выведенные из BRD и фактического кода. Конкретный
|
||||
> механизм (heartbeat живости / sub-state `finalizing` / per-stage grace / ownership-lease на
|
||||
> edge-гейты), точные имена символов/колонок/флагов и порядок врезок — **архитектурное решение**
|
||||
> (06-adr), не зона аналитика. Модули-плейсхолдеры ниже выровнены под манифест `PIPELINE_DOCS.md`.
|
||||
|
||||
## 1. Сводка изменения
|
||||
|
||||
Сделать повторную обработку reaper'ом завершившегося-но-ещё-`running` job на стадии `deploy-staging`
|
||||
**идемпотентной и владеющей состоянием**: пока исходный monitor/finalizer **жив** (или edge-гейты для
|
||||
пары `(job, stage)` уже исполняются), reaper **не должен** независимо запускать второй прогон edge-
|
||||
под-гейтов ребра `deploy-staging → deploy` (security / merge-gate / локальный re-test / coverage /
|
||||
image-freshness) и **не должен** на этом основании откатывать задачу. Корень — ошибочное допущение
|
||||
Tier-2 finalization-grace (`reaper_finalize_grace_s=300`), что финализация после штампа `finished_at`
|
||||
длится «секунды…десятки секунд»; для `deploy-staging` она длится **минуты** (полный re-test + coverage
|
||||
+ rebuild), потому что `_try_advance_stage` (тяжёлые edge-гейты) выполняется **после** штампа
|
||||
`finished_at` и **до** `_finalize_job`. Фикс должен дать reaper'у способ отличить «живой, долго
|
||||
финализирующий монитор» от «мёртвого монитора» и обеспечить строгое владение исполнением edge-гейтов.
|
||||
|
||||
## 2. Задействованные модули / пути
|
||||
| Путь | Действие |
|
||||
|------|----------|
|
||||
| `src/job_reaper.py` | изменить — Tier-2-ветка `_reap_job` (строки ~197-209), `_reap_known_outcome` / `_reap_exit0` / `_gate_driven_advance`: ввести проверку живости/владения перед side-effectful re-drive `advance_stage` для `deploy-staging`; при живом finalizer'е — **defer**, не reap |
|
||||
| `src/agents/launcher.py` | изменить (вероятно) — `_monitor_agent`: место/порядок штампа `finished_at` (строка ~861) относительно `_try_advance_stage` (строка ~998) и/или эмиссия durable-сигнала «finalizer жив/финализирует» перед запуском тяжёлых edge-гейтов |
|
||||
| `src/db.py` | изменить (вероятно) — `get_running_jobs` (строки ~1337-1367, источник `finished_age_s`) и/или аддитивная колонка владения/живости (`_ensure_column`, паттерн `tasks.cancelled_at`); `reap_running_job` — без изменения контракта атомарного флипа |
|
||||
| `src/config.py` | изменить (вероятно) — kill-switch фикса и/или per-stage/finalize-aware grace; сохранить сквозной инвариант `reaper_max_running_s > Σ gate-work + grace` (NFR-6) |
|
||||
| `src/stage_engine.py` | **только чтение/ссылка** — `advance_stage` ребро `deploy-staging` (строки ~321-383): эталон того, какие edge-гейты дублируются. Изменение нежелательно; идемпотентность предпочтительно решать на стороне reaper/launcher |
|
||||
|
||||
> Точный набор затронутых модулей и распределение логики (reaper-only vs launcher-сигнал vs db-колонка)
|
||||
> архитектор фиксирует в 06-adr. Аналитик фиксирует, что центр тяжести правки — `src/job_reaper.py`.
|
||||
|
||||
## 3. Функциональные требования
|
||||
|
||||
### FR-1 — Распознавание живости финализирующего монитора (BR-1, BR-3)
|
||||
Reaper в Tier-2 для стадии `deploy-staging` должен распознавать ситуацию «процесс агента завершён,
|
||||
но monitor/finalizer ещё **жив** и исполняет edge-гейты» и **не** трактовать её как мёртвый монитор по
|
||||
одному лишь `finished_age_s >= reaper_finalize_grace_s`. Сигнал живости должен переживать долгую
|
||||
(минуты) финализацию `deploy-staging`. **Инвариант:** живой finalizer **никогда** не reap'ается.
|
||||
|
||||
### FR-2 — Идемпотентность и строгое владение edge-гейтами (BR-2)
|
||||
Для пары `(job, stage)` на `deploy-staging` тяжёлый прогон edge-гейтов (security / merge-gate /
|
||||
локальный re-test / coverage / image-freshness) исполняется **не более одного раза одновременно**:
|
||||
актор, не владеющий состоянием, **не** запускает второй `advance_stage`/re-test/merge-gate. Текущий
|
||||
атомарный claim-before-act (`reap_running_job ... WHERE status='running'`) защищает только флип строки
|
||||
job — требование расширяет защиту на **side-effectful исполнение edge-гейтов**.
|
||||
|
||||
### FR-3 — Согласование grace/бюджета (BR-3, NFR-6)
|
||||
Длительность finalization-grace (или заменяющий её сигнал живости) должна покрывать фактический
|
||||
wall-clock финализации `deploy-staging` = Σ(security + merge re-test `merge_retest_timeout_s=900` +
|
||||
coverage + image rebuild). Любая правка grace/таймаутов сохраняет сквозной инвариант ORCH-065/109/110:
|
||||
`reaper_max_running_s (5400) > Σ(deploy-staging gate-work) + grace`.
|
||||
|
||||
### FR-4 — Сохранение добивания мёртвого finalizer'а (BR-4)
|
||||
Реально **мёртвый** monitor/finalizer на `deploy-staging` (краш посреди финализации, сигнал живости
|
||||
отсутствует/протух) по-прежнему добивается reaper'ом за ограниченное время по существующему контракту
|
||||
(retry в пределах бюджета, иначе `failed` + Telegram; Tier-3 backstop как крайний предохранитель).
|
||||
Reaper не становится no-op для `deploy-staging`.
|
||||
|
||||
### FR-5 — Отсутствие расхождения состояния (BR-5)
|
||||
Исключить одновременный ложный откат `deploy-staging → development` и успешное завершение
|
||||
`deploy → done` для одной задачи. После согласования у задачи — единственное консистентное
|
||||
терминальное/стадийное состояние; ложный developer-retry не инкрементируется. Под-гейт merge-verify
|
||||
(`deploy → done`, ORCH-071) остаётся единственным choke-point'ом в `done` — он **не** ослабляется.
|
||||
|
||||
## 4. Изменения API
|
||||
Новых публичных эндпоинтов **не требуется**. Допустима **аддитивная read-only** наблюдаемость в
|
||||
`GET /queue` (например, отметка «deploy-staging finalize in-progress / deferred-by-liveness» в блоке
|
||||
`reaper`) — без изменения существующих ключей ответа.
|
||||
|
||||
## 5. Изменения схемы БД
|
||||
Возможна **аддитивная** колонка владения/живости finalizer'а (например, durable-таймштамп
|
||||
«finalizing heartbeat» или owner-токен на job/agent_run), вводимая идемпотентно через `_ensure_column`
|
||||
(паттерн `tasks.cancelled_at` / `tasks.track`). Существующие таблицы/колонки/индексы и их семантика —
|
||||
**без изменений**. Точная необходимость и форма — за архитектором (06-adr / 08-data-requirements).
|
||||
|
||||
## 6. Требования к новым/изменённым QG checks
|
||||
**Нет.** `QG_CHECKS` и любой `check_*` (включая `check_staging_status`, `check_branch_mergeable`,
|
||||
`check_coverage_gate`, `check_security_gate`, `check_staging_image_fresh`, `check_deploy_status`) — не
|
||||
трогаются ни по составу, ни по семантике, ни по machine-verdict ключам. Багфикс — свойство страховочного
|
||||
демона/финализатора, **не** Quality Gate.
|
||||
|
||||
## 7. Совместимость / регресс
|
||||
- **Kill-switch:** фикс под выделенным флагом; `False` → строго прежнее поведение reaper (нулевая
|
||||
регрессия).
|
||||
- **Область:** поведение для **не-`deploy-staging`** стадий и путь добивания мёртвого монитора —
|
||||
сохранены; self-hosting-only/скоуп репо согласовать с существующими leaf-паттернами (если вводится
|
||||
per-repo разрез).
|
||||
- **Обратимость:** раскат обратим выключением флага (+ откатом значений grace/таймаутов к дефолтам).
|
||||
- **Never-raise / restart-safe / self-hosting (NFR-1/3/5):** любой сбой нового пути живости/владения
|
||||
деградирует безопасно (reaper-тик продолжается); новый durable-маркер восстановим после рестарта;
|
||||
фикс не рестартит прод и не пушит `main`.
|
||||
- **Сквозной инвариант (NFR-6):** `reaper_max_running_s > Σ gate-work + grace` сохранён.
|
||||
- **Анти-регресс:** структурный тест-гард (см. `04-test-plan.yaml`), полный `tests/` остаётся зелёным.
|
||||
98
docs/work-items/ORCH-113/03-acceptance-criteria.md
Normal file
98
docs/work-items/ORCH-113/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,98 @@
|
||||
---
|
||||
work_item: ORCH-113
|
||||
stage: analysis
|
||||
author_agent: analyst
|
||||
status: ready-for-review
|
||||
created_at: 2026-06-15
|
||||
model_used: claude-opus-4-8
|
||||
escalate: full-cycle
|
||||
---
|
||||
|
||||
# 03 — Критерии приёмки (Acceptance Criteria): ORCH-113 — BUG: job-reaper не должен повторно запускать финализацию `deploy-staging`, пока жив исходный finalizer
|
||||
|
||||
Work Item: **ORCH-113** · Repo: **orchestrator** · Стадия: analysis
|
||||
|
||||
Формат: каждый критерий имеет **PASS** (что должно быть истинно для приёмки) и **FAIL** (что считается
|
||||
провалом). Любой машинный/ручной reviewer проверяет их буквально по файлам репозитория и тестам.
|
||||
|
||||
---
|
||||
|
||||
## AC-1 — Нет второго прогона edge-гейтов при живом finalizer'е
|
||||
|
||||
**Условие:** job на `deploy-staging` с `exit_code=0`, `finished_age_s >= reaper_finalize_grace_s`, но
|
||||
исходный monitor/finalizer ещё **жив** (сигнал живости присутствует).
|
||||
- **PASS:** reaper **не** вызывает `_gate_driven_advance`/`advance_stage` для этого job; второй прогон
|
||||
security / merge-gate / локального re-test / coverage / image-freshness **не** запускается; reaper
|
||||
логирует defer.
|
||||
- **FAIL:** reaper запускает повторный `advance_stage` / любой edge-под-гейт, пока finalizer жив.
|
||||
|
||||
---
|
||||
|
||||
## AC-2 — Идемпотентность и строгое владение состоянием
|
||||
|
||||
**Условие:** монитор финализирует `deploy-staging` и параллельно срабатывает reaper-тик для того же
|
||||
job/stage.
|
||||
- **PASS:** тяжёлый прогон edge-гейтов (merge-gate / локальный re-test) исполняется **ровно один раз**
|
||||
для пары `(job, stage)`; актор без владения состоянием не выполняет побочных шагов (мерж/re-test/
|
||||
rollback).
|
||||
- **FAIL:** второй локальный re-test/merge-gate запускается для того же job/stage без владения
|
||||
состоянием (двойное исполнение edge-гейтов).
|
||||
|
||||
---
|
||||
|
||||
## AC-3 — Мёртвый finalizer по-прежнему добивается
|
||||
|
||||
**Условие:** monitor/finalizer на `deploy-staging` реально **умер** посреди финализации (сигнал
|
||||
живости отсутствует/протух), job завис в `running`.
|
||||
- **PASS:** reaper за ограниченное время добивает job по существующему контракту (retry в пределах
|
||||
бюджета, иначе `failed` + Telegram; Tier-3 backstop как предохранитель); reaper для `deploy-staging`
|
||||
**не** превращён в no-op.
|
||||
- **FAIL:** мёртвый finalizer на `deploy-staging` не добивается reaper'ом (зомби-job блокирует очередь).
|
||||
|
||||
---
|
||||
|
||||
## AC-4 — Нет ложного отката и расхождения состояния после успешного deploy
|
||||
|
||||
**Условие:** сценарий инцидента ORCH-111 — долгая (> grace) финализация `deploy-staging` при зелёном
|
||||
`staging_status: SUCCESS`, deploy/finalizer параллельно доходит до `deploy_status: SUCCESS` / merge PR.
|
||||
- **PASS:** задача **не** откатывается `deploy-staging → development` параллельной reaper-веткой;
|
||||
developer-retry **не** инкрементируется ложно; у задачи единственное консистентное
|
||||
терминальное/стадийное состояние (сходимость, не расхождение).
|
||||
- **FAIL:** задача откатана `deploy-staging → development` и/или начислен ложный developer-retry, в то
|
||||
время как deploy фактически успешен; ветки состояния расходятся.
|
||||
|
||||
---
|
||||
|
||||
## AC-5 — Инварианты и контракт reaper сохранены
|
||||
|
||||
**Условие:** аудит диффа и поведения при выключенном kill-switch.
|
||||
- **PASS:** `STAGE_TRANSITIONS` / `QG_CHECKS` / каждый `check_*` / machine-verdict ключи / схема
|
||||
существующих таблиц — **байт-в-байт**; БД-правки только аддитивные (`_ensure_column` /
|
||||
`CREATE TABLE IF NOT EXISTS`); reaper остаётся never-raise per unit; выключенный флаг → прежнее
|
||||
поведение; правки не рестартят прод и не пушат `main`; сквозной инвариант
|
||||
`reaper_max_running_s > Σ gate-work + grace` (ORCH-065/109/110) сохранён.
|
||||
- **FAIL:** изменены `STAGE_TRANSITIONS`/`QG_CHECKS`/семантика `check_*`/machine-verdict ключи или
|
||||
схема существующих таблиц; reaper может бросить исключение из тика; флаг `False` меняет поведение;
|
||||
нарушен сквозной бюджетный инвариант.
|
||||
|
||||
---
|
||||
|
||||
## AC-6 — Обязательный регресс-тест и зелёный полный прогон
|
||||
|
||||
**Условие:** запуск тест-сюита репозитория.
|
||||
- **PASS:** добавлен регресс-тест инцидента ORCH-111 (TC-05 в `04-test-plan.yaml`), **красный** на
|
||||
коде до фикса и **зелёный** после; полный `pytest tests/ -q` зелёный.
|
||||
- **FAIL:** регресс-теста нет, либо он зелёный и до фикса (не воспроизводит баг), либо полный регресс
|
||||
`tests/` красный.
|
||||
|
||||
---
|
||||
|
||||
## Сводная матрица AC ↔ FR/BR
|
||||
| AC | Покрывает |
|
||||
|----|-----------|
|
||||
| AC-1 | BR-1 / BR-3 / FR-1 |
|
||||
| AC-2 | BR-2 / FR-2 |
|
||||
| AC-3 | BR-4 / FR-4 |
|
||||
| AC-4 | BR-5 / FR-5 |
|
||||
| AC-5 | NFR-1 / NFR-2 / NFR-3 / NFR-6 / FR-3 |
|
||||
| AC-6 | BR-1…BR-5 (регресс-доказательство) |
|
||||
76
docs/work-items/ORCH-113/04-test-plan.yaml
Normal file
76
docs/work-items/ORCH-113/04-test-plan.yaml
Normal file
@@ -0,0 +1,76 @@
|
||||
work_item: ORCH-113
|
||||
stage: analysis
|
||||
author_agent: analyst
|
||||
status: ready-for-review
|
||||
created_at: 2026-06-15
|
||||
model_used: claude-opus-4-8
|
||||
escalate: full-cycle
|
||||
title: "job-reaper не повторяет финализацию deploy-staging при живом finalizer'е: живость + идемпотентность + строгое владение"
|
||||
framework: pytest
|
||||
scope: >
|
||||
Покрывает: распознавание живого финализирующего монитора в Tier-2 reaper на стадии deploy-staging
|
||||
(не reap по одному finished_age_s); идемпотентность и строгое владение исполнением edge-гейтов
|
||||
(не более одного локального re-test/merge-gate на (job, stage)); сохранение добивания РЕАЛЬНО
|
||||
мёртвого finalizer'а; отсутствие ложного отката deploy-staging -> development и расхождения состояния
|
||||
после успешного deploy; сохранение инвариантов (STAGE_TRANSITIONS/QG_CHECKS/check_*/machine-verdict/
|
||||
схема существующих таблиц байт-в-байт; never-raise; kill-switch; сквозной бюджет ORCH-065/109/110).
|
||||
Вне покрытия: инфра-толерантность merge-gate к таймауту re-test и tree-kill осиротевших процессов
|
||||
(ORCH-110); починка конкретных мигающих тестов; поведение enduro/не-self репо (только проверяется
|
||||
отсутствие регрессии / no-op).
|
||||
notes: >
|
||||
TC-05 — ОБЯЗАТЕЛЬНЫЙ регресс-тест инцидента ORCH-111 (deployer job 1914 / run_id 683): КРАСНЫЙ на
|
||||
коде до фикса (reaper при живом долгом finalizer'е deploy-staging независимо запускает второй прогон
|
||||
edge-гейтов и откатывает задачу), ЗЕЛЁНЫЙ после фикса. Подпроцессы (pytest re-test / coverage /
|
||||
docker), сеть, Plane и Gitea — мокаются; «живой/мёртвый finalizer» и «долгая финализация > grace»
|
||||
моделируются управляемо, без обращения наружу. Полный регресс tests/ должен оставаться зелёным.
|
||||
Точные имена символов/колонок/флагов уточняет архитектор (06-adr); модули-плейсхолдеры выровнены под
|
||||
манифест PIPELINE_DOCS.
|
||||
|
||||
tests:
|
||||
- id: TC-01
|
||||
type: unit
|
||||
description: "Tier-2 reaper на deploy-staging: exit_code=0 и finished_age_s >= grace, но finalizer ЖИВ (сигнал живости присутствует) -> reaper НЕ вызывает _gate_driven_advance/advance_stage; второй прогон edge-гейтов не запускается; логируется defer (AC-1/FR-1)."
|
||||
module: tests/test_orch113_reaper_finalizer_liveness.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-02
|
||||
type: unit
|
||||
description: "Строгое владение: при попытке повторной обработки того же (job, stage) актор без владения состоянием НЕ исполняет merge-gate/локальный re-test/advance (claim/ownership проигран -> ноль побочных эффектов), AC-2/FR-2."
|
||||
module: tests/test_orch113_reaper_finalizer_liveness.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-03
|
||||
type: unit
|
||||
description: "Мёртвый finalizer на deploy-staging (сигнал живости отсутствует/протух) -> reaper по-прежнему добивает job за ограниченное время по существующему контракту (retry в пределах бюджета, иначе failed+Telegram; Tier-3 backstop срабатывает) — reaper не no-op для deploy-staging (AC-3/FR-4)."
|
||||
module: tests/test_orch113_reaper_finalizer_liveness.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-04
|
||||
type: integration
|
||||
description: "Идемпотентность под гонкой: монитор финализирует deploy-staging и параллельно срабатывает reaper-тик -> тяжёлый прогон edge-гейтов (merge-gate/re-test) исполняется РОВНО ОДИН раз для (job, stage); нет второго re-test и нет ложного rollback (AC-2/AC-4/FR-2/FR-5)."
|
||||
module: tests/test_orch113_reaper_finalizer_liveness.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-05
|
||||
type: integration
|
||||
description: "ОБЯЗАТЕЛЬНЫЙ регресс ORCH-111: долгая (> grace) финализация deploy-staging при staging_status=SUCCESS, deploy/finalizer параллельно доходит до успеха/merge PR -> reaper НЕ откатывает deploy-staging -> development и НЕ инкрементирует developer-retry; у задачи единственное консистентное состояние. КРАСНЫЙ до фикса, ЗЕЛЁНЫЙ после (AC-4/FR-5)."
|
||||
module: tests/test_orch113_reaper_finalizer_liveness.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-06
|
||||
type: unit
|
||||
description: "Регресс-гард совместимости: kill-switch выключен ИЛИ стадия не deploy-staging -> поведение reaper байт-в-байт прежнее (Tier-2 grace, claim-before-act, dead-pid/Tier-3 пути неизменны), NFR-4/AC-5."
|
||||
module: tests/test_orch113_reaper_finalizer_liveness.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-07
|
||||
type: unit
|
||||
description: "Сквозной инвариант бюджета (ORCH-065/109/110): при дефолтном конфиге reaper_max_running_s (5400) > Σ(deploy-staging gate-work) + grace; любая правка grace/таймаутов фикса инвариант не нарушает (NFR-6/AC-5)."
|
||||
module: tests/test_orch113_reaper_finalizer_liveness.py
|
||||
expected: PASS
|
||||
|
||||
- id: TC-08
|
||||
type: unit
|
||||
description: "never-raise: сбой/исключение в новом пути распознавания живости/владения деградирует безопасно — reaper-тик не падает, прочие job обрабатываются, прод не трогается, main не пушится (NFR-1/NFR-3/AC-5)."
|
||||
module: tests/test_orch113_reaper_finalizer_liveness.py
|
||||
expected: PASS
|
||||
@@ -0,0 +1,158 @@
|
||||
---
|
||||
work_item: ORCH-113
|
||||
stage: architecture
|
||||
author_agent: architect
|
||||
status: proposed
|
||||
created_at: 2026-06-15
|
||||
model_used: claude-opus-4-8
|
||||
---
|
||||
|
||||
# ADR-001: Reaper Tier-2 — in-memory ownership-маркер финализации `deploy-staging` (живой finalizer не реапится)
|
||||
|
||||
Work Item: **ORCH-113** — BUG: job-reaper повторно запускает финализацию `deploy-staging`, пока жив исходный finalizer
|
||||
Стадия: **architecture**
|
||||
Сквозная регистрация: **`docs/architecture/adr/adr-0043-reaper-finalizer-liveness-ownership.md`** (решение кросс-каттинговое — уточняет контракт reaper ORCH-065/adr-0011).
|
||||
|
||||
## Статус
|
||||
Proposed
|
||||
|
||||
## Контекст
|
||||
|
||||
Оркестратор self-hosting: один инстанс, общая БД/очередь, `max_concurrency=1`. Финальный статус job
|
||||
(`done`/`queued`/`failed`/`cancelled`) пишется **только** в живом процессе
|
||||
(`launcher._monitor_agent → _finalize_job`). Сверено по коду:
|
||||
|
||||
- `_monitor_agent` штампит `agent_runs.finished_at`/`exit_code` **ПЕРВЫМ** (`launcher.py:861`), затем
|
||||
делает git commit/push (+PR), usage-комментарии Plane (секунды…десятки секунд), затем
|
||||
`_try_advance_stage` (`launcher.py:998`) и лишь потом `_finalize_job` (`launcher.py:1003`).
|
||||
- На ребре `deploy-staging → deploy` `_try_advance_stage → advance_stage` синхронно, **в потоке
|
||||
монитора**, прогоняет тяжёлый набор edge-под-гейтов (`stage_engine.py:327–368`):
|
||||
`security` → `merge-gate` (полный локальный re-test, бюджет `merge_retest_timeout_s=900`) →
|
||||
`coverage` (`pytest --cov`) → `image-freshness` (docker-rebuild + пересоздание staging) — это
|
||||
**минуты**, и весь объём идёт **после** штампа `finished_at` и **до** `_finalize_job`.
|
||||
- Reaper Tier-2 (`job_reaper._reap_job`, `job_reaper.py:197–209`) меряет `finished_age_s` от
|
||||
`agent_runs.finished_at` (`db.get_running_jobs`, `db.py:1360`) = **от начала** финализации. По
|
||||
истечении `reaper_finalize_grace_s=300` он трактует живого, долго финализирующего монитора как
|
||||
мёртвого и независимо запускает тот же тяжёлый advance (`_reap_exit0 → _gate_driven_advance →
|
||||
_try_advance_stage → advance_stage`).
|
||||
|
||||
Дешёвая read-only пред-проверка `_gate_is_green('deploy-staging')` читает лишь `check_staging_status`
|
||||
(frontmatter `15-staging-log.md` = `SUCCESS`) → reaper уверенно идёт в тяжёлый advance. Атомарный
|
||||
claim-before-act (`reap_running_job ... WHERE status='running'`) защищает **флип строки** job, но **не
|
||||
side-effectful исполнение edge-гейтов**: монитор не claim'ит строку перед `advance_stage`, поэтому
|
||||
монитор и reaper выполняют `advance_stage` **параллельно**.
|
||||
|
||||
**Инцидент ORCH-111 (deployer job 1914, run_id 683):** финализация `deploy-staging` заняла >300с;
|
||||
reaper повторил edge-гейты; один повторный re-test стал красным (`3 failed … 14 errors in 444.79s`);
|
||||
задача ложно откатана `deploy-staging → development` (+ ложный developer-retry), **параллельно**
|
||||
исходный finalizer довёл deploy до `SUCCESS` и смержил PR #130 (`deploy → done`). Состояние раздвоилось.
|
||||
|
||||
Источника истины «жив ли finalizer» сегодня нет: pid агента в Tier-2 уже мёртв в **обоих** случаях
|
||||
(`proc.wait()` вернулся), а живость **потока-монитора** система не наблюдает. Per-stage grace,
|
||||
покрывающая Σ финализации (`Σ ≈ 4160с`), невозможна без нарушения сквозного бюджета ORCH-065/109/110
|
||||
`reaper_max_running_s (5400) > Σ(deploy-staging gate-work) + grace (≈4460)`.
|
||||
|
||||
**Решающий факт (проверен):** монитор и reaper — daemon-**потоки одного** uvicorn-процесса (CMD без
|
||||
`--workers`; `_monitor_agent` стартует `threading.Thread`, `launcher.py:661`; reaper — daemon-поток,
|
||||
`main.py:144`), разделяющие одну SQLite-БД. Значит, живость finalizer'а можно определить **in-memory**.
|
||||
Рестарт покрыт существующим `requeue_running_jobs()` (`running → queued`), который в `main.lifespan`
|
||||
вызывается (`main.py:59`) **до** старта reaper (`main.py:144`).
|
||||
|
||||
## Решение
|
||||
|
||||
### Сводка
|
||||
Ввести **процесс-локальный реестр владения финализацией**: живой монитор регистрирует «я финализирую
|
||||
job X», а reaper в Tier-2 на стадии `deploy-staging` **не реапит** job, чьё владение активно, и
|
||||
переходит к Tier-3 backstop. Реестр in-memory — авторитетен в рамках одного процесса/БД; рестарт
|
||||
покрыт `requeue_running_jobs`. Grace и `reaper_max_running_s` не меняются → сквозной бюджет цел. Под
|
||||
глобальным kill-switch; **нулевое** изменение схемы БД и контрактов.
|
||||
|
||||
### D1 — Leaf `src/finalizer_liveness.py` (владение, FR-2)
|
||||
Новый чистый процесс-локальный модуль (паттерн `serial_gate`/`coverage_gate`: never-raise, без сети/БД):
|
||||
- `mark(job_id, run_id, stage)` — зарегистрировать активную финализацию;
|
||||
- `clear(job_id)` — снять;
|
||||
- `is_active(job_id) -> bool` — есть ли живое владение;
|
||||
- `snapshot() -> dict` — read-only для наблюдаемости.
|
||||
|
||||
Состояние — `{job_id: {"run_id", "stage", "started_ts"}}` + `threading.Lock`. Собственного TTL нет —
|
||||
ограничение по времени даёт Tier-3 (см. D3). Все функции изолированы `try/except` → дефолт
|
||||
(`is_active` при ошибке → `False`, консервативно: не блокировать добивание).
|
||||
|
||||
### D2 — Эмиссия владения в `launcher._monitor_agent` (FR-1)
|
||||
`mark(job_id, run_id, stage)` вызывается **сразу после** штампа `exit_code` (`launcher.py:864`, самый
|
||||
ранний момент, когда reaper переходит в Tier-2; до этого pid агента жив → Tier-1 защищает). Хвост
|
||||
финализации (git push … `_try_advance_stage` … `_finalize_job`) оборачивается в `try/finally`, в
|
||||
`finally` — `clear(job_id)`. Так исключение **в потоке монитора** гарантированно снимает владение →
|
||||
reaper добивает (FR-4). Только при `job_id is not None` (legacy `launch()` с `job_id=None` не в
|
||||
`get_running_jobs`). Гибель **всего процесса** → рестарт → `requeue_running_jobs` → реестр пуст
|
||||
(restart-safe без durable, NFR-5).
|
||||
|
||||
### D3 — Консультация reaper, scoped + Tier-3 backstop (FR-3, FR-4)
|
||||
В `job_reaper._reap_job`, Tier-2-ветка (`exit_code` записан, `finished_age >= grace`): **перед**
|
||||
`_reap_known_outcome` — если `settings.reaper_finalizer_liveness_enabled` **И** стадия задачи
|
||||
(`_task_meta`) `== "deploy-staging"` **И** `finalizer_liveness.is_active(job_id)` → **defer** (лог +
|
||||
счётчик), **не** реапить через Tier-2, провалиться к Tier-3. Иначе — прежний путь
|
||||
(`_reap_known_outcome; return`), байт-в-байт. **Tier-3** (`age >= reaper_max_running_s`) маркер
|
||||
**игнорирует** — добивает всегда (ограниченное время; бюджет `5400 > Σ+grace ≈ 4460` гарантирует, что
|
||||
легитимная финализация завершится до 5400 → ложного Tier-3-реапа живого finalizer'а нет).
|
||||
|
||||
### D4 — Скоуп и kill-switch (NFR-4)
|
||||
Только глобальный `reaper_finalizer_liveness_enabled` (`config.py`, env
|
||||
`ORCH_REAPER_FINALIZER_LIVENESS_ENABLED`, дефолт `True`). **Без** per-repo разреза: баг общий для
|
||||
любого репо со стадией `deploy-staging` (enduro тоже); per-repo оставил бы баг активным для части
|
||||
репо. Это сознательный отход от leaf-паттерна `*_repos` (он для **гейтов, действующих на репо**; здесь
|
||||
— наблюдатель-безопасность глобального демона). `False` → reaper никогда не консультирует маркер →
|
||||
поведение байт-в-байт прежнее; стадии `!= deploy-staging` не консультируются → не тронуты.
|
||||
|
||||
### D5 — Наблюдаемость (TZ §4)
|
||||
Счётчик `finalizer_defers_total` + размер `finalizer_liveness.snapshot()` в блоке `reaper`
|
||||
`GET /queue`. Существующие ключи ответа не меняются; новых эндпоинтов нет.
|
||||
|
||||
### Инварианты
|
||||
`STAGE_TRANSITIONS` / `QG_CHECKS` / каждый `check_*` / machine-verdict ключи / схема существующих
|
||||
таблиц — **байт-в-байт**; **нулевое** изменение схемы БД; reaper остаётся never-raise per-unit;
|
||||
`reaper_finalize_grace_s` и `reaper_max_running_s` **не меняются** (NFR-6 цел); фикс не рестартит прод
|
||||
и не пушит `main` (NFR-3). Merge-verify (`deploy → done`, ORCH-071) — единственный choke-point в
|
||||
`done`, не ослабляется (FR-5).
|
||||
|
||||
## Альтернативы
|
||||
- **Per-stage grace, покрывающая Σ** — отвергнуто: нарушает бюджет `5400 > Σ+grace` (Σ≈4160 ⇒ grace
|
||||
пришлось бы <1240, не покрывает Σ); таймер — это и есть источник бага.
|
||||
- **Durable-колонка (finalizing-heartbeat / owner-токен)** — отвергнуто: один процесс/одна БД →
|
||||
in-memory авторитетно; рестарт покрыт requeue; блокирующий re-test (900с) не может бить периодический
|
||||
heartbeat из того же потока; durable добавляет миграцию и запись ради нулевой выгоды.
|
||||
- **Sub-state `finalizing` в `jobs.status`** — отвергнуто: меняет семантику статуса, который читают
|
||||
`claim_next_job`/`requeue_running_jobs`/`reconciler`/`reaper` (`WHERE status='running'`) — нарушение
|
||||
NFR-2 и высокий радиус поражения.
|
||||
- **Lease-файл на `(job, stage)` (как merge-lease)** — отвергнуто: тяжелее (файловый I/O, TTL,
|
||||
reclaim), дублирует merge-lease; in-memory достаточно при одном процессе; TTL возвращает таймер-проблему.
|
||||
- **Флип job из `running` до тяжёлых гейтов** — отвергнуто: ломает `get_running_jobs`/метрики и
|
||||
restart-requeue (краш мид-гейт оставит job non-running и нереквью'имым).
|
||||
|
||||
## Последствия
|
||||
- **+** Устранены повторный прогон edge-гейтов, ложный откат `deploy-staging → development` и
|
||||
расхождение состояния при живом долгом finalizer'е; идемпотентность edge-гейтов через владение
|
||||
(AC-1/AC-2/AC-4).
|
||||
- **+** Реально мёртвый/застрявший finalizer добивается (finally-clear → Tier-2; иначе Tier-3);
|
||||
основная функция reaper ORCH-065 сохранена (AC-3).
|
||||
- **+** Нулевое изменение схемы и контрактов; сквозной бюджет ORCH-065/109/110 не тронут (AC-5).
|
||||
- **−** Гарантия владения валидна при **одном процессе/одной БД** (проверено: один uvicorn-воркер);
|
||||
ввод `--workers>1` потребует durable-сигнала — зафиксировано в `10-tech-risks.md` (TR-3).
|
||||
- **−** Окно «штамп `finished_at` → `mark()`» (git push) маркером не покрыто — закрыто прежним
|
||||
grace=300 (окно ≪ grace), TR-2.
|
||||
- **Откат:** `ORCH_REAPER_FINALIZER_LIVENESS_ENABLED=false` → reaper байт-в-байт прежний (маркер
|
||||
по-прежнему пишется монитором, но не консультируется — инертен). Полный откат — удаление leaf +
|
||||
двух врезок.
|
||||
|
||||
## Ссылки
|
||||
- BRD: `docs/work-items/ORCH-113/01-brd.md`
|
||||
- TRZ: `docs/work-items/ORCH-113/02-trz.md`
|
||||
- Acceptance: `docs/work-items/ORCH-113/03-acceptance-criteria.md`
|
||||
- Test-plan: `docs/work-items/ORCH-113/04-test-plan.yaml`
|
||||
- Сквозной ADR: `docs/architecture/adr/adr-0043-reaper-finalizer-liveness-ownership.md`
|
||||
- Базовые ADR: `adr-0011` (reaper/lease ORCH-065), `adr-0040` (timeout-бюджеты ORCH-109),
|
||||
`adr-0042` (merge-gate re-test ORCH-110)
|
||||
- Сверено по коду: `src/job_reaper.py`, `src/agents/launcher.py` (`_monitor_agent`/`_try_advance_stage`),
|
||||
`src/db.py` (`get_running_jobs`/`requeue_running_jobs`), `src/stage_engine.py` (`advance_stage` ребро
|
||||
`deploy-staging`), `src/config.py` (`reaper_*`), `src/main.py` (`lifespan`)
|
||||
</content>
|
||||
40
docs/work-items/ORCH-113/07-infra-requirements.md
Normal file
40
docs/work-items/ORCH-113/07-infra-requirements.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
work_item: ORCH-113
|
||||
stage: architecture
|
||||
author_agent: architect
|
||||
status: proposed
|
||||
created_at: 2026-06-15
|
||||
model_used: claude-opus-4-8
|
||||
---
|
||||
|
||||
# 07 — Инфра-требования: ORCH-113 — reaper finalizer-liveness ownership
|
||||
|
||||
Work Item: **ORCH-113** · Repo: **orchestrator** · Стадия: architecture
|
||||
|
||||
> When-applicable / информационный. Топология **не меняется**; ниже — только конфиг и операционные
|
||||
> инварианты, которые сопровождающий обязан удержать.
|
||||
|
||||
## Изменения топологии
|
||||
**N/A.** Ни новых сервисов/контейнеров, ни портов, ни томов, ни сетевых правил. Решение целиком внутри
|
||||
процесса `orchestrator` (новый leaf + две врезки в существующие потоки monitor/reaper).
|
||||
|
||||
## Новый конфиг (env)
|
||||
| Ключ | Дефолт | Назначение |
|
||||
|------|--------|-----------|
|
||||
| `ORCH_REAPER_FINALIZER_LIVENESS_ENABLED` | `true` | Kill-switch. `false` → reaper байт-в-байт прежний (маркер пишется, но не консультируется). Откат фикса = установить `false`. |
|
||||
|
||||
Существующие `reaper_finalize_grace_s` (300) и `reaper_max_running_s` (5400) — **не меняются**.
|
||||
`.env.example` пополнить новым ключом (дефолт = боевое значение, паттерн ORCH-101: пустой `.env` ⇒
|
||||
прежнее поведение).
|
||||
|
||||
## Операционные инварианты (сопровождение)
|
||||
- **Одно-процессная модель — несущий инвариант.** Авторитетность in-memory реестра владения держится
|
||||
на том, что монитор и reaper — потоки **одного** uvicorn-процесса. CMD/команда compose **не должны**
|
||||
получать `uvicorn --workers>1` без перевода сигнала в durable (см. `10-tech-risks.md` TR-3, ADR-001).
|
||||
Сверено: `Dockerfile:65`, `docker-compose.yml:36` (prod), `docker-compose.yml:123` (staging) — без
|
||||
`--workers`.
|
||||
- **Сквозной бюджет ORCH-065/109/110** `reaper_max_running_s (5400) > Σ(deploy-staging gate-work)+grace
|
||||
(≈4460)` остаётся в силе и фиксом не затрагивается (TR-4).
|
||||
- **Self-hosting-страховка:** обкатка — на staging (8501, изолированная БД) до прод-деплоя; деплой
|
||||
орка — только через статус «Confirm Deploy». Фикс не рестартит прод и не пушит `main`.
|
||||
</content>
|
||||
43
docs/work-items/ORCH-113/08-data-requirements.md
Normal file
43
docs/work-items/ORCH-113/08-data-requirements.md
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
work_item: ORCH-113
|
||||
stage: architecture
|
||||
author_agent: architect
|
||||
status: proposed
|
||||
created_at: 2026-06-15
|
||||
model_used: claude-opus-4-8
|
||||
---
|
||||
|
||||
# 08 — Требования к данным: ORCH-113 — reaper finalizer-liveness ownership
|
||||
|
||||
Work Item: **ORCH-113** · Repo: **orchestrator** · Стадия: architecture
|
||||
|
||||
> When-applicable / информационный (гейтом не парсится).
|
||||
|
||||
## Изменения схемы БД
|
||||
|
||||
**N/A — нулевое изменение схемы.** Сознательное архитектурное решение (ADR-001 / adr-0043): сигнал
|
||||
владения финализацией — **in-memory** (leaf `src/finalizer_liveness.py`), а не durable-колонка. Ни
|
||||
новых таблиц, ни новых колонок, ни индексов; `init_db()` / `_ensure_column` не трогаются. Схема
|
||||
существующих таблиц (`jobs`, `agent_runs`, `tasks`, …) и их семантика — **байт-в-байт** (NFR-2/AC-5).
|
||||
|
||||
## Новые/изменённые сущности
|
||||
|
||||
**Процесс-локальный реестр владения** (не БД): `finalizer_liveness` хранит
|
||||
`{job_id: {"run_id", "stage", "started_ts"}}` под `threading.Lock`. Запись/снятие — живой
|
||||
монитор-поток (`launcher._monitor_agent`); чтение — reaper-поток (`job_reaper`). Ключ — `jobs.id`
|
||||
(существующая сущность). Никаких новых персистентных данных.
|
||||
|
||||
## Совместимость данных / миграции
|
||||
|
||||
- **Миграций нет** — нечего мигрировать (нет схемных изменений); общая прод-БД (self-hosting +
|
||||
enduro-trails) не затрагивается.
|
||||
- **Restart-safe без durable (NFR-5):** in-memory реестр сбрасывается при рестарте процесса, что
|
||||
**безопасно** по существующему контракту: `main.lifespan` вызывает `requeue_running_jobs()`
|
||||
(`running → queued`, `main.py:59`) **до** старта reaper (`main.py:144`). После рестарта нет ни одного
|
||||
`running`-job, ссылающегося на потерянный маркер → отсутствие маркера корректно (нет живых
|
||||
finalizer'ов). Гибель **потока** монитора (не процесса) покрыта `try/finally`-снятием маркера; гибель
|
||||
**процесса** → рестарт → requeue.
|
||||
- **Авторитетность in-memory** опирается на одно-процессную модель (один uvicorn-воркер, общая
|
||||
SQLite-БД; проверено: CMD без `--workers`). Условие задокументировано как инвариант сопровождения —
|
||||
при вводе `--workers>1` сигнал должен стать durable (см. `10-tech-risks.md` TR-3).
|
||||
</content>
|
||||
37
docs/work-items/ORCH-113/10-tech-risks.md
Normal file
37
docs/work-items/ORCH-113/10-tech-risks.md
Normal file
@@ -0,0 +1,37 @@
|
||||
---
|
||||
work_item: ORCH-113
|
||||
stage: architecture
|
||||
author_agent: architect
|
||||
status: proposed
|
||||
created_at: 2026-06-15
|
||||
model_used: claude-opus-4-8
|
||||
---
|
||||
|
||||
# 10 — Технические риски: ORCH-113 — reaper finalizer-liveness ownership
|
||||
|
||||
Work Item: **ORCH-113** · Repo: **orchestrator** · Стадия: architecture
|
||||
|
||||
> Информационный (гейтом не парсится). Риски реализации и их митигейшн.
|
||||
|
||||
## Реестр рисков
|
||||
|
||||
| ID | Риск | Вер. | Влия. | Митигейшн |
|
||||
|----|------|------|-------|-----------|
|
||||
| TR-1 | **Над-толерантность:** маркер «жив» застрял (не снят) → реально мёртвый finalizer не добивается, зомби клинит очередь (регресс ORCH-065). | Низ. | Выс. | `try/finally`-снятие в `_monitor_agent` (исключение потока снимает владение); гибель процесса → рестарт → `requeue_running_jobs`. **Tier-3 backstop игнорирует маркер** и добивает при `age >= reaper_max_running_s=5400` → ограниченное время гарантировано (FR-4/AC-3). Покрытие — TC-03. |
|
||||
| TR-2 | **Окно без владения** между штампом `finished_at` (launcher:861) и `mark()` (после exit_code, launcher:864): reaper мог бы реапнуть в этом окне. | Низ. | Сред. | Окно = git push/PR/Plane-комментарии (секунды…десятки секунд) ≪ `reaper_finalize_grace_s=300` → прежний grace покрывает его; маркер ставится самым ранним возможным моментом Tier-2 (до этого pid агента жив → Tier-1 защищает). |
|
||||
| TR-3 | **Многопроцессность:** при `uvicorn --workers>1` монитор и reaper окажутся в разных процессах → in-memory реестр не разделяется → возможна двойная финализация. | Низ. | Выс. | Сейчас CMD без `--workers` (проверено: `Dockerfile:65`, `docker-compose.yml:36`). Инвариант сопровождения зафиксирован в ADR-001/adr-0043 и 08-data-requirements: ввод `--workers>1` ⇒ перевести сигнал в durable (heartbeat-колонка) — отдельная задача. Анти-дрейф можно усилить структурным тестом (нет `--workers` в CMD). |
|
||||
| TR-4 | **Нарушение сквозного бюджета** ORCH-065/109/110 при правке grace/таймаутов. | Оч. низ. | Выс. | Решение **не меняет** `reaper_finalize_grace_s` (300) и `reaper_max_running_s` (5400) — инвариант `5400 > Σ(deploy-staging gate-work)+grace ≈ 4460` тривиально цел; покрытие — TC-07/AC-5. |
|
||||
| TR-5 | **Гонка чтения/записи** реестра (монитор пишет, reaper читает). | Низ. | Сред. | `threading.Lock` вокруг операций реестра; `is_active`/`snapshot` атомарны под локом; never-raise → ошибка чтения = `False` (консервативно, не блокирует добивание). Покрытие — TC-02/TC-04/TC-08. |
|
||||
| TR-6 | **Регресс не-deploy-staging / выключенного флага** (NFR-4): фикс случайно меняет прежние пути reaper. | Низ. | Сред. | Консультация маркера gated `enabled AND stage=="deploy-staging"`; Tier-1/Tier-3/exit≠0/claim-before-act не трогаются; `False` → reaper байт-в-байт прежний. Покрытие — TC-06. |
|
||||
| TR-7 | **Ложный regression-тест** TC-05: зелёный и до фикса (не воспроизводит баг). | Сред. | Сред. | TC-05 моделирует «живой долгий finalizer > grace» управляемо (моки подпроцессов/сети); обязан быть **красным до** фикса и **зелёным после** (AC-6). Reviewer/tester проверяют красноту на базе. |
|
||||
|
||||
## Сводный вывод
|
||||
|
||||
Доминирующий класс — **над-толерантность** (TR-1) и **многопроцессная авторитетность** (TR-3); оба
|
||||
имеют низкую вероятность и закрыты соответственно Tier-3 backstop'ом (без правки бюджета) и
|
||||
зафиксированным инвариантом одно-процессной модели. Решение аддитивно, под kill-switch, без изменения
|
||||
схемы/контрактов и без правки сквозного бюджета. Эскалация `arch:major-change` **не требуется**
|
||||
(нет новой стадии/QG, нет изменения схемы БД, центр тяжести — один leaf + две точечные врезки).
|
||||
Остаточный риск для прод-конвейера (self-hosting) — **низкий**; полностью обратим выключением
|
||||
`ORCH_REAPER_FINALIZER_LIVENESS_ENABLED`.
|
||||
</content>
|
||||
86
docs/work-items/ORCH-113/12-review.md
Normal file
86
docs/work-items/ORCH-113/12-review.md
Normal file
@@ -0,0 +1,86 @@
|
||||
---
|
||||
verdict: APPROVED
|
||||
work_item: ORCH-113
|
||||
stage: review
|
||||
author_agent: reviewer
|
||||
status: approved
|
||||
created_at: 2026-06-15
|
||||
model_used: claude-opus-4-8
|
||||
type: review
|
||||
work_item_id: ORCH-113
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Review ORCH-113 — BUG: job-reaper не должен повторно запускать финализацию `deploy-staging`, пока жив исходный finalizer
|
||||
|
||||
## Summary
|
||||
|
||||
Фикс инцидента ORCH-111 реализован чисто и полно. Введён процесс-локальный реестр владения
|
||||
финализацией (`src/finalizer_liveness.py`, never-raise leaf по паттерну `serial_gate`/`coverage_gate`):
|
||||
монитор `mark()`-ит владение сразу после штампа `exit_code` и `clear()`-ит его в `finally` хвоста
|
||||
финализации; reaper в Tier-2 при `stage=="deploy-staging"` И активном владении делает **defer** вместо
|
||||
повторного `advance_stage`, проваливаясь к Tier-3 backstop (который маркер игнорирует → застрявший
|
||||
finalizer всё равно добивается).
|
||||
|
||||
Проверено по всем 4 осям; блокирующих findings нет.
|
||||
|
||||
- **ТЗ:** FR-1…FR-5 реализованы; AC-1…AC-6 покрыты тестами `tests/test_orch113_reaper_finalizer_liveness.py`
|
||||
(TC-01…TC-08). Схема БД — **нулевое** изменение (выбран in-memory реестр), что строже допущенной ТЗ §5
|
||||
«аддитивная колонка». API §4 (read-only ключи в `GET /queue`) и QG §6 (не трогать) — соблюдены.
|
||||
- **ADR:** реализация байт-в-байт соответствует ADR-001 / сквозному adr-0043 (D1–D5). Трассировка
|
||||
сохранена: авторитет Tier-3 (adr-0011/ORCH-065) и сквозной бюджет `reaper_max_running_s (5400) >
|
||||
Σ(gate-work)+grace` (ORCH-109/110) не нарушены — зафиксировано регресс-тестом TC-07. Ни один
|
||||
маркированный инвариант не сломан.
|
||||
- **Качество кода:** хвост `_monitor_agent` вынесен в `_run_monitor_finalization` **дословно** —
|
||||
подтверждено `git diff -w` (+49/−0, нулевое изменение логики); все переменные, на которые ссылается
|
||||
извлечённое тело, — параметры/локальные/модульные (нет риска `NameError`, проверено вручную).
|
||||
never-raise во всех публичных функциях и врезках. Обязательный регресс-тест багфикс-трека (ORCH-019
|
||||
BR-4 / coverage ORCH-027) присутствует: TC-05 по построению КРАСНЫЙ до фикса (assert `calls == []`,
|
||||
который pre-fix reaper нарушил бы вызовом `_try_advance_stage`) и ЗЕЛЁНЫЙ после.
|
||||
- **Документация:** обновлены в том же PR — `docs/architecture/README.md` (описание Job-reaper +
|
||||
раздел Tier-2 + список kill-switch + ссылки на ADR), `docs/architecture/internals.md` (детализация
|
||||
Tier-2), `CHANGELOG.md`, ADR-001 (work-item) и сквозной adr-0043; все номерные доки задачи (00–04,
|
||||
06-adr, 07, 08, 10) на месте.
|
||||
|
||||
**Проверка прогона:** `pytest tests/ -q` → **2001 passed**, 0 failures (AC-6); целевой файл — 13 passed.
|
||||
|
||||
## Findings
|
||||
|
||||
### P0 — Blocker
|
||||
- _нет_
|
||||
|
||||
### P1 — Must fix
|
||||
- _нет_
|
||||
|
||||
### P2 — Should fix
|
||||
- _нет_
|
||||
|
||||
### P3 — Nice-to-have (не блокирует приёмку)
|
||||
- [ ] Frontmatter обоих ADR (`ADR-001` и `adr-0043`) держит `status: proposed`. По мере мержа фикса
|
||||
статус естественно становится принятым решением — стоит при следующем касании обновить на `accepted`
|
||||
(косметика трассировки, не влияет на гейт).
|
||||
- [ ] В врезке `mark()` (`launcher._monitor_agent`, стр. ~884) делается отдельный
|
||||
`get_task_by_repo_branch(repo, branch)` ради `stage`-контекста, хотя тот же lookup повторяется ниже в
|
||||
хвосте финализации (стр. ~984). Дублирование на пути, и так делающем БД-работу, обёрнуто never-raise;
|
||||
`stage` здесь — best-effort контекст для `snapshot()` (reaper резолвит стадию независимо через
|
||||
`_task_meta`), так что корректность не зависит от него. Можно при желании переиспользовать один lookup.
|
||||
|
||||
## Документация
|
||||
|
||||
**Статус: полностью обновлена в том же PR (golden source соблюдён).**
|
||||
|
||||
| Артефакт | Изменение | Оценка |
|
||||
|----------|-----------|--------|
|
||||
| `docs/architecture/README.md` | Job-reaper компонент + раздел Tier-2 + список kill-switch (`ORCH_REAPER_FINALIZER_LIVENESS_ENABLED`) + ссылки на adr-0043 | ✅ |
|
||||
| `docs/architecture/internals.md` | Детализация Tier-2 deploy-staging defer | ✅ |
|
||||
| `CHANGELOG.md` | Развёрнутая запись `[Unreleased]` с подпунктами (leaf / эмиссия / консультация / наблюдаемость) | ✅ |
|
||||
| `docs/work-items/ORCH-113/06-adr/ADR-001-…` | Детальный ADR (D1–D5, альтернативы, последствия) | ✅ |
|
||||
| `docs/architecture/adr/adr-0043-…` | Сквозной ADR (уточняет adr-0011/0040/0042/0041) | ✅ |
|
||||
| `docs/work-items/ORCH-113/{00..04,07,08,10}` | Полный пакет номерных доков | ✅ |
|
||||
|
||||
**Обзорные доки / витрина:** правка внутренняя для job-reaper; высокоуровневые описания в
|
||||
`docs/overview/tech-architecture.md` («job-reaper возвращает в очередь job'ы, чей исполнитель умер») и
|
||||
`README.md` остаются корректными — обновления не требуют (ORCH-079/ORCH-011 не задеты). Раздел README
|
||||
«Известные ограничения» не содержит пункта, закрываемого этим PR (баг был инцидентом, не значился
|
||||
ограничением) — обновление не требуется. Известное ограничение `--workers>1` (TR-3) — системное
|
||||
пред-допущение, документировано в `10-tech-risks.md` и обоих ADR; вынос в README не обязателен.
|
||||
66
docs/work-items/ORCH-113/13-test-report.md
Normal file
66
docs/work-items/ORCH-113/13-test-report.md
Normal file
@@ -0,0 +1,66 @@
|
||||
---
|
||||
result: PASS # PASS | FAIL — машинный вердикт, UPPERCASE
|
||||
work_item: ORCH-113
|
||||
stage: testing
|
||||
author_agent: tester
|
||||
status: pass
|
||||
created_at: 2026-06-15
|
||||
model_used: claude-opus-4-8
|
||||
type: test-report
|
||||
work_item_id: ORCH-113
|
||||
---
|
||||
|
||||
# Test Report — ORCH-113
|
||||
|
||||
BUG: job-reaper не должен повторно запускать финализацию `deploy-staging`, пока жив исходный finalizer.
|
||||
|
||||
## Окружение
|
||||
- Worktree: `/repos/_wt/orchestrator/feature_ORCH-113-bug-job-reaper-must-not-re-run` (ветка `feature/ORCH-113-bug-job-reaper-must-not-re-run`)
|
||||
- Python: 3.12.13
|
||||
- pytest: 8.3.3 (plugins: cov-5.0.0, anyio-4.13.0, asyncio-0.23.8)
|
||||
- Дата: 2026-06-15
|
||||
- Review verdict (12-review.md): `APPROVED`
|
||||
|
||||
## Smoke API (read-only)
|
||||
| Endpoint | Результат |
|
||||
|----------|-----------|
|
||||
| `GET /health` | `{"status":"ok","service":"orchestrator"}` — PASS |
|
||||
| `GET /status` | Отвечает; ORCH-113 (task 101) виден на стадии `testing` — PASS |
|
||||
| `GET /queue` | Блоки `serial_gate` (ORCH-088) **и** `auto_labels` (ORCH-089) присутствуют — PASS |
|
||||
|
||||
## Результаты — покрытие тест-плана (04-test-plan.yaml)
|
||||
|
||||
Все TC реализованы в `tests/test_orch113_reaper_finalizer_liveness.py` (13 тест-функций на 8 TC).
|
||||
|
||||
| TC ID | Описание | AC / FR | Тест-функция | Результат |
|
||||
|-------|----------|---------|--------------|-----------|
|
||||
| TC-01 | Живой finalizer на `deploy-staging` (exit=0, age≥grace) → reaper НЕ вызывает `_gate_driven_advance`/`advance_stage`, логирует defer | AC-1/FR-1 | `test_tc01_live_finalizer_deploy_staging_not_reaped` | PASS |
|
||||
| TC-02 | Строгое владение: актор без владения НЕ исполняет merge-gate/re-test/advance (ноль побочных эффектов) | AC-2/FR-2 | `test_tc02_non_owner_runs_no_edge_gates` | PASS |
|
||||
| TC-03 | Мёртвый finalizer → reaper по-прежнему добивает job (Tier-2 retry + Tier-3 backstop игнорирует маркер); reaper не no-op | AC-3/FR-4 | `test_tc03_dead_finalizer_still_reaped_tier2`, `test_tc03_tier3_backstop_ignores_marker` | PASS |
|
||||
| TC-04 | Идемпотентность под гонкой: тяжёлый прогон edge-гейтов исполняется ровно один раз для `(job, stage)`, нет второго re-test/ложного rollback | AC-2/AC-4/FR-2/FR-5 | `test_tc04_idempotent_no_second_advance_under_race` | PASS |
|
||||
| TC-05 | **ОБЯЗАТЕЛЬНЫЙ регресс ORCH-111:** долгая (>grace) финализация при `staging_status=SUCCESS` → нет отката `deploy-staging → development`, нет ложного developer-retry; единственное консистентное состояние (красный до фикса, зелёный после) | AC-4/FR-5 | `test_tc05_orch111_no_false_rollback_no_retry_increment` | PASS |
|
||||
| TC-06 | Регресс-гард совместимости: kill-switch off ИЛИ не-`deploy-staging` → поведение reaper байт-в-байт прежнее | NFR-4/AC-5 | `test_tc06_killswitch_off_byte_for_byte_prior`, `test_tc06_non_deploy_staging_stage_not_consulted`, `test_tc06_within_grace_unchanged` | PASS |
|
||||
| TC-07 | Сквозной инвариант бюджета: `reaper_max_running_s (5400) > Σ(deploy-staging gate-work) + grace` (ORCH-065/109/110) | NFR-6/AC-5 | `test_tc07_budget_invariant_preserved` | PASS |
|
||||
| TC-08 | never-raise: сбой пути живости/владения деградирует безопасно — reaper-тик не падает, прочие job обрабатываются | NFR-1/NFR-3/AC-5 | `test_tc08_liveness_error_never_breaks_tick`, `test_tc08_reap_once_isolates_and_never_raises`, `test_tc08_finalizer_liveness_leaf_never_raises` | PASS |
|
||||
|
||||
**Сопоставление с 03-acceptance-criteria.md:** AC-1…AC-6 покрыты (AC-1→TC-01, AC-2→TC-02/TC-04, AC-3→TC-03, AC-4→TC-04/TC-05, AC-5→TC-06/TC-07/TC-08, AC-6→полный зелёный прогон + TC-05 как регресс-доказательство). Каждый TC из тест-плана выполнен и сопоставлен.
|
||||
|
||||
## Вывод pytest
|
||||
|
||||
Целевой файл:
|
||||
```
|
||||
tests/test_orch113_reaper_finalizer_liveness.py ... 13 passed, 1 warning in 3.60s
|
||||
```
|
||||
|
||||
Полный регресс:
|
||||
```
|
||||
$ python -m pytest tests/ -q
|
||||
........................................................................ [ 39%]
|
||||
... (snip) ...
|
||||
......................................................... [100%]
|
||||
2001 passed, 1 warning in 316.72s (0:05:16)
|
||||
```
|
||||
(единственный warning — `PydanticDeprecatedSince20` в `src/config.py:8`, не относится к задаче, присутствует исторически)
|
||||
|
||||
## Итог
|
||||
**PASS** — целевой файл 13/13 PASS, полный регресс `tests/` 2001 passed / 0 failed, smoke API (`/health`, `/status`, `/queue` с блоками `serial_gate` + `auto_labels`) зелёный, каждый TC тест-плана выполнен и сопоставлен с критериями приёмки. Задача переходит на `deploy-staging`.
|
||||
12
docs/work-items/ORCH-113/14-deploy-log.md
Normal file
12
docs/work-items/ORCH-113/14-deploy-log.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
deploy_status: SUCCESS
|
||||
work_item: ORCH-113
|
||||
hook_exit_code: 0
|
||||
deployed_by: deploy-finalizer
|
||||
---
|
||||
|
||||
# Deploy log — ORCH-036 executable self-deploy
|
||||
|
||||
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
|
||||
|
||||
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.
|
||||
25
docs/work-items/ORCH-113/17-security-report.md
Normal file
25
docs/work-items/ORCH-113/17-security-report.md
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
security_status: PASS
|
||||
secrets_found: 0
|
||||
deps_blocking: 0
|
||||
deps_warning: 4
|
||||
deps_audit_degraded: false
|
||||
---
|
||||
# Security Report — ORCH-113
|
||||
|
||||
Детерминированный security-гейт (ORCH-022): secret-scanning (gitleaks, offline) + dependency audit (pip-audit). Машинный вердикт читается ТОЛЬКО из frontmatter выше.
|
||||
|
||||
## Verdict
|
||||
clean: 0 secrets, 0 blocking CVE(s)
|
||||
|
||||
## Secrets
|
||||
- None
|
||||
|
||||
## Dependencies (blocking)
|
||||
- None
|
||||
|
||||
## Dependencies (warning)
|
||||
- `pytest==8.3.3` — GHSA-6w46-j5rx-g56g severity=UNKNOWN fix=9.0.3
|
||||
- `starlette==0.38.6` — PYSEC-2026-161 severity=UNKNOWN fix=1.0.1
|
||||
- `starlette==0.38.6` — GHSA-f96h-pmfr-66vw severity=UNKNOWN fix=0.40.0
|
||||
- `starlette==0.38.6` — GHSA-2c2j-9gv5-cj73 severity=UNKNOWN fix=0.47.2
|
||||
22
docs/work-items/ORCH-113/18-coverage-report.md
Normal file
22
docs/work-items/ORCH-113/18-coverage-report.md
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
coverage_status: PASS
|
||||
work_item: ORCH-113
|
||||
measured_coverage: 80.02
|
||||
baseline: 79.95
|
||||
floor: 0.00
|
||||
policy: both
|
||||
epsilon: 0.50
|
||||
delta: 0.07
|
||||
---
|
||||
# Coverage Report — ORCH-113
|
||||
|
||||
Детерминированный гейт покрытия (ORCH-027) — под-гейт ребра `deploy-staging→deploy` (ПОСЛЕ merge-gate, ДО image-freshness). Машинный вердикт читается ТОЛЬКО из `coverage_status:` frontmatter выше.
|
||||
|
||||
## Verdict
|
||||
measured=80.02% policy=both eps=0.50: absolute 80.02% >= floor 0.00%-eps0.50 -> PASS; baseline 80.02% >= base 79.95%-eps0.50 -> PASS
|
||||
|
||||
## Measurement
|
||||
pytest --cov=src: line coverage src/ = 80.02%
|
||||
|
||||
## Policy
|
||||
policy=both, floor=0.0%, baseline=79.95%, epsilon=0.5%
|
||||
@@ -868,6 +868,55 @@ class AgentLauncher:
|
||||
_task_id = _row[0] if _row else None
|
||||
conn.close()
|
||||
|
||||
# ORCH-113 (adr-0043 / D2): register finalizer ownership at the EARLIEST
|
||||
# moment the reaper can enter Tier-2 — exit_code is now stamped, so the agent
|
||||
# pid is dead and Tier-1 no longer protects this row. On the
|
||||
# deploy-staging -> deploy edge the rest of the finalization (git push, the
|
||||
# heavy edge sub-gates via _try_advance_stage, _finalize_job) runs IN THIS
|
||||
# THREAD for MINUTES; without ownership the reaper would treat the live
|
||||
# finalizer as dead and re-run the advance (false rollback, incident
|
||||
# ORCH-111). The marker is written unconditionally (kill-switch only gates the
|
||||
# reaper's CONSULTATION, so the disabled path is byte-for-byte prior). Only
|
||||
# queue-launched jobs (job_id is not None) are in get_running_jobs / reapable.
|
||||
if job_id is not None:
|
||||
try:
|
||||
from .. import finalizer_liveness
|
||||
_t_mark = get_task_by_repo_branch(repo, branch)
|
||||
finalizer_liveness.mark(
|
||||
job_id, run_id, _t_mark["stage"] if _t_mark else None
|
||||
)
|
||||
except Exception: # noqa: BLE001 - never-raise: marker is best-effort
|
||||
pass
|
||||
|
||||
# The finalization tail runs under try/finally so ownership is ALWAYS
|
||||
# released — including on ANY exception in this thread, which lets the reaper
|
||||
# finish a genuinely dead finalizer (FR-4).
|
||||
try:
|
||||
self._run_monitor_finalization(
|
||||
run_id, agent, repo, branch, exit_code, output_path,
|
||||
job_id, _task_id, _duration_s,
|
||||
)
|
||||
finally:
|
||||
if job_id is not None:
|
||||
try:
|
||||
from .. import finalizer_liveness
|
||||
finalizer_liveness.clear(job_id)
|
||||
except Exception: # noqa: BLE001 - never-raise
|
||||
pass
|
||||
|
||||
def _run_monitor_finalization(
|
||||
self, run_id, agent, repo, branch, exit_code, output_path,
|
||||
job_id, _task_id, _duration_s,
|
||||
):
|
||||
"""ORCH-113 (adr-0043): finalization tail of ``_monitor_agent``.
|
||||
|
||||
Extracted VERBATIM from ``_monitor_agent`` (no logic change — verify with
|
||||
``git diff -w``) so the caller can wrap it in a single ``try/finally`` that
|
||||
releases the finalizer-ownership marker (``finalizer_liveness.clear``) on any
|
||||
outcome. Runs in the monitor thread: notify, usage accounting, git
|
||||
commit/push (+PR), failure handling, usage comments, the gate-driven
|
||||
``_try_advance_stage`` and finally ``_finalize_job`` for queue-launched jobs.
|
||||
"""
|
||||
notify_agent_finished(run_id, agent, exit_code, task_id=_task_id, duration_s=_duration_s)
|
||||
|
||||
# Feature 4: parse token usage / cost from the (json) run log and record
|
||||
|
||||
@@ -544,12 +544,32 @@ class Settings(BaseSettings):
|
||||
# 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_finalizer_liveness_enabled -> ORCH-113 (adr-0043): consult the
|
||||
# process-local finalizer-ownership registry
|
||||
# (src/finalizer_liveness.py) in the Tier-2 branch. On the
|
||||
# deploy-staging -> deploy edge the monitor runs the heavy
|
||||
# edge sub-gates (security/merge-gate re-test/coverage/
|
||||
# image-freshness) in-thread AFTER the finished_at stamp and
|
||||
# BEFORE _finalize_job — MINUTES — which finished_age_s grace
|
||||
# does NOT cover, so a live long finalizer was wrongly reaped
|
||||
# and re-ran the advance (false rollback, incident ORCH-111).
|
||||
# When true AND the task stage == 'deploy-staging' AND a live
|
||||
# monitor owns the finalization, the reaper DEFERS (no second
|
||||
# advance) and falls through to the Tier-3 backstop (which
|
||||
# ignores the marker -> a stuck/dead finalizer is still reaped
|
||||
# in bounded time). false -> reaper byte-for-byte as before
|
||||
# (the marker is still written by the monitor but never
|
||||
# consulted -> inert). Global only, NO per-repo split (the bug
|
||||
# is common to every repo with a deploy-staging stage). Does
|
||||
# NOT touch grace/ceiling -> the cross-cutting budget
|
||||
# reaper_max_running_s > Σ(gate-work) + grace stays intact.
|
||||
reaper_enabled: bool = True
|
||||
reaper_interval_s: int = 60
|
||||
reaper_dead_ticks: int = 2
|
||||
reaper_max_running_s: int = 5400
|
||||
reaper_finalize_grace_s: int = 300
|
||||
lease_reclaim_enabled: bool = True
|
||||
reaper_finalizer_liveness_enabled: bool = True
|
||||
|
||||
# ORCH-063: disk-watchdog — background heartbeat that measures host-FS fill via
|
||||
# the mounted bind-paths and Telegram-alerts the operator at >= threshold. On
|
||||
|
||||
120
src/finalizer_liveness.py
Normal file
120
src/finalizer_liveness.py
Normal file
@@ -0,0 +1,120 @@
|
||||
"""ORCH-113 (adr-0043): process-local finalizer-ownership registry.
|
||||
|
||||
Leaf module — pure, process-local, never-raise (pattern of ``serial_gate`` /
|
||||
``coverage_gate``: imports nothing from ``stage_engine`` / ``launcher`` / the DB,
|
||||
talks to no network). It records "a LIVE monitor thread is currently finalizing
|
||||
job X" so the job-reaper can tell a long-running-but-alive finalizer apart from a
|
||||
genuinely dead one.
|
||||
|
||||
Why in-memory is authoritative (ADR-001 / adr-0043): the monitor
|
||||
(``launcher._monitor_agent``) and the reaper (``job_reaper``) are daemon THREADS
|
||||
of the SAME single uvicorn process (CMD has no ``--workers``), sharing one SQLite
|
||||
DB. So liveness of the finalizing thread can be observed in-process. A whole-process
|
||||
death is covered by the startup ``requeue_running_jobs()`` (``running -> queued``),
|
||||
which ``main.lifespan`` runs BEFORE the reaper starts — so a restart leaves this
|
||||
registry empty and the requeued jobs are re-driven cleanly (restart-safe, no durable
|
||||
state needed).
|
||||
|
||||
The bug this closes (incident ORCH-111, deployer job 1914): on the
|
||||
``deploy-staging -> deploy`` edge the monitor stamps ``agent_runs.finished_at``
|
||||
FIRST, then runs the heavy edge sub-gates (security -> merge-gate re-test ->
|
||||
coverage -> image-freshness) synchronously in its own thread — MINUTES — and only
|
||||
THEN ``_finalize_job``. Reaper Tier-2 measures ``finished_age_s`` from
|
||||
``finished_at`` (= the START of finalization), so once it exceeds
|
||||
``reaper_finalize_grace_s`` (300s) it treated the live, long-finalizing monitor as
|
||||
dead and independently re-ran the same heavy advance -> a second re-test went red ->
|
||||
false rollback ``deploy-staging -> development`` while the original finalizer
|
||||
concurrently merged the PR. State diverged.
|
||||
|
||||
No own TTL: time-bounding is the reaper's Tier-3 backstop (``reaper_max_running_s``),
|
||||
which deliberately IGNORES this marker so a truly stuck finalizer is still reaped in
|
||||
bounded time. Every public function is isolated (``try/except`` -> safe default);
|
||||
``is_active`` defaults to ``False`` on error (conservative: never block the reaping
|
||||
of a possibly-dead finalizer).
|
||||
|
||||
See docs/work-items/ORCH-113/06-adr/ADR-001-reaper-finalizer-liveness-ownership.md
|
||||
and the cross-cutting docs/architecture/adr/adr-0043-reaper-finalizer-liveness-ownership.md.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
|
||||
logger = logging.getLogger("orchestrator.finalizer_liveness")
|
||||
|
||||
# Process-local ownership registry: {job_id: {"run_id", "stage", "started_ts"}}.
|
||||
# Guarded by a Lock because the monitor thread writes (mark/clear) while the reaper
|
||||
# thread reads (is_active/snapshot). All state resets on process restart, which is
|
||||
# safe (the startup requeue_running_jobs covers the restart path).
|
||||
_LOCK = threading.Lock()
|
||||
_OWNED: dict[int, dict] = {}
|
||||
|
||||
|
||||
def mark(job_id: int | None, run_id: int | None, stage: str | None) -> None:
|
||||
"""Register that a live monitor thread is finalizing ``job_id``.
|
||||
|
||||
Called by ``launcher._monitor_agent`` right after the ``exit_code`` stamp (the
|
||||
earliest moment the reaper can enter Tier-2). ``stage`` is best-effort context
|
||||
for the snapshot only — the reaper decides the actual stage from ``tasks`` via
|
||||
its own ``_task_meta`` lookup. No-op when ``job_id is None`` (legacy direct
|
||||
``launch()`` jobs are not in ``get_running_jobs`` and are unreapable). Never
|
||||
raises.
|
||||
"""
|
||||
if job_id is None:
|
||||
return
|
||||
try:
|
||||
with _LOCK:
|
||||
_OWNED[job_id] = {
|
||||
"run_id": run_id,
|
||||
"stage": stage,
|
||||
"started_ts": time.time(),
|
||||
}
|
||||
except Exception as e: # noqa: BLE001 - never-raise contract
|
||||
logger.warning("finalizer_liveness.mark failed for job %s: %s", job_id, e)
|
||||
|
||||
|
||||
def clear(job_id: int | None) -> None:
|
||||
"""Release ownership of ``job_id`` (idempotent).
|
||||
|
||||
Called from the ``finally`` of the monitor's finalization tail, so ANY exception
|
||||
in the monitor thread still releases ownership -> a genuinely dead finalizer is
|
||||
reaped (FR-4). Never raises.
|
||||
"""
|
||||
if job_id is None:
|
||||
return
|
||||
try:
|
||||
with _LOCK:
|
||||
_OWNED.pop(job_id, None)
|
||||
except Exception as e: # noqa: BLE001 - never-raise contract
|
||||
logger.warning("finalizer_liveness.clear failed for job %s: %s", job_id, e)
|
||||
|
||||
|
||||
def is_active(job_id: int | None) -> bool:
|
||||
"""True iff a live monitor currently owns the finalization of ``job_id``.
|
||||
|
||||
Consulted by the reaper Tier-2 branch. Defaults to ``False`` on any error or
|
||||
when ``job_id is None`` (conservative: never block the reaping of a possibly
|
||||
dead finalizer). Never raises.
|
||||
"""
|
||||
if job_id is None:
|
||||
return False
|
||||
try:
|
||||
with _LOCK:
|
||||
return job_id in _OWNED
|
||||
except Exception as e: # noqa: BLE001 - never-raise contract
|
||||
logger.warning("finalizer_liveness.is_active failed for job %s: %s", job_id, e)
|
||||
return False
|
||||
|
||||
|
||||
def snapshot() -> dict:
|
||||
"""Read-only view of current ownership for ``GET /queue`` observability.
|
||||
|
||||
Returns ``{"active": <count>, "jobs": [job_id, ...]}``. Never raises.
|
||||
"""
|
||||
try:
|
||||
with _LOCK:
|
||||
return {"active": len(_OWNED), "jobs": sorted(_OWNED.keys())}
|
||||
except Exception as e: # noqa: BLE001 - never-raise contract
|
||||
logger.warning("finalizer_liveness.snapshot failed: %s", e)
|
||||
return {"active": 0, "jobs": []}
|
||||
@@ -38,7 +38,15 @@ Liveness (defense in depth, ADR-001 Р-1):
|
||||
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.
|
||||
row is left untouched. **ORCH-113 (adr-0043):** on the ``deploy-staging ->
|
||||
deploy`` edge the in-thread finalization runs the heavy edge sub-gates
|
||||
(security/merge-gate re-test/coverage/image-freshness) for MINUTES AFTER the
|
||||
``finished_at`` stamp, so even past the grace the monitor may be alive. Tier-2
|
||||
now consults a process-local ownership marker (``finalizer_liveness``): a job
|
||||
on ``deploy-staging`` still owned by a live finalizer is DEFERRED (not reaped via
|
||||
Tier-2 — re-running the advance caused the false rollback in incident ORCH-111)
|
||||
and falls through to the Tier-3 backstop, which IGNORES the marker. Kill-switch
|
||||
``reaper_finalizer_liveness_enabled``.
|
||||
* **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).
|
||||
@@ -142,6 +150,10 @@ class JobReaper:
|
||||
self.reaped_total: int = 0
|
||||
self.last_reaped: dict | None = None
|
||||
self.lease_reclaimed_total: int = 0
|
||||
# ORCH-113 (adr-0043 / D5): count of Tier-2 reaps deferred because a live
|
||||
# monitor still owns a deploy-staging finalization. Reset on restart (safe:
|
||||
# startup requeue_running_jobs covers the restart path).
|
||||
self.finalizer_defers_total: int = 0
|
||||
|
||||
# -- A: zombie-job reaping --------------------------------------------
|
||||
def reap_once(self) -> None:
|
||||
@@ -199,13 +211,34 @@ class JobReaper:
|
||||
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 exit_code=%s recorded %ss ago (< grace %ss) — "
|
||||
"deferring (monitor may still be finalizing)",
|
||||
job_id, exit_code, finished_age, grace,
|
||||
)
|
||||
# ORCH-113 (adr-0043 / D3): even past the grace, a LIVE monitor may
|
||||
# still be running the minutes-long deploy-staging edge sub-gates
|
||||
# in-thread — finished_age is measured from the START of finalization
|
||||
# (the finished_at stamp), and on deploy-staging the heavy advance
|
||||
# (security/merge-gate re-test/coverage/image-freshness) runs AFTER
|
||||
# that stamp and BEFORE _finalize_job. If a live finalizer still owns
|
||||
# this job, DEFER the Tier-2 reap (re-running the advance caused the
|
||||
# false rollback in incident ORCH-111) and fall through to the Tier-3
|
||||
# backstop, which IGNORES the marker so a stuck/dead finalizer is
|
||||
# still reaped in bounded time.
|
||||
if self._finalizer_owns(job):
|
||||
self.finalizer_defers_total += 1
|
||||
logger.info(
|
||||
"reaper: job %s (deploy-staging) still owned by a live "
|
||||
"finalizer %ss past grace — deferring Tier-2 (Tier-3 backstop "
|
||||
"at %ss still applies)",
|
||||
job_id, finished_age, settings.reaper_max_running_s,
|
||||
)
|
||||
# fall through to the Tier-3 backstop guard below.
|
||||
else:
|
||||
self._reap_known_outcome(job, int(exit_code))
|
||||
return
|
||||
else:
|
||||
logger.info(
|
||||
"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:
|
||||
# Tier-1: dead pid, only after `reaper_dead_ticks` consecutive dead ticks.
|
||||
@@ -400,6 +433,33 @@ class JobReaper:
|
||||
job.get("id"), e)
|
||||
return None, None, None
|
||||
|
||||
def _finalizer_owns(self, job: dict) -> bool:
|
||||
"""ORCH-113 (adr-0043 / D3): True iff a LIVE monitor still owns this job's
|
||||
``deploy-staging`` finalization, so the Tier-2 reap must be deferred.
|
||||
|
||||
Order matters for the zero-regression contract: the kill-switch is checked
|
||||
FIRST (disabled -> ``False`` with no DB lookup, so the path is byte-for-byte
|
||||
prior); then the stage is scoped to ``deploy-staging`` only (the sole edge
|
||||
whose in-thread finalization runs for minutes — every other stage is left
|
||||
untouched); only then is the process-local ownership marker consulted. Never
|
||||
raises -> ``False`` on any error (conservative: never block reaping when
|
||||
ownership is unknowable, so the Tier-3 backstop is never neutered).
|
||||
"""
|
||||
try:
|
||||
if not settings.reaper_finalizer_liveness_enabled:
|
||||
return False
|
||||
_branch, stage, _wid = self._task_meta(job)
|
||||
if stage != "deploy-staging":
|
||||
return False
|
||||
from . import finalizer_liveness
|
||||
return finalizer_liveness.is_active(job.get("id"))
|
||||
except Exception as e: # noqa: BLE001 - never break the reap tick
|
||||
logger.warning(
|
||||
"reaper: finalizer-liveness check failed for job %s: %s",
|
||||
job.get("id"), e,
|
||||
)
|
||||
return False
|
||||
|
||||
def _notify_failed(self, job: dict, reason: str) -> None:
|
||||
try:
|
||||
from .notifications import send_telegram
|
||||
@@ -463,6 +523,14 @@ class JobReaper:
|
||||
|
||||
def status(self) -> dict:
|
||||
"""Reaper snapshot for /queue observability (Р-6)."""
|
||||
# ORCH-113 (adr-0043 / D5): expose the defer counter + the current finalizer
|
||||
# ownership set (read-only, never-raise). Additive keys only — existing keys
|
||||
# are unchanged.
|
||||
try:
|
||||
from . import finalizer_liveness
|
||||
_owned = finalizer_liveness.snapshot()
|
||||
except Exception: # noqa: BLE001 - observability must never break /queue
|
||||
_owned = {"active": 0, "jobs": []}
|
||||
return {
|
||||
"enabled": settings.reaper_enabled,
|
||||
"interval": self.interval_s,
|
||||
@@ -470,6 +538,9 @@ class JobReaper:
|
||||
"reaped_total": self.reaped_total,
|
||||
"last_reaped": self.last_reaped,
|
||||
"lease_reclaimed_total": self.lease_reclaimed_total,
|
||||
"finalizer_liveness_enabled": settings.reaper_finalizer_liveness_enabled,
|
||||
"finalizer_defers_total": self.finalizer_defers_total,
|
||||
"finalizer_owned": _owned,
|
||||
}
|
||||
|
||||
|
||||
|
||||
386
tests/test_orch113_reaper_finalizer_liveness.py
Normal file
386
tests/test_orch113_reaper_finalizer_liveness.py
Normal file
@@ -0,0 +1,386 @@
|
||||
"""ORCH-113 (adr-0043): reaper must not re-run deploy-staging finalization while
|
||||
the original finalizer is alive — finalizer-liveness ownership tests (TC-01..TC-08).
|
||||
|
||||
Covers the bug from incident ORCH-111 (deployer job 1914): on the
|
||||
``deploy-staging -> deploy`` edge the live monitor runs the heavy edge sub-gates
|
||||
(security/merge-gate re-test/coverage/image-freshness) in-thread for MINUTES AFTER
|
||||
stamping ``agent_runs.finished_at`` and BEFORE ``_finalize_job``. Reaper Tier-2
|
||||
measures ``finished_age_s`` from ``finished_at``, so past ``reaper_finalize_grace_s``
|
||||
it treated the live, long-finalizing monitor as dead and independently re-ran the
|
||||
advance -> a second re-test went red -> false rollback ``deploy-staging ->
|
||||
development`` while the original finalizer concurrently merged the PR. State diverged.
|
||||
|
||||
The reaper never spawns claude / pytest / docker; we drive the DB directly (a
|
||||
'running' jobs row + a linked agent_runs exit_code) and the process-local
|
||||
``finalizer_liveness`` marker, then assert the reaper's terminal flip / deferral.
|
||||
No network, no subprocess — every external is mocked.
|
||||
"""
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import pytest
|
||||
|
||||
# Override env before importing app modules (same convention as test_job_reaper.py).
|
||||
os.environ["ORCH_DB_PATH"] = os.path.join(
|
||||
tempfile.gettempdir(), "test_orch113_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, get_job
|
||||
import src.finalizer_liveness as fl
|
||||
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 / "reaper113.db"
|
||||
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
|
||||
init_db()
|
||||
# Each test starts with a clean ownership registry (process-local module state).
|
||||
with fl._LOCK:
|
||||
fl._OWNED.clear()
|
||||
# Default: kill-switch ON (the fix is active) unless a test flips it.
|
||||
monkeypatch.setattr(db.settings, "reaper_finalizer_liveness_enabled", True)
|
||||
yield
|
||||
with fl._LOCK:
|
||||
fl._OWNED.clear()
|
||||
|
||||
|
||||
# --- helpers ----------------------------------------------------------------
|
||||
def _make_task(repo="orchestrator", branch="feature/orch113",
|
||||
stage="deploy-staging", work_item_id="ORCH-113"):
|
||||
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 _make_running_job(agent="deployer", repo="orchestrator", task_id=None,
|
||||
pid=None, age_s=0, attempts=0, max_attempts=2,
|
||||
run_id=None, exit_code=0, finished_age_s=600):
|
||||
"""Insert a job already in 'running' with a linked agent_runs row.
|
||||
|
||||
``finished_at`` is back-dated by ``finished_age_s`` so the Tier-2 grace
|
||||
(default 300) is satisfied by default; pass a small value to stay within grace.
|
||||
"""
|
||||
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, f"-{int(finished_age_s)} seconds", 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 _spy_advance(monkeypatch, side_effect=None):
|
||||
"""Patch launcher._try_advance_stage with a call recorder.
|
||||
|
||||
Returns a mutable ``calls`` list. ``side_effect(run_id, agent, repo, branch)``
|
||||
runs on each call (e.g. to simulate the false rollback to development).
|
||||
"""
|
||||
import src.agents.launcher as L
|
||||
calls: list = []
|
||||
|
||||
def _fake(run_id, agent, repo, branch):
|
||||
calls.append((run_id, agent, repo, branch))
|
||||
if side_effect is not None:
|
||||
side_effect(run_id, agent, repo, branch)
|
||||
|
||||
monkeypatch.setattr(L.launcher, "_try_advance_stage", _fake)
|
||||
return calls
|
||||
|
||||
|
||||
def _green_gate(monkeypatch):
|
||||
"""Force the read-only canonical-QG pre-eval green (staging SUCCESS)."""
|
||||
monkeypatch.setattr(JobReaper, "_gate_is_green",
|
||||
lambda self, stage, job, branch, wid: True)
|
||||
|
||||
|
||||
# --- TC-01: live finalizer on deploy-staging is NOT reaped (AC-1/FR-1) ------
|
||||
def test_tc01_live_finalizer_deploy_staging_not_reaped(monkeypatch):
|
||||
"""exit_code=0 and finished_age_s >= grace, but the finalizer is ALIVE (marked)
|
||||
-> reaper does NOT call _try_advance_stage; the row stays running; defer logged."""
|
||||
_green_gate(monkeypatch)
|
||||
calls = _spy_advance(monkeypatch)
|
||||
tid = _make_task(stage="deploy-staging")
|
||||
jid = _make_running_job(task_id=tid, exit_code=0, finished_age_s=600)
|
||||
# A live monitor owns this finalization.
|
||||
fl.mark(jid, run_id=1, stage="deploy-staging")
|
||||
|
||||
r = JobReaper()
|
||||
r.reap_once()
|
||||
|
||||
assert get_job(jid)["status"] == "running" # not reaped
|
||||
assert calls == [] # no second advance
|
||||
assert r.finalizer_defers_total == 1
|
||||
assert r.reaped_total == 0
|
||||
|
||||
|
||||
# --- TC-02: strict ownership — non-owner runs zero side effects (AC-2/FR-2) --
|
||||
def test_tc02_non_owner_runs_no_edge_gates(monkeypatch):
|
||||
"""While a live finalizer owns the (job, stage), a racing reaper tick performs
|
||||
NO side-effectful advance/merge-gate/re-test (zero side effects)."""
|
||||
_green_gate(monkeypatch)
|
||||
calls = _spy_advance(monkeypatch)
|
||||
tid = _make_task(stage="deploy-staging")
|
||||
jid = _make_running_job(task_id=tid, exit_code=0, finished_age_s=900)
|
||||
fl.mark(jid, run_id=7, stage="deploy-staging")
|
||||
|
||||
r = JobReaper()
|
||||
# Several ticks while ownership is held — still zero advances, still running.
|
||||
for _ in range(3):
|
||||
r.reap_once()
|
||||
|
||||
assert calls == []
|
||||
assert get_job(jid)["status"] == "running"
|
||||
assert r.finalizer_defers_total == 3
|
||||
|
||||
|
||||
# --- TC-03: a genuinely dead finalizer is still reaped (AC-3/FR-4) ----------
|
||||
def test_tc03_dead_finalizer_still_reaped_tier2(monkeypatch):
|
||||
"""No ownership marker (finalizer dead) -> reaper reaps via Tier-2 as before."""
|
||||
_green_gate(monkeypatch)
|
||||
calls = _spy_advance(monkeypatch)
|
||||
tid = _make_task(stage="deploy-staging")
|
||||
jid = _make_running_job(task_id=tid, exit_code=0, finished_age_s=600)
|
||||
# No fl.mark() -> ownership absent (finalizer dead).
|
||||
|
||||
r = JobReaper()
|
||||
r.reap_once()
|
||||
|
||||
assert get_job(jid)["status"] == "done" # reaped to done (gate green)
|
||||
assert len(calls) == 1 # advance driven exactly once
|
||||
assert r.finalizer_defers_total == 0
|
||||
|
||||
|
||||
def test_tc03_tier3_backstop_ignores_marker(monkeypatch):
|
||||
"""Even with an active ownership marker, a job past reaper_max_running_s is
|
||||
reaped by the Tier-3 backstop (a stuck finalizer is never immortal)."""
|
||||
monkeypatch.setattr(db.settings, "reaper_max_running_s", 1000)
|
||||
tid = _make_task(stage="deploy-staging")
|
||||
# age beyond the backstop ceiling; exit_code=0 within grace so Tier-2 defers,
|
||||
# but Tier-3 must still fire.
|
||||
jid = _make_running_job(task_id=tid, exit_code=0, finished_age_s=10,
|
||||
age_s=2000, attempts=0, max_attempts=2)
|
||||
fl.mark(jid, run_id=3, stage="deploy-staging")
|
||||
|
||||
r = JobReaper()
|
||||
r.reap_once()
|
||||
|
||||
# Backstop reaps to a retry (attempts<max) -> queued, regardless of the marker.
|
||||
assert get_job(jid)["status"] == "queued"
|
||||
assert r.reaped_total == 1
|
||||
|
||||
|
||||
# --- TC-04: idempotency under race — exactly-once advance (AC-2/AC-4/FR-2) ---
|
||||
def test_tc04_idempotent_no_second_advance_under_race(monkeypatch):
|
||||
"""Monitor finalizing (owns the job) + concurrent reaper ticks -> the heavy
|
||||
edge-gate advance is NEVER duplicated by the reaper; no false rollback."""
|
||||
rolled_back = {"hit": False}
|
||||
|
||||
def _rollback(run_id, agent, repo, branch):
|
||||
# Simulate the incident: a second advance rolls back to development.
|
||||
conn = get_db()
|
||||
conn.execute("UPDATE tasks SET stage='development' WHERE branch=?", (branch,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
rolled_back["hit"] = True
|
||||
|
||||
_green_gate(monkeypatch)
|
||||
calls = _spy_advance(monkeypatch, side_effect=_rollback)
|
||||
tid = _make_task(stage="deploy-staging")
|
||||
jid = _make_running_job(task_id=tid, exit_code=0, finished_age_s=1200)
|
||||
fl.mark(jid, run_id=9, stage="deploy-staging")
|
||||
|
||||
r = JobReaper()
|
||||
for _ in range(5):
|
||||
r.reap_once()
|
||||
|
||||
assert calls == [] # reaper never re-ran the advance
|
||||
assert rolled_back["hit"] is False
|
||||
# The task is NOT rolled back; the live finalizer remains the sole driver.
|
||||
conn = get_db()
|
||||
stage = conn.execute("SELECT stage FROM tasks WHERE id=?", (tid,)).fetchone()[0]
|
||||
conn.close()
|
||||
assert stage == "deploy-staging"
|
||||
|
||||
|
||||
# --- TC-05: MANDATORY regression for incident ORCH-111 (AC-4/FR-5) ----------
|
||||
def test_tc05_orch111_no_false_rollback_no_retry_increment(monkeypatch):
|
||||
"""Long (> grace) deploy-staging finalization at staging_status=SUCCESS while
|
||||
the deploy finalizer concurrently reaches success/merge -> reaper does NOT roll
|
||||
back deploy-staging -> development and does NOT increment developer-retry; the
|
||||
task keeps a single consistent state. RED before the fix, GREEN after."""
|
||||
def _rollback(run_id, agent, repo, branch):
|
||||
# Simulate the incident: a second advance rolls the task back to development
|
||||
# and spawns a fresh developer run (the developer-retry count is derived from
|
||||
# agent_runs — stage_engine.developer_retry_count).
|
||||
conn = get_db()
|
||||
conn.execute("UPDATE tasks SET stage='development' WHERE branch=?", (branch,))
|
||||
_t = conn.execute("SELECT id FROM tasks WHERE branch=?", (branch,)).fetchone()
|
||||
conn.execute(
|
||||
"INSERT INTO agent_runs (task_id, agent) VALUES (?, 'developer')",
|
||||
(_t[0],),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
from src.stage_engine import developer_retry_count
|
||||
_green_gate(monkeypatch) # staging_status SUCCESS
|
||||
calls = _spy_advance(monkeypatch, side_effect=_rollback)
|
||||
tid = _make_task(stage="deploy-staging")
|
||||
jid = _make_running_job(task_id=tid, exit_code=0, finished_age_s=1500)
|
||||
# The original finalizer is still alive (running the heavy edge sub-gates).
|
||||
fl.mark(jid, run_id=11, stage="deploy-staging")
|
||||
|
||||
r = JobReaper()
|
||||
r.reap_once()
|
||||
|
||||
# No second advance => no false rollback, no developer-retry increment.
|
||||
assert calls == []
|
||||
conn = get_db()
|
||||
stage = conn.execute("SELECT stage FROM tasks WHERE id=?", (tid,)).fetchone()[0]
|
||||
conn.close()
|
||||
assert stage == "deploy-staging" # NOT rolled back to development
|
||||
assert developer_retry_count(tid) == 0 # developer-retry NOT incremented
|
||||
assert get_job(jid)["status"] == "running"
|
||||
assert r.finalizer_defers_total == 1
|
||||
|
||||
|
||||
# --- TC-06: compatibility guard — kill-switch / non-deploy-staging (AC-5) ----
|
||||
def test_tc06_killswitch_off_byte_for_byte_prior(monkeypatch):
|
||||
"""Kill-switch OFF -> the marker is ignored; a deploy-staging exit0/past-grace
|
||||
job is reaped exactly as before the fix (advance driven once)."""
|
||||
monkeypatch.setattr(db.settings, "reaper_finalizer_liveness_enabled", False)
|
||||
_green_gate(monkeypatch)
|
||||
calls = _spy_advance(monkeypatch)
|
||||
tid = _make_task(stage="deploy-staging")
|
||||
jid = _make_running_job(task_id=tid, exit_code=0, finished_age_s=600)
|
||||
fl.mark(jid, run_id=5, stage="deploy-staging") # marked, but ignored
|
||||
|
||||
r = JobReaper()
|
||||
r.reap_once()
|
||||
|
||||
assert get_job(jid)["status"] == "done"
|
||||
assert len(calls) == 1
|
||||
assert r.finalizer_defers_total == 0
|
||||
|
||||
|
||||
def test_tc06_non_deploy_staging_stage_not_consulted(monkeypatch):
|
||||
"""A non-deploy-staging stage is never consulted -> reaped as before even when
|
||||
an (irrelevant) marker happens to be present."""
|
||||
_green_gate(monkeypatch)
|
||||
calls = _spy_advance(monkeypatch)
|
||||
tid = _make_task(stage="testing") # deployer also owns 'testing'
|
||||
jid = _make_running_job(task_id=tid, agent="deployer", exit_code=0,
|
||||
finished_age_s=600)
|
||||
fl.mark(jid, run_id=6, stage="testing")
|
||||
|
||||
r = JobReaper()
|
||||
r.reap_once()
|
||||
|
||||
assert get_job(jid)["status"] == "done"
|
||||
assert len(calls) == 1
|
||||
assert r.finalizer_defers_total == 0
|
||||
|
||||
|
||||
def test_tc06_within_grace_unchanged(monkeypatch):
|
||||
"""Within the finalization grace the Tier-2 path is unchanged (deferred, not
|
||||
reaped) regardless of the marker — the fix only acts PAST the grace."""
|
||||
monkeypatch.setattr(db.settings, "reaper_finalize_grace_s", 300)
|
||||
_green_gate(monkeypatch)
|
||||
calls = _spy_advance(monkeypatch)
|
||||
tid = _make_task(stage="deploy-staging")
|
||||
jid = _make_running_job(task_id=tid, exit_code=0, finished_age_s=5) # < grace
|
||||
|
||||
r = JobReaper()
|
||||
r.reap_once()
|
||||
|
||||
assert get_job(jid)["status"] == "running"
|
||||
assert calls == []
|
||||
# Within-grace deferral is the legacy path, not a finalizer-liveness defer.
|
||||
assert r.finalizer_defers_total == 0
|
||||
|
||||
|
||||
# --- TC-07: cross-cutting budget invariant (NFR-6/AC-5) ---------------------
|
||||
def test_tc07_budget_invariant_preserved():
|
||||
"""reaper_max_running_s (5400) > Σ(deploy-staging gate-work) + grace; the fix
|
||||
changed neither the grace nor the ceiling (ORCH-065/109/110 invariant)."""
|
||||
s = jr.settings
|
||||
# The fix did not touch these (zero schema/budget change).
|
||||
assert s.reaper_finalize_grace_s == 300
|
||||
assert s.reaper_max_running_s == 5400
|
||||
# Conservative Σ of the heavy deploy-staging gate-work + grace must fit.
|
||||
sigma = s.merge_retest_timeout_s + s.coverage_run_timeout_s
|
||||
assert s.reaper_max_running_s > sigma + s.reaper_finalize_grace_s
|
||||
|
||||
|
||||
# --- TC-08: never-raise — a fault in the liveness path degrades safely -------
|
||||
def test_tc08_liveness_error_never_breaks_tick(monkeypatch):
|
||||
"""An exception inside the ownership consultation must not crash the tick; the
|
||||
job is still processed (conservatively reaped, never blocked)."""
|
||||
def _boom(job_id):
|
||||
raise RuntimeError("registry exploded")
|
||||
|
||||
monkeypatch.setattr(fl, "is_active", _boom)
|
||||
_green_gate(monkeypatch)
|
||||
calls = _spy_advance(monkeypatch)
|
||||
tid = _make_task(stage="deploy-staging")
|
||||
jid = _make_running_job(task_id=tid, exit_code=0, finished_age_s=600)
|
||||
fl.mark(jid, run_id=2, stage="deploy-staging")
|
||||
|
||||
r = JobReaper()
|
||||
r.reap_once() # must not raise
|
||||
|
||||
# is_active raised -> _finalizer_owns conservatively returns False -> reaped.
|
||||
assert get_job(jid)["status"] == "done"
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_tc08_reap_once_isolates_and_never_raises(monkeypatch):
|
||||
"""A fault while resolving one job's metadata is isolated; reap_once never
|
||||
raises and other jobs are still processed."""
|
||||
def _boom(self, job):
|
||||
raise RuntimeError("meta exploded")
|
||||
|
||||
monkeypatch.setattr(JobReaper, "_task_meta", _boom)
|
||||
tid = _make_task(stage="deploy-staging")
|
||||
_make_running_job(task_id=tid, exit_code=0, finished_age_s=600)
|
||||
|
||||
r = JobReaper()
|
||||
r.reap_once() # outer + per-job never-raise -> no exception propagates
|
||||
|
||||
|
||||
def test_tc08_finalizer_liveness_leaf_never_raises():
|
||||
"""The leaf degrades safely on bad input / None job_id."""
|
||||
fl.mark(None, None, None) # no-op, no raise
|
||||
fl.clear(None) # no-op, no raise
|
||||
assert fl.is_active(None) is False
|
||||
fl.mark(1234, 1, "deploy-staging")
|
||||
assert fl.is_active(1234) is True
|
||||
snap = fl.snapshot()
|
||||
assert snap["active"] >= 1 and 1234 in snap["jobs"]
|
||||
fl.clear(1234)
|
||||
assert fl.is_active(1234) is False
|
||||
Reference in New Issue
Block a user