From 9f846b5a503241dcd8c0639058d7228cebcfbdd6 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 15:07:45 +0000 Subject: [PATCH] architect(ET): auto-commit from architect run_id=317 --- docs/architecture/README.md | 54 +++- docs/architecture/adr/README.md | 3 +- .../adr/adr-0011-job-reaper-lease-reclaim.md | 77 ++++++ docs/architecture/internals.md | 24 ++ .../ADR-001-job-reaper-and-lease-reclaim.md | 260 ++++++++++++++++++ .../ORCH-065/07-infra-requirements.md | 42 +++ .../ORCH-065/08-data-requirements.md | 29 ++ docs/work-items/ORCH-065/10-tech-risks.md | 22 ++ 8 files changed, 507 insertions(+), 4 deletions(-) create mode 100644 docs/architecture/adr/adr-0011-job-reaper-lease-reclaim.md create mode 100644 docs/work-items/ORCH-065/06-adr/ADR-001-job-reaper-and-lease-reclaim.md create mode 100644 docs/work-items/ORCH-065/07-infra-requirements.md create mode 100644 docs/work-items/ORCH-065/08-data-requirements.md create mode 100644 docs/work-items/ORCH-065/10-tech-risks.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 4ae2094..559e7dc 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -11,6 +11,7 @@ - **Quality Gates** (`src/qg/checks.py`) — проверки выхода со стадии, реестр `QG_CHECKS`. - **Agent Launcher** (`src/agents/launcher.py`) — запуск Claude CLI агентов в изолированном git worktree, мониторинг, auto-advance. - **Queue** (`src/queue_worker.py`, ORCH-1) — персистентная очередь задач (SQLite `jobs`), atomic claim, max_concurrency, ретраи, restart-safe. +- **Job-reaper** (`src/job_reaper.py`, ORCH-065 — [adr-0011](adr/adr-0011-job-reaper-lease-reclaim.md)) — фоновый daemon-поток (каркас `reconciler`), стартует/останавливается в `main.lifespan` (после `reconciler.start()` / перед `worker.stop()`). Детектирует «мёртвый» `running`-job **без рестарта** процесса (Tier-1 мёртвый `jobs.pid` после `reaper_dead_ticks` тиков; Tier-2 `agent_runs.exit_code` записан, а job ещё `running`; Tier-3 backstop `reaper_max_running_s`) и приводит строку к корректному статусу через те же контракты (`_try_advance_stage`/`_finalize_job`, gate-driven; exit≠0/неизвестно → `attempts max agent_timeout+grace). Действие + переиспользует контракты: exit0 → **gate-driven idempotent advance** + (`_try_advance_stage`+`_finalize_job`, источник истины — канонический QG, не + факт «exit0»; нет дубль-перехода); exit≠0/неизвестно → `attempts`. @@ -233,7 +281,7 @@ never-raise на единицу работы; тишина при синхрон |--------|------|----------| | GET | `/health` | health check | | GET | `/status` | активные задачи (stage != done) | -| GET | `/queue` | очередь: counts + max_concurrency + resilience + reconcile (ORCH-053) + post_deploy (ORCH-021) + последние jobs | +| GET | `/queue` | очередь: counts + max_concurrency + resilience + reconcile (ORCH-053) + reaper (ORCH-065) + post_deploy (ORCH-021) + последние jobs | | POST | `/webhook/plane` | Plane webhook | | POST | `/webhook/gitea` | Gitea webhook (push, PR, CI status) | @@ -247,4 +295,4 @@ never-raise на единицу работы; тишина при синхрон Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.md). --- -*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата).* +*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — design, ветка feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py; флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест).* diff --git a/docs/architecture/adr/README.md b/docs/architecture/adr/README.md index 76ffba8..92d4781 100644 --- a/docs/architecture/adr/README.md +++ b/docs/architecture/adr/README.md @@ -16,11 +16,12 @@ Per-work-item решения живут в `docs/work-items//06-adr/ADR-NNN- | adr-0008 | Провенанс staging-образа перед BUILD-ONCE retag | accepted | 2026-06-06 | ORCH-058 | | adr-0009 | Толерантность staging-вердикта к инфраструктурным FAIL | accepted | 2026-06-07 | ORCH-061 | | adr-0010 | Post-deploy мониторинг прода + реакция на деградацию | proposed | 2026-06-07 | ORCH-021 | +| adr-0011 | Job-reaper + проактивный реклейм merge-lease | accepted | 2026-06-07 | ORCH-065 | > ⚠️ Историческая коллизия: номер `0007` занят двумя файлами — > `adr-0007-reconciler.md` (ORCH-053) и `adr-0007-executable-self-deploy.md` > (ORCH-036). Оба accepted; для новых сквозных ADR использовать следующий -> свободный номер (текущий максимум — `0010`). +> свободный номер (текущий максимум — `0011`). ## Формат **Контекст → Решение → Альтернативы → Последствия → Связи.** Статус: proposed / accepted / superseded. diff --git a/docs/architecture/adr/adr-0011-job-reaper-lease-reclaim.md b/docs/architecture/adr/adr-0011-job-reaper-lease-reclaim.md new file mode 100644 index 0000000..44b1037 --- /dev/null +++ b/docs/architecture/adr/adr-0011-job-reaper-lease-reclaim.md @@ -0,0 +1,77 @@ +# adr-0011: Job-reaper + проактивный реклейм merge-lease + +| | | +|---|---| +| Статус | accepted | +| Дата | 2026-06-07 | +| Источник | ORCH-065 (BUG P0, блокер ORCH-54) | +| Детально | `docs/work-items/ORCH-065/06-adr/ADR-001-job-reaper-and-lease-reclaim.md` | + +## Контекст + +Единый инстанс с общей БД и очередью (`jobs`, `max_concurrency=1` для +self-hosting). Финализация статуса job (`done`/`queued`/`failed`) происходит +ТОЛЬКО в `launcher._monitor_agent → _finalize_job` внутри живого процесса. Смерть +monitor-потока/процесса между `proc.wait()` и `_finalize_job` (краш, OOM, +self-restart во время deploy) оставляет строку `jobs` навсегда `running`. При +`max_concurrency=1` одна такая зомби-строка блокирует claim всех job → +**встаёт конвейер всех проектов**. Единственная защита — `requeue_running_jobs()` +— работает ТОЛЬКО на старте процесса. Симметрично: merge-lease (ORCH-043, +файл `.merge-lease-.json`) реклеймится лишь лениво по TTL при чужом +`acquire`; liveness держателя по pid не проверяется → залипший lease блокирует +чужие merge. Это последняя ручная точка автономного self-deploy (блокер ORCH-54); +доказанные инциденты 07.06 — jobs 236/239/242/254. + +## Решение + +1. **Job-reaper** — новый daemon-поток `src/job_reaper.py` (каркас `reconciler`: + never-raise, `_stop`-Event, старт/стоп в `lifespan`, снимок в `/queue`, + kill-switch). Работает **без рестарта** процесса. Liveness — трёхуровневая: + Tier-1 мёртвый `jobs.pid` (новая колонка) после `reaper_dead_ticks` подряд + тиков; Tier-2 `agent_runs.exit_code` записан, а job ещё `running`; Tier-3 + backstop по потолку `reaper_max_running_s`. Действие переиспользует контракты: + exit0 → gate-driven idempotent advance (`_try_advance_stage`+`_finalize_job`, + источник истины — QG, не «exit0»); exit≠0 / неизвестно → `attempts= + max_concurrency`) → встаёт конвейер ВСЕХ проектов. Доказано: jobs 236/239/242/254 + (07.06). +- **B — залипший merge-lease.** Файловый lease `.merge-lease-.json` + (ORCH-043) реклеймится **лениво и только по возрасту** (`age >= + merge_lock_timeout_s`) и **только** в момент `acquire_merge_lease` другой + задачей. Liveness держателя по pid не проверяется, хотя pid в lease пишется. + Смерть с зажатым lease блокирует чужие merge до истечения TTL. +- **C — неидемпотентная финализация merge.** Если rebase+re-test зелёные, но + процесс умер до фактического merge PR — повторного докатывания нет; дорогая + работа (rebase+re-test) сделана, а задача висит. + +Факты кода, на которых строится решение: +- `_spawn` (launcher.py:401) создаёт `subprocess.Popen(["bash","-c",cmd])`; + `proc.pid` — pid агентского процесса, дочернего к процессу оркестратора в ОДНОМ + pid-namespace контейнера. Сейчас `jobs.pid` НЕ хранится. +- `_monitor_agent` (launcher.py:541) порядок: `proc.wait()` → запись + `agent_runs.finished_at/exit_code` → git commit/push (+PR) → БАГ-8 deployer + rollback → usage-комменты → `_try_advance_stage` (exit0, gate-driven advance) + → `_finalize_job` (драйв статуса job по контракту attempts/transient). +- `claim_next_job` (db.py:454) — атомарный claim через `UPDATE ... WHERE id=? AND + status='queued'` + `rowcount` (образец атомарности). +- `reconciler.py` — образец фонового daemon-потока (never-raise, `_stop`-Event, + старт/стоп в `main.lifespan`, снимок в `/queue`, kill-switch). +- `merge_gate.py`: lease пишет `pid=os.getpid()` (pid процесса оркестратора, НЕ + агента), `acquired_at`; `release_merge_lease` уже holder-aware (по `branch`) и + идемпотентен; `acquire_merge_lease` идемпотентен для «held by self» (по branch). +- `is_self_hosting_repo` / `merge_gate_repos` — образец условности (ORCH-35/43). + +## Решение + +### Р-1. Job-reaper — отдельный daemon-поток `src/job_reaper.py` + +Reaper — **новый модуль и отдельный daemon-поток** (НЕ расширение reconciler). +Обоснование: reconciler работает на уровне **stage-transition** (источник истины — +гейт/Plane); reaper работает на уровне **jobs/agent_runs** (источник истины — +liveness процесса). Это разные never-raise-домены и разные kill-switch'и; слияние +в один тик смешало бы ответственности. Reaper копирует проверенный каркас +`Reconciler`: `threading.Thread(daemon=True)` + `threading.Event`, старт/стоп в +`main.lifespan`, снимок в `/queue`, per-job изоляция исключений. + +**Liveness — трёхуровневая (defense in depth):** + +1. **Tier-1 (liveness, основной): мёртвый pid.** Добавляем колонку `jobs.pid` + (см. Р-4). В `_spawn` рядом с `run_id`/`started_at` пишем `proc.pid`. Reaper: + `pid_alive(pid)` = `os.kill(pid, 0)` с обработкой `ProcessLookupError` (мёртв) + / `PermissionError` (жив, чужой) — единственный сигнал, ловящий «monitor умер + ДО записи `finished_at`». +2. **Tier-2 (completion race): exit_code записан, job ещё `running`.** Если у + `agent_runs[run_id]` есть `finished_at`/`exit_code`, а `jobs.status='running'` + — monitor умер между записью exit_code и `_finalize_job`. Здесь исход **известен**. +3. **Tier-3 (backstop по потолку):** job висит `running` дольше + `reaper_max_running_s` (заведомо > max `agent_timeout`+grace). Реап даже когда + liveness определить нельзя (pid переиспользован/неизвестен). + +**Анти-ложноположительность (FR-1.3, AC-3):** по Tier-1 job реапится только после +`reaper_dead_ticks` (≥2) ПОДРЯД тиков мёртвого pid — in-memory streak-счётчик по +`job_id` (best-effort, сбрасывается на рестарте — но рестарт покрыт стартовым +`requeue_running_jobs`). Tier-3 — одношаговый (порог времени, streak не нужен). +Живой агент в пределах своего `agent_timeout` НЕ реапится никогда (pid жив + не +превышен потолок). + +**Действие при подтверждённой смерти (FR-1.2, AC-4) — переиспользование +существующих контрактов, без дублирования:** + +- **Атомарный reap-claim.** Перед любым действием с побочными эффектами reaper + атомарно «застолбляет» строку тем же приёмом, что `claim_next_job`: терминальный + flip несёт guard `WHERE id=? AND status='running'` и проверяет `rowcount`. При + гонке (поздно доехавший monitor, стартовый `requeue_running_jobs`) проигравший + видит `rowcount==0` и НЕ обрабатывает строку повторно (AC-5). +- **Исход известен (Tier-2, exit_code в `agent_runs`):** маршрутизируем через + существующий `launcher._finalize_job(job_id, agent, run_id, exit_code, + output_path)`: + - `exit==0`: **gate-driven idempotent advance.** Сначала проверяем, не + продвинулась ли уже стадия (текущая `tasks.stage` ≠ исходная стадия агента + или активного job нет и гейт уже пройден) → если да, просто `mark_job(done)` + (идемпотентная уборка, без дубль-перехода). Если нет — `_try_advance_stage` + (он сам гоняет канонический QG: артефакт/PR есть → зелёный гейт → advance; + нет → красный гейт → НЕ advance), затем `_finalize_job`. **Источник истины — + гейт, не «exit0»** — это исключает ложный `done` без реально выполненной + работы (если monitor умер ДО git-push, артефакта нет → гейт красный → + переходим к ветке «исход неуспешен» ниже). + - `exit!=0`: ровно существующий контракт `_finalize_job` (классификация + transient/permanent, `attempts bool` (never-raise; ошибка/`PermissionError` → считаем + «жив», т.е. консервативно НЕ реклеймим — безопаснее не трогать). +- `reclaim_stale_lease(repo) -> bool` — для репо из области (см. ниже): прочитать + lease; освободить (`release_merge_lease(repo, branch=holder_branch)` — + holder-aware), если держатель **мёртв** (`pid` из lease не жив) ИЛИ **просрочен** + (`age >= merge_lock_timeout_s`). Живой держатель в пределах TTL — НЕ трогать + (AC-8, защита легитимного merge). Каждый реклейм → `logger.warning` + + Telegram. + +**Точки вызова (FR-2.1):** +- на старте — в `lifespan` рядом с `requeue_running_jobs()`; +- периодически — из тика reaper (один общий фоновый поток на оба механизма A и B). + +**Условность (FR-2.4, AC-9):** реально только для `merge_gate_repos`/self-hosting +(тот же предикат, что merge-gate); прочие репо — no-op. Kill-switch +`lease_reclaim_enabled` (=false → остаётся лишь прежний ленивый TTL-реклейм в +`acquire_merge_lease`). Контракт **never-raise**: ошибка реклейма логируется и не +валит поток. + +**pid-семантика lease:** lease пишет pid процесса ОРКЕСТРАТОРА (`os.getpid()`). +После рестарта контейнера старый pid мёртв → детектируется. Риск pid-reuse +(контейнер мог переиспользовать номер pid) закрыт тем, что реклейм срабатывает по +**ИЛИ** (pid мёртв **ИЛИ** TTL истёк): даже при ложном «жив» TTL добьёт lease +(контракт ORCH-043 сохранён). См. 10-tech-risks. + +### Р-3. Идемпотентная финализация merge (Проблема C) — re-drive + guard, без новой merge-логики + +Per FR-3.3 — НЕ создаём дублирующую merge-логику. Восстановление обеспечивается +**штатными путями**: +- reaper доводит зомби-job до `queued` → стадия `deploy-staging`/`deploy` + переисполняется и снова проходит `check_branch_mergeable` (merge-gate), ЛИБО + reconciler доигрывает переход по зелёному гейту; +- дорогие шаги не повторяются «вхолостую»: `branch_is_behind_main == False` → этап + rebase+re-test пропускается (ветка уже догнана); +- lease при повторе берётся заново — `acquire_merge_lease` уже идемпотентен для + «held by self» по branch (FR-3.4). + +**Идемпотентность у самого merge (FR-3.2, AC-11):** добавляем детерминированный +never-raise guard `pr_already_merged(repo, branch) -> bool` (переиспользует +существующий Gitea-клиент; запрос состояния PR). Путь слияния (deployer/merge) +консультируется с этим guard ПЕРЕД повторным merge: PR уже слит → no-op (без +второго merge и без ошибки). Это единственная новая «merge-related» функция — она +не сливает, а лишь читает состояние, поэтому не нарушает «no duplicate merge +logic». + +### Р-4. Изменение схемы БД — `jobs.pid INTEGER` (lightweight migration) + +Колонка добавляется идемпотентно через существующий `_ensure_column(conn, "jobs", +"pid", "INTEGER")` в `init_db` (паттерн уже применяется к `jobs.transient_attempts` +/ `jobs.available_at` — безопасно на live prod DB). `pid` проставляется в `_spawn` +рядом с `run_id`/`started_at`. **Альтернатива без миграции отвергнута** (см. +Альтернативы): только по `agent_runs.finished_at/exit_code` нельзя поймать +зомби, у которого monitor умер ДО записи exit_code — а это и есть основной класс +инцидента. `STAGE_TRANSITIONS`, `QG_CHECKS`, схема `agent_runs`, файл-схема lease — +без изменений. + +### Р-5. Конфигурация (`src/config.py`, env `ORCH_*`) + +| Настройка | Назначение | Дефолт | +|-----------|-----------|--------| +| `reaper_enabled` | глобальный kill-switch job-reaper | `True` | +| `reaper_interval_s` | период сканирования | `60` | +| `reaper_dead_ticks` | подряд тиков мёртвого pid перед реапом (Tier-1) | `2` | +| `reaper_max_running_s` | потолок `running` (Tier-3 backstop), > max agent_timeout+grace | `3600` | +| `lease_reclaim_enabled` | kill-switch проактивного реклейма lease | `True` | +| (reuse) `merge_lock_timeout_s` | TTL lease | `300` | +| (reuse) `merge_gate_repos` | область применения lease-reclaim | как есть | + +`false` → строго прежнее поведение (AC-14). + +### Р-6. Наблюдаемость (`GET /queue`) + +Блок `reaper` (образец `reconcile`/`post_deploy`): `enabled`, `interval`, +`last_run_ts`, `reaped_total`, `last_reaped` (`{job_id, agent, outcome}`), +`lease_reclaimed_total`. Каждый reap и lease-reclaim → `logger.warning` с +идентификаторами (`job_id`, `run_id`, `pid`, `repo`, `branch`). reap→`failed` и +lease-reclaim → Telegram (AC-16). + +### Р-7. Lifespan (`src/main.py`) + +Старт (после существующего `requeue_running_jobs()`): +``` +init_db() # + _ensure_column(jobs, pid) +... orphan-recovery (M-1) ... +requeue_running_jobs() ++ startup lease-reclaim # reclaim_stale_lease для merge_gate_repos +worker.start() +reconciler.start() ++ reaper.start() # НОВЫЙ daemon-поток (A + периодический B) +``` +Стоп (reverse): `reaper.stop()` → `reconciler.stop()` → `worker.stop()`. + +## Альтернативы + +- **Reaper как часть reconciler** — отвергнуто: смешивает stage-уровень и + jobs-уровень, два разных kill-switch'а в одном тике, хуже изоляция отказов. +- **Без `jobs.pid`, только эвристика `agent_runs` + потолок** — отвергнуто как + основной механизм: не ловит зомби, чей monitor умер ДО записи `exit_code` + (главный класс инцидента). Эвристика оставлена как Tier-2/Tier-3 поверх pid. +- **БД-lock вместо файлового lease / внешний брокер очередей** — вне объёма + (BRD §4), несоразмерно для single-node SQLite. +- **Реаниматор фабрикует `exit0` и форсит `done`** — отвергнуто: ложный `done` + без реальной работы (если git-push не случился). Выбран gate-driven advance: + источник истины — канонический QG, не предположение об успехе. +- **Новый статус `reaping` в enum `jobs.status`** — отвергнуто: меняет контракт + статусов; атомарного guard `WHERE status='running'` достаточно. + +## Последствия + +**Плюсы:** +- Зомби-job самовосстанавливается БЕЗ рестарта процесса → очередь не встаёт + (групповой риск снят для всех проектов общего инстанса). +- Залипший lease освобождается проактивно (старт + период), не дожидаясь TTL и + чужого acquire. +- Незавершённый merge докатывается штатным путём, идемпотентно; ручные шаги + оператора устранены → снят технический блокер ORCH-54. +- Контракты неизменны (`STAGE_TRANSITIONS`, `QG_CHECKS`, `check_*`, БАГ-8, + exit-коды хука); один новый столбец через проверенный idempotent-паттерн. + +**Минусы / ограничения:** +- pid-liveness валиден в предположении ОДНОГО pid-namespace (агент — дочерний + процесс оркестратора в том же контейнере). Multi-container/namespaced pid → + pid-liveness ненадёжен; закрыто backstop'ом по времени и TTL. См. 10-tech-risks. +- streak-счётчик in-memory best-effort: после рестарта он сбрасывается, но + стартовый `requeue_running_jobs` покрывает рестарт-сценарий. +- Tier-3 backstop консервативен (потолок > max timeout); очень долгий легитимный + агент (близко к потолку) теоретически может быть реапнут — потолок выбран с + большим запасом, чтобы этого не случалось (AC-3). + +## Инварианты (НЕ нарушать) +- Reaper/lease-reclaim НЕ рестартят/не роняют прод-контейнер `orchestrator` и НЕ + инициируют git-push в `main` (AC-12). Реклейм lease — только удаление + файла-lease, без git-операций. +- `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, сигнатуры/поведение `check_*` (в т.ч. + `check_branch_mergeable`), БАГ-8 откат, exit-коды deploy-хука — без изменений; + новых QG checks/стадий нет (AC-13). +- never-raise на единицу фоновой работы; идемпотентность (атомарный guard + + gate-driven advance); restart-safe; тишина при отсутствии аномалий. +- Анти-ложноположительность (FR-1.3): живой долгий агент не реапится. + +## Связи +- Базируется на: ORCH-1 (очередь, adr-0002), ORCH-043 (merge-gate, adr-0006), + ORCH-053 (reconciler-паттерн, adr-0007), ORCH-36 (self-deploy, adr-0007). +- Сквозной ADR: adr-0011. +- Разблокирует: ORCH-54 (полностью автономный self-deploy). diff --git a/docs/work-items/ORCH-065/07-infra-requirements.md b/docs/work-items/ORCH-065/07-infra-requirements.md new file mode 100644 index 0000000..e7bacac --- /dev/null +++ b/docs/work-items/ORCH-065/07-infra-requirements.md @@ -0,0 +1,42 @@ +# 07 — Требования к инфраструктуре (ORCH-065) + +## Топология +**Без изменений.** Новых контейнеров, портов, сетевых сервисов, внешних +зависимостей нет. Job-reaper — ещё один фоновый daemon-поток ВНУТРИ существующего +процесса оркестратора (как `queue_worker` и `reconciler`), стартует/останавливается +в `main.lifespan`. Деплой/рестарт прод-контейнера в рамках задачи НЕ требуется и +ЗАПРЕЩЁН (self-hosting safety) — выкатка через штатный `deploy-staging → deploy`. + +## Допущение pid-namespace (важно для liveness-детекции) +- Агент запускается как `subprocess.Popen(["bash","-c",cmd])` — **дочерний + процесс оркестратора в ТОМ ЖЕ pid-namespace** (один контейнер). Значит + `os.kill(jobs.pid, 0)` корректно отражает liveness агента, пока жив сам + оркестратор. Это инвариант текущей упаковки (один контейнер на инстанс). +- Lease пишет `pid = os.getpid()` — pid ПРОЦЕССА ОРКЕСТРАТОРА. После рестарта + контейнера старый pid мёртв → детектируется. Риск переиспользования номера pid + новым процессом закрыт условием «pid мёртв **ИЛИ** TTL истёк»: TTL добивает + lease в любом случае (контракт ORCH-043 сохранён). +- **Если в будущем агенты переедут в отдельные контейнеры/namespace** — Tier-1 + pid-liveness станет ненадёжной; тогда полагаемся на Tier-2 (exit_code) и Tier-3 + (потолок `reaper_max_running_s`). Зафиксировано в 10-tech-risks. + +## Поведение при self-restart (ORCH-36 executable self-deploy) +Self-restart прод-контейнера во время `deploy` — ровно тот сценарий, что плодит +зомби: monitor-поток умирает вместе со старым контейнером. После рестарта: +1. стартовый `requeue_running_jobs()` + стартовый `reclaim_stale_lease` чистят + состояние, оставшееся от убитого процесса; +2. периодический reaper добивает то, что возникнет позже без рестарта. +Reaper/lease-reclaim сами НИКОГДА не рестартят и не роняют прод-контейнер и не +делают git-push в `main` (AC-12). + +## Эксплуатационные ручки (env, хост `.env`/`.env.staging`) +`ORCH_REAPER_ENABLED`, `ORCH_REAPER_INTERVAL_S`, `ORCH_REAPER_DEAD_TICKS`, +`ORCH_REAPER_MAX_RUNNING_S`, `ORCH_LEASE_RECLAIM_ENABLED`; переиспользуются +`ORCH_MERGE_LOCK_TIMEOUT_S`, `ORCH_MERGE_GATE_REPOS`. Все флаги документируются в +`.env.example` (developer-стадия). Полное отключение (`false`) → строго прежнее +поведение. + +## Документация эксплуатации +`docs/operations/INFRA.md` — добавить (best-effort, developer/PR) короткое +упоминание поведения reaper/lease-reclaim при self-restart. Топологическая карта +INFRA.md не меняется. diff --git a/docs/work-items/ORCH-065/08-data-requirements.md b/docs/work-items/ORCH-065/08-data-requirements.md new file mode 100644 index 0000000..90201ff --- /dev/null +++ b/docs/work-items/ORCH-065/08-data-requirements.md @@ -0,0 +1,29 @@ +# 08 — Требования к данным (ORCH-065) + +## Изменение схемы: `jobs.pid` + +| Поле | Значение | +|------|----------| +| Таблица | `jobs` | +| Колонка | `pid` | +| Тип | `INTEGER` (nullable, без DEFAULT) | +| Назначение | pid агентского процесса (`subprocess.Popen.pid` из `launcher._spawn`) для liveness-детекции зомби job-reaper'ом (Tier-1) | +| Механизм миграции | `_ensure_column(conn, "jobs", "pid", "INTEGER")` в `db.init_db` — идемпотентно, no-op если колонка уже есть | +| Безопасность на live prod DB | ДА. Тот же паттерn уже применён к `jobs.transient_attempts`, `jobs.available_at`, `events.delivery_id`, `agent_runs.*`. `ALTER TABLE ADD COLUMN` в SQLite — мгновенная метаданная-операция, не блокирует и не переписывает строки | +| Заполнение | в `_spawn` рядом с существующим `UPDATE jobs SET run_id=?, started_at=datetime('now') WHERE id=?` добавить `pid=?` (`proc.pid`). Старые строки остаются `pid IS NULL` → для них Tier-1 неприменим, работают Tier-2/Tier-3 | + +## Что НЕ меняется +- `STAGE_TRANSITIONS`, реестр `QG_CHECKS` — без изменений (это контракты). +- Схема `agent_runs` — без изменений (`finished_at`/`exit_code` уже есть — основа Tier-2). +- Файл-схема merge-lease `.merge-lease-.json` — без изменений (`pid`, + `acquired_at`, `branch`, `work_item_id`, `task_id` уже пишутся + `acquire_merge_lease`). +- `jobs.status` enum (`queued|running|done|failed`) — без изменений; новый статус + `reaping` НЕ вводится (атомарного guard `WHERE status='running'` достаточно). + +## Совместимость / откат +- Откат миграции не требуется: лишняя nullable-колонка безвредна при + `reaper_enabled=false`. +- `pid IS NULL` (строки до миграции, или если запись pid не успела) → reaper не + делает Tier-1, опирается на Tier-2 (exit_code) и Tier-3 (потолок). Поведение + деградирует gracefully, ложноположительных реапов не возникает. diff --git a/docs/work-items/ORCH-065/10-tech-risks.md b/docs/work-items/ORCH-065/10-tech-risks.md new file mode 100644 index 0000000..87b3b30 --- /dev/null +++ b/docs/work-items/ORCH-065/10-tech-risks.md @@ -0,0 +1,22 @@ +# 10 — Технические риски (ORCH-065) + +| # | Риск | Вероятн. | Влияние | Митигация | +|---|------|----------|---------|-----------| +| R-1 | **Ложноположительный реап живого долгого агента** (AC-3). Reaper помечает зомби работающий агент → потеря работы, дубль-запуск. | Сред. | Высокое | Tier-1 требует `reaper_dead_ticks`(≥2) подряд тиков мёртвого pid; живой pid = `os.kill(pid,0)` без `ProcessLookupError`. Tier-3 потолок `reaper_max_running_s` выбирается заведомо > max `agent_timeout`+grace. Юнит-тест TC-02/TC-03. | +| R-2 | **Ложный `done` без выполненной работы.** Reaper при exit0-зомби помечает job done, хотя git-push/advance не случились (monitor умер до них). | Сред. | Высокое | Реап exit0 НЕ форсит done напрямую — идёт через **gate-driven** `_try_advance_stage`: канонический QG проверяет наличие артефакта/PR; нет артефакта → красный гейт → НЕ advance → ветка «исход неуспешен» (requeue). Источник истины — гейт, не «exit0». | +| R-3 | **pid-reuse / namespace.** Номер pid переиспользован новым процессом → ложное «жив» (lease не реклеймится; зомби-job не реапится по Tier-1). | Низк. | Сред. | Lease: условие «pid мёртв **ИЛИ** TTL истёк» — TTL добивает в любом случае. Job-reaper: Tier-3 backstop по времени ловит то, что Tier-1 пропустил. Допущение «один pid-namespace» зафиксировано в 07-infra. | +| R-4 | **Гонка reaper vs поздно доехавший monitor / стартовый `requeue_running_jobs`** → двойная обработка строки. | Сред. | Сред. | Атомарный reap-claim `UPDATE ... WHERE id=? AND status='running'` + проверка `rowcount` (образец `claim_next_job`). Reaper стартует ПОСЛЕ `requeue_running_jobs` в lifespan. Юнит-тест TC-06. | +| R-5 | **Реклейм живого lease** → параллельный конфликтный merge, риск красного `main` self-hosting. | Низк. | Высокое | `reclaim_stale_lease` освобождает ТОЛЬКО при «держатель мёртв ИЛИ TTL истёк»; живой держатель в пределах TTL не трогается. holder-aware `release_merge_lease(repo, branch)`. Юнит-тест TC-12. | +| R-6 | **Реклейм инициирует git-операцию / трогает прод-контейнер** (нарушение self-hosting safety, AC-12). | Низк. | Высокое | Реклейм = только удаление файла-lease (`os.remove`), без git. Reaper не вызывает деплой-хук/рестарт. Явный инвариант в ADR + тест/ревью. | +| R-7 | **Идемпотентность merge не достигнута**: повторный проход стадии делает второй merge уже слитого PR. | Сред. | Сред. | never-raise guard `pr_already_merged(repo,branch)` (читает состояние PR) консультируется перед merge → уже слит = no-op. `branch_is_behind_main==False` пропускает rebase+re-test. Юнит-тест TC-16, интеграция TC-17. | +| R-8 | **streak-счётчик in-memory теряется при рестарте** → задержка реапа или сброс прогресса. | Низк. | Низкое | Рестарт-сценарий покрыт стартовым `requeue_running_jobs` (мгновенно чистит running). Периодический reaper нужен лишь для зомби БЕЗ рестарта; сброс счётчика лишь переоткладывает реап на `reaper_dead_ticks` тиков. | +| R-9 | **never-raise нарушен** — необработанное исключение валит daemon-поток reaper → защита тихо отключается. | Низк. | Сред. | Per-job изоляция `try/except` (образец `reconciler.reconcile_gate_once`) + внешний `try/except` в `_run`. Юнит-тест TC-08/TC-14. | +| R-10 | **Регресс существующих тестов** merge_gate/queue/reconciler/deploy. | Низк. | Сред. | Контракты неизменны (`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/exit-коды хука); только новая колонка + новый поток + флаги (дефолт сохраняет поведение). Полный прогон `pytest tests/ -q` (regression в 04-test-plan). | + +## Открытые вопросы / follow-up +- **Полная автоматизация merge-финализации.** Если деплой-merge (deployer/ORCH-36 + detached host-process) окажется не полностью идемпотентным к повторному проходу, + может понадобиться доп. работа поверх `pr_already_merged`. Здесь закрываем + технический блокер; полный авто-approve деплоя — ORCH-54. +- Допущение «агенты — дочерние процессы в одном pid-namespace» (R-3) должно быть + пересмотрено, если упаковка агентов изменится (отдельные контейнеры).