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

# ADR-001: Детерминированный test-раннер вместо LLM-тестера на стадии `testing`

Work Item: **ORCH-116** — заменить LLM-агента `tester` на стадии `testing`
(self-hosting `orchestrator`) детерминированным test-раннером (второй срез
determinization-roadmap, **rank 2 / tester-гибрид**).
Стадия: **architecture**
Сквозная регистрация: **`docs/architecture/adr/adr-0050-deterministic-test-runner.md`**
(решение кросс-каттинговое — вводит новый компонент-leaf `src/test_runner.py` и реализует
второй срез determinization-roadmap; влияет на карту LLM-консультаций всего оркестратора).

## Статус
Proposed

## Контекст

Стадию `testing` сейчас исполняет **LLM-агент `tester`**. Маршрутизация (сверено по коду
`src/stages.py:17-18`):

```python
"review":  {"next": "testing",        "agent": "tester",   "qg": "check_reviewer_verdict"},
"testing": {"next": "deploy-staging", "agent": "deployer", "qg": "check_tests_passed"},
```

То есть `tester` — **единственный** агент, запускаемый при входе в `testing` (поле `agent`
ребра `review → testing`); гейт выхода из `testing` — `check_tests_passed`. Фактическая работа
агента на этой стадии в happy-path — **в основном детерминированная** (`.openclaw/agents/tester.md`):
прогнать регресс `pytest tests/` в worktree ветки, сделать read-only smoke (`/health`, `/status`,
`/queue` + наличие блока `serial_gate`), смаппить exit-код, записать `13-test-report.md` с
машинным frontmatter `result: PASS|FAIL`.

Вердикт `result:` потребляется **детерминированным** гейтом `check_tests_passed`
(`src/qg/checks.py:182` → `_parse_tests_verdict:226`), который читает **только** YAML-frontmatter
(`result:` канонический + legacy `verdict:`/`status:`, ORCH-047) — **не** прозу и **не** покрытие
TC. По нормативной политике (`docs/architecture/llm-usage-policy.md`) это **avoidable LLM control
path**: (i) C-консультация (вердикт `result:` ветвит гейт) **и** (ii) вердикт **деривируем** из
exit-кода `pytest` + smoke. Карта (`docs/architecture/llm-call-sites.md`, строка **A5**)
классифицирует tester как **`needs-hybrid-fallback`**; roadmap
(`llm-determinization-roadmap.md`, машинный блок §4) ставит его **rank 2** (`hybrid_needed = yes`,
`first_slice = no`). ORCH-116 — реализация этого второго среза (первый, **ORCH-115/deployer**,
уже реализован — `src/staging_runner.py`).

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

**Установленные факты (сверено по коду, не изобретать):**
- Детерминированный прецедент замены агента **до `_spawn`** работает в проде: `launch_job`
  перехватывает `deploy-finalizer` (D1, `src/agents/launcher.py:397`), `post-deploy-monitor`
  (D2, `:402`) и **staging-runner** (ORCH-115, `:404-408`) до `_spawn`; `_run_staging_runner_job`
  (`:438`) — тонкая обёртка, синхронно ведущая `jobs`-строку через `mark_job` и возвращающая `None`.
- **Эталон leaf-раннера** — `src/staging_runner.py` (ORCH-115): two-level outcome, never-raise,
  kill-switch + скоуп-CSV, `proc_group`, маппинг exit-кода единым контрактом, best-effort push в
  фичеветку, инициация гейта через `advance_stage`, in-process счётчики. ORCH-116 — его зеркало для
  роли `tester` / стадии `testing`.
- Гейт `check_tests_passed` (`src/qg/checks.py:182`) читает `13-test-report.md` через
  `_repo_path(repo, branch)` (`:15`), который **читает per-branch worktree первым** (если каталог
  существует), иначе — общий клон. Значит worktree-записанный файл читается напрямую — **отдельный
  merge лога в `main` не нужен**.
- FAIL-маршрут существует и зафиксирован: `src/stage_engine.py:849` —
  `if agent == "tester" and qg_name == "check_tests_passed"` → откат `testing → development` +
  `extract_test_failures` + `enqueue_job("developer", …)` (cap `MAX_DEVELOPER_RETRIES=3`,
  `:862-892`). Ветвь матчит по `agent == "tester"` — раннер обязан инициировать гейт с
  `finished_agent="tester"`.
- Tree-kill subprocess'а под таймаутом готов: `proc_group.run_in_process_group` (ORCH-110,
  stdlib-only, never-raise, fallback к `subprocess.run`). pytest уже исполняется в worktree через
  него в coverage-gate (ORCH-027) и merge-gate re-test (ORCH-110) — тот же контейнер, без новых
  зависимостей образа.
- Пьюр-маппинг exit-кода готов: `self_deploy.map_exit_code_to_status` (`0→SUCCESS`, иначе/None/
  нечисло→`FAILED`, fail-closed, unit-tested). ORCH-116 переиспользует его, транслируя токены в
  `PASS`/`FAIL`.

«Как есть» не годится: каждый прогон tester'а тратит токены/время opus-агента (по `agent_runs`:
~60–150k / 5–20 мин на прогон) ради действия = один прогон pytest + несколько read-only GET,
встраивает недетерминизм LLM в точку ветвления `testing → deploy-staging` / `testing → development`
и принадлежит к RCA-классу «LLM принял решение, которое есть исполнение фиксированных команд +
маппинг результата» (ORCH-110/111/112/113/114/117/115).

## Решение

### Сводка

Ввести **новый leaf `src/test_runner.py`** (never-raise, по образцу `staging_runner.py`/
`self_deploy.py`/`proc_group.py`) и **перехват в `launch_job` до `_spawn`** (рядом с D1/D2/ORCH-115).
Когда на стадии `testing` для in-scope репо с тест-контрактом к запуску приходит джоб `tester`, его
обрабатывает раннер: исполняет «тест-контракт» (регресс `pytest <target>` в worktree ветки через
`proc_group` + опциональный read-only smoke), маппит exit-код в `result:` (`0→PASS`, иначе `FAIL`),
пишет `13-test-report.md`, best-effort пушит лог в фичеветку, и вызывает **существующий**
`advance_stage(current_stage="testing", finished_agent="tester")` — ровно как завершившийся
LLM-tester. Контракт артефакта, гейт `check_tests_passed`/`_parse_tests_verdict`, `STAGE_TRANSITIONS`,
схема БД — **байт-в-байт неизменны** (это замена *продюсера* артефакта, не гейта). Под kill-switch +
скоуп-CSV + резолв тест-контракта; never-raise; fail-closed; two-level outcome (анти-ORCH-110);
fail-safe к прежнему LLM-пути.

### D1 — Точка диспетчеризации: перехват в `launch_job` **до** `_spawn` (FR-1 / AC-1)

В `launcher.launch_job`, рядом с врезками D1/D2/ORCH-115, **до** `_spawn`:

```python
if job.get("agent") == "tester":
    from .. import test_runner
    if test_runner.should_intercept(job):
        return self._run_test_runner_job(job)
```

- **Дискриминатор перехвата — роль `tester` + стадия задачи + `applies(repo)`.**
  `should_intercept(job)` истинно ⇔ `agent == "tester"` **И** `applies(job["repo"])` **И**
  `tasks.stage` (по `job["task_id"]`) `== "testing"`.
- **Отличие от ORCH-115 (важно, R-1).** Роль `deployer` была **общей** для `deploy-staging` и
  `deploy`, поэтому гард по стадии у staging-раннера был обязателен для дизамбигуации «staging vs
  prod». Роль `tester` исполняет **только** стадию `testing` (единственный `agent` входа в `testing`,
  `STAGE_TRANSITIONS["review"]["agent"]`), коллизии стадий нет — но гард `tasks.stage == "testing"`
  сохраняется как **defense-in-depth** (симметрия с ORCH-115 + защита от перехвата случайного
  будущего `tester`-джоба вне `testing`).
- `should_intercept` / `applies` — **never-raise → False**: любая ошибка (DB-lookup упал) → провал в
  `_spawn` (fail-safe к прежнему LLM-пути).
- `_run_test_runner_job(job)` — тонкая обёртка-зеркало `_run_staging_runner_job` (`:438`):
  синхронно зовёт `test_runner.run_test_gate(job)`, затем `mark_job(job["id"], "done")`; любое
  исключение → `mark_job(..., "failed", error=…)`; возвращает `None` (нет `agent_runs`-строки,
  `_spawn` не вызывается, токены LLM не тратятся).

### D2 — Размещение логики: чистый leaf `src/test_runner.py` (зеркало `staging_runner`)

`run_test_gate(job)` живёт в leaf'е и владеет полным детерминированным потоком (зеркало
`staging_runner.run_staging_gate`):
1. поднять `work_item_id`/`branch` по `task_id`;
2. исполнить тест-контракт (D3) → `ProcResult` (pytest) + smoke-итог;
3. определить исход (D5);
4. на verdict-исходе: записать `13-test-report.md` (D6) и вызвать
   `advance_stage(finished_agent="tester")` (D7);
5. на tool-error-исходе: bounded DEFER (D5);
6. учесть счётчики + структурный лог (D10).

**Чистота leaf'а:** импортирует на уровне модуля только `config`, `logging` (+ `proc_group`);
лениво (внутри функций) — `db`/`git_worktree`/`self_deploy.map_exit_code_to_status`/
`qg.checks.is_self_hosting_repo`/`stage_engine.advance_stage`/`notifications`. Лениво — чтобы не
тащить тяжёлый `stage_engine` на импорте и не плодить цикл (паттерн `staging_runner`/
`transition_lease`/`merge_gate`). Все публичные функции — **never-raise** (AC-9).

### D3 — Исполнение тест-контракта: pytest (в worktree) + опц. smoke через `proc_group` (FR-2 / NFR-3 / AC-10 / AC-11)

**Тест-контракт = (обязательная регресс-команда) + (опциональный read-only smoke).**

- **Регресс:** `python -m pytest <test_runner_target>` (дефолт `tests/`, конвенция
  `merge_retest_target`) исполняется **в worktree ветки задачи** —
  `git_worktree.get_worktree_path(repo, branch)`, **НЕ** в общем `/repos/orchestrator` (анти
  checkout-гонка, как требовал промпт tester шаг 2; тот же контекст, что coverage-gate/merge-gate
  re-test) — через `proc_group.run_in_process_group(argv, cwd=<worktree>, timeout=<test_runner_timeout_s>,
  grace_s=agent_kill_grace_seconds, tree_kill=subprocess_tree_kill_enabled)` → SIGTERM→grace→SIGKILL
  всего дерева на таймауте, без сирот pytest (корень ORCH-109/110/111 закрыт). Захватывает exit-код и
  stdout (для тела отчёта/observability).
- **Опциональный smoke** (`test_runner_smoke_enabled`, дефолт `True`; зеркало шага 3 промпта tester):
  read-only `GET` против запущенного оркестратора (base URL из config — host-параметризация ORCH-101,
  без host-хардкодов): `/health`, `/status`, `/queue` + проверка **наличия** блока `serial_gate` в
  ответе `/queue`. **Smoke строго read-only** (BR-7/AC-10): никаких мутирующих запросов.
- **Итоговый verdict-токен** = `PASS` ⇔ (exit-код pytest == 0 по маппингу D4) **И** smoke прошёл
  (если включён); иначе `FAIL`. Smoke-провал → `FAIL` (детерминированно, FR-2).
- **Анти-флап smoke (уточнение архитектора):** транзиентная **недостижимость** smoke-эндпоинта
  (connection refused / таймаут на единичном GET) ретраится **ограниченно** внутри smoke-шага
  (несколько быстрых GET с коротким backoff) перед выводом `FAIL`; «достижимо, но форма неверна»
  (не-200 / нет блока `serial_gate`) → немедленный `FAIL`. Это снижает риск, что разовый блип
  прод-8500 откатит здоровую ветку, не вводя нового исхода (на исчерпании smoke-ретраев — обычный
  `FAIL`, поглощаемый developer-retry-cap). Гард `test_runner_smoke_enabled` позволяет отключить
  smoke, если он окажется шумным, без отката всего раннера.

**Self-hosting safety (BR-7 / AC-10):** в argv раннера нет литералов рестарта 8500 /
`docker compose up … orchestrator` / `--build` / force-push / правок `.env`/`docker-compose.yml`.
Раннер только исполняет pytest в worktree и делает read-only GET. Покрывается тестом запрета
литералов в его командах (зеркало TC ORCH-115).

### D4 — Маппинг exit-кода → `result:`: переиспользовать единый контракт (FR-3 / AC-3)

`result`-токен = трансляция `self_deploy.map_exit_code_to_status(returncode)`:
`SUCCESS → "PASS"`, `FAILED → "FAIL"` (т.е. `0 → PASS`; ненулевой / None / нечисло → `FAIL`,
fail-closed). **Второй несогласованный маппинг не вводится** — переиспользуется тот же пьюр-контракт
(`0→SUCCESS`-семантика, unit-tested), что у deploy-finalizer и staging-runner (BR-4). Разница лишь в
токенах (`result:` использует `PASS`/`FAIL`, а не `SUCCESS`/`FAILED`; `_TESTS_POSITIVE_TOKENS`/
`_TESTS_NEGATIVE_TOKENS`, `src/qg/checks.py:222-223`) — тонкая обёртка-транслятор поверх единого
маппера, не дубль логики. Smoke-результат **AND**-ится в итог отдельно (D3), exit-маппинг остаётся
чистой функцией одного входа (покрыт unit-тестом на каждый класс: `0`/≠0/`None`/нечисло).

### D5 — Двухуровневый исход: verdict vs tool-error (NFR-2 / AC-5 / R-4) — **ключевое решение**

Выбран **двухуровневый исход** (зеркало `staging_runner` D5, анти-ORCH-110):

- **Сюита ИСПОЛНИЛАСЬ** (`returncode is not None` и **не** `timed_out`) → доверяем коду: маппинг D4
  (+ smoke D3) → `result:` → инициировать гейт (D7). `PASS → testing → deploy-staging`;
  `FAIL → существующий откат testing → development` + developer-retry (тот же путь и cap
  `MAX_DEVELOPER_RETRIES`, что у FAIL-вердикта LLM — `stage_engine.py:849-892`; R-5/AC-4).
- **Сюита НЕ исполнилась** (tool-error: spawn-error / таймаут / `returncode is None`) — это
  **инфра-сбой, а не код-фейл**. Раннер делает **ограниченный DEFER**: re-queue свежего **`tester`**-джоба
  с задержкой `test_runner_infra_retry_delay_s` и **restart-safe маркером** `test-runner infra-retry`
  в `task_content` (счётчик — `COUNT(*)` маркера в persisted `jobs`, зеркало
  `staging_runner._infra_retry_count` / `stage_engine._merge_infra_retry_count`; **без правки схемы
  БД**, NFR-1). На **исчерпании** бюджета (`test_runner_infra_max_retries`) — **fail-closed**:
  записать `result: FAIL` + `advance_stage` (существующий откат) + один INFRA-alert (кликабельный
  номер, явно «инфра, НЕ дефект кода»). Так раннер **никогда** не делает тихий advance / ложный green
  и **никогда** не клинит очередь навсегда, но **не жжёт developer-retry на транзиентной инфре**.

**Почему не «tool-error → немедленный FAIL-откат»:** это в точности анти-паттерн ORCH-110
(инфра-таймаут, ошибочно маршрутизированный как код-фейл → откат на `development` + расход
developer-retry; на следующем retry падает так же → ручное вмешательство). Пьюр-маппинг D4 остаётся
fail-closed (None→FAIL) — это терминальный fallback **на исчерпании** defer, а не реакция на первый
же tool-error. **DEFER re-queue'ит `tester`-джоб** (не `deployer`!) — он повторно входит в этот же
раннер.

### D6 — Артефакт `13-test-report.md`: зеркало `write_staging_log` (FR-4 / AC-2 / AC-10)

Раннер пишет `docs/work-items/<work_item_id>/13-test-report.md` в worktree фичеветки
(`git_worktree.get_worktree_path`) литеральным блоком (как `staging_runner.build_staging_log`),
чтобы машинно-читаемый frontmatter был байт-точным:

```markdown
---
result: PASS                  # или FAIL — UPPERCASE, имя/регистр/токены ключа НЕ меняются
work_item: <work_item_id>
stage: testing
author_agent: test-runner
status: success               # или failed — ВЫРОВНЕН по result (см. D6.1!)
created_at: <YYYY-MM-DD>
model_used: n/a
exit_code: <returncode>
smoke: <ok|failed|skipped>
---

# Test Gate Log (deterministic runner, ORCH-116)
<exit-код pytest, краткий хвост stdout, итог smoke; вердикт зафиксирован детерминированным
test-раннером, не LLM>
```

- `author_agent: test-runner` / `model_used: n/a` честно отражают **детерминированного** продюсера;
  **machine-key `result:` и его UPPERCASE-значения/токены не меняются** (AC-2), читается
  неизменённым `_parse_tests_verdict`.
- Обязательная 52c-схема присутствует (`work_item`/`stage: testing`/`author_agent`/`status`/
  `created_at`/`model_used`).
- **Best-effort `git add/commit/push` в ФИЧЕВЕТКУ** (git-identity ORCH-101, актор `test-runner`,
  `_GIT_TIMEOUT`). Гейт читает worktree **первым** (`_repo_path:22-25`), поэтому **отдельный
  PR-merge лога в `main` НЕ выполняется** — исключение любой прямой работы с `main` усиливает
  AC-10/BR-7. Итоговый мерж фичеветки в `main` идёт штатным merge-gate/merge-verify-путём позже.

#### D6.1 — Анти-коллизия 52c-`status:` ↔ парсер вердикта (**специфично для tester, отсутствует в ORCH-115**)

**Сверено по коду:** `_parse_tests_verdict` (`src/qg/checks.py:263-277`) читает вердикт из **трёх**
равноранговых полей — `verdict:`, **`status:`** и `result:` — и применяет **negative-token-priority**:
любой негативный токен (`BLOCKED`/`FAILED`/`FAIL`/`REQUEST_CHANGES`/`REJECT`/`RED`) в `f"{verdict}
{status} {result}"` делает вердикт `False` авторитетно. У staging-гейта (ORCH-115) такой коллизии
**не было**: `_parse_staging_status` читает только `staging_status:`, а 52c-`status:` ему безразличен.
Здесь 52c-обязательное поле `status:` **читается тем же парсером, что и `result:`**.

**Следствие-мина:** если 52c-`status:` принимает значение, чей UPPERCASE содержит негативный токен
(например `status: failed` → `"FAILED"`), при `result: PASS` парсер вернёт `False` (негативный токен
авторитетнее) — **ложный FAIL** здорового прогона.

**Решение (инвариант):** 52c-`status:` раннера **ВСЕГДА выровнен по вердикту** и **никогда не
противоречит** `result:`:
- `result: PASS` → `status: success` (`"SUCCESS"` — не позитивный и **не** негативный токен;
  положительный токен `PASS` берётся из `result:` → парсер даёт `True`);
- `result: FAIL` → `status: failed` (`"FAILED"` — негативный токен, согласован с `FAIL` → `False`).

Это **тот же приём**, что `staging_runner.build_staging_log` (`status: success|failed` выровнен по
verdict'у) — но здесь он **обязателен по корректности**, а не косметика. Анти-дрейф: unit-тест,
проверяющий `_parse_tests_verdict(<тело раннера для PASS>) == (True, …)` и
`(<тело для FAIL>) == (False, …)` **через неизменённый парсер** (фиксирует, что 52c-`status:` не
ломает вердикт). Reviewer ловит любой `status:`-литерал с негативным токеном при `result: PASS` как
finding ≥P1.

### D7 — Инициация существующего гейта (FR-5 / AC-4 / R-2, граница O1)

После записи (и best-effort push) раннер вызывает:
```python
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` (и далее под-гейты ORCH-022/043/027/058 на ребре
  `deploy-staging → deploy` — **их раннер не трогает**);
- `FAIL` → существующий откат `testing → development` + developer-retry (`stage_engine.py:849`).

**`finished_agent="tester"` обязателен** (R-2): FAIL-ветвь матчит по `agent == "tester" and
qg_name == "check_tests_passed"` (`:849`); иной/`None` агент не запустит откат. **Никакой новой
ветви маршрутизации, никаких новых рёбер/исходов** (AC-4). **Граница O1:** transition-lease ORCH-114
берётся **внутри** `advance_stage` на side-effectful переходе — раннер его **не трогает**;
serial-gate ORCH-088 не взаимодействует (гейтит analyst-job claim). Код ORCH-112/ORCH-114/ORCH-115
(`staging_runner`) **не модифицируется**. never-raise.

### D8 — Kill-switch, скоуп и резолв тест-контракта: обратимость + backward-compat (FR-7 / AC-7 / AC-8 / BR-5 / BR-9)

`config.py` (паттерн `staging_runner_*`/`coverage_gate_*`):
- `test_runner_enabled: bool = True` (env `ORCH_TEST_RUNNER_ENABLED`).
- `test_runner_repos: str = ""` (env `ORCH_TEST_RUNNER_REPOS`; CSV; **пусто → self-hosting only**
  через `is_self_hosting_repo`).
- `test_runner_target: str = "tests/"` (env `ORCH_TEST_RUNNER_TARGET`; pytest-таргет, конвенция
  `merge_retest_target`).
- `test_runner_timeout_s: int = 900` (env `ORCH_TEST_RUNNER_TIMEOUT_S`; см. D9).
- `test_runner_smoke_enabled: bool = True` (env `ORCH_TEST_RUNNER_SMOKE_ENABLED`).
- `test_runner_infra_max_retries: int = 2`, `test_runner_infra_retry_delay_s: int = 30`
  (defer-бюджет D5; зеркало `staging_runner_infra_*`).

`applies(repo)` (локально, без сети, **never-raise → False**) — **проверяется первым** в
`should_intercept` (нулевой оверхед при выключенном флаге):
```text
1. test_runner_enabled == False                  -> False  (откат к LLM-пути)
2. in_scope = (membership в test_runner_repos)   если CSV непуст
            = is_self_hosting_repo(repo)          если CSV пуст
   not in_scope                                  -> False
3. _has_test_contract(repo)                       -> резолв тест-контракта (BR-9)
```
**`_has_test_contract(repo)` (BR-9 / AC-8) — отличие от ORCH-115.** У staging-раннера тест-контракт
был неявно self-hosting-bound (staging-контейнер существует только для `orchestrator`), отдельной
проверки не требовалось. Здесь резолв контракта вынесен явно: в **Phase 1** контракт известен по
умолчанию **только** для self-hosting (`return is_self_hosting_repo(repo)` — pytest+smoke);
для прочих репо контракта нет, пока не сконфигурирован (Phase 2) → `applies == False` →
**прежний LLM-tester** (fail-safe). Это делает AC-8 проверяемым: даже если в `test_runner_repos`
руками добавить не-self репо (`enduro-trails`), `_has_test_contract` вернёт `False` → раннер его не
перехватит → LLM-tester. enduro-trails и любой репо без контракта — 1:1 как до ORCH-116.

Откат = `ORCH_TEST_RUNNER_ENABLED=false` → `applies()` → `False` → `should_intercept` → `False` →
штатный `_spawn` прежнего LLM-tester'а на `testing` **байт-в-байт**.

### D9 — Бюджет времени (NFR-4 / AC-11, сквозной инвариант ORCH-065/109/110)

`test_runner_timeout_s = 900` (дефолт; малформ/непозитив → дефолт + WARNING, never-break — зеркало
`staging_runner._resolve_timeout` / `merge_gate._resolve_retest_timeout`). Обоснование **без правки
`reaper_max_running_s` (5400)**:

- **Ребро `testing` отдельно от ребра `deploy-staging`.** Гейт выхода из `testing` —
  `check_tests_passed` (читает файл, мгновенно). Окно «running» одного `tester`-джоба = только
  pytest+smoke (≤900s); тяжёлые под-гейты (security/merge re-test/coverage/image-freshness) живут на
  ребре `deploy-staging → deploy`, **не** на `testing`.
- **Σ(работ на ребре `testing`) НЕ растёт.** Прежний LLM-tester шёл под `agent_timeout_seconds`
  (сверено `config.py:159` = **1800s**; tester не имеет выделенного per-role ключа, в отличие от
  developer=3600/reviewer=3000). Раннер заменяет ≤1800s LLM-окно ограниченными ≤900s →
  `reaper_max_running_s (5400) > 900 + grace` сохранён **без** изменения reaper'а. Выбор 900s
  согласован с фактической длительностью регресс-сюиты (~517s, инцидент ORCH-110) и даёт ~74% запаса —
  тот же запас, что merge-retest-бюджет ORCH-110.

### D10 — Наблюдаемость (FR-8 / AC-13 / BR-6)

In-process счётчики `_TEST_RUNNER_COUNTERS` (зеркало `_STAGING_RUNNER_COUNTERS` /
`_MERGE_GATE_COUNTERS`): `runs`/`pass`/`fail`/`tool_error`/`deferred`. Read-only блок `test_runner` в
`GET /queue` (`enabled`/`repos`/`target`/`timeout_s`/`infra_max_retries`/счётчики) — `src/main.py`,
аддитивно, существующие ключи не трогаются. Один структурный лог-вердикт на прогон:
`work_item`/`repo`/`exit_code`/`result`/`duration_s`/`outcome` — различает «код упал» (`FAIL` от
сюиты/smoke) и «инструмент недоступен» (`tool_error`/`deferred`). Новых мутирующих эндпоинтов нет;
откат — через env-флаг.

### D11 — Гибрид: LLM строго off-control-path (BR-8 / NFR-7 / FR-9 / AC-12)

В Phase 1 на стадии `testing` (in-scope) вердикт `result:` производит **только** детерминированный
раннер; LLM **не вызывается** в потоке управления вердикта (ни happy-path, ни fail-path). Архитектура
раннера **не запрещает** будущий **опциональный off-control-path** LLM-триаж/диагностику **после**
детерминированного FAIL — но он будет **отдельной ролью/джобом**, который **не пишет и не
переопределяет** `result:` и **не добавляет ребро** в `STAGE_TRANSITIONS`. В этом срезе он **не
реализуется**. Это сохраняет `needs-hybrid-fallback`-природу A5: детерминированное ядро + (будущий)
LLM-фолбэк только на суждение.

### D12 — Норматив сопровождения LLM-карты/политики/витрины (NFR-6 / AC-14) — спека для developer

Карта/политика/roadmap и их анти-дрейф-тесты **связаны с состоянием кода**, поэтому правятся
**в development-стадии атомарно с кодом** (иначе тесты покраснеют на полу-готовой ветке — это же
причина, по которой ORCH-115 не правил их в architecture; зеркало ORCH-115 D11). README/internals/
паспорт/чейнджлог/витрина — там же. Архитектура фиксирует **точную спеку** правок (developer
применяет в том же PR):

- `docs/architecture/llm-call-sites.md` — строка **A5** и машинный `ORCH-118-INVENTORY-BLOCK`:
  tester на `testing` для in-scope репо больше не консультирует LLM в потоке управления; отразить
  реализованное детерминированное состояние (раннер-перехват до `_spawn`, как D1/D2), **сохранив**
  `avoidable=yes`/`axis=C`/`classification=needs-hybrid-fallback` (LLM-ветвь жива как fallback под
  выключенным флагом / для не-self репо / как будущий off-control-path триаж) — **зеркало** того, как
  ORCH-115 обновил A6/deployer. Заголовок таблицы и `output_consumer = _parse_tests_verdict` не
  менять (имя гейта/парсера неизменно).
- `docs/architecture/llm-determinization-roadmap.md` — §2 (tester) и машинный
  `ORCH-118-ROADMAP-BLOCK` rank 2: «второй кандидат» → «реализован (ORCH-116)». **Инвариант «ровно
  один `first_slice = yes`» держать корректным** — `first_slice` остаётся `yes` у **rank 1
  (deployer)**, у rank 2 (tester) — `no`; **не переключать** (см.
  `test_llm_determinization_docs.py`). `hybrid_needed = yes` у tester сохраняется (гибрид-природа).
- `docs/architecture/llm-usage-policy.md` — §5: единственный транспорт LLM-консультации
  (`_spawn`/S0) не нарушен; раннер LLM не зовёт; будущий off-control-path триаж — **не** новый
  транспорт control-path-консультации (он вне control-path).
- Анти-дрейф `tests/test_llm_call_site_inventory.py` / `tests/test_llm_determinization_docs.py` —
  обновить ожидания синхронно, держать зелёными (AC-14).
- Прочие docs того же PR (правило агентов №2 + витрина ORCH-011): `.openclaw/agents/tester.md`
  (пометка, что на `testing` для in-scope репо стадию ведёт детерминированный код; LLM-ветвь —
  fallback под выключенным флагом / для репо без контракта; канон промпта 52d — 5 секций, ключ
  `result:` — байт-в-байт), `docs/architecture/README.md` (новый компонент **Test-runner** в
  карте — зеркало записи **Staging-runner** + отметка «второй срез реализован» в блоке roadmap),
  `docs/architecture/internals.md` (примечание о перехвате на `testing`, рядом с ORCH-115),
  `CLAUDE.md`, `CHANGELOG.md`, `docs/overview/`.

**Обоснование против `llm-usage-policy.md` §5:** ORCH-116 **снимает** C-консультацию с деривируемым
PASS/FAIL-ядром (A5/tester) — это разрешённая реализация `needs-hybrid-fallback`, не ввод новой
LLM-консультации; политика соблюдена.

## Альтернативы

- **Новая стадия / новый QG для детерминированного testing** — отвергнуто: нарушает NFR-1
  (`STAGE_TRANSITIONS`/`QG_CHECKS` байт-в-байт). Меняется только *продюсер* артефакта; гейт и ребро
  прежние.
- **tool-error → немедленный `FAIL`-откат на `development`** — отвергнуто: анти-паттерн ORCH-110
  (инфра-сбой как код-фейл → расход developer-retry → петля). Выбран двухуровневый исход D5.
- **Свой второй exit-code→verdict маппинг** — отвергнуто: переиспользуем
  `self_deploy.map_exit_code_to_status` (BR-4, единый контракт), тонкий транслятор токенов поверх.
- **52c-`status:` произвольным значением (как чисто описательное поле)** — отвергнуто: `status:`
  **читается** `_parse_tests_verdict` с negative-token-priority → негативный токен в `status:` при
  `result: PASS` даёт ложный FAIL. Выбрано жёсткое выравнивание `status:` по вердикту (D6.1).
- **Прогон pytest в общем `/repos/orchestrator`** — отвергнуто: checkout-гонка с другими задачами
  (мина, закрытая ORCH-112); раннер исполняет pytest **в worktree ветки** (как coverage/merge-gate).
- **Merge лога отдельным PR в `main`** (как прежний tester) — отвергнуто: гейт читает worktree первым
  (`_repo_path`) → достаточно push в фичеветку; исключение прямой работы с `main` усиливает
  AC-10/BR-7.
- **Логика раннера прямо в `launcher.py`** — отвергнуто: нарушает разделение транспорт/решение; leaf
  тестируем без живого CLI (зеркало `staging_runner`/`coverage_gate`).
- **LLM-триаж как control-path-продюсер `result:`** — отвергнуто: BR-8/AC-12 (детерминированный
  раннер — единственный исполнитель вердикта; триаж — off-control-path, Phase 2).
- **Править `llm-call-sites.md`/roadmap/policy/README в architecture-стадии** — отвергнуто:
  анти-дрейф-тесты коуплены к коду; правки идут атомарно с кодом (D12, как ORCH-115).
- **DEFER через re-queue `deployer`-джоба** (копипаст из staging-раннера) — отвергнуто: DEFER должен
  re-queue'ить **`tester`**-джоб (он повторно входит в этот раннер на стадии `testing`).

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

- **+** На `testing` для `orchestrator` исчезает LLM-консультация в потоке управления вердикта:
  дешевле/быстрее/детерминированнее; минус один avoidable LLM control path (второй срез roadmap,
  rank 2).
- **+** Happy-path не вызывает `_spawn` (нет `agent_runs`-строки, нет токенов LLM на стадии `testing`).
- **+** Полная обратимость одним флагом; артефакт/гейт/ребро/схема БД неизменны → переключение
  туда-сюда не оставляет несовместимого состояния.
- **+** Двухуровневый исход (D5) убирает класс ORCH-110 (инфра ≠ код-фейл) с `testing`-ребра.
- **+** Гибрид-граница сохранена (D11): архитектура не закрывает путь к будущему off-control-path
  LLM-триажу, не пуская LLM обратно в поток управления.
- **−** Новый leaf + врезка в `launch_job` + defer-механика — рост поверхности кода. Митигейшн: leaf
  never-raise + kill-switch (fail-safe к LLM), тонкая врезка-зеркало D1/D2/ORCH-115, defer-счётчик без
  схемы БД (маркер в `task_content`), покрытие `tests/test_orch116_test_runner.py`.
- **−** Smoke зависит от достижимости запущенного оркестратора (8500) — разовый блип мог бы дать
  `FAIL`. Митигейшн: D3 (bounded smoke-ретрай транзиентной недостижимости + config-gate
  `test_runner_smoke_enabled` + developer-retry-cap); pytest остаётся первичным сигналом.
- **−** Тонкая мина 52c-`status:` ↔ парсер (D6.1) специфична для tester. Митигейшн: жёсткий инвариант
  выравнивания + unit-тест через неизменённый парсер; reviewer-ось ≥P1.
- **Откат:** `ORCH_TEST_RUNNER_ENABLED=false` → штатный `_spawn` LLM-tester'а на `testing`
  **байт-в-байт** до ORCH-116.

## Ссылки
- BRD: `docs/work-items/ORCH-116/01-brd.md`
- TRZ: `docs/work-items/ORCH-116/02-trz.md`
- Acceptance: `docs/work-items/ORCH-116/03-acceptance-criteria.md`
- Тест-план: `docs/work-items/ORCH-116/04-test-plan.yaml`
- Инфра: `docs/work-items/ORCH-116/07-infra-requirements.md`;
  Данные: `08-data-requirements.md`; Риски: `10-tech-risks.md`
- Сквозной ADR: `docs/architecture/adr/adr-0050-deterministic-test-runner.md`
- Эталон реализации: `src/staging_runner.py` (ORCH-115),
  `docs/work-items/ORCH-115/06-adr/ADR-001-deterministic-staging-runner.md`
- Сверено по коду: `src/agents/launcher.py:397/404/438`, `src/stages.py:17-18`,
  `src/qg/checks.py:15/182/222-223/226/263-277/528`, `src/stage_engine.py:849-892`,
  `src/self_deploy.py` (`map_exit_code_to_status`), `src/proc_group.py`, `src/config.py:159/162`
  (`agent_timeout_seconds`/`reaper_max_running_s`)
- Политика/карта/roadmap: `docs/architecture/llm-usage-policy.md`,
  `docs/architecture/llm-call-sites.md` (A5), `docs/architecture/llm-determinization-roadmap.md`
  (rank 2)
