The self-hosting orchestrator looped on deploy-staging -> development because
scripts/staging_check.py exited 1 on ANY failed check, so two infra-only checks
(C9a sandbox branch / C9b analyst-job — caused by SANDBOX bot accounts not being
members of the sandbox Plane project, NOT a pipeline regress) forced
staging_status: FAILED -> rollback -> loop, burning developer retries and tokens.
Direction (б) per ADR-001: classify staging checks as REAL (all pipeline checks,
fail-closed) vs SANDBOX_INFRA (narrow allowlist {C9a, C9b}, waivable). New leaf
module src/staging_verdict.py (stdlib-only, never-raise): classify_check +
compute_staging_verdict fold per-check results into a tolerant-but-fail-closed
verdict — any REAL failure -> FAILED/exit1 (safety net holds under any flag);
only C9a/C9b failed & tolerant -> SUCCESS/exit0 with waived list; only infra &
strict -> FAILED/exit1; any internal error -> FAILED/exit1 (never a false green).
staging_check.py now auto-classifies each check (public 3-tuple _items shape kept
as an ORCH-048 b6 regression guard), exposes categorized_items(), prints
INFRA-WAIVED/VERDICT lines, and exits via the verdict; new --strict flag forces
legacy strictness per-run. Kill-switch ORCH_STAGING_INFRA_TOLERANCE_ENABLED
(default true) restores legacy strict mode globally. launcher gains
action_stage_no_changes_note so "no changes to commit" on action stages is logged
as expected, not treated as under-delivery.
Contracts unchanged: STAGE_TRANSITIONS, QG_CHECKS registry, staging_status:/
deploy_status: frontmatter, hook exit-code (0/1/2), check_staging_status; no DB
migration. Docs: README, STAGING_CHECK.md, deployer.md, .env.example, CHANGELOG.
Refs: ORCH-061
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Архитектура 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) — персистентная очередь задач (SQLitejobs), atomic claim, max_concurrency, ретраи, restart-safe. - Reconciler (
src/reconciler.py, ORCH-053 — реализовано, adr-0007) — фоновый 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 (БД-fallbacksha→branchвhandle_ci_status). Источник истины — гейт/Plane, не событие; идемпотентность (active-job guard + atomic-claim + grace); kill-switchORCH_RECONCILE_ENABLED.analysisF-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.
Конвейер и 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.
Толерантность 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(envORCH_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, детально — 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, детально — 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 retagSOURCE_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, детально —
docs/work-items/ORCH-036/06-adr/ADR-001-executable-self-deploy.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,
детально — 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(логика не дублируется). - F-3: усиление
sha→branchвhandle_ci_status(БД-fallback по единственной development-задаче repo; неоднозначность → не резолвим). - F-4 observability: при разблокировке — лог-строка
reconciler: <wi> <stage> разблокирована (потерян webhook)+ Telegram (reconcile_notify_unblock); снимок состояния вGET /queue(блокreconcile).
Реализация: 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, детально — docs/work-items/ORCH-053/06-adr/ADR-001-stuck-task-reconciler.md.
Откаты
- Reviewer REQUEST_CHANGES → откат на
development+ retry (MAX_DEVELOPER_RETRIES = 3). - Tester
check_tests_passedFAIL → откат на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_passedFAIL →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)
Изоляция (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) + последние jobs |
| POST | /webhook/plane |
Plane webhook |
| POST | /webhook/gitea |
Gitea webhook (push, PR, CI status) |
Деплой и эксплуатация
Топология, контейнеры, порты, env-карта, self-hosting риски — docs/operations/INFRA.md. Деплой-хук — DEPLOY_HOOK.md. Staging — STAGING.md.
ADR
Сквозные архитектурные решения — adr/. Per-work-item решения — docs/work-items/<id>/06-adr/.
Детали реализации
Схема БД, потоки данных, resilience-слой, детали Dockerfile — 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).