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

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