ORCH-114: durable transition-ownership lease + expected-stage CAS (fix double-effect/rollback↔done class) #138

Merged
admin merged 9 commits from feature/ORCH-114-bug-pipeline-stage-transitions into main 2026-06-15 19:35:58 +03:00
Owner

ORCH-114 — Ownership-lease для side-effectful переходов стадий + умное восстановление при старте

Закрывает корневой класс инцидент-цепочки ORCH-110/111/112/113: у side-effectful переходов стадий не было единого владения. advance_stage ре-ентерабельна и писала стадию «голым» UPDATE … WHERE id=? (без CAS), а ≥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 (новый leaf src/transition_lease.py + таблица transition_lease) — владение на ВХОДЕ в side-effectful регион: второй актор, увидев живого владельца, не стартует тяжёлые под-гейты вовсе (предотвращение, не починка постфактум).
  2. Expected-stage CAS (db.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 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 (Tier-2/deploy-staging) до durable cross-path lease (defer живого, реклейм мёртвого; Tier-3 backstop игнорирует маркер → bounded; реап force-освобождает lease).
  • reconciler F-1 и Plane-webhook (_try_advance_stage) делают defer при активном lease.
  • main.lifespan зовёт recover_on_startup() после requeue_running_jobs.
  • finalizer_liveness.py не правится — остаётся поведением при выключенном ORCH-114.

Инварианты

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 инертен, reaper → ORCH-113 fallback → поведение байт-в-байт до ORCH-114.

Наблюдаемость

Read-only блок transition_lease в GET /queue + Telegram-алерт на форсированный/устаревший реклейм + опциональный POST /transition-lease/release?work_item=<id>.

Тесты

tests/test_orch114_transition_ownership.py — TC-01 (обязательный регресс класса ORCH-111: красный до фикса, зелёный после) … TC-14. Полный pytest tests/ зелёный (2048 passed); 4 webhook-теста, шпионившие за удалённым gitea.update_task_stage, переведены на новый путь записи commit_stage_cas.

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

Refs: ORCH-114

## ORCH-114 — Ownership-lease для side-effectful переходов стадий + умное восстановление при старте Закрывает **корневой класс** инцидент-цепочки ORCH-110/111/112/113: у side-effectful переходов стадий не было единого владения. `advance_stage` ре-ентерабельна и писала стадию «голым» `UPDATE … WHERE id=?` (без CAS), а ≥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** (новый leaf `src/transition_lease.py` + таблица `transition_lease`) — владение на **ВХОДЕ** в side-effectful регион: второй актор, увидев живого владельца, не стартует тяжёлые под-гейты вовсе (предотвращение, не починка постфактум). 2. **Expected-stage CAS** (`db.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 `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 (Tier-2/`deploy-staging`) до durable cross-path lease (defer живого, реклейм мёртвого; Tier-3 backstop игнорирует маркер → bounded; реап force-освобождает lease). - reconciler F-1 и Plane-webhook (`_try_advance_stage`) делают **defer** при активном lease. - `main.lifespan` зовёт `recover_on_startup()` после `requeue_running_jobs`. - `finalizer_liveness.py` **не правится** — остаётся поведением при выключенном ORCH-114. ### Инварианты `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 инертен, reaper → ORCH-113 fallback → поведение **байт-в-байт** до ORCH-114. ### Наблюдаемость Read-only блок `transition_lease` в `GET /queue` + Telegram-алерт на форсированный/устаревший реклейм + опциональный `POST /transition-lease/release?work_item=<id>`. ### Тесты `tests/test_orch114_transition_ownership.py` — TC-01 (**обязательный** регресс класса ORCH-111: красный до фикса, зелёный после) … TC-14. Полный `pytest tests/` зелёный (**2048 passed**); 4 webhook-теста, шпионившие за удалённым `gitea.update_task_stage`, переведены на новый путь записи `commit_stage_cas`. ### 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` Refs: ORCH-114
admin added 8 commits 2026-06-15 19:28:41 +03:00
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=<id>.

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
tester(ET): auto-commit from tester run_id=714
All checks were successful
CI / test (push) Successful in 1m18s
CI / test (pull_request) Successful in 1m28s
7490f4fac4
admin force-pushed feature/ORCH-114-bug-pipeline-stage-transitions from e4528d26b4 to 7490f4fac4 2026-06-15 19:28:41 +03:00 Compare
admin merged commit db2fbd23e0 into main 2026-06-15 19:35:58 +03:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: admin/orchestrator#138