From 9e543551aa2d00b1f45d30fdd329daa11be3f009 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Tue, 16 Jun 2026 02:29:50 +0300 Subject: [PATCH] analyst(ET): auto-commit from analyst run_id=738 --- docs/work-items/ORCH-116/01-brd.md | 224 ++++++++++++++++++ docs/work-items/ORCH-116/02-trz.md | 195 +++++++++++++++ .../ORCH-116/03-acceptance-criteria.md | 216 +++++++++++++++++ docs/work-items/ORCH-116/04-test-plan.yaml | 116 +++++++++ 4 files changed, 751 insertions(+) create mode 100644 docs/work-items/ORCH-116/01-brd.md create mode 100644 docs/work-items/ORCH-116/02-trz.md create mode 100644 docs/work-items/ORCH-116/03-acceptance-criteria.md create mode 100644 docs/work-items/ORCH-116/04-test-plan.yaml diff --git a/docs/work-items/ORCH-116/01-brd.md b/docs/work-items/ORCH-116/01-brd.md new file mode 100644 index 0000000..ef412b1 --- /dev/null +++ b/docs/work-items/ORCH-116/01-brd.md @@ -0,0 +1,224 @@ +--- +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/новый репо «застрянет» без продюсера отчёта. diff --git a/docs/work-items/ORCH-116/02-trz.md b/docs/work-items/ORCH-116/02-trz.md new file mode 100644 index 0000000..9edfa8d --- /dev/null +++ b/docs/work-items/ORCH-116/02-trz.md @@ -0,0 +1,195 @@ +--- +work_item: ORCH-116 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-16 +model_used: claude-opus-4-8 +--- + +# 02 — ТЗ (TRZ): ORCH-116 — детерминированный test-раннер вместо LLM-тестера + +Work Item: **ORCH-116** · Repo: **orchestrator** · Стадия: analysis + +> ТЗ описывает **конкретные изменения к реализации**, выведенные из BRD и фактического кода. +> Архитектурное обоснование (точный механизм перехвата, размещение раннера, способ инициации +> `advance_stage`, форма тест-контракта, лестница таймаутов, two-level outcome) — задача +> архитектора (`06-adr/`). Здесь — требования и привязка к реальным модулям `src/`. + +## 1. Сводка изменения + +Заменить **LLM-агента `tester`** на стадии `testing` (для self-hosting `orchestrator`) +**детерминированным test-раннером**, перехватываемым в `launch_job` **до `_spawn`** (прецедент +`deploy-finalizer`/`post-deploy-monitor`/`staging_runner`, `src/agents/launcher.py:397/402/405`). +Раннер исполняет «тест-контракт» (регресс `pytest tests/` через `proc_group.run_in_process_group` ++ опциональные read-only smoke-проверки), маппит exit-код в `result:` (`0→PASS`, иначе `FAIL`), +пишет `13-test-report.md`, best-effort пушит лог в фичеветку, затем инициирует **существующую** +оценку exit-гейта `check_tests_passed` ровно как завершившийся LLM-tester. Контракт артефакта, гейт, +`STAGE_TRANSITIONS`, схема БД — **неизменны**. Под kill-switch + скоуп-CSV + тест-контракт; +never-raise; fail-closed; two-level outcome (анти-ORCH-110). Эталон реализации — `src/staging_runner.py` +(ORCH-115). + +## 2. Задействованные модули / пути + +| Путь | Действие | Назначение | +|------|----------|------------| +| `src/test_runner.py` *(новый leaf)* | создать | Детерминированный раннер: `applies(repo)` (kill-switch + скоуп + наличие тест-контракта), `should_intercept(job)` (роль `tester` + стадия `testing`), исполнение тест-контракта (`pytest` через `proc_group`, опц. smoke), маппинг exit-кода → `result:`, запись `13-test-report.md`, best-effort push в фичеветку, инициация гейта через `advance_stage(finished_agent="tester")`, two-level outcome (tool-error DEFER), снапшот для `/queue`. Leaf-чистота по образцу `staging_runner.py`/`self_deploy.py`: на импорте только `config`/`logging`/`proc_group`; `db`/`git_worktree`/`self_deploy`/`qg.checks`/`stage_engine`/`notifications` — лениво внутри функций; never-raise. | +| `src/agents/launcher.py` | изменить | В `launch_job` добавить перехват **до `_spawn`** (рядом с D1/D2/ORCH-115): `if job.get("agent")=="tester": from .. import test_runner; if test_runner.should_intercept(job): return self._run_test_runner_job(job)`. Новый метод `_run_test_runner_job(job)` — зеркало `_run_staging_runner_job`: синхронно ведёт `jobs`-строку через `mark_job(done|failed)`, возвращает `None` (нет `agent_runs`). | +| `src/config.py` | изменить | Добавить ключи (зеркало `staging_runner_*`/`merge_retest_*`): `test_runner_enabled: bool = True` (env `ORCH_TEST_RUNNER_ENABLED`), `test_runner_repos: str = ""` (env `ORCH_TEST_RUNNER_REPOS`; пусто → self-hosting only), `test_runner_target: str = "tests/"` (pytest-таргет тест-контракта, конвенция `merge_retest_target`), `test_runner_timeout_s: int = 900` (см. FR-2/NFR-4), `test_runner_smoke_enabled: bool = True` (опц. read-only smoke), `test_runner_infra_max_retries: int = 2`, `test_runner_infra_retry_delay_s: int = 30`. Дефолты = боевое; пустой `.env` ⇒ поведение для in-scope. | +| `src/main.py` (`GET /queue`) | изменить | Read-only блок `test_runner` (флаг/скоуп/таргет/счётчики исходов) — наблюдаемость BR-6 (зеркало блока `staging_runner`). | +| `.openclaw/agents/tester.md` | изменить (docs) | Отметить, что на `testing` для in-scope репо с тест-контрактом стадию ведёт детерминированный код (зеркало формулировки `deployer.md` про staging-runner ORCH-115); LLM-ветвь `testing` остаётся fallback'ом под выключенным флагом / для репо без тест-контракта. Канон промпта 52d (5 секций, ключ `result:`) — байт-в-байт. | +| `docs/architecture/llm-call-sites.md`, `llm-determinization-roadmap.md`, `llm-usage-policy.md` | изменить (docs) | Норматив сопровождения ORCH-118 (NFR-6): отразить реализацию A5 (tester) — обновить инвентарь/политику/roadmap (rank 2 → реализовано, инвариант «ровно один `first_slice = yes`» НЕ нарушать) в том же PR; синхронно поправить `tests/test_llm_call_site_inventory.py` / `tests/test_llm_determinization_docs.py` (машинные блоки). | +| `docs/architecture/README.md`, `CLAUDE.md`, `CHANGELOG.md`, `docs/overview/` | изменить (docs) | Компонент-карта/паспорт/чейнджлог/витрина — правило для агентов №2 + витрина ORCH-011. | +| `tests/test_orch116_test_runner.py` *(новый)* | создать | Покрытие (см. `04-test-plan.yaml`). | + +> **Не трогать (NFR-1):** `src/stages.py::STAGE_TRANSITIONS`; имена/семантику `QG_CHECKS`/ +> `check_tests_passed`/`_parse_tests_verdict`/прочих `check_*` в `src/qg/checks.py`; machine-verdict-ключи +> (`result:`/`verdict:`/`status:`/…); `src/staging_runner.py` (ORCH-115); LLM-роли `reviewer`/`developer` +> (`.openclaw/agents/reviewer.md`/`developer.md`); `src/transition_lease.py` (ORCH-114); +> `src/checkout_hygiene.py` (ORCH-112); `src/proc_group.py` (переиспользуем как есть); схему БД. + +## 3. Функциональные требования + +### FR-1 — Детерминированный перехват на `testing` (без `_spawn`) +В `launch_job` (`src/agents/launcher.py`) **до** вызова `_spawn`, по образцу D1/D2/ORCH-115: если +`job.agent == "tester"` **и** `test_runner.should_intercept(job)` истинно → не вызывать `_spawn`, а +исполнить раннер синхронно (`_run_test_runner_job`). Контракт: возвращает `None` (нет `agent_runs`), +сам ведёт `jobs`-строку (`mark_job(done|failed)`) как `_run_staging_runner_job`. +- `should_intercept(job)`: `job.agent == "tester"` **И** `applies(job.repo)` **И** стадия задачи + (`tasks.stage` по `job.task_id`) == `testing`. Роль `tester` исполняет **только** стадию `testing` + (единственный `agent` для входа в `testing`, `STAGE_TRANSITIONS["review"]["agent"]`), поэтому + коллизии стадий нет; гард по стадии — defense-in-depth (R-1). never-raise → `False` (DB-сбой → + fall-through к `_spawn`, fail-safe к LLM-пути). +- `applies(repo)`: `test_runner_enabled=False` → `False` (откат к LLM-пути); непустой + `test_runner_repos` → membership; пустой CSV → `is_self_hosting_repo(repo)`; **и** тест-контракт + для репо резолвится (BR-9: иначе `False` → LLM-tester). Никакой сети, проверяется **первым** + (нулевой оверхед при выключенном флаге). never-raise → `False` (fail-safe к LLM-пути). + +### FR-2 — Исполнение тест-контракта (pytest + опц. smoke) через `proc_group` +Раннер исполняет регресс-команду тест-контракта — `python -m pytest ` (дефолт +`tests/`) — **в worktree ветки задачи** (`git_worktree.get_worktree_path(repo, branch)`, НЕ в общем +`/repos/orchestrator`: анти-checkout-гонка, как требовал промпт tester шаг 2) через +`proc_group.run_in_process_group` (ORCH-110: отдельная группа процессов, tree-kill SIGTERM→grace→SIGKILL, +grace = `agent_kill_grace_seconds`; `subprocess_tree_kill_enabled`). Таймаут — `test_runner_timeout_s` +(дефолт 900; малформ/непозитив → дефолт + WARNING, never-break — зеркало +`merge_gate._resolve_retest_timeout`/`staging_runner._resolve_timeout`). Захватывает exit-код и stdout +(для тела отчёта/observability). +- **Опциональный smoke** (`test_runner_smoke_enabled`, зеркало шага 3 промпта tester): read-only GET + `http://localhost:/health`, `/status`, `/queue` + проверка наличия блока `serial_gate` в + `/queue`. Любой провал smoke → итоговый `FAIL` (детерминированно). Smoke — строго read-only + (BR-7/AC-8): никаких мутирующих запросов к 8500. + +### FR-3 — Маппинг exit-кода → `result:` +`0 → "PASS"`, любой ненулевой / отсутствие кода / ошибка запуска → `"FAIL"` (fail-closed, никогда +ложный green). Pure-функция, согласованная по контракту с `self_deploy.map_exit_code_to_status`, но в +токенах `PASS`/`FAIL` (`result:` использует их, а не `SUCCESS`/`FAILED`; `_TESTS_POSITIVE_TOKENS`/ +`_TESTS_NEGATIVE_TOKENS`, `src/qg/checks.py:222-223`). Если архитектор предпочтёт единый маппер с +параметризованными токенами — допустимо, но **второй несогласованный маппинг не плодить** (BR-4). + +### FR-4 — Запись и push `13-test-report.md` +Раннер пишет `docs/work-items//13-test-report.md` в worktree фичеветки с frontmatter: +`result: PASS|FAIL` (UPPERCASE) + обязательная 52c-схема (`work_item`/`stage: testing`/`author_agent`/ +`status`/`created_at`/`model_used`) + информативное тело (таблица результата pytest / хвост stdout / +smoke-итог) — зеркало `staging_runner.build_staging_log`/`write_staging_log`. `author_agent: test-runner`, +`model_used: n/a` честно отражают **детерминированный** продюсер; ключ `result:` и его UPPERCASE-значение +**не** меняются. Best-effort `git add/commit/push` лога в **фичеветку** (та же git-identity ORCH-101, +актор `test-runner`; **без** отдельного PR-merge в `main` — гейт читает worktree → origin/main fallback, +`check_tests_passed`→`_repo_path`). Самостоятельный merge лога в `main` НЕ делать (усиливает BR-7/AC-8). + +### FR-5 — Инициация существующего гейта после вердикта +После записи (и best-effort push) раннер инициирует ту же оценку exit-гейта, что триггерил +завершившийся LLM-tester: `stage_engine.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`; на FAIL +запускает **существующий** откат `testing → development` + developer-retry (cap `MAX_DEVELOPER_RETRIES`, +встраивание `extract_test_failures`, `src/stage_engine.py:849-892`). **Никакой новой ветви +маршрутизации.** Lease ORCH-114 берётся внутри `advance_stage` как сейчас — раннер его не трогает +(граница задачи О1). never-raise. + +### FR-6 — Two-level outcome (tool-error DEFER, анти-ORCH-110) +По образцу `staging_runner.run_staging_gate`: +- Сюита **исполнилась** (`returncode is not None` и не `timed_out`) → довериться exit-коду (FR-3) → + записать `result:` → инициировать гейт (FR-5). FAIL → существующий rollback (FR-5). +- Сюита **не исполнилась** (tool-error: spawn-error / таймаут / `returncode None`) → **инфра-сбой, НЕ + код-фейл** → bounded DEFER: re-queue свежий `tester`-джоб с задержкой `test_runner_infra_retry_delay_s` + + restart-safe маркер (`test-runner infra-retry` в `task_content`, зеркало + `staging_runner._INFRA_RETRY_MARKER`/`stage_engine._merge_infra_retry_count`); счётчик из persisted + `jobs`. На исчерпании `test_runner_infra_max_retries` → fail-closed `result: FAIL` + запись лога + + инициация гейта (существующий rollback) + один INFRA-alert (явно «НЕ дефект кода», кликабельный + номер). **Никогда** тихий advance/ложный green; **никогда** не клинит очередь; **не** жжёт + developer-retry на транзиентной инфре. + +### FR-7 — Kill-switch, скоуп, тест-контракт (обратимость + backward-compat) +`test_runner_enabled=False` → перехват не срабатывает → на `testing` запускается прежний LLM-tester +(`_spawn`) **байт-в-байт** как до ORCH-116. `test_runner_repos` ограничивает скоуп (пусто → только +`orchestrator`). Репо без резолвимого тест-контракта → `applies==False` → LLM-tester (BR-9). Переключение +флага туда-сюда не оставляет несовместимого состояния (артефакт/гейт неизменны). + +### FR-8 — Наблюдаемость +- Read-only блок `test_runner` в `GET /queue`: `enabled`, `repos`, `target`, `timeout_s`, + `infra_max_retries`, счётчики `runs`/`pass`/`fail`/`tool_error`/`deferred` (in-process, паттерн + `staging_runner._STAGING_RUNNER_COUNTERS`). +- Один структурный лог-вердикт на прогон (`work_item`/`repo`/`exit_code`/`result`/`duration_s`/`outcome`), + различающий «код упал» (`FAIL` от сюиты) и «инструмент недоступен» (tool-error). + +### FR-9 — Гибрид: LLM строго off-control-path (BR-8 / NFR-7) +В Phase 1 LLM на стадии `testing` **отсутствует** в потоке управления вердикта (детерминированный +раннер — единственный продюсер `result:`). Архитектура раннера не должна архитектурно исключать +будущий **опциональный off-control-path** LLM-триаж/диагностику после детерминированного FAIL +(отдельная роль/джоб, **не** выносящая и **не** переопределяющая `result:`). В этом срезе он не +реализуется и **не** добавляется в `STAGE_TRANSITIONS`. + +## 4. Изменения API + +- **`GET /queue`** — добавить read-only ключ `test_runner` (наблюдаемость). Существующие поля ответа + не меняются. +- Опционально (на усмотрение архитектора, по образцу `POST /coverage/baseline`): обязательного нового + мутирующего эндпоинта нет. Откат — через env-флаг. +- Новых вебхуков нет. + +## 5. Изменения схемы БД + +**Нет.** Раннер использует существующие таблицы (`tasks` для стадии/branch/work_item_id, `jobs` для +статуса джоба и restart-safe счётчика infra-retry по маркеру в `task_content`) и worktree-механику. +Никаких новых таблиц/колонок/миграций (NFR-1). Счётчики `/queue` — in-process (паттерн +`_STAGING_RUNNER_COUNTERS`/`_MERGE_GATE_COUNTERS`), не БД. + +## 6. Требования к новым/изменённым QG checks + +**Нет новых QG и нет изменений существующих.** `check_tests_passed` / `_parse_tests_verdict` / ключ +`result:` (+ legacy `verdict:`/`status:`) (`src/qg/checks.py:182/226`) и состав `QG_CHECKS` — +**байт-в-байт неизменны**. ORCH-116 меняет только *продюсера* `13-test-report.md` (детерминированный +код вместо LLM); гейт, читающий артефакт, остаётся прежним. Это критический инвариант (NFR-1) — +reviewer ловит любое изменение имени/семантики гейта/парсера/токенов как finding ≥P1. + +## 7. Совместимость / регресс + +- **Обратная совместимость:** `test_runner_enabled=False` → прежний LLM-tester-путь байт-в-байт; + репо без тест-контракта / вне скоупа → LLM-tester (BR-9). enduro-trails не затронут (NFR-5). +- **Kill-switch / область раската:** флаг `test_runner_enabled` + CSV `test_runner_repos` (пусто → + self-hosting only). Откат = `ORCH_TEST_RUNNER_ENABLED=false`. +- **Обратимость:** полностью обратимо флагом; артефакт и гейт неизменны, переключение туда-сюда не + оставляет несовместимого состояния. +- **never-raise / fail-safe (NFR-2):** ошибка раннера → `FAIL` (fail-closed) или bounded requeue, не + «тихий advance»; tool-error не жжёт developer-retry (анти-ORCH-110). Self-hosting safety (BR-7): + никаких рестартов 8500 / force-push в `main` / правок инфры; smoke строго read-only. +- **Изоляция (NFR-3):** pytest через `proc_group` tree-kill (ORCH-110) — без сирот; таймаут согласован + со сквозным бюджетом ORCH-065/109/110 без правки `reaper_max_running_s` (NFR-4). +- **Граница (О1):** код ORCH-115 (`staging_runner`), ORCH-112 (checkout hygiene), ORCH-114 (transition + lease) и LLM-роли `reviewer`/`developer` не модифицируются. +- **Норматив сопровождения (NFR-6):** в том же PR обновить `docs/architecture/llm-call-sites.md` (A5) / + `llm-determinization-roadmap.md` (rank 2) / `llm-usage-policy.md` + анти-дрейф-тесты + (`tests/test_llm_call_site_inventory.py`, `tests/test_llm_determinization_docs.py`); `CLAUDE.md` / + `docs/architecture/README.md` / `CHANGELOG.md` / `docs/overview/`. + +## 8. Phase 2 (forward-looking, вне приёмки ORCH-116) + +Зафиксировано для преемственности — **не реализуется в этой задаче**, заводится отдельным follow-up: +- **Project test contract** для не-self репо (enduro-trails): декларативный per-repo контракт + `test` / `smoke` (команды + ожидаемые коды/эндпоинты), исполняемый тем же детерминированным + раннер-паттерном (run → map exit code → `result:` → artifact → gate). +- **Опциональный off-control-path LLM-триаж** после детерминированного FAIL: human-readable + диагностика причин падений, анализ пробелов покрытия AC, сопоставление TC ↔ критерии приёмки — + как обогащение отчёта/комментария, **не** как продюсер вердикта `result:` (NFR-7). +- Зависимость: устойчивый Phase 1 (этот work item) как доказанный паттерн перехвата + маппинга + + two-level outcome. diff --git a/docs/work-items/ORCH-116/03-acceptance-criteria.md b/docs/work-items/ORCH-116/03-acceptance-criteria.md new file mode 100644 index 0000000..cad16b4 --- /dev/null +++ b/docs/work-items/ORCH-116/03-acceptance-criteria.md @@ -0,0 +1,216 @@ +--- +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//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 | diff --git a/docs/work-items/ORCH-116/04-test-plan.yaml b/docs/work-items/ORCH-116/04-test-plan.yaml new file mode 100644 index 0000000..350f890 --- /dev/null +++ b/docs/work-items/ORCH-116/04-test-plan.yaml @@ -0,0 +1,116 @@ +work_item: ORCH-116 +stage: analysis +author_agent: analyst +status: ready-for-review +created_at: 2026-06-16 +model_used: claude-opus-4-8 +title: "Детерминированный test-раннер вместо LLM-тестера (стадия testing)" +framework: pytest +scope: > + Покрывает Phase 1: перехват tester-джоба на стадии testing до _spawn, исполнение + тест-контракта (pytest через proc_group + опц. read-only smoke), маппинг exit-кода + в result: PASS|FAIL, запись/push 13-test-report.md, инициацию существующего гейта + check_tests_passed, two-level outcome (tool-error DEFER, анти-ORCH-110), kill-switch/ + скоуп/backward-compat для репо без тест-контракта, never-raise/fail-safe, изоляцию + процесса/таймаут (tree-kill), гибрид (LLM не в control-path вердикта), наблюдаемость, + и анти-дрейф инвариант (STAGE_TRANSITIONS/QG_CHECKS/check_tests_passed/_parse_tests_verdict/ + схема БД не тронуты). Вне покрытия: Phase 2 (project test contract для не-self репо, + off-control-path LLM-триаж), ORCH-115 staging/deploy-раннер, LLM-роли reviewer/developer, + живой Claude CLI и живой прод-стенд (мокируются). +notes: > + Тесты не требуют живого Claude CLI или сети: subprocess/pytest-run (proc_group) и + advance_stage мокируются; пьюр-маппинг и рендер frontmatter тестируются напрямую; + smoke-GET мокируются. Полный регресс tests/ должен оставаться зелёным. Анти-дрейф + (TC-09) защищает критический инвариант NFR-1. Эталон реализации/покрытия — + tests/test_orch115_staging_runner.py (ORCH-115). + +tests: + - id: TC-01 + type: unit + description: "applies(repo): enabled=False -> False (откат к LLM); пустой CSV -> True только для orchestrator; непустой CSV -> membership; репо без резолвимого тест-контракта -> False (BR-9 backward-compat); ошибка -> False (never-raise, fail-safe)." + module: tests/test_orch116_test_runner.py + expected: PASS + + - id: TC-02 + type: unit + description: "Маппинг exit-кода: 0 -> PASS; 1/2/любой ненулевой -> FAIL; None/нечисло/ошибка запуска -> FAIL (fail-closed). Токены PASS/FAIL согласованы с _parse_tests_verdict; второй несогласованный маппинг не вводится." + module: tests/test_orch116_test_runner.py + expected: PASS + + - id: TC-03 + type: unit + description: "Рендер 13-test-report.md: frontmatter содержит result: PASS|FAIL (UPPERCASE) + 52c-схему (work_item/stage=testing/author_agent=test-runner/status/created_at/model_used=n/a); хвост stdout pytest и smoke-итог копируются в тело." + module: tests/test_orch116_test_runner.py + expected: PASS + + - id: TC-04 + type: integration + description: "Сгенерированный раннером 13-test-report.md читается НЕИЗМЕНЁННЫМ _parse_tests_verdict -> корректный (bool, reason) для PASS и FAIL (контракт артефакта/гейта check_tests_passed неизменен)." + module: tests/test_orch116_test_runner.py + expected: PASS + + - id: TC-05 + type: integration + description: "launch_job перехватывает tester-джоб на стадии testing для in-scope репо ДО _spawn (как D1/D2/ORCH-115): _spawn НЕ вызывается, agent_runs не создаётся, возвращается None, jobs-строка ведётся mark_job. _spawn мокирован." + module: tests/test_orch116_test_runner.py + expected: PASS + + - id: TC-06 + type: integration + description: "Дискриминатор: tester-джоб на стадии НЕ testing (защита) и любой не-tester джоб НЕ перехватываются раннером; should_intercept never-raise -> False при DB-сбое (fall-through к _spawn)." + module: tests/test_orch116_test_runner.py + expected: PASS + + - id: TC-07 + type: integration + description: "После PASS-вердикта раннер инициирует advance_stage(finished_agent='tester') ровно как завершившийся LLM-tester (advance_stage мокирован/наблюдается) -> check_tests_passed -> testing->deploy-staging; после FAIL — существующий откат testing->development + developer-retry (stage_engine.py:849)." + module: tests/test_orch116_test_runner.py + expected: PASS + + - id: TC-08 + type: integration + description: "Kill-switch: test_runner_enabled=False -> на testing для orchestrator вызывается _spawn (прежний LLM-путь байт-в-байт), раннер не активируется." + module: tests/test_orch116_test_runner.py + expected: PASS + + - id: TC-09 + type: unit + description: "Анти-дрейф NFR-1: STAGE_TRANSITIONS (src/stages.py), реестр/имена QG_CHECKS, check_tests_passed/_parse_tests_verdict и токены result:/verdict:/status: неизменны; в схеме БД нет новой таблицы/колонки от ORCH-116. Структурная проверка." + module: tests/test_orch116_test_runner.py + expected: PASS + + - id: TC-10 + type: integration + description: "Two-level outcome (анти-ORCH-110): сюита НЕ исполнилась (spawn-error/таймаут/returncode None) -> bounded DEFER (re-queue tester-джоба + restart-safe маркер), БЕЗ отката на development и БЕЗ расхода developer-retry; на исчерпании test_runner_infra_max_retries -> fail-closed FAIL + advance + INFRA-alert. Никогда тихий advance/ложный green." + module: tests/test_orch116_test_runner.py + expected: PASS + + - id: TC-11 + type: unit + description: "never-raise/fail-safe: pytest-run бросает/таймаутит/worktree-ошибка -> раннер не падает, исход FAIL (fail-closed) или bounded DEFER, никогда тихий advance/ложный green; воркер/очередь не клинятся. Все публичные функции test_runner.py never-raise." + module: tests/test_orch116_test_runner.py + expected: PASS + + - id: TC-12 + type: unit + description: "Изоляция/таймаут: pytest исполняется в worktree ветки задачи через proc_group.run_in_process_group (tree-kill); test_runner_timeout_s применяется; малформ/непозитив -> дефолт 900 + WARNING (never-break); reaper_max_running_s не правится." + module: tests/test_orch116_test_runner.py + expected: PASS + + - id: TC-13 + type: unit + description: "Self-hosting safety: в командах раннера нет запрещённых литералов (рестарт 8500 / docker compose up orchestrator / --build / force-push main / правки .env); smoke-запросы строго read-only GET (/health,/status,/queue); лог пушится только в фичеветку." + module: tests/test_orch116_test_runner.py + expected: PASS + + - id: TC-14 + type: integration + description: "Наблюдаемость + гибрид: GET /queue содержит блок test_runner (enabled/repos/target/счётчики runs/pass/fail/tool_error/deferred); на прогон пишется один структурный лог-вердикт, различающий код-фейл и tool-error; LLM не вызывается для вынесения result: в happy/fail-path." + module: tests/test_orch116_test_runner.py + expected: PASS + + - id: TC-15 + type: integration + description: "Анти-дрейф LLM-карты: llm-call-sites.md (A5)/roadmap (rank 2)/policy обновлены под реализацию (инвариант 'ровно один first_slice=yes' цел); tests/test_llm_call_site_inventory.py и tests/test_llm_determinization_docs.py остаются зелёными после правок." + module: tests/test_llm_call_site_inventory.py + expected: PASS