187 lines
20 KiB
Markdown
187 lines
20 KiB
Markdown
---
|
||
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 должен учитывать и их
|
||
(архитектор фиксирует границу).
|