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

This commit is contained in:
2026-06-17 11:22:30 +03:00
parent 453c5b7d04
commit 3fb7bd6e4c
4 changed files with 310 additions and 2 deletions

View File

@@ -1269,7 +1269,11 @@ ORCH-065 вводит фоновый watchdog, чтобы смерть проц
- **Job-reaper** (`src/job_reaper.py`) — daemon-поток по образцу `reconciler`,
работает **без рестарта**. Трёхуровневая liveness: Tier-1 мёртвый `jobs.pid`
(новая колонка) после `reaper_dead_ticks` подряд тиков (анти-ложноположительность
— живой долгий агент не реапится); Tier-2 `agent_runs.exit_code` записан, а job
— живой долгий агент не реапится; `pid IS NULL`/живой → streak сбрасывается, не
реапит). **Предусловие Tier-1 (ORCH-126, adr-0052):** `jobs.pid` отражает процесс
ИМЕННО текущего run'а — обеспечивается инвариантом «`queued ⇒ run_id/pid IS NULL
(queued-job не несёт stale pid; переиспользованный pid иначе дал бы фантомный
«живой» `running`, клинящий очередь). Tier-2 `agent_runs.exit_code` записан, а job
ещё `running` — но это окно неоднозначно (живой monitor пишет exit_code ПЕРВЫМ,
затем git push/PR/Plane-комментарии), поэтому Tier-2 реапит только после
finalization-grace `reaper_finalize_grace_s` (живой финализирующий monitor НЕ
@@ -1436,7 +1440,7 @@ Monitoring after Deploy → Done
- `events` — входящие вебхуки (дедуп)
- `tasks` — задачи и их стадии; колонки `cancelled_at`/`cancel_requested_at` (ORCH-090) — durable-метки STOP-отмены (вторая — отложенная отмена в критичном окне merge/deploy). Терминальная стадия `cancelled` (сток, параллельно `done`); натуральные ключи отменённой строки тумбстонятся суффиксом `#cancelled-<id>` (`plane_id`/`work_item_id`/`plane_issue_id`). Колонка `paused_at` (ORCH-124, adr-0051) — durable per-task park-сигнал serial-gate (NULL = не на паузе): **ортогональная** оси «терминальность» ось «пауза» (`paused_at IS NOT NULL`), читается **только** serial-gate (`task_deps`/`stages.py` её не читают); паузнутый предшественник не держит FIFO, но не обходит `repo_freeze`/`task_deps`
- `agent_runs` — запуски агентов (run_id, usage, cost)
- `jobs` — очередь задач (ORCH-1); статусы `queued|running|done|failed|cancelled` (ORCH-090: `cancelled` — терминальный исход STOP, нигде не реквью'ится); колонка `pid` (ORCH-065) — pid агентского процесса для liveness-детекции зомби job-reaper'ом
- `jobs` — очередь задач (ORCH-1); статусы `queued|running|done|failed|cancelled` (ORCH-090: `cancelled` — терминальный исход STOP, нигде не реквью'ится); колонка `pid` (ORCH-065) — pid агентского процесса для liveness-детекции зомби job-reaper'ом. **Инвариант run-ownership (ORCH-126, adr-0052): `status='queued' ⇒ run_id IS NULL AND pid IS NULL AND started_at IS NULL`** — queued-job никогда не несёт run-ownership (она принадлежит ровно одной активной попытке `running` после стампа в `_spawn`; история — в `agent_runs`). Все пути возврата в `queued` (`requeue_running_jobs`/`mark_job('queued')`/`mark_job_transient`/`reap_running_job('queued')`) сбрасывают `run_id`/`pid`; `claim_next_job` — defense-in-depth-сброс при флипе в `running`. Stale run-ownership искажала бы Tier-1 liveness reaper'а (переиспользованный pid → фантомный `running` клинит `max_concurrency=1`-очередь всех проектов) и `/metrics` (ORCH-099). «Невозможные» строки само-лечатся при старте/реапе + счётчик в `GET /queue`. **Норматив:** новый путь возврата в `queued` обязан соблюсти инвариант (reviewer: нарушение = ≥P1)
- `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` — журнал не скоупится по репо, репо-разрез на выборке)

View File

@@ -0,0 +1,99 @@
---
work_item: ORCH-126
stage: architecture
author_agent: architect
status: accepted
created_at: 2026-06-17
model_used: claude-opus-4-8
---
# adr-0052: Инвариант run-ownership строки `jobs` — «queued ⇒ run_id/pid/started_at IS NULL»
- **Статус:** accepted
- **Дата:** 2026-06-17
- **Задача:** ORCH-126 (bug-fix контрол-плейна)
- **Детальный ADR:** `docs/work-items/ORCH-126/06-adr/ADR-001-queued-job-run-ownership-hygiene.md`
## Контекст
Колонки `jobs.run_id` и `jobs.pid`**общий контракт liveness/идентичности run'а**, на который
опираются несколько подсистем контрол-плейна:
- **job-reaper (ORCH-065, adr-0011/adr-0043):** Tier-1 судит liveness running-job'а по `jobs.pid`
(`merge_gate.pid_alive`);
- **`/metrics` (ORCH-099, adr-0030):** `get_running_agents` отдаёт `run_id`/`pid` running-job'ов
как «сырьё» для sidecar;
- **scheduler/launcher (ORCH-1/ORCH-088):** `_spawn` выставляет `run_id` (после INSERT в `agent_runs`)
и `pid` (после `Popen`) **вперёд**.
Но ни один путь возврата job'а в `queued` (restart-recovery `requeue_running_jobs`,
retry `mark_job('queued')`, transient `mark_job_transient`, reaper `reap_running_job('queued')`) не
сбрасывал run-ownership — он оставался «протухшим» от прошлой попытки. Возникало физически невозможное
состояние `status='queued'` с непустыми `run_id`/`pid` при `started_at IS NULL`. Поскольку pid после
рестарта контейнера может быть **переиспользован** ОС, `pid_alive(stale)` ложно возвращает `True`,
reaper видит «живой» фантомный `running` и при `max_concurrency=1` (дефолт) клинит клейм **всей**
очереди — а это **общий** инстанс/очередь всех проктов (self-hosting). Инцидент ORCH-124/125: queued
analyst-job'ы зависали навсегда даже при `ORCH_SERIAL_GATE_ENABLED=false`.
Корень — **отсутствие именованного, принудительно соблюдаемого инварианта**, связывающего
`jobs.status` с его run-ownership-колонками.
## Решение
Зафиксировать как **системный инвариант данных контрол-плейна**:
> **`status='queued' ⇒ run_id IS NULL AND pid IS NULL AND started_at IS NULL`.**
То есть: **queued-job никогда не несёт run-ownership.** Run-ownership принадлежит ровно одной активной
попытке (`running` после стампа в `_spawn`) и история живёт в таблице `agent_runs`, а не в
`jobs.run_id`.
Соблюдение (ORCH-126, без смены схемы БД, на существующих колонках):
- **Forward-cleanup:** каждый путь перехода в `queued` выставляет `run_id=NULL, pid=NULL` той же
UPDATE-транзакцией, что чистит `started_at`/`finished_at` (атомарные `status`-guard'ы сохранены).
- **Clean claim (defense-in-depth):** `claim_next_job` при флипе `queued→running` сбрасывает stale
`pid`/`run_id` тем же UPDATE — между claim и стампом `pid` в `_spawn` строка несёт `pid IS NULL`,
не чужой pid (offline hot-path не трогается).
- **Self-heal + наблюдаемость:** «невозможные» queued-строки санируются идемпотентно при старте/реапе
(never-raise) и видны счётчиком в `GET /queue` — защита от рецидива, если будущий путь возврата в
`queued` забудет инвариант.
**Норматив на будущее:** любой новый путь, переводящий job в `queued`, **обязан** соблюсти инвариант
(сбросить `run_id`/`pid`). Reviewer ловит нарушение как ≥P1 (фантомный `running` способен заклинить
очередь всех проектов).
`STAGE_TRANSITIONS` / реестр `QG_CHECKS` / `check_*` / machine-verdict-ключи / **схема БД**
байт-в-байт. Это инвариант данных планировщика, **не** Quality Gate и **не** стадия.
## Альтернативы
- **DB-level CHECK/триггер** — отвергнуто: смена схемы; раняющий констрейнт нарушает never-raise и мог
бы заклинить очередь всех проектов. Инвариант лучше держать кодом + self-heal, чем раняющим
констрейнтом.
- **Reaper-side эвристика поверх stale pid** — отвергнуто: лечит симптом у одного читателя, оставляет
stale-данные другим (`/metrics`); reaper уже корректно трактует `pid IS NULL`.
- **Новая колонка-эпоха run'а** — отвергнуто: смена схемы, избыточно; инвариант выразим на
существующих колонках.
## Последствия
- Класс «фантомный `running` клинит `max_concurrency=1`-очередь всех проектов» закрыт у корня;
восстановлена корректность Tier-1 reaper-liveness; чище `/metrics`.
- Инвариант **назван** → перестаёт быть «неявным предположением» reaper'а/metrics и становится
проверяемым контрактом (reviewer + self-heal).
- Нулевая регрессия для здоровых job'ов и enduro-trails; миграция БД не требуется (аномальные строки
санируются при первом старте).
- Аддитивно/обратимо: **не** `arch:major-change` (нет новой стадии / QG / таблицы / смены топологии).
- **Откат:** ревертом ORCH-126 PR; опц. self-heal/диагностика — своим флагом.
## Связи
- adr-0011 / `docs/work-items/ORCH-065/06-adr/` (job-reaper Tier-1 по `jobs.pid` — читатель инварианта;
фикс восстанавливает его предусловие).
- adr-0043 / `docs/work-items/ORCH-113/06-adr/` (finalizer-liveness — ортогонален: process-local,
по `job_id`).
- adr-0045 / `docs/work-items/ORCH-114/06-adr/` (transition-lease — ортогонален: своя таблица/колонки,
recovery по boot-id).
- adr-0030 / `docs/work-items/ORCH-099/06-adr/` (`/metrics` `get_running_agents` — читатель `pid`/
`run_id`; уже допускает `pid IS NULL`).
- adr-0002 (job-queue ORCH-1 — порождающая модель `jobs`).
</content>

View File

@@ -0,0 +1,164 @@
---
work_item: ORCH-126
stage: architecture
author_agent: architect
status: accepted
created_at: 2026-06-17
model_used: claude-opus-4-8
track: bug
---
# ADR-001: Гигиена run-ownership строки `jobs` — инвариант «queued ⇒ нет run-ownership»
Work Item: **ORCH-126** — queued-job хранит протухший `run_id`/`pid` и не клеймится даже при выключенном serial-gate
Стадия: **architecture**
Сквозная регистрация: **`docs/architecture/adr/adr-0052-queued-job-run-ownership-invariant.md`** (решение кросс-каттинговое — инвариант данных, на который опираются reaper / `/metrics` / scheduler).
> 🐞 **Контекст маршрута.** Задача классифицирована как **Bug** (трек ORCH-019); аналитик отметил, что
> ADR/макет «не требуются». Однако задача дошла до стадии `architecture` (bug-fast-track не сработал —
> метка `Bug` не была проставлена в Plane на момент `start_pipeline`, либо флаг выключен), а
> детерминированный exit-гейт `check_architecture_done` требует артефакт `06-adr/` **или**
> `07-infra-requirements.md`. Поэтому фиксирую **минимальный, но настоящий** архитектурный артефакт:
> правка трогает **4 маркированных инварианта** (ORCH-065 / ORCH-113 / ORCH-114 / ORCH-099), и их
> валидация (CLAUDE.md §9) — это именно архитектурная работа. **Не** `arch:major-change` (нет новой
> стадии / компонента / QG / смены БД).
## Статус
Accepted
## Контекст
Корневая проблема — **отсутствие принудительного инварианта**, связывающего `jobs.status` с
колонками run-ownership `jobs.run_id` / `jobs.pid` / `jobs.started_at`. Run-ownership выставляется
**вперёд** в `launcher._spawn` (`run_id` после INSERT в `agent_runs`, `pid` после `Popen`,
`launcher.py:711`), но **ни один** из путей возврата job'а в `queued` его не сбрасывает. Сверено по коду:
| # | Путь (`src/db.py`) | Что чистит | `run_id`/`pid` |
|---|--------------------|-----------|----------------|
| 1 | `requeue_running_jobs()` (`:1483`, restart-recovery) | `started_at` | **НЕ чистит** |
| 2 | `mark_job(status='queued')` (`:1264`) | `started_at`/`finished_at` | **НЕ чистит** |
| 3 | `mark_job_transient()` (`:1226`) | `started_at`/`finished_at` | **НЕ чистит** |
| 4 | `reap_running_job(status='queued')` (`:1648`) | `started_at`/`finished_at` | **НЕ чистит** |
| 5 | `claim_next_job()` (`:1196`, флип `queued→running`) | ставит `started_at`, `attempts++` | **НЕ сбрасывает** stale `pid` |
Итог — `queued`-строка может нести «протухшую» run-ownership: физически невозможное состояние
(run-ownership выставлен, но запуск не состоялся: `run_id`/`pid` ≠ NULL при `started_at IS NULL`
ровно наблюдаемое в инциденте: job 2286 `queued + run_id=759/760 + pid=35/42`).
Колонки `run_id`/`pid`**общий контракт liveness**, который читают два подсистемы:
- **job-reaper (ORCH-065):** Tier-1 судит liveness по `jobs.pid` через `merge_gate.pid_alive`
(`job_reaper.py:245`). Stale-**но-переиспользованный** pid → `pid_alive(stale)=True` → reaper
«видит живой процесс» → **никогда не реапит** фантомный `running`; при `max_concurrency=1` (дефолт)
это клинит клейм **всей** очереди (общий инстанс/очередь всех проектов).
- **`/metrics` (ORCH-099):** `get_running_agents` отдаёт `run_id`/`pid` running-job'ов.
«Как есть» не годится: без сброса run-ownership на возврате в `queued` сигналы liveness/диагностики
искажены, и при выключенном serial-gate срочная задача всё равно не стартует.
## Решение
### Сводка
Ввести и **повсеместно соблюсти** инвариант жизненного цикла строки `jobs`:
> **`status='queued' ⇒ run_id IS NULL AND pid IS NULL AND started_at IS NULL`.**
Сбрасывать run-ownership (`run_id=NULL, pid=NULL`) той же UPDATE-транзакцией, что уже чистит
`started_at`, на **всех** путях возврата в `queued`; в `claim` — defense-in-depth-сброс stale `pid`/
`run_id` при флипе в `running` (до стампа в `_spawn`). Плюс — детект/санация «невозможного» состояния
(старт + реап-тик) и наблюдаемость. **Схема БД, `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_*`,
machine-verdict-ключи — байт-в-байт.** Это исправление инварианта данных планировщика, **не** новая
фича и **не** Quality Gate.
### D1 — Forward-cleanup на всех путях возврата в `queued` (FR-1 / AC-1)
В `requeue_running_jobs` / `mark_job('queued')` / `mark_job_transient` / `reap_running_job('queued')`
добавить `run_id = NULL, pid = NULL` в тот же `SET`, что чистит `started_at`/`finished_at`. Атомарные
guard'ы по `status` (`reap_running_job ... WHERE status='running'`, rowcount-проверка) — **сохранить
байт-в-байт** (restart-safe, гонка worker↔reaper↔monitor). Точка сброса — **строго на переходе В
`queued`**: сброс run-ownership активного `running`-job стёр бы идентичность живого run'а (ключевое
ограничение корректности — см. TR-1).
### D2 — Чистый claim (FR-2 / AC-3)
`claim_next_job` при флипе `queued→running` сбрасывает `pid=NULL, run_id=NULL` тем же UPDATE
(defense-in-depth поверх D1) — между claim и стампом `pid` в `_spawn` строка несёт `pid IS NULL`, а
не чужой pid. SELECT-гейт (`status='queued' AND available_at<=now` + dep-/serial-gate) — **не трогать**
(offline hot-path, NFR-2). Сброс — часть уже существующего `UPDATE … SET status='running', …` (без
нового SELECT/сети).
### D3 — Окно `_spawn` (FR-3 / AC-6)
При провале `_spawn` **до** стампа `pid` (`ensure_worktree` / `_materialize_deferred_branch` /
`_write_task_file`) обработчик `queue_worker._drain_once` возвращает job через `mark_job('queued'|
'failed')` → по D1 строка остаётся без stale `pid`/`run_id`. Новый код в launcher не нужен —
устойчивость обеспечивается D1; в development подтвердить, что повторный claim после такого провала
стартует штатно.
### D4 — Детект и санация «невозможного» состояния (FR-4 / AC-5)
«Невозможное» = `status='queued' AND (run_id IS NOT NULL OR pid IS NOT NULL OR started_at IS NOT NULL)`.
- **Авто-санация** при старте (`main.lifespan`, после `requeue_running_jobs`) и/или на реап-тике —
идемпотентное приведение к чистому `queued` (по D1), never-raise. Закрывает уже-существующие
аномальные строки на проблемной БД без миграции.
- **Наблюдаемость** — структурный WARNING (`job_id`/`run_id`/`pid`) + read-only счётчик в блоке
очереди `GET /queue` (поле/`queue.impossible_queued`); существующие поля снимка не переименовывать.
### D5 — Корректность reaper-liveness (FR-5 / AC-4) — валидация, не правка
После D1D3 reaper на свежеклеймленном `running` видит `pid IS NULL` → Tier-1 (`job_reaper.py:245`:
`if pid is not None and not pid_alive(pid)`) **пропускает** строку, сбрасывает streak
(`:257-258` «Alive / no pid → reset»); Tier-3 backstop (`reaper_max_running_s`) — без изменений
ловит реально застрявший claim→spawn в ограниченное время. **Правка reaper'а не требуется** — фикс
**восстанавливает предусловие**, на котором reaper уже спроектирован («`pid` отражает процесс ЭТОГО
run'а»).
### D6 — Без kill-switch для базового сброса (D1D3); опц. флаг для D4
Базовый сброс run-ownership (D1D3) — **безусловен** (исправление инварианта данных, для здоровых
job'ов байт-в-байт). Опциональную авто-санацию/диагностику D4 допустимо закрыть флагом (дефолт =
включено) на усмотрение developer. Отдельный фичефлаг для D1D3 не вводится (NFR-3).
## Альтернативы
- **DB-level enforcement (CHECK-констрейнт / триггер `status='queued' ⇒ run_id/pid IS NULL`)** —
отвергнуто: правка **схемы БД** (вне объёма, NFR-3); раняющий констрейнт нарушает never-raise и мог
бы заклинить очередь всех проектов; самолечение на старте (D4) безопаснее жёсткого констрейнта.
- **Только reaper-side эвристика (игнорировать `pid`, если `started_at` подозрителен)** — отвергнуто:
не лечит корень — другие читатели (`/metrics`) по-прежнему видят stale-данные; reaper уже корректно
обрабатывает `pid IS NULL` — правильнее **гарантировать** NULL, а не плодить эвристики в reaper'е.
- **Новая колонка (`run_epoch`/`claim_token`)** — отвергнуто: смена схемы (вне объёма), избыточно —
инвариант выразим на существующих колонках.
- **Сброс run-ownership где угодно (в т.ч. у активного `running`)** — отвергнуто: стёр бы идентичность
живого run'а; сброс строго на переходе В `queued` и в claim ДО `_spawn`.
## Последствия
- **+** Закрыт класс «фантомный `running` клинит `max_concurrency=1` очередь всех проектов»;
восстановлена корректность Tier-1 liveness reaper'а; чище `/metrics`.
- **+** Инвариант «queued = без run-ownership» **назван и зафиксирован** (этот ADR + сквозной
adr-0052) → защита от рецидива (новый 6-й путь возврата в `queued` обязан его соблюсти; D4
само-лечит пропуск).
- **+** Для не-stale job'ов поведение байт-в-байт (NFR-3); enduro-trails не затронут;
миграция БД не требуется.
- **** 45 точек правки → риск забыть будущий путь возврата (митигейшн: D4 startup/reap self-heal +
счётчик в `/queue` + named-инвариант) — **TR-2**.
- **** Точка сброса критична: ошибочный сброс у активного `running` стёр бы идентичность live-run'а
(митигейшн: строго на переходе В `queued` / в claim ДО `_spawn`) — **TR-1**.
- **Откат:** изменения локальны (наборы `SET` в `src/db.py` + опц. startup-хук) → ревертом PR;
опц. D4 — выключением его флага.
### Трассировка маркированных инвариантов (CLAUDE.md §9) — все сохранены
- **ORCH-065 (reaper Tier-1):** сохранён и **восстановлен** (`job_reaper.py:245/257`); Tier-3 backstop
без изменений.
- **ORCH-113 (finalizer-liveness):** ортогонален — process-local маркер по `job_id`, только для
`running`+exit_code на ребре `deploy-staging`; у queued-job финализатора нет.
- **ORCH-114 (transition-lease / `recover_on_startup`):** ортогонален — своя таблица
`transition_lease`, свои `owner_pid`/`owner_boot_id`; recovery по boot-id, не по `jobs.pid`/`run_id`.
- **ORCH-099 (`/metrics`):** улучшен — `get_running_agents` фильтрует `status='running'` и уже
допускает `pid IS NULL`; фикс убирает утечку stale-pid, контракт держится.
## Ссылки
- BRD: `docs/work-items/ORCH-126/01-brd.md`
- TRZ: `docs/work-items/ORCH-126/02-trz.md`
- Acceptance: `docs/work-items/ORCH-126/03-acceptance-criteria.md`
- Test-plan: `docs/work-items/ORCH-126/04-test-plan.yaml`
- Технические риски: `docs/work-items/ORCH-126/10-tech-risks.md`
- Сквозной ADR: `docs/architecture/adr/adr-0052-queued-job-run-ownership-invariant.md`
- Сверено по коду: `src/db.py` (`claim_next_job`/`requeue_running_jobs`/`mark_job`/`mark_job_transient`/`reap_running_job`/`get_running_agents`), `src/job_reaper.py` (`:245`/`:257`), `src/agents/launcher.py` (`_spawn` `:711`), `src/transition_lease.py`
- Маркированные инварианты: `docs/work-items/ORCH-065/06-adr/`, `ORCH-113/06-adr/`, `ORCH-114/06-adr/`, `ORCH-099/06-adr/`
</content>
</invoke>

View File

@@ -0,0 +1,41 @@
---
work_item: ORCH-126
stage: architecture
author_agent: architect
status: accepted
created_at: 2026-06-17
model_used: claude-opus-4-8
track: bug
---
# 10 — Технические риски: ORCH-126 — гигиена run-ownership queued-job
Work Item: **ORCH-126** · Repo: **orchestrator** · Стадия: architecture
> Информационный (гейтом не парсится). Риски реализации сброса `run_id`/`pid` на путях возврата в
> `queued` и их митигейшн. На bug-маршруте необязателен, но включён: правка — горячий путь клейма,
> затрагивает 4 маркированных инварианта (ORCH-065/113/114/099) на **общей** очереди всех проектов.
## Реестр рисков
| ID | Риск | Вер. | Влия. | Митигейшн |
|----|------|------|-------|-----------|
| TR-1 | **Сброс run-ownership не в той точке** — обнуление `run_id`/`pid` у **активного** `running`-job стёрло бы идентичность живого run'а (reaper/`/metrics` потеряли бы pid живого процесса; зомби-детекция сломалась бы). | Низ. | Выс. | Сброс **строго на переходе В `queued`** (D1) и в `claim` **до** `_spawn` (D2). Активный `running` не трогается. TC-10 (анти-регресс здорового job'а) + TC-04 (атомарный `WHERE status='running'`-guard сохранён). |
| TR-2 | **Забытый будущий путь возврата в `queued`** (6-й путь мимо инварианта) воскрешает класс «stale run-ownership». | Сред. | Сред. | Named-инвариант (adr-0052) + D4 startup/reap self-heal (идемпотентно лечит пропуск) + счётчик `impossible_queued` в `GET /queue` + reviewer-норматив «нарушение = ≥P1». |
| TR-3 | **Регресс reaper Tier-1** — неверная трактовка `pid IS NULL` как «dead → reap» реапнула бы легитимный старт. | Низ. | Выс. | Правка reaper НЕ требуется: `job_reaper.py:245` реапит лишь `pid is not None and not pid_alive(pid)`, `:257` сбрасывает streak при «no pid». Фикс **восстанавливает** предусловие. Покрыто TC-07. |
| TR-4 | **Гонка worker↔reaper↔monitor** на возврате в `queued` (двойная обработка строки). | Низ. | Сред. | Атомарные `status`-guard'ы (`reap_running_job ... WHERE status='running'`, rowcount) сохранены байт-в-байт (FR-1). Restart-safe (TC-04 повторный вызов → rowcount 0). |
| TR-5 | **Окно claim→spawn без pid** — job, чей `_spawn` упал до стампа pid и не был реквью́ен, висит `running` с `pid IS NULL` до Tier-3 backstop. | Низ. | Низ. | Штатный путь — немедленный реквью через `_drain_once`+D1 (TC-09). Worst-case ловит Tier-3 `reaper_max_running_s` (без изменений). Поведение не хуже текущего. |
| TR-6 | **Ошибка в горячем пути клейма роняет/клинит очередь всех проектов** (NFR-1). | Низ. | Выс. | Сброс — часть существующего `UPDATE` (без нового SELECT/сети, offline NFR-2); D4-диагностика изолирована (never-raise) от клейма. Полный `pytest tests/ -q` зелёный (AC-7). |
| TR-7 | **Невозможные строки на проде на момент апгрейда** (job 2286/2303) не санируются. | Низ. | Сред. | D4 авто-санация при первом старте `main.lifespan` (миграция не требуется); идемпотентно, выдерживает повторный рестарт (NFR-5). Покрыто TC-08. |
## Сводный вывод
Доминирующий класс — **точечная гигиена данных в горячем пути** при сохранении атомарных guard'ов и
4 маркированных инвариантов. Все риски — низкой вероятности; высокое влияние (TR-1/TR-3/TR-6) полностью
снимается тем, что (а) сброс ограничен переходом В `queued`/claim-до-`_spawn`, (б) reaper не правится и
его предусловие лишь восстанавливается, (в) изменения — внутри существующих UPDATE без сети.
**Эскалация не требуется** (`arch:major-change` — нет; возврат в анализ — нет): схема БД / `STAGE_TRANSITIONS`
/ `QG_CHECKS` / `check_*` / machine-verdict — байт-в-байт, решение реализуемо без нарушения принципов.
Остаточный риск для прод-конвейера (self-hosting) — **низкий**; обязательный регресс-тест (TC-01,
red→green) + анти-регресс здорового job'а (TC-10) фиксируют корректность.
</content>