---
work_item: ORCH-115
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-16
model_used: claude-opus-4-8
---

# 02 — ТЗ (TRZ): ORCH-115 — детерминированный staging-раннер вместо LLM-деплойера

Work Item: **ORCH-115** · Repo: **orchestrator** · Стадия: analysis

> ТЗ описывает **конкретные изменения к реализации**, выведенные из BRD и фактического кода.
> Архитектурное обоснование (точный механизм перехвата, размещение раннера, способ инициации
> `advance_stage`, лестница таймаутов) — задача архитектора (`06-adr/`). Здесь — требования и
> привязка к реальным модулям `src/`.

## 1. Сводка изменения

Заменить **LLM-агента `deployer`** на стадии `deploy-staging` (для self-hosting `orchestrator`)
**детерминированным staging-раннером**, перехватываемым в `launch_job` **до `_spawn`** (прецедент
`deploy-finalizer`/`post-deploy-monitor`, `src/agents/launcher.py:389/394`). Раннер исполняет ту же
staging-сюиту, что исполнял LLM (`docker exec orchestrator-staging python3
scripts/staging_check.py …`), маппит exit-код в `staging_status:` (`0→SUCCESS`, иначе `FAILED`),
пишет `15-staging-log.md`, best-effort мержит лог в `main`, затем инициирует **существующую** оценку
exit-гейта `check_staging_status` ровно как завершившийся LLM-deployer. Контракт артефакта, гейт,
`STAGE_TRANSITIONS`, схема БД — **неизменны**. Под kill-switch + скоуп-CSV; never-raise; fail-closed.

## 2. Задействованные модули / пути

| Путь | Действие | Назначение |
|------|----------|------------|
| `src/staging_runner.py` *(новый leaf)* | создать | Детерминированный раннер: `applies(repo)` (kill-switch + скоуп), исполнение staging-сюиты, маппинг exit-кода, запись `15-staging-log.md`, best-effort merge, снапшот для `/queue`. Leaf-чистота по образцу `self_deploy.py`/`staging_verdict.py`: импортирует только `config`/`git_worktree` (+ лениво `qg.checks.is_self_hosting_repo`), never-raise. |
| `src/agents/launcher.py` | изменить | В `launch_job` добавить перехват **до `_spawn`** (рядом с D1/D2): если джоб — `deployer` на стадии задачи `deploy-staging` и `staging_runner.applies(repo)` → исполнить раннер синхронно в воркер-треде, инициировать `advance_stage` и пометить джоб (как `_run_deploy_finalizer_job`); вернуть `None` (нет `agent_runs`-строки). |
| `src/config.py` | изменить | Добавить ключи `staging_runner_enabled: bool = True` (env `ORCH_STAGING_RUNNER_ENABLED`) и `staging_runner_repos: str = ""` (env `ORCH_STAGING_RUNNER_REPOS`; пусто → self-hosting only) + опц. `staging_runner_timeout_s` (см. FR-5). Дефолты = боевое; паттерн `coverage_gate_enabled`/`coverage_gate_repos`/`self_deploy_*`. |
| `src/stage_engine.py` | (потенциально) точечно | Если архитектор решит инициировать гейт из stage_engine, а не из launcher — добавить тонкий хелпер (вызов существующего `advance_stage(finished_agent="deployer")`). **Без** правки `STAGE_TRANSITIONS`/exit-гейтов. |
| `src/main.py` (`GET /queue`) | изменить | Read-only блок `staging_runner` (флаг/скоуп/счётчики исходов) — наблюдаемость BR-6. |
| `.openclaw/agents/deployer.md` | изменить (docs) | Отметить, что на `deploy-staging` для in-scope репо стадию ведёт детерминированный код (зеркало формулировки prod-Phase A/B/C); LLM-ветвь `deploy-staging` остаётся как fallback под выключенным флагом / для не-self репо. |
| `docs/architecture/llm-call-sites.md`, `llm-determinization-roadmap.md`, `llm-usage-policy.md` | изменить (docs) | Норматив сопровождения ORCH-118 (NFR-6): отразить реализацию A6 (deployer staging-status) — обновить инвентарь/политику/roadmap в том же PR; синхронно поправить `tests/test_llm_call_site_inventory.py` / `tests/test_llm_determinization_docs.py`. |
| `CLAUDE.md`, `CHANGELOG.md`, `docs/overview/` | изменить (docs) | Паспорт/чейнджлог/витрина — правило для агентов №2. |
| `tests/test_orch115_staging_runner.py` *(новый)* | создать | Покрытие (см. `04-test-plan.yaml`). |

> **Не трогать (NFR-1):** `src/stages.py::STAGE_TRANSITIONS`; имена/семантику `QG_CHECKS`/`check_*`/
> `_parse_*` в `src/qg/checks.py`; `src/staging_verdict.py` (переиспользуем как есть); `src/self_deploy.py`
> прод-путь; `src/transition_lease.py` (ORCH-114); `src/checkout_hygiene.py` (ORCH-112); схему БД.

## 3. Функциональные требования

### FR-1 — Детерминированный перехват на `deploy-staging` (без `_spawn`)
В `launch_job` (`src/agents/launcher.py`) **до** вызова `_spawn`, по образцу D1/D2: если
`job.agent == "deployer"` **и** стадия задачи (`tasks.stage` по `job.task_id`) == `deploy-staging`
**и** `staging_runner.applies(job.repo)` истинно → не вызывать `_spawn`, а исполнить раннер
синхронно. Контракт: возвращает `None` (нет `agent_runs`), сам ведёт `jobs`-строку
(`mark_job(done|failed|queued)`) как `_run_deploy_finalizer_job`.
- Дискриминатор «staging vs prod» — **стадия задачи**, не имя роли (роль `deployer` общая для
  `deploy-staging` и `deploy`). Для self-hosting прод-ребро не запускает `deployer` (Phase A), поэтому
  коллизии нет; гард по стадии — защита от перехвата не того джоба (R-1).
- `applies(repo)`: `staging_runner_enabled=False` → `False` (откат к LLM-пути); непустой
  `staging_runner_repos` → membership; пустой CSV → `is_self_hosting_repo(repo)`. Никакой сети,
  проверяется **первым** (нулевой оверхед при выключенном флаге). Never-raise → `False` при ошибке
  (fail-safe к прежнему LLM-пути).

### FR-2 — Исполнение staging-сюиты
Раннер исполняет ту же канонную команду, что исполнял LLM-deployer
(`.openclaw/agents/deployer.md` step 1):
`docker exec orchestrator-staging python3 /repos/orchestrator/scripts/staging_check.py
--base-url http://localhost:8501 --mode stub` (точные аргументы/таргет — из config, не хардкодить
host-специфику; ORCH-101). Захватывает exit-код (и stdout для observability/тела лога). infra-tolerance
(ORCH-061) уже **внутри** `staging_check.py` → раннер вердикт повторно не судит (BR-4).

### FR-3 — Маппинг exit-кода → `staging_status:`
`0 → "SUCCESS"`, любой ненулевой / отсутствие кода / ошибка запуска → `"FAILED"` (fail-closed,
никогда ложный green). Зеркало уже существующего `self_deploy.map_exit_code_to_status` (pure,
unit-tested) — переиспользовать общий контракт, не плодить второй маппинг.

### FR-4 — Запись и merge `15-staging-log.md`
Раннер пишет `docs/work-items/<work_item_id>/15-staging-log.md` в worktree фичеветки с frontmatter:
`staging_status: SUCCESS|FAILED` + обязательная 52c-схема (`work_item`/`stage=deploy-staging`/
`author_agent`/`status`/`created_at`/`model_used`) — зеркало `self_deploy.build_deploy_log` для
`14-deploy-log.md`. `author_agent`/`model_used` отражают **детерминированный** продюсер (например
`author_agent: staging-runner`, `model_used: n/a` или платформенный литерал — финализирует архитектор;
ключи и имя `staging_status:` не меняются). При INFRA-WAIVED-строке от `staging_check.py` — скопировать
её в тело (observability, как требовал prompt). Best-effort `git add/commit/push` лога в `main`
(зеркало `self_deploy.write_deploy_log`, тот же git-identity-паттерн ORCH-101); гейт всё равно
читает worktree → origin/main fallback (`check_staging_status` lookup order, `src/qg/checks.py:627-638`).

### FR-5 — Инициация существующего гейта после вердикта
После записи (и best-effort merge) раннер инициирует ту же оценку exit-гейта, что триггерил
завершившийся LLM-deployer: `advance_stage(task_id, current_stage="deploy-staging", repo,
work_item_id, branch, finished_agent="deployer")` (через `_try_advance_stage`-эквивалент). Это
запускает `check_staging_status` и — на SUCCESS — под-гейты security→merge→coverage→image-freshness
(ORCH-022/043/027/058) и Phase A (ORCH-036); на FAILED — существующий rollback
(`src/stage_engine.py:932`). **Никакой новой ветви маршрутизации.** Lease ORCH-114 берётся внутри
`advance_stage` как сейчас — раннер его не трогает (граница задачи).
- Таймаут раннер-subprocess — выделенный ключ `staging_runner_timeout_s` с дефолтом, согласованным
  со сквозным бюджетом ORCH-065/109/110 (NFR-4); малформ/непозитив → дефолт + WARNING (never-break).

### FR-6 — Kill-switch и скоуп (обратимость)
`staging_runner_enabled=False` → перехват не срабатывает → на `deploy-staging` запускается прежний
LLM-deployer (`_spawn`) **байт-в-байт** как до ORCH-115. `staging_runner_repos` ограничивает скоуп
(пусто → только `orchestrator`); не-self репо никогда не перехватываются (для них staging-гейт и так
N/A, `src/qg/checks.py:620`).

### FR-7 — Наблюдаемость
- Read-only блок `staging_runner` в `GET /queue`: `enabled`, `repos`, счётчики `success`/`failed`/
  `tool_error`/`runs`.
- Один структурный лог-вердикт на прогон (`work_item`/`repo`/`exit_code`/`status`/`duration_s`),
  различающий «код упал» (`FAILED` от staging-сюиты) и «инструмент недоступен» (tool-error).

## 4. Изменения API

- **`GET /queue`** — добавить read-only ключ `staging_runner` (наблюдаемость). Существующие поля
  ответа не меняются.
- Опционально (на усмотрение архитектора, по образцу `POST /coverage/baseline`): нет обязательного
  нового мутирующего эндпоинта. Откат — через env-флаг.
- Новых вебхуков нет.

## 5. Изменения схемы БД

**Нет.** Раннер использует существующие таблицы (`tasks` для стадии, `jobs` для статуса джоба) и
sentinel/worktree-механику. Никаких новых таблиц/колонок/миграций (NFR-1). Счётчики `/queue` —
in-process (паттерн `_MERGE_GATE_COUNTERS`, ORCH-110), не БД.

## 6. Требования к новым/изменённым QG checks

**Нет новых QG и нет изменений существующих.** `check_staging_status` / `_parse_staging_status` /
ключ `staging_status:` (`src/qg/checks.py:538/599`) и состав `QG_CHECKS` — **байт-в-байт неизменны**.
ORCH-115 меняет только *продюсера* `15-staging-log.md` (детерминированный код вместо LLM); гейт,
читающий артефакт, остаётся прежним. Это критический инвариант (NFR-1) — reviewer ловит любое
изменение имени/семантики гейта как finding ≥P1.

## 7. Совместимость / регресс

- **Обратная совместимость:** `staging_runner_enabled=False` → прежний LLM-deployer-путь
  байт-в-байт; не-self репо → 1:1 (N/A-pass либо LLM, в зависимости от скоупа). enduro-trails не
  затронут (NFR-5).
- **Kill-switch / область раската:** один флаг `staging_runner_enabled` + CSV `staging_runner_repos`
  (пусто → self-hosting only). Откат = `ORCH_STAGING_RUNNER_ENABLED=false`.
- **Обратимость:** полностью обратимо флагом; артефакт и гейт неизменны, так что переключение туда-сюда
  не оставляет несовместимого состояния.
- **never-raise / fail-safe (NFR-2):** ошибка раннера → `FAILED` (fail-closed) или штатный requeue,
  не «тихий advance»; сбой не клинит очередь. Self-hosting safety (BR-7): никаких рестартов 8500 /
  force-push в `main` / правок инфры.
- **Граница (О1):** код ORCH-112 (checkout hygiene) и ORCH-114 (transition lease) не модифицируется.
- **Норматив сопровождения (NFR-6):** в том же PR обновить `docs/architecture/llm-call-sites.md` /
  `llm-determinization-roadmap.md` / `llm-usage-policy.md` + соответствующие анти-дрейф тесты;
  `CLAUDE.md` / `CHANGELOG.md` / `docs/overview/`.

## 8. Phase 2 (forward-looking, вне приёмки ORCH-115)

Зафиксировано для преемственности — **не реализуется в этой задаче**, заводится отдельным follow-up:
- **Project deploy contract** для не-self репо (enduro-trails): декларативный per-repo контракт
  `deploy` / `rollback` / `healthcheck` (команды + ожидаемые коды/эндпоинты), исполняемый тем же
  детерминированным раннер-паттерном (run → map exit code → verdict → artifact → healthcheck).
- LLM остаётся допустим только как **off-control-path** debug/triage-аналитик после
  детерминированного провала (NFR-7) — не как продюсер вердикта.
- Зависимость: устойчивый Phase 1 (этот work item) как доказанный паттерн перехвата + маппинга.
