From 0939893c70a55add5e63d695cb956578cc394e6f Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 15 Jun 2026 17:02:18 +0300 Subject: [PATCH] architect(ET): auto-commit from architect run_id=709 --- docs/architecture/README.md | 43 ++- ...ransition-ownership-lease-and-stage-cas.md | 94 ++++++ docs/architecture/internals.md | 11 +- ...ransition-ownership-lease-and-stage-cas.md | 300 ++++++++++++++++++ .../ORCH-114/08-data-requirements.md | 66 ++++ docs/work-items/ORCH-114/10-tech-risks.md | 47 +++ 6 files changed, 559 insertions(+), 2 deletions(-) create mode 100644 docs/architecture/adr/adr-0045-transition-ownership-lease-and-stage-cas.md create mode 100644 docs/work-items/ORCH-114/06-adr/ADR-001-transition-ownership-lease-and-stage-cas.md create mode 100644 docs/work-items/ORCH-114/08-data-requirements.md create mode 100644 docs/work-items/ORCH-114/10-tech-risks.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 17b87c0..2bd73a4 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -1255,6 +1255,45 @@ 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`. +### Единое владение side-effectful переходами: durable transition-lease + stage-CAS (ORCH-114 — design) +Корневой класс инцидент-цепочки ORCH-110/111/112/113: **у side-effectful переходов стадий +нет единого владения**. `db.update_task_stage` — голый `UPDATE … WHERE id=?` без CAS; +`advance_stage` ре-ентерабельна и исполняет минуты-длинные необратимые под-гейты +(`deploy-staging→deploy`: security→merge-retest→coverage→image-freshness; `deploy→done`: +`merge_pr`/ratchet/proof-of-merge) **до** единственной записи стадии. ≥5 акторов входят в переход +независимо (монитор/webhook/reconciler F-1/reaper/Phase-C finalizer) + 6 путей пишут стадию в +обход `advance_stage` (5× `gitea.py`, 1× `plane.py`). ORCH-113 (`finalizer_liveness`) закрыл это лишь +in-memory, reaper-Tier-2, `deploy-staging`, теряя владение на рестарте. ORCH-114 **обобщает** ORCH-113 +до durable, кросс-путевого владения. Аддитивно, под kill-switch, never-raise; `STAGE_TRANSITIONS`/ +`QG_CHECKS`/`check_*`/вердикт-ключи/схемы существующих таблиц — байт-в-байт. +- **Два комплементарных слоя.** (1) **Durable transition-lease** (владение на **входе** в + side-effectful регион) — аддитивная таблица `transition_lease` (`task_id PK, owner, owner_pid, + owner_boot_id, run_id, stage, acquired_at`; `CREATE TABLE IF NOT EXISTS`, паттерн `repo_freeze`/ + `coverage_baseline`); второй актор, увидев **живого** владельца, не стартует под-гейты. (2) + **Expected-stage CAS** `update_task_stage_cas(task_id, expected_stage, new_stage)` + (`UPDATE … WHERE id=? AND stage=?`, rowcount==1 ⇒ выиграл; 0 ⇒ проиграл → аборт без побочных эффектов) + — покрывает остаточное окно гонки И 6 обходных путей. Без epoch-колонки: стадия *и есть* версия CAS. +- **Liveness владельца = `owner_pid` + `owner_boot_id`, НЕ heartbeat** (heartbeat отвергнут доводом + adr-0043: блокирующий 900s re-test не может его бить). Рестарт ⇒ новый boot-id ⇒ прежние lease мертвы + ⇒ реклеймятся; зависший живой добивается Tier-3 `reaper_max_running_s` (lease backstop не обходит). +- **Осведомлённость акторов:** reaper консультирует durable-lease на **всех** путях (обобщение ORCH-113: + живой → defer, мёртвый → реклейм); reconciler F-1 и webhook (Approved/Confirm Deploy) — новый skip-guard + по образцу escalated/Blocked/task-deps. `finalizer_liveness` сохранён без правок как поведение при + **выключенном** ORCH-114 (надстройка durable-слоя поверх). +- **Умное восстановление (рестарт)** — НЕ новый recovery-мозг, а композиция: `requeue_running_jobs` + + startup stale-clear (boot-id mismatch) + идемпотентность re-drive через **существующие авторитетные + факты** (SHA-in-main ORCH-071/073, `INITIATED` ORCH-036, coverage-ratchet CAS ORCH-027). +- **Бюджет (NFR-6):** lease без собственного TTL, потолок = Tier-3 `reaper_max_running_s`; сквозной + инвариант `5400 > Σ(≈4460)+grace` и `reaper_finalize_grace_s`/`reaper_max_running_s` — не тронуты. +- **Флаги:** `transition_lease_enabled` (kill-switch; `False` → байт-в-байт до-ORCH-114, CAS вырождается + в прежний `update_task_stage`, reaper → ORCH-113 fallback) + `transition_lease_repos` (CSV; **пусто → + self-hosting only**, enduro не затронут). Hot-path `claim_next_job` не тронут (fail-open, AC-8 ORCH-088). + Leaf `src/transition_lease.py` never-raise. Наблюдаемость — read-only блок `transition_lease` в + `GET /queue` + Telegram-алерт на форсированный/устаревший реклейм + опц. `POST /transition-lease/release`. + +Подробнее: [adr-0045](adr/adr-0045-transition-ownership-lease-and-stage-cas.md); детально — +`docs/work-items/ORCH-114/06-adr/ADR-001-transition-ownership-lease-and-stage-cas.md`. + ### Осмысленная статусная модель Plane (ORCH-066 — реализовано) Plane-доска была семантически перегружена: `In Progress` означал «человек запускает конвейер», «идёт анализ», «идёт прод-деплой» и «возврат из Needs Input» одновременно. @@ -1332,6 +1371,7 @@ Monitoring after Deploy → Done - `job_deps` — декларативные зависимости задач (ORCH-026, Уровень B): `(task_id, depends_on_task_id)`, аддитивная; источник истины планировщика для гейта «B ждёт A» - `repo_freeze` — durable per-repo rollback-freeze (ORCH-088, FR-5): `(id, repo, frozen_at, reason, work_item_id, cleared_at)`, аддитивная append-only; активный freeze ⇔ строка репо с `cleared_at IS NULL`. Выставляется post-deploy `DEGRADED` (`set_repo_freeze`), снимается вручную (`POST /serial-gate/unfreeze` → `cleared_at=now`). Гейтит serial-claim безусловно (деградировавшая задача уже `done`) - `lessons` — машинный журнал отклонений конвейера (ORCH-098, FR-1): `(id, created_at, updated_at, lesson_type, work_item_id, task_id, stage, agent, repo, root_cause, suggestion, status, related_task, attribution, target_repo, target_domain, source, detail)`, аддитивная идемпотентная (`CREATE TABLE IF NOT EXISTS` + три индекса); колонки атрибуции (`attribution`/`target_repo`/`target_domain`) — нуллабельны и присутствуют сразу (NFR-6), без `enum`-констрейнтов (слаги forward-compatible). Автозапись 4 типов (`gate_failure`/`merge_hold`/`transient_retry`/`deploy_degraded`, `source="auto"`, дедуп в окне `lessons_dedup_window_s`) + ручная (`source="manual"`); observer-only (не участвует в решении гейта). Leaf `src/lessons.py` never-raise, kill-switch `lessons_enabled` (без `*_repos` — журнал не скоупится по репо, репо-разрез на выборке) +- `transition_lease` — durable-владение side-effectful переходом стадии (ORCH-114, FR-1): `(task_id PK, owner, owner_pid, owner_boot_id, run_id, stage, acquired_at)`, аддитивная идемпотентная (`CREATE TABLE IF NOT EXISTS`, паттерн `repo_freeze`/`coverage_baseline`). Активная строка ⇔ актор держит владение переходом задачи; **живой** владелец ⇔ `owner_boot_id == ` И `pid_alive(owner_pid)` (рестарт ⇒ новый boot-id ⇒ прежние lease мертвы → реклейм). Захват — атомарный rowcount-guard (паттерн `claim_next_job`/`reap_running_job`); release в `try/finally`; потолок возраста = Tier-3 `reaper_max_running_s` (без собственного TTL — NFR-6). Парная CAS-запись стадии — `update_task_stage_cas(task_id, expected_stage, new_stage)` (`UPDATE … WHERE id=? AND stage=?`). Leaf `src/transition_lease.py` never-raise, kill-switch `transition_lease_enabled` + `transition_lease_repos` (пусто → self-hosting only). Обобщает in-memory `finalizer_liveness` (ORCH-113) до durable cross-path; схемы существующих таблиц не тронуты ## Изоляция (git worktree, ORCH-2) Каждая задача исполняется в отдельном git worktree, ветки не пересекаются. Репозитории проектов разделены под `/repos/`. @@ -1341,9 +1381,10 @@ Monitoring after Deploy → Done |--------|------|----------| | GET | `/health` | health check | | GET | `/status` | активные задачи (stage != done) | -| GET | `/queue` | очередь: counts + max_concurrency + resilience + reconcile (ORCH-053) + reaper (ORCH-065) + post_deploy (ORCH-021) + task_deps (ORCH-026) + serial_gate (ORCH-088) + auto_labels (ORCH-089) + stop (ORCH-090) + lessons (ORCH-098) + последние jobs | +| GET | `/queue` | очередь: counts + max_concurrency + resilience + reconcile (ORCH-053) + reaper (ORCH-065) + post_deploy (ORCH-021) + task_deps (ORCH-026) + serial_gate (ORCH-088) + auto_labels (ORCH-089) + stop (ORCH-090) + lessons (ORCH-098) + transition_lease (ORCH-114) + последние jobs | | GET | `/metrics` | ORCH-099 (FND/F1a): read-only машинное «сырьё» для sidecar F1b — конверт `schema_version`/`generated_at`/`clk_tck` + разделы `stages`/`queue`/`agents` (liveness: pid/runtime/cpu_ticks)/`cost`. never-raise по разделам; kill-switch `ORCH_METRICS_ENABLED` (дефолт `True`). Контракт — см. раздел «Сырьё-эндпоинт `/metrics`» | | POST | `/serial-gate/unfreeze` | ORCH-088 (FR-5): ручное снятие per-repo rollback-freeze (query/body `repo=`) → `{ok, repo, cleared, frozen}`; идемпотентно. Альтернатива — `UPDATE repo_freeze SET cleared_at=datetime('now') WHERE repo=? AND cleared_at IS NULL` | +| POST | `/transition-lease/release` | ORCH-114 (FR-6, **опц.**): операторский ручной реклейм застрявшего владения переходом (query/body `work_item=`) → `{ok, task_id, released}`; идемпотентно (паттерн `/serial-gate/unfreeze`). При выключенном `transition_lease_enabled` → no-op | | GET | `/lessons` | ORCH-098 (FR-4): read-only выборка журнала уроков; query-фильтры `type`/`status`/`repo`/`work_item`/`limit` → `{enabled, lessons:[…]}` (всегда `200`, чтение не мутирует). При `lessons_enabled=False` → `{enabled:false, lessons:[]}` | | POST | `/lessons` | ORCH-098 (FR-5): ручная запись урока (JSON-тело, `lesson_type` обязателен, `source="manual"` не дедупится) → `{id}`; при выключенном флаге → `{enabled:false}` | | POST | `/lessons/{id}` | ORCH-098 (FR-5): доклассификация/обновление урока (`status`/`attribution`/`target_*`/`related_task`/`root_cause`/`suggestion`), стампит `updated_at` → `{ok}` | diff --git a/docs/architecture/adr/adr-0045-transition-ownership-lease-and-stage-cas.md b/docs/architecture/adr/adr-0045-transition-ownership-lease-and-stage-cas.md new file mode 100644 index 0000000..e939d4b --- /dev/null +++ b/docs/architecture/adr/adr-0045-transition-ownership-lease-and-stage-cas.md @@ -0,0 +1,94 @@ +--- +work_item: ORCH-114 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-15 +model_used: claude-opus-4-8 +--- + +# adr-0045: Durable transition-ownership lease + expected-stage CAS — единое владение side-effectful переходами стадий + +- **Статус:** proposed +- **Дата:** 2026-06-15 +- **Задача:** ORCH-114 (bug → escalate full-cycle; системный наследник кластера ORCH-110/111/112/113) +- **Детальный ADR:** `docs/work-items/ORCH-114/06-adr/ADR-001-transition-ownership-lease-and-stage-cas.md` +- **Обобщает:** `adr-0043` (ORCH-113 in-memory finalizer-liveness — отправная точка) +- **Уточняет/опирается:** `adr-0011` (reaper/lease-reclaim ORCH-065), `adr-0040` (бюджеты ORCH-109), + `adr-0042` (merge-retest ORCH-110), `adr-0027` (merge-lease ORCH-043), `adr-0029` (coverage-ratchet ORCH-027), + ORCH-071/073/093 (SHA-in-main / already-in-main), ORCH-036 (`INITIATED` self-deploy) + +## Контекст + +Корневой класс инцидент-цепочки ORCH-110/111/112/113: **у side-effectful переходов стадий нет единого +владения**. `db.update_task_stage` — голый `UPDATE … WHERE id=?` без CAS (`db.py:671–679`); `advance_stage` +ре-ентерабельна без защиты и исполняет минуты-длинные необратимые под-гейты (`deploy-staging → deploy`: +security→merge-retest→coverage→image-freshness; `deploy → done`: `merge_pr`/ratchet/proof-of-merge) **до** +единственной записи стадии. ≥5 акторов входят в переход независимо (монитор/webhook/reconciler F-1/reaper/ +Phase-C finalizer) + 6 путей пишут стадию в обход `advance_stage` (5× `gitea.py`, 1× `plane.py:806`). +ORCH-113 (`finalizer_liveness`) закрыл это лишь in-memory, reaper-Tier-2, `deploy-staging`, теряя владение +на рестарте — остаточный кросс-путь дал двойной эффект и противоречие rollback↔done (ORCH-111, job 1914/PR #130). + +## Решение + +Два комплементарных аддитивных слоя под единым kill-switch, never-raise: + +1. **Durable transition-lease** — новая аддитивная таблица `transition_lease` + (`task_id PK, owner, owner_pid, owner_boot_id, run_id, stage, acquired_at`; `CREATE TABLE IF NOT EXISTS`, + паттерн `repo_freeze`/`coverage_baseline`). Владение захватывается на **входе** в side-effectful регион + `advance_stage` (рёбра `deploy-staging→deploy`, `deploy→done`, Phase C `run_deploy_finalizer`); второй + актор, увидев **живого** владельца, не стартует под-гейты вовсе (предотвращение класса, а не починка). + Release — в `try/finally`. **Liveness = `owner_pid` + `owner_boot_id`**, НЕ heartbeat (heartbeat отвергнут + тем же доводом, что в adr-0043: блокирующий 900s re-test не может его бить). Реклейм мёртвого/устаревшего + (pid мёртв ИЛИ boot-id чужой) — немедленно; зависший живой добивается Tier-3. +2. **Expected-stage CAS** — `update_task_stage_cas(task_id, expected_stage, new_stage)` + (`UPDATE tasks SET stage=? … WHERE id=? AND stage=?`, rowcount==1 ⇒ выиграл; 0 ⇒ проиграл → аборт без + побочных эффектов). Покрывает остаточное окно гонки И 6 обходных путей. Без epoch-колонки: для текущей + модели стадия *и есть* версия (epoch — задокументированное форвард-расширение под `--workers>1`). + +**Осведомлённость акторов:** reaper консультирует durable-lease на **всех** путях (обобщение ORCH-113): +живой → defer, мёртвый → реклейм, Tier-3 маркер игнорирует; reconciler F-1 и webhook (Approved/Confirm +Deploy) — новый skip-guard по образцу escalated/Blocked/task-deps. `finalizer_liveness` сохранён без правок +как поведение при **выключенном** ORCH-114 (надстройка durable-слоя поверх). + +**Умное восстановление (FR-4)** — НЕ новый recovery-мозг, а композиция: `requeue_running_jobs` (есть) + +startup stale-clear (boot-id mismatch ⇒ старые lease мертвы) + идемпотентность re-drive через +**авторитетные durable-факты предшественников** (SHA-in-main ORCH-071/073, `INITIATED` ORCH-036, +coverage-ratchet CAS ORCH-027). Lease лишь гарантирует **последовательную**, не конкурентную, их проверку. + +**Бюджет (NFR-6):** lease без собственного TTL; жёсткий потолок возраста = Tier-3 `reaper_max_running_s` +(5400), reaper при реапе force-освобождает lease. Сквозной инвариант `5400 > Σ(≈4460)+grace` и +`reaper_finalize_grace_s`/`reaper_max_running_s` — **не тронуты**. + +**Конфиг:** `transition_lease_enabled=True` (kill-switch) + `transition_lease_repos=""` (CSV; пусто → +self-hosting only, паттерн coverage/serial-gate). Leaf `src/transition_lease.py` never-raise. + +**Инварианты:** `STAGE_TRANSITIONS` / `QG_CHECKS` / каждый `check_*` / machine-verdict-ключи / схемы +**существующих** таблиц — байт-в-байт; +1 аддитивная таблица; механизм не рестартит прод, не пушит/ +force-push `main`, не трогает detached-деплой (NFR-5). Hot-path `claim_next_job` не тронут (fail-open). + +## Альтернативы + +- Только CAS (без lease) — не предотвращает двойной side-effect в полёте. +- Только lease (без CAS) — не покрывает 6 обходных путей + окно consult→acquire. +- Heartbeat-liveness — блокирующий re-test не бьёт heartbeat (довод adr-0043). +- Lease-файл per-task — CAS на стадию всё равно DB-операция; БД когерентнее, merge-lease-файл per-repo для + иной задачи (сериализация мержей), не дублируется. +- epoch-колонка / sub-state `finalizing` в `jobs.status` / per-stage grace на Σ — отвергнуто (как в adr-0043: + меняет семантику/нарушает бюджет/неиспользуемо). + +## Последствия + +- (+) Класс двойного эффекта закрыт в корне; конкурентный/после-рестартовый/reconciler/webhook пути покрыты. +- (+) Рестарт-safe без нового таймера; boot-id готовит multi-process; бюджет и инварианты конвейера целы; +1 таблица. +- (+) Дыра обходных путей gitea/plane закрыта CAS; откат — один env-флаг. +- (−) Полная multi-writer эксклюзия валидна при одном процессе/одной БД (как adr-0043); durable делает её + корректной для рестарта, но `--workers>1`-верификация — вне объёма (риск в `10-tech-risks.md`). + +## Связи + +- Обобщает `adr-0043`; опирается на `adr-0011`/`adr-0040`/`adr-0042`/`adr-0027`/`adr-0029` и ORCH-071/073/093/036. +- Маркеры (ORCH-078/TRACEABILITY): блоки reaper/finalizer-liveness/stage-engine несут ORCH-065/109/110/113 + + новый `ORCH-114`; правки сверяются с их ADR (анти-археология — этот сводный сквозной ADR). +- Детально: `docs/work-items/ORCH-114/06-adr/ADR-001-transition-ownership-lease-and-stage-cas.md`. + diff --git a/docs/architecture/internals.md b/docs/architecture/internals.md index a98b9d1..2f68c44 100644 --- a/docs/architecture/internals.md +++ b/docs/architecture/internals.md @@ -394,7 +394,16 @@ daemon-поток `src/job_reaper.py` (каркас `reconciler`) периоди 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); схема БД и сквозной бюджет не тронуты; + `requeue_running_jobs` (до старта reaper); схема БД и сквозной бюджет не тронуты. + **ORCH-114 (adr-0045):** обобщает это in-memory-владение до **durable, кросс-путевого** + `transition_lease` (таблица `task_id PK, owner, owner_pid, owner_boot_id, …`): reaper + консультирует durable-lease на **всех** релевантных путях (не только Tier-2/`deploy-staging`), + живость владельца = `pid_alive(owner_pid)` + совпадение boot-id (рестарт ⇒ прежние lease мертвы); + парная CAS-запись стадии (`update_task_stage_cas`, `WHERE id=? AND stage=?`) — аборт проигравшего + без побочных эффектов; reconciler F-1 и webhook тоже defer при живом владельце. Kill-switch + `ORCH_TRANSITION_LEASE_ENABLED` (off → ровно поведение ORCH-113 выше); `finalizer_liveness.py` + не правится (надстройка durable-слоя поверх). Потолок возраста lease = `reaper_max_running_s` + (Tier-3 force-освобождает), сквозной бюджет цел; - **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-114/06-adr/ADR-001-transition-ownership-lease-and-stage-cas.md b/docs/work-items/ORCH-114/06-adr/ADR-001-transition-ownership-lease-and-stage-cas.md new file mode 100644 index 0000000..6cd0c10 --- /dev/null +++ b/docs/work-items/ORCH-114/06-adr/ADR-001-transition-ownership-lease-and-stage-cas.md @@ -0,0 +1,300 @@ +--- +work_item: ORCH-114 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-15 +model_used: claude-opus-4-8 +--- + +# ADR-001: Durable transition-ownership lease + expected-stage CAS для side-effectful переходов стадий + +Work Item: **ORCH-114** — Ownership-lease для side-effectful переходов стадий + умное восстановление при старте +Стадия: **architecture** +Сквозная регистрация: **`docs/architecture/adr/adr-0045-transition-ownership-lease-and-stage-cas.md`** (решение кросс-каттинговое: новый durable-механизм владения трогает движок переходов и ≥5 фоновых акторов). + +## Статус +Proposed + +## Контекст + +ORCH-114 — **системный наследник** инцидент-цепочки ORCH-110/111/112/113. Каждый предшественник +закрыл точечный симптом, но **корневой класс остался открыт: у side-effectful переходов стадий нет +единого владения**. Факты сверены по коду: + +- **Запись стадии не атомарна по предусловию.** `db.update_task_stage` (`src/db.py:671–679`) — + голый `UPDATE tasks SET stage=?, updated_at=… WHERE id=?` **без** `WHERE stage=?` (нет CAS, нет + epoch/version-колонки). Второй вызов безусловно перезатирает первый. +- **`advance_stage` ре-ентерабельна без защиты** (`src/stage_engine.py:176–507`). Внутри нет ни + in-memory-лока на `task_id`, ни durable-маркера «переход идёт». `current_stage` читается на входе + (параметр), тяжёлые под-гейты ребра `deploy-staging → deploy` (security → merge-gate re-test → + coverage → image-freshness, **минуты**) и `_handle_merge_verify` (`deploy → done`: `merge_pr`, + ratchet baseline, proof-of-merge) исполняются **до** единственной записи стадии на `:402`. Два + конкурентных вызова оба читают `deploy-staging`, оба гоняют ВСЕ под-гейты, оба пишут `deploy`. +- **Минимум 5 путей входят в переход независимо:** монитор (`launcher._try_advance_stage`), + Plane-webhook (`plane._try_advance_stage:865`), reconciler F-1 (`advance_if_gate_passed → + advance_stage`, `stage_engine.py:573`), job-reaper (`_gate_driven_advance → launcher._try_advance_stage`, + `job_reaper.py:406`), deploy-finalizer Phase C (`run_deploy_finalizer → advance_stage`, + `stage_engine.py:1980`). Ни один не проверяет, не в этом ли переходе уже другой актор. +- **6 путей пишут стадию в ОБХОД `advance_stage`** прямым `update_task_stage` (риск BRD §8): 5 в + `webhooks/gitea.py` (`:127` arch→dev по ADR-push, `:242` dev→review по CI-green, `:333` review→testing + по PR-approved, `:359` review→dev по REQUEST_CHANGES, `:398` *→done по PR-merge) и 1 в + `webhooks/plane.py:806` (rollback Rejected). +- **ORCH-113 (`finalizer_liveness`, adr-0043)** — отправная точка, но: **process-local in-memory**, + **только reaper Tier-2**, **только `deploy-staging`**, **теряется при рестарте**. Остаточный кросс-путь + (живой монитор внутри `advance_stage(deploy-staging)` + reaper после рестарта / reconciler F-1 / + webhook) повторно входит в тот же переход → двойные эффекты и противоречие rollback↔done (инцидент + ORCH-111, job 1914 / PR #130). + +**Решающий факт, проверенный по коду:** механизм владения по pid уже существует и испытан — merge-lease +(`merge_gate.py`) штампит `os.getpid()` (`:360`) в lease-файл и реклеймит держателя по `pid_alive` +(`:452,526`); reaper Tier-1 тоже судит по `pid_alive` (`job_reaper.py:245`). ORCH-114 строит durable +владение **тем же испытанным pid-приёмом**, а не вводит новый таймер (таймер был источником бага +ORCH-111). + +## Решение + +### Сводка + +Вводятся **два комплементарных слоя**, оба аддитивные, под единым kill-switch, never-raise: + +1. **Durable transition-lease** (владение на **входе** в side-effectful регион) — новая аддитивная + таблица `transition_lease` (`src/db.py`, паттерн `repo_freeze`/`coverage_baseline`). Актор + **захватывает** владение задачей перед тяжёлой финализацией; второй актор, увидев живого владельца, + **не стартует** под-гейты вовсе. Это и убивает класс двойного эффекта (предотвращение, а не починка + постфактум). Release — в `try/finally`. **Liveness владельца = `owner_pid` + `owner_boot_id`** (НЕ + heartbeat), что делает рестарт-recovery бесплатным (boot-id новый ⇒ старые lease мертвы) и не + страдает от блокирующего re-test. +2. **Expected-stage CAS** (корректность на **коммите** записи стадии) — CAS-вариант + `update_task_stage_cas(task_id, expected_stage, new_stage)` → + `UPDATE tasks SET stage=?, updated_at=… WHERE id=? AND stage=?`, rowcount==1 ⇒ выиграл, 0 ⇒ + проиграл гонку → **аборт без побочных эффектов**. Покрывает узкое остаточное окно гонки И 6 путей + в обход `advance_stage`. + +Слой 1 гарантирует «двое не начнут»; слой 2 гарантирует «даже если начали — запишет один». Defense in +depth. `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/вердикт-ключи/схемы существующих таблиц — байт-в-байт. + +### D1 — Механизм: durable-lease (вход) + CAS (коммит), оба обязательны (FR-1, FR-2 / BR-1, BR-2, BR-6) + +Почему **оба**, а не один: +- **CAS-only недостаточно.** CAS стоит на записи стадии — в *конце* `advance_stage` (`:402`). К этому + моменту проигравший актор **уже исполнил** `merge_pr` / docker-rebuild / re-test. CAS на коммите не + предотвращает двойной побочный эффект *в полёте*. → нужен lease на **входе** в регион. +- **Lease-only недостаточно** для 6 путей в обход `advance_stage` (gitea/plane прямой + `update_task_stage`) и для остаточного окна между «consult lease» и «acquire». → нужен CAS как + backstop записи. + +Lease — это owner-эксклюзия; CAS — это атомарность-записи. Они ортогональны и складываются. + +### D2 — Форма хранения: новая таблица `transition_lease`, без новых колонок на `tasks`/`jobs` (NFR-3, NFR-4) + +Durable-владение хранится в **новой аддитивной таблице** (`CREATE TABLE IF NOT EXISTS`, паттерн +`repo_freeze`/`coverage_baseline`/`lessons`), а **не** в колонках `tasks`/`jobs`. Это **усиливает** +NFR-3: схемы существующих таблиц остаются буквально байт-в-байт; добавляется ровно один объект. + +```sql +CREATE TABLE IF NOT EXISTS transition_lease ( + task_id INTEGER PRIMARY KEY, -- одна задача = ≤1 активный владелец перехода + owner TEXT NOT NULL, -- актор: monitor|reaper|reconciler|webhook|finalizer + owner_pid INTEGER, -- pid процесса-держателя (как merge-lease) + owner_boot_id TEXT, -- нонс старта процесса (рестарт ⇒ смена ⇒ старый lease мёртв) + run_id INTEGER, -- agent_runs.id, если применимо + stage TEXT, -- from-стадия, на которой захвачено (контекст/наблюдаемость) + acquired_at TEXT NOT NULL DEFAULT (datetime('now')) +); +``` + +**CAS на запись стадии — через предикат ожидаемой стадии, без epoch-колонки.** В текущей одно-процессной +модели каждое side-effectful ребро ведёт в **отличную** стадию, поэтому `WHERE id=? AND stage=?` — +полный и корректный compare-and-swap (стадия *и есть* версия). Отдельная `epoch/version`-колонка была бы +неиспользуемой машинерией → отвергнута; задокументирована как форвард-расширение под будущий +`--workers>1` (если появится same-stage ре-ентерабельность). Это решает FR-2 «ожидаемая текущая стадия +**и/или** эпоха» в пользу стадии. + +### D3 — Liveness владельца = pid + boot-id, НЕ heartbeat (NFR-4, NFR-6) + +Владелец считается **живым** ⇔ `owner_boot_id == <текущий boot-id процесса>` **И** +`merge_gate.pid_alive(owner_pid)`. Иначе lease **устарел** → реклеймится. + +Почему не heartbeat: ORCH-113 (adr-0043, раздел «Альтернативы») **сам отверг** durable-heartbeat +доводом «блокирующий re-test не может бить heartbeat» — `merge_retest_timeout_s=900` синхронно держит +поток монитора, heartbeat с коротким окном дал бы ложное «мёртв». pid-liveness свободна от этого: процесс +жив весь re-test → lease жив; **никакого heartbeat-кода в блокирующей финализации**. + +- **Рестарт (self-deploy):** новый процесс имеет новый `boot_id` → все ранее записанные lease мгновенно + «мертвы» (boot-id mismatch) → реклеймятся → requeued job (после `requeue_running_jobs`) переисполняет + переход идемпотентно (D7). Это durable-аналог «in-memory реестр обнуляется на рестарте» (на чём держится + ORCH-113), но **переживает** рестарт как durable-запись для проверки другим актором/тиром. +- **Реальная смерть pid в том же процессе:** `pid_alive` False → реклейм немедленно (как reaper Tier-1). +- **Живой, но зависший владелец (pid жив, не прогрессирует):** добивается **Tier-3 backstop** + `reaper_max_running_s` (ниже, D8) — ограниченное время, маркер владения backstop **не обходит**. + +### D4 — Область охвата: lease на side-effectful рёбрах, CAS — на всех записях стадии + 6 обходных путях + +| Путь записи стадии | Lease | CAS | Обоснование | +|--------------------|:-----:|:---:|-------------| +| `deploy-staging → deploy` под-гейты (`stage_engine.py:321–402`) | **да** | да | необратимо: merge re-test/rebuild/инициация | +| `deploy → done` `_handle_merge_verify` (`:397–402,1726`) | **да** | да | необратимо: `merge_pr`, ratchet baseline | +| Phase C `run_deploy_finalizer` (`:1898–2009`) | **да** | да | необратимо: прод-деплой/мерж | +| прочие рёбра `advance_stage` (created→…→testing) | нет | да | обратимы; CAS инертен без гонки | +| rollback-записи `_handle_*_rollback` (`:740…1422`) | нет¹ | да | защита от rollback↔done (BR-6) | +| 5× `gitea.py` прямой `update_task_stage` | нет | **да** | закрыть обход (BRD §8) | +| 1× `plane.py:806` rollback | нет | **да** | закрыть обход (BRD §8) | + +¹ rollback исполняет тот же единственный владелец lease (он держит lease на входе в регион), поэтому +отдельный lease на rollback-запись не нужен — достаточно CAS. + +**Граница (фиксируется здесь):** обходные пути gitea/plane получают **CAS** (дёшево, закрывает дыру +BRD §8), но **не** полный lease — они не исполняют необратимых шагов (только enqueue агента/флип +индикативной стадии). CAS не даёт им перетереть авторитетную запись владельца. + +### D5 — Интеграция в `advance_stage` (FR-1, FR-2, AC-1, AC-3) + +``` +advance_stage(...): + if transition_lease.applies(repo) and <ребро side-effectful>: + if not transition_lease.acquire(task_id, owner, run_id, current_stage): + return AdvanceResult(advanced=False, note="transition-lease-busy") # чистый аборт + try: + <под-гейты / _handle_merge_verify / финализация — как сейчас> + # запись стадии — через CAS: + if not update_task_stage_cas(task_id, current_stage, next_stage): + return AdvanceResult(advanced=False, note="stage-cas-lost") # без побочных эффектов + + finally: + transition_lease.release(task_id, owner) # в т.ч. на исключении/откате (AC-3) +``` + +Проигравший acquire или CAS — **не** мутирует стадию и **не** исполняет ни одного side-effect. Release +гарантирован `finally` (lease «не течёт» на исключении/rollback). Когда kill-switch off — `acquire` +no-op→True, CAS вырождается в прежний безусловный `update_task_stage` → байт-в-байт (D9, AC-9). + +### D6 — Reaper / reconciler / webhook / startup осведомлены о владении (FR-3, FR-5, BR-3, BR-5) + +- **Reaper** (`job_reaper.py`): перед реклеймом/re-drive консультирует durable-lease на **всех** + релевантных путях (обобщение ORCH-113 за пределы Tier-2/`deploy-staging`): **живой** владелец → defer + (лог + счётчик); **мёртвый/устаревший** → реклейм. Tier-3 (`reaper_max_running_s`) маркер **игнорирует** + (добивает зависшего). Атомарный `reap_running_job` rowcount-guard сохранён. **Реклейм/реап освобождает + lease задачи** (force) — lease не переживает реап. +- **Reconciler F-1** (`reconciler.py`, перед `advance_if_gate_passed` на `:249`): новый skip-guard по + образцу escalated/Blocked/task-deps — активный живой lease → silent defer. +- **Webhook** (`plane.py` Approved/`:413` + Confirm Deploy/`:219`): активный живой lease → defer; поздний + легитимный сигнал отработает после release или станет идемпотентным no-op. +- **`finalizer_liveness` (ORCH-113) сохраняется без правок** как поведение при **выключенном** ORCH-114 + (надстройка durable-слоя поверх, TRZ §2): kill-switch off ⇒ reaper консультирует in-memory + `finalizer_liveness` (Tier-2/`deploy-staging`, ровно ORCH-113); kill-switch on ⇒ reaper консультирует + durable `transition_lease` (cross-path). Так ORCH-114 **обобщает** ORCH-113, не ломая его контракт/тест. + +### D7 — Умное восстановление = stale-clear + авторитетные факты, БЕЗ нового «recovery-мозга» (FR-4, BR-4, BR-6, NFR-7) + +Ключевое архитектурное решение, снимающее риск BRD §8 («некорректный smart-recovery сам станет источником +двойного применения»): ORCH-114 **не строит** новую машину восстановления. Восстановление = композиция: + +1. **`requeue_running_jobs()`** (существует, `db.py:1320`; в `main.lifespan` до старта reaper) — running→queued. +2. **`transition_lease.recover_on_startup()`** — boot-id новый ⇒ все ранее записанные lease мертвы; + reaper/claim их реклеймят (наблюдаемо: лог + алерт на форсированный реклейм). +3. **Идемпотентность re-drive — уже обеспечена авторитетными durable-фактами предшественников**, lease их + не дублирует, а лишь гарантирует **последовательную** (не конкурентную) их проверку: + - **SHA-in-main** (ORCH-071/073/093): `merge_gate.verify_merged_to_main` / `ensure_open_pr → + "already-in-main"` → повторный `_handle_merge_verify` доводит до `done` **без** второго `merge_pr`. + - **Маркер `INITIATED`** (self-deploy ORCH-036): Phase B idempotency-guard (`stage_engine.py:1567`) → + повторный заход не инициирует второй прод-деплой. + - **Coverage-ratchet CAS** (ORCH-027): `ratchet_coverage_baseline` (`UPDATE … WHERE coverage<=?`) — + повторный ratchet идемпотентен по построению. + +Итог: после смерти процесса в середине финализации система сходится к **единственному** исходу — +незавершённое дорешается, уже применённый необратимый шаг **не** повторяется (источник истины «уже +применено» = авторитетные факты, не in-memory). + +### D8 — Сквозной бюджет reaper: без новых таймаутов (NFR-6) + +Lease **не вводит** собственный долгий TTL. Его жёсткий потолок возраста **совпадает** с Tier-3 +`reaper_max_running_s` (5400): reaper при реапе job на Tier-3 force-освобождает lease — lease и job +реклеймятся в один момент. Раннее обнаружение смерти — через pid+boot (D3), а не таймер. Поэтому +сквозной инвариант ORCH-065/109/110/113 `reaper_max_running_s (5400) > Σ(deploy-staging gate-work ≈4460) ++ grace` **остаётся нетронутым**, `reaper_finalize_grace_s`/`reaper_max_running_s` **не меняются**. Новых +бюджетных констант, требующих согласования, нет. + +### D9 — never-raise / fail-open / fail-closed / kill-switch (FR-7, NFR-1, NFR-8, AC-9, AC-10) + +- **Hot-path `claim_next_job` НЕ трогается** — lease консультируется на пути перехода/финализации и в + reaper/reconciler/webhook, **не** в claim. → общая очередь всех проектов не может заклиниться на баге + lease (fail-open по построению, AC-8 ORCH-088 цел). +- **acquire/guard-ошибка** (БД-сбой/повреждённая строка): на side-effectful пути → консервативный + **defer/abort текущей попытки без побочных эффектов** (fail-closed к недвоению; не вечный клин — следующий + тик/reaper переисполнит, в пределе Tier-3 добьёт). guard reconciler/webhook → консервативный skip. +- **CAS-ошибка** → аборт записи (не слепой write). +- **Kill-switch `transition_lease_enabled=False`** → lease не пишется/не читается; CAS вырождается в + прежний `update_task_stage`; reaper → ORCH-113 fallback; reconciler/webhook skip-guard инертен → **байт-в-байт** + до-ORCH-114 (зелёный существующий `pytest tests/` без правок ожиданий). + +### D10 — Наблюдаемость и конфигурация (FR-6, BR-7, NFR-2, AC-12) + +- `GET /queue` — аддитивный read-only блок `transition_lease` (держатели/owner/stage/возраст/defer-счётчики/ + форсированные/устаревшие реклеймы); существующие ключи не тронуты. +- **Telegram-алерт** (`send_telegram`, кликабельный номер) на форсированный/устаревший реклейм. +- **Опц.** `POST /transition-lease/release?work_item=` — операторский ручной реклейм (паттерн + `POST /serial-gate/unfreeze`). +- **Опц.** lessons-journal автозапись (ORCH-098, `source="auto"`) на форсированный реклейм. +- **Флаги** (`config.py`): `transition_lease_enabled: bool = True` (env `ORCH_TRANSITION_LEASE_ENABLED`, + kill-switch); `transition_lease_repos: str = ""` (CSV; **пусто → self-hosting only**, паттерн + `coverage_gate_repos`/`serial_gate_repos` → enduro не затронут). Новых таймаутов нет (D8). +- **Leaf `src/transition_lease.py`** (never-raise, паттерн `serial_gate`/`coverage_gate`/`finalizer_liveness`): + `applies(repo)` / `acquire(task_id, owner, run_id, stage) -> bool` / `is_held_by_live_owner(task_id) -> bool` + / `release(task_id, owner=None)` / `reclaim_if_stale(task_id) -> bool` / `recover_on_startup()` / `snapshot()`. + +## Альтернативы + +- **Только CAS/epoch, без lease** — отвергнуто: CAS на коммите не предотвращает двойной side-effect + *в полёте* (re-test/rebuild/merge исполняются до записи стадии). Не закрывает класс ORCH-111. +- **Только durable-lease, без CAS** — отвергнуто: не покрывает 6 путей в обход `advance_stage` и узкое + окно «consult→acquire». +- **Heartbeat-liveness** — отвергнуто доводом самого ORCH-113: блокирующий 900s re-test не может бить + heartbeat → ложное «мёртв». pid+boot свободна от этого. +- **Lease-файл per-task** (клон merge-lease) — отвергнуто: CAS на запись стадии — DB-операция; держать + владение в той же транзакционной БД когерентнее и позволяет атомарный acquire тем же rowcount-guard + идиомом (`claim_next_job`/`reap_running_job`), что код уже доверяет. merge-lease-файл остаётся per-**repo** + для **другой** задачи (сериализация мержей между задачами репо) — не дублируется. +- **`epoch/version`-колонка на `tasks`** — отвергнуто (для текущей модели): стадия *и есть* версия для + side-effectful рёбер; колонка была бы неиспользуемой. Задокументирована как форвард-расширение. +- **Sub-state `finalizing` в `jobs.status`** — отвергнуто (как в ORCH-113): меняет семантику статуса для + claim/requeue/reconciler/reaper — нарушение NFR-3. +- **Per-stage grace, покрывающая Σ финализации** — отвергнуто (как в ORCH-113): нарушает бюджет + `5400 > Σ+grace`; таймер = источник бага. +- **Бесшовно для всех репо (без self-hosting-скоупа)** — отвергнуто: по образцу coverage/serial-gate + область по умолчанию self-hosting (необратимые эффекты живут на self-hosting-рёбрах); enduro инертен. +- **Новый бесшовный «recovery-мозг»** — отвергнуто (BRD §8 риск): композиция requeue + stale-clear + + авторитетные факты (D7) проще и не вносит нового источника двойного применения. + +## Последствия + +- **+** Класс двойного эффекта/противоречия rollback↔done закрыт **в корне** (предотвращение на входе), + а не починкой постфактум; покрыты конкурентный, reaper-после-рестарта, reconciler и webhook пути. +- **+** Рестарт-safe без нового таймера и без переписывания на multi-process (boot-id готовит почву под + будущий `--workers>1`, NFR-4). +- **+** Сквозной бюджет reaper и все инварианты конвейера (`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/ + вердикт-ключи) не тронуты; схемы существующих таблиц байт-в-байт; +1 аддитивная таблица. +- **+** Дыра обходных путей gitea/plane (BRD §8) закрыта CAS. +- **−** Гарантия эксклюзии валидна при одном процессе/одной БД (как ORCH-113); durable-lease лишь делает + её **корректной** и для рестарта/будущей multi-process — но полноценная multi-writer верификация — вне + объёма (риск TR-6 в `10-tech-risks.md`). +- **−** Узкое окно «штамп `finished_at` → acquire» (как ORCH-113) маркером не покрыто — закрыто прежним + grace=300 + CAS-backstop. +- **Откат:** `ORCH_TRANSITION_LEASE_ENABLED=false` → байт-в-байт до-ORCH-114 (таблица остаётся инертной). + +## Ссылки +- BRD: `docs/work-items/ORCH-114/01-brd.md` +- TRZ: `docs/work-items/ORCH-114/02-trz.md` +- Acceptance: `docs/work-items/ORCH-114/03-acceptance-criteria.md` +- Данные: `docs/work-items/ORCH-114/08-data-requirements.md` +- Риски: `docs/work-items/ORCH-114/10-tech-risks.md` +- Сквозной ADR: `docs/architecture/adr/adr-0045-transition-ownership-lease-and-stage-cas.md` +- Предшественники: `adr-0043` (ORCH-113 finalizer-liveness), `adr-0042` (ORCH-110 merge-retest), + `adr-0027`/`merge-lease` (ORCH-043), `adr-0040` (ORCH-109 бюджеты), `adr-0011` (ORCH-065 reaper), + `adr-0029` (ORCH-027 coverage-ratchet) +- Сверено по коду: `src/db.py:671–679,1320–1335,1464–1505,988–1055`, `src/stage_engine.py:176–507,1726–2009`, + `src/finalizer_liveness.py`, `src/job_reaper.py:245,406,436–461`, `src/reconciler.py:249,515–575`, + `src/webhooks/plane.py:219,413,806`, `src/webhooks/gitea.py:127,242,333,359,398`, + `src/merge_gate.py:311–411,452,526` + + diff --git a/docs/work-items/ORCH-114/08-data-requirements.md b/docs/work-items/ORCH-114/08-data-requirements.md new file mode 100644 index 0000000..5dc6989 --- /dev/null +++ b/docs/work-items/ORCH-114/08-data-requirements.md @@ -0,0 +1,66 @@ +--- +work_item: ORCH-114 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-15 +model_used: claude-opus-4-8 +--- + +# 08 — Требования к данным: ORCH-114 — Durable transition-ownership lease + expected-stage CAS + +Work Item: **ORCH-114** · Repo: **orchestrator** · Стадия: architecture + +> When-applicable / информационный (гейтом не парсится). Сверено по `src/db.py`. + +## Изменения схемы БД + +**Ровно один новый объект — аддитивная таблица `transition_lease`** (`CREATE TABLE IF NOT EXISTS` в +`init_db()`, паттерн `repo_freeze`/`coverage_baseline`/`lessons`): + +```sql +CREATE TABLE IF NOT EXISTS transition_lease ( + task_id INTEGER PRIMARY KEY, -- одна задача = ≤1 активный владелец side-effectful перехода + owner TEXT NOT NULL, -- актор-держатель: monitor|reaper|reconciler|webhook|finalizer + owner_pid INTEGER, -- pid процесса-держателя (liveness, как merge-lease os.getpid()) + owner_boot_id TEXT, -- нонс старта процесса; рестарт ⇒ смена ⇒ прежний lease мёртв + run_id INTEGER, -- agent_runs.id если применимо (контекст) + stage TEXT, -- from-стадия захвата (наблюдаемость/контекст) + acquired_at TEXT NOT NULL DEFAULT (datetime('now')) +); +``` + +Индекс не требуется (доступ по PK `task_id`); `snapshot()` для `GET /queue` — full-scan по малой таблице +(в любой момент строк ≈ числу активных side-effectful переходов, единицы). + +**Изменений существующих таблиц НЕТ.** `tasks` / `jobs` / `agent_runs` / `events` / `job_deps` / +`repo_freeze` / `coverage_baseline` / `lessons` — схемы **байт-в-байт** (NFR-3, AC-11). **Колонка +`epoch/version` НЕ добавляется** (ADR-001 D2: для одно-процессной модели стадия *и есть* версия CAS; +epoch — форвард-расширение, не вводится сейчас). + +## Новые/изменённые сущности + +- **Таблица `transition_lease`** — durable-владение side-effectful переходом задачи. Инвариант: + активная строка для `task_id` ⇔ некий актор держит владение переходом этой задачи. **Живой** владелец ⇔ + `owner_boot_id == ` И `merge_gate.pid_alive(owner_pid)`; иначе **устарел** → + реклеймится. Захват — атомарный rowcount-guard (паттерн `claim_next_job`/`reap_running_job`): `INSERT … ON + CONFLICT(task_id)` берётся только при отсутствии живого владельца (иначе rowcount==0 → busy). +- **Функция `update_task_stage_cas(task_id, expected_stage, new_stage) -> bool`** (новая, в `db.py`): + `UPDATE tasks SET stage=?, updated_at=datetime('now') WHERE id=? AND stage=?`; возвращает `cur.rowcount==1` + (выиграл CAS) / `False` (проиграл — стадия уже не та, что читали → аборт без побочных эффектов). + Прежний `update_task_stage` **сохраняется без изменений** (путь kill-switch-off и записи вне + side-effectful области). + +## Совместимость данных / миграции + +- **Аддитивно/идемпотентно/restart-safe:** `CREATE TABLE IF NOT EXISTS` в `init_db()` — повторный старт + no-op; на живой общей прод-БД данные enduro-trails не затрагиваются (новая таблица изолирована). +- **Никакого backfill** существующих строк не требуется (таблица заполняется рантаймом при захвате владения). +- **Рестарт-семантика:** durable-строки lease переживают рестарт физически; новый процесс получает новый + `owner_boot_id` → ранее записанные строки трактуются как устаревшие и реклеймятся (ADR-001 D3/D7); + `recover_on_startup()` зачищает их наблюдаемо (после `requeue_running_jobs`). +- **Откат (NFR-8):** при `transition_lease_enabled=False` таблица не читается/не пишется и остаётся + инертной; удалять её при откате не требуется. Поведение БД-слоя — байт-в-байт до-ORCH-114. +- **enduro-trails:** при `transition_lease_repos=""` (self-hosting only) механизм для enduro не активируется + — нулевая регрессия. + diff --git a/docs/work-items/ORCH-114/10-tech-risks.md b/docs/work-items/ORCH-114/10-tech-risks.md new file mode 100644 index 0000000..88ef771 --- /dev/null +++ b/docs/work-items/ORCH-114/10-tech-risks.md @@ -0,0 +1,47 @@ +--- +work_item: ORCH-114 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-15 +model_used: claude-opus-4-8 +--- + +# 10 — Технические риски: ORCH-114 — Durable transition-ownership lease + expected-stage CAS + +Work Item: **ORCH-114** · Repo: **orchestrator** · Стадия: architecture + +> Информационный (гейтом не парсится). Риски реализации и митигейшн. Сверено по коду. + +## Реестр рисков + +| ID | Риск | Вер. | Влия. | Митигейшн | +|----|------|------|-------|-----------| +| **TR-1** | **Дедлок / over-block:** «жёсткое» владение заклинивает легитимный путь — reaper не добивает зависший finalizer, задача висит нетерминальной с удержанным lease (клинит serial-gate репо). | Сред. | Выс. | ADR D3/D8: liveness = pid+boot (мёртвый владелец реклеймится немедленно); Tier-3 `reaper_max_running_s` **игнорирует** lease и добивает зависшего живого; reaper при реапе force-освобождает lease. `release` в `try/finally`. Опц. `POST /transition-lease/release`. Обязательный тест AC-3 (release на исключении/откате) + AC-5 (bounded reclaim). | +| **TR-2** | **Lease «течёт»** при исключении/откате в `advance_stage` → задача навсегда заблокирована. | Сред. | Выс. | `release` строго в `finally` вокруг всего side-effectful региона (ADR D5); регресс-тест AC-3 (acquire→raise→release). Бэкстоп: stale-реклейм по pid/boot + Tier-3. | +| **TR-3** | **Buggy «smart recovery»** сам становится источником двойного применения необратимого шага после рестарта. | Сред. | Выс. | ADR D7: НЕ новый recovery-мозг, а композиция `requeue_running_jobs` + stale-clear + **существующие авторитетные факты** (SHA-in-main ORCH-071/073, `INITIATED` ORCH-036, coverage-ratchet CAS). Обязательный restart-recovery регресс (BR-8/AC-6): процесс убит в середине финализации → ровно один исход, без второго `merge_pr`/ratchet/deploy. | +| **TR-4** | **Скрытые обходные пути** (gitea `handle_push`/`handle_ci_status`/`handle_pr`, plane `_rollback_stage:806`) пишут стадию мимо `advance_stage` → CAS-инвариант обходится. | Выс. | Сред. | ADR D4: 6 обходных `update_task_stage` переведены на `update_task_stage_cas`; граница зафиксирована в ADR. Структурный аудит (AC-11): ни одного безусловного `update_task_stage` на side-effectful/конкурентных путях при флаге on. | +| **TR-5** | **Гонка consult→acquire:** актор A прошёл guard «lease свободен», но B захватил между проверкой и acquire A. | Сред. | Сред. | Двойной слой (ADR D1): даже если оба прошли consult, `acquire` атомарен (rowcount-guard, один INSERT выигрывает), а проигравший CAS на коммите не пишет стадию и не делает side-effect. Consult — лишь дешёвый front-defer, не источник истины. | +| **TR-6** | **Multi-process (`--workers>1`):** pid+boot-liveness и SQLite-CAS на одной БД корректны для одного процесса; ввод воркеров потребует верификации (SQLite write-lock contention, boot-id на процесс). | Низ. | Сред. | Вне объёма (BRD §scope: модель остаётся одно-процессной). Durable-форма (таблица + pid/boot + CAS) спроектирована совместимой; epoch-колонка — документированное форвард-расширение (ADR D2). Зафиксировано как ограничение в adr-0045 «Последствия (−)». | +| **TR-7** | **Бюджетный конфликт:** lease, удерживаемый дольше `reaper_max_running_s`, нарушает сквозной инвариант ORCH-065/109/110/113. | Низ. | Выс. | ADR D8: lease без собственного TTL, потолок = Tier-3 `reaper_max_running_s` (5400); `reaper_finalize_grace_s`/`reaper_max_running_s` НЕ меняются; инвариант `5400 > Σ(≈4460)+grace` цел. Новых бюджетных констант нет. | +| **TR-8** | **Регрессия ORCH-113:** обобщение `finalizer_liveness` ломает его контракт/тест или меняет поведение при выключенном ORCH-114. | Сред. | Сред. | ADR D6: `finalizer_liveness.py` **не правится**, остаётся поведением kill-switch-off (reaper → in-memory, Tier-2/`deploy-staging`); on → durable cross-path. Зелёный существующий тест ORCH-113 + AC-9 (флаг off → байт-в-байт). Сверка маркеров ORCH-113 (TRACEABILITY/ORCH-078). | +| **TR-9** | **Сбой механизма заклинивает общую очередь** всех проектов (enduro + orchestrator), нарушая AC-8 ORCH-088. | Низ. | Выс. | ADR D9: hot-path `claim_next_job` **не трогается** (lease консультируется на пути перехода/reaper/reconciler/webhook, не в claim). never-raise; acquire/guard-ошибка → defer (не клин), CAS-ошибка → аборт записи. Регресс AC-10. | +| **TR-10** | **Ложная stale-реклейм** живого владельца (pid переиспользован ОС после рестарта, boot-id совпал случайно). | Низ. | Сред. | `owner_boot_id` — достаточно энтропийный нонс старта процесса (не предсказуемый), плюс pid-проверка; коллизия (тот же boot-id И тот же pid у нового процесса) практически невозможна. Бэкстоп: CAS на коммите не даст двойной записи даже при ложном реклейме. | + +## Сводный вывод + +Доминирующий класс рисков — **корректность владения и восстановления на необратимых рёбрах** +(TR-1/TR-2/TR-3) и **полнота охвата путей** (TR-4/TR-5). Все они снимаются архитектурой defense-in-depth +(lease на входе + CAS на коммите) и принципом «не строить новый recovery-мозг, опереться на существующие +авторитетные факты» (D7) — это сознательно минимизирует площадь нового кода, способного двоить +необратимый шаг. + +**Эскалация `arch:major-change`: рекомендуется.** Изменение вводит новый durable-компонент (leaf +`transition_lease` + таблица), трогает движок переходов и ≥5 фоновых акторов и помечается сводным сквозным +ADR (adr-0045). Реализующему агенту (developer) обязательны: регресс двойного эффекта (AC-1, red→green), +restart-recovery (AC-6), kill-switch-off байт-в-байт (AC-9), сохранность бюджета (AC-5/NFR-6) и аудит +обходных путей (TR-4/AC-11). Возврата в анализ не требуется — требования полны и реализуемы без нарушения +архитектурных принципов (всё в Docker/одном процессе/SQLite, без новых внешних зависимостей и без рестарта +прода). Остаточный риск для прод-конвейера (self-hosting) при дисциплине тестов — **низкий**; единственное +осознанное ограничение — multi-process (TR-6), явно вне объёма. +