---
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")     # без побочных эффектов
          <enqueue next agent, notify, …>
      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=<id>` — операторский ручной реклейм (паттерн
  `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`
</content>
</invoke>
