Files
orchestrator/docs/work-items/ORCH-116/02-trz.md

196 lines
21 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
---
# 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 <test_runner_target>` (дефолт
`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:<port>/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/<work_item_id>/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.