# 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 \$? > <result-sentinel>' >> <hook.log> 2>&1 </dev/null &"
```
ssh-команда возвращается сразу; remote-процесс detached. Запись sentinel `result`
делает **обёртка** (`echo $? > 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 (`<repos_dir>/.merge-lease-<repo>.json`) состояние деплоя
хранится в файлах под `<repos_dir>/.deploy-state-<repo>/<work_item_id>/` (вне 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`).
