From ab5e4c345baf6755fb8d773882f4ce7f359d6c5c Mon Sep 17 00:00:00 2001 From: Slava Date: Mon, 15 Jun 2026 15:48:08 +0300 Subject: [PATCH 1/9] docs: init ORCH-114 business request --- docs/work-items/ORCH-114/00-business-request.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 docs/work-items/ORCH-114/00-business-request.md diff --git a/docs/work-items/ORCH-114/00-business-request.md b/docs/work-items/ORCH-114/00-business-request.md new file mode 100644 index 0000000..d5ed759 --- /dev/null +++ b/docs/work-items/ORCH-114/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: BUG: pipeline stage transitions need ownership lease and smart startup recovery + +Work Item ID: ORCH-114 + +## Description + +TBD From 9fcca9efbc874894c7e5f725946cf2654f613bc0 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 15 Jun 2026 15:57:22 +0300 Subject: [PATCH 2/9] analyst(ET): auto-commit from analyst run_id=708 --- docs/work-items/ORCH-114/01-brd.md | 186 ++++++++++++++++++ docs/work-items/ORCH-114/02-trz.md | 143 ++++++++++++++ .../ORCH-114/03-acceptance-criteria.md | 177 +++++++++++++++++ docs/work-items/ORCH-114/04-test-plan.yaml | 107 ++++++++++ 4 files changed, 613 insertions(+) create mode 100644 docs/work-items/ORCH-114/01-brd.md create mode 100644 docs/work-items/ORCH-114/02-trz.md create mode 100644 docs/work-items/ORCH-114/03-acceptance-criteria.md create mode 100644 docs/work-items/ORCH-114/04-test-plan.yaml diff --git a/docs/work-items/ORCH-114/01-brd.md b/docs/work-items/ORCH-114/01-brd.md new file mode 100644 index 0000000..17a4852 --- /dev/null +++ b/docs/work-items/ORCH-114/01-brd.md @@ -0,0 +1,186 @@ +--- +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 должен учитывать и их + (архитектор фиксирует границу). diff --git a/docs/work-items/ORCH-114/02-trz.md b/docs/work-items/ORCH-114/02-trz.md new file mode 100644 index 0000000..ad2e392 --- /dev/null +++ b/docs/work-items/ORCH-114/02-trz.md @@ -0,0 +1,143 @@ +--- +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 +--- + +# 02 — ТЗ (TRZ): ORCH-114 — Ownership-lease для side-effectful переходов стадий + умное восстановление при старте + +Work Item: **ORCH-114** · Repo: **orchestrator** · Стадия: analysis + +> ТЗ описывает **конкретные требования к реализации**, выведенные из BRD (`01-brd.md`) и фактического +> кода. **Выбор механизма** (durable lease / heartbeat / transition-epoch / форма хранения, набор +> покрываемых рёбер) и архитектурное обоснование — задача архитектора (`06-adr/`). Здесь — *что* +> должно быть истинно и *какие модули* затрагиваются, не *как* именно. + +## 1. Сводка изменения + +Вводится **единый инвариант владения** side-effectful переходом стадии: запись стадии и исполнение +тяжёлых под-гейтов/финализации защищаются **durable-механизмом владения** (lease/epoch) + **CAS** на +запись стадии, так что в любой момент переход конкретной задачи доводит **ровно один** актор, а +конкурентный/после-рестартовый повторный вход (reaper / reconciler / webhook / startup-requeue) +**откладывается или становится no-op** вместо повторного применения необратимых эффектов +(merge_pr / coverage-ratchet / image-rebuild / инициация прод-деплоя / противоречивый rollback↔done). +Аддитивно, под kill-switch, never-raise; `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / вердикт-ключи +/ схемы существующих таблиц — не трогаются (обобщает и делает durable процесс-локальный +`finalizer_liveness` ORCH-113). + +## 2. Задействованные модули / пути + +| Путь | Действие | Назначение в ORCH-114 | +|------|----------|------------------------| +| `src/stage_engine.py` | изменить | `advance_stage`: захват владения на границе side-effectful перехода/финализации; CAS на запись стадии; release в `try/finally`; проигравший — чистый аборт. Покрыть `_handle_merge_verify`, под-гейты `deploy-staging→deploy`, `run_deploy_finalizer` (Phase C), `advance_if_gate_passed` (F-1). | +| `src/db.py` | изменить | CAS-вариант записи стадии (запись только при совпадении ожидаемой текущей стадии/эпохи; rowcount-результат). Durable-хелперы владения (acquire / heartbeat-touch / release / reclaim / snapshot) — форму хранилища задаёт архитектор. | +| `src/finalizer_liveness.py` | изменить/обобщить | Отправная точка: обобщить process-local реестр до **durable, кросс-путевого** владения (или надстроить durable-слой поверх). Сохранить контракт never-raise + kill-switch. | +| `src/job_reaper.py` | изменить | Tier-2/Tier-3 осведомлены о durable-владении на **всех** релевантных путях (не только Tier-2/`deploy-staging`): defer при живом, реклейм мёртвого/устаревшего в ограниченное время (NFR-6). | +| `src/queue_worker.py` | изменить | `requeue_running_jobs` / стартовое восстановление сверяется с durable-владением: не пере-исполнять уже применённый необратимый шаг (умное восстановление). `claim_next_job` — не вносить сетевых зависимостей. | +| `src/reconciler.py` | изменить | F-1 (`advance_if_gate_passed`) — defer при активном lease перехода (по образцу skip-guard'ов escalated/Blocked/deps). | +| `src/webhooks/plane.py` | изменить | Пути продвижения (`_try_advance_stage`, Approved / Confirm Deploy) — defer при активном lease. | +| `src/webhooks/gitea.py` | изменить (учесть) | Прямые записи стадии в обход `advance_stage` (`handle_push`/`handle_ci_status`/`handle_pr`) должны попадать под тот же CAS-инвариант либо явно исключаться архитектором (граница в ADR). | +| `src/main.py` | изменить | Порядок старта демонов / точка восстановления; read-only блок в `GET /queue`; опц. operator-эндпоинт реклейма. | +| `src/config.py` | изменить | Kill-switch(и) + бюджеты/таймауты владения + (опц.) CSV-скоуп репо. | +| `tests/test_orch114_transition_ownership.py` | создать | Покрытие FR-1…FR-7 (см. `04-test-plan.yaml`). | + +## 3. Функциональные требования + +### FR-1 — Единое владение side-effectful переходом (BR-1, BR-6) +На границе, где начинается side-effectful финализация/переход (минимум: под-гейты +`deploy-staging→deploy`, `_handle_merge_verify` на `deploy→done`, Phase C `run_deploy_finalizer`), +актор **захватывает владение** задачей. Пока владение активно, другой актор не исполняет тот же +переход. Release — детерминированно в `try/finally` (в т.ч. на исключении/откате). Владение +**durable** (NFR-4): переживает рестарт и доступно для проверки другому актору/новому процессу. + +### FR-2 — Compare-and-swap / epoch на запись стадии (BR-2) +Запись стадии для side-effectful переходов выполняется только если предусловие (ожидаемая текущая +стадия и/или эпоха перехода) не изменилось с момента чтения. Реализуется через CAS-вариант +`update_task_stage` (`UPDATE … SET stage=?[, epoch=epoch+1] WHERE id=? AND stage=?[ AND epoch=?]`, +решение по форме — архитектор). Проигравший гонку писатель получает «lost-race» результат, **не** +мутирует стадию и **не** выполняет ни одного побочного эффекта (merge_pr / ratchet / rebuild / +deploy-init / enqueue следующего агента). Инвариант распространяется и на пути, пишущие стадию в +обход `advance_stage` (gitea-webhook), — либо CAS, либо явное исключение (граница в ADR). + +### FR-3 — Reaper, осведомлённый о владении на всех путях (BR-3, NFR-6) +Job-reaper перед реклеймом сверяется с durable-владением **не только** в Tier-2 для `deploy-staging` +(текущая область ORCH-113), а на всех релевантных тирах/рёбрах: **живой** владелец → **defer** +(лог + счётчик, без повторного advance); **мёртвый/устаревший** владелец → реклейм в ограниченное +время (Tier-3 backstop `reaper_max_running_s` добивает зависшего; маркер владения backstop не +обходит инвариант бюджета). Сохранить атомарный `reap_running_job` rowcount-guard. + +### FR-4 — Умное восстановление при старте (BR-4, BR-6, NFR-7) +Стартовое восстановление (`requeue_running_jobs` + последующий цикл) использует durable-владение/эпоху, +чтобы **детерминированно** различить: (a) финализация не начиналась / безопасно перезапустить → +re-drive; (b) необратимый шаг уже применён (мерж в `main` / ratchet / прод-деплой инициирован) → +**сойтись к done/консистентному исходу без повторного применения**. Источник истины для «уже +применено» — авторитетные durable-факты (SHA-in-main ORCH-071/073, маркер `INITIATED` self-deploy, +durable-lease/эпоха), а не in-memory состояние. + +### FR-5 — Skip/defer в reconciler и webhook (BR-5) +Reconciler F-1 (`advance_if_gate_passed`) и webhook-пути продвижения (`plane._try_advance_stage`, +Approved/Confirm Deploy) при **активном** lease перехода для задачи **откладывают** действие +(silent skip + наблюдаемость), по образцу существующих skip-guard'ов F-1 (escalated / Blocked / +task-deps). Fail-safe: неопределённость состояния lease → консервативный skip (не дублировать). + +### FR-6 — Наблюдаемость (BR-7) +Аддитивный read-only блок в `GET /queue` (по образцу `serial_gate`/`merge_gate`/`reaper`): +держатели lease, возраст владения, defer-счётчики, форсированные/устаревшие реклеймы. Алерт +(`send_telegram`, кликабельный номер) на форсированный/устаревший реклейм. Опц. запись в +lessons-journal (ORCH-098, `source="auto"`). Опц. operator-эндпоинт ручного реклейма (по образцу +`POST /serial-gate/unfreeze`). + +### FR-7 — Конфигурация, обратимость, never-raise (BR-9, NFR-1, NFR-2, NFR-8) +Все публичные функции владения — never-raise (ошибка → безопасный дефолт + WARNING). Kill-switch +возвращает поведение **байт-в-байт** к до-ORCH-114 (lease не пишется/не читается, CAS вырождается в +прежний безусловный `update_task_stage`). Hot-path — fail-open; prod-safety — fail-closed. + +## 4. Изменения API +- **`GET /queue`** — аддитивный read-only блок владения переходом (имя ключа уточнит архитектор, + напр. `transition_ownership` / `transition_lease`). Существующие ключи `/queue` — байт-в-байт. +- **(Опционально, по решению архитектора)** `POST /transition-lease/release?work_item=` — + операторский ручной реклейм застрявшего владения (паттерн `POST /serial-gate/unfreeze`). +- `GET /metrics` (ORCH-099) — при необходимости аддитивное поле без бампа `schema_version` (sidecar + обязан толерировать незнакомые ключи). Прочие эндпоинты — не трогаются. + +## 5. Изменения схемы БД +**Требование (форму выбирает архитектор, `06-adr/` + `08-data-requirements.md`):** для durable-владения +(NFR-4) и CAS/epoch (FR-2) требуется **аддитивное, идемпотентное** durable-состояние. Кандидаты +(не предписание): +- доп. аддитивная таблица владения (`CREATE TABLE IF NOT EXISTS`, паттерн `repo_freeze`/ + `coverage_baseline`/`lessons`) с `(task_id/job_id, owner, run_id, stage, acquired_at, heartbeat_at, + expires_at)`; **либо** +- аддитивные колонки на `tasks`/`jobs` (`_ensure_column`, паттерн `tasks.track`/`tasks.cancelled_at`), + включая возможную `epoch/version`-колонку для CAS. + +**Жёсткие ограничения (NFR-3):** только аддитивно/идемпотентно; **схемы существующих таблиц +(`tasks`/`jobs`/`agent_runs` и пр.) — байт-в-байт**; никаких изменений существующих столбцов/индексов, +ломающих обратную совместимость; restart-safe инициализация в `init_db()`. + +## 6. Требования к новым/изменённым QG checks +**Нет.** `QG_CHECKS` / `check_*` / `_parse_*` / машинные вердикт-ключи — **не трогаются**. Владение +переходом — свойство **движка переходов и фоновых акторов**, а **не** Quality Gate и **не** стадия +(аналогично тому, как merge-lease/serial-gate/finalizer-liveness — врезки/leaf'ы, а не QG). Никаких +новых стадий/рёбер в `STAGE_TRANSITIONS`. + +## 7. Совместимость / регресс +- **Kill-switch** (новый флаг в `config.py`, env `ORCH_*`): `False` → CAS вырождается в прежний + безусловный `update_task_stage`, lease не пишется/не читается, reaper/reconciler/webhook ведут себя + как до ORCH-114 — **байт-в-байт** (включая зелёный существующий `pytest tests/`). +- **Область раската:** по образцу leaf-гейтов; durable-lease минимально применяется к self-hosting + рёбрам (`deploy-staging`/`deploy→done`, где живут необратимые эффекты); generic-CAS инертен при + отсутствии гонки (нулевая стоимость на не-затронутых переходах). Точную область фиксирует архитектор. +- **Обратимость:** механизм аддитивен и изолирован; откат = выключить kill-switch (durable-таблица/ + колонки остаются инертными). +- **never-raise / fail-open / fail-closed:** hot-path claim/guard — fail-open (не клинить общую + очередь, AC-8 ORCH-088); prod-safety/необратимость — fail-closed; любой сбой механизма — WARNING + + безопасный дефолт. +- **Сквозной бюджет:** lease согласован с `reaper_max_running_s`/`reaper_finalize_grace_s` (ORCH-065/ + 109/110/113) — не удлиняет финализацию за backstop; устаревший владелец добивается в ограниченное время. +- **Маркеры трассировки (ORCH-078):** правки в блоках с маркерами ORCH-065/109/110/111/113 (reaper, + finalizer-liveness, merge-gate) сверяются с их ADR перед изменением; новый код помечается `ORCH-114`. +- **enduro-trails:** при флаге off / репо вне области — нулевая регрессия. diff --git a/docs/work-items/ORCH-114/03-acceptance-criteria.md b/docs/work-items/ORCH-114/03-acceptance-criteria.md new file mode 100644 index 0000000..64c5760 --- /dev/null +++ b/docs/work-items/ORCH-114/03-acceptance-criteria.md @@ -0,0 +1,177 @@ +--- +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 +--- + +# 03 — Критерии приёмки (Acceptance Criteria): ORCH-114 — Ownership-lease для side-effectful переходов + умное восстановление при старте + +Work Item: **ORCH-114** · Repo: **orchestrator** · Стадия: analysis + +Формат: каждый критерий имеет **PASS** (что должно быть истинно для приёмки) и **FAIL** (что +считается провалом). Reviewer/CI проверяет их буквально по файлам и тестам репозитория. + +--- + +## AC-1 — Обязательный регресс: нет двойного эффекта при конкурентном входе в переход + +**Условие:** два актора одновременно входят в `advance_stage(deploy-staging)` для одной задачи +(живой монитор-финализатор + второй путь: reaper / reconciler F-1 / webhook). Тест-двойники для +`merge_pr` / coverage-ratchet / image-rebuild / deploy-init считают число вызовов. +- **PASS:** side-effectful шаги (merge_pr, ratchet baseline, image-rebuild, инициация прод-деплоя, + enqueue следующего агента) выполняются **ровно один раз**; персистится **ровно один** согласованный + исход стадии; второй актор получает «lost-race»/defer и **не** выполняет побочных эффектов. Тест + **красный до фикса, зелёный после** (воспроизводит класс инцидента ORCH-111). +- **FAIL:** любой side-effectful шаг вызван дважды; либо два противоречивых исхода (один откатил на + `development`, другой довёл до `done`); либо тест не воспроизводит проблему до фикса. + +--- + +## AC-2 — Compare-and-swap на запись стадии + +**Условие:** CAS-вариант записи стадии вызывается двумя писателями с одинаковым ожидаемым предусловием; +первый применяется, второй приходит со «устаревшим» ожиданием. +- **PASS:** первый writer применяет переход (rowcount=1); второй получает «lost-race» (rowcount=0), + стадия **не** мутируется повторно; при выключенном kill-switch CAS вырождается в прежний безусловный + `update_task_stage` (байт-в-байт). +- **FAIL:** второй writer перезатирает стадию; либо CAS меняет семантику записи при выключенном флаге. + +--- + +## AC-3 — Жизненный цикл владения: acquire / release / реклейм + +**Условие:** актор начинает side-effectful финализацию. +- **PASS:** владение захватывается на границе финализации и освобождается в `try/finally` — в т.ч. + при исключении и при откате (rollback); durable-запись владения видна другому актору; после release + владение реклеймится/свободно. +- **FAIL:** владение не освобождается при исключении/откате (lease «течёт» и клинит задачу); либо не + захватывается на границе. + +--- + +## AC-4 — Reaper откладывает реклейм при живом владении (все пути, не только Tier-2/deploy-staging) + +**Условие:** durable-владение активно (живой финализатор), reaper делает тик. +- **PASS:** reaper **defer** (лог + счётчик, без повторного `advance_stage`) пока владение живо и в + пределах бюджета; область defer обобщена за пределы Tier-2/`deploy-staging` ORCH-113 на релевантные + пути; атомарный `reap_running_job` rowcount-guard сохранён. +- **FAIL:** reaper повторно исполняет финализацию при живом владельце; либо defer ограничен только + прежней узкой областью, оставляя кросс-путь открытым. + +--- + +## AC-5 — Reaper добивает мёртвое/устаревшее владение в ограниченное время + +**Условие:** владелец провально мёртв/завис (lease устарел), финализация не прогрессирует. +- **PASS:** reaper реклеймит задачу в пределах Tier-3 backstop `reaper_max_running_s` (маркер владения + backstop не обходит); задача не остаётся навсегда заклиненной; сквозной инвариант + `reaper_max_running_s > Σ(deploy-staging gate-work) + grace` сохранён. +- **FAIL:** мёртвое владение блокирует задачу бессрочно; либо нарушен бюджетный инвариант ORCH-065/ + 109/110/113. + +--- + +## AC-6 — Умное восстановление при рестарте процесса + +**Условие:** процесс убит **в середине** финализации `deploy-staging`/`deploy` (in-memory состояние +потеряно); процесс перезапущен (`requeue_running_jobs` + цикл). +- **PASS:** восстановление через durable-владение/эпоху + авторитетные факты (SHA-in-main ORCH-071/073, + маркер `INITIATED`) детерминированно сходится к **единственному** согласованному исходу: незавершённое + дорешается, **уже применённый** необратимый шаг (мерж/ratchet/прод-деплой) **не** применяется повторно. +- **FAIL:** после рестарта переход исполняется заново с двойным необратимым эффектом; либо возникает + противоречие rollback↔done; либо задача застревает нетерминальной с удержанным lease. + +--- + +## AC-7 — Reconciler F-1 пропускает переход при активном lease + +**Условие:** lease перехода активен; reconciler F-1 сканирует задачу. +- **PASS:** F-1 **defer/skip** (silent, наблюдаемо), не вызывает `advance_stage`, по образцу skip-guard'ов + escalated/Blocked/task-deps; fail-safe: неопределённость lease → консервативный skip. +- **FAIL:** F-1 продвигает стадию параллельно живому владельцу. + +--- + +## AC-8 — Webhook-путь пропускает переход при активном lease + +**Условие:** lease перехода активен; приходит Plane-webhook (Approved / Confirm Deploy) на ту же задачу. +- **PASS:** webhook-путь продвижения **defer**, не дублирует переход/финализацию при живом владельце; + поздний легитимный сигнал не теряется (повторно отработает после release или станет идемпотентным no-op). +- **FAIL:** webhook повторно входит в переход параллельно владельцу и даёт двойной эффект. + +--- + +## AC-9 — Kill-switch off → поведение байт-в-байт прежнее + +**Условие:** новый kill-switch выключен. +- **PASS:** lease не пишется/не читается; CAS вырождается в прежний безусловный `update_task_stage`; + reaper/reconciler/webhook/startup ведут себя как до ORCH-114; существующий `pytest tests/` зелёный + без правок ожиданий; enduro не затронут. +- **FAIL:** при выключенном флаге наблюдается любое отличие от до-ORCH-114 поведения. + +--- + +## AC-10 — never-raise + fail-open (hot-path) / fail-closed (prod-safety) + +**Условие:** механизм владения сталкивается с ошибкой (БД-сбой/повреждённая запись lease/исключение). +- **PASS:** ни одна публичная функция владения не роняет конвейер; горячий путь claim/guard — + **fail-open** (общая очередь всех проектов не клинится); решения, критичные для необратимости/прода — + **fail-closed**; на ошибке — WARNING + безопасный дефолт. +- **FAIL:** ошибка механизма роняет claim/конвейер; либо hot-path заклинивает очередь; либо + prod-критичное решение фейлит «открыто». + +--- + +## AC-11 — Инварианты конвейера не тронуты + +**Условие:** аудит диффа против `src/stages.py`, `src/qg/checks.py`, схемы БД. +- **PASS:** `STAGE_TRANSITIONS` / реестр `QG_CHECKS` / семантика и имена `check_*` / машинные + вердикт-ключи (`verdict:`/`result:`/`deploy_status:`/`staging_status:`/`security_status:`/ + `coverage_status:`) — **байт-в-байт**; любое новое хранилище аддитивно/идемпотентно + (`CREATE TABLE IF NOT EXISTS` / `_ensure_column`), схемы существующих таблиц неизменны. +- **FAIL:** изменены состав/порядок стадий, реестр/семантика гейтов, вердикт-ключи или существующие + столбцы/индексы. + +--- + +## AC-12 — Наблюдаемость + +**Условие:** `GET /queue` при включённом флаге. +- **PASS:** присутствует аддитивный read-only блок владения (держатели/возраст/defer-счётчики/реклеймы); + существующие ключи `/queue` не сломаны; форсированный/устаревший реклейм даёт Telegram-алерт с + кликабельным номером. +- **FAIL:** блок отсутствует/ломает существующий вывод; реклейм происходит молча без наблюдаемости. + +--- + +## AC-13 — Self-hosting безопасность + +**Условие:** аудит механизма владения на побочные действия. +- **PASS:** механизм **никогда** не рестартит прод-контейнер `orchestrator`, не пушит/force-push в + `main`, не трогает detached deploy-процесс; деплой орка по-прежнему только через staging-гейт (8501) + и `Confirm Deploy`. +- **FAIL:** механизм владения инициирует рестарт прода/мутацию `main`/вмешательство в detached-деплой. + +--- + +## Сводная матрица AC ↔ FR/BR/NFR + +| AC | Покрывает | Тип проверки | +|----|-----------|--------------| +| AC-1 | BR-1, BR-6, BR-8 / FR-1, FR-2 | integration (regression, red→green) | +| AC-2 | BR-2 / FR-2 | unit | +| AC-3 | BR-1 / FR-1 | unit | +| AC-4 | BR-3 / FR-3 | integration | +| AC-5 | BR-3, NFR-6 / FR-3 | unit/integration | +| AC-6 | BR-4, BR-6, NFR-4, NFR-7 / FR-1…FR-4 | integration (restart-recovery) | +| AC-7 | BR-5 / FR-5 | unit/integration | +| AC-8 | BR-5 / FR-5 | unit/integration | +| AC-9 | BR-9, NFR-8 / FR-7 | regression (kill-switch off) | +| AC-10 | NFR-1 / FR-7 | unit | +| AC-11 | NFR-3 / FR-6 (negative) | structural audit | +| AC-12 | BR-7 / FR-6 | unit/integration | +| AC-13 | NFR-5 / FR-1 | structural audit | diff --git a/docs/work-items/ORCH-114/04-test-plan.yaml b/docs/work-items/ORCH-114/04-test-plan.yaml new file mode 100644 index 0000000..8ffbaa1 --- /dev/null +++ b/docs/work-items/ORCH-114/04-test-plan.yaml @@ -0,0 +1,107 @@ +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 +title: "Ownership-lease для side-effectful переходов стадий + умное восстановление при старте" +framework: pytest +scope: > + Покрывается: единое владение side-effectful переходом (FR-1), CAS/epoch на запись стадии (FR-2), + осведомлённость reaper о живом/мёртвом владении на всех путях (FR-3), умное восстановление при + рестарте (FR-4), skip/defer в reconciler F-1 и webhook (FR-5), наблюдаемость (FR-6), kill-switch + и never-raise (FR-7). Вне покрытия: переход на uvicorn --workers>1, частные симптомы ORCH-110/112 + (уже закрыты и переиспользуются), изменение STAGE_TRANSITIONS/QG_CHECKS/check_*. +notes: > + TC-01 — ОБЯЗАТЕЛЬНЫЙ регресс класса инцидента ORCH-111: красный до фикса, зелёный после. + Все side-effectful вызовы (merge_pr / coverage-ratchet / image-rebuild / deploy-init) проверяются + через тест-двойники со счётчиком вызовов — без сети/реального git/прода/ssh. Restart-recovery + моделируется сбросом in-memory состояния + повторным прогоном стартового восстановления над durable + состоянием БД. Полный регресс tests/ должен оставаться зелёным; при выключенном kill-switch + поведение байт-в-байт прежнее. + +tests: + - id: TC-01 + type: integration + description: "ОБЯЗАТЕЛЬНЫЙ РЕГРЕСС. Два конкурентных входа в advance_stage(deploy-staging) одной задачи (живой финализатор + reaper/reconciler/webhook): каждый side-effectful шаг (merge_pr/ratchet/rebuild/deploy-init/enqueue) вызывается ровно один раз; персистится один согласованный исход; второй актор — lost-race/defer без побочных эффектов. Красный до фикса, зелёный после." + module: tests/test_orch114_transition_ownership.py + expected: PASS + + - id: TC-02 + type: unit + description: "CAS-запись стадии: первый writer применяет (rowcount=1), второй с устаревшим предусловием получает lost-race (rowcount=0) и не мутирует стадию (FR-2/AC-2)." + module: tests/test_orch114_transition_ownership.py + expected: PASS + + - id: TC-03 + type: unit + description: "Жизненный цикл владения: acquire на границе финализации; release в try/finally при нормальном завершении, при исключении и при откате (lease не течёт); durable-запись видна другому актору (FR-1/AC-3)." + module: tests/test_orch114_transition_ownership.py + expected: PASS + + - id: TC-04 + type: integration + description: "Reaper defer при живом владении на путях за пределами Tier-2/deploy-staging ORCH-113: повторный advance не выполняется, атомарный reap_running_job rowcount-guard сохранён, ведётся счётчик defer (FR-3/AC-4)." + module: tests/test_orch114_transition_ownership.py + expected: PASS + + - id: TC-05 + type: unit + description: "Reaper добивает мёртвое/устаревшее владение в пределах Tier-3 backstop reaper_max_running_s; маркер владения backstop не обходит; задача не клинится бессрочно (FR-3/AC-5/NFR-6)." + module: tests/test_orch114_transition_ownership.py + expected: PASS + + - id: TC-06 + type: integration + description: "Умное восстановление при рестарте: процесс убит в середине финализации, in-memory сброшен; стартовое восстановление над durable-состоянием + авторитетными фактами (SHA-in-main, INITIATED) сходится к единственному исходу без повторного необратимого эффекта (FR-4/AC-6)." + module: tests/test_orch114_transition_ownership.py + expected: PASS + + - id: TC-07 + type: integration + description: "Reconciler F-1 (advance_if_gate_passed) делает defer/skip при активном lease перехода; fail-safe: неопределённость lease → консервативный skip (FR-5/AC-7)." + module: tests/test_orch114_transition_ownership.py + expected: PASS + + - id: TC-08 + type: integration + description: "Webhook-путь (plane._try_advance_stage, Approved/Confirm Deploy) делает defer при активном lease; поздний легитимный сигнал не теряется (повтор после release / идемпотентный no-op) (FR-5/AC-8)." + module: tests/test_orch114_transition_ownership.py + expected: PASS + + - id: TC-09 + type: integration + description: "Kill-switch off: lease не пишется/не читается, CAS вырождается в прежний безусловный update_task_stage, reaper/reconciler/webhook/startup — байт-в-байт до ORCH-114; существующий pytest tests/ зелёный (FR-7/AC-9/NFR-8)." + module: tests/test_orch114_transition_ownership.py + expected: PASS + + - id: TC-10 + type: unit + description: "never-raise + fail-open/fail-closed: ошибка/повреждённая запись lease/исключение БД не роняют конвейер; hot-path claim/guard fail-open; prod-safety решение fail-closed; WARNING + безопасный дефолт (FR-7/AC-10/NFR-1)." + module: tests/test_orch114_transition_ownership.py + expected: PASS + + - id: TC-11 + type: unit + description: "Структурный аудит инвариантов: STAGE_TRANSITIONS / QG_CHECKS / имена-семантика check_* / вердикт-ключи байт-в-байт; новое хранилище аддитивно/идемпотентно (CREATE TABLE IF NOT EXISTS / _ensure_column), схемы существующих таблиц неизменны (NFR-3/AC-11)." + module: tests/test_orch114_transition_ownership.py + expected: PASS + + - id: TC-12 + type: integration + description: "Наблюдаемость: GET /queue несёт аддитивный read-only блок владения (держатели/возраст/defer/реклеймы), существующие ключи не сломаны; форсированный/устаревший реклейм даёт Telegram-алерт с кликабельным номером (FR-6/AC-12)." + module: tests/test_orch114_transition_ownership.py + expected: PASS + + - id: TC-13 + type: unit + description: "Self-hosting безопасность: механизм владения не инициирует рестарт прод-контейнера, не пушит/force-push в main, не трогает detached deploy-процесс (NFR-5/AC-13)." + module: tests/test_orch114_transition_ownership.py + expected: PASS + + - id: TC-14 + type: integration + description: "Полный регресс конвейера: pytest tests/ остаётся зелёным; deploy-staging-ребро и deploy-finalizer (Phase C) проходят при включённом механизме без двойных эффектов в одно-акторном happy-path (BR-8)." + module: tests/ + expected: PASS From cc03e68847b12369870313e36f73d70351b487d4 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 15 Jun 2026 17:02:18 +0300 Subject: [PATCH 3/9] 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), явно вне объёма. + From 6ea4402942e0055fa661297b863a2ae4589e7b29 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 15 Jun 2026 17:37:11 +0300 Subject: [PATCH 4/9] fix(stage-engine): durable transition-ownership lease + expected-stage CAS (ORCH-114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Close the root class of the ORCH-110/111/112/113 incident chain: side-effectful stage transitions had no single ownership. `advance_stage` is re-enterable and wrote the stage with a bare `UPDATE ... WHERE id=?` (no compare-and-swap), while >=5 actors (monitor / Plane-webhook / reconciler F-1 / job-reaper / deploy-finalizer) enter the same transition independently. A concurrent or post-restart re-entry therefore re-applied irreversible effects (merge_pr / coverage-ratchet / image-rebuild / prod-deploy initiation) and produced a contradictory rollback<->done (incident ORCH-111, job 1914 / PR #130). Two complementary layers, both additive, under one kill-switch, never-raise: 1. Durable transition-lease (new table `transition_lease`) — owner-exclusion on ENTRY to the side-effectful region: a second actor that sees a LIVE owner does not start the heavy sub-gates at all (prevention, not post-hoc repair). 2. Expected-stage CAS (`db.update_task_stage_cas`) — atomicity on the stage WRITE: a lost race aborts with NO side effect. Also closes the 6 paths that write the stage in bypass of advance_stage (gitea x5 + plane rollback). Owner liveness = owner_pid + owner_boot_id (NOT a heartbeat — a blocking 900s merge re-test cannot beat one; ADR-001 D3), making restart recovery free (a fresh boot_id renders every prior lease stale -> reclaimed by recover_on_startup). The lease has no own TTL: its hard age ceiling is the reaper Tier-3 backstop reaper_max_running_s, so the cross-cutting budget invariant ORCH-065/109/110/113 is untouched. Generalises ORCH-113 finalizer-liveness (process-local, Tier-2, deploy-staging) to a durable cross-path lease: the reaper consults it on all relevant paths (defer live, reclaim dead; Tier-3 ignores the marker -> bounded; a reap force-releases the lease); reconciler F-1 and the Plane webhook defer on an active lease; main.lifespan calls recover_on_startup() after requeue_running_jobs. finalizer_liveness.py is unchanged (it remains the kill-switch-off fallback). Scope self-hosting (transition_lease_repos="" -> orchestrator only; enduro untouched). Kill-switch ORCH_TRANSITION_LEASE_ENABLED=false -> CAS degenerates to the prior unconditional update_task_stage, lease inert, reaper -> ORCH-113 fallback (byte-for- byte pre-ORCH-114). STAGE_TRANSITIONS / QG_CHECKS / check_* / machine-verdict keys / existing table schemas — byte-for-byte (one additive table, no epoch column on tasks). Observability: read-only `transition_lease` block in GET /queue + a Telegram alert on forced/stale reclaim + optional POST /transition-lease/release?work_item=. Coverage: tests/test_orch114_transition_ownership.py (TC-01 mandatory regression of the ORCH-111 class — red before fix, green after; TC-02..TC-14). Full suite green (2048 passed); the 4 webhook tests that spied on the removed gitea.update_task_stage were updated to spy on the new commit_stage_cas write path. ADR: docs/work-items/ORCH-114/06-adr/ADR-001-transition-ownership-lease-and-stage-cas.md Cross-cutting: docs/architecture/adr/adr-0045-transition-ownership-lease-and-stage-cas.md Refs: ORCH-114 Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 3 + docs/architecture/README.md | 3 +- docs/architecture/internals.md | 23 +- src/config.py | 37 ++ src/db.py | 55 ++ src/job_reaper.py | 47 +- src/main.py | 58 ++ src/reconciler.py | 20 + src/stage_engine.py | 87 ++- src/transition_lease.py | 471 +++++++++++++++ src/webhooks/gitea.py | 124 ++-- src/webhooks/plane.py | 29 +- tests/conftest.py | 25 + tests/test_orch114_transition_ownership.py | 645 +++++++++++++++++++++ tests/test_webhooks.py | 46 +- 15 files changed, 1591 insertions(+), 82 deletions(-) create mode 100644 src/transition_lease.py create mode 100644 tests/test_orch114_transition_ownership.py diff --git a/CHANGELOG.md b/CHANGELOG.md index ccc2783..99553ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу. ## [Unreleased] +- **Ownership-lease для side-effectful переходов стадий + умное восстановление при старте** (ORCH-114, `fix`, bug→escalate full-cycle): закрыт **корневой класс** инцидент-цепочки ORCH-110/111/112/113 — у side-effectful переходов стадий не было единого владения. `advance_stage` ре-ентерабельна и пишет стадию «голым» `UPDATE … WHERE id=?` (без compare-and-swap), а ≥5 акторов (монитор / Plane-webhook / reconciler F-1 / job-reaper / deploy-finalizer) входят в один переход независимо → конкурентный или после-рестартовый повторный вход **дважды** применял необратимые эффекты (merge_pr / coverage-ratchet / image-rebuild / инициация прод-деплоя) и давал **противоречие rollback↔done** (инцидент ORCH-111, job 1914 / PR #130). Два комплементарных слоя, оба аддитивные, под единым kill-switch, never-raise: **(1) durable transition-lease** (новая таблица `transition_lease`) — владение на ВХОДЕ в side-effectful регион (второй актор, увидев живого владельца, не стартует тяжёлые под-гейты вовсе — предотвращение, не починка постфактум); **(2) expected-stage CAS** (`update_task_stage_cas`) — на ЗАПИСИ стадии (проигравший гонку — аборт без побочных эффектов), что закрывает и **6 путей записи стадии в обход `advance_stage`** (gitea×5 + plane rollback). Liveness владельца = `owner_pid` + `owner_boot_id` (НЕ heartbeat: блокирующий 900s merge re-test не может бить heartbeat — довод самого ORCH-113), что делает рестарт-recovery бесплатным (новый процесс → новый boot-id → все прежние lease мгновенно устаревшие → реклеймятся). Lease без собственного TTL: его потолок возраста = Tier-3 backstop `reaper_max_running_s` (5400) → сквозной бюджет ORCH-065/109/110/113 не тронут. `STAGE_TRANSITIONS` / реестр `QG_CHECKS` / семантика и имена `check_*` / machine-verdict-ключи / **схемы существующих таблиц** — байт-в-байт (одна аддитивная таблица, без epoch-колонки на `tasks`). Скоуп self-hosting (`transition_lease_repos=""` → только `orchestrator`; enduro не затронут); kill-switch `ORCH_TRANSITION_LEASE_ENABLED=false` → CAS вырождается в прежний безусловный `update_task_stage`, lease инертен → поведение байт-в-байт до ORCH-114. ADR: `docs/work-items/ORCH-114/06-adr/ADR-001-transition-ownership-lease-and-stage-cas.md`, сквозной `docs/architecture/adr/adr-0045-transition-ownership-lease-and-stage-cas.md`. + - **Leaf `src/transition_lease.py` (новый, чистый never-raise):** по образцу `serial_gate`/`coverage_gate`/`finalizer_liveness` (импортирует только `db`+`config`, лениво `merge_gate.pid_alive`/`qg.checks`/`notifications`; НЕ импортирует `stage_engine`/`launcher`) — `applies(repo)` / `acquire(task_id, owner, run_id, stage)` (атомарный rowcount-guard `INSERT … ON CONFLICT DO NOTHING` после очистки stale-строки) / `is_held_by_live_owner(task_id)` (fail-closed → defer на сомнении) / `release(task_id, force=False)` (holder-aware по boot) / `reclaim_if_stale` / `recover_on_startup` / `commit_stage_cas(task_id, expected, new, repo)` (flag-off → unconditional `update_task_stage`; flag-on → CAS) / `snapshot()`. + - **Интеграция:** `advance_stage` захватывает lease на входе в side-effectful ребро (`deploy-staging`/`deploy`), пишет стадию через CAS, освобождает lease в `try/finally` (на любом исходе, включая исключение/откат); job-reaper `_finalizer_owns` обобщён с процесс-локального ORCH-113 (Tier-2/`deploy-staging`) на **durable cross-path** lease (defer при живом владельце; Tier-3 backstop игнорирует маркер → bounded reclaim; реап force-освобождает lease); reconciler F-1 и Plane-webhook (`_try_advance_stage`) делают **defer** при активном lease; `main.lifespan` зовёт `recover_on_startup()` после `requeue_running_jobs`. Наблюдаемость — read-only блок `transition_lease` в `GET /queue` + Telegram-алерт на форсированный/устаревший реклейм + опциональный `POST /transition-lease/release?work_item=`. Покрытие — `tests/test_orch114_transition_ownership.py` (TC-01 обязательный регресс класса ORCH-111: красный до фикса, зелёный после; TC-02…TC-14). Флаги (`config.py`, дефолт = боевое): `transition_lease_enabled` (env `ORCH_TRANSITION_LEASE_ENABLED`), `transition_lease_repos` (env `ORCH_TRANSITION_LEASE_REPOS`). - **Гигиена shared deploy-базы: устойчивый self-deploy `git pull` к грязному дереву** (ORCH-112, `fix`, bug→escalate full-cycle): устранён инцидент ORCH-111 — self-deploy падал на шаге `git pull origin main` хост-хука с `error: Your local changes to the following files would be overwritten by merge: src/config.py` (грязь от неуспешной/отменённой/брошенной задачи ORCH-104 в общем main checkout) → деплой вставал → ручное вмешательство (на self-hosting — групповой риск). Решение — **resilient-pull, встроенный в прод-deploy-хук** (`--deploy`): перед `git pull` хук при обнаружении грязи приводит deploy-базу к чистому актуальному `origin/main` (`git fetch` + `git reset --hard origin/main` + **скоупленный** `git clean -fd`). Аддитивно, под kill-switch, never-raise, скоуп self-hosting; `STAGE_TRANSITIONS` / реестр `QG_CHECKS` / семантика и имена `check_*` / machine-verdict-ключи / схема БД / exit-code-контракт хука (0/1/2, ORCH-036) — **байт-в-байт не тронуты** (это устойчивость deploy-пути, **не** Quality Gate и **не** стадия). ADR: `docs/work-items/ORCH-112/06-adr/ADR-001-deploy-base-checkout-hygiene.md`, сквозной `docs/architecture/adr/adr-0044-deploy-base-checkout-hygiene.md`. - **Leaf `src/checkout_hygiene.py` (новый, чистый never-raise):** по образцу `serial_gate`/`cancel`/`self_deploy` (импортирует только `config`, лениво `self_deploy`/`qg.checks`/`notifications`) — `applies(repo)` (kill-switch `checkout_hygiene_enabled` + скоуп `checkout_hygiene_repos`, пусто → self-hosting only, локально и ПЕРВЫМ), `hook_env(repo, work_item_id)` (env-префикс `CHECKOUT_HYGIENE=1 HYGIENE_REPORT=`, инжектится в detached-команду хука только при `applies==True`, иначе `""` → голый pull 1:1), `read_report`/`alert_dirty` (наблюдаемость), `snapshot()` (read-only блок `GET /queue`). - **Хук-блок «2a. Resilient pull» (`scripts/orchestrator-deploy-hook.sh`):** между шагом «1. Capture PREV_IMG» и «2. Pull», под `if [[ "${CHECKOUT_HYGIENE:-0}" == "1" ]]`. **Сохранность (NFR-2, жёсткий контракт):** `git clean` — **только `-fd`, НИКОГДА `-x`** (иначе удалил бы gitignored `.env`/прод-секреты, `data/*.db`/БД, `build/`); явные `-e '.deploy-prev-image-*'` и `-e 'deploy-hook.log'` (untracked-но-НЕ-ignored — иначе сломался бы rollback `do_rollback`); sibling `/.deploy-state-*`/`.merge-lease-*.json` (под родителем `$REPO`) и `.git/worktrees/*` (внутри `.git/`) — вне области `git clean` в `$REPO`. Каждый git-шаг — `|| log "...continuing"` (never-break): сбой гигиены не ухудшает исход относительно текущего голого pull; на чистой базе блок — no-op (happy-path и exit-коды байт-в-байт). `--build-staging` (build из worktree, без pull) не затронут. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 2bd73a4..91f73dc 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -12,6 +12,7 @@ - **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`. `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схемы существующих таблиц — байт-в-байт. Подробнее ниже (§ «Единое владение side-effectful переходами»). Детали — `docs/work-items/ORCH-114/06-adr/ADR-001-transition-ownership-lease-and-stage-cas.md`. - **Reconciler** (`src/reconciler.py`, ORCH-053 — реализовано, [adr-0007](adr/adr-0007-reconciler.md)) — фоновый daemon-поток (паттерн `queue_worker`), стартует/останавливается в `main.lifespan` (после `worker.start()` / перед `worker.stop()`). Реконсилирует рассинхрон «источник истины ≠ стадия задачи» при потерянном webhook. F-1 gate-side (продвигает застрявшую стадию по локальной БД через штатный `advance_stage(..., finished_agent=None)`), F-2 plane-side (опрос Plane API → `handle_*` из `plane.py`), F-3 (БД-fallback `sha→branch` в `handle_ci_status`). Источник истины — гейт/Plane, не событие; идемпотентность (active-job guard + atomic-claim + grace); kill-switch `ORCH_RECONCILE_ENABLED`. `analysis` F-1 не трогает (человеческий гейт). F-1 также пропускает escalated (retry≥лимита) и Blocked/Needs-Input задачи (ORCH-060). Наблюдаемость — блок `reconcile` в `GET /queue`. - **Disk-watchdog** (`src/disk_watchdog.py`, ORCH-063 — [adr-0024](adr/adr-0024-disk-watchdog.md)) — фоновый daemon-поток (каркас `reconciler`/`job_reaper`), стартует/останавливается в `main.lifespan` (старт последним — после `reaper.start()`; стоп первым в reverse-порядке; гард `disk_monitor_enabled`). Каждые `disk_monitor_interval_s` (дефолт 300с) меряет заполнение **хост-ФС** по смонтированным bind-путям (`/repos`, `/app/data`) через stdlib `shutil.disk_usage` (не overlay `/` контейнера, не субпроцесс `df`; дедуп путей по `st_dev`). Решение об алерте — pure-функция `decide_action(used_pct, threshold, prev_state, now, realert_s)`: алерт на пересечении порога (дефолт **85%**), cooldown-повтор `disk_monitor_realert_s` (анти-спам, не на каждом тике), однократный recovery при возврате ниже порога. Алерт — `send_telegram` (notifying, best-effort). Состояние анти-спама — in-memory (без миграции БД). never-raise (per-path/per-tick/per-send); только читает и уведомляет — не трогает диск/контейнер, не рестартит прод (self-hosting безопасность). Kill-switch `ORCH_DISK_MONITOR_ENABLED`; снимок — блок `disk_monitor` в `GET /queue` (`enabled`/`threshold_pct`/`interval_s`/`realert_s`/`paths`[`used_pct`/`free_gb`/`alerting`/`last_alert_at`]). `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД — не тронуты. Детали — `docs/work-items/ORCH-063/06-adr/ADR-001-disk-watchdog.md`. - **Build-cache-pruner** (`src/build_cache_pruner.py`, ORCH-062 — [adr-0025](adr/adr-0025-build-cache-pruner.md)) — фоновый daemon-поток (каркас `disk_watchdog`), стартует/останавливается в `main.lifespan` (старт последним — после `disk_watchdog.start()`; стоп первым в reverse; гард `build_cache_prune_enabled`). «Вторая половина» disk-watchdog: **watchdog сигналит — pruner убирает**. Каждые `build_cache_prune_interval_s` (дефолт 21600с = 6ч) выполняет **строго `docker builder prune -f --filter until=`** (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`. @@ -1255,7 +1256,7 @@ 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) +### Единое владение side-effectful переходами: durable transition-lease + stage-CAS (ORCH-114 — реализовано) Корневой класс инцидент-цепочки ORCH-110/111/112/113: **у side-effectful переходов стадий нет единого владения**. `db.update_task_stage` — голый `UPDATE … WHERE id=?` без CAS; `advance_stage` ре-ентерабельна и исполняет минуты-длинные необратимые под-гейты diff --git a/docs/architecture/internals.md b/docs/architecture/internals.md index 2f68c44..daa79f2 100644 --- a/docs/architecture/internals.md +++ b/docs/architecture/internals.md @@ -188,6 +188,21 @@ CREATE TABLE events ( payload TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); + +-- ORCH-114 (adr-0045): durable transition-ownership lease. ОДНА аддитивная таблица +-- (CREATE TABLE IF NOT EXISTS, паттерн repo_freeze/coverage_baseline/lessons) — одна +-- строка = ≤1 активный владелец side-effectful перехода задачи. Живость владельца = +-- owner_boot_id (нонс старта процесса; рестарт ⇒ смена ⇒ прежний lease мёртв) + +-- pid_alive(owner_pid). БЕЗ epoch/version-колонки на tasks (стадия = версия CAS). +CREATE TABLE transition_lease ( + task_id INTEGER PRIMARY KEY, + owner TEXT NOT NULL, -- monitor|reaper|reconciler|webhook|finalizer|engine + owner_pid INTEGER, + owner_boot_id TEXT, + run_id INTEGER, + stage TEXT, -- from-стадия захвата (контекст/наблюдаемость) + acquired_at TEXT DEFAULT (datetime('now')) +); ``` ## Deployment @@ -369,7 +384,13 @@ status='queued'` и проверяет `rowcount`. При гонке двух т В `main.py` lifespan **после** M-1 orphan-recovery вызывается `requeue_running_jobs()`: jobs со статусом `running` (воркер умёр на рестарте) → возвращаются в `queued`. -Потом стартует воркер; на shutdown — `worker.stop()` (Event.set + join). +**ORCH-114 (adr-0045):** сразу следом вызывается `transition_lease.recover_on_startup()` — +новый процесс имеет свежий `boot_id`, поэтому ВСЕ записанные ранее `transition_lease` +устарели (boot-id mismatch) → реклеймятся, и только что requeued-jobs переисполняют свои +side-effectful переходы **последовательно** (один владелец), без двойного необратимого +эффекта. Идемпотентность самого re-drive обеспечивают существующие авторитетные факты +(SHA-in-main ORCH-071/073, маркер `INITIATED` ORCH-036, coverage-ratchet CAS ORCH-027) — +НЕ новый recovery-мозг. Потом стартует воркер; на shutdown — `worker.stop()` (Event.set + join). ### Job-reaper (ORCH-065, рестарт НЕ требуется) diff --git a/src/config.py b/src/config.py index d142ba1..a21b7c8 100644 --- a/src/config.py +++ b/src/config.py @@ -590,6 +590,43 @@ class Settings(BaseSettings): lease_reclaim_enabled: bool = True reaper_finalizer_liveness_enabled: bool = True + # ORCH-114 (adr-0045): durable transition-ownership lease + expected-stage CAS for + # side-effectful stage transitions. Generalises the process-local ORCH-113 + # finalizer-liveness to a DURABLE, cross-path owner-exclusion (additive table + # `transition_lease`) so a concurrent OR post-restart re-entry into a side-effectful + # transition (reaper / reconciler / webhook / startup-requeue) is deferred or a + # no-op instead of re-applying an irreversible effect (merge_pr / coverage-ratchet / + # image-rebuild / prod-deploy initiation / contradictory rollback↔done). Two + # complementary layers, both gated by the SINGLE kill-switch below: + # (1) durable lease on ENTRY to the side-effectful region (a second actor seeing a + # live owner does not start the heavy sub-gates at all — prevention, not repair); + # (2) expected-stage CAS on the stage WRITE (update_task_stage_cas: a lost race -> + # abort with NO side effect), which also closes the 6 paths that write the + # stage in bypass of advance_stage (gitea/plane direct update_task_stage). + # Liveness of the owner = owner_pid + owner_boot_id (NOT a heartbeat — a blocking + # 900s merge re-test cannot beat a heartbeat; ADR-001 D3), which makes restart + # recovery free (a new process -> new boot_id -> all prior leases are instantly + # stale -> reclaimed). The lease has NO own TTL: its hard age ceiling IS the reaper + # Tier-3 backstop reaper_max_running_s (5400), so the cross-cutting budget invariant + # ORCH-065/109/110/113 is untouched. STAGE_TRANSITIONS / QG_CHECKS / check_* / + # machine-verdict keys / existing table schemas — byte-for-byte. never-raise: + # hot-path guard fail-open (never wedge the shared queue), prod-safety fail-closed. + # See docs/work-items/ORCH-114/06-adr/ADR-001-transition-ownership-lease-and-stage-cas.md + # and the cross-cutting docs/architecture/adr/adr-0045-…md. + # transition_lease_enabled -> SINGLE kill-switch (env ORCH_TRANSITION_LEASE_ENABLED). + # False -> the lease is neither written nor read AND the + # CAS degenerates to the prior unconditional + # update_task_stage -> behaviour byte-for-byte as before + # ORCH-114 (reaper -> ORCH-113 in-memory fallback, + # reconciler/webhook skip-guard inert). Default True. + # transition_lease_repos -> CSV scope (env ORCH_TRANSITION_LEASE_REPOS). Empty -> + # applies ONLY to the self-hosting repo (orchestrator), + # where the irreversible side-effectful edges live; + # non-empty -> only the listed repos. Mirrors + # coverage_gate_repos -> enduro untouched at the default. + transition_lease_enabled: bool = True + transition_lease_repos: str = "" + # ORCH-063: disk-watchdog — background heartbeat that measures host-FS fill via # the mounted bind-paths and Telegram-alerts the operator at >= threshold. On # 07.06.2026 the mva154 host disk silently hit 100% and stalled the WHOLE diff --git a/src/db.py b/src/db.py index a158985..8bf1c1f 100644 --- a/src/db.py +++ b/src/db.py @@ -263,6 +263,28 @@ def init_db(): _ensure_column(conn, "lessons", "attribution", "TEXT") _ensure_column(conn, "lessons", "target_repo", "TEXT") _ensure_column(conn, "lessons", "target_domain", "TEXT") + # ORCH-114 (adr-0045 / 08-data-requirements.md): durable transition-ownership + # lease. ONE additive object (CREATE TABLE IF NOT EXISTS, pattern repo_freeze/ + # coverage_baseline/lessons) -> idempotent, restart-safe on the shared prod DB; + # existing tables (tasks/jobs/agent_runs/...) untouched byte-for-byte (NFR-3, + # AC-11). One row per task = at most one active owner of a side-effectful + # transition. Liveness of the holder = owner_boot_id (this process's start nonce) + # + owner_pid (os.getpid of the holding process); a row from a previous boot is + # instantly stale on restart -> reclaimed (ADR-001 D3). No index needed (access by + # PK task_id; snapshot() is a full-scan over a tiny table). The src/transition_lease.py + # leaf wraps all access in its never-raise contract. NO epoch/version column (D2: + # for the one-process model the stage IS the CAS version). + conn.executescript(""" + CREATE TABLE IF NOT EXISTS transition_lease ( + task_id INTEGER PRIMARY KEY, + owner TEXT NOT NULL, + owner_pid INTEGER, + owner_boot_id TEXT, + run_id INTEGER, + stage TEXT, + acquired_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + """) conn.commit() conn.close() @@ -679,6 +701,39 @@ def update_task_stage(task_id: int, stage: str): conn.close() +def update_task_stage_cas(task_id: int, expected_stage: str, new_stage: str) -> bool: + """ORCH-114 (adr-0045 / FR-2): compare-and-swap variant of update_task_stage. + + Writes the stage ONLY when the task is still at ``expected_stage`` (the value the + caller read before running the side-effectful region) — ``UPDATE … SET stage=? + WHERE id=? AND stage=?`` — and reports whether THIS writer won. Returns: + + * ``True`` -> ``rowcount == 1``: the CAS succeeded, the stage moved exactly once. + * ``False`` -> ``rowcount == 0``: the task is no longer at ``expected_stage`` + (another actor already advanced/rolled it back, or the row is gone) -> the + caller MUST abort WITHOUT applying any side effect (merge_pr / ratchet / + rebuild / deploy-init / enqueue) — it lost the race. + + In the current one-process model each side-effectful edge leads to a DISTINCT + next stage, so the stage itself is a complete version for the compare-and-swap; + no separate epoch/version column is needed (ADR-001 D2). The plain + ``update_task_stage`` above is kept unchanged for the kill-switch-off path and + for non-side-effectful writes. Mirrors the atomic rowcount-guard idiom of + ``claim_next_job`` / ``reap_running_job``. + """ + conn = get_db() + try: + cur = conn.execute( + "UPDATE tasks SET stage = ?, updated_at = datetime('now') " + "WHERE id = ? AND stage = ?", + (new_stage, task_id, expected_stage), + ) + conn.commit() + return cur.rowcount == 1 + finally: + conn.close() + + # --------------------------------------------------------------------------- # ORCH-019: bug-fast-track task type (tasks.track) helpers # --------------------------------------------------------------------------- diff --git a/src/job_reaper.py b/src/job_reaper.py index 970cb28..3d790cb 100644 --- a/src/job_reaper.py +++ b/src/job_reaper.py @@ -434,18 +434,35 @@ class JobReaper: return None, None, None def _finalizer_owns(self, job: dict) -> bool: - """ORCH-113 (adr-0043 / D3): True iff a LIVE monitor still owns this job's - ``deploy-staging`` finalization, so the Tier-2 reap must be deferred. + """True iff a LIVE actor still owns this job's side-effectful finalization, so + the Tier-2 reap must be deferred. - Order matters for the zero-regression contract: the kill-switch is checked - FIRST (disabled -> ``False`` with no DB lookup, so the path is byte-for-byte - prior); then the stage is scoped to ``deploy-staging`` only (the sole edge - whose in-thread finalization runs for minutes — every other stage is left - untouched); only then is the process-local ownership marker consulted. Never - raises -> ``False`` on any error (conservative: never block reaping when - ownership is unknowable, so the Tier-3 backstop is never neutered). + ORCH-114 (adr-0045 / D6) GENERALISES the ORCH-113 process-local, Tier-2, + ``deploy-staging``-only marker to a DURABLE, cross-path lease: when the + transition-lease applies to this repo, consult ``transition_lease`` keyed on + the task (covers EVERY relevant edge — deploy-staging AND deploy->done — and + survives restart). Otherwise (kill-switch off) fall back to the unchanged + ORCH-113 in-memory ``finalizer_liveness`` (Tier-2 / ``deploy-staging`` only), + so the disabled path is byte-for-byte prior. + + Either way the Tier-3 backstop (``reaper_max_running_s``) IGNORES this marker + (it does not call here), so a stuck/dead finalizer is still reaped in bounded + time. Never raises -> ``False`` on any error (conservative: never block reaping + when ownership is unknowable, so the backstop is never neutered). """ try: + repo = job.get("repo") + # ORCH-114: durable cross-path lease (when enabled for this repo). + try: + from . import transition_lease + if transition_lease.applies(repo): + return transition_lease.is_held_by_live_owner(job.get("task_id")) + except Exception as e: # noqa: BLE001 - fall back to ORCH-113 on any error + logger.warning( + "reaper: transition-lease check failed for job %s: %s", + job.get("id"), e, + ) + # ORCH-113 fallback (kill-switch off): process-local, Tier-2/deploy-staging. if not settings.reaper_finalizer_liveness_enabled: return False _branch, stage, _wid = self._task_meta(job) @@ -472,6 +489,18 @@ class JobReaper: def _note_reap(self, job: dict, outcome: str, reason: str) -> None: """Record + log one successful reap (Р-6 observability).""" + # ORCH-114 (adr-0045 / D6): a reap reclaims the job, so its durable + # transition-lease must NOT outlive it — force-release (any owner/boot) so a + # requeued job can re-acquire cleanly. never-raise; no-op when the lease is + # disabled / no row exists. + try: + from . import transition_lease + transition_lease.release(job.get("task_id"), force=True) + except Exception as e: # noqa: BLE001 - never break the reap + logger.warning( + "reaper: transition-lease force-release failed for job %s: %s", + job.get("id"), e, + ) self.reaped_total += 1 self.last_reaped = { "job_id": job.get("id"), diff --git a/src/main.py b/src/main.py index f47fdad..5790877 100644 --- a/src/main.py +++ b/src/main.py @@ -60,6 +60,25 @@ async def lifespan(app: FastAPI): if requeued: log.warning(f"Queue-recovery: requeued {requeued} running job(s) after restart") + # ORCH-114 (adr-0045 / D7 / FR-4): clear durable transition-leases left by the + # PREVIOUS process boot. This process has a fresh boot_id, so every prior lease is + # stale by construction -> reclaim it so the just-requeued jobs can re-drive their + # side-effectful transitions cleanly. Idempotency of the re-drive comes from the + # authoritative durable facts (SHA-in-main / the INITIATED self-deploy marker / + # the coverage-ratchet CAS), NOT from a new recovery brain — the lease only + # guarantees the re-drive runs SEQUENTIALLY (one owner), never concurrently. Runs + # AFTER requeue_running_jobs and BEFORE the reaper starts. never raises. + try: + from . import transition_lease + cleared_leases = transition_lease.recover_on_startup() + if cleared_leases: + log.warning( + f"Transition-lease recovery: cleared {cleared_leases} stale lease(s) " + f"from a previous boot" + ) + except Exception as e: + log.warning(f"Transition-lease recovery skipped: {e}") + # ORCH-065: proactive startup reclaim of dead/stale merge-leases, next to the # queue-recovery above. A lease held by the previous (now dead) process pid is # released at once instead of waiting for the TTL / a foreign acquire so the @@ -215,6 +234,7 @@ async def queue(): from . import bug_fast_track from . import lessons from . import checkout_hygiene + from . import transition_lease from .disk_watchdog import disk_watchdog from .build_cache_pruner import build_cache_pruner return { @@ -258,6 +278,11 @@ async def queue(): # ORCH-112 (D3): deploy-base checkout-hygiene observability (read-only) — # kill-switch + scope. Additive block; never-raise. "checkout_hygiene": checkout_hygiene.snapshot(), + # ORCH-114 (adr-0045 / D10 / FR-6): durable transition-ownership lease + # observability (read-only) — kill-switch, scope, boot_id, active holders + # (owner/stage/age/live) + defer/reclaim/CAS-lost counters. Additive block; + # never-raise. + "transition_lease": transition_lease.snapshot(), # ORCH-098 (FR-4 / AC-4): lessons-journal observability (read-only) — # kill-switch + counts by type/status + last N lessons. Additive block; # never-raise (snapshot() returns {"enabled": ...} minimum on error). @@ -324,6 +349,39 @@ async def serial_gate_unfreeze(repo: str = ""): return {"ok": True, "repo": repo, "cleared": cleared, "frozen": frozen} +@app.post("/transition-lease/release") +async def transition_lease_release(work_item: str = ""): + """ORCH-114 (adr-0045 / D10): operator manual reclaim of a stuck transition-lease. + + By образцу ``POST /serial-gate/unfreeze``: if a lease somehow outlives its owner + (the normal try/finally release + the reaper force-release + the Tier-3 backstop + should make this unnecessary), an operator can force-release it by work-item id so + a re-approve / the reconciler can re-drive the transition. Idempotent: releasing a + free task reports ``released: false``. Read-only/never-raise otherwise. + """ + from . import transition_lease + from .db import get_task_by_work_item_id + if not work_item or not work_item.strip(): + return {"ok": False, "error": "missing 'work_item'", "work_item": work_item} + work_item = work_item.strip() + task = get_task_by_work_item_id(work_item) + if not task: + return {"ok": False, "error": "task not found", "work_item": work_item} + task_id = task["id"] + held_before = transition_lease.is_held_by_live_owner(task_id) + transition_lease.release(task_id, force=True) + if held_before: + try: + from .notifications import send_telegram, link_for + send_telegram( + f"🔓 {link_for(work_item)}: transition-lease сброшен вручную " + f"(task {task_id}). Переход может быть пере-исполнен." + ) + except Exception: + pass + return {"ok": True, "work_item": work_item, "task_id": task_id, "released": held_before} + + @app.post("/fs-normalize/check") async def fs_normalize_check(normalize: bool = False): """ORCH-057 (D6 / AC-4): force a fresh legacy-ownership detect (bypass the TTL diff --git a/src/reconciler.py b/src/reconciler.py index 315cb6d..dcc8d5a 100644 --- a/src/reconciler.py +++ b/src/reconciler.py @@ -70,6 +70,7 @@ from .webhooks.plane import handle_status_start, handle_verdict from .notifications import send_telegram, link_for from . import projects from . import task_deps +from . import transition_lease logger = logging.getLogger("orchestrator.reconciler") @@ -153,6 +154,10 @@ class Reconciler: # ORCH-068 observability: terminal-state skips and dedup suppressions. self.skipped_terminal_total: int = 0 self.deduped_total: int = 0 + # ORCH-114 (adr-0045 / FR-5): F-1 advances deferred because a live actor owns + # the task's side-effectful transition (transition-lease active). Reset on + # restart (safe: a live lease is itself recovered/reclaimed on restart). + self.transition_lease_defers_total: int = 0 # ORCH-068 (TR-3): in-memory dedup guard {issue_id -> last unblocked # state uuid}. Best-effort (resets on restart, like unblocked_total); # suppresses a repeat unblock notification for the same issue+state. @@ -246,6 +251,19 @@ class Reconciler: if cyc: task_deps.handle_cycle(cyc) return + # ORCH-114 (adr-0045 / FR-5, AC-7): a live actor already owns this task's + # side-effectful transition -> F-1 must NOT advance it in parallel. Silent + # defer (mirrors the escalated/Blocked/task-deps skip-guards above); the owner + # finishes the transition or, on death, the reaper reclaims it in bounded time. + # fail-safe: is_held_by_live_owner is conservative (True on doubt -> defer). + # never raises; no-op (False) when the lease is disabled / repo out of scope. + if transition_lease.is_held_by_live_owner(task_id): + self.transition_lease_defers_total += 1 + logger.debug( + f"reconciler F-1: task {task_id} has an active transition-lease — " + f"deferring advance to its owner" + ) + return result = advance_if_gate_passed( task_id, stage, @@ -596,6 +614,8 @@ class Reconciler: # ORCH-068 observability. "skipped_terminal_total": self.skipped_terminal_total, "deduped_total": self.deduped_total, + # ORCH-114 observability: F-1 advances deferred to a live lease owner. + "transition_lease_defers_total": self.transition_lease_defers_total, } diff --git a/src/stage_engine.py b/src/stage_engine.py index ae6c8ea..a2a38ce 100644 --- a/src/stage_engine.py +++ b/src/stage_engine.py @@ -41,6 +41,7 @@ from . import self_deploy from . import post_deploy from . import labels from . import bug_fast_track +from . import transition_lease from .notifications import ( notify_stage_change, notify_qg_failure, @@ -173,6 +174,20 @@ def developer_retry_count(task_id: int) -> int: _developer_retry_count = developer_retry_count +def _is_side_effectful_edge(current_stage: str | None, next_stage: str | None) -> bool: + """ORCH-114 (adr-0045 D4): does this ``advance_stage`` edge run IRREVERSIBLE work + that must be owned by exactly one actor (lease on entry)? + + * ``deploy-staging`` (-> deploy): the heavy edge sub-gates (security / merge-gate + re-test / coverage / image-freshness rebuild) + Phase A. + * ``deploy`` (-> done OR Phase B): merge_pr / coverage-ratchet / proof-of-merge, + or the detached prod-deploy initiation (confirm_deploy). + Every other edge (created -> … -> testing) is reversible and is protected by the + CAS-on-write alone (no lease). Pure, never raises. + """ + return current_stage in ("deploy-staging", "deploy") + + def advance_stage( task_id: int, current_stage: str, @@ -210,6 +225,12 @@ def advance_stage( """ result = AdvanceResult(from_stage=current_stage) agent = finished_agent + # ORCH-114 (adr-0045): set True once we acquire the durable transition-lease on a + # side-effectful edge, so the finally below ALWAYS releases it (on success, on a + # lost CAS, on a sub-gate rollback, and on ANY exception caught by the outer + # except). Released holder-aware (this process only) so a reaper reclaim + reacquire + # in between is never clobbered. + _lease_held = False try: qg_name = get_qg_for_stage(current_stage) next_stage = get_next_stage(current_stage) @@ -240,6 +261,28 @@ def advance_stage( result.note = "terminal" return result + # --- ORCH-114 transition-ownership lease: acquire on ENTRY (ADR-001 D5) ---- + # On a side-effectful edge (deploy-staging / deploy) acquire the DURABLE + # owner-exclusion lease BEFORE the Phase B / sub-gate / merge-verify region. A + # second concurrent actor (reaper / reconciler / webhook / a re-driven startup + # job) that sees a live owner gets a clean "busy" defer here and does NOT start + # the heavy region at all — this is what kills the double-effect class + # (incident ORCH-111) at the root. Released in the `finally` below. Kill-switch + # off / repo out of scope -> applies() False -> no lease, byte-for-byte prior. + if _is_side_effectful_edge(current_stage, next_stage) and transition_lease.applies(repo): + if not transition_lease.acquire( + task_id, finished_agent or "engine", run_id=None, stage=current_stage + ): + logger.info( + f"Task {task_id}: transition-lease busy on " + f"{current_stage}->{next_stage} — deferring (another actor owns " + f"this transition)" + ) + result.note = "transition-lease-busy" + result.advanced = False + return result + _lease_held = True + # --- ORCH-036/059 Phase B: "Confirm Deploy" on `deploy` -> initiate ---- # ORCH-059: the prod-deploy trigger is now the DEDICATED "Confirm Deploy" # status (confirm_deploy=True), NOT the overloaded "Approved". On the @@ -399,7 +442,23 @@ def advance_stage( return result # --- Advance --------------------------------------------------------- - update_task_stage(task_id, next_stage) + # ORCH-114 (adr-0045 / FR-2): expected-stage compare-and-swap. Writes the + # stage only if the task is STILL at current_stage (the value we read on + # entry); a lost race (another writer advanced/rolled back first) returns + # False -> abort here WITHOUT any side effect (no notify / no arm / no + # terminal-sync / no enqueue). Kill-switch off / repo out of scope -> + # degenerates to the prior unconditional update_task_stage (returns True) -> + # byte-for-byte prior behaviour. Defense-in-depth: under the lease acquired + # above this CAS practically always wins; it also covers the narrow + # consult->acquire window and any bypass writer (TR-5). + if not transition_lease.commit_stage_cas(task_id, current_stage, next_stage, repo): + logger.info( + f"Task {task_id}: stage-CAS lost on {current_stage}->{next_stage} — " + f"aborting without side effects (another writer advanced first)" + ) + result.note = "stage-cas-lost" + result.advanced = False + return result # Telegram live tracker: the analysis->architecture advance is the human # Approved gate clearing -> stamp the END of "Ревью БРД" (the only # human time). Idempotent: only the first stamp counts. @@ -510,6 +569,16 @@ def advance_stage( logger.error(f"advance_stage failed for task_id={task_id}: {e}") result.note = f"error: {e}" return result + finally: + # ORCH-114 (adr-0045 / AC-3): release the transition-lease on EVERY exit — + # normal advance, lost CAS, sub-gate rollback, Phase A/B early return, and any + # exception caught above — so the lease never "leaks" and wedges the task. + # holder-aware (force=False): only releases a row this process owns. + if _lease_held: + try: + transition_lease.release(task_id) + except Exception as e: # noqa: BLE001 - never-raise (Tier-3 backstop bounds it) + logger.warning(f"Task {task_id}: transition-lease release failed: {e}") def advance_if_gate_passed( @@ -1482,7 +1551,21 @@ def _handle_self_deploy_phase_a( restart-safe `approve-requested` marker records that Phase A ran. The merge lease stays HELD. """ - update_task_stage(task_id, "deploy") + # ORCH-114 (adr-0045 / D4): this IS the deploy-staging -> deploy stage write on + # the self-hosting path (advance_stage's line-402 CAS is not reached — Phase A + # returns first). Use the same expected-stage CAS. It runs under the transition- + # lease acquired by advance_stage, so it practically always wins; a lost CAS + # (a concurrent writer despite the lease) -> abort Phase A WITHOUT initiating the + # prod-deploy ask / autoDeploy (no double effect). Kill-switch off / repo out of + # scope -> unconditional update (byte-for-byte). + if not transition_lease.commit_stage_cas(task_id, current_stage, "deploy", repo): + logger.info( + f"Task {task_id}: Phase A stage-CAS lost ({current_stage}->deploy) — " + f"aborting Phase A without side effects" + ) + result.note = "phase-a-cas-lost" + result.advanced = False + return notify_stage_change(task_id, current_stage, "deploy") result.advanced = True result.to_stage = "deploy" diff --git a/src/transition_lease.py b/src/transition_lease.py new file mode 100644 index 0000000..c0e4440 --- /dev/null +++ b/src/transition_lease.py @@ -0,0 +1,471 @@ +"""ORCH-114 (adr-0045): durable transition-ownership lease + expected-stage CAS. + +Leaf module — pure, never-raise (pattern of ``serial_gate`` / ``coverage_gate`` / +``finalizer_liveness``: imports only ``db`` + ``config`` and lazily +``merge_gate.pid_alive`` / ``qg.checks.is_self_hosting_repo`` / ``notifications``; +it NEVER imports ``stage_engine`` / ``launcher`` and talks to no network). + +The bug class it closes +----------------------- +``stage_engine.advance_stage`` is the single entry to side-effectful transitions +(the heavy ``deploy-staging -> deploy`` edge sub-gates — security / merge-gate +re-test / coverage / image-freshness — and the ``deploy -> done`` merge-verify: +``merge_pr`` / coverage-ratchet / proof-of-merge). It is RE-ENTERABLE: at least +five actors (monitor / Plane-webhook / reconciler F-1 / job-reaper / deploy +finalizer) can enter the SAME transition independently, and the stage write was a +bare ``UPDATE … WHERE id=?`` with no compare-and-swap. Two concurrent — or a +post-restart re-driven — entry therefore re-applied irreversible effects and +produced contradictory outcomes (one path rolled back to ``development`` while +another merged + finished — incident ORCH-111, job 1914 / PR #130). ORCH-113 +closed only the in-memory, Tier-2, ``deploy-staging``-only slice of this; it is +lost on restart. + +Two complementary layers (ADR-001 D1), both gated by one kill-switch: + + 1. **Durable lease (owner-exclusion on ENTRY).** A row in the additive + ``transition_lease`` table (one per task) records "an actor owns this task's + side-effectful transition". A second actor that sees a LIVE owner does not + start the heavy sub-gates AT ALL (prevention, not post-hoc repair). + 2. **Expected-stage CAS (atomicity on the WRITE).** ``update_task_stage_cas`` + writes the stage only when the task is still at the expected stage; a lost + race aborts with NO side effect. It also closes the six paths that write the + stage in BYPASS of ``advance_stage`` (gitea / plane direct ``update_task_stage``). + +Liveness without a heartbeat (ADR-001 D3) +----------------------------------------- +An owner is LIVE ⇔ ``owner_boot_id == `` AND +``merge_gate.pid_alive(owner_pid)``. There is NO heartbeat (a blocking 900 s merge +re-test cannot beat one — the very argument ORCH-113 used to reject heartbeats). +This makes restart recovery free: a new process has a new ``boot_id`` so every row +written by a previous process is instantly stale and reclaimed +(``recover_on_startup``). Within the one-process model every live owner shares the +SAME boot id and pid, so a same-boot row is by definition owned by the (alive) +current process; only a different-boot row can be stale — which is why the +acquire/recover logic keys staleness on the boot id. + +No own TTL (ADR-001 D8): the lease's hard age ceiling IS the reaper Tier-3 backstop +``reaper_max_running_s`` (the reaper force-releases the lease when it reaps), so the +cross-cutting budget invariant ORCH-065/109/110/113 is untouched. + +never-raise (ADR-001 D9 / NFR-1): every public function is isolated. The +directional defaults: + * ``acquire`` error -> ``False`` (busy): the caller DEFERS/aborts a side-effectful + transition rather than risk a double effect (fail-CLOSED to no-double-effect). + * ``is_held_by_live_owner`` error -> ``True`` (treat as held): the consulting + reconciler / webhook / reaper conservatively DEFERS (the safe action; the reaper + Tier-3 backstop still bounds a genuinely stuck task). + * ``commit_stage_cas`` error on the CAS path -> ``False``: abort the write, never a + blind overwrite. +The hot claim path (``db.claim_next_job``) is deliberately NOT touched, so a lease +bug can never wedge the shared queue of all projects (AC-8 ORCH-088 intact). + +See docs/work-items/ORCH-114/06-adr/ADR-001-transition-ownership-lease-and-stage-cas.md +and the cross-cutting docs/architecture/adr/adr-0045-transition-ownership-lease-and-stage-cas.md. +""" +from __future__ import annotations + +import logging +import os +import secrets +import threading + +from . import db +from .config import settings + +logger = logging.getLogger("orchestrator.transition_lease") + +# Per-process boot nonce (ADR-001 D3). Generated ONCE at import: every lease row a +# previous process wrote carries a DIFFERENT boot id and is therefore instantly +# stale after a restart -> reclaimed by recover_on_startup / acquire. Not derived +# from the clock so it cannot collide across a fast restart. +_BOOT_ID = secrets.token_hex(16) + +# Best-effort observability counters (reset on restart, like the reaper's). Guarded +# by a lock because the monitor / reaper / reconciler / webhook threads all touch +# them. Never a source of truth — purely for GET /queue. +_LOCK = threading.Lock() +_COUNTERS: dict[str, int] = { + "acquired_total": 0, # leases successfully acquired + "busy_total": 0, # acquire deferred — a live owner already held it + "released_total": 0, # normal try/finally releases + "cas_lost_total": 0, # stage-CAS lost the race (aborted without side effect) + "stale_reclaims_total": 0, # rows reclaimed because the owner was not live + "force_reclaims_total": 0, # rows force-released (reaper / operator) +} + + +def _bump(key: str, n: int = 1) -> None: + try: + with _LOCK: + _COUNTERS[key] = _COUNTERS.get(key, 0) + n + except Exception: # noqa: BLE001 - counters never break a caller + pass + + +def boot_id() -> str: + """This process's boot nonce (exposed for tests / observability).""" + return _BOOT_ID + + +# --------------------------------------------------------------------------- +# Conditionality (mirrors coverage_gate_applies — self-hosting-only by default) +# --------------------------------------------------------------------------- +def _enabled() -> bool: + try: + return bool(getattr(settings, "transition_lease_enabled", False)) + except Exception: # noqa: BLE001 + return False + + +def applies(repo: str) -> bool: + """Whether the transition-lease + CAS are REAL for this repo (ADR-001 D10). + + * ``transition_lease_enabled=False`` -> always False (kill-switch; the lease is + neither written nor read AND ``commit_stage_cas`` degenerates to the prior + unconditional ``update_task_stage`` -> behaviour byte-for-byte as before + ORCH-114). + * ``transition_lease_repos`` (CSV) non-empty -> real only for the listed repos. + * empty CSV -> real ONLY for the self-hosting repo (``orchestrator``), where the + irreversible side-effectful edges live (mirrors coverage_gate_repos -> enduro + untouched at the default). + Never raises -> False on error (the safe "mechanism inert" default == kill-switch + off). + """ + try: + if not _enabled(): + return False + raw = (getattr(settings, "transition_lease_repos", "") or "").strip() + if raw: + allowed = {r.strip().lower() for r in raw.split(",") if r.strip()} + return (repo or "").strip().lower() in allowed + from .qg.checks import is_self_hosting_repo + return is_self_hosting_repo(repo) + except Exception as e: # noqa: BLE001 - never-raise contract + logger.warning("transition_lease.applies error for %s: %s", repo, e) + return False + + +# --------------------------------------------------------------------------- +# Liveness +# --------------------------------------------------------------------------- +def _pid_alive(pid) -> bool: + """Probe ``pid`` liveness via ``merge_gate.pid_alive`` (ADR-001 references it for + a single shared semantics). Lazy import keeps this module a leaf; on import error + fall back to a conservative ``True`` (a lease whose pid we cannot probe is treated + as live — the boot-id check below + the Tier-3 backstop still bound it). + """ + try: + from .merge_gate import pid_alive + return pid_alive(pid) + except Exception: # noqa: BLE001 + return True + + +def _row_is_live(owner_boot_id, owner_pid) -> bool: + """True iff the lease owner is LIVE (this process's boot AND a live pid). + + A row from a DIFFERENT boot id (a previous process) is dead by construction + (ADR-001 D3); a same-boot row is owned by the current — alive — process, but we + still probe the pid for forward-compatibility with a future multi-process model. + """ + if owner_boot_id != _BOOT_ID: + return False + return _pid_alive(owner_pid) + + +def is_held_by_live_owner(task_id) -> bool: + """True iff an active lease row for ``task_id`` is owned by a LIVE actor. + + Consulted by the reconciler F-1 / Plane-webhook DEFER guards and the reaper. + Returns ``False`` when there is no row or the owner is stale. Fail-CLOSED on any + error -> ``True`` (treat as held): the consulting caller DEFERS, which is always + the safe-against-double-effect action (the reaper Tier-3 backstop still bounds a + truly stuck task). When the mechanism is disabled -> ``False`` (no defer). + """ + if task_id is None: + return False + if not _enabled(): + return False + try: + conn = db.get_db() + try: + row = conn.execute( + "SELECT owner_boot_id, owner_pid FROM transition_lease WHERE task_id=?", + (task_id,), + ).fetchone() + finally: + conn.close() + if row is None: + return False + return _row_is_live(row["owner_boot_id"], row["owner_pid"]) + except Exception as e: # noqa: BLE001 - fail-CLOSED on doubt (conservative defer) + logger.warning( + "transition_lease.is_held_by_live_owner error for task %s -> " + "fail-CLOSED (defer): %s", task_id, e, + ) + return True + + +# --------------------------------------------------------------------------- +# Acquire / release / reclaim +# --------------------------------------------------------------------------- +def _clear_stale_row(conn, task_id) -> int: + """Delete the lease row for ``task_id`` IFF its owner is not live. Returns the + rowcount. Uses the caller's connection (same transaction as the INSERT in + ``acquire``). Raises on a real DB fault (the caller swallows).""" + row = conn.execute( + "SELECT owner_boot_id, owner_pid FROM transition_lease WHERE task_id=?", + (task_id,), + ).fetchone() + if row is None: + return 0 + if _row_is_live(row["owner_boot_id"], row["owner_pid"]): + return 0 + cur = conn.execute("DELETE FROM transition_lease WHERE task_id=?", (task_id,)) + return cur.rowcount or 0 + + +def acquire(task_id, owner: str, run_id=None, stage: str | None = None) -> bool: + """Acquire the side-effectful-transition lease for ``task_id`` (ADR-001 D5). + + Atomic rowcount-guard (pattern ``claim_next_job`` / ``reap_running_job``): a stale + row (owner from a previous boot / dead pid) is cleared first, then an + ``INSERT … ON CONFLICT(task_id) DO NOTHING`` competes only with LIVE same-process + owners. ``rowcount == 1`` -> WE won. ``rowcount == 0`` -> a live owner already + holds it -> ``False`` (the caller DEFERS without starting the heavy region). + + Kill-switch off -> ``True`` (no-op acquire; the caller proceeds exactly as before + ORCH-114; ``release`` is then an idempotent no-op). ``task_id is None`` -> ``True`` + (a job with no task cannot be leased — legacy direct ``launch()``; proceed). + + never-raise: any error -> ``False`` (busy) so the caller DEFERS a side-effectful + transition rather than risk a double effect (fail-CLOSED to no-double-effect, + ADR-001 D9). + """ + if not _enabled(): + return True + if task_id is None: + return True + try: + conn = db.get_db() + try: + _clear_stale_row(conn, task_id) + cur = conn.execute( + "INSERT INTO transition_lease " + "(task_id, owner, owner_pid, owner_boot_id, run_id, stage) " + "VALUES (?, ?, ?, ?, ?, ?) " + "ON CONFLICT(task_id) DO NOTHING", + (task_id, owner or "engine", os.getpid(), _BOOT_ID, run_id, stage), + ) + conn.commit() + won = (cur.rowcount == 1) + finally: + conn.close() + if won: + _bump("acquired_total") + return True + _bump("busy_total") + logger.info( + "transition_lease: task %s busy (a live owner holds the transition); " + "%s defers", task_id, owner, + ) + return False + except Exception as e: # noqa: BLE001 - fail-CLOSED (busy) to avoid double effects + logger.warning("transition_lease.acquire error for task %s: %s", task_id, e) + return False + + +def release(task_id, force: bool = False) -> None: + """Release the lease for ``task_id`` (ADR-001 D5). Idempotent, never raises. + + * ``force=False`` (normal try/finally release in ``advance_stage``): delete only + a row owned by THIS process (``owner_boot_id == boot``), so a release delayed + past a reaper-reclaim-then-reacquire can never delete a lease a DIFFERENT + process/owner acquired afterwards (holder-aware, mirrors ``release_merge_lease``). + * ``force=True`` (reaper reap / operator endpoint): delete unconditionally. + """ + if task_id is None: + return + if not _enabled(): + return + try: + conn = db.get_db() + try: + if force: + cur = conn.execute( + "DELETE FROM transition_lease WHERE task_id=?", (task_id,) + ) + else: + cur = conn.execute( + "DELETE FROM transition_lease WHERE task_id=? AND owner_boot_id=?", + (task_id, _BOOT_ID), + ) + conn.commit() + n = cur.rowcount or 0 + finally: + conn.close() + if n: + _bump("force_reclaims_total" if force else "released_total", n) + except Exception as e: # noqa: BLE001 - never-raise (a leaked lease is bounded by Tier-3) + logger.warning("transition_lease.release error for task %s: %s", task_id, e) + + +def reclaim_if_stale(task_id) -> bool: + """Reclaim (delete) the lease row for ``task_id`` IFF its owner is not live. + + Returns True iff a stale row was reclaimed. Used by the operator endpoint and as + a backstop. never-raise -> False on error. + """ + if task_id is None or not _enabled(): + return False + try: + conn = db.get_db() + try: + n = _clear_stale_row(conn, task_id) + conn.commit() + finally: + conn.close() + if n: + _bump("stale_reclaims_total", n) + logger.warning("transition_lease: reclaimed stale lease for task %s", task_id) + return n > 0 + except Exception as e: # noqa: BLE001 - never-raise + logger.warning("transition_lease.reclaim_if_stale error for task %s: %s", task_id, e) + return False + + +def recover_on_startup() -> int: + """Clear every lease left by a PREVIOUS process boot (ADR-001 D7). + + Called from ``main.lifespan`` right after ``requeue_running_jobs`` and BEFORE the + reaper starts. A fresh process boot id means every existing row predates this + process -> stale -> deleted, so the requeued jobs re-drive their transitions + cleanly (idempotency comes from the authoritative durable facts — SHA-in-main, + the INITIATED self-deploy marker, the coverage-ratchet CAS — NOT from a new + recovery brain). Returns the number of rows cleared. never-raise -> 0 on error. + """ + if not _enabled(): + return 0 + try: + conn = db.get_db() + try: + cur = conn.execute( + "DELETE FROM transition_lease " + "WHERE owner_boot_id IS NULL OR owner_boot_id != ?", + (_BOOT_ID,), + ) + conn.commit() + n = cur.rowcount or 0 + finally: + conn.close() + if n: + _bump("stale_reclaims_total", n) + logger.warning( + "transition_lease.recover_on_startup: cleared %d stale lease(s) from a " + "previous boot", n, + ) + # FR-6 / AC-12: a forced/stale reclaim is observable (Telegram alert). A + # restart-time bulk reclaim is summarised (per-task clickable alerts come + # from the operator endpoint). best-effort, never-raise. + try: + from .notifications import send_telegram + send_telegram( + f"♻️ Transition-lease recovery: сброшено {n} устаревш" + f"(ий/их) lease после рестарта (переходы будут пере-исполнены " + f"последовательно)." + ) + except Exception: # noqa: BLE001 - alert is best-effort + pass + return n + except Exception as e: # noqa: BLE001 - never-raise + logger.warning("transition_lease.recover_on_startup error: %s", e) + return 0 + + +# --------------------------------------------------------------------------- +# Stage write — compare-and-swap wrapper (ADR-001 D5 / FR-2) +# --------------------------------------------------------------------------- +def commit_stage_cas(task_id, expected_stage: str, new_stage: str, repo: str) -> bool: + """Write the task stage under the ORCH-114 contract. Returns True iff the write + was applied (and the caller may proceed with side effects), False iff the writer + lost the CAS race (the caller MUST abort WITHOUT side effects). + + * ``applies(repo)`` False (kill-switch off / repo out of scope) -> the prior + unconditional ``update_task_stage`` (byte-for-byte) -> always True. Not wrapped + in a swallowing try, so a DB error propagates EXACTLY as before ORCH-114. + * ``applies(repo)`` True -> ``update_task_stage_cas`` (expected-stage compare-and- + swap). A lost race -> False (no side effect). never-raise on the CAS path: a DB + error -> False (abort the write; never a blind overwrite). + """ + try: + scoped = applies(repo) + except Exception: # noqa: BLE001 - applies already never-raises; belt-and-suspenders + scoped = False + if not scoped: + db.update_task_stage(task_id, new_stage) + return True + try: + won = db.update_task_stage_cas(task_id, expected_stage, new_stage) + if not won: + _bump("cas_lost_total") + return won + except Exception as e: # noqa: BLE001 - abort the write (no blind overwrite) + logger.warning( + "transition_lease.commit_stage_cas error for task %s (%s->%s): %s", + task_id, expected_stage, new_stage, e, + ) + return False + + +# --------------------------------------------------------------------------- +# Observability snapshot for GET /queue (ADR-001 D10 / FR-6) +# --------------------------------------------------------------------------- +def snapshot() -> dict: + """Read-only transition-lease summary for GET /queue. Additive block; existing + /queue keys untouched. never-raise -> a minimal dict on error. + """ + try: + enabled = _enabled() + except Exception: # noqa: BLE001 + enabled = False + try: + repos_cfg = getattr(settings, "transition_lease_repos", "") or "" + except Exception: # noqa: BLE001 + repos_cfg = "" + holders: list[dict] = [] + try: + conn = db.get_db() + try: + rows = conn.execute( + "SELECT task_id, owner, owner_pid, owner_boot_id, run_id, stage, " + "acquired_at, " + "CAST(strftime('%s','now') - strftime('%s', acquired_at) AS INTEGER) " + " AS age_s " + "FROM transition_lease ORDER BY task_id" + ).fetchall() + finally: + conn.close() + for r in rows: + holders.append({ + "task_id": r["task_id"], + "owner": r["owner"], + "stage": r["stage"], + "run_id": r["run_id"], + "age_s": r["age_s"], + "live": _row_is_live(r["owner_boot_id"], r["owner_pid"]), + }) + except Exception as e: # noqa: BLE001 - never break /queue + logger.warning("transition_lease.snapshot error: %s", e) + try: + with _LOCK: + counters = dict(_COUNTERS) + except Exception: # noqa: BLE001 + counters = {} + return { + "enabled": enabled, + "repos": repos_cfg, + "boot_id": _BOOT_ID, + "active": len(holders), + "holders": holders, + "counters": counters, + } diff --git a/src/webhooks/gitea.py b/src/webhooks/gitea.py index cf6316a..c0fa610 100644 --- a/src/webhooks/gitea.py +++ b/src/webhooks/gitea.py @@ -13,7 +13,6 @@ from ..config import settings from ..db import ( get_db, get_task_by_repo_branch, - update_task_stage, enqueue_job, insert_event_dedup, ) @@ -24,6 +23,7 @@ from ..notifications import notify_stage_change, notify_qg_failure, notify_error from ..agents.launcher import launcher from ..plane_sync import notify_stage_change as plane_notify_stage from ..projects import get_project_by_repo +from .. import transition_lease logger = logging.getLogger("orchestrator.webhooks.gitea") @@ -124,18 +124,25 @@ async def handle_push(payload: dict): if has_adr: # Advance to development next_stage = "development" - update_task_stage(task_id, next_stage) - notify_stage_change(task_id, current_stage, next_stage) - plane_notify_stage(work_item_id, current_stage, next_stage) + # ORCH-114 (adr-0045 / D4, TR-4): this push-driven advance writes the stage + # in BYPASS of advance_stage -> route through the expected-stage CAS so it + # cannot clobber a concurrent authoritative write; a lost race skips the + # notify + enqueue (no duplicate agent). Kill-switch off -> unconditional + # (byte-for-byte). + if transition_lease.commit_stage_cas(task_id, current_stage, next_stage, repo_name): + notify_stage_change(task_id, current_stage, next_stage) + plane_notify_stage(work_item_id, current_stage, next_stage) - agent = get_agent_for_stage(current_stage) - if agent: - try: - task_desc = f"Work item: {work_item_id}\nRepo: {repo_name}\nBranch: {branch}\nStage: {next_stage}" - job_id = enqueue_job(agent, repo_name, task_desc, task_id=task_id) - logger.info(f"Task {task_id}: push triggered {current_stage} → {next_stage}, enqueued '{agent}' (job_id={job_id})") - except Exception as e: - notify_error(task_id, f"Failed to launch agent '{agent}': {e}") + agent = get_agent_for_stage(current_stage) + if agent: + try: + task_desc = f"Work item: {work_item_id}\nRepo: {repo_name}\nBranch: {branch}\nStage: {next_stage}" + job_id = enqueue_job(agent, repo_name, task_desc, task_id=task_id) + logger.info(f"Task {task_id}: push triggered {current_stage} → {next_stage}, enqueued '{agent}' (job_id={job_id})") + except Exception as e: + notify_error(task_id, f"Failed to launch agent '{agent}': {e}") + else: + logger.info(f"Task {task_id}: push-advance stage-CAS lost ({current_stage}->{next_stage}); another writer moved it") elif current_stage == "development": # Source files pushed — just log, wait for CI @@ -239,18 +246,22 @@ async def handle_ci_status(payload: dict): passed, reason = check_ci_green(repo_name, branch) if passed: next_stage = "review" - update_task_stage(task_id, next_stage) - notify_stage_change(task_id, current_stage, next_stage) - plane_notify_stage(work_item_id, current_stage, next_stage) + # ORCH-114 (adr-0045 / D4, TR-4): CI-green advance in BYPASS of + # advance_stage -> expected-stage CAS; a lost race skips notify + enqueue. + if transition_lease.commit_stage_cas(task_id, current_stage, next_stage, repo_name): + notify_stage_change(task_id, current_stage, next_stage) + plane_notify_stage(work_item_id, current_stage, next_stage) - agent = get_agent_for_stage(current_stage) - if agent: - try: - task_desc = f"Work item: {work_item_id}\nRepo: {repo_name}\nBranch: {branch}\nStage: {next_stage}" - job_id = enqueue_job(agent, repo_name, task_desc, task_id=task_id) - logger.info(f"Task {task_id}: CI green → {next_stage}, enqueued '{agent}' (job_id={job_id})") - except Exception as e: - notify_error(task_id, f"Failed to launch agent '{agent}': {e}") + agent = get_agent_for_stage(current_stage) + if agent: + try: + task_desc = f"Work item: {work_item_id}\nRepo: {repo_name}\nBranch: {branch}\nStage: {next_stage}" + job_id = enqueue_job(agent, repo_name, task_desc, task_id=task_id) + logger.info(f"Task {task_id}: CI green → {next_stage}, enqueued '{agent}' (job_id={job_id})") + except Exception as e: + notify_error(task_id, f"Failed to launch agent '{agent}': {e}") + else: + logger.info(f"Task {task_id}: CI-green stage-CAS lost ({current_stage}->{next_stage}); another writer moved it") else: notify_qg_failure(task_id, current_stage, "check_ci_green", reason) @@ -330,18 +341,22 @@ async def handle_pr(payload: dict): passed, reason = check_review_approved(repo_name, pr_number) if passed: next_stage = "testing" - update_task_stage(task_id, next_stage) - notify_stage_change(task_id, current_stage, next_stage) - plane_notify_stage(work_item_id, current_stage, next_stage) + # ORCH-114 (adr-0045 / D4, TR-4): PR-approved advance in BYPASS of + # advance_stage -> expected-stage CAS; a lost race skips notify + enqueue. + if transition_lease.commit_stage_cas(task_id, current_stage, next_stage, repo_name): + notify_stage_change(task_id, current_stage, next_stage) + plane_notify_stage(work_item_id, current_stage, next_stage) - agent = get_agent_for_stage(current_stage) - if agent: - try: - task_desc = f"Work item: {work_item_id}\nRepo: {repo_name}\nBranch: {head_branch}\nStage: {next_stage}" - job_id = enqueue_job(agent, repo_name, task_desc, task_id=task_id) - logger.info(f"Task {task_id}: PR approved → {next_stage}, enqueued '{agent}' (job_id={job_id})") - except Exception as e: - notify_error(task_id, f"Failed to launch agent '{agent}': {e}") + agent = get_agent_for_stage(current_stage) + if agent: + try: + task_desc = f"Work item: {work_item_id}\nRepo: {repo_name}\nBranch: {head_branch}\nStage: {next_stage}" + job_id = enqueue_job(agent, repo_name, task_desc, task_id=task_id) + logger.info(f"Task {task_id}: PR approved → {next_stage}, enqueued '{agent}' (job_id={job_id})") + except Exception as e: + notify_error(task_id, f"Failed to launch agent '{agent}': {e}") + else: + logger.info(f"Task {task_id}: PR-approved stage-CAS lost ({current_stage}->{next_stage}); another writer moved it") else: notify_qg_failure(task_id, current_stage, "check_review_approved", reason) @@ -355,18 +370,24 @@ async def handle_pr(payload: dict): conn.close() if retry_count < MAX_DEV_RETRIES: - # Back to development, relaunch developer - update_task_stage(task_id, "development") - notify_stage_change(task_id, current_stage, "development") - try: - task_desc = ( - f"Work item: {work_item_id}\nRepo: {repo_name}\nBranch: {head_branch}\n" - f"Stage: development\nNote: Changes requested in review (attempt {retry_count + 1}/{MAX_DEV_RETRIES})" - ) - job_id = enqueue_job("developer", repo_name, task_desc, task_id=task_id) - logger.info(f"Task {task_id}: changes requested, enqueued developer (attempt {retry_count + 1}, job_id={job_id})") - except Exception as e: - notify_error(task_id, f"Failed to relaunch developer: {e}") + # Back to development, relaunch developer. + # ORCH-114 (adr-0045 / D4, TR-4): REQUEST_CHANGES rollback writes the + # stage in BYPASS of advance_stage -> expected-stage CAS so it cannot + # clobber a concurrent authoritative write (e.g. a task that already + # advanced); a lost race skips the rollback + developer relaunch. + if transition_lease.commit_stage_cas(task_id, current_stage, "development", repo_name): + notify_stage_change(task_id, current_stage, "development") + try: + task_desc = ( + f"Work item: {work_item_id}\nRepo: {repo_name}\nBranch: {head_branch}\n" + f"Stage: development\nNote: Changes requested in review (attempt {retry_count + 1}/{MAX_DEV_RETRIES})" + ) + job_id = enqueue_job("developer", repo_name, task_desc, task_id=task_id) + logger.info(f"Task {task_id}: changes requested, enqueued developer (attempt {retry_count + 1}, job_id={job_id})") + except Exception as e: + notify_error(task_id, f"Failed to relaunch developer: {e}") + else: + logger.info(f"Task {task_id}: REQUEST_CHANGES rollback stage-CAS lost ({current_stage}->development); another writer moved it") else: notify_error(task_id, f"Max developer retries ({MAX_DEV_RETRIES}) reached, escalating") logger.error(f"Task {task_id}: max retries reached, needs manual intervention") @@ -395,6 +416,11 @@ async def handle_pr(payload: dict): f"deployer verdict (check_deploy_status), ignoring merge-driven done." ) return - update_task_stage(task_id, "done") - notify_stage_change(task_id, current_stage, "done") - logger.info(f"Task {task_id}: PR merged, stage → done") + # ORCH-114 (adr-0045 / D4, TR-4): merge-driven done writes the stage in BYPASS + # of advance_stage -> expected-stage CAS so a concurrent authoritative writer + # is not clobbered; a lost race skips the (idempotent) notify. + if transition_lease.commit_stage_cas(task_id, current_stage, "done", repo_name): + notify_stage_change(task_id, current_stage, "done") + logger.info(f"Task {task_id}: PR merged, stage → done") + else: + logger.info(f"Task {task_id}: merge-driven done stage-CAS lost ({current_stage}->done); another writer moved it") diff --git a/src/webhooks/plane.py b/src/webhooks/plane.py index b69b826..417935c 100644 --- a/src/webhooks/plane.py +++ b/src/webhooks/plane.py @@ -14,7 +14,6 @@ from ..db import ( get_task_by_plane_id, get_next_work_item_id, ensure_unique_work_item_id, - update_task_stage, enqueue_job, insert_event_dedup, create_task_atomic, @@ -35,6 +34,7 @@ from ..projects import ( get_project_by_repo, known_plane_project_ids, ) +from .. import transition_lease logger = logging.getLogger("orchestrator.webhooks.plane") @@ -803,7 +803,17 @@ async def _rollback_stage( if not prev_stage: logger.info(f"Task {task_id}: rejected at {current_stage} but no previous stage") return - update_task_stage(task_id, prev_stage) + # ORCH-114 (adr-0045 / D4, TR-4): this Rejected-rollback writes the stage in + # BYPASS of advance_stage. Route it through the expected-stage CAS so it can never + # clobber an authoritative write made by a concurrent owner (e.g. a deploy->done + # finalizer) — a lost race aborts the rollback WITHOUT its side effects. Kill-switch + # off / repo out of scope -> unconditional update (byte-for-byte). + if not transition_lease.commit_stage_cas(task_id, current_stage, prev_stage, repo): + logger.info( + f"Task {task_id}: rollback stage-CAS lost ({current_stage}->{prev_stage}) " + f"— task already moved by another writer; skipping rollback" + ) + return notify_stage_change(task_id, current_stage, prev_stage) # Feature 3: plane_notify_stage moves the board to the prev stage's status. plane_notify_stage(work_item_id, current_stage, prev_stage) @@ -857,10 +867,25 @@ async def _try_advance_stage( advance_stage). It is True ONLY on the "Confirm Deploy" path (handle_confirm_deploy) and gates Phase B of the self-hosting prod deploy; the plain Approved path (handle_verdict) leaves it at the default False. + + ORCH-114 (adr-0045 / FR-5, AC-8): if a live actor already owns this task's + side-effectful transition (transition-lease active), DEFER — do not re-enter the + transition in parallel. The late legitimate signal is not lost: once the owner + releases (or dies and the reaper reclaims), a re-approve / the reconciler re-drives + it, or advance_stage becomes an idempotent no-op against the authoritative facts + (SHA-in-main / INITIATED). never raises; no-op when the lease is disabled / repo + out of scope. """ import asyncio from ..stage_engine import advance_stage + if transition_lease.is_held_by_live_owner(task_id): + logger.info( + f"Task {task_id}: transition-lease active — deferring webhook advance " + f"from {current_stage} (confirm_deploy={confirm_deploy})" + ) + return + await asyncio.to_thread( advance_stage, task_id, diff --git a/tests/conftest.py b/tests/conftest.py index aec232e..7f55a30 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -133,3 +133,28 @@ def _disable_merge_verify(monkeypatch): _cfg.settings, "merge_verify_autocreate_pr_enabled", False, raising=False ) yield + + +@pytest.fixture(autouse=True) +def _disable_transition_lease(monkeypatch): + """ORCH-114: disable the transition-ownership lease + expected-stage CAS by + default in ALL tests. + + The prod default is ON for the self-hosting repo (``transition_lease_enabled=True``, + ``transition_lease_repos=""`` -> orchestrator only). Left ON, the expected-stage + CAS (``update_task_stage_cas``) would change the stage-write semantics for every + existing test that calls ``advance_stage`` / the gitea-plane webhook handlers with + repo ``orchestrator`` (a CAS write needs the task row to actually BE at the + expected stage; the bare ``update_task_stage`` did not). We therefore default the + kill-switch OFF for the whole suite (mirrors ``_disable_merge_verify`` / + ``_disable_*`` precedent), which makes ``commit_stage_cas`` degenerate to the prior + unconditional ``update_task_stage`` and the lease inert -> the existing 2000+ tests + stay byte-for-byte (AC-9). The dedicated ORCH-114 test module + (``test_orch114_transition_ownership.py``) re-enables it via its own monkeypatch, + scoping the feature ON to just those tests. + """ + from src import config as _cfg + monkeypatch.setattr( + _cfg.settings, "transition_lease_enabled", False, raising=False + ) + yield diff --git a/tests/test_orch114_transition_ownership.py b/tests/test_orch114_transition_ownership.py new file mode 100644 index 0000000..6b6389d --- /dev/null +++ b/tests/test_orch114_transition_ownership.py @@ -0,0 +1,645 @@ +"""ORCH-114 (adr-0045): durable transition-ownership lease + expected-stage CAS. + +Covers FR-1…FR-7 / AC-1…AC-13 (TC-01..TC-14, see 04-test-plan.yaml). The mechanism +prevents a concurrent OR post-restart re-entry into a side-effectful stage transition +(``deploy-staging -> deploy`` sub-gates, ``deploy -> done`` merge-verify, Phase C +finalize) from re-applying an irreversible effect or producing a contradictory +rollback↔done — incident ORCH-111. + +No network / no real git / no docker / no prod: the heavy edge sub-gates and the +finalization handlers are stubbed with call-counters and the DB is driven directly +(the same convention as test_orch113_reaper_finalizer_liveness.py). + +The autouse conftest fixture defaults the kill-switch OFF for the whole suite; this +module re-enables it per test (``_enable``) so the feature is scoped ON here. +""" +import inspect +import os +import tempfile + +import pytest + +os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_orch114.db")) +os.environ.setdefault("ORCH_REPOS_DIR", tempfile.gettempdir()) +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") + +import src.db as db +from src.db import init_db, get_db, get_job, update_task_stage_cas +import src.transition_lease as tl +import src.stage_engine as se +from src.job_reaper import JobReaper + +_REPO = "orchestrator" # self-hosting -> transition_lease.applies(repo) is True + + +@pytest.fixture(autouse=True) +def fresh_db(tmp_path, monkeypatch): + dbfile = tmp_path / "orch114.db" + monkeypatch.setattr(db.settings, "db_path", str(dbfile)) + init_db() + # Reset the leaf's in-memory counters between tests (process-local module state). + with tl._LOCK: + for k in tl._COUNTERS: + tl._COUNTERS[k] = 0 + yield + + +def _enable(monkeypatch, repos: str = ""): + """Turn the ORCH-114 mechanism ON (it is OFF by default via conftest).""" + monkeypatch.setattr(db.settings, "transition_lease_enabled", True, raising=False) + monkeypatch.setattr(db.settings, "transition_lease_repos", repos, raising=False) + + +def _disable(monkeypatch): + monkeypatch.setattr(db.settings, "transition_lease_enabled", False, raising=False) + + +# --- helpers ---------------------------------------------------------------- +def _make_task(stage="deploy-staging", repo=_REPO, branch="feature/orch114", + work_item_id="ORCH-114"): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) " + "VALUES (?, ?, ?, ?, ?)", + (work_item_id, work_item_id, repo, branch, stage), + ) + tid = cur.lastrowid + conn.commit() + conn.close() + return tid + + +def _task_stage(tid): + conn = get_db() + row = conn.execute("SELECT stage FROM tasks WHERE id=?", (tid,)).fetchone() + conn.close() + return row[0] if row else None + + +def _make_running_job(agent="deployer", repo=_REPO, task_id=None, pid=None, + age_s=0, attempts=0, max_attempts=2, run_id=None, + exit_code=0, finished_age_s=600): + conn = get_db() + if run_id is None and exit_code is not None: + cur = conn.execute( + "INSERT INTO agent_runs (task_id, agent, finished_at, exit_code) " + "VALUES (?, ?, datetime('now', ?), ?)", + (task_id, agent, f"-{int(finished_age_s)} seconds", exit_code), + ) + run_id = cur.lastrowid + cur = conn.execute( + "INSERT INTO jobs (agent, repo, task_id, status, attempts, max_attempts, " + "run_id, pid, started_at) " + "VALUES (?, ?, ?, 'running', ?, ?, ?, ?, datetime('now', ?))", + (agent, repo, task_id, attempts, max_attempts, run_id, pid, + f"-{int(age_s)} seconds"), + ) + job_id = cur.lastrowid + conn.commit() + conn.close() + return job_id + + +def _stub_side_effects(monkeypatch): + """Patch the deploy-staging edge sub-gates + Phase A with call-counters. + + Each sub-gate returns False (no intervention) so advance_stage proceeds to Phase + A; Phase A is stubbed to a counter that does NOT touch the network/prod. Returns + the shared ``counts`` dict. + """ + counts = {"security": 0, "merge": 0, "coverage": 0, "image": 0, "phase_a": 0} + + def _mk(key): + def _fake(task_id, current_stage, repo, work_item_id, branch, agent, result): + counts[key] += 1 + return False # no intervention -> advance continues + return _fake + + monkeypatch.setattr(se, "_handle_security_gate", _mk("security")) + monkeypatch.setattr(se, "_handle_merge_gate", _mk("merge")) + monkeypatch.setattr(se, "_handle_coverage_gate", _mk("coverage")) + monkeypatch.setattr(se, "_handle_image_freshness", _mk("image")) + + def _fake_phase_a(task_id, current_stage, repo, work_item_id, branch, result): + counts["phase_a"] += 1 + result.advanced = True + result.to_stage = "deploy" + + monkeypatch.setattr(se, "_handle_self_deploy_phase_a", _fake_phase_a) + # The QG (check_staging_status) is the entry gate; force it green so we reach the + # side-effectful sub-gates instead of rolling back. + monkeypatch.setattr(se, "_run_qg", lambda *a, **k: (True, "ok")) + return counts + + +# =========================================================================== +# TC-01 — MANDATORY regression: no double effect on concurrent entry (AC-1) +# =========================================================================== +def test_tc01_concurrent_entry_no_double_effect(monkeypatch): + _enable(monkeypatch) + counts = _stub_side_effects(monkeypatch) + tid = _make_task(stage="deploy-staging") + + # Actor A — a LIVE finalizer — owns the transition (acquired on entry). + assert tl.acquire(tid, "monitor", stage="deploy-staging") is True + + # Actor B (reaper/reconciler/webhook re-drive) enters the SAME transition. + res_b = se.advance_stage(tid, "deploy-staging", _REPO, "ORCH-114", "feature/orch114", + finished_agent=None) + # Busy -> deferred WITHOUT any side effect, stage unchanged. + assert res_b.note == "transition-lease-busy" + assert res_b.advanced is False + assert counts == {"security": 0, "merge": 0, "coverage": 0, "image": 0, "phase_a": 0} + assert _task_stage(tid) == "deploy-staging" + + # The owner finishes (release), then drives the transition exactly once. + tl.release(tid, force=True) + res_a = se.advance_stage(tid, "deploy-staging", _REPO, "ORCH-114", "feature/orch114", + finished_agent="deployer") + # Each side-effectful step ran EXACTLY once (one consistent outcome). + assert counts == {"security": 1, "merge": 1, "coverage": 1, "image": 1, "phase_a": 1} + assert res_a.advanced is True + + +def test_tc01_red_before_fix_demonstration(monkeypatch): + """With the kill-switch OFF (== before ORCH-114) the second actor is NOT blocked + and re-runs every sub-gate -> the double-effect bug. This is the RED that the + lease turns GREEN.""" + _disable(monkeypatch) + counts = _stub_side_effects(monkeypatch) + tid = _make_task(stage="deploy-staging") + + # acquire is a no-op when disabled -> no owner-exclusion. + assert tl.acquire(tid, "monitor", stage="deploy-staging") is True + se.advance_stage(tid, "deploy-staging", _REPO, "ORCH-114", "feature/orch114", + finished_agent=None) + # Without the lease the "second" actor ran the side effects (the bug). + assert counts["merge"] == 1 and counts["security"] == 1 + + +# =========================================================================== +# TC-02 — expected-stage CAS on the stage write (AC-2) +# =========================================================================== +def test_tc02_cas_first_wins_second_lost(monkeypatch): + tid = _make_task(stage="review") + # First writer with the correct expectation wins. + assert update_task_stage_cas(tid, "review", "testing") is True + assert _task_stage(tid) == "testing" + # Second writer with the now-stale expectation loses; stage is NOT re-mutated. + assert update_task_stage_cas(tid, "review", "development") is False + assert _task_stage(tid) == "testing" + + +def test_tc02_commit_cas_killswitch_off_unconditional(monkeypatch): + """Kill-switch off / repo out of scope -> commit_stage_cas degenerates to the + prior unconditional update_task_stage (byte-for-byte: the expected_stage is + ignored, the write always lands).""" + _disable(monkeypatch) + tid = _make_task(stage="review") + # Even a WRONG expected stage writes unconditionally when the mechanism is off. + assert tl.commit_stage_cas(tid, "totally-wrong", "testing", _REPO) is True + assert _task_stage(tid) == "testing" + + +def test_tc02_commit_cas_enabled_does_real_cas(monkeypatch): + _enable(monkeypatch) + tid = _make_task(stage="review") + # Wrong expectation -> CAS lost, no write. + assert tl.commit_stage_cas(tid, "wrong", "testing", _REPO) is False + assert _task_stage(tid) == "review" + # Correct expectation -> CAS won. + assert tl.commit_stage_cas(tid, "review", "testing", _REPO) is True + assert _task_stage(tid) == "testing" + + +# =========================================================================== +# TC-03 — ownership lifecycle: acquire / release / reclaim (AC-3) +# =========================================================================== +def test_tc03_acquire_release_visible_durably(monkeypatch): + _enable(monkeypatch) + tid = _make_task() + assert tl.is_held_by_live_owner(tid) is False + assert tl.acquire(tid, "monitor", run_id=7, stage="deploy-staging") is True + assert tl.is_held_by_live_owner(tid) is True + # Durable: a fresh DB read (snapshot) sees the holder. + snap = tl.snapshot() + assert snap["active"] == 1 + assert snap["holders"][0]["task_id"] == tid + assert snap["holders"][0]["owner"] == "monitor" + assert snap["holders"][0]["live"] is True + # A second acquire by another actor is busy while the live owner holds it. + assert tl.acquire(tid, "reaper", stage="deploy-staging") is False + tl.release(tid, force=True) + assert tl.is_held_by_live_owner(tid) is False + + +def test_tc03_release_in_finally_on_exception(monkeypatch): + """advance_stage must release the lease even when a sub-gate raises (try/finally).""" + _enable(monkeypatch) + monkeypatch.setattr(se, "_run_qg", lambda *a, **k: (True, "ok")) + + def _boom(*a, **k): + raise RuntimeError("sub-gate exploded") + + monkeypatch.setattr(se, "_handle_security_gate", _boom) + tid = _make_task(stage="deploy-staging") + + res = se.advance_stage(tid, "deploy-staging", _REPO, "ORCH-114", "feature/orch114", + finished_agent="deployer") + # The outer except swallowed the error; the finally released the lease. + assert res.advanced is False + assert tl.is_held_by_live_owner(tid) is False + + +# =========================================================================== +# TC-04 — reaper defers on a live lease, cross-path (beyond deploy-staging) (AC-4) +# =========================================================================== +def test_tc04_reaper_defers_on_deploy_edge(monkeypatch): + """ORCH-114 generalises ORCH-113 beyond Tier-2/deploy-staging: a live lease on the + deploy->done edge also defers the reaper.""" + _enable(monkeypatch) + monkeypatch.setattr(JobReaper, "_gate_is_green", + lambda self, stage, job, branch, wid: True) + calls = [] + import src.agents.launcher as L + monkeypatch.setattr(L.launcher, "_try_advance_stage", + lambda *a, **k: calls.append(a)) + tid = _make_task(stage="deploy") # NOT deploy-staging -> proves generalisation + jid = _make_running_job(task_id=tid, exit_code=0, finished_age_s=600) + assert tl.acquire(tid, "finalizer", stage="deploy") is True + + r = JobReaper() + r.reap_once() + + assert get_job(jid)["status"] == "running" # not reaped + assert calls == [] # no second advance + assert r.finalizer_defers_total == 1 + + +# =========================================================================== +# TC-05 — reaper reaps a dead/stale lease in bounded time (Tier-3) (AC-5) +# =========================================================================== +def test_tc05_tier3_backstop_reaps_and_releases_lease(monkeypatch): + _enable(monkeypatch) + monkeypatch.setattr(db.settings, "reaper_max_running_s", 1000) + tid = _make_task(stage="deploy") + jid = _make_running_job(task_id=tid, exit_code=0, finished_age_s=10, + age_s=2000, attempts=0, max_attempts=2) + assert tl.acquire(tid, "finalizer", stage="deploy") is True + + r = JobReaper() + r.reap_once() + + # Backstop reaps regardless of the marker; the lease is force-released with the job. + assert get_job(jid)["status"] == "queued" + assert tl.is_held_by_live_owner(tid) is False + + +def test_tc05_reclaim_if_stale_removes_dead_boot_row(monkeypatch): + _enable(monkeypatch) + tid = _make_task() + # A row from a PREVIOUS process boot (a dead owner) is stale. + conn = get_db() + conn.execute( + "INSERT INTO transition_lease (task_id, owner, owner_pid, owner_boot_id) " + "VALUES (?, 'monitor', 1, 'OLD-DEAD-BOOT')", + (tid,), + ) + conn.commit() + conn.close() + assert tl.is_held_by_live_owner(tid) is False # stale -> not live + assert tl.reclaim_if_stale(tid) is True + assert tl.snapshot()["active"] == 0 + + +def test_tc05_budget_invariant_preserved(): + """The lease introduced no new TTL; the cross-cutting reaper budget is untouched.""" + s = db.settings + assert s.reaper_max_running_s == 5400 + assert s.reaper_finalize_grace_s == 300 + sigma = s.merge_retest_timeout_s + s.coverage_run_timeout_s + assert s.reaper_max_running_s > sigma + s.reaper_finalize_grace_s + + +# =========================================================================== +# TC-06 — smart restart recovery (AC-6) +# =========================================================================== +def test_tc06_recover_on_startup_clears_previous_boot_lease(monkeypatch): + _enable(monkeypatch) + tid = _make_task(stage="deploy") + # Simulate a process that died MID-finalization: a lease row with a DIFFERENT boot. + conn = get_db() + conn.execute( + "INSERT INTO transition_lease (task_id, owner, owner_pid, owner_boot_id) " + "VALUES (?, 'finalizer', 999999, 'PREVIOUS-BOOT')", + (tid,), + ) + conn.commit() + conn.close() + # Before recovery the row is stale (boot mismatch) -> not a live owner. + assert tl.is_held_by_live_owner(tid) is False + + # Startup recovery (after requeue_running_jobs) clears it deterministically. + assert tl.recover_on_startup() == 1 + assert tl.snapshot()["active"] == 0 + # The requeued job can now re-drive the transition cleanly (no stale owner blocks). + assert tl.acquire(tid, "monitor", stage="deploy") is True + + +def test_tc06_recovery_does_not_touch_current_boot_lease(monkeypatch): + """A lease this very process holds must NOT be cleared by recovery (only previous + boots are stale).""" + _enable(monkeypatch) + tid = _make_task() + assert tl.acquire(tid, "monitor", stage="deploy-staging") is True + assert tl.recover_on_startup() == 0 # current-boot lease is live, kept + assert tl.is_held_by_live_owner(tid) is True + + +# =========================================================================== +# TC-07 — reconciler F-1 defers on an active lease (AC-7) +# =========================================================================== +def test_tc07_reconciler_f1_defers(monkeypatch): + _enable(monkeypatch) + from src.reconciler import Reconciler + import src.reconciler as rec + + # Spy on the advance path; it must NOT be called while the lease is held. + advanced = [] + monkeypatch.setattr(rec, "advance_if_gate_passed", + lambda *a, **k: advanced.append(a)) + # Pass the cheap local guards so we reach the lease check. + monkeypatch.setattr(rec, "has_active_job_for_task", lambda *a, **k: False) + monkeypatch.setattr(rec, "developer_retry_count", lambda *a, **k: 0) + monkeypatch.setattr(rec, "MAX_DEVELOPER_RETRIES", 3, raising=False) + monkeypatch.setattr(rec, "grace_for_stage", lambda *a, **k: 0) + + r = Reconciler() + monkeypatch.setattr(r, "_resolve_issue_status", lambda task: ({}, {}, None)) + monkeypatch.setattr(r, "_is_terminal_state", lambda *a, **k: False) + monkeypatch.setattr(r, "_is_blocked_or_needs_input", lambda *a, **k: False) + + tid = _make_task(stage="review") + assert tl.acquire(tid, "monitor", stage="review") is True + + r._reconcile_gate_task({ + "id": tid, "stage": "review", "repo": _REPO, + "work_item_id": "ORCH-114", "branch": "feature/orch114", "age_s": 10_000, + }) + + assert advanced == [] # F-1 deferred + assert r.transition_lease_defers_total == 1 + + +# =========================================================================== +# TC-08 — webhook path defers on an active lease (AC-8) +# =========================================================================== +def test_tc08_plane_webhook_defers(monkeypatch): + _enable(monkeypatch) + import asyncio + from src.webhooks.plane import _try_advance_stage + + called = [] + monkeypatch.setattr(se, "advance_stage", lambda *a, **k: called.append(a)) + + tid = _make_task(stage="deploy") + assert tl.acquire(tid, "finalizer", stage="deploy") is True + + # Lease held -> the webhook advance is deferred (advance_stage NOT invoked). + asyncio.run(_try_advance_stage(tid, "deploy", _REPO, "ORCH-114", "feature/orch114")) + assert called == [] + + # The late legitimate signal is not lost: after release it advances. + tl.release(tid, force=True) + asyncio.run(_try_advance_stage(tid, "deploy", _REPO, "ORCH-114", "feature/orch114")) + assert len(called) == 1 + + +# =========================================================================== +# TC-09 — kill-switch off -> byte-for-byte prior (AC-9) +# =========================================================================== +def test_tc09_killswitch_off_inert(monkeypatch): + _disable(monkeypatch) + tid = _make_task(stage="review") + # Lease neither written nor read. + assert tl.acquire(tid, "monitor", stage="review") is True # no-op True + assert tl.is_held_by_live_owner(tid) is False + assert tl.snapshot()["enabled"] is False + assert tl.snapshot()["active"] == 0 + # CAS degenerates to the unconditional update (expected ignored). + assert tl.commit_stage_cas(tid, "anything", "testing", _REPO) is True + assert _task_stage(tid) == "testing" + + +def test_tc09_applies_scope(monkeypatch): + _enable(monkeypatch) # empty repos CSV -> self-hosting only + assert tl.applies("orchestrator") is True + assert tl.applies("enduro-trails") is False + # Explicit CSV scope. + _enable(monkeypatch, repos="enduro-trails") + assert tl.applies("enduro-trails") is True + assert tl.applies("orchestrator") is False + + +# =========================================================================== +# TC-10 — never-raise + fail-open (hot path) / fail-closed (prod safety) (AC-10) +# =========================================================================== +def test_tc10_never_raise_on_db_error(monkeypatch): + _enable(monkeypatch) + + def _boom(*a, **k): + raise RuntimeError("DB exploded") + + monkeypatch.setattr(tl.db, "get_db", _boom) + # acquire -> fail-CLOSED (busy) so a side-effectful caller DEFERS (no double effect). + assert tl.acquire(123, "monitor", stage="deploy") is False + # is_held_by_live_owner -> fail-CLOSED (treat as held -> conservative defer). + assert tl.is_held_by_live_owner(123) is True + # release / reclaim / recover / snapshot never raise. + tl.release(123, force=True) + assert tl.reclaim_if_stale(123) is False + assert tl.recover_on_startup() == 0 + assert isinstance(tl.snapshot(), dict) + + +def test_tc10_commit_cas_error_aborts_write(monkeypatch): + _enable(monkeypatch) + monkeypatch.setattr(tl.db, "update_task_stage_cas", + lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom"))) + # CAS error -> abort the write (never a blind overwrite) -> False, no raise. + assert tl.commit_stage_cas(1, "review", "testing", _REPO) is False + + +def test_tc10_hot_claim_path_not_touched(): + """AC-8 ORCH-088 intact: the hot claim path does NOT consult the transition-lease, + so a lease bug can never wedge the shared queue (fail-open by construction).""" + src_claim = inspect.getsource(db.claim_next_job) + assert "transition_lease" not in src_claim + + +# =========================================================================== +# TC-11 — structural audit: pipeline invariants untouched, storage additive (AC-11) +# =========================================================================== +def test_tc11_stage_transitions_and_qg_untouched(): + from src.stages import STAGE_TRANSITIONS + from src.qg.checks import QG_CHECKS + # The canonical edge order is intact (no new stages/edges). + assert STAGE_TRANSITIONS["deploy-staging"]["next"] == "deploy" + assert STAGE_TRANSITIONS["deploy-staging"]["qg"] == "check_staging_status" + assert STAGE_TRANSITIONS["deploy"]["next"] == "done" + # The QG registry still carries the machine-verdict gates byte-for-byte. + for name in ("check_staging_status", "check_deploy_status", "check_coverage_gate"): + assert name in QG_CHECKS + + +def test_tc11_storage_additive_existing_tables_unchanged(): + conn = get_db() + # The additive table exists (CREATE TABLE IF NOT EXISTS). + row = conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='transition_lease'" + ).fetchone() + assert row is not None + # `tasks` schema is byte-for-byte: NO epoch/version column was added (ADR D2). + cols = {r[1] for r in conn.execute("PRAGMA table_info(tasks)").fetchall()} + conn.close() + assert "epoch" not in cols and "version" not in cols + assert {"id", "stage", "repo", "branch", "work_item_id"} <= cols + + +def test_tc11_bypass_paths_use_cas_not_unconditional_write(): + """The 6 bypass writers (gitea x5 + plane rollback) + the main advance write route + through commit_stage_cas; none does an unconditional update_task_stage on the + concurrent path (TR-4).""" + import src.webhooks.gitea as g + import src.webhooks.plane as p + gsrc = inspect.getsource(g) + assert "commit_stage_cas" in gsrc + # The gitea handlers no longer import / call the bare update_task_stage. + assert "update_task_stage(" not in gsrc + psrc = inspect.getsource(p._rollback_stage) + assert "commit_stage_cas" in psrc + assert "update_task_stage(" not in psrc + # The main advance write uses CAS. + asrc = inspect.getsource(se.advance_stage) + assert "commit_stage_cas(task_id, current_stage, next_stage" in asrc + + +# =========================================================================== +# TC-12 — observability (AC-12) +# =========================================================================== +def test_tc12_snapshot_shape_and_counters(monkeypatch): + _enable(monkeypatch) + tid = _make_task(stage="deploy-staging") + tl.acquire(tid, "monitor", run_id=3, stage="deploy-staging") + snap = tl.snapshot() + assert snap["enabled"] is True + assert snap["active"] == 1 + assert set(snap.keys()) >= {"enabled", "repos", "boot_id", "active", "holders", "counters"} + h = snap["holders"][0] + assert {"task_id", "owner", "stage", "age_s", "live"} <= set(h.keys()) + assert snap["counters"]["acquired_total"] >= 1 + + +def test_tc12_forced_reclaim_emits_telegram(monkeypatch): + _enable(monkeypatch) + sent = [] + monkeypatch.setattr("src.notifications.send_telegram", + lambda *a, **k: sent.append(a), raising=False) + tid = _make_task() + # A previous-boot (stale) lease that recovery force-reclaims at startup. + conn = get_db() + conn.execute( + "INSERT INTO transition_lease (task_id, owner, owner_pid, owner_boot_id) " + "VALUES (?, 'finalizer', 1, 'PREV-BOOT')", + (tid,), + ) + conn.commit() + conn.close() + assert tl.recover_on_startup() == 1 + assert len(sent) == 1 # forced/stale reclaim is observable via Telegram + + +def test_tc12_queue_block_wired(): + """GET /queue carries the additive transition_lease block (read-only).""" + import src.main as main_mod + qsrc = inspect.getsource(main_mod.queue) + assert '"transition_lease": transition_lease.snapshot()' in qsrc + + +# =========================================================================== +# TC-13 — self-hosting safety (AC-13) +# =========================================================================== +def _code_only(module) -> str: + """Return the module source with comments AND string literals stripped, so a + structural audit scans EXECUTABLE code only (not docstring prose). Mirrors the + tokenize approach of tests/test_no_host_hardcodes.py.""" + import io + import tokenize + src = inspect.getsource(module) + out = [] + for tok in tokenize.generate_tokens(io.StringIO(src).readline): + if tok.type in (tokenize.COMMENT, tokenize.STRING): + continue + out.append(tok.string) + return " ".join(out) + + +def test_tc13_leaf_has_no_dangerous_side_effects(): + """The ownership mechanism never restarts the prod container, never pushes / + force-pushes main, never spawns a subprocess and never touches the detached + deploy process. Scans EXECUTABLE code only (docstring prose is excluded).""" + code = _code_only(tl) + forbidden = ["subprocess", "system", "docker", "force_push", "Popen", + "os.kill", "restart", "rmtree", "remove"] + for token in forbidden: + assert token not in code, f"transition_lease must not reference {token!r} in code" + + +def test_tc13_leaf_imports_only_safe_modules(): + """The leaf imports only db + config at module load (lazily merge_gate / qg / + notifications) — it never imports stage_engine / launcher / self_deploy.""" + src_tl = inspect.getsource(tl) + assert "import stage_engine" not in src_tl + assert "from .stage_engine" not in src_tl + assert "import launcher" not in src_tl + assert "self_deploy" not in src_tl + + +# =========================================================================== +# TC-14 — full pipeline happy-path with the mechanism ON (BR-8) +# =========================================================================== +def test_tc14_single_actor_happy_path_one_set_of_effects(monkeypatch): + """A single advance on deploy-staging with the mechanism ON runs each sub-gate + exactly once and leaves NO lease behind (clean acquire+release).""" + _enable(monkeypatch) + counts = _stub_side_effects(monkeypatch) + tid = _make_task(stage="deploy-staging") + + res = se.advance_stage(tid, "deploy-staging", _REPO, "ORCH-114", "feature/orch114", + finished_agent="deployer") + assert counts == {"security": 1, "merge": 1, "coverage": 1, "image": 1, "phase_a": 1} + assert res.advanced is True + # The lease was released in the finally (no leak). + assert tl.is_held_by_live_owner(tid) is False + + +def test_tc14_deploy_to_done_finalize_advances_via_cas(monkeypatch): + """The deploy->done finalize path (Phase C) reaches the terminal write via the CAS + and releases the lease (single consistent done).""" + _enable(monkeypatch) + monkeypatch.setattr(se, "_run_qg", lambda *a, **k: (True, "ok")) + # merge-verify CONFIRMED (no HOLD) so advance proceeds to done. + monkeypatch.setattr(se, "_handle_merge_verify", lambda *a, **k: False) + # Avoid post-deploy / plane side effects on the done write. + monkeypatch.setattr(se.post_deploy, "post_deploy_applies", lambda *a, **k: False) + monkeypatch.setattr(se, "set_issue_done", lambda *a, **k: None, raising=False) + monkeypatch.setattr(se.merge_gate, "release_merge_lease", lambda *a, **k: None) + monkeypatch.setattr(se, "enqueue_job", lambda *a, **k: 1, raising=False) + tid = _make_task(stage="deploy") + + res = se.advance_stage(tid, "deploy", _REPO, "ORCH-114", "feature/orch114", + finished_agent="deployer") + assert res.advanced is True + assert _task_stage(tid) == "done" + assert tl.is_held_by_live_owner(tid) is False diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index dfcc1fa..c034ee8 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -396,15 +396,19 @@ def _mock_db_with_retry_count(count): @patch("src.webhooks.gitea.notify_error") @patch("src.webhooks.gitea.notify_qg_failure") @patch("src.webhooks.gitea.enqueue_job") -@patch("src.webhooks.gitea.update_task_stage") +@patch("src.webhooks.gitea.transition_lease.commit_stage_cas") @patch("src.webhooks.gitea.get_db") @patch("src.webhooks.gitea.get_task_by_repo_branch") @patch("src.webhooks.gitea.get_project_by_repo") def test_ci_failure_development_retries_developer_under_limit( - mock_proj, mock_task, mock_get_db, mock_update_stage, + mock_proj, mock_task, mock_get_db, mock_commit_cas, mock_enqueue, mock_qg, mock_err, ): - """retry_count < MAX_DEV_RETRIES → relaunch developer, stage untouched.""" + """retry_count < MAX_DEV_RETRIES → relaunch developer, stage untouched. + + ORCH-114: the CI-failure path never writes the stage (no advance) -> the + expected-stage CAS write helper is never invoked. + """ from src.webhooks.gitea import handle_ci_status mock_proj.return_value = {"repo": "enduro-trails"} @@ -423,19 +427,19 @@ def test_ci_failure_development_retries_developer_under_limit( assert mock_enqueue.call_args[0][0] == "developer" # No escalation. assert not mock_err.called - # Stage stays on development — no update_task_stage in the CI-failure path. - assert not mock_update_stage.called + # Stage stays on development — no stage write in the CI-failure path. + assert not mock_commit_cas.called @patch("src.webhooks.gitea.notify_error") @patch("src.webhooks.gitea.notify_qg_failure") @patch("src.webhooks.gitea.enqueue_job") -@patch("src.webhooks.gitea.update_task_stage") +@patch("src.webhooks.gitea.transition_lease.commit_stage_cas") @patch("src.webhooks.gitea.get_db") @patch("src.webhooks.gitea.get_task_by_repo_branch") @patch("src.webhooks.gitea.get_project_by_repo") def test_ci_failure_development_escalates_at_limit( - mock_proj, mock_task, mock_get_db, mock_update_stage, + mock_proj, mock_task, mock_get_db, mock_commit_cas, mock_enqueue, mock_qg, mock_err, ): """retry_count >= MAX_DEV_RETRIES → escalate via notify_error, no relaunch.""" @@ -458,8 +462,8 @@ def test_ci_failure_development_escalates_at_limit( err_msg = " ".join(str(a) for a in mock_err.call_args[0]) assert "Max developer retries" in err_msg assert "after CI failure" in err_msg - # Stage untouched. - assert not mock_update_stage.called + # Stage untouched (no stage write). + assert not mock_commit_cas.called # --------------------------------------------------------------------------- @@ -483,11 +487,11 @@ def _merged_pr_payload(branch="feature/ET-012-x"): @patch("src.webhooks.gitea.notify_stage_change") -@patch("src.webhooks.gitea.update_task_stage") +@patch("src.webhooks.gitea.transition_lease.commit_stage_cas") @patch("src.webhooks.gitea.get_task_by_repo_branch") @patch("src.webhooks.gitea.get_project_by_repo") def test_merge_on_deploy_stage_does_not_set_done( - mock_proj, mock_task, mock_update_stage, mock_notify, + mock_proj, mock_task, mock_commit_cas, mock_notify, ): """FIX 1: merge at deploy stage is ignored — done is gated by deployer verdict.""" from src.webhooks.gitea import handle_pr @@ -499,28 +503,34 @@ def test_merge_on_deploy_stage_does_not_set_done( asyncio.run(handle_pr(_merged_pr_payload())) - # The merge-driven done path must NOT run on deploy. - assert not mock_update_stage.called + # The merge-driven done path must NOT run on deploy (no stage write). + assert not mock_commit_cas.called assert not mock_notify.called @patch("src.webhooks.gitea.notify_stage_change") -@patch("src.webhooks.gitea.update_task_stage") +@patch("src.webhooks.gitea.transition_lease.commit_stage_cas") @patch("src.webhooks.gitea.get_task_by_repo_branch") @patch("src.webhooks.gitea.get_project_by_repo") def test_merge_on_non_deploy_stage_sets_done( - mock_proj, mock_task, mock_update_stage, mock_notify, + mock_proj, mock_task, mock_commit_cas, mock_notify, ): - """FIX 1: merge behaviour is preserved for non-deploy stages (e.g. review).""" + """FIX 1: merge behaviour is preserved for non-deploy stages (e.g. review). + + ORCH-114: the merge-driven done write now goes through the expected-stage CAS + helper (commit_stage_cas(task_id, current_stage, "done", repo)); on a won CAS the + notify still fires. + """ from src.webhooks.gitea import handle_pr mock_proj.return_value = {"repo": "enduro-trails"} mock_task.return_value = { "id": 2, "stage": "review", "work_item_id": "ET-013", } + mock_commit_cas.return_value = True asyncio.run(handle_pr(_merged_pr_payload(branch="feature/ET-013-x"))) - # Non-deploy stages still get the merge-driven done. - mock_update_stage.assert_called_once_with(2, "done") + # Non-deploy stages still get the merge-driven done (review -> done via CAS). + mock_commit_cas.assert_called_once_with(2, "review", "done", "enduro-trails") assert mock_notify.called From 4a6b32e61de75da5c03261b0e9943a1728cd3ee0 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 15 Jun 2026 17:54:55 +0300 Subject: [PATCH 5/9] reviewer(ET): auto-commit from reviewer run_id=711 --- docs/work-items/ORCH-114/12-review.md | 127 ++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 docs/work-items/ORCH-114/12-review.md diff --git a/docs/work-items/ORCH-114/12-review.md b/docs/work-items/ORCH-114/12-review.md new file mode 100644 index 0000000..d376565 --- /dev/null +++ b/docs/work-items/ORCH-114/12-review.md @@ -0,0 +1,127 @@ +--- +verdict: REQUEST_CHANGES +work_item: ORCH-114 +stage: review +author_agent: reviewer +status: changes-requested +created_at: 2026-06-15 +model_used: claude-opus-4-8 +type: review +work_item_id: ORCH-114 +version: 1 +--- + +# Review ORCH-114 — Durable transition-ownership lease + expected-stage CAS + +## Summary + +Сильная, аккуратная реализация корневого класса инцидент-цепочки ORCH-110/111/112/113. +Архитектура (durable-lease на входе + expected-stage CAS на записи, liveness по `pid`+`boot_id` +без heartbeat, self-hosting-скоуп, kill-switch, never-raise) реализована **в точном соответствии +с ADR-001 D1–D10**. Код-инварианты конвейера соблюдены: `src/stages.py` и `src/qg/checks.py` +**не тронуты** (AC-11), схема БД аддитивна (одна таблица `transition_lease`, без epoch-колонки), +hot-path `claim_next_job` не консультирует lease (AC-10/fail-open). Прогон **полного** `pytest +tests/` — **2048 passed** в детерминированном порядке (плагина random-ordering нет), т.е. AC-9 +(байт-в-байт при kill-switch off) и CI-green выполняются операционно. Тесты содержательные: +TC-01a/b воспроизводят обязательный регресс класса ORCH-111 (kill-switch off = «красный», +on = «зелёный»), TC-02…TC-14 покрывают AC-2…AC-13. + +Однако вердикт — **REQUEST_CHANGES**: документация (golden source наравне с кодом) обновлена +**частично**. Архитектурные доки (`docs/architecture/README.md`, `internals.md`, `CHANGELOG.md`, +ADR-001 + сквозной adr-0045) — образцовы, но **`.env.example` и `CLAUDE.md` не обновлены** (P1), +а витрина `docs/overview/` (ORCH-011) не отражает новый механизм (P2). Плюс две незначительные +оси-уточнения (P2). + +## Findings + +### P0 — Blocker +- _(нет)_ + +### P1 — Must fix +- [ ] **`.env.example` не содержит новых ключей конфигурации** `ORCH_TRANSITION_LEASE_ENABLED` + и `ORCH_TRANSITION_LEASE_REPOS` (добавлены в `src/config.py:transition_lease_enabled`/ + `transition_lease_repos`). ORCH-101 нормативно объявил `.env.example` **«каноном 100% ключей + старта»**, и все сопоставимые kill-switch'и там присутствуют (`ORCH_SERIAL_GATE_ENABLED:220`, + `ORCH_CHECKOUT_HYGIENE_ENABLED:349`, `ORCH_COVERAGE_GATE_ENABLED:511`). Изменение конфигурации + без отражения в каноническом env-файле — нарушение оси «документация = golden source» (доступ + оператора к kill-switch'у). **Fix:** добавить обе строки в `.env.example` (с дефолтами + `true`/пусто) рядом с блоком прочих gate-флагов. + _Ссылка: правило документирования CLAUDE.md §2 («конфигурация → env-канон»), ORCH-101 (adr-0036)._ + +- [ ] **`CLAUDE.md` не дополнен паспорт-секцией ORCH-114.** ORCH-114 — крупный кросс-каттинговый + механизм (новая durable-таблица, новый leaf `src/transition_lease.py`, врезки в `advance_stage`/ + `job_reaper`/`reconciler`/обоих webhook'ах/`main.lifespan`, новый эндпоинт, новый инвариант + владения переходом). Конвенция паспорта **универсальна**: каждая сопоставимая фича несёт секцию + в `CLAUDE.md` (ORCH-112/110/098/094/093/090/089/088/027/019…), а футер прямо гласит «Поддерживается + агентами при каждой доработке». Для self-hosting это критично: будущий агент, правящий + `advance_stage`/reaper/webhooks, обязан найти инвариант ORCH-114 в первом обязательном к чтению + доке (ORCH-078 трассировка опирается на паспорт как индекс). **Fix:** добавить секцию ORCH-114 + в `CLAUDE.md` по образцу ORCH-112/113 (механизм, инвариант, флаги, ADR-ссылки). + _Ссылка: CLAUDE.md правила §1, §2 + футер; ORCH-078 (TRACEABILITY)._ + +### P2 — Should fix +- [ ] **Витрина системы `docs/overview/` не обновлена (ORCH-011).** Добавлена durable-таблица + `transition_lease` и read-only блок `transition_lease` в `GET /queue`, но: + `tech-data-model.md` (раздел «Вспомогательные таблицы», строки 46–49) перечисляет + `repo_freeze`/`coverage_baseline`/`tracker_messages`/`lessons` — без `transition_lease`; + `tech-observability.md` (перечень блоков `/queue`, стр. 22–23) и `tech-architecture.md` + (список компонентов с job-reaper, стр. 46) тоже не упоминают механизм. Индекс витрины прямо + требует «изменил функциональность платформы → обнови витрину `docs/overview/` в том же PR» + (`docs/overview/README.md:66`). Понижено до P2 (не стадия/гейт/агент/интеграция → машинные + проверки `tests/test_system_docs.py` это не ловят), но completeness-норму витрины нарушает. + **Fix:** добавить строку `transition_lease` в таблицу `tech-data-model.md`, упоминание блока + в `tech-observability.md`, компонент в `tech-architecture.md`. + +- [ ] **Расхождение код ↔ ADR D4 по CAS на rollback-записях.** ADR-001 D4 (таблица) предписывает + rollback-записям `_handle_*_rollback` **CAS** (столбец «да») как защиту rollback↔done (BR-6). + В коде rollback-записи на side-effectful рёбрах остались голым `update_task_stage(task_id, + "development")` — `_handle_merge_gate_rollback:1246`, `_handle_security_gate:1323`, + `_handle_coverage_gate:1411`, `_handle_image_freshness:1491`. **Корректность не нарушена:** эти + хендлеры вызываются строго внутри `advance_stage` (стр. 378–407) под удерживаемым lease → + единственный владелец → конкурентного противоречия нет. Но налицо буквальное расхождение + реализации с собственным ADR. **Fix (на выбор):** либо провести эти записи через + `transition_lease.commit_stage_cas(...)` (как в forward/bypass-путях), либо уточнить ADR D4, что + под lease CAS на in-region rollback избыточен (чтобы доки соответствовали коду). + _Ссылка: ADR-001 D4/D5, BR-6, AC-6._ + +- [ ] **Изоляция тестов: модуль `tests/test_orch114_transition_ownership.py` протекает в + процесс-wide `settings.db_path`.** Module-level `os.environ.setdefault("ORCH_DB_PATH", …)` + (стр. 22) при первом импорте инстанцирует `Settings` с `db_path=test_orch114.db`, а позднейший + `os.environ["ORCH_DB_PATH"]=…` в `tests/test_webhooks.py:9` уже не влияет (settings создан) → + при прогоне двух модулей вместе (`pytest tests/test_orch114_transition_ownership.py + tests/test_webhooks.py`) **4 существующих webhook-теста падают** (`assert 'architecture' == + 'analysis'` и др.). **Полный `pytest tests/` зелёный** (промежуточные модули перетирают + состояние; плагина random-ordering нет) → CI/coverage-gate **не** ломаются, поэтому P2, не + блокер. Но developer/reviewer, гоняющие подмножество, получат ложные падения. **Fix:** + изолировать db_path модуля через conftest-fixture/собственный tmp без правки процесс-wide env + (паттерн `fresh_db` уже есть — достаточно убрать module-level `setdefault`). + +### P3 — Nice to have +- [ ] `reconciler`/`plane._try_advance_stage` зовут `is_held_by_live_owner(task_id)` без + предварительного `applies(repo)` (в отличие от reaper). Для out-of-scope, но включённого репо + (enduro при дефолте) это лишний безвредный `SELECT` (строк нет → `False`, defer не возникает). + Функционально корректно; при желании — добавить дешёвый `applies`-гард ради нулевого оверхеда. + +## Документация + +**Обновлено (образцово):** +- `docs/architecture/README.md` — новый компонент в списке, отдельная секция «Единое владение + side-effectful переходами», строка таблицы БД `transition_lease`, API-таблица + (`POST /transition-lease/release`), описание блока `/queue`. +- `docs/architecture/internals.md` — DDL `transition_lease`, lifespan-recovery, секция reaper. +- `CHANGELOG.md` — подробная запись ORCH-114 в `[Unreleased]`. +- ADR: `docs/work-items/ORCH-114/06-adr/ADR-001-…` + сквозной + `docs/architecture/adr/adr-0045-…` — полные, со сверкой по коду. +- Work-item доки `02-trz`/`03-acceptance-criteria`/`08-data-requirements`/`10-tech-risks` — на месте. + +**Нужно обновить (см. findings):** +- `.env.example` — добавить `ORCH_TRANSITION_LEASE_ENABLED` / `ORCH_TRANSITION_LEASE_REPOS` **(P1)**. +- `CLAUDE.md` — добавить паспорт-секцию ORCH-114 **(P1)**. +- `docs/overview/` (`tech-data-model.md` / `tech-observability.md` / `tech-architecture.md`) — + отразить таблицу/блок/компонент `transition_lease` **(P2)**. + +## Verdict rationale +Любой P1 → `REQUEST_CHANGES`. Кода-корректность и инварианты конвейера — без P0/P1; блокирует +**частичная незавершённость документации golden-source** (`.env.example`, `CLAUDE.md`). После +закрытия двух P1 (и желательно P2) задача готова к повторному ревью — реализация по существу +зрелая. From c4a97a7a2844fc22d74ea3b2d99af18f0082658d Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 15 Jun 2026 18:16:49 +0300 Subject: [PATCH 6/9] =?UTF-8?q?fix(stage-engine):=20address=20ORCH-114=20r?= =?UTF-8?q?eview=20=E2=80=94=20env/docs=20canon=20+=20in-region=20rollback?= =?UTF-8?q?=20CAS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves the REQUEST_CHANGES findings on ORCH-114 (durable transition-ownership lease + expected-stage CAS): P1 — documentation = golden source: - .env.example: add ORCH_TRANSITION_LEASE_ENABLED / ORCH_TRANSITION_LEASE_REPOS (canon of 100% start keys, ORCH-101), next to the other gate kill-switches. - CLAUDE.md: add the ORCH-114 passport section (mechanism, invariant, flags, ADR links) so a future agent editing advance_stage/reaper/webhooks finds the ownership invariant in the first mandatory-read doc (ORCH-078 traceability index). P2 — should-fix: - docs/overview/ (system showcase, ORCH-011): add transition_lease to tech-data-model.md (helper tables), tech-observability.md (/queue blocks) and tech-architecture.md (components). - ADR-001 D4 alignment: the four side-effectful-edge rollback handlers (_handle_merge_gate_rollback / _handle_security_gate / _handle_coverage_gate / _handle_image_freshness) now write `development` through the expected-stage CAS via a shared _rollback_stage_cas helper (defence against the rollback↔done contradiction, BR-6) instead of a bare unconditional update_task_stage. Under the held lease the sole owner always wins; a lost race aborts WITHOUT side effects. Kill-switch off / out-of-scope repo -> degenerates to the prior write -> 1:1. - Test isolation: make tests/test_webhooks.py order-independent by pinning the proj-1 registry per-test (mirrors test_webhook_dedup.proj_registry); it had only passed by relying on import order. Drop the needless module-level ORCH_DB_PATH setdefault in test_orch114 (fresh_db already isolates db_path). New regression tests (TC-11): in-region rollback writes route through CAS; rollback CAS wins when at expected stage; rollback CAS-lost does NOT clobber `done`; kill-switch-off rollback degenerates to the unconditional write. ruff clean (src/stage_engine.py, src/transition_lease.py); full suite 2052 passed. Refs: ORCH-114 Co-Authored-By: Claude Opus 4.8 --- .env.example | 28 +++++++++ CHANGELOG.md | 2 +- CLAUDE.md | 43 +++++++++++++ docs/overview/tech-architecture.md | 1 + docs/overview/tech-data-model.md | 1 + docs/overview/tech-observability.md | 5 +- src/stage_engine.py | 37 +++++++++-- tests/test_orch114_transition_ownership.py | 71 +++++++++++++++++++++- tests/test_webhooks.py | 22 +++++++ 9 files changed, 202 insertions(+), 8 deletions(-) diff --git a/.env.example b/.env.example index caea51a..701771a 100644 --- a/.env.example +++ b/.env.example @@ -434,6 +434,34 @@ ORCH_REAPER_MAX_RUNNING_S=5400 ORCH_REAPER_FINALIZE_GRACE_S=300 ORCH_LEASE_RECLAIM_ENABLED=true +# ORCH-114 (adr-0045): durable transition-ownership lease + expected-stage CAS for +# side-effectful stage transitions. Generalises the process-local ORCH-113 finalizer- +# liveness into a DURABLE, cross-path owner-exclusion (additive table `transition_lease`) +# so a concurrent OR post-restart re-entry into a side-effectful transition (reaper / +# reconciler / webhook / startup-requeue) is deferred or a no-op instead of re-applying +# an irreversible effect (merge_pr / coverage-ratchet / image-rebuild / prod-deploy +# initiation / contradictory rollback<->done). Two layers, both gated by the SINGLE +# kill-switch below: (1) a durable lease on ENTRY to the side-effectful region (a second +# actor that sees a live owner does not start the heavy sub-gates at all); (2) an +# expected-stage CAS on the stage WRITE (a lost race -> abort with NO side effect), which +# also closes the paths that write the stage in bypass of advance_stage. Owner liveness = +# owner_pid + owner_boot_id (NOT a heartbeat), so restart recovery is free (new process -> +# new boot_id -> all prior leases instantly stale -> reclaimed). The lease has NO own TTL: +# its hard age ceiling IS the reaper Tier-3 backstop (ORCH_REAPER_MAX_RUNNING_S), so the +# cross-cutting budget invariant ORCH-065/109/110/113 is untouched. STAGE_TRANSITIONS / +# QG_CHECKS / check_* / machine-verdict keys / existing table schemas — byte-for-byte. +# TRANSITION_LEASE_ENABLED -> SINGLE kill-switch. false -> the lease is neither written +# nor read AND the CAS degenerates to the prior unconditional +# update_task_stage -> behaviour byte-for-byte as before +# ORCH-114 (reaper -> ORCH-113 in-memory fallback, +# reconciler/webhook skip-guard inert). Default true. +# TRANSITION_LEASE_REPOS -> CSV scope. Empty -> applies ONLY to the self-hosting repo +# (orchestrator), where the irreversible side-effectful edges +# live; non-empty -> only the listed repos. Mirrors +# ORCH_COVERAGE_GATE_REPOS -> enduro untouched at the default. +ORCH_TRANSITION_LEASE_ENABLED=true +ORCH_TRANSITION_LEASE_REPOS= + # ORCH-063: disk-watchdog — background heartbeat that measures HOST-FS fill via the # mounted bind-paths (/repos, /app/data) with shutil.disk_usage (NOT the container # overlay /) and Telegram-alerts the operator at >= threshold. On 07.06.2026 the diff --git a/CHANGELOG.md b/CHANGELOG.md index 99553ef..848e112 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ ## [Unreleased] - **Ownership-lease для side-effectful переходов стадий + умное восстановление при старте** (ORCH-114, `fix`, bug→escalate full-cycle): закрыт **корневой класс** инцидент-цепочки ORCH-110/111/112/113 — у side-effectful переходов стадий не было единого владения. `advance_stage` ре-ентерабельна и пишет стадию «голым» `UPDATE … WHERE id=?` (без compare-and-swap), а ≥5 акторов (монитор / Plane-webhook / reconciler F-1 / job-reaper / deploy-finalizer) входят в один переход независимо → конкурентный или после-рестартовый повторный вход **дважды** применял необратимые эффекты (merge_pr / coverage-ratchet / image-rebuild / инициация прод-деплоя) и давал **противоречие rollback↔done** (инцидент ORCH-111, job 1914 / PR #130). Два комплементарных слоя, оба аддитивные, под единым kill-switch, never-raise: **(1) durable transition-lease** (новая таблица `transition_lease`) — владение на ВХОДЕ в side-effectful регион (второй актор, увидев живого владельца, не стартует тяжёлые под-гейты вовсе — предотвращение, не починка постфактум); **(2) expected-stage CAS** (`update_task_stage_cas`) — на ЗАПИСИ стадии (проигравший гонку — аборт без побочных эффектов), что закрывает и **6 путей записи стадии в обход `advance_stage`** (gitea×5 + plane rollback). Liveness владельца = `owner_pid` + `owner_boot_id` (НЕ heartbeat: блокирующий 900s merge re-test не может бить heartbeat — довод самого ORCH-113), что делает рестарт-recovery бесплатным (новый процесс → новый boot-id → все прежние lease мгновенно устаревшие → реклеймятся). Lease без собственного TTL: его потолок возраста = Tier-3 backstop `reaper_max_running_s` (5400) → сквозной бюджет ORCH-065/109/110/113 не тронут. `STAGE_TRANSITIONS` / реестр `QG_CHECKS` / семантика и имена `check_*` / machine-verdict-ключи / **схемы существующих таблиц** — байт-в-байт (одна аддитивная таблица, без epoch-колонки на `tasks`). Скоуп self-hosting (`transition_lease_repos=""` → только `orchestrator`; enduro не затронут); kill-switch `ORCH_TRANSITION_LEASE_ENABLED=false` → CAS вырождается в прежний безусловный `update_task_stage`, lease инертен → поведение байт-в-байт до ORCH-114. ADR: `docs/work-items/ORCH-114/06-adr/ADR-001-transition-ownership-lease-and-stage-cas.md`, сквозной `docs/architecture/adr/adr-0045-transition-ownership-lease-and-stage-cas.md`. - **Leaf `src/transition_lease.py` (новый, чистый never-raise):** по образцу `serial_gate`/`coverage_gate`/`finalizer_liveness` (импортирует только `db`+`config`, лениво `merge_gate.pid_alive`/`qg.checks`/`notifications`; НЕ импортирует `stage_engine`/`launcher`) — `applies(repo)` / `acquire(task_id, owner, run_id, stage)` (атомарный rowcount-guard `INSERT … ON CONFLICT DO NOTHING` после очистки stale-строки) / `is_held_by_live_owner(task_id)` (fail-closed → defer на сомнении) / `release(task_id, force=False)` (holder-aware по boot) / `reclaim_if_stale` / `recover_on_startup` / `commit_stage_cas(task_id, expected, new, repo)` (flag-off → unconditional `update_task_stage`; flag-on → CAS) / `snapshot()`. - - **Интеграция:** `advance_stage` захватывает lease на входе в side-effectful ребро (`deploy-staging`/`deploy`), пишет стадию через CAS, освобождает lease в `try/finally` (на любом исходе, включая исключение/откат); job-reaper `_finalizer_owns` обобщён с процесс-локального ORCH-113 (Tier-2/`deploy-staging`) на **durable cross-path** lease (defer при живом владельце; Tier-3 backstop игнорирует маркер → bounded reclaim; реап force-освобождает lease); reconciler F-1 и Plane-webhook (`_try_advance_stage`) делают **defer** при активном lease; `main.lifespan` зовёт `recover_on_startup()` после `requeue_running_jobs`. Наблюдаемость — read-only блок `transition_lease` в `GET /queue` + Telegram-алерт на форсированный/устаревший реклейм + опциональный `POST /transition-lease/release?work_item=`. Покрытие — `tests/test_orch114_transition_ownership.py` (TC-01 обязательный регресс класса ORCH-111: красный до фикса, зелёный после; TC-02…TC-14). Флаги (`config.py`, дефолт = боевое): `transition_lease_enabled` (env `ORCH_TRANSITION_LEASE_ENABLED`), `transition_lease_repos` (env `ORCH_TRANSITION_LEASE_REPOS`). + - **Интеграция:** `advance_stage` захватывает lease на входе в side-effectful ребро (`deploy-staging`/`deploy`), пишет стадию через CAS, освобождает lease в `try/finally` (на любом исходе, включая исключение/откат); **rollback-записи side-effectful под-гейтов** (`_handle_merge_gate_rollback`/`_handle_security_gate`/`_handle_coverage_gate`/`_handle_image_freshness`) пишут `development` через тот же CAS (общий хелпер `_rollback_stage_cas`, ADR-001 D4: защита rollback↔done — под держимым lease это единственный владелец, проигранный CAS → аборт без side-effects, не слепой перетир `done`); job-reaper `_finalizer_owns` обобщён с процесс-локального ORCH-113 (Tier-2/`deploy-staging`) на **durable cross-path** lease (defer при живом владельце; Tier-3 backstop игнорирует маркер → bounded reclaim; реап force-освобождает lease); reconciler F-1 и Plane-webhook (`_try_advance_stage`) делают **defer** при активном lease; `main.lifespan` зовёт `recover_on_startup()` после `requeue_running_jobs`. Наблюдаемость — read-only блок `transition_lease` в `GET /queue` + Telegram-алерт на форсированный/устаревший реклейм + опциональный `POST /transition-lease/release?work_item=`. Покрытие — `tests/test_orch114_transition_ownership.py` (TC-01 обязательный регресс класса ORCH-111: красный до фикса, зелёный после; TC-02…TC-14 + регресс CAS на in-region rollback). Флаги (`config.py`, дефолт = боевое): `transition_lease_enabled` (env `ORCH_TRANSITION_LEASE_ENABLED`), `transition_lease_repos` (env `ORCH_TRANSITION_LEASE_REPOS`). - **Гигиена shared deploy-базы: устойчивый self-deploy `git pull` к грязному дереву** (ORCH-112, `fix`, bug→escalate full-cycle): устранён инцидент ORCH-111 — self-deploy падал на шаге `git pull origin main` хост-хука с `error: Your local changes to the following files would be overwritten by merge: src/config.py` (грязь от неуспешной/отменённой/брошенной задачи ORCH-104 в общем main checkout) → деплой вставал → ручное вмешательство (на self-hosting — групповой риск). Решение — **resilient-pull, встроенный в прод-deploy-хук** (`--deploy`): перед `git pull` хук при обнаружении грязи приводит deploy-базу к чистому актуальному `origin/main` (`git fetch` + `git reset --hard origin/main` + **скоупленный** `git clean -fd`). Аддитивно, под kill-switch, never-raise, скоуп self-hosting; `STAGE_TRANSITIONS` / реестр `QG_CHECKS` / семантика и имена `check_*` / machine-verdict-ключи / схема БД / exit-code-контракт хука (0/1/2, ORCH-036) — **байт-в-байт не тронуты** (это устойчивость deploy-пути, **не** Quality Gate и **не** стадия). ADR: `docs/work-items/ORCH-112/06-adr/ADR-001-deploy-base-checkout-hygiene.md`, сквозной `docs/architecture/adr/adr-0044-deploy-base-checkout-hygiene.md`. - **Leaf `src/checkout_hygiene.py` (новый, чистый never-raise):** по образцу `serial_gate`/`cancel`/`self_deploy` (импортирует только `config`, лениво `self_deploy`/`qg.checks`/`notifications`) — `applies(repo)` (kill-switch `checkout_hygiene_enabled` + скоуп `checkout_hygiene_repos`, пусто → self-hosting only, локально и ПЕРВЫМ), `hook_env(repo, work_item_id)` (env-префикс `CHECKOUT_HYGIENE=1 HYGIENE_REPORT=`, инжектится в detached-команду хука только при `applies==True`, иначе `""` → голый pull 1:1), `read_report`/`alert_dirty` (наблюдаемость), `snapshot()` (read-only блок `GET /queue`). - **Хук-блок «2a. Resilient pull» (`scripts/orchestrator-deploy-hook.sh`):** между шагом «1. Capture PREV_IMG» и «2. Pull», под `if [[ "${CHECKOUT_HYGIENE:-0}" == "1" ]]`. **Сохранность (NFR-2, жёсткий контракт):** `git clean` — **только `-fd`, НИКОГДА `-x`** (иначе удалил бы gitignored `.env`/прод-секреты, `data/*.db`/БД, `build/`); явные `-e '.deploy-prev-image-*'` и `-e 'deploy-hook.log'` (untracked-но-НЕ-ignored — иначе сломался бы rollback `do_rollback`); sibling `/.deploy-state-*`/`.merge-lease-*.json` (под родителем `$REPO`) и `.git/worktrees/*` (внутри `.git/`) — вне области `git clean` в `$REPO`. Каждый git-шаг — `|| log "...continuing"` (never-break): сбой гигиены не ухудшает исход относительно текущего голого pull; на чистой базе блок — no-op (happy-path и exit-коды байт-в-байт). `--build-staging` (build из worktree, без pull) не затронут. diff --git a/CLAUDE.md b/CLAUDE.md index ff45b52..b24be1c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -323,6 +323,49 @@ to the following files would be overwritten by merge: src/config.py` — гря фикса, зелёный после). Детали — `docs/work-items/ORCH-112/06-adr/ADR-001-deploy-base-checkout-hygiene.md`, сквозной `docs/architecture/adr/adr-0044-deploy-base-checkout-hygiene.md`. +## Единое владение side-effectful переходами: durable-lease + expected-stage CAS (ORCH-114) +Закрыт **корневой класс** инцидент-цепочки **ORCH-110/111/112/113**: у side-effectful переходов +стадий не было единого владения. `advance_stage` ре-ентерабельна и писала стадию «голым» +`UPDATE … WHERE id=?` (без compare-and-swap), а ≥5 акторов (монитор / Plane-webhook / reconciler +F-1 / job-reaper / deploy-finalizer) входят в один переход независимо → конкурентный или +после-рестартовый повторный вход **дважды** применял необратимые эффекты (`merge_pr` / +coverage-ratchet / image-rebuild / инициация прод-деплоя) и давал **противоречие rollback↔done** +(инцидент ORCH-111, job 1914 / PR #130). Это **обобщение** процесс-локальной finalizer-liveness +ORCH-113 в **durable cross-path** владение. Аддитивно, под единым kill-switch, never-raise; новый +leaf `src/transition_lease.py`. **Инвариант:** `STAGE_TRANSITIONS` / реестр `QG_CHECKS` / семантика +и имена `check_*` / machine-verdict-ключи / **схемы существующих таблиц** — байт-в-байт (одна +аддитивная таблица `transition_lease`, без epoch-колонки на `tasks`); hot-path `claim_next_job` +lease **не консультирует** (fail-open, очередь репо никогда не клинится). +- **Два комплементарных слоя (оба под `transition_lease_enabled`):** (1) **durable transition-lease** + (таблица `transition_lease`) — владение на ВХОДЕ в side-effectful регион: второй актор, увидев + живого владельца (`is_held_by_live_owner`), не стартует тяжёлые под-гейты вовсе (предотвращение, + не починка постфактум); (2) **expected-stage CAS** (`db.update_task_stage_cas` ↔ + `commit_stage_cas`) — на ЗАПИСИ стадии: проигравший гонку аборт без побочных эффектов. CAS + закрывает и **6 путей записи стадии в обход `advance_stage`** (gitea×5 + plane rollback). +- **Liveness владельца = `owner_pid` + `owner_boot_id` (НЕ heartbeat):** блокирующий 900s merge + re-test не может бить heartbeat (довод самого ORCH-113) → рестарт-recovery бесплатен (новый + процесс → новый `boot_id` → все прежние lease мгновенно устаревшие → реклеймятся). + `main.lifespan` зовёт `recover_on_startup()` после `requeue_running_jobs`. Lease **без + собственного TTL**: его потолок возраста = Tier-3 backstop `reaper_max_running_s` (5400) → + сквозной бюджет ORCH-065/109/110/113 не тронут. +- **Интеграция:** `advance_stage` захватывает lease на входе в side-effectful ребро + (`deploy-staging`/`deploy`), пишет стадию через CAS, освобождает lease в `try/finally` (на + любом исходе, включая исключение/откат); job-reaper `_finalizer_owns` обобщён с процесс-локального + ORCH-113 на durable cross-path (defer при живом владельце; Tier-3 backstop игнорирует маркер → + bounded reclaim; реап force-освобождает lease); reconciler F-1 и Plane-webhook (`_try_advance_stage`) + делают **defer** при активном lease. +- **Флаги** (`config.py`, дефолт = боевое): `transition_lease_enabled` (env + `ORCH_TRANSITION_LEASE_ENABLED`; `False` → lease не пишется/не читается, CAS вырождается в прежний + безусловный `update_task_stage` → байт-в-байт до ORCH-114: reaper → ORCH-113 in-memory fallback, + reconciler/webhook skip-guard инертны), `transition_lease_repos` (env `ORCH_TRANSITION_LEASE_REPOS`; + CSV; **пусто → self-hosting only** — где живут необратимые рёбра; зеркало `coverage_gate_repos`, + enduro не затронут). Наблюдаемость — read-only блок `transition_lease` в `GET /queue` + + Telegram-алерт на форсированный/устаревший реклейм + опциональный + `POST /transition-lease/release?work_item=`. Покрытие — `tests/test_orch114_transition_ownership.py` + (TC-01 обязательный регресс класса ORCH-111: красный до фикса, зелёный после; TC-02…TC-14). Детали — + `docs/work-items/ORCH-114/06-adr/ADR-001-transition-ownership-lease-and-stage-cas.md`, сквозной + `docs/architecture/adr/adr-0045-transition-ownership-lease-and-stage-cas.md`. + ## Машинный журнал уроков (ORCH-098) Шаг 1 («Фундамент», F2) эпика саморазвития: формализует свободнотекстовые «уроки» из `memory/` в **машинную структурированную таблицу отклонений конвейера** `lessons`, фундамент для будущих diff --git a/docs/overview/tech-architecture.md b/docs/overview/tech-architecture.md index a639087..cacfc6c 100644 --- a/docs/overview/tech-architecture.md +++ b/docs/overview/tech-architecture.md @@ -32,6 +32,7 @@ worker запустил агента стадии → результат про | **Очередь задач** (`jobs` + worker) | Собственная очередь на SQLite: атомарный захват job'а, ретраи с backoff, зависимости между job'ами, ограничение параллелизма. | | **State machine** (`src/stages.py`) | Карта стадий `STAGE_TRANSITIONS`: для каждой стадии — следующая, агент и гейт выхода. Единственный источник истины о конвейере. | | **Stage engine** (`src/stage_engine.py`) | Исполняет переходы: диспетчеризация гейтов, откаты, под-гейты деплойного ребра, синхронизация статусов с Plane. | +| **Transition-lease** (`src/transition_lease.py`) | Durable-владение side-effectful переходом стадии: один владелец на задачу (lease на входе + expected-stage CAS на записи), liveness по pid+boot-id. Не даёт конкурентному или после-рестартовому повторному входу дважды применить необратимый эффект (merge / деплой / ratchet). | | **Agent launcher** (`src/agents/launcher.py`) | Запускает Claude CLI агента в изолированном git worktree ветки задачи, следит за процессом (watchdog), авто-продвигает стадию по завершении. | | **Реестр гейтов** (`src/qg/checks.py`) | `QG_CHECKS` — машинные проверки выхода со стадий; вердикты читаются только из YAML-frontmatter артефактов. | | **Plane-sync** (`src/plane_sync.py`) | Индикация статусов в Plane (слой «показать человеку», никогда не управление конвейером). | diff --git a/docs/overview/tech-data-model.md b/docs/overview/tech-data-model.md index e3be005..bf2cdf1 100644 --- a/docs/overview/tech-data-model.md +++ b/docs/overview/tech-data-model.md @@ -47,6 +47,7 @@ deploy-лога; манифест — [PIPELINE_DOCS](../_standards/PIPELINE_DOC | `coverage_baseline` | базовая линия покрытия тестами; растёт только вверх (ratchet) | | `tracker_messages` | леджер всех Telegram-карточек задачи (зачистка сирот) | | `lessons` | машинный журнал уроков — структурированные отклонения конвейера | +| `transition_lease` | durable-владение side-effectful переходом стадии: один владелец на задачу, liveness по pid+boot-id (предотвращает двойное применение необратимых эффектов) | Все изменения схемы — аддитивные и идемпотентные (`CREATE TABLE IF NOT EXISTS`, ensure-column при старте): обновление платформы не требует ручных миграций. diff --git a/docs/overview/tech-observability.md b/docs/overview/tech-observability.md index 27a513b..0a10794 100644 --- a/docs/overview/tech-observability.md +++ b/docs/overview/tech-observability.md @@ -20,8 +20,9 @@ ## Служебные страницы платформы - **`GET /queue`** — человекочитаемый снимок всего конвейера: очередь и job'ы, состояние - serial gate и заморозок, авто-лейблы, багфикс-трек, coverage, журнал уроков, фоновые - демоны. Первая точка диагностики «что сейчас происходит». + serial gate и заморозок, авто-лейблы, багфикс-трек, coverage, журнал уроков, владение + переходами (`transition_lease`), фоновые демоны. Первая точка диагностики «что сейчас + происходит». - **`GET /metrics`** — машинный контракт для внешнего наблюдателя (версионированная схема): health, возраст последних событий, счётчики сбоев. - **`GET /health`** — живость процесса. diff --git a/src/stage_engine.py b/src/stage_engine.py index a2a38ce..9e32e10 100644 --- a/src/stage_engine.py +++ b/src/stage_engine.py @@ -1233,6 +1233,31 @@ def _merge_gate_infra_retry_impl( ) +def _rollback_stage_cas(task_id, current_stage, repo, result: AdvanceResult) -> bool: + """ORCH-114 (ADR-001 D4): write a rollback stage (`development`) through the + expected-stage CAS — the same contract as the forward/bypass writes. + + Returns True iff the write was applied (the caller proceeds with the rollback side + effects); False iff the CAS was lost (the caller MUST abort WITHOUT side effects). + + These in-region rollback handlers run inside ``advance_stage`` under the held + transition-lease, so this is the sole owner and the CAS practically always wins. A + lost race means a concurrent winner already advanced this task (e.g. to ``done``) — + rolling back to ``development`` would be exactly the rollback↔done contradiction + BR-6 guards against, so we abort instead of a blind overwrite. Kill-switch off / + repo out of scope -> commit_stage_cas degenerates to the prior unconditional + ``update_task_stage`` (always True) -> byte-for-byte (AC-9). + """ + if transition_lease.commit_stage_cas(task_id, current_stage, "development", repo): + return True + logger.info( + f"Task {task_id}: rollback stage-CAS lost on {current_stage}->development " + f"— aborting rollback without side effects (a concurrent winner advanced)" + ) + result.note = "rollback-cas-lost" + return False + + def _handle_merge_gate_rollback( task_id, current_stage, repo, work_item_id, branch, reason, result: AdvanceResult ): @@ -1243,7 +1268,8 @@ def _handle_merge_gate_rollback( already released by check_branch_mergeable on failure; a defensive holder-aware release here is a harmless no-op. """ - update_task_stage(task_id, "development") + if not _rollback_stage_cas(task_id, current_stage, repo, result): + return notify_stage_change(task_id, current_stage, "development") plane_notify_stage(work_item_id, current_stage, "development") result.rolled_back_to = "development" @@ -1320,7 +1346,8 @@ def _handle_security_gate( result.qg_passed = False result.qg_reason = reason - update_task_stage(task_id, "development") + if not _rollback_stage_cas(task_id, current_stage, repo, result): + return True notify_stage_change(task_id, current_stage, "development") plane_notify_stage(work_item_id, current_stage, "development") result.rolled_back_to = "development" @@ -1408,7 +1435,8 @@ def _handle_coverage_gate( result.qg_passed = False result.qg_reason = reason - update_task_stage(task_id, "development") + if not _rollback_stage_cas(task_id, current_stage, repo, result): + return True notify_stage_change(task_id, current_stage, "development") plane_notify_stage(work_item_id, current_stage, "development") result.rolled_back_to = "development" @@ -1488,7 +1516,8 @@ def _handle_image_freshness( result.qg_passed = False result.qg_reason = reason - update_task_stage(task_id, "development") + if not _rollback_stage_cas(task_id, current_stage, repo, result): + return True notify_stage_change(task_id, current_stage, "development") plane_notify_stage(work_item_id, current_stage, "development") result.rolled_back_to = "development" diff --git a/tests/test_orch114_transition_ownership.py b/tests/test_orch114_transition_ownership.py index 6b6389d..caf168e 100644 --- a/tests/test_orch114_transition_ownership.py +++ b/tests/test_orch114_transition_ownership.py @@ -19,7 +19,11 @@ import tempfile import pytest -os.environ.setdefault("ORCH_DB_PATH", os.path.join(tempfile.gettempdir(), "test_orch114.db")) +# NB: deliberately NO module-level os.environ["ORCH_DB_PATH"] setdefault — pinning the +# process-wide settings.db_path on first import is needless here (the autouse `fresh_db` +# fixture below isolates db_path per-test via monkeypatch). The cross-module settings +# singleton (e.g. ORCH_PROJECTS_JSON) is whoever imports `src` first; test_webhooks now +# pins its own registry per-test rather than relying on import order (ORCH-114 review P2). os.environ.setdefault("ORCH_REPOS_DIR", tempfile.gettempdir()) os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") @@ -525,6 +529,71 @@ def test_tc11_bypass_paths_use_cas_not_unconditional_write(): assert "commit_stage_cas(task_id, current_stage, next_stage" in asrc +def test_tc11_inregion_rollback_writes_use_cas(monkeypatch): + """ADR-001 D4: the four side-effectful-edge rollback handlers + (_handle_merge_gate_rollback / _handle_security_gate / _handle_coverage_gate / + _handle_image_freshness) write `development` through the expected-stage CAS + (via _rollback_stage_cas), NOT a bare unconditional update_task_stage. (The + non-side-effectful launcher rollbacks in _handle_qg_failure_rollbacks are out of + scope — no lease is held there.)""" + for fn in ( + se._handle_merge_gate_rollback, + se._handle_security_gate, + se._handle_coverage_gate, + se._handle_image_freshness, + ): + src = inspect.getsource(fn) + assert "_rollback_stage_cas(task_id, current_stage, repo, result)" in src, ( + f"{fn.__name__} must route the rollback write through the CAS helper" + ) + assert 'update_task_stage(task_id, "development")' not in src, ( + f"{fn.__name__} must not do a bare unconditional rollback write" + ) + # The helper itself goes through commit_stage_cas. + assert "commit_stage_cas(task_id, current_stage" in inspect.getsource( + se._rollback_stage_cas + ) + + +def test_tc11_rollback_cas_wins_when_at_expected_stage(monkeypatch): + """With the mechanism ON, a rollback whose task is STILL at current_stage wins the + CAS -> the stage is written to `development` and the caller proceeds (returns True).""" + _enable(monkeypatch) + tid = _make_task(stage="deploy-staging") + result = se.AdvanceResult() + assert se._rollback_stage_cas(tid, "deploy-staging", _REPO, result) is True + assert _task_stage(tid) == "development" + assert result.note != "rollback-cas-lost" + + +def test_tc11_rollback_cas_lost_aborts_without_overwriting_done(monkeypatch): + """BR-6 / ADR-001 D4: if a concurrent winner already advanced the task to `done`, + the stale rollback LOSES the expected-stage CAS -> it must NOT overwrite `done` + with `development`, and the caller aborts the rollback side effects.""" + _enable(monkeypatch) + tid = _make_task(stage="deploy-staging") + # Simulate a concurrent winner having advanced the task to terminal `done`. + conn = get_db() + conn.execute("UPDATE tasks SET stage='done' WHERE id=?", (tid,)) + conn.commit() + conn.close() + result = se.AdvanceResult() + # The rollback still believes current_stage is deploy-staging (its read-on-entry). + assert se._rollback_stage_cas(tid, "deploy-staging", _REPO, result) is False + assert _task_stage(tid) == "done" # NOT clobbered back to development + assert result.note == "rollback-cas-lost" + + +def test_tc11_rollback_cas_killswitch_off_unconditional(monkeypatch): + """Kill-switch off -> _rollback_stage_cas degenerates to the prior unconditional + write (always True, no CAS), so behaviour is byte-for-byte pre-ORCH-114 (AC-9).""" + _disable(monkeypatch) + tid = _make_task(stage="done") # even a mismatched stage writes unconditionally + result = se.AdvanceResult() + assert se._rollback_stage_cas(tid, "deploy-staging", _REPO, result) is True + assert _task_stage(tid) == "development" + + # =========================================================================== # TC-12 — observability (AC-12) # =========================================================================== diff --git a/tests/test_webhooks.py b/tests/test_webhooks.py index c034ee8..67c917d 100644 --- a/tests/test_webhooks.py +++ b/tests/test_webhooks.py @@ -25,6 +25,28 @@ os.environ["ORCH_PROJECTS_JSON"] = ( from fastapi.testclient import TestClient from src.main import app from src.db import init_db, get_db +from src import projects as projects_mod + + +@pytest.fixture(autouse=True) +def proj_registry(): + """Pin the shared project registry to proj-1/enduro-trails for each test. + + The registry (projects.PROJECTS / _BY_PLANE_ID) is a process-wide singleton built + at FIRST `src` import: this module's import-time ORCH_PROJECTS_JSON only wins if + test_webhooks happens to import `src` before any other module (true when it runs + right after test_webhook_dedup, false for an arbitrary subset like + `pytest test_orch114… test_webhooks`). Forcing the registry per-test makes these + fixtures order-independent (mirrors test_webhook_dedup.proj_registry; ORCH-114 + review P2).""" + os.environ["ORCH_PROJECTS_JSON"] = ( + '[{"plane_project_id": "proj-1", "repo": "enduro-trails", ' + '"work_item_prefix": "ET", "name": "enduro-trails"}]' + ) + projects_mod.settings.projects_json = os.environ["ORCH_PROJECTS_JSON"] + projects_mod.reload_projects() + yield + projects_mod.reload_projects() @pytest.fixture(autouse=True) From d4eca78423816aa87af155ee3357eb101c96e865 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 15 Jun 2026 19:21:33 +0300 Subject: [PATCH 7/9] reviewer(ET): auto-commit from reviewer run_id=713 --- docs/work-items/ORCH-114/12-review.md | 176 ++++++++++++-------------- 1 file changed, 84 insertions(+), 92 deletions(-) diff --git a/docs/work-items/ORCH-114/12-review.md b/docs/work-items/ORCH-114/12-review.md index d376565..5d040aa 100644 --- a/docs/work-items/ORCH-114/12-review.md +++ b/docs/work-items/ORCH-114/12-review.md @@ -1,36 +1,68 @@ --- -verdict: REQUEST_CHANGES +verdict: APPROVED # APPROVED | REQUEST_CHANGES — строго одно из двух, UPPERCASE work_item: ORCH-114 stage: review author_agent: reviewer -status: changes-requested +status: approved created_at: 2026-06-15 model_used: claude-opus-4-8 type: review work_item_id: ORCH-114 -version: 1 +version: 2 --- # Review ORCH-114 — Durable transition-ownership lease + expected-stage CAS ## Summary -Сильная, аккуратная реализация корневого класса инцидент-цепочки ORCH-110/111/112/113. -Архитектура (durable-lease на входе + expected-stage CAS на записи, liveness по `pid`+`boot_id` -без heartbeat, self-hosting-скоуп, kill-switch, never-raise) реализована **в точном соответствии -с ADR-001 D1–D10**. Код-инварианты конвейера соблюдены: `src/stages.py` и `src/qg/checks.py` -**не тронуты** (AC-11), схема БД аддитивна (одна таблица `transition_lease`, без epoch-колонки), -hot-path `claim_next_job` не консультирует lease (AC-10/fail-open). Прогон **полного** `pytest -tests/` — **2048 passed** в детерминированном порядке (плагина random-ordering нет), т.е. AC-9 -(байт-в-байт при kill-switch off) и CI-green выполняются операционно. Тесты содержательные: -TC-01a/b воспроизводят обязательный регресс класса ORCH-111 (kill-switch off = «красный», -on = «зелёный»), TC-02…TC-14 покрывают AC-2…AC-13. +Повторное ревью (цикл 2). Прошлый вердикт был `REQUEST_CHANGES` из-за **частичной +незавершённости документации golden-source** (два P1 + три P2/P3). Кода-корректность и +инварианты конвейера уже тогда были без P0/P1. В этом цикле developer **закрыл все ранее +поднятые findings**: -Однако вердикт — **REQUEST_CHANGES**: документация (golden source наравне с кодом) обновлена -**частично**. Архитектурные доки (`docs/architecture/README.md`, `internals.md`, `CHANGELOG.md`, -ADR-001 + сквозной adr-0045) — образцовы, но **`.env.example` и `CLAUDE.md` не обновлены** (P1), -а витрина `docs/overview/` (ORCH-011) не отражает новый механизм (P2). Плюс две незначительные -оси-уточнения (P2). +- **P1 `.env.example`** → закрыт: добавлены `ORCH_TRANSITION_LEASE_ENABLED=true` / + `ORCH_TRANSITION_LEASE_REPOS=` с подробной нормативной шапкой (рядом с блоком reaper-флагов). +- **P1 `CLAUDE.md`** → закрыт: добавлена полноценная паспорт-секция «Единое владение + side-effectful переходами: durable-lease + expected-stage CAS (ORCH-114)» (механизм, два + слоя, инвариант, флаги, ADR-ссылки) — по образцу ORCH-112/113. +- **P2 витрина `docs/overview/`** → закрыта: `tech-data-model.md` (строка таблицы + `transition_lease`), `tech-observability.md` (упоминание блока `/queue`), + `tech-architecture.md` (компонент `Transition-lease`). +- **P2 расхождение код↔ADR D4 (CAS на rollback-записях)** → закрыто: введён общий хелпер + `_rollback_stage_cas`, и все четыре in-region rollback-записи (`_handle_merge_gate_rollback`/ + `_handle_security_gate`/`_handle_coverage_gate`/`_handle_image_freshness`) теперь пишут + `development` через тот же expected-stage CAS. Реализация совпала с собственным ADR D4; + добавлены целевые тесты (TC-11 `test_tc11_inregion_rollback_writes_use_cas`, + `test_tc11_rollback_cas_lost_aborts_without_overwriting_done`). +- **P2 изоляция тестов** → закрыта: убран module-level `os.environ.setdefault("ORCH_DB_PATH")`, + `test_webhooks.py` пинит собственный реестр per-test, добавлен autouse-fixture + `_disable_transition_lease` (kill-switch off для всего suite по образцу `_disable_merge_verify`). + +Архитектура реализована **в точном соответствии с ADR-001 D1–D10**: durable-lease на входе +(`acquire`/`release` в `try/finally`) + expected-stage CAS на записи (включая 6 путей в обход +`advance_stage`), liveness по `pid`+`boot_id` без heartbeat, реклейм при рестарте +(`recover_on_startup` в `main.lifespan`), reaper/reconciler/webhook defer при живом владельце, +Tier-3 backstop добивает зависшего. `src/transition_lease.py` — чистый never-raise leaf +(импортирует только `db`+`config`, лениво `merge_gate.pid_alive`/`qg.checks`/`notifications`). + +**Инварианты конвейера (AC-11):** `src/stages.py` и `src/qg/checks.py` **не тронуты** (нет в +диффе), `STAGE_TRANSITIONS`/`QG_CHECKS` встречаются в src-диффе только в комментарии; +machine-verdict ключи не тронуты; БД аддитивна (одна таблица `transition_lease`, +`CREATE TABLE IF NOT EXISTS`, без epoch-колонки). hot-path `claim_next_job` lease не +консультирует (AC-10/fail-open). + +**Багфикс-трек (ORCH-019/BR-4):** задача `bug→escalate full-cycle`. Регресс-тест-фиксатор +**присутствует**: `test_tc01_concurrent_entry_no_double_effect` (зелёный с lease) + +`test_tc01_red_before_fix_demonstration` (красный при kill-switch off — второй актор +переисполняет все под-гейты, воспроизводя класс ORCH-111). Требование выполнено. + +**Прогоны (verified):** +- `pytest tests/test_orch114_transition_ownership.py tests/test_webhooks.py` — **50 passed** + (именно та комбинация двух модулей, что в прошлом цикле давала 4 падения — изоляция + подтверждённо починена). +- `pytest tests/` (полный) — **2052 passed** в детерминированном порядке → AC-9 (байт-в-байт + при kill-switch off) и CI-green выполняются операционно; ORCH-113-тесты в наборе зелёные + (контракт предшественника не сломан, ORCH-078). ## Findings @@ -38,90 +70,50 @@ ADR-001 + сквозной adr-0045) — образцовы, но **`.env.exampl - _(нет)_ ### P1 — Must fix -- [ ] **`.env.example` не содержит новых ключей конфигурации** `ORCH_TRANSITION_LEASE_ENABLED` - и `ORCH_TRANSITION_LEASE_REPOS` (добавлены в `src/config.py:transition_lease_enabled`/ - `transition_lease_repos`). ORCH-101 нормативно объявил `.env.example` **«каноном 100% ключей - старта»**, и все сопоставимые kill-switch'и там присутствуют (`ORCH_SERIAL_GATE_ENABLED:220`, - `ORCH_CHECKOUT_HYGIENE_ENABLED:349`, `ORCH_COVERAGE_GATE_ENABLED:511`). Изменение конфигурации - без отражения в каноническом env-файле — нарушение оси «документация = golden source» (доступ - оператора к kill-switch'у). **Fix:** добавить обе строки в `.env.example` (с дефолтами - `true`/пусто) рядом с блоком прочих gate-флагов. - _Ссылка: правило документирования CLAUDE.md §2 («конфигурация → env-канон»), ORCH-101 (adr-0036)._ - -- [ ] **`CLAUDE.md` не дополнен паспорт-секцией ORCH-114.** ORCH-114 — крупный кросс-каттинговый - механизм (новая durable-таблица, новый leaf `src/transition_lease.py`, врезки в `advance_stage`/ - `job_reaper`/`reconciler`/обоих webhook'ах/`main.lifespan`, новый эндпоинт, новый инвариант - владения переходом). Конвенция паспорта **универсальна**: каждая сопоставимая фича несёт секцию - в `CLAUDE.md` (ORCH-112/110/098/094/093/090/089/088/027/019…), а футер прямо гласит «Поддерживается - агентами при каждой доработке». Для self-hosting это критично: будущий агент, правящий - `advance_stage`/reaper/webhooks, обязан найти инвариант ORCH-114 в первом обязательном к чтению - доке (ORCH-078 трассировка опирается на паспорт как индекс). **Fix:** добавить секцию ORCH-114 - в `CLAUDE.md` по образцу ORCH-112/113 (механизм, инвариант, флаги, ADR-ссылки). - _Ссылка: CLAUDE.md правила §1, §2 + футер; ORCH-078 (TRACEABILITY)._ +- _(нет)_ ### P2 — Should fix -- [ ] **Витрина системы `docs/overview/` не обновлена (ORCH-011).** Добавлена durable-таблица - `transition_lease` и read-only блок `transition_lease` в `GET /queue`, но: - `tech-data-model.md` (раздел «Вспомогательные таблицы», строки 46–49) перечисляет - `repo_freeze`/`coverage_baseline`/`tracker_messages`/`lessons` — без `transition_lease`; - `tech-observability.md` (перечень блоков `/queue`, стр. 22–23) и `tech-architecture.md` - (список компонентов с job-reaper, стр. 46) тоже не упоминают механизм. Индекс витрины прямо - требует «изменил функциональность платформы → обнови витрину `docs/overview/` в том же PR» - (`docs/overview/README.md:66`). Понижено до P2 (не стадия/гейт/агент/интеграция → машинные - проверки `tests/test_system_docs.py` это не ловят), но completeness-норму витрины нарушает. - **Fix:** добавить строку `transition_lease` в таблицу `tech-data-model.md`, упоминание блока - в `tech-observability.md`, компонент в `tech-architecture.md`. - -- [ ] **Расхождение код ↔ ADR D4 по CAS на rollback-записях.** ADR-001 D4 (таблица) предписывает - rollback-записям `_handle_*_rollback` **CAS** (столбец «да») как защиту rollback↔done (BR-6). - В коде rollback-записи на side-effectful рёбрах остались голым `update_task_stage(task_id, - "development")` — `_handle_merge_gate_rollback:1246`, `_handle_security_gate:1323`, - `_handle_coverage_gate:1411`, `_handle_image_freshness:1491`. **Корректность не нарушена:** эти - хендлеры вызываются строго внутри `advance_stage` (стр. 378–407) под удерживаемым lease → - единственный владелец → конкурентного противоречия нет. Но налицо буквальное расхождение - реализации с собственным ADR. **Fix (на выбор):** либо провести эти записи через - `transition_lease.commit_stage_cas(...)` (как в forward/bypass-путях), либо уточнить ADR D4, что - под lease CAS на in-region rollback избыточен (чтобы доки соответствовали коду). - _Ссылка: ADR-001 D4/D5, BR-6, AC-6._ - -- [ ] **Изоляция тестов: модуль `tests/test_orch114_transition_ownership.py` протекает в - процесс-wide `settings.db_path`.** Module-level `os.environ.setdefault("ORCH_DB_PATH", …)` - (стр. 22) при первом импорте инстанцирует `Settings` с `db_path=test_orch114.db`, а позднейший - `os.environ["ORCH_DB_PATH"]=…` в `tests/test_webhooks.py:9` уже не влияет (settings создан) → - при прогоне двух модулей вместе (`pytest tests/test_orch114_transition_ownership.py - tests/test_webhooks.py`) **4 существующих webhook-теста падают** (`assert 'architecture' == - 'analysis'` и др.). **Полный `pytest tests/` зелёный** (промежуточные модули перетирают - состояние; плагина random-ordering нет) → CI/coverage-gate **не** ломаются, поэтому P2, не - блокер. Но developer/reviewer, гоняющие подмножество, получат ложные падения. **Fix:** - изолировать db_path модуля через conftest-fixture/собственный tmp без правки процесс-wide env - (паттерн `fresh_db` уже есть — достаточно убрать module-level `setdefault`). +- _(нет)_ ### P3 — Nice to have -- [ ] `reconciler`/`plane._try_advance_stage` зовут `is_held_by_live_owner(task_id)` без - предварительного `applies(repo)` (в отличие от reaper). Для out-of-scope, но включённого репо - (enduro при дефолте) это лишний безвредный `SELECT` (строк нет → `False`, defer не возникает). - Функционально корректно; при желании — добавить дешёвый `applies`-гард ради нулевого оверхеда. +- [ ] **Merge-lease не освобождается на (практически недостижимой) ветке CAS-lost в + rollback'е coverage/image-freshness.** В `_handle_coverage_gate`/`_handle_image_freshness` + `release_merge_lease` стоит **после** `_rollback_stage_cas`, поэтому при проигранном CAS + (`return True`) штатное освобождение merge-lease пропускается. Под держимым transition-lease + эта ветка практически недостижима (единственный владелец → CAS почти всегда выигрывает; чтобы + проиграть, нужен аномальный bypass-писатель, сдвинувший стадию с `deploy-staging`). Даже в + этом крайнем случае утечка **ограничена** собственным TTL+reclaim merge-lease (ORCH-043/065). + Корректность недвоения не нарушена. При желании — продублировать holder-aware + `release_merge_lease` до CAS-проверки (или задокументировать намеренный аборт). Не блокер. + _Ссылка: ADR-001 D1/D4, ORCH-027/058 rollback._ +- [ ] **`reconciler`/`plane._try_advance_stage` зовут `is_held_by_live_owner(task_id)` без + предварительного `applies(repo)`** (в отличие от reaper). Для out-of-scope, но включённого + репо (enduro при дефолте) это безвредный лишний `SELECT` (строк нет → `False`). Функционально + корректно; ради нулевого оверхеда можно добавить дешёвый `applies`-гард. (Перенос P3 из цикла 1 + — остаётся косметикой.) ## Документация -**Обновлено (образцово):** -- `docs/architecture/README.md` — новый компонент в списке, отдельная секция «Единое владение - side-effectful переходами», строка таблицы БД `transition_lease`, API-таблица - (`POST /transition-lease/release`), описание блока `/queue`. -- `docs/architecture/internals.md` — DDL `transition_lease`, lifespan-recovery, секция reaper. +**Обновлено и проверено (полно):** +- `.env.example` — `ORCH_TRANSITION_LEASE_ENABLED` / `ORCH_TRANSITION_LEASE_REPOS` (канон ключей + старта, ORCH-101). +- `CLAUDE.md` — паспорт-секция ORCH-114. +- `docs/overview/` — `tech-data-model.md` / `tech-observability.md` / `tech-architecture.md` + (витрина системы, ORCH-011). - `CHANGELOG.md` — подробная запись ORCH-114 в `[Unreleased]`. +- `docs/architecture/README.md` + `internals.md` — компонент, секция, таблица БД, API-эндпоинт + `POST /transition-lease/release`, блок `/queue`. - ADR: `docs/work-items/ORCH-114/06-adr/ADR-001-…` + сквозной `docs/architecture/adr/adr-0045-…` — полные, со сверкой по коду. -- Work-item доки `02-trz`/`03-acceptance-criteria`/`08-data-requirements`/`10-tech-risks` — на месте. +- Work-item доки `02-trz`/`03-acceptance-criteria`/`04-test-plan`/`08-data-requirements`/ + `10-tech-risks` — на месте. -**Нужно обновить (см. findings):** -- `.env.example` — добавить `ORCH_TRANSITION_LEASE_ENABLED` / `ORCH_TRANSITION_LEASE_REPOS` **(P1)**. -- `CLAUDE.md` — добавить паспорт-секцию ORCH-114 **(P1)**. -- `docs/overview/` (`tech-data-model.md` / `tech-observability.md` / `tech-architecture.md`) — - отразить таблицу/блок/компонент `transition_lease` **(P2)**. +**Необновлённой документации при изменении `src/` не выявлено.** Пунктов README «Известные +ограничения», закрываемых этим PR, нет. ## Verdict rationale -Любой P1 → `REQUEST_CHANGES`. Кода-корректность и инварианты конвейера — без P0/P1; блокирует -**частичная незавершённость документации golden-source** (`.env.example`, `CLAUDE.md`). После -закрытия двух P1 (и желательно P2) задача готова к повторному ревью — реализация по существу -зрелая. +Все P0/P1/P2 прошлого цикла закрыты; новых P0/P1 не выявлено по всем четырём осям +(ТЗ/AC, ADR, качество кода + обязательный регресс-тест багфикс-трека, документация +golden-source). Остаются только два P3-наблюдения (косметика/недостижимая граница), не +блокирующие приёмку. Полный `pytest tests/` зелёный (2052 passed), инварианты конвейера и +схемы существующих таблиц — байт-в-байт. → **APPROVED**. From 7490f4fac4cb6e635beeb13430554839bdafc20b Mon Sep 17 00:00:00 2001 From: claude-bot Date: Mon, 15 Jun 2026 19:24:46 +0300 Subject: [PATCH 8/9] tester(ET): auto-commit from tester run_id=714 --- docs/work-items/ORCH-114/13-test-report.md | 85 ++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 docs/work-items/ORCH-114/13-test-report.md diff --git a/docs/work-items/ORCH-114/13-test-report.md b/docs/work-items/ORCH-114/13-test-report.md new file mode 100644 index 0000000..56e85ce --- /dev/null +++ b/docs/work-items/ORCH-114/13-test-report.md @@ -0,0 +1,85 @@ +--- +result: PASS # PASS | FAIL — машинный вердикт, UPPERCASE +work_item: ORCH-114 +stage: testing +author_agent: tester +status: pass +created_at: 2026-06-15 +model_used: claude-opus-4-8 +type: test-report +work_item_id: ORCH-114 +--- + +# Test Report — ORCH-114 + +Durable transition-ownership lease + expected-stage CAS для side-effectful переходов стадий +(закрытие корневого класса инцидент-цепочки ORCH-110/111/112/113). + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 (plugins: cov-5.0.0, anyio-4.13.0, asyncio-0.23.8) +- Worktree: `/repos/_wt/orchestrator/feature_ORCH-114-bug-pipeline-stage-transitions/` +- Branch: `feature/ORCH-114-bug-pipeline-stage-transitions` +- Дата: 2026-06-15 + +## Предусловия +- Review-вердикт `12-review.md`: **APPROVED** (цикл 2, нет P0/P1/P2; только два P3-наблюдения). +- Тесты прогнаны в worktree ветки задачи (не в общем `/repos/orchestrator`) — анти-гонка checkout. + +## Smoke API (read-only, prod 8500) +- `GET /health` → `{"status":"ok","service":"orchestrator"}` — **OK**. +- `GET /status` → активная задача 103 (ORCH-114, stage=testing) видна — **OK**. +- `GET /queue` → блок `serial_gate` присутствует (ORCH-088), блок `auto_labels` присутствует + (ORCH-089) — **OK** (смок-инвариант соблюдён). +- Блок `transition_lease` в `/queue` прод-инстанса (8500) **отсутствует** — это **ожидаемо**, не + регресс: новый код ORCH-114 живёт в ветке/worktree и ещё не задеплоен в прод (стадия testing + предшествует deploy-staging/deploy). Наблюдаемость блока `transition_lease` покрыта unit-тестами + TC-12 (`test_tc12_queue_block_wired`). + +## Результаты — покрытие каждого TC из 04-test-plan.yaml + +| TC ID | Тип | Описание (кратко) | AC | Результат | +|-------|-----|-------------------|----|-----------| +| TC-01 | integration | ОБЯЗ. РЕГРЕСС: конкурентный вход в `advance_stage(deploy-staging)` — каждый side-effect ровно раз; красный до фикса, зелёный после | AC-1 | PASS | +| TC-02 | unit | CAS-запись стадии: первый writer rowcount=1, второй lost-race rowcount=0, без мутации | AC-2 | PASS | +| TC-03 | unit | Жизненный цикл владения: acquire/release в `try/finally` (норм + исключение), durable-видимость | AC-3 | PASS | +| TC-04 | integration | Reaper defer при живом владении за пределами Tier-2/deploy-staging; rowcount-guard сохранён | AC-4 | PASS | +| TC-05 | unit/integration | Reaper добивает мёртвое/устаревшее владение в Tier-3 backstop; бюджет-инвариант сохранён | AC-5 | PASS | +| TC-06 | integration | Умное восстановление при рестарте: сходимость к единственному исходу без повторного эффекта | AC-6 | PASS | +| TC-07 | integration | Reconciler F-1 defer/skip при активном lease; fail-safe консервативный skip | AC-7 | PASS | +| TC-08 | integration | Webhook-путь (Approved/Confirm Deploy) defer при активном lease; поздний сигнал не теряется | AC-8 | PASS | +| TC-09 | integration | Kill-switch off: lease инертен, CAS вырождается в безусловный write — байт-в-байт | AC-9 | PASS | +| TC-10 | unit | never-raise + fail-open (hot-path) / fail-closed (prod-safety) на ошибках БД/lease | AC-10 | PASS | +| TC-11 | unit | Структурный аудит: `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/вердикт-ключи байт-в-байт; хранилище аддитивно | AC-11 | PASS | +| TC-12 | integration | Наблюдаемость: блок `/queue`, Telegram-алерт на форсированный реклейм | AC-12 | PASS | +| TC-13 | unit | Self-hosting безопасность: нет рестарта прода / push в `main` / detached-вмешательства | AC-13 | PASS | +| TC-14 | integration | Полный регресс конвейера зелёный; happy-path deploy-staging/finalizer без двойных эффектов | BR-8 | PASS | + +**Сопоставление с `03-acceptance-criteria.md`:** все 13 AC покрыты соответствующими TC (см. колонку +AC). Каждый TC из `04-test-plan.yaml` (TC-01…TC-14) выполнен и совпал с `expected: PASS`. + +Детализация по dedicated-модулю `tests/test_orch114_transition_ownership.py` (34 теста, разбивка +TC-01…TC-13 на под-кейсы) — все PASSED. TC-14 — полный регресс `tests/`. + +## Вывод pytest + +Dedicated-модуль: +``` +tests/test_orch114_transition_ownership.py — 34 passed, 1 warning in 3.84s +``` + +Полный регресс (TC-14 / AC-9 / CI-green): +``` +2052 passed, 1 warning in 106.62s (0:01:46) +``` +(единственный warning — `PydanticDeprecatedSince20` в `src/config.py:8`, преждесуществующий, +не связан с ORCH-114.) + +Обязательный регресс класса ORCH-111 присутствует и зелёный: +`test_tc01_concurrent_entry_no_double_effect` (PASS с lease) + +`test_tc01_red_before_fix_demonstration` (демонстрация красного при kill-switch off). + +## Итог +**PASS** — полный pytest зелёный (2052 passed), все 14 TC выполнены и сопоставлены с 13 AC, smoke +read-only (`/health`/`/status`/`/queue` c блоками `serial_gate` + `auto_labels`) OK. Задача готова +к переходу на `deploy-staging`. From eb34324852508d69fcc31c9d36dd525c4da8c1c1 Mon Sep 17 00:00:00 2001 From: deploy-finalizer Date: Mon, 15 Jun 2026 19:35:56 +0300 Subject: [PATCH 9/9] deploy(ORCH-036): finalize SUCCESS for ORCH-114 --- docs/work-items/ORCH-114/14-deploy-log.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 docs/work-items/ORCH-114/14-deploy-log.md diff --git a/docs/work-items/ORCH-114/14-deploy-log.md b/docs/work-items/ORCH-114/14-deploy-log.md new file mode 100644 index 0000000..7b5c29f --- /dev/null +++ b/docs/work-items/ORCH-114/14-deploy-log.md @@ -0,0 +1,12 @@ +--- +deploy_status: SUCCESS +work_item: ORCH-114 +hook_exit_code: 0 +deployed_by: deploy-finalizer +--- + +# Deploy log — ORCH-036 executable self-deploy + +Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`. + +Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.