From 04f9b9cbcecdf2cab8583ce4cfbe9563af249a58 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Tue, 16 Jun 2026 02:41:50 +0300 Subject: [PATCH] architect(ET): auto-commit from architect run_id=739 --- .../adr/adr-0049-deterministic-test-runner.md | 115 +++++ .../ADR-001-deterministic-test-runner.md | 471 ++++++++++++++++++ .../ORCH-116/07-infra-requirements.md | 69 +++ .../ORCH-116/08-data-requirements.md | 54 ++ docs/work-items/ORCH-116/10-tech-risks.md | 47 ++ 5 files changed, 756 insertions(+) create mode 100644 docs/architecture/adr/adr-0049-deterministic-test-runner.md create mode 100644 docs/work-items/ORCH-116/06-adr/ADR-001-deterministic-test-runner.md create mode 100644 docs/work-items/ORCH-116/07-infra-requirements.md create mode 100644 docs/work-items/ORCH-116/08-data-requirements.md create mode 100644 docs/work-items/ORCH-116/10-tech-risks.md diff --git a/docs/architecture/adr/adr-0049-deterministic-test-runner.md b/docs/architecture/adr/adr-0049-deterministic-test-runner.md new file mode 100644 index 0000000..c1c878a --- /dev/null +++ b/docs/architecture/adr/adr-0049-deterministic-test-runner.md @@ -0,0 +1,115 @@ +--- +work_item: ORCH-116 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-16 +model_used: claude-opus-4-8 +--- + +# adr-0049: Детерминированный test-раннер — второй реализованный срез determinization-roadmap (tester-гибрид) + +> **Сквозной (cross-cutting) ADR.** Агрегирует решение ORCH-116, влияющее на **весь** +> оркестратор: вводит новый компонент-leaf `src/test_runner.py`, снимает вторую avoidable +> LLM-консультацию из потока управления (`tester`/`result:`, A5) и переводит rank-2 +> determinization-roadmap из «план» в «реализовано». Локальная детализация (все решения +> D1–D12, включая tester-специфичную анти-коллизию `status:` D6.1) — +> `docs/work-items/ORCH-116/06-adr/ADR-001-deterministic-test-runner.md`. + +## Статус +Proposed + +## Контекст + +ORCH-118 ([adr-0047](adr-0047-llm-usage-policy-and-call-site-map.md)) зафиксировал нормативную +политику и карту LLM-консультаций и назвал **avoidable LLM control paths = `{tester, deployer}`**. +Первый срез — **deployer (staging-status, rank 1)** — реализован **ORCH-115** +([adr-0048](adr-0048-deterministic-staging-runner.md)). Второй кандидат — **tester (rank 2, +`needs-hybrid-fallback`, `hybrid_needed = yes`, `first_slice = no`)**. ORCH-116 — его фактическая +реализация. + +Вердикт `result:` на стадии `testing` сейчас эмитит LLM-агент `tester`, но **PASS/FAIL-ядро** есть +**чистый маппинг** exit-кода `pytest` + read-only smoke, а гейт `check_tests_passed` +(`_parse_tests_verdict`) детерминирован и читает **только** frontmatter `result:` (+ legacy +`verdict:`/`status:`). Это удовлетворяет обоим условиям «avoidable»: C-консультация **и** +деривируемый вердикт. **Гибрид-нюанс:** прежний промпт нёс ещё и настоящее суждение (триаж падений, +маппинг TC↔критерии) — поэтому ORCH-116 выносит из потока управления **только PASS/FAIL-исполнителя**, +оставляя LLM допустимым лишь как будущий **off-control-path** триаж (Phase 2, не control-path). + +Прецедент детерминированной замены агента (`launch_job`-перехват до `_spawn`, D1/D2 + +**рабочий эталон `src/staging_runner.py`** ORCH-115) и эталон «детерминированный джоб → `advance_stage`» +уже в проде — архитектурный риск замены снят. + +## Решение + +**Новый leaf `src/test_runner.py` + перехват в `launch_job` до `_spawn`** (рядом с D1/D2/ORCH-115). +На `testing` для in-scope репо с резолвимым тест-контрактом джоб `tester` обрабатывает раннер: +исполняет регресс `pytest ` **в worktree ветки** через `proc_group` (tree-kill, ORCH-110) + +опциональный read-only smoke, маппит exit-код единым контрактом `self_deploy.map_exit_code_to_status` +(транслируя токены в `PASS`/`FAIL`), пишет `13-test-report.md` (тот же machine-key `result:`), +best-effort пушит лог в фичеветку, вызывает **существующий** `advance_stage(current_stage="testing", +finished_agent="tester")`. + +Кросс-каттинговые инварианты (сохранены **байт-в-байт**): +- `STAGE_TRANSITIONS` (`src/stages.py`), реестр и имена `QG_CHECKS`/`check_tests_passed`/ + `_parse_tests_verdict`/прочих `check_*`/`_parse_*`, machine-verdict-ключи (`result:`/`verdict:`/ + `status:`/`staging_status:`/`deploy_status:`/`security_status:`/`coverage_status:`), **схема БД** — + не тронуты. Это замена *продюсера* артефакта, не гейта/стадии. +- Единственный транспорт LLM-консультации (`launcher._spawn`/S0, + [llm-usage-policy.md](../llm-usage-policy.md) §5) — соблюдён: раннер **не зовёт LLM**; второй + транспорт не вводится; будущий off-control-path триаж — вне control-path (не контр-пример политике). +- Сквозной бюджет времени ORCH-065/109/110 (`reaper_max_running_s` (5400) > Σ(работ на ребре)) — + соблюдён **без** правки `reaper_max_running_s`: ребро `testing` отдельно от `deploy-staging`, окно + раннера ≤900s ≤ прежнего LLM-окна `agent_timeout_seconds` (1800s). +- Граница ORCH-112/ORCH-114/ORCH-115: transition-lease берётся **внутри** `advance_stage`; раннер + lease/гигиену/`staging_runner` не модифицирует. + +Скоуп — **self-hosting only** (`test_runner_repos=""` → `is_self_hosting_repo` + резолв +тест-контракта `_has_test_contract`, в Phase 1 = self-hosting), под kill-switch +`test_runner_enabled` (off → `_spawn` LLM-tester'а байт-в-байт). never-raise во всех публичных +функциях; **двухуровневый исход** (verdict при исполнившейся сюите; bounded defer → fail-closed на +tool-error/таймауте) убирает с `testing`-ребра RCA-класс ORCH-110 (инфра ≠ код-фейл). +**Backward-compat (BR-9):** репо без резолвимого тест-контракта → `applies==False` → прежний +LLM-tester (enduro-trails не затронут). + +**Tester-специфичная анти-коллизия (D6.1 локального ADR, отсутствует в ORCH-115):** +`_parse_tests_verdict` читает вердикт из **трёх** полей (`verdict:`/**`status:`**/`result:`) с +negative-token-priority — поэтому обязательное 52c-поле `status:` раннера **жёстко выровнено** по +вердикту (`success` для PASS / `failed` для FAIL), иначе негативный токен в `status:` при `result: +PASS` дал бы ложный FAIL. Зафиксировано unit-тестом через неизменённый парсер. + +**Эволюция карты LLM (норматив сопровождения, в том же PR — D12 локального ADR):** +`llm-call-sites.md` (A5 → реализовано детерминированно, но `avoidable=yes`/`axis=C`/ +`needs-hybrid-fallback` сохранены — LLM-ветвь как fallback / будущий off-control-path триаж), +`llm-determinization-roadmap.md` (rank 2 tester → реализован; **инвариант «ровно один +`first_slice = yes`» цел** — `first_slice` остаётся у rank 1/deployer, у tester — `no`), +`llm-usage-policy.md` (§5 — транспорт не нарушен), плюс анти-дрейф-тесты +(`test_llm_call_site_inventory.py`/`test_llm_determinization_docs.py`). Эти правки коуплены к коду → +применяются в development атомарно с реализацией, не в architecture-стадии (как ORCH-115). + +## Последствия + +- **+** Минус ещё один avoidable LLM control path; второй доказанный раннер-паттерн (теперь и для + `needs-hybrid-fallback`-кандидата, не только `replace-deterministic-now`). +- **+** Дешевле/быстрее/детерминированнее собственный `testing`; нет токенов/латентности LLM в точке + ветвления `testing → deploy-staging` / `testing → development`. +- **+** Паттерн остаётся переиспользуемым: leaf + перехват до `_spawn` + `advance_stage` — шаблон для + Phase 2 (project test contract не-self репо + опциональный off-control-path LLM-триаж). +- **+** Гибрид-граница (D11 локального ADR): архитектура не закрывает будущий off-control-path триаж, + не пуская LLM обратно в поток управления вердикта. +- **−** Новый компонент + врезка + defer-механика + tester-специфичная анти-коллизия `status:`. + Митигейшн: never-raise leaf, kill-switch (fail-safe к LLM), без схемы БД, инвариант выравнивания + `status:` + структурное покрытие `tests/test_orch116_test_runner.py`. +- **Откат:** `ORCH_TEST_RUNNER_ENABLED=false` → прежний LLM-путь на `testing` байт-в-байт. + +## Ссылки +- Локальный ADR: `docs/work-items/ORCH-116/06-adr/ADR-001-deterministic-test-runner.md` +- Первый срез: [adr-0048](adr-0048-deterministic-staging-runner.md) (ORCH-115, `src/staging_runner.py`) +- Политика/карта/roadmap: [llm-usage-policy.md](../llm-usage-policy.md), + [llm-call-sites.md](../llm-call-sites.md) (A5), + [llm-determinization-roadmap.md](../llm-determinization-roadmap.md) (rank 2), + [adr-0047](adr-0047-llm-usage-policy-and-call-site-map.md) +- Прецеденты: D1/D2 (`launcher.py:397/402`), `_run_staging_runner_job` (`launcher.py:438`), + `run_staging_gate` (`staging_runner.py`), `proc_group` (ORCH-110, + [adr-0042](adr-0042-merge-gate-retest-infra-tolerance-and-tree-kill.md)), + transition-lease (ORCH-114, [adr-0045](adr-0045-transition-ownership-lease-and-stage-cas.md)) diff --git a/docs/work-items/ORCH-116/06-adr/ADR-001-deterministic-test-runner.md b/docs/work-items/ORCH-116/06-adr/ADR-001-deterministic-test-runner.md new file mode 100644 index 0000000..2f682fd --- /dev/null +++ b/docs/work-items/ORCH-116/06-adr/ADR-001-deterministic-test-runner.md @@ -0,0 +1,471 @@ +--- +work_item: ORCH-116 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-16 +model_used: claude-opus-4-8 +--- + +# ADR-001: Детерминированный test-раннер вместо LLM-тестера на стадии `testing` + +Work Item: **ORCH-116** — заменить LLM-агента `tester` на стадии `testing` +(self-hosting `orchestrator`) детерминированным test-раннером (второй срез +determinization-roadmap, **rank 2 / tester-гибрид**). +Стадия: **architecture** +Сквозная регистрация: **`docs/architecture/adr/adr-0049-deterministic-test-runner.md`** +(решение кросс-каттинговое — вводит новый компонент-leaf `src/test_runner.py` и реализует +второй срез determinization-roadmap; влияет на карту LLM-консультаций всего оркестратора). + +## Статус +Proposed + +## Контекст + +Стадию `testing` сейчас исполняет **LLM-агент `tester`**. Маршрутизация (сверено по коду +`src/stages.py:17-18`): + +```python +"review": {"next": "testing", "agent": "tester", "qg": "check_reviewer_verdict"}, +"testing": {"next": "deploy-staging", "agent": "deployer", "qg": "check_tests_passed"}, +``` + +То есть `tester` — **единственный** агент, запускаемый при входе в `testing` (поле `agent` +ребра `review → testing`); гейт выхода из `testing` — `check_tests_passed`. Фактическая работа +агента на этой стадии в happy-path — **в основном детерминированная** (`.openclaw/agents/tester.md`): +прогнать регресс `pytest tests/` в worktree ветки, сделать read-only smoke (`/health`, `/status`, +`/queue` + наличие блока `serial_gate`), смаппить exit-код, записать `13-test-report.md` с +машинным frontmatter `result: PASS|FAIL`. + +Вердикт `result:` потребляется **детерминированным** гейтом `check_tests_passed` +(`src/qg/checks.py:182` → `_parse_tests_verdict:226`), который читает **только** YAML-frontmatter +(`result:` канонический + legacy `verdict:`/`status:`, ORCH-047) — **не** прозу и **не** покрытие +TC. По нормативной политике (`docs/architecture/llm-usage-policy.md`) это **avoidable LLM control +path**: (i) C-консультация (вердикт `result:` ветвит гейт) **и** (ii) вердикт **деривируем** из +exit-кода `pytest` + smoke. Карта (`docs/architecture/llm-call-sites.md`, строка **A5**) +классифицирует tester как **`needs-hybrid-fallback`**; roadmap +(`llm-determinization-roadmap.md`, машинный блок §4) ставит его **rank 2** (`hybrid_needed = yes`, +`first_slice = no`). ORCH-116 — реализация этого второго среза (первый, **ORCH-115/deployer**, +уже реализован — `src/staging_runner.py`). + +**Гибридная природа (ключевое отличие от ORCH-115).** Tester — `needs-hybrid-fallback`, не +`replace-deterministic-now`: его **PASS/FAIL-ядро** полностью детерминируемо (exit-код pytest + +smoke), но прежний промпт нёс ещё и **настоящее суждение** — триаж падений, анализ пробелов +покрытия AC, сопоставление TC ↔ критерии приёмки, человекочитаемую диагностику. ORCH-116 выносит +из потока управления **только PASS/FAIL-исполнителя** (его делает детерминированный код); LLM на +стадии `testing` остаётся допустимым лишь как **off-control-path** триаж/диагностика **после** +детерминированного провала и **никогда** как первичный исполнитель вердикта гейта (BR-8 / NFR-7). +В Phase 1 off-control-path-триаж **не реализуется**, но архитектура его **не запрещает**. + +**Установленные факты (сверено по коду, не изобретать):** +- Детерминированный прецедент замены агента **до `_spawn`** работает в проде: `launch_job` + перехватывает `deploy-finalizer` (D1, `src/agents/launcher.py:397`), `post-deploy-monitor` + (D2, `:402`) и **staging-runner** (ORCH-115, `:404-408`) до `_spawn`; `_run_staging_runner_job` + (`:438`) — тонкая обёртка, синхронно ведущая `jobs`-строку через `mark_job` и возвращающая `None`. +- **Эталон leaf-раннера** — `src/staging_runner.py` (ORCH-115): two-level outcome, never-raise, + kill-switch + скоуп-CSV, `proc_group`, маппинг exit-кода единым контрактом, best-effort push в + фичеветку, инициация гейта через `advance_stage`, in-process счётчики. ORCH-116 — его зеркало для + роли `tester` / стадии `testing`. +- Гейт `check_tests_passed` (`src/qg/checks.py:182`) читает `13-test-report.md` через + `_repo_path(repo, branch)` (`:15`), который **читает per-branch worktree первым** (если каталог + существует), иначе — общий клон. Значит worktree-записанный файл читается напрямую — **отдельный + merge лога в `main` не нужен**. +- FAIL-маршрут существует и зафиксирован: `src/stage_engine.py:849` — + `if agent == "tester" and qg_name == "check_tests_passed"` → откат `testing → development` + + `extract_test_failures` + `enqueue_job("developer", …)` (cap `MAX_DEVELOPER_RETRIES=3`, + `:862-892`). Ветвь матчит по `agent == "tester"` — раннер обязан инициировать гейт с + `finished_agent="tester"`. +- Tree-kill subprocess'а под таймаутом готов: `proc_group.run_in_process_group` (ORCH-110, + stdlib-only, never-raise, fallback к `subprocess.run`). pytest уже исполняется в worktree через + него в coverage-gate (ORCH-027) и merge-gate re-test (ORCH-110) — тот же контейнер, без новых + зависимостей образа. +- Пьюр-маппинг exit-кода готов: `self_deploy.map_exit_code_to_status` (`0→SUCCESS`, иначе/None/ + нечисло→`FAILED`, fail-closed, unit-tested). ORCH-116 переиспользует его, транслируя токены в + `PASS`/`FAIL`. + +«Как есть» не годится: каждый прогон tester'а тратит токены/время opus-агента (по `agent_runs`: +~60–150k / 5–20 мин на прогон) ради действия = один прогон pytest + несколько read-only GET, +встраивает недетерминизм LLM в точку ветвления `testing → deploy-staging` / `testing → development` +и принадлежит к RCA-классу «LLM принял решение, которое есть исполнение фиксированных команд + +маппинг результата» (ORCH-110/111/112/113/114/117/115). + +## Решение + +### Сводка + +Ввести **новый leaf `src/test_runner.py`** (never-raise, по образцу `staging_runner.py`/ +`self_deploy.py`/`proc_group.py`) и **перехват в `launch_job` до `_spawn`** (рядом с D1/D2/ORCH-115). +Когда на стадии `testing` для in-scope репо с тест-контрактом к запуску приходит джоб `tester`, его +обрабатывает раннер: исполняет «тест-контракт» (регресс `pytest ` в worktree ветки через +`proc_group` + опциональный read-only smoke), маппит exit-код в `result:` (`0→PASS`, иначе `FAIL`), +пишет `13-test-report.md`, best-effort пушит лог в фичеветку, и вызывает **существующий** +`advance_stage(current_stage="testing", finished_agent="tester")` — ровно как завершившийся +LLM-tester. Контракт артефакта, гейт `check_tests_passed`/`_parse_tests_verdict`, `STAGE_TRANSITIONS`, +схема БД — **байт-в-байт неизменны** (это замена *продюсера* артефакта, не гейта). Под kill-switch + +скоуп-CSV + резолв тест-контракта; never-raise; fail-closed; two-level outcome (анти-ORCH-110); +fail-safe к прежнему LLM-пути. + +### D1 — Точка диспетчеризации: перехват в `launch_job` **до** `_spawn` (FR-1 / AC-1) + +В `launcher.launch_job`, рядом с врезками D1/D2/ORCH-115, **до** `_spawn`: + +```python +if job.get("agent") == "tester": + from .. import test_runner + if test_runner.should_intercept(job): + return self._run_test_runner_job(job) +``` + +- **Дискриминатор перехвата — роль `tester` + стадия задачи + `applies(repo)`.** + `should_intercept(job)` истинно ⇔ `agent == "tester"` **И** `applies(job["repo"])` **И** + `tasks.stage` (по `job["task_id"]`) `== "testing"`. +- **Отличие от ORCH-115 (важно, R-1).** Роль `deployer` была **общей** для `deploy-staging` и + `deploy`, поэтому гард по стадии у staging-раннера был обязателен для дизамбигуации «staging vs + prod». Роль `tester` исполняет **только** стадию `testing` (единственный `agent` входа в `testing`, + `STAGE_TRANSITIONS["review"]["agent"]`), коллизии стадий нет — но гард `tasks.stage == "testing"` + сохраняется как **defense-in-depth** (симметрия с ORCH-115 + защита от перехвата случайного + будущего `tester`-джоба вне `testing`). +- `should_intercept` / `applies` — **never-raise → False**: любая ошибка (DB-lookup упал) → провал в + `_spawn` (fail-safe к прежнему LLM-пути). +- `_run_test_runner_job(job)` — тонкая обёртка-зеркало `_run_staging_runner_job` (`:438`): + синхронно зовёт `test_runner.run_test_gate(job)`, затем `mark_job(job["id"], "done")`; любое + исключение → `mark_job(..., "failed", error=…)`; возвращает `None` (нет `agent_runs`-строки, + `_spawn` не вызывается, токены LLM не тратятся). + +### D2 — Размещение логики: чистый leaf `src/test_runner.py` (зеркало `staging_runner`) + +`run_test_gate(job)` живёт в leaf'е и владеет полным детерминированным потоком (зеркало +`staging_runner.run_staging_gate`): +1. поднять `work_item_id`/`branch` по `task_id`; +2. исполнить тест-контракт (D3) → `ProcResult` (pytest) + smoke-итог; +3. определить исход (D5); +4. на verdict-исходе: записать `13-test-report.md` (D6) и вызвать + `advance_stage(finished_agent="tester")` (D7); +5. на tool-error-исходе: bounded DEFER (D5); +6. учесть счётчики + структурный лог (D10). + +**Чистота leaf'а:** импортирует на уровне модуля только `config`, `logging` (+ `proc_group`); +лениво (внутри функций) — `db`/`git_worktree`/`self_deploy.map_exit_code_to_status`/ +`qg.checks.is_self_hosting_repo`/`stage_engine.advance_stage`/`notifications`. Лениво — чтобы не +тащить тяжёлый `stage_engine` на импорте и не плодить цикл (паттерн `staging_runner`/ +`transition_lease`/`merge_gate`). Все публичные функции — **never-raise** (AC-9). + +### D3 — Исполнение тест-контракта: pytest (в worktree) + опц. smoke через `proc_group` (FR-2 / NFR-3 / AC-10 / AC-11) + +**Тест-контракт = (обязательная регресс-команда) + (опциональный read-only smoke).** + +- **Регресс:** `python -m pytest ` (дефолт `tests/`, конвенция + `merge_retest_target`) исполняется **в worktree ветки задачи** — + `git_worktree.get_worktree_path(repo, branch)`, **НЕ** в общем `/repos/orchestrator` (анти + checkout-гонка, как требовал промпт tester шаг 2; тот же контекст, что coverage-gate/merge-gate + re-test) — через `proc_group.run_in_process_group(argv, cwd=, timeout=, + grace_s=agent_kill_grace_seconds, tree_kill=subprocess_tree_kill_enabled)` → SIGTERM→grace→SIGKILL + всего дерева на таймауте, без сирот pytest (корень ORCH-109/110/111 закрыт). Захватывает exit-код и + stdout (для тела отчёта/observability). +- **Опциональный smoke** (`test_runner_smoke_enabled`, дефолт `True`; зеркало шага 3 промпта tester): + read-only `GET` против запущенного оркестратора (base URL из config — host-параметризация ORCH-101, + без host-хардкодов): `/health`, `/status`, `/queue` + проверка **наличия** блока `serial_gate` в + ответе `/queue`. **Smoke строго read-only** (BR-7/AC-10): никаких мутирующих запросов. +- **Итоговый verdict-токен** = `PASS` ⇔ (exit-код pytest == 0 по маппингу D4) **И** smoke прошёл + (если включён); иначе `FAIL`. Smoke-провал → `FAIL` (детерминированно, FR-2). +- **Анти-флап smoke (уточнение архитектора):** транзиентная **недостижимость** smoke-эндпоинта + (connection refused / таймаут на единичном GET) ретраится **ограниченно** внутри smoke-шага + (несколько быстрых GET с коротким backoff) перед выводом `FAIL`; «достижимо, но форма неверна» + (не-200 / нет блока `serial_gate`) → немедленный `FAIL`. Это снижает риск, что разовый блип + прод-8500 откатит здоровую ветку, не вводя нового исхода (на исчерпании smoke-ретраев — обычный + `FAIL`, поглощаемый developer-retry-cap). Гард `test_runner_smoke_enabled` позволяет отключить + smoke, если он окажется шумным, без отката всего раннера. + +**Self-hosting safety (BR-7 / AC-10):** в argv раннера нет литералов рестарта 8500 / +`docker compose up … orchestrator` / `--build` / force-push / правок `.env`/`docker-compose.yml`. +Раннер только исполняет pytest в worktree и делает read-only GET. Покрывается тестом запрета +литералов в его командах (зеркало TC ORCH-115). + +### D4 — Маппинг exit-кода → `result:`: переиспользовать единый контракт (FR-3 / AC-3) + +`result`-токен = трансляция `self_deploy.map_exit_code_to_status(returncode)`: +`SUCCESS → "PASS"`, `FAILED → "FAIL"` (т.е. `0 → PASS`; ненулевой / None / нечисло → `FAIL`, +fail-closed). **Второй несогласованный маппинг не вводится** — переиспользуется тот же пьюр-контракт +(`0→SUCCESS`-семантика, unit-tested), что у deploy-finalizer и staging-runner (BR-4). Разница лишь в +токенах (`result:` использует `PASS`/`FAIL`, а не `SUCCESS`/`FAILED`; `_TESTS_POSITIVE_TOKENS`/ +`_TESTS_NEGATIVE_TOKENS`, `src/qg/checks.py:222-223`) — тонкая обёртка-транслятор поверх единого +маппера, не дубль логики. Smoke-результат **AND**-ится в итог отдельно (D3), exit-маппинг остаётся +чистой функцией одного входа (покрыт unit-тестом на каждый класс: `0`/≠0/`None`/нечисло). + +### D5 — Двухуровневый исход: verdict vs tool-error (NFR-2 / AC-5 / R-4) — **ключевое решение** + +Выбран **двухуровневый исход** (зеркало `staging_runner` D5, анти-ORCH-110): + +- **Сюита ИСПОЛНИЛАСЬ** (`returncode is not None` и **не** `timed_out`) → доверяем коду: маппинг D4 + (+ smoke D3) → `result:` → инициировать гейт (D7). `PASS → testing → deploy-staging`; + `FAIL → существующий откат testing → development` + developer-retry (тот же путь и cap + `MAX_DEVELOPER_RETRIES`, что у FAIL-вердикта LLM — `stage_engine.py:849-892`; R-5/AC-4). +- **Сюита НЕ исполнилась** (tool-error: spawn-error / таймаут / `returncode is None`) — это + **инфра-сбой, а не код-фейл**. Раннер делает **ограниченный DEFER**: re-queue свежего **`tester`**-джоба + с задержкой `test_runner_infra_retry_delay_s` и **restart-safe маркером** `test-runner infra-retry` + в `task_content` (счётчик — `COUNT(*)` маркера в persisted `jobs`, зеркало + `staging_runner._infra_retry_count` / `stage_engine._merge_infra_retry_count`; **без правки схемы + БД**, NFR-1). На **исчерпании** бюджета (`test_runner_infra_max_retries`) — **fail-closed**: + записать `result: FAIL` + `advance_stage` (существующий откат) + один INFRA-alert (кликабельный + номер, явно «инфра, НЕ дефект кода»). Так раннер **никогда** не делает тихий advance / ложный green + и **никогда** не клинит очередь навсегда, но **не жжёт developer-retry на транзиентной инфре**. + +**Почему не «tool-error → немедленный FAIL-откат»:** это в точности анти-паттерн ORCH-110 +(инфра-таймаут, ошибочно маршрутизированный как код-фейл → откат на `development` + расход +developer-retry; на следующем retry падает так же → ручное вмешательство). Пьюр-маппинг D4 остаётся +fail-closed (None→FAIL) — это терминальный fallback **на исчерпании** defer, а не реакция на первый +же tool-error. **DEFER re-queue'ит `tester`-джоб** (не `deployer`!) — он повторно входит в этот же +раннер. + +### D6 — Артефакт `13-test-report.md`: зеркало `write_staging_log` (FR-4 / AC-2 / AC-10) + +Раннер пишет `docs/work-items//13-test-report.md` в worktree фичеветки +(`git_worktree.get_worktree_path`) литеральным блоком (как `staging_runner.build_staging_log`), +чтобы машинно-читаемый frontmatter был байт-точным: + +```markdown +--- +result: PASS # или FAIL — UPPERCASE, имя/регистр/токены ключа НЕ меняются +work_item: +stage: testing +author_agent: test-runner +status: success # или failed — ВЫРОВНЕН по result (см. D6.1!) +created_at: +model_used: n/a +exit_code: +smoke: +--- + +# Test Gate Log (deterministic runner, ORCH-116) + +``` + +- `author_agent: test-runner` / `model_used: n/a` честно отражают **детерминированного** продюсера; + **machine-key `result:` и его UPPERCASE-значения/токены не меняются** (AC-2), читается + неизменённым `_parse_tests_verdict`. +- Обязательная 52c-схема присутствует (`work_item`/`stage: testing`/`author_agent`/`status`/ + `created_at`/`model_used`). +- **Best-effort `git add/commit/push` в ФИЧЕВЕТКУ** (git-identity ORCH-101, актор `test-runner`, + `_GIT_TIMEOUT`). Гейт читает worktree **первым** (`_repo_path:22-25`), поэтому **отдельный + PR-merge лога в `main` НЕ выполняется** — исключение любой прямой работы с `main` усиливает + AC-10/BR-7. Итоговый мерж фичеветки в `main` идёт штатным merge-gate/merge-verify-путём позже. + +#### D6.1 — Анти-коллизия 52c-`status:` ↔ парсер вердикта (**специфично для tester, отсутствует в ORCH-115**) + +**Сверено по коду:** `_parse_tests_verdict` (`src/qg/checks.py:263-277`) читает вердикт из **трёх** +равноранговых полей — `verdict:`, **`status:`** и `result:` — и применяет **negative-token-priority**: +любой негативный токен (`BLOCKED`/`FAILED`/`FAIL`/`REQUEST_CHANGES`/`REJECT`/`RED`) в `f"{verdict} +{status} {result}"` делает вердикт `False` авторитетно. У staging-гейта (ORCH-115) такой коллизии +**не было**: `_parse_staging_status` читает только `staging_status:`, а 52c-`status:` ему безразличен. +Здесь 52c-обязательное поле `status:` **читается тем же парсером, что и `result:`**. + +**Следствие-мина:** если 52c-`status:` принимает значение, чей UPPERCASE содержит негативный токен +(например `status: failed` → `"FAILED"`), при `result: PASS` парсер вернёт `False` (негативный токен +авторитетнее) — **ложный FAIL** здорового прогона. + +**Решение (инвариант):** 52c-`status:` раннера **ВСЕГДА выровнен по вердикту** и **никогда не +противоречит** `result:`: +- `result: PASS` → `status: success` (`"SUCCESS"` — не позитивный и **не** негативный токен; + положительный токен `PASS` берётся из `result:` → парсер даёт `True`); +- `result: FAIL` → `status: failed` (`"FAILED"` — негативный токен, согласован с `FAIL` → `False`). + +Это **тот же приём**, что `staging_runner.build_staging_log` (`status: success|failed` выровнен по +verdict'у) — но здесь он **обязателен по корректности**, а не косметика. Анти-дрейф: unit-тест, +проверяющий `_parse_tests_verdict(<тело раннера для PASS>) == (True, …)` и +`(<тело для FAIL>) == (False, …)` **через неизменённый парсер** (фиксирует, что 52c-`status:` не +ломает вердикт). Reviewer ловит любой `status:`-литерал с негативным токеном при `result: PASS` как +finding ≥P1. + +### D7 — Инициация существующего гейта (FR-5 / AC-4 / R-2, граница O1) + +После записи (и best-effort push) раннер вызывает: +```python +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` (и далее под-гейты ORCH-022/043/027/058 на ребре + `deploy-staging → deploy` — **их раннер не трогает**); +- `FAIL` → существующий откат `testing → development` + developer-retry (`stage_engine.py:849`). + +**`finished_agent="tester"` обязателен** (R-2): FAIL-ветвь матчит по `agent == "tester" and +qg_name == "check_tests_passed"` (`:849`); иной/`None` агент не запустит откат. **Никакой новой +ветви маршрутизации, никаких новых рёбер/исходов** (AC-4). **Граница O1:** transition-lease ORCH-114 +берётся **внутри** `advance_stage` на side-effectful переходе — раннер его **не трогает**; +serial-gate ORCH-088 не взаимодействует (гейтит analyst-job claim). Код ORCH-112/ORCH-114/ORCH-115 +(`staging_runner`) **не модифицируется**. never-raise. + +### D8 — Kill-switch, скоуп и резолв тест-контракта: обратимость + backward-compat (FR-7 / AC-7 / AC-8 / BR-5 / BR-9) + +`config.py` (паттерн `staging_runner_*`/`coverage_gate_*`): +- `test_runner_enabled: bool = True` (env `ORCH_TEST_RUNNER_ENABLED`). +- `test_runner_repos: str = ""` (env `ORCH_TEST_RUNNER_REPOS`; CSV; **пусто → self-hosting only** + через `is_self_hosting_repo`). +- `test_runner_target: str = "tests/"` (env `ORCH_TEST_RUNNER_TARGET`; pytest-таргет, конвенция + `merge_retest_target`). +- `test_runner_timeout_s: int = 900` (env `ORCH_TEST_RUNNER_TIMEOUT_S`; см. D9). +- `test_runner_smoke_enabled: bool = True` (env `ORCH_TEST_RUNNER_SMOKE_ENABLED`). +- `test_runner_infra_max_retries: int = 2`, `test_runner_infra_retry_delay_s: int = 30` + (defer-бюджет D5; зеркало `staging_runner_infra_*`). + +`applies(repo)` (локально, без сети, **never-raise → False**) — **проверяется первым** в +`should_intercept` (нулевой оверхед при выключенном флаге): +```text +1. test_runner_enabled == False -> False (откат к LLM-пути) +2. in_scope = (membership в test_runner_repos) если CSV непуст + = is_self_hosting_repo(repo) если CSV пуст + not in_scope -> False +3. _has_test_contract(repo) -> резолв тест-контракта (BR-9) +``` +**`_has_test_contract(repo)` (BR-9 / AC-8) — отличие от ORCH-115.** У staging-раннера тест-контракт +был неявно self-hosting-bound (staging-контейнер существует только для `orchestrator`), отдельной +проверки не требовалось. Здесь резолв контракта вынесен явно: в **Phase 1** контракт известен по +умолчанию **только** для self-hosting (`return is_self_hosting_repo(repo)` — pytest+smoke); +для прочих репо контракта нет, пока не сконфигурирован (Phase 2) → `applies == False` → +**прежний LLM-tester** (fail-safe). Это делает AC-8 проверяемым: даже если в `test_runner_repos` +руками добавить не-self репо (`enduro-trails`), `_has_test_contract` вернёт `False` → раннер его не +перехватит → LLM-tester. enduro-trails и любой репо без контракта — 1:1 как до ORCH-116. + +Откат = `ORCH_TEST_RUNNER_ENABLED=false` → `applies()` → `False` → `should_intercept` → `False` → +штатный `_spawn` прежнего LLM-tester'а на `testing` **байт-в-байт**. + +### D9 — Бюджет времени (NFR-4 / AC-11, сквозной инвариант ORCH-065/109/110) + +`test_runner_timeout_s = 900` (дефолт; малформ/непозитив → дефолт + WARNING, never-break — зеркало +`staging_runner._resolve_timeout` / `merge_gate._resolve_retest_timeout`). Обоснование **без правки +`reaper_max_running_s` (5400)**: + +- **Ребро `testing` отдельно от ребра `deploy-staging`.** Гейт выхода из `testing` — + `check_tests_passed` (читает файл, мгновенно). Окно «running» одного `tester`-джоба = только + pytest+smoke (≤900s); тяжёлые под-гейты (security/merge re-test/coverage/image-freshness) живут на + ребре `deploy-staging → deploy`, **не** на `testing`. +- **Σ(работ на ребре `testing`) НЕ растёт.** Прежний LLM-tester шёл под `agent_timeout_seconds` + (сверено `config.py:159` = **1800s**; tester не имеет выделенного per-role ключа, в отличие от + developer=3600/reviewer=3000). Раннер заменяет ≤1800s LLM-окно ограниченными ≤900s → + `reaper_max_running_s (5400) > 900 + grace` сохранён **без** изменения reaper'а. Выбор 900s + согласован с фактической длительностью регресс-сюиты (~517s, инцидент ORCH-110) и даёт ~74% запаса — + тот же запас, что merge-retest-бюджет ORCH-110. + +### D10 — Наблюдаемость (FR-8 / AC-13 / BR-6) + +In-process счётчики `_TEST_RUNNER_COUNTERS` (зеркало `_STAGING_RUNNER_COUNTERS` / +`_MERGE_GATE_COUNTERS`): `runs`/`pass`/`fail`/`tool_error`/`deferred`. Read-only блок `test_runner` в +`GET /queue` (`enabled`/`repos`/`target`/`timeout_s`/`infra_max_retries`/счётчики) — `src/main.py`, +аддитивно, существующие ключи не трогаются. Один структурный лог-вердикт на прогон: +`work_item`/`repo`/`exit_code`/`result`/`duration_s`/`outcome` — различает «код упал» (`FAIL` от +сюиты/smoke) и «инструмент недоступен» (`tool_error`/`deferred`). Новых мутирующих эндпоинтов нет; +откат — через env-флаг. + +### D11 — Гибрид: LLM строго off-control-path (BR-8 / NFR-7 / FR-9 / AC-12) + +В Phase 1 на стадии `testing` (in-scope) вердикт `result:` производит **только** детерминированный +раннер; LLM **не вызывается** в потоке управления вердикта (ни happy-path, ни fail-path). Архитектура +раннера **не запрещает** будущий **опциональный off-control-path** LLM-триаж/диагностику **после** +детерминированного FAIL — но он будет **отдельной ролью/джобом**, который **не пишет и не +переопределяет** `result:` и **не добавляет ребро** в `STAGE_TRANSITIONS`. В этом срезе он **не +реализуется**. Это сохраняет `needs-hybrid-fallback`-природу A5: детерминированное ядро + (будущий) +LLM-фолбэк только на суждение. + +### D12 — Норматив сопровождения LLM-карты/политики/витрины (NFR-6 / AC-14) — спека для developer + +Карта/политика/roadmap и их анти-дрейф-тесты **связаны с состоянием кода**, поэтому правятся +**в development-стадии атомарно с кодом** (иначе тесты покраснеют на полу-готовой ветке — это же +причина, по которой ORCH-115 не правил их в architecture; зеркало ORCH-115 D11). README/internals/ +паспорт/чейнджлог/витрина — там же. Архитектура фиксирует **точную спеку** правок (developer +применяет в том же PR): + +- `docs/architecture/llm-call-sites.md` — строка **A5** и машинный `ORCH-118-INVENTORY-BLOCK`: + tester на `testing` для in-scope репо больше не консультирует LLM в потоке управления; отразить + реализованное детерминированное состояние (раннер-перехват до `_spawn`, как D1/D2), **сохранив** + `avoidable=yes`/`axis=C`/`classification=needs-hybrid-fallback` (LLM-ветвь жива как fallback под + выключенным флагом / для не-self репо / как будущий off-control-path триаж) — **зеркало** того, как + ORCH-115 обновил A6/deployer. Заголовок таблицы и `output_consumer = _parse_tests_verdict` не + менять (имя гейта/парсера неизменно). +- `docs/architecture/llm-determinization-roadmap.md` — §2 (tester) и машинный + `ORCH-118-ROADMAP-BLOCK` rank 2: «второй кандидат» → «реализован (ORCH-116)». **Инвариант «ровно + один `first_slice = yes`» держать корректным** — `first_slice` остаётся `yes` у **rank 1 + (deployer)**, у rank 2 (tester) — `no`; **не переключать** (см. + `test_llm_determinization_docs.py`). `hybrid_needed = yes` у tester сохраняется (гибрид-природа). +- `docs/architecture/llm-usage-policy.md` — §5: единственный транспорт LLM-консультации + (`_spawn`/S0) не нарушен; раннер LLM не зовёт; будущий off-control-path триаж — **не** новый + транспорт control-path-консультации (он вне control-path). +- Анти-дрейф `tests/test_llm_call_site_inventory.py` / `tests/test_llm_determinization_docs.py` — + обновить ожидания синхронно, держать зелёными (AC-14). +- Прочие docs того же PR (правило агентов №2 + витрина ORCH-011): `.openclaw/agents/tester.md` + (пометка, что на `testing` для in-scope репо стадию ведёт детерминированный код; LLM-ветвь — + fallback под выключенным флагом / для репо без контракта; канон промпта 52d — 5 секций, ключ + `result:` — байт-в-байт), `docs/architecture/README.md` (новый компонент **Test-runner** в + карте — зеркало записи **Staging-runner** + отметка «второй срез реализован» в блоке roadmap), + `docs/architecture/internals.md` (примечание о перехвате на `testing`, рядом с ORCH-115), + `CLAUDE.md`, `CHANGELOG.md`, `docs/overview/`. + +**Обоснование против `llm-usage-policy.md` §5:** ORCH-116 **снимает** C-консультацию с деривируемым +PASS/FAIL-ядром (A5/tester) — это разрешённая реализация `needs-hybrid-fallback`, не ввод новой +LLM-консультации; политика соблюдена. + +## Альтернативы + +- **Новая стадия / новый QG для детерминированного testing** — отвергнуто: нарушает NFR-1 + (`STAGE_TRANSITIONS`/`QG_CHECKS` байт-в-байт). Меняется только *продюсер* артефакта; гейт и ребро + прежние. +- **tool-error → немедленный `FAIL`-откат на `development`** — отвергнуто: анти-паттерн ORCH-110 + (инфра-сбой как код-фейл → расход developer-retry → петля). Выбран двухуровневый исход D5. +- **Свой второй exit-code→verdict маппинг** — отвергнуто: переиспользуем + `self_deploy.map_exit_code_to_status` (BR-4, единый контракт), тонкий транслятор токенов поверх. +- **52c-`status:` произвольным значением (как чисто описательное поле)** — отвергнуто: `status:` + **читается** `_parse_tests_verdict` с negative-token-priority → негативный токен в `status:` при + `result: PASS` даёт ложный FAIL. Выбрано жёсткое выравнивание `status:` по вердикту (D6.1). +- **Прогон pytest в общем `/repos/orchestrator`** — отвергнуто: checkout-гонка с другими задачами + (мина, закрытая ORCH-112); раннер исполняет pytest **в worktree ветки** (как coverage/merge-gate). +- **Merge лога отдельным PR в `main`** (как прежний tester) — отвергнуто: гейт читает worktree первым + (`_repo_path`) → достаточно push в фичеветку; исключение прямой работы с `main` усиливает + AC-10/BR-7. +- **Логика раннера прямо в `launcher.py`** — отвергнуто: нарушает разделение транспорт/решение; leaf + тестируем без живого CLI (зеркало `staging_runner`/`coverage_gate`). +- **LLM-триаж как control-path-продюсер `result:`** — отвергнуто: BR-8/AC-12 (детерминированный + раннер — единственный исполнитель вердикта; триаж — off-control-path, Phase 2). +- **Править `llm-call-sites.md`/roadmap/policy/README в architecture-стадии** — отвергнуто: + анти-дрейф-тесты коуплены к коду; правки идут атомарно с кодом (D12, как ORCH-115). +- **DEFER через re-queue `deployer`-джоба** (копипаст из staging-раннера) — отвергнуто: DEFER должен + re-queue'ить **`tester`**-джоб (он повторно входит в этот раннер на стадии `testing`). + +## Последствия + +- **+** На `testing` для `orchestrator` исчезает LLM-консультация в потоке управления вердикта: + дешевле/быстрее/детерминированнее; минус один avoidable LLM control path (второй срез roadmap, + rank 2). +- **+** Happy-path не вызывает `_spawn` (нет `agent_runs`-строки, нет токенов LLM на стадии `testing`). +- **+** Полная обратимость одним флагом; артефакт/гейт/ребро/схема БД неизменны → переключение + туда-сюда не оставляет несовместимого состояния. +- **+** Двухуровневый исход (D5) убирает класс ORCH-110 (инфра ≠ код-фейл) с `testing`-ребра. +- **+** Гибрид-граница сохранена (D11): архитектура не закрывает путь к будущему off-control-path + LLM-триажу, не пуская LLM обратно в поток управления. +- **−** Новый leaf + врезка в `launch_job` + defer-механика — рост поверхности кода. Митигейшн: leaf + never-raise + kill-switch (fail-safe к LLM), тонкая врезка-зеркало D1/D2/ORCH-115, defer-счётчик без + схемы БД (маркер в `task_content`), покрытие `tests/test_orch116_test_runner.py`. +- **−** Smoke зависит от достижимости запущенного оркестратора (8500) — разовый блип мог бы дать + `FAIL`. Митигейшн: D3 (bounded smoke-ретрай транзиентной недостижимости + config-gate + `test_runner_smoke_enabled` + developer-retry-cap); pytest остаётся первичным сигналом. +- **−** Тонкая мина 52c-`status:` ↔ парсер (D6.1) специфична для tester. Митигейшн: жёсткий инвариант + выравнивания + unit-тест через неизменённый парсер; reviewer-ось ≥P1. +- **Откат:** `ORCH_TEST_RUNNER_ENABLED=false` → штатный `_spawn` LLM-tester'а на `testing` + **байт-в-байт** до ORCH-116. + +## Ссылки +- BRD: `docs/work-items/ORCH-116/01-brd.md` +- TRZ: `docs/work-items/ORCH-116/02-trz.md` +- Acceptance: `docs/work-items/ORCH-116/03-acceptance-criteria.md` +- Тест-план: `docs/work-items/ORCH-116/04-test-plan.yaml` +- Инфра: `docs/work-items/ORCH-116/07-infra-requirements.md`; + Данные: `08-data-requirements.md`; Риски: `10-tech-risks.md` +- Сквозной ADR: `docs/architecture/adr/adr-0049-deterministic-test-runner.md` +- Эталон реализации: `src/staging_runner.py` (ORCH-115), + `docs/work-items/ORCH-115/06-adr/ADR-001-deterministic-staging-runner.md` +- Сверено по коду: `src/agents/launcher.py:397/404/438`, `src/stages.py:17-18`, + `src/qg/checks.py:15/182/222-223/226/263-277/528`, `src/stage_engine.py:849-892`, + `src/self_deploy.py` (`map_exit_code_to_status`), `src/proc_group.py`, `src/config.py:159/162` + (`agent_timeout_seconds`/`reaper_max_running_s`) +- Политика/карта/roadmap: `docs/architecture/llm-usage-policy.md`, + `docs/architecture/llm-call-sites.md` (A5), `docs/architecture/llm-determinization-roadmap.md` + (rank 2) diff --git a/docs/work-items/ORCH-116/07-infra-requirements.md b/docs/work-items/ORCH-116/07-infra-requirements.md new file mode 100644 index 0000000..111546c --- /dev/null +++ b/docs/work-items/ORCH-116/07-infra-requirements.md @@ -0,0 +1,69 @@ +--- +work_item: ORCH-116 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-16 +model_used: claude-opus-4-8 +--- + +# 07 — Инфраструктурные требования: ORCH-116 — детерминированный test-раннер + +Work Item: **ORCH-116** · Repo: **orchestrator** · Стадия: architecture + +> Топология **не меняется** (всё в Docker на одном сервере mva154, SQLite, собственная очередь). +> Раздел фиксирует **рантайм-предусловия** детерминированного раннера и подтверждает отсутствие +> новых компонентов/портов/зависимостей образа. + +## 1. Топология — без изменений + +Новых контейнеров / сервисов / портов / сетей **нет**. Раннер исполняется **внутри уже работающего +прод-контейнера `orchestrator` (8500)** как синхронный обработчик джоба `tester` (перехват в +`launch_job` до `_spawn`) — там же, где сейчас стартует LLM-tester. Staging-контур (8501) для +ORCH-116 **не используется** (в отличие от ORCH-115) — это ребро `testing`, а не `deploy-staging`. + +## 2. Рантайм-предусловия (предсуществующие, проверить) + +| # | Предусловие | Статус | Обоснование | +|---|-------------|--------|-------------| +| P-1 | `python -m pytest` исполним внутри прод/staging-образа | **уже выполнено** | pytest уже гоняется в worktree внутри этого же образа coverage-gate (ORCH-027) и merge-gate re-test (ORCH-110). Новых pip-зависимостей **нет** (в отличие от `pytest-cov` ORCH-027 — он не требуется: ORCH-116 читает только exit-код, не покрытие). | +| P-2 | Per-branch worktree ветки задачи материализуем (`git_worktree.get_worktree_path`) | **уже выполнено** | механика worktree используется всеми гейтами/раннерами; раннер исполняет pytest **в worktree ветки** (анти checkout-гонка, ORCH-112), не в общем `/repos/orchestrator`. | +| P-3 | `proc_group.run_in_process_group` (tree-kill) доступен на POSIX-хосте | **уже выполнено** | ORCH-110; fallback к `subprocess.run` на не-POSIX (`subprocess_tree_kill_enabled`). | +| P-4 | Read-only smoke: запущенный оркестратор отвечает на `GET /health`, `/status`, `/queue` по config-резолвнутому base URL | **уже выполнено** | те же эндпоинты read-only опрашивал LLM-tester (шаг 3 промпта). Base URL — из config (host-параметризация ORCH-101, без host-хардкодов). Smoke **строго read-only**; опционален (`test_runner_smoke_enabled`). | +| P-5 | git-identity актора `test-runner` для best-effort push лога в фичеветку | **уже выполнено** | HOME + email-домен из `settings` (ORCH-101), как у `staging-runner`. Push **только в фичеветку**, никогда в `main`/force-push. | + +## 3. Конфигурация (env, дефолт = боевое; пустой `.env` ⇒ поведение для in-scope) + +| Ключ | env | Дефолт | Назначение | +|------|-----|--------|------------| +| `test_runner_enabled` | `ORCH_TEST_RUNNER_ENABLED` | `True` | kill-switch (off → LLM-tester байт-в-байт) | +| `test_runner_repos` | `ORCH_TEST_RUNNER_REPOS` | `""` | CSV-скоуп; пусто → self-hosting only | +| `test_runner_target` | `ORCH_TEST_RUNNER_TARGET` | `tests/` | pytest-таргет тест-контракта | +| `test_runner_timeout_s` | `ORCH_TEST_RUNNER_TIMEOUT_S` | `900` | таймаут pytest (D9; согласован со сквозным бюджетом ORCH-065/109/110 без правки `reaper_max_running_s`) | +| `test_runner_smoke_enabled` | `ORCH_TEST_RUNNER_SMOKE_ENABLED` | `True` | опц. read-only smoke | +| `test_runner_infra_max_retries` | `ORCH_TEST_RUNNER_INFRA_MAX_RETRIES` | `2` | бюджет tool-error DEFER (D5) | +| `test_runner_infra_retry_delay_s` | `ORCH_TEST_RUNNER_INFRA_RETRY_DELAY_S` | `30` | задержка DEFER-re-queue | + +> `.env.example` пополнить этими ключами (канон старта, норматив ORCH-101). Изменений +> `docker-compose.yml` / `Dockerfile` / образа **нет**. + +## 4. Сквозной бюджет времени (NFR-4) + +Ребро `testing` отдельно от `deploy-staging`. Окно «running» `tester`-джоба = только pytest+smoke +(≤`test_runner_timeout_s`=900s); тяжёлые под-гейты (security/merge/coverage/image-freshness) живут на +ребре `deploy-staging → deploy`. Прежний LLM-tester шёл под `agent_timeout_seconds`=1800s +(`config.py:159`; tester без выделенного per-role ключа). 900 < 1800 → Σ(работ на ребре `testing`) +**не растёт** → инвариант `reaper_max_running_s (5400) > Σ + grace` сохранён **без** правки reaper'а. + +## 5. Self-hosting safety (BR-7 / AC-10) + +Раннер на `testing` **никогда** не рестартит контейнер 8500, не выполняет `docker compose up -d +orchestrator`/`--build`, не пушит force в `main`, не правит `.env`/`.env.staging`/`docker-compose.yml`. +Он лишь исполняет pytest в worktree, делает read-only GET и пишет/пушит лог в фичеветку. Деплой-решений +ORCH-116 не принимает (это стадия `testing`, до прод-деплоя) — staging-гейт остаётся обязательной +страховкой на последующих рёбрах. + +## 6. Вывод + +Инфраструктурных изменений **нет** (топология/порты/образ/зависимости — без правок). Все предусловия +P-1…P-5 предсуществуют. Эскалация `arch:major-change` **не требуется**. diff --git a/docs/work-items/ORCH-116/08-data-requirements.md b/docs/work-items/ORCH-116/08-data-requirements.md new file mode 100644 index 0000000..1a15000 --- /dev/null +++ b/docs/work-items/ORCH-116/08-data-requirements.md @@ -0,0 +1,54 @@ +--- +work_item: ORCH-116 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-16 +model_used: claude-opus-4-8 +--- + +# 08 — Требования к данным: ORCH-116 — детерминированный test-раннер + +Work Item: **ORCH-116** · Repo: **orchestrator** · Стадия: architecture + +## 1. Изменения схемы БД — НЕТ (NFR-1) + +Новых таблиц / колонок / индексов / миграций **нет**. Раннер использует **существующие** структуры: + +| Структура | Использование | Запись? | +|-----------|---------------|---------| +| `tasks` (`stage`, `branch`, `work_item_id`) | резолв полей задачи по `task_id`; гард стадии `testing` в `should_intercept`; продвижение/откат — через **существующий** `advance_stage` (он же пишет стадию под transition-lease ORCH-114) | раннер сам стадию **не** пишет — только через `advance_stage` | +| `jobs` (`status`, `task_content`) | `mark_job(done\|failed)` для строки джоба (через `_run_test_runner_job`); restart-safe счётчик tool-error DEFER — `COUNT(*)` по маркеру `test-runner infra-retry` в `task_content` re-queued джоба | да (`mark_job`, `enqueue_job` — существующие API) | +| `agent_runs` | **НЕ создаётся** — детерминированный раннер не спавнит LLM (happy-path без `_spawn`) | нет | +| `transition_lease` (ORCH-114) | берётся/освобождается **внутри** `advance_stage` на side-effectful переходе | раннер **не трогает** | + +## 2. Restart-safe счётчик DEFER — без колонки (зеркало ORCH-115/110) + +Бюджет tool-error DEFER (D5) считается **из persisted очереди `jobs`** подсчётом маркера +`test-runner infra-retry` в `task_content` re-queued джоба (зеркало +`staging_runner._infra_retry_count` / `stage_engine._merge_infra_retry_count`). Это переживает +рестарт сервиса **без** новой колонки/таблицы — намеренно, ради NFR-1 (схема БД байт-в-байт). + +## 3. Артефакт `13-test-report.md` — контракт frontmatter неизменен (AC-2) + +Раннер пишет тот же файл с тем же machine-key, что читает гейт: +- `result: PASS|FAIL` (UPPERCASE) — канонический ключ `_parse_tests_verdict` (`src/qg/checks.py:265`); + имя/регистр/токены **не меняются**. +- Обязательная 52c-схема: `work_item` / `stage: testing` / `author_agent: test-runner` / + `status: success|failed` / `created_at` / `model_used: n/a`. +- **Инвариант D6.1 (данные):** `status:` **читается** тем же парсером (`verdict:`/`status:`/`result:`, + negative-token-priority) → значение `status:` **обязано** быть выровнено по вердикту + (`success`↔PASS, `failed`↔FAIL); иначе негативный токен в `status:` при `result: PASS` исказит + вердикт. Это требование к **значению данных**, не к схеме. + +## 4. Счётчики наблюдаемости — in-process, не БД + +Блок `test_runner` в `GET /queue` питается **in-process** счётчиками `_TEST_RUNNER_COUNTERS` +(`runs`/`pass`/`fail`/`tool_error`/`deferred`) — паттерн `_STAGING_RUNNER_COUNTERS`/ +`_MERGE_GATE_COUNTERS`. В БД **не** персистятся (обнуляются при рестарте — приемлемо для +оперативной наблюдаемости). + +## 5. Вывод + +Требований к изменению данных/схемы **нет**. Совместимость с общей БД (self-hosting + enduro-trails) +сохранена: аддитивных объектов не вводится, существующие read/write идут через существующие API. diff --git a/docs/work-items/ORCH-116/10-tech-risks.md b/docs/work-items/ORCH-116/10-tech-risks.md new file mode 100644 index 0000000..3668d78 --- /dev/null +++ b/docs/work-items/ORCH-116/10-tech-risks.md @@ -0,0 +1,47 @@ +--- +work_item: ORCH-116 +stage: architecture +author_agent: architect +status: proposed +created_at: 2026-06-16 +model_used: claude-opus-4-8 +--- + +# 10 — Технические риски: ORCH-116 — детерминированный test-раннер + +Work Item: **ORCH-116** · Repo: **orchestrator** · Стадия: architecture + +> Информационный (гейтом не парсится). Покрывает риски BRD §8 (R-1…R-7) + риски, выявленные +> архитектором по коду (TR-8…TR-11, специфичные для роли `tester` / стадии `testing`). + +## Реестр рисков + +| ID | Риск | Вер. | Влия. | Митигейшн | +|----|------|------|-------|-----------| +| TR-1 (R-1) | Точка диспетчеризации «до `_spawn`» перехватывает не тот джоб | Низ. | Выс. | `should_intercept` = `agent=="tester"` **И** `applies(repo)` **И** `tasks.stage=="testing"` (D1). Роль `tester` исполняет ТОЛЬКО `testing` (`STAGE_TRANSITIONS["review"]["agent"]`) → коллизии стадий нет; гард по стадии — defense-in-depth. never-raise → `False` → `_spawn`. Покрыто тестом перехвата/не-перехвата. | +| TR-2 (R-2) | После вердикта гейт не инициирован → задача зависает на `testing` | Низ. | Выс. | D7: раннер вызывает `advance_stage(current_stage="testing", finished_agent="tester")` в `run_test_gate` (never-raise). **`finished_agent="tester"` обязателен** — FAIL-ветвь `stage_engine.py:849` матчит по `agent=="tester"`. Покрыто тестом PASS-advance и FAIL-rollback. | +| TR-3 (R-3) | Таймаут/изоляция pytest; утечка процессов (корень ORCH-109/110/111) | Сред. | Выс. | D3: pytest через `proc_group.run_in_process_group` (tree-kill SIGTERM→grace→SIGKILL); таймаут `test_runner_timeout_s`=900 (малформ→дефолт+WARNING); прогон **в worktree ветки**, не в общем клоне. Покрыто тестом изоляции/таймаута. | +| TR-4 (R-4) | Two-level outcome неверен: tool-error жжёт developer-retry (регресс ORCH-110) ИЛИ ложный green | Сред. | Выс. | D5: сюита исполнилась → verdict→advance; сюита НЕ исполнилась (spawn-error/таймаут/`None`) → bounded DEFER (re-queue `tester`-джоба, restart-safe маркер) → на исчерпании fail-closed `FAIL`+advance+alert. Никогда тихий advance/ложный green; не жжёт developer-retry на инфре. Покрыто тестом обоих уровней. | +| TR-5 (R-5) | Откат FAIL не совпадает с LLM-путём (developer-retry cap / `extract_test_failures`) | Низ. | Сред. | D7 переиспользует **существующий** откат `stage_engine.py:849-892` (тот же `MAX_DEVELOPER_RETRIES`, `extract_test_failures`, `enqueue_job("developer", …)`). Новой ветви маршрутизации нет. Покрыто тестом эквивалентности. | +| TR-6 (R-6) | LLM протаскивается обратно в поток управления вердикта (нарушение BR-8) | Низ. | Сред. | D11: детерминированный раннер — единственный продюсер `result:`; off-control-path триаж не реализуется и не добавляет ребро в `STAGE_TRANSITIONS`. Анти-дрейф LLM-карты (D12) + reviewer-ось AC-12. | +| TR-7 (R-7) | Backward-compat: репо без тест-контракта зависает без продюсера отчёта | Низ. | Выс. | D8: `_has_test_contract(repo)` (Phase 1 = self-hosting) — `applies==False` для не-self/без-контракта → `should_intercept==False` → прежний LLM-tester (fail-safe). Покрыто тестом для не-self репо. | +| **TR-8** | **52c-`status:` ↔ парсер: ложный FAIL.** `_parse_tests_verdict` читает вердикт из `verdict:`/**`status:`**/`result:` с negative-token-priority. 52c-`status: failed` (`"FAILED"`) при `result: PASS` → негативный токен авторитетен → ложный FAIL здорового прогона. **(Отсутствует в ORCH-115 — там гейт читает только `staging_status:`.)** | Сред. | Выс. | D6.1: `status:` ВСЕГДА выровнен по вердикту (`success`↔PASS / `failed`↔FAIL); `"SUCCESS"` — не негативный/не позитивный токен, позитив берётся из `result: PASS`. **Обязательный** unit-тест: `_parse_tests_verdict(<тело раннера PASS>)==(True,…)` и `(FAIL)==(False,…)` через **неизменённый** парсер. Reviewer: `status:`-литерал с негативным токеном при `result: PASS` → ≥P1. | +| **TR-9** | **Smoke против прод-8500 флапает.** Разовый блип запущенного оркестратора (connection refused/таймаут) → `FAIL` → откат здоровой ветки + расход developer-retry. | Сред. | Сред. | D3: bounded smoke-ретрай **транзиентной недостижимости** (несколько быстрых GET с коротким backoff) перед `FAIL`; «достижимо, но форма неверна» → немедленный `FAIL`. `test_runner_smoke_enabled` позволяет отключить smoke без отката раннера. pytest — первичный сигнал; developer-retry-cap поглощает остаточный шум. | +| **TR-10** | **DEFER re-queue'ит не тот агент.** Копипаст из `staging_runner` мог бы re-queue'ить `deployer`-джоб → задача уйдёт в чужой обработчик. | Низ. | Выс. | D5: DEFER re-queue'ит **`tester`**-джоб (`enqueue_job("tester", …)`), повторно входящий в этот раннер на стадии `testing`. Покрыто тестом DEFER (проверка роли re-queued джоба). | +| **TR-11** | **Дрейф LLM-карты/политики/витрины** при реализации (NFR-6): инвариант «ровно один `first_slice=yes`» нарушен / `avoidable=yes` снят с tester / анти-дрейф-тесты красные. | Сред. | Сред. | D12: точная спека правок (A5 → реализовано, но `avoidable=yes`/`axis=C`/`needs-hybrid-fallback` СОХРАНЯЮТСЯ; rank 2 tester → реализован, `first_slice` НЕ переключать — остаётся у rank 1/deployer). Правки атомарно с кодом в development + зелёные `test_llm_call_site_inventory.py`/`test_llm_determinization_docs.py`. Reviewer-ось AC-14 ≥P1. | +| TR-12 | Скоуп-дрейф: правка `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_tests_passed`/`_parse_tests_verdict`/machine-verdict/схемы БД | Низ. | Выс. | NFR-1: замена только *продюсера*. Анти-дрейф-тест на неизменность гейта/токенов/схемы (AC-6). Reviewer ловит как ≥P1. | + +## Сводный вывод + +Доминирующий класс — **корректность интеграции детерминированного раннера в существующий гейт** +(`finished_agent="tester"`, two-level outcome, эквивалентность отката) и **две tester-специфичные +мины, которых не было в ORCH-115**: (TR-8) коллизия 52c-`status:` с `_parse_tests_verdict` и +(TR-9) флап smoke против прод-8500. Обе закрыты архитектурно (D6.1 — жёсткое выравнивание `status:` ++ unit-тест через неизменённый парсер; D3 — bounded smoke-ретрай + config-gate). Остаточный риск для +прод-конвейера (self-hosting) — **низкий**: leaf never-raise + kill-switch (мгновенный откат к +LLM-tester байт-в-байт), без правки гейта/стадии/схемы БД, граница с ORCH-112/114/115 соблюдена, +сквозной бюджет времени сохранён без правки `reaper_max_running_s`. + +Эскалация `arch:major-change` **не требуется** (нет новой стадии/QG/смены БД; новый компонент-leaf — +аддитивный, под kill-switch, по доказанному прецеденту ORCH-115). Возврат в анализ **не требуется** +(ТЗ удовлетворяется без нарушения принципов архитектуры).