Compare commits
24 Commits
feature/OR
...
feature/OR
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f4d79baee | ||
|
|
7cdef6d377 | ||
| ca41d9210b | |||
| 48943fe10a | |||
| 86fe8dd509 | |||
| dd07b58165 | |||
| b67a61ecef | |||
| 8fcb867dcf | |||
| 4815e378d9 | |||
| 67e98b8296 | |||
|
|
cad5e98892 | ||
| bb03350ec9 | |||
| 930e65298c | |||
| cba67a4270 | |||
| 720c31393a | |||
| 9b7c855df3 | |||
| a6b444c356 | |||
| dbf14e3d5a | |||
| 4bebb921ff | |||
| 9f846b5a50 | |||
| b760b24a48 | |||
| f0ac9d5562 | |||
| 987ea810bf | |||
| f85e449d80 |
29
.env.example
29
.env.example
@@ -117,6 +117,35 @@ ORCH_RECONCILE_GRACE_OVERRIDES_JSON=
|
|||||||
ORCH_RECONCILE_NOTIFY_UNBLOCK=true
|
ORCH_RECONCILE_NOTIFY_UNBLOCK=true
|
||||||
ORCH_RECONCILE_SKIP_BLOCKED_ENABLED=true
|
ORCH_RECONCILE_SKIP_BLOCKED_ENABLED=true
|
||||||
|
|
||||||
|
# ORCH-065: job-reaper + proactive merge-lease reclaim. A background daemon thread
|
||||||
|
# (src/job_reaper.py, started LAST in main.lifespan after requeue_running_jobs) reaps
|
||||||
|
# zombie 'running' jobs whose monitor/process died before writing the terminal status
|
||||||
|
# (one zombie at max_concurrency=1 blocks the whole shared queue) and periodically
|
||||||
|
# reclaims dead/stale merge-leases. Liveness is three-tier: Tier-1 dead jobs.pid
|
||||||
|
# (os.kill(pid,0)) after REAPER_DEAD_TICKS consecutive dead ticks (anti-false-positive
|
||||||
|
# for a live agent); Tier-2 agent_runs.exit_code recorded but job still 'running'
|
||||||
|
# (only after a REAPER_FINALIZE_GRACE_S finalization grace, so a live monitor still
|
||||||
|
# doing git push / PR / Plane comments is never reaped); Tier-3 backstop after
|
||||||
|
# REAPER_MAX_RUNNING_S. The terminal flip carries an atomic status='running' guard and
|
||||||
|
# precedes any advance/enqueue (claim-before-act) so it never double-processes/-advances
|
||||||
|
# a row racing a late monitor or requeue_running_jobs.
|
||||||
|
# REAPER_ENABLED -> global kill-switch (false -> strictly prior behaviour).
|
||||||
|
# REAPER_INTERVAL_S -> background scan period (seconds).
|
||||||
|
# REAPER_DEAD_TICKS -> consecutive dead-pid ticks before reaping (Tier-1, >=2).
|
||||||
|
# REAPER_MAX_RUNNING_S -> Tier-3 backstop ceiling; must exceed max agent_timeout+grace.
|
||||||
|
# REAPER_FINALIZE_GRACE_S -> Tier-2 grace: how long agent_runs.exit_code must have been
|
||||||
|
# recorded before a still-'running' job is reaped; MUST exceed
|
||||||
|
# the max finalization window (git push + PR + Plane comments).
|
||||||
|
# LEASE_RECLAIM_ENABLED -> kill-switch for the proactive stale/dead lease reclaim
|
||||||
|
# (false -> only the legacy lazy TTL reclaim in acquire_merge_lease).
|
||||||
|
# (reuse) ORCH_MERGE_LOCK_TIMEOUT_S -> lease TTL; ORCH_MERGE_GATE_REPOS -> reclaim scope.
|
||||||
|
ORCH_REAPER_ENABLED=true
|
||||||
|
ORCH_REAPER_INTERVAL_S=60
|
||||||
|
ORCH_REAPER_DEAD_TICKS=2
|
||||||
|
ORCH_REAPER_MAX_RUNNING_S=3600
|
||||||
|
ORCH_REAPER_FINALIZE_GRACE_S=300
|
||||||
|
ORCH_LEASE_RECLAIM_ENABLED=true
|
||||||
|
|
||||||
# ORCH-021: post-deploy production monitoring + degradation reaction. After the
|
# ORCH-021: post-deploy production monitoring + degradation reaction. After the
|
||||||
# terminal deploy->done transition for an applicable repo, a reserved-agent job
|
# terminal deploy->done transition for an applicable repo, a reserved-agent job
|
||||||
# `post-deploy-monitor` (no LLM, modelled on deploy-finalizer) probes prod over a
|
# `post-deploy-monitor` (no LLM, modelled on deploy-finalizer) probes prod over a
|
||||||
|
|||||||
@@ -91,6 +91,30 @@ The verdict contract is unchanged: `docs/work-items/<work_item_id>/14-deploy-log
|
|||||||
frontmatter field `deploy_status: SUCCESS|FAILED` (the gate `check_deploy_status` parses ONLY this).
|
frontmatter field `deploy_status: SUCCESS|FAILED` (the gate `check_deploy_status` parses ONLY this).
|
||||||
**What changed (ORCH-36): WHO and WHEN writes that verdict, for the self-hosting repo.**
|
**What changed (ORCH-36): WHO and WHEN writes that verdict, for the self-hosting repo.**
|
||||||
|
|
||||||
|
### ⚠️ Idempotent merge guard — consult `pr_already_merged` BEFORE merging (ORCH-065)
|
||||||
|
|
||||||
|
The `deploy` stage can be **re-driven**: if a process/monitor thread died after the PR
|
||||||
|
merged but before the job finalised, the job-reaper requeues it and this stage runs **again**
|
||||||
|
(ADR-001 ORCH-065, Р-3). A blind second merge of an already-merged PR makes Gitea return a
|
||||||
|
merge error → a false БАГ-8 rollback. To stay idempotent, **before you merge the feature
|
||||||
|
branch PR into `main`, consult the deterministic guard** `merge_gate.pr_already_merged(repo, branch)`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Already merged? exit 0 = yes (skip the merge), exit 1 = no (merge normally).
|
||||||
|
python3 -c "import sys; from src.merge_gate import pr_already_merged; \
|
||||||
|
sys.exit(0 if pr_already_merged('<repo>', '<branch>') else 1)" && MERGED=1 || MERGED=0
|
||||||
|
```
|
||||||
|
|
||||||
|
- `MERGED=1` (PR already merged) → **do NOT merge again** (no second merge, no error).
|
||||||
|
Treat the merge as already done and continue to write the deploy verdict
|
||||||
|
(`deploy_status: SUCCESS` once the deploy itself is health-ok). This is the AC-11 no-op.
|
||||||
|
- `MERGED=0` (not merged) → merge the PR normally, then proceed.
|
||||||
|
|
||||||
|
The guard is **never-raise** (any Gitea/parse error → `False` → "not known-merged", so a real
|
||||||
|
merge is never silently skipped). This is the single consultation point ADR-001 Р-3 /
|
||||||
|
README / CHANGELOG refer to: the **merge path (deployer/merge) consults the guard before a
|
||||||
|
(repeat) merge**.
|
||||||
|
|
||||||
### Self-hosting repo (`orchestrator`) — you do NOT deploy yourself
|
### Self-hosting repo (`orchestrator`) — you do NOT deploy yourself
|
||||||
|
|
||||||
For `orchestrator` the `deploy` stage is orchestrated by **deterministic code** in
|
For `orchestrator` the `deploy` stage is orchestrated by **deterministic code** in
|
||||||
@@ -124,4 +148,7 @@ deploys go through `scripts/orchestrator-deploy-hook.sh` (parametrised; defaults
|
|||||||
|
|
||||||
- Always write machine-readable YAML frontmatter — the quality gates parse ONLY the frontmatter fields, never the body prose.
|
- Always write machine-readable YAML frontmatter — the quality gates parse ONLY the frontmatter fields, never the body prose.
|
||||||
- Never push directly to `main`. Always use a PR or the artifact merge pattern.
|
- Never push directly to `main`. Always use a PR or the artifact merge pattern.
|
||||||
|
- **Idempotent merge (ORCH-065):** before any (re-)merge of a feature PR into `main`, consult
|
||||||
|
`merge_gate.pr_already_merged(repo, branch)` (see the `deploy` stage section). Already merged
|
||||||
|
→ no second merge, no error — the stage is a no-op on the merge and proceeds to its verdict.
|
||||||
- Never modify `.env`, `.env.staging`, `docker-compose.yml`, or production infrastructure.
|
- Never modify `.env`, `.env.staging`, `docker-compose.yml`, or production infrastructure.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -64,6 +64,10 @@ created → analysis → architecture → development → review → testing →
|
|||||||
- **НЕ перезапускать / не ронять прод-контейнер** `orchestrator` в рамках задачи — встанет конвейер всех проектов.
|
- **НЕ перезапускать / не ронять прод-контейнер** `orchestrator` в рамках задачи — встанет конвейер всех проектов.
|
||||||
- Любой деплой/рестарт self = групповой риск. Детали и топология — `docs/operations/INFRA.md`.
|
- Любой деплой/рестарт self = групповой риск. Детали и топология — `docs/operations/INFRA.md`.
|
||||||
- Стадия `deploy-staging` (порт 8501) — обязательная страховка перед прод-деплоем орка.
|
- Стадия `deploy-staging` (порт 8501) — обязательная страховка перед прод-деплоем орка.
|
||||||
|
- Прод-деплой орка запускается ТОЛЬКО переводом задачи на стадии `deploy` в выделенный
|
||||||
|
Plane-статус **«Confirm Deploy»** (ORCH-059). Статус `Approved` — человеческий гейт
|
||||||
|
конвейера и прод-деплой НЕ запускает (на `deploy` — no-op). Это разделяет «одобрить
|
||||||
|
артефакт» и «выкатить в прод», чтобы привычный approve не ронял прод случайным кликом.
|
||||||
|
|
||||||
---
|
---
|
||||||
*Паспорт проекта orchestrator. Поддерживается агентами при каждой доработке. Изолирован: описывает только этот проект (канон per-repo, см. ORCH-9).*
|
*Паспорт проекта orchestrator. Поддерживается агентами при каждой доработке. Изолирован: описывает только этот проект (канон per-repo, см. ORCH-9).*
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
- **Quality Gates** (`src/qg/checks.py`) — проверки выхода со стадии, реестр `QG_CHECKS`.
|
- **Quality Gates** (`src/qg/checks.py`) — проверки выхода со стадии, реестр `QG_CHECKS`.
|
||||||
- **Agent Launcher** (`src/agents/launcher.py`) — запуск Claude CLI агентов в изолированном git worktree, мониторинг, auto-advance.
|
- **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.
|
- **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`→`queued`, иначе `failed`+Telegram). Атомарный reap-claim (guard `status='running'`) совместим со стартовым `requeue_running_jobs`. Тот же поток периодически делает проактивный реклейм stale/dead merge-lease (см. ниже). never-raise; kill-switch `ORCH_REAPER_ENABLED`; снимок в `GET /queue` (блок `reaper`).
|
||||||
- **Reconciler** (`src/reconciler.py`, ORCH-053 — реализовано, [adr-0007](adr/adr-0007-reconciler.md)) — фоновый daemon-поток (паттерн `queue_worker`), стартует/останавливается в `main.lifespan` (после `worker.start()` / перед `worker.stop()`). Реконсилирует рассинхрон «источник истины ≠ стадия задачи» при потерянном webhook. F-1 gate-side (продвигает застрявшую стадию по локальной БД через штатный `advance_stage(..., finished_agent=None)`), F-2 plane-side (опрос Plane API → `handle_*` из `plane.py`), F-3 (БД-fallback `sha→branch` в `handle_ci_status`). Источник истины — гейт/Plane, не событие; идемпотентность (active-job guard + atomic-claim + grace); kill-switch `ORCH_RECONCILE_ENABLED`. `analysis` F-1 не трогает (человеческий гейт). F-1 также пропускает escalated (retry≥лимита) и Blocked/Needs-Input задачи (ORCH-060). Наблюдаемость — блок `reconcile` в `GET /queue`.
|
- **Reconciler** (`src/reconciler.py`, ORCH-053 — реализовано, [adr-0007](adr/adr-0007-reconciler.md)) — фоновый daemon-поток (паттерн `queue_worker`), стартует/останавливается в `main.lifespan` (после `worker.start()` / перед `worker.stop()`). Реконсилирует рассинхрон «источник истины ≠ стадия задачи» при потерянном webhook. F-1 gate-side (продвигает застрявшую стадию по локальной БД через штатный `advance_stage(..., finished_agent=None)`), F-2 plane-side (опрос Plane API → `handle_*` из `plane.py`), F-3 (БД-fallback `sha→branch` в `handle_ci_status`). Источник истины — гейт/Plane, не событие; идемпотентность (active-job guard + atomic-claim + grace); kill-switch `ORCH_RECONCILE_ENABLED`. `analysis` F-1 не трогает (человеческий гейт). F-1 также пропускает escalated (retry≥лимита) и Blocked/Needs-Input задачи (ORCH-060). Наблюдаемость — блок `reconcile` в `GET /queue`.
|
||||||
- **Project Registry** (`src/projects.py`, ORCH-6) — Plane project id → repo + prefix; фильтрация вебхуков по проекту.
|
- **Project Registry** (`src/projects.py`, ORCH-6) — Plane project id → repo + prefix; фильтрация вебхуков по проекту.
|
||||||
- **Plane Sync** (`src/plane_sync.py`) — синхронизация статусов/комментариев в Plane.
|
- **Plane Sync** (`src/plane_sync.py`) — синхронизация статусов/комментариев в Plane.
|
||||||
@@ -69,21 +70,25 @@ Self-hosting зацикливался на `deploy-staging`: `scripts/staging_ch
|
|||||||
а `deploy_status: SUCCESS` означает доказанный health-ok, не декларацию LLM. Три фазы
|
а `deploy_status: SUCCESS` означает доказанный health-ok, не декларацию LLM. Три фазы
|
||||||
(детерминированно, без LLM в критическом пути self-restart):
|
(детерминированно, без LLM в критическом пути self-restart):
|
||||||
- **Фаза A (вход в `deploy`)** — при `deploy_require_manual_approve=true` вместо запуска
|
- **Фаза A (вход в `deploy`)** — при `deploy_require_manual_approve=true` вместо запуска
|
||||||
прод-deployer выставляется approval-pending статус Plane + запрос approve
|
прод-deployer выставляется approval-pending статус Plane + запрос перевести задачу
|
||||||
(Plane-коммент + Telegram). Перехват в `advance_stage` ПОСЛЕ `check_staging_status`
|
в статус **«Confirm Deploy»** (ORCH-059; Plane-коммент + Telegram). Перехват в
|
||||||
и merge-gate.
|
`advance_stage` ПОСЛЕ `check_staging_status` и merge-gate.
|
||||||
- **Фаза B (Plane → `Approved`)** — `advance_stage(deploy, finished_agent=None)`
|
- **Фаза B (Plane → `Confirm Deploy`, ORCH-059)** —
|
||||||
|
`advance_stage(deploy, finished_agent=None, confirm_deploy=True)`
|
||||||
запускает **detached host-процесс** (ssh + setsid → хук с прод-параметрами +
|
запускает **detached host-процесс** (ssh + setsid → хук с прод-параметрами +
|
||||||
build-once retag `SOURCE_IMAGE`) и ставит детерминированный **finalizer-job**;
|
build-once retag `SOURCE_IMAGE`) и ставит детерминированный **finalizer-job**;
|
||||||
маркер `initiated` — идемпотентность. Возврат БЕЗ advance (вердикта ещё нет).
|
маркер `initiated` — идемпотентность. Возврат БЕЗ advance (вердикта ещё нет).
|
||||||
|
Обычный `Approved` на `deploy` (`confirm_deploy=False`) — детерминированный no-op
|
||||||
|
(не деплоит и не откатывает).
|
||||||
- **Фаза C (finalizer)** — новый контейнер после рестарта читает sentinel `result`
|
- **Фаза C (finalizer)** — новый контейнер после рестарта читает sentinel `result`
|
||||||
(exit-code хука), маппит `0→SUCCESS / иначе→FAILED`, пишет `14-deploy-log.md`,
|
(exit-code хука), маппит `0→SUCCESS / иначе→FAILED`, пишет `14-deploy-log.md`,
|
||||||
вызывает `advance_stage(deploy, finished_agent="deployer")` → существующие контракты:
|
вызывает `advance_stage(deploy, finished_agent="deployer")` → существующие контракты:
|
||||||
`SUCCESS → done`, `FAILED → откат БАГ-8 на development`.
|
`SUCCESS → done`, `FAILED → откат БАГ-8 на development`.
|
||||||
|
|
||||||
Approve = смена статуса Plane на `Approved` (status-only verdict model; комментарии
|
Триггер прод-деплоя = смена статуса Plane на `Confirm Deploy` (ORCH-059; status-only
|
||||||
не управляют конвейером). На старте — обязательный ручной approve (флаг `true`); полный
|
verdict model; комментарии не управляют конвейером). `Approved` остаётся исключительно
|
||||||
авто — отдельная задача (ORCH-54). Условность как ORCH-35: реально для `orchestrator`,
|
человеческим гейтом конвейера и прод-деплой не запускает. На старте — обязательный
|
||||||
|
ручной approve (флаг `true`); полный авто — отдельная задача (ORCH-54). Условность как ORCH-35: реально для `orchestrator`,
|
||||||
прочие репо — прежний синхронный ssh-деплой агентом. Контракты не меняются:
|
прочие репо — прежний синхронный ssh-деплой агентом. Контракты не меняются:
|
||||||
`STAGE_TRANSITIONS`, реестр QG, `check_deploy_status`/`_parse_deploy_status`, БАГ-8,
|
`STAGE_TRANSITIONS`, реестр QG, `check_deploy_status`/`_parse_deploy_status`, БАГ-8,
|
||||||
terminal-sync, merge-gate, exit-code-контракт хука. Restart-safe состояние —
|
terminal-sync, merge-gate, exit-code-контракт хука. Restart-safe состояние —
|
||||||
@@ -91,6 +96,31 @@ sentinel-файлы (`<repos_dir>/.deploy-state-<repo>/<wi>/`), без мигр
|
|||||||
Подробнее: [adr-0007](adr/adr-0007-executable-self-deploy.md), детально —
|
Подробнее: [adr-0007](adr/adr-0007-executable-self-deploy.md), детально —
|
||||||
`docs/work-items/ORCH-036/06-adr/ADR-001-executable-self-deploy.md`.
|
`docs/work-items/ORCH-036/06-adr/ADR-001-executable-self-deploy.md`.
|
||||||
|
|
||||||
|
#### Выделенный статус-триггер прод-деплоя «Confirm Deploy» (ORCH-059 — реализовано)
|
||||||
|
Перегрузка: один Plane-статус `Approved` служил И человеческим гейтом BRD на
|
||||||
|
`analysis` (`check_analysis_approved`), И триггером Фазы B прод-деплоя на `deploy`
|
||||||
|
— привычный жест approve молча запускал прод-рестарт (групповой self-hosting
|
||||||
|
риск). ORCH-059 разделяет жесты: вводится отдельный логический статус
|
||||||
|
`confirm_deploy` («Confirm Deploy»), который триггерит **ТОЛЬКО** Фазу B на
|
||||||
|
`deploy`; `Approved` остаётся исключительно гейтом конвейера.
|
||||||
|
- `_PLANE_NAME_TO_KEY` += `"Confirm Deploy" → "confirm_deploy"`; в
|
||||||
|
`_DEFAULT_STATES` ключ НЕ добавляется (нет UUID для enduro/fallback) →
|
||||||
|
**fail-closed**: нет статуса → нет деплоя, без `KeyError` (доступ через `.get`).
|
||||||
|
- `handle_issue_updated` маршрутизирует `Confirm Deploy` → `handle_confirm_deploy`
|
||||||
|
(гард `stage=="deploy"`) → `_try_advance_stage(..., confirm_deploy=True)`.
|
||||||
|
- `advance_stage` получает kwarg `confirm_deploy: bool=False`; блок Фазы B
|
||||||
|
(`deploy`+`finished_agent is None`+self-hosting) деплоит ТОЛЬКО при
|
||||||
|
`confirm_deploy=True`, иначе (обычный `Approved`) — **no-op** (`check_deploy_status`
|
||||||
|
не запускается → нет ложного отката БАГ-8).
|
||||||
|
- CTA Фазы A (`_handle_self_deploy_phase_a`) просит «Confirm Deploy», не «Approved».
|
||||||
|
- Условность как ORCH-35/36 (только `orchestrator`); Фазы A/C, `STAGE_TRANSITIONS`,
|
||||||
|
`QG_CHECKS`, `check_deploy_status`, merge-gate, схема БД — без изменений.
|
||||||
|
- Эксплуатация: в Plane-проекте ORCH создать статус «Confirm Deploy» + сброс кэша
|
||||||
|
состояний (`docs/work-items/ORCH-059/07-infra-requirements.md`).
|
||||||
|
|
||||||
|
Детально — `docs/work-items/ORCH-059/06-adr/ADR-001-confirm-deploy-status.md`
|
||||||
|
(уточняет/триггер Фазы B относительно adr-0007).
|
||||||
|
|
||||||
### Post-deploy наблюдение прода + реакция на деградацию (ORCH-021 — реализовано)
|
### Post-deploy наблюдение прода + реакция на деградацию (ORCH-021 — реализовано)
|
||||||
Конвейер заканчивался на `deploy → done` и **забывал про прод**: «успех» = health-check
|
Конвейер заканчивался на `deploy → done` и **забывал про прод**: «успех» = health-check
|
||||||
в момент рестарта (~60с). Класс «зелёный деплой, красный прод» (прецедент ET-8 —
|
в момент рестарта (~60с). Класс «зелёный деплой, красный прод» (прецедент ET-8 —
|
||||||
@@ -190,6 +220,64 @@ never-raise на единицу работы; тишина при синхрон
|
|||||||
и реестры (`STAGE_TRANSITIONS`/`QG_CHECKS`) не меняются. Подробнее:
|
и реестры (`STAGE_TRANSITIONS`/`QG_CHECKS`) не меняются. Подробнее:
|
||||||
[adr-0007](adr/adr-0007-reconciler.md), детально — `docs/work-items/ORCH-053/06-adr/ADR-001-stuck-task-reconciler.md`.
|
[adr-0007](adr/adr-0007-reconciler.md), детально — `docs/work-items/ORCH-053/06-adr/ADR-001-stuck-task-reconciler.md`.
|
||||||
|
|
||||||
|
### Job-reaper + проактивный реклейм merge-lease (ORCH-065 — design)
|
||||||
|
Финализация статуса 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 → встаёт конвейер
|
||||||
|
ВСЕХ проектов (инциденты 07.06: jobs 236/239/242/254). `requeue_running_jobs()`
|
||||||
|
спасал ТОЛЬКО на старте процесса. Симметрично залипал merge-lease (ORCH-043):
|
||||||
|
реклейм был лениво-по-TTL и только при чужом `acquire`, liveness держателя по pid
|
||||||
|
не проверялся. Это последняя ручная точка автономного self-deploy (блокер ORCH-54).
|
||||||
|
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
|
||||||
|
ещё `running` — но это окно неоднозначно (живой monitor пишет exit_code ПЕРВЫМ,
|
||||||
|
затем git push/PR/Plane-комментарии), поэтому Tier-2 реапит только после
|
||||||
|
finalization-grace `reaper_finalize_grace_s` (живой финализирующий monitor НЕ
|
||||||
|
реапится); Tier-3 backstop по потолку `reaper_max_running_s` (> max
|
||||||
|
agent_timeout+grace). Действие переиспользует контракты по принципу
|
||||||
|
**claim-before-act**: для exit0 канонический QG оценивается read-only ПЕРЕД
|
||||||
|
атомарным claim, затем claim `done` ПЕРВЫМ и только победитель claim делает
|
||||||
|
`_try_advance_stage` (advance+enqueue) — проигравший claim (поздний monitor /
|
||||||
|
стартовый requeue) не выполняет побочных эффектов (нет дубль-advance/-enqueue);
|
||||||
|
источник истины — канонический QG, не факт «exit0»; гейт красный или exit≠0/
|
||||||
|
неизвестно → `attempts<max`→`queued`, иначе `failed`+Telegram. Атомарный
|
||||||
|
reap-claim (`UPDATE ... WHERE id=? AND status='running'`) совместим со стартовым
|
||||||
|
`requeue_running_jobs` (restart-safe, без двойной обработки).
|
||||||
|
- **Проактивный реклейм stale/dead lease** (функции в `merge_gate.py`:
|
||||||
|
`pid_alive`, `reclaim_stale_lease`) — на старте (рядом с `requeue_running_jobs`)
|
||||||
|
и периодически из тика reaper: освобождает lease, чей держатель **мёртв** (pid
|
||||||
|
не жив) ИЛИ **просрочен** (TTL `merge_lock_timeout_s`); живой держатель в
|
||||||
|
пределах TTL — НЕ трогать (защита легитимного merge). holder-aware, never-raise,
|
||||||
|
условность как ORCH-43 (`merge_gate_repos`/self-hosting).
|
||||||
|
- **Идемпотентная финализация merge** — без новой merge-логики: re-drive через
|
||||||
|
reaper→`queued`→переисполнение стадии / reconciler; дорогие шаги не повторяются
|
||||||
|
(`branch_is_behind_main==False`); добавлен never-raise guard `pr_already_merged`
|
||||||
|
(читает состояние PR) — уже слит = no-op. **Консультируется самим merge-актором:**
|
||||||
|
фактический merge PR в `main` делает агент `deployer` (в начале стадии `deploy`),
|
||||||
|
поэтому wiring — в его промпте `.openclaw/agents/deployer.md`, который вызывает
|
||||||
|
`pr_already_merged` ПЕРЕД любым (повторным) merge (AC-11). Чек `check_branch_mergeable`
|
||||||
|
НЕ меняется (AC-13): он на ПЕРВОМ ребре `deploy-staging → deploy`, а риск второго
|
||||||
|
merge — на re-drive самой стадии `deploy`.
|
||||||
|
- **Схема БД:** единственное изменение — `jobs.pid INTEGER` через идемпотентный
|
||||||
|
`_ensure_column` (live-safe). `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_*`, БАГ-8,
|
||||||
|
exit-коды хука, файл-схема lease — без изменений.
|
||||||
|
- **Наблюдаемость:** блок `reaper` в `GET /queue` (enabled, interval, last_run_ts,
|
||||||
|
reaped_total, last_reaped, lease_reclaimed_total); каждый reap/lease-reclaim →
|
||||||
|
`logger.warning`; reap→`failed` и lease-reclaim → Telegram.
|
||||||
|
- **Kill-switch'и:** `ORCH_REAPER_ENABLED`, `ORCH_REAPER_INTERVAL_S`,
|
||||||
|
`ORCH_REAPER_DEAD_TICKS`, `ORCH_REAPER_MAX_RUNNING_S`,
|
||||||
|
`ORCH_REAPER_FINALIZE_GRACE_S`, `ORCH_LEASE_RECLAIM_ENABLED`; `false` → строго
|
||||||
|
прежнее поведение.
|
||||||
|
|
||||||
|
Подробнее: [adr-0011](adr/adr-0011-job-reaper-lease-reclaim.md), детально —
|
||||||
|
`docs/work-items/ORCH-065/06-adr/ADR-001-job-reaper-and-lease-reclaim.md`.
|
||||||
|
|
||||||
## Откаты
|
## Откаты
|
||||||
- Reviewer REQUEST_CHANGES → откат на `development` + retry (`MAX_DEVELOPER_RETRIES = 3`).
|
- Reviewer REQUEST_CHANGES → откат на `development` + retry (`MAX_DEVELOPER_RETRIES = 3`).
|
||||||
- Tester `check_tests_passed` FAIL → откат на `development` + retry.
|
- Tester `check_tests_passed` FAIL → откат на `development` + retry.
|
||||||
@@ -223,7 +311,7 @@ never-raise на единицу работы; тишина при синхрон
|
|||||||
- `events` — входящие вебхуки (дедуп)
|
- `events` — входящие вебхуки (дедуп)
|
||||||
- `tasks` — задачи и их стадии
|
- `tasks` — задачи и их стадии
|
||||||
- `agent_runs` — запуски агентов (run_id, usage, cost)
|
- `agent_runs` — запуски агентов (run_id, usage, cost)
|
||||||
- `jobs` — очередь задач (ORCH-1)
|
- `jobs` — очередь задач (ORCH-1); колонка `pid` (ORCH-065) — pid агентского процесса для liveness-детекции зомби job-reaper'ом
|
||||||
|
|
||||||
## Изоляция (git worktree, ORCH-2)
|
## Изоляция (git worktree, ORCH-2)
|
||||||
Каждая задача исполняется в отдельном git worktree, ветки не пересекаются. Репозитории проектов разделены под `/repos/<project>`.
|
Каждая задача исполняется в отдельном git worktree, ветки не пересекаются. Репозитории проектов разделены под `/repos/<project>`.
|
||||||
@@ -233,7 +321,7 @@ never-raise на единицу работы; тишина при синхрон
|
|||||||
|--------|------|----------|
|
|--------|------|----------|
|
||||||
| GET | `/health` | health check |
|
| GET | `/health` | health check |
|
||||||
| GET | `/status` | активные задачи (stage != done) |
|
| 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/plane` | Plane webhook |
|
||||||
| POST | `/webhook/gitea` | Gitea webhook (push, PR, CI status) |
|
| POST | `/webhook/gitea` | Gitea webhook (push, PR, CI status) |
|
||||||
|
|
||||||
@@ -247,4 +335,4 @@ never-raise на единицу работы; тишина при синхрон
|
|||||||
Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.md).
|
Схема БД, потоки данных, 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`) — реализовано в ветке 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 (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест); ORCH-059 (выделенный статус-триггер прод-деплоя «Confirm Deploy», ADR `docs/work-items/ORCH-059/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-059 (маппинг `"Confirm Deploy"→"confirm_deploy"` в src/plane_sync.py `_PLANE_NAME_TO_KEY`, НЕ в `_DEFAULT_STATES` = fail-closed; ветка `handle_confirm_deploy` + fail-closed `.get("confirm_deploy")` в src/webhooks/plane.py `handle_issue_updated`; keyword-only `confirm_deploy` в src/stage_engine.py `advance_stage` — Фаза B деплоит ТОЛЬКО при `confirm_deploy=True`, иначе `Approved`-на-`deploy` = no-op; CTA Фазы A просит «Confirm Deploy»; эксплуатация — статус доски «Confirm Deploy» в Plane-проекте ORCH, `docs/work-items/ORCH-059/07-infra-requirements.md`).*
|
||||||
|
|||||||
@@ -16,11 +16,12 @@ Per-work-item решения живут в `docs/work-items/<id>/06-adr/ADR-NNN-
|
|||||||
| adr-0008 | Провенанс staging-образа перед BUILD-ONCE retag | accepted | 2026-06-06 | ORCH-058 |
|
| adr-0008 | Провенанс staging-образа перед BUILD-ONCE retag | accepted | 2026-06-06 | ORCH-058 |
|
||||||
| adr-0009 | Толерантность staging-вердикта к инфраструктурным FAIL | accepted | 2026-06-07 | ORCH-061 |
|
| adr-0009 | Толерантность staging-вердикта к инфраструктурным FAIL | accepted | 2026-06-07 | ORCH-061 |
|
||||||
| adr-0010 | Post-deploy мониторинг прода + реакция на деградацию | proposed | 2026-06-07 | ORCH-021 |
|
| adr-0010 | Post-deploy мониторинг прода + реакция на деградацию | proposed | 2026-06-07 | ORCH-021 |
|
||||||
|
| adr-0011 | Job-reaper + проактивный реклейм merge-lease | accepted | 2026-06-07 | ORCH-065 |
|
||||||
|
|
||||||
> ⚠️ Историческая коллизия: номер `0007` занят двумя файлами —
|
> ⚠️ Историческая коллизия: номер `0007` занят двумя файлами —
|
||||||
> `adr-0007-reconciler.md` (ORCH-053) и `adr-0007-executable-self-deploy.md`
|
> `adr-0007-reconciler.md` (ORCH-053) и `adr-0007-executable-self-deploy.md`
|
||||||
> (ORCH-036). Оба accepted; для новых сквозных ADR использовать следующий
|
> (ORCH-036). Оба accepted; для новых сквозных ADR использовать следующий
|
||||||
> свободный номер (текущий максимум — `0010`).
|
> свободный номер (текущий максимум — `0011`).
|
||||||
|
|
||||||
## Формат
|
## Формат
|
||||||
**Контекст → Решение → Альтернативы → Последствия → Связи.** Статус: proposed / accepted / superseded.
|
**Контекст → Решение → Альтернативы → Последствия → Связи.** Статус: proposed / accepted / superseded.
|
||||||
|
|||||||
82
docs/architecture/adr/adr-0011-job-reaper-lease-reclaim.md
Normal file
82
docs/architecture/adr/adr-0011-job-reaper-lease-reclaim.md
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# 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-<repo>.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` — но только
|
||||||
|
после finalization-grace `reaper_finalize_grace_s` (окно неоднозначно: живой
|
||||||
|
monitor пишет exit_code ПЕРВЫМ, затем git push/PR/Plane-комментарии, поэтому
|
||||||
|
живой финализирующий monitor НЕ реапится); Tier-3 backstop по потолку
|
||||||
|
`reaper_max_running_s`. Действие — **claim-before-act**: для exit0 канонический
|
||||||
|
QG оценивается read-only ПЕРЕД атомарным claim, затем claim `done` ПЕРВЫМ и
|
||||||
|
только победитель claim выполняет `_try_advance_stage` (advance+enqueue) —
|
||||||
|
проигравший не делает побочных эффектов (источник истины — QG, не «exit0»);
|
||||||
|
гейт красный или exit≠0 / неизвестно → `attempts<max` → `queued`, иначе
|
||||||
|
`failed`+Telegram. Атомарный reap-claim (`UPDATE ... WHERE id=? AND
|
||||||
|
status='running'` + `rowcount`, как `claim_next_job`) исключает двойную
|
||||||
|
обработку (совместимость со стартовым `requeue_running_jobs`).
|
||||||
|
2. **Проактивный реклейм stale/dead lease** — функции в `merge_gate.py`
|
||||||
|
(`pid_alive`, `reclaim_stale_lease`), вызываемые на старте (рядом с
|
||||||
|
`requeue_running_jobs`) и периодически из тика reaper. Освобождение, если
|
||||||
|
держатель **мёртв** (pid не жив) ИЛИ **просрочен** (TTL); живой держатель в
|
||||||
|
пределах TTL — НЕ трогать. holder-aware, never-raise, условность как ORCH-43.
|
||||||
|
3. **Идемпотентная финализация merge** — без новой merge-логики: re-drive через
|
||||||
|
reaper→`queued`→переисполнение стадии / reconciler; дорогие шаги не
|
||||||
|
повторяются (`branch_is_behind_main==False`); добавлен детерминированный
|
||||||
|
never-raise guard `pr_already_merged` (читает состояние PR), консультируемый
|
||||||
|
перед повторным merge → уже слит = no-op.
|
||||||
|
4. **Схема БД** — `jobs.pid INTEGER` через идемпотентный `_ensure_column`
|
||||||
|
(паттерн live-safe миграции). Больше ничего не меняется.
|
||||||
|
|
||||||
|
Kill-switch'и (`ORCH_*`): `reaper_enabled`, `reaper_interval_s`,
|
||||||
|
`reaper_dead_ticks`, `reaper_max_running_s`, `reaper_finalize_grace_s`,
|
||||||
|
`lease_reclaim_enabled`; переиспользуются `merge_lock_timeout_s`,
|
||||||
|
`merge_gate_repos`. `false` → строго прежнее поведение.
|
||||||
|
|
||||||
|
## Альтернативы
|
||||||
|
- Reaper внутри reconciler — отвергнуто (смешение stage- и jobs-уровней, общий
|
||||||
|
kill-switch, хуже изоляция).
|
||||||
|
- Только эвристика `agent_runs` без `jobs.pid` — отвергнуто как основной механизм
|
||||||
|
(не ловит зомби, чей monitor умер до записи exit_code); оставлена как Tier-2/3.
|
||||||
|
- БД-lock / внешний брокер очередей — вне объёма (single-node SQLite).
|
||||||
|
- Форс `done` по факту exit0 — отвергнуто; выбран gate-driven advance.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
- (+) Зомби-job и залипший lease самовосстанавливаются без рестарта и без
|
||||||
|
оператора; очередь общего инстанса не встаёт; снят технический блокер ORCH-54.
|
||||||
|
- (+) Контракты неизменны (`STAGE_TRANSITIONS`, `QG_CHECKS`, `check_*`, БАГ-8,
|
||||||
|
exit-коды хука); одна колонка через проверенный idempotent-паттерн.
|
||||||
|
- (−) pid-liveness валиден в предположении одного pid-namespace (агент —
|
||||||
|
дочерний процесс оркестратора); закрыто backstop'ом по времени и TTL.
|
||||||
|
- (−) streak-счётчик in-memory (сброс на рестарте; рестарт покрыт
|
||||||
|
`requeue_running_jobs`).
|
||||||
|
|
||||||
|
## Связи
|
||||||
|
- Базируется: adr-0002 (очередь), adr-0006 (merge-gate), adr-0007 (reconciler /
|
||||||
|
self-deploy).
|
||||||
|
- Разблокирует: ORCH-54.
|
||||||
@@ -326,6 +326,7 @@ webhook (plane/gitea) background thread (queue_worker)
|
|||||||
| `status` | `queued` → `running` → `done` \| `failed` |
|
| `status` | `queued` → `running` → `done` \| `failed` |
|
||||||
| `attempts` / `max_attempts` | счётчик попыток (инкремент при claim) / лимит ретраев (default 2) |
|
| `attempts` / `max_attempts` | счётчик попыток (инкремент при claim) / лимит ретраев (default 2) |
|
||||||
| `run_id` | FK на `agent_runs.id` после старта |
|
| `run_id` | FK на `agent_runs.id` после старта |
|
||||||
|
| `pid` | (ORCH-065) pid агентского процесса (`proc.pid` из `_spawn`); liveness-сигнал для job-reaper. Добавляется `_ensure_column` (idempotent) |
|
||||||
| `task_content` | ТЗ, которое пишется в task-файл агента |
|
| `task_content` | ТЗ, которое пишется в task-файл агента |
|
||||||
| `error` | последняя ошибка |
|
| `error` | последняя ошибка |
|
||||||
|
|
||||||
@@ -343,6 +344,36 @@ status='queued'` и проверяет `rowcount`. При гонке двух т
|
|||||||
jobs со статусом `running` (воркер умёр на рестарте) → возвращаются в `queued`.
|
jobs со статусом `running` (воркер умёр на рестарте) → возвращаются в `queued`.
|
||||||
Потом стартует воркер; на shutdown — `worker.stop()` (Event.set + join).
|
Потом стартует воркер; на shutdown — `worker.stop()` (Event.set + join).
|
||||||
|
|
||||||
|
### Job-reaper (ORCH-065, рестарт НЕ требуется)
|
||||||
|
|
||||||
|
`requeue_running_jobs()` спасает ТОЛЬКО на старте процесса. Зомби-job, возникший
|
||||||
|
**без** рестарта (умер monitor-поток/дочерний процесс, а сервис жив), оставался
|
||||||
|
`running` навсегда и при `max_concurrency=1` блокировал всю очередь. Фоновый
|
||||||
|
daemon-поток `src/job_reaper.py` (каркас `reconciler`) периодически
|
||||||
|
(`reaper_interval_s`) сканирует `running`-jobs и реапит «мёртвые»:
|
||||||
|
- **Tier-1** — `jobs.pid` мёртв (`os.kill(pid,0)`→`ProcessLookupError`) на
|
||||||
|
протяжении `reaper_dead_ticks` подряд тиков (анти-ложноположительность);
|
||||||
|
- **Tier-2** — у `agent_runs[run_id]` записан `exit_code`, а `jobs.status` ещё
|
||||||
|
`running`. Окно неоднозначно: живой monitor пишет `exit_code` ПЕРВЫМ, затем
|
||||||
|
git push/PR/Plane-комментарии (секунды-десятки секунд) и лишь потом
|
||||||
|
`_finalize_job`; pid агента к этому моменту мёртв в обоих случаях. Поэтому
|
||||||
|
Tier-2 реапит только после finalization-grace `reaper_finalize_grace_s`
|
||||||
|
(`finished_age_s >= grace`) — живой финализирующий monitor НЕ реапится;
|
||||||
|
- **Tier-3** — backstop: job висит `running` дольше `reaper_max_running_s`.
|
||||||
|
|
||||||
|
Реап атомарен (`UPDATE jobs SET ... WHERE id=? AND status='running'` + `rowcount`,
|
||||||
|
как `claim_next_job`) → совместим со стартовым `requeue_running_jobs` без двойной
|
||||||
|
обработки. Действие — **claim-before-act**: для exit0 канонический QG оценивается
|
||||||
|
read-only ПЕРЕД атомарным claim, затем claim `done` ПЕРВЫМ и только победитель
|
||||||
|
claim делает `_try_advance_stage` (advance+enqueue) — проигравший (поздний monitor
|
||||||
|
/ стартовый requeue) не выполняет побочных эффектов (нет дубль-advance/-enqueue);
|
||||||
|
источник истины — QG, не «exit0»; гейт красный или exit≠0/неизвестно →
|
||||||
|
`attempts<max`→`queued`, иначе `failed`+Telegram. Тот же поток на старте и
|
||||||
|
периодически делает проактивный реклейм stale/dead merge-lease (`merge_gate.py`:
|
||||||
|
`pid_alive`/`reclaim_stale_lease`). never-raise; kill-switch `ORCH_REAPER_ENABLED`
|
||||||
|
/ `ORCH_LEASE_RECLAIM_ENABLED`; снимок в `GET /queue` (блок `reaper`). Подробнее —
|
||||||
|
adr-0011.
|
||||||
|
|
||||||
### Конфиг
|
### Конфиг
|
||||||
|
|
||||||
- `ORCH_MAX_CONCURRENCY` (default 1) — лимит параллельных jobs.
|
- `ORCH_MAX_CONCURRENCY` (default 1) — лимит параллельных jobs.
|
||||||
|
|||||||
78
docs/history/LESSONS_2026-06-07_autonomy-closure.md
Normal file
78
docs/history/LESSONS_2026-06-07_autonomy-closure.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# Lessons Learned — 2026-06-07: замыкание автономности self-deploy (5 задач в прод)
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
За одну сессию закрыты в прод **5 задач**, завершающих автономный self-deploy эпика ORCH-54:
|
||||||
|
|
||||||
|
| Задача | Что | Прод-коммит |
|
||||||
|
|--------|-----|-------------|
|
||||||
|
| ORCH-58 | provenance retag-guard (свежесть staging-образа перед BUILD-ONCE) | 094b5e2 |
|
||||||
|
| ORCH-60 | reconciler не трогает escalated/Blocked/Needs-Input | d4c6cc0 |
|
||||||
|
| ORCH-61 | фикс петли deploy-staging (staging_verdict: waive sandbox-infra FAILs C9a/C9b) | e18947d |
|
||||||
|
| ORCH-21 | post-deploy мониторинг прода + auto-rollback (self-hosting=alert-only) | f85e449 |
|
||||||
|
| ORCH-65 | job-reaper + stale merge-lease reclaim + idempotent merge | bb03350 |
|
||||||
|
|
||||||
|
**Главное:** после ORCH-60/61 конвейер впервые провёз задачи (ORCH-21/65) через deploy-staging
|
||||||
|
**автономно** без отката; после ORCH-65 (job-reaper в проде) зомби-job и зависшие merge-lease
|
||||||
|
лечатся сами. Последняя ручная точка автономного деплоя закрыта.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Класс багов: «процесс умер — ресурс захвачен навсегда» (ORCH-65)
|
||||||
|
Три связанных отказа, все воспроизвелись на ORCH-58/60/61/21:
|
||||||
|
- **zombie jobs:** агент завершился/умер, строка jobs осталась running. requeue_running_jobs()
|
||||||
|
спасает только на старте процесса; зомби без рестарта не лечился → при concurrency=1 встаёт
|
||||||
|
конвейер ВСЕХ проектов. (jobs 236/239/242/254/265 — все зомби за сессию.)
|
||||||
|
- **stale merge-lease:** merge-gate берёт .merge-lease-<repo>.json, делает rebase+re-test green,
|
||||||
|
а на финальном merge процесс умирает с зажатым lease → merge не докатывается.
|
||||||
|
- **неидемпотентный merge:** re-drive повторно пытается слить уже слитый PR.
|
||||||
|
Фикс: фоновый job_reaper (паттерн reconciler, dead_ticks streak + мёртвый pid + exit_code,
|
||||||
|
атомарный reap-claim, never-raise, kill-switch, снимок в /queue) + проактивный lease-reclaim
|
||||||
|
по pid + guard pr_already_merged ПЕРЕД merge.
|
||||||
|
|
||||||
|
## Петля deploy-staging (ORCH-61) — ДВЕ причины
|
||||||
|
1. ложный check_staging_status FAILED: staging_check падает на C9a/C9b (sandbox e2e branch +
|
||||||
|
analyst-job-in-queue), т.к. bot-токены SANDBOX-проекта не настроены — НЕ регресс кода.
|
||||||
|
2. no-changes для action-стадий (деплой = рестарт/retag, не правка → коммитить нечего).
|
||||||
|
Фикс: staging_verdict waive sandbox-infra-only FAILs.
|
||||||
|
|
||||||
|
## Инфра-каскад от переполненного диска (инцидент дня)
|
||||||
|
- Частые build-once/--build-staging пересборки за день забили docker build cache до 11 ГБ →
|
||||||
|
диск 100% → CI red (No space left).
|
||||||
|
- ДАЖЕ после чистки диска Gitea осталась в сломанном состоянии: внутренняя queue
|
||||||
|
(/data/gitea/queues/common/*.log) залипла → post-receive hook 500 → actions tasks НЕ
|
||||||
|
создаются, CI не триггерится вовсе (статус пустой, не failure). runner при этом online+idle.
|
||||||
|
- Лечение: docker builder prune -af + рестарт Gitea (queue распускается → CI ожил).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Уроки
|
||||||
|
1. **Self-hosting safety (сквозной принцип):** прод-орк обслуживает ВСЕ проекты. Нельзя авто-
|
||||||
|
откатывать/рестартить self в рамках задачи; нельзя пушить main. ORCH-21 post-deploy для
|
||||||
|
self-hosting = alert-only, авто-rollback только для не-self репо.
|
||||||
|
2. **TDD без доводки (повтор ORCH-58 и ORCH-65 v1):** тесты есть, реализация/wiring не
|
||||||
|
подключены к боевому пути → мёртвый код + врущая дока. Reviewer обязан грепать вызовы из
|
||||||
|
прод-кода, не только наличие функции.
|
||||||
|
3. **Concurrency-баги ловятся итеративно:** ORCH-65 3 прохода reviewer (мёртвый guard → race
|
||||||
|
condition side-effects-before-claim → approve) — каждый раз НОВЫЙ реальный дефект, не
|
||||||
|
зацикливание. Atomic-claim ДО side-effects — обязательное правило.
|
||||||
|
4. **При красном CI + зелёных локальных тестах — ПЕРВЫМ делом df -h / и docker system df**,
|
||||||
|
не копаться в коде. После disk-full обязателен рестарт Gitea (queue залипает).
|
||||||
|
5. **Bootstrap-разрыв:** задача про автономность деплоя не может задеплоить себя автономно,
|
||||||
|
пока её механизм не в проде. Последний прод-деплой каждого такого фикса — вручную.
|
||||||
|
6. **Перед прод-retag (build-once SOURCE_IMAGE=staging):** проверить revision-label staging-
|
||||||
|
образа == целевой main HEAD, иначе guard fail-closed (by design). Если != → пересобрать
|
||||||
|
--build-staging GIT_SHA=<main HEAD>.
|
||||||
|
|
||||||
|
## Ручная доводка прод-deploy (схема до ORCH-65 в проде)
|
||||||
|
cancel zombie job → park task In Progress → merge PR (Gitea pulls/{n}/merge Do=merge, CI green)
|
||||||
|
→ --build-staging GIT_SHA=<main HEAD> (проставит label) → rollback-снимок → --deploy с
|
||||||
|
EXPECTED_REVISION=<sha> (guard сверит → retag → health 200) → Plane Done + UPDATE tasks stage=done.
|
||||||
|
|
||||||
|
## Follow-up (Backlog)
|
||||||
|
- ORCH-62: авто-prune docker build cache (cron/daemon.json defaultKeepStorage).
|
||||||
|
- ORCH-63: мониторинг диска mva154 + алерт >85%.
|
||||||
|
- ORCH-64: починить NTP/часы mva154 (ушли ~+3ч от UTC).
|
||||||
|
|
||||||
|
## Осталось в эпике ORCH-54
|
||||||
|
ORCH-22 (security-гейт), ORCH-59 (Confirm Deploy статус), ORCH-23 (budget circuit-breaker),
|
||||||
|
P2: ORCH-57, ORCH-51.
|
||||||
30
docs/work-items/ORCH-022/15-staging-log.md
Normal file
30
docs/work-items/ORCH-022/15-staging-log.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
staging_status: SUCCESS
|
||||||
|
timestamp: 2026-06-07T18:02:27+00:00
|
||||||
|
base_url: http://localhost:8501
|
||||||
|
---
|
||||||
|
|
||||||
|
# Staging Gate Log
|
||||||
|
|
||||||
|
Staging test suite completed via canonical run (ORCH-048, ADR-001):
|
||||||
|
|
||||||
|
```
|
||||||
|
docker exec orchestrator-staging \
|
||||||
|
python3 /repos/orchestrator/scripts/staging_check.py \
|
||||||
|
--base-url http://localhost:8501 --mode stub
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result: 8/10 checks PASS — exit code 0 (advance).**
|
||||||
|
|
||||||
|
All REAL (pipeline) checks green: A1, A2, A3 (SMOKE), B4, B5, B6 (ACCESS), C7, C8 (E2E).
|
||||||
|
|
||||||
|
Two sandbox-infra-only checks failed and were waived per ORCH-061
|
||||||
|
(`staging_infra_tolerance_enabled=True`) — these depend on SANDBOX bot accounts
|
||||||
|
being members of the SANDBOX Plane project, not on the pipeline:
|
||||||
|
|
||||||
|
```
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
Cleanup ran (Plane SANDBOX test issue deleted, HTTP 204). Exit code 0 → `staging_status: SUCCESS`.
|
||||||
7
docs/work-items/ORCH-059/00-business-request.md
Normal file
7
docs/work-items/ORCH-059/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Business Request: Approve деплоя через статус Confirm Deploy (вместо перегруженного Approved)
|
||||||
|
|
||||||
|
Work Item ID: ORCH-059
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
TBD
|
||||||
115
docs/work-items/ORCH-059/01-brd.md
Normal file
115
docs/work-items/ORCH-059/01-brd.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# 01 — BRD: Approve прод-деплоя через выделенный статус «Confirm Deploy»
|
||||||
|
|
||||||
|
Work Item: **ORCH-059**
|
||||||
|
Repo: `orchestrator`
|
||||||
|
Stage: analysis
|
||||||
|
Тип: enhancement / risk-reduction (self-hosting)
|
||||||
|
|
||||||
|
## 1. Контекст и проблема
|
||||||
|
|
||||||
|
В ORCH-036 («исполняемый самодеплой стадии `deploy`») прод-деплой self-hosting
|
||||||
|
инстанса (контейнер `orchestrator`, порт 8500) запускается **Фазой B**: человек
|
||||||
|
переводит issue в Plane-статус **`Approved`**, webhook
|
||||||
|
`work_item.updated` → `handle_issue_updated` → `handle_verdict(approved=True)`
|
||||||
|
→ `_try_advance_stage` → `advance_stage(finished_agent=None)`, и в
|
||||||
|
`stage_engine.advance_stage` срабатывает блок
|
||||||
|
`current_stage == "deploy" and finished_agent is None` →
|
||||||
|
`_handle_self_deploy_phase_b` → detached host-деплой прода.
|
||||||
|
|
||||||
|
**Перегрузка статуса.** Тот же самый Plane-статус `Approved` (UUID
|
||||||
|
`a519a341-…`) используется как **человеческий гейт одобрения BRD** на ранней
|
||||||
|
стадии `analysis` (`check_analysis_approved`: analysis → architecture) и в общем
|
||||||
|
verdict-роутинге `handle_verdict`. Один и тот же визуальный «Approved» на доске
|
||||||
|
означает две принципиально разные вещи:
|
||||||
|
|
||||||
|
- на `analysis` — «BRD/ТЗ/AC приняты, продолжай конвейер» (дёшево, обратимо);
|
||||||
|
- на `deploy` — «**ВЫКАТИ В ПРОД** инструмент, который прямо сейчас обслуживает
|
||||||
|
все проекты из одного инстанса с общей БД» (дорого, групповой риск, см.
|
||||||
|
раздел Self-hosting в `CLAUDE.md`).
|
||||||
|
|
||||||
|
### Последствия (Pain)
|
||||||
|
- **Двусмысленность семантики.** Один статус — два смысла; оператор не видит из
|
||||||
|
названия, что клик на `deploy` запускает реальный прод-рестарт.
|
||||||
|
- **Риск случайного клика.** Привычный жест «Approved» (которым оператор
|
||||||
|
штатно одобряет BRD десятки раз) на стадии `deploy` молча триггерит
|
||||||
|
прод-деплой. Цена ошибки — незапланированный рестарт прод-инстанса,
|
||||||
|
встающий конвейер всех проектов.
|
||||||
|
- **Несоответствие ожиданиям ORCH-036.** В scope ORCH-36 заявлялась Telegram
|
||||||
|
inline-кнопка подтверждения; в коде её **нет** — developer реализовал approve
|
||||||
|
исключительно через Plane-статус. Отдельного «осознанного» жеста подтверждения
|
||||||
|
деплоя в системе сейчас не существует.
|
||||||
|
|
||||||
|
## 2. Решение Owner
|
||||||
|
|
||||||
|
Ввести **отдельный Plane-статус `Confirm Deploy`** в проекте ORCH, который
|
||||||
|
триггерит **ТОЛЬКО** Фазу B self-deploy на стадии `deploy`. Статус `Approved`
|
||||||
|
перестаёт запускать прод-деплой и сохраняет единственный смысл — человеческое
|
||||||
|
одобрение на гейтах конвейера (прежде всего BRD на `analysis`).
|
||||||
|
|
||||||
|
Минимальная правка: `handle_verdict` в `src/webhooks/plane.py` + регистрация
|
||||||
|
нового состояния в проекте ORCH (Plane + резолвер состояний).
|
||||||
|
|
||||||
|
## 3. Бизнес-цели
|
||||||
|
- **BG-1.** Убрать двусмысленность: жест «запустить прод-деплой» отделён от жеста
|
||||||
|
«одобрить артефакт».
|
||||||
|
- **BG-2.** Снизить риск случайного прод-деплоя: запуск прода требует явного,
|
||||||
|
редко используемого статуса `Confirm Deploy`, а не привычного `Approved`.
|
||||||
|
- **BG-3.** Не сломать работающий self-hosting конвейер при доработке самого
|
||||||
|
инструмента (нулевая регрессия `analysis`-гейта и не-self репозиториев).
|
||||||
|
|
||||||
|
## 4. Объём (Scope)
|
||||||
|
|
||||||
|
### В объёме
|
||||||
|
- Новый логический статус `confirm_deploy` («Confirm Deploy») в резолвере
|
||||||
|
состояний Plane (`src/plane_sync.py`).
|
||||||
|
- Маршрутизация нового статуса в `src/webhooks/plane.py`
|
||||||
|
(`handle_issue_updated` / `handle_verdict`) на путь Фазы B прод-деплоя.
|
||||||
|
- Прекращение триггера Фазы B по статусу `Approved` на стадии `deploy`.
|
||||||
|
- Обновление текста CTA Фазы A (Plane-комментарий + Telegram в
|
||||||
|
`stage_engine._handle_self_deploy_phase_a`): инструктировать оператора
|
||||||
|
переводить задачу в `Confirm Deploy`, а не в `Approved`.
|
||||||
|
- Конфигурация Plane: создание статуса «Confirm Deploy» в проекте ORCH
|
||||||
|
(предусловие эксплуатации — фиксируется в TRZ/AC как требование среды).
|
||||||
|
- Обновление документации (`CLAUDE.md`, `docs/architecture/README.md` секция
|
||||||
|
ORCH-036, `CHANGELOG.md`) и ADR per-work-item.
|
||||||
|
|
||||||
|
### Вне объёма
|
||||||
|
- Telegram inline-кнопки подтверждения деплоя (отдельная задача; здесь не
|
||||||
|
реализуем — управление по-прежнему статусом Plane).
|
||||||
|
- Полностью автоматический approve деплоя (ORCH-54).
|
||||||
|
- Изменение Фаз A/C, exit-кодов хука, merge-gate, `check_deploy_status`,
|
||||||
|
схемы БД, реестров `STAGE_TRANSITIONS` / `QG_CHECKS`.
|
||||||
|
- Поведение прод-деплоя для не-self репозиториев (остаётся прежним).
|
||||||
|
- Post-deploy наблюдение (ORCH-021) — не затрагивается.
|
||||||
|
|
||||||
|
## 5. Заинтересованные стороны
|
||||||
|
- **Owner/оператор** — переводит задачи по статусам; главный выгодоприобретатель
|
||||||
|
снижения риска.
|
||||||
|
- **Self-hosting конвейер** — все проекты на общем инстансе; косвенно зависят от
|
||||||
|
безопасности прод-деплоя орка.
|
||||||
|
|
||||||
|
## 6. Допущения
|
||||||
|
- A-1. Plane позволяет добавить кастомный статус «Confirm Deploy» в проект ORCH;
|
||||||
|
его UUID резолвится через `get_project_states` (API `/states/`).
|
||||||
|
- A-2. Статус `Confirm Deploy` нужен только проекту ORCH (self-hosting). Прочие
|
||||||
|
проекты прод-деплой через Plane-approve не используют
|
||||||
|
(`self_deploy_applies` → только `orchestrator`).
|
||||||
|
- A-3. Оператор переводит задачу в `Confirm Deploy` только когда она реально
|
||||||
|
находится на стадии `deploy` (approval-pending после Фазы A).
|
||||||
|
|
||||||
|
## 7. Риски (детально — 10-tech-risks.md, ведёт архитектор)
|
||||||
|
- R-1. Новый логический ключ `confirm_deploy` отсутствует в fallback
|
||||||
|
`_DEFAULT_STATES` и в проектах без этого статуса → обращение к ключу должно
|
||||||
|
быть безопасным (fail-closed: нет статуса → нет деплоя, не падение).
|
||||||
|
- R-2. Регрессия: `Approved` на `deploy` после правки не должен НИ
|
||||||
|
запускать деплой, НИ вызывать ложный откат/advance.
|
||||||
|
- R-3. Самоправка прода: правка не должна потребовать ручного рестарта прод-
|
||||||
|
контейнера вне штатной стадии deploy-staging → deploy.
|
||||||
|
|
||||||
|
## 8. Definition of Done (бизнес-уровень)
|
||||||
|
- Перевод задачи стадии `deploy` в `Confirm Deploy` запускает прод-деплой
|
||||||
|
(Фаза B) ровно так же, как раньше делал `Approved`.
|
||||||
|
- Перевод задачи стадии `deploy` в `Approved` прод-деплой НЕ запускает.
|
||||||
|
- `Approved` на `analysis` (и прочих человеческих гейтах) работает без изменений.
|
||||||
|
- CTA Фазы A просит `Confirm Deploy`.
|
||||||
|
- Документация и ADR обновлены в том же PR.
|
||||||
103
docs/work-items/ORCH-059/02-trz.md
Normal file
103
docs/work-items/ORCH-059/02-trz.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# 02 — ТЗ: выделенный статус «Confirm Deploy» как триггер прод-деплоя
|
||||||
|
|
||||||
|
Work Item: **ORCH-059** · Repo: `orchestrator` · Stage: analysis
|
||||||
|
|
||||||
|
> ТЗ описывает **что** должно измениться и **поведенческий контракт**. Конкретный
|
||||||
|
> дизайн (сигнатуры, способ проброса признака «confirm-deploy» из webhook в
|
||||||
|
> `stage_engine`, sentinel-обработка) — за архитектором (ADR per-work-item).
|
||||||
|
> Точки касания ниже заданы бизнес-запросом Owner и текущей реализацией ORCH-036.
|
||||||
|
|
||||||
|
## 1. Задействованные модули `src/`
|
||||||
|
|
||||||
|
| Модуль | Роль в задаче |
|
||||||
|
|--------|---------------|
|
||||||
|
| `src/plane_sync.py` | Резолвер состояний Plane. Добавить логический ключ `confirm_deploy` ↔ имя статуса «Confirm Deploy»; обеспечить безопасный доступ при отсутствии статуса (fallback/неполный конфиг). |
|
||||||
|
| `src/webhooks/plane.py` | `handle_issue_updated` — маршрутизация нового статуса; `handle_verdict` — отделить «подтверждение деплоя» от обычного approve; снять триггер Фазы B со статуса `Approved` на `deploy`. |
|
||||||
|
| `src/stage_engine.py` | Блок Фазы B (`current_stage == "deploy" and finished_agent is None`) должен срабатывать ТОЛЬКО по сигналу confirm-deploy, не по обычному Approved. Обновить CTA-текст Фазы A (`_handle_self_deploy_phase_a`). |
|
||||||
|
| `src/config.py` | (опционально, на усмотрение архитектора) флаг/имя статуса, если потребуется конфигурируемость. По умолчанию — не требуется. |
|
||||||
|
|
||||||
|
## 2. Поведенческий контракт (требования)
|
||||||
|
|
||||||
|
### TRZ-1. Регистрация статуса «Confirm Deploy»
|
||||||
|
Резолвер состояний (`get_project_states`) обязан возвращать UUID статуса
|
||||||
|
«Confirm Deploy» под логическим ключом `confirm_deploy` для проекта ORCH.
|
||||||
|
Маппинг имени `"Confirm Deploy" → "confirm_deploy"` добавляется в
|
||||||
|
`_PLANE_NAME_TO_KEY`. Для проектов/сред, где статус отсутствует (enduro,
|
||||||
|
fallback `_DEFAULT_STATES`, недоступный API), ключ может отсутствовать —
|
||||||
|
обращение к нему должно быть **fail-closed**: «нет статуса → ветка confirm-deploy
|
||||||
|
не активируется», без `KeyError`/исключения.
|
||||||
|
|
||||||
|
### TRZ-2. Триггер прод-деплоя по «Confirm Deploy»
|
||||||
|
Когда задача находится на стадии `deploy` и issue переводится в статус
|
||||||
|
`Confirm Deploy`, система обязана инициировать **Фазу B** прод-деплоя
|
||||||
|
(эквивалент текущего `_handle_self_deploy_phase_b`: idempotency-guard `initiated`,
|
||||||
|
`self_deploy.initiate_deploy`, постановка `deploy-finalizer`, комментарии/Telegram).
|
||||||
|
Поведение, идемпотентность и Фаза C — **без изменений** относительно ORCH-036;
|
||||||
|
меняется только **что именно является триггером**.
|
||||||
|
|
||||||
|
### TRZ-3. `Approved` больше не запускает прод-деплой
|
||||||
|
Перевод задачи стадии `deploy` в статус `Approved` **не должен** инициировать
|
||||||
|
Фазу B. Он не должен также вызывать ложный откат (БАГ-8) или ложный advance
|
||||||
|
по `check_deploy_status` (вердикта ещё нет). Допустимое поведение — **no-op с
|
||||||
|
логированием** (issue остаётся на `deploy`/approval-pending). Конкретный способ
|
||||||
|
(игнор на уровне webhook-роутинга или на уровне `stage_engine`) — за архитектором.
|
||||||
|
|
||||||
|
### TRZ-4. Сохранность гейта `Approved` на остальных стадиях
|
||||||
|
Статус `Approved` обязан продолжать работать как человеческий гейт:
|
||||||
|
- `analysis` → `architecture` (`check_analysis_approved`, approved-via-status);
|
||||||
|
- любой иной человеческий approve-advance, существующий сегодня.
|
||||||
|
Регрессия `handle_verdict(approved=True)` для НЕ-`deploy` стадий недопустима.
|
||||||
|
|
||||||
|
### TRZ-5. CTA Фазы A
|
||||||
|
Текст запроса approve в `_handle_self_deploy_phase_a` (Plane-комментарий + Telegram)
|
||||||
|
обязан инструктировать оператора переводить задачу в статус **`Confirm Deploy`**
|
||||||
|
(а не `Approved`) для запуска прод-деплоя.
|
||||||
|
|
||||||
|
### TRZ-6. Условность (как ORCH-35/36)
|
||||||
|
Ветка confirm-deploy реальна только для self-hosting
|
||||||
|
(`self_deploy.self_deploy_applies(repo)` → `orchestrator`). Для прочих репо —
|
||||||
|
прежнее поведение (синхронный деплой агентом), статус `Confirm Deploy` не
|
||||||
|
требуется и не влияет.
|
||||||
|
|
||||||
|
## 3. Изменения API
|
||||||
|
Изменений HTTP-эндпоинтов **нет**. Канал — существующий `POST /webhook/plane`
|
||||||
|
(событие `work_item.updated`). Внешнее изменение: в проекте ORCH появляется
|
||||||
|
дополнительный статус доски «Confirm Deploy» (Plane-конфигурация, не код-API).
|
||||||
|
|
||||||
|
## 4. Изменения схемы БД
|
||||||
|
**Нет.** `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, таблицы `tasks`/`jobs`/
|
||||||
|
`agent_runs`/`events` — без изменений. Статусы — на стороне Plane; restart-safe
|
||||||
|
состояние деплоя — существующие sentinel-файлы ORCH-036 (без миграций).
|
||||||
|
|
||||||
|
## 5. Требования к новым QG checks
|
||||||
|
**Нет.** Новый Quality Gate не вводится. `check_deploy_status` /
|
||||||
|
`_parse_deploy_status` и контракт exit-кодов хука (0/1/2) — без изменений.
|
||||||
|
|
||||||
|
## 6. Конфигурация среды (предусловие эксплуатации)
|
||||||
|
- В проекте ORCH в Plane создаётся статус доски **«Confirm Deploy»** (точное имя,
|
||||||
|
чувствительно к регистру — должно совпасть с ключом `_PLANE_NAME_TO_KEY`).
|
||||||
|
- Размещение статуса на доске — рядом со стадией deploy/approval-pending
|
||||||
|
(рекомендация эксплуатации, не код).
|
||||||
|
- Кэш состояний (`get_project_states` / `reload_project_states`): после создания
|
||||||
|
статуса может потребоваться сброс кэша или рестарт по штатной стадии deploy.
|
||||||
|
|
||||||
|
## 7. Артефакты, создаваемые/обновляемые по pipeline
|
||||||
|
- `docs/work-items/ORCH-059/06-adr/ADR-001-confirm-deploy-status.md` — решение
|
||||||
|
(как отличается триггер; где разрезается перегрузка `Approved`; fail-closed
|
||||||
|
при отсутствии статуса) — **ведёт архитектор**.
|
||||||
|
- `CLAUDE.md` — упоминание выделенного статуса approve прод-деплоя (раздел
|
||||||
|
self-hosting / артефакты).
|
||||||
|
- `docs/architecture/README.md` — секция ORCH-036: уточнить, что Фаза B
|
||||||
|
триггерится статусом `Confirm Deploy`, а не `Approved`.
|
||||||
|
- `CHANGELOG.md` — запись ORCH-059.
|
||||||
|
- `12-review.md`, `13-test-report.md`, `14-deploy-log.md`, `15-staging-log.md` —
|
||||||
|
штатно по стадиям конвейера.
|
||||||
|
|
||||||
|
## 8. Совместимость и инварианты
|
||||||
|
- Не меняются: `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_deploy_status`,
|
||||||
|
БАГ-8 (FAILED → откат на development), merge-gate, exit-коды хука, Фазы A/C,
|
||||||
|
схема БД, post-deploy (ORCH-021).
|
||||||
|
- Self-hosting safety: правка НЕ требует внепланового рестарта прод-контейнера;
|
||||||
|
выкат — через штатный deploy-staging (8501) → deploy.
|
||||||
|
- Never-crash: отсутствие статуса `Confirm Deploy` в резолвере не приводит к
|
||||||
|
исключению в webhook-пути.
|
||||||
76
docs/work-items/ORCH-059/03-acceptance-criteria.md
Normal file
76
docs/work-items/ORCH-059/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
# 03 — Критерии приёмки: ORCH-059
|
||||||
|
|
||||||
|
Repo: `orchestrator` · Stage: analysis
|
||||||
|
Каждый критерий — однозначный PASS/FAIL. Проверка: unit/integration (см.
|
||||||
|
`04-test-plan.yaml`) + ручная верификация для инфра-предусловий.
|
||||||
|
|
||||||
|
## AC-1 — Статус «Confirm Deploy» резолвится
|
||||||
|
**Given** проект ORCH со статусом доски «Confirm Deploy»
|
||||||
|
**When** вызывается резолвер состояний для проекта ORCH
|
||||||
|
**Then** возвращается логический ключ `confirm_deploy` с непустым UUID,
|
||||||
|
а маппинг `"Confirm Deploy" → "confirm_deploy"` присутствует в `_PLANE_NAME_TO_KEY`.
|
||||||
|
**FAIL:** ключ отсутствует или указывает на UUID статуса `Approved`.
|
||||||
|
|
||||||
|
## AC-2 — «Confirm Deploy» на стадии `deploy` запускает Фазу B
|
||||||
|
**Given** задача self-hosting (`orchestrator`) на стадии `deploy`,
|
||||||
|
`deploy_require_manual_approve=true`, маркер `initiated` отсутствует
|
||||||
|
**When** приходит `work_item.updated` со статусом `Confirm Deploy`
|
||||||
|
**Then** инициируется Фаза B: вызывается `self_deploy.initiate_deploy`,
|
||||||
|
ставится job `deploy-finalizer`, пишется маркер `initiated`.
|
||||||
|
**FAIL:** прод-деплой не инициирован, либо finalizer не поставлен.
|
||||||
|
|
||||||
|
## AC-3 — «Approved» на стадии `deploy` НЕ запускает прод-деплой
|
||||||
|
**Given** та же задача на стадии `deploy`
|
||||||
|
**When** приходит `work_item.updated` со статусом `Approved`
|
||||||
|
**Then** `self_deploy.initiate_deploy` **НЕ** вызывается; Фаза B не стартует;
|
||||||
|
задача не откатывается (БАГ-8 не срабатывает) и не «доходит» по
|
||||||
|
`check_deploy_status` (вердикта нет); событие залогировано как no-op.
|
||||||
|
**FAIL:** вызван `initiate_deploy`, либо произошёл откат/ложный advance.
|
||||||
|
|
||||||
|
## AC-4 — «Approved» на `analysis` работает без регрессии
|
||||||
|
**Given** задача на стадии `analysis` (BRD готов, approval-pending)
|
||||||
|
**When** issue переводится в `Approved`
|
||||||
|
**Then** срабатывает approved-via-status и задача продвигается
|
||||||
|
`analysis → architecture` (как до правки).
|
||||||
|
**FAIL:** approve на analysis перестал продвигать конвейер.
|
||||||
|
|
||||||
|
## AC-5 — Идемпотентность Фазы B по «Confirm Deploy»
|
||||||
|
**Given** задача на `deploy`, маркер `initiated` уже существует
|
||||||
|
**When** повторно приходит статус `Confirm Deploy` (двойной клик / дубль webhook)
|
||||||
|
**Then** повторного `initiate_deploy` не происходит (no-op,
|
||||||
|
`self-deploy-already-initiated`).
|
||||||
|
**FAIL:** прод-деплой запускается повторно.
|
||||||
|
|
||||||
|
## AC-6 — CTA Фазы A просит «Confirm Deploy»
|
||||||
|
**Given** Фаза A (`deploy-staging → deploy`, approval-pending)
|
||||||
|
**When** формируются Plane-комментарий и Telegram-уведомление запроса approve
|
||||||
|
**Then** текст инструктирует перевести задачу в статус **`Confirm Deploy`**
|
||||||
|
(а не «Approved») для запуска прод-деплоя.
|
||||||
|
**FAIL:** CTA по-прежнему упоминает только «Approved».
|
||||||
|
|
||||||
|
## AC-7 — Fail-closed при отсутствии статуса
|
||||||
|
**Given** среда без статуса «Confirm Deploy» (enduro / fallback `_DEFAULT_STATES`
|
||||||
|
/ недоступный Plane API)
|
||||||
|
**When** обрабатывается `work_item.updated`
|
||||||
|
**Then** webhook-путь не выбрасывает исключение; ветка confirm-deploy не
|
||||||
|
активируется (прод-деплой не запускается «вслепую»).
|
||||||
|
**FAIL:** `KeyError`/исключение в обработчике, либо ложный запуск Фазы B.
|
||||||
|
|
||||||
|
## AC-8 — Условность для не-self репозиториев
|
||||||
|
**Given** не-self репозиторий (`self_deploy_applies(repo) == False`)
|
||||||
|
**When** приходит любой verdict-статус на стадии `deploy`
|
||||||
|
**Then** поведение прод-деплоя не меняется относительно текущего (синхронный
|
||||||
|
деплой агентом); статус `Confirm Deploy` не требуется.
|
||||||
|
**FAIL:** изменилось поведение деплоя не-self проекта.
|
||||||
|
|
||||||
|
## AC-9 — Инварианты не нарушены
|
||||||
|
**Then** `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_deploy_status`/
|
||||||
|
`_parse_deploy_status`, контракт exit-кодов хука (0/1/2), Фазы A/C, merge-gate,
|
||||||
|
схема БД — без изменений; `pytest tests/ -q` зелёный.
|
||||||
|
**FAIL:** изменён любой из перечисленных контрактов или красные тесты.
|
||||||
|
|
||||||
|
## AC-10 — Документация обновлена (golden source)
|
||||||
|
**Then** в том же PR обновлены `CLAUDE.md`, секция ORCH-036 в
|
||||||
|
`docs/architecture/README.md`, `CHANGELOG.md`; заведён
|
||||||
|
`06-adr/ADR-001-confirm-deploy-status.md`.
|
||||||
|
**FAIL:** функционал изменён, документация — нет (Reviewer → REQUEST_CHANGES).
|
||||||
109
docs/work-items/ORCH-059/04-test-plan.yaml
Normal file
109
docs/work-items/ORCH-059/04-test-plan.yaml
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
work_item: ORCH-059
|
||||||
|
title: Approve прод-деплоя через выделенный статус «Confirm Deploy»
|
||||||
|
repo: orchestrator
|
||||||
|
stage: analysis
|
||||||
|
|
||||||
|
# Контракт-тесты: триггер прод-деплоя смещается с перегруженного `Approved`
|
||||||
|
# на выделенный статус `Confirm Deploy`. Деплой и сетевые вызовы мокаются.
|
||||||
|
tests:
|
||||||
|
- id: TC-01
|
||||||
|
type: unit
|
||||||
|
description: "_PLANE_NAME_TO_KEY содержит маппинг 'Confirm Deploy' -> 'confirm_deploy'"
|
||||||
|
module: tests/test_plane_states.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
- id: TC-02
|
||||||
|
type: unit
|
||||||
|
description: >-
|
||||||
|
get_project_states для проекта ORCH (мок API со статусом 'Confirm Deploy')
|
||||||
|
возвращает непустой UUID под ключом 'confirm_deploy', отличный от 'approved'
|
||||||
|
module: tests/test_plane_states.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
- id: TC-03
|
||||||
|
type: unit
|
||||||
|
description: >-
|
||||||
|
Fail-closed: при отсутствии статуса 'Confirm Deploy' (fallback _DEFAULT_STATES /
|
||||||
|
недоступный API) доступ к ключу confirm_deploy не выбрасывает исключение
|
||||||
|
и не активирует ветку confirm-deploy
|
||||||
|
module: tests/test_plane_states.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
- id: TC-04
|
||||||
|
type: unit
|
||||||
|
description: >-
|
||||||
|
handle_issue_updated: статус 'Confirm Deploy' на задаче стадии deploy
|
||||||
|
маршрутизируется на путь Фазы B (а не на обычный approve/advance)
|
||||||
|
module: tests/test_plane_confirm_deploy.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
- id: TC-05
|
||||||
|
type: unit
|
||||||
|
description: >-
|
||||||
|
handle_verdict/Approved на стадии deploy НЕ вызывает self_deploy.initiate_deploy
|
||||||
|
(initiate_deploy замокан и не должен быть вызван)
|
||||||
|
module: tests/test_plane_confirm_deploy.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
- id: TC-06
|
||||||
|
type: unit
|
||||||
|
description: >-
|
||||||
|
Approved на стадии analysis по-прежнему продвигает analysis -> architecture
|
||||||
|
(approved-via-status, регрессия гейта check_analysis_approved)
|
||||||
|
module: tests/test_plane_confirm_deploy.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
- id: TC-07
|
||||||
|
type: unit
|
||||||
|
description: >-
|
||||||
|
stage_engine: блок Фазы B (current_stage==deploy, finished_agent is None)
|
||||||
|
инициирует deploy ТОЛЬКО по сигналу confirm-deploy; Approved-сигнал -> no-op
|
||||||
|
module: tests/test_stage_engine_phase_b.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
- id: TC-08
|
||||||
|
type: unit
|
||||||
|
description: >-
|
||||||
|
Идемпотентность: при существующем маркере 'initiated' повторный
|
||||||
|
Confirm Deploy не вызывает initiate_deploy (self-deploy-already-initiated)
|
||||||
|
module: tests/test_stage_engine_phase_b.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
- id: TC-09
|
||||||
|
type: unit
|
||||||
|
description: >-
|
||||||
|
CTA Фазы A (_handle_self_deploy_phase_a): текст Plane-комментария и Telegram
|
||||||
|
содержат 'Confirm Deploy' и не предлагают 'Approved' как триггер деплоя
|
||||||
|
module: tests/test_stage_engine_phase_a_cta.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
- id: TC-10
|
||||||
|
type: integration
|
||||||
|
description: >-
|
||||||
|
E2E (мок Plane API + self_deploy): задача на deploy -> webhook Confirm Deploy
|
||||||
|
-> initiate_deploy вызван, deploy-finalizer поставлен, маркер initiated записан
|
||||||
|
module: tests/test_confirm_deploy_integration.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
- id: TC-11
|
||||||
|
type: integration
|
||||||
|
description: >-
|
||||||
|
E2E: задача на deploy -> webhook Approved -> прод-деплой НЕ инициирован,
|
||||||
|
задача остаётся на deploy (нет отката, нет advance в done)
|
||||||
|
module: tests/test_confirm_deploy_integration.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
- id: TC-12
|
||||||
|
type: integration
|
||||||
|
description: >-
|
||||||
|
Условность: для не-self репозитория verdict-статусы на deploy не меняют
|
||||||
|
поведение деплоя (self_deploy_applies == False)
|
||||||
|
module: tests/test_confirm_deploy_integration.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
regression:
|
||||||
|
- id: RG-01
|
||||||
|
type: integration
|
||||||
|
description: "pytest tests/ -q зелёный; STAGE_TRANSITIONS и QG_CHECKS без изменений"
|
||||||
|
module: tests/
|
||||||
|
expected: PASS
|
||||||
156
docs/work-items/ORCH-059/06-adr/ADR-001-confirm-deploy-status.md
Normal file
156
docs/work-items/ORCH-059/06-adr/ADR-001-confirm-deploy-status.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# ADR-001 (ORCH-059): Выделенный статус «Confirm Deploy» как триггер прод-деплоя
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
Accepted (design) — реализация в ветке `feature/ORCH-059-approve-confirm-deploy-approve`.
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
ORCH-036 (исполняемый самодеплой стадии `deploy`) запускает прод-деплой
|
||||||
|
self-hosting инстанса **Фазой B**: человек переводит issue в Plane-статус
|
||||||
|
`Approved` → webhook `work_item.updated` → `handle_issue_updated` →
|
||||||
|
`handle_verdict(approved=True)` → `_try_advance_stage` →
|
||||||
|
`advance_stage(finished_agent=None)`; в `stage_engine.advance_stage` блок
|
||||||
|
`current_stage == "deploy" and finished_agent is None` →
|
||||||
|
`_handle_self_deploy_phase_b` → detached host-деплой прода (8500).
|
||||||
|
|
||||||
|
Тот же UUID `Approved` (`a519a341-…`, `_DEFAULT_STATES["approved"]`) — это
|
||||||
|
**человеческий гейт одобрения** на стадии `analysis`
|
||||||
|
(`check_analysis_approved`, путь `approved-via-status`) и общий verdict-роутинг
|
||||||
|
в `handle_verdict`. Один визуальный «Approved» на доске значит две принципиально
|
||||||
|
разные вещи: «принять BRD» (дёшево, обратимо) и «**ВЫКАТИТЬ В ПРОД** инструмент,
|
||||||
|
обслуживающий все проекты из одного инстанса с общей БД» (дорого, групповой
|
||||||
|
риск). Привычный жест approve на стадии `deploy` молча триггерит прод-рестарт —
|
||||||
|
цена случайного клика высока (см. self-hosting в `CLAUDE.md`).
|
||||||
|
|
||||||
|
Ограничения, формирующие дизайн (см. `02-trz.md`, `03-acceptance-criteria.md`):
|
||||||
|
1. **Нулевая регрессия** гейта `Approved` на `analysis` и прочих стадиях (TRZ-4).
|
||||||
|
2. **Fail-closed**: среды без статуса (enduro, fallback `_DEFAULT_STATES`,
|
||||||
|
недоступный API) не должны падать и не должны «вслепую» деплоить (TRZ-1, R-1).
|
||||||
|
3. **`Approved` на `deploy` не должен** запускать Фазу B И не должен вызывать
|
||||||
|
ложный откат (БАГ-8) или ложный advance по `check_deploy_status` — вердикта
|
||||||
|
ещё нет (TRZ-3, R-2).
|
||||||
|
4. **Без правки контрактов**: `STAGE_TRANSITIONS`, `QG_CHECKS`,
|
||||||
|
`check_deploy_status`, Фазы A/C, merge-gate, exit-коды хука, схема БД (TRZ-8).
|
||||||
|
5. **Self-hosting safety**: правка — чистая маршрутизация, не требует внепланового
|
||||||
|
рестарта прода; выкат через штатный `deploy-staging` (8501) → `deploy` (R-3).
|
||||||
|
|
||||||
|
## Решение
|
||||||
|
Ввести отдельный логический статус `confirm_deploy` («Confirm Deploy»), который
|
||||||
|
триггерит **ТОЛЬКО** Фазу B на стадии `deploy`. `Approved` теряет смысл «запусти
|
||||||
|
прод-деплой» и остаётся исключительно человеческим гейтом конвейера.
|
||||||
|
|
||||||
|
Четыре точечные правки в трёх модулях:
|
||||||
|
|
||||||
|
### 1. Резолвер состояний — `src/plane_sync.py`
|
||||||
|
- В `_PLANE_NAME_TO_KEY` добавить маппинг `"Confirm Deploy" → "confirm_deploy"`.
|
||||||
|
- В `_DEFAULT_STATES` ключ `confirm_deploy` **НЕ добавлять** (реального UUID для
|
||||||
|
enduro/fallback нет; отсутствие ключа = fail-closed). Для проекта ORCH ключ
|
||||||
|
резолвится `get_project_states` из живого Plane API; для проектов без статуса и
|
||||||
|
на fallback-пути ключ просто отсутствует в результирующем словаре.
|
||||||
|
- Следствие: `get_project_states(orch)["confirm_deploy"]` → реальный UUID;
|
||||||
|
`get_project_states(enduro).get("confirm_deploy")` → `None`.
|
||||||
|
|
||||||
|
### 2. Маршрутизация webhook — `src/webhooks/plane.py`
|
||||||
|
В `handle_issue_updated`, **до** ветки `approved`, добавить fail-closed-ветку:
|
||||||
|
```python
|
||||||
|
confirm_state = proj_states.get("confirm_deploy") # .get -> AC-7/R-1
|
||||||
|
if confirm_state and new_state == confirm_state:
|
||||||
|
await handle_confirm_deploy(data, project_id)
|
||||||
|
elif new_state == proj_states["in_progress"]:
|
||||||
|
...
|
||||||
|
elif new_state == proj_states["approved"]:
|
||||||
|
await handle_verdict(data, project_id, approved=True)
|
||||||
|
```
|
||||||
|
Новый `handle_confirm_deploy(data, project_id)`:
|
||||||
|
- резолвит задачу по `plane_id`;
|
||||||
|
- если `stage != "deploy"` → **no-op с логом** (Confirm Deploy осмыслен только на
|
||||||
|
approval-pending стадии `deploy`; защищает прочие гейты от случайного approve);
|
||||||
|
- иначе → `_try_advance_stage(..., confirm_deploy=True)`.
|
||||||
|
|
||||||
|
`handle_verdict(approved=True)` не меняется — продолжает звать `_try_advance_stage`
|
||||||
|
с `confirm_deploy=False` (дефолт).
|
||||||
|
|
||||||
|
### 3. Сигнал в движок — `src/stage_engine.advance_stage(...)`
|
||||||
|
Добавить keyword-only параметр `confirm_deploy: bool = False` (back-compat: все
|
||||||
|
существующие вызовы из launcher/reconciler/finalizer/webhook передают
|
||||||
|
`finished_agent`, новый kwarg дефолтный). Блок Фазы B переписать так, чтобы он
|
||||||
|
**всегда возвращался рано** для `deploy + finished_agent is None` self-hosting,
|
||||||
|
но деплоил только по сигналу:
|
||||||
|
```python
|
||||||
|
if (current_stage == "deploy" and finished_agent is None
|
||||||
|
and settings.deploy_require_manual_approve
|
||||||
|
and self_deploy.self_deploy_applies(repo)):
|
||||||
|
if confirm_deploy:
|
||||||
|
_handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result)
|
||||||
|
else:
|
||||||
|
# TRZ-3/R-2: обычный Approved на deploy — no-op; НЕ запускаем
|
||||||
|
# check_deploy_status (вердикта ещё нет -> ложный откат БАГ-8).
|
||||||
|
result.note = "approved-on-deploy-noop"
|
||||||
|
return result
|
||||||
|
```
|
||||||
|
Ключевое: возврат **до** блока Quality Gate в обоих случаях → `check_deploy_status`
|
||||||
|
по `Approved` на `deploy` не исполняется. Фаза C (finalizer,
|
||||||
|
`finished_agent="deployer"`) не затронута — условие требует `finished_agent is
|
||||||
|
None`.
|
||||||
|
|
||||||
|
### 4. CTA Фазы A — `src/stage_engine._handle_self_deploy_phase_a`
|
||||||
|
Текст Plane-комментария и Telegram изменить: вместо «смените статус на Approved»
|
||||||
|
инструктировать перевести задачу в статус **«Confirm Deploy»** для запуска
|
||||||
|
прод-деплоя (TRZ-5/AC-6).
|
||||||
|
|
||||||
|
### Условность (как ORCH-35/36)
|
||||||
|
Вся ветка реальна только для `self_deploy.self_deploy_applies(repo)` →
|
||||||
|
`orchestrator`. Прочие репо — прежний синхронный ssh-деплой агентом; статус
|
||||||
|
`Confirm Deploy` им не нужен и на них не влияет (AC-8).
|
||||||
|
|
||||||
|
## Альтернативы
|
||||||
|
- **A. Telegram inline-кнопка подтверждения** вместо нового статуса — отклонено:
|
||||||
|
кнопочная инфраструктура в коде отсутствует, заявлено вне scope (ORCH-036 п.
|
||||||
|
«inline-кнопка» не реализован); управление остаётся статусом Plane.
|
||||||
|
- **B. Добавить `confirm_deploy` в `_DEFAULT_STATES`** — отклонено: реального UUID
|
||||||
|
«Confirm Deploy» для enduro/fallback нет; пришлось бы подставить фиктивный или
|
||||||
|
дублирующий UUID, что ломает fail-closed (enduro «получил бы» триггер деплоя) и
|
||||||
|
смешивает семантику.
|
||||||
|
- **C. Отдельный публичный entrypoint `stage_engine.initiate_confirm_deploy()`**,
|
||||||
|
минующий `advance_stage` — отклонено: дублирует гарды
|
||||||
|
(`deploy_require_manual_approve`, `self_deploy_applies`, idempotency `initiated`),
|
||||||
|
и всё равно пришлось бы внутри `advance_stage` гасить `Approved`-на-`deploy` в
|
||||||
|
no-op. Параметр-сигнал проще и держит единую точку правды.
|
||||||
|
- **D. Сигнал через sentinel-маркер, записываемый webhook’ом** — отклонено: вызов
|
||||||
|
синхронный в пределах одного `advance_stage`, persistence не нужна; параметр
|
||||||
|
явнее и не плодит файловое состояние.
|
||||||
|
|
||||||
|
## Последствия
|
||||||
|
**Плюсы**
|
||||||
|
- Жест «запустить прод-деплой» отделён от «одобрить артефакт»; случайный approve
|
||||||
|
на доске больше не роняет прод (BG-1, BG-2).
|
||||||
|
- `Approved` на `deploy` детерминированно безопасен: no-op без отката/advance
|
||||||
|
(закрывает R-2).
|
||||||
|
- Fail-closed: нет статуса → нет деплоя, нет исключения (R-1, AC-7).
|
||||||
|
- Минимальный диффузный риск: контракты `STAGE_TRANSITIONS`/`QG_CHECKS`/
|
||||||
|
`check_deploy_status`/Фазы A/C/merge-gate/схема БД не тронуты (AC-9).
|
||||||
|
- Реконсилятор F-1 на `deploy` (finished_agent=None) теперь попадает в no-op-ветку
|
||||||
|
вместо прежнего неявного запуска Фазы B → прод-деплой невозможно инициировать
|
||||||
|
автоматически, только явным человеческим `Confirm Deploy` (усиление safety).
|
||||||
|
|
||||||
|
**Минусы / цена**
|
||||||
|
- Эксплуатационное предусловие: в Plane-проекте ORCH нужно создать статус доски
|
||||||
|
«Confirm Deploy» (точное имя, регистр) и сбросить кэш состояний — см.
|
||||||
|
`07-infra-requirements.md`. До создания статуса прод-деплой через approve не
|
||||||
|
запустится (это и есть желаемое fail-closed-поведение).
|
||||||
|
- Сигнатура `advance_stage` расширена одним kwarg (обратносовместимо).
|
||||||
|
|
||||||
|
**Хэндофф документации (golden source, в том же PR — стадия development).**
|
||||||
|
ADR (этот файл) — артефакт архитектора. Переписать `Approve = Approved` →
|
||||||
|
`Confirm Deploy` в `docs/architecture/README.md` (секция ORCH-036), `CLAUDE.md`
|
||||||
|
(self-hosting/артефакты) и добавить запись в `CHANGELOG.md` обязан developer
|
||||||
|
одновременно с кодом (AC-10), чтобы доки не описывали ещё не существующее
|
||||||
|
поведение. В README на стадии architecture добавлена forward-looking пометка
|
||||||
|
ORCH-059 (design), как принято для незамёрженных доработок.
|
||||||
|
|
||||||
|
## Связанные ADR
|
||||||
|
- `adr-0007-executable-self-deploy.md` (ORCH-036) — задаёт Фазы A/B/C; ORCH-059
|
||||||
|
меняет **только триггер** Фазы B (`Approved` → `Confirm Deploy`) и делает
|
||||||
|
`Approved`-на-`deploy` no-op; Фазы внутренне не меняются.
|
||||||
|
- `adr-0003-staging-gate.md` (ORCH-35) — паттерн условности self-hosting.
|
||||||
|
- `adr-0007-reconciler.md` (ORCH-053) — реконсилятор F-1: поведение на `deploy`
|
||||||
|
становится no-op (см. Последствия).
|
||||||
44
docs/work-items/ORCH-059/07-infra-requirements.md
Normal file
44
docs/work-items/ORCH-059/07-infra-requirements.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# 07 — Требования к инфраструктуре: ORCH-059
|
||||||
|
|
||||||
|
Work Item: **ORCH-059** · Repo: `orchestrator`
|
||||||
|
Связано: `06-adr/ADR-001-confirm-deploy-status.md`, `02-trz.md` §6.
|
||||||
|
|
||||||
|
> Топология контейнеров/портов/деплоя НЕ меняется (см. `docs/operations/INFRA.md`).
|
||||||
|
> Единственное инфра-требование ORCH-059 — конфигурация Plane-доски проекта ORCH.
|
||||||
|
|
||||||
|
## IR-1. Статус доски «Confirm Deploy» в проекте ORCH (предусловие эксплуатации)
|
||||||
|
- В Plane-проекте **ORCH** создать кастомный статус доски с **точным** именем
|
||||||
|
`Confirm Deploy` (case-sensitive, ровно один пробел) — должно посимвольно
|
||||||
|
совпасть с ключом `_PLANE_NAME_TO_KEY["Confirm Deploy"]`. Несовпадение →
|
||||||
|
fail-closed (деплой не запустится), не краш (R-9).
|
||||||
|
- UUID статуса генерирует Plane; код резолвит его через `get_project_states`
|
||||||
|
(`GET /workspaces/<ws>/projects/<orch>/states/`). Хардкодить UUID не нужно.
|
||||||
|
- **Размещение** на доске — рядом с approval-pending/`deploy` (рекомендация
|
||||||
|
эксплуатации, на поведение кода не влияет).
|
||||||
|
- **Только проект ORCH** (self-hosting). Для enduro и прочих проектов статус НЕ
|
||||||
|
создаётся и НЕ требуется — `self_deploy_applies` истинно лишь для `orchestrator`.
|
||||||
|
|
||||||
|
## IR-2. Сброс кэша состояний после создания статуса
|
||||||
|
`get_project_states` кэширует резолв per-project на время жизни процесса
|
||||||
|
(`_STATES_CACHE`). После создания статуса в Plane закэшированный словарь не
|
||||||
|
содержит `confirm_deploy` (R-5). Применить ОДНО из:
|
||||||
|
- вызвать `reload_project_states(<orch_project_id>)` (или полный сброс), либо
|
||||||
|
- штатно перезапустить прод по конвейеру `deploy-staging → deploy` (рестарт
|
||||||
|
процесса очищает кэш).
|
||||||
|
|
||||||
|
> Внеплановый ручной рестарт прод-контейнера для применения этой задачи **не
|
||||||
|
> требуется** и противопоказан (self-hosting групповой риск). Выкат — только через
|
||||||
|
> штатный staging→deploy.
|
||||||
|
|
||||||
|
## IR-3. Контрольная проверка готовности среды
|
||||||
|
После IR-1+IR-2:
|
||||||
|
1. `get_project_states(<orch>)` содержит `confirm_deploy` с непустым UUID,
|
||||||
|
отличным от `approved` (AC-1, TC-02).
|
||||||
|
2. Перевод тестовой задачи стадии `deploy` (sandbox) в `Confirm Deploy` запускает
|
||||||
|
Фазу B; перевод в `Approved` — нет (AC-2/AC-3).
|
||||||
|
|
||||||
|
## Что НЕ меняется
|
||||||
|
- Порты (8500 prod / 8501 staging), контейнеры, compose-профили, env-карта,
|
||||||
|
деплой-хук, схема БД, sentinel-каталоги ORCH-036 — без изменений.
|
||||||
|
- HTTP-эндпоинты (`POST /webhook/plane` тот же канал, событие
|
||||||
|
`work_item.updated`).
|
||||||
25
docs/work-items/ORCH-059/10-tech-risks.md
Normal file
25
docs/work-items/ORCH-059/10-tech-risks.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# 10 — Технические риски: ORCH-059
|
||||||
|
|
||||||
|
Work Item: **ORCH-059** · Repo: `orchestrator` · ведёт: архитектор
|
||||||
|
Связано: `06-adr/ADR-001-confirm-deploy-status.md`.
|
||||||
|
|
||||||
|
| ID | Риск | Вероятн. | Влияние | Митигация | Проверка |
|
||||||
|
|----|------|----------|---------|-----------|----------|
|
||||||
|
| R-1 | Ключ `confirm_deploy` отсутствует в `_DEFAULT_STATES` / у проектов без статуса → `KeyError` в webhook-пути | Сред | Выс (краш обработчика) | Доступ ТОЛЬКО через `.get("confirm_deploy")`; `_DEFAULT_STATES` не содержит ключ намеренно; отсутствие → ветка не активируется (fail-closed) | TC-03, AC-7 |
|
||||||
|
| R-2 | `Approved` на `deploy` после правки вызывает `check_deploy_status` (вердикта нет) → ложный откат БАГ-8 / ложный advance | Выс | Выс (петля dev↔deploy, ложный rollback прода) | Блок Фазы B возвращается рано для `deploy + finished_agent is None` self-hosting в ОБОИХ случаях; `Approved` → `note=approved-on-deploy-noop`, QG не запускается | TC-05, TC-07, TC-11, AC-3 |
|
||||||
|
| R-3 | Самоправка прода требует внепланового рестарта прод-контейнера | Низ | Выс (встаёт конвейер всех проектов) | Изменение — чистая маршрутизация в коде; выкат через штатный `deploy-staging` (8501) → `deploy`; sentinel-состояние ORCH-036 не трогаем | AC-9, RG-01 |
|
||||||
|
| R-4 | `Confirm Deploy` прислан на не-`deploy` стадии (оператор ошибся) → срабатывает как обычный approve и продвигает чужой гейт | Низ | Сред | `handle_confirm_deploy` гардит `stage == "deploy"`; иначе no-op с логом | TC-04 (+ ручная верификация) |
|
||||||
|
| R-5 | Кэш `get_project_states` закэширован до создания статуса «Confirm Deploy» → ключ не виден после конфигурации Plane | Сред | Сред (деплой не запускается) | После создания статуса в Plane — `reload_project_states(orch)` или штатный рестарт по стадии `deploy`; зафиксировано в `07-infra-requirements.md` | ручная верификация |
|
||||||
|
| R-6 | Новый kwarg `confirm_deploy` ломает существующие вызовы `advance_stage` (launcher/reconciler/finalizer) | Низ | Выс | keyword-only с дефолтом `False`; все вызовы передают `finished_agent`; не-`deploy`/finished_agent≠None пути не затронуты | RG-01, AC-9 |
|
||||||
|
| R-7 | Регрессия идемпотентности Фазы B (двойной `Confirm Deploy`) | Низ | Сред | Внутренности `_handle_self_deploy_phase_b` (маркер `initiated`) не меняются; меняется только триггер | TC-08, AC-5 |
|
||||||
|
| R-8 | Реконсилятор F-1 на `deploy` (finished_agent=None) меняет поведение | Низ | Низ (улучшение) | Намеренно: раньше неявно мог войти в Фазу B, теперь → no-op. Прод-деплой инициируется только явным `Confirm Deploy`. Документировано в ADR/README | RG-01 |
|
||||||
|
| R-9 | Несовпадение имени статуса в Plane и `_PLANE_NAME_TO_KEY` (регистр/пробел) → ключ не резолвится | Сред | Сред (деплой не запускается, fail-closed) | Точное имя «Confirm Deploy» (case-sensitive) — требование среды в `07-infra-requirements.md`; маппинг ровно этой строкой | TC-01, TC-02 |
|
||||||
|
|
||||||
|
## Сводный вывод
|
||||||
|
Все риски — низкого/среднего остаточного уровня после митигаций. Доминирующий
|
||||||
|
класс — **fail-closed**: любая неполнота конфигурации (нет статуса, протухший кэш,
|
||||||
|
недоступный API) приводит к «деплой не запускается», а не к «деплой запускается
|
||||||
|
вслепую» или к крашу. Контракты конвейера (`STAGE_TRANSITIONS`, `QG_CHECKS`,
|
||||||
|
`check_deploy_status`, Фазы A/C, merge-gate, схема БД) не затрагиваются, поэтому
|
||||||
|
поверхность регрессии ограничена тремя модулями (`plane_sync.py`,
|
||||||
|
`webhooks/plane.py`, `stage_engine.py`).
|
||||||
59
docs/work-items/ORCH-059/12-review.md
Normal file
59
docs/work-items/ORCH-059/12-review.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
type: review
|
||||||
|
work_item_id: ORCH-059
|
||||||
|
verdict: APPROVED
|
||||||
|
version: 1
|
||||||
|
---
|
||||||
|
|
||||||
|
# Review ORCH-059
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Выделенный Plane-статус «Confirm Deploy» как единственный триггер Фазы B прод-деплоя
|
||||||
|
self-hosting; `Approved` на стадии `deploy` становится детерминированным no-op. Реализация
|
||||||
|
точно соответствует ТЗ (TRZ-1..6), ADR-001 и критериям приёмки (AC-1..10). Четыре точечные
|
||||||
|
правки в трёх модулях (`plane_sync.py`, `webhooks/plane.py`, `stage_engine.py`), без изменения
|
||||||
|
контрактов (`STAGE_TRANSITIONS`, `QG_CHECKS`, `check_deploy_status`, Фазы A/C, merge-gate, схема
|
||||||
|
БД). Документация обновлена в том же PR. `pytest tests/ -q` — 763 passed.
|
||||||
|
|
||||||
|
## Соответствие ТЗ и ADR
|
||||||
|
- **TRZ-1 / AC-1** — `"Confirm Deploy" → "confirm_deploy"` добавлен в `_PLANE_NAME_TO_KEY`;
|
||||||
|
намеренно отсутствует в `_DEFAULT_STATES` → fail-closed. Покрыто `test_tc01/tc02`.
|
||||||
|
- **TRZ-2 / AC-2** — `handle_confirm_deploy` (гард `stage=="deploy"`) →
|
||||||
|
`_try_advance_stage(..., confirm_deploy=True)` → Фаза B. Покрыто `test_tc04/tc07/tc10`.
|
||||||
|
- **TRZ-3 / AC-3** — `Approved` на `deploy`: ранний возврат ДО Quality Gate с
|
||||||
|
`note="approved-on-deploy-noop"`, без `initiate_deploy`, без ложного отката БАГ-8.
|
||||||
|
Покрыто `test_tc05/tc07_approved_without_confirm_is_noop/tc11`.
|
||||||
|
- **TRZ-4 / AC-4** — `handle_verdict(approved=True)` не тронут; approve на `analysis`
|
||||||
|
продвигает конвейер. Покрыто `test_tc06_approved_on_analysis_still_advances`.
|
||||||
|
- **AC-5** — идемпотентность повторного «Confirm Deploy» (`self-deploy-already-initiated`).
|
||||||
|
Покрыто `test_tc08`, `test_tc06_approved_calls_prod_hook_exactly_once`.
|
||||||
|
- **TRZ-5 / AC-6** — CTA Фазы A (Plane-коммент + Telegram) просит «Confirm Deploy» и явно
|
||||||
|
отмечает, что «Approved» прод-деплой не запускает. Покрыто `test_tc09`.
|
||||||
|
- **TRZ-1 / AC-7** — доступ через `.get("confirm_deploy")`, отсутствие статуса → ветка не
|
||||||
|
активируется, без `KeyError`. Покрыто `test_tc03` (API недоступен / статуса нет на доске).
|
||||||
|
- **TRZ-6 / AC-8** — условность через `self_deploy.self_deploy_applies`; не-self репо без
|
||||||
|
изменений. Покрыто `test_tc12`.
|
||||||
|
- **AC-9** — контракты и схема БД не изменены; 763 теста зелёные.
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### P0 — Blocker
|
||||||
|
- нет
|
||||||
|
|
||||||
|
### P1 — Must fix
|
||||||
|
- нет
|
||||||
|
|
||||||
|
### P2 — Should fix
|
||||||
|
- нет
|
||||||
|
|
||||||
|
## Документация
|
||||||
|
Обновлено в том же PR (AC-10 выполнен):
|
||||||
|
- `CLAUDE.md` — раздел self-hosting: прод-деплой только через «Confirm Deploy», `Approved` = no-op.
|
||||||
|
- `docs/architecture/README.md` — секция ORCH-036 уточнена + добавлена подсекция ORCH-059
|
||||||
|
(статус-триггер «Confirm Deploy»), запись в перечне статусов доработок.
|
||||||
|
- `CHANGELOG.md` — запись ORCH-059 в `[Unreleased] / Added`.
|
||||||
|
- ADR `docs/work-items/ORCH-059/06-adr/ADR-001-confirm-deploy-status.md` — заведён, отражает
|
||||||
|
реализацию (4 правки, fail-closed, рассмотренные альтернативы).
|
||||||
|
- `07-infra-requirements.md` — эксплуатационное предусловие (создать статус доски + сброс кэша).
|
||||||
|
|
||||||
|
Документация консистентна с кодом; golden-source инвариант соблюдён.
|
||||||
71
docs/work-items/ORCH-059/13-test-report.md
Normal file
71
docs/work-items/ORCH-059/13-test-report.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
---
|
||||||
|
type: test-report
|
||||||
|
work_item_id: ORCH-059
|
||||||
|
result: PASS
|
||||||
|
---
|
||||||
|
|
||||||
|
# Test Report — ORCH-059
|
||||||
|
|
||||||
|
Выделенный Plane-статус «Confirm Deploy» как единственный триггер Фазы B прод-деплоя
|
||||||
|
self-hosting; `Approved` на стадии `deploy` — детерминированный no-op.
|
||||||
|
|
||||||
|
## Окружение
|
||||||
|
- Python: 3.12.13
|
||||||
|
- pytest: 8.3.3
|
||||||
|
- Prod orchestrator (8500): `/health` → `{"status":"ok"}`
|
||||||
|
- Дата: 2026-06-07
|
||||||
|
|
||||||
|
## Результаты (контракт-тесты `04-test-plan.yaml`)
|
||||||
|
|
||||||
|
| TC ID | Описание | Тест | Результат |
|
||||||
|
|-------|----------|------|-----------|
|
||||||
|
| TC-01 | `_PLANE_NAME_TO_KEY`: `'Confirm Deploy' → 'confirm_deploy'` | test_tc01_confirm_deploy_name_to_key_mapping; test_tc01_confirm_deploy_not_in_default_states | PASS |
|
||||||
|
| TC-02 | `get_project_states` ORCH резолвит непустой UUID под `confirm_deploy`, ≠ `approved` | test_tc02_get_project_states_resolves_confirm_deploy | PASS |
|
||||||
|
| TC-03 | Fail-closed при отсутствии статуса (API недоступен / нет на доске) — без исключения | test_tc03_fail_closed_when_api_unreachable; test_tc03_fail_closed_when_status_not_on_board | PASS |
|
||||||
|
| TC-04 | `handle_issue_updated`: `Confirm Deploy` на `deploy` → путь Фазы B | test_tc04_confirm_deploy_routes_phase_b; test_tc04b_confirm_deploy_off_deploy_stage_is_noop | PASS |
|
||||||
|
| TC-05 | `Approved` на `deploy` НЕ вызывает `initiate_deploy` | test_tc05_approved_on_deploy_does_not_initiate | PASS |
|
||||||
|
| TC-06 | `Approved` на `analysis` по-прежнему продвигает → architecture | test_tc06_approved_on_analysis_still_advances | PASS |
|
||||||
|
| TC-07 | stage_engine: Фаза B только по confirm-deploy; `Approved` → no-op | test_tc07_confirm_deploy_initiates; test_tc07_approved_without_confirm_is_noop | PASS |
|
||||||
|
| TC-08 | Идемпотентность: повтор `Confirm Deploy` при маркере `initiated` → no-op | test_tc08_idempotent_repeat_confirm_deploy | PASS |
|
||||||
|
| TC-09 | CTA Фазы A содержит «Confirm Deploy», не предлагает «Approved» как триггер | test_tc09_phase_a_cta_requests_confirm_deploy | PASS |
|
||||||
|
| TC-10 | E2E: `Confirm Deploy` → `initiate_deploy` вызван, finalizer поставлен, маркер записан | test_tc10_confirm_deploy_e2e_initiates | PASS |
|
||||||
|
| TC-11 | E2E: `Approved` → деплой НЕ инициирован, задача остаётся на `deploy` | test_tc11_approved_e2e_noop | PASS |
|
||||||
|
| TC-12 | Условность: не-self репо verdict-статусы не меняют поведение деплоя | test_tc12_non_self_repo_unaffected | PASS |
|
||||||
|
| RG-01 | Полный регресс зелёный; STAGE_TRANSITIONS / QG_CHECKS без изменений | tests/ (763 passed) | PASS |
|
||||||
|
|
||||||
|
Все 16 целевых тестов ORCH-059 (TC-01..TC-12) — PASS.
|
||||||
|
|
||||||
|
## Сопоставление с критериями приёмки (`03-acceptance-criteria.md`)
|
||||||
|
|
||||||
|
| AC | Покрытие | Результат |
|
||||||
|
|----|----------|-----------|
|
||||||
|
| AC-1 Статус резолвится | TC-01, TC-02 | PASS |
|
||||||
|
| AC-2 Confirm Deploy на `deploy` → Фаза B | TC-04, TC-07, TC-10 | PASS |
|
||||||
|
| AC-3 Approved на `deploy` НЕ деплоит | TC-05, TC-07, TC-11 | PASS |
|
||||||
|
| AC-4 Approved на `analysis` без регрессии | TC-06 | PASS |
|
||||||
|
| AC-5 Идемпотентность Фазы B | TC-08 | PASS |
|
||||||
|
| AC-6 CTA Фазы A просит Confirm Deploy | TC-09 | PASS |
|
||||||
|
| AC-7 Fail-closed без статуса | TC-03 | PASS |
|
||||||
|
| AC-8 Условность для не-self | TC-12 | PASS |
|
||||||
|
| AC-9 Инварианты, pytest зелёный | RG-01 (763 passed) | PASS |
|
||||||
|
| AC-10 Документация обновлена | проверено reviewer (12-review.md, APPROVED) | PASS |
|
||||||
|
|
||||||
|
## Smoke test API (prod 8500)
|
||||||
|
- `GET /health` → `{"status":"ok","service":"orchestrator"}`
|
||||||
|
- `GET /status` → 200, активные задачи отдаются (вкл. ORCH-059 на `testing`)
|
||||||
|
- `GET /queue` → 200, counts + resilience + reconcile + reaper + post_deploy
|
||||||
|
|
||||||
|
## Вывод pytest
|
||||||
|
```
|
||||||
|
======================= 763 passed, 1 warning in 15.45s ========================
|
||||||
|
```
|
||||||
|
Целевой набор ORCH-059:
|
||||||
|
```
|
||||||
|
======================== 16 passed, 1 warning in 0.75s =========================
|
||||||
|
```
|
||||||
|
(1 warning — PydanticDeprecatedSince20 в `src/config.py`, не относится к ORCH-059.)
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
**PASS** — все контракт-тесты (TC-01..TC-12) и регресс (763 passed) зелёные,
|
||||||
|
критерии приёмки AC-1..AC-10 покрыты, smoke API OK. Задача готова к стадии
|
||||||
|
deploy-staging.
|
||||||
12
docs/work-items/ORCH-059/14-deploy-log.md
Normal file
12
docs/work-items/ORCH-059/14-deploy-log.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
deploy_status: SUCCESS
|
||||||
|
work_item: ORCH-059
|
||||||
|
hook_exit_code: 0
|
||||||
|
deployed_by: deploy-finalizer
|
||||||
|
---
|
||||||
|
|
||||||
|
# Deploy log — ORCH-036 executable self-deploy
|
||||||
|
|
||||||
|
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
|
||||||
|
|
||||||
|
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.
|
||||||
29
docs/work-items/ORCH-059/15-staging-log.md
Normal file
29
docs/work-items/ORCH-059/15-staging-log.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
staging_status: SUCCESS
|
||||||
|
timestamp: 2026-06-07T19:19:25Z
|
||||||
|
base_url: http://localhost:8501
|
||||||
|
---
|
||||||
|
|
||||||
|
# Staging Gate Log
|
||||||
|
|
||||||
|
Staging test suite completed. Verdict: **SUCCESS** (exit 0).
|
||||||
|
|
||||||
|
Canonical run inside the `orchestrator-staging` container (ORCH-048, ADR-001):
|
||||||
|
`python3 /repos/orchestrator/scripts/staging_check.py --base-url http://localhost:8501 --mode stub`
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
- RESULT: 8/10 checks PASS
|
||||||
|
- REAL failed: none
|
||||||
|
- SANDBOX_INFRA failed: C9a (branch in orchestrator-sandbox), C9b (analyst job enqueued)
|
||||||
|
|
||||||
|
All REAL pipeline checks (Block A SMOKE, Block B ACCESS incl. B6 registry isolation,
|
||||||
|
C7/C8) are green. The two failing checks are sandbox-infra-only (SANDBOX bot accounts
|
||||||
|
not members of the SANDBOX Plane project) and were waived per ORCH-061. Exit code 0.
|
||||||
|
|
||||||
|
```
|
||||||
|
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
|
||||||
|
```
|
||||||
|
|
||||||
|
tolerance: staging_infra_tolerance_enabled=True
|
||||||
14
docs/work-items/ORCH-059/16-post-deploy-log.md
Normal file
14
docs/work-items/ORCH-059/16-post-deploy-log.md
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
---
|
||||||
|
post_deploy_status: HEALTHY
|
||||||
|
action_taken: NONE
|
||||||
|
work_item: ORCH-059
|
||||||
|
window_s: 900
|
||||||
|
checks_total: 30
|
||||||
|
checks_failed: 0
|
||||||
|
---
|
||||||
|
|
||||||
|
# Post-deploy log — ORCH-021 post-deploy monitor
|
||||||
|
|
||||||
|
Наблюдение прода завершено: `post_deploy_status: HEALTHY`, `action_taken: NONE`.
|
||||||
|
|
||||||
|
Окно наблюдения: 900s; опросов всего: 30, из них с провалом: 0.
|
||||||
7
docs/work-items/ORCH-065/00-business-request.md
Normal file
7
docs/work-items/ORCH-065/00-business-request.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Business Request: BUG: zombie jobs + merge-lease залип (процесс умер, статус running)
|
||||||
|
|
||||||
|
Work Item ID: ORCH-065
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
TBD
|
||||||
103
docs/work-items/ORCH-065/01-brd.md
Normal file
103
docs/work-items/ORCH-065/01-brd.md
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
# BRD — ORCH-065: zombie jobs + залипший merge-lease
|
||||||
|
|
||||||
|
Work Item ID: ORCH-065
|
||||||
|
Тип: BUG (P0)
|
||||||
|
Репозиторий: orchestrator (self-hosting)
|
||||||
|
Эпик: блокер ORCH-54 (полностью автономный self-deploy)
|
||||||
|
|
||||||
|
## 1. Контекст и проблема
|
||||||
|
|
||||||
|
Оркестратор — единый инстанс с **общей БД и общей очередью** (`jobs`,
|
||||||
|
`max_concurrency=1` для self-hosting), обслуживающий несколько проектов. Финальная
|
||||||
|
автономность self-deploy упирается в два связанных класса отказов, оба сводящиеся
|
||||||
|
к «процесс умер/завершился, а состояние осталось захваченным навсегда»:
|
||||||
|
|
||||||
|
### Проблема A — zombie jobs (строка `jobs` навсегда `running`)
|
||||||
|
Агент (deployer/developer/reviewer) завершается **или умирает** (краш, OOM,
|
||||||
|
рестарт контейнера в ходе self-deploy, гибель monitor-потока), но строка в таблице
|
||||||
|
`jobs` остаётся в статусе `running`. Финализация статуса job выполняется **только**
|
||||||
|
в `_monitor_agent` → `_finalize_job` внутри того же процесса; если этот поток/процесс
|
||||||
|
не доживает до финализации — job «зомбирован».
|
||||||
|
|
||||||
|
- Единственная имеющаяся защита — `requeue_running_jobs()` в `main.lifespan`,
|
||||||
|
срабатывающая **исключительно на старте процесса**. Зомби, возникший **без**
|
||||||
|
рестарта (умер дочерний процесс/monitor-поток, а сервис жив), не реанимируется
|
||||||
|
никогда.
|
||||||
|
- При `max_concurrency=1` одна зомби-строка `running` блокирует claim всех
|
||||||
|
последующих job (`count_running_jobs() >= max_concurrency` → claim не происходит)
|
||||||
|
→ **встаёт конвейер всех проектов**.
|
||||||
|
|
||||||
|
### Проблема B — залипший merge-lease
|
||||||
|
Merge-gate (ORCH-043) берёт файловый lease `<repos_dir>/.merge-lease-<repo>.json`
|
||||||
|
ПЕРЕД rebase+re-test и держит его до фактического merge PR в `main`. Если процесс
|
||||||
|
умирает на финальном merge **с зажатым lease**:
|
||||||
|
|
||||||
|
- Реклейм lease реализован **лениво и только по возрасту** (`age >=
|
||||||
|
merge_lock_timeout_s`) и **только в момент `acquire_merge_lease` другой задачей**.
|
||||||
|
Проактивного освобождения (на старте / периодически) нет; **liveness держателя по
|
||||||
|
pid не проверяется** (хотя `pid` в lease пишется).
|
||||||
|
- Пострадавшая задача сама re-drive не получает: merge не финализируется → задача
|
||||||
|
висит, lease мешает чужим merge до истечения TTL.
|
||||||
|
|
||||||
|
### Проблема C — неидемпотентная финализация merge
|
||||||
|
Если rebase+re-test прошли зелёно (ветка догнана и проверена), но процесс умер до
|
||||||
|
завершения слияния PR — повторного «докатывания» merge нет. Задача застревает в
|
||||||
|
полу-выполненном состоянии, хотя вся дорогая работа (rebase+re-test) уже сделана.
|
||||||
|
|
||||||
|
## 2. Бизнес-последствия
|
||||||
|
- **Это ПОСЛЕДНЯЯ ручная точка автономного деплоя.** Без фикса ни одна self-hosting
|
||||||
|
задача не доезжает до прода без оператора (cancel zombie + ручной merge PR +
|
||||||
|
ручной `--deploy`).
|
||||||
|
- Прямой блокер эпика ORCH-54.
|
||||||
|
- Доказанные инциденты (07.06): ORCH-58/60/61/21 — каждый раз после успешного
|
||||||
|
deployer-прохода job оставался `running`; jobs **236/239/242/254** — зомби,
|
||||||
|
прод-merge/deploy доводились вручную.
|
||||||
|
- Групповой риск: зомби в общей очереди при concurrency=1 останавливает конвейер
|
||||||
|
enduro-trails и всех прочих проектов.
|
||||||
|
|
||||||
|
## 3. Цель
|
||||||
|
Сделать так, чтобы **смерть процесса/потока на любой стадии (включая self-restart
|
||||||
|
во время deploy) НЕ оставляла навсегда захваченных ресурсов** — ни строки `jobs` в
|
||||||
|
`running`, ни merge-lease. Конвейер должен самовосстанавливаться без оператора, при
|
||||||
|
этом сохраняя все инварианты self-hosting (не ронять прод-контейнер, не трогать
|
||||||
|
`main`, fail-closed на реальных ошибках).
|
||||||
|
|
||||||
|
## 4. Объём (Scope)
|
||||||
|
|
||||||
|
### В объёме
|
||||||
|
1. **Job-reaper** — фоновый watchdog (паттерн `reconciler`/`queue_worker`),
|
||||||
|
детектирующий «мёртвый» `running`-job и приводящий его строку в корректный
|
||||||
|
терминальный/повторный статус (`done`/`failed`/`queued`) детерминированно,
|
||||||
|
без LLM. Restart-safe и работающий **без** рестарта процесса.
|
||||||
|
2. **Проактивный реклейм stale merge-lease** — освобождение lease, чей держатель
|
||||||
|
мёртв (pid не жив) ИЛИ возраст превысил TTL — на старте и периодически (reaper/
|
||||||
|
reconciler), а не только лениво при чужом `acquire`.
|
||||||
|
3. **Идемпотентная финализация merge** — если rebase+re-test зелёные, но merge не
|
||||||
|
состоялся, операция повторяется/докатывается без потери уже сделанной работы.
|
||||||
|
|
||||||
|
### Вне объёма
|
||||||
|
- Переход на внешний брокер очередей / смену схемы блокировок merge на БД-lock.
|
||||||
|
- Полный авто-approve деплоя (ORCH-54) — отдельная задача; здесь только снятие
|
||||||
|
технического блокера.
|
||||||
|
- Изменение конвейера стадий (`STAGE_TRANSITIONS`) и реестра гейтов как контрактов.
|
||||||
|
|
||||||
|
## 5. Заинтересованные стороны
|
||||||
|
- Owner оркестратора (self-hosting автономность).
|
||||||
|
- Все проекты на общем инстансе (enduro-trails и пр.) — страдают от блокировки
|
||||||
|
общей очереди.
|
||||||
|
|
||||||
|
## 6. Допущения и ограничения
|
||||||
|
- `max_concurrency=1` для self-hosting сохраняется.
|
||||||
|
- Self-hosting safety (CLAUDE.md): нельзя ронять/рестартить прод-контейнер в рамках
|
||||||
|
задачи; нельзя пушить/форс-пушить `main`; реклейм lease не должен прерывать
|
||||||
|
легитимно работающий merge.
|
||||||
|
- Никаких ложных реанимаций: живой, но долгий job не должен помечаться зомби
|
||||||
|
(нужен порог/грейс «N тиков» + проверка реальной смерти, а не просто долготы).
|
||||||
|
- Контракт **never-raise** для всей новой фоновой логики (как у reconciler/merge_gate).
|
||||||
|
- Kill-switch на каждый новый механизм (как `reconcile_enabled` / `merge_gate_enabled`).
|
||||||
|
|
||||||
|
## 7. Критерий успеха (бизнес-уровень)
|
||||||
|
После фикса воспроизводимый сценарий «успешный deployer-проход + смерть процесса/
|
||||||
|
self-restart» НЕ оставляет зомби-job и зажатого lease: задача либо корректно
|
||||||
|
доезжает до `done` сама, либо откатывается по штатному контракту — **без участия
|
||||||
|
оператора**. Регресс-тест на jobs-зомби и stale-lease зелёный.
|
||||||
170
docs/work-items/ORCH-065/02-trz.md
Normal file
170
docs/work-items/ORCH-065/02-trz.md
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
# ТЗ — 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): живой долгий агент не реапится.
|
||||||
122
docs/work-items/ORCH-065/03-acceptance-criteria.md
Normal file
122
docs/work-items/ORCH-065/03-acceptance-criteria.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# Критерии приёмки — ORCH-065
|
||||||
|
|
||||||
|
Work Item ID: ORCH-065
|
||||||
|
Формат: каждый критерий имеет явное условие PASS/FAIL. Все критерии должны быть
|
||||||
|
PASS для прохождения review/testing.
|
||||||
|
|
||||||
|
## A. Job-reaper (Проблема A)
|
||||||
|
|
||||||
|
### AC-1 — реап мёртвого running-job без рестарта
|
||||||
|
- PASS: при наличии строки `jobs` в статусе `running`, чей процесс/исполнитель
|
||||||
|
достоверно мёртв (pid не существует ИЛИ `agent_runs.exit_code` записан, а job всё
|
||||||
|
ещё `running`) и условие устойчивости (FR-1.3) выполнено, фоновый reaper переводит
|
||||||
|
строку в корректный статус (`done`/`queued`/`failed`) **без перезапуска процесса**.
|
||||||
|
- FAIL: строка остаётся `running` после `reaper_dead_ticks` тиков / превышения
|
||||||
|
`reaper_max_running_s`.
|
||||||
|
|
||||||
|
### AC-2 — разблокировка очереди при concurrency=1
|
||||||
|
- PASS: после реапа зомби-строки `count_running_jobs()` снижается, и следующий
|
||||||
|
queued-job успешно claim'ится воркером.
|
||||||
|
- FAIL: очередь остаётся заблокированной зомби-строкой.
|
||||||
|
|
||||||
|
### AC-3 — анти-ложноположительность (живой долгий агент не реапится)
|
||||||
|
- PASS: `running`-job с ЖИВЫМ процессом в пределах его `agent_timeout` НЕ помечается
|
||||||
|
зомби (ни по одному тику, ни в пределах `reaper_max_running_s`, если потолок
|
||||||
|
больше таймаута).
|
||||||
|
- FAIL: живой агент помечен `failed`/`queued` reaper'ом.
|
||||||
|
|
||||||
|
### AC-4 — корректный исход по результату
|
||||||
|
- PASS: при `agent_runs.exit_code == 0` reaper доводит до успешного завершения без
|
||||||
|
дублирования уже выполненного stage-advance (идемпотентно); при неуспехе и
|
||||||
|
`attempts < max_attempts` → `queued`; при исчерпании → `failed` + Telegram.
|
||||||
|
- FAIL: успешный исход помечен `failed`; либо дублируется stage-переход; либо
|
||||||
|
исчерпанный бюджет молча зацикливается на `queued`.
|
||||||
|
|
||||||
|
### AC-5 — restart-safe совместимость
|
||||||
|
- PASS: одновременная работа стартового `requeue_running_jobs()` и периодического
|
||||||
|
reaper не приводит к двойной обработке одной строки (атомарный UPDATE с guard
|
||||||
|
`status='running'`).
|
||||||
|
- FAIL: одна строка обработана дважды / гонка приводит к рассинхрону статуса.
|
||||||
|
|
||||||
|
## B. Stale/dead merge-lease reclaim (Проблема B)
|
||||||
|
|
||||||
|
### AC-6 — реклейм lease мёртвого держателя
|
||||||
|
- PASS: lease `.merge-lease-<repo>.json`, чей `pid` не существует, проактивно
|
||||||
|
освобождается на старте И периодическим потоком (не дожидаясь TTL и не дожидаясь
|
||||||
|
чужого `acquire`).
|
||||||
|
- FAIL: lease мёртвого держателя остаётся до истечения `merge_lock_timeout_s` или
|
||||||
|
до следующего чужого `acquire`.
|
||||||
|
|
||||||
|
### AC-7 — реклейм по TTL сохранён
|
||||||
|
- PASS: lease старше `merge_lock_timeout_s` освобождается (существующий контракт не
|
||||||
|
сломан), с `logger.warning`.
|
||||||
|
- FAIL: просроченный lease не освобождается.
|
||||||
|
|
||||||
|
### AC-8 — не трогать живой lease
|
||||||
|
- PASS: lease с ЖИВЫМ держателем (pid жив) и возрастом `< merge_lock_timeout_s` НЕ
|
||||||
|
освобождается (защита легитимного merge).
|
||||||
|
- FAIL: освобождён lease живого держателя → возможен параллельный конфликтный merge.
|
||||||
|
|
||||||
|
### AC-9 — условность и never-raise
|
||||||
|
- PASS: реклейм реален только для `merge_gate_repos`/self-hosting; для прочих репо
|
||||||
|
— no-op; любая ошибка реклейма логируется и не валит поток (never-raise).
|
||||||
|
- FAIL: реклейм выполняется для не-self-hosting репо; либо ошибка пробрасывается
|
||||||
|
наружу/роняет поток.
|
||||||
|
|
||||||
|
## C. Идемпотентная финализация merge (Проблема C)
|
||||||
|
|
||||||
|
### AC-10 — докатывание незавершённого merge
|
||||||
|
- PASS: сценарий «rebase+re-test зелёные, merge не состоялся, процесс умер»
|
||||||
|
восстанавливается автоматически (job → `queued` reaper'ом / reconciler доигрывает),
|
||||||
|
и merge доводится без повторного ненужного прогона дорогих шагов.
|
||||||
|
- FAIL: задача остаётся в полу-выполненном состоянии, требует ручного merge.
|
||||||
|
|
||||||
|
### AC-11 — идемпотентность при уже слитом PR
|
||||||
|
- PASS: повторный вызов финализации при уже слитом PR — no-op (определяется по
|
||||||
|
состоянию PR/`main`), без ошибки и без второго merge.
|
||||||
|
- FAIL: второй merge / ошибка при уже слитом PR.
|
||||||
|
|
||||||
|
## D. Инварианты и безопасность self-hosting
|
||||||
|
|
||||||
|
### AC-12 — прод-контейнер не трогается
|
||||||
|
- PASS: ни reaper, ни lease-reclaim не рестартят/не роняют прод-контейнер и не
|
||||||
|
инициируют git-push в `main`.
|
||||||
|
- FAIL: любая из новых веток кода рестартит self / пушит main.
|
||||||
|
|
||||||
|
### AC-13 — контракты неизменны
|
||||||
|
- PASS: `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, сигнатуры/поведение `check_*`,
|
||||||
|
БАГ-8 откат, exit-коды deploy-хука — без изменений; новых QG checks/стадий нет.
|
||||||
|
- FAIL: затронут любой из перечисленных контрактов.
|
||||||
|
|
||||||
|
### AC-14 — kill-switches
|
||||||
|
- PASS: `reaper_enabled=false` → reaper не работает (строго прежнее поведение);
|
||||||
|
`lease_reclaim_enabled=false` → проактивный реклейм отключён (остаётся лишь
|
||||||
|
прежний ленивый TTL-реклейм в `acquire`).
|
||||||
|
- FAIL: флаг `false` не отключает соответствующий механизм.
|
||||||
|
|
||||||
|
## E. Наблюдаемость
|
||||||
|
|
||||||
|
### AC-15 — блок reaper в /queue
|
||||||
|
- PASS: `GET /queue` содержит блок `reaper` (enabled, interval, last_run_ts,
|
||||||
|
reaped_total, last_reaped, lease_reclaimed_total).
|
||||||
|
- FAIL: блок отсутствует/не обновляется.
|
||||||
|
|
||||||
|
### AC-16 — логи и алерты
|
||||||
|
- PASS: каждый reap и lease-reclaim → `logger.warning` с идентификаторами;
|
||||||
|
reap→`failed` и lease-reclaim → Telegram.
|
||||||
|
- FAIL: реап/реклейм происходят молча.
|
||||||
|
|
||||||
|
## F. Документация (gate reviewer)
|
||||||
|
|
||||||
|
### AC-17 — golden-source обновлён в этом же PR
|
||||||
|
- PASS: обновлены `docs/architecture/README.md` (раздел про reaper + lease-reclaim),
|
||||||
|
`CHANGELOG.md`, `.env.example` (новые `ORCH_*` флаги); заведён
|
||||||
|
`06-adr/ADR-001-*.md`.
|
||||||
|
- FAIL: код изменён, документация — нет (reviewer → REQUEST_CHANGES).
|
||||||
|
|
||||||
|
## G. Тесты
|
||||||
|
|
||||||
|
### AC-18 — регресс-тесты зелёные
|
||||||
|
- PASS: новые unit/integration тесты (см. 04-test-plan.yaml) проходят; существующий
|
||||||
|
`pytest tests/ -q` зелёный (нет регресса merge_gate / queue / reconciler).
|
||||||
|
- FAIL: любой тест из плана красный или сломан существующий тест.
|
||||||
196
docs/work-items/ORCH-065/04-test-plan.yaml
Normal file
196
docs/work-items/ORCH-065/04-test-plan.yaml
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
work_item: ORCH-065
|
||||||
|
description: >
|
||||||
|
Тест-план для фикса zombie jobs (job-reaper), залипшего merge-lease
|
||||||
|
(проактивный реклейм dead/stale lease) и идемпотентной финализации merge.
|
||||||
|
Все новые фоновые механизмы — never-raise, restart-safe, kill-switch.
|
||||||
|
Модуль reaper и точные имена функций уточнит архитектор; в module указаны
|
||||||
|
кандидатные пути (tests/test_job_reaper.py / tests/test_merge_lease_reclaim.py).
|
||||||
|
|
||||||
|
tests:
|
||||||
|
# ---- A. Job-reaper ----
|
||||||
|
- id: TC-01
|
||||||
|
type: unit
|
||||||
|
description: >
|
||||||
|
Reaper переводит running-job с мёртвым исполнителем в корректный статус
|
||||||
|
без рестарта процесса (pid не существует / exit_code записан, job всё ещё
|
||||||
|
running). Покрывает AC-1.
|
||||||
|
module: tests/test_job_reaper.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
- id: TC-02
|
||||||
|
type: unit
|
||||||
|
description: >
|
||||||
|
Анти-ложноположительность: running-job с ЖИВЫМ процессом в пределах
|
||||||
|
agent_timeout НЕ реапится (ни по одному тику, ни в пределах reaper_max_running_s).
|
||||||
|
Покрывает AC-3.
|
||||||
|
module: tests/test_job_reaper.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
- id: TC-03
|
||||||
|
type: unit
|
||||||
|
description: >
|
||||||
|
Устойчивость: job помечается зомби только после reaper_dead_ticks
|
||||||
|
последовательных тиков смерти pid (>=2), а не на первом тике. Покрывает FR-1.3.
|
||||||
|
module: tests/test_job_reaper.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
- id: TC-04
|
||||||
|
type: unit
|
||||||
|
description: >
|
||||||
|
Backstop по потолку: job, висящий running дольше reaper_max_running_s,
|
||||||
|
реапится даже если liveness определить нельзя. Покрывает FR-1.1/AC-1.
|
||||||
|
module: tests/test_job_reaper.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
- id: TC-05
|
||||||
|
type: unit
|
||||||
|
description: >
|
||||||
|
Корректный исход: exit_code==0 -> успешное завершение без дублирования
|
||||||
|
stage-advance; неуспех при attempts<max -> queued; исчерпан бюджет -> failed
|
||||||
|
+ Telegram. Покрывает AC-4.
|
||||||
|
module: tests/test_job_reaper.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
- id: TC-06
|
||||||
|
type: unit
|
||||||
|
description: >
|
||||||
|
Атомарность reap-UPDATE с guard status='running': параллельная обработка
|
||||||
|
одной строки (стартовый requeue_running_jobs + reaper) не приводит к двойному
|
||||||
|
reap. Покрывает AC-5.
|
||||||
|
module: tests/test_job_reaper.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
- id: TC-07
|
||||||
|
type: unit
|
||||||
|
description: >
|
||||||
|
Kill-switch reaper_enabled=false -> reaper не трогает ни одной строки
|
||||||
|
(строго прежнее поведение). Покрывает AC-14.
|
||||||
|
module: tests/test_job_reaper.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
- id: TC-08
|
||||||
|
type: unit
|
||||||
|
description: >
|
||||||
|
never-raise: ошибка БД/ОС внутри одного тика reaper не пробрасывается и не
|
||||||
|
останавливает поток (изоляция per-job, образец reconciler). Покрывает AC-9.
|
||||||
|
module: tests/test_job_reaper.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
- id: TC-09
|
||||||
|
type: integration
|
||||||
|
description: >
|
||||||
|
Очередь разблокируется: после reap зомби-строки при max_concurrency=1
|
||||||
|
следующий queued-job успешно claim'ится (claim_next_job + count_running_jobs).
|
||||||
|
Покрывает AC-2.
|
||||||
|
module: tests/test_queue.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
# ---- B. Stale/dead merge-lease reclaim ----
|
||||||
|
- id: TC-10
|
||||||
|
type: unit
|
||||||
|
description: >
|
||||||
|
Реклейм lease с мёртвым pid (os.kill(pid,0) -> ProcessLookupError)
|
||||||
|
проактивно, не дожидаясь TTL и чужого acquire. Покрывает AC-6.
|
||||||
|
module: tests/test_merge_lease_reclaim.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
- id: TC-11
|
||||||
|
type: unit
|
||||||
|
description: >
|
||||||
|
Реклейм по TTL (age >= merge_lock_timeout_s) сохранён, с logger.warning.
|
||||||
|
Покрывает AC-7. (расширяет существующий stale-lease сценарий merge_gate.)
|
||||||
|
module: tests/test_merge_lease_reclaim.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
- id: TC-12
|
||||||
|
type: unit
|
||||||
|
description: >
|
||||||
|
Живой lease (pid жив, age < TTL) НЕ освобождается — защита легитимного merge.
|
||||||
|
Покрывает AC-8.
|
||||||
|
module: tests/test_merge_lease_reclaim.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
- id: TC-13
|
||||||
|
type: unit
|
||||||
|
description: >
|
||||||
|
Условность: реклейм реален только для merge_gate_repos/self-hosting; для
|
||||||
|
прочих репо no-op. Покрывает AC-9.
|
||||||
|
module: tests/test_merge_lease_reclaim.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
- id: TC-14
|
||||||
|
type: unit
|
||||||
|
description: >
|
||||||
|
never-raise: ошибка чтения/удаления lease-файла не валит реклейм-поток.
|
||||||
|
Покрывает AC-9.
|
||||||
|
module: tests/test_merge_lease_reclaim.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
- id: TC-15
|
||||||
|
type: unit
|
||||||
|
description: >
|
||||||
|
Kill-switch lease_reclaim_enabled=false -> проактивный реклейм отключён,
|
||||||
|
остаётся лишь прежний ленивый TTL-реклейм в acquire_merge_lease.
|
||||||
|
Покрывает AC-14.
|
||||||
|
module: tests/test_merge_lease_reclaim.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
# ---- C. Идемпотентная финализация merge ----
|
||||||
|
- id: TC-16
|
||||||
|
type: unit
|
||||||
|
description: >
|
||||||
|
Идемпотентность финализации: повторный вызов при уже слитом PR / уже
|
||||||
|
актуальном main (branch_is_behind_main == False) — no-op, без ошибки и без
|
||||||
|
второго merge. Покрывает AC-11.
|
||||||
|
module: tests/test_merge_gate.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
- id: TC-17
|
||||||
|
type: integration
|
||||||
|
description: >
|
||||||
|
Восстановление: сценарий "rebase+re-test зелёные, merge не состоялся,
|
||||||
|
процесс умер" -> job доводится до queued reaper'ом и merge докатывается
|
||||||
|
штатным путём без повторного дорогого re-test, когда безопасно. Покрывает AC-10.
|
||||||
|
module: tests/test_merge_gate_race.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
# ---- D/E. Инварианты и наблюдаемость ----
|
||||||
|
- id: TC-18
|
||||||
|
type: integration
|
||||||
|
description: >
|
||||||
|
GET /queue содержит блок reaper (enabled, interval, last_run_ts,
|
||||||
|
reaped_total, last_reaped, lease_reclaimed_total). Покрывает AC-15.
|
||||||
|
module: tests/test_queue.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
- id: TC-19
|
||||||
|
type: unit
|
||||||
|
description: >
|
||||||
|
Контракты неизменны: STAGE_TRANSITIONS и реестр QG_CHECKS не получили новых
|
||||||
|
стадий/чеков; check_branch_mergeable сигнатурно не изменён. Покрывает AC-13.
|
||||||
|
module: tests/test_config.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
- id: TC-20
|
||||||
|
type: unit
|
||||||
|
description: >
|
||||||
|
Новые настройки reaper_*/lease_reclaim_* присутствуют в config с дефолтами и
|
||||||
|
читаются из ORCH_* env. Покрывает §5 ТЗ / AC-14.
|
||||||
|
module: tests/test_config.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
- id: TC-21
|
||||||
|
type: unit
|
||||||
|
description: >
|
||||||
|
Стартовый реклейм stale/dead lease вызывается в lifespan рядом с
|
||||||
|
requeue_running_jobs (smoke на регистрацию старт/стоп reaper-потока).
|
||||||
|
Покрывает FR-2.1 / AC-6.
|
||||||
|
module: tests/test_job_reaper.py
|
||||||
|
expected: PASS
|
||||||
|
|
||||||
|
regression:
|
||||||
|
- command: pytest tests/ -q
|
||||||
|
expected: PASS
|
||||||
|
note: >
|
||||||
|
Полный прогон не должен ломать существующие тесты merge_gate / queue /
|
||||||
|
reconciler / deploy.
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
# ADR-001 (ORCH-065): Job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge
|
||||||
|
|
||||||
|
## Статус
|
||||||
|
Accepted — 2026-06-07
|
||||||
|
|
||||||
|
Связь со сквозным ADR: [docs/architecture/adr/adr-0011-job-reaper-lease-reclaim.md](../../../architecture/adr/adr-0011-job-reaper-lease-reclaim.md).
|
||||||
|
|
||||||
|
## Контекст
|
||||||
|
|
||||||
|
Оркестратор — единый инстанс с **общей БД и общей очередью** (`jobs`,
|
||||||
|
`max_concurrency=1` для self-hosting). BRD/ТЗ фиксируют три связанных класса
|
||||||
|
отказов «процесс/поток умер, а состояние осталось захваченным навсегда»:
|
||||||
|
|
||||||
|
- **A — zombie jobs.** Статус job (`done`/`queued`/`failed`) выставляется ТОЛЬКО
|
||||||
|
в `launcher._monitor_agent` → `_finalize_job` внутри того же процесса. Смерть
|
||||||
|
monitor-потока/процесса между `proc.wait()` и `_finalize_job` (краш, OOM,
|
||||||
|
self-restart во время deploy) оставляет строку `jobs` навсегда `running`.
|
||||||
|
Единственная защита — `requeue_running_jobs()`, срабатывает ТОЛЬКО на старте
|
||||||
|
процесса. Зомби без рестарта не реанимируется никогда. При `max_concurrency=1`
|
||||||
|
одна зомби-строка блокирует claim всех job (`count_running_jobs() >=
|
||||||
|
max_concurrency`) → встаёт конвейер ВСЕХ проектов. Доказано: jobs 236/239/242/254
|
||||||
|
(07.06).
|
||||||
|
- **B — залипший merge-lease.** Файловый lease `.merge-lease-<repo>.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`.** Окно
|
||||||
|
**неоднозначно**: это И «monitor умер между записью exit_code и
|
||||||
|
`_finalize_job`», И «живой monitor ещё финализирует» — `_monitor_agent`
|
||||||
|
пишет `exit_code` ПЕРВЫМ, затем git commit/push (+PR), БАГ-8-проверку и
|
||||||
|
сетевые usage-комментарии в Plane (секунды-десятки секунд), и лишь потом
|
||||||
|
`_try_advance_stage` → `_finalize_job`. pid агента к этому моменту уже мёртв в
|
||||||
|
ОБОИХ случаях, поэтому по pid их не различить. **Анти-ложноположительность
|
||||||
|
Tier-2 (FR-1.3, AC-3): finalization-grace.** Job реапится по Tier-2 только
|
||||||
|
когда `exit_code` записан не меньше `reaper_finalize_grace_s` назад (потолок
|
||||||
|
заведомо > максимального окна финализации). В пределах grace строка не
|
||||||
|
трогается (живой финализирующий monitor НИКОГДА не реапится; нет дубль-advance
|
||||||
|
/ дубль-enqueue). После grace monitor заведомо мёртв → исход **известен**.
|
||||||
|
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`, grace прошёл):**
|
||||||
|
- `exit==0`: **claim-BEFORE-act, gate-driven idempotent advance.** Порядок
|
||||||
|
критичен (см. «Атомарный reap-claim» выше): атомарный claim ОБЯЗАН
|
||||||
|
предшествовать любому `advance_stage`/`enqueue_job`. Поскольку claim
|
||||||
|
переводит строку ИЗ `running`, прогнать advance ДО claim, чтобы узнать цвет
|
||||||
|
гейта, нельзя — поэтому канонический QG оценивается **read-only, без
|
||||||
|
побочных эффектов** (тот же `_run_qg`, что у reconciler) ПЕРЕД claim:
|
||||||
|
- стадия уже продвинута мимо этого агента → атомарный `done` без advance
|
||||||
|
(идемпотентная уборка);
|
||||||
|
- гейт зелёный → атомарный claim `done` ПЕРВЫМ, и только победитель claim
|
||||||
|
выполняет `_try_advance_stage` (advance + `enqueue_job` следующей стадии)
|
||||||
|
РОВНО один раз; проигравший claim (поздний monitor / стартовый
|
||||||
|
`requeue_running_jobs`) НЕ делает побочных эффектов (нет дубль-advance /
|
||||||
|
дубль-enqueue);
|
||||||
|
- гейт красный (monitor умер ДО git-push, артефакта нет) → НЕ выдумываем
|
||||||
|
`done`, уходим в ветку «исход неуспешен» ниже.
|
||||||
|
**Источник истины — гейт, не «exit0».**
|
||||||
|
- `exit!=0`: ровно существующий контракт `_finalize_job` (классификация
|
||||||
|
transient/permanent, `attempts<max` → `queued`, иначе `failed`+Telegram).
|
||||||
|
- **Исход неизвестен (Tier-1 мёртвый pid без exit_code, или Tier-3 backstop):**
|
||||||
|
не выдумываем `exit0`. Если `attempts < max_attempts` → `queued` (как
|
||||||
|
`requeue_running_jobs`); если бюджет исчерпан → `failed` + Telegram-алерт.
|
||||||
|
|
||||||
|
**Restart-safe (FR-1.5, AC-5):** reaper стартует в `lifespan` ПОСЛЕ
|
||||||
|
`requeue_running_jobs()`, поэтому при рестарте сначала отрабатывает стартовый
|
||||||
|
requeue, а периодический reaper лишь добивает то, что возникнет позже; атомарный
|
||||||
|
guard `status='running'` исключает двойную обработку.
|
||||||
|
|
||||||
|
### Р-2. Проактивный реклейм stale/dead merge-lease — функции в `merge_gate.py`
|
||||||
|
|
||||||
|
Логика lease живёт в одном месте (`merge_gate.py`). Добавляем:
|
||||||
|
- `pid_alive(pid) -> 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` |
|
||||||
|
| `reaper_finalize_grace_s` | Tier-2 grace: сколько `exit_code` должен быть записан до реапа (> max окна финализации) | `300` |
|
||||||
|
| `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).
|
||||||
42
docs/work-items/ORCH-065/07-infra-requirements.md
Normal file
42
docs/work-items/ORCH-065/07-infra-requirements.md
Normal file
@@ -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 не меняется.
|
||||||
29
docs/work-items/ORCH-065/08-data-requirements.md
Normal file
29
docs/work-items/ORCH-065/08-data-requirements.md
Normal file
@@ -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-<repo>.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, ложноположительных реапов не возникает.
|
||||||
22
docs/work-items/ORCH-065/10-tech-risks.md
Normal file
22
docs/work-items/ORCH-065/10-tech-risks.md
Normal file
@@ -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) должно быть
|
||||||
|
пересмотрено, если упаковка агентов изменится (отдельные контейнеры).
|
||||||
70
docs/work-items/ORCH-065/12-review.md
Normal file
70
docs/work-items/ORCH-065/12-review.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
---
|
||||||
|
type: review
|
||||||
|
work_item_id: ORCH-065
|
||||||
|
verdict: APPROVED
|
||||||
|
version: 3
|
||||||
|
---
|
||||||
|
|
||||||
|
# Review ORCH-065
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Задача закрывает три связанных класса отказов «процесс/поток умер, а ресурс остался
|
||||||
|
захваченным навсегда»: zombie jobs (A), залипший merge-lease (B), неидемпотентная
|
||||||
|
финализация merge (C). Реализация качественная: новый daemon-поток `src/job_reaper.py`
|
||||||
|
по образцу `reconciler` (never-raise, kill-switch, снимок в `/queue`), трёхуровневая
|
||||||
|
liveness, атомарный `reap_running_job(... WHERE status='running')`, проактивный реклейм
|
||||||
|
lease (`pid_alive` + `reclaim_stale_lease`), идемпотентный guard `pr_already_merged`,
|
||||||
|
колонка `jobs.pid` через идемпотентный `_ensure_column`.
|
||||||
|
|
||||||
|
**Все блокеры предыдущих ревью устранены:**
|
||||||
|
- v1 P0 (guard `pr_already_merged` не подключён к merge-пути) — устранён `aa46e5d`:
|
||||||
|
промпт `.openclaw/agents/deployer.md` консультирует `pr_already_merged` ПЕРЕД любым
|
||||||
|
(повторным) merge (AC-11 wiring на месте, подтверждено строками 94–105/152).
|
||||||
|
- v2 P1 (Tier-2 реапит живой финализирующий monitor; side-effects ДО атомарного claim,
|
||||||
|
нарушение ADR-001 Р-1) — устранён `3e2eb27` двумя мерами:
|
||||||
|
1. **Tier-2 finalization grace** — новая колонка `finished_age_s` в `get_running_jobs`
|
||||||
|
(`src/db.py:609`) + настройка `reaper_finalize_grace_s` (дефолт 300с); Tier-2
|
||||||
|
реапит только при `finished_age >= grace`, иначе строка не трогается
|
||||||
|
(`src/job_reaper.py:197-209`). Живой финализирующий monitor больше не реапится
|
||||||
|
(FR-1.3/AC-3).
|
||||||
|
2. **claim-before-act** — `_reap_exit0` (`src/job_reaper.py:242-286`) сначала оценивает
|
||||||
|
канонический QG read-only (`_gate_is_green` → `_run_qg`, без побочных эффектов),
|
||||||
|
затем атомарно claim `done` ПЕРВЫМ, и только победитель claim выполняет
|
||||||
|
`_gate_driven_advance`. Проигравший гонку (поздний monitor / стартовый requeue) не
|
||||||
|
делает НИКАКИХ побочных эффектов → нет дубль-advance/дубль-enqueue (FR-1.2/AC-4).
|
||||||
|
- v2 P3 (битая ссылка на adr-0011 в CHANGELOG) — исправлена в `3e2eb27`
|
||||||
|
(`adr-0011-job-reaper-lease-reclaim.md`).
|
||||||
|
|
||||||
|
Инварианты сохранены (AC-13): ORCH-065-коммиты (`1a2e881`/`aa46e5d`/`3e2eb27`) НЕ касаются
|
||||||
|
`src/stages.py` и `src/qg/checks.py` — `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/БАГ-8/
|
||||||
|
exit-коды хука не тронуты; реклейм lease — только удаление файла, без git-операций
|
||||||
|
(AC-12). Документация (README, internals, ADR-001, глобальный adr-0011, CHANGELOG,
|
||||||
|
.env.example) обновлена в этом же PR (AC-17). Новые тесты покрывают grace-окно,
|
||||||
|
lost-claim-no-side-effects, already-advanced-идемпотентность. `pytest tests/ -q` —
|
||||||
|
**747 passed**.
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### P0 — Blocker
|
||||||
|
- нет
|
||||||
|
|
||||||
|
### P1 — Must fix
|
||||||
|
- нет
|
||||||
|
|
||||||
|
### P2 — Should fix
|
||||||
|
- нет
|
||||||
|
|
||||||
|
### P3 — Nice to have
|
||||||
|
- нет
|
||||||
|
|
||||||
|
## Документация
|
||||||
|
|
||||||
|
Обновлена корректно и в этом же PR (AC-17 PASS): `docs/architecture/README.md`
|
||||||
|
(раздел про job-reaper + lease-reclaim, таблицы БД и `/queue`),
|
||||||
|
`docs/architecture/internals.md`, `docs/architecture/adr/adr-0011-job-reaper-lease-reclaim.md`
|
||||||
|
(+ запись в `adr/README.md`),
|
||||||
|
`docs/work-items/ORCH-065/06-adr/ADR-001-job-reaper-and-lease-reclaim.md`, `CHANGELOG.md`
|
||||||
|
(ссылка на adr-0011 исправлена), `.env.example` (флаги `ORCH_REAPER_*` /
|
||||||
|
`ORCH_REAPER_FINALIZE_GRACE_S` / `ORCH_LEASE_RECLAIM_ENABLED`). ADR-001 Р-1 и реализация
|
||||||
|
exit0-пути теперь согласованы (claim-before-act).
|
||||||
92
docs/work-items/ORCH-065/13-test-report.md
Normal file
92
docs/work-items/ORCH-065/13-test-report.md
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
---
|
||||||
|
type: test-report
|
||||||
|
work_item_id: ORCH-065
|
||||||
|
result: PASS
|
||||||
|
---
|
||||||
|
|
||||||
|
# Test Report — ORCH-065
|
||||||
|
|
||||||
|
Тема: job-reaper + проактивный реклейм stale/dead merge-lease + идемпотентная
|
||||||
|
финализация merge. Прогон полного регресса в ветке
|
||||||
|
`feature/ORCH-065-bug-zombie-jobs-merge-lease-ru`. Review-вердикт — APPROVED (v3).
|
||||||
|
|
||||||
|
## Окружение
|
||||||
|
- Python: 3.12.13
|
||||||
|
- pytest: 8.3.3
|
||||||
|
- Ветка: feature/ORCH-065-bug-zombie-jobs-merge-lease-ru (worktree)
|
||||||
|
- Прод (8500): health `200 {"status":"ok"}` — НЕ перезапускался (self-hosting инвариант соблюдён)
|
||||||
|
- Дата: 2026-06-07
|
||||||
|
|
||||||
|
## Smoke API (прод 8500, read-only)
|
||||||
|
| Endpoint | Результат |
|
||||||
|
|----------|-----------|
|
||||||
|
| `GET /health` | 200 `{"status":"ok","service":"orchestrator"}` |
|
||||||
|
| `GET /status` | 200, активные задачи отдаются (ORCH-065 в `testing`, ET-013 в `development`) |
|
||||||
|
| `GET /queue` | 200, counts/resilience/reconcile/post_deploy присутствуют |
|
||||||
|
|
||||||
|
Примечание: блок `reaper` в `/queue` прода (8500) ОТСУТСТВУЕТ — ожидаемо, т.к. прод
|
||||||
|
исполняет ещё не задеплоенный (до-ORCH-065) код. Контракт блока `reaper` проверен
|
||||||
|
тестом TC-18 (`tests/test_queue.py::test_tc18_queue_endpoint_has_reaper_block`)
|
||||||
|
против кода ветки — PASS. Curl недоступен в окружении, smoke выполнен через
|
||||||
|
`urllib.request` (read-only, без побочных эффектов на прод).
|
||||||
|
|
||||||
|
## Результаты по тест-плану (04-test-plan.yaml)
|
||||||
|
|
||||||
|
| TC ID | Тип | Модуль | Покрывает | Результат |
|
||||||
|
|-------|-----|--------|-----------|-----------|
|
||||||
|
| TC-01 | unit | test_job_reaper.py | AC-1 (реап мёртвого job без рестарта) | PASS |
|
||||||
|
| TC-02 | unit | test_job_reaper.py | AC-3 (живой агент не реапится) | PASS |
|
||||||
|
| TC-03 | unit | test_job_reaper.py | FR-1.3 (устойчивость reaper_dead_ticks) | PASS |
|
||||||
|
| TC-04 | unit | test_job_reaper.py | FR-1.1/AC-1 (backstop reaper_max_running_s) | PASS |
|
||||||
|
| TC-05 | unit | test_job_reaper.py | AC-4 (исход по результату: done/queued/failed) | PASS |
|
||||||
|
| TC-06 | unit | test_job_reaper.py | AC-5 (атомарность reap-UPDATE guard) | PASS |
|
||||||
|
| TC-07 | unit | test_job_reaper.py | AC-14 (kill-switch reaper_enabled=false) | PASS |
|
||||||
|
| TC-08 | unit | test_job_reaper.py | AC-9 (never-raise per-job) | PASS |
|
||||||
|
| TC-09 | integration | test_queue.py | AC-2 (разблокировка очереди concurrency=1) | PASS |
|
||||||
|
| TC-10 | unit | test_merge_lease_reclaim.py | AC-6 (реклейм lease мёртвого pid) | PASS |
|
||||||
|
| TC-11 | unit | test_merge_lease_reclaim.py | AC-7 (реклейм по TTL сохранён) | PASS |
|
||||||
|
| TC-12 | unit | test_merge_lease_reclaim.py | AC-8 (живой lease не трогается) | PASS |
|
||||||
|
| TC-13 | unit | test_merge_lease_reclaim.py | AC-9 (условность self-hosting/no-op) | PASS |
|
||||||
|
| TC-14 | unit | test_merge_lease_reclaim.py | AC-9 (never-raise при ошибке lease-файла) | PASS |
|
||||||
|
| TC-15 | unit | test_merge_lease_reclaim.py | AC-14 (kill-switch lease_reclaim_enabled=false) | PASS |
|
||||||
|
| TC-16 | unit | test_merge_gate.py | AC-11 (идемпотентность при уже слитом PR) | PASS |
|
||||||
|
| TC-17 | integration | test_merge_gate_race.py | AC-10 (докатывание незавершённого merge) | PASS |
|
||||||
|
| TC-18 | integration | test_queue.py | AC-15 (блок reaper в /queue) | PASS |
|
||||||
|
| TC-19 | unit | test_config.py | AC-13 (контракты STAGE_TRANSITIONS/QG_CHECKS неизменны) | PASS |
|
||||||
|
| TC-20 | unit | test_config.py | §5/AC-14 (новые настройки reaper_*/lease_reclaim_*) | PASS |
|
||||||
|
| TC-21 | unit | test_job_reaper.py | FR-2.1/AC-6 (стартовый реклейм в lifespan) | PASS |
|
||||||
|
|
||||||
|
Все 21 TC из плана — PASS.
|
||||||
|
|
||||||
|
## Сопоставление с критериями приёмки (03-acceptance-criteria.md)
|
||||||
|
- A (AC-1…AC-5): job-reaper — покрыты TC-01..TC-06, TC-09 → PASS
|
||||||
|
- B (AC-6…AC-9): lease-reclaim — покрыты TC-10..TC-15 → PASS
|
||||||
|
- C (AC-10, AC-11): идемпотентная финализация — TC-16, TC-17 → PASS
|
||||||
|
- D (AC-12 прод не трогается, AC-13 контракты, AC-14 kill-switches): TC-07, TC-15, TC-19, TC-20 + smoke прода без рестарта → PASS
|
||||||
|
- E (AC-15 /queue, AC-16 логи/алерты): TC-18 → PASS
|
||||||
|
- F (AC-17 документация): review подтвердил обновление README/internals/ADR-001/adr-0011/CHANGELOG/.env.example (APPROVED) → PASS
|
||||||
|
- G (AC-18 регресс зелёный): `pytest tests/` 747 passed → PASS
|
||||||
|
|
||||||
|
## Вывод pytest
|
||||||
|
|
||||||
|
### Целевые модули плана
|
||||||
|
```
|
||||||
|
$ python -m pytest tests/test_job_reaper.py tests/test_merge_lease_reclaim.py \
|
||||||
|
tests/test_merge_gate.py tests/test_merge_gate_race.py \
|
||||||
|
tests/test_queue.py tests/test_config.py -q
|
||||||
|
92 passed, 1 warning in 3.40s
|
||||||
|
```
|
||||||
|
|
||||||
|
### Полный регресс
|
||||||
|
```
|
||||||
|
$ python -m pytest tests/ -v --tb=short
|
||||||
|
======================= 747 passed, 1 warning in 15.47s ========================
|
||||||
|
```
|
||||||
|
(1 warning — PydanticDeprecatedSince20 в src/config.py, не связан с ORCH-065,
|
||||||
|
предсуществующий.)
|
||||||
|
|
||||||
|
## Итог
|
||||||
|
**PASS.** Полный регресс — 747 passed, 0 failed. Все 21 TC тест-плана зелёные,
|
||||||
|
все критерии приёмки (AC-1…AC-18) подтверждены. Smoke прода — health/status/queue
|
||||||
|
200 OK, прод-контейнер не перезапускался (self-hosting инвариант соблюдён).
|
||||||
|
Задача готова к переходу на стадию `deploy-staging`.
|
||||||
32
docs/work-items/ORCH-065/15-staging-log.md
Normal file
32
docs/work-items/ORCH-065/15-staging-log.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
staging_status: SUCCESS
|
||||||
|
timestamp: 2026-06-07T16:13:48Z
|
||||||
|
base_url: http://localhost:8501
|
||||||
|
---
|
||||||
|
|
||||||
|
# Staging Gate Log
|
||||||
|
|
||||||
|
Staging test suite completed against the live staging environment (8501),
|
||||||
|
run canonically inside the `orchestrator-staging` container (ORCH-048, ADR-001).
|
||||||
|
|
||||||
|
**Verdict:** SUCCESS — `staging_check.py` exited **0**. All REAL (pipeline)
|
||||||
|
checks green; the only failures are the two known sandbox-infra checks
|
||||||
|
(C9a/C9b), which are waived per ORCH-061 because every REAL check passed.
|
||||||
|
|
||||||
|
## Result
|
||||||
|
|
||||||
|
- RESULT: 8/10 checks PASS
|
||||||
|
- REAL failed: none
|
||||||
|
- SANDBOX_INFRA failed (waived): C9a Branch appears in orchestrator-sandbox; C9b Analyst job enqueued in staging queue
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
## Block detail
|
||||||
|
|
||||||
|
- [Block A] SMOKE — A1 /health, A2 /queue, A3 ORCH_STAGING=true → PASS
|
||||||
|
- [Block B] ACCESS — B4 Plane sandbox, B5 Gitea orchestrator-sandbox (push=true), B6 registry isolation (sandbox present, prod ET/ORCH absent) → PASS
|
||||||
|
- [Block C] E2E (stub) — C7 create issue in SANDBOX, C8 trigger pipeline via /webhook/plane → PASS; C9a/C9b → FAIL (sandbox-infra, waived)
|
||||||
|
- CLEANUP — Plane issue deleted (HTTP 204)
|
||||||
|
|
||||||
|
tolerance: staging_infra_tolerance_enabled=True
|
||||||
@@ -417,6 +417,14 @@ class AgentLauncher:
|
|||||||
"UPDATE agent_runs SET output_path = ? WHERE id = ?",
|
"UPDATE agent_runs SET output_path = ? WHERE id = ?",
|
||||||
(output_path, run_id),
|
(output_path, run_id),
|
||||||
)
|
)
|
||||||
|
# ORCH-065: stamp the agent process pid onto the job row so the job-reaper
|
||||||
|
# can probe liveness (os.kill(pid, 0)). proc.pid only exists after Popen,
|
||||||
|
# so this is a second UPDATE next to run_id/started_at (set above in _spawn).
|
||||||
|
if job_id is not None:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE jobs SET pid = ? WHERE id = ?",
|
||||||
|
(proc.pid, job_id),
|
||||||
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|||||||
@@ -296,6 +296,42 @@ class Settings(BaseSettings):
|
|||||||
post_deploy_auto_rollback: bool = False
|
post_deploy_auto_rollback: bool = False
|
||||||
post_deploy_base_url: str = "http://localhost:8500"
|
post_deploy_base_url: str = "http://localhost:8500"
|
||||||
|
|
||||||
|
# ORCH-065: job-reaper + proactive merge-lease reclaim. A background daemon
|
||||||
|
# thread (modelled on the reconciler) makes "the monitor thread / process died
|
||||||
|
# while a job/lease was held" self-heal WITHOUT a restart. Status (done/queued/
|
||||||
|
# failed) is otherwise only ever set by launcher._monitor_agent -> _finalize_job
|
||||||
|
# inside the live process; a death there left the jobs row 'running' forever and
|
||||||
|
# (at max_concurrency=1) wedged the queue of EVERY project (incidents 07.06: jobs
|
||||||
|
# 236/239/242/254). The same thread proactively reclaims a stale/dead merge-lease
|
||||||
|
# (ORCH-043) instead of waiting for the lazy TTL on the next foreign acquire. See
|
||||||
|
# docs/architecture/adr/adr-0011-job-reaper-lease-reclaim.md.
|
||||||
|
# reaper_enabled -> global kill-switch (false -> strictly prior behaviour;
|
||||||
|
# only the startup requeue_running_jobs remains).
|
||||||
|
# reaper_interval_s -> background scan period (seconds).
|
||||||
|
# reaper_dead_ticks -> Tier-1: consecutive ticks a job's pid must be dead
|
||||||
|
# before it is reaped (>=2 anti-false-positive; a live
|
||||||
|
# long-running agent is NEVER reaped).
|
||||||
|
# reaper_max_running_s -> Tier-3 backstop ceiling: a job 'running' longer than
|
||||||
|
# this is reaped even when liveness is unknowable. MUST be
|
||||||
|
# > max agent_timeout + grace so a legit agent is safe.
|
||||||
|
# reaper_finalize_grace_s -> Tier-2 anti-false-positive: a LIVE monitor writes
|
||||||
|
# agent_runs.exit_code FIRST, THEN does git commit/push +
|
||||||
|
# PR + Plane usage comments (seconds..minutes) and only
|
||||||
|
# then _finalize_job. The agent pid is already dead in
|
||||||
|
# that window, so pid cannot tell "monitor died" from
|
||||||
|
# "monitor still finalizing". A job is reaped via Tier-2
|
||||||
|
# only once exit_code has been recorded for at least this
|
||||||
|
# many seconds (MUST be > the max finalization window).
|
||||||
|
# lease_reclaim_enabled -> kill-switch for the proactive stale/dead lease reclaim
|
||||||
|
# (false -> only the legacy lazy TTL reclaim in acquire).
|
||||||
|
# (reuse) merge_lock_timeout_s -> lease TTL; merge_gate_repos -> reclaim scope.
|
||||||
|
reaper_enabled: bool = True
|
||||||
|
reaper_interval_s: int = 60
|
||||||
|
reaper_dead_ticks: int = 2
|
||||||
|
reaper_max_running_s: int = 3600
|
||||||
|
reaper_finalize_grace_s: int = 300
|
||||||
|
lease_reclaim_enabled: bool = True
|
||||||
|
|
||||||
# Telegram notifications
|
# Telegram notifications
|
||||||
telegram_bot_token: str = ""
|
telegram_bot_token: str = ""
|
||||||
telegram_chat_id: str = ""
|
telegram_chat_id: str = ""
|
||||||
|
|||||||
81
src/db.py
81
src/db.py
@@ -76,6 +76,11 @@ def init_db():
|
|||||||
# (CREATE TABLE IF NOT EXISTS won't add columns to an already-created table).
|
# (CREATE TABLE IF NOT EXISTS won't add columns to an already-created table).
|
||||||
_ensure_column(conn, "jobs", "transient_attempts", "INTEGER NOT NULL DEFAULT 0")
|
_ensure_column(conn, "jobs", "transient_attempts", "INTEGER NOT NULL DEFAULT 0")
|
||||||
_ensure_column(conn, "jobs", "available_at", "TEXT")
|
_ensure_column(conn, "jobs", "available_at", "TEXT")
|
||||||
|
# ORCH-065: pid of the spawned agent process, stamped in launcher._spawn next to
|
||||||
|
# run_id/started_at. The job-reaper uses it for Tier-1 liveness (os.kill(pid, 0))
|
||||||
|
# to detect a 'running' job whose process died before _finalize_job. Idempotent
|
||||||
|
# ALTER (no-op once present) -> safe on the live prod DB.
|
||||||
|
_ensure_column(conn, "jobs", "pid", "INTEGER")
|
||||||
# ORCH-5 (M-7): webhook delivery de-dup. Add events.delivery_id and a PARTIAL
|
# ORCH-5 (M-7): webhook delivery de-dup. Add events.delivery_id and a PARTIAL
|
||||||
# unique index. Partial (WHERE delivery_id IS NOT NULL) so pre-existing rows
|
# unique index. Partial (WHERE delivery_id IS NOT NULL) so pre-existing rows
|
||||||
# (which have NULL delivery_id) never collide with each other. Restart-safe:
|
# (which have NULL delivery_id) never collide with each other. Restart-safe:
|
||||||
@@ -593,6 +598,82 @@ def requeue_running_jobs() -> int:
|
|||||||
return int(n)
|
return int(n)
|
||||||
|
|
||||||
|
|
||||||
|
def get_running_jobs() -> list[dict]:
|
||||||
|
"""ORCH-065: snapshot of every 'running' job for the job-reaper scan.
|
||||||
|
|
||||||
|
Each row carries the job columns plus four reaper inputs:
|
||||||
|
* ``running_age_s`` — seconds since ``started_at`` (Tier-3 backstop);
|
||||||
|
* ``exit_code`` — the linked ``agent_runs.exit_code`` (Tier-2: process
|
||||||
|
finished but the job is still 'running' -> monitor died mid-finalize);
|
||||||
|
* ``finished_at_run`` — the linked ``agent_runs.finished_at``;
|
||||||
|
* ``finished_age_s`` — seconds since ``agent_runs.finished_at`` (Tier-2
|
||||||
|
finalization grace: a LIVE monitor writes exit_code, THEN does git
|
||||||
|
push / PR / Plane comments before _finalize_job, so a freshly-finished
|
||||||
|
run is NOT yet a zombie — the reaper waits ``reaper_finalize_grace_s``).
|
||||||
|
|
||||||
|
A LEFT JOIN on ``run_id`` keeps jobs with no agent_runs row (exit_code NULL).
|
||||||
|
Read-only; never mutates. The reaper applies liveness/streak/backstop on top.
|
||||||
|
"""
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT j.*, "
|
||||||
|
"CAST(strftime('%s','now') - strftime('%s', j.started_at) AS INTEGER) "
|
||||||
|
" AS running_age_s, "
|
||||||
|
"r.exit_code AS exit_code, r.finished_at AS finished_at_run, "
|
||||||
|
"CAST(strftime('%s','now') - strftime('%s', r.finished_at) AS INTEGER) "
|
||||||
|
" AS finished_age_s "
|
||||||
|
"FROM jobs j LEFT JOIN agent_runs r ON r.id = j.run_id "
|
||||||
|
"WHERE j.status='running'"
|
||||||
|
).fetchall()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def reap_running_job(
|
||||||
|
job_id: int,
|
||||||
|
status: str,
|
||||||
|
run_id: int | None = None,
|
||||||
|
error: str | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""ORCH-065: atomic terminal flip of a RUNNING job by the job-reaper.
|
||||||
|
|
||||||
|
Mirrors ``mark_job`` but carries the ``status='running'`` guard in the WHERE
|
||||||
|
clause and reports ``rowcount`` so a late-arriving monitor / the startup
|
||||||
|
``requeue_running_jobs`` / a second reaper tick can never double-process the
|
||||||
|
same row (AC-5, restart-safe). Returns True iff THIS call won the flip
|
||||||
|
(rowcount == 1); False -> someone else already moved the row.
|
||||||
|
|
||||||
|
Status semantics match ``mark_job``: done/failed stamp ``finished_at``; queued
|
||||||
|
clears ``started_at``/``finished_at`` so the next claim treats it as fresh.
|
||||||
|
"""
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
sets = ["status = ?"]
|
||||||
|
params: list = [status]
|
||||||
|
if run_id is not None:
|
||||||
|
sets.append("run_id = ?")
|
||||||
|
params.append(run_id)
|
||||||
|
if error is not None:
|
||||||
|
sets.append("error = ?")
|
||||||
|
params.append(error)
|
||||||
|
if status in ("done", "failed"):
|
||||||
|
sets.append("finished_at = datetime('now')")
|
||||||
|
elif status == "queued":
|
||||||
|
sets.append("started_at = NULL")
|
||||||
|
sets.append("finished_at = NULL")
|
||||||
|
params.append(job_id)
|
||||||
|
cur = conn.execute(
|
||||||
|
f"UPDATE jobs SET {', '.join(sets)} WHERE id = ? AND status='running'",
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cur.rowcount == 1
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def get_job(job_id: int) -> dict | None:
|
def get_job(job_id: int) -> dict | None:
|
||||||
"""Fetch a single job by id."""
|
"""Fetch a single job by id."""
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
|
|||||||
467
src/job_reaper.py
Normal file
467
src/job_reaper.py
Normal file
@@ -0,0 +1,467 @@
|
|||||||
|
"""ORCH-065: job-reaper + proactive merge-lease reclaim background daemon.
|
||||||
|
|
||||||
|
Three failure classes share one root cause — "the thread/process died while it
|
||||||
|
still held captured state" — and one inert recovery layer
|
||||||
|
(``requeue_running_jobs``) that only fires on a process restart:
|
||||||
|
|
||||||
|
* **A — zombie jobs.** A job's terminal status (``done``/``queued``/``failed``)
|
||||||
|
is written ONLY inside ``launcher._monitor_agent -> _finalize_job`` in the
|
||||||
|
live process. If that thread/process dies between ``proc.wait()`` and the
|
||||||
|
status write (crash, OOM, self-restart mid-deploy) the ``jobs`` row stays
|
||||||
|
``running`` forever. At ``max_concurrency=1`` one zombie blocks the claim of
|
||||||
|
EVERY project's jobs -> the whole shared pipeline stalls.
|
||||||
|
* **B — stuck merge-lease.** The file lease ``.merge-lease-<repo>.json``
|
||||||
|
(ORCH-043) is reclaimed only lazily, by TTL, and only when ANOTHER task tries
|
||||||
|
to acquire it. Holder liveness (pid) is never probed, so a death with the
|
||||||
|
lease held blocks foreign merges until the TTL expires.
|
||||||
|
|
||||||
|
This module is a background daemon thread modelled on ``reconciler``
|
||||||
|
(``threading.Thread(daemon=True)`` + ``threading.Event``, start/stop in
|
||||||
|
``main.lifespan``, ``/queue`` snapshot, per-unit never-raise, kill-switch). Each
|
||||||
|
tick: (1) scans ``running`` jobs and reaps the dead ones via three-tier liveness
|
||||||
|
detection; (2) proactively reclaims dead/stale merge-leases (mechanism B) for the
|
||||||
|
in-scope repos.
|
||||||
|
|
||||||
|
Liveness (defense in depth, ADR-001 Р-1):
|
||||||
|
* **Tier-1 (primary): dead pid.** ``jobs.pid`` (stamped by ``launcher._spawn``)
|
||||||
|
probed with ``merge_gate.pid_alive``. A job is reaped only after
|
||||||
|
``reaper_dead_ticks`` (>=2) CONSECUTIVE dead-pid ticks — an in-memory streak
|
||||||
|
counter kills false positives (AC-3); a live agent within its timeout is
|
||||||
|
never reaped.
|
||||||
|
* **Tier-2 (completion race): exit_code recorded but job still running.** This
|
||||||
|
window is AMBIGUOUS — it is both "the monitor died between writing
|
||||||
|
``agent_runs.exit_code`` and ``_finalize_job``" AND "a LIVE monitor is still
|
||||||
|
finalizing" (``_monitor_agent`` writes ``exit_code`` FIRST, then git
|
||||||
|
commit/push (+PR), the БАГ-8 check and network Plane usage comments — seconds
|
||||||
|
to tens of seconds — and ONLY THEN ``_try_advance_stage`` -> ``_finalize_job``).
|
||||||
|
The agent pid is already dead in BOTH cases, so it cannot disambiguate. The
|
||||||
|
reaper therefore treats it as a dead monitor (KNOWN outcome) only after a
|
||||||
|
finalization grace: ``exit_code`` recorded for >= ``reaper_finalize_grace_s``
|
||||||
|
(a live finalizing monitor is NEVER reaped, FR-1.3/AC-3). Within the grace the
|
||||||
|
row is left untouched.
|
||||||
|
* **Tier-3 (backstop): age ceiling.** A job ``running`` longer than
|
||||||
|
``reaper_max_running_s`` (deliberately > max ``agent_timeout`` + grace) is
|
||||||
|
reaped even when liveness cannot be determined (pid reused / unknown).
|
||||||
|
|
||||||
|
Action on confirmed death reuses existing contracts (no new merge/stage logic):
|
||||||
|
* The reaper's ONLY mutating write to a job row is the atomic terminal flip
|
||||||
|
``db.reap_running_job(... WHERE status='running')`` — so a late-arriving
|
||||||
|
monitor / the startup ``requeue_running_jobs`` / a second tick can never
|
||||||
|
double-process a row (AC-5; the loser sees ``rowcount==0``).
|
||||||
|
* **exit0 (Tier-2): claim-BEFORE-act (ADR-001 Р-1).** The source of truth is the
|
||||||
|
canonical quality gate, NOT "exit0". If the stage already advanced -> atomic
|
||||||
|
``done`` claim only (idempotent cleanup). Else evaluate the canonical QG
|
||||||
|
READ-ONLY (no side effects, the reconciler pattern): red (e.g. the monitor died
|
||||||
|
before git-push, so no artifact) -> failure path (no false ``done``); green ->
|
||||||
|
atomically claim ``done`` FIRST, and only the claim winner then runs
|
||||||
|
``launcher._try_advance_stage`` (advance + ``enqueue_job`` of the next stage).
|
||||||
|
A tick that loses the claim performs NO side effects, so a late-finalizing
|
||||||
|
monitor / the startup ``requeue_running_jobs`` can never be double-advanced or
|
||||||
|
double-enqueued.
|
||||||
|
* **exit!=0 (Tier-2) / unknown outcome (Tier-1 dead pid, Tier-3 backstop):**
|
||||||
|
``attempts < max_attempts`` -> ``queued`` (mirrors ``requeue_running_jobs``);
|
||||||
|
budget exhausted -> ``failed`` + Telegram. We never fabricate exit0.
|
||||||
|
|
||||||
|
Invariants (ТЗ §8 / ADR-001): never-raise per unit of work; idempotency (atomic
|
||||||
|
guard + gate-driven advance); restart-safe (the reaper starts AFTER the startup
|
||||||
|
``requeue_running_jobs``); silence when nothing is anomalous; the reaper NEVER
|
||||||
|
restarts/kills the prod container and NEVER pushes ``main``. ``STAGE_TRANSITIONS``
|
||||||
|
/ ``QG_CHECKS`` and every ``check_*`` signature are unchanged.
|
||||||
|
|
||||||
|
See docs/work-items/ORCH-065/06-adr/ADR-001-job-reaper-and-lease-reclaim.md and
|
||||||
|
the cross-cutting docs/architecture/adr/adr-0011-job-reaper-lease-reclaim.md.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from .config import settings
|
||||||
|
from .db import (
|
||||||
|
get_db,
|
||||||
|
get_running_jobs,
|
||||||
|
reap_running_job,
|
||||||
|
)
|
||||||
|
from .stages import STAGE_TRANSITIONS, get_agent_for_stage
|
||||||
|
|
||||||
|
logger = logging.getLogger("orchestrator.job_reaper")
|
||||||
|
|
||||||
|
|
||||||
|
def reclaim_all_stale_leases() -> int:
|
||||||
|
"""Proactively reclaim dead/stale merge-leases for every in-scope repo.
|
||||||
|
|
||||||
|
Used both at startup (``main.lifespan``, next to ``requeue_running_jobs``) and
|
||||||
|
on every reaper tick (mechanism B). Iterates the merge-gate scope
|
||||||
|
(``merge_gate_repos`` CSV, else self-hosting ``orchestrator``) and calls the
|
||||||
|
never-raise ``merge_gate.reclaim_stale_lease`` per repo. Returns the number of
|
||||||
|
leases actually reclaimed. Never raises (per-repo isolation).
|
||||||
|
"""
|
||||||
|
if not settings.lease_reclaim_enabled:
|
||||||
|
return 0
|
||||||
|
reclaimed = 0
|
||||||
|
try:
|
||||||
|
from . import merge_gate
|
||||||
|
raw = (settings.merge_gate_repos or "").strip()
|
||||||
|
if raw:
|
||||||
|
repos = [r.strip() for r in raw.split(",") if r.strip()]
|
||||||
|
else:
|
||||||
|
from .qg.checks import SELF_HOSTING_REPO
|
||||||
|
repos = [SELF_HOSTING_REPO]
|
||||||
|
for repo in repos:
|
||||||
|
try:
|
||||||
|
if merge_gate.reclaim_stale_lease(repo):
|
||||||
|
reclaimed += 1
|
||||||
|
except Exception as e: # noqa: BLE001 - isolate one repo's failure
|
||||||
|
logger.error("lease-reclaim failed for repo %s: %s", repo, e)
|
||||||
|
except Exception as e: # noqa: BLE001 - never-raise contract
|
||||||
|
logger.error("reclaim_all_stale_leases error: %s", e)
|
||||||
|
return reclaimed
|
||||||
|
|
||||||
|
|
||||||
|
class JobReaper:
|
||||||
|
"""Background daemon that reaps zombie jobs and reclaims stale merge-leases.
|
||||||
|
|
||||||
|
Modelled on ``Reconciler``: a ``threading.Thread(daemon=True)`` + a
|
||||||
|
``threading.Event`` for a clean stop. The only in-memory state is the
|
||||||
|
best-effort Tier-1 dead-pid streak counter (``_streak``) and the
|
||||||
|
observability counters (``reaped_total`` / ``last_reaped`` /
|
||||||
|
``lease_reclaimed_total`` / ``last_run_ts``); all reset on restart, which is
|
||||||
|
safe because the startup ``requeue_running_jobs`` covers the restart path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, interval_s: float | None = None):
|
||||||
|
self.interval_s = (
|
||||||
|
interval_s if interval_s is not None else settings.reaper_interval_s
|
||||||
|
)
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
# Tier-1 anti-false-positive: {job_id: consecutive dead-pid ticks}.
|
||||||
|
self._streak: dict[int, int] = {}
|
||||||
|
# Best-effort observability (Р-6).
|
||||||
|
self.last_run_ts: float | None = None
|
||||||
|
self.reaped_total: int = 0
|
||||||
|
self.last_reaped: dict | None = None
|
||||||
|
self.lease_reclaimed_total: int = 0
|
||||||
|
|
||||||
|
# -- A: zombie-job reaping --------------------------------------------
|
||||||
|
def reap_once(self) -> None:
|
||||||
|
"""One scan over all ``running`` jobs (per-job never-raise) + lease reclaim."""
|
||||||
|
if settings.reaper_enabled:
|
||||||
|
try:
|
||||||
|
running = get_running_jobs()
|
||||||
|
except Exception as e: # noqa: BLE001 - never break the tick
|
||||||
|
logger.error("reaper: get_running_jobs failed: %s", e)
|
||||||
|
running = []
|
||||||
|
seen: set[int] = set()
|
||||||
|
for job in running:
|
||||||
|
jid = job.get("id")
|
||||||
|
if jid is not None:
|
||||||
|
seen.add(jid)
|
||||||
|
try:
|
||||||
|
self._reap_job(job)
|
||||||
|
except Exception as e: # noqa: BLE001 - isolate one job's failure
|
||||||
|
logger.error(
|
||||||
|
"reaper: job %s (agent=%s) failed: %s",
|
||||||
|
job.get("id"), job.get("agent"), e,
|
||||||
|
)
|
||||||
|
# Forget streaks for rows that are no longer running (reaped / requeued
|
||||||
|
# / finished by the monitor) so the dict cannot grow unbounded.
|
||||||
|
self._streak = {k: v for k, v in self._streak.items() if k in seen}
|
||||||
|
# Mechanism B: proactive stale/dead lease reclaim (own kill-switch).
|
||||||
|
try:
|
||||||
|
self.lease_reclaimed_total += reclaim_all_stale_leases()
|
||||||
|
except Exception as e: # noqa: BLE001 - never break the tick
|
||||||
|
logger.error("reaper: lease reclaim sweep failed: %s", e)
|
||||||
|
|
||||||
|
def _reap_job(self, job: dict) -> None:
|
||||||
|
"""Apply the three-tier liveness policy to a single running job."""
|
||||||
|
from . import merge_gate
|
||||||
|
|
||||||
|
job_id = job["id"]
|
||||||
|
pid = job.get("pid")
|
||||||
|
age = int(job.get("running_age_s") or 0)
|
||||||
|
exit_code = job.get("exit_code") # from the LEFT JOIN on agent_runs
|
||||||
|
|
||||||
|
# Tier-2: the process finished (exit_code recorded) but the job is still
|
||||||
|
# 'running'. This is AMBIGUOUS: it is BOTH "the monitor died mid-finalize"
|
||||||
|
# AND "a LIVE monitor is still finalizing" — _monitor_agent writes exit_code
|
||||||
|
# FIRST, then does git commit/push (+PR), the БАГ-8 check, network Plane
|
||||||
|
# usage comments (seconds..tens of seconds), and ONLY THEN _try_advance_stage
|
||||||
|
# -> _finalize_job. The agent pid is already dead in BOTH cases, so pid can
|
||||||
|
# NOT disambiguate. We treat it as a dead monitor (KNOWN outcome) only after
|
||||||
|
# a finalization grace: exit_code must have been recorded for at least
|
||||||
|
# `reaper_finalize_grace_s` (FR-1.3/AC-3 — a live finalizing monitor is never
|
||||||
|
# reaped). Within the grace window we leave the row alone (and fall through to
|
||||||
|
# the Tier-3 backstop only, which never trips before the grace given a sane
|
||||||
|
# config where reaper_max_running_s > reaper_finalize_grace_s).
|
||||||
|
if exit_code is not None:
|
||||||
|
self._streak.pop(job_id, None)
|
||||||
|
finished_age = job.get("finished_age_s")
|
||||||
|
grace = int(settings.reaper_finalize_grace_s)
|
||||||
|
if finished_age is not None and int(finished_age) >= grace:
|
||||||
|
self._reap_known_outcome(job, int(exit_code))
|
||||||
|
return
|
||||||
|
logger.info(
|
||||||
|
"reaper: job %s exit_code=%s recorded %ss ago (< grace %ss) — "
|
||||||
|
"deferring (monitor may still be finalizing)",
|
||||||
|
job_id, exit_code, finished_age, grace,
|
||||||
|
)
|
||||||
|
# fall through to the Tier-3 backstop guard below.
|
||||||
|
else:
|
||||||
|
# Tier-1: dead pid, only after `reaper_dead_ticks` consecutive dead ticks.
|
||||||
|
if pid is not None and not merge_gate.pid_alive(pid):
|
||||||
|
n = self._streak.get(job_id, 0) + 1
|
||||||
|
self._streak[job_id] = n
|
||||||
|
if n >= max(int(settings.reaper_dead_ticks), 1):
|
||||||
|
self._streak.pop(job_id, None)
|
||||||
|
self._reap_unknown_outcome(job, reason=f"dead pid={pid}")
|
||||||
|
return
|
||||||
|
logger.info(
|
||||||
|
"reaper: job %s pid=%s dead (streak %d/%d) — deferring",
|
||||||
|
job_id, pid, n, settings.reaper_dead_ticks,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Alive / no pid -> reset the streak (must be CONSECUTIVE).
|
||||||
|
self._streak.pop(job_id, None)
|
||||||
|
|
||||||
|
# Tier-3: backstop ceiling (one-shot; reaps even when liveness is unknown).
|
||||||
|
if age >= int(settings.reaper_max_running_s):
|
||||||
|
self._streak.pop(job_id, None)
|
||||||
|
self._reap_unknown_outcome(
|
||||||
|
job, reason=f"backstop age={age}s>={settings.reaper_max_running_s}s"
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- reap actions ------------------------------------------------------
|
||||||
|
def _reap_known_outcome(self, job: dict, exit_code: int) -> None:
|
||||||
|
"""Tier-2: the agent's exit_code is known; drive the job's terminal status."""
|
||||||
|
if exit_code == 0:
|
||||||
|
self._reap_exit0(job)
|
||||||
|
else:
|
||||||
|
self._reap_unknown_outcome(job, reason=f"exit={exit_code}")
|
||||||
|
|
||||||
|
def _reap_exit0(self, job: dict) -> None:
|
||||||
|
"""Reap an exit0 Tier-2 job with claim-BEFORE-act (ADR-001 Р-1).
|
||||||
|
|
||||||
|
The atomic ``reap_running_job`` claim (guard ``WHERE status='running'``) MUST
|
||||||
|
precede any ``advance_stage`` / ``enqueue_job`` side effect, so a reaper tick
|
||||||
|
that LOSES the row (to a late-finalizing monitor or the startup
|
||||||
|
``requeue_running_jobs``) performs NO side effects — no duplicate advance, no
|
||||||
|
duplicate ``enqueue_job`` of the next stage (FR-1.2/AC-4).
|
||||||
|
|
||||||
|
Because the claim flips the row OUT of 'running', we cannot run the advance
|
||||||
|
first to learn the gate colour. Instead we evaluate the canonical quality gate
|
||||||
|
READ-ONLY (no side effects — the pattern the reconciler uses) to choose the
|
||||||
|
terminal status BEFORE claiming:
|
||||||
|
* already advanced past this agent -> idempotent clean ``done`` (no advance);
|
||||||
|
* gate green -> claim ``done`` first, THEN advance exactly once;
|
||||||
|
* gate red (e.g. monitor died before git-push -> no artifact) -> NOT a real
|
||||||
|
success: route to the retry/fail contract (never a false ``done``).
|
||||||
|
"""
|
||||||
|
job_id = job["id"]
|
||||||
|
run_id = job.get("run_id")
|
||||||
|
agent = job.get("agent")
|
||||||
|
branch, stage, work_item_id = self._task_meta(job)
|
||||||
|
candidates = {s for s in STAGE_TRANSITIONS if get_agent_for_stage(s) == agent}
|
||||||
|
|
||||||
|
if stage is None or stage not in candidates:
|
||||||
|
# Stage already advanced past this agent (or unknown) -> a clean 'done'
|
||||||
|
# is correct WITHOUT re-advancing. Atomic claim only (idempotent cleanup).
|
||||||
|
if reap_running_job(job_id, "done", run_id=run_id):
|
||||||
|
self._note_reap(job, "done", reason="exit0, already advanced")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not branch or not self._gate_is_green(stage, job, branch, work_item_id):
|
||||||
|
# exit0 but the gate is red -> do NOT fabricate 'done'; treat as failure
|
||||||
|
# (retry within budget, else failed + Telegram).
|
||||||
|
self._reap_unknown_outcome(job, reason="exit0 but gate red")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Gate green. CLAIM-BEFORE-ACT: own the row atomically FIRST.
|
||||||
|
if not reap_running_job(job_id, "done", run_id=run_id):
|
||||||
|
# Lost the race -> the winner (late monitor / startup requeue) owns the
|
||||||
|
# advance; we do NOTHING (no duplicate side effects).
|
||||||
|
return
|
||||||
|
# We exclusively own the row now -> drive the gate-based advance exactly once.
|
||||||
|
self._gate_driven_advance(job)
|
||||||
|
self._note_reap(job, "done", reason="exit0, gate green")
|
||||||
|
|
||||||
|
def _gate_is_green(
|
||||||
|
self, stage: str, job: dict, branch: str, work_item_id: str | None
|
||||||
|
) -> bool:
|
||||||
|
"""Read-only canonical-QG evaluation for a reaped exit0 job (no side effects).
|
||||||
|
|
||||||
|
Mirrors the reconciler's cheap pre-evaluation: dispatch the stage's QG via
|
||||||
|
the SAME ``_run_qg`` the webhook path uses, returning its pass/fail WITHOUT
|
||||||
|
running ``advance_stage`` (so no stage move / enqueue / notification happens
|
||||||
|
here). A stage with no registered gate is treated as green (nothing blocks a
|
||||||
|
clean 'done'). Never raises -> any error returns False (conservative: route
|
||||||
|
to retry, never a false 'done').
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from .stages import get_qg_for_stage
|
||||||
|
from .stage_engine import _run_qg
|
||||||
|
qg_name = get_qg_for_stage(stage)
|
||||||
|
if not qg_name:
|
||||||
|
return True
|
||||||
|
passed, _reason = _run_qg(qg_name, job.get("repo"), work_item_id, branch)
|
||||||
|
return bool(passed)
|
||||||
|
except Exception as e: # noqa: BLE001 - never break the reap
|
||||||
|
logger.warning(
|
||||||
|
"reaper: gate pre-eval failed for job %s (stage=%s): %s",
|
||||||
|
job.get("id"), stage, e,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _reap_unknown_outcome(self, job: dict, reason: str) -> None:
|
||||||
|
"""Tier-1/Tier-3 (or exit!=0): outcome not a clean success.
|
||||||
|
|
||||||
|
Mirrors ``requeue_running_jobs`` / the permanent-failure contract:
|
||||||
|
``attempts < max_attempts`` -> ``queued`` (a retry); budget exhausted ->
|
||||||
|
``failed`` + Telegram. The terminal flip is the atomic ``reap_running_job``
|
||||||
|
guard, so a racing requeue/monitor never double-processes the row.
|
||||||
|
"""
|
||||||
|
job_id = job["id"]
|
||||||
|
run_id = job.get("run_id")
|
||||||
|
attempts = int(job.get("attempts") or 0)
|
||||||
|
max_attempts = int(job.get("max_attempts") or 2)
|
||||||
|
err = f"reaped: {reason} (run_id={run_id})"
|
||||||
|
if attempts < max_attempts:
|
||||||
|
if reap_running_job(job_id, "queued", run_id=run_id, error=err):
|
||||||
|
self._note_reap(job, "queued", reason=reason)
|
||||||
|
else:
|
||||||
|
if reap_running_job(job_id, "failed", run_id=run_id, error=err):
|
||||||
|
self._note_reap(job, "failed", reason=reason)
|
||||||
|
self._notify_failed(job, reason)
|
||||||
|
|
||||||
|
def _gate_driven_advance(self, job: dict) -> bool:
|
||||||
|
"""Idempotent, gate-driven stage advance for a reaped exit0 job.
|
||||||
|
|
||||||
|
Returns True iff the stage is (or has become) advanced past this agent's
|
||||||
|
stage — i.e. the canonical quality gate is satisfied and a clean ``done``
|
||||||
|
is correct. Returns False when the gate is still red (the caller then
|
||||||
|
routes the job to the failure path instead of a false ``done``).
|
||||||
|
|
||||||
|
The advance itself reuses the UNCHANGED ``launcher._try_advance_stage``
|
||||||
|
(which runs the canonical QG and the unified ``advance_stage``); the
|
||||||
|
reaper never duplicates ``update_task_stage`` / ``enqueue_job``.
|
||||||
|
"""
|
||||||
|
agent = job.get("agent")
|
||||||
|
repo = job.get("repo")
|
||||||
|
run_id = job.get("run_id")
|
||||||
|
branch, stage, _wid = self._task_meta(job)
|
||||||
|
# Candidate stages whose finishing agent is THIS agent (deployer maps to
|
||||||
|
# both 'testing' and 'deploy-staging', hence a set).
|
||||||
|
candidates = {s for s in STAGE_TRANSITIONS if get_agent_for_stage(s) == agent}
|
||||||
|
if stage is None or stage not in candidates:
|
||||||
|
# Stage already advanced past this agent (or unknown) -> idempotent
|
||||||
|
# cleanup: a clean 'done' is correct without re-advancing.
|
||||||
|
return True
|
||||||
|
if not branch:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
from .agents.launcher import launcher
|
||||||
|
launcher._try_advance_stage(run_id, agent, repo, branch)
|
||||||
|
except Exception as e: # noqa: BLE001 - never break the reap
|
||||||
|
logger.error("reaper: gate-driven advance failed for job %s: %s",
|
||||||
|
job.get("id"), e)
|
||||||
|
return False
|
||||||
|
# Re-read the stage: advanced out of the candidate set -> gate was green.
|
||||||
|
_branch, new_stage, _wid2 = self._task_meta(job)
|
||||||
|
return new_stage is None or new_stage not in candidates
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _task_meta(job: dict) -> tuple[str | None, str | None, str | None]:
|
||||||
|
"""Resolve (branch, stage, work_item_id) for the job's task. Never raises."""
|
||||||
|
task_id = job.get("task_id")
|
||||||
|
if not task_id:
|
||||||
|
return None, None, None
|
||||||
|
try:
|
||||||
|
conn = get_db()
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT branch, stage, work_item_id FROM tasks WHERE id = ?",
|
||||||
|
(task_id,),
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
if not row:
|
||||||
|
return None, None, None
|
||||||
|
return row["branch"], row["stage"], row["work_item_id"]
|
||||||
|
except Exception as e: # noqa: BLE001 - never-raise contract
|
||||||
|
logger.warning("reaper: task lookup failed for job %s: %s",
|
||||||
|
job.get("id"), e)
|
||||||
|
return None, None, None
|
||||||
|
|
||||||
|
def _notify_failed(self, job: dict, reason: str) -> None:
|
||||||
|
try:
|
||||||
|
from .notifications import send_telegram
|
||||||
|
send_telegram(
|
||||||
|
f"\U0001f6a8 reaper: job {job.get('id')} ({job.get('agent')}, "
|
||||||
|
f"repo {job.get('repo')}) reaped as FAILED: {reason}"
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001 - telegram best-effort
|
||||||
|
logger.warning("reaper: failed-notify telegram error: %s", e)
|
||||||
|
|
||||||
|
def _note_reap(self, job: dict, outcome: str, reason: str) -> None:
|
||||||
|
"""Record + log one successful reap (Р-6 observability)."""
|
||||||
|
self.reaped_total += 1
|
||||||
|
self.last_reaped = {
|
||||||
|
"job_id": job.get("id"),
|
||||||
|
"agent": job.get("agent"),
|
||||||
|
"outcome": outcome,
|
||||||
|
}
|
||||||
|
logger.warning(
|
||||||
|
"reaper: job %s (agent=%s, repo=%s, run_id=%s, pid=%s) reaped -> %s (%s)",
|
||||||
|
job.get("id"), job.get("agent"), job.get("repo"),
|
||||||
|
job.get("run_id"), job.get("pid"), outcome, reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- loop / lifecycle --------------------------------------------------
|
||||||
|
def _tick(self) -> None:
|
||||||
|
try:
|
||||||
|
self.reap_once()
|
||||||
|
finally:
|
||||||
|
self.last_run_ts = datetime.now(timezone.utc).timestamp()
|
||||||
|
|
||||||
|
def _run(self) -> None:
|
||||||
|
logger.info(
|
||||||
|
"JobReaper started (interval=%ss, enabled=%s, dead_ticks=%s, "
|
||||||
|
"max_running_s=%s, lease_reclaim=%s)",
|
||||||
|
self.interval_s, settings.reaper_enabled, settings.reaper_dead_ticks,
|
||||||
|
settings.reaper_max_running_s, settings.lease_reclaim_enabled,
|
||||||
|
)
|
||||||
|
while not self._stop.is_set():
|
||||||
|
try:
|
||||||
|
self._tick()
|
||||||
|
except Exception as e: # noqa: BLE001 - outer never-raise
|
||||||
|
logger.error("JobReaper loop error: %s", e)
|
||||||
|
self._stop.wait(self.interval_s)
|
||||||
|
logger.info("JobReaper stopped")
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
"""Start the daemon thread (idempotent: a live thread is a no-op)."""
|
||||||
|
if self._thread and self._thread.is_alive():
|
||||||
|
return
|
||||||
|
self._stop.clear()
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._run, name="job-reaper", daemon=True
|
||||||
|
)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def stop(self, timeout: float = 5.0) -> None:
|
||||||
|
self._stop.set()
|
||||||
|
if self._thread:
|
||||||
|
self._thread.join(timeout=timeout)
|
||||||
|
|
||||||
|
def status(self) -> dict:
|
||||||
|
"""Reaper snapshot for /queue observability (Р-6)."""
|
||||||
|
return {
|
||||||
|
"enabled": settings.reaper_enabled,
|
||||||
|
"interval": self.interval_s,
|
||||||
|
"last_run_ts": self.last_run_ts,
|
||||||
|
"reaped_total": self.reaped_total,
|
||||||
|
"last_reaped": self.last_reaped,
|
||||||
|
"lease_reclaimed_total": self.lease_reclaimed_total,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Module-level singleton used by the FastAPI lifespan.
|
||||||
|
reaper = JobReaper()
|
||||||
32
src/main.py
32
src/main.py
@@ -60,6 +60,19 @@ async def lifespan(app: FastAPI):
|
|||||||
if requeued:
|
if requeued:
|
||||||
log.warning(f"Queue-recovery: requeued {requeued} running job(s) after restart")
|
log.warning(f"Queue-recovery: requeued {requeued} running job(s) after restart")
|
||||||
|
|
||||||
|
# ORCH-065: proactive startup reclaim of dead/stale merge-leases, next to the
|
||||||
|
# queue-recovery above. A lease held by the previous (now dead) process pid is
|
||||||
|
# released at once instead of waiting for the TTL / a foreign acquire so the
|
||||||
|
# next merge is not blocked. Conditional (merge_gate_repos / self-hosting) and
|
||||||
|
# gated by ORCH_LEASE_RECLAIM_ENABLED; never raises.
|
||||||
|
try:
|
||||||
|
from .job_reaper import reclaim_all_stale_leases
|
||||||
|
reclaimed = reclaim_all_stale_leases()
|
||||||
|
if reclaimed:
|
||||||
|
log.warning(f"Startup lease-reclaim: reclaimed {reclaimed} stale merge-lease(s)")
|
||||||
|
except Exception as e:
|
||||||
|
log.warning(f"Startup lease-reclaim skipped: {e}")
|
||||||
|
|
||||||
# L-2: rotate old per-run logs at startup (best-effort; never fatal).
|
# L-2: rotate old per-run logs at startup (best-effort; never fatal).
|
||||||
try:
|
try:
|
||||||
import os as _os
|
import os as _os
|
||||||
@@ -85,13 +98,22 @@ async def lifespan(app: FastAPI):
|
|||||||
from .reconciler import reconciler
|
from .reconciler import reconciler
|
||||||
reconciler.start()
|
reconciler.start()
|
||||||
|
|
||||||
|
# ORCH-065: start the job-reaper LAST (after requeue_running_jobs + the worker
|
||||||
|
# + the reconciler) so its atomic status='running' guard never races the
|
||||||
|
# startup requeue. It reaps zombie jobs and periodically reclaims stale
|
||||||
|
# merge-leases. Kill-switch: ORCH_REAPER_ENABLED.
|
||||||
|
from .job_reaper import reaper
|
||||||
|
reaper.start()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
finally:
|
finally:
|
||||||
# Graceful shutdown order mirrors startup in reverse: stop the reconciler
|
# Graceful shutdown order mirrors startup in reverse: stop the reaper
|
||||||
# first (it must not enqueue new work while the worker is winding down),
|
# first, then the reconciler (it must not enqueue new work while the
|
||||||
# then the worker. Running agents keep going; their jobs are requeued on
|
# worker is winding down), then the worker. Running agents keep going;
|
||||||
# next start via queue-recovery if the process dies.
|
# their jobs are requeued on next start via queue-recovery if the
|
||||||
|
# process dies.
|
||||||
|
reaper.stop()
|
||||||
reconciler.stop()
|
reconciler.stop()
|
||||||
worker.stop()
|
worker.stop()
|
||||||
|
|
||||||
@@ -123,6 +145,7 @@ async def queue():
|
|||||||
from .db import job_status_counts, recent_jobs
|
from .db import job_status_counts, recent_jobs
|
||||||
from .queue_worker import worker
|
from .queue_worker import worker
|
||||||
from .reconciler import reconciler
|
from .reconciler import reconciler
|
||||||
|
from .job_reaper import reaper
|
||||||
from . import post_deploy
|
from . import post_deploy
|
||||||
return {
|
return {
|
||||||
"counts": job_status_counts(),
|
"counts": job_status_counts(),
|
||||||
@@ -130,6 +153,7 @@ async def queue():
|
|||||||
"poll_interval": worker.poll_interval,
|
"poll_interval": worker.poll_interval,
|
||||||
"resilience": worker.status(),
|
"resilience": worker.status(),
|
||||||
"reconcile": reconciler.status(),
|
"reconcile": reconciler.status(),
|
||||||
|
"reaper": reaper.status(),
|
||||||
"post_deploy": post_deploy.status(),
|
"post_deploy": post_deploy.status(),
|
||||||
"recent": recent_jobs(10),
|
"recent": recent_jobs(10),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -338,3 +338,150 @@ def release_merge_lease(repo: str, branch: str | None = None) -> None:
|
|||||||
return
|
return
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.warning("merge-lease release error for %s: %s", repo, e)
|
logger.warning("merge-lease release error for %s: %s", repo, e)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ORCH-065: proactive stale/dead merge-lease reclaim (Problem B)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def pid_alive(pid) -> bool:
|
||||||
|
"""Return True iff process ``pid`` is alive (``os.kill(pid, 0)`` liveness probe).
|
||||||
|
|
||||||
|
Semantics (ADR-001 Р-2, never-raise):
|
||||||
|
* ``ProcessLookupError`` -> the process is gone -> ``False`` (reclaimable).
|
||||||
|
* ``PermissionError`` -> the pid exists but is owned by another user ->
|
||||||
|
``True`` (alive; conservatively do NOT reclaim).
|
||||||
|
* missing / invalid pid -> ``True`` (conservative: a lease that predates the
|
||||||
|
pid field, or a malformed pid, is NOT reclaimed on the liveness signal —
|
||||||
|
the TTL backstop still catches it).
|
||||||
|
Never raises; any unexpected OS/type error -> conservative ``True``.
|
||||||
|
"""
|
||||||
|
if not pid:
|
||||||
|
return True
|
||||||
|
try:
|
||||||
|
os.kill(int(pid), 0)
|
||||||
|
return True
|
||||||
|
except ProcessLookupError:
|
||||||
|
return False
|
||||||
|
except PermissionError:
|
||||||
|
return True
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _lease_reclaim_applies(repo: str) -> bool:
|
||||||
|
"""Whether proactive lease-reclaim is REAL for ``repo`` (same scope as merge-gate).
|
||||||
|
|
||||||
|
Reuses ``qg.checks._merge_gate_applies`` (``merge_gate_repos`` CSV, else the
|
||||||
|
self-hosting ``orchestrator``) so reclaim and the gate share one predicate
|
||||||
|
(ADR-001 Р-2 / FR-2.4). Imported lazily to avoid an import cycle (qg.checks
|
||||||
|
imports merge_gate lazily inside ``check_branch_mergeable``). Never raises:
|
||||||
|
any error -> ``False`` (no-op, the safe default).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from .qg.checks import _merge_gate_applies
|
||||||
|
return _merge_gate_applies(repo)
|
||||||
|
except Exception as e: # noqa: BLE001 - never-raise contract
|
||||||
|
logger.warning("lease-reclaim applicability check failed for %s: %s", repo, e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def reclaim_stale_lease(repo: str) -> bool:
|
||||||
|
"""Proactively reclaim a dead/stale merge-lease for ``repo`` (ADR-001 Р-2).
|
||||||
|
|
||||||
|
Unlike the lazy TTL reclaim inside ``acquire_merge_lease`` (which only fires
|
||||||
|
when ANOTHER task tries to acquire), this releases the lease as soon as the
|
||||||
|
holder is provably gone — without waiting for the TTL or a foreign acquire:
|
||||||
|
|
||||||
|
* holder pid is dead (``pid_alive`` is False) -> reclaim, OR
|
||||||
|
* lease age >= ``merge_lock_timeout_s`` (TTL) -> reclaim (AC-7).
|
||||||
|
|
||||||
|
A LIVE holder within its TTL is never touched (AC-8 — protects a legitimate
|
||||||
|
in-flight merge). Reclaim is holder-aware (``release_merge_lease(repo,
|
||||||
|
branch=holder)``) so it can never delete a lease a different task acquired in
|
||||||
|
the meantime. Conditional (FR-2.4): real only for ``merge_gate_repos`` /
|
||||||
|
self-hosting; other repos -> no-op. Kill-switch ``lease_reclaim_enabled``.
|
||||||
|
|
||||||
|
Returns True iff a lease was reclaimed. Never raises (AC-9): any read/remove
|
||||||
|
error is logged and swallowed so a single bad lease never kills the reaper
|
||||||
|
thread. Does NOT run any git operation — only the lease file is removed.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if not settings.lease_reclaim_enabled:
|
||||||
|
return False
|
||||||
|
if not _lease_reclaim_applies(repo):
|
||||||
|
return False
|
||||||
|
path = _lease_path(repo)
|
||||||
|
existing = _read_lease(path)
|
||||||
|
if existing is None:
|
||||||
|
return False # no lease (or unreadable -> _read_lease already logged)
|
||||||
|
holder = existing.get("branch")
|
||||||
|
pid = existing.get("pid")
|
||||||
|
age = time.time() - float(existing.get("acquired_at") or 0)
|
||||||
|
dead = not pid_alive(pid)
|
||||||
|
expired = age >= settings.merge_lock_timeout_s
|
||||||
|
if not (dead or expired):
|
||||||
|
return False # live holder within TTL -> protect legitimate merge
|
||||||
|
why = f"dead pid={pid}" if dead else f"stale age={age:.0f}s>=TTL"
|
||||||
|
release_merge_lease(repo, branch=holder)
|
||||||
|
logger.warning(
|
||||||
|
"merge-lease for %s reclaimed proactively (%s, holder=%s)",
|
||||||
|
repo, why, holder,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
from .notifications import send_telegram
|
||||||
|
send_telegram(
|
||||||
|
f"\U0001f527 merge-lease для {repo} освобождён проактивно "
|
||||||
|
f"({why}, holder={holder})"
|
||||||
|
)
|
||||||
|
except Exception as e: # noqa: BLE001 - telegram best-effort, never fatal
|
||||||
|
logger.warning("lease-reclaim telegram failed for %s: %s", repo, e)
|
||||||
|
return True
|
||||||
|
except Exception as e: # noqa: BLE001 - never-raise contract
|
||||||
|
logger.warning("reclaim_stale_lease unexpected error for %s: %s", repo, e)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ORCH-065: idempotent merge finalization guard (Problem C)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def pr_already_merged(repo: str, branch: str) -> bool:
|
||||||
|
"""Return True iff the PR for ``branch`` is ALREADY merged (ADR-001 Р-3, FR-3.2).
|
||||||
|
|
||||||
|
A deterministic, read-only guard the merge path consults BEFORE attempting a
|
||||||
|
(second) merge so a re-driven / reaped task is idempotent: an already-merged
|
||||||
|
PR -> no-op, never a duplicate merge and never an error. This is the ONLY new
|
||||||
|
merge-related helper and it does NOT merge — it only READS the PR state via
|
||||||
|
the existing Gitea client, so it does not introduce duplicate merge logic.
|
||||||
|
|
||||||
|
Consultation point: the actual merge actor is the **deployer agent** (it merges
|
||||||
|
the feature PR at the start of the ``deploy`` stage — see webhooks/gitea.py),
|
||||||
|
so the wiring lives in the deployer prompt (``.openclaw/agents/deployer.md``),
|
||||||
|
which runs this exact function before any (re-)merge. The merge-gate quality
|
||||||
|
check (``qg.checks.check_branch_mergeable``) is intentionally NOT modified
|
||||||
|
(ORCH-065 AC-13: ``check_*`` behaviour unchanged) — it runs on the FIRST
|
||||||
|
deploy-staging -> deploy edge and does not re-run on a ``deploy``-stage re-drive,
|
||||||
|
which is exactly where the second-merge risk lives.
|
||||||
|
|
||||||
|
Queries Gitea ``GET /repos/{owner}/{repo}/pulls?state=all&head=<branch>`` and
|
||||||
|
reports True when any matching PR has ``merged == True``. Never raises (AC-9):
|
||||||
|
any HTTP/parse error -> ``False`` (conservative: "not known-merged" lets the
|
||||||
|
normal gate re-evaluate rather than silently skipping a real merge).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
owner = settings.gitea_owner
|
||||||
|
headers = {"Authorization": f"token {settings.gitea_token}"}
|
||||||
|
resp = httpx.get(
|
||||||
|
f"{settings.gitea_url}/api/v1/repos/{owner}/{repo}/pulls",
|
||||||
|
params={"state": "all", "head": branch},
|
||||||
|
headers=headers, timeout=_SHORT_TIMEOUT,
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return False
|
||||||
|
for pr in resp.json() or []:
|
||||||
|
if pr.get("merged") is True:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
except Exception as e: # noqa: BLE001 - never-raise contract
|
||||||
|
logger.warning("pr_already_merged check failed for %s/%s: %s", repo, branch, e)
|
||||||
|
return False
|
||||||
|
|||||||
@@ -128,6 +128,12 @@ _PLANE_NAME_TO_KEY: dict[str, str] = {
|
|||||||
"Needs Input": "needs_input",
|
"Needs Input": "needs_input",
|
||||||
"In Review": "in_review",
|
"In Review": "in_review",
|
||||||
"Blocked": "blocked",
|
"Blocked": "blocked",
|
||||||
|
# ORCH-059: dedicated prod-deploy trigger status, distinct from the
|
||||||
|
# human-gate "Approved". Resolved from the live Plane API for the ORCH
|
||||||
|
# project; intentionally ABSENT from _DEFAULT_STATES so environments without
|
||||||
|
# this board status (enduro / API fallback) fail-closed — no UUID, no
|
||||||
|
# confirm-deploy branch, no KeyError (accessed via .get).
|
||||||
|
"Confirm Deploy": "confirm_deploy",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Per-project state cache: {project_id: {logical_key: state_uuid}}
|
# Per-project state cache: {project_id: {logical_key: state_uuid}}
|
||||||
|
|||||||
@@ -171,6 +171,8 @@ def advance_stage(
|
|||||||
work_item_id: str,
|
work_item_id: str,
|
||||||
branch: str,
|
branch: str,
|
||||||
finished_agent: str | None = None,
|
finished_agent: str | None = None,
|
||||||
|
*,
|
||||||
|
confirm_deploy: bool = False,
|
||||||
) -> AdvanceResult:
|
) -> AdvanceResult:
|
||||||
"""Run the current stage's quality gate and advance / roll back the pipeline.
|
"""Run the current stage's quality gate and advance / roll back the pipeline.
|
||||||
|
|
||||||
@@ -187,6 +189,13 @@ def advance_stage(
|
|||||||
approved/REQUEST_CHANGES/tester/architect branches. In the
|
approved/REQUEST_CHANGES/tester/architect branches. In the
|
||||||
plane webhook path it is None, so those agent-specific
|
plane webhook path it is None, so those agent-specific
|
||||||
branches simply do not trigger (matches old plane behavior).
|
branches simply do not trigger (matches old plane behavior).
|
||||||
|
confirm_deploy: ORCH-059 — keyword-only signal that the human flipped the
|
||||||
|
issue to the dedicated "Confirm Deploy" status. ONLY this
|
||||||
|
signal initiates Phase B of the self-hosting prod deploy on
|
||||||
|
the `deploy` stage. A plain `Approved` on `deploy`
|
||||||
|
(confirm_deploy=False) is a deliberate no-op (no prod
|
||||||
|
deploy, no false БАГ-8 rollback). All non-webhook callers
|
||||||
|
leave it at the default.
|
||||||
|
|
||||||
Returns AdvanceResult describing what happened.
|
Returns AdvanceResult describing what happened.
|
||||||
"""
|
"""
|
||||||
@@ -203,21 +212,32 @@ def advance_stage(
|
|||||||
result.note = "terminal"
|
result.note = "terminal"
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# --- ORCH-036 Phase B: human Approved on `deploy` -> initiate deploy --
|
# --- ORCH-036/059 Phase B: "Confirm Deploy" on `deploy` -> initiate ----
|
||||||
# A human flipping the Plane status to Approved on the `deploy` stage
|
# ORCH-059: the prod-deploy trigger is now the DEDICATED "Confirm Deploy"
|
||||||
# (finished_agent is None) is the prod-deploy trigger for the self-hosting
|
# status (confirm_deploy=True), NOT the overloaded "Approved". On the
|
||||||
# repo. Initiate the DETACHED host deploy + enqueue the finalizer and
|
# `deploy` stage (finished_agent is None) for the self-hosting repo we
|
||||||
# return WITHOUT running check_deploy_status (the verdict does not exist
|
# always return early WITHOUT running check_deploy_status (the verdict
|
||||||
# yet — running the gate now would read a stale/absent log and falsely
|
# does not exist yet — running the gate now would read a stale/absent log
|
||||||
# roll back, R-2). The finalizer (Phase C, finished_agent="deployer")
|
# and falsely roll back, R-2/БАГ-8), but we only initiate the DETACHED
|
||||||
# records the verdict later; that path is NOT intercepted here.
|
# host deploy + enqueue the finalizer when confirm_deploy is set. A plain
|
||||||
|
# Approved (confirm_deploy=False) is a deliberate no-op — it neither
|
||||||
|
# deploys nor rolls back (TRZ-3/AC-3). The finalizer (Phase C,
|
||||||
|
# finished_agent="deployer") records the verdict later; that path is NOT
|
||||||
|
# intercepted here (it requires finished_agent set).
|
||||||
if (
|
if (
|
||||||
current_stage == "deploy"
|
current_stage == "deploy"
|
||||||
and finished_agent is None
|
and finished_agent is None
|
||||||
and settings.deploy_require_manual_approve
|
and settings.deploy_require_manual_approve
|
||||||
and self_deploy.self_deploy_applies(repo)
|
and self_deploy.self_deploy_applies(repo)
|
||||||
):
|
):
|
||||||
_handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result)
|
if confirm_deploy:
|
||||||
|
_handle_self_deploy_phase_b(task_id, repo, work_item_id, branch, result)
|
||||||
|
else:
|
||||||
|
result.note = "approved-on-deploy-noop"
|
||||||
|
logger.info(
|
||||||
|
f"Task {task_id}: Approved on `deploy` without Confirm Deploy "
|
||||||
|
f"— no-op (prod deploy requires the 'Confirm Deploy' status)"
|
||||||
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
# --- Quality gate ----------------------------------------------------
|
# --- Quality gate ----------------------------------------------------
|
||||||
@@ -998,9 +1018,11 @@ def _handle_self_deploy_phase_a(
|
|||||||
|
|
||||||
Staging is green and the branch is mergeable; for the self-hosting repo we do
|
Staging is green and the branch is mergeable; for the self-hosting repo we do
|
||||||
NOT auto-deploy to prod. Move the task onto the `deploy` stage (so a later
|
NOT auto-deploy to prod. Move the task onto the `deploy` stage (so a later
|
||||||
human Approved lands there -> Phase B), set the issue approval-pending and ask
|
human "Confirm Deploy" lands there -> Phase B), set the issue approval-pending
|
||||||
the human to flip the status to Approved. A restart-safe `approve-requested`
|
and ask the human to flip the status to "Confirm Deploy" (ORCH-059: the
|
||||||
marker records that Phase A ran. The merge lease stays HELD.
|
dedicated prod-deploy trigger, distinct from the human-gate "Approved"). A
|
||||||
|
restart-safe `approve-requested` marker records that Phase A ran. The merge
|
||||||
|
lease stays HELD.
|
||||||
"""
|
"""
|
||||||
update_task_stage(task_id, "deploy")
|
update_task_stage(task_id, "deploy")
|
||||||
notify_stage_change(task_id, current_stage, "deploy")
|
notify_stage_change(task_id, current_stage, "deploy")
|
||||||
@@ -1022,13 +1044,14 @@ def _handle_self_deploy_phase_a(
|
|||||||
if work_item_id:
|
if work_item_id:
|
||||||
plane_add_comment(
|
plane_add_comment(
|
||||||
work_item_id,
|
work_item_id,
|
||||||
"\U0001f7e1 Staging зелёный. Требуется ручной approve для ПРОД-деплоя: "
|
"\U0001f7e1 Staging зелёный. Требуется ручное подтверждение ПРОД-деплоя: "
|
||||||
"смените статус задачи на «Approved», чтобы запустить деплой в прод (8500).",
|
"смените статус задачи на «Confirm Deploy», чтобы запустить деплой в прод "
|
||||||
|
"(8500). Статус «Approved» прод-деплой НЕ запускает.",
|
||||||
author="deployer",
|
author="deployer",
|
||||||
)
|
)
|
||||||
send_telegram(
|
send_telegram(
|
||||||
f"\U0001f7e1 {work_item_id}: staging OK. Ждёт approve на ПРОД-деплой "
|
f"\U0001f7e1 {work_item_id}: staging OK. Ждёт подтверждения ПРОД-деплоя "
|
||||||
f"(смените статус на Approved)."
|
f"(смените статус на «Confirm Deploy»)."
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
f"Task {task_id}: self-deploy Phase A — advanced to deploy, "
|
f"Task {task_id}: self-deploy Phase A — advanced to deploy, "
|
||||||
|
|||||||
@@ -150,8 +150,15 @@ async def handle_issue_updated(data: dict, project_id: str = ""):
|
|||||||
# both enduro (b873d9eb) and orchestrator (e331bfb3) In Progress trigger the
|
# both enduro (b873d9eb) and orchestrator (e331bfb3) In Progress trigger the
|
||||||
# pipeline. Using PLANE_STATES["in_progress"] here was the root-cause blocker.
|
# pipeline. Using PLANE_STATES["in_progress"] here was the root-cause blocker.
|
||||||
proj_states = get_project_states(project_id)
|
proj_states = get_project_states(project_id)
|
||||||
|
# ORCH-059: the dedicated "Confirm Deploy" status is the prod-deploy trigger.
|
||||||
|
# fail-closed via .get — environments without the status (enduro / API
|
||||||
|
# fallback) resolve to None, so the branch simply never activates (no KeyError,
|
||||||
|
# no blind deploy). Checked before `approved` so the two gestures never alias.
|
||||||
|
confirm_state = proj_states.get("confirm_deploy")
|
||||||
if new_state == proj_states["in_progress"]:
|
if new_state == proj_states["in_progress"]:
|
||||||
await handle_status_start(data, project_id)
|
await handle_status_start(data, project_id)
|
||||||
|
elif confirm_state and new_state == confirm_state:
|
||||||
|
await handle_confirm_deploy(data, project_id)
|
||||||
elif new_state == proj_states["approved"]:
|
elif new_state == proj_states["approved"]:
|
||||||
await handle_verdict(data, project_id, approved=True)
|
await handle_verdict(data, project_id, approved=True)
|
||||||
elif new_state == proj_states["rejected"]:
|
elif new_state == proj_states["rejected"]:
|
||||||
@@ -160,6 +167,45 @@ async def handle_issue_updated(data: dict, project_id: str = ""):
|
|||||||
logger.info(f"issue {plane_id} updated to state {new_state[:8]}..., no pipeline action")
|
logger.info(f"issue {plane_id} updated to state {new_state[:8]}..., no pipeline action")
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_confirm_deploy(data: dict, project_id: str = ""):
|
||||||
|
"""ORCH-059: a human flipped the issue to the dedicated "Confirm Deploy"
|
||||||
|
status — the explicit trigger for the self-hosting prod deploy (Phase B).
|
||||||
|
|
||||||
|
Guarded to the `deploy` stage: "Confirm Deploy" is only meaningful on the
|
||||||
|
approval-pending `deploy` stage (Phase A advanced the task there). On any
|
||||||
|
other stage it is a no-op-with-log, so a stray Confirm Deploy can never
|
||||||
|
perturb another gate.
|
||||||
|
|
||||||
|
Routes to the unified stage engine with ``confirm_deploy=True`` so ONLY this
|
||||||
|
path initiates Phase B; a plain Approved on `deploy` stays a no-op (TRZ-3).
|
||||||
|
"""
|
||||||
|
plane_id = str(data.get("id") or "")
|
||||||
|
task = get_task_by_plane_id(plane_id)
|
||||||
|
if not task:
|
||||||
|
logger.warning(f"Confirm Deploy for {plane_id} but no task found, ignoring")
|
||||||
|
return
|
||||||
|
|
||||||
|
task_id = task["id"]
|
||||||
|
current_stage = task["stage"]
|
||||||
|
repo = task["repo"]
|
||||||
|
work_item_id = task.get("work_item_id", "")
|
||||||
|
branch = task.get("branch", "")
|
||||||
|
|
||||||
|
if current_stage != "deploy":
|
||||||
|
logger.info(
|
||||||
|
f"Confirm Deploy for {plane_id} but stage is '{current_stage}' "
|
||||||
|
f"(not 'deploy'); no-op"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Task {task_id}: Confirm Deploy status on `deploy` -> initiate Phase B prod deploy"
|
||||||
|
)
|
||||||
|
await _try_advance_stage(
|
||||||
|
task_id, current_stage, repo, work_item_id, branch, confirm_deploy=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def handle_status_start(data: dict, project_id: str = ""):
|
async def handle_status_start(data: dict, project_id: str = ""):
|
||||||
"""An issue moved into In Progress.
|
"""An issue moved into In Progress.
|
||||||
|
|
||||||
@@ -633,7 +679,8 @@ async def _rollback_stage(
|
|||||||
|
|
||||||
|
|
||||||
async def _try_advance_stage(
|
async def _try_advance_stage(
|
||||||
task_id: int, current_stage: str, repo: str, work_item_id: str, branch: str
|
task_id: int, current_stage: str, repo: str, work_item_id: str, branch: str,
|
||||||
|
confirm_deploy: bool = False,
|
||||||
):
|
):
|
||||||
"""Thin async wrapper over the unified stage engine (ORCH-4 / M-3).
|
"""Thin async wrapper over the unified stage engine (ORCH-4 / M-3).
|
||||||
|
|
||||||
@@ -642,10 +689,15 @@ async def _try_advance_stage(
|
|||||||
is synchronous. We run it off the event loop via asyncio.to_thread so there
|
is synchronous. We run it off the event loop via asyncio.to_thread so there
|
||||||
is exactly one implementation shared with the launcher.
|
is exactly one implementation shared with the launcher.
|
||||||
|
|
||||||
finished_agent is None on this webhook path (a human Approved status change,
|
finished_agent is None on this webhook path (a human status change, not a
|
||||||
not a finished agent), so the agent-specific rollback branches inside the
|
finished agent), so the agent-specific rollback branches inside the engine
|
||||||
engine intentionally do not trigger — the webhook path only runs the QG and
|
intentionally do not trigger — the webhook path only runs the QG and either
|
||||||
either advances or reports the failure.
|
advances or reports the failure.
|
||||||
|
|
||||||
|
ORCH-059: ``confirm_deploy`` is threaded through (keyword-only on
|
||||||
|
advance_stage). It is True ONLY on the "Confirm Deploy" path
|
||||||
|
(handle_confirm_deploy) and gates Phase B of the self-hosting prod deploy; the
|
||||||
|
plain Approved path (handle_verdict) leaves it at the default False.
|
||||||
"""
|
"""
|
||||||
import asyncio
|
import asyncio
|
||||||
from ..stage_engine import advance_stage
|
from ..stage_engine import advance_stage
|
||||||
@@ -658,6 +710,7 @@ async def _try_advance_stage(
|
|||||||
work_item_id,
|
work_item_id,
|
||||||
branch,
|
branch,
|
||||||
None,
|
None,
|
||||||
|
confirm_deploy=confirm_deploy,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -165,3 +165,82 @@ def test_staging_infra_tolerance_env_override_true(monkeypatch):
|
|||||||
"""The field is read verbatim from its ORCH_* env var."""
|
"""The field is read verbatim from its ORCH_* env var."""
|
||||||
monkeypatch.setenv("ORCH_STAGING_INFRA_TOLERANCE_ENABLED", "true")
|
monkeypatch.setenv("ORCH_STAGING_INFRA_TOLERANCE_ENABLED", "true")
|
||||||
assert Settings().staging_infra_tolerance_enabled is True
|
assert Settings().staging_infra_tolerance_enabled is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ORCH-065 / TC-20: reaper_* + lease_reclaim_* settings defaults + env override.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
_REAPER_ENV = (
|
||||||
|
"ORCH_REAPER_ENABLED",
|
||||||
|
"ORCH_REAPER_INTERVAL_S",
|
||||||
|
"ORCH_REAPER_DEAD_TICKS",
|
||||||
|
"ORCH_REAPER_MAX_RUNNING_S",
|
||||||
|
"ORCH_LEASE_RECLAIM_ENABLED",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reaper_settings_defaults(monkeypatch):
|
||||||
|
"""TC-20 / §5: documented defaults when no env is set."""
|
||||||
|
for name in _REAPER_ENV:
|
||||||
|
monkeypatch.delenv(name, raising=False)
|
||||||
|
s = Settings()
|
||||||
|
assert s.reaper_enabled is True
|
||||||
|
assert s.reaper_interval_s == 60
|
||||||
|
assert s.reaper_dead_ticks == 2
|
||||||
|
assert s.reaper_max_running_s == 3600
|
||||||
|
assert s.lease_reclaim_enabled is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_reaper_settings_env_override(monkeypatch):
|
||||||
|
"""TC-20 / §5 / AC-14: each field is read from its ORCH_* env var."""
|
||||||
|
monkeypatch.setenv("ORCH_REAPER_ENABLED", "false")
|
||||||
|
monkeypatch.setenv("ORCH_REAPER_INTERVAL_S", "30")
|
||||||
|
monkeypatch.setenv("ORCH_REAPER_DEAD_TICKS", "5")
|
||||||
|
monkeypatch.setenv("ORCH_REAPER_MAX_RUNNING_S", "1200")
|
||||||
|
monkeypatch.setenv("ORCH_LEASE_RECLAIM_ENABLED", "false")
|
||||||
|
s = Settings()
|
||||||
|
assert s.reaper_enabled is False
|
||||||
|
assert s.reaper_interval_s == 30
|
||||||
|
assert s.reaper_dead_ticks == 5
|
||||||
|
assert s.reaper_max_running_s == 1200
|
||||||
|
assert s.lease_reclaim_enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ORCH-065 / TC-19: contracts unchanged — no new stages / QG checks; the
|
||||||
|
# check_branch_mergeable signature is intact (AC-13).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_tc19_stage_transitions_unchanged():
|
||||||
|
"""No new pipeline stage was introduced by ORCH-065."""
|
||||||
|
from src.stages import STAGE_TRANSITIONS
|
||||||
|
assert set(STAGE_TRANSITIONS) == {
|
||||||
|
"created", "analysis", "architecture", "development", "review",
|
||||||
|
"testing", "deploy-staging", "deploy", "done",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_tc19_qg_checks_registry_unchanged():
|
||||||
|
"""No new quality-gate check was added to the registry by ORCH-065."""
|
||||||
|
from src.qg.checks import QG_CHECKS
|
||||||
|
assert set(QG_CHECKS) == {
|
||||||
|
"check_analysis_approved",
|
||||||
|
"check_analysis_complete",
|
||||||
|
"check_architecture_done",
|
||||||
|
"check_ci_green",
|
||||||
|
"check_review_approved",
|
||||||
|
"check_tests_passed",
|
||||||
|
"check_reviewer_verdict",
|
||||||
|
"check_tests_local",
|
||||||
|
"check_deploy_status",
|
||||||
|
"check_staging_status",
|
||||||
|
"check_branch_mergeable",
|
||||||
|
"check_staging_image_fresh",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_tc19_check_branch_mergeable_signature_intact():
|
||||||
|
"""check_branch_mergeable still takes exactly (repo, work_item_id, branch)."""
|
||||||
|
import inspect
|
||||||
|
from src.qg.checks import check_branch_mergeable
|
||||||
|
params = list(inspect.signature(check_branch_mergeable).parameters)
|
||||||
|
assert params == ["repo", "work_item_id", "branch"]
|
||||||
|
|||||||
171
tests/test_confirm_deploy_integration.py
Normal file
171
tests/test_confirm_deploy_integration.py
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
"""ORCH-059 TC-10/11/12: end-to-end routing from a Plane webhook payload through
|
||||||
|
handle_issue_updated into the stage engine, with the host deploy mocked.
|
||||||
|
|
||||||
|
Contract (AC-2, AC-3, AC-8):
|
||||||
|
* TC-10 — task on `deploy` + webhook "Confirm Deploy" -> initiate_deploy called,
|
||||||
|
`deploy-finalizer` enqueued, `initiated` marker written.
|
||||||
|
* TC-11 — task on `deploy` + webhook "Approved" -> NO prod deploy initiated, the
|
||||||
|
task stays on `deploy` (no rollback, no advance to done).
|
||||||
|
* TC-12 — non-self repo: verdict statuses on `deploy` do not change deploy
|
||||||
|
behaviour (self_deploy_applies == False; the confirm-deploy branch is inert).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_test_db = os.path.join(tempfile.gettempdir(), "test_orch_confirm_e2e.db")
|
||||||
|
os.environ["ORCH_DB_PATH"] = _test_db
|
||||||
|
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
|
||||||
|
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||||
|
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock # noqa: E402
|
||||||
|
|
||||||
|
import src.db as _db # noqa: E402
|
||||||
|
from src.db import init_db, get_db # noqa: E402
|
||||||
|
from src import stage_engine # noqa: E402
|
||||||
|
from src import self_deploy # noqa: E402
|
||||||
|
import src.plane_sync as plane_sync # noqa: E402
|
||||||
|
import src.webhooks.plane as wh # noqa: E402
|
||||||
|
|
||||||
|
IN_PROGRESS = "11111111-1111-1111-1111-111111111111"
|
||||||
|
APPROVED = "22222222-2222-2222-2222-222222222222"
|
||||||
|
REJECTED = "33333333-3333-3333-3333-333333333333"
|
||||||
|
CONFIRM = "44444444-4444-4444-4444-444444444444"
|
||||||
|
|
||||||
|
# ORCH project: Confirm Deploy resolved. enduro-like project: NO confirm_deploy key.
|
||||||
|
_STATES_SELF = {
|
||||||
|
"in_progress": IN_PROGRESS,
|
||||||
|
"approved": APPROVED,
|
||||||
|
"rejected": REJECTED,
|
||||||
|
"confirm_deploy": CONFIRM,
|
||||||
|
}
|
||||||
|
_STATES_NONSELF = {
|
||||||
|
"in_progress": IN_PROGRESS,
|
||||||
|
"approved": APPROVED,
|
||||||
|
"rejected": REJECTED,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def fresh_db(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(_db.settings, "db_path", _test_db)
|
||||||
|
if os.path.exists(_test_db):
|
||||||
|
os.unlink(_test_db)
|
||||||
|
init_db()
|
||||||
|
monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path))
|
||||||
|
monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path))
|
||||||
|
monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True)
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def silence_engine(monkeypatch):
|
||||||
|
for name in (
|
||||||
|
"notify_stage_change", "notify_qg_failure", "send_telegram",
|
||||||
|
"plane_notify_stage", "plane_notify_qg", "plane_add_comment",
|
||||||
|
"set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress",
|
||||||
|
"set_issue_blocked", "set_issue_done",
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(stage_engine, name, MagicMock(), raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_task(stage, repo, branch, wi, plane_id):
|
||||||
|
conn = get_db()
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(plane_id, wi, repo, branch, stage),
|
||||||
|
)
|
||||||
|
task_id = cur.lastrowid
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return task_id
|
||||||
|
|
||||||
|
|
||||||
|
def _stage(task_id):
|
||||||
|
conn = get_db()
|
||||||
|
row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||||
|
conn.close()
|
||||||
|
return row[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _jobs():
|
||||||
|
conn = get_db()
|
||||||
|
rows = conn.execute("SELECT agent FROM jobs ORDER BY id").fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [r[0] for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(state_uuid, plane_id):
|
||||||
|
return {"id": plane_id, "state": {"id": state_uuid}}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TC-10: E2E Confirm Deploy -> prod deploy initiated
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tc10_confirm_deploy_e2e_initiates(monkeypatch):
|
||||||
|
monkeypatch.setattr(plane_sync, "get_project_states", lambda pid: _STATES_SELF)
|
||||||
|
initiate = MagicMock(return_value=(True, "ok"))
|
||||||
|
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate)
|
||||||
|
|
||||||
|
task_id = _make_task("deploy", "orchestrator", "feature/ORCH-059-x",
|
||||||
|
"ORCH-059", "plane-ORCH-059")
|
||||||
|
|
||||||
|
await wh.handle_issue_updated(_payload(CONFIRM, "plane-ORCH-059"), "orch-proj")
|
||||||
|
|
||||||
|
initiate.assert_called_once()
|
||||||
|
assert "deploy-finalizer" in _jobs()
|
||||||
|
assert self_deploy.has_marker("orchestrator", "ORCH-059", self_deploy.INITIATED)
|
||||||
|
# Verdict comes later via the finalizer — still on `deploy`.
|
||||||
|
assert _stage(task_id) == "deploy"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TC-11: E2E Approved -> no prod deploy, task stays on deploy
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tc11_approved_e2e_noop(monkeypatch):
|
||||||
|
monkeypatch.setattr(plane_sync, "get_project_states", lambda pid: _STATES_SELF)
|
||||||
|
initiate = MagicMock(return_value=(True, "ok"))
|
||||||
|
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate)
|
||||||
|
|
||||||
|
task_id = _make_task("deploy", "orchestrator", "feature/ORCH-059-x",
|
||||||
|
"ORCH-059", "plane-ORCH-059")
|
||||||
|
|
||||||
|
await wh.handle_issue_updated(_payload(APPROVED, "plane-ORCH-059"), "orch-proj")
|
||||||
|
|
||||||
|
initiate.assert_not_called()
|
||||||
|
assert "deploy-finalizer" not in _jobs()
|
||||||
|
assert _stage(task_id) == "deploy" # no rollback, no advance to done
|
||||||
|
assert not self_deploy.has_marker("orchestrator", "ORCH-059", self_deploy.INITIATED)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TC-12: non-self repo -> confirm-deploy branch inert (fail-closed, no key)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tc12_non_self_repo_unaffected(monkeypatch):
|
||||||
|
# Non-self project has no confirm_deploy key at all -> the branch never fires.
|
||||||
|
monkeypatch.setattr(plane_sync, "get_project_states", lambda pid: _STATES_NONSELF)
|
||||||
|
initiate = MagicMock(return_value=(True, "ok"))
|
||||||
|
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate)
|
||||||
|
# Stub the deploy gate so the legacy non-self path stays deterministic (no
|
||||||
|
# real git/network); its verdict is irrelevant to this test's assertions.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
stage_engine, "QG_CHECKS",
|
||||||
|
{**stage_engine.QG_CHECKS, "check_deploy_status": lambda *a, **k: (True, "ok")},
|
||||||
|
)
|
||||||
|
|
||||||
|
task_id = _make_task("deploy", "enduro-trails", "feature/ET-009-x",
|
||||||
|
"ET-009", "plane-ET-009")
|
||||||
|
|
||||||
|
# An Approved on a non-self deploy task does not initiate self-deploy logic.
|
||||||
|
await wh.handle_issue_updated(_payload(APPROVED, "plane-ET-009"), "enduro-proj")
|
||||||
|
|
||||||
|
initiate.assert_not_called()
|
||||||
|
# The (absent) Confirm Deploy status simply maps to no pipeline action.
|
||||||
|
assert self_deploy.self_deploy_applies("enduro-trails") is False
|
||||||
@@ -139,12 +139,14 @@ def test_tc06_approved_calls_prod_hook_exactly_once(monkeypatch):
|
|||||||
ssh_run = MagicMock(return_value=MagicMock(returncode=0, stdout="", stderr=""))
|
ssh_run = MagicMock(return_value=MagicMock(returncode=0, stdout="", stderr=""))
|
||||||
monkeypatch.setattr(self_deploy.subprocess, "run", ssh_run)
|
monkeypatch.setattr(self_deploy.subprocess, "run", ssh_run)
|
||||||
|
|
||||||
task_id = _make_task("deploy") # already on deploy, awaiting Approved
|
task_id = _make_task("deploy") # already on deploy, awaiting Confirm Deploy
|
||||||
|
|
||||||
# 1st human Approved -> Phase B initiates the detached deploy.
|
# ORCH-059: Phase B is now triggered by the dedicated "Confirm Deploy" status
|
||||||
|
# (confirm_deploy=True), NOT by a plain Approved. 1st Confirm Deploy ->
|
||||||
|
# Phase B initiates the detached deploy.
|
||||||
res1 = advance_stage(
|
res1 = advance_stage(
|
||||||
task_id, "deploy", "orchestrator", "ORCH-036",
|
task_id, "deploy", "orchestrator", "ORCH-036",
|
||||||
"feature/ORCH-036-x", finished_agent=None,
|
"feature/ORCH-036-x", finished_agent=None, confirm_deploy=True,
|
||||||
)
|
)
|
||||||
assert res1.note == "self-deploy-initiated"
|
assert res1.note == "self-deploy-initiated"
|
||||||
assert ssh_run.call_count == 1
|
assert ssh_run.call_count == 1
|
||||||
@@ -152,10 +154,10 @@ def test_tc06_approved_calls_prod_hook_exactly_once(monkeypatch):
|
|||||||
assert any(j["agent"] == "deploy-finalizer" for j in _jobs())
|
assert any(j["agent"] == "deploy-finalizer" for j in _jobs())
|
||||||
assert self_deploy.has_marker("orchestrator", "ORCH-036", self_deploy.INITIATED)
|
assert self_deploy.has_marker("orchestrator", "ORCH-036", self_deploy.INITIATED)
|
||||||
|
|
||||||
# 2nd (duplicate) Approved -> idempotent no-op, hook NOT called again.
|
# 2nd (duplicate) Confirm Deploy -> idempotent no-op, hook NOT called again.
|
||||||
res2 = advance_stage(
|
res2 = advance_stage(
|
||||||
task_id, "deploy", "orchestrator", "ORCH-036",
|
task_id, "deploy", "orchestrator", "ORCH-036",
|
||||||
"feature/ORCH-036-x", finished_agent=None,
|
"feature/ORCH-036-x", finished_agent=None, confirm_deploy=True,
|
||||||
)
|
)
|
||||||
assert res2.note == "self-deploy-already-initiated"
|
assert res2.note == "self-deploy-already-initiated"
|
||||||
assert ssh_run.call_count == 1 # still exactly one prod deploy
|
assert ssh_run.call_count == 1 # still exactly one prod deploy
|
||||||
|
|||||||
388
tests/test_job_reaper.py
Normal file
388
tests/test_job_reaper.py
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
"""ORCH-065: job-reaper unit tests (TC-01..TC-08, TC-21).
|
||||||
|
|
||||||
|
The reaper never spawns claude; we drive the DB directly (a 'running' jobs row +
|
||||||
|
optional agent_runs exit_code/pid) and assert the terminal flip + side-effects.
|
||||||
|
``os.kill`` liveness is monkeypatched so a 'dead'/'alive' pid is deterministic.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Override env before importing app modules (same convention as test_queue.py).
|
||||||
|
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch_reaper.db")
|
||||||
|
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
|
||||||
|
os.environ["ORCH_GITEA_TOKEN"] = "test-token"
|
||||||
|
os.environ["ORCH_PLANE_API_TOKEN"] = "test-token"
|
||||||
|
|
||||||
|
import src.db as db
|
||||||
|
from src.db import init_db, get_db, enqueue_job, get_job
|
||||||
|
import src.job_reaper as jr
|
||||||
|
from src.job_reaper import JobReaper
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def fresh_db(tmp_path, monkeypatch):
|
||||||
|
dbfile = tmp_path / "reaper.db"
|
||||||
|
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
|
||||||
|
init_db()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
# --- helpers ----------------------------------------------------------------
|
||||||
|
def _make_running_job(agent="developer", repo="orchestrator", task_id=None,
|
||||||
|
pid=None, age_s=0, attempts=0, max_attempts=2,
|
||||||
|
run_id=None, exit_code=None, finished_age_s=600):
|
||||||
|
"""Insert a job already in 'running' with the given pid/age/attempts.
|
||||||
|
|
||||||
|
started_at is back-dated by ``age_s`` seconds so running_age_s reflects it.
|
||||||
|
When ``exit_code`` is given an agent_runs row is created and linked (Tier-2);
|
||||||
|
its ``finished_at`` is back-dated by ``finished_age_s`` seconds so the
|
||||||
|
Tier-2 finalization grace (``reaper_finalize_grace_s``, default 300) is
|
||||||
|
satisfied by default — pass a small ``finished_age_s`` to exercise the
|
||||||
|
"monitor may still be finalizing" deferral.
|
||||||
|
"""
|
||||||
|
conn = get_db()
|
||||||
|
if run_id is None and exit_code is not None:
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO agent_runs (task_id, agent, finished_at, exit_code) "
|
||||||
|
"VALUES (?, ?, datetime('now', ?), ?)",
|
||||||
|
(task_id, agent, f"-{int(finished_age_s)} seconds", exit_code),
|
||||||
|
)
|
||||||
|
run_id = cur.lastrowid
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO jobs (agent, repo, task_id, status, attempts, max_attempts, "
|
||||||
|
"run_id, pid, started_at) "
|
||||||
|
"VALUES (?, ?, ?, 'running', ?, ?, ?, ?, datetime('now', ?))",
|
||||||
|
(agent, repo, task_id, attempts, max_attempts, run_id, pid,
|
||||||
|
f"-{int(age_s)} seconds"),
|
||||||
|
)
|
||||||
|
job_id = cur.lastrowid
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return job_id
|
||||||
|
|
||||||
|
|
||||||
|
def _make_task(repo="orchestrator", branch="feature/x", stage="development",
|
||||||
|
work_item_id="ORCH-1"):
|
||||||
|
conn = get_db()
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(work_item_id, work_item_id, repo, branch, stage),
|
||||||
|
)
|
||||||
|
tid = cur.lastrowid
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return tid
|
||||||
|
|
||||||
|
|
||||||
|
def _dead_pid(monkeypatch):
|
||||||
|
"""Force merge_gate.pid_alive -> False (process gone) for the reaper."""
|
||||||
|
import src.merge_gate as mg
|
||||||
|
monkeypatch.setattr(mg, "pid_alive", lambda pid: False)
|
||||||
|
|
||||||
|
|
||||||
|
def _alive_pid(monkeypatch):
|
||||||
|
import src.merge_gate as mg
|
||||||
|
monkeypatch.setattr(mg, "pid_alive", lambda pid: True)
|
||||||
|
|
||||||
|
|
||||||
|
# --- TC-01: dead executor -> reaped without process restart -----------------
|
||||||
|
def test_tc01_dead_pid_reaped_to_queued(monkeypatch):
|
||||||
|
_dead_pid(monkeypatch)
|
||||||
|
jid = _make_running_job(pid=999999, attempts=0, max_attempts=2)
|
||||||
|
r = JobReaper()
|
||||||
|
r.reap_once() # tick 1 (streak=1, dead_ticks default 2 -> not yet)
|
||||||
|
assert get_job(jid)["status"] == "running"
|
||||||
|
r.reap_once() # tick 2 -> reaped
|
||||||
|
assert get_job(jid)["status"] == "queued"
|
||||||
|
assert r.reaped_total == 1
|
||||||
|
assert r.last_reaped["job_id"] == jid
|
||||||
|
|
||||||
|
|
||||||
|
# --- TC-02: live agent within timeout is NEVER reaped -----------------------
|
||||||
|
def test_tc02_alive_pid_never_reaped(monkeypatch):
|
||||||
|
_alive_pid(monkeypatch)
|
||||||
|
jid = _make_running_job(pid=4321, age_s=10)
|
||||||
|
r = JobReaper()
|
||||||
|
for _ in range(5):
|
||||||
|
r.reap_once()
|
||||||
|
assert get_job(jid)["status"] == "running"
|
||||||
|
assert r.reaped_total == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_tc02_alive_within_max_running_not_reaped(monkeypatch):
|
||||||
|
_alive_pid(monkeypatch)
|
||||||
|
monkeypatch.setattr(db.settings, "reaper_max_running_s", 3600)
|
||||||
|
jid = _make_running_job(pid=4321, age_s=1800) # < ceiling, alive
|
||||||
|
r = JobReaper()
|
||||||
|
r.reap_once()
|
||||||
|
assert get_job(jid)["status"] == "running"
|
||||||
|
|
||||||
|
|
||||||
|
# --- TC-03: zombie only after reaper_dead_ticks consecutive ticks -----------
|
||||||
|
def test_tc03_requires_consecutive_dead_ticks(monkeypatch):
|
||||||
|
monkeypatch.setattr(db.settings, "reaper_dead_ticks", 3)
|
||||||
|
import src.merge_gate as mg
|
||||||
|
# Dead, dead, ALIVE (resets), dead, dead, dead -> reaped only on the 6th tick.
|
||||||
|
seq = iter([False, False, True, False, False, False])
|
||||||
|
monkeypatch.setattr(mg, "pid_alive", lambda pid: next(seq))
|
||||||
|
jid = _make_running_job(pid=999998)
|
||||||
|
r = JobReaper()
|
||||||
|
for _ in range(5):
|
||||||
|
r.reap_once()
|
||||||
|
assert get_job(jid)["status"] == "running"
|
||||||
|
r.reap_once() # 6th tick: third CONSECUTIVE dead -> reaped
|
||||||
|
assert get_job(jid)["status"] == "queued"
|
||||||
|
|
||||||
|
|
||||||
|
# --- TC-04: backstop ceiling reaps even when liveness is unknown ------------
|
||||||
|
def test_tc04_backstop_ceiling(monkeypatch):
|
||||||
|
_alive_pid(monkeypatch) # liveness says "alive", but age exceeds the ceiling
|
||||||
|
monkeypatch.setattr(db.settings, "reaper_max_running_s", 100)
|
||||||
|
jid = _make_running_job(pid=4321, age_s=500)
|
||||||
|
r = JobReaper()
|
||||||
|
r.reap_once()
|
||||||
|
assert get_job(jid)["status"] == "queued"
|
||||||
|
assert r.reaped_total == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_tc04_backstop_no_pid(monkeypatch):
|
||||||
|
monkeypatch.setattr(db.settings, "reaper_max_running_s", 100)
|
||||||
|
jid = _make_running_job(pid=None, age_s=500)
|
||||||
|
r = JobReaper()
|
||||||
|
r.reap_once()
|
||||||
|
assert get_job(jid)["status"] == "queued"
|
||||||
|
|
||||||
|
|
||||||
|
# --- TC-05: correct outcome by exit_code (Tier-2) ---------------------------
|
||||||
|
def _gate(monkeypatch, green: bool):
|
||||||
|
"""Force the reaper's READ-ONLY gate pre-evaluation to green/red."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
JobReaper, "_gate_is_green",
|
||||||
|
lambda self, stage, job, branch, wid: green,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tc05_exit0_gate_green_done(monkeypatch):
|
||||||
|
# A developer job runs to LEAVE the 'architecture' stage (-> 'development').
|
||||||
|
tid = _make_task(stage="architecture")
|
||||||
|
jid = _make_running_job(agent="developer", task_id=tid, exit_code=0)
|
||||||
|
_gate(monkeypatch, green=True)
|
||||||
|
# gate green -> the claim flips 'done' FIRST, then the advance runs.
|
||||||
|
import src.agents.launcher as L
|
||||||
|
monkeypatch.setattr(
|
||||||
|
L.launcher, "_try_advance_stage",
|
||||||
|
lambda run_id, agent, repo, branch: db.update_task_stage(tid, "development"),
|
||||||
|
)
|
||||||
|
r = JobReaper()
|
||||||
|
r.reap_once()
|
||||||
|
assert get_job(jid)["status"] == "done"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tc05_exit0_gate_red_requeues(monkeypatch):
|
||||||
|
tid = _make_task(stage="architecture")
|
||||||
|
jid = _make_running_job(agent="developer", task_id=tid, exit_code=0,
|
||||||
|
attempts=0, max_attempts=2)
|
||||||
|
_gate(monkeypatch, green=False) # read-only pre-eval says red
|
||||||
|
# The advance path must NEVER run when the gate is red (claim-before-act).
|
||||||
|
import src.agents.launcher as L
|
||||||
|
called = []
|
||||||
|
monkeypatch.setattr(L.launcher, "_try_advance_stage",
|
||||||
|
lambda run_id, agent, repo, branch: called.append(1))
|
||||||
|
r = JobReaper()
|
||||||
|
r.reap_once()
|
||||||
|
assert get_job(jid)["status"] == "queued" # exit0 but gate red -> not 'done'
|
||||||
|
assert not called, "no advance/side-effects on a red gate"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tc05_exit0_already_advanced_done_no_side_effects(monkeypatch):
|
||||||
|
# Stage already past the developer candidate set -> idempotent clean 'done'
|
||||||
|
# with NO advance call (the monitor already advanced before dying).
|
||||||
|
tid = _make_task(stage="development") # developer's candidate is 'architecture'
|
||||||
|
jid = _make_running_job(agent="developer", task_id=tid, exit_code=0)
|
||||||
|
import src.agents.launcher as L
|
||||||
|
called = []
|
||||||
|
monkeypatch.setattr(L.launcher, "_try_advance_stage",
|
||||||
|
lambda run_id, agent, repo, branch: called.append(1))
|
||||||
|
r = JobReaper()
|
||||||
|
r.reap_once()
|
||||||
|
assert get_job(jid)["status"] == "done"
|
||||||
|
assert not called, "already-advanced reap must not re-advance"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tc05_nonzero_exit_requeue_then_failed(monkeypatch):
|
||||||
|
sent = []
|
||||||
|
monkeypatch.setattr(jr, "JobReaper", JobReaper)
|
||||||
|
tid = _make_task(stage="development")
|
||||||
|
jid = _make_running_job(agent="developer", task_id=tid, exit_code=1,
|
||||||
|
attempts=1, max_attempts=2)
|
||||||
|
r = JobReaper()
|
||||||
|
import src.notifications as notif
|
||||||
|
monkeypatch.setattr(notif, "send_telegram", lambda *a, **k: sent.append(a))
|
||||||
|
r.reap_once() # attempts(1) < max(2) -> queued
|
||||||
|
assert get_job(jid)["status"] == "queued"
|
||||||
|
|
||||||
|
# Now exhaust the budget.
|
||||||
|
jid2 = _make_running_job(agent="developer", task_id=tid, exit_code=1,
|
||||||
|
attempts=2, max_attempts=2)
|
||||||
|
r.reap_once()
|
||||||
|
assert get_job(jid2)["status"] == "failed"
|
||||||
|
assert sent, "failed reap must send a Telegram alert"
|
||||||
|
|
||||||
|
|
||||||
|
# --- TC-05b: Tier-2 finalization grace (live monitor still finalizing) -------
|
||||||
|
def test_tc05_tier2_within_grace_not_reaped(monkeypatch):
|
||||||
|
"""exit_code freshly recorded -> a LIVE monitor may still be finalizing.
|
||||||
|
|
||||||
|
The reaper must NOT reap it within ``reaper_finalize_grace_s`` (FR-1.3/AC-3:
|
||||||
|
a live finalizing monitor — git push / PR / Plane comments — is never reaped,
|
||||||
|
no dup advance / enqueue).
|
||||||
|
"""
|
||||||
|
monkeypatch.setattr(db.settings, "reaper_finalize_grace_s", 300)
|
||||||
|
tid = _make_task(stage="architecture")
|
||||||
|
# exit_code recorded only 5s ago -> still inside the finalization grace.
|
||||||
|
jid = _make_running_job(agent="developer", task_id=tid, exit_code=0,
|
||||||
|
finished_age_s=5)
|
||||||
|
import src.agents.launcher as L
|
||||||
|
called = []
|
||||||
|
monkeypatch.setattr(L.launcher, "_try_advance_stage",
|
||||||
|
lambda run_id, agent, repo, branch: called.append(1))
|
||||||
|
r = JobReaper()
|
||||||
|
r.reap_once()
|
||||||
|
assert get_job(jid)["status"] == "running" # deferred, NOT reaped
|
||||||
|
assert r.reaped_total == 0
|
||||||
|
assert not called, "a live finalizing monitor must not be advanced by the reaper"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tc05_tier2_after_grace_reaped(monkeypatch):
|
||||||
|
"""Once exit_code has been recorded longer than the grace, the monitor is
|
||||||
|
genuinely dead and the Tier-2 reap proceeds."""
|
||||||
|
monkeypatch.setattr(db.settings, "reaper_finalize_grace_s", 300)
|
||||||
|
tid = _make_task(stage="architecture")
|
||||||
|
jid = _make_running_job(agent="developer", task_id=tid, exit_code=0,
|
||||||
|
finished_age_s=600) # well past the grace
|
||||||
|
_gate(monkeypatch, green=True)
|
||||||
|
import src.agents.launcher as L
|
||||||
|
monkeypatch.setattr(
|
||||||
|
L.launcher, "_try_advance_stage",
|
||||||
|
lambda run_id, agent, repo, branch: db.update_task_stage(tid, "development"),
|
||||||
|
)
|
||||||
|
r = JobReaper()
|
||||||
|
r.reap_once()
|
||||||
|
assert get_job(jid)["status"] == "done"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tc05_tier2_lost_claim_no_side_effects(monkeypatch):
|
||||||
|
"""claim-BEFORE-act: when another actor (a late monitor / startup requeue)
|
||||||
|
moves the row out of 'running' AFTER the reaper read it but BEFORE the atomic
|
||||||
|
claim, the reaper's claim loses (rowcount==0) and it performs NO advance side
|
||||||
|
effects (no dup advance / dup enqueue) — ADR-001 Р-1."""
|
||||||
|
monkeypatch.setattr(db.settings, "reaper_finalize_grace_s", 0)
|
||||||
|
tid = _make_task(stage="architecture")
|
||||||
|
jid = _make_running_job(agent="developer", task_id=tid, exit_code=0,
|
||||||
|
finished_age_s=10)
|
||||||
|
import src.agents.launcher as L
|
||||||
|
called = []
|
||||||
|
monkeypatch.setattr(L.launcher, "_try_advance_stage",
|
||||||
|
lambda run_id, agent, repo, branch: called.append(1))
|
||||||
|
|
||||||
|
# The read-only gate pre-eval reports green, but the row is concurrently
|
||||||
|
# claimed by someone else right before the reaper's atomic claim runs.
|
||||||
|
def green_then_steal(self, stage, job, branch, wid):
|
||||||
|
db.requeue_running_jobs() # another actor wins the 'running' row first
|
||||||
|
return True
|
||||||
|
|
||||||
|
monkeypatch.setattr(JobReaper, "_gate_is_green", green_then_steal)
|
||||||
|
r = JobReaper()
|
||||||
|
r.reap_once()
|
||||||
|
# Reaper lost the atomic claim -> no advance, no double work. The row stays
|
||||||
|
# where the winner left it ('queued'), not flipped to 'done' by the reaper.
|
||||||
|
assert not called, "reaper that lost the claim must not advance/enqueue"
|
||||||
|
assert get_job(jid)["status"] == "queued"
|
||||||
|
assert r.reaped_total == 0
|
||||||
|
|
||||||
|
|
||||||
|
# --- TC-06: atomicity — reaper vs requeue_running_jobs (status guard) --------
|
||||||
|
def test_tc06_atomic_no_double_reap(monkeypatch):
|
||||||
|
_dead_pid(monkeypatch)
|
||||||
|
monkeypatch.setattr(db.settings, "reaper_dead_ticks", 1)
|
||||||
|
jid = _make_running_job(pid=999997, attempts=0, max_attempts=2)
|
||||||
|
# Simulate the startup requeue winning the row first.
|
||||||
|
n = db.requeue_running_jobs()
|
||||||
|
assert n == 1
|
||||||
|
assert get_job(jid)["status"] == "queued"
|
||||||
|
# The reaper now scans: the row is no longer 'running' -> reap_running_job's
|
||||||
|
# WHERE status='running' guard yields rowcount 0 -> no second processing.
|
||||||
|
r = JobReaper()
|
||||||
|
r.reap_once()
|
||||||
|
assert get_job(jid)["status"] == "queued"
|
||||||
|
assert r.reaped_total == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_tc06_reap_running_job_guard_returns_false_when_not_running():
|
||||||
|
jid = enqueue_job("developer", "orchestrator") # status 'queued', not running
|
||||||
|
assert db.reap_running_job(jid, "done") is False
|
||||||
|
assert get_job(jid)["status"] == "queued"
|
||||||
|
|
||||||
|
|
||||||
|
# --- TC-07: kill-switch reaper_enabled=False -> no-op -----------------------
|
||||||
|
def test_tc07_kill_switch(monkeypatch):
|
||||||
|
_dead_pid(monkeypatch)
|
||||||
|
monkeypatch.setattr(db.settings, "reaper_enabled", False)
|
||||||
|
monkeypatch.setattr(db.settings, "lease_reclaim_enabled", False)
|
||||||
|
jid = _make_running_job(pid=999996, age_s=99999)
|
||||||
|
r = JobReaper()
|
||||||
|
for _ in range(3):
|
||||||
|
r.reap_once()
|
||||||
|
assert get_job(jid)["status"] == "running"
|
||||||
|
assert r.reaped_total == 0
|
||||||
|
|
||||||
|
|
||||||
|
# --- TC-08: never-raise — a DB/OS error in one tick does not propagate -------
|
||||||
|
def test_tc08_never_raise_isolates_per_job(monkeypatch):
|
||||||
|
_dead_pid(monkeypatch)
|
||||||
|
monkeypatch.setattr(db.settings, "reaper_dead_ticks", 1)
|
||||||
|
good = _make_running_job(pid=111, attempts=0, max_attempts=2)
|
||||||
|
bad = _make_running_job(pid=222, attempts=0, max_attempts=2)
|
||||||
|
|
||||||
|
r = JobReaper()
|
||||||
|
orig = r._reap_job
|
||||||
|
|
||||||
|
def boom(job):
|
||||||
|
if job["id"] == bad:
|
||||||
|
raise RuntimeError("simulated per-job failure")
|
||||||
|
return orig(job)
|
||||||
|
|
||||||
|
monkeypatch.setattr(r, "_reap_job", boom)
|
||||||
|
# Must not raise despite the bad job blowing up.
|
||||||
|
r.reap_once()
|
||||||
|
# The good job is still reaped; the bad one is isolated (stays running).
|
||||||
|
assert get_job(good)["status"] == "queued"
|
||||||
|
assert get_job(bad)["status"] == "running"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tc08_reap_once_outer_never_raises(monkeypatch):
|
||||||
|
monkeypatch.setattr(jr, "get_running_jobs",
|
||||||
|
lambda: (_ for _ in ()).throw(RuntimeError("db down")))
|
||||||
|
r = JobReaper()
|
||||||
|
# reap_once swallows... actually get_running_jobs is iterated in the for; the
|
||||||
|
# _tick wrapper guarantees the loop never dies. Assert _tick is safe.
|
||||||
|
r._tick()
|
||||||
|
assert r.last_run_ts is not None
|
||||||
|
|
||||||
|
|
||||||
|
# --- TC-21: startup lease-reclaim + reaper start/stop smoke -----------------
|
||||||
|
def test_tc21_reaper_start_stop_smoke():
|
||||||
|
r = JobReaper(interval_s=0.05)
|
||||||
|
r.start()
|
||||||
|
assert r._thread is not None and r._thread.is_alive()
|
||||||
|
r.stop(timeout=2)
|
||||||
|
assert not r._thread.is_alive()
|
||||||
|
|
||||||
|
|
||||||
|
def test_tc21_reclaim_all_stale_leases_callable(monkeypatch):
|
||||||
|
# No lease files present -> 0 reclaimed, never raises (registration smoke).
|
||||||
|
monkeypatch.setattr(db.settings, "lease_reclaim_enabled", True)
|
||||||
|
assert jr.reclaim_all_stale_leases() == 0
|
||||||
@@ -11,6 +11,7 @@ import subprocess
|
|||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
# Env before importing app modules (same convention as the other suites).
|
# Env before importing app modules (same convention as the other suites).
|
||||||
@@ -299,3 +300,85 @@ def test_tc11_release_missing_is_noop(lease_dir):
|
|||||||
# Releasing a non-existent lease never raises.
|
# Releasing a non-existent lease never raises.
|
||||||
merge_gate.release_merge_lease("orchestrator", "feature/none")
|
merge_gate.release_merge_lease("orchestrator", "feature/none")
|
||||||
merge_gate.release_merge_lease("orchestrator") # force form
|
merge_gate.release_merge_lease("orchestrator") # force form
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ORCH-065 / TC-16: idempotent merge finalization — pr_already_merged guard.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class _FakeResp:
|
||||||
|
def __init__(self, status_code, payload):
|
||||||
|
self.status_code = status_code
|
||||||
|
self._payload = payload
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return self._payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_tc16_pr_already_merged_true(monkeypatch):
|
||||||
|
"""A merged PR -> True so a re-driven/reaped task is a no-op (no second merge)."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
httpx, "get",
|
||||||
|
lambda *a, **k: _FakeResp(200, [{"number": 7, "merged": True}]),
|
||||||
|
)
|
||||||
|
assert merge_gate.pr_already_merged("orchestrator", "feature/x") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_tc16_pr_open_not_merged_false(monkeypatch):
|
||||||
|
"""An open / not-yet-merged PR -> False (the normal merge path proceeds)."""
|
||||||
|
monkeypatch.setattr(
|
||||||
|
httpx, "get",
|
||||||
|
lambda *a, **k: _FakeResp(200, [{"number": 7, "merged": False}]),
|
||||||
|
)
|
||||||
|
assert merge_gate.pr_already_merged("orchestrator", "feature/x") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_tc16_pr_no_pr_false(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
httpx, "get", lambda *a, **k: _FakeResp(200, []),
|
||||||
|
)
|
||||||
|
assert merge_gate.pr_already_merged("orchestrator", "feature/x") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_tc16_pr_already_merged_never_raises(monkeypatch):
|
||||||
|
"""Any HTTP/parse error -> False (conservative), never an exception (AC-9)."""
|
||||||
|
def boom(*a, **k):
|
||||||
|
raise RuntimeError("gitea down")
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "get", boom)
|
||||||
|
assert merge_gate.pr_already_merged("orchestrator", "feature/x") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_tc16_pr_non_200_false(monkeypatch):
|
||||||
|
monkeypatch.setattr(
|
||||||
|
httpx, "get", lambda *a, **k: _FakeResp(500, None),
|
||||||
|
)
|
||||||
|
assert merge_gate.pr_already_merged("orchestrator", "feature/x") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ORCH-065 / TC-16 (wiring): the merge path consults the guard.
|
||||||
|
#
|
||||||
|
# pr_already_merged is consulted by the actual merge actor — the deployer agent
|
||||||
|
# (webhooks/gitea.py: "the deployer merges the PR at the START of its run"). The
|
||||||
|
# `deploy` stage can be re-driven by the job-reaper, so the deployer prompt MUST
|
||||||
|
# instruct an idempotent pre-merge consult of pr_already_merged (ADR-001 Р-3 /
|
||||||
|
# README / CHANGELOG). This test fails if that wiring regresses, so the guard can
|
||||||
|
# never silently become dead code again while the docs claim it is consulted.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_tc16_deployer_prompt_consults_guard():
|
||||||
|
"""The deployer prompt (merge path) wires the idempotent merge guard."""
|
||||||
|
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
prompt_path = os.path.join(repo_root, ".openclaw", "agents", "deployer.md")
|
||||||
|
with open(prompt_path, "r", encoding="utf-8") as f:
|
||||||
|
prompt = f.read()
|
||||||
|
|
||||||
|
# The guard function is named and the prompt instructs consulting it BEFORE merge.
|
||||||
|
assert "pr_already_merged" in prompt, "deployer prompt must name the guard"
|
||||||
|
lowered = prompt.lower()
|
||||||
|
assert "before" in lowered and "merge" in lowered, (
|
||||||
|
"deployer prompt must instruct consulting the guard BEFORE merging"
|
||||||
|
)
|
||||||
|
# The idempotent no-op contract (already merged -> no second merge) is documented.
|
||||||
|
assert "no second merge" in lowered, (
|
||||||
|
"deployer prompt must document the already-merged no-op (AC-11)"
|
||||||
|
)
|
||||||
|
|||||||
@@ -148,3 +148,63 @@ def test_tc24_red_catch_up_fails_and_releases_main_stays_green(race_repo, monkey
|
|||||||
assert _origin_main_sha(origin) == main_before
|
assert _origin_main_sha(origin) == main_before
|
||||||
# The lease was released on failure (a later task can proceed).
|
# The lease was released on failure (a later task can proceed).
|
||||||
assert merge_gate._read_lease(merge_gate._lease_path(repo)) is None
|
assert merge_gate._read_lease(merge_gate._lease_path(repo)) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ORCH-065 / TC-17: recovery — "rebase+re-test green, merge not done, process
|
||||||
|
# died" -> reaper requeues -> the merge re-drives the STANDARD path WITHOUT a
|
||||||
|
# second expensive re-test when safe (the branch is already up-to-date). AC-10.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_tc17_redrive_skips_expensive_retest_when_already_caught_up(
|
||||||
|
race_repo, monkeypatch
|
||||||
|
):
|
||||||
|
repo, origin = race_repo
|
||||||
|
main_before = _origin_main_sha(origin)
|
||||||
|
|
||||||
|
# First pass: B catches up (real rebase onto C1) with a GREEN re-test. This is
|
||||||
|
# the work that completed before the process died — the lease is held, the
|
||||||
|
# branch is now caught up on origin.
|
||||||
|
retest_calls = []
|
||||||
|
|
||||||
|
def _retest(r, b):
|
||||||
|
retest_calls.append((r, b))
|
||||||
|
return True, "re-test green"
|
||||||
|
|
||||||
|
monkeypatch.setattr(merge_gate, "retest_branch", _retest)
|
||||||
|
passed, reason = check_branch_mergeable(repo, "ORCH-B", "feature/B")
|
||||||
|
assert passed is True
|
||||||
|
assert reason == "rebased onto main, re-test green"
|
||||||
|
assert len(retest_calls) == 1 # the expensive re-test ran ONCE
|
||||||
|
|
||||||
|
# The process "died" before the merge: release the lease the way the reaper /
|
||||||
|
# reconciler recovery path would (the row is requeued; the branch stays caught
|
||||||
|
# up because the rebase was already pushed).
|
||||||
|
merge_gate.release_merge_lease(repo, "feature/B")
|
||||||
|
|
||||||
|
# Re-drive (standard path) after recovery: the branch already contains
|
||||||
|
# origin/main, so branch_is_behind_main is False and the gate short-circuits to
|
||||||
|
# the up-to-date pass WITHOUT re-running the expensive rebase+re-test.
|
||||||
|
assert merge_gate.branch_is_behind_main(repo, "feature/B") is False
|
||||||
|
passed2, reason2 = check_branch_mergeable(repo, "ORCH-B", "feature/B")
|
||||||
|
assert passed2 is True
|
||||||
|
assert reason2 == "branch up-to-date with main"
|
||||||
|
assert len(retest_calls) == 1 # NOT re-run on the re-drive (no double cost)
|
||||||
|
# origin/main was never pushed by the gate across the whole recovery.
|
||||||
|
assert _origin_main_sha(origin) == main_before
|
||||||
|
|
||||||
|
|
||||||
|
def test_tc17_pr_already_merged_makes_redrive_a_noop(race_repo, monkeypatch):
|
||||||
|
"""If the PR actually merged before the process died, the idempotency guard
|
||||||
|
reports it so the re-drive is a no-op (no second merge)."""
|
||||||
|
import httpx
|
||||||
|
repo, _ = race_repo
|
||||||
|
|
||||||
|
class _R:
|
||||||
|
status_code = 200
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def json():
|
||||||
|
return [{"merged": True}]
|
||||||
|
|
||||||
|
monkeypatch.setattr(httpx, "get", lambda *a, **k: _R())
|
||||||
|
assert merge_gate.pr_already_merged(repo, "feature/B") is True
|
||||||
|
|||||||
138
tests/test_merge_lease_reclaim.py
Normal file
138
tests/test_merge_lease_reclaim.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"""ORCH-065: proactive stale/dead merge-lease reclaim (TC-10..TC-15).
|
||||||
|
|
||||||
|
Exercises merge_gate.reclaim_stale_lease / pid_alive directly with lease files
|
||||||
|
written into a tmp repos_dir. No git ops run (reclaim only removes the lease
|
||||||
|
file). pid liveness is monkeypatched so 'dead'/'alive' are deterministic.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch_lease.db")
|
||||||
|
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
|
||||||
|
os.environ["ORCH_GITEA_TOKEN"] = "test-token"
|
||||||
|
os.environ["ORCH_PLANE_API_TOKEN"] = "test-token"
|
||||||
|
|
||||||
|
from src import merge_gate
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def repos_dir(tmp_path, monkeypatch):
|
||||||
|
d = tmp_path / "repos"
|
||||||
|
d.mkdir()
|
||||||
|
monkeypatch.setattr(merge_gate.settings, "repos_dir", str(d))
|
||||||
|
monkeypatch.setattr(merge_gate.settings, "lease_reclaim_enabled", True)
|
||||||
|
monkeypatch.setattr(merge_gate.settings, "merge_gate_repos", "") # self-hosting only
|
||||||
|
monkeypatch.setattr(merge_gate.settings, "merge_lock_timeout_s", 300)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def _write_lease(repos_dir, repo, branch="feature/x", pid=1234, age_s=0):
|
||||||
|
path = os.path.join(str(repos_dir), f".merge-lease-{repo}.json")
|
||||||
|
holder = {
|
||||||
|
"branch": branch,
|
||||||
|
"work_item_id": "ORCH-1",
|
||||||
|
"task_id": 1,
|
||||||
|
"acquired_at": time.time() - age_s,
|
||||||
|
"pid": pid,
|
||||||
|
}
|
||||||
|
with open(path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(json.dumps(holder))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _no_telegram(monkeypatch):
|
||||||
|
import src.notifications as notif
|
||||||
|
monkeypatch.setattr(notif, "send_telegram", lambda *a, **k: None)
|
||||||
|
|
||||||
|
|
||||||
|
# --- TC-10: reclaim a lease with a DEAD pid, proactively --------------------
|
||||||
|
def test_tc10_reclaim_dead_pid(repos_dir, monkeypatch):
|
||||||
|
_no_telegram(monkeypatch)
|
||||||
|
path = _write_lease(repos_dir, "orchestrator", pid=999999, age_s=0)
|
||||||
|
monkeypatch.setattr(merge_gate, "pid_alive", lambda pid: False)
|
||||||
|
assert merge_gate.reclaim_stale_lease("orchestrator") is True
|
||||||
|
assert not os.path.exists(path) # lease removed
|
||||||
|
|
||||||
|
|
||||||
|
# --- TC-11: reclaim by TTL is preserved -------------------------------------
|
||||||
|
def test_tc11_reclaim_by_ttl(repos_dir, monkeypatch):
|
||||||
|
_no_telegram(monkeypatch)
|
||||||
|
# pid alive, but the lease is older than the TTL -> still reclaimed.
|
||||||
|
path = _write_lease(repos_dir, "orchestrator", pid=4321, age_s=999)
|
||||||
|
monkeypatch.setattr(merge_gate, "pid_alive", lambda pid: True)
|
||||||
|
assert merge_gate.reclaim_stale_lease("orchestrator") is True
|
||||||
|
assert not os.path.exists(path)
|
||||||
|
|
||||||
|
|
||||||
|
# --- TC-12: a LIVE lease within TTL is NOT released -------------------------
|
||||||
|
def test_tc12_live_lease_protected(repos_dir, monkeypatch):
|
||||||
|
_no_telegram(monkeypatch)
|
||||||
|
path = _write_lease(repos_dir, "orchestrator", pid=4321, age_s=10)
|
||||||
|
monkeypatch.setattr(merge_gate, "pid_alive", lambda pid: True)
|
||||||
|
assert merge_gate.reclaim_stale_lease("orchestrator") is False
|
||||||
|
assert os.path.exists(path) # untouched
|
||||||
|
|
||||||
|
|
||||||
|
# --- TC-13: conditional — non-self-hosting repos are a no-op ----------------
|
||||||
|
def test_tc13_non_scope_repo_noop(repos_dir, monkeypatch):
|
||||||
|
_no_telegram(monkeypatch)
|
||||||
|
path = _write_lease(repos_dir, "enduro-trails", pid=999999, age_s=999)
|
||||||
|
monkeypatch.setattr(merge_gate, "pid_alive", lambda pid: False)
|
||||||
|
assert merge_gate.reclaim_stale_lease("enduro-trails") is False
|
||||||
|
assert os.path.exists(path) # out of scope -> untouched
|
||||||
|
|
||||||
|
|
||||||
|
def test_tc13_merge_gate_repos_csv_scope(repos_dir, monkeypatch):
|
||||||
|
_no_telegram(monkeypatch)
|
||||||
|
monkeypatch.setattr(merge_gate.settings, "merge_gate_repos", "enduro-trails")
|
||||||
|
path = _write_lease(repos_dir, "enduro-trails", pid=999999, age_s=0)
|
||||||
|
monkeypatch.setattr(merge_gate, "pid_alive", lambda pid: False)
|
||||||
|
assert merge_gate.reclaim_stale_lease("enduro-trails") is True
|
||||||
|
assert not os.path.exists(path)
|
||||||
|
|
||||||
|
|
||||||
|
# --- TC-14: never-raise on a read/remove error ------------------------------
|
||||||
|
def test_tc14_never_raise_on_read_error(repos_dir, monkeypatch):
|
||||||
|
_no_telegram(monkeypatch)
|
||||||
|
_write_lease(repos_dir, "orchestrator", pid=1, age_s=999)
|
||||||
|
|
||||||
|
def boom(path):
|
||||||
|
raise OSError("simulated read failure")
|
||||||
|
|
||||||
|
monkeypatch.setattr(merge_gate, "_read_lease", boom)
|
||||||
|
# Must not raise; returns False (could not reclaim).
|
||||||
|
assert merge_gate.reclaim_stale_lease("orchestrator") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_tc14_no_lease_file_is_noop(repos_dir, monkeypatch):
|
||||||
|
_no_telegram(monkeypatch)
|
||||||
|
assert merge_gate.reclaim_stale_lease("orchestrator") is False
|
||||||
|
|
||||||
|
|
||||||
|
# --- TC-15: kill-switch lease_reclaim_enabled=False -------------------------
|
||||||
|
def test_tc15_kill_switch(repos_dir, monkeypatch):
|
||||||
|
_no_telegram(monkeypatch)
|
||||||
|
monkeypatch.setattr(merge_gate.settings, "lease_reclaim_enabled", False)
|
||||||
|
path = _write_lease(repos_dir, "orchestrator", pid=999999, age_s=999)
|
||||||
|
monkeypatch.setattr(merge_gate, "pid_alive", lambda pid: False)
|
||||||
|
assert merge_gate.reclaim_stale_lease("orchestrator") is False
|
||||||
|
assert os.path.exists(path) # proactive reclaim off -> untouched
|
||||||
|
|
||||||
|
|
||||||
|
# --- pid_alive semantics ----------------------------------------------------
|
||||||
|
def test_pid_alive_dead_process():
|
||||||
|
# PID 999999999 almost certainly does not exist.
|
||||||
|
assert merge_gate.pid_alive(999999999) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_pid_alive_self():
|
||||||
|
assert merge_gate.pid_alive(os.getpid()) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_pid_alive_missing_pid_conservative():
|
||||||
|
assert merge_gate.pid_alive(None) is True
|
||||||
|
assert merge_gate.pid_alive(0) is True
|
||||||
152
tests/test_plane_confirm_deploy.py
Normal file
152
tests/test_plane_confirm_deploy.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
"""ORCH-059 TC-04/05/06: webhook routing for the dedicated "Confirm Deploy"
|
||||||
|
status vs. the overloaded "Approved".
|
||||||
|
|
||||||
|
Contract (AC-2, AC-3, AC-4):
|
||||||
|
* TC-04 — handle_issue_updated routes a "Confirm Deploy" status on a `deploy`
|
||||||
|
task to the Phase B path (handle_confirm_deploy -> advance_stage with
|
||||||
|
confirm_deploy=True), NOT the plain approve/advance path.
|
||||||
|
* TC-05 — an "Approved" status on a `deploy` task does NOT initiate the prod
|
||||||
|
deploy (self_deploy.initiate_deploy is never called).
|
||||||
|
* TC-06 — an "Approved" status on an `analysis` task still advances
|
||||||
|
analysis -> architecture (the approved-via-status human gate is intact).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_test_db = os.path.join(tempfile.gettempdir(), "test_orch_confirm_routing.db")
|
||||||
|
os.environ["ORCH_DB_PATH"] = _test_db
|
||||||
|
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
|
||||||
|
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||||
|
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock # noqa: E402
|
||||||
|
|
||||||
|
import src.db as _db # noqa: E402
|
||||||
|
from src.db import init_db, get_db # noqa: E402
|
||||||
|
from src import stage_engine # noqa: E402
|
||||||
|
from src import self_deploy # noqa: E402
|
||||||
|
import src.plane_sync as plane_sync # noqa: E402
|
||||||
|
import src.webhooks.plane as wh # noqa: E402
|
||||||
|
|
||||||
|
IN_PROGRESS = "11111111-1111-1111-1111-111111111111"
|
||||||
|
APPROVED = "22222222-2222-2222-2222-222222222222"
|
||||||
|
REJECTED = "33333333-3333-3333-3333-333333333333"
|
||||||
|
CONFIRM = "44444444-4444-4444-4444-444444444444"
|
||||||
|
|
||||||
|
_STATES = {
|
||||||
|
"in_progress": IN_PROGRESS,
|
||||||
|
"approved": APPROVED,
|
||||||
|
"rejected": REJECTED,
|
||||||
|
"confirm_deploy": CONFIRM,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def fresh_db(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(_db.settings, "db_path", _test_db)
|
||||||
|
if os.path.exists(_test_db):
|
||||||
|
os.unlink(_test_db)
|
||||||
|
init_db()
|
||||||
|
# Deterministic per-project states (no network). handle_issue_updated imports
|
||||||
|
# get_project_states locally from ..plane_sync, so patch it at the source.
|
||||||
|
monkeypatch.setattr(plane_sync, "get_project_states", lambda pid: _STATES)
|
||||||
|
# Isolate sentinel dirs.
|
||||||
|
monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path))
|
||||||
|
monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path))
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def silence_engine(monkeypatch):
|
||||||
|
for name in (
|
||||||
|
"notify_stage_change", "notify_qg_failure", "send_telegram",
|
||||||
|
"plane_notify_stage", "plane_notify_qg", "plane_add_comment",
|
||||||
|
"set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress",
|
||||||
|
"set_issue_blocked", "set_issue_done",
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(stage_engine, name, MagicMock(), raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_task(stage, repo="orchestrator", branch="feature/ORCH-059-x",
|
||||||
|
wi="ORCH-059", plane_id="plane-ORCH-059"):
|
||||||
|
conn = get_db()
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(plane_id, wi, repo, branch, stage),
|
||||||
|
)
|
||||||
|
task_id = cur.lastrowid
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return task_id
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(state_uuid, plane_id="plane-ORCH-059"):
|
||||||
|
return {"id": plane_id, "state": {"id": state_uuid}}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TC-04: "Confirm Deploy" routes to the Phase B path with confirm_deploy=True
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tc04_confirm_deploy_routes_phase_b(monkeypatch):
|
||||||
|
_make_task("deploy")
|
||||||
|
spy = AsyncMock()
|
||||||
|
monkeypatch.setattr(wh, "_try_advance_stage", spy)
|
||||||
|
# handle_verdict must NOT be taken for the confirm-deploy status.
|
||||||
|
verdict_spy = AsyncMock()
|
||||||
|
monkeypatch.setattr(wh, "handle_verdict", verdict_spy)
|
||||||
|
|
||||||
|
await wh.handle_issue_updated(_payload(CONFIRM), "proj")
|
||||||
|
|
||||||
|
spy.assert_awaited_once()
|
||||||
|
# confirm_deploy=True must be threaded through.
|
||||||
|
assert spy.await_args.kwargs.get("confirm_deploy") is True
|
||||||
|
verdict_spy.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tc04b_confirm_deploy_off_deploy_stage_is_noop(monkeypatch):
|
||||||
|
"""Guard: a stray "Confirm Deploy" on a non-deploy stage is a no-op (no advance)."""
|
||||||
|
_make_task("analysis")
|
||||||
|
spy = AsyncMock()
|
||||||
|
monkeypatch.setattr(wh, "_try_advance_stage", spy)
|
||||||
|
|
||||||
|
await wh.handle_confirm_deploy(_payload(CONFIRM), "proj")
|
||||||
|
|
||||||
|
spy.assert_not_awaited()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TC-05: "Approved" on `deploy` does NOT initiate the prod deploy
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tc05_approved_on_deploy_does_not_initiate(monkeypatch):
|
||||||
|
monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True)
|
||||||
|
_make_task("deploy")
|
||||||
|
initiate = MagicMock()
|
||||||
|
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate)
|
||||||
|
|
||||||
|
# Real routing: Approved -> handle_verdict -> _try_advance_stage(confirm_deploy=False)
|
||||||
|
# -> advance_stage -> the deploy block no-ops (does not initiate).
|
||||||
|
await wh.handle_issue_updated(_payload(APPROVED), "proj")
|
||||||
|
|
||||||
|
initiate.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TC-06: "Approved" on `analysis` still advances analysis -> architecture
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tc06_approved_on_analysis_still_advances(monkeypatch):
|
||||||
|
task_id = _make_task("analysis")
|
||||||
|
|
||||||
|
await wh.handle_issue_updated(_payload(APPROVED), "proj")
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
stage = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
assert stage == "architecture"
|
||||||
120
tests/test_plane_states.py
Normal file
120
tests/test_plane_states.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"""ORCH-059 TC-01/02/03: resolver registration of the dedicated "Confirm Deploy"
|
||||||
|
status and its fail-closed absence in fallback environments.
|
||||||
|
|
||||||
|
Contract (AC-1, AC-7):
|
||||||
|
* TC-01 — _PLANE_NAME_TO_KEY maps the board name "Confirm Deploy" to the logical
|
||||||
|
key "confirm_deploy".
|
||||||
|
* TC-02 — get_project_states for an ORCH-like project (Plane API mocked to
|
||||||
|
include a "Confirm Deploy" state) returns a NON-empty uuid under
|
||||||
|
"confirm_deploy", distinct from "approved".
|
||||||
|
* TC-03 — fail-closed: when the status is absent (API fallback to
|
||||||
|
_DEFAULT_STATES / unreachable Plane), the key is simply missing and a .get
|
||||||
|
access yields None WITHOUT raising — the confirm-deploy branch never activates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||||
|
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||||
|
os.environ["ORCH_DB_PATH"] = os.path.join(tempfile.gettempdir(), "test_orch_plane_states.db")
|
||||||
|
|
||||||
|
import src.plane_sync as plane_sync # noqa: E402
|
||||||
|
from src.plane_sync import ( # noqa: E402
|
||||||
|
_PLANE_NAME_TO_KEY,
|
||||||
|
_DEFAULT_STATES,
|
||||||
|
get_project_states,
|
||||||
|
reload_project_states,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def fresh_cache():
|
||||||
|
reload_project_states()
|
||||||
|
yield
|
||||||
|
reload_project_states()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TC-01: name -> key mapping is registered
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_tc01_confirm_deploy_name_to_key_mapping():
|
||||||
|
assert _PLANE_NAME_TO_KEY.get("Confirm Deploy") == "confirm_deploy"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tc01_confirm_deploy_not_in_default_states():
|
||||||
|
"""Fail-closed by construction: NO fallback UUID exists for confirm_deploy, so
|
||||||
|
enduro / API-fallback environments never resolve a (wrong) deploy trigger."""
|
||||||
|
assert "confirm_deploy" not in _DEFAULT_STATES
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TC-02: live API resolves a real, distinct uuid for an ORCH-like project
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_tc02_get_project_states_resolves_confirm_deploy(monkeypatch):
|
||||||
|
confirm_uuid = "cfd00000-0000-0000-0000-000000000059"
|
||||||
|
approved_uuid = "a519a341-dada-4a91-8910-7604f82b79c5"
|
||||||
|
|
||||||
|
class _Resp:
|
||||||
|
def raise_for_status(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return {
|
||||||
|
"results": [
|
||||||
|
{"name": "In Progress", "id": "b873d9eb-993c-48cd-97ac-99a9b1623967"},
|
||||||
|
{"name": "Approved", "id": approved_uuid},
|
||||||
|
{"name": "Confirm Deploy", "id": confirm_uuid},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(plane_sync.httpx, "get", lambda *a, **k: _Resp())
|
||||||
|
|
||||||
|
states = get_project_states("orch-project-uuid")
|
||||||
|
assert states.get("confirm_deploy") == confirm_uuid
|
||||||
|
# Distinct gestures: confirm-deploy must NOT alias the human "Approved" gate.
|
||||||
|
assert states["confirm_deploy"] != states["approved"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TC-03: fail-closed when the status is absent (API fallback / unreachable)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_tc03_fail_closed_when_api_unreachable(monkeypatch):
|
||||||
|
"""A Plane outage -> get_project_states falls back to _DEFAULT_STATES, which
|
||||||
|
has no confirm_deploy key. .get must yield None, never raise."""
|
||||||
|
|
||||||
|
def _boom(*a, **k):
|
||||||
|
raise RuntimeError("plane down")
|
||||||
|
|
||||||
|
monkeypatch.setattr(plane_sync.httpx, "get", _boom)
|
||||||
|
|
||||||
|
states = get_project_states("any-project-uuid")
|
||||||
|
# No KeyError, branch never activates.
|
||||||
|
assert states.get("confirm_deploy") is None
|
||||||
|
# The human gate "Approved" still resolves (fallback is intact).
|
||||||
|
assert states.get("approved") == _DEFAULT_STATES["approved"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_tc03_fail_closed_when_status_not_on_board(monkeypatch):
|
||||||
|
"""Project whose board lacks "Confirm Deploy": the key is filled by NEITHER the
|
||||||
|
API loop NOR the _DEFAULT_STATES backfill -> absent -> fail-closed."""
|
||||||
|
|
||||||
|
class _Resp:
|
||||||
|
def raise_for_status(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def json(self):
|
||||||
|
return {
|
||||||
|
"results": [
|
||||||
|
{"name": "In Progress", "id": "b873d9eb-993c-48cd-97ac-99a9b1623967"},
|
||||||
|
{"name": "Approved", "id": "a519a341-dada-4a91-8910-7604f82b79c5"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr(plane_sync.httpx, "get", lambda *a, **k: _Resp())
|
||||||
|
|
||||||
|
states = get_project_states("board-without-confirm")
|
||||||
|
assert states.get("confirm_deploy") is None
|
||||||
|
assert states.get("approved") == "a519a341-dada-4a91-8910-7604f82b79c5"
|
||||||
@@ -302,3 +302,58 @@ class TestWorkerConcurrency:
|
|||||||
assert count_running_jobs() == 0
|
assert count_running_jobs() == 0
|
||||||
counts = job_status_counts()
|
counts = job_status_counts()
|
||||||
assert counts["failed"] == 1
|
assert counts["failed"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ORCH-065: job-reaper unblocks the shared queue (TC-09) + /queue block (TC-18)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
class TestReaperUnblocksQueue:
|
||||||
|
def test_tc09_reap_unblocks_claim_at_concurrency_1(self, monkeypatch):
|
||||||
|
"""A zombie 'running' row at max_concurrency=1 blocks every claim; once the
|
||||||
|
reaper reaps it the next queued job can be claimed (AC-2)."""
|
||||||
|
import src.merge_gate as mg
|
||||||
|
from src.job_reaper import JobReaper
|
||||||
|
|
||||||
|
monkeypatch.setattr(db.settings, "reaper_dead_ticks", 1)
|
||||||
|
monkeypatch.setattr(mg, "pid_alive", lambda pid: False) # zombie pid dead
|
||||||
|
|
||||||
|
# A zombie row stuck 'running' with a dead pid.
|
||||||
|
conn = db.get_db()
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO jobs (agent, repo, status, attempts, max_attempts, pid, "
|
||||||
|
"started_at) VALUES ('developer','r','running',2,2,999999,datetime('now'))"
|
||||||
|
)
|
||||||
|
zombie = cur.lastrowid
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# A second job waits in the queue behind it.
|
||||||
|
nxt = enqueue_job("analyst", "r")
|
||||||
|
|
||||||
|
# At concurrency 1 the slot is fully occupied -> nothing else can run.
|
||||||
|
assert count_running_jobs() == 1
|
||||||
|
|
||||||
|
monkeypatch.setattr("src.notifications.send_telegram", lambda *a, **k: None)
|
||||||
|
JobReaper().reap_once() # dead pid, attempts>=max -> failed
|
||||||
|
|
||||||
|
assert get_job(zombie)["status"] == "failed"
|
||||||
|
assert count_running_jobs() == 0
|
||||||
|
# Queue is unblocked: the next job claims successfully.
|
||||||
|
claimed = claim_next_job()
|
||||||
|
assert claimed is not None and claimed["id"] == nxt
|
||||||
|
|
||||||
|
def test_tc18_queue_endpoint_has_reaper_block(self):
|
||||||
|
"""GET /queue exposes the reaper observability block (AC-15).
|
||||||
|
|
||||||
|
Calls the endpoint coroutine directly (no lifespan / no background
|
||||||
|
threads / no network) so the test stays hermetic.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import src.main as main
|
||||||
|
|
||||||
|
body = asyncio.run(main.queue())
|
||||||
|
assert "reaper" in body
|
||||||
|
reaper = body["reaper"]
|
||||||
|
for key in ("enabled", "interval", "last_run_ts", "reaped_total",
|
||||||
|
"last_reaped", "lease_reclaimed_total"):
|
||||||
|
assert key in reaper
|
||||||
|
|||||||
101
tests/test_stage_engine_phase_a_cta.py
Normal file
101
tests/test_stage_engine_phase_a_cta.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"""ORCH-059 TC-09: the Phase A CTA asks the operator for "Confirm Deploy".
|
||||||
|
|
||||||
|
Contract (AC-6): when Phase A advances `deploy-staging` -> `deploy` and requests
|
||||||
|
manual approval, both the Plane comment and the Telegram notification must
|
||||||
|
instruct the operator to flip the status to "Confirm Deploy" (the dedicated
|
||||||
|
prod-deploy trigger) — and must NOT present "Approved" as the deploy trigger.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_test_db = os.path.join(tempfile.gettempdir(), "test_orch_phase_a_cta.db")
|
||||||
|
os.environ["ORCH_DB_PATH"] = _test_db
|
||||||
|
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
|
||||||
|
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||||
|
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock # noqa: E402
|
||||||
|
|
||||||
|
import src.db as _db # noqa: E402
|
||||||
|
from src.db import init_db, get_db # noqa: E402
|
||||||
|
from src import stage_engine # noqa: E402
|
||||||
|
from src import self_deploy # noqa: E402
|
||||||
|
from src.stage_engine import advance_stage # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _pass(*a, **k):
|
||||||
|
return (True, "ok")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def fresh_db(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(_db.settings, "db_path", _test_db)
|
||||||
|
if os.path.exists(_test_db):
|
||||||
|
os.unlink(_test_db)
|
||||||
|
init_db()
|
||||||
|
monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path))
|
||||||
|
monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path))
|
||||||
|
monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True)
|
||||||
|
# Pass the staging / merge / freshness sub-gates so the edge reaches Phase A.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
stage_engine, "QG_CHECKS",
|
||||||
|
{**stage_engine.QG_CHECKS,
|
||||||
|
"check_staging_status": _pass,
|
||||||
|
"check_branch_mergeable": _pass,
|
||||||
|
"check_staging_image_fresh": _pass},
|
||||||
|
)
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
def _make_task(stage="deploy-staging", repo="orchestrator",
|
||||||
|
branch="feature/ORCH-059-x", wi="ORCH-059"):
|
||||||
|
conn = get_db()
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(f"plane-{wi}", wi, repo, branch, stage),
|
||||||
|
)
|
||||||
|
task_id = cur.lastrowid
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return task_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_tc09_phase_a_cta_requests_confirm_deploy(monkeypatch):
|
||||||
|
# Silence everything EXCEPT the two CTA channels we want to inspect.
|
||||||
|
for name in (
|
||||||
|
"notify_stage_change", "notify_qg_failure", "plane_notify_stage",
|
||||||
|
"plane_notify_qg", "set_issue_in_review", "set_issue_needs_input",
|
||||||
|
"set_issue_in_progress", "set_issue_blocked", "set_issue_done",
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(stage_engine, name, MagicMock(), raising=False)
|
||||||
|
plane_comment = MagicMock()
|
||||||
|
telegram = MagicMock()
|
||||||
|
monkeypatch.setattr(stage_engine, "plane_add_comment", plane_comment)
|
||||||
|
monkeypatch.setattr(stage_engine, "send_telegram", telegram)
|
||||||
|
|
||||||
|
task_id = _make_task()
|
||||||
|
res = advance_stage(
|
||||||
|
task_id, "deploy-staging", "orchestrator", "ORCH-059",
|
||||||
|
"feature/ORCH-059-x", finished_agent="deployer",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res.note == "self-deploy-approval-pending"
|
||||||
|
|
||||||
|
# The Plane comment CTA mentions "Confirm Deploy" as the trigger.
|
||||||
|
plane_comment.assert_called_once()
|
||||||
|
comment_text = plane_comment.call_args.args[1]
|
||||||
|
assert "Confirm Deploy" in comment_text
|
||||||
|
# The Telegram CTA mentions "Confirm Deploy" too.
|
||||||
|
telegram.assert_called_once()
|
||||||
|
tg_text = telegram.call_args.args[0]
|
||||||
|
assert "Confirm Deploy" in tg_text
|
||||||
|
|
||||||
|
# Neither CTA presents bare "Approved" as the deploy trigger. (The comment may
|
||||||
|
# mention Approved only to clarify it does NOT trigger; assert no instruction
|
||||||
|
# to "set status to Approved".)
|
||||||
|
assert "статус задачи на «Approved»" not in comment_text
|
||||||
|
assert "на Approved" not in tg_text
|
||||||
141
tests/test_stage_engine_phase_b.py
Normal file
141
tests/test_stage_engine_phase_b.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"""ORCH-059 TC-07/08: the Phase B block in stage_engine.advance_stage initiates
|
||||||
|
the prod deploy ONLY on the confirm-deploy signal.
|
||||||
|
|
||||||
|
Contract (AC-2, AC-3, AC-5):
|
||||||
|
* TC-07 — on (current_stage=="deploy", finished_agent is None) for the
|
||||||
|
self-hosting repo: confirm_deploy=True -> Phase B initiates; confirm_deploy
|
||||||
|
omitted/False (a plain Approved) -> a no-op that neither initiates the deploy
|
||||||
|
nor runs check_deploy_status (no false БАГ-8 rollback).
|
||||||
|
* TC-08 — idempotency: with the `initiated` marker already present, a repeated
|
||||||
|
confirm-deploy does NOT initiate again.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
_test_db = os.path.join(tempfile.gettempdir(), "test_orch_phase_b.db")
|
||||||
|
os.environ["ORCH_DB_PATH"] = _test_db
|
||||||
|
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
|
||||||
|
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
|
||||||
|
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock # noqa: E402
|
||||||
|
|
||||||
|
import src.db as _db # noqa: E402
|
||||||
|
from src.db import init_db, get_db # noqa: E402
|
||||||
|
from src import stage_engine # noqa: E402
|
||||||
|
from src import self_deploy # noqa: E402
|
||||||
|
from src.stage_engine import advance_stage # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def fresh_db(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.setattr(_db.settings, "db_path", _test_db)
|
||||||
|
if os.path.exists(_test_db):
|
||||||
|
os.unlink(_test_db)
|
||||||
|
init_db()
|
||||||
|
monkeypatch.setattr(self_deploy.settings, "repos_dir", str(tmp_path))
|
||||||
|
monkeypatch.setattr(self_deploy.settings, "host_repos_dir", str(tmp_path))
|
||||||
|
monkeypatch.setattr(stage_engine.settings, "deploy_require_manual_approve", True)
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def silence_side_effects(monkeypatch):
|
||||||
|
for name in (
|
||||||
|
"notify_stage_change", "notify_qg_failure", "send_telegram",
|
||||||
|
"plane_notify_stage", "plane_notify_qg", "plane_add_comment",
|
||||||
|
"set_issue_in_review", "set_issue_needs_input", "set_issue_in_progress",
|
||||||
|
"set_issue_blocked", "set_issue_done",
|
||||||
|
):
|
||||||
|
monkeypatch.setattr(stage_engine, name, MagicMock(), raising=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_task(stage, repo="orchestrator", branch="feature/ORCH-059-x", wi="ORCH-059"):
|
||||||
|
conn = get_db()
|
||||||
|
cur = conn.execute(
|
||||||
|
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(f"plane-{wi}", wi, repo, branch, stage),
|
||||||
|
)
|
||||||
|
task_id = cur.lastrowid
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return task_id
|
||||||
|
|
||||||
|
|
||||||
|
def _stage(task_id):
|
||||||
|
conn = get_db()
|
||||||
|
row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone()
|
||||||
|
conn.close()
|
||||||
|
return row[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TC-07: confirm-deploy initiates; plain Approved is a no-op
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_tc07_confirm_deploy_initiates(monkeypatch):
|
||||||
|
initiate = MagicMock(return_value=(True, "ok"))
|
||||||
|
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate)
|
||||||
|
|
||||||
|
task_id = _make_task("deploy")
|
||||||
|
res = advance_stage(
|
||||||
|
task_id, "deploy", "orchestrator", "ORCH-059",
|
||||||
|
"feature/ORCH-059-x", finished_agent=None, confirm_deploy=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res.note == "self-deploy-initiated"
|
||||||
|
initiate.assert_called_once()
|
||||||
|
assert self_deploy.has_marker("orchestrator", "ORCH-059", self_deploy.INITIATED)
|
||||||
|
# Did NOT advance off deploy — the finalizer records the verdict later.
|
||||||
|
assert _stage(task_id) == "deploy"
|
||||||
|
|
||||||
|
|
||||||
|
def test_tc07_approved_without_confirm_is_noop(monkeypatch):
|
||||||
|
"""A plain Approved on `deploy` (confirm_deploy defaults to False): no
|
||||||
|
initiate_deploy, no rollback, no advance — a deterministic no-op (AC-3)."""
|
||||||
|
initiate = MagicMock(return_value=(True, "ok"))
|
||||||
|
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate)
|
||||||
|
# If check_deploy_status were (wrongly) run, it would intervene; spy to prove
|
||||||
|
# it is never invoked on this no-op path.
|
||||||
|
gate = MagicMock(return_value=(False, "FAILED"))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
stage_engine, "QG_CHECKS",
|
||||||
|
{**stage_engine.QG_CHECKS, "check_deploy_status": gate},
|
||||||
|
)
|
||||||
|
|
||||||
|
task_id = _make_task("deploy")
|
||||||
|
res = advance_stage(
|
||||||
|
task_id, "deploy", "orchestrator", "ORCH-059",
|
||||||
|
"feature/ORCH-059-x", finished_agent=None, # confirm_deploy omitted -> False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res.note == "approved-on-deploy-noop"
|
||||||
|
initiate.assert_not_called()
|
||||||
|
gate.assert_not_called() # check_deploy_status NOT run -> no false БАГ-8
|
||||||
|
assert res.advanced is False
|
||||||
|
assert res.rolled_back_to is None
|
||||||
|
assert _stage(task_id) == "deploy" # stays put, no rollback to development
|
||||||
|
assert not self_deploy.has_marker("orchestrator", "ORCH-059", self_deploy.INITIATED)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TC-08: idempotency — existing `initiated` marker -> repeat is a no-op
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def test_tc08_idempotent_repeat_confirm_deploy(monkeypatch):
|
||||||
|
initiate = MagicMock(return_value=(True, "ok"))
|
||||||
|
monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", initiate)
|
||||||
|
|
||||||
|
task_id = _make_task("deploy")
|
||||||
|
# Pre-seed the initiated marker (a deploy already in flight).
|
||||||
|
self_deploy.write_marker("orchestrator", "ORCH-059", self_deploy.INITIATED, content="1")
|
||||||
|
|
||||||
|
res = advance_stage(
|
||||||
|
task_id, "deploy", "orchestrator", "ORCH-059",
|
||||||
|
"feature/ORCH-059-x", finished_agent=None, confirm_deploy=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert res.note == "self-deploy-already-initiated"
|
||||||
|
initiate.assert_not_called()
|
||||||
Reference in New Issue
Block a user