Files
orchestrator/docs/work-items/ORCH-116/01-brd.md
claude-bot 50c48c2c03
All checks were successful
CI / test (push) Successful in 1m8s
analyst(ET): auto-commit from analyst run_id=738
2026-06-16 02:29:50 +03:00

225 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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-строки ~60150k токенов / 520 мин на прогон; точное число — `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/новый репо «застрянет» без продюсера отчёта.