196 lines
21 KiB
Markdown
196 lines
21 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
|
||
---
|
||
|
||
# 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.
|