diff --git a/CHANGELOG.md b/CHANGELOG.md index 3460e28..8e6b52b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,11 @@ Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу. ## [Unreleased] +- **Карта LLM-консультаций + control-path-ось «avoidable» + roadmap + нормативная политика** (ORCH-118, `docs`+`test`, inventory-first, docs+tests only): зонтичный follow-up RCA-трека ORCH-114/117 — у оркестратора не было ни нормативного критерия «где LLM нужен, а где это avoidable control path», ни карты мест вызова LLM, прибитой к коду. Выпущена **доказательная карта** каждого места, где control-path потребляет (или способен потребить) суждение LLM, с control-path-разметкой и классификацией; **упорядоченный roadmap** детерминированных замен; **нормативная политика** использования LLM; набор **структурных анти-дрейф тестов**. Это **docs + tests only**: `src/**`-рантайм не меняется → `STAGE_TRANSITIONS` / реестр и имена `QG_CHECKS`/`check_*` / machine-verdict-ключи / схема БД — **байт-в-байт не тронуты**; раннеры замен **не** реализуются (FR-7); конкретные follow-up Plane-ID **не** фиксируются (R3/NFR-6 — кандидаты по роли). kill-switch не нужен (нет рантайм-поведения), как ORCH-077/079/101/102/103/011. ADR: `docs/work-items/ORCH-118/06-adr/ADR-001-llm-call-site-map-and-determinization-roadmap.md`, сквозной `docs/architecture/adr/adr-0047-llm-usage-policy-and-call-site-map.md`. + - **Единица инвентаря — LLM-консультация** (control-path потребляет суждение LLM), а **не** «спавн процесса / существование Claude CLI» (R4, capability ≠ consultation). Карта разводит **три ортогональных факта**: (1) consultation ≠ transport/slot (единственный транспорт LLM-консультации в `src/**` — `launcher._spawn`, `launcher.py:472`/CLI-сборка `610-614`; иного транспорта нет; job-роли `deploy-finalizer`/`post-deploy-monitor` занимают слот, но перехватываются в `launch_job` **до** `_spawn`, `launcher.py:389/394` — консультации нет); (2) **control-path (C) ≠ artifact-producer (P)** по коду-потребителю в `src/qg/checks.py` (C: гейт ветвится на LLM-вердикте; P: детерминированный гейт судит артефакт независимо — файлы/CI); (3) деривируемость вердикта из tool-сигналов. + - **Нормативное определение** «avoidable LLM control path» = двухбитный предикат (C-консультация **И** вердикт деривируем из exit-кодов `pytest`/smoke/`staging_check.py`/деплоя). Поимённый целевой набор (доказательно, прибит тестами): **avoidable = `{tester, deployer}`**; control-path-но-keep = `{reviewer}` (вердикт «приемлемость кода/решения» НЕ деривируем); не-control-path (P, keep) = `{analyst, architect, developer}`; уже детерминированы (эталон) = `{deploy-finalizer, post-deploy-monitor}`. + - **Документы (durable, `docs/architecture/`):** `llm-call-sites.md` (карта + машинно-читаемый блок инвентаря + control-path-разметка + классификация, снимок), `llm-determinization-roadmap.md` (порядок замен; рекомендованный первый срез — **deployer staging-status**, чистый маппинг exit-кода `staging_check.py`; прод уже детерминирован Phase A/B/C ORCH-036), `llm-usage-policy.md` (нормативный принцип + критерии keep/replace через ось + определение «avoidable»). Витрина `docs/overview/tech-quality-security.md` и `docs/architecture/README.md` ссылаются на карту и политику. + - **Анти-дрейф тесты (offline, без сети/LLM/subprocess-к-модели):** `tests/test_llm_call_site_inventory.py` (TC-01 единственный транспорт = `_spawn`; TC-12 отсутствие иного LLM-транспорта; TC-02 детерминированные модули без консультации; TC-03 промпты↔файлы; TC-04 тотальность+согласованность класса с осью; TC-05 keep-LLM именует суждение; TC-06 capability≠consultation; TC-09 снимок рантайм-контрактов; **TC-13** control-path-разметка сверена с потребителем в `src/qg/checks.py`; **TC-14** avoidable-набор = `{tester, deployer}`) и `tests/test_llm_determinization_docs.py` (TC-07 полнота roadmap+первый срез; TC-08 политика нормативна+определяет термин; TC-11 анти-фабрикация follow-up ID). Дискриминатор всех проверок — **«консультирует LLM», а не «спавнит subprocess»**. Норматив сопровождения: менял место вызова LLM или потребителя вердикта в `src/qg/checks.py` → обнови карту/разметку и политику в том же PR. - **Sandbox-only fail-closed изоляция записи в Plane из тест-процесса** (ORCH-117, `fix`, bug→escalate full-cycle): закрыт корневой класс инцидента **ORCH-114** — тест/worktree-процесс выполнил РЕАЛЬНУЮ запись (`PATCH …/issues/… state=` + комментарий «Stage: deploy → done») против **боевого** Plane-проекта, т.к. тест/staging-процессы наследуют живой боевой Plane-токен (`PLANE_HEADERS`/`PROJECT_ID` захвачены литералами **на импорте** — подмена env/токена постфактум бесполезна, NFR-4), и **ничто** не принуждало их писать только в sandbox. Симметрия прецеденту `tests/conftest.py::_no_telegram` (autouse-глушилка Telegram «pytest на проде слал реальные сообщения») — для Plane-**записи** такой защиты не было. Аддитивно, never-raise в боевом пути; `STAGE_TRANSITIONS`/реестр `QG_CHECKS`/семантика и имена `check_*`/machine-verdict-ключи/схема БД — **байт-в-байт не тронуты** (это изоляция клиента Plane, **не** Quality Gate и **не** стадия). Новый чистый leaf `src/plane_write_guard.py` (`decide(project_id, op, work_item) -> (ALLOW|BLOCK, reason)`, по образцу `deploy_status_guard`/`serial_gate`) врезан в **3 примитива записи** `plane_sync` (`update_issue_state`/`add_comment`/`_set_issue_state_direct`) **на момент вызова** — сразу после локального `_resolve_project_id` и **до** любого сетевого шага (ни GET, ни PATCH/POST). Гард активен **только в тест-процессе** (детект `"pytest" in sys.modules` / `PYTEST_CURRENT_TEST`); боевой и staging рантаймы (`uvicorn src.main:app`, без pytest в процессе) — строгий **no-op** (NFR-2/NFR-3). В тест-процессе запись разрешена **только** при одновременном (а) opt-in `plane_test_write_enabled=True` **и** (б) целевом проекте ∈ sandbox-allowlist `plane_test_sandbox_projects` (дефолт = единственный SANDBOX `8c5a3025-…`); иначе — default-deny; нерезолвимый проект → блок (fail-closed, NFR-1); боевой проект запрещён **даже при opt-in** (allowlist sandbox-only). Второй независимый sandbox-bound слой — autouse-floor `tests/conftest.py::_plane_sandbox_only` (opt-in OFF для всего сьюта, по образцу `_no_telegram`/`_disable_*`); sandbox-e2e ре-энейблит opt-in в своей фикстуре поверх floor. **Умышленно БЕЗ kill-switch прод-блока** (NFR-6/FR-7/anti-drift): выключателя, переоткрывающего прод-запись из pytest, нет — единственный реверс — sandbox-bound opt-in. Аудит: блок → громкий структурный ERROR (`project_id`/`work_item`/`op`/`reason` — делает инцидент класса ORCH-114 очевидным), разрешённая sandbox-запись → INFO. Новые ключи `ORCH_PLANE_TEST_WRITE_ENABLED` (дефолт `false`) / `ORCH_PLANE_TEST_SANDBOX_PROJECTS` (дефолт = SANDBOX id) с безопасными дефолтами; `scripts/staging_check.py` Block C (E2E в SANDBOX) — отдельный процесс с собственными httpx-вызовами, гардом не затронут. Покрытие — `tests/test_orch117_plane_write_isolation.py` (TC-01 — обязательный регресс ORCH-114: красный до врезки, зелёный после; TC-02…TC-14). ADR: `docs/work-items/ORCH-117/06-adr/ADR-001-sandbox-only-plane-write-guard.md`, сквозной `docs/architecture/adr/adr-0046-sandbox-only-plane-write-guard.md`. - **Ownership-lease для side-effectful переходов стадий + умное восстановление при старте** (ORCH-114, `fix`, bug→escalate full-cycle): закрыт **корневой класс** инцидент-цепочки ORCH-110/111/112/113 — у side-effectful переходов стадий не было единого владения. `advance_stage` ре-ентерабельна и пишет стадию «голым» `UPDATE … WHERE id=?` (без compare-and-swap), а ≥5 акторов (монитор / Plane-webhook / reconciler F-1 / job-reaper / deploy-finalizer) входят в один переход независимо → конкурентный или после-рестартовый повторный вход **дважды** применял необратимые эффекты (merge_pr / coverage-ratchet / image-rebuild / инициация прод-деплоя) и давал **противоречие rollback↔done** (инцидент ORCH-111, job 1914 / PR #130). Два комплементарных слоя, оба аддитивные, под единым kill-switch, never-raise: **(1) durable transition-lease** (новая таблица `transition_lease`) — владение на ВХОДЕ в side-effectful регион (второй актор, увидев живого владельца, не стартует тяжёлые под-гейты вовсе — предотвращение, не починка постфактум); **(2) expected-stage CAS** (`update_task_stage_cas`) — на ЗАПИСИ стадии (проигравший гонку — аборт без побочных эффектов), что закрывает и **6 путей записи стадии в обход `advance_stage`** (gitea×5 + plane rollback). Liveness владельца = `owner_pid` + `owner_boot_id` (НЕ heartbeat: блокирующий 900s merge re-test не может бить heartbeat — довод самого ORCH-113), что делает рестарт-recovery бесплатным (новый процесс → новый boot-id → все прежние lease мгновенно устаревшие → реклеймятся). Lease без собственного TTL: его потолок возраста = Tier-3 backstop `reaper_max_running_s` (5400) → сквозной бюджет ORCH-065/109/110/113 не тронут. `STAGE_TRANSITIONS` / реестр `QG_CHECKS` / семантика и имена `check_*` / machine-verdict-ключи / **схемы существующих таблиц** — байт-в-байт (одна аддитивная таблица, без epoch-колонки на `tasks`). Скоуп self-hosting (`transition_lease_repos=""` → только `orchestrator`; enduro не затронут); kill-switch `ORCH_TRANSITION_LEASE_ENABLED=false` → CAS вырождается в прежний безусловный `update_task_stage`, lease инертен → поведение байт-в-байт до ORCH-114. ADR: `docs/work-items/ORCH-114/06-adr/ADR-001-transition-ownership-lease-and-stage-cas.md`, сквозной `docs/architecture/adr/adr-0045-transition-ownership-lease-and-stage-cas.md`. - **Leaf `src/transition_lease.py` (новый, чистый never-raise):** по образцу `serial_gate`/`coverage_gate`/`finalizer_liveness` (импортирует только `db`+`config`, лениво `merge_gate.pid_alive`/`qg.checks`/`notifications`; НЕ импортирует `stage_engine`/`launcher`) — `applies(repo)` / `acquire(task_id, owner, run_id, stage)` (атомарный rowcount-guard `INSERT … ON CONFLICT DO NOTHING` после очистки stale-строки) / `is_held_by_live_owner(task_id)` (fail-closed → defer на сомнении) / `release(task_id, force=False)` (holder-aware по boot) / `reclaim_if_stale` / `recover_on_startup` / `commit_stage_cas(task_id, expected, new, repo)` (flag-off → unconditional `update_task_stage`; flag-on → CAS) / `snapshot()`. diff --git a/docs/architecture/llm-call-sites.md b/docs/architecture/llm-call-sites.md new file mode 100644 index 0000000..aed8e95 --- /dev/null +++ b/docs/architecture/llm-call-sites.md @@ -0,0 +1,156 @@ +# LLM call-site map — inventory, control-path axis & classification (ORCH-118) + +> **Что это.** Доказательная карта **каждого места**, где control-path оркестратора +> потребляет (или способен потребить) суждение LLM. Единица инвентаря — **LLM-консультация** +> (control-path потребляет суждение LLM), **не** «спавн процесса / существование Claude CLI» +> (capability ≠ consultation, BRD §0 / R4). +> +> **Снимок, прибитый к коду.** Карта — *снимок*; её инварианты держат структурные тесты +> `tests/test_llm_call_site_inventory.py` (анти-дрейф). Меняешь место вызова LLM или потребителя +> вердикта в `src/qg/checks.py` → **обнови эту карту и политику в том же PR** (норматив сопровождения). +> +> **Источник истины** содержательной классификации — ADR +> `docs/work-items/ORCH-118/06-adr/ADR-001-llm-call-site-map-and-determinization-roadmap.md` +> (D2/D3/D4) и сквозной `docs/architecture/adr/adr-0047-llm-usage-policy-and-call-site-map.md`. +> Нормативное определение «avoidable LLM control path» и критерии keep/replace — в +> [`llm-usage-policy.md`](llm-usage-policy.md); порядок замен — в +> [`llm-determinization-roadmap.md`](llm-determinization-roadmap.md). + +--- + +## 0. Три ортогональных факта (как читать карту) + +Карта **явно** разводит три раздельных факта — их смешение было корнем блокеров R3→R5: + +1. **Ось 1 — consultation ≠ transport/slot.** «LLM-консультация» = точка, где решение/артефакт + конвейера **потребляет суждение LLM**. Транспорт (`_spawn`) — реализация, не определение. Слот + агента (job-роли `D1`/`D2`) делает site LLM-**capable**, но консультация гейтится потоком + управления (перехват **до** `_spawn`) → **capability ≠ consultation**. +2. **Ось 2 — control-path (C) ≠ artifact-producer (P).** Определяется **кодом-потребителем** вывода + роли в `src/qg/checks.py`: + - **(C) control-path** — LLM эмитит machine-verdict, на котором **ветвится `check_*`-гейт** + (PASS → дальше / FAIL → откат). Суждение LLM **входит** в поток управления. + - **(P) artifact-producer** — LLM производит артефакт, а продвижение решает **детерминированный + гейт**, судящий артефакт **независимо** (наличие файлов / CI-статус). Суждение LLM в control + flow **не входит**. +3. **Ось 3 — деривируемость вердикта.** Вердикт C-консультации либо есть **детерминированная функция + tool-сигналов** (exit-code `pytest`/smoke/`staging_check.py`/деплоя), которые оркестратор **уже + вычисляет сам**, либо требует **настоящего суждения**, не сводимого к exit-коду. + +> **Avoidable LLM control path** (нормативное определение — [`llm-usage-policy.md`](llm-usage-policy.md)): +> call-site, для которого выполнены **оба** условия — **(i)** это C-консультация (её LLM-вердикт +> потребляется потоком управления) **и** **(ii)** вердикт **деривируем** из tool-сигналов. Тогда +> суждение LLM не добавляет информации → консультацию можно снять без потери смысла. + +--- + +## 1. Инвентарь LLM-консультаций (полный, привязан к коду) + +Каждая запись несёт: `id` · `location (file:line)` · `trigger` · `stage/owner` · `output artifact` · +`machine-verdict key` · `output consumer` (кто потребляет вывод роли) · `est. tokens/runtime` +(оценка из `agent_runs`) · `consults-LLM` · `axis` (C/P) · `classification` · `rationale`. + +| id | location (file:line) | trigger | stage / owner | output artifact | machine-verdict key | output consumer (`src/qg/checks.py`) | est. tokens/runtime (оценка) | consults-LLM | axis | classification | rationale | +|----|----------------------|---------|---------------|-----------------|---------------------|--------------------------------------|------------------------------|--------------|------|----------------|-----------| +| **S0** | `src/agents/launcher.py:472` (`_spawn`; CLI-сборка `610-614`; парс токенов `_monitor_agent:838`) | `launch_job` → `_spawn` для любой из 6 ролей | — (транспорт) | — | — | — | — | **транспорт** (capability) | — | — | Единственный транспорт LLM-консультации в `src/**`; не call-site решения | +| **A1** | `.openclaw/agents/analyst.md` | стадия `analysis` | analyst | `01-brd` … `04-test-plan` | — | `check_analysis_complete:33` (наличие файлов) | ~80–200k / 5–20 мин | да (через S0) | **P** | `keep-LLM` | анализ требований / BRD/ТЗ — настоящее суждение; гейт судит лишь наличие артефактов | +| **A2** | `.openclaw/agents/architect.md` | стадия `architecture` | architect | `06-adr/`, `07` | — | `check_architecture_done:62` (наличие 06-adr/07) | ~80–200k / 5–20 мин | да (через S0) | **P** | `keep-LLM` | архитектурное решение / ADR — настоящее суждение | +| **A3** | `.openclaw/agents/developer.md` | стадия `development` | developer | код + PR | — | `check_ci_green:82` (+ `check_branch_mergeable:657`) — CI/merge | ~150–400k / 10–40 мин | да (через S0) | **P** | `keep-LLM` | написание кода — настоящее суждение; гейт судит CI/merge, не самоотчёт | +| **A4** | `.openclaw/agents/reviewer.md` | стадия `review` | reviewer | `12-review.md` | `verdict:` | `check_reviewer_verdict:336` (`verdict:`) | ~100–300k / 5–25 мин | да (через S0) | **C** | `keep-LLM` | control path, но вердикт «приемлемость кода/решения» **НЕ деривируем** из exit-кода — настоящее суждение | +| **A5** | `.openclaw/agents/tester.md` | стадия `testing` | tester | `13-test-report.md` | `result:` | `check_tests_passed:182` → `_parse_tests_verdict:226` (`result:`) | ~60–150k / 5–20 мин | да (через S0) | **C** | `needs-hybrid-fallback` | **avoidable**: PASS/FAIL = exit-code `pytest`+smoke (деривируем); LLM нужен лишь на триаж падений / маппинг TC↔критерии | +| **A6** | `.openclaw/agents/deployer.md` | стадии `deploy-staging` / `deploy` | deployer | `15-staging-log.md` / `14-deploy-log.md` | `staging_status:` / `deploy_status:` | `check_staging_status:599` → `_parse_staging_status:538` (`staging_status:`); `check_deploy_status:473` → `_parse_deploy_status:413` (`deploy_status:`) | ~40–120k / 3–15 мин | да (через S0) | **C** | `replace-deterministic-now` | **avoidable**: staging-вердикт = маппинг exit-кода `staging_check.py`; прод уже детерминирован Phase A/B/C (ORCH-036) | +| **D1** | `src/agents/launcher.py:389` (перехват в `launch_job` **до** `_spawn`; «Not an LLM spawn» `407`) | post-deploy edge | deploy-finalizer | jobs-row | — | — | — (детерминированный) | **нет** (слот, перехват до `_spawn`) | — | `already-deterministic` (эталон) | Занимает слот агента, но LLM не консультируется — рабочий прецедент замены | +| **D2** | `src/agents/launcher.py:394` (перехват в `launch_job` **до** `_spawn`; «Not an LLM spawn» `428`) | post-deploy observation | post-deploy-monitor | jobs-row | — | — | — (детерминированный) | **нет** (слот, перехват до `_spawn`) | — | `already-deterministic` (эталон) | Тик наблюдения; LLM не консультируется | + +> **Итог (поимённо).** `avoidable LLM control paths = {tester, deployer}`; control-path-но-keep = +> `{reviewer}`; не-control-path (P) = `{analyst, architect, developer}`; already-deterministic-эталон = +> `{deploy-finalizer, post-deploy-monitor}`. + +### 1.1 Машинно-читаемый блок инвентаря + +> Стабильный заголовок таблицы (`id | role | location | output_consumer | consults_llm | axis | +> avoidable | classification`) парсится `tests/test_llm_call_site_inventory.py` (split по `|`, без +> новых зависимостей) и сверяется с кодом (TC-03/04/05/13/14). **Не менять заголовок и значения без +> синхронной правки кода/тестов.** + + +| id | role | location | output_consumer | consults_llm | axis | avoidable | classification | +|----|------|----------|-----------------|--------------|------|-----------|----------------| +| S0 | _spawn | src/agents/launcher.py:472 | - | transport | - | - | - | +| A1 | analyst | .openclaw/agents/analyst.md | check_analysis_complete | yes | P | no | keep-LLM | +| A2 | architect | .openclaw/agents/architect.md | check_architecture_done | yes | P | no | keep-LLM | +| A3 | developer | .openclaw/agents/developer.md | check_ci_green | yes | P | no | keep-LLM | +| A4 | reviewer | .openclaw/agents/reviewer.md | check_reviewer_verdict | yes | C | no | keep-LLM | +| A5 | tester | .openclaw/agents/tester.md | _parse_tests_verdict | yes | C | yes | needs-hybrid-fallback | +| A6 | deployer | .openclaw/agents/deployer.md | _parse_staging_status | yes | C | yes | replace-deterministic-now | +| D1 | deploy-finalizer | src/agents/launcher.py:389 | - | no | - | - | already-deterministic | +| D2 | post-deploy-monitor | src/agents/launcher.py:394 | - | no | - | - | already-deterministic | + + +### 1.2 keep-LLM — названное суждение (обоснование) + +> Для каждой `keep-LLM`-записи назван **конкретный** вид суждения, ради которого LLM сохраняется. +> Для C-keep (`reviewer`) обоснование явно фиксирует **НЕ-деривируемость** вердикта (почему не сводится +> к exit-коду). Парсится TC-05 (`- role: текст`). + + +- analyst: анализ требований и написание BRD/ТЗ — настоящее суждение; детерминированный гейт `check_analysis_complete` судит лишь наличие файлов, не их содержательное качество. +- architect: архитектурное решение и ADR — настоящее суждение о компромиссах/инвариантах; `check_architecture_done` судит лишь наличие 06-adr/07. +- developer: написание кода — настоящее суждение; гейт `check_ci_green` судит CI/merge, а не самоотчёт агента. +- reviewer: «приемлемость кода/решения» — настоящее суждение, которое НЕ деривируемо (not derivable) из exit-кода `pytest`/smoke/деплоя; в отличие от tester/deployer вердикт reviewer'а не сводится к tool-сигналу, поэтому это control-path-но-keep, а не avoidable. + + +--- + +## 2. Таксономия классификации (4 класса, выведена из осей) + +Четыре взаимоисключающих класса; класс **выводится** из осей §0 (а не постулируется): + +- **`keep-LLM`** — нужно настоящее суждение (обязательно **назвать** конкретное суждение, §1.2). +- **`replace-deterministic-now`** — безопасная детерминированная замена сейчас. +- **`replace-later/risky`** — замена возможна позже / с предпосылками. +- **`needs-hybrid-fallback`** — детерминированное ядро + LLM-фолбэк только на суждение. + +**Правило вывода:** +`P → keep-LLM`; `C + не-деривируемый вердикт → keep-LLM`; +`C + деривируемый вердикт → replace-* / needs-hybrid-fallback` (**= avoidable**). + +`deploy-finalizer`/`post-deploy-monitor` помечены `already-deterministic` — вне таксономии замен +(эталон: LLM не консультируется, перехват до `_spawn`). + +--- + +## 3. Детерминизм не-агентских control-path'ов (доказательство, FR-3 / AC-3) + +Эти пути **не консультируют LLM** (ни через `_spawn`-транспорт, ни альтернативным транспортом). ⚠️ Они +**спавнят subprocess'ы** (`git`/`pytest`/`docker`/`ssh`/сканеры/`staging_check.py`) — это +детерминированные **инструменты**, не LLM: доказательство детерминизма — **отсутствие *LLM*-транспорта**, +а не отсутствие *subprocess* (дискриминатор §0). Проверяется TC-02. + +| Путь / модуль | `file:line` (якорь) | Природа | +|---------------|---------------------|---------| +| Маршрутизация стадий | `src/stages.py::STAGE_TRANSITIONS:12`, `advance_stage` в `src/stage_engine.py` | статический словарь + детерминированная функция | +| Реестр Quality Gate | `src/qg/checks.py::QG_CHECKS:812` (14 имён) | словарь имя→функция | +| Все `check_*` | `src/qg/checks.py` (`33/62/82/182/336/473/599/657`, …) | файловые/HTTP/exit-code проверки | +| Парсеры вердиктов `_parse_*` | `src/qg/checks.py:226/413/538` через `src/frontmatter.py::parse_frontmatter` | YAML-frontmatter парс (читают, **не производят** вердикт) | +| Классификатор ошибок | `src/error_classifier.py` | regex по строкам | +| Под-гейты | `src/{security_gate,merge_gate,coverage_gate,staging_verdict}.py` | сканеры/`pytest --cov`/git/маппинг exit-кодов | +| Self-deploy Phase A/B/C | `src/self_deploy.py` | детерминированный detached-деплой (ORCH-036) | +| Сериализация / владение | `src/{serial_gate,transition_lease,reconciler,job_reaper}.py` | FIFO-гейт / durable-lease / CAS / reaper | + +Любая найденная **неожиданная** LLM-консультация в этих путях добавляется в инвентарь §1 и +классифицируется §2 (тогда TC-02 станет красным — точка дрейфа). + +--- + +## 4. Скоуп и анти-дрейф + +- **Docs + tests only (ORCH-118).** `STAGE_TRANSITIONS` / реестр и имена `QG_CHECKS`/`check_*` / + machine-verdict-ключи (`verdict:`/`result:`/`staging_status:`/`deploy_status:`/`security_status:`/ + `coverage_status:`) / схема БД — **байт-в-байт не тронуты**. Раннеры замен **не** реализованы + (см. roadmap — follow-up'ы по роли, без Plane-ID). +- **Анти-дрейф тесты:** `tests/test_llm_call_site_inventory.py` (TC-01…TC-06, TC-09, TC-12, TC-13, + TC-14) и `tests/test_llm_determinization_docs.py` (TC-07/08/11). Дискриминатор всех проверок — + **«консультирует LLM», а не «спавнит subprocess»**. +- **Связанные документы:** [`llm-usage-policy.md`](llm-usage-policy.md), + [`llm-determinization-roadmap.md`](llm-determinization-roadmap.md). diff --git a/docs/architecture/llm-determinization-roadmap.md b/docs/architecture/llm-determinization-roadmap.md new file mode 100644 index 0000000..126b208 --- /dev/null +++ b/docs/architecture/llm-determinization-roadmap.md @@ -0,0 +1,70 @@ +# LLM determinization roadmap (ORCH-118) + +> **Что это.** Упорядоченный план детерминированных замен **avoidable LLM control paths** +> (`{tester, deployer}` — см. [`llm-call-sites.md`](llm-call-sites.md)). Это **транзиентный план**: +> он обновляется по мере закрытия follow-up'ов. ORCH-118 раннеры **не реализует** (FR-7); старт каждого +> кандидата гейтится утверждением карты. +> +> **Кандидаты названы ПО РОЛИ.** Конкретные follow-up Plane-ID **не фиксируются** ни в одном артефакте +> (R3 / NFR-6): этих work item нет в подтверждённом backlog; ID присваивается при заведении задачи. +> Анти-фабрикация прибита тестом `TC-11` (`tests/test_llm_determinization_docs.py`). +> +> **Оценки экономии — «оценка до фактического замера» (NFR-5).** Источник — существующие колонки +> `agent_runs` (`model`/`effort`/токены/стоимость/время); точные числа снимаются при заведении +> follow-up'а через `GET /metrics` / запрос к `agent_runs`. Здесь — порядок величины, а не контракт. + +--- + +## 1. Рекомендованный первый срез — **deployer (staging-status)** + +Обоснование (самый низкорисковый «чисто деривируемый» control path): + +1. **Деривируемость максимальна.** Вердикт `staging_status:` — **чистый маппинг** exit-кода + `scripts/staging_check.py`; готовый leaf `src/staging_verdict.py::compute_staging_verdict` (ORCH-061) + уже считает этот вердикт детерминированно. +2. **Прод уже детерминирован.** Ребро `deploy` (`deploy_status:`) для self-hosting `orchestrator` + производит детерминированный finalizer (Phase A/B/C, ORCH-036) — LLM в критическом + self-restart-пути нет. Срез не трогает критический путь → минимальная поверхность риска. +3. **Опирается на прецедент** `D1`/`D2` (`launch_job`-перехват **до** `_spawn`) — архитектурный риск + замены агента уже снят рабочим паттерном. +4. **`replace-deterministic-now`, без hybrid-fallback** (в отличие от tester). + +## 2. Второй кандидат — **tester (гибрид)** + +Детерминированное ядро (`pytest` + smoke даёт PASS/FAIL по exit-коду) покрывает основной вердикт; +LLM-фолбэк сохраняется **только** на суждение, не сводимое к exit-коду: **триаж падений** и **маппинг +TC ↔ критерии приёмки**. Поэтому `needs-hybrid-fallback`, а не `replace-deterministic-now`: поверхность +суждения шире и объём работы больше. + +## 3. Общие требования к каждому follow-up'у + +Каждый кандидат при заведении задачи несёт: kill-switch + обратимость (паттерн +ORCH-022/027/043/089/090 — флаг `*_enabled`, пустой CSV `*_repos` → self-hosting only), скоуп-гард +(не трогать `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схему БД), а замена-агента — +перехват в `launch_job` **до** `_spawn` (как `D1`/`D2`). Свежесть прецедента — инцидент-трек +ORCH-110/111/112/113/114/117 (единое детерминированное владение side-effectful путями). + +--- + +## 4. Машинно-читаемый блок roadmap + +> Заголовок (`rank | role | dependencies | savings_estimate_source | security_risk | hybrid_needed | +> followup_type | first_slice`) парсится `tests/test_llm_determinization_docs.py::test_tc07_*`. +> `followup_type` — **по роли**, без Plane-ID. Ровно один `first_slice = yes`. + + +| rank | role | dependencies | savings_estimate_source | security_risk | hybrid_needed | followup_type | first_slice | +|------|------|--------------|-------------------------|---------------|---------------|---------------|-------------| +| 1 | deployer | staging_verdict.compute_staging_verdict (ORCH-061) + launch_job pre-spawn precedent (D1/D2) | agent_runs (deployer rows; estimate pending GET /metrics) | low (prod already deterministic via Phase A/B/C ORCH-036) | no | deployer-replacement (staging-status mapping) | yes | +| 2 | tester | deterministic pytest+smoke core; LLM fallback for failure triage / TC-to-criteria mapping | agent_runs (tester rows; estimate pending GET /metrics) | medium (failure-triage judgment must stay correct) | yes | tester-hybrid (deterministic core + LLM fallback) | no | + + +--- + +## 5. Вне scope + +- `reviewer` — C, но **keep** (вердикт «приемлемость кода/решения» не деривируем): **не** в roadmap'е. +- `analyst`/`architect`/`developer` — P (artifact-producer, не control path): **не** в roadmap'е. +- Реализация раннеров — отдельные follow-up задачи (по роли), стартуют после утверждения карты. + +Связанные документы: [`llm-call-sites.md`](llm-call-sites.md), [`llm-usage-policy.md`](llm-usage-policy.md). diff --git a/docs/architecture/llm-usage-policy.md b/docs/architecture/llm-usage-policy.md new file mode 100644 index 0000000..ceb0e89 --- /dev/null +++ b/docs/architecture/llm-usage-policy.md @@ -0,0 +1,96 @@ +# LLM usage policy (ORCH-118) + +> **Нормативный durable-документ.** Формулирует принцип использования LLM в оркестраторе, критерии +> «keep vs replace» через **control-path-ось**, и нормативное **определение «avoidable LLM control +> path»**. Применяется ко **всем** будущим правкам control-path'ов. Сопутствующие артефакты — +> карта [`llm-call-sites.md`](llm-call-sites.md) и roadmap [`llm-determinization-roadmap.md`](llm-determinization-roadmap.md). + +--- + +## 1. Принцип + +**LLM — только там, где нужно настоящее суждение.** Если решение/вердикт control-path'а есть +**детерминированная функция tool-сигналов**, которые оркестратор уже вычисляет (exit-code `pytest`, +smoke, `staging_check.py`, статус деплоя, наличие файлов, CI-статус), — оно должно приниматься +**детерминированно**, а не консультацией LLM. LLM сохраняется там, где требуется суждение, **не +сводимое** к tool-сигналу (анализ требований, архитектурное решение, написание кода, приемлемость +ревью). + +Это защищает **автономность** (NFR-2): меньше точек, где недетерминизм/стоимость/латентность LLM +встроены в поток управления, и меньше класса инцидентов «LLM-агент принял решение, которое на деле +есть исполнение фиксированных команд и маппинг результата» (RCA-трек ORCH-110/111/112/113/114/117). + +--- + +## 2. Три оси решения (ground-truth — код) + +1. **consultation ≠ transport/slot.** «LLM консультируется» ⇔ решение/артефакт конвейера **потребляет + суждение LLM**. Существование транспорта (`_spawn`) или слота агента (job-роли с перехватом до + `_spawn`) — это **capability**, не консультация. +2. **control-path (C) ≠ artifact-producer (P)** — определяется **кодом-потребителем** вывода роли: + - **(C)** LLM эмитит machine-verdict, на котором **ветвится `check_*`-гейт** → суждение входит в + поток управления. + - **(P)** LLM производит артефакт, а продвижение решает **детерминированный гейт** независимо + (наличие файлов / CI) → суждение в control flow не входит. +3. **деривируемость вердикта** — вердикт C-консультации либо детерминированная функция tool-сигналов, + либо настоящее суждение, не сводимое к exit-коду. + +--- + +## 3. Нормативное определение «avoidable LLM control path» + +Это **двухбитный проверяемый предикат над `src/qg/checks.py`**, а не «удобство на глаз». + + +Call-site является **avoidable LLM control path** тогда и только тогда, когда выполнены **оба** условия: +- **(i)** это **C (control-path)** консультация — её LLM-вердикт потребляется потоком управления + (`check_*`-гейт ветвится на нём: PASS → дальше / FAIL → откат); +- **(ii)** вердикт **деривируем** (derivable) из tool-сигналов, которые оркестратор уже вычисляет сам — + exit-code `pytest` / smoke / `staging_check.py` / статус деплоя. + +Если оба условия выполнены, суждение LLM не добавляет информации → консультацию можно снять без потери +смысла (заменить детерминированным раннером или гибридом с LLM-фолбэком только на не-деривируемую часть). + + +**Поимённый целевой набор** (сверен с кодом, прибит тестами TC-13/TC-14): + +- **avoidable LLM control paths = `{tester, deployer}`** — C **и** вердикт деривируем + (`result:` = exit-code `pytest`+smoke; `staging_status:` = маппинг exit-кода `staging_check.py`). +- **`reviewer`** — C, но **keep**: вердикт «приемлемость кода/решения» **НЕ деривируем** из exit-кода + (настоящее суждение). Это control-path-но-keep, **не** avoidable. +- **`analyst` / `architect` / `developer`** — **не** control path (**P**, artifact-producer): + детерминированный гейт судит артефакт независимо. + +--- + +## 4. Критерии решения: keep vs replace + +| Ситуация (по осям §2) | Решение | Класс | +|-----------------------|---------|-------| +| **P** — artifact-producer (детерминированный гейт судит артефакт) | **keep** LLM | `keep-LLM` | +| **C**, вердикт **НЕ деривируем** (настоящее суждение) | **keep** LLM (назвать суждение) | `keep-LLM` | +| **C**, вердикт **деривируем**, замена безопасна сейчас | **replace** | `replace-deterministic-now` | +| **C**, вердикт деривируем, но замена позже / с предпосылками | **replace later** | `replace-later/risky` | +| **C**, ядро деривируемо, но часть требует суждения | **hybrid** (детерм. ядро + LLM-фолбэк) | `needs-hybrid-fallback` | + +> **keep-LLM требует обоснования:** любая `keep-LLM`-запись обязана **назвать конкретное суждение**; +> для C-keep — явно зафиксировать **не-деривируемость** вердикта (почему не сводится к exit-коду). + +--- + +## 5. Требование к новым/изменённым control-path'ам (норматив) + +- **Обоснование против политики.** Любой **новый** или изменённый control-path, который консультирует + LLM, обязан в своём ADR обосновать это против настоящей политики: показать, что он **P** (artifact + judged independently) **или** **C с не-деривируемым** вердиктом. C-консультация с деривируемым + вердиктом — это `avoidable`; её ввод без обоснования reviewer ловит как finding ≥P1. +- **Reviewer-ось (как ORCH-079) — требование, не реализация гейта.** Политика **рекомендует** + reviewer'у проверять соответствие новых control-path'ов настоящей политике; ORCH-118 **не** вводит + новый Quality Gate (`QG_CHECKS`/`check_*` не меняются) — это нормативное требование процесса. +- **Норматив сопровождения.** Меняешь место вызова LLM или потребителя вердикта в `src/qg/checks.py` → + обнови карту [`llm-call-sites.md`](llm-call-sites.md) и эту политику **в том же PR** (анти-дрейф + держат TC-13/TC-14). +- **Единственный транспорт.** Единственный разрешённый транспорт LLM-консультации в `src/**` — это + `launcher._spawn` (S0). Ввод второго транспорта (новый `_spawn`, импорт `anthropic`/`openai`/иного + LLM-SDK, прямой HTTP Anthropic/Claude, второй model-invoking subprocess) запрещён без явного ADR; + прибито тестами TC-01/TC-12. diff --git a/docs/overview/tech-quality-security.md b/docs/overview/tech-quality-security.md index f8ca251..4a24573 100644 --- a/docs/overview/tech-quality-security.md +++ b/docs/overview/tech-quality-security.md @@ -40,6 +40,22 @@ - Анти-регресс машинный: структурные тесты сканируют исполняемый код на боевые хост-литералы, а документацию — на секретоподобные значения; находка рвёт CI. +## Где уместен LLM: карта вызовов и нормативная политика + +Платформа держит **доказательную карту** всех мест, где поток управления потребляет суждение +LLM, и **нормативную политику** «LLM — только там, где нужно настоящее суждение». Карта разводит +три факта: консультация ≠ транспорт/слот; **control-path** (вердикт LLM ветвит поток управления) +≠ **artifact-producer** (детерминированный гейт судит артефакт независимо); и деривируемость +вердикта из tool-сигналов. Путь называется **avoidable LLM control path**, когда он одновременно +control-path и его вердикт деривируем из exit-кодов (тогда консультацию можно заменить +детерминированным раннером). Поимённо: avoidable = `{tester, deployer}`; настоящее суждение +сохраняется у `{analyst, architect, developer, reviewer}`. Карта — снимок, прибитый структурными +анти-дрейф тестами; реализация замен — отдельные follow-up'ы по роли. + +- Карта вызовов LLM: [`../architecture/llm-call-sites.md`](../architecture/llm-call-sites.md) +- Нормативная политика: [`../architecture/llm-usage-policy.md`](../architecture/llm-usage-policy.md) +- Порядок замен: [`../architecture/llm-determinization-roadmap.md`](../architecture/llm-determinization-roadmap.md) + ## Self-hosting-страховки Платформа дорабатывает сама себя тем же конвейером — прод-инстанс при этом обслуживает и diff --git a/tests/test_llm_call_site_inventory.py b/tests/test_llm_call_site_inventory.py new file mode 100644 index 0000000..4316378 --- /dev/null +++ b/tests/test_llm_call_site_inventory.py @@ -0,0 +1,448 @@ +# ORCH-118 (FR-6 / AC-1, AC-3, AC-6, AC-7, AC-10): structural anti-drift tests for +# the LLM call-site map (docs/architecture/llm-call-sites.md). +# +# UNIT OF INVENTORY = an *LLM consultation* (a control-path consumes an LLM +# judgment), NOT "a process is spawned / Claude CLI exists" (R4, BRD §0). The +# discriminator of every check below is therefore **"consults an LLM"**, never +# "spawns a subprocess": the orchestrator spawns dozens of deterministic tools +# (git / pytest / docker / ssh / scanners / staging_check.py) and those are +# explicitly NOT matched — otherwise the test would degenerate into "count every +# Popen" (the corruption these tests exist to avoid). +# +# These tests are fully offline and deterministic: no network, no LLM, no +# subprocess-to-a-model. They read repository source + the canonical machine +# blocks embedded in the map and assert the map stays in sync with the code. +# +# The map carries two machine-readable blocks the parser keys on: +# * ORCH-118-INVENTORY-BLOCK — the call-site table (8 columns, D2) +# * ORCH-118-KEEP-JUSTIFICATION-BLOCK — keep-LLM named-judgment list (TC-05) +# Both are human-readable markdown AND machine-parseable (stdlib split), per +# ADR-001 D2/D5 (no brittle prose regex). + +import re +from pathlib import Path + +from src.qg.checks import QG_CHECKS +from src.stages import STAGE_TRANSITIONS + +REPO_ROOT = Path(__file__).resolve().parents[1] +SRC = REPO_ROOT / "src" +WATCHDOG = REPO_ROOT / "watchdog" +AGENTS_DIR = REPO_ROOT / ".openclaw" / "agents" +MAP = REPO_ROOT / "docs" / "architecture" / "llm-call-sites.md" + +# The single allowed transport (S0): launcher._spawn builds + launches the Claude CLI. +TRANSPORT_FILE = "src/agents/launcher.py" + +# Roles split by control-path axis (§0-bis). Ground truth lives in src/qg/checks.py +# (the deterministic consumers) — the map must mirror it. +P_ROLES = frozenset({"analyst", "architect", "developer"}) +C_ROLES = frozenset({"reviewer", "tester", "deployer"}) +AGENT_ROLES = P_ROLES | C_ROLES + +# P-roles are consumed by deterministic gates that judge an ARTIFACT (file +# presence / CI) independently of any LLM self-report. +P_CONSUMERS = frozenset( + {"check_analysis_complete", "check_architecture_done", "check_ci_green"} +) +# C-roles are consumed by verdict-parsers that READ a machine-verdict the LLM wrote +# — the LLM judgment branches the control flow (PASS->advance / FAIL->rollback). +C_CONSUMERS = frozenset( + { + "check_reviewer_verdict", + "_parse_tests_verdict", + "_parse_staging_status", + "_parse_deploy_status", + } +) + +ALLOWED_CLASSES = frozenset( + { + "keep-LLM", + "replace-deterministic-now", + "replace-later/risky", + "needs-hybrid-fallback", + "already-deterministic", + } +) + +AGENT_IDS = frozenset({"A1", "A2", "A3", "A4", "A5", "A6"}) +ALL_IDS = frozenset({"S0", "A1", "A2", "A3", "A4", "A5", "A6", "D1", "D2"}) + +# Deterministic leaf modules / routing that must NOT consult an LLM (FR-3 / AC-3). +DETERMINISTIC_MODULES = ( + "stages", + "stage_engine", + "serial_gate", + "merge_gate", + "coverage_gate", + "security_gate", + "staging_verdict", + "review_parse", + "error_classifier", + "frontmatter", + "self_deploy", + "post_deploy", + "transition_lease", + "reconciler", + "job_reaper", +) + +# Alternative-LLM-transport signatures forbidden anywhere in src/** + watchdog/** +# (TC-12 / FR-6f): an LLM SDK import or a direct Anthropic/Claude HTTP endpoint. +_FORBIDDEN_TRANSPORT_RE = ( + re.compile(r"^\s*(?:from|import)\s+anthropic\b", re.M), + re.compile(r"^\s*(?:from|import)\s+openai\b", re.M), + re.compile(r"api\.anthropic\.com"), + re.compile(r"/v1/messages"), +) + +# Frozen snapshot of the runtime contract (TC-09 / FR-7 / AC-7). ORCH-118 is +# docs+tests-only; if this drifts, the map task touched the stage machine / gates. +EXPECTED_STAGE_AGENTS = frozenset( + {"analyst", "architect", "developer", "reviewer", "tester", "deployer"} +) +EXPECTED_QG_CHECKS = frozenset( + { + "check_analysis_approved", + "check_analysis_complete", + "check_architecture_done", + "check_ci_green", + "check_review_approved", + "check_tests_passed", + "check_reviewer_verdict", + "check_tests_local", + "check_deploy_status", + "check_staging_status", + "check_branch_mergeable", + "check_staging_image_fresh", + "check_security_gate", + "check_coverage_gate", + } +) + + +# --------------------------------------------------------------------------- +# Helpers (stdlib only) +# --------------------------------------------------------------------------- +def _src_py_files() -> list[Path]: + return sorted(SRC.glob("**/*.py")) + + +def _src_and_watchdog_py_files() -> list[Path]: + files = list(SRC.glob("**/*.py")) + if WATCHDOG.is_dir(): + files.extend(WATCHDOG.glob("**/*.py")) + return sorted(files) + + +def _rel(p: Path) -> str: + return p.relative_to(REPO_ROOT).as_posix() + + +def _function_body(source: str, name: str) -> str: + """Return the source text of ``def `` up to (excluding) the next + same-or-lower-indent def/class/decorator. Robust to line drift.""" + lines = source.splitlines() + start = None + indent = 0 + for i, line in enumerate(lines): + stripped = line.lstrip() + if stripped.startswith(f"def {name}("): + start = i + indent = len(line) - len(stripped) + break + assert start is not None, f"def {name}( not found in source" + body = [lines[start]] + for line in lines[start + 1 :]: + if not line.strip(): + body.append(line) + continue + cur_indent = len(line) - len(line.lstrip()) + head = line.lstrip() + if cur_indent <= indent and head.startswith(("def ", "class ", "@")): + break + body.append(line) + return "\n".join(body) + + +def _extract_block(text: str, name: str) -> str: + start = f"" + end = f"" + assert start in text, f"missing block start marker {start!r} in map" + assert end in text, f"missing block end marker {end!r} in map" + return text.split(start, 1)[1].split(end, 1)[0] + + +def _parse_pipe_table(block: str) -> list[dict]: + """Parse a GitHub-style pipe table into a list of {column: value} dicts.""" + header = None + rows: list[dict] = [] + for raw in block.splitlines(): + line = raw.strip() + if not line.startswith("|"): + continue + cells = [c.strip() for c in line.strip("|").split("|")] + joined = "".join(cells) + if joined and set(joined) <= set("-: "): + continue # separator row |---|---| + if header is None: + header = [c.lower() for c in cells] + continue + rows.append(dict(zip(header, cells))) + return rows + + +def _inventory_rows() -> list[dict]: + block = _extract_block(MAP.read_text(encoding="utf-8"), "ORCH-118-INVENTORY-BLOCK") + rows = _parse_pipe_table(block) + assert rows, "inventory block parsed to zero rows" + return rows + + +def _agent_rows() -> list[dict]: + return [r for r in _inventory_rows() if r["id"] in AGENT_IDS] + + +def _by_role() -> dict[str, dict]: + return {r["role"]: r for r in _agent_rows()} + + +def _parse_justifications() -> dict[str, str]: + """Parse the keep-LLM named-judgment list: ``- role: justification text``.""" + block = _extract_block( + MAP.read_text(encoding="utf-8"), "ORCH-118-KEEP-JUSTIFICATION-BLOCK" + ) + out: dict[str, str] = {} + for raw in block.splitlines(): + line = raw.strip() + m = re.match(r"^[-*]\s*([A-Za-z_-]+)\s*:\s*(.+)$", line) + if m: + out[m.group(1).strip()] = m.group(2).strip() + return out + + +# --------------------------------------------------------------------------- +# TC-01 — single LLM-consultation transport (necessary, completed by TC-12). +# --------------------------------------------------------------------------- +def test_tc01_single_llm_transport(): + """Exactly one place in src/** assembles+launches the Claude CLI, matched by + the CONJUNCTION of transport signals (CLAUDE_BIN AND --system-prompt AND a + process launcher) — and it is launcher._spawn. The conjunction is mandatory: + bare CLAUDE_BIN would false-positive on preflight.py (existence check) and + config.py (path literal), neither of which consults an LLM (ADR D5a).""" + hits = [] + for f in _src_py_files(): + text = f.read_text(encoding="utf-8") + launches = ("Popen" in text) or ('"bash"' in text) or ("'bash'" in text) + if "--system-prompt" in text and "CLAUDE_BIN" in text and launches: + hits.append(_rel(f)) + assert hits == [TRANSPORT_FILE], ( + "expected the single LLM-transport to be launcher._spawn; got: " + repr(hits) + ) + # The transport assembly lives inside _spawn specifically. + launcher = (SRC / "agents" / "launcher.py").read_text(encoding="utf-8") + assert "--system-prompt" in _function_body(launcher, "_spawn"), ( + "--system-prompt is not inside def _spawn — transport moved?" + ) + + +# --------------------------------------------------------------------------- +# TC-12 — no alternative LLM transport (FR-6f / AC-1, AC-6). +# --------------------------------------------------------------------------- +def test_tc12_no_alternative_llm_transport(): + """No LLM-SDK import, no direct Anthropic/Claude HTTP endpoint, and no SECOND + --system-prompt-bearing subprocess builder anywhere in src/** + watchdog/**. + Closes the gap 'one _spawn green, a new consultation grew next to it'.""" + sdk_offenders = [] + for f in _src_and_watchdog_py_files(): + text = f.read_text(encoding="utf-8") + for rx in _FORBIDDEN_TRANSPORT_RE: + if rx.search(text): + sdk_offenders.append(f"{_rel(f)}: {rx.pattern}") + assert not sdk_offenders, ( + "alternative LLM transport found (allowed transport = S0/launcher._spawn " + "only):\n" + "\n".join(sdk_offenders) + ) + # No second --system-prompt builder outside the allowlisted transport file. + second_builders = [ + _rel(f) + for f in _src_and_watchdog_py_files() + if "--system-prompt" in f.read_text(encoding="utf-8") + and _rel(f) != TRANSPORT_FILE + ] + assert second_builders == [], ( + "a second --system-prompt subprocess builder appeared: " + repr(second_builders) + ) + + +# --------------------------------------------------------------------------- +# TC-02 — deterministic modules carry no LLM consultation (FR-6b / AC-3). +# --------------------------------------------------------------------------- +def test_tc02_deterministic_modules_no_llm_consultation(): + """The listed routing/leaf modules do not consult an LLM (no _spawn transport, + no alternative transport). Their git/pytest/docker/ssh/scanner subprocesses are + deterministic TOOLS, not LLM consultations — discriminator is 'consults LLM', + not 'spawns subprocess'.""" + offenders = [] + for mod in DETERMINISTIC_MODULES: + path = SRC / f"{mod}.py" + assert path.is_file(), f"deterministic module missing: {path}" + text = path.read_text(encoding="utf-8") + if "--system-prompt" in text: + offenders.append(f"{mod}: builds --system-prompt (LLM transport)") + for rx in _FORBIDDEN_TRANSPORT_RE: + if rx.search(text): + offenders.append(f"{mod}: {rx.pattern}") + assert not offenders, "LLM consultation found in deterministic path:\n" + "\n".join( + offenders + ) + + +# --------------------------------------------------------------------------- +# TC-03 — prompt files on disk match the map, both ways (FR-6c / AC-1). +# --------------------------------------------------------------------------- +def test_tc03_prompt_files_match_map(): + on_disk = {p.stem for p in AGENTS_DIR.glob("*.md")} + in_map = {r["role"] for r in _agent_rows()} + assert on_disk == set(AGENT_ROLES), ( + f"prompt files on disk drifted from the 6 canonical roles: {on_disk}" + ) + assert in_map == on_disk, ( + f"map agent roles {in_map} != prompt files on disk {on_disk}" + ) + + +# --------------------------------------------------------------------------- +# TC-04 — totality + axis-consistent classification (FR-6d / FR-2 / AC-2). +# --------------------------------------------------------------------------- +def test_tc04_classification_total_and_axis_consistent(): + rows = _inventory_rows() + ids = [r["id"] for r in rows] + assert len(ids) == len(set(ids)), f"duplicate call-site ids: {ids}" + assert set(ids) == set(ALL_IDS), f"call-site id set drifted: {set(ids)}" + + for r in rows: + cls = r["classification"] + if r["id"] == "S0": + assert cls in ("-", "—"), f"S0 (transport) must not be classified: {cls!r}" + else: + assert cls in ALLOWED_CLASSES or cls in ("-", "—"), ( + f"{r['id']} class out of taxonomy: {cls!r}" + ) + + # Class is DERIVED from the axis (not postulated): P->keep; C+!avoidable->keep; + # C+avoidable->replace-*/hybrid. + for r in _agent_rows(): + axis = r["axis"].upper() + avoidable = r["avoidable"].lower() + cls = r["classification"] + if axis == "P": + assert cls == "keep-LLM", f"{r['role']} is P but class {cls!r}" + elif axis == "C" and avoidable == "no": + assert cls == "keep-LLM", f"{r['role']} is C-keep but class {cls!r}" + elif axis == "C" and avoidable == "yes": + assert cls in { + "replace-deterministic-now", + "replace-later/risky", + "needs-hybrid-fallback", + }, f"{r['role']} is avoidable but class {cls!r}" + else: + raise AssertionError(f"{r['role']}: bad axis/avoidable {axis!r}/{avoidable!r}") + + +# --------------------------------------------------------------------------- +# TC-05 — keep-LLM requires a named judgment; C-keep states non-derivability. +# --------------------------------------------------------------------------- +def test_tc05_keep_llm_named_judgment(): + keep_roles = {r["role"] for r in _agent_rows() if r["classification"] == "keep-LLM"} + assert keep_roles == {"analyst", "architect", "developer", "reviewer"}, ( + f"keep-LLM role set drifted: {keep_roles}" + ) + just = _parse_justifications() + for role in keep_roles: + assert just.get(role, "").strip(), f"keep-LLM role {role} has no named judgment" + # reviewer is C-keep: its justification must explain NON-derivability of the verdict. + assert "deriv" in just["reviewer"].lower(), ( + "reviewer (C-keep) justification must state the verdict is NOT derivable " + "from an exit-code" + ) + + +# --------------------------------------------------------------------------- +# TC-06 — capability != consultation: D1/D2 intercepted before _spawn (FR-6e). +# --------------------------------------------------------------------------- +def test_tc06_capability_not_consultation(): + launcher = (SRC / "agents" / "launcher.py").read_text(encoding="utf-8") + body = _function_body(launcher, "launch_job") + i_finalizer = body.find('"deploy-finalizer"') + i_monitor = body.find('"post-deploy-monitor"') + i_spawn = body.find("self._spawn(") + assert i_finalizer != -1, "deploy-finalizer guard not found in launch_job" + assert i_monitor != -1, "post-deploy-monitor guard not found in launch_job" + assert i_spawn != -1, "self._spawn( call not found in launch_job" + assert i_finalizer < i_spawn, "deploy-finalizer guard must precede _spawn" + assert i_monitor < i_spawn, "post-deploy-monitor guard must precede _spawn" + assert "return self._run_deploy_finalizer_job" in body + assert "return self._run_post_deploy_monitor_job" in body + + +# --------------------------------------------------------------------------- +# TC-09 — runtime contract snapshot unchanged (FR-7 / AC-7). +# --------------------------------------------------------------------------- +def test_tc09_runtime_contract_snapshot(): + agents = {t["agent"] for t in STAGE_TRANSITIONS.values() if t["agent"]} + assert agents == set(EXPECTED_STAGE_AGENTS), ( + f"STAGE_TRANSITIONS agent set changed: {agents}" + ) + assert set(QG_CHECKS) == set(EXPECTED_QG_CHECKS), ( + f"QG_CHECKS name set changed: {set(QG_CHECKS)}" + ) + + +# --------------------------------------------------------------------------- +# TC-13 — control-path axis is consistent with the real consumer (FR-6g / AC-10). +# --------------------------------------------------------------------------- +def test_tc13_control_path_axis_correct(): + checks_src = (SRC / "qg" / "checks.py").read_text(encoding="utf-8") + rows = _agent_rows() + for r in rows: + role = r["role"] + axis = r["axis"].upper() + consumer = r["output_consumer"].split(":")[0].strip() + assert re.search(rf"def {re.escape(consumer)}\(", checks_src), ( + f"{role}: output_consumer {consumer!r} is not a def in src/qg/checks.py" + ) + if role in P_ROLES: + assert axis == "P", f"{role} must be axis P, got {axis!r}" + assert consumer in P_CONSUMERS, ( + f"{role} (P) consumer {consumer!r} is not a deterministic artifact gate" + ) + elif role in C_ROLES: + assert axis == "C", f"{role} must be axis C, got {axis!r}" + assert consumer in C_CONSUMERS, ( + f"{role} (C) consumer {consumer!r} is not a verdict-parser" + ) + else: + raise AssertionError(f"unexpected agent role in map: {role!r}") + assert {r["role"] for r in rows if r["axis"].upper() == "P"} == set(P_ROLES) + assert {r["role"] for r in rows if r["axis"].upper() == "C"} == set(C_ROLES) + + +# --------------------------------------------------------------------------- +# TC-14 — avoidable LLM control-path set is exactly {tester, deployer} (FR-6h). +# --------------------------------------------------------------------------- +def test_tc14_avoidable_set_fixed(): + rows = _agent_rows() + by_role = {r["role"]: r for r in rows} + avoidable = {r["role"] for r in rows if r["avoidable"].lower() == "yes"} + assert avoidable == {"tester", "deployer"}, ( + f"avoidable LLM control-path set drifted from {{tester, deployer}}: {avoidable}" + ) + # reviewer: control path (C) but KEEP — verdict not derivable. + assert by_role["reviewer"]["axis"].upper() == "C" + assert by_role["reviewer"]["avoidable"].lower() == "no" + # analyst / architect / developer: NOT control path (P artifact-producer). + for role in ("analyst", "architect", "developer"): + assert by_role[role]["axis"].upper() == "P", f"{role} must be P (not control path)" + assert by_role[role]["avoidable"].lower() == "no", f"{role} must not be avoidable" diff --git a/tests/test_llm_determinization_docs.py b/tests/test_llm_determinization_docs.py new file mode 100644 index 0000000..677c261 --- /dev/null +++ b/tests/test_llm_determinization_docs.py @@ -0,0 +1,152 @@ +# ORCH-118 (FR-4 / FR-5 / FR-8 / AC-4, AC-5, AC-9, AC-10): structural tests for the +# determinization roadmap and the LLM usage policy. +# +# These are offline/deterministic (no network, no LLM, no subprocess). They assert +# the roadmap carries the mandatory per-candidate attributes (named BY ROLE, never a +# fabricated Plane-ID), that the policy is normative and defines "avoidable LLM +# control path" as a checkable predicate, and that NO doc binds a candidate to a +# non-existent follow-up Plane-ID (R3 / NFR-6 anti-fabrication). + +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[1] +ARCH = REPO_ROOT / "docs" / "architecture" +MAP = ARCH / "llm-call-sites.md" +ROADMAP = ARCH / "llm-determinization-roadmap.md" +POLICY = ARCH / "llm-usage-policy.md" +WORK_ITEMS = REPO_ROOT / "docs" / "work-items" + +# A follow-up Plane-ID pattern in the ORCH-1XX range. ORCH-118 itself is allowed; +# any OTHER ORCH-1XX referenced in a doc must resolve to a real work-item dir — +# this catches the R2 anti-pattern of binding the map to invented IDs +# (ORCH-115 / ORCH-116, which do not exist). +_PLANE_ID_RE = re.compile(r"ORCH-1\d\d") +_SELF_ID = "ORCH-118" + + +def _extract_block(text: str, name: str) -> str: + start = f"" + end = f"" + assert start in text, f"missing block start marker {start!r}" + assert end in text, f"missing block end marker {end!r}" + return text.split(start, 1)[1].split(end, 1)[0] + + +def _parse_pipe_table(block: str) -> list[dict]: + header = None + rows: list[dict] = [] + for raw in block.splitlines(): + line = raw.strip() + if not line.startswith("|"): + continue + cells = [c.strip() for c in line.strip("|").split("|")] + joined = "".join(cells) + if joined and set(joined) <= set("-: "): + continue + if header is None: + header = [c.lower() for c in cells] + continue + rows.append(dict(zip(header, cells))) + return rows + + +def _roadmap_rows() -> list[dict]: + block = _extract_block(ROADMAP.read_text(encoding="utf-8"), "ORCH-118-ROADMAP-BLOCK") + rows = _parse_pipe_table(block) + assert rows, "roadmap block parsed to zero rows" + return rows + + +# --------------------------------------------------------------------------- +# TC-07 — roadmap completeness + recommended first slice (FR-4 / AC-4). +# --------------------------------------------------------------------------- +def test_tc07_roadmap_completeness_and_first_slice(): + rows = _roadmap_rows() + roles = {r["role"] for r in rows} + # The two avoidable LLM control paths are the roadmap candidates. + assert {"deployer", "tester"} <= roles, f"roadmap missing candidates: {roles}" + + ranks = [] + first_slice_roles = [] + for r in rows: + role = r["role"] + assert r["dependencies"].strip(), f"{role}: empty dependencies" + # Savings estimate must cite its source (agent_runs / usage). + assert "agent_runs" in r["savings_estimate_source"], ( + f"{role}: savings estimate not sourced from agent_runs" + ) + assert r["security_risk"].strip(), f"{role}: empty security_risk" + assert r["hybrid_needed"].lower() in {"yes", "no"}, ( + f"{role}: hybrid_needed must be yes/no, got {r['hybrid_needed']!r}" + ) + # follow-up is named BY ROLE, never a Plane-ID (R3 / NFR-6 / AC-9). + ftype = r["followup_type"] + assert ftype.strip(), f"{role}: empty followup_type" + assert not re.search(r"ORCH-\d+", ftype), ( + f"{role}: followup_type binds a Plane-ID ({ftype!r}) — forbidden (AC-9)" + ) + assert role in ftype, f"{role}: followup_type must name the role, got {ftype!r}" + ranks.append(int(r["rank"])) + if r["first_slice"].lower() == "yes": + first_slice_roles.append(role) + + assert ranks == sorted(ranks), f"roadmap not ordered by rank: {ranks}" + assert len(set(ranks)) == len(ranks), f"duplicate ranks: {ranks}" + # Exactly one recommended first slice, and it is the deployer (staging) replacement. + assert first_slice_roles == ["deployer"], ( + f"recommended first slice must be exactly [deployer]; got {first_slice_roles}" + ) + + +# --------------------------------------------------------------------------- +# TC-08 — policy exists, is normative, and defines "avoidable LLM control path". +# --------------------------------------------------------------------------- +def test_tc08_policy_normative_and_defines_avoidable(): + assert POLICY.is_file(), "llm-usage-policy.md missing" + text = POLICY.read_text(encoding="utf-8") + + # Principle: LLM only where genuine judgment is needed. + assert "настоящее суждение" in text, "policy missing the core principle" + # keep vs replace criteria, framed through the control-path axis. + low = text.lower() + assert "keep" in low and "replace" in low, "policy missing keep/replace criteria" + assert "control path" in low or "control-path" in low, ( + "policy keep/replace criteria not framed through the control-path axis" + ) + + # The defined term appears as a defined term. + assert "avoidable llm control path" in low, ( + "policy does not define the term 'avoidable LLM control path'" + ) + # Machine-readable definition block: two-bit predicate (C consultation AND + # derivable verdict). + block = _extract_block(text, "ORCH-118-AVOIDABLE-DEFINITION-BLOCK").lower() + assert "control" in block, "definition missing the control-path condition (i)" + assert "deriv" in block, "definition missing the derivability condition (ii)" + # The verdict-derivability condition names a real tool signal. + assert any(sig in block for sig in ("exit-code", "exit code", "pytest", "staging_check")), ( + "derivability condition does not reference a concrete tool signal" + ) + + +# --------------------------------------------------------------------------- +# TC-11 — anti-fabrication: no candidate bound to a non-existent follow-up ID. +# --------------------------------------------------------------------------- +def test_tc11_no_fabricated_followup_ids(): + """Every ORCH-1XX referenced in the map / roadmap / policy (other than ORCH-118 + itself) MUST resolve to a real docs/work-items/ dir. This catches the R2 defect + of pinning the map to invented IDs (ORCH-115 / ORCH-116).""" + offenders = [] + for doc in (MAP, ROADMAP, POLICY): + assert doc.is_file(), f"doc missing: {doc}" + text = doc.read_text(encoding="utf-8") + for token in set(_PLANE_ID_RE.findall(text)): + if token == _SELF_ID: + continue + if not (WORK_ITEMS / token).is_dir(): + offenders.append(f"{doc.name}: references non-existent work item {token}") + assert not offenders, ( + "fabricated / unresolvable follow-up Plane-ID(s) found (name follow-ups BY " + "ROLE, not by invented ID — R3 / NFR-6 / AC-9):\n" + "\n".join(offenders) + )