171 lines
15 KiB
Markdown
171 lines
15 KiB
Markdown
# ТЗ — ORCH-065: job-reaper + stale merge-lease reclaim + идемпотентный merge
|
||
|
||
Work Item ID: ORCH-065
|
||
Базируется на: 01-brd.md
|
||
Примечание архитектору: ТЗ фиксирует ТРЕБОВАНИЯ и кандидатные модули. Выбор
|
||
конкретной реализации (новый модуль vs расширение reconciler; колонка `jobs.pid`
|
||
vs эвристика на `agent_runs`) — за стадией architecture (ADR). Если какая-либо
|
||
часть ТЗ окажется нереализуемой/спорной — вернуть в Анализ, не комментировать
|
||
задним числом.
|
||
|
||
## 0. Текущее состояние (факты из кода)
|
||
|
||
| Место | Факт |
|
||
|-------|------|
|
||
| `src/queue_worker.py` `_drain_once` | claim не происходит, пока `count_running_jobs() >= max_concurrency`. Одна зомби-строка `running` при concurrency=1 блокирует всю очередь. |
|
||
| `src/agents/launcher.py` `_monitor_agent` → `_finalize_job` | статус job (`done`/`queued`/`failed`) выставляется ТОЛЬКО в этом monitor-потоке. Смерть потока/процесса до финализации ⇒ job навсегда `running`. |
|
||
| `src/main.py` (lifespan, строки ~55-61) | `requeue_running_jobs()` вызывается ТОЛЬКО при старте процесса. |
|
||
| `src/db.py` `requeue_running_jobs` | flip всех `running`→`queued`. Без рестарта не запускается. |
|
||
| `src/db.py` таблица `jobs` | колонки `pid`/`heartbeat` НЕТ; есть `run_id`, `started_at`, `attempts`, `max_attempts`. |
|
||
| `src/merge_gate.py` `acquire_merge_lease` | реклейм stale lease (age `>= merge_lock_timeout_s`) и corrupt — ТОЛЬКО лениво в момент `acquire`. Lease пишет `pid`, но liveness по pid нигде не проверяется. |
|
||
| `src/merge_gate.py` `release_merge_lease` | holder-aware (по `branch`), идемпотентен. Вызовы: `webhooks/gitea.py:380` (PR-merged), `stage_engine.py:352/740/876/952`, `qg/checks.py:683/691/697`. |
|
||
| `src/qg/checks.py` `check_branch_mergeable` | при SUCCESS lease ДЕРЖИТСЯ до фактического merge PR. Если процесс умрёт после этого — lease зажат. |
|
||
| `src/reconciler.py` | паттерн-образец фонового daemon-потока (never-raise, kill-switch, observability в `/queue`). |
|
||
|
||
## 1. Задействованные модули `src/`
|
||
|
||
- `src/db.py` — новые job-запросы для reaper (выборка stale running-job, атомарный
|
||
reap). Возможна lightweight-миграция (см. §3).
|
||
- **Job-reaper** — НОВЫЙ модуль (кандидат `src/job_reaper.py`) ИЛИ расширение
|
||
`src/reconciler.py`. Решение — за архитектором; ТЗ требует daemon-поток по образцу
|
||
`reconciler` (never-raise, `_stop`-Event, старт/стоп в `main.lifespan`, снимок в
|
||
`/queue`).
|
||
- `src/merge_gate.py` — функция проактивного реклейма stale/dead lease (по pid-
|
||
liveness + по TTL); helper проверки liveness pid; helper идемпотентной
|
||
финализации merge.
|
||
- `src/main.py` — старт/стоп нового daemon-потока в `lifespan` (после `worker.start()`
|
||
/ `reconciler.start()`, симметрично остановка перед `worker.stop()`); вызов
|
||
стартового реклейма stale-lease рядом с `requeue_running_jobs()`.
|
||
- `src/config.py` — новые настройки/флаги (см. §5).
|
||
- `src/main.py` `GET /queue` — блок наблюдаемости reaper (образец `reconcile`/
|
||
`post_deploy`).
|
||
|
||
## 2. Функциональные требования
|
||
|
||
### FR-1. Job-reaper (Проблема A)
|
||
- Фоновый поток периодически (`reaper_interval_s`) сканирует строки `jobs` в статусе
|
||
`running`.
|
||
- Для каждого `running`-job определяет, **жив ли его исполнитель**. «Мёртвым» job
|
||
считается, когда выполнено и устойчиво (см. FR-1.3) хотя бы одно из:
|
||
- процесс агента (по pid/run_id) не существует, а финализация не произошла;
|
||
- `agent_runs` строки run_id имеет `finished_at`/`exit_code` (процесс реально
|
||
завершился), но `jobs.status` всё ещё `running` (monitor умер между записью
|
||
exit_code и `_finalize_job`);
|
||
- job висит `running` дольше предохранительного потолка
|
||
`reaper_max_running_s` (заведомо больше любого легитимного `agent_timeout` +
|
||
grace) — backstop на случай, когда liveness определить нельзя.
|
||
- FR-1.2 Действие при подтверждённой смерти:
|
||
- если есть достоверный успешный исход (`agent_runs.exit_code == 0`) — довести job
|
||
к корректному завершению **через тот же контракт**, что `_finalize_job`
|
||
(включая, при необходимости, повторную попытку auto-advance) — НЕ дублировать
|
||
переход, если он уже произошёл (идемпотентность через `has_active_job_for_task`
|
||
/ проверку стадии);
|
||
- если исход неуспешный/неизвестен и бюджет попыток не исчерпан
|
||
(`attempts < max_attempts`) — `queued` (повторная постановка), как делает
|
||
`requeue_running_jobs`;
|
||
- если бюджет исчерпан — `failed` + Telegram-алерт.
|
||
- FR-1.3 **Анти-ложноположительность.** Job помечается зомби только после
|
||
устойчивого подтверждения смерти: процесс мёртв на протяжении `reaper_dead_ticks`
|
||
последовательных тиков (≥2) ИЛИ превышен `reaper_max_running_s`. Живой долгий
|
||
агент (в пределах своего `agent_timeout`) НИКОГДА не реапится.
|
||
- FR-1.4 Работает **без рестарта** процесса (главное отличие от существующего
|
||
`requeue_running_jobs`).
|
||
- FR-1.5 Restart-safe: после рестарта поведение корректно совмещается со стартовым
|
||
`requeue_running_jobs()` (нет двойной обработки одной строки; атомарность reap-
|
||
UPDATE с guard по `status='running'`, как в `claim_next_job`).
|
||
|
||
### FR-2. Проактивный реклейм stale/dead merge-lease (Проблема B)
|
||
- FR-2.1 На старте процесса (рядом с `requeue_running_jobs()` в `lifespan`) и
|
||
периодически в фоновом потоке: для каждого репо с merge-gate проверить lease и
|
||
освободить его, если держатель **мёртв** или lease **просрочен**.
|
||
- FR-2.2 «Держатель мёртв» = pid из lease не существует в системе (liveness-проба,
|
||
напр. `os.kill(pid, 0)` с обработкой `ProcessLookupError`/`PermissionError`),
|
||
при условии что pid принадлежит этому хосту/неймспейсу. «Просрочен» = `age >=
|
||
merge_lock_timeout_s` (существующий TTL-контракт сохраняется).
|
||
- FR-2.3 Реклейм **holder-aware и безопасен**: НЕ освобождать lease, чей держатель
|
||
жив и в пределах TTL (защита легитимного merge). Логировать `warning` при каждом
|
||
реклейме (наблюдаемость, как сейчас в `acquire_merge_lease`).
|
||
- FR-2.4 Условность как ORCH-35/43: реально только для self-hosting/`merge_gate_repos`;
|
||
прочие репо — no-op.
|
||
- FR-2.5 Контракт **never-raise**; любая ошибка реклейма не должна валить поток.
|
||
|
||
### FR-3. Идемпотентная финализация merge (Проблема C)
|
||
- FR-3.1 Если ветка прошла rebase+re-test (догнана до `origin/main` и зелёная), но
|
||
merge PR не состоялся из-за смерти процесса — система должна **докатить/повторить**
|
||
merge без повторного прогона дорогих шагов, когда это безопасно.
|
||
- FR-3.2 Финализация merge должна быть **идемпотентной**: повторный вызов при уже
|
||
слитом PR — no-op (определять по состоянию PR в Gitea и/или по
|
||
`branch_is_behind_main`/состоянию `main`), без ошибки и без второго слияния.
|
||
- FR-3.3 Восстановление re-drive обеспечивается штатными механизмами (reaper
|
||
довёл job до `queued` → повторный проход стадии `deploy`/merge-gate; либо
|
||
reconciler доигрывает переход). Дублирующая логика merge НЕ создаётся — переиспользуются
|
||
существующие пути (`check_branch_mergeable` / deployer-merge).
|
||
- FR-3.4 При повторе lease берётся заново (идемпотентный re-acquire «held by self»
|
||
по branch уже поддержан в `acquire_merge_lease`).
|
||
|
||
### FR-4. Наблюдаемость
|
||
- FR-4.1 Блок `reaper` в `GET /queue`: enabled, interval, last_run_ts, reaped_total,
|
||
last_reaped (job_id/agent), lease_reclaimed_total (best-effort, как у reconciler).
|
||
- FR-4.2 Каждый reap и каждый lease-reclaim — `logger.warning` с идентификаторами
|
||
(job_id, run_id, pid, repo, branch).
|
||
- FR-4.3 При reap→`failed` и при lease-reclaim — Telegram (как существующие алерты).
|
||
|
||
## 3. Изменения схемы БД
|
||
- Текущая `jobs` НЕ содержит `pid`. Для надёжной pid-liveness job-reaper'у, скорее
|
||
всего, потребуется **lightweight-миграция**: добавить `jobs.pid INTEGER` (через
|
||
`_ensure_column`, идемпотентно, безопасно на live prod DB — паттерн уже
|
||
применяется в `db.py`). pid проставляется в `_spawn` рядом с `run_id`/`started_at`.
|
||
- **Альтернатива без миграции** (на усмотрение архитектора): определять смерть по
|
||
`agent_runs.finished_at/exit_code` + потолку `reaper_max_running_s`, без хранения
|
||
pid в `jobs`. ADR должен зафиксировать выбор и обоснование.
|
||
- Реестры `STAGE_TRANSITIONS` и `QG_CHECKS` — **без изменений** (новых стадий/гейтов
|
||
не вводим; reaper и lease-reclaim — фоновые механизмы, не стадии).
|
||
- Merge-lease остаётся файловым (`.merge-lease-<repo>.json`); схема файла lease
|
||
не меняется (pid и acquired_at уже есть).
|
||
|
||
## 4. Изменения API
|
||
- `GET /queue` — добавить блок `reaper` (read-only наблюдаемость). Прочие endpoints
|
||
без изменений. Новых webhook-роутов нет.
|
||
|
||
## 5. Конфигурация / kill-switches (`src/config.py`)
|
||
Именование — по образцу `reconcile_*` / `merge_*`. Кандидаты (точные имена/дефолты
|
||
уточняет архитектор):
|
||
|
||
| Настройка | Назначение | Дефолт (предложение) |
|
||
|-----------|-----------|----------------------|
|
||
| `reaper_enabled` | глобальный kill-switch job-reaper | `true` |
|
||
| `reaper_interval_s` | период сканирования | `60` |
|
||
| `reaper_dead_ticks` | сколько подряд тиков pid должен быть мёртв перед reap | `2` |
|
||
| `reaper_max_running_s` | потолок «running» (backstop), > max agent_timeout+grace | `3600` |
|
||
| `lease_reclaim_enabled` | kill-switch проактивного реклейма lease | `true` |
|
||
| (переиспользуется) `merge_lock_timeout_s` | TTL lease | `300` (как есть) |
|
||
| (переиспользуется) `merge_gate_repos` | область применения lease-reclaim | как есть |
|
||
|
||
Все флаги — пробрасываются из env (`ORCH_*`), `false` → строго прежнее поведение.
|
||
|
||
## 6. Требования к QG checks
|
||
- Новых QG checks НЕ вводить (это фоновые resilience-механизмы, не гейты выхода со
|
||
стадии). `check_branch_mergeable` остаётся контрактно неизменным; допускается лишь
|
||
переиспользование его как идемпотентного пути финализации merge (FR-3.3).
|
||
|
||
## 7. Артефакты pipeline, создаваемые/обновляемые в ЭТОМ PR
|
||
- Код: см. §1.
|
||
- `06-adr/ADR-001-*.md` — архитектурное решение (где живёт reaper; pid-колонка vs
|
||
эвристика; механизм идемпотентного merge) — создаёт architect.
|
||
- `docs/architecture/README.md` — новый раздел про job-reaper + lease-reclaim
|
||
(golden-source, в этом же PR).
|
||
- `docs/architecture/internals.md` — детали (если затрагивается схема БД / потоки).
|
||
- `CHANGELOG.md` — запись ORCH-065.
|
||
- `.env.example` — новые `ORCH_*` флаги (канон секретов/настроек).
|
||
- `docs/operations/INFRA.md` — упоминание поведения при self-restart, если
|
||
затрагивается (best-effort).
|
||
|
||
## 8. Инварианты (НЕ нарушать)
|
||
- Не ронять/не рестартить прод-контейнер `orchestrator` в рамках задачи.
|
||
- Никогда не пушить/форс-пушить `main`; реклейм lease не инициирует git-операций.
|
||
- `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, контракты `check_*`, БАГ-8 откат,
|
||
exit-коды deploy-хука — без изменений.
|
||
- never-raise на единицу фоновой работы; идемпотентность; restart-safe; тишина при
|
||
отсутствии аномалий (как reconciler).
|
||
- Анти-ложноположительность (FR-1.3): живой долгий агент не реапится.
|