---
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 == <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 не активируется
  — нулевая регрессия.
</content>
