--- 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//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) как доказанный паттерн перехвата + маппинга.