architect(ET): auto-commit from architect run_id=709
This commit is contained in:
@@ -1255,6 +1255,45 @@ finalizer-liveness ownership); детально —
|
||||
`docs/work-items/ORCH-065/06-adr/ADR-001-job-reaper-and-lease-reclaim.md`,
|
||||
`docs/work-items/ORCH-113/06-adr/ADR-001-reaper-finalizer-liveness-ownership.md`.
|
||||
|
||||
### Единое владение side-effectful переходами: durable transition-lease + stage-CAS (ORCH-114 — design)
|
||||
Корневой класс инцидент-цепочки ORCH-110/111/112/113: **у side-effectful переходов стадий
|
||||
нет единого владения**. `db.update_task_stage` — голый `UPDATE … WHERE id=?` без CAS;
|
||||
`advance_stage` ре-ентерабельна и исполняет минуты-длинные необратимые под-гейты
|
||||
(`deploy-staging→deploy`: security→merge-retest→coverage→image-freshness; `deploy→done`:
|
||||
`merge_pr`/ratchet/proof-of-merge) **до** единственной записи стадии. ≥5 акторов входят в переход
|
||||
независимо (монитор/webhook/reconciler F-1/reaper/Phase-C finalizer) + 6 путей пишут стадию в
|
||||
обход `advance_stage` (5× `gitea.py`, 1× `plane.py`). ORCH-113 (`finalizer_liveness`) закрыл это лишь
|
||||
in-memory, reaper-Tier-2, `deploy-staging`, теряя владение на рестарте. ORCH-114 **обобщает** ORCH-113
|
||||
до durable, кросс-путевого владения. Аддитивно, под kill-switch, never-raise; `STAGE_TRANSITIONS`/
|
||||
`QG_CHECKS`/`check_*`/вердикт-ключи/схемы существующих таблиц — байт-в-байт.
|
||||
- **Два комплементарных слоя.** (1) **Durable transition-lease** (владение на **входе** в
|
||||
side-effectful регион) — аддитивная таблица `transition_lease` (`task_id PK, owner, owner_pid,
|
||||
owner_boot_id, run_id, stage, acquired_at`; `CREATE TABLE IF NOT EXISTS`, паттерн `repo_freeze`/
|
||||
`coverage_baseline`); второй актор, увидев **живого** владельца, не стартует под-гейты. (2)
|
||||
**Expected-stage CAS** `update_task_stage_cas(task_id, expected_stage, new_stage)`
|
||||
(`UPDATE … WHERE id=? AND stage=?`, rowcount==1 ⇒ выиграл; 0 ⇒ проиграл → аборт без побочных эффектов)
|
||||
— покрывает остаточное окно гонки И 6 обходных путей. Без epoch-колонки: стадия *и есть* версия CAS.
|
||||
- **Liveness владельца = `owner_pid` + `owner_boot_id`, НЕ heartbeat** (heartbeat отвергнут доводом
|
||||
adr-0043: блокирующий 900s re-test не может его бить). Рестарт ⇒ новый boot-id ⇒ прежние lease мертвы
|
||||
⇒ реклеймятся; зависший живой добивается Tier-3 `reaper_max_running_s` (lease backstop не обходит).
|
||||
- **Осведомлённость акторов:** reaper консультирует durable-lease на **всех** путях (обобщение ORCH-113:
|
||||
живой → defer, мёртвый → реклейм); reconciler F-1 и webhook (Approved/Confirm Deploy) — новый skip-guard
|
||||
по образцу escalated/Blocked/task-deps. `finalizer_liveness` сохранён без правок как поведение при
|
||||
**выключенном** ORCH-114 (надстройка durable-слоя поверх).
|
||||
- **Умное восстановление (рестарт)** — НЕ новый recovery-мозг, а композиция: `requeue_running_jobs` +
|
||||
startup stale-clear (boot-id mismatch) + идемпотентность re-drive через **существующие авторитетные
|
||||
факты** (SHA-in-main ORCH-071/073, `INITIATED` ORCH-036, coverage-ratchet CAS ORCH-027).
|
||||
- **Бюджет (NFR-6):** lease без собственного TTL, потолок = Tier-3 `reaper_max_running_s`; сквозной
|
||||
инвариант `5400 > Σ(≈4460)+grace` и `reaper_finalize_grace_s`/`reaper_max_running_s` — не тронуты.
|
||||
- **Флаги:** `transition_lease_enabled` (kill-switch; `False` → байт-в-байт до-ORCH-114, CAS вырождается
|
||||
в прежний `update_task_stage`, reaper → ORCH-113 fallback) + `transition_lease_repos` (CSV; **пусто →
|
||||
self-hosting only**, enduro не затронут). Hot-path `claim_next_job` не тронут (fail-open, AC-8 ORCH-088).
|
||||
Leaf `src/transition_lease.py` never-raise. Наблюдаемость — read-only блок `transition_lease` в
|
||||
`GET /queue` + Telegram-алерт на форсированный/устаревший реклейм + опц. `POST /transition-lease/release`.
|
||||
|
||||
Подробнее: [adr-0045](adr/adr-0045-transition-ownership-lease-and-stage-cas.md); детально —
|
||||
`docs/work-items/ORCH-114/06-adr/ADR-001-transition-ownership-lease-and-stage-cas.md`.
|
||||
|
||||
### Осмысленная статусная модель Plane (ORCH-066 — реализовано)
|
||||
Plane-доска была семантически перегружена: `In Progress` означал «человек запускает
|
||||
конвейер», «идёт анализ», «идёт прод-деплой» и «возврат из Needs Input» одновременно.
|
||||
@@ -1332,6 +1371,7 @@ Monitoring after Deploy → Done
|
||||
- `job_deps` — декларативные зависимости задач (ORCH-026, Уровень B): `(task_id, depends_on_task_id)`, аддитивная; источник истины планировщика для гейта «B ждёт A»
|
||||
- `repo_freeze` — durable per-repo rollback-freeze (ORCH-088, FR-5): `(id, repo, frozen_at, reason, work_item_id, cleared_at)`, аддитивная append-only; активный freeze ⇔ строка репо с `cleared_at IS NULL`. Выставляется post-deploy `DEGRADED` (`set_repo_freeze`), снимается вручную (`POST /serial-gate/unfreeze` → `cleared_at=now`). Гейтит serial-claim безусловно (деградировавшая задача уже `done`)
|
||||
- `lessons` — машинный журнал отклонений конвейера (ORCH-098, FR-1): `(id, created_at, updated_at, lesson_type, work_item_id, task_id, stage, agent, repo, root_cause, suggestion, status, related_task, attribution, target_repo, target_domain, source, detail)`, аддитивная идемпотентная (`CREATE TABLE IF NOT EXISTS` + три индекса); колонки атрибуции (`attribution`/`target_repo`/`target_domain`) — нуллабельны и присутствуют сразу (NFR-6), без `enum`-констрейнтов (слаги forward-compatible). Автозапись 4 типов (`gate_failure`/`merge_hold`/`transient_retry`/`deploy_degraded`, `source="auto"`, дедуп в окне `lessons_dedup_window_s`) + ручная (`source="manual"`); observer-only (не участвует в решении гейта). Leaf `src/lessons.py` never-raise, kill-switch `lessons_enabled` (без `*_repos` — журнал не скоупится по репо, репо-разрез на выборке)
|
||||
- `transition_lease` — durable-владение side-effectful переходом стадии (ORCH-114, FR-1): `(task_id PK, owner, owner_pid, owner_boot_id, run_id, stage, acquired_at)`, аддитивная идемпотентная (`CREATE TABLE IF NOT EXISTS`, паттерн `repo_freeze`/`coverage_baseline`). Активная строка ⇔ актор держит владение переходом задачи; **живой** владелец ⇔ `owner_boot_id == <boot-id процесса>` И `pid_alive(owner_pid)` (рестарт ⇒ новый boot-id ⇒ прежние lease мертвы → реклейм). Захват — атомарный rowcount-guard (паттерн `claim_next_job`/`reap_running_job`); release в `try/finally`; потолок возраста = Tier-3 `reaper_max_running_s` (без собственного TTL — NFR-6). Парная CAS-запись стадии — `update_task_stage_cas(task_id, expected_stage, new_stage)` (`UPDATE … WHERE id=? AND stage=?`). Leaf `src/transition_lease.py` never-raise, kill-switch `transition_lease_enabled` + `transition_lease_repos` (пусто → self-hosting only). Обобщает in-memory `finalizer_liveness` (ORCH-113) до durable cross-path; схемы существующих таблиц не тронуты
|
||||
|
||||
## Изоляция (git worktree, ORCH-2)
|
||||
Каждая задача исполняется в отдельном git worktree, ветки не пересекаются. Репозитории проектов разделены под `/repos/<project>`.
|
||||
@@ -1341,9 +1381,10 @@ Monitoring after Deploy → Done
|
||||
|--------|------|----------|
|
||||
| GET | `/health` | health check |
|
||||
| GET | `/status` | активные задачи (stage != done) |
|
||||
| GET | `/queue` | очередь: counts + max_concurrency + resilience + reconcile (ORCH-053) + reaper (ORCH-065) + post_deploy (ORCH-021) + task_deps (ORCH-026) + serial_gate (ORCH-088) + auto_labels (ORCH-089) + stop (ORCH-090) + lessons (ORCH-098) + последние jobs |
|
||||
| GET | `/queue` | очередь: counts + max_concurrency + resilience + reconcile (ORCH-053) + reaper (ORCH-065) + post_deploy (ORCH-021) + task_deps (ORCH-026) + serial_gate (ORCH-088) + auto_labels (ORCH-089) + stop (ORCH-090) + lessons (ORCH-098) + transition_lease (ORCH-114) + последние jobs |
|
||||
| GET | `/metrics` | ORCH-099 (FND/F1a): read-only машинное «сырьё» для sidecar F1b — конверт `schema_version`/`generated_at`/`clk_tck` + разделы `stages`/`queue`/`agents` (liveness: pid/runtime/cpu_ticks)/`cost`. never-raise по разделам; kill-switch `ORCH_METRICS_ENABLED` (дефолт `True`). Контракт — см. раздел «Сырьё-эндпоинт `/metrics`» |
|
||||
| POST | `/serial-gate/unfreeze` | ORCH-088 (FR-5): ручное снятие per-repo rollback-freeze (query/body `repo=<repo>`) → `{ok, repo, cleared, frozen}`; идемпотентно. Альтернатива — `UPDATE repo_freeze SET cleared_at=datetime('now') WHERE repo=? AND cleared_at IS NULL` |
|
||||
| POST | `/transition-lease/release` | ORCH-114 (FR-6, **опц.**): операторский ручной реклейм застрявшего владения переходом (query/body `work_item=<id>`) → `{ok, task_id, released}`; идемпотентно (паттерн `/serial-gate/unfreeze`). При выключенном `transition_lease_enabled` → no-op |
|
||||
| GET | `/lessons` | ORCH-098 (FR-4): read-only выборка журнала уроков; query-фильтры `type`/`status`/`repo`/`work_item`/`limit` → `{enabled, lessons:[…]}` (всегда `200`, чтение не мутирует). При `lessons_enabled=False` → `{enabled:false, lessons:[]}` |
|
||||
| POST | `/lessons` | ORCH-098 (FR-5): ручная запись урока (JSON-тело, `lesson_type` обязателен, `source="manual"` не дедупится) → `{id}`; при выключенном флаге → `{enabled:false}` |
|
||||
| POST | `/lessons/{id}` | ORCH-098 (FR-5): доклассификация/обновление урока (`status`/`attribution`/`target_*`/`related_task`/`root_cause`/`suggestion`), стампит `updated_at` → `{ok}` |
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
work_item: ORCH-114
|
||||
stage: architecture
|
||||
author_agent: architect
|
||||
status: proposed
|
||||
created_at: 2026-06-15
|
||||
model_used: claude-opus-4-8
|
||||
---
|
||||
|
||||
# adr-0045: Durable transition-ownership lease + expected-stage CAS — единое владение side-effectful переходами стадий
|
||||
|
||||
- **Статус:** proposed
|
||||
- **Дата:** 2026-06-15
|
||||
- **Задача:** ORCH-114 (bug → escalate full-cycle; системный наследник кластера ORCH-110/111/112/113)
|
||||
- **Детальный ADR:** `docs/work-items/ORCH-114/06-adr/ADR-001-transition-ownership-lease-and-stage-cas.md`
|
||||
- **Обобщает:** `adr-0043` (ORCH-113 in-memory finalizer-liveness — отправная точка)
|
||||
- **Уточняет/опирается:** `adr-0011` (reaper/lease-reclaim ORCH-065), `adr-0040` (бюджеты ORCH-109),
|
||||
`adr-0042` (merge-retest ORCH-110), `adr-0027` (merge-lease ORCH-043), `adr-0029` (coverage-ratchet ORCH-027),
|
||||
ORCH-071/073/093 (SHA-in-main / already-in-main), ORCH-036 (`INITIATED` self-deploy)
|
||||
|
||||
## Контекст
|
||||
|
||||
Корневой класс инцидент-цепочки ORCH-110/111/112/113: **у side-effectful переходов стадий нет единого
|
||||
владения**. `db.update_task_stage` — голый `UPDATE … WHERE id=?` без CAS (`db.py:671–679`); `advance_stage`
|
||||
ре-ентерабельна без защиты и исполняет минуты-длинные необратимые под-гейты (`deploy-staging → deploy`:
|
||||
security→merge-retest→coverage→image-freshness; `deploy → done`: `merge_pr`/ratchet/proof-of-merge) **до**
|
||||
единственной записи стадии. ≥5 акторов входят в переход независимо (монитор/webhook/reconciler F-1/reaper/
|
||||
Phase-C finalizer) + 6 путей пишут стадию в обход `advance_stage` (5× `gitea.py`, 1× `plane.py:806`).
|
||||
ORCH-113 (`finalizer_liveness`) закрыл это лишь in-memory, reaper-Tier-2, `deploy-staging`, теряя владение
|
||||
на рестарте — остаточный кросс-путь дал двойной эффект и противоречие rollback↔done (ORCH-111, job 1914/PR #130).
|
||||
|
||||
## Решение
|
||||
|
||||
Два комплементарных аддитивных слоя под единым kill-switch, never-raise:
|
||||
|
||||
1. **Durable transition-lease** — новая аддитивная таблица `transition_lease`
|
||||
(`task_id PK, owner, owner_pid, owner_boot_id, run_id, stage, acquired_at`; `CREATE TABLE IF NOT EXISTS`,
|
||||
паттерн `repo_freeze`/`coverage_baseline`). Владение захватывается на **входе** в side-effectful регион
|
||||
`advance_stage` (рёбра `deploy-staging→deploy`, `deploy→done`, Phase C `run_deploy_finalizer`); второй
|
||||
актор, увидев **живого** владельца, не стартует под-гейты вовсе (предотвращение класса, а не починка).
|
||||
Release — в `try/finally`. **Liveness = `owner_pid` + `owner_boot_id`**, НЕ heartbeat (heartbeat отвергнут
|
||||
тем же доводом, что в adr-0043: блокирующий 900s re-test не может его бить). Реклейм мёртвого/устаревшего
|
||||
(pid мёртв ИЛИ boot-id чужой) — немедленно; зависший живой добивается Tier-3.
|
||||
2. **Expected-stage CAS** — `update_task_stage_cas(task_id, expected_stage, new_stage)`
|
||||
(`UPDATE tasks SET stage=? … WHERE id=? AND stage=?`, rowcount==1 ⇒ выиграл; 0 ⇒ проиграл → аборт без
|
||||
побочных эффектов). Покрывает остаточное окно гонки И 6 обходных путей. Без epoch-колонки: для текущей
|
||||
модели стадия *и есть* версия (epoch — задокументированное форвард-расширение под `--workers>1`).
|
||||
|
||||
**Осведомлённость акторов:** reaper консультирует durable-lease на **всех** путях (обобщение ORCH-113):
|
||||
живой → defer, мёртвый → реклейм, Tier-3 маркер игнорирует; reconciler F-1 и webhook (Approved/Confirm
|
||||
Deploy) — новый skip-guard по образцу escalated/Blocked/task-deps. `finalizer_liveness` сохранён без правок
|
||||
как поведение при **выключенном** ORCH-114 (надстройка durable-слоя поверх).
|
||||
|
||||
**Умное восстановление (FR-4)** — НЕ новый recovery-мозг, а композиция: `requeue_running_jobs` (есть) +
|
||||
startup stale-clear (boot-id mismatch ⇒ старые lease мертвы) + идемпотентность re-drive через
|
||||
**авторитетные durable-факты предшественников** (SHA-in-main ORCH-071/073, `INITIATED` ORCH-036,
|
||||
coverage-ratchet CAS ORCH-027). Lease лишь гарантирует **последовательную**, не конкурентную, их проверку.
|
||||
|
||||
**Бюджет (NFR-6):** lease без собственного TTL; жёсткий потолок возраста = Tier-3 `reaper_max_running_s`
|
||||
(5400), reaper при реапе force-освобождает lease. Сквозной инвариант `5400 > Σ(≈4460)+grace` и
|
||||
`reaper_finalize_grace_s`/`reaper_max_running_s` — **не тронуты**.
|
||||
|
||||
**Конфиг:** `transition_lease_enabled=True` (kill-switch) + `transition_lease_repos=""` (CSV; пусто →
|
||||
self-hosting only, паттерн coverage/serial-gate). Leaf `src/transition_lease.py` never-raise.
|
||||
|
||||
**Инварианты:** `STAGE_TRANSITIONS` / `QG_CHECKS` / каждый `check_*` / machine-verdict-ключи / схемы
|
||||
**существующих** таблиц — байт-в-байт; +1 аддитивная таблица; механизм не рестартит прод, не пушит/
|
||||
force-push `main`, не трогает detached-деплой (NFR-5). Hot-path `claim_next_job` не тронут (fail-open).
|
||||
|
||||
## Альтернативы
|
||||
|
||||
- Только CAS (без lease) — не предотвращает двойной side-effect в полёте.
|
||||
- Только lease (без CAS) — не покрывает 6 обходных путей + окно consult→acquire.
|
||||
- Heartbeat-liveness — блокирующий re-test не бьёт heartbeat (довод adr-0043).
|
||||
- Lease-файл per-task — CAS на стадию всё равно DB-операция; БД когерентнее, merge-lease-файл per-repo для
|
||||
иной задачи (сериализация мержей), не дублируется.
|
||||
- epoch-колонка / sub-state `finalizing` в `jobs.status` / per-stage grace на Σ — отвергнуто (как в adr-0043:
|
||||
меняет семантику/нарушает бюджет/неиспользуемо).
|
||||
|
||||
## Последствия
|
||||
|
||||
- (+) Класс двойного эффекта закрыт в корне; конкурентный/после-рестартовый/reconciler/webhook пути покрыты.
|
||||
- (+) Рестарт-safe без нового таймера; boot-id готовит multi-process; бюджет и инварианты конвейера целы; +1 таблица.
|
||||
- (+) Дыра обходных путей gitea/plane закрыта CAS; откат — один env-флаг.
|
||||
- (−) Полная multi-writer эксклюзия валидна при одном процессе/одной БД (как adr-0043); durable делает её
|
||||
корректной для рестарта, но `--workers>1`-верификация — вне объёма (риск в `10-tech-risks.md`).
|
||||
|
||||
## Связи
|
||||
|
||||
- Обобщает `adr-0043`; опирается на `adr-0011`/`adr-0040`/`adr-0042`/`adr-0027`/`adr-0029` и ORCH-071/073/093/036.
|
||||
- Маркеры (ORCH-078/TRACEABILITY): блоки reaper/finalizer-liveness/stage-engine несут ORCH-065/109/110/113 +
|
||||
новый `ORCH-114`; правки сверяются с их ADR (анти-археология — этот сводный сквозной ADR).
|
||||
- Детально: `docs/work-items/ORCH-114/06-adr/ADR-001-transition-ownership-lease-and-stage-cas.md`.
|
||||
</content>
|
||||
@@ -394,7 +394,16 @@ daemon-поток `src/job_reaper.py` (каркас `reconciler`) периоди
|
||||
try/finally): при `stage=="deploy-staging"` И активном владении → **defer**;
|
||||
Tier-3 backstop маркер игнорирует (мёртвый/застрявший finalizer добивается).
|
||||
Kill-switch `ORCH_REAPER_FINALIZER_LIVENESS_ENABLED`; in-memory, restart-safe через
|
||||
`requeue_running_jobs` (до старта reaper); схема БД и сквозной бюджет не тронуты;
|
||||
`requeue_running_jobs` (до старта reaper); схема БД и сквозной бюджет не тронуты.
|
||||
**ORCH-114 (adr-0045):** обобщает это in-memory-владение до **durable, кросс-путевого**
|
||||
`transition_lease` (таблица `task_id PK, owner, owner_pid, owner_boot_id, …`): reaper
|
||||
консультирует durable-lease на **всех** релевантных путях (не только Tier-2/`deploy-staging`),
|
||||
живость владельца = `pid_alive(owner_pid)` + совпадение boot-id (рестарт ⇒ прежние lease мертвы);
|
||||
парная CAS-запись стадии (`update_task_stage_cas`, `WHERE id=? AND stage=?`) — аборт проигравшего
|
||||
без побочных эффектов; reconciler F-1 и webhook тоже defer при живом владельце. Kill-switch
|
||||
`ORCH_TRANSITION_LEASE_ENABLED` (off → ровно поведение ORCH-113 выше); `finalizer_liveness.py`
|
||||
не правится (надстройка durable-слоя поверх). Потолок возраста lease = `reaper_max_running_s`
|
||||
(Tier-3 force-освобождает), сквозной бюджет цел;
|
||||
- **Tier-3** — backstop: job висит `running` дольше `reaper_max_running_s`.
|
||||
|
||||
Реап атомарен (`UPDATE jobs SET ... WHERE id=? AND status='running'` + `rowcount`,
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
---
|
||||
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>
|
||||
66
docs/work-items/ORCH-114/08-data-requirements.md
Normal file
66
docs/work-items/ORCH-114/08-data-requirements.md
Normal file
@@ -0,0 +1,66 @@
|
||||
---
|
||||
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>
|
||||
47
docs/work-items/ORCH-114/10-tech-risks.md
Normal file
47
docs/work-items/ORCH-114/10-tech-risks.md
Normal file
@@ -0,0 +1,47 @@
|
||||
---
|
||||
work_item: ORCH-114
|
||||
stage: architecture
|
||||
author_agent: architect
|
||||
status: proposed
|
||||
created_at: 2026-06-15
|
||||
model_used: claude-opus-4-8
|
||||
---
|
||||
|
||||
# 10 — Технические риски: ORCH-114 — Durable transition-ownership lease + expected-stage CAS
|
||||
|
||||
Work Item: **ORCH-114** · Repo: **orchestrator** · Стадия: architecture
|
||||
|
||||
> Информационный (гейтом не парсится). Риски реализации и митигейшн. Сверено по коду.
|
||||
|
||||
## Реестр рисков
|
||||
|
||||
| ID | Риск | Вер. | Влия. | Митигейшн |
|
||||
|----|------|------|-------|-----------|
|
||||
| **TR-1** | **Дедлок / over-block:** «жёсткое» владение заклинивает легитимный путь — reaper не добивает зависший finalizer, задача висит нетерминальной с удержанным lease (клинит serial-gate репо). | Сред. | Выс. | ADR D3/D8: liveness = pid+boot (мёртвый владелец реклеймится немедленно); Tier-3 `reaper_max_running_s` **игнорирует** lease и добивает зависшего живого; reaper при реапе force-освобождает lease. `release` в `try/finally`. Опц. `POST /transition-lease/release`. Обязательный тест AC-3 (release на исключении/откате) + AC-5 (bounded reclaim). |
|
||||
| **TR-2** | **Lease «течёт»** при исключении/откате в `advance_stage` → задача навсегда заблокирована. | Сред. | Выс. | `release` строго в `finally` вокруг всего side-effectful региона (ADR D5); регресс-тест AC-3 (acquire→raise→release). Бэкстоп: stale-реклейм по pid/boot + Tier-3. |
|
||||
| **TR-3** | **Buggy «smart recovery»** сам становится источником двойного применения необратимого шага после рестарта. | Сред. | Выс. | ADR D7: НЕ новый recovery-мозг, а композиция `requeue_running_jobs` + stale-clear + **существующие авторитетные факты** (SHA-in-main ORCH-071/073, `INITIATED` ORCH-036, coverage-ratchet CAS). Обязательный restart-recovery регресс (BR-8/AC-6): процесс убит в середине финализации → ровно один исход, без второго `merge_pr`/ratchet/deploy. |
|
||||
| **TR-4** | **Скрытые обходные пути** (gitea `handle_push`/`handle_ci_status`/`handle_pr`, plane `_rollback_stage:806`) пишут стадию мимо `advance_stage` → CAS-инвариант обходится. | Выс. | Сред. | ADR D4: 6 обходных `update_task_stage` переведены на `update_task_stage_cas`; граница зафиксирована в ADR. Структурный аудит (AC-11): ни одного безусловного `update_task_stage` на side-effectful/конкурентных путях при флаге on. |
|
||||
| **TR-5** | **Гонка consult→acquire:** актор A прошёл guard «lease свободен», но B захватил между проверкой и acquire A. | Сред. | Сред. | Двойной слой (ADR D1): даже если оба прошли consult, `acquire` атомарен (rowcount-guard, один INSERT выигрывает), а проигравший CAS на коммите не пишет стадию и не делает side-effect. Consult — лишь дешёвый front-defer, не источник истины. |
|
||||
| **TR-6** | **Multi-process (`--workers>1`):** pid+boot-liveness и SQLite-CAS на одной БД корректны для одного процесса; ввод воркеров потребует верификации (SQLite write-lock contention, boot-id на процесс). | Низ. | Сред. | Вне объёма (BRD §scope: модель остаётся одно-процессной). Durable-форма (таблица + pid/boot + CAS) спроектирована совместимой; epoch-колонка — документированное форвард-расширение (ADR D2). Зафиксировано как ограничение в adr-0045 «Последствия (−)». |
|
||||
| **TR-7** | **Бюджетный конфликт:** lease, удерживаемый дольше `reaper_max_running_s`, нарушает сквозной инвариант ORCH-065/109/110/113. | Низ. | Выс. | ADR D8: lease без собственного TTL, потолок = Tier-3 `reaper_max_running_s` (5400); `reaper_finalize_grace_s`/`reaper_max_running_s` НЕ меняются; инвариант `5400 > Σ(≈4460)+grace` цел. Новых бюджетных констант нет. |
|
||||
| **TR-8** | **Регрессия ORCH-113:** обобщение `finalizer_liveness` ломает его контракт/тест или меняет поведение при выключенном ORCH-114. | Сред. | Сред. | ADR D6: `finalizer_liveness.py` **не правится**, остаётся поведением kill-switch-off (reaper → in-memory, Tier-2/`deploy-staging`); on → durable cross-path. Зелёный существующий тест ORCH-113 + AC-9 (флаг off → байт-в-байт). Сверка маркеров ORCH-113 (TRACEABILITY/ORCH-078). |
|
||||
| **TR-9** | **Сбой механизма заклинивает общую очередь** всех проектов (enduro + orchestrator), нарушая AC-8 ORCH-088. | Низ. | Выс. | ADR D9: hot-path `claim_next_job` **не трогается** (lease консультируется на пути перехода/reaper/reconciler/webhook, не в claim). never-raise; acquire/guard-ошибка → defer (не клин), CAS-ошибка → аборт записи. Регресс AC-10. |
|
||||
| **TR-10** | **Ложная stale-реклейм** живого владельца (pid переиспользован ОС после рестарта, boot-id совпал случайно). | Низ. | Сред. | `owner_boot_id` — достаточно энтропийный нонс старта процесса (не предсказуемый), плюс pid-проверка; коллизия (тот же boot-id И тот же pid у нового процесса) практически невозможна. Бэкстоп: CAS на коммите не даст двойной записи даже при ложном реклейме. |
|
||||
|
||||
## Сводный вывод
|
||||
|
||||
Доминирующий класс рисков — **корректность владения и восстановления на необратимых рёбрах**
|
||||
(TR-1/TR-2/TR-3) и **полнота охвата путей** (TR-4/TR-5). Все они снимаются архитектурой defense-in-depth
|
||||
(lease на входе + CAS на коммите) и принципом «не строить новый recovery-мозг, опереться на существующие
|
||||
авторитетные факты» (D7) — это сознательно минимизирует площадь нового кода, способного двоить
|
||||
необратимый шаг.
|
||||
|
||||
**Эскалация `arch:major-change`: рекомендуется.** Изменение вводит новый durable-компонент (leaf
|
||||
`transition_lease` + таблица), трогает движок переходов и ≥5 фоновых акторов и помечается сводным сквозным
|
||||
ADR (adr-0045). Реализующему агенту (developer) обязательны: регресс двойного эффекта (AC-1, red→green),
|
||||
restart-recovery (AC-6), kill-switch-off байт-в-байт (AC-9), сохранность бюджета (AC-5/NFR-6) и аудит
|
||||
обходных путей (TR-4/AC-11). Возврата в анализ не требуется — требования полны и реализуемы без нарушения
|
||||
архитектурных принципов (всё в Docker/одном процессе/SQLite, без новых внешних зависимостей и без рестарта
|
||||
прода). Остаточный риск для прод-конвейера (self-hosting) при дисциплине тестов — **низкий**; единственное
|
||||
осознанное ограничение — multi-process (TR-6), явно вне объёма.
|
||||
</content>
|
||||
Reference in New Issue
Block a user