42 KiB
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); гейт выхода из 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", …)(capMAX_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:
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):
- поднять
work_item_id/branchпоtask_id; - исполнить тест-контракт (D3) →
ProcResult(pytest) + smoke-итог; - определить исход (D5);
- на verdict-исходе: записать
13-test-report.md(D6) и вызватьadvance_stage(finished_agent="tester")(D7); - на tool-error-исходе: bounded DEFER (D5);
- учесть счётчики + структурный лог (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-onlyGETпротив запущенного оркестратора (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 (тот же путь и capMAX_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(*)маркера в persistedjobs, зеркало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-keyresult:и его 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) раннер вызывает:
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(envORCH_TEST_RUNNER_ENABLED).test_runner_repos: str = ""(envORCH_TEST_RUNNER_REPOS; CSV; пусто → self-hosting only черезis_self_hosting_repo).test_runner_target: str = "tests/"(envORCH_TEST_RUNNER_TARGET; pytest-таргет, конвенцияmerge_retest_target).test_runner_timeout_s: int = 900(envORCH_TEST_RUNNER_TIMEOUT_S; см. D9).test_runner_smoke_enabled: bool = True(envORCH_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=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-BLOCKrank 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-gatetest_runner_smoke_enabled+ developer-retry-cap); pytest остаётся первичным сигналом. - − Тонкая мина 52c-
status:↔ парсер (D6.1) специфична для tester. Митигейшн: жёсткий инвариант выравнивания + unit-тест через неизменённый парсер; reviewer-ось ≥P1. - Откат:
ORCH_TEST_RUNNER_ENABLED=false→ штатный_spawnLLM-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)