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