225 lines
23 KiB
Markdown
225 lines
23 KiB
Markdown
---
|
||
work_item: ORCH-116
|
||
stage: analysis
|
||
author_agent: analyst
|
||
status: ready-for-review
|
||
created_at: 2026-06-16
|
||
model_used: claude-opus-4-8
|
||
---
|
||
|
||
# 01 — BRD (бизнес-требования): ORCH-116 — заменить LLM-тестера детерминированным test-раннером
|
||
|
||
Work Item: **ORCH-116** · Repo: **orchestrator** · Стадия: analysis
|
||
|
||
## 1. Бизнес-контекст и проблема
|
||
|
||
Стадия `testing` сейчас исполняется **LLM-агентом `tester`** (`src/stages.py:17`,
|
||
`STAGE_TRANSITIONS["review"]["agent"] = "tester"` — tester запускается при входе в `testing`).
|
||
Фактическая работа агента на этой стадии в happy-path — **в основном детерминированная**
|
||
(`.openclaw/agents/tester.md`): прогнать регресс `pytest tests/ -v --tb=short` в worktree ветки,
|
||
сделать read-only smoke (`/health`, `/status`, `/queue` + наличие блока `serial_gate`),
|
||
собрать exit-код, записать `13-test-report.md` с машинным frontmatter `result: PASS|FAIL`. Гейт
|
||
`check_tests_passed` (`src/qg/checks.py:182` → `_parse_tests_verdict:226`) читает **только** ключ
|
||
`result:` из frontmatter — **не** прозу и **не** покрытие TC.
|
||
|
||
Это **avoidable LLM control path** по нормативной политике (`docs/architecture/llm-usage-policy.md`):
|
||
(i) это **C-консультация** — вердикт `result:` потребляется гейтом `check_tests_passed`, и
|
||
(ii) вердикт PASS/FAIL **деривируем** из exit-кода `pytest` + smoke (детерминированные сигналы).
|
||
Карта вызовов (`docs/architecture/llm-call-sites.md`, строка **A5**) классифицирует tester как
|
||
**`needs-hybrid-fallback`**, а roadmap (`docs/architecture/llm-determinization-roadmap.md`, машинный
|
||
блок §4) ставит его **rank 2** с `hybrid_needed = yes`, `first_slice = no`. Это **второй срез**
|
||
determinization-roadmap — после реализованного первого среза **ORCH-115** (детерминированный
|
||
`staging_runner`, deployer на `deploy-staging`).
|
||
|
||
**Гибридная природа (ключевое отличие от ORCH-115).** Tester — `needs-hybrid-fallback`, не
|
||
`replace-deterministic-now`: его **PASS/FAIL-ядро** полностью детерминируемо (exit-код pytest +
|
||
smoke), но часть работы прежнего промпта — **триаж падений**, **анализ пробелов покрытия
|
||
AC**, **сопоставление TC ↔ критерии приёмки** и **человекочитаемая диагностика** — это настоящее
|
||
суждение. ORCH-116 выносит из потока управления **только PASS/FAIL-исполнителя** (его делает
|
||
детерминированный код); опциональный LLM-аналитик допустим как **off-control-path** триаж/
|
||
диагностика после детерминированного провала, но **никогда** как первичный исполнитель вердикта
|
||
гейта (BR-8 / NFR-7).
|
||
|
||
**Боль / риск, который закрываем:**
|
||
- **Недетерминизм в потоке управления.** Решение «advance на `deploy-staging` или rollback на
|
||
`development`» на стадии `testing` зависит от LLM-сессии (стоимость, латентность, риск
|
||
галлюцинации/неверного вердикта), хотя сводится к exit-коду pytest + smoke.
|
||
- **Стоимость и латентность.** Каждый прогон tester'а тратит токены/время opus-агента (оценка по
|
||
`agent_runs`: tester-строки ~60–150k токенов / 5–20 мин на прогон; точное число — `GET /metrics`)
|
||
ради действия, которое выполняется одним прогоном pytest + несколькими read-only GET.
|
||
- **Избыточность с уже-зелёным сигналом.** Регресс уже прогоняется в CI (`check_ci_green` гейтит
|
||
`development → review`, `src/qg/checks.py:82`), повторно в merge-gate re-test (ORCH-043/110) и в
|
||
coverage-gate (ORCH-027). Повторный прогон pytest на стадии `testing` — подтверждение факта, а не
|
||
суждение → естественный кандидат на детерминизацию.
|
||
- **Класс инцидентов «LLM принял решение, которое есть исполнение фиксированных команд + маппинг
|
||
результата»** — тот же RCA-трек, что ORCH-110/111/112/113/114/117/115.
|
||
|
||
Установленные факты (не изобретать):
|
||
- Гейт `check_tests_passed`/`_parse_tests_verdict` читает **только** `result:` (плюс legacy-поля
|
||
`verdict:`/`status:`, ORCH-047) из frontmatter `13-test-report.md`; покрытие TC / сопоставление с
|
||
AC гейтом **не парсится** (это была нагрузка промпта, не требование гейта). Значит замена
|
||
*продюсера* `result:` детерминированным кодом сохраняет контракт гейта байт-в-байт.
|
||
- Детерминированный прецедент замены агента **до `_spawn`** уже работает и проверен: `launch_job`
|
||
перехватывает `deploy-finalizer` (D1, `src/agents/launcher.py:397`), `post-deploy-monitor`
|
||
(D2, `:402`) и **staging-runner** (ORCH-115, `:405-408`) до `_spawn`; `src/staging_runner.py` —
|
||
готовый эталон leaf-раннера (two-level outcome, never-raise, kill-switch, proc_group).
|
||
- Изоляция спавненного pytest уже решена: `src/proc_group.py::run_in_process_group` (ORCH-110)
|
||
даёт tree-kill (`os.killpg`, каскад SIGTERM→grace→SIGKILL) — корень CPU-голодания от
|
||
осиротевших pytest (инцидент ORCH-109/111) закрыт; раннер обязан использовать его.
|
||
- Маппинг exit-кода — тривиальная pure-функция (`0 → PASS`, иначе `FAIL`), зеркало
|
||
`self_deploy.map_exit_code_to_status` (но в токенах `PASS`/`FAIL`, а не `SUCCESS`/`FAILED`).
|
||
|
||
## 2. Объём (scope)
|
||
|
||
### В объёме (Phase 1)
|
||
- **Детерминированный test-раннер** для стадии `testing` репо `orchestrator` (self-hosting):
|
||
исполняет «тест-контракт» (сконфигурированные test/smoke-команды) в worktree ветки задачи,
|
||
маппит exit-код в `result: PASS|FAIL`, пишет `13-test-report.md`, инициирует существующий гейт
|
||
`check_tests_passed` — **без** запуска LLM-агента `tester`.
|
||
- **Тест-контракт** — сконфигурированный набор команд: обязательная регресс-команда
|
||
(`pytest tests/`, переиспользуя конвенцию `merge_retest_target`) + опциональные read-only
|
||
smoke-проверки (зеркало шага 3 промпта tester: `/health`, `/status`, `/queue` + наличие блока
|
||
`serial_gate`). Для self-hosting `orchestrator` контракт известен по умолчанию.
|
||
- Раннер активируется через **перехват в `launch_job` до `_spawn`** (прецедент D1/D2/ORCH-115),
|
||
**без правки `src/stages.py`/`STAGE_TRANSITIONS`** (роль `tester` в словаре остаётся; меняется лишь
|
||
*кто* обрабатывает джоб на стадии `testing` для in-scope репо с тест-контрактом).
|
||
- После выпуска вердикта раннер инициирует **существующую** оценку exit-гейта `check_tests_passed`
|
||
ровно как завершившийся LLM-tester (`advance_stage(finished_agent="tester")`): PASS → `deploy-staging`
|
||
(и далее под-гейты ORCH-022/043/027/058 на ребре `deploy-staging → deploy`); FAIL → существующий
|
||
откат `testing → development` + developer-retry (`src/stage_engine.py:849`).
|
||
- Two-level outcome (анти-ORCH-110, по образцу `staging_runner`): сюита **исполнилась** → вердикт →
|
||
advance; сюита **не исполнилась** (tool-error: spawn-error/таймаут/`returncode None`) → bounded
|
||
DEFER, на исчерпании → fail-closed `FAIL` + advance + alert. Инфра-сбой **не жжёт** developer-retry.
|
||
- Kill-switch + скоуп-CSV + **тест-контракт** (паттерн ORCH-022/027/043/089/090/115): `*_enabled`
|
||
(откат к LLM-пути), `*_repos` (пусто → self-hosting only), backward-compat: репо без тест-контракта
|
||
→ раннер не применяется → прежний LLM-tester.
|
||
- Наблюдаемость: read-only блок в `GET /queue` + структурный лог вердикта.
|
||
|
||
### Вне объёма (явно НЕ делаем в ORCH-116)
|
||
- **ORCH-115 (детерминированный deploy/staging-раннер)** — **по явной границе задачи не смешиваем**:
|
||
ORCH-116 не модифицирует `src/staging_runner.py` и не трогает ребро `deploy-staging`/`deploy`.
|
||
- **LLM-роли `reviewer` и `developer`** — **остаются без изменений** (граница задачи). reviewer —
|
||
control-path-но-keep (вердикт `verdict:` не деривируем из tool-сигнала, `llm-call-sites.md`).
|
||
- **Реализация опционального off-control-path LLM-триажа/диагностики после FAIL** — не делается в
|
||
этом срезе (forward-looking, §6 BRD и §8 TRZ). Архитектура раннера **не должна запрещать** её
|
||
(NFR-7), но и не реализует.
|
||
- **Сопоставление TC ↔ критерии приёмки / анализ пробелов покрытия AC как условие гейта** — гейт
|
||
`check_tests_passed` его не требует (читает только `result:`); в потоке управления его нет. Это
|
||
off-control-path диагностика (см. выше).
|
||
- **Любая правка `STAGE_TRANSITIONS` / реестра и имён `QG_CHECKS` / семантики `check_tests_passed` /
|
||
`_parse_tests_verdict` / machine-verdict-ключей (`result:`/`verdict:`/`status:`) / схемы БД**
|
||
(см. NFR-1).
|
||
- **Замена/правка `check_ci_green` / merge-gate re-test / coverage-gate** — они продолжают работать
|
||
как есть; ORCH-116 меняет только продюсера `13-test-report.md`.
|
||
|
||
## 3. Заинтересованные стороны
|
||
|
||
- **Заказчик / Owner** (`homenet542@gmail.com`) — инициатор детерминизации LLM-control-path'ов.
|
||
- **Платформа orchestrator (self-hosting)** — прямой потребитель: дешевле/быстрее/детерминированнее
|
||
собственная стадия `testing`.
|
||
- **Другие проекты на общем инстансе** (enduro-trails) — НЕ затронуты в Phase 1 (скоуп self-hosting +
|
||
backward-compat для репо без тест-контракта); выигрывают позже от Phase 2 (project test contract).
|
||
- **Reviewer / Developer-роли конвейера** — принимают результат через неизменные гейты; их LLM-роли
|
||
не трогаются.
|
||
|
||
## 4. Бизнес-требования (BR)
|
||
|
||
- **BR-1 — Детерминированный PASS/FAIL без LLM.** На стадии `testing` для in-scope репо с
|
||
тест-контрактом вердикт `result:` производится детерминированным кодом (исполнение тест-контракта
|
||
+ маппинг exit-кода), **без** консультации LLM. Happy-path `testing` не вызывает `_spawn`.
|
||
- **BR-2 — Контракт артефакта неизменен.** Раннер пишет тот же `13-test-report.md` с тем же
|
||
frontmatter-ключом `result: PASS|FAIL`, который читает `check_tests_passed`/`_parse_tests_verdict`.
|
||
Гейт и парсер байт-в-байт не меняются.
|
||
- **BR-3 — Эквивалентность маршрутизации.** PASS → продвижение на `deploy-staging` через
|
||
существующий путь; FAIL → существующий откат `testing → development` + инкремент developer-retry
|
||
(тот же путь и cap `MAX_DEVELOPER_RETRIES`, что у FAIL-вердикта LLM-tester'а, `src/stage_engine.py:849`).
|
||
Никаких новых рёбер/исходов.
|
||
- **BR-4 — Переиспользование существующей инфраструктуры.** Раннер исполняет pytest через
|
||
`proc_group.run_in_process_group` (ORCH-110, tree-kill), переиспользует exit-code→verdict-маппинг
|
||
(зеркало `self_deploy.map_exit_code_to_status`, в токенах `PASS`/`FAIL`) и конвенцию таргета
|
||
(`merge_retest_target`/`tests/`). Не плодит второй несогласованный маппинг/механизм.
|
||
- **BR-5 — Обратимость одним флагом.** Глобальный kill-switch возвращает прежний LLM-tester-путь на
|
||
`testing` байт-в-байт; скоуп-CSV ограничивает раннер in-scope репо (пусто → только `orchestrator`).
|
||
- **BR-6 — Наблюдаемость.** Исход раннера (запущен / PASS / FAIL / tool-error / defer) виден в
|
||
`GET /queue` и в структурном логе; «код упал» (детерминированный FAIL) и «инструмент недоступен»
|
||
(tool-error) различимы.
|
||
- **BR-7 — Self-hosting safety.** Раннер на `testing` **никогда** не рестартит прод-контейнер 8500,
|
||
не трогает `main` force-push'ем, не правит `.env`/`docker-compose.yml`. Он лишь читает, исполняет
|
||
тест-контракт в worktree (pytest) и read-only smoke против 8500, пишет лог и best-effort пушит лог
|
||
в фичеветку (merge в `main` — штатным merge-gate-путём).
|
||
- **BR-8 — Гибрид: LLM только off-control-path.** Детерминированный раннер — **единственный**
|
||
исполнитель вердикта `result:`. LLM на стадии `testing` допустим лишь как опциональный
|
||
off-control-path триаж/диагностика после детерминированного FAIL и **не** выносит/не переопределяет
|
||
машинный вердикт гейта. В Phase 1 он не реализуется (NFR-7), но архитектурно не запрещён.
|
||
- **BR-9 — Backward-compatibility для репо без тест-контракта.** Репо, для которого тест-контракт не
|
||
сконфигурирован/не резолвится, раннер **не перехватывает** → стадию `testing` ведёт прежний
|
||
LLM-tester (fail-safe). enduro-trails и любые будущие репо без контракта — 1:1 как до ORCH-116.
|
||
|
||
## 5. Нефункциональные требования (NFR)
|
||
|
||
- **NFR-1 — Скоуп-инвариант (анти-дрейф).** `STAGE_TRANSITIONS` (`src/stages.py`), реестр и имена
|
||
`QG_CHECKS`/`check_tests_passed`/`_parse_tests_verdict` (`src/qg/checks.py`), machine-verdict-ключи
|
||
(`result:`/`verdict:`/`status:`/`staging_status:`/`deploy_status:`/`security_status:`/`coverage_status:`),
|
||
схема БД — **байт-в-байт не тронуты**. Это замена *продюсера* артефакта, не гейта.
|
||
- **NFR-2 — never-raise / fail-safe.** Любая ошибка раннера (pytest не запустился, таймаут, I/O) →
|
||
безопасный детерминированный исход без падения воркера: либо `FAIL` (fail-closed, никогда ложный
|
||
green), либо штатный bounded requeue/defer — **не** «тихий advance». Сбой раннера не клинит очередь
|
||
всех проектов. **Tool-error ≠ code-fail:** инфра-сбой не жжёт developer-retry (анти-ORCH-110).
|
||
- **NFR-3 — Изоляция процесса / таймаут.** Спавненный pytest исполняется через `proc_group`
|
||
(tree-kill, ORCH-110); сирот pytest не оставляет; ограниченный таймаут.
|
||
- **NFR-4 — Сквозные бюджеты времени.** Таймаут раннера согласован со сквозным инвариантом
|
||
ORCH-065/109/110 (`reaper_max_running_s` > Σ(работ на ребре) + grace) — **без** правки
|
||
`reaper_max_running_s`. Ребро `testing` отдельно от ребра `deploy-staging`; бюджет ≤ окна, которое
|
||
раннер замещает (прежний tester шёл под `agent_timeout_seconds`).
|
||
- **NFR-5 — Совместимость с не-self репо.** Для репо вне скоупа / без тест-контракта `testing` ведёт
|
||
себя 1:1 как до ORCH-116 (LLM-tester). enduro-trails не затронут.
|
||
- **NFR-6 — Соответствие политике LLM.** Изменение снимает LLM-консультацию A5 из потока управления;
|
||
карта `docs/architecture/llm-call-sites.md` (A5) / `llm-determinization-roadmap.md` (rank 2) /
|
||
`llm-usage-policy.md` и анти-дрейф-тесты обновляются **в том же PR** (норматив сопровождения ORCH-118).
|
||
- **NFR-7 — Не запрещать будущий off-control-path LLM-триаж.** Архитектура раннера не должна
|
||
архитектурно исключать опциональный LLM debug/triage-аналитик после детерминированного FAIL
|
||
(будущее улучшение); в ORCH-116 он не реализуется.
|
||
|
||
## 6. Допущения и ограничения
|
||
|
||
- **Допущение А1.** Регресс-сюита `orchestrator` (`pytest tests/`) исполняема в worktree ветки задачи
|
||
и её exit-код — авторитетный сигнал PASS/FAIL (как уже трактуют CI / merge-gate re-test / coverage-gate).
|
||
- **Допущение А2.** Для self-hosting `orchestrator` тест-контракт известен по умолчанию
|
||
(pytest + read-only smoke против 8500). Для прочих репо контракт отсутствует, пока не сконфигурирован
|
||
(Phase 2) → раннер их не перехватывает (BR-9).
|
||
- **Допущение А3.** Перехват «до `_spawn`» по имени джоб-роли (`tester`) + стадии задачи (`testing`)
|
||
— достаточный механизм диспетчеризации (как D1/D2/ORCH-115); конкретный механизм финализирует
|
||
архитектор (06-adr).
|
||
- **Ограничение О1.** Граница задачи: не смешивать с ORCH-115 (его код не модифицируется); LLM-роли
|
||
`reviewer`/`developer` не трогаются; код ORCH-112/114 не модифицируется.
|
||
- **Ограничение О2.** Phase 2 (project test contract для не-self репо + опциональный off-control-path
|
||
LLM-триаж) — отдельный follow-up; ORCH-116 закрывает только Phase 1.
|
||
|
||
## 7. Критерии успеха
|
||
|
||
Стадия `testing` для `orchestrator` проходит без запуска LLM-агента `tester`: детерминированный
|
||
раннер исполняет тест-контракт (pytest + smoke), пишет корректный `13-test-report.md` (`result:
|
||
PASS|FAIL`), инициирует неизменный гейт `check_tests_passed`, и конвейер продвигается
|
||
(`testing → deploy-staging`) / откатывается (`testing → development` + developer-retry) ровно как
|
||
раньше — при неизменных `STAGE_TRANSITIONS`/`QG_CHECKS`/гейтах/схеме БД, под kill-switch с откатом к
|
||
прежнему LLM-поведению, с backward-compat для репо без тест-контракта. Детальные PASS/FAIL —
|
||
`03-acceptance-criteria.md`.
|
||
|
||
## 8. Риски
|
||
|
||
Краткий перечень (детали — `10-tech-risks.md`, заполняет архитектор):
|
||
- **R-1** — точка диспетчеризации «до `_spawn`» должна корректно отличать testing-tester от любого
|
||
иного джоба (по роли + стадии задачи), иначе можно перехватить не тот джоб.
|
||
- **R-2** — после выпуска вердикта нужно надёжно инициировать `advance_stage(finished_agent="tester")`,
|
||
иначе задача зависнет на `testing` (нет «финиша агента», который раньше триггерил гейт).
|
||
- **R-3** — таймаут/изоляция pytest-subprocess; утечка процессов (корень инцидента ORCH-109/110/111) —
|
||
обязателен `proc_group` tree-kill.
|
||
- **R-4** — корректность two-level outcome: tool-error не должен жечь developer-retry (анти-ORCH-110),
|
||
но и не давать ложный green/тихий advance.
|
||
- **R-5** — корректность отката FAIL (developer-retry cap, встраивание `extract_test_failures`) —
|
||
должна совпасть с LLM-путём `src/stage_engine.py:849`.
|
||
- **R-6** — гибрид: не протащить LLM обратно в поток управления вердикта (BR-8); off-control-path
|
||
триаж — отдельная роль/джоб, не выносящая `result:`.
|
||
- **R-7** — backward-compat: репо без тест-контракта обязаны откатываться на LLM-tester (BR-9), иначе
|
||
enduro/новый репо «застрянет» без продюсера отчёта.
|