fix(queue): enforce queued ⇒ no run-ownership invariant (ORCH-126) #145

Merged
admin merged 8 commits from feature/ORCH-126-bug-queued-job-can-keep-stale- into main 2026-06-17 11:56:28 +03:00
22 changed files with 1527 additions and 10 deletions

View File

@@ -453,6 +453,18 @@ ORCH_REAPER_MAX_RUNNING_S=5400
ORCH_REAPER_FINALIZE_GRACE_S=300
ORCH_LEASE_RECLAIM_ENABLED=true
# ORCH-126 (adr-0052): run-ownership hygiene of the `jobs` row — invariant
# `status='queued' => run_id IS NULL AND pid IS NULL AND started_at IS NULL`. The BASE
# reset on every requeue/claim path (requeue_running_jobs / mark_job('queued') /
# mark_job_transient / reap_running_job('queued') / claim_next_job) is UNCONDITIONAL
# (no flag — it fixes a data invariant). This kill-switch gates ONLY the optional
# detect/self-heal sweep of "impossible" queued rows (a queued job still carrying
# run_id/pid/started_at — the incident state of job 2286) run at startup + on each
# reaper tick, plus its read-only /queue counter (reaper.impossible_queued_total).
# IMPOSSIBLE_QUEUED_SANITIZE_ENABLED -> default true; false -> the sweep is a no-op
# (D1-D3 still enforce the invariant going forward).
ORCH_IMPOSSIBLE_QUEUED_SANITIZE_ENABLED=true
# ORCH-114 (adr-0045): durable transition-ownership lease + expected-stage CAS for
# side-effectful stage transitions. Generalises the process-local ORCH-113 finalizer-
# liveness into a DURABLE, cross-path owner-exclusion (additive table `transition_lease`)

View File

@@ -1,4 +1,4 @@
Work item: ORCH-124
Work item: ORCH-126
Repo: orchestrator
Branch: feature/ORCH-124-bug-serial-gate-treats-backlog
Branch: feature/ORCH-126-bug-queued-job-can-keep-stale-
Stage: development

View File

@@ -3,6 +3,14 @@
Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу.
## [Unreleased]
- **Гигиена run-ownership строки `jobs` — инвариант «queued ⇒ run_id/pid/started_at IS NULL»** (ORCH-126, `fix`, трек Bug): багфикс контрол-плейна (инцидент ORCH-124/125) — при `ORCH_SERIAL_GATE_ENABLED=false` queued analyst-job'ы зависали навсегда (job 2286: `status=queued + run_id=759/760 + pid=35/42 + started_at=NULL` — физически невозможное состояние). **Причина:** ни один путь возврата job в `queued` (restart `requeue_running_jobs` / retry `mark_job('queued')` / transient `mark_job_transient` / reap `reap_running_job('queued')`) **не сбрасывал run-ownership** (`run_id`/`pid`); после рестарта контейнера pid мог быть **переиспользован** ОС`pid_alive(stale)=True` → job-reaper (ORCH-065) Tier-1 «видел живой» фантомный `running` и при `max_concurrency=1` клинил клейм **всей** общей очереди всех проектов. **Инвариант (adr-0052):** `status='queued' ⇒ run_id IS NULL AND pid IS NULL AND started_at IS NULL` — queued-job никогда не несёт run-ownership (история run'а — в `agent_runs`, не в `jobs.run_id`). Фикс на **существующих колонках**: `STAGE_TRANSITIONS` / реестр `QG_CHECKS` / `check_*` / machine-verdict-ключи / **схема БД** — байт-в-байт не тронуты; для здоровых job'ов и enduro поведение байт-в-байт; миграция не требуется. ADR: `docs/work-items/ORCH-126/06-adr/ADR-001-queued-job-run-ownership-hygiene.md`, сквозной `docs/architecture/adr/adr-0052-queued-job-run-ownership-invariant.md`.
- **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` той же UPDATE-транзакцией, что чистит `started_at`/`finished_at`. Атомарные `status`-guard'ы (`reap_running_job … WHERE status='running'`, rowcount) — **сохранены байт-в-байт** (restart-safe, гонка worker↔reaper↔monitor — TR-4). Каллер-переданный `run_id` для `queued` **игнорируется** (инвариант важнее: `launcher._finalize_permanent`/reaper по-прежнему передают старый `run_id`, но для `queued` он сбрасывается). Безусловно — исправление инварианта данных, без флага (D6).
- **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; без нового SELECT/сети).
- **D3 — Окно `_spawn` (FR-3/AC-6):** провал `_spawn` до стампа `pid` (`ensure_worktree`/материализация ветки/запись task-файла) → `queue_worker._drain_once` возвращает job через `mark_job('queued')` → по D1 строка чистая; повторный claim стартует штатно (без «частично стартовавшего» зависания). Нового кода в launcher не потребовалось.
- **D4 — Детект + self-heal невозможного состояния (FR-4/AC-5):** `db.find_impossible_queued_jobs()`/`db.sanitize_impossible_queued()` идемпотентно приводят «невозможные» queued-строки (`queued` с непустым `run_id`/`pid`/`started_at`) к чистому `queued`; вызывается при старте (`main.lifespan` после `requeue_running_jobs`) и на каждом реап-тике (`JobReaper.sanitize_impossible_queued_once`, never-raise) — закрывает уже-существующие аномалии на проблемной БД **без миграции** (TR-7) и забытый будущий 6-й путь возврата (TR-2). Наблюдаемость: структурный WARNING (`job_id`/`run_id`/`pid`) + read-only счётчик `impossible_queued_total`/`last_impossible_queued` в блоке `reaper` снимка `GET /queue`. Kill-switch `impossible_queued_sanitize_enabled` (env `ORCH_IMPOSSIBLE_QUEUED_SANITIZE_ENABLED`, дефолт on; гейтит **только** D4-sweep, базовый сброс D1-D3 безусловен).
- **D5 — Корректность reaper-liveness (FR-5/AC-4) — валидация, не правка:** после D1-D3 reaper на свежеклеймленном `running` видит `pid IS NULL` → Tier-1 (`job_reaper.py:245`: `if pid is not None and not pid_alive(pid)`) пропускает, сбрасывает streak; Tier-3 backstop (`reaper_max_running_s`) — без изменений. **Правка reaper не требуется** — фикс восстанавливает предусловие «`pid` отражает процесс ЭТОГО run'а». Маркированные инварианты ORCH-065/113/114/099 — сохранены (трассировка CLAUDE.md §9).
- **Покрытие:** `tests/test_orch126_queued_stale_run.py` (TC-01 — обязательный регресс, КРАСНЫЙ до фикса / ЗЕЛЁНЫЙ после: stale `running``requeue_running_jobs` → чистый `queued`; TC-02…TC-10: сброс на каждом пути, чистый claim, claim без старвейшна при serial-gate off, reaper не реапит `pid IS NULL`, self-heal идемпотентность + счётчик + kill-switch, окно `_spawn`, анти-регресс здорового job'а — терминальные исходы/`run_id`-линк не затронуты). Полный `pytest tests/ -q` — зелёный.
- **Доки:** `docs/architecture/internals.md` (раздел «Инвариант run-ownership строки `jobs`» + аннотации `jobs.run_id`/`pid` + queue-recovery), `.env.example` (флаг `ORCH_IMPOSSIBLE_QUEUED_SANITIZE_ENABLED` в блоке reaper); сквозной ADR `adr-0052` (уже заведён архитектором).
- **Serial-gate «пауза без блокировки» — явный per-task park-сигнал** (ORCH-124, `fix`): багфикс (метка `Bug`, эскалирован в full-cycle) инцидента **ORCH-116/ORCH-123**. `serial_gate` определял «активную задачу репо» **исключительно по машинной стадии** `tasks.stage NOT IN ('done','cancelled')`, а Plane-статусы Backlog/Blocked/Needs-Input (слой B индикации, ORCH-066) **не меняют `tasks.stage`** (слой A) ⇒ приостановленный предшественник был неотличим от активного и держал FIFO-гейт закрытым против срочного успешника (ORCH-116 поставлен на паузу, чтобы пропустить фикс ORCH-123 — фикс не стартовал, пока ORCH-116 формально не `done`). У оператора не было чистого механизма «пауза без блокировки», отдельного от cancel (терминал) и от глобального выключения гейта. **Инвариант:** правка **планировщика очереди** (claim) и наблюдаемости, **не** Quality Gate — `STAGE_TRANSITIONS` / состав `QG_CHECKS` / семантика и имена `check_*` / machine-verdict ключи (`verdict:`/`result:`/`deploy_status:`/`staging_status:`/`security_status:`) / схемы существующих таблиц — **байт-в-байт не тронуты**. Аддитивно, под независимым под-флагом, never-raise, restart-safe, fail-OPEN на hot-claim сохранён. ADR: `docs/work-items/ORCH-124/06-adr/ADR-001-serial-gate-pause-without-blocking.md`, сквозной `docs/architecture/adr/adr-0051-serial-gate-pause-without-blocking.md`.
- **Механизм (D1):** явный durable DB-сигнал «park» на уровне задачи, инициируемый оператором через API — **не** маппинг Plane-статуса (перегружал бы слой A/B ORCH-066 / анти-паттерн ORCH-059) и **не** `task_deps` (моделирует обратное направление «B ждёт A»). Чистое намерение, отличное от cancel и от kill-switch; DB-резолвимо, offline, webhook-независимо (потерянный webhook не рассинхронит сигнал).
- **Хранилище (D2):** аддитивная нуллабельная колонка `tasks.paused_at TEXT` через `_ensure_column` (паттерн `tasks.cancelled_at`/`cancel_requested_at`/`track`; `src/db.py`) — NULL = не на паузе; ISO-таймстамп = поставлена оператором на паузу. На уже-мигрированной БД — no-op; все существующие строки дефолтят в NULL ⇒ поведение до ORCH-124 до первой явной паузы (enduro не затронут на общей прод-БД). Хелперы `db.set_task_paused`/`clear_task_paused`/`is_task_paused` (never-raise; `is_task_paused` на ошибке → «не на паузе» = задача активна = гейт скорее закрыт = анти-stale-base-safe).

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

@@ -402,8 +402,8 @@ webhook (plane/gitea) background thread (queue_worker)
|--------|------------|
| `status` | `queued``running``done` \| `failed` \| `cancelled` (ORCH-090: терминальный исход STOP-отмены, не реквью'ится) |
| `attempts` / `max_attempts` | счётчик попыток (инкремент при claim) / лимит ретраев (default 2) |
| `run_id` | FK на `agent_runs.id` после старта |
| `pid` | (ORCH-065) pid агентского процесса (`proc.pid` из `_spawn`); liveness-сигнал для job-reaper. Добавляется `_ensure_column` (idempotent) |
| `run_id` | FK на `agent_runs.id` после старта. **ORCH-126 (adr-0052):** run-ownership; `queued ⇒ run_id IS NULL` (история run'а живёт в `agent_runs`, не в `jobs.run_id`) |
| `pid` | (ORCH-065) pid агентского процесса (`proc.pid` из `_spawn`); liveness-сигнал для job-reaper. Добавляется `_ensure_column` (idempotent). **ORCH-126 (adr-0052):** `queued ⇒ pid IS NULL` — иначе протухший (возможно переиспользованный ОС) pid ложно «оживает» в Tier-1 reaper и клинит очередь |
| `task_content` | ТЗ, которое пишется в task-файл агента |
| `error` | последняя ошибка |
@@ -419,6 +419,10 @@ status='queued'` и проверяет `rowcount`. При гонке двух т
В `main.py` lifespan **после** M-1 orphan-recovery вызывается `requeue_running_jobs()`:
jobs со статусом `running` (воркер умёр на рестарте) → возвращаются в `queued`.
**ORCH-126 (adr-0052):** возврат в `queued` сбрасывает run-ownership (`run_id=NULL, pid=NULL`
вместе с `started_at`) — мёртвый воркер оставил их протухшими, и фантомный pid заклинил бы
Tier-1 reaper. Сразу следом `reaper.sanitize_impossible_queued_once()` идемпотентно санирует
любые «невозможные» queued-строки (`queued` с непустым `run_id`/`pid`/`started_at`).
**ORCH-114 (adr-0045):** сразу следом вызывается `transition_lease.recover_on_startup()`
новый процесс имеет свежий `boot_id`, поэтому ВСЕ записанные ранее `transition_lease`
устарели (boot-id mismatch) → реклеймятся, и только что requeued-jobs переисполняют свои
@@ -475,6 +479,35 @@ claim делает `_try_advance_stage` (advance+enqueue) — проигравш
/ `ORCH_LEASE_RECLAIM_ENABLED`; снимок в `GET /queue` (блок `reaper`). Подробнее —
adr-0011.
### Инвариант run-ownership строки `jobs` (ORCH-126, adr-0052)
Колонки `jobs.run_id`/`jobs.pid`**общий контракт liveness/идентичности run'а** (читают
job-reaper Tier-1 по `pid`, `/metrics` `get_running_agents`). Системный инвариант данных:
> **`status='queued' ⇒ run_id IS NULL AND pid IS NULL AND started_at IS NULL`.**
То есть **queued-job никогда не несёт run-ownership** — оно принадлежит ровно одной активной
попытке (`running` после стампа в `_spawn`). Корень дефекта (инцидент ORCH-124/125, job 2286
`queued + run_id=759 + pid=35 + started_at=NULL`): ни один путь возврата в `queued` не сбрасывал
run-ownership, а после рестарта контейнера pid мог быть **переиспользован** ОС`pid_alive(stale)`
ложно `True` → reaper «видел живой» фантомный `running` и при `max_concurrency=1` клинил клейм
**всей** общей очереди. Соблюдение (без смены схемы БД):
- **Forward-cleanup** — каждый путь перехода в `queued` (`requeue_running_jobs`,
`mark_job('queued')`, `mark_job_transient`, `reap_running_job('queued')`) выставляет
`run_id=NULL, pid=NULL` той же UPDATE-транзакцией, что чистит `started_at` (атомарные
`status`-guard'ы сохранены). Безусловно (исправление инварианта данных, без флага).
- **Clean claim (defense-in-depth)** — `claim_next_job` при флипе `queued→running` сбрасывает
stale `pid`/`run_id` тем же UPDATE → между claim и стампом `pid` в `_spawn` строка несёт
`pid IS NULL`. SELECT-гейт не тронут (offline hot-path).
- **Self-heal + наблюдаемость** — `db.sanitize_impossible_queued()` идемпотентно санирует
«невозможные» queued-строки при старте (`main.lifespan`) и на каждом реап-тике (never-raise,
kill-switch `ORCH_IMPOSSIBLE_QUEUED_SANITIZE_ENABLED`, дефолт on); счётчик
`impossible_queued_total` в блоке `reaper` снимка `GET /queue`.
**Норматив:** любой новый путь возврата job в `queued` ОБЯЗАН соблюсти инвариант (сбросить
`run_id`/`pid`); reviewer ловит нарушение как ≥P1. Подробнее — adr-0052,
`docs/work-items/ORCH-126/06-adr/ADR-001-queued-job-run-ownership-hygiene.md`.
### Конфиг
- `ORCH_MAX_CONCURRENCY` (default 1) — лимит параллельных jobs.

View File

@@ -0,0 +1,7 @@
# Business Request: BUG: queued job can keep stale run_id/pid and never be claimed even with serial gate disabled
Work Item ID: ORCH-126
## Description
TBD

View File

@@ -0,0 +1,139 @@
---
work_item: ORCH-126
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-17
model_used: claude-opus-4-8
track: bug
---
# 01 — BRD / Bug-report: ORCH-126 — queued-job хранит протухший run_id/pid и не клеймится даже при выключенном serial-gate
Work Item: **ORCH-126** · Repo: **orchestrator** · Стадия: analysis · Трек: **Bug (укороченный маршрут, ORCH-019)**
> 🐞 **Багфикс-трек, облегчённый пакет (ORCH-019).** Дефект контрол-плейна **локализован, причина
> установлена по коду**, корректное поведение однозначно (queued-job не должен нести run-ownership).
> Правка — точечная гигиена жизненного цикла строки `jobs` + диагностика, по существующим паттернам;
> **ADR/макет не требуются** → стадия `architecture` пропускается, `escalate: full-cycle` НЕ ставится.
> ⚠️ **Трассировка (CLAUDE.md §9):** правка затрагивает инварианты **ORCH-065** (Tier-1 pid-liveness
> reaper'а), **ORCH-113** (finalizer-liveness), **ORCH-114** (`recover_on_startup`), **ORCH-099**
> (`/metrics` читает `pid`/`run_id`) — перед изменением прочитать их `06-adr/` и не сломать.
---
## 1. Бизнес-контекст и проблема
### Симптом (наблюдаемое, из инцидента)
Второй дефект контрол-плейна, найденный при попытке провести срочные задачи **ORCH-124/125** мимо
serial-gate. Даже при `ORCH_SERIAL_GATE_ENABLED=false` queued analyst-job'ы зависают и **никогда не
переходят в `running`**:
- **job 2286 (ORCH-125):** `status=queued` при `run_id=759/760` **и** `pid=35/42`, тогда как
`started_at=NULL`**физически невозможное состояние** (run-ownership выставлен, но запуск не
состоялся).
- **job 2303 (ORCH-124):** при выключенном serial-gate **минутами** оставался `queued`; счётчики
очереди `queued=1, running=0` — задача не клеймится, хотя слот свободен.
Вывод инцидента: это **claim/restart/zombie-state баг, независимый от семантики serial-gate**.
### Причина симптома (установленный факт — по коду)
Ни один путь возврата job'а в `queued` **не сбрасывает run-ownership** (`run_id`, `pid`). Эти колонки
выставляются в `launcher._spawn` (`run_id` после INSERT в `agent_runs`, `pid` после `Popen`), но при
любом откате в `queued` остаются «протухшими» от прошлой попытки. Затронуты **5 точек**:
| # | Путь | Что чистит | `run_id`/`pid` |
|---|------|-----------|----------------|
| 1 | `db.requeue_running_jobs()` (restart-recovery, `src/db.py:1475`) | `started_at` | **НЕ чистит** |
| 2 | `db.mark_job(status='queued')` (`src/db.py:1239`) | `started_at`/`finished_at` | **НЕ чистит** |
| 3 | `db.mark_job_transient()` (`src/db.py:1213`) | `started_at`/`finished_at` | **НЕ чистит** |
| 4 | `db.reap_running_job(status='queued')` (`src/db.py:1619`) | `started_at`/`finished_at` | **НЕ чистит** |
| 5 | `db.claim_next_job()` (`src/db.py:1143`) | ставит `started_at`, `attempts++` | **НЕ сбрасывает** stale `pid` |
Это в точности воспроизводит наблюдаемое `queued + run_id=759/760 + pid=35/42 + started_at=NULL`
(пути 14: задача требовала рестарта/ретрая/реапа).
### Механизм «никогда не клеймится» (гипотеза к подтверждению в development — взаимодействие с reaper)
`claim_next_job` сам по себе на `run_id`/`pid` **не смотрит** (SELECT гейтит лишь
`status='queued' AND available_at<=now` + dep/serial-gate), поэтому stale-метаданные **не блокируют
SELECT напрямую**. Старвейшн рождается из взаимодействия stale-`pid` с job-reaper'ом (ORCH-065),
который сканирует `status='running'` и судит Tier-1 liveness по `jobs.pid` через `merge_gate.pid_alive`:
1. **Окно claim→spawn.** `claim_next_job` ставит `running`+`started_at`, но `pid` остаётся **stale**;
реальный `pid` пишется только в `_spawn` **после `Popen`** (`launcher.py:711`). Между этими шагами
(или если `_spawn` упал на `ensure_worktree`/`_materialize_deferred_branch` до строки 711) reaper
видит `running` со **старым** `pid`.
2. **pid переиспользован** (вероятно после рестарта контейнера) → `pid_alive(stale)=True` → reaper
«видит живой процесс» и **никогда не реапит** действительно застрявшую строку; при
`max_concurrency=1` (дефолт) этот фантомный `running` блокирует клейм **всей** очереди.
3. **pid мёртв** → reaper копит dead-тики (`reaper_dead_ticks=2`) против **чужого** pid и может
отбросить легитимно-стартующий job обратно в `queued`/`failed` — «выглядит частично стартовавшим,
но фактически не запускается».
Таким образом stale run-ownership **искажает сигналы liveness/диагностики** (reaper + `/metrics`
`get_running_agents`), делая клейм/рестарт ненадёжным. Точный путь старвейшна подтверждается
добавляемой авто-санацией/диагностикой (см. 04-test-plan).
## 2. Объём (scope)
### В объёме
- Аудит и фикс гигиены строки `jobs` вокруг возврата в `queued`: `claim_next_job`,
`requeue_running_jobs`, `mark_job`, `mark_job_transient`, `reap_running_job`, окно `_spawn`.
- Гарантия: queued-job либо чисто клеймится, либо детерминированно сбрасывается/реквью́ится при
рестарте — **без** удержания stale run-ownership.
- Авто-санация **или** явная диагностика «невозможного» queued-состояния (`run_id`/`pid` есть, а
`started_at` нет).
- Тесты на restart/requeue и stale queued-run-метаданные.
### Вне объёма
- Семантика serial-gate (ORCH-088/124), dep-gate (ORCH-026) — НЕ трогаем (баг от них независим).
- Изменение `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict-ключей / **схемы БД**
(новых колонок не вводим — фикс на существующих).
- Переписывание reaper'а (ORCH-065/113) и transition-lease (ORCH-114) — лишь не сломать их инварианты.
## 3. Заинтересованные стороны
- **Оператор** (заказчик фикса) — нуждается в надёжном bypass'е: при выключенном serial-gate срочная
задача обязана стартовать.
- **Self-hosting orchestrator** + все проекты на общем инстансе/очереди — фантомный `running` при
`max_concurrency=1` клинит очередь **всех** проектов.
## 4. Бизнес-требования (BR)
- **BR-1** — При выключенном serial-gate (`ORCH_SERIAL_GATE_ENABLED=false`) валидный queued ORCH-job
клеймится и переходит в `running` штатно (без зависания).
- **BR-2** — Job, возвращённый в `queued` (рестарт / ретрай / transient / reap), **не несёт** stale
run-ownership: после возврата `run_id IS NULL` и `pid IS NULL`.
- **BR-3** — Свежеклеймленный, ещё не заспавненный job **не несёт** stale `pid` (reaper не судит
liveness по чужому процессу).
- **BR-4** — «Невозможные» queued-состояния (`run_id`/`pid` при отсутствии `started_at`)
авто-санируются **или** явно сигнализируются (лог + наблюдаемость в `GET /queue`).
- **BR-5** — Регресс-тест: до фикса воспроизводит stale-состояние/старвейшн (красный), после — зелёный.
## 5. Нефункциональные требования (NFR)
- **NFR-1 (never-raise / never-wedge):** правки в горячем пути клейма не должны ронять или
заклинивать очередь всех проектов; ошибка диагностики — изолирована и не влияет на клейм.
- **NFR-2 (offline hot-path):** `claim_next_job` остаётся offline (только локальная БД), без сети.
- **NFR-3 (совместимость):** схема БД не меняется; поведение для не-stale job'ов байт-в-байт;
enduro-trails не затронут.
- **NFR-4 (self-hosting safety):** правка не рестартит/не роняет прод-контейнер, не трогает `main`,
без новых процессов; миграция БД не требуется (правки на существующих колонках).
- **NFR-5 (restart-safe / идемпотентность):** санация выдерживает повторный рестарт и гонку
worker↔reaper↔monitor (атомарные guard'ы по `status` сохранены).
## 6. Допущения и ограничения
- Дефолт `max_concurrency=1` (`config.py:114`) — единственный stuck-`running` клинит очередь;
поэтому корректность liveness reaper'а критична.
- `run_id` для queued-job — мёртвая ссылка на прошлую попытку (текущего run'а нет), её сброс безопасен;
история живёт в таблице `agent_runs`, не в `jobs.run_id`.
- Env читается на старте процесса: на self-hosting выключение флага требует управляемого рестарта
(вне объёма этого фикса; здесь — гарантия корректного клейма после рестарта).
## 7. Критерии успеха
Queued ORCH-job при выключенном serial-gate стартует штатно; queued-job'ы никогда не удерживают stale
run-ownership после рестарта/ретрая; невозможные queued-состояния авто-санируются или явно видны;
регресс-тесты покрывают restart/requeue и stale queued-run-метаданные. Полный `pytest tests/ -q`
зелёный. Детальные PASS/FAIL — `03-acceptance-criteria.md`.
## 8. Риски
- Сброс `run_id`/`pid` в неверной точке мог бы стереть идентичность активного run'а → строго на
переходе **в `queued`** и/или в claim **до** `_spawn` (детали — TRZ FR-1..FR-3, решает developer).
- Взаимодействие с reaper Tier-1/Tier-3 и transition-lease — см. трассировку выше; детальный
риск-разбор — `10-tech-risks.md` (на укороченном маршруте не обязателен).

View File

@@ -0,0 +1,97 @@
---
work_item: ORCH-126
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-17
model_used: claude-opus-4-8
track: bug
---
# 02 — ТЗ (TRZ): ORCH-126 — гигиена run-ownership queued-job + диагностика невозможных состояний
Work Item: **ORCH-126** · Repo: **orchestrator** · Стадия: analysis · Трек: **Bug**
> ТЗ описывает **конкретные изменения к реализации**, выведенные из BRD и фактического кода.
> «Как именно» (точка сброса, форма диагностики) — в пределах FR; финальный выбор — за developer.
> Архитектурного решения/ADR не требуется (укороченный маршрут ORCH-019).
## 1. Сводка изменения
Сбрасывать run-ownership строки `jobs` (`run_id`, `pid`) **во всех путях возврата job'а в `queued`**
и/или в момент claim **до** `_spawn`, чтобы (а) queued-job никогда не нёс протухший `run_id`/`pid`,
(б) свежеклеймленный-ещё-не-заспавненный job не нёс stale `pid`, который job-reaper примет за чужой
живой/мёртвый процесс. Плюс — детектор «невозможного» queued-состояния: авто-санация при старте/реапе
**и** наблюдаемость (лог + счётчик в `GET /queue`). Схема БД и контракты гейтов не меняются.
## 2. Задействованные модули / пути
| Путь | Действие |
|------|----------|
| `src/db.py` | изменить: `requeue_running_jobs` / `mark_job('queued')` / `mark_job_transient` / `reap_running_job('queued')` — добавить сброс `run_id=NULL, pid=NULL`; `claim_next_job`сброс stale `pid``run_id`) при флипе в `running` |
| `src/agents/launcher.py` | проверить окно `_spawn` (claim→`run_id`/`started_at``Popen``pid`): убедиться, что при провале `_spawn` до строки 711 job не остаётся со stale `pid` (опирается на сброс в `claim`/`mark_job`) |
| `src/job_reaper.py` | (опц., по выбору developer) Tier-1 анти-false-positive: `pid IS NULL` у свежего `running` уже трактуется как «нет pid → не реапить»; добавить авто-санацию/счётчик невозможных queued-строк, если фикс на стороне reaper |
| `src/main.py` | (опц.) при старте после `requeue_running_jobs` — лог/санация обнаруженных невозможных queued-состояний (наблюдаемость BR-4) |
| `tests/test_orch126_queued_stale_run.py` | создать: регресс + покрытие FR-1..FR-4 |
## 3. Функциональные требования
### FR-1 — Сброс run-ownership на всех путях возврата в `queued` (BR-2)
Каждый путь, переводящий job в `queued`, обязан выставить `run_id=NULL` **и** `pid=NULL` той же
UPDATE-транзакцией, что уже чистит `started_at`/`finished_at`:
`db.requeue_running_jobs()` (restart-recovery), `db.mark_job(status='queued')`,
`db.mark_job_transient()`, `db.reap_running_job(status='queued')`.
Инвариант: **`status='queued' ⇒ run_id IS NULL AND pid IS NULL AND started_at IS NULL`**.
Атомарные guard'ы по `status` (`reap_running_job ... WHERE status='running'`) — сохранить байт-в-байт.
### FR-2 — Чистый claim (BR-1, BR-3)
`db.claim_next_job` при флипе `queued→running` не должен оставлять stale `pid``run_id`) от прошлой
попытки: либо сбросить их в том же UPDATE (`pid=NULL, run_id=NULL`), либо опираться на FR-1 (тогда
queued-job их уже не несёт). Defense-in-depth (оба) — предпочтительно. SELECT-гейт
(`status='queued' AND available_at<=now` + dep/serial-gate) — **не трогать** (NFR-2 offline).
Результат: между claim и стампом `pid` в `_spawn` job имеет `pid IS NULL` (не чужой pid).
### FR-3 — Безопасность окна `_spawn` (BR-3)
Если `_spawn` падает **до** стампа `pid` (`launcher.py:711`) — `ensure_worktree`/
`_materialize_deferred_branch`/`_write_task_file`, — обработчик `queue_worker._drain_once`
(`mark_job('queued'|'failed')`) обязан, по FR-1, оставить job без stale `pid`. Проверить, что
повторный claim после такого провала стартует штатно (а не оседает «частично стартовавшим»).
### FR-4 — Детект и обработка невозможного состояния (BR-4)
«Невозможное» queued-состояние = `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` (FR-1) идемпотентно, never-raise.
- **Наблюдаемость** — структурный WARNING с `job_id`/`run_id`/`pid` + read-only счётчик в блоке
очереди `GET /queue` (например `queue.impossible_queued` или поле в существующем снимке worker'а).
### FR-5 — Корректность reaper-liveness (BR-1, NFR-5)
После FR-1..FR-3 job-reaper (ORCH-065) на свежеклеймленном `running` видит `pid IS NULL` → Tier-1
не копит dead-тики против чужого pid и не реапит легитимный старт; фантомный «живой» pid не блокирует
очередь. Инварианты ORCH-065/113/114 (Tier-2 finalize-grace, finalizer-liveness, transition-lease) —
не нарушать.
## 4. Изменения API
Нет новых эндпоинтов. **Расширение наблюдаемости** read-only снимка `GET /queue` — добавить
счётчик/индикатор обнаруженных и санированных невозможных queued-состояний (BR-4); существующие поля
снимка не переименовывать.
## 5. Изменения схемы БД
**Нет.** Колонки `jobs.run_id` / `jobs.pid` / `jobs.started_at` уже существуют; фикс — корректное
заполнение/сброс. Никаких `_ensure_column`/новых таблиц/индексов.
## 6. Требования к новым/изменённым QG checks
**Нет.** `STAGE_TRANSITIONS` / реестр `QG_CHECKS` / `check_*` / machine-verdict-ключи — байт-в-байт.
Дефект — свойство гигиены данных планировщика, не Quality Gate.
## 7. Совместимость / регресс
- **Обратная совместимость:** для не-stale job'ов поведение байт-в-байт (они и так не несут
`run_id`/`pid` в `queued`); фикс лишь нормализует аномальные строки.
- **Область:** общий планировщик/очередь (не self-hosting-scoped leaf) — затрагивает все проекты, но
семантически нейтрально (приведение к уже-документированному инварианту «queued = без run-ownership»).
- **Kill-switch:** правка — исправление инварианта данных, не новая фича; отдельный флаг не требуется.
Опциональную авто-санацию/диагностику (FR-4) допустимо закрыть под флаг, если developer сочтёт
нужным (дефолт = включено), но базовый сброс FR-1..FR-3 — безусловен.
- **Обратимость:** изменения локальны (UPDATE-наборы в `db.py`); откат — ревертом PR.
- **Миграция:** не требуется; существующие аномальные строки санируются при первом старте (FR-4).
- **Трассировка (CLAUDE.md §9):** перед правкой `pid`/`run_id`-логики прочитать ADR ORCH-065
(reaper Tier-1), ORCH-113 (finalizer-liveness), ORCH-114 (transition-lease/`recover_on_startup`),
ORCH-099 (`/metrics`); не сломать их инварианты.

View File

@@ -0,0 +1,107 @@
---
work_item: ORCH-126
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-17
model_used: claude-opus-4-8
track: bug
---
# 03 — Критерии приёмки (Acceptance Criteria): ORCH-126 — гигиена run-ownership queued-job
Work Item: **ORCH-126** · Repo: **orchestrator** · Стадия: analysis · Трек: **Bug**
Формат: каждый критерий имеет **PASS** / **FAIL**. Reviewer проверяет буквально по файлам репозитория
и прогону тестов.
---
## AC-1 — Инвариант чистого queued после возврата
**Условие:** job возвращён в `queued` любым путём — `requeue_running_jobs` (restart),
`mark_job('queued')` (retry), `mark_job_transient` (transient), `reap_running_job('queued')` (reaper).
- **PASS:** после операции строка имеет `run_id IS NULL` **И** `pid IS NULL` **И** `started_at IS NULL`.
- **FAIL:** хотя бы один из `run_id`/`pid`/`started_at` остаётся непустым у `status='queued'`.
---
## AC-2 — Клейм при выключенном serial-gate стартует штатно
**Условие:** `ORCH_SERIAL_GATE_ENABLED=false`, есть один валидный queued ORCH-job, `running=0`,
`max_concurrency≥1`.
- **PASS:** `claim_next_job` выбирает job, флипает в `running`, `_spawn` стартует; задача не зависает.
- **FAIL:** job остаётся `queued` при свободном слоте (повтор `queued=1, running=0`).
---
## AC-3 — Свежеклеймленный job не несёт чужой pid
**Условие:** job клеймится из `queued`, который ранее нёс stale `pid`/`run_id`; до выполнения стампа
`pid` в `_spawn`.
- **PASS:** сразу после `claim_next_job` (до `_spawn` Popen-стампа) `jobs.pid IS NULL` (не stale).
- **FAIL:** после claim строка несёт `pid` от прошлой попытки.
---
## AC-4 — Reaper не реапит легитимный старт по чужому pid
**Условие:** job только что склеймлен (`running`, `started_at=now`, `pid IS NULL`), job-reaper
делает тик.
- **PASS:** reaper не считает строку мёртвой (Tier-1 пропускает `pid IS NULL`); не возвращает её в
`queued`/`failed`; легитимный старт не сбивается. Фантомный «живой» stale-pid больше не блокирует
клейм очереди.
- **FAIL:** reaper реапит стартующий job по чужому pid, либо вечно держит фантомный `running`.
---
## AC-5 — Невозможное queued-состояние авто-санируется / явно видно
**Условие:** в БД присутствует строка `status='queued'` с непустым `run_id`/`pid`/`started_at`
(например после апгрейда на проблемной БД или гонки).
- **PASS:** состояние приводится к чистому `queued` (авто-санация при старте/реапе, идемпотентно,
never-raise) **и/или** явно сигнализируется (структурный WARNING с `job_id`/`run_id`/`pid` +
счётчик в `GET /queue`).
- **FAIL:** аномальная строка остаётся незамеченной и неисправленной.
---
## AC-6 — Окно `_spawn` устойчиво к провалу до стампа pid
**Условие:** `_spawn` падает до стампа `pid` (`ensure_worktree`/материализация ветки/запись task-файла);
job уходит назад в `queued` обработчиком `_drain_once`.
- **PASS:** строка в `queued` чистая (AC-1); повторный claim стартует штатно; нет «частично
стартовавшего» зависания.
- **FAIL:** после провала job несёт stale `pid`/`run_id` или не клеймится повторно.
---
## AC-7 — Без регрессов контрактов и схемы
**Условие:** прогон полного `pytest tests/ -q`; проверка диффа.
- **PASS:** все тесты зелёные; `STAGE_TRANSITIONS` / реестр `QG_CHECKS` / `check_*` /
machine-verdict-ключи / схема БД (`jobs`) — без изменений; новых колонок/таблиц нет;
поведение для не-stale job'ов неизменно; enduro не затронут.
- **FAIL:** падение тестов, изменён контракт гейта/схема БД, или регресс для здоровых job'ов.
---
## AC-8 — Обязательный регресс-тест красный→зелёный
**Условие:** тест из `04-test-plan.yaml`, фиксирующий stale-состояние и/или старвейшн.
- **PASS:** тест красный на коде ДО фикса, зелёный ПОСЛЕ.
- **FAIL:** тест зелёный до фикса (не воспроизводит баг) или красный после.
---
## Сводная матрица AC ↔ FR/BR
| AC | Покрывает |
|----|-----------|
| AC-1 | BR-2 / FR-1 |
| AC-2 | BR-1 / FR-2 |
| AC-3 | BR-3 / FR-2 |
| AC-4 | BR-1, BR-3 / FR-5 |
| AC-5 | BR-4 / FR-4 |
| AC-6 | BR-3 / FR-3 |
| AC-7 | NFR-1..NFR-4 |
| AC-8 | BR-5 / FR-1..FR-4 |

View File

@@ -0,0 +1,102 @@
work_item: ORCH-126
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-17
model_used: claude-opus-4-8
title: "ORCH-126 — гигиена run-ownership queued-job + диагностика невозможных состояний"
framework: pytest
track: bug
scope: >
Покрывает: сброс run_id/pid на всех путях возврата job в queued (restart/retry/transient/reap),
чистый claim без stale pid, устойчивость окна _spawn, детект/санацию невозможных queued-состояний,
корректность Tier-1 reaper-liveness. Вне покрытия: семантика serial-gate (ORCH-088/124) и dep-gate
(ORCH-026), переписывание reaper/transition-lease, изменение схемы БД.
notes: >
TC-01 — ОБЯЗАТЕЛЬНЫЙ регресс-тест бага: красный на коде ДО фикса, зелёный ПОСЛЕ (BR-5 / AC-8).
Тесты используют изолированную временную SQLite-БД (db.init_db во временной директории), без сети,
без Claude CLI, без реального Popen (spawn мокается/подменяется). Полный регресс pytest tests/ -q
должен оставаться зелёным (AC-7).
tests:
- id: TC-01
type: integration
description: >
РЕГРЕСС (red→green): job в running со stale run_id+pid (started_at set) проходит
requeue_running_jobs() (имитация рестарта); ассерт — строка queued с run_id IS NULL,
pid IS NULL, started_at IS NULL. До фикса pid/run_id остаются (красный), после — чисто.
module: tests/test_orch126_queued_stale_run.py
expected: PASS
- id: TC-02
type: unit
description: >
mark_job(job_id, 'queued') сбрасывает run_id и pid (а не только started_at/finished_at).
module: tests/test_orch126_queued_stale_run.py
expected: PASS
- id: TC-03
type: unit
description: >
mark_job_transient(...) на job со stale run_id/pid возвращает чистый queued (run_id/pid NULL),
сохраняя инкремент transient_attempts и backoff available_at.
module: tests/test_orch126_queued_stale_run.py
expected: PASS
- id: TC-04
type: unit
description: >
reap_running_job(job_id, 'queued', ...) сбрасывает run_id/pid; атомарный guard
WHERE status='running' сохранён (повторный вызов на уже-queued строке -> rowcount 0).
module: tests/test_orch126_queued_stale_run.py
expected: PASS
- id: TC-05
type: integration
description: >
claim_next_job() флипает queued->running и не оставляет stale pid: сразу после claim
jobs.pid IS NULL (до _spawn-стампа). Подтверждает чистый старт (AC-3).
module: tests/test_orch126_queued_stale_run.py
expected: PASS
- id: TC-06
type: integration
description: >
При выключенном serial-gate (ORCH_SERIAL_GATE_ENABLED=false) валидный queued ORCH-job
выбирается claim_next_job при running=0 и не зависает (AC-2).
module: tests/test_orch126_queued_stale_run.py
expected: PASS
- id: TC-07
type: integration
description: >
Reaper-тик на свежеклеймленном running с pid IS NULL не реапит строку (Tier-1 пропускает
pid IS NULL), легитимный старт сохраняется (AC-4).
module: tests/test_orch126_queued_stale_run.py
expected: PASS
- id: TC-08
type: integration
description: >
Детект/санация невозможного состояния: предзаписанная строка queued с непустым pid/run_id/
started_at приводится к чистому queued (авто-санация при старте/реапе) И/ИЛИ фиксируется
счётчиком в снимке очереди; операция идемпотентна и never-raise (AC-5).
module: tests/test_orch126_queued_stale_run.py
expected: PASS
- id: TC-09
type: integration
description: >
Окно _spawn: при провале launch (launch_job/_spawn бросает до стампа pid) обработчик
_drain_once возвращает job в queued чистым (pid/run_id NULL); повторный claim стартует
штатно (AC-6).
module: tests/test_orch126_queued_stale_run.py
expected: PASS
- id: TC-10
type: unit
description: >
Анти-регресс: для здорового job (никогда не стартовал) поведение mark_job/claim байт-в-байт;
терминальные исходы (done/failed/cancelled) и стамп finished_at не затронуты сбросом ownership.
module: tests/test_orch126_queued_stale_run.py
expected: PASS

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>

View File

@@ -0,0 +1,118 @@
---
verdict: APPROVED
work_item: ORCH-126
stage: review
author_agent: reviewer
status: approved
created_at: 2026-06-17
model_used: claude-opus-4-8
type: review
work_item_id: ORCH-126
version: 1
---
# Review ORCH-126 — гигиена run-ownership строки `jobs` (инвариант «queued ⇒ run_id/pid/started_at IS NULL»)
## Summary
Точечный, корректно-локализованный багфикс контрол-плейна (трек **Bug**, инцидент ORCH-124/125,
job 2286). Реализация **полностью соответствует ТЗ/AC и ADR-001 + сквозному adr-0052**, не меняет
схему БД, `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_*` и machine-verdict-ключи (AC-7), несёт
обязательный регресс-тест красный→зелёный (AC-8/TC-01) и обновляет документацию в том же PR. Все
4 маркированных инварианта (ORCH-065/113/114/099) сохранены и трассированы. **P0/P1 findings нет.**
Проверка выполнена по коммиту `d7e7a4d` (фактический объём ORCH-126); широкий `main...HEAD` раздут
устаревшим локальным `main` и к ревью не относится.
### Соответствие ТЗ / AC (ось 1) — выполнено
- **FR-1 / AC-1:** сброс `run_id=NULL, pid=NULL` той же UPDATE-транзакцией во всех 4 путях возврата
в `queued``requeue_running_jobs` (`db.py:1506`), `mark_job('queued')` (`:1264`),
`mark_job_transient` (`:1226`), `reap_running_job('queued')` (`:1668`). Атомарные `status`-guard'ы
(`WHERE status='running'`, rowcount) сохранены байт-в-байт (TC-04 проверяет «второй reap проигрывает»).
- **FR-2 / AC-3:** `claim_next_job` сбрасывает `pid=NULL, run_id=NULL` внутри существующего одного
UPDATE флипа `queued→running`; SELECT-гейт не тронут (offline hot-path, NFR-2). Возврат — re-SELECT
после UPDATE → отдаваемый dict корректно несёт `pid IS NULL` (TC-05).
- **FR-3 / AC-6:** окно `_spawn` устойчиво за счёт D1 (нового кода в launcher не требуется); TC-09
подтверждает чистый requeue + повторный claim без «частично стартовавшего» зависания.
- **FR-4 / AC-5:** `find_impossible_queued_jobs` / `sanitize_impossible_queued` (идемпотентно,
never-raise) + вызов при старте (`main.lifespan` после `requeue_running_jobs`) и на каждом
реап-тике; счётчик `impossible_queued_total`/`last_impossible_queued` в блоке `reaper` снимка
`GET /queue` (TC-08/08b, kill-switch проверен).
- **FR-5 / AC-4:** reaper Tier-1 не правился (подтверждено diff'ом) — `if pid is not None and not
pid_alive(pid)` пропускает `pid IS NULL`, `else` сбрасывает streak (`job_reaper.py:290/302`);
Tier-3 backstop неизменен. TC-07 фиксирует «свежий running с pid IS NULL не реапится».
- **AC-7:** схема БД (`jobs`) без изменений, новых колонок/таблиц нет; для здоровых job'ов поведение
байт-в-байт (TC-10 — терминальные исходы и `run_id`-линк не затронуты); enduro не затронут.
### Соответствие ADR / трассировка (ось 2) — выполнено
- ADR-001 (`06-adr/`) + сквозной `adr-0052` присутствуют, согласованы с кодом, статус `accepted`.
- Маркированные инварианты (CLAUDE.md §9): **ORCH-065** Tier-1 — восстановлен и не изменён;
**ORCH-113** finalizer-liveness и **ORCH-114** transition-lease — ортогональны (свои маркеры/таблицы,
recovery по `boot_id`, не по `jobs.pid`); **ORCH-099** `/metrics` — улучшен (убрана утечка stale-pid).
Проверено: коммит **не трогает** `_reap_job`/`pid_alive` — только аддитивный sanitize-метод/вызов/поля
`status()`.
- **Багфикс-трек (ORCH-019, BR-4):** обязательный регресс-фиксатор присутствует — **TC-01** красный на
коде до фикса (старый `requeue_running_jobs` оставлял `run_id=759`), зелёный после.
### Качество кода (ось 3) — приемлемо
- never-raise соблюдён: sanitize обёрнут в `try/except` в reaper и в `main.lifespan`; db-функции —
`try/finally` на соединении.
- `mark_job`/`reap_running_job`: каллер-переданный `run_id` для `status='queued'` корректно
игнорируется (`if run_id is not None and status != "queued"`) и принудительно зануляется в
`elif`-ветке — без конфликта двойного SET.
- Docstrings на новых публичных функциях есть; тесты содержательные (seeded-DB, без сети/Popen).
- env-маппинг корректен: `impossible_queued_sanitize_enabled` ↔ `ORCH_IMPOSSIBLE_QUEUED_SANITIZE_ENABLED`
(env_prefix `ORCH_`), флаг задокументирован в `.env.example`.
### Документация (ось 4, приоритет) — выполнено
`src/` изменён → документация обновлена в том же PR:
- `docs/architecture/internals.md` — новый раздел «Инвариант run-ownership строки `jobs`» +
аннотации `jobs.run_id`/`pid` + queue-recovery (корректный дом для внутренностей очереди/reaper).
- `.env.example` — флаг `ORCH_IMPOSSIBLE_QUEUED_SANITIZE_ENABLED` в блоке reaper.
- `CHANGELOG.md` — детальная запись (D1D5 + покрытие).
- ADR-001 + сквозной `adr-0052` — заведены.
- README API-таблица **не требует** правки: новых эндпоинтов нет (TRZ §4) — лишь read-only под-поле
в существующем блоке `reaper` снимка `GET /queue` (паттерн прошлых reaper-метрик, напр.
`finalizer_defers_total` ORCH-113).
- Витрина `docs/overview/` (ORCH-011) **не требует** правки: grep по витрине не нашёл упоминаний
run-ownership/reaper/impossible-queued — фикс внутренней гигиены данных не меняет описанных в
витрине стадий/гейтов/агентов/интеграций; ничего не выдаётся за устаревшее.
- Обзорный `README.md` «Известные ограничения» (ORCH-079): данный дефект там не числился пунктом —
обновления не требуется.
## Findings
### P0 — Blocker
- Нет.
### P1 — Must fix
- Нет.
### P2 — Should fix
- Нет.
### P3 — Nice to have (не влияет на вердикт)
- [ ] `sanitize_impossible_queued()` (`db.py`) делает `find_*` (откр/закр соединение) и затем
**отдельный** UPDATE в новом соединении; возвращаемый `anomalies` — pre-heal снимок. При редкой
гонке (claim флипнул строку `queued→running` между find и UPDATE) защитный `WHERE status='queued'`
корректно пропустит строку, но функция всё равно отчитается о ней как «исцелённой» → косметический
пере-подсчёт счётчика `impossible_queued_total`. Безвреден и идемпотентен; при желании — считать по
фактическому `rowcount` UPDATE.
- [ ] В коммит попал рабочий файл-черновик `.task-dev.md` (`ORCH-124 → ORCH-126`). Это **не** регресс
данного PR — файл коммитится во всех прошлых задачах, влитых в `main` (ORCH-124/116/115/112…);
housekeeping-замечание: уместно добавить `.task-dev.md`/`.task-review.md` в `.gitignore` отдельной
задачей.
## Документация
**Обновлена в том же PR — требование выполнено.** `internals.md` (раздел инварианта + аннотации
колонок + queue-recovery), `.env.example` (новый флаг), `CHANGELOG.md`, ADR-001 + сквозной `adr-0052`.
README API-таблица и витрина `docs/overview/` правки **не требуют** (новых эндпоинтов нет — read-only
под-поле существующего блока `reaper`; в витрине нет затронутых фактов). Документация консистентна с
реализацией; стейл-ссылок не обнаружено.
## Проверка тестами
- `pytest tests/test_orch126_queued_stale_run.py -q` → **11 passed**.
- Регресс смежных подсистем: `test_orch113_reaper_finalizer_liveness.py`,
`test_orch114_transition_ownership.py`, `test_webhooks.py` → **63 passed**.
- TC-01 — обязательный регресс красный→зелёный (подтверждено по семантике до-фиксового
`requeue_running_jobs`).

View File

@@ -0,0 +1,40 @@
---
result: PASS
work_item: ORCH-126
stage: testing
author_agent: test-runner
status: success
created_at: 2026-06-17
model_used: n/a
exit_code: 0
smoke: ok
---
# Test Gate Log (deterministic runner, ORCH-116)
pytest exit-code `0` -> `result: PASS` (smoke: ok).
Вердикт зафиксирован детерминированным test-раннером (ORCH-116), не LLM. PASS/FAIL = exit-код `pytest` + read-only smoke (`/health`, `/status`, `/queue` + блок `serial_gate`).
pytest stdout (tail):
```
.............................................. [ 65%]
........................................................................ [ 69%]
........................................................................ [ 72%]
........................................................................ [ 75%]
........................................................................ [ 78%]
........................................................................ [ 82%]
........................................................................ [ 85%]
........................................................................ [ 88%]
........................................................................ [ 92%]
........................................................................ [ 95%]
........................................................................ [ 98%]
............................. [100%]
=============================== warnings summary ===============================
src/config.py:8
/repos/_wt/orchestrator/feature_ORCH-126-bug-queued-job-can-keep-stale-/src/config.py:8: PydanticDeprecatedSince20: Support for class-based `config` is deprecated, use ConfigDict instead. Deprecated in Pydantic V2.0 to be removed in V3.0. See Pydantic V2 Migration Guide at https://errors.pydantic.dev/2.13/migration/
class Settings(BaseSettings):
-- Docs: https://docs.pytest.org/en/stable/how-to/capture-warnings.html
2189 passed, 1 warning in 95.34s (0:01:35)
```

View File

@@ -0,0 +1,12 @@
---
deploy_status: SUCCESS
work_item: ORCH-126
hook_exit_code: 0
deployed_by: deploy-finalizer
---
# Deploy log — ORCH-036 executable self-deploy
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.

View File

@@ -0,0 +1,46 @@
---
staging_status: SUCCESS
work_item: ORCH-126
stage: deploy-staging
author_agent: staging-runner
status: success
created_at: 2026-06-17
model_used: n/a
exit_code: 0
base_url: http://localhost:8501
---
# Staging Gate Log (deterministic runner, ORCH-115)
Staging suite exit-code `0` -> `staging_status: SUCCESS`.
Вердикт зафиксирован детерминированным staging-раннером (ORCH-115), не LLM. infra-tolerance (ORCH-061) уже учтена внутри `staging_check.py` — раннер её не пересуживает.
INFRA-WAIVED lines (ORCH-061, copied for observability):
- INFRA-WAIVED: C9a Branch appears in orchestrator-sandbox, C9b Analyst job enqueued in staging queue (known sandbox-infra; real checks green)
Staging suite stdout (tail):
```
(waiting for analyst job in queue)
· waiting... (waiting for analyst job in queue)
· waiting... (waiting for analyst job in queue)
· waiting... (waiting for analyst job in queue)
· waiting... (waiting for analyst job in queue)
· waiting... (waiting for analyst job in queue)
✗ FAIL C9b Analyst job enqueued in staging queue
[CLEANUP]
· CLEANUP: no branch to delete
✓ PASS CLEANUP: deleted Plane issue bd1cf253-c962-40cc-9d09-80b8fb11dfcc (HTTP 204)
· CLEANUP DB: no task row found for plane_id=bd1cf253-c962-40cc-9d09-80b8fb11dfcc
· CLEANUP DB dedup: no such table: events_dedup
============================================================
 RESULT: 8/10 checks PASS
REAL failed : none
SANDBOX_INFRA failed: ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue']
============================================================
· tolerance: staging_infra_tolerance_enabled=True
INFRA-WAIVED: C9a Branch appears in orchestrator-sandbox, C9b Analyst job enqueued in staging queue (known sandbox-infra; real checks green)
VERDICT: SUCCESS (exit 0) — SUCCESS (infra-waived): ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue'] are known sandbox-infra checks; all real checks green
```

View File

@@ -722,6 +722,17 @@ class Settings(BaseSettings):
lease_reclaim_enabled: bool = True
reaper_finalizer_liveness_enabled: bool = True
# ORCH-126 (D4/FR-4): detect + self-heal "impossible" queued rows — a job that
# is `status='queued'` while still carrying run-ownership (run_id/pid/started_at),
# which is physically impossible (the incident state of job 2286: `queued +
# run_id=759 + pid=35 + started_at=NULL`). The BASE run-ownership reset on every
# requeue/claim path (D1-D3 in src/db.py) is UNCONDITIONAL — this kill-switch
# gates ONLY the optional detect/sanitize sweep (run at startup in main.lifespan
# and on each job-reaper tick) plus its read-only /queue counter. Default on;
# False -> the sweep is a no-op (D1-D3 still enforce the invariant going forward).
# never-raise: a sweep error is isolated and never wedges startup / the reaper.
impossible_queued_sanitize_enabled: bool = True
# ORCH-114 (adr-0045): durable transition-ownership lease + expected-stage CAS for
# side-effectful stage transitions. Generalises the process-local ORCH-113
# finalizer-liveness to a DURABLE, cross-path owner-exclusion (additive table

View File

@@ -1194,8 +1194,16 @@ def claim_next_job() -> dict | None:
return None
job_id = row["id"]
cur = conn.execute(
# ORCH-126 (D2/FR-2): reset run-ownership on the queued->running flip
# (defense-in-depth over D1). Between this claim and the launcher
# stamping the real pid in _spawn, the row MUST carry pid IS NULL —
# not a stale (possibly OS-reused) pid that the job-reaper's Tier-1
# liveness probe (ORCH-065) would mistake for this run's process. The
# SELECT gate above is untouched (offline hot path, NFR-2); this is
# part of the existing single UPDATE (no new SELECT / network).
"UPDATE jobs SET status='running', "
"attempts = attempts + 1, started_at = datetime('now') "
"attempts = attempts + 1, started_at = datetime('now'), "
"pid = NULL, run_id = NULL "
"WHERE id = ? AND status='queued'",
(job_id,),
)
@@ -1217,12 +1225,19 @@ def mark_job_transient(job_id: int, available_at_sql_offset_seconds: int,
Increments `transient_attempts` (separate from the code-fault `attempts`),
sets status back to 'queued', and gates re-pickup via `available_at` =
now + backoff seconds. started_at/finished_at are cleared.
ORCH-126 (D1/FR-1): also resets run-ownership (run_id/pid) so the requeued job
obeys the invariant `status='queued' ⇒ run_id IS NULL AND pid IS NULL AND
started_at IS NULL` (see mark_job). The transient bookkeeping
(transient_attempts / available_at backoff) is preserved.
"""
conn = get_db()
sets = [
"status='queued'",
"transient_attempts = transient_attempts + 1",
"available_at = datetime('now', ?)",
"run_id = NULL",
"pid = NULL",
"started_at = NULL",
"finished_at = NULL",
]
@@ -1249,11 +1264,23 @@ def mark_job(
- 'done'/'failed'/'cancelled' (ORCH-090) also stamp finished_at.
- 'queued' (requeue for retry) clears started_at/finished_at so the next
claim treats it as fresh.
ORCH-126 (D1/FR-1): a 'queued' requeue ALSO resets run-ownership
(run_id/pid), enforcing the lifecycle invariant
``status='queued' ⇒ run_id IS NULL AND pid IS NULL AND started_at IS NULL``.
A requeued job must carry NO run-ownership: a stale (and possibly OS-reused)
pid would fool the job-reaper's Tier-1 liveness probe (ORCH-065) into either
guarding a phantom 'running' forever (wedging the shared queue at
max_concurrency=1) or reaping a fresh start. The run history lives in
agent_runs, not jobs.run_id, so dropping the link is safe. Any caller-supplied
run_id is therefore IGNORED for 'queued' (the link is cleared) — callers such
as launcher._finalize_permanent / reaper still pass the old run_id for the
non-queued paths, where it is preserved as before.
"""
conn = get_db()
sets = ["status = ?"]
params: list = [status]
if run_id is not None:
if run_id is not None and status != "queued":
sets.append("run_id = ?")
params.append(run_id)
if error is not None:
@@ -1262,6 +1289,8 @@ def mark_job(
if status in ("done", "failed", "cancelled"):
sets.append("finished_at = datetime('now')")
elif status == "queued":
sets.append("run_id = NULL")
sets.append("pid = NULL")
sets.append("started_at = NULL")
sets.append("finished_at = NULL")
params.append(job_id)
@@ -1477,10 +1506,17 @@ def requeue_running_jobs() -> int:
died on restart -> put it back to 'queued'. attempts are kept as-is (the next
claim does NOT re-increment beyond what is needed; claim_next_job increments on
pickup). Returns the number of requeued jobs.
ORCH-126 (D1/FR-1): also resets run-ownership (run_id/pid). The dead worker's
run_id/pid are stale by construction after a restart; leaving them would make a
just-requeued job carry a phantom pid that the job-reaper's Tier-1 probe could
mistake for a live process (incident: job 2286 ``queued + run_id=759 + pid=35 +
started_at=NULL``). Enforces ``status='queued' ⇒ run_id/pid/started_at IS NULL``.
"""
conn = get_db()
cur = conn.execute(
"UPDATE jobs SET status='queued', started_at = NULL "
"UPDATE jobs SET status='queued', started_at = NULL, "
"run_id = NULL, pid = NULL "
"WHERE status='running'"
)
conn.commit()
@@ -1632,12 +1668,17 @@ def reap_running_job(
Status semantics match ``mark_job``: done/failed stamp ``finished_at``; queued
clears ``started_at``/``finished_at`` so the next claim treats it as fresh.
ORCH-126 (D1/FR-1): a 'queued' reap ALSO resets run-ownership (run_id/pid),
mirroring ``mark_job`` — the invariant ``status='queued' ⇒ run_id/pid/started_at
IS NULL`` (a reaper-requeued job must not carry the dead run's pid). Any
caller-supplied run_id is ignored for 'queued'.
"""
conn = get_db()
try:
sets = ["status = ?"]
params: list = [status]
if run_id is not None:
if run_id is not None and status != "queued":
sets.append("run_id = ?")
params.append(run_id)
if error is not None:
@@ -1646,6 +1687,8 @@ def reap_running_job(
if status in ("done", "failed", "cancelled"): # ORCH-090: cancelled is terminal
sets.append("finished_at = datetime('now')")
elif status == "queued":
sets.append("run_id = NULL")
sets.append("pid = NULL")
sets.append("started_at = NULL")
sets.append("finished_at = NULL")
params.append(job_id)
@@ -1659,6 +1702,54 @@ def reap_running_job(
conn.close()
def find_impossible_queued_jobs() -> list[dict]:
"""ORCH-126 (D4/FR-4): rows that violate the queued lifecycle invariant.
An "impossible" queued state is ``status='queued' AND (run_id IS NOT NULL OR
pid IS NOT NULL OR started_at IS NOT NULL)`` — run-ownership is set but the run
never actually started (the incident state of job 2286). Read-only; returns the
offending rows' identity columns (id/run_id/pid/started_at + agent/repo) for
diagnostics. Empty list when the invariant holds everywhere.
"""
conn = get_db()
try:
rows = conn.execute(
"SELECT id, run_id, pid, started_at, agent, repo FROM jobs "
"WHERE status='queued' AND (run_id IS NOT NULL OR pid IS NOT NULL "
"OR started_at IS NOT NULL)"
).fetchall()
finally:
conn.close()
return [dict(r) for r in rows]
def sanitize_impossible_queued() -> list[dict]:
"""ORCH-126 (D4/FR-4): idempotently restore the queued invariant for any
"impossible" queued row (run-ownership set while queued).
Returns the rows it healed (pre-heal id/run_id/pid/started_at) so the caller can
log them; empty list when nothing was anomalous. The UPDATE carries the same
``status='queued'`` guard, so it can never touch a live ``running`` row (TR-1).
Used at startup (``main.lifespan``) and on each reaper tick to self-heal both a
forgotten future requeue path (TR-2) and pre-existing rows on a problematic DB
(TR-7) without a schema migration.
"""
anomalies = find_impossible_queued_jobs()
if not anomalies:
return []
conn = get_db()
try:
conn.execute(
"UPDATE jobs SET run_id = NULL, pid = NULL, started_at = NULL "
"WHERE status='queued' AND (run_id IS NOT NULL OR pid IS NOT NULL "
"OR started_at IS NOT NULL)"
)
conn.commit()
finally:
conn.close()
return anomalies
def get_job(job_id: int) -> dict | None:
"""Fetch a single job by id."""
conn = get_db()

View File

@@ -154,10 +154,55 @@ class JobReaper:
# monitor still owns a deploy-staging finalization. Reset on restart (safe:
# startup requeue_running_jobs covers the restart path).
self.finalizer_defers_total: int = 0
# ORCH-126 (D4/FR-4): count of "impossible" queued rows self-healed (queued
# while carrying stale run-ownership) + the last healed batch. Reset on
# restart (safe: the startup sweep + every tick re-detect any residue).
self.impossible_queued_total: int = 0
self.last_impossible_queued: dict | None = None
# -- ORCH-126 (D4/FR-4): impossible-queued self-heal -------------------
def sanitize_impossible_queued_once(self) -> int:
"""Detect + self-heal "impossible" queued rows (queued while carrying stale
run-ownership run_id/pid/started_at — the incident state of job 2286).
Idempotent, never-raise, gated by ``impossible_queued_sanitize_enabled``
(default on; D1-D3 enforce the invariant going forward regardless). Bumps the
observability counters + logs one WARNING per healed row. Used both at startup
(``main.lifespan``) and on every reaper tick. Returns the count healed.
"""
if not getattr(settings, "impossible_queued_sanitize_enabled", True):
return 0
try:
from .db import sanitize_impossible_queued
healed = sanitize_impossible_queued()
except Exception as e: # noqa: BLE001 - never break the tick / startup
logger.error("reaper: impossible-queued sanitize failed: %s", e)
return 0
if healed:
self.impossible_queued_total += len(healed)
self.last_impossible_queued = {
"count": len(healed),
"jobs": [
{"job_id": r.get("id"), "run_id": r.get("run_id"),
"pid": r.get("pid")}
for r in healed
],
}
for r in healed:
logger.warning(
"reaper: sanitized impossible queued job %s (run_id=%s, pid=%s, "
"started_at=%s) -> clean queued (ORCH-126 invariant)",
r.get("id"), r.get("run_id"), r.get("pid"), r.get("started_at"),
)
return len(healed)
# -- A: zombie-job reaping --------------------------------------------
def reap_once(self) -> None:
"""One scan over all ``running`` jobs (per-job never-raise) + lease reclaim."""
# ORCH-126 (D4/FR-4): self-heal impossible queued rows first (independent of
# reaper_enabled — own kill-switch, never-raise) so a stale run-ownership row
# is normalised before the running-scan reasons about liveness.
self.sanitize_impossible_queued_once()
if settings.reaper_enabled:
try:
running = get_running_jobs()
@@ -570,6 +615,13 @@ class JobReaper:
"finalizer_liveness_enabled": settings.reaper_finalizer_liveness_enabled,
"finalizer_defers_total": self.finalizer_defers_total,
"finalizer_owned": _owned,
# ORCH-126 (D4/FR-4): impossible-queued self-heal observability
# (read-only) — kill-switch + cumulative healed count + last batch.
"impossible_queued_sanitize_enabled": getattr(
settings, "impossible_queued_sanitize_enabled", True
),
"impossible_queued_total": self.impossible_queued_total,
"last_impossible_queued": self.last_impossible_queued,
}

View File

@@ -60,6 +60,23 @@ async def lifespan(app: FastAPI):
if requeued:
log.warning(f"Queue-recovery: requeued {requeued} running job(s) after restart")
# ORCH-126 (D4/FR-4): self-heal any "impossible" queued rows — a job left
# `status='queued'` while still carrying run-ownership (run_id/pid/started_at),
# which is physically impossible (the incident state of job 2286). Runs right
# after requeue_running_jobs so a row the dead worker left half-claimed is
# normalised before the worker/reaper start. Routed through the reaper so the
# cumulative /queue counter has a single owner. Idempotent + never-raise; the
# recurring reaper tick keeps it clean thereafter.
try:
from .job_reaper import reaper as _reaper
healed = _reaper.sanitize_impossible_queued_once()
if healed:
log.warning(
f"Queued-hygiene: sanitized {healed} impossible queued job(s) at startup"
)
except Exception as e:
log.warning(f"Queued-hygiene sanitize skipped: {e}")
# ORCH-114 (adr-0045 / D7 / FR-4): clear durable transition-leases left by the
# PREVIOUS process boot. This process has a fresh boot_id, so every prior lease is
# stale by construction -> reclaim it so the just-requeued jobs can re-drive their

View File

@@ -0,0 +1,317 @@
"""ORCH-126: run-ownership hygiene of the `jobs` row — invariant
``status='queued' ⇒ run_id IS NULL AND pid IS NULL AND started_at IS NULL``.
Covers FR-1…FR-5 / AC-1…AC-8 (TC-01..TC-10, see 04-test-plan.yaml). The defect:
no path that returns a job to ``queued`` reset its run-ownership (``run_id`` /
``pid``), so a requeued/restart-recovered job carried a stale (and possibly
OS-reused) pid. The job-reaper (ORCH-065) judges Tier-1 liveness by ``jobs.pid``,
so a reused pid made it guard a phantom ``running`` forever — at ``max_concurrency=1``
this wedged the claim of EVERY project's queue (incident: job 2286 ``queued +
run_id=759/760 + pid=35/42 + started_at=NULL``).
No network / no Claude CLI / no real Popen: jobs are seeded directly in an
isolated temp SQLite DB (the convention of test_orch114_transition_ownership.py).
TC-01 is the MANDATORY regression (red before the fix, green after).
"""
import os
import tempfile
import pytest
os.environ.setdefault("ORCH_REPOS_DIR", tempfile.gettempdir())
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
import src.db as db
from src.db import (
init_db,
get_db,
get_job,
claim_next_job,
mark_job,
mark_job_transient,
reap_running_job,
requeue_running_jobs,
find_impossible_queued_jobs,
sanitize_impossible_queued,
)
from src.job_reaper import JobReaper
_REPO = "orchestrator"
@pytest.fixture(autouse=True)
def fresh_db(tmp_path, monkeypatch):
dbfile = tmp_path / "orch126.db"
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
init_db()
# Keep the scheduler gates out of the way of the claim tests (the bug is
# independent of serial-gate / dep-gate semantics — BRD §2 out-of-scope).
monkeypatch.setattr(db.settings, "serial_gate_enabled", False, raising=False)
monkeypatch.setattr(db.settings, "task_deps_enabled", False, raising=False)
yield
def _seed_job(
*,
status="queued",
run_id=None,
pid=None,
started_at=None,
finished_at=None,
agent="developer",
repo=_REPO,
attempts=0,
max_attempts=2,
transient_attempts=0,
):
"""Insert a job row with arbitrary (possibly invalid) run-ownership columns."""
conn = get_db()
cur = conn.execute(
"INSERT INTO jobs (agent, repo, status, run_id, pid, started_at, "
"finished_at, attempts, max_attempts, transient_attempts) "
"VALUES (?,?,?,?,?,?,?,?,?,?)",
(agent, repo, status, run_id, pid, started_at, finished_at,
attempts, max_attempts, transient_attempts),
)
jid = cur.lastrowid
conn.commit()
conn.close()
return jid
def _row(job_id):
return get_job(job_id)
# --------------------------------------------------------------------------- #
# TC-01 — MANDATORY regression (red -> green): restart-recovery clears ownership
# --------------------------------------------------------------------------- #
def test_tc01_requeue_running_clears_stale_ownership():
"""A job left 'running' with stale run_id+pid+started_at (the incident state)
is restored to a CLEAN queued by requeue_running_jobs() — run_id/pid/started_at
all NULL. Red on the code BEFORE the fix (run_id/pid kept), green after (AC-1/AC-8).
"""
jid = _seed_job(
status="running", run_id=759, pid=35, started_at="2026-06-17 10:00:00",
)
n = requeue_running_jobs()
assert n == 1
r = _row(jid)
assert r["status"] == "queued"
assert r["run_id"] is None
assert r["pid"] is None
assert r["started_at"] is None
# --------------------------------------------------------------------------- #
# TC-02 — mark_job(..., 'queued') resets run_id and pid
# --------------------------------------------------------------------------- #
def test_tc02_mark_job_queued_resets_ownership():
jid = _seed_job(
status="running", run_id=760, pid=42, started_at="2026-06-17 10:00:00",
)
# A real caller (launcher._finalize_permanent) requeues WITH the old run_id —
# the invariant must win and drop it regardless.
mark_job(jid, "queued", run_id=760, error="exit_code=1")
r = _row(jid)
assert r["status"] == "queued"
assert r["run_id"] is None
assert r["pid"] is None
assert r["started_at"] is None
assert r["finished_at"] is None
assert r["error"] == "exit_code=1"
# --------------------------------------------------------------------------- #
# TC-03 — mark_job_transient resets ownership but keeps transient bookkeeping
# --------------------------------------------------------------------------- #
def test_tc03_mark_job_transient_resets_ownership_keeps_backoff():
jid = _seed_job(
status="running", run_id=761, pid=50, started_at="2026-06-17 10:00:00",
transient_attempts=1,
)
mark_job_transient(jid, 30, error="overloaded")
r = _row(jid)
assert r["status"] == "queued"
assert r["run_id"] is None
assert r["pid"] is None
assert r["started_at"] is None
# Transient bookkeeping preserved.
assert r["transient_attempts"] == 2
assert r["available_at"] is not None
assert r["error"] == "overloaded"
# --------------------------------------------------------------------------- #
# TC-04 — reap_running_job('queued') resets ownership; atomic guard preserved
# --------------------------------------------------------------------------- #
def test_tc04_reap_running_job_queued_resets_and_guard_holds():
jid = _seed_job(
status="running", run_id=762, pid=60, started_at="2026-06-17 10:00:00",
)
won = reap_running_job(jid, "queued", run_id=762, error="dead pid")
assert won is True
r = _row(jid)
assert r["status"] == "queued"
assert r["run_id"] is None
assert r["pid"] is None
assert r["started_at"] is None
# Atomic WHERE status='running' guard: a second call on the now-queued row
# must lose (rowcount 0) — restart-safe / race-safe (TR-4).
won2 = reap_running_job(jid, "queued", error="again")
assert won2 is False
# --------------------------------------------------------------------------- #
# TC-05 — claim_next_job leaves no stale pid (defense-in-depth, AC-3)
# --------------------------------------------------------------------------- #
def test_tc05_claim_clears_stale_pid_before_spawn():
"""A queued job carrying a stale pid/run_id (impossible state) is claimed into
'running' with pid/run_id reset to NULL — BEFORE the launcher stamps the real
pid in _spawn. Red before the fix (claim left the stale pid), green after."""
jid = _seed_job(status="queued", run_id=900, pid=999999)
claimed = claim_next_job()
assert claimed is not None
assert claimed["id"] == jid
assert claimed["status"] == "running"
assert claimed["pid"] is None
assert claimed["run_id"] is None
r = _row(jid)
assert r["status"] == "running"
assert r["pid"] is None
assert r["run_id"] is None
assert r["started_at"] is not None # claim still stamps started_at
# --------------------------------------------------------------------------- #
# TC-06 — claim works (no starvation) with serial-gate disabled (AC-2)
# --------------------------------------------------------------------------- #
def test_tc06_claim_starts_with_serial_gate_off(monkeypatch):
monkeypatch.setattr(db.settings, "serial_gate_enabled", False, raising=False)
jid = _seed_job(status="queued", agent="analyst")
claimed = claim_next_job()
assert claimed is not None and claimed["id"] == jid
assert claimed["status"] == "running"
# Queue did not hang: nothing left queued, exactly one running.
counts = db.job_status_counts()
assert counts["queued"] == 0
assert counts["running"] == 1
# --------------------------------------------------------------------------- #
# TC-07 — reaper does not reap a freshly-claimed running job with pid IS NULL (AC-4)
# --------------------------------------------------------------------------- #
def test_tc07_reaper_skips_pid_null_fresh_running(monkeypatch):
monkeypatch.setattr(db.settings, "lease_reclaim_enabled", False, raising=False)
monkeypatch.setattr(db.settings, "reaper_enabled", True, raising=False)
# Fresh running job: pid NULL (claim reset it, _spawn not yet stamped),
# started_at = now -> small age, no agent_runs row -> exit_code NULL.
jid = _seed_job(status="running", pid=None, run_id=None,
started_at=None)
conn = get_db()
conn.execute("UPDATE jobs SET started_at = datetime('now') WHERE id=?", (jid,))
conn.commit()
conn.close()
reaper = JobReaper()
reaper.reap_once()
r = _row(jid)
# Tier-1 skips pid IS NULL; Tier-3 backstop not reached -> still running.
assert r["status"] == "running"
# --------------------------------------------------------------------------- #
# TC-08 — detect + self-heal the impossible queued state (idempotent, AC-5)
# --------------------------------------------------------------------------- #
def test_tc08_sanitize_impossible_queued_idempotent():
jid = _seed_job(
status="queued", run_id=759, pid=35, started_at="2026-06-17 10:00:00",
)
# Detection sees the anomaly.
found = find_impossible_queued_jobs()
assert any(f["id"] == jid for f in found)
# First sanitize heals it and reports it.
healed = sanitize_impossible_queued()
assert len(healed) == 1 and healed[0]["id"] == jid
r = _row(jid)
assert r["status"] == "queued"
assert r["run_id"] is None and r["pid"] is None and r["started_at"] is None
# Idempotent: nothing left to heal on a clean DB.
assert find_impossible_queued_jobs() == []
assert sanitize_impossible_queued() == []
def test_tc08b_reaper_sanitize_counter_and_status(monkeypatch):
"""The reaper's sanitize pass bumps an observability counter exposed in /queue
and never raises; gated by the kill-switch."""
monkeypatch.setattr(db.settings, "lease_reclaim_enabled", False, raising=False)
monkeypatch.setattr(
db.settings, "impossible_queued_sanitize_enabled", True, raising=False
)
_seed_job(status="queued", run_id=10, pid=11, started_at="2026-06-17 10:00:00")
reaper = JobReaper()
n = reaper.sanitize_impossible_queued_once()
assert n == 1
assert reaper.impossible_queued_total == 1
st = reaper.status()
assert st["impossible_queued_total"] == 1
assert st["last_impossible_queued"]["count"] == 1
# Kill-switch off -> no-op even with an anomaly present.
_seed_job(status="queued", run_id=12, pid=13, started_at="2026-06-17 10:00:00")
monkeypatch.setattr(
db.settings, "impossible_queued_sanitize_enabled", False, raising=False
)
assert reaper.sanitize_impossible_queued_once() == 0
# --------------------------------------------------------------------------- #
# TC-09 — _spawn window: a launch failure before the pid stamp requeues cleanly
# --------------------------------------------------------------------------- #
def test_tc09_spawn_failure_requeues_clean_and_reclaimable(monkeypatch):
"""Simulate the queue worker's launch-failure handler: claim flips queued->running
(pid reset to NULL by D2), launch raises before stamping pid, the handler requeues
via mark_job('queued') (D1) -> the row is clean and immediately re-claimable (AC-6).
"""
jid = _seed_job(status="queued", run_id=900, pid=999999, attempts=0, max_attempts=2)
claimed = claim_next_job()
assert claimed["id"] == jid and claimed["pid"] is None # D2 already cleaned
# _spawn fails before stamping pid -> the queue worker marks it back to queued.
mark_job(jid, "queued", error="launch error: ensure_worktree failed")
r = _row(jid)
assert r["status"] == "queued"
assert r["pid"] is None and r["run_id"] is None and r["started_at"] is None
# Re-claim starts normally (no "partially started" wedge).
again = claim_next_job()
assert again is not None and again["id"] == jid
assert again["status"] == "running" and again["pid"] is None
# --------------------------------------------------------------------------- #
# TC-10 — anti-regression: a healthy job's terminal outcomes are untouched
# --------------------------------------------------------------------------- #
def test_tc10_healthy_job_terminal_outcomes_unchanged():
# done: run_id link kept, finished_at stamped, NO ownership reset.
jid = _seed_job(status="running", run_id=500, pid=12345,
started_at="2026-06-17 10:00:00")
mark_job(jid, "done", run_id=500)
r = _row(jid)
assert r["status"] == "done"
assert r["run_id"] == 500 # link preserved for done
assert r["finished_at"] is not None
assert r["started_at"] == "2026-06-17 10:00:00" # not cleared for terminal
# failed: same — run_id kept, finished_at stamped.
jid2 = _seed_job(status="running", run_id=501, pid=222,
started_at="2026-06-17 10:00:00")
mark_job(jid2, "failed", run_id=501, error="boom")
r2 = _row(jid2)
assert r2["status"] == "failed"
assert r2["run_id"] == 501
assert r2["finished_at"] is not None
assert r2["error"] == "boom"
# A never-started healthy queued job is NOT flagged as impossible.
healthy = _seed_job(status="queued")
assert all(f["id"] != healthy for f in find_impossible_queued_jobs())