--- 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/новый репо «застрянет» без продюсера отчёта.