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

# 03 — Критерии приёмки (Acceptance Criteria): ORCH-116 — детерминированный test-раннер

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

Формат: каждый критерий имеет **PASS** (что должно быть истинно для приёмки) и **FAIL**
(что считается провалом). Любой машинный/ручной reviewer проверяет их буквально по файлам
репозитория.

---

## AC-1 — Детерминированный перехват на `testing` (нет `_spawn`/LLM)

**Условие:** При включённом флаге и in-scope репо с тест-контрактом джоб `tester` на стадии
`testing` обрабатывается раннером, а не LLM-агентом.
- **PASS:** `launch_job` (`src/agents/launcher.py`) перехватывает джоб **до** `_spawn` (рядом с
  D1/D2/ORCH-115) при `agent=="tester"` + `test_runner.should_intercept(job)` (стадия задачи
  `testing` + `applies(repo)`); `_spawn` не вызывается; не создаётся строка `agent_runs`; джоб
  ведётся `mark_job(...)` самим раннером (`_run_test_runner_job` → `None`). Тест воспроизводит это
  без живого Claude CLI.
- **FAIL:** На `testing` для in-scope репо при включённом флаге всё ещё вызывается `_spawn` /
  создаётся `agent_runs`-строка LLM-tester'а.

---

## AC-2 — Контракт артефакта `13-test-report.md` неизменен

**Условие:** Раннер пишет тот же артефакт с тем же machine-key, что читает гейт.
- **PASS:** Создаётся `docs/work-items/<work_item_id>/13-test-report.md` с frontmatter
  `result: PASS|FAIL` (UPPERCASE) + обязательная 52c-схема (`work_item`/`stage: testing`/
  `author_agent`/`status`/`created_at`/`model_used`). **Неизменённый** `_parse_tests_verdict` читает
  его и возвращает корректный вердикт.
- **FAIL:** Изменено имя/регистр/токены ключа `result:` (или legacy `verdict:`/`status:`),
  отсутствует frontmatter, вердикт записан только прозой; либо парсер `_parse_tests_verdict` пришлось
  менять.

---

## AC-3 — Корректный exit-code → verdict маппинг

**Условие:** Exit-код тест-контракта детерминированно маппится в вердикт.
- **PASS:** `0 → PASS`; любой ненулевой / None / ошибка запуска → `FAIL` (fail-closed). Маппинг —
  pure-функция, согласованная по контракту с `self_deploy.map_exit_code_to_status` (токены `PASS`/
  `FAIL`), покрыта unit-тестом на каждый класс входа. Второй несогласованный маппинг не вводится.
- **FAIL:** Ненулевой код даёт `PASS`; ошибка/None даёт `PASS` (ложный green); раннер вводит второй
  несогласованный маппинг или нестандартные токены.

---

## AC-4 — Эквивалентность маршрутизации (PASS / FAIL)

**Условие:** После вердикта конвейер ведёт себя ровно как при завершившемся LLM-tester'е.
- **PASS:** PASS → раннер инициирует `advance_stage(finished_agent="tester")` → `check_tests_passed`
  → продвижение `testing → deploy-staging`. FAIL → существующий откат `testing → development` с
  инкрементом developer-retry и встраиванием `extract_test_failures` (`src/stage_engine.py:849-892`),
  тот же исход и cap `MAX_DEVELOPER_RETRIES`, что у FAIL-вердикта LLM.
- **FAIL:** Задача зависает на `testing` (гейт не инициирован); или FAIL не откатывает / откатывает
  иначе; или появляется новое ребро/исход.

---

## AC-5 — Two-level outcome: tool-error ≠ code-fail (анти-ORCH-110)

**Условие:** Невозможность исполнить сюиту трактуется как инфра-сбой, не как провал кода.
- **PASS:** Сюита исполнилась (реальный exit-код) → вердикт → advance (FAIL → существующий rollback +
  developer-retry). Сюита НЕ исполнилась (spawn-error / таймаут / `returncode None`) → bounded DEFER
  (re-queue `tester`-джоба + restart-safe маркер `test-runner infra-retry`, счётчик из persisted
  `jobs`), **без** отката на `development` и **без** расхода developer-retry; на исчерпании
  `test_runner_infra_max_retries` → fail-closed `result: FAIL` + advance + INFRA-alert (явно «НЕ дефект
  кода»).
- **FAIL:** Tool-error немедленно откатывает на `development` и жжёт developer-retry; либо tool-error
  даёт `PASS`/тихий advance; либо DEFER бесконечен (не клинит, но и не сходится к fail-closed).

---

## AC-6 — Инвариант скоупа: гейты/стадии/схема БД не тронуты (анти-дрейф)

**Условие:** Изменена только сторона *продюсера*, не контракт конвейера.
- **PASS:** `git diff` не затрагивает `src/stages.py::STAGE_TRANSITIONS`; имена/семантику
  `QG_CHECKS`/`check_tests_passed`/`_parse_tests_verdict`/прочих `check_*` в `src/qg/checks.py`;
  machine-verdict-ключи и токены (`result:`/`verdict:`/`status:`/`staging_status:`/`deploy_status:`/
  `security_status:`/`coverage_status:`); схему БД (нет новых таблиц/колонок/миграций). Анти-дрейф-тест
  это подтверждает.
- **FAIL:** Любой из перечисленных артефактов изменён по имени/семантике/структуре.

---

## AC-7 — Kill-switch и скоуп (обратимость)

**Условие:** Флаг возвращает прежнее поведение; скоуп ограничивает раннер.
- **PASS:** `test_runner_enabled=False` → на `testing` запускается прежний LLM-tester через `_spawn`
  (байт-в-байт до ORCH-116). Пустой `test_runner_repos` → раннер активен только для `orchestrator`.
  Покрыто тестом для обоих значений флага.
- **FAIL:** При выключенном флаге раннер всё равно перехватывает; либо не-self репо из скоупа
  перехватывается ошибочно.

---

## AC-8 — Backward-compatibility для репо без тест-контракта

**Условие:** Репо без резолвимого тест-контракта обслуживается прежним LLM-tester'ом.
- **PASS:** `applies(repo)` → `False`, когда тест-контракт для репо не сконфигурирован/не резолвится
  (вне скоупа или нет команды) → `should_intercept` → `False` → `_spawn` (LLM-tester). enduro-trails и
  любой репо без контракта — 1:1 как до ORCH-116. Покрыто тестом.
- **FAIL:** Репо без тест-контракта перехватывается раннером и остаётся без продюсера `13-test-report.md`
  / зависает.

---

## AC-9 — never-raise / fail-safe (инструмент недоступен)

**Условие:** Любая ошибка раннера приводит к безопасному детерминированному исходу.
- **PASS:** pytest не запустился / worktree-ошибка / I/O / таймаут → раннер не роняет воркер; исход —
  `FAIL` (fail-closed) **или** bounded DEFER (AC-5), **никогда** тихий advance/ложный green. Все
  публичные функции `test_runner.py` — never-raise; `applies()`/`should_intercept()` при ошибке →
  `False` (fall-through к `_spawn`). Очередь всех проектов не клинится.
- **FAIL:** Ошибка раннера роняет воркер/клинит очередь; либо ошибка/таймаут даёт `PASS`.

---

## AC-10 — Self-hosting safety

**Условие:** Раннер на `testing` не выполняет опасных для прода действий.
- **PASS:** Раннер не рестартит контейнер 8500, не выполняет `docker compose up -d orchestrator`/
  `--build`, не пушит force в `main`, не правит `.env`/`.env.staging`/`docker-compose.yml`. Smoke —
  строго read-only GET (`/health`/`/status`/`/queue`). Лог пушится только в фичеветку (merge в `main`
  — штатным merge-gate-путём). Подтверждается ревью кода раннера + тестом отсутствия запрещённых
  литералов в его командах.
- **FAIL:** Раннер содержит путь, рестартящий 8500 / force-push в `main` / правящий инфру / мутирующий
  smoke-запрос.

---

## AC-11 — Изоляция процесса и таймаут (proc_group / tree-kill)

**Условие:** pytest-subprocess ограничен по времени и не оставляет сирот.
- **PASS:** Раннер запускает pytest **в worktree ветки** через `proc_group.run_in_process_group`
  (отдельная группа процессов, tree-kill при таймауте, grace = `agent_kill_grace_seconds`) с таймаутом
  `test_runner_timeout_s` (согласован со сквозным бюджетом ORCH-065/109/110, без правки
  `reaper_max_running_s`); малформ/непозитив таймаут → дефолт + WARNING; осиротевших pytest-процессов
  не остаётся.
- **FAIL:** Нет таймаута / зависший subprocess клинит воркер; pytest бежит в общем `/repos/orchestrator`
  (checkout-гонка); остаются сироты процессов; правится `reaper_max_running_s`.

---

## AC-12 — Гибрид: LLM не в потоке управления вердикта

**Условие:** Детерминированный раннер — единственный исполнитель `result:`.
- **PASS:** В Phase 1 на стадии `testing` (in-scope) вердикт `result:` производит **только**
  детерминированный код; LLM не вызывается в happy-path и в fail-path для вынесения/переопределения
  `result:`. Если добавлен off-control-path триаж — он не пишет/не меняет `result:` и не добавляет
  ребро в `STAGE_TRANSITIONS`.
- **FAIL:** LLM вызывается для вынесения/переопределения машинного вердикта гейта; либо триаж-роль
  гейтит продвижение.

---

## AC-13 — Наблюдаемость

**Условие:** Исход раннера виден и различим.
- **PASS:** `GET /queue` содержит read-only блок `test_runner` (`enabled`/`repos`/`target`/`timeout_s`/
  счётчики `runs`/`pass`/`fail`/`tool_error`/`deferred`); на каждый прогон — один структурный
  лог-вердикт (`work_item`/`repo`/`exit_code`/`result`/`duration_s`/`outcome`), различающий код-фейл и
  tool-error.
- **FAIL:** Нет блока в `/queue`; исход раннера не логируется/не различим.

---

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

**Условие:** Документация обновлена в том же PR (правило агентов №2 + норматив ORCH-118).
- **PASS:** `docs/architecture/llm-call-sites.md` (строка A5) / `llm-determinization-roadmap.md`
  (rank 2) / `llm-usage-policy.md` отражают реализацию детерминированного tester (инвариант «ровно один
  `first_slice = yes`» НЕ нарушен); анти-дрейф-тесты (`tests/test_llm_call_site_inventory.py`,
  `tests/test_llm_determinization_docs.py`) зелёные; `.openclaw/agents/tester.md`,
  `docs/architecture/README.md`, `CLAUDE.md`, `CHANGELOG.md`, `docs/overview/` обновлены.
- **FAIL:** Карта/политика/roadmap/витрина не обновлены; анти-дрейф-тесты красные (reviewer: ≥P1).

---

## AC-15 — Полный регресс зелёный

**Условие:** Существующий конвейер не сломан.
- **PASS:** `pytest tests/ -q` зелёный; новый `tests/test_orch116_test_runner.py` зелёный;
  зелёные анти-дрейф LLM-тесты.
- **FAIL:** Любой ранее зелёный тест становится красным; новые тесты падают.

---

## Сводная матрица AC ↔ FR/BR
| AC | Покрывает |
|----|-----------|
| AC-1 | BR-1 / FR-1 |
| AC-2 | BR-2 / FR-4 |
| AC-3 | BR-4 / FR-2 / FR-3 |
| AC-4 | BR-3 / FR-5 |
| AC-5 | NFR-2 / FR-6 |
| AC-6 | NFR-1 / FR-7 |
| AC-7 | BR-5 / FR-7 |
| AC-8 | BR-9 / FR-1 / FR-7 |
| AC-9 | NFR-2 / FR-1 / FR-6 |
| AC-10 | BR-7 / FR-2 / FR-4 |
| AC-11 | NFR-3 / NFR-4 / FR-2 |
| AC-12 | BR-8 / NFR-7 / FR-9 |
| AC-13 | BR-6 / FR-8 |
| AC-14 | NFR-6 |
| AC-15 | NFR-5 / NFR-1 |
