Files
orchestrator/docs/architecture/README.md
claude-bot 1d72c44587 fix(reconciler): stop F-2 livelock spam on synced terminal tasks + cache TTL
Reconciler F-2 spammed Telegram "<wi> разблокирована" every ~120s for a
fully-synchronized Done task (incident ET-002, 191+ msgs/night) after the
ORCH-066 Plane status model merge. Two stacked defects (defense in depth):

- D1 (selection): actionable states were told apart by bare UUID, so a Done
  issue aliased onto the approved UUID entered the approved branch. Now
  terminal states are excluded by Plane state GROUP (completed/cancelled),
  a project-independent discriminator robust to UUID aliasing; per-issue
  check with a logical-key fallback when the group is unavailable.
  get_project_states caches {uuid -> group} from the same /states/ fetch;
  new sibling accessor get_project_state_groups.
- D2 (notification): _note_unblock fired unconditionally after _dispatch.
  Now it only fires on a confirmed state change (stage before/after _dispatch;
  task-appears for the start case) — handlers' contracts untouched.
- TR-3: in-memory dedup guard {issue_id -> last unblocked state} as a backstop.
- TR-4: _STATES_CACHE lived for the whole process lifetime, so a new Plane
  status was invisible without a restart. Added TTL ORCH_PLANE_STATES_TTL_S
  (default 300s; 0 = previous lifetime cache) reusing reload_project_states();
  a failed refresh serves the stale-but-correct set, not enduro defaults.

STAGE_TRANSITIONS / QG_CHECKS / DB schema / handle_* contracts / F-1 / F-3
unchanged; never-raise preserved; self-hosting tick never restarts prod.
Observability: skipped_terminal_total / deduped_total in /queue reconcile block.

Tests: tests/test_reconciler_plane.py (TC-01..TC-10),
tests/test_plane_states_cache.py (TC-11/TC-12).

Refs: ORCH-068

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 05:18:46 +00:00

320 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Архитектура Orchestrator
## Обзор
Мульти-агентный оркестратор разработки. Принимает webhooks от Plane (управление задачами) и Gitea (git-события), ведёт задачи по конвейеру стадий через Quality Gates, на каждой стадии запускает Claude CLI агента. Поддерживает несколько проектов (multi-repo) и self-hosting (дорабатывает сам себя).
## Компоненты
- **Webhook Receivers** (`src/webhooks/plane.py`, `gitea.py`) — приём событий, HMAC-проверка, дедупликация (`_dedup.py`). Роуты: `POST /webhook/plane`, `POST /webhook/gitea`.
- **State Machine** (`src/stages.py`) — `STAGE_TRANSITIONS`: переходы, агент и QG каждой стадии. Хелперы: `get_next_stage`, `get_agent_for_stage`, `get_qg_for_stage`, `get_previous_stage`.
- **Stage Engine** (`src/stage_engine.py`) — исполнение переходов, диспетчеризация QG (`_run_qg`), откаты, синхронизация с Plane.
- **Review/Test Parsers** (`src/review_parse.py`, ORCH-046) — defensive-извлечение дословного must-fix текста из артефактов для встраивания в `task_desc` заворота: `extract_review_findings` (P0/P1 из `12-review.md`), `extract_test_failures` (фрагмент тела `13-test-report.md`). Контракт «never raise»: любая ошибка → `""`.
- **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` неизменна (обратная совместимость).
## Конвейер и Quality Gates
```
created → analysis → architecture → development → review → testing → deploy-staging → deploy → done
↑ │
└──── REQUEST_CHANGES ──────┘ (откат на development, max 3 retries)
```
| Стадия | Агент (выход) | Quality Gate | Артефакт |
|--------|---------------|--------------|----------|
| created | analyst | — | — |
| analysis | architect | `check_analysis_approved` | 01-brd / 02-trz / 03-acceptance-criteria / 04-test-plan.yaml |
| architecture | developer | `check_architecture_done` | 06-adr/ |
| development | reviewer | `check_ci_green` | код + PR |
| review | tester | `check_reviewer_verdict` | 12-review.md (`verdict:`) |
| testing | deployer | `check_tests_passed` | 13-test-report.md |
| deploy-staging | deployer | `check_staging_status` | 15-staging-log.md (`staging_status:`) |
| deploy | — | `check_deploy_status` | 14-deploy-log.md (`deploy_status:`) |
| done | — | — | — |
**Реестр QG** (`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 (ORCH-043), check_staging_image_fresh (ORCH-058).
**Канон гейтов:** машинные вердикты читаются ТОЛЬКО из YAML-frontmatter, никогда из прозы. Лог-файлы мержатся в `origin/main` отдельным PR; гейт читает из `origin/main`.
### Условный staging-гейт (ORCH-35)
`check_staging_status` реален только для self-hosting (`is_self_hosting_repo(repo)``orchestrator`); для остальных проектов → no-op `(True, "Staging gate N/A")`. Для orchestrator парсит `staging_status:` из `15-staging-log.md`; FAILED → откат на `development`. Подробнее: [ADR-0003](adr/adr-0003-staging-gate.md).
### Толерантность staging-вердикта к инфра-FAIL (ORCH-061 — design)
Self-hosting зацикливался на `deploy-staging`: `scripts/staging_check.py` давал ложный FAILED на C9a/C9b (ветка в sandbox / analyst-job в очереди), вызванный **отсутствием sandbox-настроек** (bot-аккаунты не члены SANDBOX-проекта), а не регрессом кода → откат `deploy-staging → development` → петля. ORCH-061 классифицирует проверки suite на **REAL** (pipeline) и **SANDBOX_INFRA** (узкий allowlist `{C9a, C9b}`) и делает вердикт толерантным к инфра-FAIL, сохраняя fail-closed для реальных проверок:
- Чистая логика — leaf-модуль `src/staging_verdict.py` (`classify_check`, `compute_staging_verdict`, never-raise). Упала хоть одна REAL → FAILED/exit1; упали ТОЛЬКО SANDBOX_INFRA и толерантность вкл → SUCCESS/exit0 (waived); waiver применяется только когда все REAL (вкл. C7/C8) зелёные.
- `scripts/staging_check.py` помечает проверки категориями, считает вердикт через `staging_verdict`, печатает `INFRA-WAIVED` (наблюдаемость).
- Kill-switch `staging_infra_tolerance_enabled` (env `ORCH_STAGING_INFRA_TOLERANCE_ENABLED`, дефолт `true`, в `.env.staging`); `false` → 1:1 прежнее строгое поведение.
- `check_staging_status` / `_parse_staging_status` / `STAGE_TRANSITIONS` / реестр `QG_CHECKS`**без изменений** (новый QG-чек не вводится); условность ORCH-35 и схема БД сохранены.
- Инвариант: «no changes to commit» на action-стадиях (`deploy-staging`/`deploy`) не есть недовыполнение — продвижение определяется exit0 + гейт-вердиктом (launcher не откатывает; добавлена observability-строка).
Подробнее: [adr-0009](adr/adr-0009-staging-infra-tolerance.md), детально — `docs/work-items/ORCH-061/06-adr/ADR-001-staging-infra-tolerance.md`.
### Merge-gate: догон `main` + re-test + сериализация слияний (ORCH-043)
Детерминированный под-гейт (`check_branch_mergeable`, без LLM) на ребре **`deploy-staging → deploy`**: исполняется ПОСЛЕ `check_staging_status` и ДО запуска deployer'а, который вливает PR в `main` (deployer мержит в начале стадии `deploy`). Стадии (`STAGE_TRANSITIONS`) НЕ меняются — это «под-гейт» ребра, а не отдельная стадия (триггер — то же событие «staging-deployer завершился»).
Назначение: ветка валидируется относительно того `main`, из которого создана; параллельная задача могла уйти вперёд → семантический конфликт слияния (зелёная ветка ломает обновлённый `main`). Merge-gate гарантирует проверку против **актуального** `origin/main` перед слиянием:
- **Догон:** ветка отстаёт (⇔ `origin/main` не предок HEAD) → `rebase origin/main` в worktree + `push --force-with-lease` (ТОЛЬКО ветка задачи; `main` — никогда). Текстовый конфликт → `rebase --abort` → откат на `development`.
- **Re-test:** `python -m pytest` (`merge_retest_target`, дефолт `tests/`) в worktree догнанной ветки, тайм-аут `merge_retest_timeout_s`. Красный/тайм-аут → откат на `development`.
- **Сериализация (merge-lock):** файловый **merge-lease** на репо (`<repos_dir>/.merge-lease-<repo>.json`), живёт от гейта до фактического merge. Acquire **неблокирующий** (anti-deadlock при `max_concurrency=1`): busy → **defer** (повторная постановка deployer'а на `deploy-staging` с задержкой через `available_at`), а не откат. Release — на PR-merged вебхуке / `deploy→done` / откате / по возрасту (crash-реклейм). Restart-safe; без изменения схемы БД.
- **Условность (как ORCH-35):** реален для `orchestrator`; прочие репо — no-op. Флаги `merge_gate_enabled` / `merge_gate_repos` — поэтапный раскат. Контракт **never-raise**.
Подробнее: [adr-0006](adr/adr-0006-merge-gate.md), детально — `docs/work-items/ORCH-043/06-adr/ADR-001-merge-gate.md`.
### Исполняемый самодеплой стадии `deploy` (ORCH-36)
`deploy` перестаёт быть «бумажной»: для self-hosting (`is_self_hosting_repo`) стадия
РЕАЛЬНО деплоит прод (8500) через хост-хук `scripts/orchestrator-deploy-hook.sh`,
а `deploy_status: SUCCESS` означает доказанный health-ok, не декларацию LLM. Три фазы
(детерминированно, без LLM в критическом пути self-restart):
- **Фаза A (вход в `deploy`)** — при `deploy_require_manual_approve=true` вместо запуска
прод-deployer выставляется approval-pending статус Plane + запрос approve
(Plane-коммент + Telegram). Перехват в `advance_stage` ПОСЛЕ `check_staging_status`
и merge-gate.
- **Фаза B (Plane → `Approved`)** — `advance_stage(deploy, finished_agent=None)`
запускает **detached host-процесс** (ssh + setsid → хук с прод-параметрами +
build-once retag `SOURCE_IMAGE`) и ставит детерминированный **finalizer-job**;
маркер `initiated` — идемпотентность. Возврат БЕЗ advance (вердикта ещё нет).
- **Фаза C (finalizer)** — новый контейнер после рестарта читает sentinel `result`
(exit-code хука), маппит `0→SUCCESS / иначе→FAILED`, пишет `14-deploy-log.md`,
вызывает `advance_stage(deploy, finished_agent="deployer")` → существующие контракты:
`SUCCESS → done`, `FAILED → откат БАГ-8 на development`.
Approve = смена статуса Plane на `Approved` (status-only verdict model; комментарии
не управляют конвейером). На старте — обязательный ручной approve (флаг `true`); полный
авто — отдельная задача (ORCH-54). Условность как ORCH-35: реально для `orchestrator`,
прочие репо — прежний синхронный ssh-деплой агентом. Контракты не меняются:
`STAGE_TRANSITIONS`, реестр QG, `check_deploy_status`/`_parse_deploy_status`, БАГ-8,
terminal-sync, merge-gate, exit-code-контракт хука. Restart-safe состояние —
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-образ свеж и провалидирован». Этой гарантии нет:
конвейер нигде не пересобирает staging-образ из провалидированного коммита → retag мог тихо
промоутнуть УСТАРЕВШИЙ образ (инцидент LESSONS_ORCH-036 п.4 — зелёный деплой молча
откатывал прод). ORCH-058 обеспечивает инвариант `INV-FRESH` **двумя слоями** (defense in
depth), только для self-hosting:
- **A — пересборка (liveness):** детерминированный QG-под-чек `check_staging_image_fresh` на
ребре `deploy-staging → deploy` ПОСЛЕ merge-gate и ДО Phase A пересобирает
`orchestrator-orchestrator-staging` из worktree валидированного коммита
(`--build-arg GIT_SHA=<sha>`, OCI-лейбл `org.opencontainers.image.revision`), пересоздаёт
8501 и прогоняет `staging_check` против свежего образа → валидируем и промоутим один
артефакт. FAIL → откат на `development` (как merge-gate). Сборки/recreate — ТОЛЬКО staging.
- **B — fail-closed guard (safety):** хук шагом 2b ПЕРЕД `docker tag` сверяет лейбл `revision`
у `SOURCE_IMAGE` с `EXPECTED_REVISION` (пробрасывает `build_deploy_command`). Несовпадение
/ пустой лейбл / пустой ожидаемый SHA / ошибка inspect → `exit 1` → FAILED (БАГ-8 откат),
прод не трогается. Делает тихий промоут устаревшего образа структурно невозможным даже при
отключённой/проигравшей гонку A.
Якорь «провалидированного коммита» — `git rev-parse HEAD` worktree ПОСЛЕ merge-gate (один
helper `validated_revision` питает и штамп A, и `EXPECTED_REVISION` B). Единый kill-switch
`image_freshness_enabled` включает A+B **как целое** (нет «B без A» = вечного fail-fast);
`image_freshness_repos` (пусто → self-hosting). `STAGE_TRANSITIONS`, exit-code хука (0/1/2),
`check_deploy_status`, БАГ-8, merge-gate, схема БД — НЕ меняются (под-гейт ребра + лейбл
образа, без миграций). Подробнее: [adr-0008](adr/adr-0008-staging-image-provenance.md),
детально — `docs/work-items/ORCH-058/06-adr/ADR-001-staging-image-provenance.md`.
### Reconciler: реконсиляция потерянных webhook (ORCH-053 — реализовано)
Конвейер продвигается только входящими webhook; потерянное событие (502 на ребилде,
нет ретраев у Plane/Gitea, неразрезолвленный `sha→branch`) → задача застревает молча
(инцидент ORCH-044). Фоновый поток `reconciler` периодически (`reconcile_interval_s`)
находит застрявшие задачи и доигрывает пропущенный переход **через те же штатные
гейты/обработчики**, что и webhook:
- **F-1 gate-side:** для задач со `stage∉{done}`, без активного job и
`age(updated_at) ≥ grace_for_stage(stage)` — read-only пред-оценка канонического QG;
зелёный → `stage_engine.advance_stage(..., finished_agent=None)`; красный →
тишина (спам нотификаций структурно невозможен). `analysis` не реконсилируется.
**Skip escalated / Blocked / Needs-Input (ORCH-060):** ДО оценки гейта F-1
пропускает (молча, без advance/нотификаций) задачи, которые ждут человека —
(1) исчерпавшие лимит developer-ретраев (`developer_retry_count(task_id) >=
MAX_DEVELOPER_RETRIES`, детерминированно, без сети — закрывает bounce-петлю
ET-013) и (2) в явном Plane-статусе **Blocked** / **Needs Input** (Вариант A —
запрос Plane API, без миграции БД; never-raise → консервативный skip). Гард
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`
(подавленные повторные нотификации).
Реализация: `src/reconciler.py` (daemon-поток по образцу `queue_worker`), стартует в
`main.lifespan` **после** `worker.start()`, останавливается в `finally` **перед**
`worker.stop()`.
Инварианты: источник истины — гейт/Plane, не событие; идемпотентность (active-job
guard + atomic-claim на создании под process-wide Lock + grace + `max_concurrency=1`);
never-raise на единицу работы; тишина при синхронности; restart-safe; kill-switch
`ORCH_RECONCILE_ENABLED` (+ `ORCH_RECONCILE_PLANE_ENABLED` гасит только F-2). Схема БД
и реестры (`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.
- Deploy / deploy-staging FAILED → откат на `development`.
- Merge-gate FAIL (конфликт rebase / красный re-test, ORCH-043) → откат на `development` + retry; `merge-lock busy` → **defer** (не откат, dev-retry не тратится).
- `get_previous_stage` использует порядок ключей `STAGE_TRANSITIONS`.
### Обогащение `task_desc` при заворотах (ORCH-046)
При откате на `development` `task_desc` (попадает в `.task-dev.md` developer-агента) несёт **дословный must-fix текст**, а не только ссылку — чтобы агент видел суть претензий сразу и не повторял ту же ошибку:
- **reviewer REQUEST_CHANGES** → дословные пункты P0/P1 из секции `## Findings` файла `12-review.md` (`extract_review_findings`);
- **tester `check_tests_passed` FAIL** → `reason` гейта + фрагмент тела `13-test-report.md` (приоритет: `## Вывод pytest` → FAIL-строки `## Результаты` → `## Итог`; `extract_test_failures`).
Ссылка на полный файл-артефакт сохраняется всегда («Полный контекст»). Парсеры `src/review_parse.py` — defensive (never-raise); при отсутствующем/битом артефакте `task_desc` graceful-фоллбэк на прежнюю ссылку-строку, последовательность отката и retry-счётчик не меняются (ADR `docs/work-items/ORCH-046/06-adr/ADR-001-embed-findings-in-task-desc.md`).
### Plane Sync: единый status-коммент агентов (ORCH-016)
Все агенты (analyst / architect / developer / reviewer / tester / deployer) пишут финальный коммент через **один хелпер** `usage.build_status_comment(...)` (ADR `docs/work-items/ORCH-016/06-adr/ADR-001-unified-status-comment.md`). Формат HTML, разделители `<br>`:
```
{ICON} {RoleName} — {описание стадии}
[Verdict|Status: VALUE] # reviewer/tester/deployer, из YAML-frontmatter артефакта
[Длительность: 4m 12s] # явный duration_s от launcher, либо fallback из agent_runs
<b>Документы:</b><ul><li><a href="…">label</a></li>…</ul>
[<sub>8.5M in / 45.8k out · $7.29</sub>] # тех-хвост usage; опускается при нулях
```
- **Длительность** считается launcher'ом (`_monitor_agent`) и пробрасывается в `_post_usage_comments`; для analyst (коммент строится в `stage_engine`) используется DB-фоллбэк `usage.get_agent_duration(task_id, agent)`.
- **Vердикт-парсер** — `src/frontmatter.read_frontmatter_value(...)` (defensive, никогда не raise). Машинные ключи: reviewer → `verdict:` (12-review.md); **testing-гейт `check_tests_passed` (13-test-report.md) → любое из трёх равноправных: `result:` (канон промпта тестера), `verdict:`, `status:`** (ORCH-047, ADR-001); deployer → `deploy_status:` (14-deploy-log.md), `staging_status:` (15-staging-log.md). Negative-токен в любом поле авторитетен (перебивает positive).
- Формат коммента **не** меняет реестр гейтов и стадий; коммент — отображение, не управление.
## База данных (SQLite)
- `events` — входящие вебхуки (дедуп)
- `tasks` — задачи и их стадии
- `agent_runs` — запуски агентов (run_id, usage, cost)
- `jobs` — очередь задач (ORCH-1); колонка `pid` (ORCH-065) — pid агентского процесса для liveness-детекции зомби job-reaper'ом
## Изоляция (git worktree, ORCH-2)
Каждая задача исполняется в отдельном git worktree, ветки не пересекаются. Репозитории проектов разделены под `/repos/<project>`.
## API
| Method | Path | Описание |
|--------|------|----------|
| 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 |
| POST | `/webhook/plane` | Plane webhook |
| POST | `/webhook/gitea` | Gitea webhook (push, PR, CI status) |
## Деплой и эксплуатация
Топология, контейнеры, порты, env-карта, self-hosting риски — [docs/operations/INFRA.md](../operations/INFRA.md). Деплой-хук — [DEPLOY_HOOK.md](../operations/DEPLOY_HOOK.md). Staging — [STAGING.md](../operations/STAGING.md).
## ADR
Сквозные архитектурные решения — [adr/](adr/). Per-work-item решения — `docs/work-items/<id>/06-adr/`.
## Детали реализации
Схема БД, потоки данных, 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`).*