Files
orchestrator/docs/work-items/ORCH-116/06-adr/ADR-001-deterministic-test-runner.md

42 KiB
Raw Blame History

work_item, stage, author_agent, status, created_at, model_used
work_item stage author_agent status created_at model_used
ORCH-116 architecture architect proposed 2026-06-16 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-0049-deterministic-test-runner.md (решение кросс-каттинговое — вводит новый компонент-leaf src/test_runner.py и реализует второй срез determinization-roadmap; влияет на карту LLM-консультаций всего оркестратора).

Статус

Proposed

Контекст

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

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

То есть testerединственный агент, запускаемый при входе в testing (поле agent ребра review → testing); гейт выхода из testingcheck_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:849if 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: ~60150k / 520 мин на прогон) ради действия = один прогон 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:

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 / appliesnever-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 был байт-точным:

---
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: PASSstatus: success ("SUCCESS" — не позитивный и не негативный токен; положительный токен PASS берётся из result: → парсер даёт True);
  • result: FAILstatus: failed ("FAILED" — негативный токен, согласован с FAILFalse).

Это тот же приём, что 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) раннер вызывает:

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 (нулевой оверхед при выключенном флаге):

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=falseapplies()Falseshould_interceptFalse → штатный _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. Гейт выхода из testingcheck_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-0049-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)