fix(queue): enforce queued ⇒ no run-ownership invariant (ORCH-126)
All checks were successful
CI / test (push) Successful in 1m14s
CI / test (pull_request) Successful in 1m15s

Queued analyst-jobs hung forever even with ORCH_SERIAL_GATE_ENABLED=false
(incident ORCH-124/125, job 2286: queued + run_id=759/760 + pid=35/42 +
started_at=NULL — physically impossible). No path returning a job to
'queued' reset its run-ownership (run_id/pid); after a container restart a
reused pid made pid_alive(stale)=True, so the job-reaper Tier-1 saw a phantom
'running' and at max_concurrency=1 wedged the claim of the whole shared queue.

Enforce the invariant `status='queued' ⇒ run_id IS NULL AND pid IS NULL AND
started_at IS NULL` on existing columns (no schema change):

- D1 forward-cleanup: requeue_running_jobs / mark_job('queued') /
  mark_job_transient / reap_running_job('queued') reset run_id=NULL, pid=NULL
  in the same UPDATE that clears started_at; atomic status-guards preserved.
- D2 clean claim: claim_next_job resets pid/run_id on the queued->running flip
  (defense-in-depth) so the row carries pid IS NULL until _spawn stamps it.
- D4 self-heal + observability: db.find_impossible_queued_jobs /
  sanitize_impossible_queued run at startup (main.lifespan) and on each reaper
  tick (JobReaper.sanitize_impossible_queued_once, never-raise); counter
  impossible_queued_total in the GET /queue reaper block. Kill-switch
  ORCH_IMPOSSIBLE_QUEUED_SANITIZE_ENABLED (default on; gates only the D4 sweep).
- D5: reaper Tier-1 unchanged — the fix restores its precondition (pid reflects
  THIS run). Marked invariants ORCH-065/113/114/099 preserved.

Tests: tests/test_orch126_queued_stale_run.py (TC-01 mandatory regression
red->green; TC-02..TC-10). Full pytest tests/ -q green (2189 passed).
Docs: internals.md (run-ownership invariant section), .env.example, CHANGELOG;
cross-cutting adr-0052.

Refs: ORCH-126
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 11:39:26 +03:00
parent 3fb7bd6e4c
commit d7e7a4d817
9 changed files with 549 additions and 8 deletions

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.