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

# 01 — BRD (бизнес-требования): ORCH-116 — заменить LLM-тестера детерминированным test-раннером

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

## 1. Бизнес-контекст и проблема

Стадия `testing` сейчас исполняется **LLM-агентом `tester`** (`src/stages.py:17`,
`STAGE_TRANSITIONS["review"]["agent"] = "tester"` — tester запускается при входе в `testing`).
Фактическая работа агента на этой стадии в happy-path — **в основном детерминированная**
(`.openclaw/agents/tester.md`): прогнать регресс `pytest tests/ -v --tb=short` в worktree ветки,
сделать read-only smoke (`/health`, `/status`, `/queue` + наличие блока `serial_gate`),
собрать exit-код, записать `13-test-report.md` с машинным frontmatter `result: PASS|FAIL`. Гейт
`check_tests_passed` (`src/qg/checks.py:182` → `_parse_tests_verdict:226`) читает **только** ключ
`result:` из frontmatter — **не** прозу и **не** покрытие TC.

Это **avoidable LLM control path** по нормативной политике (`docs/architecture/llm-usage-policy.md`):
(i) это **C-консультация** — вердикт `result:` потребляется гейтом `check_tests_passed`, и
(ii) вердикт PASS/FAIL **деривируем** из exit-кода `pytest` + smoke (детерминированные сигналы).
Карта вызовов (`docs/architecture/llm-call-sites.md`, строка **A5**) классифицирует tester как
**`needs-hybrid-fallback`**, а roadmap (`docs/architecture/llm-determinization-roadmap.md`, машинный
блок §4) ставит его **rank 2** с `hybrid_needed = yes`, `first_slice = no`. Это **второй срез**
determinization-roadmap — после реализованного первого среза **ORCH-115** (детерминированный
`staging_runner`, deployer на `deploy-staging`).

**Гибридная природа (ключевое отличие от ORCH-115).** Tester — `needs-hybrid-fallback`, не
`replace-deterministic-now`: его **PASS/FAIL-ядро** полностью детерминируемо (exit-код pytest +
smoke), но часть работы прежнего промпта — **триаж падений**, **анализ пробелов покрытия
AC**, **сопоставление TC ↔ критерии приёмки** и **человекочитаемая диагностика** — это настоящее
суждение. ORCH-116 выносит из потока управления **только PASS/FAIL-исполнителя** (его делает
детерминированный код); опциональный LLM-аналитик допустим как **off-control-path** триаж/
диагностика после детерминированного провала, но **никогда** как первичный исполнитель вердикта
гейта (BR-8 / NFR-7).

**Боль / риск, который закрываем:**
- **Недетерминизм в потоке управления.** Решение «advance на `deploy-staging` или rollback на
  `development`» на стадии `testing` зависит от LLM-сессии (стоимость, латентность, риск
  галлюцинации/неверного вердикта), хотя сводится к exit-коду pytest + smoke.
- **Стоимость и латентность.** Каждый прогон tester'а тратит токены/время opus-агента (оценка по
  `agent_runs`: tester-строки ~60–150k токенов / 5–20 мин на прогон; точное число — `GET /metrics`)
  ради действия, которое выполняется одним прогоном pytest + несколькими read-only GET.
- **Избыточность с уже-зелёным сигналом.** Регресс уже прогоняется в CI (`check_ci_green` гейтит
  `development → review`, `src/qg/checks.py:82`), повторно в merge-gate re-test (ORCH-043/110) и в
  coverage-gate (ORCH-027). Повторный прогон pytest на стадии `testing` — подтверждение факта, а не
  суждение → естественный кандидат на детерминизацию.
- **Класс инцидентов «LLM принял решение, которое есть исполнение фиксированных команд + маппинг
  результата»** — тот же RCA-трек, что ORCH-110/111/112/113/114/117/115.

Установленные факты (не изобретать):
- Гейт `check_tests_passed`/`_parse_tests_verdict` читает **только** `result:` (плюс legacy-поля
  `verdict:`/`status:`, ORCH-047) из frontmatter `13-test-report.md`; покрытие TC / сопоставление с
  AC гейтом **не парсится** (это была нагрузка промпта, не требование гейта). Значит замена
  *продюсера* `result:` детерминированным кодом сохраняет контракт гейта байт-в-байт.
- Детерминированный прецедент замены агента **до `_spawn`** уже работает и проверен: `launch_job`
  перехватывает `deploy-finalizer` (D1, `src/agents/launcher.py:397`), `post-deploy-monitor`
  (D2, `:402`) и **staging-runner** (ORCH-115, `:405-408`) до `_spawn`; `src/staging_runner.py` —
  готовый эталон leaf-раннера (two-level outcome, never-raise, kill-switch, proc_group).
- Изоляция спавненного pytest уже решена: `src/proc_group.py::run_in_process_group` (ORCH-110)
  даёт tree-kill (`os.killpg`, каскад SIGTERM→grace→SIGKILL) — корень CPU-голодания от
  осиротевших pytest (инцидент ORCH-109/111) закрыт; раннер обязан использовать его.
- Маппинг exit-кода — тривиальная pure-функция (`0 → PASS`, иначе `FAIL`), зеркало
  `self_deploy.map_exit_code_to_status` (но в токенах `PASS`/`FAIL`, а не `SUCCESS`/`FAILED`).

## 2. Объём (scope)

### В объёме (Phase 1)
- **Детерминированный test-раннер** для стадии `testing` репо `orchestrator` (self-hosting):
  исполняет «тест-контракт» (сконфигурированные test/smoke-команды) в worktree ветки задачи,
  маппит exit-код в `result: PASS|FAIL`, пишет `13-test-report.md`, инициирует существующий гейт
  `check_tests_passed` — **без** запуска LLM-агента `tester`.
- **Тест-контракт** — сконфигурированный набор команд: обязательная регресс-команда
  (`pytest tests/`, переиспользуя конвенцию `merge_retest_target`) + опциональные read-only
  smoke-проверки (зеркало шага 3 промпта tester: `/health`, `/status`, `/queue` + наличие блока
  `serial_gate`). Для self-hosting `orchestrator` контракт известен по умолчанию.
- Раннер активируется через **перехват в `launch_job` до `_spawn`** (прецедент D1/D2/ORCH-115),
  **без правки `src/stages.py`/`STAGE_TRANSITIONS`** (роль `tester` в словаре остаётся; меняется лишь
  *кто* обрабатывает джоб на стадии `testing` для in-scope репо с тест-контрактом).
- После выпуска вердикта раннер инициирует **существующую** оценку exit-гейта `check_tests_passed`
  ровно как завершившийся LLM-tester (`advance_stage(finished_agent="tester")`): PASS → `deploy-staging`
  (и далее под-гейты ORCH-022/043/027/058 на ребре `deploy-staging → deploy`); FAIL → существующий
  откат `testing → development` + developer-retry (`src/stage_engine.py:849`).
- Two-level outcome (анти-ORCH-110, по образцу `staging_runner`): сюита **исполнилась** → вердикт →
  advance; сюита **не исполнилась** (tool-error: spawn-error/таймаут/`returncode None`) → bounded
  DEFER, на исчерпании → fail-closed `FAIL` + advance + alert. Инфра-сбой **не жжёт** developer-retry.
- Kill-switch + скоуп-CSV + **тест-контракт** (паттерн ORCH-022/027/043/089/090/115): `*_enabled`
  (откат к LLM-пути), `*_repos` (пусто → self-hosting only), backward-compat: репо без тест-контракта
  → раннер не применяется → прежний LLM-tester.
- Наблюдаемость: read-only блок в `GET /queue` + структурный лог вердикта.

### Вне объёма (явно НЕ делаем в ORCH-116)
- **ORCH-115 (детерминированный deploy/staging-раннер)** — **по явной границе задачи не смешиваем**:
  ORCH-116 не модифицирует `src/staging_runner.py` и не трогает ребро `deploy-staging`/`deploy`.
- **LLM-роли `reviewer` и `developer`** — **остаются без изменений** (граница задачи). reviewer —
  control-path-но-keep (вердикт `verdict:` не деривируем из tool-сигнала, `llm-call-sites.md`).
- **Реализация опционального off-control-path LLM-триажа/диагностики после FAIL** — не делается в
  этом срезе (forward-looking, §6 BRD и §8 TRZ). Архитектура раннера **не должна запрещать** её
  (NFR-7), но и не реализует.
- **Сопоставление TC ↔ критерии приёмки / анализ пробелов покрытия AC как условие гейта** — гейт
  `check_tests_passed` его не требует (читает только `result:`); в потоке управления его нет. Это
  off-control-path диагностика (см. выше).
- **Любая правка `STAGE_TRANSITIONS` / реестра и имён `QG_CHECKS` / семантики `check_tests_passed` /
  `_parse_tests_verdict` / machine-verdict-ключей (`result:`/`verdict:`/`status:`) / схемы БД**
  (см. NFR-1).
- **Замена/правка `check_ci_green` / merge-gate re-test / coverage-gate** — они продолжают работать
  как есть; ORCH-116 меняет только продюсера `13-test-report.md`.

## 3. Заинтересованные стороны

- **Заказчик / Owner** (`homenet542@gmail.com`) — инициатор детерминизации LLM-control-path'ов.
- **Платформа orchestrator (self-hosting)** — прямой потребитель: дешевле/быстрее/детерминированнее
  собственная стадия `testing`.
- **Другие проекты на общем инстансе** (enduro-trails) — НЕ затронуты в Phase 1 (скоуп self-hosting +
  backward-compat для репо без тест-контракта); выигрывают позже от Phase 2 (project test contract).
- **Reviewer / Developer-роли конвейера** — принимают результат через неизменные гейты; их LLM-роли
  не трогаются.

## 4. Бизнес-требования (BR)

- **BR-1 — Детерминированный PASS/FAIL без LLM.** На стадии `testing` для in-scope репо с
  тест-контрактом вердикт `result:` производится детерминированным кодом (исполнение тест-контракта
  + маппинг exit-кода), **без** консультации LLM. Happy-path `testing` не вызывает `_spawn`.
- **BR-2 — Контракт артефакта неизменен.** Раннер пишет тот же `13-test-report.md` с тем же
  frontmatter-ключом `result: PASS|FAIL`, который читает `check_tests_passed`/`_parse_tests_verdict`.
  Гейт и парсер байт-в-байт не меняются.
- **BR-3 — Эквивалентность маршрутизации.** PASS → продвижение на `deploy-staging` через
  существующий путь; FAIL → существующий откат `testing → development` + инкремент developer-retry
  (тот же путь и cap `MAX_DEVELOPER_RETRIES`, что у FAIL-вердикта LLM-tester'а, `src/stage_engine.py:849`).
  Никаких новых рёбер/исходов.
- **BR-4 — Переиспользование существующей инфраструктуры.** Раннер исполняет pytest через
  `proc_group.run_in_process_group` (ORCH-110, tree-kill), переиспользует exit-code→verdict-маппинг
  (зеркало `self_deploy.map_exit_code_to_status`, в токенах `PASS`/`FAIL`) и конвенцию таргета
  (`merge_retest_target`/`tests/`). Не плодит второй несогласованный маппинг/механизм.
- **BR-5 — Обратимость одним флагом.** Глобальный kill-switch возвращает прежний LLM-tester-путь на
  `testing` байт-в-байт; скоуп-CSV ограничивает раннер in-scope репо (пусто → только `orchestrator`).
- **BR-6 — Наблюдаемость.** Исход раннера (запущен / PASS / FAIL / tool-error / defer) виден в
  `GET /queue` и в структурном логе; «код упал» (детерминированный FAIL) и «инструмент недоступен»
  (tool-error) различимы.
- **BR-7 — Self-hosting safety.** Раннер на `testing` **никогда** не рестартит прод-контейнер 8500,
  не трогает `main` force-push'ем, не правит `.env`/`docker-compose.yml`. Он лишь читает, исполняет
  тест-контракт в worktree (pytest) и read-only smoke против 8500, пишет лог и best-effort пушит лог
  в фичеветку (merge в `main` — штатным merge-gate-путём).
- **BR-8 — Гибрид: LLM только off-control-path.** Детерминированный раннер — **единственный**
  исполнитель вердикта `result:`. LLM на стадии `testing` допустим лишь как опциональный
  off-control-path триаж/диагностика после детерминированного FAIL и **не** выносит/не переопределяет
  машинный вердикт гейта. В Phase 1 он не реализуется (NFR-7), но архитектурно не запрещён.
- **BR-9 — Backward-compatibility для репо без тест-контракта.** Репо, для которого тест-контракт не
  сконфигурирован/не резолвится, раннер **не перехватывает** → стадию `testing` ведёт прежний
  LLM-tester (fail-safe). enduro-trails и любые будущие репо без контракта — 1:1 как до ORCH-116.

## 5. Нефункциональные требования (NFR)

- **NFR-1 — Скоуп-инвариант (анти-дрейф).** `STAGE_TRANSITIONS` (`src/stages.py`), реестр и имена
  `QG_CHECKS`/`check_tests_passed`/`_parse_tests_verdict` (`src/qg/checks.py`), machine-verdict-ключи
  (`result:`/`verdict:`/`status:`/`staging_status:`/`deploy_status:`/`security_status:`/`coverage_status:`),
  схема БД — **байт-в-байт не тронуты**. Это замена *продюсера* артефакта, не гейта.
- **NFR-2 — never-raise / fail-safe.** Любая ошибка раннера (pytest не запустился, таймаут, I/O) →
  безопасный детерминированный исход без падения воркера: либо `FAIL` (fail-closed, никогда ложный
  green), либо штатный bounded requeue/defer — **не** «тихий advance». Сбой раннера не клинит очередь
  всех проектов. **Tool-error ≠ code-fail:** инфра-сбой не жжёт developer-retry (анти-ORCH-110).
- **NFR-3 — Изоляция процесса / таймаут.** Спавненный pytest исполняется через `proc_group`
  (tree-kill, ORCH-110); сирот pytest не оставляет; ограниченный таймаут.
- **NFR-4 — Сквозные бюджеты времени.** Таймаут раннера согласован со сквозным инвариантом
  ORCH-065/109/110 (`reaper_max_running_s` > Σ(работ на ребре) + grace) — **без** правки
  `reaper_max_running_s`. Ребро `testing` отдельно от ребра `deploy-staging`; бюджет ≤ окна, которое
  раннер замещает (прежний tester шёл под `agent_timeout_seconds`).
- **NFR-5 — Совместимость с не-self репо.** Для репо вне скоупа / без тест-контракта `testing` ведёт
  себя 1:1 как до ORCH-116 (LLM-tester). enduro-trails не затронут.
- **NFR-6 — Соответствие политике LLM.** Изменение снимает LLM-консультацию A5 из потока управления;
  карта `docs/architecture/llm-call-sites.md` (A5) / `llm-determinization-roadmap.md` (rank 2) /
  `llm-usage-policy.md` и анти-дрейф-тесты обновляются **в том же PR** (норматив сопровождения ORCH-118).
- **NFR-7 — Не запрещать будущий off-control-path LLM-триаж.** Архитектура раннера не должна
  архитектурно исключать опциональный LLM debug/triage-аналитик после детерминированного FAIL
  (будущее улучшение); в ORCH-116 он не реализуется.

## 6. Допущения и ограничения

- **Допущение А1.** Регресс-сюита `orchestrator` (`pytest tests/`) исполняема в worktree ветки задачи
  и её exit-код — авторитетный сигнал PASS/FAIL (как уже трактуют CI / merge-gate re-test / coverage-gate).
- **Допущение А2.** Для self-hosting `orchestrator` тест-контракт известен по умолчанию
  (pytest + read-only smoke против 8500). Для прочих репо контракт отсутствует, пока не сконфигурирован
  (Phase 2) → раннер их не перехватывает (BR-9).
- **Допущение А3.** Перехват «до `_spawn`» по имени джоб-роли (`tester`) + стадии задачи (`testing`)
  — достаточный механизм диспетчеризации (как D1/D2/ORCH-115); конкретный механизм финализирует
  архитектор (06-adr).
- **Ограничение О1.** Граница задачи: не смешивать с ORCH-115 (его код не модифицируется); LLM-роли
  `reviewer`/`developer` не трогаются; код ORCH-112/114 не модифицируется.
- **Ограничение О2.** Phase 2 (project test contract для не-self репо + опциональный off-control-path
  LLM-триаж) — отдельный follow-up; ORCH-116 закрывает только Phase 1.

## 7. Критерии успеха

Стадия `testing` для `orchestrator` проходит без запуска LLM-агента `tester`: детерминированный
раннер исполняет тест-контракт (pytest + smoke), пишет корректный `13-test-report.md` (`result:
PASS|FAIL`), инициирует неизменный гейт `check_tests_passed`, и конвейер продвигается
(`testing → deploy-staging`) / откатывается (`testing → development` + developer-retry) ровно как
раньше — при неизменных `STAGE_TRANSITIONS`/`QG_CHECKS`/гейтах/схеме БД, под kill-switch с откатом к
прежнему LLM-поведению, с backward-compat для репо без тест-контракта. Детальные PASS/FAIL —
`03-acceptance-criteria.md`.

## 8. Риски

Краткий перечень (детали — `10-tech-risks.md`, заполняет архитектор):
- **R-1** — точка диспетчеризации «до `_spawn`» должна корректно отличать testing-tester от любого
  иного джоба (по роли + стадии задачи), иначе можно перехватить не тот джоб.
- **R-2** — после выпуска вердикта нужно надёжно инициировать `advance_stage(finished_agent="tester")`,
  иначе задача зависнет на `testing` (нет «финиша агента», который раньше триггерил гейт).
- **R-3** — таймаут/изоляция pytest-subprocess; утечка процессов (корень инцидента ORCH-109/110/111) —
  обязателен `proc_group` tree-kill.
- **R-4** — корректность two-level outcome: tool-error не должен жечь developer-retry (анти-ORCH-110),
  но и не давать ложный green/тихий advance.
- **R-5** — корректность отката FAIL (developer-retry cap, встраивание `extract_test_failures`) —
  должна совпасть с LLM-путём `src/stage_engine.py:849`.
- **R-6** — гибрид: не протащить LLM обратно в поток управления вердикта (BR-8); off-control-path
  триаж — отдельная роль/джоб, не выносящая `result:`.
- **R-7** — backward-compat: репо без тест-контракта обязаны откатываться на LLM-tester (BR-9), иначе
  enduro/новый репо «застрянет» без продюсера отчёта.
