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

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

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

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

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

Заменить **LLM-агента `tester`** на стадии `testing` (для self-hosting `orchestrator`)
**детерминированным test-раннером**, перехватываемым в `launch_job` **до `_spawn`** (прецедент
`deploy-finalizer`/`post-deploy-monitor`/`staging_runner`, `src/agents/launcher.py:397/402/405`).
Раннер исполняет «тест-контракт» (регресс `pytest tests/` через `proc_group.run_in_process_group`
+ опциональные read-only smoke-проверки), маппит exit-код в `result:` (`0→PASS`, иначе `FAIL`),
пишет `13-test-report.md`, best-effort пушит лог в фичеветку, затем инициирует **существующую**
оценку exit-гейта `check_tests_passed` ровно как завершившийся LLM-tester. Контракт артефакта, гейт,
`STAGE_TRANSITIONS`, схема БД — **неизменны**. Под kill-switch + скоуп-CSV + тест-контракт;
never-raise; fail-closed; two-level outcome (анти-ORCH-110). Эталон реализации — `src/staging_runner.py`
(ORCH-115).

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

| Путь | Действие | Назначение |
|------|----------|------------|
| `src/test_runner.py` *(новый leaf)* | создать | Детерминированный раннер: `applies(repo)` (kill-switch + скоуп + наличие тест-контракта), `should_intercept(job)` (роль `tester` + стадия `testing`), исполнение тест-контракта (`pytest` через `proc_group`, опц. smoke), маппинг exit-кода → `result:`, запись `13-test-report.md`, best-effort push в фичеветку, инициация гейта через `advance_stage(finished_agent="tester")`, two-level outcome (tool-error DEFER), снапшот для `/queue`. Leaf-чистота по образцу `staging_runner.py`/`self_deploy.py`: на импорте только `config`/`logging`/`proc_group`; `db`/`git_worktree`/`self_deploy`/`qg.checks`/`stage_engine`/`notifications` — лениво внутри функций; never-raise. |
| `src/agents/launcher.py` | изменить | В `launch_job` добавить перехват **до `_spawn`** (рядом с D1/D2/ORCH-115): `if job.get("agent")=="tester": from .. import test_runner; if test_runner.should_intercept(job): return self._run_test_runner_job(job)`. Новый метод `_run_test_runner_job(job)` — зеркало `_run_staging_runner_job`: синхронно ведёт `jobs`-строку через `mark_job(done|failed)`, возвращает `None` (нет `agent_runs`). |
| `src/config.py` | изменить | Добавить ключи (зеркало `staging_runner_*`/`merge_retest_*`): `test_runner_enabled: bool = True` (env `ORCH_TEST_RUNNER_ENABLED`), `test_runner_repos: str = ""` (env `ORCH_TEST_RUNNER_REPOS`; пусто → self-hosting only), `test_runner_target: str = "tests/"` (pytest-таргет тест-контракта, конвенция `merge_retest_target`), `test_runner_timeout_s: int = 900` (см. FR-2/NFR-4), `test_runner_smoke_enabled: bool = True` (опц. read-only smoke), `test_runner_infra_max_retries: int = 2`, `test_runner_infra_retry_delay_s: int = 30`. Дефолты = боевое; пустой `.env` ⇒ поведение для in-scope. |
| `src/main.py` (`GET /queue`) | изменить | Read-only блок `test_runner` (флаг/скоуп/таргет/счётчики исходов) — наблюдаемость BR-6 (зеркало блока `staging_runner`). |
| `.openclaw/agents/tester.md` | изменить (docs) | Отметить, что на `testing` для in-scope репо с тест-контрактом стадию ведёт детерминированный код (зеркало формулировки `deployer.md` про staging-runner ORCH-115); LLM-ветвь `testing` остаётся fallback'ом под выключенным флагом / для репо без тест-контракта. Канон промпта 52d (5 секций, ключ `result:`) — байт-в-байт. |
| `docs/architecture/llm-call-sites.md`, `llm-determinization-roadmap.md`, `llm-usage-policy.md` | изменить (docs) | Норматив сопровождения ORCH-118 (NFR-6): отразить реализацию A5 (tester) — обновить инвентарь/политику/roadmap (rank 2 → реализовано, инвариант «ровно один `first_slice = yes`» НЕ нарушать) в том же PR; синхронно поправить `tests/test_llm_call_site_inventory.py` / `tests/test_llm_determinization_docs.py` (машинные блоки). |
| `docs/architecture/README.md`, `CLAUDE.md`, `CHANGELOG.md`, `docs/overview/` | изменить (docs) | Компонент-карта/паспорт/чейнджлог/витрина — правило для агентов №2 + витрина ORCH-011. |
| `tests/test_orch116_test_runner.py` *(новый)* | создать | Покрытие (см. `04-test-plan.yaml`). |

> **Не трогать (NFR-1):** `src/stages.py::STAGE_TRANSITIONS`; имена/семантику `QG_CHECKS`/
> `check_tests_passed`/`_parse_tests_verdict`/прочих `check_*` в `src/qg/checks.py`; machine-verdict-ключи
> (`result:`/`verdict:`/`status:`/…); `src/staging_runner.py` (ORCH-115); LLM-роли `reviewer`/`developer`
> (`.openclaw/agents/reviewer.md`/`developer.md`); `src/transition_lease.py` (ORCH-114);
> `src/checkout_hygiene.py` (ORCH-112); `src/proc_group.py` (переиспользуем как есть); схему БД.

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

### FR-1 — Детерминированный перехват на `testing` (без `_spawn`)
В `launch_job` (`src/agents/launcher.py`) **до** вызова `_spawn`, по образцу D1/D2/ORCH-115: если
`job.agent == "tester"` **и** `test_runner.should_intercept(job)` истинно → не вызывать `_spawn`, а
исполнить раннер синхронно (`_run_test_runner_job`). Контракт: возвращает `None` (нет `agent_runs`),
сам ведёт `jobs`-строку (`mark_job(done|failed)`) как `_run_staging_runner_job`.
- `should_intercept(job)`: `job.agent == "tester"` **И** `applies(job.repo)` **И** стадия задачи
  (`tasks.stage` по `job.task_id`) == `testing`. Роль `tester` исполняет **только** стадию `testing`
  (единственный `agent` для входа в `testing`, `STAGE_TRANSITIONS["review"]["agent"]`), поэтому
  коллизии стадий нет; гард по стадии — defense-in-depth (R-1). never-raise → `False` (DB-сбой →
  fall-through к `_spawn`, fail-safe к LLM-пути).
- `applies(repo)`: `test_runner_enabled=False` → `False` (откат к LLM-пути); непустой
  `test_runner_repos` → membership; пустой CSV → `is_self_hosting_repo(repo)`; **и** тест-контракт
  для репо резолвится (BR-9: иначе `False` → LLM-tester). Никакой сети, проверяется **первым**
  (нулевой оверхед при выключенном флаге). never-raise → `False` (fail-safe к LLM-пути).

### FR-2 — Исполнение тест-контракта (pytest + опц. smoke) через `proc_group`
Раннер исполняет регресс-команду тест-контракта — `python -m pytest <test_runner_target>` (дефолт
`tests/`) — **в worktree ветки задачи** (`git_worktree.get_worktree_path(repo, branch)`, НЕ в общем
`/repos/orchestrator`: анти-checkout-гонка, как требовал промпт tester шаг 2) через
`proc_group.run_in_process_group` (ORCH-110: отдельная группа процессов, tree-kill SIGTERM→grace→SIGKILL,
grace = `agent_kill_grace_seconds`; `subprocess_tree_kill_enabled`). Таймаут — `test_runner_timeout_s`
(дефолт 900; малформ/непозитив → дефолт + WARNING, never-break — зеркало
`merge_gate._resolve_retest_timeout`/`staging_runner._resolve_timeout`). Захватывает exit-код и stdout
(для тела отчёта/observability).
- **Опциональный smoke** (`test_runner_smoke_enabled`, зеркало шага 3 промпта tester): read-only GET
  `http://localhost:<port>/health`, `/status`, `/queue` + проверка наличия блока `serial_gate` в
  `/queue`. Любой провал smoke → итоговый `FAIL` (детерминированно). Smoke — строго read-only
  (BR-7/AC-8): никаких мутирующих запросов к 8500.

### FR-3 — Маппинг exit-кода → `result:`
`0 → "PASS"`, любой ненулевой / отсутствие кода / ошибка запуска → `"FAIL"` (fail-closed, никогда
ложный green). Pure-функция, согласованная по контракту с `self_deploy.map_exit_code_to_status`, но в
токенах `PASS`/`FAIL` (`result:` использует их, а не `SUCCESS`/`FAILED`; `_TESTS_POSITIVE_TOKENS`/
`_TESTS_NEGATIVE_TOKENS`, `src/qg/checks.py:222-223`). Если архитектор предпочтёт единый маппер с
параметризованными токенами — допустимо, но **второй несогласованный маппинг не плодить** (BR-4).

### FR-4 — Запись и push `13-test-report.md`
Раннер пишет `docs/work-items/<work_item_id>/13-test-report.md` в worktree фичеветки с frontmatter:
`result: PASS|FAIL` (UPPERCASE) + обязательная 52c-схема (`work_item`/`stage: testing`/`author_agent`/
`status`/`created_at`/`model_used`) + информативное тело (таблица результата pytest / хвост stdout /
smoke-итог) — зеркало `staging_runner.build_staging_log`/`write_staging_log`. `author_agent: test-runner`,
`model_used: n/a` честно отражают **детерминированный** продюсер; ключ `result:` и его UPPERCASE-значение
**не** меняются. Best-effort `git add/commit/push` лога в **фичеветку** (та же git-identity ORCH-101,
актор `test-runner`; **без** отдельного PR-merge в `main` — гейт читает worktree → origin/main fallback,
`check_tests_passed`→`_repo_path`). Самостоятельный merge лога в `main` НЕ делать (усиливает BR-7/AC-8).

### FR-5 — Инициация существующего гейта после вердикта
После записи (и best-effort push) раннер инициирует ту же оценку exit-гейта, что триггерил
завершившийся LLM-tester: `stage_engine.advance_stage(task_id, current_stage="testing", repo,
work_item_id, branch, finished_agent="tester")`. Это запускает `check_tests_passed` (`_parse_tests_verdict`
читает `result:` из `13-test-report.md`) — на PASS продвигает `testing → deploy-staging`; на FAIL
запускает **существующий** откат `testing → development` + developer-retry (cap `MAX_DEVELOPER_RETRIES`,
встраивание `extract_test_failures`, `src/stage_engine.py:849-892`). **Никакой новой ветви
маршрутизации.** Lease ORCH-114 берётся внутри `advance_stage` как сейчас — раннер его не трогает
(граница задачи О1). never-raise.

### FR-6 — Two-level outcome (tool-error DEFER, анти-ORCH-110)
По образцу `staging_runner.run_staging_gate`:
- Сюита **исполнилась** (`returncode is not None` и не `timed_out`) → довериться exit-коду (FR-3) →
  записать `result:` → инициировать гейт (FR-5). FAIL → существующий rollback (FR-5).
- Сюита **не исполнилась** (tool-error: spawn-error / таймаут / `returncode None`) → **инфра-сбой, НЕ
  код-фейл** → bounded DEFER: re-queue свежий `tester`-джоб с задержкой `test_runner_infra_retry_delay_s`
  + restart-safe маркер (`test-runner infra-retry` в `task_content`, зеркало
  `staging_runner._INFRA_RETRY_MARKER`/`stage_engine._merge_infra_retry_count`); счётчик из persisted
  `jobs`. На исчерпании `test_runner_infra_max_retries` → fail-closed `result: FAIL` + запись лога +
  инициация гейта (существующий rollback) + один INFRA-alert (явно «НЕ дефект кода», кликабельный
  номер). **Никогда** тихий advance/ложный green; **никогда** не клинит очередь; **не** жжёт
  developer-retry на транзиентной инфре.

### FR-7 — Kill-switch, скоуп, тест-контракт (обратимость + backward-compat)
`test_runner_enabled=False` → перехват не срабатывает → на `testing` запускается прежний LLM-tester
(`_spawn`) **байт-в-байт** как до ORCH-116. `test_runner_repos` ограничивает скоуп (пусто → только
`orchestrator`). Репо без резолвимого тест-контракта → `applies==False` → LLM-tester (BR-9). Переключение
флага туда-сюда не оставляет несовместимого состояния (артефакт/гейт неизменны).

### FR-8 — Наблюдаемость
- Read-only блок `test_runner` в `GET /queue`: `enabled`, `repos`, `target`, `timeout_s`,
  `infra_max_retries`, счётчики `runs`/`pass`/`fail`/`tool_error`/`deferred` (in-process, паттерн
  `staging_runner._STAGING_RUNNER_COUNTERS`).
- Один структурный лог-вердикт на прогон (`work_item`/`repo`/`exit_code`/`result`/`duration_s`/`outcome`),
  различающий «код упал» (`FAIL` от сюиты) и «инструмент недоступен» (tool-error).

### FR-9 — Гибрид: LLM строго off-control-path (BR-8 / NFR-7)
В Phase 1 LLM на стадии `testing` **отсутствует** в потоке управления вердикта (детерминированный
раннер — единственный продюсер `result:`). Архитектура раннера не должна архитектурно исключать
будущий **опциональный off-control-path** LLM-триаж/диагностику после детерминированного FAIL
(отдельная роль/джоб, **не** выносящая и **не** переопределяющая `result:`). В этом срезе он не
реализуется и **не** добавляется в `STAGE_TRANSITIONS`.

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

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

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

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

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

**Нет новых QG и нет изменений существующих.** `check_tests_passed` / `_parse_tests_verdict` / ключ
`result:` (+ legacy `verdict:`/`status:`) (`src/qg/checks.py:182/226`) и состав `QG_CHECKS` —
**байт-в-байт неизменны**. ORCH-116 меняет только *продюсера* `13-test-report.md` (детерминированный
код вместо LLM); гейт, читающий артефакт, остаётся прежним. Это критический инвариант (NFR-1) —
reviewer ловит любое изменение имени/семантики гейта/парсера/токенов как finding ≥P1.

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

- **Обратная совместимость:** `test_runner_enabled=False` → прежний LLM-tester-путь байт-в-байт;
  репо без тест-контракта / вне скоупа → LLM-tester (BR-9). enduro-trails не затронут (NFR-5).
- **Kill-switch / область раската:** флаг `test_runner_enabled` + CSV `test_runner_repos` (пусто →
  self-hosting only). Откат = `ORCH_TEST_RUNNER_ENABLED=false`.
- **Обратимость:** полностью обратимо флагом; артефакт и гейт неизменны, переключение туда-сюда не
  оставляет несовместимого состояния.
- **never-raise / fail-safe (NFR-2):** ошибка раннера → `FAIL` (fail-closed) или bounded requeue, не
  «тихий advance»; tool-error не жжёт developer-retry (анти-ORCH-110). Self-hosting safety (BR-7):
  никаких рестартов 8500 / force-push в `main` / правок инфры; smoke строго read-only.
- **Изоляция (NFR-3):** pytest через `proc_group` tree-kill (ORCH-110) — без сирот; таймаут согласован
  со сквозным бюджетом ORCH-065/109/110 без правки `reaper_max_running_s` (NFR-4).
- **Граница (О1):** код ORCH-115 (`staging_runner`), ORCH-112 (checkout hygiene), ORCH-114 (transition
  lease) и LLM-роли `reviewer`/`developer` не модифицируются.
- **Норматив сопровождения (NFR-6):** в том же PR обновить `docs/architecture/llm-call-sites.md` (A5) /
  `llm-determinization-roadmap.md` (rank 2) / `llm-usage-policy.md` + анти-дрейф-тесты
  (`tests/test_llm_call_site_inventory.py`, `tests/test_llm_determinization_docs.py`); `CLAUDE.md` /
  `docs/architecture/README.md` / `CHANGELOG.md` / `docs/overview/`.

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

Зафиксировано для преемственности — **не реализуется в этой задаче**, заводится отдельным follow-up:
- **Project test contract** для не-self репо (enduro-trails): декларативный per-repo контракт
  `test` / `smoke` (команды + ожидаемые коды/эндпоинты), исполняемый тем же детерминированным
  раннер-паттерном (run → map exit code → `result:` → artifact → gate).
- **Опциональный off-control-path LLM-триаж** после детерминированного FAIL: human-readable
  диагностика причин падений, анализ пробелов покрытия AC, сопоставление TC ↔ критерии приёмки —
  как обогащение отчёта/комментария, **не** как продюсер вердикта `result:` (NFR-7).
- Зависимость: устойчивый Phase 1 (этот work item) как доказанный паттерн перехвата + маппинга +
  two-level outcome.
