architect(ET): auto-commit from architect run_id=709
All checks were successful
CI / test (push) Successful in 1m10s

This commit is contained in:
2026-06-15 17:02:18 +03:00
parent a685e0cbc9
commit 0939893c70
6 changed files with 559 additions and 2 deletions

View File

@@ -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}` |

View File

@@ -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:671679`); `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>

View File

@@ -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`,