# ADR-001: Исполняемый самодеплой — стадия `deploy` дёргает хост-хук (Вариант B) Work Item: ORCH-036 Stage: architecture Автор: architect Дата: 2026-06-06 ## Статус Accepted ## Контекст Стадия `deploy` сейчас «бумажная»: deployer-агент (LLM) пишет в `14-deploy-log.md` `deploy_status: SUCCESS|FAILED`, а гейт `check_deploy_status` (`src/qg/checks.py:464`) парсит этот вердикт и двигает `deploy → done`. Реального docker-деплоя нет (прод катается руками). BRD ORCH-36 требует превратить стадию в РЕАЛЬНЫЙ самодеплой с обязательным ручным approve, build-once и авто-rollback (BR-1…BR-10). Три твёрдых ограничения, разведанных в коде, определяют дизайн: 1. **Self-restart (BR-2).** Прод-контейнер `orchestrator` (8500) — ОДИН на все проекты, и в нём же исполняется deployer. `docker compose up -d orchestrator` из контейнера убьёт процесс агента/воркера на середине. Реальный рестарт обязан делать ВНЕШНИЙ процесс на хосте, переживающий гибель контейнера. 2. **Status-only verdict model.** Комментарии Plane НЕ управляют конвейером — механизм `:approved:`/`:rejected:` был удалён (`src/webhooks/plane.py:544`, bug-3 «echo self-hit»). Единственный человеческий гейт — **смена статуса Plane на `Approved`** (`handle_verdict` → `_try_advance_stage` → `advance_stage`). 3. **Гонка чтения гейта.** Так как реальный рестарт асинхронный и убивает контейнер, `check_deploy_status` нельзя выполнять на выходе агента — вердикта ещё нет; его преждевременное чтение → ложный FAILED → ложный откат. Контракты, которые НЕ меняются (BR-9, AC-10): `STAGE_TRANSITIONS`, `check_deploy_status` / `_parse_deploy_status` (frontmatter only), откат БАГ-8 (`deploy → development`), terminal-sync `deploy → done`, merge-gate (ORCH-43), exit-code-контракт хука (0/1/2). ## Решение Деплой стадии `deploy` для self-hosting (`orchestrator`) разбивается на **три фазы**, оркеструемые детерминированным кодом (без LLM в критическом пути self-restart). Для НЕ-self репо (enduro-trails и пр.) поведение НЕ меняется — прежний синхронный ssh-деплой агентом. ### Условность по репо Вся новая логика гейтится `is_self_hosting_repo(repo)` (как ORCH-35). Не-self репо идут существующим путём: deployer-агент на стадии `deploy` делает ssh-деплой синхронно, пишет `14-deploy-log.md`, гейт срабатывает на выходе агента. ### Фаза A — запрос approve (вход в `deploy`) В `advance_stage` на ребре `deploy-staging → deploy` (ПОСЛЕ зелёного `check_staging_status` и merge-gate ORCH-43), для self-hosting + `deploy_require_ manual_approve=true`: - **НЕ** ставить в очередь прод-deployer (перехватить штатный `enqueue_job(get_agent_for_stage("deploy-staging"))`); - выставить issue в approval-pending статус (паттерн `set_issue_in_review`), написать Plane-коммент «approve для прод-деплоя» + Telegram (BR-5); - записать restart-safe маркер `approve-requested` (sentinel-файл, см. ниже). Задача остаётся на стадии `deploy` и ждёт человека. `STAGE_TRANSITIONS` не меняется. При `deploy_require_manual_approve=false` (вне объёма, флаг НЕ выключается в ORCH-36 — AC-12) Фаза A сразу переходит к Фазе B без человеческого гейта. Структурная ветка закладывается, но дефолт `true`. ### Фаза B — инициация деплоя (смена статуса Plane → Approved) Человек ставит issue в `Approved`. `handle_verdict(approved=True)` → `_try_advance_stage` → `advance_stage(current_stage="deploy", finished_agent=None)`. Новая ветка-перехват в `advance_stage`: - условие: `current_stage=="deploy"` И `finished_agent is None` (человеческий путь) И self-hosting И approve-флаг И маркер `initiated` ОТСУТСТВУЕТ; - действие: запустить **внешний detached host-процесс** (см. ниже) и поставить в очередь детерминированный **finalizer-job** с задержкой; записать маркер `initiated` (идемпотентность: повторный Approved не запускает деплой дважды); Plane-коммент «прод-деплой стартовал» + Telegram (BR-5); - **вернуться БЕЗ advance** (НЕ запускать `check_deploy_status` — вердикта ещё нет). Дискриминатор `finished_agent` разводит Фазу B (человек, `None`) и Фазу C (finalizer, `"deployer"`), поэтому повторное использование `advance_stage` безопасно. ### Фаза C — фиксация вердикта (детерминированный finalizer) Finalizer-job (claim'ится воркером уже в НОВОМ контейнере после рестарта): - читает sentinel `result` (exit-code хука, записан host-процессом); - если `result` ещё нет и бюджет попыток не исчерпан → **defer** (повторный finalizer-job с `available_at_delay_s`, как merge-gate defer); бюджет считается из `jobs` (`LIKE '%deploy-finalize%'`, restart-safe); - если `result` есть → **маппинг exit-code → deploy_status** (детерминированный, unit-тестируемый): `0 → SUCCESS`, `1|2|иное → FAILED`; записать `14-deploy-log.md` (frontmatter `deploy_status:`), смержить в `main` (паттерн лога), затем вызвать `advance_stage(current_stage="deploy", finished_agent="deployer")`; - далее срабатывают СУЩЕСТВУЮЩИЕ контракты: `SUCCESS` → terminal-sync `deploy → done` + release merge-lease; `FAILED` → откат БАГ-8 `deploy → development` + `set_issue_blocked` + Plane/Telegram (BR-3, AC-4). `_parse_deploy_status` НЕ меняется. ### Механизм detached-запуска: ssh + setsid Выбор: **ssh на хост (`slin@DEPLOY_SSH_HOST`) с setsid-detached исполнением** хука. Обоснование: ssh-ключи уже смонтированы (INFRA P-2), не-self репо уже деплоятся по ssh (единый путь), хук живёт на хосте и под `slin` имеет полный доступ к docker вне контейнера → переживает рестарт 8500 (BR-2). `setsid`/`nohup` + redirect отвязывает удалённый процесс от ssh-канала, чтобы он пережил гибель ssh-клиента при рестарте контейнера. Отвергнуто: вызов через docker.sock изнутри контейнера = ровно мина «убей себя на середине вызова». Эскиз (точная сборка — за разработчиком): ``` ssh -o StrictHostKeyChecking=no slin@$DEPLOY_SSH_HOST \ "setsid bash -c 'cd /home/slin/repos/orchestrator && \ SOURCE_IMAGE=orchestrator-orchestrator-staging \ TARGET_SERVICE=orchestrator TARGET_PORT=8500 \ TARGET_IMAGE=orchestrator-orchestrator COMPOSE_PROFILE= \ PREV_IMAGE_FILE=.deploy-prev-image-prod \ bash scripts/orchestrator-deploy-hook.sh --deploy; \ echo \$? > ' >> 2>&1 result`), а НЕ хук — контракт хука нетронут. ### Build-once (BR-6, AC-7) Прод обязан подняться на ОБРАЗЕ, прошедшем staging (а не на пересборке). Решение: расширить хук **опциональным** `SOURCE_IMAGE` (обратно совместимо: не задан → текущее поведение). При заданном `SOURCE_IMAGE` хук ПЕРЕД `up -d --no-build` делает `docker tag $SOURCE_IMAGE $TARGET_IMAGE`. Для прод-self: `SOURCE_IMAGE=orchestrator-orchestrator-staging` → `TARGET_IMAGE=orchestrator-orchestrator`. Это единственное допустимое изменение хука; exit-code-контракт и дефолтное staging-поведение не меняются. `git pull` хука обновляет рабочее дерево хоста для будущих сборок, но РАЗВЁРНУТЫЙ артефакт = перетегированный staging-образ. ### Restart-safe состояние: sentinel-файлы (без миграции БД) По образцу merge-lease (`/.merge-lease-.json`) состояние деплоя хранится в файлах под `/.deploy-state-//` (вне git, видны и хосту, и контейнеру через mount `/home/slin/repos ↔ /repos`): - `approve-requested` — Фаза A выполнена; - `initiated` — Фаза B запущена (idempotency-guard); - `result` — exit-code хука (пишет host-обёртка). Бюджет finalize-defer считается из `jobs` (restart-safe), новых таблиц/колонок НЕТ (TRZ §4). ## Последствия ### Плюсы - `deploy_status: SUCCESS` становится ДОКАЗАННЫМ (реальный health-ok хука), не декларацией LLM (BR-1). - Self-restart безопасен: рестарт 8500 делает внешний host-процесс; орк себя не убивает (BR-2). Вердикт фиксирует НОВЫЙ контейнер после рестарта. - Критический путь self-restart **детерминирован** (без LLM) — главный выигрыш по безопасности self-hosting; зеркалит детерминизм merge-gate ORCH-43. - Approve вписан в существующую status-only модель — restart-safe, аудируемо в Plane, идемпотентно (маркер `initiated`). - Гонка чтения гейта закрыта: гейт читает РЕАЛЬНЫЙ итог через finalizer-defer. - Build-once гарантирует «что тестировали — то в проде». - Нетронуты: `STAGE_TRANSITIONS`, реестр QG, `_parse_deploy_status`, БАГ-8, terminal-sync, merge-gate, контракт хука (exit-code). ### Минусы / ограничения - Вводится **новый детерминированный job-handler** в очереди (reserved-agent `deploy-finalizer`, не-LLM) — расширение dispatch воркера/лаунчера. Контейнированное, но это новая под-компонента → задача помечается `arch:major-change`. - Перехваты в `advance_stage` усложняют стадию `deploy` (три ветки по `finished_agent`/маркерам). Требуется аккуратное покрытие тестами (TC-04…TC-09). - Build-once зависит от того, что deploy-staging оставил валидный образ `orchestrator-orchestrator-staging`; при rebase merge-gate возможен дрейф образ↔main (см. 10-tech-risks R-3). - Approve = смена статуса Plane на `Approved`; человек должен понимать, что на стадии `deploy` `Approved` означает «деплой в прод» (документируется в deployer.md и INFRA.md). ### Что обязан сделать developer 1. `src/config.py`: `deploy_require_manual_approve: bool = True` + прод-параметры хука/ssh + `deploy_finalize_delay_s` / `deploy_finalize_max_attempts`. 2. `src/stage_engine.py`: перехваты Фазы A/B + ветка finalizer (Фаза C через `advance_stage(..., finished_agent="deployer")`). 3. Очередь: reserved-agent `deploy-finalizer` (детерминированный handler: read-result | defer | map+write+advance). Маппинг exit→status — отдельная чистая функция (unit TC-01/02/03). 4. `scripts/orchestrator-deploy-hook.sh`: опциональный `SOURCE_IMAGE` retag (обратно совместимо) + прод `PREV_IMAGE_FILE`. 5. Уведомления (Plane+Telegram) на initiate/success/rollback (BR-5). 6. Документация: `deployer.md`, `INFRA.md`, `DEPLOY_HOOK.md`, `CHANGELOG.md`. 7. Отладка — только на staging-цели хука; прод 8500 в разработке не трогать. ## Связанные решения - Глобальный ADR: `docs/architecture/adr/adr-0007-executable-self-deploy.md`. - ORCH-35 staging-gate (`adr-0003`), ORCH-43 merge-gate (`adr-0006`), ORCH-21 auto-rollback, ORCH-34 хук, ORCH-40 run-as-host-uid (`adr-0005`).