Compare commits

..

1 Commits

Author SHA1 Message Date
01684a89df fix(docker): drop COPY of gitignored data/ so staging image builds from a worktree
All checks were successful
CI / test (push) Successful in 17s
CI / test (pull_request) Successful in 16s
The staging-image rebuild (check_staging_image_fresh, ORCH-058) uses the task
git worktree as the docker build context. `data/` is gitignored (runtime SQLite
DB + backups) so it is absent in every worktree -> `COPY data/ ./data/` failed
the build (rc=1) -> deploy-staging rolled back to development (the loop ORCH-061
targets, surfaced one step later once the C9a/C9b waiver let the pipeline reach
the rebuild). The DB always arrives via the compose bind mount, so baking it in
was pointless (and leaked a stale host DB into the image).

Replace `COPY data/ ./data/` with `RUN mkdir -p /app/data` and add a static
regression guard asserting the Dockerfile never COPYs a gitignored path.

Refs: ORCH-061

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 13:39:02 +00:00
75 changed files with 134 additions and 7103 deletions

View File

@@ -116,65 +116,3 @@ ORCH_RECONCILE_GRACE_DEFAULT_S=600
ORCH_RECONCILE_GRACE_OVERRIDES_JSON=
ORCH_RECONCILE_NOTIFY_UNBLOCK=true
ORCH_RECONCILE_SKIP_BLOCKED_ENABLED=true
# ORCH-068: TTL (seconds) for the per-project Plane states cache (plane_sync
# _STATES_CACHE). Historically the cache lived for the whole process lifetime,
# so a status added to Plane after start was invisible until a restart
# ("stale set -> no pipeline action"). With a TTL the entry self-heals by
# re-fetching /states/ once it expires (reuses reload_project_states()).
# >0 -> re-fetch after this many seconds (default 300 = 5 min);
# 0 -> disable TTL -> strictly the previous lifetime cache (back-compat).
ORCH_PLANE_STATES_TTL_S=300
# 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
# 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
# window and reacts to a degradation the restart-time health-check missed (class
# "green deploy, red prod", precedent ET-8). State is in sentinel files
# (.post-deploy-state-<repo>/<wi>/), no DB migration.
# MONITOR_ENABLED -> global kill-switch; false -> pipeline is 1:1 as before ORCH-021.
# REPOS -> CSV of repos where monitoring is REAL; empty -> only self-hosting.
# WINDOW_S -> observation window length (~15 min).
# INTERVAL_S -> seconds between probe ticks.
# FAIL_THRESHOLD -> N CONSECUTIVE health failures -> DEGRADED.
# 5XX_THRESHOLD -> window 5xx ratio above this -> DEGRADED.
# AUTO_ROLLBACK -> allow auto-rollback; acts ONLY for non-self repos. Self-hosting
# is ALWAYS ALERT_ONLY (a tick NEVER restarts the prod container).
# BASE_URL -> base URL of the observed prod instance.
ORCH_POST_DEPLOY_MONITOR_ENABLED=true
ORCH_POST_DEPLOY_REPOS=
ORCH_POST_DEPLOY_WINDOW_S=900
ORCH_POST_DEPLOY_INTERVAL_S=30
ORCH_POST_DEPLOY_FAIL_THRESHOLD=3
ORCH_POST_DEPLOY_5XX_THRESHOLD=0.5
ORCH_POST_DEPLOY_AUTO_ROLLBACK=false
ORCH_POST_DEPLOY_BASE_URL=http://localhost:8500

View File

@@ -91,30 +91,6 @@ 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).
**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
For `orchestrator` the `deploy` stage is orchestrated by **deterministic code** in
@@ -148,7 +124,4 @@ 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.
- 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.

File diff suppressed because one or more lines are too long

View File

@@ -47,7 +47,7 @@ created → analysis → architecture → development → review → testing →
- Машинные вердикты Quality Gate — строго YAML-frontmatter (`verdict:`, `deploy_status:`, `staging_status:`), никогда проза
## Артефакты задачи (`docs/work-items/<plane-id>/`)
`00-business-request.md`, `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`, `04-test-plan.yaml`, `06-adr/ADR-NNN-slug.md`, `07-infra-requirements.md`, `08-data-requirements.md`, `10-tech-risks.md`, `12-review.md`, `13-test-report.md`, `14-deploy-log.md`, `15-staging-log.md`, `16-post-deploy-log.md` (post-deploy наблюдение, ORCH-021).
`00-business-request.md`, `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`, `04-test-plan.yaml`, `06-adr/ADR-NNN-slug.md`, `07-infra-requirements.md`, `08-data-requirements.md`, `10-tech-risks.md`, `12-review.md`, `13-test-report.md`, `14-deploy-log.md`, `15-staging-log.md`.
## Правила для агентов
1. Перед любым действием прочесть этот файл и `docs/architecture/README.md`.

View File

@@ -20,13 +20,15 @@ RUN groupadd -g 1000 app && useradd -u 1000 -g 1000 -m -d /home/slin -s /bin/bas
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY src/ ./src/
# ORCH-021: do NOT `COPY data/ ./data/`. `data/` is gitignored (SQLite DB dir) and
# is provided at runtime as a bind-mount volume (`./data:/app/data`, see
# docker-compose.yml) which shadows anything baked into the image — so the COPY was
# dead weight. Worse, the ORCH-058 staging rebuild (`check_staging_image_fresh`)
# builds with the task *worktree* as the docker build context; a fresh worktree never
# contains the untracked `data/`, so `COPY data/` failed `docker build` with exit 1
# and bounced the task off `deploy-staging`. We just ensure the mountpoint exists.
# ORCH-061: do NOT `COPY data/ ./data/`. `data/` is gitignored (runtime SQLite DB
# + backups), so it is ABSENT in every git worktree. The staging-image rebuild of
# ORCH-058 (`check_staging_image_fresh` / hook `--build-staging`) uses the task
# WORKTREE as the build context, where `data/` does not exist -> `COPY data/`
# fails the build (rc=1) -> deploy-staging rolls back to development (the loop this
# task fixes). It is also pointless: the DB always arrives via the compose bind
# mount (`./data:/app/data` prod, `./data/staging:/app/data` staging), which
# overrides anything baked in (and baking the host DB into the image leaks stale
# state). Just ensure the mount target exists; sqlite creates the .db file.
RUN mkdir -p /app/data
ENV PYTHONPATH=/app
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8500"]

View File

@@ -11,10 +11,9 @@
- **Quality Gates** (`src/qg/checks.py`) — проверки выхода со стадии, реестр `QG_CHECKS`.
- **Agent Launcher** (`src/agents/launcher.py`) — запуск Claude CLI агентов в изолированном git worktree, мониторинг, auto-advance.
- **Queue** (`src/queue_worker.py`, ORCH-1) — персистентная очередь задач (SQLite `jobs`), atomic claim, max_concurrency, ретраи, restart-safe.
- **Job-reaper** (`src/job_reaper.py`, ORCH-065 — [adr-0011](adr/adr-0011-job-reaper-lease-reclaim.md)) — фоновый daemon-поток (каркас `reconciler`), стартует/останавливается в `main.lifespan` (после `reconciler.start()` / перед `worker.stop()`). Детектирует «мёртвый» `running`-job **без рестарта** процесса (Tier-1 мёртвый `jobs.pid` после `reaper_dead_ticks` тиков; Tier-2 `agent_runs.exit_code` записан, а job ещё `running`; Tier-3 backstop `reaper_max_running_s`) и приводит строку к корректному статусу через те же контракты (`_try_advance_stage`/`_finalize_job`, gate-driven; exit≠0/неизвестно → `attempts<max``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`.
- **Project Registry** (`src/projects.py`, ORCH-6) — Plane project id → repo + prefix; фильтрация вебхуков по проекту.
- **Plane Sync** (`src/plane_sync.py`) — синхронизация статусов/комментариев в Plane. Резолв статусов проекта `get_project_states` (ORCH-10) кэширует `{logical_key→uuid}` per-project; **ORCH-068** добавляет в кэш-запись `{uuid→group}` (для терминал-исключения F-2) и **TTL** `ORCH_PLANE_STATES_TTL_S` (дефолт 300с; `0` → прежний lifetime-кэш) — устаревший набор статусов самозалечивается без рестарта процесса через существующий `reload_project_states()` (баг кэша после появления нового Plane-статуса). Форма возврата `get_project_states` неизменна (обратная совместимость).
- **Plane Sync** (`src/plane_sync.py`) — синхронизация статусов/комментариев в Plane.
## Конвейер и Quality Gates
@@ -92,42 +91,6 @@ sentinel-файлы (`<repos_dir>/.deploy-state-<repo>/<wi>/`), без мигр
Подробнее: [adr-0007](adr/adr-0007-executable-self-deploy.md), детально —
`docs/work-items/ORCH-036/06-adr/ADR-001-executable-self-deploy.md`.
### Post-deploy наблюдение прода + реакция на деградацию (ORCH-021 — реализовано)
Конвейер заканчивался на `deploy → done` и **забывал про прод**: «успех» = health-check
в момент рестарта (~60с). Класс «зелёный деплой, красный прод» (прецедент ET-8 —
деградация через минуты под трафиком, health `200 ok`, фича сломана). ORCH-021 продлевает
ответственность **ЗА** `done`: для применимого репо после терминального перехода армится
наблюдение окна `post_deploy_window_s` (~15 мин) с интервалом `post_deploy_interval_s`;
деградация фиксируется по детерминированным порогам, при подтверждении — реакция.
Механизм — **reserved-agent job `post-deploy-monitor`** (калька `deploy-finalizer`, НЕ
стадия и НЕ daemon): арм в `advance_stage` в блоке `next_stage == "done"`
(`post_deploy.arm_monitor`, sentinel `armed` = идемпотентность); тик перехватывается в
`launcher.launch_job` ДО `_spawn``stage_engine.run_post_deploy_monitor` (один опрос →
append в `series` → классификация → перепостановка с задержкой ИЛИ реакция+артефакт+`done`).
Чистая логика — новый leaf-модуль `src/post_deploy.py` (never-raise): `post_deploy_applies`,
`probe_signals` (`/health` 200+`{"status":"ok"}` + доля 5xx на `/status`,`/queue`),
`classify` (HEALTHY|DEGRADED — главный предмет юнит-тестов), `decide_action`,
sentinel-state, `write_post_deploy_log`.
- **Пороги (BR-3):** `DEGRADED``≥ post_deploy_fail_threshold` ПОСЛЕДОВАТЕЛЬНЫХ провалов
health ИЛИ доля 5xx `> post_deploy_5xx_threshold`; одиночный глюк → HEALTHY (нет ложных
откатов).
- **Реакция:** self-hosting (`orchestrator`) — ВСЕГДА `ALERT_ONLY` (Telegram+Plane, ручной
approve; тик НИКОГДА не откатывает/рестартит прод-контейнер); не-self +
`post_deploy_auto_rollback=true` → хук `--rollback` (`0→ROLLBACK_OK`,
`1/2→ROLLBACK_FAILED`+алерт); дефолт → `ALERT_ONLY`.
- **Артефакт** `16-post-deploy-log.md` (YAML-frontmatter `post_deploy_status`/
`action_taken`/…) — машиночитаемо для петли уроков ORCH-8; best-effort.
- **Наблюдаемость** — блок `post_deploy` в `GET /queue` (образец `reconcile`).
- **Инварианты:** `STAGE_TRANSITIONS`, `QG_CHECKS`, `check_deploy_status`, terminal-sync,
merge-gate, exit-коды хука (0/1/2), схема БД — НЕ меняются. Restart-safe (sentinel
`.post-deploy-state-<repo>/<wi>/` + jobs-очередь). Kill-switch
`post_deploy_monitor_enabled`, область `post_deploy_repos` (пусто → self-hosting).
Условность как ORCH-35/36/43/58.
Подробнее: [adr-0010](adr/adr-0010-post-deploy-monitor.md), детально —
`docs/work-items/ORCH-021/06-adr/ADR-001-post-deploy-monitor.md`.
### Свежесть артефакта BUILD-ONCE: провенанс staging-образа (ORCH-058 — реализовано)
BUILD-ONCE retag (ORCH-36) промоутит `SOURCE_IMAGE=orchestrator-orchestrator-staging` в прод
**без rebuild**, полагаясь на «staging-образ свеж и провалидирован». Этой гарантии нет:
@@ -155,6 +118,14 @@ helper `validated_revision` питает и штамп A, и `EXPECTED_REVISION`
образа, без миграций). Подробнее: [adr-0008](adr/adr-0008-staging-image-provenance.md),
детально — `docs/work-items/ORCH-058/06-adr/ADR-001-staging-image-provenance.md`.
**Инвариант build-context (ORCH-061):** staging-rebuild собирает образ из **git-воркти**
задачи, а воркти содержит только git-tracked файлы. Поэтому `Dockerfile` НЕ должен
`COPY` ни одного gitignore-пути — иначе `docker build` падает (rc=1) и `deploy-staging`
зацикливается на откате в `development`. В частности `data/` (рантайм-БД + бэкапы)
gitignore'нут и приходит исключительно через compose bind-mount (`./data:/app/data`),
поэтому образ лишь создаёт каталог монтирования (`RUN mkdir -p /app/data`), а не копирует
его. Гард — `tests/test_dockerfile_worktree_buildable.py`.
### Reconciler: реконсиляция потерянных webhook (ORCH-053 — реализовано)
Конвейер продвигается только входящими webhook; потерянное событие (502 на ребилде,
нет ретраев у Plane/Gitea, неразрезолвленный `sha→branch`) → задача застревает молча
@@ -174,21 +145,11 @@ helper `validated_revision` питает и штамп A, и `EXPECTED_REVISION`
retry-count проверяется первым (дёшево, локальный SQL).
- **F-2 plane-side:** опрос Plane API per-project → `handle_status_start` /
`handle_verdict` из `webhooks/plane.py` (логика не дублируется).
**ORCH-068 (livelock-fix):** (1) задачи в **терминальной группе** Plane
(`state.group ∈ {completed, cancelled}`, fallback — логические ключи
`done`/`cancelled`) исключаются из actionable-выборки per-issue — проектно-независимо,
устойчиво к UUID-алиасингу после переименований статусов (ORCH-066); (2) `_note_unblock`
(лог + Telegram + `unblocked_total`) вызывается ТОЛЬКО при **подтверждённом state change**
(сравнение стадии задачи до/после `_dispatch`; no-op dispatch → тишина), плюс in-memory
дедуп по `issue_id→state`. Восстанавливает инвариант silence-when-in-sync (AC-9/AC-10).
Детали — `docs/work-items/ORCH-068/06-adr/ADR-001-reconciler-terminal-exclusion-and-cache-ttl.md`.
- **F-3:** усиление `sha→branch` в `handle_ci_status` (БД-fallback по единственной
development-задаче repo; неоднозначность → не резолвим).
- **F-4 observability:** при разблокировке — лог-строка `reconciler: <wi> <stage>
разблокирована (потерян webhook)` + Telegram (`reconcile_notify_unblock`); снимок
состояния в `GET /queue` (блок `reconcile`). **ORCH-068** добавляет в снимок
счётчики `skipped_terminal_total` (исключённые терминалы) и `deduped_total`
(подавленные повторные нотификации).
состояния в `GET /queue` (блок `reconcile`).
Реализация: `src/reconciler.py` (daemon-поток по образцу `queue_worker`), стартует в
`main.lifespan` **после** `worker.start()`, останавливается в `finally` **перед**
@@ -201,64 +162,6 @@ never-raise на единицу работы; тишина при синхрон
и реестры (`STAGE_TRANSITIONS`/`QG_CHECKS`) не меняются. Подробнее:
[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`).
- Tester `check_tests_passed` FAIL → откат на `development` + retry.
@@ -292,7 +195,7 @@ ORCH-065 вводит фоновый watchdog, чтобы смерть проц
- `events` — входящие вебхуки (дедуп)
- `tasks` — задачи и их стадии
- `agent_runs` — запуски агентов (run_id, usage, cost)
- `jobs` — очередь задач (ORCH-1); колонка `pid` (ORCH-065) — pid агентского процесса для liveness-детекции зомби job-reaper'ом
- `jobs` — очередь задач (ORCH-1)
## Изоляция (git worktree, ORCH-2)
Каждая задача исполняется в отдельном git worktree, ветки не пересекаются. Репозитории проектов разделены под `/repos/<project>`.
@@ -302,7 +205,7 @@ ORCH-065 вводит фоновый watchdog, чтобы смерть проц
|--------|------|----------|
| GET | `/health` | health check |
| GET | `/status` | активные задачи (stage != done) |
| GET | `/queue` | очередь: counts + max_concurrency + resilience + reconcile (ORCH-053) + reaper (ORCH-065) + post_deploy (ORCH-021) + последние jobs |
| GET | `/queue` | очередь: counts + max_concurrency + resilience + reconcile (ORCH-053) + последние jobs |
| POST | `/webhook/plane` | Plane webhook |
| POST | `/webhook/gitea` | Gitea webhook (push, PR, CI status) |
@@ -316,4 +219,4 @@ ORCH-065 вводит фоновый watchdog, чтобы смерть проц
Схема БД, потоки данных, 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-перехвата); 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-068 (livelock-fix reconciler F-2: терминал-исключение по группе состояния + `_note_unblock` только при подтверждённом state change + дедуп; TTL `_STATES_CACHE`, `docs/work-items/ORCH-068/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-068 (D1 терминал-гард по группе `_is_terminal_state` + `get_project_state_groups` в src/plane_sync.py; D2 сравнение стадии до/после `_dispatch` + дедуп-словарь в src/reconciler.py; TTL-запись `_STATES_CACHE` + флаг `plane_states_ttl_s` в src/config.py; счётчики `skipped_terminal_total`/`deduped_total` в `/queue`; обновлять также при изменении src/reconciler.py F-2, src/plane_sync.py `get_project_states`/`get_project_state_groups`/`_STATES_CACHE`).*
*Актуально на 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).*

View File

@@ -15,13 +15,11 @@ Per-work-item решения живут в `docs/work-items/<id>/06-adr/ADR-NNN-
| adr-0007 | Исполняемый самодеплой стадии `deploy` (файл adr-0007-executable-self-deploy) | accepted | 2026-06-06 | ORCH-036 |
| adr-0008 | Провенанс staging-образа перед BUILD-ONCE retag | accepted | 2026-06-06 | ORCH-058 |
| adr-0009 | Толерантность staging-вердикта к инфраструктурным FAIL | accepted | 2026-06-07 | ORCH-061 |
| adr-0010 | Post-deploy мониторинг прода + реакция на деградацию | proposed | 2026-06-07 | ORCH-021 |
| adr-0011 | Job-reaper + проактивный реклейм merge-lease | accepted | 2026-06-07 | ORCH-065 |
> ⚠️ Историческая коллизия: номер `0007` занят двумя файлами —
> `adr-0007-reconciler.md` (ORCH-053) и `adr-0007-executable-self-deploy.md`
> (ORCH-036). Оба accepted; для новых сквозных ADR использовать следующий
> свободный номер (текущий максимум — `0011`).
> свободный номер (текущий максимум — `0009`).
## Формат
**Контекст → Решение → Альтернативы → Последствия → Связи.** Статус: proposed / accepted / superseded.

View File

@@ -69,15 +69,6 @@ grace + `max_concurrency=1`); never-raise на единицу работы; ти
задачи. Инварианты adr-0007 сохранены (схема/реестры не меняются, never-raise,
тишина при пропуске).
- **ORCH-068** (`docs/work-items/ORCH-068/06-adr/ADR-001-reconciler-terminal-exclusion-and-cache-ttl.md`):
фикс livelock F-2 (спам `_note_unblock` по синхронизированной done-задаче после
ORCH-066). F-2 исключает терминалы по **группе состояния** (`completed`/`cancelled`,
fallback — ключи `done`/`cancelled`) проектно-независимо; `_note_unblock` — только при
подтверждённом state change (сравнение стадии до/после `_dispatch`) + in-memory дедуп;
`_STATES_CACHE` получает TTL (`ORCH_PLANE_STATES_TTL_S`, дефолт 300с, `0`=lifetime).
Инварианты adr-0007 сохранены (источник истины — Plane; реестры/схема/`handle_*`/F-1/F-3
не меняются; never-raise; kill-switch'и).
## Связи
adr-0002 (очередь / `available_at`, single-process-singleton), adr-0003 (условный
гейт — образец условности/флагов раската), adr-0006 (merge-gate как под-гейт ребра

View File

@@ -1,85 +0,0 @@
# adr-0010: Post-deploy мониторинг прода + реакция на деградацию
- **Статус:** proposed (design) — реализация в ветке `feature/ORCH-021-post-deploy-rollback`
- **Дата:** 2026-06-07
- **Задача:** ORCH-021
- **Метка:** `arch:major-change` (новая под-компонента + новый reserved-agent job-kind)
- **Детальный ADR:** `docs/work-items/ORCH-021/06-adr/ADR-001-post-deploy-monitor.md`
## Контекст
Конвейер заканчивается на `deploy → done`: `check_deploy_status` видит
`deploy_status: SUCCESS` → terminal-sync (Plane → Done, release merge-lease), и
оркестратор **забывает про прод**. «Успех» сегодня = health-check в момент рестарта
(~60с окно в `orchestrator-deploy-hook.sh`). Класс инцидентов «зелёный деплой, красный
прод» (прецедент **ET-8**): деградация проявляется через минуты под боевым трафиком,
health отвечает `200 ok`, фича сломана. Для self-hosting опасно вдвойне — сломанный
прод-орк (8500) обслуживает ВСЕ проекты из общего инстанса.
## Решение
Продлить ответственность конвейера **ЗА** `done`: после терминального перехода для
применимого репо армится пост-деплой наблюдение окна `post_deploy_window_s` (дефолт
~15 мин) с интервалом `post_deploy_interval_s`; деградация фиксируется по
**детерминированным порогам**, при подтверждении выполняется реакция.
**Механизм — reserved-agent job `post-deploy-monitor`** (калька `deploy-finalizer`,
ORCH-36), НЕ отдельная стадия и НЕ daemon-поток:
- **Арм:** в `stage_engine.advance_stage`, в блоке `next_stage == "done"`, при
`post_deploy.post_deploy_applies(repo)``post_deploy.arm_monitor(...)` (sentinel
`armed` = идемпотентность, первый job через `enqueue_job(available_at_delay_s=...)`).
- **Тик:** `launcher.launch_job` перехватывает `agent == "post-deploy-monitor"` ДО
`_spawn``stage_engine.run_post_deploy_monitor(job)`: один опрос сигналов, append в
персистентный `series`, классификация; HEALTHY и окно не истекло → перепостановка с
задержкой; иначе → реакция + артефакт + `mark_done`.
- **Чистая логика — новый leaf-модуль `src/post_deploy.py`** (never-raise, по образцу
`self_deploy.py`/`staging_verdict.py`): `post_deploy_applies`, `probe_signals`
(опрос `/health` + доля 5xx на `/status`,`/queue`), `classify` (HEALTHY|DEGRADED —
главный предмет юнит-тестов), `decide_action` (NONE|ROLLBACK|ALERT_ONLY с учётом
self-hosting), sentinel-state хелперы, `write_post_deploy_log`.
**Сигналы и пороги (детерминированно, AC-3…AC-6):** `DEGRADED``≥
post_deploy_fail_threshold` ПОСЛЕДОВАТЕЛЬНЫХ провалов health ИЛИ доля 5xx на окне `>
post_deploy_5xx_threshold`. Одиночный глюк < порога → HEALTHY (нет ложных откатов).
**Реакция (BR-4/BR-5):**
- **Self-hosting (`orchestrator`) — ВСЕГДА `ALERT_ONLY`:** громкий Telegram + Plane,
запрос ручного approve отката. Тик НИКОГДА не откатывает/рестартит прод-контейнер
(структурный инвариант). Откат прод-орка, если оператор решит, — только detached
host-процесс (`self_deploy.initiate_deploy`), вне тика (MVP).
- **Не-self + `post_deploy_auto_rollback=True`:** хук `--rollback` с прод-env; exit
`0 → ROLLBACK_OK`, `1/2 → ROLLBACK_FAILED` + громкий алерт.
- Дефолт (`auto_rollback=False`) → `ALERT_ONLY`.
**Артефакт `16-post-deploy-log.md`** (новый) с YAML-frontmatter (`post_deploy_status`,
`action_taken`, `window_s`, `checks_total/failed`) — машиночитаемо для петли уроков
ORCH-8; best-effort. **Наблюдаемость** — блок `post_deploy` в `GET /queue` (образец
`reconcile.status()`).
## Альтернативы
- **Daemon-watchdog (как reconciler)** — отклонён: per-task серия опросов в памяти не
restart-safe (а деплой орка = рестарт); restart-safe-вариант требует тех же sentinel,
reserved-agent проще и уже имеет проверенную jobs+sentinel машинерию.
- **Отдельная пост-deploy стадия + QG** — отклонён: меняет `STAGE_TRANSITIONS`/
`QG_CHECKS`, ломает семантику терминального `done`; наблюдение принципиально ПОСЛЕ
`done`.
- **Авто-rollback прод-орка из тика** — отклонён (self-hosting safety): групповой риск;
контейнер не откатит себя надёжно. Self → alert + ручной approve (как ORCH-54).
- **Колонка в `tasks`** — отклонён: миграция на проде; sentinel-файлы restart-safe
(как ORCH-36/53/58).
## Последствия
- Класс «зелёный деплой, красный прод» закрыт измеримыми порогами; деградация =
сигнал для ORCH-8.
- Реестры (`STAGE_TRANSITIONS`/`QG_CHECKS`), контракт `check_deploy_status`,
terminal-sync, merge-gate, exit-code-контракт хука, схема БД — **не меняются**.
- Дефолты безопасны: kill-switch on, auto-rollback off, self только alert.
- Ограничение: монитор self бежит внутри наблюдаемого прода — полностью wedged
контейнер = пропущенный тик/алерт (known MVP gap; внешний watchdog — follow-up).
- Self-hosting: тик не рестартит/не роняет прод-контейнер; kill-switch
`post_deploy_monitor_enabled` обязателен; поэтапный раскат через `post_deploy_repos`.
## Связи
adr-0007-executable-self-deploy (ORCH-36 — sentinel/detached-host/finalizer образец,
`map_exit_code_to_status`), adr-0007-reconciler (ORCH-53 — daemon/`status()` образец,
отклонён как основной механизм), adr-0006 (merge-gate — условность/флаги раската),
adr-0003 (staging-gate — образец условности), adr-0008 (provenance — `.deploy-prev-image`/
хук-откат). Прецедент ET-8. Будущее: ORCH-8 (петля уроков), ORCH-54 (полный авто).

View File

@@ -1,82 +0,0 @@
# 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.

View File

@@ -326,7 +326,6 @@ webhook (plane/gitea) background thread (queue_worker)
| `status` | `queued``running``done` \| `failed` |
| `attempts` / `max_attempts` | счётчик попыток (инкремент при claim) / лимит ретраев (default 2) |
| `run_id` | FK на `agent_runs.id` после старта |
| `pid` | (ORCH-065) pid агентского процесса (`proc.pid` из `_spawn`); liveness-сигнал для job-reaper. Добавляется `_ensure_column` (idempotent) |
| `task_content` | ТЗ, которое пишется в task-файл агента |
| `error` | последняя ошибка |
@@ -344,36 +343,6 @@ status='queued'` и проверяет `rowcount`. При гонке двух т
jobs со статусом `running` (воркер умёр на рестарте) → возвращаются в `queued`.
Потом стартует воркер; на 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.

View File

@@ -1,78 +0,0 @@
# 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.

View File

@@ -1,7 +0,0 @@
# Business Request: [★ высокий] Post-deploy мониторинг прода + авто-rollback при деградации
Work Item ID: ORCH-021
## Description
TBD

View File

@@ -1,88 +0,0 @@
# BRD — ORCH-021: Post-deploy мониторинг прода + авто-rollback при деградации
Work Item: ORCH-021
Приоритет: высокий (★)
Источник: предложение Стрим, одобрено Славой (2026-06-04)
Стадия: analysis
## 1. Проблема (Why)
Сейчас конвейер заканчивается на `deploy → done`: как только `check_deploy_status`
видит `deploy_status: SUCCESS`, задача закрывается и оркестратор **забывает про прод**.
«Успех» деплоя сегодня означает только то, что health-check в момент рестарта
прошёл (10×6с в `scripts/orchestrator-deploy-hook.sh`) — узкое окно ~60 секунд.
**Прямой урок ET-8:** деплой отрапортовал SUCCESS, а на проде фича не работала.
Класс инцидентов — «зелёный деплой, красный прод»:
- деградация проявляется через минуты, а не в первые 60с (прогрев кэшей, фоновые
миграции, отложенные запросы, утечки, рост 5xx под реальным трафиком);
- health-эндпоинт отвечает `200 ok`, но ключевая функциональность сломана;
- регресс виден только под боевым трафиком, которого нет в момент рестарта.
После закрытия задачи никакого пригляда за продом нет — деградацию замечает человек
постфактум. Для self-hosting это особенно опасно: сломанный прод-орк (8500) обслуживает
ВСЕ проекты (enduro-trails) из общего инстанса.
## 2. Цель (What)
Продлить ответственность конвейера за прод **после** `deploy → done`: в течение
заданного окна наблюдать ключевые сигналы здоровья прода и при доказанной деградации
выполнить реакцию (откат на предыдущий образ или громкий алерт с запросом ручного
отката). Закрыть класс «зелёный деплой, красный прод».
Механизм частичного отката уже есть: `do_rollback()` и режим `--rollback` в
`scripts/orchestrator-deploy-hook.sh` умеют вернуть предыдущий образ из
`PREV_IMAGE_FILE` (`.deploy-prev-image-prod`), который сохраняется при каждом деплое.
Задача — построить **наблюдение поверх** этого и привязать решение к измеримым порогам.
## 3. Заинтересованные стороны
- **Owner (Слава)** — принимает риск авто-отката прода; получает алерты.
- **Стрим** — инициатор; потребитель сигнала деградации для петли уроков (ORCH-8).
- **Другие проекты (enduro-trails)** — косвенно: устойчивость общего инстанса.
## 4. Бизнес-требования
| # | Требование | Приоритет |
|---|------------|-----------|
| BR-1 | После `deploy → done` прод наблюдается в течение конфигурируемого окна (дефолт ~15 мин), а не забывается. | Must |
| BR-2 | Деградация определяется по **детерминированным измеримым сигналам**: периодический `/health` (HTTP 200 + `{"status":"ok"}`) и доля HTTP 5xx на ключевых эндпоинтах (`/status`, `/queue`). | Must |
| BR-3 | Деградация фиксируется только по **порогам** (N последовательных провалов / окно), а не по разовому сетевому глюку — чтобы не было ложных откатов. | Must |
| BR-4 | При подтверждённой деградации система выполняет реакцию: **авто-rollback** на `.deploy-prev-image-prod` (через существующий хук `--rollback`) **либо** громкий алерт с запросом ручного отката — в зависимости от политики репозитория. | Must |
| BR-5 | **Self-hosting safety:** для самого `orchestrator` авто-откат прода = рестарт инструмента, обслуживающего все проекты. По умолчанию для self-hosting реакция — **алерт + ручной approve отката** (по образцу deploy Phase A/B), НЕ автоматический откат. Для не-self репозиториев допустим авто-откат. | Must |
| BR-6 | Любой исход (наблюдение начато, деградация, откат, откат-провал, окно завершилось чисто) уведомляется в Telegram и комментарием в Plane; результат наблюдения фиксируется артефактом. | Must |
| BR-7 | Мониторинг — **restart-safe**: рестарт оркестратора (в т.ч. сам деплой) не теряет и не задваивает наблюдение. Идемпотентность по образцу reconciler / deploy-finalizer. | Must |
| BR-8 | Глобальный kill-switch (env-флаг) и список репозиториев, на которые распространяется фича (по образцу `merge_gate_enabled` / `image_freshness_enabled` / `self_deploy_repos`). Выключенный флаг = прежнее поведение (наблюдения нет). | Must |
| BR-9 | Наблюдаемость: текущее состояние пост-деплой наблюдения отражается в `GET /queue` (по образцу блока `reconcile`). | Should |
| BR-10 | Сигнал деградации пригоден для будущей петли уроков (ORCH-8): фиксируется в артефакте/логе в машиночитаемом виде. | Should |
| BR-11 | Доменный smoke результата фичи (проверка, что конкретная фича реально работает) — желателен, но выносится в follow-up; MVP ограничивается health + 5xx. | Could |
## 5. Вне рамок (Out of scope)
- Полноценная система метрик/APM (Prometheus, дашборды) — фича опирается на уже
существующие HTTP-эндпоинты, не вводит сбор метрик.
- Универсальный доменный smoke для произвольной фичи (BR-11 — follow-up).
- Полностью автоматический откат прод-орка без участия человека (противоречит
self-hosting safety; отдельная задача при наборе доверия, аналогично ORCH-54 для deploy).
- Изменение момента вердикта `deploy_status` / контракта `check_deploy_status`
(наблюдение происходит ПОСЛЕ `done`, не заменяет deploy-gate).
## 6. Связи
- **ET-8** — прецедент «deploy SUCCESS, прод не работает». Обоснование задачи.
- **ORCH-36** (`docs/architecture/adr/adr-0007-executable-self-deploy.md`) — Phase A/B/C
исполняемого самодеплоя; пост-деплой наблюдение продлевает ответственность ЗА `done`,
переиспользует sentinel-паттерн и detached-host-процесс для self-rollback.
- **ORCH-53** (`src/reconciler.py`) — каноничный паттерн фонового daemon-потока
(watchdog), запускаемого в `main.lifespan`; образец для пост-деплой наблюдателя.
- **ORCH-58** — `.deploy-prev-image` и хук-механика отката, на которые опирается реакция.
- **ORCH-8** — деградация прода = сигнал для петли уроков (BR-10).
- **ORCH-12** — фича может оформиться как пост-deploy стадия ИЛИ как watchdog (решение
архитектора, см. §7).
## 7. Открытые архитектурные вопросы (для архитектора, НЕ решаются в анализе)
1. **Где живёт наблюдение:** отдельная пост-deploy стадия конвейера vs фоновый
watchdog-daemon (по образцу `reconciler`) vs reserved-agent job (по образцу
`deploy-finalizer`). Анализ задаёт требования (BR-1, BR-7), выбор механизма — за архитектором.
2. **Механизм self-rollback для self-hosting:** откат прод-орка требует detached
host-процесса (контейнер не может надёжно откатить себя, умирая) — переиспользовать
ли `self_deploy.initiate_deploy` / хук `--rollback`.
3. Точные пороги и веса сигналов (BR-3) — анализ предлагает дефолты (см. AC), архитектор
фиксирует реализацию.

View File

@@ -1,165 +0,0 @@
# ТЗ — ORCH-021: Post-deploy мониторинг прода + авто-rollback
Work Item: ORCH-021
Стадия: analysis → (architecture)
> Документ описывает ТРЕБОВАНИЯ к изменениям и НАЗЫВАЕТ задействованные модули.
> Выбор механизма (стадия vs watchdog vs reserved-agent) и точная реализация —
> зона архитектора (см. BRD §7). Здесь фиксируется, ЧТО должно измениться и КАКИЕ
> контракты НЕЛЬЗЯ ломать.
## 1. Контекст в коде (как есть сейчас)
- Конвейер заканчивается в `src/stages.py`: `deploy → done`, gate `check_deploy_status`.
Терминальный переход `deploy → done` исполняется в `src/stage_engine.py::advance_stage`
(блок «Terminal sync», `set_issue_done`, release merge-lease). После этого ничего
не наблюдает за продом.
- `scripts/orchestrator-deploy-hook.sh` уже умеет:
- `health_check(max_attempts, sleep, label)` — опрос `http://localhost:$TARGET_PORT/health`
с проверкой `"status":"ok"`;
- `do_rollback()` — retag `PREV_IMAGE_FILE``TARGET_IMAGE` + рестарт + пост-rollback
health-check; коды возврата 0 (ок) / 1 (нет prev-образа) / 2 (rollback тоже упал);
- режим `--rollback` (ручной откат);
- при обычном деплое сохраняет `PREV_IMG` в `PREV_IMAGE_FILE`
(`.deploy-prev-image-prod` для прода, см. `settings.deploy_prod_prev_image_file`).
- Self-deploy прода идёт через detached host-процесс: `src/self_deploy.py`
(`build_deploy_command`, `initiate_deploy`, sentinel-маркеры под
`.deploy-state-<repo>/<wi>/`, `read_result`, `map_exit_code_to_status`).
- Фоновый daemon-паттерн: `src/reconciler.py` (`threading.Thread(daemon=True)` +
`threading.Event`, старт/стоп в `src/main.py::lifespan` после `worker.start()` /
перед `worker.stop()`, `status()` в `GET /queue`).
- Reserved-agent (детерминированный no-LLM job) паттерн: `deploy-finalizer`
перехват в `src/agents/launcher.py::launch_job` ДО `_spawn`, исполнение
`stage_engine.run_deploy_finalizer`, отложенная постановка через
`enqueue_job(..., available_at_delay_s=...)`.
- Условность self-hosting: `src/qg/checks.py::is_self_hosting_repo`,
`src/self_deploy.py::self_deploy_applies` (флаг + CSV-репо; пусто → только `orchestrator`).
- Наблюдаемые эндпоинты прода (`src/main.py`): `GET /health`, `GET /status`, `GET /queue`.
- API БД: `src/db.py::enqueue_job` (с `available_at_delay_s`), `get_db`,
`update_task_stage`, `get_active_tasks_for_reconcile`.
## 2. Требуемые изменения
### 2.1. Новый leaf-модуль чистой логики наблюдения — `src/post_deploy.py` (новый)
Контракт **never-raise** (по образцу `self_deploy.py` / `staging_verdict.py`).
Чистые, юнит-тестируемые функции:
- **Опрос сигналов:** функция, опрашивающая `/health` и ключевые эндпоинты
(`/status`, `/queue`) прод-инстанса (base-url из config), возвращающая структуру
с результатами (код ответа, ok-флаг, доля 5xx). Сеть/таймаут → консервативный
результат, не исключение.
- **Классификация деградации** (чистая, без сети): на вход — серия результатов
опросов; на выход — вердикт `HEALTHY | DEGRADED` по порогам (BR-3):
`≥ post_deploy_fail_threshold` последовательных провалов health ИЛИ доля 5xx
выше `post_deploy_5xx_threshold` на окне. Эта функция — основной предмет
юнит-тестов (детерминированная, как `compute_staging_verdict` в ORCH-061).
- **Решение о реакции** (чистая): по `(repo, вердикт, политика)` → одно из
`NONE | ROLLBACK | ALERT_ONLY`, с учётом self-hosting (BR-5).
- **Запись артефакта** результата наблюдения (см. §2.5), best-effort.
- Условность: хелпер `post_deploy_applies(repo)` (флаг + CSV-репо, пусто →
только self-hosting), по образцу `self_deploy_applies` / `_merge_gate_applies`.
### 2.2. Оркестрация наблюдения (механизм — выбор архитектора)
Требования к механизму (независимо от выбора стадия/watchdog/reserved-agent):
- запускается ПОСЛЕ перехода `deploy → done` для применимого репозитория (BR-1);
- наблюдает окно `post_deploy_window_s` с интервалом `post_deploy_interval_s`;
- **restart-safe и идемпотентен** (BR-7): состояние наблюдения — в sentinel-файлах
(по образцу `.deploy-state-<repo>/<wi>/`, напр. маркеры `monitor-started` /
`monitor-done`) ИЛИ через отложенные `enqueue_job(available_at_delay_s=...)`;
повторный старт не задваивает наблюдение и не теряет его при рестарте;
- по итогу вызывает «Решение о реакции» из `src/post_deploy.py` и исполняет реакцию (§2.3).
Кандидатные точки интеграции (на выбор архитектора, см. BRD §7):
- хук в `stage_engine.advance_stage` в блоке `next_stage == "done"` — арм наблюдения;
- reserved-agent `post-deploy-monitor` (расширение `launcher.launch_job` ДО `_spawn`,
как `deploy-finalizer`), с само-перепостановкой через `available_at_delay_s`;
- отдельный daemon-поток `PostDeployWatcher` (как `Reconciler`), старт/стоп в `main.lifespan`.
### 2.3. Реакция на деградацию
- **Не-self репозитории / политика auto:** вызвать существующий хук в режиме отката
(`scripts/orchestrator-deploy-hook.sh --rollback` с прод-параметрами окружения,
как в `self_deploy.build_deploy_command`, но action=`--rollback`). Маппинг
exit-code хука (0/1/2) в исход переиспользует логику `self_deploy.map_exit_code_to_status`
по смыслу (0 → откат успешен; 1/2 → откат не выполнен/провалился → громкий алерт).
- **Self-hosting (`orchestrator`) по умолчанию (BR-5):** НЕ откатывать автоматически.
Сформировать громкий алерт (Telegram + Plane-коммент) и запросить ручной approve
отката (по образцу deploy Phase A — статус Plane / Telegram CTA). Откат самого
прод-орка, если выполняется, — только через detached host-процесс (нельзя надёжно
откатить контейнер, который при этом умирает; переиспользовать механику
`self_deploy.initiate_deploy`).
- Команда отката для self НЕ должна ронять прод-контейнер в рамках обычного тика
наблюдения (CLAUDE.md: не ронять/не рестартить прод-контейнер вне явного действия).
### 2.4. Конфигурация — `src/config.py` (расширение `Settings`)
Добавить (env-префикс `ORCH_`, дефолты безопасные):
- `post_deploy_monitor_enabled: bool = True` — глобальный kill-switch (BR-8).
- `post_deploy_repos: str = ""` — CSV применимых репо; пусто → только self-hosting
(по образцу `self_deploy_repos` / `merge_gate_repos` / `image_freshness_repos`).
- `post_deploy_window_s: int = 900` — длина окна наблюдения (дефолт ~15 мин, BR-1).
- `post_deploy_interval_s: int = 30` — интервал между опросами.
- `post_deploy_fail_threshold: int = 3` — N последовательных провалов health → DEGRADED.
- `post_deploy_5xx_threshold: float = 0.5` — порог доли 5xx на окне → DEGRADED.
- `post_deploy_auto_rollback: bool = False` — глобально разрешён ли авто-откат;
при `True` действует для не-self репо; для self всегда требует approve (BR-5).
- `post_deploy_base_url: str = "http://localhost:8500"` — base-url наблюдаемого прода.
- `post_deploy_target` параметры отката — переиспользовать существующие
`deploy_prod_*` (service/port/image/prev_image_file), новых дублей не вводить.
### 2.5. Артефакт задачи — `16-post-deploy-log.md` (новый)
В `docs/work-items/<plane-id>/`. YAML-frontmatter (машиночитаемо, канон гейтов;
для будущей петли уроков BR-10):
```
---
post_deploy_status: HEALTHY | DEGRADED
action_taken: NONE | ROLLBACK_OK | ROLLBACK_FAILED | ALERT_ONLY
work_item: <plane-id>
window_s: <int>
checks_total: <int>
checks_failed: <int>
---
```
Тело — человекочитаемая сводка опросов. Записывается best-effort (по образцу
`self_deploy.write_deploy_log`); отсутствие файла не должно ничего ронять.
> Артефакт `16-post-deploy-log.md` добавить в перечень артефактов в `CLAUDE.md`
> и таблицу/описание в `docs/architecture/README.md` (golden-source, в том же PR).
### 2.6. Наблюдаемость — `GET /queue` (`src/main.py`) (BR-9)
Добавить блок `post_deploy` со снимком состояния (enabled, window, активные
наблюдения, последний исход) — по образцу блока `reconcile` (метод `status()`).
### 2.7. Изменения схемы БД
**Не требуются.** Состояние наблюдения — sentinel-файлы (restart-safe, без миграции,
по образцу ORCH-36) и/или отложенные jobs. Если архитектор выберет колонку в `tasks`
для отметки наблюдения — потребуется миграция; предпочтительно избежать (как ORCH-36/53/58).
### 2.8. Новые QG checks
**Не требуются.** Наблюдение происходит ПОСЛЕ `done` и не является gate'ом стадии;
реестр `QG_CHECKS` и `STAGE_TRANSITIONS` не меняются (если архитектор НЕ выберет
вариант «отдельная пост-deploy стадия» — тогда потребуется новая стадия+gate, что
надо явно отразить в ADR; по умолчанию предпочтителен вариант без изменения реестров).
## 3. Инварианты (НЕ ломать)
- `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, контракт `check_deploy_status` /
`_parse_deploy_status`, момент вердикта `deploy_status`, БАГ-8 откат, terminal-sync
`deploy → done`, merge-gate, exit-code-контракт хука (0/1/2) — без изменений.
- Контракт хука: дефолты STAGING-безопасны; прод-параметры приходят только через env.
- Условность как ORCH-35/36/43/58: реально для `orchestrator`/listed-repos, прочие — no-op.
- Never-raise: ошибка в наблюдении не роняет worker / lifespan / конвейер других проектов.
- Self-hosting: тик наблюдения НИКОГДА не рестартит прод-контейнер сам по себе (BR-5).
## 4. Задействованные модули (сводка)
| Модуль | Изменение |
|--------|-----------|
| `src/post_deploy.py` | **новый** — чистая логика опроса/классификации/решения/артефакта, never-raise |
| `src/config.py` | +параметры `post_deploy_*` (kill-switch, окно, пороги, политика) |
| `src/stage_engine.py` и/или `src/agents/launcher.py` и/или `src/main.py` | арм/исполнение наблюдения (точка — за архитектором) |
| `scripts/orchestrator-deploy-hook.sh` | переиспользуется (`--rollback`); правки — только если откат self требует отдельной ветки (за архитектором) |
| `src/main.py` | блок `post_deploy` в `GET /queue` (BR-9); возможный старт daemon в `lifespan` |
| `docs/work-items/<id>/16-post-deploy-log.md` | **новый** артефакт |
| `CLAUDE.md`, `docs/architecture/README.md`, `CHANGELOG.md` | обновить (golden-source, в том же PR) |
| ADR | `docs/work-items/ORCH-021/06-adr/ADR-001-*.md` (+ возможный сквозной `adr/adr-00NN`) |
## 5. Артефакты по pipeline, которые должны появиться/обновиться
- `16-post-deploy-log.md` (новый, машиночитаемый frontmatter).
- Обновлённые `CLAUDE.md` (перечень артефактов), `docs/architecture/README.md`
(описание пост-деплой наблюдения), `CHANGELOG.md`.
- ADR work-item (`06-adr/`) с зафиксированным выбором механизма и порогов.

View File

@@ -1,106 +0,0 @@
# Критерии приёмки — ORCH-021
Work Item: ORCH-021
Формат: каждый критерий имеет чёткое условие PASS/FAIL и проверяется тестом
из `04-test-plan.yaml`.
## Наблюдение и сигналы
### AC-1 — наблюдение армится после deploy→done
- **PASS:** для применимого репозитория после терминального перехода `deploy → done`
пост-деплой наблюдение инициируется (создаётся sentinel/отложенный job/запись в watcher).
- **FAIL:** переход `deploy → done` не приводит к старту наблюдения.
### AC-2 — наблюдение НЕ армится для неприменимых репо
- **PASS:** для репозитория вне области (не self-hosting и не в `post_deploy_repos`)
`post_deploy_applies(repo)` → False; наблюдение не стартует; конвейер не меняется.
- **FAIL:** наблюдение стартует для неприменимого репо.
### AC-3 — классификация HEALTHY
- **PASS:** серия опросов без провалов (или провалов меньше `post_deploy_fail_threshold`
и доля 5xx ниже `post_deploy_5xx_threshold`) → вердикт `HEALTHY`.
- **FAIL:** при здоровых сигналах возвращается `DEGRADED`.
### AC-4 — классификация DEGRADED по порогу провалов health
- **PASS:** `≥ post_deploy_fail_threshold` ПОСЛЕДОВАТЕЛЬНЫХ провалов health → `DEGRADED`.
- **FAIL:** порог достигнут, но вердикт не `DEGRADED`.
### AC-5 — классификация DEGRADED по доле 5xx
- **PASS:** доля 5xx на окне выше `post_deploy_5xx_threshold``DEGRADED`,
даже если `/health` отвечает 200.
- **FAIL:** превышение порога 5xx не даёт `DEGRADED`.
### AC-6 — устойчивость к разовому глюку (нет ложного срабатывания)
- **PASS:** одиночный провал (1 < `post_deploy_fail_threshold`) с последующим
восстановлением → итог `HEALTHY`, реакции нет.
- **FAIL:** одиночный разовый провал приводит к `DEGRADED`/откату.
## Реакция
### AC-7 — авто-rollback для не-self репо при политике auto
- **PASS:** при `post_deploy_auto_rollback=True` и НЕ-self репо вердикт `DEGRADED`
приводит к вызову отката (хук `--rollback` с прод-параметрами); `action_taken`
фиксируется как `ROLLBACK_OK`/`ROLLBACK_FAILED` по exit-code.
- **FAIL:** откат не вызывается, либо вызывается с staging-дефолтами, либо роняет прод напрямую.
### AC-8 — self-hosting НЕ откатывается автоматически (safety)
- **PASS:** для `orchestrator` вердикт `DEGRADED` НЕ приводит к автоматическому
откату/рестарту прод-контейнера в тике наблюдения; вместо этого формируется
громкий алерт + запрос ручного approve (`action_taken: ALERT_ONLY`).
- **FAIL:** тик наблюдения автоматически откатывает/рестартит прод-орк.
### AC-9 — откат-провал эскалируется
- **PASS:** если откат вызван и вернул код 1/2 (нет prev-образа / откат тоже упал) →
`action_taken: ROLLBACK_FAILED` + громкий Telegram-алерт о необходимости ручного вмешательства.
- **FAIL:** провал отката проглатывается тихо.
## Конфигурация и совместимость
### AC-10 — kill-switch выключает фичу
- **PASS:** `post_deploy_monitor_enabled=False` → наблюдение не армится ни для кого;
поведение конвейера 1:1 как до ORCH-021.
- **FAIL:** при выключенном флаге наблюдение всё равно работает.
### AC-11 — пороги/окно конфигурируемы через env
- **PASS:** `post_deploy_window_s`, `post_deploy_interval_s`, `post_deploy_fail_threshold`,
`post_deploy_5xx_threshold` читаются из `Settings` (env `ORCH_*`) и влияют на поведение.
- **FAIL:** значения захардкожены.
### AC-12 — реестры и схема БД не изменены
- **PASS:** `STAGE_TRANSITIONS`, `QG_CHECKS`, контракт `check_deploy_status` и схема
таблиц БД не изменены (если архитектор не вводит явно новую стадию — тогда это
отражено в ADR и тестах). Существующие тесты deploy/staging/merge-gate зелёные.
- **FAIL:** молча сломан какой-либо существующий контракт/тест.
## Наблюдаемость, артефакт, идемпотентность
### AC-13 — артефакт 16-post-deploy-log.md с машиночитаемым frontmatter
- **PASS:** по итогу наблюдения пишется `16-post-deploy-log.md` с валидным YAML-frontmatter
(`post_deploy_status`, `action_taken`); запись best-effort (её отсутствие ничего не роняет).
- **FAIL:** артефакт не пишется или frontmatter невалиден/непарсится.
### AC-14 — наблюдаемость в /queue
- **PASS:** `GET /queue` содержит блок `post_deploy` со снимком состояния (enabled,
window, активные/последний исход).
- **FAIL:** состояние наблюдения нигде не видно.
### AC-15 — идемпотентность / restart-safe
- **PASS:** повторный арм для той же задачи (двойной webhook / рестарт оркестратора)
не создаёт второе параллельное наблюдение и не теряет уже идущее.
- **FAIL:** дублируется наблюдение или теряется при рестарте.
### AC-16 — never-raise
- **PASS:** любая ошибка опроса/сети/файлов/классификации логируется и НЕ роняет
worker / lifespan / конвейер других проектов.
- **FAIL:** исключение из наблюдения всплывает и ломает обслуживание других проектов.
### AC-17 — уведомления
- **PASS:** ключевые события (наблюдение начато, DEGRADED, откат/алерт, чистое
завершение окна) уведомляются в Telegram и/или Plane-комментарием.
- **FAIL:** деградация/откат происходят молча.
### AC-18 — документация обновлена (golden-source)
- **PASS:** в том же PR обновлены `CLAUDE.md` (артефакт `16-post-deploy-log.md`),
`docs/architecture/README.md` (описание пост-деплой наблюдения), `CHANGELOG.md`,
и заведён ADR work-item.
- **FAIL:** функционал есть, документация не обновлена (reviewer → REQUEST_CHANGES).

View File

@@ -1,163 +0,0 @@
work_item: ORCH-021
description: >
Тест-план пост-деплой мониторинга прода + авто-rollback. Упор на детерминированную
чистую логику классификации/решения (юнит, без сети/LLM) и на интеграцию
армирования наблюдения после deploy->done. Сетевые опросы и хук-вызовы мокируются.
Имена модулей/функций — целевые (src/post_deploy.py); архитектор уточняет точную
сигнатуру, тесты адаптируются под ADR.
tests:
# --- Классификация деградации (чистая логика, ядро) ---
- id: TC-01
type: unit
description: "HEALTHY: серия опросов без провалов (< порога) -> вердикт HEALTHY"
module: tests/test_post_deploy.py
covers: [AC-3]
expected: PASS
- id: TC-02
type: unit
description: "DEGRADED: N последовательных провалов health (== fail_threshold) -> DEGRADED"
module: tests/test_post_deploy.py
covers: [AC-4]
expected: PASS
- id: TC-03
type: unit
description: "DEGRADED по 5xx: доля 5xx выше порога при health=200 -> DEGRADED"
module: tests/test_post_deploy.py
covers: [AC-5]
expected: PASS
- id: TC-04
type: unit
description: "Нет ложного срабатывания: одиночный провал (1 < threshold) + восстановление -> HEALTHY"
module: tests/test_post_deploy.py
covers: [AC-6]
expected: PASS
- id: TC-05
type: unit
description: "Пороги читаются из Settings (env ORCH_*), изменение порога меняет вердикт на тех же данных"
module: tests/test_post_deploy.py
covers: [AC-11]
expected: PASS
# --- Решение о реакции (чистая логика + self-hosting safety) ---
- id: TC-06
type: unit
description: "Решение: не-self репо + auto_rollback=True + DEGRADED -> ROLLBACK"
module: tests/test_post_deploy.py
covers: [AC-7]
expected: PASS
- id: TC-07
type: unit
description: "Решение self-hosting: orchestrator + DEGRADED -> ALERT_ONLY (НИКОГДА не авто-rollback)"
module: tests/test_post_deploy.py
covers: [AC-8]
expected: PASS
- id: TC-08
type: unit
description: "Решение: HEALTHY -> NONE (реакции нет) для любого репо"
module: tests/test_post_deploy.py
covers: [AC-3]
expected: PASS
# --- Условность / kill-switch ---
- id: TC-09
type: unit
description: "post_deploy_applies: пусто в repos -> True только для orchestrator, False для enduro-trails"
module: tests/test_post_deploy.py
covers: [AC-2]
expected: PASS
- id: TC-10
type: unit
description: "kill-switch: post_deploy_monitor_enabled=False -> applies()=False для всех; наблюдение не армится"
module: tests/test_post_deploy.py
covers: [AC-10]
expected: PASS
# --- Маппинг exit-code отката -> исход ---
- id: TC-11
type: unit
description: "Откат exit 0 -> action_taken=ROLLBACK_OK"
module: tests/test_post_deploy.py
covers: [AC-7]
expected: PASS
- id: TC-12
type: unit
description: "Откат exit 1/2 (нет prev-образа / откат упал) -> ROLLBACK_FAILED + эскалация-алерт"
module: tests/test_post_deploy.py
covers: [AC-9]
expected: PASS
# --- Артефакт ---
- id: TC-13
type: unit
description: "16-post-deploy-log.md пишется с валидным YAML-frontmatter (post_deploy_status/action_taken), парсится yaml.safe_load"
module: tests/test_post_deploy.py
covers: [AC-13]
expected: PASS
# --- never-raise ---
- id: TC-14
type: unit
description: "Опрос при сетевой ошибке/таймауте -> консервативный результат (провал-как-down), исключение НЕ всплывает"
module: tests/test_post_deploy.py
covers: [AC-16]
expected: PASS
- id: TC-15
type: unit
description: "Ошибка записи артефакта (нет каталога/IO) -> логируется, функция возвращает False, не raise"
module: tests/test_post_deploy.py
covers: [AC-16, AC-13]
expected: PASS
# --- Интеграция: армирование после deploy->done ---
- id: TC-16
type: integration
description: "advance_stage deploy->done для orchestrator армит наблюдение (sentinel/job создан); для enduro-trails — нет"
module: tests/test_post_deploy_integration.py
covers: [AC-1, AC-2]
expected: PASS
- id: TC-17
type: integration
description: "Идемпотентность: повторный арм той же задачи (двойной webhook) не создаёт второе наблюдение"
module: tests/test_post_deploy_integration.py
covers: [AC-15]
expected: PASS
- id: TC-18
type: integration
description: "Полный цикл DEGRADED -> для не-self вызывается откат (хук замокан), пишется лог, шлётся уведомление"
module: tests/test_post_deploy_integration.py
covers: [AC-7, AC-13, AC-17]
expected: PASS
- id: TC-19
type: integration
description: "Self-hosting DEGRADED: тик НЕ вызывает рестарт/откат прод-контейнера, формирует алерт+approve-запрос"
module: tests/test_post_deploy_integration.py
covers: [AC-8, AC-17]
expected: PASS
# --- Наблюдаемость и обратная совместимость ---
- id: TC-20
type: integration
description: "GET /queue содержит блок post_deploy со снимком состояния"
module: tests/test_post_deploy_integration.py
covers: [AC-14]
expected: PASS
- id: TC-21
type: integration
description: "Регресс: существующие тесты deploy/staging/merge-gate/reconciler зелёные; STAGE_TRANSITIONS и QG_CHECKS не изменены"
module: tests/test_stages.py
covers: [AC-12]
expected: PASS

View File

@@ -1,212 +0,0 @@
# ADR-001 (ORCH-021): Post-deploy мониторинг прода + реакция на деградацию
## Статус
Proposed (design) — реализация в ветке `feature/ORCH-021-post-deploy-rollback`.
Сквозной индексный ADR: `docs/architecture/adr/adr-0010-post-deploy-monitor.md`.
Помечено `arch:major-change` (новая под-компонента + новый reserved-agent job-kind).
## Контекст
Конвейер заканчивается на `deploy → done` (`check_deploy_status` видит
`deploy_status: SUCCESS` → terminal-sync, Plane → Done, release merge-lease). После
этого оркестратор **забывает про прод**. «Успех» сегодня = прохождение health-check
в момент рестарта (10×6с в `scripts/orchestrator-deploy-hook.sh`) — узкое окно ~60с.
Класс инцидентов «зелёный деплой, красный прод» (прецедент **ET-8**): деградация
проявляется через минуты под боевым трафиком (прогрев кэшей, фоновые миграции,
утечки, рост 5xx), health отвечает `200 ok`, но фича сломана. Для self-hosting это
критично: сломанный прод-орк (8500) обслуживает ВСЕ проекты из общего инстанса.
BRD/ТЗ задают требования (BR-1…BR-11, AC-1…AC-18) и оставляют архитектору **три
открытых вопроса** (BRD §7): (1) где живёт наблюдение — стадия / watchdog-daemon /
reserved-agent job; (2) механизм self-rollback; (3) пороги/веса сигналов.
Существующие переиспользуемые механики:
- **deploy-finalizer** (ORCH-36, `stage_engine.run_deploy_finalizer` + перехват в
`launcher.launch_job` ДО `_spawn`) — детерминированный no-LLM reserved-agent job,
само-перепостановка через `enqueue_job(available_at_delay_s=...)`, defer-budget,
restart-safe (jobs-очередь + sentinel-файлы `.deploy-state-<repo>/<wi>/`).
- **self_deploy.py** — sentinel-state хелперы (`write_marker`/`has_marker`/
`read_result`/`clear_state`), detached host-процесс (`build_deploy_command`/
`initiate_deploy`: ssh + setsid), `map_exit_code_to_status`, `self_deploy_applies`.
- **reconciler.py** — daemon-поток + `status()` в `GET /queue`.
- **хук `--rollback`** (`do_rollback`): retag `PREV_IMAGE_FILE``TARGET_IMAGE` +
рестарт + health, коды 0 / 1 (нет prev-образа) / 2 (rollback тоже упал).
- **Условность** ORCH-35/36/43/58: `is_self_hosting_repo`, флаг + CSV-репо.
## Решение
### 1. Механизм наблюдения — reserved-agent job `post-deploy-monitor` (Вариант B)
Наблюдение реализуется как **детерминированный no-LLM reserved-agent job**, точная
калька **deploy-finalizer**. Один «тик» наблюдения = один job: он делает ОДИН опрос
сигналов, обновляет персистентные счётчики в sentinel-файлах, классифицирует и либо
**перепостанавливает себя** с задержкой `post_deploy_interval_s` (окно не истекло и
ещё не DEGRADED), либо завершает наблюдение (DEGRADED → реакция; либо окно истекло →
HEALTHY). Это «watchdog поверх очереди»: между тиками job не выполняется (он
запланирован в будущем через `available_at_delay_s`), worker свободен для других
проектов — ровно как defer у finalizer.
**Почему НЕ daemon-watchdog (Вариант A, как reconciler):** daemon тикает глобально, а
не per-task; серию опросов (последовательные провалы health, доля 5xx на окне) пришлось
бы держать в памяти → теряется/двоится при рестарте (а сам деплой орка = рестарт). Чтобы
сделать daemon restart-safe, всё равно нужны персистентные per-task счётчики в sentinel —
тогда reserved-agent проще и уже имеет проверенную restart-safe машинерию (jobs-очередь
+ `requeue_running_jobs` + sentinels). Per-task жизненный цикл естественно ложится на
job-цепочку, а не на глобальный sweep.
**Почему НЕ отдельная пост-deploy стадия (Вариант C):** меняет `STAGE_TRANSITIONS` +
реестр `QG_CHECKS` (нарушает AC-12, ТЗ §2.8 — явно непредпочтительно); ломает семантику
`deploy → done` как терминального перехода (Plane уже Done). Наблюдение происходит
**ПОСЛЕ** `done` — «продление ответственности ЗА done», а не новая стадия конвейера.
### 2. Арм наблюдения — хук в terminal-блоке `advance_stage`
В `stage_engine.advance_stage`, в существующем блоке `next_stage == "done"` (после
`set_issue_done` и `release_merge_lease`), добавляется арм:
```
if next_stage == "done" and post_deploy.post_deploy_applies(repo):
post_deploy.arm_monitor(repo, work_item_id, branch, task_id)
```
`arm_monitor` (never-raise): если sentinel `armed` отсутствует → создаёт state-dir,
пишет `armed` (идемпотентность, по образцу `INITIATED`), инициализирует `series`-файл,
ставит первый `post-deploy-monitor` job через `enqueue_job(available_at_delay_s=
post_deploy_interval_s)`. Если `armed` уже есть → no-op (двойной webhook / reconciler
F-1 / finalizer Phase C могут довести `done` повторно — AC-15). Выключенный
kill-switch / неприменимый репо → `post_deploy_applies` False → арма нет (AC-2/AC-10).
### 3. Чистая логика — новый leaf-модуль `src/post_deploy.py` (never-raise)
По образцу `self_deploy.py` / `staging_verdict.py`. Импортирует только config (+lazy
`qg.checks.is_self_hosting_repo`), НЕ импортирует `stage_engine`/`launcher`. Функции:
- **`post_deploy_applies(repo) -> bool`** — флаг `post_deploy_monitor_enabled` +
CSV `post_deploy_repos` (пусто → только self-hosting). Калька `self_deploy_applies`.
- **`probe_signals(base_url) -> ProbeResult`** — один опрос: `GET /health` (HTTP 200 +
`{"status":"ok"}`) и ключевые эндпоинты `/status`, `/queue` (учёт доли 5xx).
Сеть/таймаут → консервативный «провал»-результат, не исключение.
- **`classify(series, fail_threshold, 5xx_threshold) -> "HEALTHY"|"DEGRADED"`** —
чистая, без сети, **главный предмет юнит-тестов** (детерминированная, как
`compute_staging_verdict`): `DEGRADED` если `≥ fail_threshold` ПОСЛЕДОВАТЕЛЬНЫХ
провалов health (AC-4) ИЛИ доля 5xx на окне `> 5xx_threshold` (AC-5). Иначе
`HEALTHY` (одиночный провал < порога с восстановлением → HEALTHY, AC-3/AC-6).
- **`decide_action(repo, verdict) -> "NONE"|"ROLLBACK"|"ALERT_ONLY"`** — чистая:
`HEALTHY → NONE`; `DEGRADED` + self-hosting → `ALERT_ONLY` (BR-5/AC-8, ВСЕГДА);
`DEGRADED` + не-self + `post_deploy_auto_rollback=True``ROLLBACK`; иначе →
`ALERT_ONLY`.
- **Sentinel-state хелперы** (state-dir `.post-deploy-state-<repo>/<wi>/`, по образцу
`self_deploy._state_dir`): `armed`, `series` (JSON-список результатов опросов,
append каждый тик — restart-safe счётчики), `done`. `read_series`/`append_probe`/
`mark_done`/`has_marker` — never-raise.
- **`write_post_deploy_log(...)`** — артефакт `16-post-deploy-log.md`, best-effort
(по образцу `self_deploy.write_deploy_log`).
- **`build_rollback_command(repo)`** — argv хука `--rollback` с прод-env (как
`build_deploy_command`, но action=`--rollback`; переиспользует `deploy_prod_*`).
### 4. Исполнение тика — `stage_engine.run_post_deploy_monitor(job)` + перехват в launcher
По образцу `run_deploy_finalizer` / `_run_deploy_finalizer_job`:
`launcher.launch_job` перехватывает `agent == "post-deploy-monitor"` ДО `_spawn`
`stage_engine.run_post_deploy_monitor(job)`. Алгоритм тика (never-raise):
1. `mark_done` уже стоит → no-op (AC-15, защита от дубля).
2. `probe = post_deploy.probe_signals(base_url)`; `append_probe(series, probe)`.
3. `verdict = classify(series, ...)`.
4. **Если `HEALTHY` и окно не истекло** (число тиков < `window_s/interval_s`) →
перепостановка `post-deploy-monitor` через `available_at_delay_s=interval_s`
(как finalizer defer; счётчик тиков — из jobs-очереди/`series`, restart-safe).
5. **Если `HEALTHY` и окно истекло** → исход `NONE`, `write_post_deploy_log(HEALTHY,
NONE)`, `mark_done`, нотификация «окно завершилось чисто» (BR-6/AC-17).
6. **Если `DEGRADED`** → `action = decide_action(...)`; исполнить реакцию (§5),
`write_post_deploy_log`, `mark_done`, нотификации.
`mark_done` + sentinel `armed` дают идемпотентность; jobs-очередь +
`requeue_running_jobs` + `series` дают restart-safe (AC-15). Бюджет тиков bounded
(`window_s/interval_s`) — анти-livelock, как `deploy_finalize_max_attempts`.
### 5. Реакция на деградацию
- **Self-hosting (`orchestrator`), всегда (BR-5/AC-8):** `ALERT_ONLY`. НЕ откатывать
и НЕ рестартить прод-контейнер в тике. Громкий Telegram + Plane-коммент с запросом
ручного approve отката (по образцу deploy Phase A CTA). `action_taken: ALERT_ONLY`.
Откат самого прод-орка (если оператор решит) — ТОЛЬКО через detached host-процесс
(контейнер не откатит себя, умирая); переиспользуется механика
`self_deploy.initiate_deploy`, но в MVP она вне тика наблюдения (ручной approve →
отдельный путь, как ORCH-54 для авто-deploy). Тик self НИКОГДА не запускает хук
`--rollback` (структурный инвариант).
- **Не-self + `post_deploy_auto_rollback=True` (AC-7):** вызвать хук `--rollback` с
прод-env (`build_rollback_command`). Маппинг exit-code по смыслу
`map_exit_code_to_status`: `0 → ROLLBACK_OK`; `1/2 → ROLLBACK_FAILED` + громкий
Telegram о необходимости ручного вмешательства (AC-9). Целевой контейнер не есть
orchestrator → его рестарт безопасен для конвейера.
- **Не-self + auto_rollback=False (дефолт):** `ALERT_ONLY`.
### 6. Артефакт `16-post-deploy-log.md` (новый, машиночитаемый)
YAML-frontmatter (канон гейтов; для петли уроков ORCH-8, BR-10):
```
---
post_deploy_status: HEALTHY | DEGRADED
action_taken: NONE | ROLLBACK_OK | ROLLBACK_FAILED | ALERT_ONLY
work_item: <plane-id>
window_s: <int>
checks_total: <int>
checks_failed: <int>
---
```
Тело — человекочитаемая сводка опросов. Best-effort (отсутствие файла ничего не роняет,
AC-13). **Не** читается ни одним гейтом — наблюдение происходит после `done`.
### 7. Конфигурация — `src/config.py` (env-префикс `ORCH_`)
- `post_deploy_monitor_enabled: bool = True` — глобальный kill-switch (BR-8/AC-10).
- `post_deploy_repos: str = ""` — CSV применимых репо; пусто → только self-hosting.
- `post_deploy_window_s: int = 900` — окно наблюдения (~15 мин, BR-1).
- `post_deploy_interval_s: int = 30` — интервал опросов.
- `post_deploy_fail_threshold: int = 3` — N послед. провалов health → DEGRADED.
- `post_deploy_5xx_threshold: float = 0.5` — порог доли 5xx → DEGRADED.
- `post_deploy_auto_rollback: bool = False` — глоб. разрешение авто-отката (для self
всегда требует approve, BR-5).
- `post_deploy_base_url: str = "http://localhost:8500"` — наблюдаемый прод.
- Параметры отката — переиспользовать существующие `deploy_prod_*` (новых дублей нет).
### 8. Наблюдаемость — блок `post_deploy` в `GET /queue` (BR-9/AC-14)
По образцу блока `reconcile` (метод `status()`): `enabled`, `window_s`, `interval_s`,
активные наблюдения (по sentinel `armed` без `done`), последний исход
(`post_deploy_status`/`action_taken`). Best-effort, never-raise.
### Инварианты (НЕ меняются)
`STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status`,
момент вердикта `deploy_status`, БАГ-8 откат, terminal-sync `deploy → done`, merge-gate,
exit-code-контракт хука (0/1/2), схема БД. Условность как ORCH-35/36/43/58. Never-raise
во всём наблюдении (AC-16). Тик self НИКОГДА не рестартит прод-контейнер (AC-8).
## Альтернативы
- **Daemon-watchdog (как reconciler)** — отклонён: per-task серия в памяти не
restart-safe; restart-safe-вариант требует тех же sentinel-счётчиков → reserved-agent
проще и уже проверен.
- **Отдельная пост-deploy стадия + QG** — отклонён: меняет реестры (AC-12), ломает
семантику терминального `done`; наблюдение принципиально ПОСЛЕ `done`.
- **Авто-rollback прод-орка из тика** — отклонён (BR-5): контейнер не откатит себя
надёжно; групповой риск для всех проектов. Self → только ALERT + ручной approve.
- **Новая колонка в `tasks` для отметки наблюдения** — отклонён: миграция на проде
(риск, как в adr-0007); sentinel-файлы достаточны и restart-safe (как ORCH-36/53/58).
- **Прометей/APM** — вне рамок (BR out-of-scope): опираемся на существующие
HTTP-эндпоинты, не вводим сбор метрик.
## Последствия
- Класс «зелёный деплой, красный прод» закрыт измеримыми порогами; деградация —
машиночитаемый сигнал для петли уроков (ORCH-8).
- Плюс: максимальное переиспользование проверенной finalizer/sentinel/hook-машинерии;
нулевая миграция БД; реестры не тронуты; дефолты безопасны (auto-rollback off, self
только alert).
- Минус/ограничение: монитор self бежит ВНУТРИ наблюдаемого прод-контейнера — если
контейнер полностью wedged, worker может не выполнить тик и алерта не будет (gap).
Это known limitation MVP; внешний независимый watchdog — follow-up (вне рамок).
- Минус: каждый тик на короткое время занимает single-worker (`max_concurrency=1`);
митигируется коротким опросом (~секунды) и `interval_s` между тиками (defer не держит
worker), как finalizer.
- Доменный smoke результата фичи (BR-11) — follow-up; MVP = health + 5xx.
## Связи
- **ET-8** — обоснование (deploy SUCCESS, прод не работает).
- **adr-0007-executable-self-deploy** (ORCH-36) — sentinel-паттерн, detached
host-процесс, `map_exit_code_to_status`, deploy-finalizer reserved-agent (образец).
- **adr-0007-reconciler** (ORCH-53) — daemon/`status()` образец (рассмотрен и отклонён
как основной механизм; `status()`-снимок в `/queue` переиспользуется).
- **adr-0006-merge-gate** / **adr-0003-staging-gate** — образец условности и флагов
раската (`*_enabled` + `*_repos`).
- **adr-0008-staging-image-provenance** — `.deploy-prev-image` / хук-механика отката.
- **ORCH-8** — петля уроков (потребитель `16-post-deploy-log.md`).
- **ORCH-54** — будущий полный авто (включая авто-approve отката self), по аналогии
с авто-deploy.

View File

@@ -1,56 +0,0 @@
# 07 — Инфраструктурные требования (ORCH-021)
> Топология НЕ меняется. Фича опирается на уже существующие HTTP-эндпоинты прода и
> существующий деплой-хук. Этот документ фиксирует, какие инфра-предпосылки должны
> выполняться, чтобы наблюдение и реакция работали.
## 1. Топология — без изменений
- Прод `orchestrator` (8500), staging `orchestrator-staging` (8501), один сервер
mva154 (см. `docs/operations/INFRA.md`). Новых контейнеров/портов/сервисов нет.
- Наблюдение — внутрипроцессный reserved-agent job в worker'е прод-контейнера.
Daemon-потоков не добавляется (в отличие от reconciler).
## 2. Наблюдаемый прод — HTTP-эндпоинты
- Монитор опрашивает `post_deploy_base_url` (дефолт `http://localhost:8500`):
- `GET /health` → ожидается HTTP 200 + тело `{"status":"ok"}` (BR-2);
- `GET /status`, `GET /queue` → учёт доли HTTP 5xx (BR-2).
- Эндпоинты уже существуют (`src/main.py`). Новых эндпоинтов фича НЕ вводит
(out-of-scope APM/метрики).
- Для self-hosting `base_url=localhost:8500` означает: монитор бьёт по собственному
контейнеру. Это допустимо для MVP (см. риск R-1 в `10-tech-risks.md`).
## 3. Деплой-хук `--rollback` — предпосылки реакции
- Реакция ROLLBACK (только не-self + `post_deploy_auto_rollback=True`) вызывает
`scripts/orchestrator-deploy-hook.sh --rollback` с прод-env (переиспользуются
`deploy_prod_*`: `TARGET_SERVICE`/`TARGET_PORT`/`TARGET_IMAGE`/`COMPOSE_PROFILE`/
`PREV_IMAGE_FILE`), по образцу `self_deploy.build_deploy_command`.
- Предпосылка: при штатном деплое хук сохраняет предыдущий образ в
`PREV_IMAGE_FILE` (`.deploy-prev-image-prod`). Без снимка → хук вернёт exit 1
(«нет prev-образа») → `ROLLBACK_FAILED` + алерт (AC-9). Контракт exit-кодов хука
(0/1/2) НЕ меняется.
- **Self-hosting:** откат прод-орка хуком в тике ЗАПРЕЩЁН (контейнер не откатит себя,
умирая). Если оператор по алерту решит откатить — только detached host-процесс
(ssh + setsid, механика `self_deploy.initiate_deploy`), как у Phase B самодеплоя.
Предпосылки для detached-пути (ssh-доступ host, shared-mount state-dir) уже
выполнены для ORCH-36; в MVP detached-откат self вне тика наблюдения.
## 4. Restart-safe состояние — shared mount
- Состояние наблюдения — sentinel-файлы под `.post-deploy-state-<repo>/<wi>/`
(`armed`, `series`, `done`) на том же mount `settings.repos_dir`, что и
`.deploy-state-*` (ORCH-36). Миграции БД нет (см. `08-data-requirements.md`).
- `requeue_running_jobs` (ORCH-1) восстанавливает claimed `post-deploy-monitor` job
после рестарта; `series` хранит счётчики опросов → наблюдение продолжается
с того же места (BR-7/AC-15).
## 5. Конфигурация окружения (env `ORCH_*`)
Новые ключи (дефолты безопасны, в `.env`/`.env.staging` по необходимости):
`post_deploy_monitor_enabled` (kill-switch, дефолт true), `post_deploy_repos` (CSV,
пусто → self-hosting), `post_deploy_window_s` (900), `post_deploy_interval_s` (30),
`post_deploy_fail_threshold` (3), `post_deploy_5xx_threshold` (0.5),
`post_deploy_auto_rollback` (false), `post_deploy_base_url` (localhost:8500).
Параметры отката — существующие `deploy_prod_*`, новых дублей не вводить.
## 6. Чего НЕ требуется
- Новых контейнеров, портов, сетевых правил, секретов.
- Prometheus / Grafana / APM (out-of-scope).
- Изменений compose-топологии или деплой-пути не-self репо.

View File

@@ -1,40 +0,0 @@
# 08 — Требования к данным / схеме БД (ORCH-021)
## Вывод: миграция БД НЕ требуется
Состояние наблюдения хранится в **sentinel-файлах** (restart-safe, без миграции —
по образцу ORCH-36/53/58), а не в таблицах. Реестры и схема не меняются (AC-12).
## 1. Существующие таблицы — без изменений
- `events`, `tasks`, `agent_runs`, `jobs` — структура не меняется.
- В `tasks` НЕ вводится колонка статуса/окна наблюдения (намеренно — миграция на
проде = риск, как обосновано в adr-0007; альтернатива отклонена в ADR-001 §Альтернативы).
## 2. Очередь `jobs` — переиспользование, без схемы
- `post-deploy-monitor` — новый **job-kind** (значение в существующей колонке
`agent`/`task_content`), НЕ новая колонка. Ставится через существующий
`enqueue_job(..., available_at_delay_s=...)` (ORCH-1).
- Счётчик тиков/деферов восстанавливается из jobs-очереди (как
`_deploy_finalize_defer_count` считает по `task_content LIKE`), restart-safe.
## 3. Sentinel-состояние (файлы, не БД)
State-dir `.post-deploy-state-<repo>/<work_item_id>/` на `settings.repos_dir`
(по образцу `.deploy-state-*`):
| Файл | Назначение |
|------|------------|
| `armed` | наблюдение заармлено (идемпотентность арма; калька `INITIATED`) |
| `series` | JSON-список результатов опросов (счётчики health-fail / 5xx; restart-safe) |
| `done` | наблюдение завершено (защита от повторной обработки) |
Все обращения — never-raise (по образцу `self_deploy.has_marker`/`write_marker`/
`read_result`). Отсутствие/битость файла → консервативный фоллбэк, не исключение.
## 4. Артефакт `16-post-deploy-log.md` — файл репозитория, не БД
Машиночитаемый YAML-frontmatter (`post_deploy_status`, `action_taken`, `window_s`,
`checks_total`, `checks_failed`) пишется best-effort в `docs/work-items/<id>/`; в БД
не реплицируется. Источник для петли уроков ORCH-8 (BR-10).
## 5. Очистка состояния
По завершении окна / реакции `done`-маркер ставится; state-dir можно чистить
best-effort (по образцу `self_deploy.clear_state`) — необязательно для корректности,
но желательно для гигиены. Stale-`armed` без `done` после краха → виден в `/queue`
как «активное наблюдение» и доигрывается восстановленным job'ом.

View File

@@ -1,20 +0,0 @@
# 10 — Технические риски (ORCH-021)
| # | Риск | Вероятн. | Влияние | Митигация |
|---|------|----------|---------|-----------|
| R-1 | **Монитор self бежит внутри наблюдаемого прода.** Полностью wedged прод-контейнер → worker не выполнит тик → деградация не замечена, алерта нет. | Сред. | Высок. | Known MVP limitation (зафиксировано в ADR-001 §Последствия). Health в момент рестарта (хук) + reconciler ловят часть случаев. Внешний независимый watchdog — follow-up (вне рамок). |
| R-2 | **Ложный авто-rollback** по сетевому глюку. | Низк. | Высок. | Пороги по N ПОСЛЕДОВАТЕЛЬНЫХ провалов + доля 5xx на окне (BR-3/AC-6), а не разовый провал. Self ВСЕГДА `ALERT_ONLY` (BR-5). `auto_rollback=False` по умолчанию. |
| R-3 | **Авто-rollback прод-орка убивает инструмент всех проектов.** | Низк. | Критич. | Структурный инвариант: тик self НИКОГДА не откатывает/рестартит прод-контейнер (AC-8). Self → только alert + ручной approve. Откат self — только detached host-процесс вне тика. |
| R-4 | **Нет prev-образа** при ROLLBACK → откат невозможен. | Сред. | Сред. | Хук возвращает exit 1 → `ROLLBACK_FAILED` + громкий алерт (AC-9), деградация не проглатывается тихо. |
| R-5 | **Дубль/потеря наблюдения** при двойном webhook / рестарте. | Сред. | Сред. | Идемпотентность: sentinel `armed` (арм-гард) + `done` (защита от повторной обработки) + restart-safe jobs-очередь + `series` (AC-15). По образцу finalizer. |
| R-6 | **Исключение в наблюдении роняет worker / конвейер других проектов.** | Низк. | Высок. | Контракт never-raise во всём `post_deploy.py` и `run_post_deploy_monitor` (AC-16), по образцу `self_deploy`/`staging_verdict`. |
| R-7 | **Тик занимает single-worker** (`max_concurrency=1`) → задержка других задач. | Низк. | Низк. | Опрос короткий (~секунды), между тиками job не выполняется (defer через `available_at_delay_s`) — worker свободен, как у finalizer. Окно bounded (`window_s/interval_s`). |
| R-8 | **Скрытое изменение контракта** (реестры/гейты/exit-коды/схема). | Низк. | Высок. | Инвариант: `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_deploy_status`/terminal-sync/merge-gate/exit-коды/схема БД НЕ меняются (AC-12). Существующие тесты deploy/staging/merge-gate должны остаться зелёными. |
| R-9 | **5xx на `/queue`/`/status` из-за самого монитора** (рекурсивная нагрузка). | Низк. | Низк. | Интервал `post_deploy_interval_s` (30с) — низкая частота; опрос лёгкий GET. |
| R-10 | **Артефакт `16-post-deploy-log.md` не пишется / невалиден** → петля уроков без данных. | Низк. | Низк. | Best-effort запись с валидным frontmatter (AC-13); отсутствие файла ничего не роняет. Парсинг — defensive. |
## Эскалация
- Изменение помечено `arch:major-change` (новая под-компонента `src/post_deploy.py`
+ новый reserved-agent job-kind `post-deploy-monitor`).
- R-1 (gap наблюдения для wedged self-контейнера) — кандидат на отдельную задачу
(внешний watchdog), вне рамок ORCH-021.

View File

@@ -1,99 +0,0 @@
---
type: review
work_item_id: ORCH-021
verdict: APPROVED
version: 2
---
# Review ORCH-021 — Post-deploy мониторинг прода + реакция на деградацию
## Summary
Реализация продлевает ответственность конвейера ЗА терминальный переход
`deploy → done`, закрывая класс инцидентов «зелёный деплой, красный прод» (ET-8).
Механизм — детерминированный reserved-agent job `post-deploy-monitor` (вариант B
из ADR-001, точная калька `deploy-finalizer`): арм в `stage_engine.advance_stage`
(блок `next_stage == "done"`), один тик = один job (перехват в
`launcher.launch_job` ДО `_spawn``stage_engine.run_post_deploy_monitor`),
чистая логика в новом leaf-модуле `src/post_deploy.py` (never-raise).
Проверены все четыре оси. Реализация соответствует ТЗ (`02-trz.md`), ADR-001 и
глобальному adr-0010, удовлетворяет всем критериям приёмки AC-1…AC-18.
Документация (golden-source) обновлена в том же PR. Регрессов нет.
## Соответствие ТЗ
- §2.1 `src/post_deploy.py` (leaf, never-raise): `post_deploy_applies`,
`probe_signals`, `classify`, `decide_action`, sentinel-state, артефакт,
`build_rollback_command` — все на месте. ✅
- §2.2 Оркестрация: арм в terminal-блоке + reserved-agent тик с
само-перепостановкой через `available_at_delay_s`; restart-safe (sentinel
`armed`/`series`/`done` + jobs-очередь). ✅
- §2.3 Реакция: non-self+auto → хук `--rollback` (синхронно, целевой ≠ orch);
self-hosting → ВСЕГДА `ALERT_ONLY`. ✅
- §2.4 Конфигурация: все `post_deploy_*` в `src/config.py`, дефолты безопасны
(kill-switch on, auto-rollback off), параметры отката переиспользуют
`deploy_prod_*`. ✅
- §2.5 Артефакт `16-post-deploy-log.md` с машиночитаемым frontmatter,
best-effort. ✅
- §2.6 Блок `post_deploy` в `GET /queue`. ✅
- §2.7/§2.8/§3 Инварианты: `STAGE_TRANSITIONS`, `QG_CHECKS`,
`check_deploy_status`, terminal-sync, merge-gate, exit-code-контракт хука,
схема БД — не тронуты (подтверждено зелёным полным прогоном). ✅
## Соответствие ADR
Реализация 1:1 повторяет ADR-001: механизм (reserved-agent, не стадия/не daemon),
точки интеграции, пороги BR-3, политика реакции BR-5 (self never auto-rollback —
структурный инвариант в `decide_action` + отсутствие вызова `run_rollback` на
ALERT_ONLY). Нарушений глобальных ADR не выявлено.
## Качество кода
- Контракт never-raise выдержан во всех публичных функциях и в каждой ветке
`run_post_deploy_monitor`; launcher оборачивает тик в доп. guard (AC-16).
- `classify` fail-safe → HEALTHY на мусорном входе (ложный DEGRADED опаснее).
- Docstrings содержательные, со ссылками на AC/BR.
- Условность раската по образцу ORCH-35/36/43/58 (флаг + CSV-репо).
## Тесты
30 тестов ORCH-021 (`tests/test_post_deploy.py`,
`tests/test_post_deploy_integration.py`) — содержательные, покрывают
классификацию (AC-3..6), self-hosting safety (TC-19 явно проверяет, что хук
`--rollback` НЕ вызывается для self — AC-8), idempotency двойного арма (AC-15),
kill-switch/условность (AC-2/10/11), exit-code маппинг (AC-9), frontmatter
артефакта (AC-13), never-raise (AC-16), `/queue` (AC-14). Полный прогон
`pytest tests/`**701 passed** (регрессов нет, AC-12).
## Findings
### P0 — Blocker
- нет
### P1 — Must fix
- нет
### P2 — Should fix
- нет
### P3 — Nice to have
- [ ] `run_post_deploy_monitor`: в ветке `ALERT_ONLY` для **не-self** репо при
`post_deploy_auto_rollback=false` текст алерта упоминает «авто-rollback для
self-hosting запрещён (BR-5)», что для не-self случая формулировка не совсем
точна (косметика сообщения; на поведение не влияет).
- [ ] `write_post_deploy_log` коммитит/пушит артефакт в ветку задачи, которая к
моменту наблюдения уже слита/может быть удалена — артефакт может не попасть в
`main`. Контракт best-effort соблюдён (never-raise, ничего не роняет); как
улучшение наблюдаемости — рассмотреть запись лог-артефакта отдельным путём.
## Документация
Обновлено в том же PR (golden-source, AC-18 — PASS):
- `CLAUDE.md``16-post-deploy-log.md` добавлен в перечень артефактов;
- `docs/architecture/README.md` — раздел «Post-deploy наблюдение прода» + блок
`post_deploy` в таблице API `/queue`;
- `docs/architecture/adr/adr-0010-post-deploy-monitor.md` — новый сквозной ADR;
- `docs/work-items/ORCH-021/06-adr/ADR-001-post-deploy-monitor.md` — детальный ADR;
- `CHANGELOG.md` — запись в `Added` (+ fix Dockerfile `COPY data/`);
- `README.md` / `.env.example` — все `ORCH_POST_DEPLOY_*` env задокументированы.
Изменение `src/` сопровождено обновлением документации — правило CLAUDE.md №2/№6
выполнено.
## Вердикт
Только P3 (nice-to-have) findings, блокеров и must-fix нет → **APPROVED**.

View File

@@ -1,82 +0,0 @@
---
type: test-report
work_item_id: ORCH-021
result: PASS
---
# Test Report — ORCH-021
Post-deploy наблюдение прода + реакция на деградацию (reserved-agent job
`post-deploy-monitor`, leaf-модуль `src/post_deploy.py`).
## Окружение
- Python: 3.12.13
- pytest: 8.3.3 (asyncio mode=AUTO, anyio 4.13.0)
- Ветка: feature/ORCH-021-post-deploy-rollback
- Дата: 2026-06-07
## Прогон
- `pytest tests/ -v --tb=short`**701 passed, 1 warning** (Pydantic V2 deprecation, не относится к задаче).
- Целевые модули `tests/test_post_deploy.py` + `tests/test_post_deploy_integration.py`**30 passed**.
## Smoke-test (read-only, прод 8500)
`curl` в окружении недоступен — опрос через `python urllib` (read-only, прод-контейнер не трогается).
| Эндпоинт | Результат |
|----------|-----------|
| `GET /health` | 200 `{"status":"ok","service":"orchestrator"}` |
| `GET /status` | 200, активная задача ORCH-021 на стадии `testing` |
| `GET /queue` | 200, counts/resilience/reconcile присутствуют |
> Примечание: блок `post_deploy` в **живом** `/queue` отсутствует — это ожидаемо: прод
> сейчас работает на коде ДО ORCH-021 (задача ещё не задеплоена, стадия testing).
> Наличие блока (AC-14) проверяется интеграционным тестом TC-20 против кода ветки → PASS.
> Smoke-проверка подтверждает живость окружения, не версию ветки.
## Результаты по тест-плану (04-test-plan.yaml)
| TC ID | Описание | Покрывает AC | Тест-функция | Результат |
|-------|----------|--------------|--------------|-----------|
| TC-01 | HEALTHY: серия без провалов < порога | AC-3 | test_tc01_healthy_no_failures | PASS |
| TC-02 | DEGRADED: N посл. провалов health == threshold | AC-4 | test_tc02_degraded_consecutive_health_failures | PASS |
| TC-03 | DEGRADED по 5xx при health=200 | AC-5 | test_tc03_degraded_by_5xx_ratio_even_when_health_200 | PASS |
| TC-04 | Нет ложного срабатывания: одиночный глюк + восстановление | AC-6 | test_tc04_no_false_trip_single_glitch_then_recovery | PASS |
| TC-05 | Пороги из Settings меняют вердикт на тех же данных | AC-11 | test_tc05_thresholds_change_verdict_on_same_data, test_classify_uses_settings_thresholds | PASS |
| TC-06 | не-self + auto_rollback=True + DEGRADED → ROLLBACK | AC-7 | test_tc06_nonself_auto_rollback_degraded_rolls_back | PASS |
| TC-07 | self-hosting + DEGRADED → ALERT_ONLY (никогда не авто-rollback) | AC-8 | test_tc07_self_hosting_degraded_never_rolls_back | PASS |
| TC-08 | HEALTHY → NONE для любого репо | AC-3 | test_tc08_healthy_means_none_for_any_repo, test_nonself_default_policy_alert_only | PASS |
| TC-09 | post_deploy_applies: пусто → только orchestrator | AC-2 | test_tc09_applies_empty_repos_only_self_hosting, test_tc09_applies_explicit_repos_csv | PASS |
| TC-10 | kill-switch: monitor_enabled=False → applies()=False для всех | AC-10 | test_tc10_kill_switch_disables_for_everyone | PASS |
| TC-11 | Откат exit 0 → ROLLBACK_OK | AC-7 | test_tc11_rollback_exit0_is_ok | PASS |
| TC-12 | Откат exit 1/2 → ROLLBACK_FAILED + эскалация | AC-9 | test_tc12_rollback_exit_nonzero_is_failed | PASS |
| TC-13 | 16-post-deploy-log.md: валидный YAML-frontmatter | AC-13 | test_tc13_log_frontmatter_parses | PASS |
| TC-14 | Опрос при сетевой ошибке → консервативный, не raise | AC-16 | test_tc14_probe_network_error_is_conservative_not_raise, test_tc14_classify_junk_input_swallowed | PASS |
| TC-15 | Ошибка записи артефакта → False, не raise | AC-16, AC-13 | test_tc15_write_log_no_worktree_returns_false | PASS |
| TC-16 | advance_stage deploy→done армит наблюдение (self), не армит (non-self) | AC-1, AC-2 | test_tc16_arm_for_self_hosting, test_tc16_no_arm_for_nonself, test_tc16_no_arm_when_kill_switch_off | PASS |
| TC-17 | Идемпотентность: повторный арм не задваивает | AC-15 | test_tc17_double_arm_is_noop | PASS |
| TC-18 | Полный цикл DEGRADED → не-self откат + лог + уведомление | AC-7, AC-13, AC-17 | test_tc18_degraded_nonself_rolls_back | PASS |
| TC-19 | Self-hosting DEGRADED → НЕ рестарт/откат, алерт+approve | AC-8, AC-17 | test_tc19_degraded_self_hosting_alert_only | PASS |
| TC-20 | GET /queue содержит блок post_deploy | AC-14 | test_tc20_queue_block_present | PASS |
| TC-21 | Регресс: deploy/staging/merge-gate/reconciler зелёные; STAGE_TRANSITIONS/QG_CHECKS не изменены | AC-12 | tests/test_stages.py (+ полный прогон 701) | PASS |
Доп. тесты ветки (не из плана, подтверждают контракты): `test_series_append_and_read_roundtrip`,
`test_mark_done_idempotency_marker`, `test_healthy_tick_requeues_without_finishing`,
`test_finished_window_tick_is_noop` — все PASS.
## Покрытие критериев приёмки
AC-1…AC-18 — все покрыты прошедшими тестами (см. таблицу). AC-12 (реестры/схема БД
не изменены) дополнительно подтверждён зелёным полным регрессом 701 теста, включая
deploy/staging/merge-gate/reconciler. AC-18 (документация) — вне scope прогона тестов,
подтверждён ревью (12-review.md, verdict APPROVED).
## Вывод pytest (хвост)
```
======================= 701 passed, 1 warning in 12.71s ========================
```
```
======================== 30 passed, 1 warning in 0.58s =========================
```
## Итог
**PASS.** Все 21 тест-кейс плана зелёные, полный регресс (701) зелёный, smoke прод-эндпоинтов
OK (окружение живо). Существующие контракты не сломаны. Задача готова к стадии deploy-staging.

View File

@@ -1,42 +0,0 @@
---
staging_status: SUCCESS
timestamp: 2026-06-07T14:37:33Z
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed. Verdict: **SUCCESS** (exit 0).
Run canonically inside the `orchestrator-staging` container (ORCH-048, ADR-001)
via the Docker Engine API over the mounted socket (`docker` CLI is not installed
in the prod-agent container; `network_mode: host` + group `999` allow direct
socket access):
```
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 appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue']
tolerance: staging_infra_tolerance_enabled=True
INFRA-WAIVED: C9a Branch appears in orchestrator-sandbox, C9b Analyst job enqueued in staging queue (known sandbox-infra; real checks green)
VERDICT: SUCCESS (exit 0) — SUCCESS (infra-waived): ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue'] are known sandbox-infra checks; all real checks green
```
- **Block A (SMOKE):** A1 `/health` 200 ok, A2 `/queue` 200, A3 `ORCH_STAGING=true` — all PASS.
- **Block B (ACCESS):** B4 Plane sandbox, B5 Gitea `orchestrator-sandbox` (push=true),
B6 registry isolation (sandbox present, prod ET/ORCH absent) — all PASS.
- **Block C (E2E, stub):** C7 create issue in SANDBOX, C8 trigger pipeline via
`/webhook/plane` — PASS. C9a/C9b FAILED but are sandbox-infra checks (bot accounts
not members of the SANDBOX Plane project) — **waived** per ORCH-061; not a pipeline
regression. Cleanup deleted the test Plane issue (HTTP 204).
All REAL pipeline checks are green; the only failures are the two known
sandbox-infra checks, which the verdict tolerates (`staging_infra_tolerance_enabled=true`).
The script exited 0 → advance.

View File

@@ -1,30 +0,0 @@
---
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`.

View File

@@ -1,29 +0,0 @@
---
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

View File

@@ -12,6 +12,7 @@ Work Item: **ORCH-061** · Репо: `orchestrator` (self-hosting)
| **R-6** | never-raise нарушен: исключение из `staging_verdict`/классификатора. | Низкая | Среднее | `src/staging_verdict.py` — pure, без I/O; контракт never-raise (на битом вводе → консервативный FAILED). Логика вне пути `advance_stage` (исполняется в subprocess suite), поэтому в конвейер исключение структурно не попадает (AC-10). |
| **R-7** | FR-3: правка no-changes протекает на code-стадию (`development`) и маскирует «developer ничего не сделал». | Низкая | Среднее | Observability-строка ограничена `stage ∈ {deploy-staging, deploy}` и `self_deploy_applies(repo)`; логика продвижения launcher не меняется. Regression-guard TC-07. |
| **R-8** | Self-hosting: правки случайно затронут прод 8500 / не-self репо. | Низкая | Критич. | Изменения только на self-deploy-пути и в suite (бежит лишь для `orchestrator`-staging). `check_staging_status` для не-self репо неизменно `(True, N/A)` (AC-6/TC-08). Сборки/recreate — только 8501. Прод 8500 не трогается (AC-12). |
| **R-9** (realized) | Та же петля `deploy-staging → development` по ВТОРОЙ причине: `docker build` staging-образа падает (rc=1), т.к. `Dockerfile` `COPY data/ ./data/` ссылается на gitignore-каталог, отсутствующий в build-context воркти. Всплыло, когда waiver C9a/C9b впервые пропустил конвейер до пересборки образа (`check_staging_image_fresh`, ORCH-058). | — (произошло) | Высокое | `COPY data/ ./data/``RUN mkdir -p /app/data`. `data/` приходит через compose bind-mount, в образ запекать нечего. Инвариант: `Dockerfile` не `COPY` gitignore-путей (иначе сборка из воркти ломается). Гард — `tests/test_dockerfile_worktree_buildable.py`. |
## Контрактные инварианты (не нарушать)
- `STAGE_TRANSITIONS`, `get_previous_stage` — без изменений.

View File

@@ -1,7 +0,0 @@
# Business Request: BUG: zombie jobs + merge-lease залип (процесс умер, статус running)
Work Item ID: ORCH-065
## Description
TBD

View File

@@ -1,103 +0,0 @@
# 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 зелёный.

View File

@@ -1,170 +0,0 @@
# ТЗ — 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): живой долгий агент не реапится.

View File

@@ -1,122 +0,0 @@
# Критерии приёмки — 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: любой тест из плана красный или сломан существующий тест.

View File

@@ -1,196 +0,0 @@
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.

View File

@@ -1,275 +0,0 @@
# 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).

View File

@@ -1,42 +0,0 @@
# 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 не меняется.

View File

@@ -1,29 +0,0 @@
# 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, ложноположительных реапов не возникает.

View File

@@ -1,22 +0,0 @@
# 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) должно быть
пересмотрено, если упаковка агентов изменится (отдельные контейнеры).

View File

@@ -1,70 +0,0 @@
---
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 на месте, подтверждено строками 94105/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).

View File

@@ -1,92 +0,0 @@
---
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`.

View File

@@ -1,32 +0,0 @@
---
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

View File

@@ -1,39 +0,0 @@
---
staging_status: SUCCESS
timestamp: 2026-06-07T22:01:57Z
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed against the live `orchestrator-staging` instance (port 8501),
run canonically via `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 are green. The two failing checks are the known
SANDBOX_INFRA-only checks C9a/C9b (sandbox branch / analyst-job — depend on
SANDBOX bot accounts being project members, not on the pipeline), which are
waived under ORCH-061 since every REAL check passed.
```
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
```
## Check breakdown
| Block | Check | Result |
|-------|-------|--------|
| A SMOKE | A1 GET /health → 200 status=ok | PASS |
| A SMOKE | A2 GET /queue → 200 with counts/max_concurrency/resilience | PASS |
| A SMOKE | A3 ORCH_STAGING=true (not prod) | PASS |
| B ACCESS | B4 Plane: sandbox project accessible | PASS |
| B ACCESS | B5 Gitea: orchestrator-sandbox accessible, push=true | PASS |
| B ACCESS | B6 Registry: sandbox present, prod ET/ORCH absent | PASS |
| C E2E | C7 Create issue in Plane SANDBOX | PASS |
| C E2E | C8 Trigger pipeline via /webhook/plane | PASS |
| C E2E | C9a Branch appears in orchestrator-sandbox | FAIL (waived — sandbox-infra) |
| C E2E | C9b Analyst job enqueued in staging queue | FAIL (waived — sandbox-infra) |
CLEANUP completed: test Plane issue deleted (HTTP 204); no branch to delete.

View File

@@ -1,7 +0,0 @@
# Business Request: BUG: reconciler livelock — спам unblock done-задачи (ET-002)
Work Item ID: ORCH-068
## Description
TBD

View File

@@ -1,52 +0,0 @@
# BRD — ORCH-068: BUG reconciler livelock (спам unblock done-задачи)
## 1. Контекст и предыстория
Reconciler (`src/reconciler.py`, ORCH-053) — фоновый поток, который доигрывает пропущенные webhook-переходы. Ветвь **F-2 (plane-side)** опрашивает Plane per-project и реплеит In Progress / Approved / Rejected через штатные `handle_status_start` / `handle_verdict`. При фактической разблокировке вызывается `_note_unblock` → лог + Telegram.
ORCH-066 (мердж `feature/ORCH-066-plane`, прод 2026-06-07 ~22:16 UTC) ввела новую статусную модель Plane (маппинг стадия↔статус, новые имена `Done`, `Monitoring after Deploy` и т.п.). Это спровоцировало регрессию в F-2.
## 2. Проблема (бизнес-симптом)
С 22:17 UTC (рестарт прод-контейнера после деплоя ORCH-066) reconciler каждые ~120с шлёт в Telegram:
```
reconciler: ET-002 done разблокирована (потерян webhook)
```
для задачи **ET-002** (enduro-trails), которая в Done с 2026-05-21. На момент анализа — 191+ сообщений подряд, поток не прекращается (ночной спам, найден Славой 2026-06-08 01:22 UTC).
**Ключевой факт:** ET-002 полностью синхронизирована — БД `stage=done`, Plane `state=Done`. Reconciler обязан молчать (инвариант «silence when in sync», AC-9/AC-10 из ORCH-053), но шлёт уведомления вхолостую.
## 3. Диагностика (проведена, root cause найден)
1. **Деньги/токены НЕ тратятся:** `jobs` / `agent_runs` за 4ч пусты; `advance_stage` для done = no-op; `handle_verdict` для done-задачи ничего не меняет. Это «дешёвый», но шумный и подрывающий доверие баг (livelock + ложный alert-fatigue).
2. **Механизм:**
- `_reconcile_plane_project` (`src/reconciler.py` ~241) тянет `list_issues_by_state(pid, [in_progress, approved, rejected])`.
- На enduro-trails статусы «схлопнуты»: после ORCH-066 терминальный `Done` алиасится под UUID `approved` (см. ниже п.4) → ET-002 (Plane=Done) **попадает** в actionable-выборку.
- В `_reconcile_plane_issue` (~295) срабатывает ветка `new_state == approved and task is not None``handle_verdict(approved)` (no-op, задача уже done) **+ безусловный `_note_unblock`**.
- `_note_unblock` (~317) вызывается **сразу после `_dispatch`, не проверяя фактическое изменение состояния** — хотя его docstring обещает «fires only on an actual state change, never per idle tick». Инвариант нарушен.
3. **Два независимых дефекта складываются:**
- **D1 (выборка):** терминальные статусы (`Done`/`Cancelled`) не исключены из actionable-набора F-2; на «схлопывающих» проектах Done не отличается от approved по голому UUID.
- **D2 (нотификация):** `_note_unblock` срабатывает безусловно после no-op dispatch, а не только при подтверждённом state change.
4. **Почему `get_project_states` схлопывает:** функция строит маппинг по *именам* статусов из Plane API, затем недостающие ключи добивает из `_DEFAULT_STATES` (enduro-значения). После ORCH-066 набор статусов enduro изменился — голый UUID перестал однозначно различать `Done` (completed-группа) и `approved` (review). Группа состояния (`state.group`) при этом различает их корректно, но в коде не используется.
## 4. Связанный баг (BUG КЭША СТАТУСОВ, найден 2026-06-07 при деплое ORCH-066)
`_STATES_CACHE` (`src/plane_sync.py` ~134) кэширует статусы Plane на **весь lifetime процесса**. После создания нового Plane-статуса (напр. `Confirm Deploy`) боевой процесс держит устаревший набор → webhook на новый статус даёт «no pipeline action» (Phase B не триггерится). Лечилось только рестартом орка. Примитив сброса уже есть — `reload_project_states()` — но он нигде не вызывается автоматически.
Оба бага — следствие хрупкости статусной модели после ORCH-066. **Решение:** вести их в одном work item (см. scope ниже), окончательное разделение — на усмотрение архитектора.
## 5. Цели (Goals)
- G1. Reconciler НЕ шлёт «разблокирована» для синхронизированной done/cancelled задачи (восстановить инвариант silence-when-in-sync).
- G2. `_note_unblock` срабатывает **только** при реальном state change (соблюдён AC-9/AC-10).
- G3. Дедуп: нет повторного спама по той же задаче без изменения её состояния.
- G4. Корректное различение терминальных (`Done`/`Cancelled`) и review-статусов (`approved`/`rejected`) даже на проектах, «схлопывающих» их по UUID — на всех проектах (enduro И orchestrator).
- G5 (secondary). Устаревший `_STATES_CACHE` обновляется без рестарта процесса (TTL / flush-on-unknown / endpoint).
## 6. Не-цели (Out of scope)
- N1. Менять source-of-truth: ориентир F-2 на Plane **остаётся** корректным по дизайну (таблица `tasks` без status-колонки; статусы двигает человек в Plane). Идею F-2 НЕ переписываем — баг в маппинге/нотификации, не в концепции.
- N2. Менять реестры `STAGE_TRANSITIONS` / `QG_CHECKS`, схему БД, контракты гейтов.
- N3. Менять поведение F-1 (gate-side) и F-3.
- N4. Полный авто-approve деплоя (ORCH-54).
## 7. Затронутые стороны
- **Все проекты на одном инстансе** (enduro-trails + orchestrator, общая БД/очередь) — баг проявился на ET-002, но фикс выборки терминалов обязан быть проектно-независимым.
- **Self-hosting:** правка идёт в работающий прод-инструмент → обязательна страховка staging (8501), запрет на рестарт прод-контейнера в рамках задачи.
## 8. Критерий успеха (бизнес)
Тик reconciler для синхронизированной done/cancelled задачи = **0 уведомлений, 0 jobs, 0 токенов**. Telegram-спам прекращён. Легитимная разблокировка (реально потерянный approved/in_progress webhook) по-прежнему работает (нет регресса F-2).

View File

@@ -1,68 +0,0 @@
# ТЗ — ORCH-068: устранить livelock reconciler F-2 + спам unblock done-задачи
> Документ описывает ТРЕБОВАНИЯ к изменению поведения (что и где), а не выбор реализации. Конкретный механизм (state.group vs явный allowlist терминалов; TTL vs flush-on-unknown) — решение архитектора в ADR.
## 1. Затронутые модули `src/`
| Модуль | Роль в баге | Требуемое изменение |
|--------|-------------|---------------------|
| `src/reconciler.py` | F-2 `_reconcile_plane_project` / `_reconcile_plane_issue` / `_note_unblock` | Исключить терминальные статусы из actionable-выборки; `_note_unblock` только при подтверждённом state change; дедуп. |
| `src/plane_sync.py` | `get_project_states`, `list_issues_by_state`, `_STATES_CACHE` | Дать способ различать терминальные/review статусы (группа состояния); устранить вечно-устаревший кэш (TTL/flush). |
| `src/config.py` | флаги | (если нужны) новые kill-switch/настройки TTL — с дефолтами, не меняющими прод-инварианты. |
| `src/main.py` (`/queue`) | наблюдаемость | (опц.) отразить дедуп/skip-терминалов в снимке `reconcile`. |
**НЕ трогать:** `src/stages.py` (`STAGE_TRANSITIONS`), `src/qg/checks.py` (`QG_CHECKS`), схему БД, контракты `handle_status_start` / `handle_verdict`, F-1 (`reconcile_gate_once`), F-3.
## 2. Требования к F-2 (src/reconciler.py)
### TR-1. Исключить терминальные статусы из actionable-выборки
`_reconcile_plane_project` НЕ должен подавать задачи в терминальном Plane-статусе (`Done` и прочие completed-группы, `Cancelled`) ни в `list_issues_by_state`, ни в последующее сравнение веток.
- Требование проектно-независимое: работает на enduro И orchestrator, независимо от того, «схлопывает» ли проект статусы по UUID.
- Различение `Done`/`Cancelled` (completed) от `approved`/`rejected` (review) НЕ должно опираться только на голый UUID, если проект их алиасит. Допустимый ориентир — группа состояния Plane (`state.group`: `completed`/`started`/`unstarted`/`backlog`/`cancelled`) либо явный набор логических ключей терминалов. Выбор — за архитектором.
### TR-2. `_note_unblock` — только при реальном state change
`_note_unblock` (лог + Telegram + инкремент `unblocked_total`) ВЫЗЫВАЕТСЯ ТОЛЬКО когда диспетчеризованный обработчик фактически изменил состояние задачи (advance / replayed transition, реально сдвинувший стадию). No-op dispatch (задача уже в целевом состоянии) → нотификация НЕ отправляется.
- Сейчас `_dispatch` (`asyncio.run(coro_fn(...))`) отбрасывает результат, а `_note_unblock` зовётся безусловно. Требуется механизм подтверждения изменения (напр. сравнение стадии задачи до/после dispatch через `get_task_by_plane_id`, либо проброс сигнала из обработчика). Конкретику выбирает архитектор; контракт обработчиков `handle_*` менять НЕ обязательно (предпочтительно сравнение состояния до/после на стороне reconciler).
- Восстановить соответствие docstring `_note_unblock`: «Fires only on an actual state change … never per idle tick».
### TR-3. Дедуп / идемпотентность нотификаций
Reconciler НЕ должен слать повторное уведомление о той же задаче, если её состояние не менялось с прошлого тика. TR-1+TR-2 закрывают основной кейс (done более не входит в выборку и не нотифицируется); TR-3 — дополнительная страховка (best-effort), чтобы любой будущий no-op путь не дал повторного спама.
## 3. Требования к статус-кэшу (src/plane_sync.py) — secondary
### TR-4. Устаревший `_STATES_CACHE` обновляется без рестарта
После появления нового Plane-статуса процесс не должен бесконечно держать устаревший набор. Допустимые подходы (выбор архитектора, можно комбинировать):
- TTL на запись кэша (напр. `ORCH_PLANE_STATES_TTL_S`, дефолт разумный, 0/неуст. = прежнее поведение для совместимости);
- flush-on-unknown: при детекте неизвестного статуса в вебхуке/реконсилере — вызвать существующий `reload_project_states(pid)` и перезапросить;
- админ-эндпоинт/сигнал для ручного flush без рестарта.
`reload_project_states()` уже существует — переиспользовать как примитив сброса, новую логику сброса не дублировать.
## 4. Изменения API
- Новых обязательных endpoint'ов нет.
- Опционально (TR-4, на усмотрение архитектора): admin-эндпоинт сброса кэша статусов (напр. `POST /admin/plane-states/reload`) — если выбран этот вариант flush. Должен быть защищён/идемпотентен; документировать в README таблице API.
- Снимок `GET /queue` (блок `reconcile`) — без ломающих изменений; допустимо добавить поля наблюдаемости (skip-терминалов / dedup-счётчик).
## 5. Изменения схемы БД
**Нет.** Дедуп TR-3 реализуется in-memory (best-effort, как существующие счётчики `unblocked_total`/`last_unblocked`, AC-11 ORCH-053 допускает их сброс при рестарте) либо через сравнение живого состояния Plane/БД. Миграции не требуются.
## 6. Требования к новым QG checks
Нет. Реестр `QG_CHECKS` не меняется.
## 7. Инварианты (обязаны сохраниться)
- INV-1. Source of truth F-2 — Plane (НЕ меняем).
- INV-2. never-raise на единицу работы (per-issue / per-project / per-tick) сохранён.
- INV-3. Kill-switch `ORCH_RECONCILE_ENABLED` (+ `ORCH_RECONCILE_PLANE_ENABLED` гасит только F-2) — работают.
- INV-4. F-1 / F-3 поведение не изменено.
- INV-5. 0 jobs / 0 токенов для синхронизированных задач (как сейчас) сохранено.
- INV-6. Легитимная разблокировка реально-потерянного approved/in_progress webhook продолжает работать (нет регресса F-2).
- INV-7. Self-hosting: тик reconciler НИКОГДА не рестартит/не роняет прод-контейнер.
## 8. Артефакты pipeline, которые надо обновить в ТОМ ЖЕ PR
- `CLAUDE.md` — если меняется наблюдаемое поведение reconciler/кэша (раздел про reconciler/правила).
- `docs/architecture/README.md` — секция «Reconciler … (ORCH-053)»: уточнить исключение терминалов + дедуп; при TR-4 — секция «Plane Sync».
- `docs/architecture/adr/adr-0007-reconciler.md` (или новый per-WI ADR `docs/work-items/ORCH-068/06-adr/ADR-001-…`) — зафиксировать решение по терминалам/группе состояния и по кэшу.
- `CHANGELOG.md` — запись о фиксе (`fix:`).
- `.env.example` / `.env.staging` — если введён новый флаг (TTL/kill-switch).
## 9. Замечания по приёмке/тестированию
- Регресс-тест ОБЯЗАН покрывать: задача в `Done` (синхронизирована) → тик F-2 = 0 нотификаций, на enduro И orchestrator проектах (terminal не зависит от алиасинга).
- Тест НЕ должен делать реальных сетевых вызовов — мокать `list_issues_by_state` / `get_project_states` / `send_telegram` / `_dispatch` (handle_*).

View File

@@ -1,84 +0,0 @@
# Критерии приёмки — ORCH-068
Формат: каждый AC имеет чёткое условие PASS/FAIL. Задача принимается только при ВСЕХ PASS.
## Основное (P0) — livelock / спам
### AC-1. Синхронизированная done-задача → тишина
**Дано:** задача в Plane `state=Done`, БД `stage=done`, активных job нет.
**Когда:** выполняется один тик F-2 (`reconcile_plane_once` / `_reconcile_plane_project`).
- PASS: `_note_unblock` НЕ вызван; `send_telegram` НЕ вызван; `unblocked_total` не изменился; создано 0 jobs.
- FAIL: любое уведомление/лог «разблокирована»/инкремент счётчика для этой задачи.
### AC-2. Терминалы исключены из actionable-выборки
**Дано:** проект, где `Done` (и/или `Cancelled`) по UUID совпадает/«схлопнут» с `approved`/`rejected`.
**Когда:** `_reconcile_plane_project` формирует набор и обходит issues.
- PASS: issue в терминальном статусе (completed-группа / `Cancelled`) НЕ попадает ни в одну из веток `in_progress/approved/rejected`; для неё F-2 — no-op silence.
- FAIL: терминальная issue заходит в ветку approved/rejected/in_progress.
### AC-3. `_note_unblock` только при реальном state change
**Дано:** dispatch обработчика (`handle_verdict`/`handle_status_start`) фактически НЕ изменил стадию задачи (no-op, задача уже в целевом состоянии).
- PASS: `_note_unblock` НЕ вызван.
- FAIL: `_note_unblock` вызван после no-op dispatch.
### AC-4. Дедуп по неизменному состоянию
**Дано:** две последовательные итерации тика по одной и той же синхронизированной задаче, состояние между тиками не менялось.
- PASS: суммарно 0 повторных уведомлений по этой задаче.
- FAIL: повторное уведомление на втором тике без изменения состояния.
### AC-5. Нет регресса легитимной разблокировки F-2
**Дано:** задача, у которой Plane=`Approved`, а локальная стадия НЕ продвинулась (реально потерянный verdict-webhook), grace выдержан, активных job нет.
**Когда:** тик F-2.
- PASS: `handle_verdict(approved)` доигран; задача продвинута; `_note_unblock` вызван РОВНО один раз (реальный state change).
- FAIL: задача не продвинута ИЛИ нотификация не отправлена ИЛИ отправлена многократно.
### AC-6. Аналогично для in_progress-старта и rejected-отката
- PASS: потерянный `In Progress` (task is None) → старт пайплайна + 1 unblock; потерянный `Rejected` → откат + 1 unblock — оба только при реальном изменении.
- FAIL: ложный/повторный unblock или отсутствие легитимного.
## Инварианты (P0)
### AC-7. Деньги/ресурсы не тратятся на синхронизированные задачи
- PASS: 0 jobs, 0 agent_runs, 0 токенов для done/cancelled задач (как до бага).
- FAIL: любой созданный job/agent_run.
### AC-8. never-raise сохранён
**Дано:** `list_issues_by_state` / `get_project_states` / `_dispatch` / `send_telegram` бросают исключение.
- PASS: тик не падает; ошибка изолирована (per-issue / per-project), логируется; остальные задачи обрабатываются.
- FAIL: непойманное исключение роняет тик/поток.
### AC-9. Kill-switch'и работают
- PASS: `ORCH_RECONCILE_ENABLED=false` → F-1 и F-2 не выполняются; `ORCH_RECONCILE_PLANE_ENABLED=false` → F-2 не выполняется, F-1 работает.
- FAIL: любой свитч не гасит соответствующую ветку.
### AC-10. F-1 / F-3 / реестры / схема БД не изменены
- PASS: `STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, контракты `handle_*`, поведение F-1/F-3 — без изменений (diff не затрагивает).
- FAIL: любое изменение перечисленного.
### AC-11. Self-hosting безопасность
- PASS: ни один путь reconciler не рестартит/не роняет прод-контейнер `orchestrator`.
- FAIL: обратное.
## Secondary (P1) — кэш статусов
### AC-12. Устаревший `_STATES_CACHE` обновляется без рестарта
**Дано:** после старта процесса в Plane появился новый статус (его UUID отсутствует в кэше).
**Когда:** срабатывает выбранный механизм (TTL истёк / flush-on-unknown / ручной flush).
- PASS: следующий `get_project_states` возвращает свежий набор, включающий новый статус; webhook на новый статус даёт корректное pipeline-действие БЕЗ рестарта.
- FAIL: процесс продолжает отдавать устаревший набор → «no pipeline action».
### AC-13. Совместимость кэша по умолчанию
- PASS: при дефолтных настройках (TTL не задан / flush не сработал) поведение `get_project_states` не регрессирует; enduro по-прежнему получает свои UUID, fallback на `_DEFAULT_STATES` при недоступности API сохранён.
- FAIL: регресс резолва статусов или потеря fallback.
## Документация (P0 по правилам проекта)
### AC-14. Документация обновлена в том же PR
- PASS: обновлены применимые из {`docs/architecture/README.md` (Reconciler/Plane Sync), ADR, `CHANGELOG.md`, `CLAUDE.md`, `.env.example`}; reviewer подтверждает.
- FAIL: поведение изменено, доки/ADR/CHANGELOG не обновлены.
## Тесты (P0)
### AC-15. `pytest tests/ -q` зелёный
- PASS: весь набор тестов проходит; добавлены регресс-тесты из `04-test-plan.yaml`, включая done→silence на enduro и orchestrator.
- FAIL: любой красный тест или отсутствие регресс-теста на основной баг.

View File

@@ -1,122 +0,0 @@
work_item: ORCH-068
description: >
Регрессионные и модульные тесты на устранение livelock reconciler F-2
(спам _note_unblock для синхронизированной done-задачи) и связанного бага
кэша статусов. Все тесты офлайн: Plane API / Telegram / dispatch мокаются.
Целевые модули: src/reconciler.py, src/plane_sync.py.
tests:
# ---------- P0: основной баг (livelock / спам) ----------
- id: TC-01
type: unit
description: >
Синхронизированная done-задача (Plane=Done, БД=done, нет активных job):
один тик F-2 -> _note_unblock НЕ вызван, send_telegram НЕ вызван,
unblocked_total не изменился, 0 jobs. (AC-1, AC-7)
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-02
type: unit
description: >
Терминал «схлопнут» с approved по UUID: issue в Done с тем же UUID, что и
approved-набор, НЕ заходит ни в одну ветку in_progress/approved/rejected
(silence). Проверка проектно-независимого исключения терминалов. (AC-2)
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-03
type: unit
description: >
Cancelled терминал также исключён из actionable-выборки -> тик = silence,
0 нотификаций. (AC-2)
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-04
type: unit
description: >
_note_unblock не вызывается после no-op dispatch: handle_verdict не сдвинул
стадию (задача уже в целевом состоянии) -> 0 нотификаций. (AC-3)
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-05
type: unit
description: >
Дедуп: два последовательных тика по одной синхронизированной задаче без
изменения состояния -> суммарно 0 повторных уведомлений. (AC-4)
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-06
type: unit
description: >
Нет регресса: Plane=Approved, локальная стадия не продвинута, grace выдержан,
нет активных job -> handle_verdict доигран, задача продвинута, _note_unblock
вызван РОВНО один раз. (AC-5)
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-07
type: unit
description: >
Нет регресса для in_progress (task is None -> старт пайплайна, 1 unblock) и
rejected (task существует -> откат, 1 unblock), оба только при реальном
изменении состояния. (AC-6)
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-08
type: unit
description: >
never-raise: list_issues_by_state / get_project_states / _dispatch /
send_telegram бросают исключение -> тик не падает, ошибка изолирована и
залогирована, прочие issues обработаны. (AC-8)
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-09
type: unit
description: >
Kill-switch: reconcile_enabled=False -> F-2 не выполняется;
reconcile_plane_enabled=False -> F-2 не выполняется, F-1 не затронут. (AC-9)
module: tests/test_reconciler_plane.py
expected: PASS
- id: TC-10
type: integration
description: >
End-to-end F-2 на двух проектах (enduro И orchestrator): задача в Done на
каждом -> тик reconcile_plane_once = 0 нотификаций / 0 jobs на обоих,
независимо от алиасинга статусов проекта. Главный регресс-тест бага. (AC-1, AC-2)
module: tests/test_reconciler_plane.py
expected: PASS
# ---------- P1: связанный баг кэша статусов ----------
- id: TC-11
type: unit
description: >
Устаревший _STATES_CACHE обновляется без рестарта: после появления нового
статуса срабатывает выбранный механизм (TTL/flush) -> следующий
get_project_states содержит новый статус. (AC-12)
module: tests/test_plane_states_cache.py
expected: PASS
- id: TC-12
type: unit
description: >
Совместимость по умолчанию: при дефолтных настройках get_project_states не
регрессирует — enduro отдаёт свои UUID, fallback на _DEFAULT_STATES при
недоступности API сохранён. (AC-13)
module: tests/test_plane_states_cache.py
expected: PASS
# ---------- P0: общий прогон ----------
- id: TC-13
type: integration
description: >
Полный набор pytest tests/ -q зелёный (нет регресса в reconciler/plane/qg/
stage_engine). (AC-15)
module: tests/
expected: PASS

View File

@@ -1,162 +0,0 @@
# ADR-001 (ORCH-068): Исключение терминалов из F-2 по группе состояния + подтверждённый unblock + TTL кэша статусов
- **Статус:** Accepted
- **Дата:** 2026-06-08
- **Задача:** ORCH-068 (BUG: reconciler livelock — спам «разблокирована» по синхронизированной done-задаче)
- **Сквозной ADR:** уточняет [adr-0007-reconciler.md](../../../architecture/adr/adr-0007-reconciler.md) (F-2) — реестры/схема НЕ меняются
- **Связанные:** ORCH-053 (reconciler F-2), ORCH-066 (новая статусная модель Plane — триггер регрессии), ORCH-060 (F-1 пред-гарды), ORCH-10 (`get_project_states`)
## Контекст
Reconciler F-2 (`src/reconciler.py`, `_reconcile_plane_project` / `_reconcile_plane_issue`)
опрашивает Plane per-project и доигрывает потерянные webhook-переходы через штатные
`handle_status_start` / `handle_verdict`. После мерджа ORCH-066 (новая статусная модель
Plane) на проде с 22:17 UTC reconciler каждые ~120с слал в Telegram
`reconciler: ET-002 done разблокирована (потерян webhook)` для задачи ET-002, которая
полностью синхронизирована (БД `stage=done`, Plane `state=Done` с 2026-05-21). 191+
сообщений за ночь — livelock без advance/jobs/токенов, но подрывающий доверие alert-fatigue.
Диагностика (BRD §3) выявила **два независимых, складывающихся дефекта**:
- **D1 (выборка):** F-2 различает actionable-статусы по **голому UUID**
(`in_progress`/`approved`/`rejected`). `get_project_states` строит маппинг по *именам*
статусов, недостающие ключи добивает из `_DEFAULT_STATES` (enduro-значения). После
ORCH-066 набор имён enduro изменился → терминальный `Done` перестал однозначно
отличаться от `approved` по UUID, и ET-002 (Plane=Done) **попала** в actionable-набор
ветки `approved`. Терминальные статусы (`Done`/`Cancelled`) нигде не исключаются из F-2.
- **D2 (нотификация):** `_note_unblock` вызывается **безусловно сразу после `_dispatch`**,
не проверяя, изменил ли обработчик реально состояние задачи. `handle_verdict(approved)`
для уже-`done` задачи — no-op, но нотификация всё равно уходит. Это прямо нарушает
собственный docstring `_note_unblock` («fires only on an actual state change, never per
idle tick») и инвариант silence-when-in-sync (AC-9/AC-10 ORCH-053).
Связанный secondary-баг (BRD §4): `_STATES_CACHE` (`src/plane_sync.py`) кэширует статусы
на **весь lifetime процесса**. После появления нового Plane-статуса боевой процесс держит
устаревший набор → webhook на новый статус даёт «no pipeline action», лечилось только
рестартом орка. Примитив сброса `reload_project_states()` уже есть, но автоматически не
вызывается.
Ограничения (из ТЗ, обязаны сохраниться): источник истины F-2 — Plane (не переписываем);
НЕ трогать `STAGE_TRANSITIONS` / `QG_CHECKS` / схему БД / контракты `handle_*` / F-1 / F-3;
never-raise per unit of work; kill-switch'и; 0 jobs/0 токенов для синхронизированных задач;
self-hosting — reconciler НИКОГДА не рестартит прод-контейнер.
## Решение
Чиним **оба** дефекта независимыми слоями (defense in depth, как принято в проекте —
ORCH-058) плюс TTL для кэша. Все правки локальны в `src/reconciler.py` и
`src/plane_sync.py`; реестры, схема БД и контракты обработчиков не меняются.
### Слой D1 — исключение терминалов по ГРУППЕ состояния (TR-1, AC-2)
Различаем терминальные (`completed`/`cancelled`) и review/work-статусы по **группе
состояния Plane** (`state.group ∈ {backlog, unstarted, started, completed, cancelled}`),
а НЕ по голому UUID. Группа — авторитетный, проектно-независимый дискриминатор: она
корректно различает `Done` (completed) и `approved` (started/review) даже когда проект
«схлопывает» их по UUID после переименований.
Механика (single API fetch, без новых сетевых вызовов):
- `/states/`-ответ Plane содержит для каждого статуса поле `group`. Расширяем кэш-запись
`_STATES_CACHE` так, чтобы из ОДНОГО запроса хранить и текущий `{logical_key → uuid}`,
и `{uuid → group}`. `get_project_states` сохраняет **прежнюю сигнатуру и форму возврата**
(`{logical_key: uuid}`) — обратная совместимость (AC-13). Добавляется sibling-аксессор
`get_project_state_groups(project_id) -> dict[uuid, group]` (или эквивалент), читающий ту
же кэш-запись.
- В `_reconcile_plane_issue` ДО выбора ветки: если группа `new_state`
{`completed`, `cancelled`} → **тишина** (return, no-op). Fallback, когда группа
недоступна (API не отдал `group` / fallback на `_DEFAULT_STATES`): исключать по логическим
ключам терминалов `{states.get("done"), states.get("cancelled")}`.
Терминал-исключение применяется **per-issue** (а не сужением `wanted`-набора
`list_issues_by_state`), потому что при UUID-алиасинге терминал может физически совпадать с
actionable-UUID в `wanted` — фильтрация по UUID его не отсечёт, а проверка группы отсечёт.
### Слой D2 — `_note_unblock` только при подтверждённом state change (TR-2, AC-3)
`_note_unblock` (лог + Telegram + `unblocked_total`) вызывается ТОЛЬКО когда диспетчеризованный
обработчик **фактически изменил состояние задачи**. Реализация — сравнение состояния
**до/после на стороне reconciler** (предпочтение ТЗ; контракты `handle_*` НЕ меняются):
- `approved`/`rejected` (task существует): захватить `stage_before` (из уже прочитанного
`task`), после `_dispatch` перечитать `get_task_by_plane_id(issue_id)``stage_after`;
`_note_unblock` только если `stage_after != stage_before`.
- `in_progress` + `task is None` (старт пайплайна): подтверждение = задача **появилась**
после dispatch (`get_task_by_plane_id` теперь не None).
No-op dispatch (задача уже в целевом состоянии) → 0 нотификаций. Восстанавливает соответствие
docstring и инвариант silence-when-in-sync.
### Слой TR-3 — дедуп нотификаций (страховка, AC-4)
In-memory best-effort guard: `{issue_id → last_unblocked_state_uuid}`. `_note_unblock` для
issue+state, уже отмеченного, подавляется. Сбрасывается при рестарте (допустимо — AC-11
ORCH-053, как `unblocked_total`/`last_unblocked`). D1+D2 закрывают основной кейс; TR-3 —
дополнительная сетка против любого будущего no-op-пути.
### Слой TR-4 — TTL кэша статусов (secondary, AC-12/AC-13)
Кэш-запись `_STATES_CACHE` хранит timestamp; `get_project_states` перезапрашивает API при
истечении `ORCH_PLANE_STATES_TTL_S`. Примитив инвалидации — существующий
`reload_project_states()` (не дублируем логику сброса). Новый флаг
`plane_states_ttl_s` (env `ORCH_PLANE_STATES_TTL_S`):
- дефолт **300** (5 мин) — устаревший набор самозалечивается без рестарта (G5);
- `0` — отключает TTL → строго прежний lifetime-кэш (escape hatch / strict back-compat).
Fallback на `_DEFAULT_STATES` при недоступности API сохранён без изменений; TTL-перезапрос
возвращает тот же корректный набор → не регресс (AC-13).
## Альтернативы
- **Явный allowlist логических ключей терминалов (`done`/`cancelled`) без группы** —
отклонён как primary: хрупок к будущим переименованиям/добавлению completed-статусов
(`Monitoring after Deploy` и т.п.) и к UUID-алиасингу. Оставлен как **fallback**, когда
`group` недоступен.
- **Сужение `wanted`-набора в `list_issues_by_state`** — недостаточно: при UUID-алиасинге
терминал совпадает с actionable-UUID и не отсекается фильтром по UUID. Нужна проверка
группы per-issue.
- **Проброс «changed»-сигнала из `handle_*`** — отклонён: меняет контракт обработчиков
(запрещено ТЗ N2). Выбрано сравнение до/после на стороне reconciler.
- **Флаг подавления нотификаций в `advance_stage`** — отклонён (как и в adr-0007):
трогает общий критический путь.
- **flush-on-unknown как primary для кэша** — допустимо ТЗ и дешевле, но недетерминирован
для юнит-теста (TC-11) и не лечит «тихий устаревший набор» без триггера-вебхука. Выбран
TTL (детерминированный, самозалечивающий); flush-on-unknown может быть добавлен позже как
комплемент, переиспользуя `reload_project_states`.
- **Admin-эндпоинт `POST /admin/plane-states/reload`** — отклонён в объёме (требует
ручного действия, не лечит автоматически); TTL покрывает G5 без нового API.
## Последствия
- **Плюсы:** livelock устранён двумя независимыми слоями; терминал-исключение
проектно-независимо (enduro И orchestrator), устойчиво к будущим переименованиям статусов;
`_note_unblock` снова соответствует своему контракту; устаревший кэш самозалечивается без
рестарта прода. Реестры/схема/контракты/F-1/F-3 не тронуты.
- **Минусы / плата:** один доп. accessor группы (тот же API-запрос, без новой сетевой
стоимости); TTL добавляет редкий перезапрос `/states/` (раз в 5 мин/проект); дедуп-словарь
— небольшая in-memory структура, неперсистентная (приемлемо).
- **Совместимость:** `get_project_states` форма возврата неизменна; `plane_states_ttl_s=0`
→ строго прежнее поведение кэша; `_DEFAULT_STATES`-fallback сохранён.
- **Self-hosting:** ни один путь не рестартит прод-контейнер (AC-11); правка
обязательно проходит staging-гейт (8501) перед прод-деплоем орка.
- **Наблюдаемость (опц.):** допустимо добавить в блок `reconcile` снимка `GET /queue`
счётчики `skipped_terminal` / `deduped` без ломающих изменений.
## Инварианты (подтверждение)
INV-1 источник истины F-2 = Plane — сохранён (правим маппинг/нотификацию, не концепцию).
INV-2 never-raise per-issue/-project/-tick — сохранён (новый guard в том же try-периметре).
INV-3 kill-switch'и `ORCH_RECONCILE_ENABLED` / `ORCH_RECONCILE_PLANE_ENABLED` — без изменений.
INV-4 F-1/F-3 — не тронуты. INV-5 0 jobs/0 токенов для done/cancelled — восстановлен.
INV-6 легитимная разблокировка реально-потерянного approved/in_progress — работает (D2
подтверждает реальный change, не подавляет его). INV-7 self-hosting — тик не рестартит прод.
## Объём изменений (для разработчика)
- `src/reconciler.py`: терминал-гард по группе + fallback в `_reconcile_plane_issue`;
before/after-сравнение стадии вокруг `_dispatch`; in-memory дедуп-словарь в `Reconciler`.
- `src/plane_sync.py`: кэш-запись с timestamp + `{uuid→group}`; `get_project_state_groups`;
TTL-логика в `get_project_states` (переиспользуя `reload_project_states`).
- `src/config.py`: флаг `plane_states_ttl_s` (env `ORCH_PLANE_STATES_TTL_S`, дефолт 300).
- `.env.example` / `.env.staging`: задокументировать `ORCH_PLANE_STATES_TTL_S`.
- Доки в ТОМ ЖЕ PR: `docs/architecture/README.md` (Reconciler/Plane Sync), `CHANGELOG.md`
(`fix:`), `CLAUDE.md` (при изменении наблюдаемого поведения), этот ADR.
- Тесты: `04-test-plan.yaml` (TC-01…TC-13), офлайн (мок Plane/Telegram/`_dispatch`).

View File

@@ -1,17 +0,0 @@
# Технические риски — ORCH-068
| ID | Риск | Вероятность | Влияние | Митигация |
|----|------|-------------|---------|-----------|
| R-1 | Plane `/states/` не отдаёт поле `group` (старая версия API / урезанный ответ) → терминал-исключение по группе не срабатывает | Низкая | Высокое (рецидив livelock) | Fallback на логические ключи терминалов `{done, cancelled}` при отсутствии `group`; never-raise → консервативная тишина при сбое резолва |
| R-2 | Over-exclusion: легитимная задача в started/review-группе ошибочно классифицирована как терминал → пропущена легитимная разблокировка (регресс INV-6) | Низкая | Среднее | Исключаем ТОЛЬКО группы `completed`/`cancelled`; `approved`/`rejected` относятся к started/unstarted → не задеты; регресс-тесты TC-06/TC-07 |
| R-3 | Гонка before/after: между `stage_before` и `stage_after` живой webhook двигает стадию → ложный `_note_unblock` | Очень низкая | Низкое | active-job guard + `max_concurrency=1` уже сериализуют; дедуп TR-3 подавляет повтор; ложный unblock безвреден (0 jobs/токенов) |
| R-4 | TTL `300s` провоцирует частые `/states/`-перезапросы при многих проектах | Низкая | Низкое | 1 запрос/проект/5 мин — пренебрежимо; `ORCH_PLANE_STATES_TTL_S=0` отключает TTL |
| R-5 | TTL-перезапрос в момент недоступности Plane → временный fallback на `_DEFAULT_STATES` (enduro) для не-enduro проекта | Низкая | Среднее | Поведение идентично текущему cold-cache fallback; самозалечивается следующим успешным запросом; не хуже статус-кво |
| R-6 | Дедуп-словарь растёт неограниченно (по issue_id) | Очень низкая | Низкое | Ключи — только реально разблокированные issue (редки); сбрасывается при рестарте; при необходимости — ограничить размер/LRU |
| R-7 | Изменение в `get_project_states` (кэш-запись) ломает прочих потребителей формы возврата | Низкая | Высокое | Внешняя сигнатура и форма `{logical_key: uuid}` сохранены; группа — отдельный accessor; покрыто TC-12 (совместимость по умолчанию) |
| R-8 | Self-hosting: правка в работающем прод-инструменте | — | Высокое | Обязательный staging-гейт (8501); запрет рестарта прод-контейнера в рамках задачи; INV-7 |
## Замечания
- Все правки локальны (`reconciler.py`, `plane_sync.py`, `config.py`); схема БД, реестры
`STAGE_TRANSITIONS`/`QG_CHECKS`, контракты `handle_*`, F-1/F-3 — не затронуты (AC-10).
- Тесты офлайн (мок Plane API / Telegram / `_dispatch`) — сетевых вызовов в CI нет.

View File

@@ -1,47 +0,0 @@
---
type: review
work_item_id: ORCH-068
verdict: APPROVED
version: 1
---
# Review ORCH-068
## Summary
Фикс livelock reconciler F-2 (спам `_note_unblock` по синхронизированной done-задаче после ORCH-066) реализован чисто и полностью по ТЗ/ADR. Два независимых слоя (D1 терминал-исключение по группе состояния + D2 подтверждённый state change) плюс TR-3 дедуп и TR-4 TTL кэша. Правки строго локальны в `src/reconciler.py` (F-2), `src/plane_sync.py`, `src/config.py`. Запрещённые ТЗ артефакты (`STAGE_TRANSITIONS`, `QG_CHECKS`, схема БД, контракты `handle_*`, F-1, F-3) не тронуты — diff не выходит за 3 файла `src/`. `pytest tests/ -q`**764 passed**.
## Соответствие ТЗ
- **TR-1** (исключить терминалы) ✅ — `_is_terminal_state` по `state.group ∈ {completed, cancelled}` с fallback на логические ключи `done`/`cancelled`; проверка per-issue (а не сужением `wanted`), что корректно для UUID-алиасинга.
- **TR-2** (`_note_unblock` только при реальном change) ✅ — `_stage_changed` (сравнение стадии до/после `_dispatch`), для in_progress-старта подтверждение = задача появилась; контракты `handle_*` не менялись.
- **TR-3** (дедуп) ✅ — in-memory guard `{issue_id → state_uuid}`, best-effort, сброс при рестарте (как `unblocked_total`).
- **TR-4** (TTL кэша) ✅ — `plane_states_ttl_s` (дефолт 300, `0`=lifetime), переиспользует `reload_project_states`; форма возврата `get_project_states` неизменна; при сбое перезапроса отдаётся stale-but-correct набор.
## Соответствие ADR
ADR-001 (terminal-exclusion-and-cache-ttl) реализован 1:1: группа как primary-дискриминатор, allowlist-fallback, before/after-сравнение на стороне reconciler, TTL с инвалидацией через существующий примитив. Сквозной adr-0007 дополнен корректной ссылкой. Все инварианты INV-1…INV-7 сохранены (источник истины Plane, never-raise per-issue, kill-switch'и, F-1/F-3 нетронуты, self-hosting не рестартит прод).
## Критерии приёмки
AC-1…AC-15 — все PASS. Покрытие тестами адресное: TC-01 (synced Done silence), TC-02 (aliased terminal по группе — ядро D1), TC-03 (Cancelled), TC-04 (no-op silence), TC-05 (дедуп), TC-06/TC-07 (легитимный unblock ×1), TC-08 (never-raise изоляция), TC-09 (kill-switch), TC-10 (enduro И orchestrator — headline-регресс), TC-11/TC-12 (TTL self-heal + back-compat + stale-on-failure).
## Findings
### P0 — Blocker
- нет
### P1 — Must fix
- нет
### P2 — Should fix
- нет
### P3 — Nice-to-have
- [ ] Дедуп-guard ключуется по `issue_id → state_uuid` без сброса при смене состояния. Теоретический edge-case: задача legitимно проходит `approved`(uuid_X)→…→снова `approved`(тот же uuid_X) — повторное (но легитимное) уведомление будет подавлено. Функционального ущерба нет (advance выполняется в `_dispatch` независимо от нотификации), это потеря только уведомления, и D2 — основной гард. Best-effort по контракту ТЗ. Можно при желании чистить запись при детекте смены состояния away-and-back. Не блокирует.
## Документация
Обновлена полно и в том же PR (AC-14 PASS):
- `docs/architecture/README.md` — компонент **Plane Sync** (TTL + `{uuid→group}`) и секция **Reconciler/F-2/F-4** (терминал-исключение, дедуп, счётчики `skipped_terminal_total`/`deduped_total`); футер «обновлять при изменении» расширен записью ORCH-068.
- `docs/architecture/adr/adr-0007-reconciler.md` — добавлена кросс-ссылка на per-WI ADR.
- `docs/work-items/ORCH-068/06-adr/ADR-001-…` — детальный ADR (Accepted).
- `CHANGELOG.md` — запись `### Fixed` (D1/D2/TR-3/TR-4, инварианты, тесты).
- `.env.example``ORCH_PLANE_STATES_TTL_S=300` с комментарием.
Изменение `src/` сопровождено соответствующим обновлением документации — требование golden-source выполнено.

View File

@@ -1,64 +0,0 @@
---
type: test-report
work_item_id: ORCH-068
result: PASS
---
# Test Report — ORCH-068
Фикс livelock reconciler F-2 (спам `_note_unblock` по синхронизированной
done-задаче) + связанный баг устаревшего `_STATES_CACHE`.
## Окружение
- Python: 3.12.13
- pytest: 8.3.3 (plugins: anyio-4.13.0, asyncio-0.23.8)
- Среда исполнения: worktree `feature/ORCH-068-bug-reconciler-livelock-unbloc`
- Prod health (8500): `{"status":"ok","service":"orchestrator"}` — OK (read-only smoke)
- Дата: 2026-06-08T05:13:59Z
## Smoke test API (read-only, прод 8500 не трогался деструктивно)
| Endpoint | Результат |
|----------|-----------|
| `GET /health` | `{"status":"ok",...}` — OK |
| `GET /status` | 200, активные задачи отданы — OK |
| `GET /queue` | 200, counts + блок `reconcile` (enabled/plane_enabled/unblocked_total) — OK |
## Результаты
| TC ID | Описание | Тест | Результат |
|-------|----------|------|-----------|
| TC-01 | Синхронизированная done → тишина (AC-1, AC-7) | `test_tc01_synced_done_is_silent` | PASS |
| TC-02 | Терминал «схлопнут» с approved по UUID исключён (AC-2) | `test_tc02_terminal_aliased_to_approved_excluded` | PASS |
| TC-03 | Cancelled терминал исключён (AC-2) | `test_tc03_cancelled_excluded` | PASS |
| TC-04 | `_note_unblock` не вызван после no-op dispatch (AC-3) | `test_tc04_noop_dispatch_no_unblock` | PASS |
| TC-05 | Дедуп: 0 повторных уведомлений (AC-4) | `test_tc05_dedup_no_repeat_notification` | PASS |
| TC-06 | Легитимный approved → unblock ровно один раз (AC-5) | `test_tc06_legit_approved_unblock_once` | PASS |
| TC-07 | in_progress-старт и rejected-откат, каждый 1 unblock (AC-6) | `test_tc07_in_progress_start_and_rejected_each_one_unblock` | PASS |
| TC-08 | never-raise: изоляция ошибок (AC-8) | `test_tc08_never_raise_isolation` | PASS |
| TC-09 | Kill-switch'и F-2 (AC-9) | `test_tc09_kill_switches` | PASS |
| TC-10 | done→silence на enduro И orchestrator (headline-регресс, AC-1/AC-2) | `test_tc10_done_silent_on_all_projects` | PASS |
| TC-11 | Устаревший `_STATES_CACHE` self-heal по TTL (AC-12) | `test_tc11_stale_cache_refreshes_after_ttl` + accessor/zero-ttl тесты | PASS |
| TC-12 | Совместимость кэша по умолчанию + fallback (AC-13) | `test_tc12_enduro_uuids_unchanged`, `test_tc12_api_error_falls_back_to_defaults`, `test_tc12_stale_served_when_refresh_fails` | PASS |
| TC-13 | Полный прогон `pytest tests/` зелёный (AC-15) | весь набор | PASS |
## Соответствие критериям приёмки
AC-1…AC-15 — все PASS. Целевые регресс-тесты (TC-01..TC-12) проходят;
полный набор без регрессий в reconciler / plane / qg / stage_engine / webhooks.
## Вывод pytest
### Целевые файлы (tests/test_reconciler_plane.py + tests/test_plane_states_cache.py)
```
collected 26 items
... (все 26 PASSED, включая tc01..tc17 reconciler + tc11/tc12 cache)
======================== 26 passed, 1 warning in 0.82s =========================
```
### Полный набор
```
======================= 764 passed, 1 warning in 13.66s ========================
```
(1 warning — PydanticDeprecatedSince20 в src/config.py, не относится к ORCH-068, предсуществующий.)
## Итог
PASS

View File

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

View File

@@ -1,33 +0,0 @@
---
staging_status: SUCCESS
timestamp: 2026-06-08T05:17:46Z
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed. Exit code 0 → SUCCESS.
Executed canonically inside the `orchestrator-staging` container (ORCH-048, ADR-001),
so the B6 registry-isolation check read the staging instance's own process-env
(`.env.staging` → SANDBOX-only registry):
```
docker exec orchestrator-staging \
python3 /repos/orchestrator/scripts/staging_check.py \
--base-url http://localhost:8501 --mode stub
```
## Result: 8/10 checks PASS
- REAL failed: none
- All REAL checks green (Block A SMOKE, Block B ACCESS incl. B6 registry isolation,
C7 create issue, C8 trigger 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
Per ORCH-061, the two infra-only checks C9a/C9b (which depend on SANDBOX bot accounts
being project members, not on the pipeline) are tolerated when every REAL check is
green; the script prints `INFRA-WAIVED:`/`VERDICT:` lines and exits 0. Verdict trusts
the exit code.

View File

@@ -1,14 +0,0 @@
---
post_deploy_status: HEALTHY
action_taken: NONE
work_item: ORCH-068
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.

View File

@@ -249,11 +249,6 @@ class AgentLauncher:
"""
if job.get("agent") == "deploy-finalizer":
return self._run_deploy_finalizer_job(job)
# ORCH-021: the reserved-agent `post-deploy-monitor` is also a
# DETERMINISTIC (no-LLM) tick — intercept it BEFORE _spawn and run one
# observation tick synchronously. Returns None (no agent_run row).
if job.get("agent") == "post-deploy-monitor":
return self._run_post_deploy_monitor_job(job)
return self._spawn(
job["agent"],
job["repo"],
@@ -283,27 +278,6 @@ class AgentLauncher:
pass
return None
def _run_post_deploy_monitor_job(self, job: dict):
"""ORCH-021: run one deterministic post-deploy monitor tick for a job.
Not an LLM spawn — there is no subprocess/monitor, so we mark the jobs row
done/failed here. The tick never-raises, but we guard anyway so a monitor
fault can never wedge the worker / starve other projects (AC-16).
"""
from ..db import mark_job
from .. import stage_engine
try:
stage_engine.run_post_deploy_monitor(job)
mark_job(job["id"], "done")
logger.info(f"post-deploy-monitor job {job['id']} done")
except Exception as e:
logger.error(f"post-deploy-monitor job {job['id']} failed: {e}")
try:
mark_job(job["id"], "failed", error=f"post-deploy-monitor error: {e}")
except Exception:
pass
return None
def _spawn(self, agent: str, repo: str, task_content: str = None,
task_id: int = None, job_id: int = None) -> int:
"""Shared spawn implementation for launch() and launch_job().
@@ -417,14 +391,6 @@ class AgentLauncher:
"UPDATE agent_runs SET output_path = ? WHERE 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.close()

View File

@@ -265,85 +265,6 @@ class Settings(BaseSettings):
reconcile_notify_unblock: bool = True
reconcile_skip_blocked_enabled: bool = True
# ORCH-068: TTL for the per-project Plane states cache (_STATES_CACHE in
# plane_sync). Historically the cache lived for the whole process lifetime,
# so a status added to Plane after start was never seen without a restart
# ("stale set -> no pipeline action"). With a TTL the entry self-heals by
# re-fetching /states/ after it expires (invalidation reuses the existing
# reload_project_states() primitive — no duplicated reset logic).
# plane_states_ttl_s (env ORCH_PLANE_STATES_TTL_S):
# >0 -> seconds before a cache entry is re-fetched (default 300 = 5 min);
# 0 -> disable TTL -> strictly the previous lifetime cache (back-compat
# escape hatch). get_project_states return shape is unchanged.
plane_states_ttl_s: int = 300
# ORCH-021: post-deploy production monitoring + degradation reaction. After
# the terminal deploy->done transition for an applicable repo, a reserved-agent
# `post-deploy-monitor` job (no LLM, modelled on deploy-finalizer) probes prod
# over a window and reacts to a degradation the restart-time health-check
# missed (class "green deploy, red prod", precedent ET-8). State is in sentinel
# files (.post-deploy-state-<repo>/<wi>/), no DB migration. See
# docs/architecture/adr/adr-0010-post-deploy-monitor.md.
# post_deploy_monitor_enabled -> global kill-switch (BR-8); False -> the
# pipeline is 1:1 as before ORCH-021 (no arm).
# post_deploy_repos -> CSV of repos where monitoring is REAL; empty
# -> only the self-hosting repo (orchestrator).
# Mirrors self_deploy_repos / merge_gate_repos.
# post_deploy_window_s -> observation window length (~15 min, BR-1).
# post_deploy_interval_s -> seconds between probe ticks.
# post_deploy_fail_threshold -> N CONSECUTIVE health failures -> DEGRADED.
# post_deploy_5xx_threshold -> window 5xx ratio above this -> DEGRADED.
# post_deploy_auto_rollback -> globally allow auto-rollback; True acts ONLY
# for non-self repos. For self-hosting the
# reaction is ALWAYS ALERT_ONLY (BR-5) — a tick
# NEVER restarts the prod orchestrator container.
# post_deploy_base_url -> base URL of the observed prod instance.
# Rollback target params reuse the existing deploy_prod_* settings (no dupes).
post_deploy_monitor_enabled: bool = True
post_deploy_repos: str = ""
post_deploy_window_s: int = 900
post_deploy_interval_s: int = 30
post_deploy_fail_threshold: int = 3
post_deploy_5xx_threshold: float = 0.5
post_deploy_auto_rollback: bool = False
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_bot_token: str = ""
telegram_chat_id: str = ""

View File

@@ -76,11 +76,6 @@ def init_db():
# (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", "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
# 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:
@@ -598,82 +593,6 @@ def requeue_running_jobs() -> int:
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:
"""Fetch a single job by id."""
conn = get_db()

View File

@@ -1,467 +0,0 @@
"""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()

View File

@@ -60,19 +60,6 @@ async def lifespan(app: FastAPI):
if requeued:
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).
try:
import os as _os
@@ -98,22 +85,13 @@ async def lifespan(app: FastAPI):
from .reconciler import reconciler
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:
yield
finally:
# Graceful shutdown order mirrors startup in reverse: stop the reaper
# first, then the reconciler (it must not enqueue new work while the
# worker is winding down), then the worker. Running agents keep going;
# their jobs are requeued on next start via queue-recovery if the
# process dies.
reaper.stop()
# Graceful shutdown order mirrors startup in reverse: stop the reconciler
# first (it must not enqueue new work while the worker is winding down),
# then the worker. Running agents keep going; their jobs are requeued on
# next start via queue-recovery if the process dies.
reconciler.stop()
worker.stop()
@@ -145,15 +123,11 @@ async def queue():
from .db import job_status_counts, recent_jobs
from .queue_worker import worker
from .reconciler import reconciler
from .job_reaper import reaper
from . import post_deploy
return {
"counts": job_status_counts(),
"max_concurrency": worker.max_concurrency,
"poll_interval": worker.poll_interval,
"resilience": worker.status(),
"reconcile": reconciler.status(),
"reaper": reaper.status(),
"post_deploy": post_deploy.status(),
"recent": recent_jobs(10),
}

View File

@@ -338,150 +338,3 @@ def release_merge_lease(repo: str, branch: str | None = None) -> None:
return
except OSError as 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

View File

@@ -1,7 +1,6 @@
"""Plane API sync — update issue state and add comments."""
import logging
import time
import httpx
from .config import settings
@@ -131,42 +130,18 @@ _PLANE_NAME_TO_KEY: dict[str, str] = {
"Blocked": "blocked",
}
# Per-project state cache (ORCH-10 + ORCH-068).
#
# Each entry is a RECORD, not a bare mapping:
# {"states": {logical_key: state_uuid}, # the ORCH-10 mapping (unchanged shape)
# "groups": {state_uuid: group}, # ORCH-068 D1: {uuid -> Plane state.group}
# "ts": monotonic timestamp} # ORCH-068 TR-4: for TTL self-heal
# get_project_states() still RETURNS the bare {logical_key: state_uuid} mapping
# (backward compatible — AC-13); the richer record is internal.
_STATES_CACHE: dict[str, dict] = {}
def _cache_record_fresh(record: dict) -> bool:
"""ORCH-068 (TR-4): is a cache record still within its TTL?
``plane_states_ttl_s <= 0`` disables the TTL -> a record never expires
(strictly the previous lifetime-cache behaviour, back-compat escape hatch).
"""
ttl = settings.plane_states_ttl_s
if ttl <= 0:
return True
ts = record.get("ts", 0.0)
return (time.monotonic() - ts) <= ttl
# Per-project state cache: {project_id: {logical_key: state_uuid}}
_STATES_CACHE: dict[str, dict[str, str]] = {}
def get_project_states(project_id: str) -> dict[str, str]:
"""ORCH-10: resolve {logical_key -> state_uuid} for a specific Plane project.
Source of truth: Plane API GET /projects/<project_id>/states/.
Results are cached per project_id. ORCH-068 (TR-4): a cached entry is
re-fetched once it is older than ``plane_states_ttl_s`` (default 300s) so a
status added to Plane after start self-heals without a process restart;
``plane_states_ttl_s = 0`` keeps the previous lifetime cache.
Results are cached per project_id for the lifetime of the process.
Falls back to _DEFAULT_STATES (enduro-trails values) if:
* project_id is empty/None,
* the API call fails (network error, non-2xx) AND nothing is cached,
* the API call fails (network error, non-2xx),
* the response contains no recognisable states.
The enduro-trails project therefore returns the same UUIDs as before
@@ -176,9 +151,8 @@ def get_project_states(project_id: str) -> dict[str, str]:
if not project_id:
return _DEFAULT_STATES
cached = _STATES_CACHE.get(project_id)
if cached is not None and _cache_record_fresh(cached):
return cached["states"]
if project_id in _STATES_CACHE:
return _STATES_CACHE[project_id]
url = f"{PLANE_BASE}/workspaces/{WORKSPACE}/projects/{project_id}/states/"
try:
@@ -191,21 +165,12 @@ def get_project_states(project_id: str) -> dict[str, str]:
raise ValueError(f"unexpected states response shape: {type(items)}")
resolved: dict[str, str] = {}
groups: dict[str, str] = {}
for item in items:
name = item.get("name", "")
uid = item.get("id", "")
key = _PLANE_NAME_TO_KEY.get(name)
if key and uid:
resolved[key] = uid
# ORCH-068 D1: capture {uuid -> group} for terminal-state detection
# (a single API fetch — no extra network cost). The group is the
# authoritative, project-independent discriminator of terminal
# (completed/cancelled) vs review/work statuses, robust to UUID
# aliasing after status renames (ORCH-066).
grp = item.get("group", "")
if uid and grp:
groups[uid] = grp
if not resolved:
raise ValueError("no recognisable states in API response")
@@ -215,26 +180,13 @@ def get_project_states(project_id: str) -> dict[str, str]:
for k, v in _DEFAULT_STATES.items():
resolved.setdefault(k, v)
_STATES_CACHE[project_id] = {
"states": resolved,
"groups": groups,
"ts": time.monotonic(),
}
_STATES_CACHE[project_id] = resolved
logger.debug(
f"get_project_states: cached {len(resolved)} states / "
f"{len(groups)} groups for project {project_id[:8]}..."
f"get_project_states: cached {len(resolved)} states for project {project_id[:8]}..."
)
return resolved
except Exception as e:
# On a transient API failure keep serving the stale (but project-correct)
# set if we have one — far safer than reverting to enduro defaults.
if cached is not None:
logger.warning(
f"get_project_states: API refresh failed for project "
f"{project_id[:8]}..., serving stale cached set. Error: {e}"
)
return cached["states"]
logger.warning(
f"get_project_states: API failed for project {project_id[:8]}..., "
f"falling back to _DEFAULT_STATES. Error: {e}"
@@ -242,23 +194,6 @@ def get_project_states(project_id: str) -> dict[str, str]:
return _DEFAULT_STATES
def get_project_state_groups(project_id: str) -> dict[str, str]:
"""ORCH-068 (D1): return {state_uuid -> group} for a Plane project.
Reads the SAME cache record populated by ``get_project_states`` (no extra
network call). Call ``get_project_states(project_id)`` first to ensure the
record is fresh/populated. Returns ``{}`` when nothing is cached (e.g. the
API was unreachable and the caller fell back to ``_DEFAULT_STATES``); the
reconciler then falls back to logical terminal keys.
"""
record = _STATES_CACHE.get(project_id)
if isinstance(record, dict):
groups = record.get("groups")
if isinstance(groups, dict):
return groups
return {}
def reload_project_states(project_id: str = None) -> None:
"""ORCH-10: clear the per-project states cache.

View File

@@ -1,614 +0,0 @@
"""Post-deploy production monitoring + degradation reaction (ORCH-021).
The pipeline used to end at ``deploy -> done`` and then **forget about prod**:
"success" meant the health-check passed at restart (~60s window in
``scripts/orchestrator-deploy-hook.sh``). The class of incidents "green deploy,
red prod" (precedent ET-8 — degradation appears minutes later under real
traffic; ``/health`` answers ``200 ok`` while the feature is broken) was never
caught. ORCH-021 extends responsibility **PAST** ``done``: after the terminal
transition for an applicable repo we arm an observation window
(``post_deploy_window_s`` ~15 min, interval ``post_deploy_interval_s``);
degradation is detected by deterministic thresholds and, when confirmed,
triggers a reaction.
The observation mechanism (ADR-001 §1, Variant B) is a **reserved-agent job**
``post-deploy-monitor`` — a deterministic, no-LLM job modelled exactly on
``deploy-finalizer``. One "tick" == one job: it does ONE probe, appends to a
persisted ``series`` file, classifies, and either re-queues itself with a delay
(``available_at_delay_s``) or finishes (DEGRADED -> reaction; or window expired
-> HEALTHY). Between ticks no job runs (it is scheduled in the future), so the
single worker stays free for other projects — exactly like the finalizer defer.
This module is a **leaf** (mirrors ``self_deploy.py`` / ``staging_verdict.py``):
it imports only config (and lazily ``qg.checks.is_self_hosting_repo``), never
``stage_engine`` / ``launcher`` — the orchestration that needs those lives in
``stage_engine.run_post_deploy_monitor``. Every public helper honours a
**never-raise** contract so a monitoring hiccup can never crash the worker /
lifespan / the pipeline of other projects (AC-16).
Restart-safe state lives in sentinel files under
``<repos_dir>/.post-deploy-state-<repo>/<work_item_id>/`` (mirrors the
deploy-state pattern, no DB migration — ТЗ §2.7):
* ``armed`` — monitoring armed for this work item (idempotency-guard, AC-15);
* ``series`` — JSON list of probe results (restart-safe streak/5xx counters);
* ``done`` — monitoring finished (anti-dupe, AC-15).
Self-hosting safety (BR-5 / AC-8): a monitor tick NEVER auto-rolls-back or
restarts the prod ``orchestrator`` container — for ``orchestrator`` the reaction
is ALWAYS ``ALERT_ONLY`` (loud Telegram + Plane, manual approve).
"""
from __future__ import annotations
import glob
import json
import logging
import os
import shlex
import subprocess
import urllib.error
import urllib.request
from dataclasses import dataclass
from .config import settings
logger = logging.getLogger("orchestrator.post_deploy")
# Sentinel marker filenames (see module docstring).
ARMED = "armed"
SERIES = "series"
DONE = "done"
# Verdicts (classify).
HEALTHY = "HEALTHY"
DEGRADED = "DEGRADED"
# Reaction decisions (decide_action).
NONE = "NONE"
ROLLBACK = "ROLLBACK"
ALERT_ONLY = "ALERT_ONLY"
# action_taken values written to the artefact frontmatter.
ROLLBACK_OK = "ROLLBACK_OK"
ROLLBACK_FAILED = "ROLLBACK_FAILED"
# The 5xx-monitored endpoints (besides /health, whose 200+ok is its own signal).
_FIVEXX_ENDPOINTS = ("/status", "/queue")
_PROBE_TIMEOUT = 5
_SSH_TIMEOUT = 60
_GIT_TIMEOUT = 60
# ---------------------------------------------------------------------------
# Conditionality (mirrors self_deploy_applies / _merge_gate_applies)
# ---------------------------------------------------------------------------
def post_deploy_applies(repo: str) -> bool:
"""Whether post-deploy monitoring is REAL for this repo (AC-2 / AC-10).
Mirrors the ORCH-35/36/43/58 conditional rollout:
* ``post_deploy_monitor_enabled=False`` -> always False (global
kill-switch); the pipeline is 1:1 as before ORCH-021 (AC-10).
* ``post_deploy_repos`` (CSV) non-empty -> real only for listed repos.
* empty CSV -> real ONLY for the self-hosting repo (``orchestrator``).
Never raises.
"""
try:
if not settings.post_deploy_monitor_enabled:
return False
raw = (settings.post_deploy_repos or "").strip()
if raw:
allowed = {r.strip().lower() for r in raw.split(",") if r.strip()}
return (repo or "").strip().lower() in allowed
# Lazy import keeps this module a leaf (avoid importing qg at load time).
from .qg.checks import is_self_hosting_repo
return is_self_hosting_repo(repo)
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("post_deploy_applies error for %s: %s", repo, e)
return False
# ---------------------------------------------------------------------------
# Signal probe (one tick)
# ---------------------------------------------------------------------------
@dataclass
class ProbeResult:
"""Outcome of ONE probe tick (JSON-serialisable via ``as_dict``).
``health_ok`` — ``/health`` answered HTTP 200 with ``{"status": "ok"}``.
``total`` — number of 5xx-monitored endpoints probed (``/status``,
``/queue``) — the denominator of the window 5xx ratio.
``fivexx`` — how many of those returned 5xx (or were unreachable, which
is conservatively counted as a server failure).
``detail`` — human-readable note (logs / artefact body).
"""
health_ok: bool
total: int
fivexx: int
detail: str = ""
def as_dict(self) -> dict:
return {
"health_ok": bool(self.health_ok),
"total": int(self.total),
"fivexx": int(self.fivexx),
"detail": str(self.detail),
}
def _http_status(url: str) -> tuple[int, str]:
"""GET ``url`` -> (http_code, body). Network/timeout -> (0, "").
Never raises. ``urllib`` raises ``HTTPError`` for >=400 responses; we treat
that as a real status code (so a 5xx is observed, not swallowed).
"""
try:
with urllib.request.urlopen(url, timeout=_PROBE_TIMEOUT) as resp: # noqa: S310
body = resp.read(4096).decode("utf-8", "replace")
return int(getattr(resp, "status", resp.getcode())), body
except urllib.error.HTTPError as e:
try:
body = e.read(4096).decode("utf-8", "replace")
except Exception:
body = ""
return int(e.code), body
except Exception as e: # noqa: BLE001 - URLError / socket timeout / anything
logger.warning("post_deploy probe error for %s: %s", url, e)
return 0, ""
def probe_signals(base_url: str) -> ProbeResult:
"""Probe ``/health`` + the key endpoints of the prod instance ONCE (AC-16).
``/health`` is healthy iff HTTP 200 AND the body parses to
``{"status": "ok"}``. ``/status`` and ``/queue`` contribute to the window
5xx ratio: an HTTP 5xx OR an unreachable endpoint (network error / timeout,
code 0) is counted as a failure (conservative — a down server is bad). A
network failure yields a conservative "failed" probe, NEVER an exception
(TC-14).
"""
base = (base_url or "").rstrip("/")
# --- /health: the primary liveness signal ---
code, body = _http_status(base + "/health")
health_ok = False
if code == 200:
try:
health_ok = json.loads(body).get("status") == "ok"
except Exception:
health_ok = False
# --- /status, /queue: 5xx ratio over the window ---
total = 0
fivexx = 0
for ep in _FIVEXX_ENDPOINTS:
total += 1
ep_code, _ = _http_status(base + ep)
if ep_code == 0 or 500 <= ep_code <= 599:
fivexx += 1
detail = f"health={code}({'ok' if health_ok else 'bad'}) 5xx={fivexx}/{total}"
return ProbeResult(health_ok=health_ok, total=total, fivexx=fivexx, detail=detail)
# ---------------------------------------------------------------------------
# Classification (pure, no I/O — the MAIN unit-test subject, like
# compute_staging_verdict in ORCH-061)
# ---------------------------------------------------------------------------
def classify(series, fail_threshold: int, fivexx_threshold: float) -> str:
"""Fold a probe series into ``HEALTHY`` | ``DEGRADED`` (deterministic, pure).
``series`` — iterable of probe dicts (``{"health_ok", "total", "fivexx"}``),
as persisted by :func:`append_probe`.
Decision (BR-3 / AC-3..AC-6):
* ``>= fail_threshold`` CONSECUTIVE health failures -> ``DEGRADED`` (AC-4);
* window 5xx ratio ``sum(fivexx)/sum(total)`` strictly ``> fivexx_threshold``
-> ``DEGRADED`` even if ``/health`` answers 200 (AC-5);
* otherwise ``HEALTHY`` — a single glitch below the threshold that recovers
does NOT trip (AC-3 / AC-6, no false rollback).
Never raises: on malformed input it returns ``HEALTHY`` (fail-SAFE — a false
``DEGRADED`` would trigger an unwanted rollback, the worse outcome).
"""
try:
# Non-list input is malformed -> fail-safe HEALTHY (never a false rollback).
if not isinstance(series, (list, tuple)):
return HEALTHY
# Longest run of consecutive health failures.
streak = 0
best = 0
total = 0
fivexx = 0
for row in series:
# A non-dict row is malformed: skip it (do NOT count it as a failure,
# which could fabricate a DEGRADED streak from garbage).
if not isinstance(row, dict):
continue
ok = bool(row.get("health_ok"))
total += int(row.get("total") or 0)
fivexx += int(row.get("fivexx") or 0)
if ok:
streak = 0
else:
streak += 1
if streak > best:
best = streak
if best >= int(fail_threshold):
return DEGRADED
if total > 0 and (fivexx / total) > float(fivexx_threshold):
return DEGRADED
return HEALTHY
except Exception as e: # noqa: BLE001 - never-raise; fail-safe to HEALTHY
logger.warning("post_deploy classify error: %s", e)
return HEALTHY
def decide_action(repo: str, verdict: str) -> str:
"""Decide the reaction for ``(repo, verdict)`` (pure, BR-5 / AC-7 / AC-8).
* ``HEALTHY`` -> ``NONE`` (no reaction, any repo);
* ``DEGRADED`` + self-hosting -> ``ALERT_ONLY`` (ALWAYS — the tick
NEVER auto-rolls-back / restarts the prod orchestrator container, AC-8);
* ``DEGRADED`` + non-self + ``post_deploy_auto_rollback=True`` -> ``ROLLBACK``;
* ``DEGRADED`` + non-self + auto_rollback False (default) -> ``ALERT_ONLY``.
Never raises: on doubt returns ``ALERT_ONLY`` (never an unexpected rollback).
"""
try:
if verdict != DEGRADED:
return NONE
from .qg.checks import is_self_hosting_repo
if is_self_hosting_repo(repo):
return ALERT_ONLY # BR-5: self-hosting is NEVER auto-rolled-back
if settings.post_deploy_auto_rollback:
return ROLLBACK
return ALERT_ONLY
except Exception as e: # noqa: BLE001 - never-raise; safe default
logger.warning("post_deploy decide_action error for %s: %s", repo, e)
return ALERT_ONLY
def map_rollback_exit_code(exit_code) -> str:
"""Map a ``--rollback`` hook exit-code to an ``action_taken`` (pure, AC-9).
Hook exit-code contract (unchanged, 0/1/2):
* ``0`` -> ``ROLLBACK_OK`` (rollback proven healthy);
* ``1`` (no prev image), ``2`` (rollback also failed), anything else, or a
non-int/None -> ``ROLLBACK_FAILED`` (fail-closed -> loud escalation).
"""
try:
code = int(exit_code)
except (TypeError, ValueError):
return ROLLBACK_FAILED
return ROLLBACK_OK if code == 0 else ROLLBACK_FAILED
# ---------------------------------------------------------------------------
# Sentinel state (restart-safe, no DB migration — ТЗ §2.7)
# ---------------------------------------------------------------------------
def _state_dir(base: str, repo: str, work_item_id: str | None) -> str:
return os.path.join(base, f".post-deploy-state-{repo}", (work_item_id or "_"))
def state_dir(repo: str, work_item_id: str | None) -> str:
"""State dir as seen from the container (``settings.repos_dir`` mount)."""
return _state_dir(settings.repos_dir, repo, work_item_id)
def host_state_dir(repo: str, work_item_id: str | None) -> str:
"""State dir as seen from the HOST (``settings.host_repos_dir``).
Same physical directory as :func:`state_dir` via the shared mount; the host
path is what we embed in an ssh command if a host-side helper needs it.
"""
return _state_dir(settings.host_repos_dir, repo, work_item_id)
def marker_path(repo: str, work_item_id: str | None, name: str) -> str:
return os.path.join(state_dir(repo, work_item_id), name)
def has_marker(repo: str, work_item_id: str | None, name: str) -> bool:
"""True iff the named sentinel exists. Never raises."""
try:
return os.path.isfile(marker_path(repo, work_item_id, name))
except Exception as e: # noqa: BLE001 - never-raise
logger.warning("has_marker error for %s/%s/%s: %s", repo, work_item_id, name, e)
return False
def write_marker(repo: str, work_item_id: str | None, name: str, content: str = "") -> bool:
"""Create/overwrite a sentinel (best-effort). Returns True on success."""
try:
d = state_dir(repo, work_item_id)
os.makedirs(d, exist_ok=True)
with open(os.path.join(d, name), "w", encoding="utf-8") as f:
f.write(str(content))
return True
except OSError as e:
logger.warning("write_marker error for %s/%s/%s: %s", repo, work_item_id, name, e)
return False
def mark_done(repo: str, work_item_id: str | None) -> bool:
"""Mark monitoring finished for this work item (anti-dupe, AC-15)."""
return write_marker(repo, work_item_id, DONE, "done")
def read_series(repo: str, work_item_id: str | None) -> list:
"""Read the persisted probe series (JSON list). Missing/corrupt -> ``[]``.
Never raises — restart-safe streak/5xx counters survive a container restart.
"""
p = marker_path(repo, work_item_id, SERIES)
try:
with open(p, "r", encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, list) else []
except FileNotFoundError:
return []
except Exception as e: # noqa: BLE001 - never-raise; corrupt -> empty
logger.warning("read_series error for %s/%s: %s", repo, work_item_id, e)
return []
def append_probe(repo: str, work_item_id: str | None, probe: ProbeResult) -> list:
"""Append a probe to the persisted series and return the new list.
Best-effort (a write error logs and returns the in-memory list so the tick
still classifies). Never raises.
"""
series = read_series(repo, work_item_id)
try:
series.append(probe.as_dict() if isinstance(probe, ProbeResult) else dict(probe))
except Exception as e: # noqa: BLE001
logger.warning("append_probe coerce error for %s/%s: %s", repo, work_item_id, e)
return series
try:
d = state_dir(repo, work_item_id)
os.makedirs(d, exist_ok=True)
with open(os.path.join(d, SERIES), "w", encoding="utf-8") as f:
json.dump(series, f)
except OSError as e:
logger.warning("append_probe write error for %s/%s: %s", repo, work_item_id, e)
return series
def arm_monitor(repo: str, work_item_id: str | None, branch: str, task_id: int) -> bool:
"""Arm post-deploy monitoring after ``deploy -> done`` (AC-1 / AC-15).
Idempotent: if the ``armed`` sentinel already exists this is a no-op (a double
webhook / reconciler F-1 / finalizer Phase C can drive ``done`` more than once,
AC-15). Otherwise creates the state dir, writes ``armed`` + an empty ``series``,
and enqueues the FIRST ``post-deploy-monitor`` job with a delay of one interval
(so the prod has settled before the first probe). Returns True iff it armed a
NEW monitor. Never raises — the caller (terminal block of ``advance_stage``)
must never be crashed by a monitoring hiccup.
"""
try:
if has_marker(repo, work_item_id, ARMED):
logger.info("arm_monitor: already armed for %s/%s (no-op)", repo, work_item_id)
return False
write_marker(repo, work_item_id, ARMED, "armed")
# Initialise an empty series so read_series is well-defined from tick 1.
try:
d = state_dir(repo, work_item_id)
os.makedirs(d, exist_ok=True)
with open(os.path.join(d, SERIES), "w", encoding="utf-8") as f:
json.dump([], f)
except OSError as e:
logger.warning("arm_monitor: series init error for %s/%s: %s", repo, work_item_id, e)
# Lazy import keeps this module a leaf (db is a low-level dependency).
from .db import enqueue_job
task_desc = (
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
f"Stage: post-deploy\nNote: post-deploy monitor tick 1 "
f"(window {settings.post_deploy_window_s}s, interval "
f"{settings.post_deploy_interval_s}s)."
)
job_id = enqueue_job(
"post-deploy-monitor", repo, task_desc, task_id=task_id,
available_at_delay_s=settings.post_deploy_interval_s,
)
logger.info(
"arm_monitor: armed post-deploy monitor for %s/%s (job_id=%s)",
repo, work_item_id, job_id,
)
return True
except Exception as e: # noqa: BLE001 - never-raise contract
logger.error("arm_monitor error for %s/%s: %s", repo, work_item_id, e)
return False
def max_ticks() -> int:
"""Bounded tick budget for the window (anti-livelock, like
``deploy_finalize_max_attempts``): ``window_s // interval_s`` (>= 1)."""
try:
interval = max(1, int(settings.post_deploy_interval_s))
return max(1, int(settings.post_deploy_window_s) // interval)
except Exception: # noqa: BLE001 - never-raise
return 1
# ---------------------------------------------------------------------------
# Rollback command (non-self repos only; reuses deploy_prod_* env — ТЗ §2.4)
# ---------------------------------------------------------------------------
def build_rollback_command(repo: str) -> list[str]:
"""Build the ssh argv that runs the deploy hook in ``--rollback`` mode.
Mirrors ``self_deploy.build_deploy_command`` (same prod-env, INFRA P-2 ssh
target) but the action is ``--rollback`` and the call is SYNCHRONOUS (the
target container is NOT the orchestrator, so it is safe to wait for the hook
exit-code directly — no detached setsid wrapper, no ``result`` sentinel).
Reuses the existing ``deploy_prod_*`` settings; no new duplicate config.
"""
env_assignments = (
f"TARGET_SERVICE={shlex.quote(settings.deploy_prod_target_service)} "
f"TARGET_PORT={int(settings.deploy_prod_target_port)} "
f"TARGET_IMAGE={shlex.quote(settings.deploy_prod_target_image)} "
f"COMPOSE_PROFILE={shlex.quote(settings.deploy_prod_compose_profile)} "
f"PREV_IMAGE_FILE={shlex.quote(settings.deploy_prod_prev_image_file)}"
)
inner = (
f"cd {shlex.quote(settings.deploy_host_repo_path)} && "
f"{env_assignments} "
f"bash {shlex.quote(settings.deploy_hook_script)} --rollback"
)
user = (settings.deploy_ssh_user or "").strip()
host = (settings.deploy_ssh_host or "").strip()
target = f"{user}@{host}" if user else host
return ["ssh", "-o", "StrictHostKeyChecking=no", target, inner]
def run_rollback(repo: str) -> tuple[int, str]:
"""Run the ``--rollback`` hook synchronously. Returns ``(exit_code, detail)``.
Never raises: an ssh launch error / timeout maps to a non-zero exit-code so
the caller records ``ROLLBACK_FAILED`` and escalates (AC-9). NEVER used for
the self-hosting repo (``decide_action`` returns ``ALERT_ONLY`` there) — the
structural guard against a tick restarting the prod orchestrator (AC-8).
"""
cmd = build_rollback_command(repo)
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=_SSH_TIMEOUT)
except subprocess.TimeoutExpired:
return 2, "rollback ssh timeout"
except (subprocess.SubprocessError, OSError) as e:
return 2, f"rollback ssh error: {e}"
detail = ((r.stderr or "") + (r.stdout or "")).strip()[:200]
return int(r.returncode), detail
# ---------------------------------------------------------------------------
# Artefact 16-post-deploy-log.md (machine-readable frontmatter — ТЗ §2.5)
# ---------------------------------------------------------------------------
def build_post_deploy_log(
work_item_id: str,
status: str,
action_taken: str,
window_s: int,
checks_total: int,
checks_failed: int,
body_extra: str = "",
) -> str:
"""Render a 16-post-deploy-log.md body. Only the YAML-frontmatter is machine
read (canon of gates; the loop-of-lessons ORCH-8 consumes it, BR-10). The
body is informational. Parseable by ``yaml.safe_load`` (AC-13).
"""
return (
"---\n"
f"post_deploy_status: {status}\n"
f"action_taken: {action_taken}\n"
f"work_item: {work_item_id}\n"
f"window_s: {int(window_s)}\n"
f"checks_total: {int(checks_total)}\n"
f"checks_failed: {int(checks_failed)}\n"
"---\n\n"
"# Post-deploy log — ORCH-021 post-deploy monitor\n\n"
f"Наблюдение прода завершено: `post_deploy_status: {status}`, "
f"`action_taken: {action_taken}`.\n\n"
f"Окно наблюдения: {int(window_s)}s; опросов всего: {int(checks_total)}, "
f"из них с провалом: {int(checks_failed)}.\n"
f"{body_extra}"
)
def write_post_deploy_log(
repo: str,
work_item_id: str,
branch: str,
status: str,
action_taken: str,
window_s: int,
checks_total: int,
checks_failed: int,
body_extra: str = "",
) -> bool:
"""Write 16-post-deploy-log.md into the task worktree and best-effort
commit+push it. Returns True iff the file was written. Never raises — the
artefact is best-effort, its absence rolls nothing back (AC-13 / TC-15).
"""
from .git_worktree import get_worktree_path
rel = f"docs/work-items/{work_item_id}/16-post-deploy-log.md"
try:
wt = get_worktree_path(repo, branch)
except Exception as e: # noqa: BLE001 - never-raise
logger.error("write_post_deploy_log: worktree error for %s/%s: %s", repo, branch, e)
return False
path = os.path.join(wt, rel)
content = build_post_deploy_log(
work_item_id, status, action_taken, window_s, checks_total, checks_failed, body_extra
)
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
except OSError as e:
logger.error("write_post_deploy_log: write error at %s: %s", path, e)
return False
git_env = {
**os.environ,
"HOME": "/home/slin",
"GIT_AUTHOR_NAME": "post-deploy-monitor",
"GIT_AUTHOR_EMAIL": "post-deploy-monitor@mva154.local",
"GIT_COMMITTER_NAME": "post-deploy-monitor",
"GIT_COMMITTER_EMAIL": "post-deploy-monitor@mva154.local",
}
try:
subprocess.run(["git", "-C", wt, "add", rel],
capture_output=True, timeout=_GIT_TIMEOUT, env=git_env)
commit = subprocess.run(
["git", "-C", wt, "commit", "-m",
f"docs(ORCH-021): post-deploy {status}/{action_taken} for {work_item_id}"],
capture_output=True, text=True, timeout=_GIT_TIMEOUT, env=git_env,
)
if commit.returncode == 0:
subprocess.run(["git", "-C", wt, "push", "origin", branch],
capture_output=True, timeout=_GIT_TIMEOUT, env=git_env)
except (subprocess.SubprocessError, OSError) as e:
logger.warning("write_post_deploy_log: git commit/push best-effort failed: %s", e)
return True
# ---------------------------------------------------------------------------
# Observability snapshot for GET /queue (BR-9 / AC-14)
# ---------------------------------------------------------------------------
def status() -> dict:
"""Post-deploy snapshot for /queue observability. Never raises.
``active`` — work items with an ``armed`` sentinel but no ``done`` yet (a
monitoring window in flight). ``last_outcome`` — best-effort last finished
window read from the most-recent ``done`` state dir's series length.
"""
snap = {
"enabled": False,
"window_s": None,
"interval_s": None,
"repos": "",
"active": [],
"active_count": 0,
}
try:
snap["enabled"] = bool(settings.post_deploy_monitor_enabled)
snap["window_s"] = int(settings.post_deploy_window_s)
snap["interval_s"] = int(settings.post_deploy_interval_s)
snap["repos"] = settings.post_deploy_repos or ""
pattern = os.path.join(settings.repos_dir, ".post-deploy-state-*", "*")
active: list[str] = []
for d in glob.glob(pattern):
try:
if not os.path.isdir(d):
continue
if os.path.isfile(os.path.join(d, ARMED)) and not os.path.isfile(
os.path.join(d, DONE)
):
active.append(os.path.basename(d))
except Exception: # noqa: BLE001 - skip one dir
continue
snap["active"] = sorted(active)
snap["active_count"] = len(active)
except Exception as e: # noqa: BLE001 - never-raise
logger.warning("post_deploy status snapshot error: %s", e)
return snap

View File

@@ -60,12 +60,7 @@ from .stage_engine import (
MAX_DEVELOPER_RETRIES,
)
from .stages import get_qg_for_stage
from .plane_sync import (
fetch_issue_state,
get_project_states,
get_project_state_groups,
list_issues_by_state,
)
from .plane_sync import fetch_issue_state, get_project_states, list_issues_by_state
from .webhooks.plane import handle_status_start, handle_verdict
from .notifications import send_telegram
from . import projects
@@ -144,13 +139,6 @@ class Reconciler:
self.last_run_ts: float | None = None
self.unblocked_total: int = 0
self.last_unblocked: str | None = None
# ORCH-068 observability: terminal-state skips and dedup suppressions.
self.skipped_terminal_total: int = 0
self.deduped_total: int = 0
# ORCH-068 (TR-3): in-memory dedup guard {issue_id -> last unblocked
# state uuid}. Best-effort (resets on restart, like unblocked_total);
# suppresses a repeat unblock notification for the same issue+state.
self._unblock_dedup: dict[str, str] = {}
# -- F-1: gate-side ----------------------------------------------------
def reconcile_gate_once(self) -> None:
@@ -254,9 +242,6 @@ class Reconciler:
pid = proj.plane_project_id
# Resolve the actionable state uuids per-project (never hardcode).
states = get_project_states(pid)
# ORCH-068 D1: {uuid -> group} from the SAME cache record (no extra
# fetch); empty when the API was unreachable -> per-issue fallback by key.
groups = get_project_state_groups(pid)
in_progress = states["in_progress"]
approved = states["approved"]
rejected = states["rejected"]
@@ -264,36 +249,16 @@ class Reconciler:
for issue in issues:
try:
self._reconcile_plane_issue(
issue, pid, in_progress, approved, rejected, states, groups
issue, pid, in_progress, approved, rejected
)
except Exception as e: # noqa: BLE001 - isolate one issue's failure
logger.error(
f"reconciler F-2: issue {issue.get('id')} failed: {e}"
)
def _is_terminal_state(
self, state_uuid: str, states: dict, groups: dict
) -> bool:
"""ORCH-068 D1: is ``state_uuid`` a terminal (completed/cancelled) state?
Primary discriminator is the Plane **state group** (project-independent,
robust to UUID aliasing after status renames): ``group`` in
``{completed, cancelled}`` -> terminal. When the group is unavailable
(API gave no ``group`` / we fell back to ``_DEFAULT_STATES``), fall back
to the logical terminal keys ``done`` / ``cancelled``.
"""
if not state_uuid:
return False
grp = groups.get(state_uuid)
if grp:
return grp in {"completed", "cancelled"}
# Fallback (group unknown): logical terminal keys for this project.
return state_uuid in {states.get("done"), states.get("cancelled")}
def _reconcile_plane_issue(
self, issue: dict, project_id: str,
in_progress: str, approved: str, rejected: str,
states: dict, groups: dict,
) -> None:
issue_id = str(issue.get("id") or "")
if not issue_id:
@@ -301,15 +266,6 @@ class Reconciler:
state = issue.get("state")
new_state = state.get("id") if isinstance(state, dict) else state
# ORCH-068 D1: a terminal issue (Done / Cancelled) is fully in sync by
# definition -> never actionable. Excluded per-issue (not by narrowing
# `wanted`) because UUID aliasing can make a terminal uuid collide with
# an actionable one — only the state GROUP disentangles them. Restores
# the silence-when-in-sync invariant (AC-1/AC-2).
if self._is_terminal_state(new_state, states, groups):
self.skipped_terminal_total += 1
return
# Grace ("lost, not merely delayed"): use the issue's own updated_at age.
# A missing/unparseable timestamp is treated as old enough (the active-job
# guard + atomic create-claim still prevent doubling).
@@ -334,41 +290,18 @@ class Reconciler:
if new_state == in_progress and task is None:
# In Progress without a task -> start the pipeline (lost start webhook).
# ORCH-068 D2: confirm a REAL change (the task now exists) before
# announcing — a no-op dispatch stays silent.
self._dispatch(handle_status_start, issue_data, project_id)
if get_task_by_plane_id(issue_id) is not None:
self._note_unblock(issue_id, "analysis", new_state)
self._note_unblock(issue_id, "analysis")
elif new_state == approved and task is not None:
# Approved but the stage never advanced -> replay the verdict.
stage_before = task["stage"]
self._dispatch(handle_verdict, issue_data, project_id, approved=True)
if self._stage_changed(issue_id, stage_before):
self._note_unblock(
task.get("work_item_id") or issue_id, stage_before, new_state
)
self._note_unblock(task.get("work_item_id") or issue_id, task["stage"])
elif new_state == rejected and task is not None:
# Rejected but never rolled back -> replay the verdict.
stage_before = task["stage"]
self._dispatch(handle_verdict, issue_data, project_id, approved=False)
if self._stage_changed(issue_id, stage_before):
self._note_unblock(
task.get("work_item_id") or issue_id, stage_before, new_state
)
self._note_unblock(task.get("work_item_id") or issue_id, task["stage"])
# else: everything is in sync -> silence (AC-10).
@staticmethod
def _stage_changed(issue_id: str, stage_before: str) -> bool:
"""ORCH-068 D2: did the dispatched handler actually move the stage?
Re-reads the task after ``_dispatch`` and compares to the captured
``stage_before``. A no-op replay (the task was already in the target
state) leaves the stage unchanged -> no unblock notification.
"""
after = get_task_by_plane_id(issue_id)
stage_after = after["stage"] if after else stage_before
return stage_after != stage_before
@staticmethod
def _dispatch(coro_fn, *args, **kwargs) -> None:
"""Run an async plane handler from this sync thread.
@@ -381,27 +314,12 @@ class Reconciler:
asyncio.run(coro_fn(*args, **kwargs))
# -- observability (F-4) ----------------------------------------------
def _note_unblock(
self, work_item_id: str, stage: str, state_uuid: str | None = None
) -> None:
def _note_unblock(self, work_item_id: str, stage: str) -> None:
"""Record + announce that a stuck task was unblocked (AC-12).
Fires only on an actual state change (an advance / replayed transition),
never per idle tick, so it does not conflict with AC-9 / AC-10.
ORCH-068 (TR-3): an in-memory dedup guard keyed by ``issue_id ->
state_uuid`` suppresses a repeat notification for the same issue+state
if a future no-op path ever reaches here. ``state_uuid`` is the issue's
Plane state; ``work_item_id`` doubles as the issue id for the
pipeline-start case (which has no work item yet).
"""
dedup_key = work_item_id
if state_uuid is not None and self._unblock_dedup.get(dedup_key) == state_uuid:
self.deduped_total += 1
return
if state_uuid is not None:
self._unblock_dedup[dedup_key] = state_uuid
self.unblocked_total += 1
self.last_unblocked = work_item_id
logger.info(
@@ -462,9 +380,6 @@ class Reconciler:
"last_run_ts": self.last_run_ts,
"unblocked_total": self.unblocked_total,
"last_unblocked": self.last_unblocked,
# ORCH-068 observability.
"skipped_terminal_total": self.skipped_terminal_total,
"deduped_total": self.deduped_total,
}

View File

@@ -37,7 +37,6 @@ from .review_parse import extract_review_findings, extract_test_failures
from .qg.checks import QG_CHECKS
from . import merge_gate
from . import self_deploy
from . import post_deploy
from .notifications import (
notify_stage_change,
notify_qg_failure,
@@ -353,17 +352,6 @@ def advance_stage(
except Exception as e: # noqa: BLE001 - defensive
logger.warning(f"Task {task_id}: merge-lease release on done failed: {e}")
# ORCH-021: arm post-deploy monitoring PAST `done`. Responsibility extends
# beyond the restart-time health-check to catch the "green deploy, red prod"
# class (ET-8). Idempotent (sentinel `armed`) + conditional (applies()), so a
# double webhook / reconciler / finalizer re-driving `done` never doubles it
# and non-applicable repos are untouched. never-raise (arm_monitor + guard).
if next_stage == "done" and post_deploy.post_deploy_applies(repo):
try:
post_deploy.arm_monitor(repo, work_item_id, branch, task_id)
except Exception as e: # noqa: BLE001 - monitoring must never crash done
logger.warning(f"Task {task_id}: post-deploy arm failed: {e}")
# --- Launch the next agent (ORCH-4 fix: current_stage, not next) -----
next_agent = get_agent_for_stage(current_stage)
if next_agent:
@@ -1188,139 +1176,3 @@ def run_deploy_finalizer(job: dict):
branch=branch,
finished_agent="deployer",
)
def run_post_deploy_monitor(job: dict):
"""ORCH-021 — one post-deploy monitor tick (reserved-agent, no LLM).
A deterministic tick modelled on ``run_deploy_finalizer``: it does ONE probe
of the prod instance, appends to the persisted ``series`` (restart-safe
streak/5xx counters), classifies, and then either RE-QUEUES itself with a
delay (window not over and still HEALTHY) or FINISHES the window (DEGRADED ->
reaction; window expired -> HEALTHY). Observation happens entirely AFTER the
terminal ``done`` — it never touches ``STAGE_TRANSITIONS`` / ``QG_CHECKS`` and
never restarts the prod orchestrator container itself (AC-8 / AC-12).
never-raise into the caller (the launcher marks the job done/failed); each
branch is individually defensive.
"""
task_id = job.get("task_id")
repo = job.get("repo")
try:
conn = get_db()
row = conn.execute(
"SELECT work_item_id, branch FROM tasks WHERE id=?", (task_id,)
).fetchone()
conn.close()
except Exception as e: # noqa: BLE001 - never-raise
logger.error(f"post-deploy-monitor: db error for task_id={task_id}: {e}")
return
if not row:
logger.error(f"post-deploy-monitor: no task row for task_id={task_id}")
return
work_item_id, branch = row[0], row[1]
# AC-15: a finished window is a no-op (defends against a duplicate job).
if post_deploy.has_marker(repo, work_item_id, post_deploy.DONE):
logger.info(f"post-deploy-monitor: {work_item_id} already done (no-op)")
return
# One probe -> append -> classify (restart-safe via the persisted series).
probe = post_deploy.probe_signals(settings.post_deploy_base_url)
series = post_deploy.append_probe(repo, work_item_id, probe)
verdict = post_deploy.classify(
series,
settings.post_deploy_fail_threshold,
settings.post_deploy_5xx_threshold,
)
ticks = len(series)
budget = post_deploy.max_ticks()
logger.info(
f"post-deploy-monitor: {work_item_id} tick {ticks}/{budget} "
f"probe=[{probe.detail}] verdict={verdict}"
)
# HEALTHY and window not exhausted -> defer the next tick (worker stays free).
if verdict == post_deploy.HEALTHY and ticks < budget:
task_desc = (
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
f"Stage: post-deploy\nNote: post-deploy monitor tick {ticks + 1} "
f"(healthy so far; re-poll after {settings.post_deploy_interval_s}s)."
)
enqueue_job(
"post-deploy-monitor", repo, task_desc, task_id=task_id,
available_at_delay_s=settings.post_deploy_interval_s,
)
return
checks_total = ticks
checks_failed = sum(1 for r in series if not r.get("health_ok"))
# HEALTHY and window exhausted -> clean finish (BR-6 / AC-17).
if verdict == post_deploy.HEALTHY:
post_deploy.write_post_deploy_log(
repo, work_item_id, branch, post_deploy.HEALTHY, post_deploy.NONE,
settings.post_deploy_window_s, checks_total, checks_failed,
)
post_deploy.mark_done(repo, work_item_id)
_notify_post_deploy(
work_item_id,
f"{work_item_id}: пост-деплой окно завершено чисто "
f"(HEALTHY, {checks_total} опросов).",
)
return
# DEGRADED -> decide + execute the reaction (§5), write artefact, finish.
action = post_deploy.decide_action(repo, verdict)
action_taken = post_deploy.ALERT_ONLY
if action == post_deploy.ROLLBACK:
# Non-self repo + auto policy: run the --rollback hook synchronously (the
# target is NOT the orchestrator, so its restart is safe for the pipeline).
exit_code, detail = post_deploy.run_rollback(repo)
action_taken = post_deploy.map_rollback_exit_code(exit_code)
if action_taken == post_deploy.ROLLBACK_OK:
_notify_post_deploy(
work_item_id,
f"⚠️ {work_item_id}: пост-деплой DEGRADED -> авто-rollback выполнен "
f"(exit {exit_code}).",
)
else:
# AC-9: a failed rollback escalates loudly for manual intervention.
_notify_post_deploy(
work_item_id,
f"🚨 {work_item_id}: пост-деплой DEGRADED -> авто-rollback ПРОВАЛИЛСЯ "
f"(exit {exit_code}: {detail}). Нужно ручное вмешательство.",
)
else:
# ALERT_ONLY: self-hosting ALWAYS lands here — the tick NEVER auto-rolls-back
# or restarts the prod orchestrator container (BR-5 / AC-8). Loud alert +
# manual-approve request (mirrors deploy Phase A CTA).
action_taken = post_deploy.ALERT_ONLY
_notify_post_deploy(
work_item_id,
f"🚨 {work_item_id}: пост-деплой DEGRADED ({checks_failed}/{checks_total} "
f"провалов). Требуется ручной approve отката — авто-rollback для "
f"self-hosting запрещён (BR-5).",
)
post_deploy.write_post_deploy_log(
repo, work_item_id, branch, post_deploy.DEGRADED, action_taken,
settings.post_deploy_window_s, checks_total, checks_failed,
)
post_deploy.mark_done(repo, work_item_id)
def _notify_post_deploy(work_item_id: str, message: str) -> None:
"""Best-effort Telegram + Plane notification for a post-deploy event (AC-17).
Never raises — a notification failure must not wedge the monitor tick.
"""
try:
send_telegram(message)
except Exception as e: # noqa: BLE001 - never break the tick
logger.warning(f"post-deploy notify telegram failed for {work_item_id}: {e}")
if work_item_id:
try:
plane_add_comment(work_item_id, message, author="deployer")
except Exception as e: # noqa: BLE001 - never break the tick
logger.warning(f"post-deploy notify plane failed for {work_item_id}: {e}")

View File

@@ -165,82 +165,3 @@ def test_staging_infra_tolerance_env_override_true(monkeypatch):
"""The field is read verbatim from its ORCH_* env var."""
monkeypatch.setenv("ORCH_STAGING_INFRA_TOLERANCE_ENABLED", "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"]

View File

@@ -102,31 +102,6 @@ def test_tc08_dockerfile_stamps_revision_label():
assert "LABEL org.opencontainers.image.revision=$GIT_SHA" in text
# ---------------------------------------------------------------------------
# TC-08b (ORCH-021 regression): the Dockerfile must not COPY a gitignored path.
# The ORCH-058 staging rebuild builds with the task *worktree* as the docker build
# context. A fresh worktree contains only tracked files, so any `COPY <gitignored>`
# (notably `data/`, the SQLite dir) makes `docker build` fail with exit 1 and bounces
# the task off `deploy-staging`. `data/` is a runtime bind-mount volume anyway, so it
# must never be a COPY source.
# ---------------------------------------------------------------------------
def test_tc08b_dockerfile_does_not_copy_gitignored_data_dir():
text = _DOCKERFILE.read_text(encoding="utf-8")
gitignore = (_ROOT / ".gitignore").read_text(encoding="utf-8").splitlines()
# Precondition: `data/` really is gitignored (the build context will not have it).
assert "data/" in [ln.strip() for ln in gitignore]
# The Dockerfile must not COPY it (would break the worktree-context staging build).
copy_sources = [
line.split()[1]
for line in text.splitlines()
if line.strip().upper().startswith("COPY") and len(line.split()) >= 3
]
assert "data/" not in copy_sources, (
"Dockerfile must not `COPY data/` — it's gitignored and absent from the "
"worktree build context used by the ORCH-058 staging rebuild (exit 1)."
)
# ---------------------------------------------------------------------------
# TC-09: caller↔hook contract — rebuild_staging_image builds the right command
# ---------------------------------------------------------------------------

View File

@@ -90,10 +90,6 @@ def test_tc17_success_deploy_syncs_terminal_done(monkeypatch):
# Spy the merge-lease release to confirm the terminal-sync still frees it.
release = MagicMock()
monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", release)
# ORCH-021 arms an orthogonal post-deploy-monitor reserved job at deploy->done
# for the self-hosting repo; disable it here so this test stays focused on the
# ORCH-036 terminal-sync contract (no PIPELINE agent launched leaving deploy).
monkeypatch.setattr(stage_engine.post_deploy.settings, "post_deploy_monitor_enabled", False)
task_id = _make_task("deploy")
stage_engine.run_deploy_finalizer(

View File

@@ -0,0 +1,90 @@
"""ORCH-061 regression: the image must build from a git WORKTREE context.
The staging-image rebuild of ORCH-058 (``check_staging_image_fresh`` / the deploy
hook's ``--build-staging`` mode) uses the task **worktree** as the ``docker build``
context. A git worktree only contains git-TRACKED files, so any ``COPY`` of a
gitignored path makes ``docker build`` fail (rc=1) -> ``deploy-staging`` rolls back
to ``development`` (the exact loop ORCH-061 fixes).
The concrete regression: ``COPY data/ ./data/`` referenced ``data/`` which is
gitignored (runtime SQLite DB + backups) and therefore absent in every worktree.
At runtime ``data/`` always arrives via the compose bind mount
(``./data:/app/data`` / ``./data/staging:/app/data``), so baking it in was both
build-breaking and pointless.
These tests guard the invariant statically (no docker required): the Dockerfile
must not ``COPY`` a path that ``.gitignore`` excludes.
"""
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
DOCKERFILE = REPO_ROOT / "Dockerfile"
GITIGNORE = REPO_ROOT / ".gitignore"
def _dockerfile_copy_sources() -> list[str]:
"""Source paths from every ``COPY <src...> <dst>`` line in the Dockerfile.
``--from`` (multi-stage / build-context) COPYs are skipped — they do not read
the worktree build context. The last token on a COPY line is the destination.
"""
sources: list[str] = []
for raw in DOCKERFILE.read_text().splitlines():
line = raw.strip()
if not line.upper().startswith("COPY "):
continue
if "--from" in line:
continue
tokens = line.split()[1:] # drop the COPY keyword
tokens = [t for t in tokens if not t.startswith("--")]
if len(tokens) >= 2:
sources.extend(tokens[:-1]) # all but the destination
return sources
def _gitignored_dirs() -> set[str]:
"""Top-level directory names excluded by ``.gitignore`` (e.g. ``data``)."""
dirs: set[str] = set()
for raw in GITIGNORE.read_text().splitlines():
entry = raw.strip()
if not entry or entry.startswith("#"):
continue
entry = entry.rstrip("/")
# only care about simple top-level dir patterns (no globs / nested paths)
if entry and "/" not in entry and "*" not in entry:
dirs.add(entry)
return dirs
def test_dockerfile_does_not_copy_gitignored_data():
"""``data/`` (gitignored runtime dir) must never be a Dockerfile COPY source."""
copy_sources = _dockerfile_copy_sources()
offending = [s for s in copy_sources if s.rstrip("/") == "data"]
assert not offending, (
"Dockerfile COPYs gitignored 'data/' -> build fails from a worktree "
f"context (rc=1). Offending COPY sources: {offending}. "
"Use `RUN mkdir -p /app/data` and rely on the compose bind mount instead."
)
def test_dockerfile_copies_only_git_tracked_sources():
"""No Dockerfile COPY source may be a gitignored top-level directory."""
gitignored = _gitignored_dirs()
copy_sources = [s.rstrip("/") for s in _dockerfile_copy_sources()]
leaking = sorted(set(copy_sources) & gitignored)
assert not leaking, (
"Dockerfile COPYs gitignored path(s) absent from git worktrees: "
f"{leaking}. The staging rebuild (ORCH-058) builds from the worktree and "
"will fail (rc=1)."
)
def test_data_dir_mount_target_is_created():
"""The image must create the /app/data mount target (no COPY dependency)."""
text = DOCKERFILE.read_text()
assert re.search(r"mkdir\s+-p\s+/app/data", text), (
"Dockerfile must `RUN mkdir -p /app/data` so the compose bind-mount "
"target exists without depending on a (gitignored) host data/ dir."
)

View File

@@ -1,388 +0,0 @@
"""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

View File

@@ -11,7 +11,6 @@ import subprocess
import tempfile
import time
import httpx
import pytest
# Env before importing app modules (same convention as the other suites).
@@ -300,85 +299,3 @@ def test_tc11_release_missing_is_noop(lease_dir):
# Releasing a non-existent lease never raises.
merge_gate.release_merge_lease("orchestrator", "feature/none")
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)"
)

View File

@@ -148,63 +148,3 @@ def test_tc24_red_catch_up_fails_and_releases_main_stays_green(race_repo, monkey
assert _origin_main_sha(origin) == main_before
# The lease was released on failure (a later task can proceed).
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

View File

@@ -1,138 +0,0 @@
"""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

View File

@@ -1,180 +0,0 @@
"""ORCH-068 (TR-4): tests for the Plane states cache TTL self-heal.
The per-project ``_STATES_CACHE`` used to live for the whole process lifetime,
so a status added to Plane after start was never seen without a restart
("stale set -> no pipeline action"). ORCH-068 adds a TTL: an entry is
re-fetched once it is older than ``plane_states_ttl_s`` (default 300s); ``0``
disables the TTL (strictly the previous lifetime cache).
All tests are offline: the Plane API (httpx) and the monotonic clock are mocked.
"""
import os
import tempfile
from unittest.mock import MagicMock, patch
import pytest
os.environ.setdefault("ORCH_PLANE_API_URL", "http://plane.local")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_WORKSPACE_SLUG", "test-ws")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
_test_db = os.path.join(tempfile.gettempdir(), "test_plane_states_cache.db")
os.environ["ORCH_DB_PATH"] = _test_db
import src.plane_sync as ps # noqa: E402
_PROJECT = "proj-ttl"
_ET_PROJECT = "7a79f0a9-5278-49cd-9007-9a338f238f9c"
def _resp(data: dict, status: int = 200):
m = MagicMock()
m.status_code = status
m.json.return_value = data
if status >= 400:
from httpx import HTTPStatusError
m.raise_for_status.side_effect = HTTPStatusError(
"error", request=MagicMock(), response=MagicMock()
)
else:
m.raise_for_status.return_value = None
return m
def _states_response(in_progress_uuid: str) -> dict:
"""A minimal /states/ payload; In Progress carries the given UUID."""
return {
"results": [
{"id": in_progress_uuid, "name": "In Progress", "group": "started"},
{"id": "uuid-done", "name": "Done", "group": "completed"},
]
}
@pytest.fixture(autouse=True)
def reset_cache():
ps.reload_project_states()
yield
ps.reload_project_states()
# ---------------------------------------------------------------------------
# TC-11 (AC-12): a stale cache entry self-heals after the TTL — no restart.
# ---------------------------------------------------------------------------
def test_tc11_stale_cache_refreshes_after_ttl(monkeypatch):
monkeypatch.setattr(ps.settings, "plane_states_ttl_s", 300)
clock = {"t": 1000.0}
monkeypatch.setattr(ps.time, "monotonic", lambda: clock["t"])
responses = iter([
_resp(_states_response("uuid-A")), # first fetch: old set
_resp(_states_response("uuid-B")), # second fetch: new status appeared
])
mock_get = MagicMock(side_effect=lambda *a, **k: next(responses))
monkeypatch.setattr(ps.httpx, "get", mock_get)
# t=1000: first call -> fetch set A.
s1 = ps.get_project_states(_PROJECT)
assert s1["in_progress"] == "uuid-A"
assert mock_get.call_count == 1
# t=1100: within TTL -> served from cache, no new fetch.
clock["t"] = 1100.0
s2 = ps.get_project_states(_PROJECT)
assert s2["in_progress"] == "uuid-A"
assert mock_get.call_count == 1
# t=1400: TTL (300s) elapsed -> re-fetch -> fresh set B (self-heal).
clock["t"] = 1400.0
s3 = ps.get_project_states(_PROJECT)
assert s3["in_progress"] == "uuid-B"
assert mock_get.call_count == 2
def test_tc11_ttl_zero_keeps_lifetime_cache(monkeypatch):
"""plane_states_ttl_s=0 -> strictly the previous lifetime cache (back-compat)."""
monkeypatch.setattr(ps.settings, "plane_states_ttl_s", 0)
clock = {"t": 1000.0}
monkeypatch.setattr(ps.time, "monotonic", lambda: clock["t"])
responses = iter([
_resp(_states_response("uuid-A")),
_resp(_states_response("uuid-B")),
])
mock_get = MagicMock(side_effect=lambda *a, **k: next(responses))
monkeypatch.setattr(ps.httpx, "get", mock_get)
assert ps.get_project_states(_PROJECT)["in_progress"] == "uuid-A"
clock["t"] = 1_000_000.0 # far in the future
# TTL disabled -> still the cached A, never re-fetched.
assert ps.get_project_states(_PROJECT)["in_progress"] == "uuid-A"
assert mock_get.call_count == 1
def test_tc11_groups_exposed_via_accessor(monkeypatch):
"""get_project_state_groups returns {uuid -> group} from the same record."""
monkeypatch.setattr(ps.settings, "plane_states_ttl_s", 300)
monkeypatch.setattr(ps.httpx, "get", lambda *a, **k: _resp(_states_response("uuid-A")))
ps.get_project_states(_PROJECT)
groups = ps.get_project_state_groups(_PROJECT)
assert groups["uuid-A"] == "started"
assert groups["uuid-done"] == "completed"
def test_tc11_groups_empty_when_uncached(monkeypatch):
"""No cache record (e.g. API fell back to defaults) -> groups == {}."""
assert ps.get_project_state_groups("never-fetched") == {}
# ---------------------------------------------------------------------------
# TC-12 (AC-13): default-config compatibility — enduro UUIDs + API-error fallback.
# ---------------------------------------------------------------------------
def test_tc12_enduro_uuids_unchanged(monkeypatch):
"""enduro project still resolves its own UUIDs (return shape unchanged)."""
body = {
"results": [
{"id": "b873d9eb-993c-48cd-97ac-99a9b1623967",
"name": "In Progress", "group": "started"},
]
}
monkeypatch.setattr(ps.httpx, "get", lambda *a, **k: _resp(body))
states = ps.get_project_states(_ET_PROJECT)
assert states["in_progress"] == "b873d9eb-993c-48cd-97ac-99a9b1623967"
# Missing keys are still backfilled from _DEFAULT_STATES (complete mapping).
assert states["done"] == ps._DEFAULT_STATES["done"]
def test_tc12_api_error_falls_back_to_defaults(monkeypatch):
"""API failure with nothing cached -> _DEFAULT_STATES (fallback preserved)."""
monkeypatch.setattr(
ps.httpx, "get", MagicMock(side_effect=Exception("network error"))
)
states = ps.get_project_states(_PROJECT)
assert states is ps._DEFAULT_STATES
def test_tc12_stale_served_when_refresh_fails(monkeypatch):
"""TTL expiry + transient API failure -> serve the stale (project-correct)
set rather than reverting to enduro defaults."""
monkeypatch.setattr(ps.settings, "plane_states_ttl_s", 300)
clock = {"t": 1000.0}
monkeypatch.setattr(ps.time, "monotonic", lambda: clock["t"])
calls = {"n": 0}
def flaky_get(*a, **k):
calls["n"] += 1
if calls["n"] == 1:
return _resp(_states_response("uuid-A"))
raise Exception("transient outage")
monkeypatch.setattr(ps.httpx, "get", flaky_get)
assert ps.get_project_states(_PROJECT)["in_progress"] == "uuid-A"
clock["t"] = 2000.0 # past TTL -> refresh attempt fails
states = ps.get_project_states(_PROJECT)
assert states["in_progress"] == "uuid-A" # stale-but-correct, not defaults
assert states is not ps._DEFAULT_STATES

View File

@@ -1,210 +0,0 @@
"""ORCH-021 unit tests — post-deploy monitor pure logic (TC-01..TC-15).
The deterministic, network-free core (classification + reaction decision +
exit-code mapping + artefact frontmatter + never-raise) of ``src/post_deploy.py``.
Network probes and the rollback hook are exercised via mocks; the classifier is
the main subject (mirrors compute_staging_verdict in ORCH-061).
"""
import os
import tempfile
import pytest
import yaml
# Isolate the settings singleton onto a tmp repos_dir BEFORE importing the module.
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from src import post_deploy # noqa: E402
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _probe(health_ok=True, total=2, fivexx=0):
return {"health_ok": health_ok, "total": total, "fivexx": fivexx}
@pytest.fixture(autouse=True)
def _tmp_state(monkeypatch, tmp_path):
monkeypatch.setattr(post_deploy.settings, "repos_dir", str(tmp_path))
monkeypatch.setattr(post_deploy.settings, "host_repos_dir", str(tmp_path))
yield
# ---------------------------------------------------------------------------
# TC-01..TC-05 — classification (the core)
# ---------------------------------------------------------------------------
def test_tc01_healthy_no_failures():
series = [_probe() for _ in range(5)]
assert post_deploy.classify(series, fail_threshold=3, fivexx_threshold=0.5) == "HEALTHY"
def test_tc02_degraded_consecutive_health_failures():
# Exactly fail_threshold consecutive failures -> DEGRADED (>= contract).
series = [_probe(health_ok=False) for _ in range(3)]
assert post_deploy.classify(series, fail_threshold=3, fivexx_threshold=0.5) == "DEGRADED"
def test_tc03_degraded_by_5xx_ratio_even_when_health_200():
# /health stays 200 (health_ok True) but the 5xx ratio is above threshold.
series = [_probe(health_ok=True, total=2, fivexx=2) for _ in range(3)]
assert post_deploy.classify(series, fail_threshold=10, fivexx_threshold=0.5) == "DEGRADED"
def test_tc04_no_false_trip_single_glitch_then_recovery():
# One isolated failure (1 < threshold) surrounded by healthy probes -> HEALTHY.
series = [_probe(), _probe(health_ok=False), _probe(), _probe()]
assert post_deploy.classify(series, fail_threshold=3, fivexx_threshold=0.5) == "HEALTHY"
def test_tc05_thresholds_change_verdict_on_same_data():
# Same data, different threshold flips the verdict (AC-11): two consecutive fails.
series = [_probe(health_ok=False), _probe(health_ok=False)]
assert post_deploy.classify(series, fail_threshold=3, fivexx_threshold=0.5) == "HEALTHY"
assert post_deploy.classify(series, fail_threshold=2, fivexx_threshold=0.5) == "DEGRADED"
def test_classify_uses_settings_thresholds(monkeypatch):
# The tick reads thresholds from Settings (env ORCH_*) — verify the wiring point.
monkeypatch.setattr(post_deploy.settings, "post_deploy_fail_threshold", 2)
series = [_probe(health_ok=False), _probe(health_ok=False)]
assert post_deploy.classify(
series,
post_deploy.settings.post_deploy_fail_threshold,
post_deploy.settings.post_deploy_5xx_threshold,
) == "DEGRADED"
# ---------------------------------------------------------------------------
# TC-06..TC-08 — reaction decision (self-hosting safety)
# ---------------------------------------------------------------------------
def test_tc06_nonself_auto_rollback_degraded_rolls_back(monkeypatch):
monkeypatch.setattr(post_deploy.settings, "post_deploy_auto_rollback", True)
assert post_deploy.decide_action("enduro-trails", "DEGRADED") == "ROLLBACK"
def test_tc07_self_hosting_degraded_never_rolls_back(monkeypatch):
# orchestrator (self-hosting) is ALWAYS ALERT_ONLY, even with auto_rollback on.
monkeypatch.setattr(post_deploy.settings, "post_deploy_auto_rollback", True)
assert post_deploy.decide_action("orchestrator", "DEGRADED") == "ALERT_ONLY"
def test_tc08_healthy_means_none_for_any_repo():
assert post_deploy.decide_action("orchestrator", "HEALTHY") == "NONE"
assert post_deploy.decide_action("enduro-trails", "HEALTHY") == "NONE"
def test_nonself_default_policy_alert_only(monkeypatch):
monkeypatch.setattr(post_deploy.settings, "post_deploy_auto_rollback", False)
assert post_deploy.decide_action("enduro-trails", "DEGRADED") == "ALERT_ONLY"
# ---------------------------------------------------------------------------
# TC-09..TC-10 — conditionality / kill-switch
# ---------------------------------------------------------------------------
def test_tc09_applies_empty_repos_only_self_hosting(monkeypatch):
monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True)
monkeypatch.setattr(post_deploy.settings, "post_deploy_repos", "")
assert post_deploy.post_deploy_applies("orchestrator") is True
assert post_deploy.post_deploy_applies("enduro-trails") is False
def test_tc09_applies_explicit_repos_csv(monkeypatch):
monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True)
monkeypatch.setattr(post_deploy.settings, "post_deploy_repos", "enduro-trails")
assert post_deploy.post_deploy_applies("enduro-trails") is True
assert post_deploy.post_deploy_applies("orchestrator") is False
def test_tc10_kill_switch_disables_for_everyone(monkeypatch):
monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", False)
assert post_deploy.post_deploy_applies("orchestrator") is False
assert post_deploy.post_deploy_applies("enduro-trails") is False
# ---------------------------------------------------------------------------
# TC-11..TC-12 — rollback exit-code mapping
# ---------------------------------------------------------------------------
def test_tc11_rollback_exit0_is_ok():
assert post_deploy.map_rollback_exit_code(0) == "ROLLBACK_OK"
def test_tc12_rollback_exit_nonzero_is_failed():
assert post_deploy.map_rollback_exit_code(1) == "ROLLBACK_FAILED"
assert post_deploy.map_rollback_exit_code(2) == "ROLLBACK_FAILED"
assert post_deploy.map_rollback_exit_code(None) == "ROLLBACK_FAILED"
assert post_deploy.map_rollback_exit_code("garbage") == "ROLLBACK_FAILED"
# ---------------------------------------------------------------------------
# TC-13 — artefact frontmatter
# ---------------------------------------------------------------------------
def test_tc13_log_frontmatter_parses():
body = post_deploy.build_post_deploy_log(
"ORCH-021", "DEGRADED", "ALERT_ONLY", 900, 12, 4
)
assert body.startswith("---\n")
fm = body.split("---", 2)[1]
data = yaml.safe_load(fm)
assert data["post_deploy_status"] == "DEGRADED"
assert data["action_taken"] == "ALERT_ONLY"
assert data["work_item"] == "ORCH-021"
assert data["window_s"] == 900
assert data["checks_total"] == 12
assert data["checks_failed"] == 4
# ---------------------------------------------------------------------------
# TC-14..TC-15 — never-raise
# ---------------------------------------------------------------------------
def test_tc14_probe_network_error_is_conservative_not_raise(monkeypatch):
# urlopen raises on every call -> health bad + monitored endpoints counted as
# 5xx, but NO exception propagates (the helper swallows and reports code 0).
def boom(*a, **k):
raise OSError("network down")
monkeypatch.setattr(post_deploy.urllib.request, "urlopen", boom)
res = post_deploy.probe_signals("http://localhost:8500")
assert res.health_ok is False
assert res.total == 2
assert res.fivexx == 2 # unreachable endpoints counted as failures
def test_tc14_classify_junk_input_swallowed():
# If classify gets junk it must not raise (fail-safe to HEALTHY).
assert post_deploy.classify("not-a-list", 3, 0.5) == "HEALTHY"
assert post_deploy.classify([{"bad": "row"}], 3, 0.5) == "HEALTHY"
assert post_deploy.classify(None, 3, 0.5) == "HEALTHY"
def test_tc15_write_log_no_worktree_returns_false(monkeypatch):
# get_worktree_path raises -> write returns False, no exception (best-effort).
def boom(repo, branch):
raise FileNotFoundError("no worktree")
monkeypatch.setattr("src.git_worktree.get_worktree_path", boom)
ok = post_deploy.write_post_deploy_log(
"nope-repo", "ORCH-021", "feature/x", "HEALTHY", "NONE", 900, 3, 0
)
assert ok is False
# ---------------------------------------------------------------------------
# Sentinel state restart-safe counters
# ---------------------------------------------------------------------------
def test_series_append_and_read_roundtrip():
post_deploy.write_marker("orchestrator", "ORCH-021", post_deploy.ARMED, "armed")
post_deploy.append_probe("orchestrator", "ORCH-021", post_deploy.ProbeResult(False, 2, 1, "x"))
post_deploy.append_probe("orchestrator", "ORCH-021", post_deploy.ProbeResult(True, 2, 0, "y"))
series = post_deploy.read_series("orchestrator", "ORCH-021")
assert len(series) == 2
assert series[0]["health_ok"] is False
assert series[1]["health_ok"] is True
def test_mark_done_idempotency_marker():
assert post_deploy.has_marker("orchestrator", "ORCH-021", post_deploy.DONE) is False
post_deploy.mark_done("orchestrator", "ORCH-021")
assert post_deploy.has_marker("orchestrator", "ORCH-021", post_deploy.DONE) is True

View File

@@ -1,259 +0,0 @@
"""ORCH-021 integration tests — arming + tick orchestration (TC-16..TC-20).
Exercises the wiring in ``stage_engine`` (arm on deploy->done,
``run_post_deploy_monitor`` tick + reaction) and the ``/queue`` observability
block, with the network probe and the rollback hook mocked. Mirrors the
test_deploy_terminal_sync.py harness.
"""
import os
import tempfile
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_orch_post_deploy.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 post_deploy # 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()
# State sentinels live under the tmp repos_dir (container view).
monkeypatch.setattr(post_deploy.settings, "repos_dir", str(tmp_path))
monkeypatch.setattr(post_deploy.settings, "host_repos_dir", str(tmp_path))
monkeypatch.setattr(stage_engine.settings, "repos_dir", str(tmp_path))
# The artefact write is best-effort; stub it so no worktree is needed.
monkeypatch.setattr(post_deploy, "write_post_deploy_log", MagicMock(return_value=True))
yield
@pytest.fixture(autouse=True)
def silence_side_effects(monkeypatch):
for name in (
"notify_stage_change", "notify_qg_failure", "notify_approve_requested",
"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())
def _make_task(stage, repo="orchestrator", branch="feature/ORCH-021-x", wi="ORCH-021"):
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 _jobs(agent=None):
conn = get_db()
if agent:
rows = conn.execute(
"SELECT agent FROM jobs WHERE agent=? ORDER BY id", (agent,)
).fetchall()
else:
rows = conn.execute("SELECT agent FROM jobs ORDER BY id").fetchall()
conn.close()
return [r[0] for r in rows]
def _pass(*a, **k):
return (True, "ok")
def _drive_deploy_to_done(monkeypatch, task_id, repo="orchestrator",
branch="feature/ORCH-021-x", wi="ORCH-021"):
"""Advance a deploy-stage task to done through the real terminal block."""
monkeypatch.setattr(
stage_engine, "QG_CHECKS",
{**stage_engine.QG_CHECKS, "check_deploy_status": _pass},
)
monkeypatch.setattr(stage_engine.merge_gate, "release_merge_lease", MagicMock())
return stage_engine.advance_stage(
task_id=task_id, current_stage="deploy", repo=repo,
work_item_id=wi, branch=branch, finished_agent="deployer",
)
# ---------------------------------------------------------------------------
# TC-16 — arm on deploy->done (applicable repo only)
# ---------------------------------------------------------------------------
def test_tc16_arm_for_self_hosting(monkeypatch):
monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True)
monkeypatch.setattr(post_deploy.settings, "post_deploy_repos", "")
task_id = _make_task("deploy")
_drive_deploy_to_done(monkeypatch, task_id)
assert post_deploy.has_marker("orchestrator", "ORCH-021", post_deploy.ARMED)
assert "post-deploy-monitor" in _jobs("post-deploy-monitor")
def test_tc16_no_arm_for_nonself(monkeypatch):
monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True)
monkeypatch.setattr(post_deploy.settings, "post_deploy_repos", "")
task_id = _make_task("deploy", repo="enduro-trails", branch="feature/ET-9", wi="ET-9")
_drive_deploy_to_done(monkeypatch, task_id, repo="enduro-trails",
branch="feature/ET-9", wi="ET-9")
assert not post_deploy.has_marker("enduro-trails", "ET-9", post_deploy.ARMED)
assert _jobs("post-deploy-monitor") == []
def test_tc16_no_arm_when_kill_switch_off(monkeypatch):
monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", False)
task_id = _make_task("deploy")
_drive_deploy_to_done(monkeypatch, task_id)
assert not post_deploy.has_marker("orchestrator", "ORCH-021", post_deploy.ARMED)
assert _jobs("post-deploy-monitor") == []
# ---------------------------------------------------------------------------
# TC-17 — idempotent arm (double webhook)
# ---------------------------------------------------------------------------
def test_tc17_double_arm_is_noop(monkeypatch):
monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True)
armed1 = post_deploy.arm_monitor("orchestrator", "ORCH-021", "feature/ORCH-021-x", 1)
armed2 = post_deploy.arm_monitor("orchestrator", "ORCH-021", "feature/ORCH-021-x", 1)
assert armed1 is True
assert armed2 is False
# Exactly ONE monitor job enqueued despite two arm calls.
assert _jobs("post-deploy-monitor") == ["post-deploy-monitor"]
# ---------------------------------------------------------------------------
# TC-18 — DEGRADED -> non-self auto-rollback (hook mocked)
# ---------------------------------------------------------------------------
def test_tc18_degraded_nonself_rolls_back(monkeypatch):
monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True)
monkeypatch.setattr(post_deploy.settings, "post_deploy_repos", "enduro-trails")
monkeypatch.setattr(post_deploy.settings, "post_deploy_auto_rollback", True)
monkeypatch.setattr(post_deploy.settings, "post_deploy_fail_threshold", 1)
monkeypatch.setattr(post_deploy.settings, "post_deploy_window_s", 30)
monkeypatch.setattr(post_deploy.settings, "post_deploy_interval_s", 30) # budget=1 tick
# Probe reports unhealthy.
monkeypatch.setattr(
post_deploy, "probe_signals",
lambda url: post_deploy.ProbeResult(False, 2, 2, "down"),
)
rollback = MagicMock(return_value=(0, "ok"))
monkeypatch.setattr(post_deploy, "run_rollback", rollback)
notify = MagicMock()
monkeypatch.setattr(stage_engine, "_notify_post_deploy", notify)
logspy = MagicMock(return_value=True)
monkeypatch.setattr(post_deploy, "write_post_deploy_log", logspy)
task_id = _make_task("done", repo="enduro-trails", branch="feature/ET-9", wi="ET-9")
post_deploy.write_marker("enduro-trails", "ET-9", post_deploy.ARMED, "armed")
stage_engine.run_post_deploy_monitor(
{"task_id": task_id, "repo": "enduro-trails", "id": 1, "agent": "post-deploy-monitor"}
)
rollback.assert_called_once_with("enduro-trails")
assert post_deploy.has_marker("enduro-trails", "ET-9", post_deploy.DONE)
# Artefact written with ROLLBACK_OK; a notification was sent.
args = logspy.call_args[0]
assert "DEGRADED" in args
assert "ROLLBACK_OK" in args
assert notify.called
# ---------------------------------------------------------------------------
# TC-19 — self-hosting DEGRADED never rolls back, alerts instead
# ---------------------------------------------------------------------------
def test_tc19_degraded_self_hosting_alert_only(monkeypatch):
monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True)
monkeypatch.setattr(post_deploy.settings, "post_deploy_auto_rollback", True)
monkeypatch.setattr(post_deploy.settings, "post_deploy_fail_threshold", 1)
monkeypatch.setattr(post_deploy.settings, "post_deploy_window_s", 30)
monkeypatch.setattr(post_deploy.settings, "post_deploy_interval_s", 30)
monkeypatch.setattr(
post_deploy, "probe_signals",
lambda url: post_deploy.ProbeResult(False, 2, 2, "down"),
)
# Rollback hook MUST NOT be called for self-hosting (AC-8 structural invariant).
rollback = MagicMock(return_value=(0, "ok"))
monkeypatch.setattr(post_deploy, "run_rollback", rollback)
notify = MagicMock()
monkeypatch.setattr(stage_engine, "_notify_post_deploy", notify)
logspy = MagicMock(return_value=True)
monkeypatch.setattr(post_deploy, "write_post_deploy_log", logspy)
task_id = _make_task("done")
post_deploy.write_marker("orchestrator", "ORCH-021", post_deploy.ARMED, "armed")
stage_engine.run_post_deploy_monitor(
{"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "post-deploy-monitor"}
)
rollback.assert_not_called()
assert post_deploy.has_marker("orchestrator", "ORCH-021", post_deploy.DONE)
args = logspy.call_args[0]
assert "DEGRADED" in args
assert "ALERT_ONLY" in args
assert notify.called
def test_healthy_tick_requeues_without_finishing(monkeypatch):
# HEALTHY and window not exhausted -> re-queue, do NOT mark done.
monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True)
monkeypatch.setattr(post_deploy.settings, "post_deploy_window_s", 90)
monkeypatch.setattr(post_deploy.settings, "post_deploy_interval_s", 30) # budget=3
monkeypatch.setattr(
post_deploy, "probe_signals",
lambda url: post_deploy.ProbeResult(True, 2, 0, "ok"),
)
task_id = _make_task("done")
post_deploy.write_marker("orchestrator", "ORCH-021", post_deploy.ARMED, "armed")
stage_engine.run_post_deploy_monitor(
{"task_id": task_id, "repo": "orchestrator", "id": 1, "agent": "post-deploy-monitor"}
)
assert not post_deploy.has_marker("orchestrator", "ORCH-021", post_deploy.DONE)
# A follow-up tick job was enqueued.
assert _jobs("post-deploy-monitor") == ["post-deploy-monitor"]
def test_finished_window_tick_is_noop(monkeypatch):
# AC-15: a tick after the window is done is a no-op (no new job, no re-probe).
probe = MagicMock()
monkeypatch.setattr(post_deploy, "probe_signals", probe)
task_id = _make_task("done")
post_deploy.mark_done("orchestrator", "ORCH-021")
stage_engine.run_post_deploy_monitor(
{"task_id": task_id, "repo": "orchestrator", "id": 9, "agent": "post-deploy-monitor"}
)
probe.assert_not_called()
# ---------------------------------------------------------------------------
# TC-20 — /queue observability block
# ---------------------------------------------------------------------------
def test_tc20_queue_block_present(monkeypatch):
monkeypatch.setattr(post_deploy.settings, "post_deploy_monitor_enabled", True)
post_deploy.write_marker("orchestrator", "ORCH-021", post_deploy.ARMED, "armed")
snap = post_deploy.status()
assert snap["enabled"] is True
assert snap["window_s"] == post_deploy.settings.post_deploy_window_s
assert "ORCH-021" in snap["active"]
assert snap["active_count"] >= 1
# A finished window drops out of "active".
post_deploy.mark_done("orchestrator", "ORCH-021")
snap2 = post_deploy.status()
assert "ORCH-021" not in snap2["active"]

View File

@@ -302,58 +302,3 @@ class TestWorkerConcurrency:
assert count_running_jobs() == 0
counts = job_status_counts()
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

View File

@@ -295,342 +295,3 @@ def test_tc17_polls_all_projects_resolves_states_per_project(monkeypatch):
# state uuids are resolved per-project (not hardcoded): each call carries them.
for _pid, states in issues_calls:
assert set(states) == {_IN_PROGRESS, _APPROVED, _REJECTED}
# ===========================================================================
# ORCH-068: livelock-fix — terminal exclusion (D1) + confirmed-change unblock
# (D2) + dedup (TR-3). The old code spammed `_note_unblock` every ~120s for a
# fully synchronized Done task (incident: ET-002, 191+ Telegram messages/night).
# ===========================================================================
_DONE = "uuid-done"
_CANCELLED = "uuid-cancelled"
def _patch_states_with_terminals(monkeypatch, *, alias_done_to_approved=False):
"""Patch F-2 state resolution to include terminals + their groups.
``alias_done_to_approved`` models the regression trigger (ORCH-066): the
project "collapses" Done onto the approved UUID, so a genuinely-Done issue
would enter the ``approved`` branch by UUID. Only the state GROUP
(``completed``) disentangles it — the heart of D1.
"""
done_uuid = _APPROVED if alias_done_to_approved else _DONE
states = {
"in_progress": _IN_PROGRESS,
"approved": _APPROVED,
"rejected": _REJECTED,
"done": done_uuid,
"cancelled": _CANCELLED,
}
groups = {
_IN_PROGRESS: "started",
_APPROVED: "started",
_REJECTED: "started",
done_uuid: "completed", # genuinely-done issue -> completed group
_CANCELLED: "cancelled",
}
monkeypatch.setattr(reconciler_mod, "get_project_states", lambda pid: states)
monkeypatch.setattr(
reconciler_mod, "get_project_state_groups", lambda pid: groups
)
return states, groups
def _spy_telegram(monkeypatch):
sent = []
monkeypatch.setattr(reconciler_mod, "send_telegram", lambda msg: sent.append(msg))
return sent
def _job_count():
conn = get_db()
n = conn.execute("SELECT COUNT(*) FROM jobs").fetchone()[0]
conn.close()
return n
# ---------------------------------------------------------------------------
# TC-01 (AC-1, AC-7): synchronized Done task -> total silence, 0 jobs.
# ---------------------------------------------------------------------------
def test_tc01_synced_done_is_silent(monkeypatch, single_project):
start, verdict = _patch_handlers(monkeypatch)
_patch_states_with_terminals(monkeypatch)
sent = _spy_telegram(monkeypatch)
_make_task("iss-done", stage="done", wi="ET-002")
_patch_issues(monkeypatch, [
{"id": "iss-done", "state": {"id": _DONE}, "updated_at": _OLD_TS},
])
recon = Reconciler()
recon.reconcile_plane_once()
start.assert_not_called()
verdict.assert_not_called()
assert sent == []
assert recon.unblocked_total == 0
assert recon.skipped_terminal_total == 1
assert _job_count() == 0
# ---------------------------------------------------------------------------
# TC-02 (AC-2): Done UUID aliased onto approved -> still excluded by GROUP.
# ---------------------------------------------------------------------------
def test_tc02_terminal_aliased_to_approved_excluded(monkeypatch, single_project):
start, verdict = _patch_handlers(monkeypatch)
_patch_states_with_terminals(monkeypatch, alias_done_to_approved=True)
sent = _spy_telegram(monkeypatch)
# Task is Done; its Plane state UUID equals the approved UUID (aliasing).
_make_task("iss-alias", stage="done", wi="ET-002")
_patch_issues(monkeypatch, [
{"id": "iss-alias", "state": {"id": _APPROVED}, "updated_at": _OLD_TS},
])
recon = Reconciler()
recon.reconcile_plane_once()
# Without the group check this would enter the approved branch and notify.
start.assert_not_called()
verdict.assert_not_called()
assert sent == []
assert recon.unblocked_total == 0
assert recon.skipped_terminal_total == 1
# ---------------------------------------------------------------------------
# TC-03 (AC-2): Cancelled terminal is also excluded.
# ---------------------------------------------------------------------------
def test_tc03_cancelled_excluded(monkeypatch, single_project):
start, verdict = _patch_handlers(monkeypatch)
_patch_states_with_terminals(monkeypatch)
sent = _spy_telegram(monkeypatch)
_make_task("iss-cancel", stage="done", wi="ET-003")
_patch_issues(monkeypatch, [
{"id": "iss-cancel", "state": {"id": _CANCELLED}, "updated_at": _OLD_TS},
])
recon = Reconciler()
recon.reconcile_plane_once()
start.assert_not_called()
verdict.assert_not_called()
assert sent == []
assert recon.unblocked_total == 0
assert recon.skipped_terminal_total == 1
# ---------------------------------------------------------------------------
# TC-04 (AC-3): no-op dispatch (stage unchanged) -> no notification.
# ---------------------------------------------------------------------------
def test_tc04_noop_dispatch_no_unblock(monkeypatch, single_project):
# handle_verdict is a no-op AsyncMock -> the task stage never moves.
start, verdict = _patch_handlers(monkeypatch)
sent = _spy_telegram(monkeypatch)
_make_task("iss-noop", stage="review")
_patch_issues(monkeypatch, [
{"id": "iss-noop", "state": {"id": _APPROVED}, "updated_at": _OLD_TS},
])
recon = Reconciler()
recon.reconcile_plane_once()
# The handler was replayed (idempotent), but nothing changed -> silence.
assert verdict.call_count == 1
assert sent == []
assert recon.unblocked_total == 0
# ---------------------------------------------------------------------------
# TC-05 (AC-4): two consecutive ticks on a synced task -> 0 repeat unblocks;
# plus a direct check of the in-memory dedup guard.
# ---------------------------------------------------------------------------
def test_tc05_dedup_no_repeat_notification(monkeypatch, single_project):
start, verdict = _patch_handlers(monkeypatch)
_patch_states_with_terminals(monkeypatch)
sent = _spy_telegram(monkeypatch)
_make_task("iss-dedup", stage="done", wi="ET-004")
_patch_issues(monkeypatch, [
{"id": "iss-dedup", "state": {"id": _DONE}, "updated_at": _OLD_TS},
])
recon = Reconciler()
recon.reconcile_plane_once()
recon.reconcile_plane_once()
assert sent == []
assert recon.unblocked_total == 0
# Direct dedup-guard exercise: the same issue+state notifies at most once.
recon._note_unblock("ET-004", "review", "state-x")
recon._note_unblock("ET-004", "review", "state-x")
assert recon.unblocked_total == 1
assert recon.deduped_total == 1
# ---------------------------------------------------------------------------
# TC-06 (AC-5): legit lost Approved webhook -> replayed, advanced, ONE unblock.
# ---------------------------------------------------------------------------
def test_tc06_legit_approved_unblock_once(monkeypatch, single_project):
_patch_states_with_terminals(monkeypatch) # non-terminal approved -> actionable
sent = _spy_telegram(monkeypatch)
_make_task("iss-appr", stage="review", wi="ET-005")
async def fake_verdict(issue_data, project_id, approved=True):
# Simulate the real handler advancing the stage (review -> testing).
conn = get_db()
conn.execute(
"UPDATE tasks SET stage='testing' WHERE plane_id=?",
(issue_data["id"],),
)
conn.commit()
conn.close()
monkeypatch.setattr(reconciler_mod, "handle_verdict", fake_verdict)
monkeypatch.setattr(reconciler_mod, "handle_status_start", AsyncMock())
_patch_issues(monkeypatch, [
{"id": "iss-appr", "state": {"id": _APPROVED}, "updated_at": _OLD_TS},
])
recon = Reconciler()
recon.reconcile_plane_once()
assert recon.unblocked_total == 1
assert len(sent) == 1
assert "ET-005" in sent[0]
# ---------------------------------------------------------------------------
# TC-07 (AC-6): lost In Progress start (task appears) and lost Rejected
# rollback (stage moves) each fire exactly one unblock.
# ---------------------------------------------------------------------------
def test_tc07_in_progress_start_and_rejected_each_one_unblock(
monkeypatch, single_project
):
_patch_states_with_terminals(monkeypatch)
sent = _spy_telegram(monkeypatch)
async def fake_start(issue_data, project_id):
# Simulate the real start handler creating the task.
_make_task(issue_data["id"], stage="analysis", wi="ET-006")
async def fake_verdict(issue_data, project_id, approved=True):
conn = get_db()
conn.execute(
"UPDATE tasks SET stage='development' WHERE plane_id=?",
(issue_data["id"],),
)
conn.commit()
conn.close()
monkeypatch.setattr(reconciler_mod, "handle_status_start", fake_start)
monkeypatch.setattr(reconciler_mod, "handle_verdict", fake_verdict)
# Rejected task already exists at review; In Progress one has no task yet.
_make_task("iss-rej", stage="review", wi="ET-007")
_patch_issues(monkeypatch, [
{"id": "iss-start", "state": {"id": _IN_PROGRESS}, "updated_at": _OLD_TS},
{"id": "iss-rej", "state": {"id": _REJECTED}, "updated_at": _OLD_TS},
])
recon = Reconciler()
recon.reconcile_plane_once()
assert recon.unblocked_total == 2
assert len(sent) == 2
# ---------------------------------------------------------------------------
# TC-08 (AC-8): never-raise — a failing dependency isolates to its unit of work.
# ---------------------------------------------------------------------------
def test_tc08_never_raise_isolation(monkeypatch, single_project):
_patch_states_with_terminals(monkeypatch)
monkeypatch.setattr(reconciler_mod, "send_telegram", lambda msg: None)
# _dispatch blows up for one issue -> isolated; the tick must not crash.
def boom_dispatch(*a, **k):
raise RuntimeError("handler exploded")
monkeypatch.setattr(Reconciler, "_dispatch", staticmethod(boom_dispatch))
_make_task("iss-boom", stage="review", wi="ET-008")
_patch_issues(monkeypatch, [
{"id": "iss-boom", "state": {"id": _APPROVED}, "updated_at": _OLD_TS},
])
recon = Reconciler()
recon.reconcile_plane_once() # must NOT raise
assert recon.unblocked_total == 0
# list_issues_by_state raising -> per-project isolation, still no crash.
def boom_list(pid, states):
raise RuntimeError("plane down")
monkeypatch.setattr(reconciler_mod, "list_issues_by_state", boom_list)
recon.reconcile_plane_once() # must NOT raise
# ---------------------------------------------------------------------------
# TC-09 (AC-9): kill-switches mute F-2.
# ---------------------------------------------------------------------------
def test_tc09_kill_switches(monkeypatch, single_project):
start, verdict = _patch_handlers(monkeypatch)
_patch_states_with_terminals(monkeypatch)
called = {"list": 0}
def counting_list(pid, states):
called["list"] += 1
return [{"id": "iss-x", "state": {"id": _APPROVED}, "updated_at": _OLD_TS}]
monkeypatch.setattr(reconciler_mod, "list_issues_by_state", counting_list)
monkeypatch.setattr(reconciler_mod.settings, "reconcile_enabled", False)
Reconciler().reconcile_plane_once()
assert called["list"] == 0 # global switch off -> F-2 never runs
monkeypatch.setattr(reconciler_mod.settings, "reconcile_enabled", True)
monkeypatch.setattr(reconciler_mod.settings, "reconcile_plane_enabled", False)
Reconciler().reconcile_plane_once()
assert called["list"] == 0 # F-2 switch off -> still no poll
# ---------------------------------------------------------------------------
# TC-10 (AC-1, AC-2): end-to-end on BOTH registry projects (enduro AND
# orchestrator): a Done task on each -> 0 notifications / 0 jobs, regardless
# of per-project status aliasing. The headline regression test.
# ---------------------------------------------------------------------------
def test_tc10_done_silent_on_all_projects(monkeypatch):
from src import projects as projects_mod
projects_mod.reload_projects()
assert len({p.plane_project_id for p in projects_mod.PROJECTS}) >= 2
start, verdict = _patch_handlers(monkeypatch)
sent = _spy_telegram(monkeypatch)
states = {
"in_progress": _IN_PROGRESS,
"approved": _APPROVED,
"rejected": _REJECTED,
"done": _DONE,
"cancelled": _CANCELLED,
}
groups = {_DONE: "completed", _CANCELLED: "cancelled"}
monkeypatch.setattr(reconciler_mod, "get_project_states", lambda pid: states)
monkeypatch.setattr(
reconciler_mod, "get_project_state_groups", lambda pid: groups
)
# Each project returns a Done issue (unique id per project).
monkeypatch.setattr(
reconciler_mod, "list_issues_by_state",
lambda pid, st: [
{"id": f"done-{pid}", "state": {"id": _DONE}, "updated_at": _OLD_TS}
],
)
recon = Reconciler()
recon.reconcile_plane_once()
start.assert_not_called()
verdict.assert_not_called()
assert sent == []
assert recon.unblocked_total == 0
assert recon.skipped_terminal_total >= 2 # one per project
assert _job_count() == 0