---
work_item: ORCH-123
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-16
model_used: claude-opus-4-8
---

# ADR-001: Стратегия исполнения staging-раннера — host-side ssh + классификация environment-дефекта

Work Item: **ORCH-123** — staging-runner (ORCH-115) исполняет `docker exec` **изнутри**
прод-контейнера, где нет docker CLI → постоянный environment-дефект ложно маршрутизируется как
код-фейл (откат `deploy-staging → development`). Стадия: **architecture** (задача-`Bug`,
эскалирована `escalate: full-cycle` — `01-brd.md`, D5 ORCH-019).
Сквозная регистрация: **`docs/architecture/adr/adr-0049-host-side-docker-execution-boundary.md`**
(решение кросс-каттинговое — кодифицирует инвариант «docker-операции исполняются host-side»,
охватывающий ORCH-036/058/115/123/101, и амендит execution-strategy-решение adr-0048).

## Статус
Proposed

## Контекст

**Установленный факт (сверено по коду и инфраструктуре, BRD §1):** ORCH-115
(`src/staging_runner.py:154`, `build_staging_command`) исполняет staging-сюиту командой
`["docker", "exec", "orchestrator-staging", "python3", ".../staging_check.py", …]` **изнутри
прод-контейнера `orchestrator`** через `proc_group.run_in_process_group` → `subprocess.Popen`.
Прод-контейнер docker CLI **не содержит**:

- `Dockerfile:11` ставит `openssh-client git curl ca-certificates` (+ pinned gitleaks) — бинаря
  `docker` нет; `python:3.12-slim` его тоже не несёт → `Popen(["docker", …])` падает
  `FileNotFoundError: [Errno 2] No such file or directory: docker`.
- `docker-compose.yml:40` монтирует `/var/run/docker.sock` (rw) в сервис `orchestrator` и
  добавляет `group_add 999` (ORCH-040 «МИНА 1»). **Сокет в контейнере есть, а CLI-клиента,
  который бы им воспользовался, — нет.** Проблема не в доступе к Docker, а в **отсутствии
  исполняемого клиента** в образе.

Цепочка отказа в проде (job ORCH-116): `Popen` → `OSError` → `proc_group` деградирует в
`ProcResult(returncode=None, timed_out=False)` → в `run_staging_gate` (`:528`)
`suite_ran = (returncode is not None) and (not timed_out)` = **False** → ветка tool-error →
`_handle_tool_error` (`:415`) → инфра-DEFER ×2 → бюджет исчерпан →
`write_staging_log(..., "FAILED", tool_error=True)` + `_advance` → **существующий откат
`deploy-staging → development`** (как код-фейл задачи). Так до фикса **любая** self-hosting задача,
доходящая до `deploy-staging`, обречена на ложный откат.

**Корневая классификационная ошибка (BRD §1):** двухуровневый исход ORCH-115 (D5) различает только
«сюита исполнилась» vs «сюита не исполнилась (транзиентная инфра → DEFER → fail-closed FAILED)».
Отсутствие docker CLI — **детерминированный, постоянный environment-дефект**, а не транзиентная
икота: ретраи бессмысленны (каждая попытка падает идентично), а терминальный
fail-closed-FAILED-откат винит код/разработчика за дефект окружения раннера.

**Установленный паттерн, от которого ORCH-115 отклонился (сверено по коду):** прод-деплой ORCH-036
(`src/self_deploy.py:220`, `build_deploy_command`) и image-freshness ORCH-058
(`src/image_freshness.py`) **уже** исполняют все docker-операции **host-side** через
`ssh + setsid`/`ssh` на `deploy_ssh_host=127.0.0.1` (ssh-ключ смонтирован, `openssh-client` в
образе). `image_freshness.image_revision` (`:185`) прямо документирует инвариант:
> *«`docker` lives on the HOST (the container ships only `openssh-client git`)»*
и при `ssh_target` обёртывает `docker image inspect` в ssh. Хост-хук
`scripts/orchestrator-deploy-hook.sh --build-staging` (`:197`) уже делает host-side
`docker exec "$STAGING_CONTAINER" python3 staging_check.py` — на хосте, где docker CLI есть.

**Вывод:** ORCH-115 при замене LLM-деплойера детерминированным раннером **отклонился** от уже
установленного инварианта «docker-операции — host-side, не изнутри app-контейнера». Дефект — в
**стратегии исполнения** раннера, **не** в гейте `check_staging_status` и **не** в контракте
`15-staging-log.md` (BRD §«Вне объёма»; NFR-1).

> 🔎 **Проверка точки BRD (как сюита исполнялась до ORCH-115 LLM-деплойером):** до ORCH-115
> staging-сюиту исполнял LLM-агент `deployer` (`.openclaw/agents/deployer.md` шаг 1), которому
> формулировка шага позволяла исполнить шаги на хосте/в нужном контексте; ORCH-115 жёстко зашил
> `docker exec` именно **изнутри** прод-контейнера через `proc_group`. То есть ORCH-115 сделал
> ранее-гибкий путь жёстко-неработоспособным в проде. На выбор стратегии фикса это не влияет
> (host-side восстанавливает рабочий инвариант независимо от истории).

## Решение

Восстановить **host-side стратегию исполнения** staging-сюиты через уже доверенный ssh-канал
(ORCH-036/058) и ввести **трёхстороннюю классификацию исхода**, чтобы постоянный
environment-дефект и транзиентная инфра **никогда** не оканчивались код-фейл-откатом, а только
реально исполнившаяся упавшая сюита — откатывала (анти-over-tolerance). Гейт / контракт артефакта /
`STAGE_TRANSITIONS` / схема БД — **байт-в-байт неизменны** (замена *стратегии исполнения продюсера*,
не гейта). Аддитивно, под флагами, never-raise, скоуп self-hosting.

### D1 — Стратегия исполнения: host-side ssh, обёртывающий `docker exec` (BR-1 / FR-1 / AC-1, AC-2)

`staging_runner.build_staging_command()` теперь строит **ssh-обёрнутую** команду (зеркало
`self_deploy.build_deploy_command` / `image_freshness.image_revision(ssh_target=…)`):

```
ssh -o StrictHostKeyChecking=no <deploy_ssh_user>@<deploy_ssh_host> \
    'docker exec <STAGING_SERVICE> python3 <repos_dir>/<SELF_HOSTING_REPO>/scripts/staging_check.py \
       --base-url http://localhost:<staging_port> --mode stub'
```

- Внутренняя команда (`docker exec … staging_check.py … --mode stub`) — **та же**, что прежде
  (контракт сюиты и exit-кода не меняется, BRD §«Вне объёма»); собирается из config (ORCH-101, без
  host-хардкодов), `shlex.quote`-ится в inner-строку ssh.
- Канал — существующий: `deploy_ssh_user`/`deploy_ssh_host` (compose:
  `ORCH_DEPLOY_SSH_HOST=127.0.0.1`, `ORCH_DEPLOY_SSH_USER=slin`); ssh-ключ смонтирован
  (`${ORCH_HOST_SSH_DIR}:…/.ssh:ro`), `openssh-client` в образе (`Dockerfile:11`). **Новых
  секретов/привилегий не вводится** (NFR-3).
- Запуск по-прежнему через `proc_group.run_in_process_group` (bounded local timeout + tree-kill
  локального ssh-клиента, ORCH-110). **Ограничение (документируется, D-Последствия):** tree-kill
  убивает локальный ssh-клиент, но не гарантирует убийство удалённого `docker exec`-дерева на
  хосте; backstop — bounded таймаут самой `staging_check.py` внутри сюиты. Это **то же** допущение,
  что у уже принятого `image_freshness.rebuild_staging_image` (синхронный ssh без remote tree-kill).
- **Self-hosting safety (NFR-2 / AC-8):** argv содержит **только** `docker exec <staging-service>
  python3 staging_check.py … --mode stub` — **никаких** литералов рестарта прод-8500,
  `docker compose up orchestrator`/`--build` прода, force-push, записи в `main`, правки `.env`.
  Структурный анти-литерал-тест (ORCH-115 TC-12, расширяется TC-08 ORCH-123) держит инвариант на
  ssh-обёрнутой команде.

### D2 — Почему host-side ssh, а НЕ socket/CLI-в-контейнере — security-review (NFR-3 / AC-9) — **ключевое решение безопасности**

BRD/TRZ перечисляют четыре кандидата (host-side ssh / Docker SDK поверх `docker.sock` / docker CLI
в образе / выделенный hook-режим). Любой вариант с **прямым использованием `docker.sock`/CLI
изнутри контейнера** = **root-эквивалент на хосте** и требует явного security-обоснования.

**Разбор поверхности атаки:**
- `/var/run/docker.sock` смонтирован rw + `group_add 999` **уже** (ORCH-040, «МИНА 1»). Это
  **дремлющая** возможность: контейнер может *достучаться* до сокета, но **не имеет клиента**, чтобы
  им воспользоваться. Добавление docker CLI в образ **или** Docker SDK (`pip install docker`)
  превратило бы дремлющую возможность в **активный root-эквивалентный путь исполнения**, доступный
  не только worker-коду, но и **любому subprocess'у в том же контейнере — включая LLM-агентов**,
  которых оркестратор спавнит (`launcher._spawn`). Это **расширение поверхности атаки** (компрометация
  агента/зависимости → полный контроль docker → root на хосте, обслуживающем ВСЕ проекты).
- Host-side ssh держит docker-операции **на хосте** под **существующим** доверенным key-gated
  каналом (ORCH-036/058), **не наделяя** in-container код docker-возможностями. Это **строго
  безопаснее** и согласуется с задокументированным инвариантом `image_freshness`.

**Решение:** выбран **host-side ssh** (D1). Docker CLI/SDK в контейнер **не добавляется**;
использование `docker.sock` **не расширяется**. ORCH-123 поверхность ORCH-040 (смонтированный сокет)
**не трогает и не расширяет** (правка compose вне объёма — BRD §«Вне объёма», happy-path
прод-деплоя/ORCH-040 не трогаем). Если будущая задача потребует socket/CLI-в-контейнере — это
**отдельный** явный security-review; этот ADR его не выдаёт (AC-9 удовлетворён выбором ssh-варианта,
переиспользующего доверенный ORCH-036 механизм без расширения привилегий).

### D3 — Трёхсторонняя классификация исхода (FR-2 / FR-3 / AC-3, AC-4, AC-6) — **ключевое решение**

Чистая функция `classify_staging_outcome(result, ssh_configured) -> str` (зеркало
`merge_gate.classify_retest_failure`, ORCH-110 D2; pure, never-raise) различает **три** класса:

| Класс | Детерминированный сигнал | Семантика |
|-------|--------------------------|-----------|
| `permanent-env` | `returncode is None and not timed_out` (spawn-error локального бинаря) **ИЛИ** ssh-target не сконфигурирован **ИЛИ** `returncode in (126, 127)` **ИЛИ** stderr содержит маркеры `command not found` / `executable file not found` / `No such container` / `is not running` / `Cannot connect to the Docker daemon` / `Is the docker daemon running` | Постоянный дефект окружения (нет docker/ssh, контейнер не поднят, демон недоступен). Ретрай бессмыслен. |
| `transient-infra` | `timed_out` **ИЛИ** `returncode == 255` (ssh transport/connection) | Транзиентная инфра (сеть/handshake/стенд на миг недоступен). Ретрай осмыслен. |
| `suite-ran` | распознанный исполнительный exit-код (любой иной int) **и НЕТ** env-маркера в stderr | Сюита **реально исполнилась** — доверяем коду (`0→SUCCESS`, `≠0→FAILED`). |

- **Дизамбигуация «сюита exit=1 vs `docker exec` No such container exit=1»** — по **скану stderr на
  env-маркеры** (а не по голому exit-коду): env-маркер → `permanent-env`; нет маркера → `suite-ran`
  (`staging_check.py` пишет PASS/FAIL в stdout, её коды — 0/≠0 без 126/127/255). Маркер-сет
  финализирует разработчик; набор выше — стартовый, по образцу scope-guard `merge_gate`.
- **Анти-over-tolerance (BR-3 / AC-4, зеркало ORCH-110 BR-6):** распознанный `suite-ran` exit-код
  **никогда** не реклассифицируется в инфру → реальный фейл сюиты (`exit≠0`) идёт прежним путём.
- **Fail-safe при неоднозначности:** неизвестный сигнал по умолчанию → `transient-infra` (DEFER), а
  **не** `suite-ran` — mis-route environment→код-фейл есть именно тот дефект, что чиним; но
  распознанный suite-exit без env-маркера всегда `suite-ran` (BR-3 не ослаблен).

### D4 — Маршрутизация исходов (BR-2 / BR-3 / FR-2 / FR-3 / AC-3, AC-4) — **инвариант: инфра ≠ код-фейл**

| Класс | Действие |
|-------|----------|
| `suite-ran` | **Без изменений** (ORCH-115): `write_staging_log` + `_advance` → `SUCCESS`→под-гейты+Phase A; `FAILED`→**прежний** откат `deploy-staging → development` + developer-retry (`stage_engine.py:932`). BR-3 байт-в-байт. |
| `permanent-env` | **Немедленный отличимый infra-исход:** DEFER-цикл **пропускается** (ретрай постоянного дефекта бессмыслен — FR-3); записывается структурный `permanent-env`-лог + операторский алерт («инфра/окружение, **НЕ дефект кода**», кликабельный номер); **НЕ** `_advance`, **НЕ** откат, **НЕ** developer-retry. Задача **остаётся на `deploy-staging`** (infra-HOLD). Счётчик `permanent_env`++. |
| `transient-infra` | **Существующий** bounded DEFER (re-queue `deployer`-джоба + delay + restart-safe маркер `_INFRA_RETRY_MARKER`, cap `staging_runner_infra_max_retries`). |

**Изменение терминала исчерпания DEFER (супершид ORCH-115 ADR D5):** при исчерпании бюджета DEFER
исход — **infra-HOLD + алерт** (как `permanent-env`), а **НЕ** прежний fail-closed
`write_staging_log("FAILED") + _advance` (= откат). Прежний терминал нарушал BR-2 (транзиентная инфра,
которая не прояснилась, всё равно ложно откатывалась как код-фейл). Новый терминал выравнивает
staging-раннер с моделью `merge_gate` infra-tolerance (ORCH-110 D3: исчерпание инфра → алерт «НЕ
дефект кода», задача **не** уходит в `development`).

**Корневой инвариант (нормативно):** «сюита **не исполнилась**» (environment ИЛИ транзиентная инфра)
**никогда** не оканчивается код-фейл-откатом `deploy-staging → development` и **никогда** не жжёт
developer-retry. Откат — **только** для реально исполнившейся сюиты с `exit≠0` (`suite-ran`/`FAILED`).
Это закрывает RCA-класс ORCH-110/111…117 на staging-ребре.

**Re-drive / отсутствие вечного залипания (NFR-5 / AC-11):** infra-HOLD оставляет задачу на
`deploy-staging` в восстановимом состоянии (worker не падает, тугого ретрай-цикла нет, исход виден в
алерте + `GET /queue` + структурном логе). После починки окружения оператором задача переоткатывается
**существующим** механизмом (оператор / reconciler ORCH-053), без нового движка. **Граница (риск
R-2):** reconciler/reaper **должен** трактовать infra-held `deploy-staging`-задачу как удержанную
(пере-выдать `deployer`-джоб), **не** откатывать её в `development` — иначе реинтродукция дефекта;
developer верифицирует (`10-tech-risks.md`).

### D5 — Prod-like preflight (BR-4 / FR-4 / AC-5)

`staging_runner.preflight() -> tuple[bool, str]` (bounded, never-raise, self-hosting-скоуп —
`applies(repo)` первым):
- Пробит **канал исполнения** host-side коротким bounded ssh-зондом:
  `ssh <target> 'command -v docker >/dev/null && docker inspect -f "{{.State.Running}}"
  <STAGING_SERVICE>'` → распознаёт «нет docker на хосте» / «staging-контейнер не поднят» / «ssh
  недоступен» **до** того, как реальная задача дойдёт до ложного исхода.
- Вызывается на **старте сервиса** в `main.lifespan` (best-effort, после
  `requeue_running_jobs`/`recover_on_startup`, **никогда не блокирует старт**) → условие инцидента
  всплывает на рестарте/деплое самого орка, а не постфактум на реальной задаче. Эмитит структурный
  лог + Telegram-алерт при неудаче + поле в `snapshot()`.
- **Чисто наблюдательный:** preflight **не** гейтит/блокирует конвейер, **не** трогает стадии/QG,
  never-raise (FR-4). Регресс-тест TC-01 моделирует «нет docker CLI в контейнере» (монки-патч
  argv/spawn) — **красный до фикса** (in-container `docker exec` → spawn-error → tool-error → ложный
  откат), **зелёный после** (host-side стратегия + `permanent-env`-классификация → нет код-фейл-отката).

### D6 — Условность / kill-switch / обратимость (FR-5 / NFR-4 / NFR-6 / AC-10)

`config.py` (паттерн `staging_runner_*` / ORCH-101 «дефолт = боевое»):
- **Новый:** `staging_runner_exec_host_side: bool = True` (env `ORCH_STAGING_RUNNER_EXEC_HOST_SIDE`).
  `True` (дефолт) → host-side ssh-стратегия (D1, фикс). `False` → прежний in-container `docker exec`
  (валиден **только** там, где docker CLI запечён в образ; в текущем проде — нет). Добавить в
  `.env.example`.
- **Переиспользуются:** `staging_runner_enabled` (kill-switch; `False` → прежний LLM-`deployer` через
  `_spawn` **байт-в-байт**, ORCH-115 fail-safe), `staging_runner_repos` (CSV; пусто → self-hosting
  only через `is_self_hosting_repo`), `staging_runner_timeout_s`, `staging_runner_infra_max_retries`/
  `_retry_delay_s` (DEFER-бюджет транзиентной инфры), `deploy_ssh_user`/`deploy_ssh_host`/
  `deploy_host_repo_path` (ssh-канал).
- `applies(repo)` (локально, без сети) — **первым** в `should_intercept`: выключенный флаг = нулевой
  оверхед, на `deploy-staging` штатно `_spawn`. **Откат:** `ORCH_STAGING_RUNNER_ENABLED=false`
  (→ LLM-путь) **или** `ORCH_STAGING_RUNNER_EXEC_HOST_SIDE=false` (→ прежний in-container call).
  Для прочих репо `applies==False` → no-op (нулевая регрессия enduro). NFR-6.

### D7 — Сохранность контракта (NFR-1 / AC-7) — границы O1

**Байт-в-байт неизменны:** `STAGE_TRANSITIONS` (`src/stages.py`), реестр и имена `QG_CHECKS`/
`check_staging_status`/`_parse_staging_status`, machine-verdict-ключи (`staging_status:`/
`deploy_status:`/…), **схема БД** (restart-safe счётчик DEFER — маркер в `task_content`, без
миграции). `15-staging-log.md` сохраняет `staging_status:`-frontmatter (UPPERCASE) + 52c-схему
(`author_agent: staging-runner` / `model_used: n/a`); вердикт читается ТОЛЬКО из frontmatter через
`src/frontmatter.py`. Это замена *стратегии исполнения продюсера*, не гейта/стадии (зеркало
инварианта ORCH-115 NFR-1). transition-lease ORCH-114 берётся **внутри** `advance_stage` — раннер
его не трогает (граница O1, как ORCH-115 D7).

### D8 — Наблюдаемость (BR-6 / AC-12-набл.)

`_STAGING_RUNNER_COUNTERS` расширяется ключом `permanent_env` (+ существующие
`runs`/`success`/`failed`/`tool_error`/`deferred`). `snapshot()` / read-only блок `staging_runner` в
`GET /queue` различает **три** не-успешных класса: `code-fail` (исполнившаяся сюита `FAILED`) vs
`transient-infra` (`deferred`) vs `permanent-env` (новый). Один структурный лог-вердикт на прогон
(`work_item`/`repo`/`exit_code`/`status`/`duration_s`/`outcome` ∈ {`code-pass`,`code-fail`,
`transient-infra`,`permanent-env`}) + Telegram-алерт на `permanent-env`/infra-исчерпание с
кликабельным номером и явной формулировкой «инфра/окружение, НЕ дефект кода».

### D9 — Норматив сопровождения docs (BR-5 / FR-6 / AC-12) — спека для development

Документация границы исполнения и витрина **коуплены к состоянию кода** (описывают post-fix
поведение; структурный анти-дрейф TC-13 проверяет наличие разделов). По прецеденту **ORCH-115 D11 /
adr-0048** (и маршрутизации analyst'а — `02-trz.md` §2) эти правки применяет **developer атомарно с
кодом** в том же PR, **не** architecture-стадия (иначе доки/тесты описывают не-существующий ещё код).
Точная спека (developer применяет дословно):

- **`docs/operations/INFRA.md`** — в раздел `## ⚠️ Self-hosting` (или новый под-раздел «Граница
  исполнения docker-операций») зафиксировать: контейнер `orchestrator` несёт **только**
  `openssh-client git curl` (docker CLI **нет**); `/var/run/docker.sock` смонтирован, но **не
  используется изнутри** (нет клиента, сознательно — см. D2); **все** docker-операции
  (прод-деплой ORCH-036, image-freshness ORCH-058, **staging-runner ORCH-123**) исполняются
  **host-side** через ssh на `127.0.0.1` под смонтированным ключом; staging-сюита (`staging_check.py`)
  по-прежнему исполняется **внутри** `orchestrator-staging` (8501) — меняется лишь **кто инициирует**
  `docker exec` (host vs прод-контейнер).
- **`docs/architecture/README.md`** — в секции про staging-гейт/ORCH-115 (или соседней) описать
  host-side стратегию исполнения staging-раннера и трёхстороннюю классификацию (env ≠ код-фейл),
  сослаться на `adr-0049`.
- **`CLAUDE.md`** — раздел ORCH-115 дополнить фиксом ORCH-123 (host-side exec + классификация
  environment-дефекта); **`CHANGELOG.md`** — запись `fix:`; **`docs/overview/`** — если затронут
  машинно-проверяемый факт (`tests/test_system_docs.py`).
- **Анти-дрейф ORCH-115 (TC-14):** существующие `tests/test_orch115_staging_runner.py` и
  LLM-determinization-тесты остаются зелёными (фикс не ломает инварианты ORCH-115: контракт/гейт/
  один транспорт LLM `_spawn`/«ровно один first_slice» целы).

## Альтернативы

- **Docker CLI в образ (`Dockerfile`) + `docker exec` через смонтированный сокет** — отвергнуто
  (D2): активирует дремлющий root-эквивалентный путь для всего, что исполняется в контейнере (вкл.
  LLM-агентов) — расширение поверхности атаки. Требовало бы отдельного security-review; host-side ssh
  достигает цели без расширения привилегий.
- **Docker SDK (`pip install docker`) поверх `docker.sock`** — отвергнуто: та же security-проблема,
  что выше, + новая зависимость + не переиспользует доказанный канал.
- **Выделенный режим хука (`--run-staging-check`) через ssh** — отвергнуто как избыточное: прецедент
  `image_freshness.image_revision(ssh_target=…)` уже исполняет docker-команду через **прямой** ssh
  (без хука); `--build-staging` хука перегружен ребилдом образа (это работа ORCH-058, не раннера) —
  переиспользовать его нельзя, а заводить новый hook-режим тяжелее прямого ssh-wrap. Прямой
  ssh-обёртки (D1) достаточно.
- **tool-error → немедленный `FAILED`-откат на `development`** (исходный путь до ORCH-115 D5) —
  отвергнуто: это в точности дефект ORCH-123 (и анти-паттерн ORCH-110).
- **Сохранить ORCH-115 D5 терминал (исчерпание DEFER → fail-closed `FAILED` + `_advance`)** —
  отвергнуто: нарушает BR-2 (транзиентная инфра, не прояснившаяся за N попыток, всё равно ложно
  откатывается как код-фейл). Заменён на infra-HOLD + алерт (D4), как ORCH-110 D3.
- **Двухсторонняя классификация (как ORCH-115)** — отвергнуто: не отличает постоянный
  environment-дефект (ретрай бессмыслен, нужен немедленный HOLD) от транзиентной инфры (ретрай
  осмыслен). Трёхсторонняя (D3) различает (FR-3).

## Последствия

- **+** На `deploy-staging` для `orchestrator` staging-сюита **реально исполняется в проде**
  (host-side через доверенный ssh-канал); задача уровня ORCH-116 доходит до `deploy`, а не
  откатывается (BR-1 / AC-2).
- **+** **Инфра/environment ≠ код-фейл** на staging-ребре: ни environment-дефект, ни транзиентная
  инфра не дают ложного отката `→ development` и не жгут developer-retry (BR-2). Закрыт RCA-класс
  ORCH-110 на staging-ребре полностью (не только частично, как ORCH-115 D5).
- **+** Анти-over-tolerance цел: реально исполнившаяся упавшая сюита по-прежнему откатывает (BR-3).
- **+** Переиспользует существующий доверенный ssh-канал (ORCH-036/058) — **без** расширения
  привилегий, **без** docker CLI/SDK в контейнере (NFR-3); согласовано с задокументированным
  инвариантом `image_freshness`.
- **+** Полная обратимость двумя флагами (`staging_runner_enabled` → LLM;
  `staging_runner_exec_host_side` → in-container); контракт/гейт/ребро/схема БД неизменны.
- **−** Remote tree-kill ограничен локальным ssh-клиентом (удалённое `docker exec`-дерево не
  гарантированно убивается tree-kill'ом) — **то же** допущение, что у принятого
  `image_freshness.rebuild_staging_image`; backstop — bounded таймаут внутри `staging_check.py`.
- **−** Permanent-env / исчерпавшая-DEFER задача **держится** на `deploy-staging` до починки
  окружения оператором → блокирует serial-gate репо (ORCH-088) для **более поздних** задач.
  Принятый tradeoff (зеркало ORCH-110 infra-HOLD): environment-проблема, требующая оператора,
  **должна** остановить репо, а не молча ложно-откатывать; скоуп self-hosting only.
- **−** Классификация по stderr-маркерам имеет остаточную неоднозначность. Митигейшн: fail-safe
  default → `transient-infra` (не `suite-ran`); распознанный suite-exit без env-маркера всегда
  доверяется (BR-3); набор маркеров — по образцу `merge_gate` scope-guard, под покрытием TC-06.
- **Откат:** `ORCH_STAGING_RUNNER_ENABLED=false` → прежний LLM-`deployer` на `deploy-staging`
  байт-в-байт; либо `ORCH_STAGING_RUNNER_EXEC_HOST_SIDE=false` → прежний in-container `docker exec`.

## Ссылки
- BRD: `docs/work-items/ORCH-123/01-brd.md`; ТЗ: `02-trz.md`; Acceptance: `03-acceptance-criteria.md`;
  Test-plan: `04-test-plan.yaml`
- Инфра: `docs/work-items/ORCH-123/07-infra-requirements.md`; Риски: `10-tech-risks.md`
  (данные — `08` N/A: схема БД не меняется, TRZ §5)
- Сквозной ADR: `docs/architecture/adr/adr-0049-host-side-docker-execution-boundary.md`
- Амендит / опирается на: ORCH-115 (`docs/work-items/ORCH-115/06-adr/ADR-001-deterministic-staging-runner.md`,
  `adr-0048`) — стратегия исполнения D3 и терминал D5; ORCH-036 (`adr-0007-executable-self-deploy.md` /
  `self_deploy` ssh-механизм), ORCH-058 (`adr-0008-staging-image-provenance.md` / `image_freshness`
  host-side docker), ORCH-110 (`adr-0042-merge-gate-retest-infra-tolerance-and-tree-kill.md` / proc_group
  + infra-tolerance + classify), ORCH-101 (`adr-0036-replication-foundation-host-parametrization.md` /
  host-параметризация)
- Сверено по коду: `src/staging_runner.py:154/415/487/528`, `src/self_deploy.py:220/287`,
  `src/image_freshness.py:185/246`, `scripts/orchestrator-deploy-hook.sh:166/197`,
  `src/proc_group.py`, `src/merge_gate.py:229`, `src/agents/launcher.py:406/438`,
  `src/main.py:16/58/73`, `Dockerfile:11`, `docker-compose.yml` (docker.sock + ssh mounts),
  `src/config.py:301-304/455-459`
