From 1e74b9d0420e03a2adf7fd7f7c9beeaea9833846 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 15 Jun 2026 12:24:41 +0300 Subject: [PATCH] architect(ET): auto-commit from architect run_id=693 --- docs/architecture/README.md | 24 ++- ...043-reaper-finalizer-liveness-ownership.md | 95 +++++++++++ docs/architecture/internals.md | 11 +- ...001-reaper-finalizer-liveness-ownership.md | 158 ++++++++++++++++++ .../ORCH-113/07-infra-requirements.md | 40 +++++ .../ORCH-113/08-data-requirements.md | 43 +++++ docs/work-items/ORCH-113/10-tech-risks.md | 37 ++++ 7 files changed, 401 insertions(+), 7 deletions(-) create mode 100644 docs/architecture/adr/adr-0043-reaper-finalizer-liveness-ownership.md create mode 100644 docs/work-items/ORCH-113/06-adr/ADR-001-reaper-finalizer-liveness-ownership.md create mode 100644 docs/work-items/ORCH-113/07-infra-requirements.md create mode 100644 docs/work-items/ORCH-113/08-data-requirements.md create mode 100644 docs/work-items/ORCH-113/10-tech-risks.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 87322ee..af4759c 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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`** (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` означал «человек запускает diff --git a/docs/architecture/adr/adr-0043-reaper-finalizer-liveness-ownership.md b/docs/architecture/adr/adr-0043-reaper-finalizer-liveness-ownership.md new file mode 100644 index 0000000..c410dd6 --- /dev/null +++ b/docs/architecture/adr/adr-0043-reaper-finalizer-liveness-ownership.md @@ -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`. + diff --git a/docs/architecture/internals.md b/docs/architecture/internals.md index 3373331..a98b9d1 100644 --- a/docs/architecture/internals.md +++ b/docs/architecture/internals.md @@ -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`, diff --git a/docs/work-items/ORCH-113/06-adr/ADR-001-reaper-finalizer-liveness-ownership.md b/docs/work-items/ORCH-113/06-adr/ADR-001-reaper-finalizer-liveness-ownership.md new file mode 100644 index 0000000..106da6e --- /dev/null +++ b/docs/work-items/ORCH-113/06-adr/ADR-001-reaper-finalizer-liveness-ownership.md @@ -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`) + diff --git a/docs/work-items/ORCH-113/07-infra-requirements.md b/docs/work-items/ORCH-113/07-infra-requirements.md new file mode 100644 index 0000000..ff06b80 --- /dev/null +++ b/docs/work-items/ORCH-113/07-infra-requirements.md @@ -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`. + diff --git a/docs/work-items/ORCH-113/08-data-requirements.md b/docs/work-items/ORCH-113/08-data-requirements.md new file mode 100644 index 0000000..612ba35 --- /dev/null +++ b/docs/work-items/ORCH-113/08-data-requirements.md @@ -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). + diff --git a/docs/work-items/ORCH-113/10-tech-risks.md b/docs/work-items/ORCH-113/10-tech-risks.md new file mode 100644 index 0000000..1a1b995 --- /dev/null +++ b/docs/work-items/ORCH-113/10-tech-risks.md @@ -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`. +