--- work_item: ORCH-114 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-114 — Ownership-lease для side-effectful переходов стадий + умное восстановление при старте Work Item: **ORCH-114** · Repo: **orchestrator** · Стадия: analysis > **Багфикс-трек → ЭСКАЛАЦИЯ В ПОЛНЫЙ ЦИКЛ (`escalate: full-cycle`).** Задача пришла под меткой > `Bug` (укороченный маршрут ORCH-019, пропуск `architecture`), но дефект **системный и > архитектурный**: вводится глобальный инвариант владения переходом, durable-механизм, переживающий > рестарт процесса, и compare-and-swap на запись стадии. Это требует **ADR** (выбор механизма: > lease / heartbeat / transition-epoch / CAS) и затрагивает поведение всего конвейера и нескольких > фоновых акторов. Поэтому выпускается **полный** analysis-пакет; оператор снимает багфикс-трек > эндпоинтом `POST /bug-fast-track/escalate?work_item=ORCH-114` → задача уходит в `architecture` > (ADR-001 D5 ORCH-019). --- ## 1. Бизнес-контекст и проблема ORCH-114 — **системный наследник** инцидент-цепочки ORCH-110 / ORCH-111 / ORCH-112 / ORCH-113. Каждый предшественник закрыл **точечный** симптом, но **корневой класс** остался открыт: **у side-effectful переходов стадий нет единого владения (ownership)**. ### Корень (установленный факт, верифицировано кодом) `stage_engine.advance_stage(...)` — единая точка перехода между стадиями и исполнения тяжёлых под-гейтов ребра `deploy-staging → deploy` (security → merge-gate re-test → coverage → image-freshness) и под-гейта `deploy → done` (`_handle_merge_verify`: `merge_pr`, ratchet coverage-baseline, запись proof-of-merge). При этом: - **Запись стадии не атомарна по предусловию.** `db.update_task_stage(task_id, stage)` — «голый» `UPDATE tasks SET stage=? WHERE id=?` **без** `WHERE stage=?` (нет compare-and-swap, нет epoch/version-колонки). Любой второй вызов безусловно перезатирает результат первого. - **`advance_stage` ре-ентерабельна без защиты.** Внутри неё нет ни in-memory-лока на `task_id`, ни durable-маркера «переход в процессе». Два конкурентных вызова для одной задачи оба читают `stage='deploy-staging'`, оба прогоняют ВСЕ под-гейты, оба пишут `deploy`, оба ставят следующего агента. - **Минимум 5 путей входят в переход независимо:** (1) монитор агента (`launcher._try_advance_stage`, auto-advance по `exit_code==0`), (2) Plane-webhook (`webhooks/plane._try_advance_stage`, Approved / Confirm Deploy), (3) reconciler F-1 (`advance_if_gate_passed → advance_stage`, `finished_agent=None`), (4) job-reaper (`job_reaper._gate_driven_advance → launcher._try_advance_stage`), (5) deploy-finalizer Phase C (`run_deploy_finalizer → advance_stage(finished_agent="deployer")`). Ни один не проверяет, не находится ли **другой** актор уже внутри того же перехода. ### Почему предшественники не закрыли класс | Задача | Что закрыла | Что осталось открытым | |--------|-------------|------------------------| | **ORCH-110** | merge-gate re-test: ложный rollback по инфра-таймауту + tree-kill осиротевших pytest | Только merge-gate re-test; не вводит владения переходом | | **ORCH-112** | гигиена общего deploy-checkout (грязь блокировала `git pull`) | Только чистка артефактов; не про конкурентные переходы | | **ORCH-113** | reaper не пере-исполняет **живую** финализацию `deploy-staging` (Tier-2) | **process-local in-memory** реестр (`finalizer_liveness`), **только reaper**, **только Tier-2**, **только `deploy-staging`**; **теряется при рестарте**; **НЕ** покрывает reconciler / webhook / restart-recovery | Таким образом, **остаточный кросс-путь** (ORCH-113 §ограничения сам это фиксирует): живой монитор внутри `advance_stage(deploy-staging)` — и параллельно reaper (при выключенном liveness-флаге или **после рестарта**, когда in-memory реестр пуст), либо reconciler F-1, либо webhook-путь — повторно входят в тот же переход. Результат — **двойные** эффекты (security/merge/coverage/image-freshness/ прод-деплой) и **противоречивые** исходы (один путь откатывает на `development`, другой доводит до `done`). Именно это наблюдалось в инциденте ORCH-111 (job 1914 / PR #130): повторный re-test покраснел и дал ложный откат `deploy-staging → development` с ложным developer-retry, **одновременно** с успешной финализацией и мержем оригинального монитора. ### Особый разрез — рестарт процесса (self-hosting) Прод-контейнер `orchestrator` рестартится при self-деплое. Если процесс умирает **в середине** финализации, in-memory `finalizer_liveness._OWNED` исчезает, `requeue_running_jobs` переводит `running → queued`, и переход может быть **пере-исполнен с нуля** без знания, что часть необратимых шагов (мерж в `main`, ratchet baseline, прод-деплой) уже применена. **Durable**-сигнал владения, переживающий рестарт, отсутствует — это ключевая дельта ORCH-114 над ORCH-113. --- ## 2. Объём (scope) ### В объёме - Единый **инвариант владения** side-effectful переходом/финализацией: в любой момент времени переход конкретной задачи исполняет **не более одного** актора. - **Compare-and-swap (CAS)** / transition-epoch на запись стадии: писатель применяет переход только если предусловие (текущая стадия / эпоха) не изменилось с момента чтения; проигравший — аборт **без** побочных эффектов. - **Durable** механизм владения (lease/heartbeat/epoch — выбор за архитектором), переживающий рестарт процесса. - Осведомлённость **job-reaper** и **startup-requeue** о живой / устаревшей финализации (обобщение ORCH-113 за пределы Tier-2 / `deploy-staging` / in-memory). - **Reconciler F-1** и **webhook**-пути: skip/defer при активном lease перехода. - **Умное восстановление при старте**: после смерти процесса в середине финализации система сходится к **единственному** согласованному исходу (без двойных необратимых эффектов и без противоречий rollback↔done). - **Наблюдаемость**: read-only блок в `GET /queue` + алерт на форсированный/устаревший реклейм lease. - **Регресс-тесты**: `deploy-staging`-ребро, deploy-finalizer (Phase C), restart-recovery. ### Вне объёма - Изменение состава/порядка стадий (`STAGE_TRANSITIONS`), реестра `QG_CHECKS`, семантики/имён `check_*`, машинных вердикт-ключей (`verdict:`/`result:`/`deploy_status:`/`staging_status:`/ `security_status:`/`coverage_status:`) — **байт-в-байт не трогаются**. - Повторная починка частных симптомов ORCH-110/112 (merge-retest tree-kill, checkout-hygiene) — они уже закрыты; ORCH-114 их **переиспользует**, не переписывает. - Переход на `uvicorn --workers>1` / мульти-процессную модель (остаётся одно-процессной; durable-lease лишь делает инвариант корректным и на этот случай, но миграция модели — отдельная задача). - Выбор конкретного механизма (lease vs heartbeat vs epoch), точная форма хранения (доп. таблица vs доп. колонки) и порядок старта демонов — **решает архитектор** в `06-adr/` (это требования к свойствам, не к реализации). --- ## 3. Заинтересованные стороны - **Owner / оператор self-hosting** — заказчик; страдает от ложных откатов, двойных деплоев и ручного разбора расхождений состояния. - **Все проекты в общем инстансе** (orchestrator + enduro-trails) — групповой риск: расхождение состояния и ложный freeze репо клинят общую очередь. - **Принимает результат:** Owner; технически — финальная стадия конвейера (CI/гейты), не агент сам. --- ## 4. Бизнес-требования (BR) | ID | Требование (проверяемое) | Покрытие | |----|---------------------------|----------| | **BR-1** | Side-effectful переход/финализацию задачи в любой момент исполняет **не более одного** актора (единое владение). | FR-1 / AC-1 | | **BR-2** | Запись стадии для side-effectful переходов защищена **compare-and-swap / epoch**: проигравший гонку писатель не мутирует стадию и **не выполняет** побочных эффектов. | FR-2 / AC-1, AC-2 | | **BR-3** | **job-reaper** осведомлён о живой vs устаревшей финализации на **всех** релевантных путях (не только Tier-2/`deploy-staging`): defer при живом владении, реклейм мёртвого/устаревшего владельца в **ограниченное** время. | FR-3 / AC-4, AC-5 | | **BR-4** | **Startup-requeue / восстановление при старте** учитывает незавершённую финализацию через durable-состояние: не пере-исполняет уже применённый необратимый шаг. | FR-4 / AC-6 | | **BR-5** | **Reconciler F-1** и **webhook**-пути продвижения **пропускают/откладывают** переход, пока активен lease владения для задачи. | FR-5 / AC-7, AC-8 | | **BR-6** | После смерти процесса в середине финализации система сходится к **единственному** согласованному исходу: **нет** двойного `merge_pr` / ratchet baseline / image-rebuild / инициации прод-деплоя и **нет** противоречия rollback↔done. | FR-1…FR-4 / AC-1, AC-6 | | **BR-7** | Состояние владения переходом **наблюдаемо**: read-only блок в `GET /queue` + алерт при форсированном/устаревшем реклейме. | FR-6 / AC-12 | | **BR-8** | Поставляются **регресс-тесты** на конкурентный двойной эффект (`deploy-staging`), deploy-finalizer (Phase C) и restart-recovery; обязательный регресс воспроизводит исходный класс (красный до фикса, зелёный после). | FR-7 / AC-1, AC-6, тест-план | | **BR-9** | Механизм **обратим**: kill-switch возвращает поведение **байт-в-байт** к состоянию до ORCH-114. | FR-7 / AC-9 | --- ## 5. Нефункциональные требования (NFR) | ID | Требование | |----|------------| | **NFR-1** | **never-raise.** Любая ошибка механизма владения изолируется. Горячий путь claim/guard — **fail-open** (не заклинить общую очередь всех проектов, AC-8 ORCH-088); решения, критичные для безопасности прода/необратимости — **fail-closed**. | | **NFR-2** | **Kill-switch + область раската** по образцу leaf-гейтов (`serial_gate`/`coverage_gate`/`finalizer_liveness`): глобальный флаг + при необходимости CSV-скоуп репо (пусто → self-hosting only). При выключенном флаге — нулевая регрессия (enduro не затронут). | | **NFR-3** | **Инварианты конвейера не тронуты:** `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / машинные вердикт-ключи / схемы существующих таблиц — байт-в-байт. Любое новое хранилище — **аддитивно и идемпотентно** (`CREATE TABLE IF NOT EXISTS` / `_ensure_column`). | | **NFR-4** | **Durable / restart-safe.** Сигнал владения переживает рестарт процесса (ключевая дельта над in-memory `finalizer_liveness` ORCH-113); после рестарта восстановление детерминированно решает «дорешать vs уже применено». | | **NFR-5** | **Self-hosting безопасность.** Механизм владения сам по себе **никогда** не рестартит прод-контейнер, не пушит/force-push в `main`, не трогает detached deploy-процесс (NFR-3 ORCH-090/112). | | **NFR-6** | **Сквозной бюджет reaper сохранён:** инвариант ORCH-065/109/110/113 `reaper_max_running_s (5400) > Σ(deploy-staging gate-work ≈4460) + grace`. Lease **не** удлиняет финализацию за backstop без согласованной правки бюджета; устаревший/мёртвый владелец добивается Tier-3 в ограниченное время. | | **NFR-7** | **Идемпотентность.** Повторный заход в уже применённый переход — **no-op** (по epoch / SHA-in-main / lease), никогда не второй побочный эффект. | | **NFR-8** | **Обратная совместимость.** При флаге off / репо вне области — путь старта, claim и переходы байт-в-байт прежние (enduro и текущий orchestrator). | --- ## 6. Допущения и ограничения - **Одно-процессная модель сейчас** (один uvicorn-воркер без `--workers`), но требование NFR-4 (durable) делает инвариант корректным и при будущем рестарте/мульти-процессности — без переписывания. - **Источник истины планировщика — локальная БД** (offline hot-path, NFR-2/ORCH-026/088): механизм владения не должен вносить сетевых зависимостей в горячий claim. - **Переиспользуются существующие durable-примитивы:** атомарный `reap_running_job` (rowcount-guard), `claim_next_job` (rowcount-guard), `requeue_running_jobs`, merge-lease (ORCH-043). ORCH-114 **достраивает** владение поверх них, а не дублирует. - **`finalizer_liveness` (ORCH-113)** — отправная точка: ORCH-114 обобщает её до durable, кросс-путевого владения; решение «расширить / заменить / надстроить» принимает архитектор. - Точные **D-решения** (durable shape, эпоха vs lease-таблица, набор покрываемых рёбер сверх `deploy-staging`/`deploy→done`, порядок старта демонов) — за архитектором (`06-adr/`, `10-tech-risks.md`). ## 7. Критерии успеха Кратко (детальные PASS/FAIL — `03-acceptance-criteria.md`): - Конкурентный/после-рестартовый повторный вход в side-effectful переход **не** даёт двойных эффектов и противоречивых исходов; ровно один актор владеет и доводит переход. - CAS/epoch на запись стадии: проигравший — чистый аборт. - reaper / startup / reconciler / webhook осведомлены о живом lease (defer) и о мёртвом (реклейм в ограниченное время). - Полный `pytest tests/` зелёный; новые регресс-тесты (двойной эффект, restart-recovery) зелёные; при выключенном kill-switch — поведение байт-в-байт прежнее. ## 8. Риски Краткий перечень (детали — `10-tech-risks.md`, заполняет архитектор): - **Дедлок / over-block:** слишком «жёсткое» владение может заклинить легитимный путь (reaper не добьёт зависший финализатор) → требование NFR-6 (bounded reclaim) и fail-open на hot-path. - **Бюджет vs lease:** lease, удерживаемый дольше `reaper_max_running_s`, конфликтует со сквозным бюджетом → согласование с ORCH-065/109/110/113. - **Durable-состояние и гонки на рестарте:** некорректный «умный recovery» может сам стать источником двойного применения → обязательный restart-recovery регресс (BR-8). - **Скрытые пути перехода** (gitea-webhook `handle_push`/`handle_ci_status`/`handle_pr` пишут стадию **в обход** `advance_stage` через прямой `update_task_stage`) → охват CAS должен учитывать и их (архитектор фиксирует границу).