--- 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 ` (дефолт `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:/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//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.