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)