---
work_item: ORCH-116
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-16
model_used: claude-opus-4-8
---

# adr-0050: Детерминированный test-раннер — второй реализованный срез determinization-roadmap (tester-гибрид)

> **Сквозной (cross-cutting) ADR.** Агрегирует решение ORCH-116, влияющее на **весь**
> оркестратор: вводит новый компонент-leaf `src/test_runner.py`, снимает вторую avoidable
> LLM-консультацию из потока управления (`tester`/`result:`, A5) и переводит rank-2
> determinization-roadmap из «план» в «реализовано». Локальная детализация (все решения
> D1–D12, включая tester-специфичную анти-коллизию `status:` D6.1) —
> `docs/work-items/ORCH-116/06-adr/ADR-001-deterministic-test-runner.md`.

## Статус
Proposed

## Контекст

ORCH-118 ([adr-0047](adr-0047-llm-usage-policy-and-call-site-map.md)) зафиксировал нормативную
политику и карту LLM-консультаций и назвал **avoidable LLM control paths = `{tester, deployer}`**.
Первый срез — **deployer (staging-status, rank 1)** — реализован **ORCH-115**
([adr-0048](adr-0048-deterministic-staging-runner.md)). Второй кандидат — **tester (rank 2,
`needs-hybrid-fallback`, `hybrid_needed = yes`, `first_slice = no`)**. ORCH-116 — его фактическая
реализация.

Вердикт `result:` на стадии `testing` сейчас эмитит LLM-агент `tester`, но **PASS/FAIL-ядро** есть
**чистый маппинг** exit-кода `pytest` + read-only smoke, а гейт `check_tests_passed`
(`_parse_tests_verdict`) детерминирован и читает **только** frontmatter `result:` (+ legacy
`verdict:`/`status:`). Это удовлетворяет обоим условиям «avoidable»: C-консультация **и**
деривируемый вердикт. **Гибрид-нюанс:** прежний промпт нёс ещё и настоящее суждение (триаж падений,
маппинг TC↔критерии) — поэтому ORCH-116 выносит из потока управления **только PASS/FAIL-исполнителя**,
оставляя LLM допустимым лишь как будущий **off-control-path** триаж (Phase 2, не control-path).

Прецедент детерминированной замены агента (`launch_job`-перехват до `_spawn`, D1/D2 +
**рабочий эталон `src/staging_runner.py`** ORCH-115) и эталон «детерминированный джоб → `advance_stage`»
уже в проде — архитектурный риск замены снят.

## Решение

**Новый leaf `src/test_runner.py` + перехват в `launch_job` до `_spawn`** (рядом с D1/D2/ORCH-115).
На `testing` для in-scope репо с резолвимым тест-контрактом джоб `tester` обрабатывает раннер:
исполняет регресс `pytest <target>` **в worktree ветки** через `proc_group` (tree-kill, ORCH-110) +
опциональный read-only smoke, маппит exit-код единым контрактом `self_deploy.map_exit_code_to_status`
(транслируя токены в `PASS`/`FAIL`), пишет `13-test-report.md` (тот же machine-key `result:`),
best-effort пушит лог в фичеветку, вызывает **существующий** `advance_stage(current_stage="testing",
finished_agent="tester")`.

Кросс-каттинговые инварианты (сохранены **байт-в-байт**):
- `STAGE_TRANSITIONS` (`src/stages.py`), реестр и имена `QG_CHECKS`/`check_tests_passed`/
  `_parse_tests_verdict`/прочих `check_*`/`_parse_*`, machine-verdict-ключи (`result:`/`verdict:`/
  `status:`/`staging_status:`/`deploy_status:`/`security_status:`/`coverage_status:`), **схема БД** —
  не тронуты. Это замена *продюсера* артефакта, не гейта/стадии.
- Единственный транспорт LLM-консультации (`launcher._spawn`/S0,
  [llm-usage-policy.md](../llm-usage-policy.md) §5) — соблюдён: раннер **не зовёт LLM**; второй
  транспорт не вводится; будущий off-control-path триаж — вне control-path (не контр-пример политике).
- Сквозной бюджет времени ORCH-065/109/110 (`reaper_max_running_s` (5400) > Σ(работ на ребре)) —
  соблюдён **без** правки `reaper_max_running_s`: ребро `testing` отдельно от `deploy-staging`, окно
  раннера ≤900s ≤ прежнего LLM-окна `agent_timeout_seconds` (1800s).
- Граница ORCH-112/ORCH-114/ORCH-115: transition-lease берётся **внутри** `advance_stage`; раннер
  lease/гигиену/`staging_runner` не модифицирует.

Скоуп — **self-hosting only** (`test_runner_repos=""` → `is_self_hosting_repo` + резолв
тест-контракта `_has_test_contract`, в Phase 1 = self-hosting), под kill-switch
`test_runner_enabled` (off → `_spawn` LLM-tester'а байт-в-байт). never-raise во всех публичных
функциях; **двухуровневый исход** (verdict при исполнившейся сюите; bounded defer → fail-closed на
tool-error/таймауте) убирает с `testing`-ребра RCA-класс ORCH-110 (инфра ≠ код-фейл).
**Backward-compat (BR-9):** репо без резолвимого тест-контракта → `applies==False` → прежний
LLM-tester (enduro-trails не затронут).

**Tester-специфичная анти-коллизия (D6.1 локального ADR, отсутствует в ORCH-115):**
`_parse_tests_verdict` читает вердикт из **трёх** полей (`verdict:`/**`status:`**/`result:`) с
negative-token-priority — поэтому обязательное 52c-поле `status:` раннера **жёстко выровнено** по
вердикту (`success` для PASS / `failed` для FAIL), иначе негативный токен в `status:` при `result:
PASS` дал бы ложный FAIL. Зафиксировано unit-тестом через неизменённый парсер.

**Эволюция карты LLM (норматив сопровождения, в том же PR — D12 локального ADR):**
`llm-call-sites.md` (A5 → реализовано детерминированно, но `avoidable=yes`/`axis=C`/
`needs-hybrid-fallback` сохранены — LLM-ветвь как fallback / будущий off-control-path триаж),
`llm-determinization-roadmap.md` (rank 2 tester → реализован; **инвариант «ровно один
`first_slice = yes`» цел** — `first_slice` остаётся у rank 1/deployer, у tester — `no`),
`llm-usage-policy.md` (§5 — транспорт не нарушен), плюс анти-дрейф-тесты
(`test_llm_call_site_inventory.py`/`test_llm_determinization_docs.py`). Эти правки коуплены к коду →
применяются в development атомарно с реализацией, не в architecture-стадии (как ORCH-115).

## Последствия

- **+** Минус ещё один avoidable LLM control path; второй доказанный раннер-паттерн (теперь и для
  `needs-hybrid-fallback`-кандидата, не только `replace-deterministic-now`).
- **+** Дешевле/быстрее/детерминированнее собственный `testing`; нет токенов/латентности LLM в точке
  ветвления `testing → deploy-staging` / `testing → development`.
- **+** Паттерн остаётся переиспользуемым: leaf + перехват до `_spawn` + `advance_stage` — шаблон для
  Phase 2 (project test contract не-self репо + опциональный off-control-path LLM-триаж).
- **+** Гибрид-граница (D11 локального ADR): архитектура не закрывает будущий off-control-path триаж,
  не пуская LLM обратно в поток управления вердикта.
- **−** Новый компонент + врезка + defer-механика + tester-специфичная анти-коллизия `status:`.
  Митигейшн: never-raise leaf, kill-switch (fail-safe к LLM), без схемы БД, инвариант выравнивания
  `status:` + структурное покрытие `tests/test_orch116_test_runner.py`.
- **Откат:** `ORCH_TEST_RUNNER_ENABLED=false` → прежний LLM-путь на `testing` байт-в-байт.

## Ссылки
- Локальный ADR: `docs/work-items/ORCH-116/06-adr/ADR-001-deterministic-test-runner.md`
- Первый срез: [adr-0048](adr-0048-deterministic-staging-runner.md) (ORCH-115, `src/staging_runner.py`)
- Политика/карта/roadmap: [llm-usage-policy.md](../llm-usage-policy.md),
  [llm-call-sites.md](../llm-call-sites.md) (A5),
  [llm-determinization-roadmap.md](../llm-determinization-roadmap.md) (rank 2),
  [adr-0047](adr-0047-llm-usage-policy-and-call-site-map.md)
- Прецеденты: D1/D2 (`launcher.py:397/402`), `_run_staging_runner_job` (`launcher.py:438`),
  `run_staging_gate` (`staging_runner.py`), `proc_group` (ORCH-110,
  [adr-0042](adr-0042-merge-gate-retest-infra-tolerance-and-tree-kill.md)),
  transition-lease (ORCH-114, [adr-0045](adr-0045-transition-ownership-lease-and-stage-cas.md))
