Compare commits

..

23 Commits

Author SHA1 Message Date
65c17d85e3 Merge pull request 'feat(staging): deterministic staging-runner replacing LLM deployer on deploy-staging (ORCH-115)' (#141) from feature/ORCH-115-orch-replace-llm-deployer-with into main
Some checks failed
CI / test (push) Has been cancelled
2026-06-16 02:21:03 +03:00
deploy-finalizer
17312ac86f deploy(ORCH-036): finalize SUCCESS for ORCH-115
All checks were successful
CI / test (push) Successful in 1m8s
2026-06-16 02:21:02 +03:00
a975591a3c deploy-staging(ORCH-115): staging gate SUCCESS (8/10 PASS, C9a/C9b infra-waived)
All checks were successful
CI / test (push) Successful in 1m12s
CI / test (pull_request) Successful in 1m14s
2026-06-16 02:15:21 +03:00
aed3ba0cbb tester(ET): auto-commit from tester run_id=736
All checks were successful
CI / test (push) Successful in 1m10s
CI / test (pull_request) Successful in 1m7s
2026-06-16 02:11:37 +03:00
e3ce01b824 reviewer(ET): auto-commit from reviewer run_id=735
All checks were successful
CI / test (push) Successful in 1m16s
CI / test (pull_request) Successful in 1m13s
2026-06-16 02:08:18 +03:00
b50cf1dd08 feat(staging): deterministic staging-runner replacing LLM deployer on deploy-staging (ORCH-115)
All checks were successful
CI / test (push) Successful in 1m8s
CI / test (pull_request) Successful in 1m8s
Replace the LLM `deployer` agent on the `deploy-staging` stage (self-hosting
orchestrator) with a deterministic staging-runner intercepted in launch_job
BEFORE _spawn (the deploy-finalizer / post-deploy-monitor reserved-agent
precedent). The runner executes the SAME staging suite, maps the exit-code to
`staging_status:` via the existing self_deploy.map_exit_code_to_status contract,
writes 15-staging-log.md, and initiates the UNCHANGED check_staging_status gate
exactly as a finished LLM-deployer would.

Invariant (NFR-1): this replaces only the *producer* of the artifact — the
artifact contract, the gate / _parse_staging_status / check_staging_status name,
STAGE_TRANSITIONS, the machine-verdict key `staging_status:` and the DB schema are
byte-for-byte unchanged. Additive, under a kill-switch + repo-scope CSV,
never-raise, fail-safe back to the LLM path.

Two-level outcome (D5, anti ORCH-110): suite executed -> verdict -> advance
(FAILED -> the existing deploy-staging -> development rollback + developer-retry,
same as a FAILED LLM verdict); tool-error (suite did not execute) -> bounded DEFER
-> fail-closed FAILED + alert on exhaustion (infra != code fault; never a silent
advance / false green).

First implemented slice of the LLM determinization roadmap (ORCH-118 A6,
replace-deterministic-now).

- New leaf src/staging_runner.py (never-raise; proc_group tree-kill + timeout)
- launch_job intercept + _run_staging_runner_job (mirror _run_deploy_finalizer_job)
- config: ORCH_STAGING_RUNNER_* keys (enabled/repos/timeout/infra-retry budget)
- GET /queue staging_runner observability block
- docs: llm-call-sites/roadmap/usage-policy (A6 implemented; machine blocks +
  single-transport invariant intact), deployer.md (LLM branch -> fallback),
  CLAUDE.md, CHANGELOG.md, overview (tech-pipeline/tech-agents/tech-quality-security),
  .env.example
- tests/test_orch115_staging_runner.py (TC-01..TC-13); LLM anti-drift green (TC-14)

Refs: ORCH-115

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 01:59:43 +03:00
f120e4bd8f architect(ET): auto-commit from architect run_id=733
All checks were successful
CI / test (push) Successful in 1m9s
2026-06-16 01:37:27 +03:00
ac203c0ccf analyst(ET): auto-commit from analyst run_id=732
All checks were successful
CI / test (push) Successful in 1m6s
2026-06-16 01:11:35 +03:00
a353a72f20 docs: init ORCH-115 business request
All checks were successful
CI / test (push) Successful in 1m8s
2026-06-16 01:02:37 +03:00
e0d0c32888 Merge pull request 'ORCH-118: LLM call-site map, control-path axis, roadmap & usage policy (docs+tests only)' (#140) from feature/ORCH-118-orch-replace-avoidable-llm-con into main
Some checks failed
CI / test (push) Has been cancelled
2026-06-16 00:42:50 +03:00
deploy-finalizer
6f4996ad31 deploy(ORCH-036): finalize SUCCESS for ORCH-118
All checks were successful
CI / test (push) Successful in 1m7s
2026-06-16 00:42:48 +03:00
b93b4587ad docs(ORCH-118): staging gate log — staging_status SUCCESS (infra-waived C9a/C9b)
All checks were successful
CI / test (push) Successful in 1m13s
CI / test (pull_request) Successful in 1m14s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 00:28:51 +03:00
20fbb2e202 tester(ET): auto-commit from tester run_id=730
All checks were successful
CI / test (push) Successful in 1m7s
CI / test (pull_request) Successful in 1m7s
2026-06-16 00:25:39 +03:00
5b36ca0a82 reviewer(ET): auto-commit from reviewer run_id=729
All checks were successful
CI / test (push) Successful in 1m10s
CI / test (pull_request) Successful in 1m11s
2026-06-16 00:22:52 +03:00
9710d5f80d docs(llm): LLM call-site map, control-path axis, roadmap & usage policy + anti-drift tests
All checks were successful
CI / test (push) Successful in 1m8s
CI / test (pull_request) Successful in 1m10s
ORCH-118 (inventory-first, docs+tests only): publish an evidence-based map of
every place the orchestrator's control flow consumes (or can consume) an LLM
judgment, mark the control-path axis (C control-path vs P artifact-producer),
define "avoidable LLM control path" as a checkable two-bit predicate, classify
each call-site, and order the deterministic-replacement roadmap. Pin the map to
code with offline structural anti-drift tests.

- docs/architecture/llm-call-sites.md   — map + machine-readable inventory block
  + control-path axis + classification + keep-LLM justifications + deterministic
  non-agent paths (FR-1/FR-2/FR-3/FR-8).
- docs/architecture/llm-determinization-roadmap.md — ordered candidates BY ROLE,
  savings sourced from agent_runs, recommended first slice = deployer staging
  (FR-4). No fabricated follow-up Plane-IDs (R3/NFR-6).
- docs/architecture/llm-usage-policy.md — normative principle, keep/replace
  criteria via the axis, definition of "avoidable LLM control path" (FR-5/FR-8).
- tests/test_llm_call_site_inventory.py — TC-01/02/03/04/05/06/09/12/13/14.
- tests/test_llm_determinization_docs.py — TC-07/08/11.
- CHANGELOG.md + docs/overview/tech-quality-security.md — golden-source sync (AC-8).

Avoidable LLM control paths = {tester, deployer}; control-path-keep = {reviewer};
not-control-path (P) = {analyst, architect, developer}. Single LLM transport =
launcher._spawn (S0); no alternative transport (TC-12). Runtime untouched:
STAGE_TRANSITIONS / QG_CHECKS / check_* / machine-verdict keys / DB schema are
byte-for-byte; no replacement runners implemented (FR-7). Full suite: 2081 passed.

Refs: ORCH-118
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 00:13:07 +03:00
7597804f8c architect(ET): auto-commit from architect run_id=727
All checks were successful
CI / test (push) Successful in 1m7s
2026-06-15 23:56:27 +03:00
70171eb1c1 analyst(ET): auto-commit from analyst run_id=726
All checks were successful
CI / test (push) Successful in 1m7s
2026-06-15 23:42:44 +03:00
55c13abb9a analyst(ET): auto-commit from analyst run_id=725
All checks were successful
CI / test (push) Successful in 1m9s
2026-06-15 23:29:04 +03:00
bc5d550965 analyst(ET): auto-commit from analyst run_id=724
All checks were successful
CI / test (push) Successful in 1m8s
2026-06-15 23:17:18 +03:00
c7bed44845 analyst(ET): auto-commit from analyst run_id=723
All checks were successful
CI / test (push) Successful in 1m10s
2026-06-15 23:01:03 +03:00
d60980c149 analyst(ET): auto-commit from analyst run_id=722
All checks were successful
CI / test (push) Successful in 1m9s
2026-06-15 22:49:43 +03:00
729caf8e2f docs: init ORCH-118 business request
All checks were successful
CI / test (push) Successful in 1m12s
2026-06-15 22:40:12 +03:00
13589fcaf4 Merge pull request 'ORCH-117: sandbox-only fail-closed guard for Plane writes from test process (fix regression ORCH-114)' (#139) from feature/ORCH-117-bug-test-staging-plane-writes- into main
Some checks failed
CI / test (push) Has been cancelled
2026-06-15 22:32:06 +03:00
47 changed files with 5325 additions and 16 deletions

View File

@@ -557,6 +557,27 @@ ORCH_COVERAGE_EPSILON=0.5
ORCH_COVERAGE_TOOL_FAIL_CLOSED=false
ORCH_COVERAGE_RUN_TIMEOUT_S=900
# ORCH-115: deterministic staging-runner replacing the LLM `deployer` on the
# `deploy-staging` stage (self-hosting orchestrator). Intercepted in launch_job
# BEFORE _spawn (deploy-finalizer / post-deploy-monitor precedent): runs the same
# staging suite, maps exit-code -> staging_status:, writes 15-staging-log.md and
# initiates the UNCHANGED check_staging_status gate. Replaces only the producer of
# the artifact; the gate / STAGE_TRANSITIONS / DB schema are byte-for-byte unchanged.
# See ADR-001-deterministic-staging-runner.md / adr-0048.
# STAGING_RUNNER_ENABLED -> kill-switch; false -> the prior LLM deployer
# runs on deploy-staging via _spawn 1:1.
# STAGING_RUNNER_REPOS -> CSV scope; empty -> self-hosting only.
# STAGING_RUNNER_TIMEOUT_S -> wall-clock budget for the docker-exec suite
# (malformed/non-positive -> default 600 + WARNING).
# STAGING_RUNNER_INFRA_MAX_RETRIES -> tool-error (suite did NOT execute) bounded DEFER
# budget before a fail-closed FAILED (anti ORCH-110).
# STAGING_RUNNER_INFRA_RETRY_DELAY_S-> delay before the re-queued deployer job.
ORCH_STAGING_RUNNER_ENABLED=true
ORCH_STAGING_RUNNER_REPOS=
ORCH_STAGING_RUNNER_TIMEOUT_S=600
ORCH_STAGING_RUNNER_INFRA_MAX_RETRIES=2
ORCH_STAGING_RUNNER_INFRA_RETRY_DELAY_S=30
# ORCH-057 (follow-up ORCH-040): legacy root-owned ownership detect + actionable
# worktree error. After the uid migration (user: "1000:1000") legacy root:root files
# in /repos broke worktree creation under uid 1000 with a raw "Permission denied".

View File

@@ -45,6 +45,16 @@ then emit `staging_status:` / `deploy_status:`.
Run the staging test suite against the live staging environment and write the verdict.
> **ORCH-115 — deterministic runner leads this stage for in-scope repos.** On `deploy-staging` for
> the self-hosting `orchestrator` repo, this stage is now driven by **deterministic code**
> (`src/staging_runner.py`, intercepted in `launch_job` BEFORE `_spawn`, mirroring the prod Phase
> A/B/C pattern) — it runs the SAME canonical staging suite below, maps the exit code to
> `staging_status:` via the same `0 → SUCCESS / non-zero → FAILED` contract, writes
> `15-staging-log.md`, and initiates the unchanged `check_staging_status` gate. The LLM steps below
> remain the **fallback** under a disabled kill-switch (`ORCH_STAGING_RUNNER_ENABLED=false`) or for
> non-self repos. The artifact contract / gate / machine key `staging_status:` are unchanged. Details:
> `docs/work-items/ORCH-115/06-adr/ADR-001-deterministic-staging-runner.md`.
**Steps:**
1. Run the staging suite. **CANONICAL: run INSIDE the `orchestrator-staging` container via

View File

@@ -1,4 +1,4 @@
Work item: ORCH-112
Work item: ORCH-115
Repo: orchestrator
Branch: feature/ORCH-112-bug-failed-cancelled-task-arti
Branch: feature/ORCH-115-orch-replace-llm-deployer-with
Stage: development

View File

@@ -3,6 +3,17 @@
Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу.
## [Unreleased]
- **Детерминированный staging-раннер вместо LLM-деплойера на `deploy-staging`** (ORCH-115, `feat`): первый реализованный срез determinization-roadmap (ORCH-118 A6, `replace-deterministic-now`) — на стадии `deploy-staging` для self-hosting `orchestrator` **LLM-агент `deployer` заменён детерминированным кодом** (`src/staging_runner.py`). Работа агента на этой стадии была чисто механической (запуск staging-сюиты + маппинг exit-кода `staging_check.py``staging_status:`); каждый прогон жёг токены/время opus-агента (~40120k / 315 мин) и встраивал недетерминизм LLM в точку ветвления гейта. **Инвариант (NFR-1):** это замена *продюсера* артефакта, **не** гейта — контракт `15-staging-log.md`, гейт `check_staging_status`/`_parse_staging_status`, `STAGE_TRANSITIONS`, machine-verdict `staging_status:`, схема БД — **байт-в-байт не тронуты**. Аддитивно, под kill-switch, never-raise, fail-closed, скоуп self-hosting. ADR: `docs/work-items/ORCH-115/06-adr/ADR-001-deterministic-staging-runner.md`, сквозной `docs/architecture/adr/adr-0048-deterministic-staging-runner.md`.
- **Перехват в `launch_job` до `_spawn` (D1):** `if job.agent=="deployer" and staging_runner.should_intercept(job)``_run_staging_runner_job` (зеркало `_run_deploy_finalizer_job`, прецедент `deploy-finalizer`/`post-deploy-monitor` `launcher.py:389/394`): синхронно ведёт `jobs`-строку через `mark_job`, возвращает `None` (нет `agent_runs`-строки, нет токенов). Дискриминатор «staging vs prod» — **стадия задачи** `deploy-staging` (роль `deployer` общая для `deploy-staging`/`deploy`), не имя роли; `should_intercept` never-raise → `False` → штатный `_spawn` (fail-safe к LLM-пути). Для не-self репо `applies==False` → прод-`deployer` никогда не перехватывается.
- **Leaf `src/staging_runner.py` (новый, чистый never-raise):** по образцу `self_deploy`/`proc_group`/`staging_verdict` (на импорте только `config`/`proc_group`; `db`/`git_worktree`/`stage_engine`/`qg.checks`/`notifications` — лениво). Исполняет ту же сюиту (`docker exec orchestrator-staging python3 <repos_dir>/<self-repo>/scripts/staging_check.py --base-url http://localhost:<staging_port> --mode stub`, арги из config — ORCH-101, без host-хардкодов) через `proc_group.run_in_process_group` (tree-kill, таймаут `staging_runner_timeout_s=600`, малформ/непозитив → дефолт + WARNING); маппит exit-код **единым** контрактом `self_deploy.map_exit_code_to_status` (`0→SUCCESS`/иначе/None→`FAILED`, fail-closed; ORCH-061 infra-tolerance остаётся внутри `staging_check.py`, раннер не пересуживает — BR-4); пишет `15-staging-log.md` (тот же machine-key `staging_status:` UPPERCASE + 52c-схема, `author_agent: staging-runner`/`model_used: n/a`) + best-effort commit/push в **фичеветку** (не в `main` — гейт читает worktree первым, усиливает AC-8/BR-7); вызывает **существующий** `advance_stage(current_stage="deploy-staging", finished_agent="deployer")` — без новых рёбер/исходов (transition-lease ORCH-114 берётся внутри `advance_stage`, раннер не трогает — граница O1).
- **Двухуровневый исход (D5, анти-ORCH-110):** сюита **исполнилась** (реальный exit-код) → verdict→advance (FAILED → тот же откат `deploy-staging → development` + developer-retry, что у FAILED-вердикта LLM, `stage_engine.py:932`); сюита **не исполнилась** (tool-error: spawn-error/таймаут/`returncode None`) → инфра-сбой ≠ код-фейл → bounded **DEFER** (re-queue `deployer`-джоба с задержкой + restart-safe маркер `staging-runner infra-retry` в `task_content`, счётчик подсчётом маркера — без правки схемы БД), на исчерпании `staging_runner_infra_max_retries=2` → fail-closed `FAILED` + advance + инфра-alert. Раннер **никогда** не делает тихий advance/ложный green, **никогда** не клинит очередь и **не** жжёт developer-retry на транзиентной инфре.
- **Self-hosting safety (BR-7/AC-8):** в командной строке раннера нет рестарта 8500 / `docker compose up orchestrator` / `--build` / force-push в `main` / правок `.env` — раннер только читает/исполняет staging-сюиту (8501) и пишет лог. Наблюдаемость (D10) — in-process счётчики (`runs`/`success`/`failed`/`tool_error`/`deferred`) + read-only блок `staging_runner` в `GET /queue` + один структурный лог-вердикт на прогон (различает код-фейл и tool-error). Флаги (`config.py`, дефолт = боевое): `staging_runner_enabled` (env `ORCH_STAGING_RUNNER_ENABLED`), `staging_runner_repos` (CSV; пусто → self-hosting only), `staging_runner_timeout_s`, `staging_runner_infra_max_retries`, `staging_runner_infra_retry_delay_s`. Откат = `ORCH_STAGING_RUNNER_ENABLED=false` → на `deploy-staging` снова LLM-`deployer` через `_spawn` **байт-в-байт**.
- **Норматив сопровождения ORCH-118 (NFR-6):** обновлены `docs/architecture/llm-call-sites.md` (A6 — реализован; машинный `ORCH-118-INVENTORY-BLOCK` сохраняет deployer как `avoidable=yes`/`axis=C` — LLM-ветвь жива как fallback), `llm-determinization-roadmap.md` (rank 1 deployer — ✅ реализован; машинный блок/инвариант «ровно один `first_slice = yes`» целы), `llm-usage-policy.md` (§5 — единственный транспорт S0 не нарушен, раннер LLM не зовёт), `.openclaw/agents/deployer.md` (LLM-ветвь `deploy-staging` — fallback), витрина `docs/overview/tech-pipeline.md`/`tech-agents.md`. Покрытие — `tests/test_orch115_staging_runner.py` (TC-01…TC-13) + зелёные `tests/test_llm_call_site_inventory.py`/`test_llm_determinization_docs.py` (TC-14).
- **Карта 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=<Done>` + комментарий «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()`.

View File

@@ -411,6 +411,52 @@ Plane, **не** Quality Gate и **не** стадия).
`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`.
## Детерминированный staging-раннер вместо LLM-деплойера (ORCH-115)
Первый реализованный срез determinization-roadmap (ORCH-118 A6, `replace-deterministic-now`): на
стадии `deploy-staging` для self-hosting `orchestrator` **LLM-агент `deployer` заменён
детерминированным кодом** (`src/staging_runner.py`). Работа агента на этой стадии была чисто
механической (запуск staging-сюиты + маппинг exit-кода) — теперь её делает leaf, перехватываемый в
`launch_job` **до `_spawn`** (прецедент `deploy-finalizer`/`post-deploy-monitor`,
`launcher.py:389/394`). **Инвариант (NFR-1):** замена *продюсера* артефакта, **не** гейта —
контракт `15-staging-log.md`, гейт/`_parse_staging_status`/имя `check_staging_status`,
`STAGE_TRANSITIONS`, machine-verdict `staging_status:`, схема БД — **байт-в-байт не тронуты**.
Аддитивно, под kill-switch, never-raise, fail-closed.
- **Перехват (D1):** `launch_job` — `if job.agent=="deployer" and staging_runner.should_intercept(job)`
→ `_run_staging_runner_job` (зеркало `_run_deploy_finalizer_job`): синхронно ведёт `jobs`-строку
через `mark_job`, возвращает `None` (нет `agent_runs`). Дискриминатор «staging vs prod» —
**стадия задачи** `deploy-staging` (роль `deployer` общая для `deploy-staging`/`deploy`), не имя
роли; `should_intercept` never-raise → `False` → штатный `_spawn` (fail-safe к LLM-пути).
- **Раннер (D2-D7):** leaf по образцу `self_deploy`/`proc_group`/`staging_verdict` (на импорте только
`config`/`proc_group`; `db`/`git_worktree`/`stage_engine`/`qg.checks`/`notifications` — лениво).
Исполняет ту же сюиту (`docker exec orchestrator-staging python3 .../staging_check.py --base-url
http://localhost:8501 --mode stub`, арги из config — ORCH-101) через `proc_group` (tree-kill,
таймаут `staging_runner_timeout_s=600`); маппит exit-код **единым** контрактом
`self_deploy.map_exit_code_to_status` (`0→SUCCESS`/иначе/None→`FAILED`; ORCH-061 infra-tolerance
остаётся внутри `staging_check.py`, раннер не пересуживает); пишет `15-staging-log.md`
(`author_agent: staging-runner`/`model_used: n/a`, 52c-схема) + best-effort push в **фичеветку**
(не в `main` — гейт читает worktree первым, усиливает AC-8); вызывает **существующий**
`advance_stage(current_stage="deploy-staging", finished_agent="deployer")` — без новых
рёбер/исходов (lease ORCH-114 берётся внутри `advance_stage`, раннер не трогает).
- **Двухуровневый исход (D5, анти-ORCH-110):** сюита **исполнилась** (реальный exit-код) → verdict→
advance (FAILED → тот же откат `deploy-staging → development` + developer-retry, что у LLM); сюита
**не исполнилась** (tool-error: spawn-error/таймаут/`returncode None`) → инфра-сбой ≠ код-фейл →
bounded **DEFER** (re-queue `deployer`-джоба + restart-safe маркер `staging-runner infra-retry` в
`task_content`, без правки схемы БД), на исчерпании `staging_runner_infra_max_retries` → fail-closed
`FAILED` + advance + alert. Никогда тихий advance/ложный green; не клинит очередь; не жжёт
developer-retry на транзиентной инфре.
- **Флаги** (`config.py`, дефолт = боевое): `staging_runner_enabled` (kill-switch, env
`ORCH_STAGING_RUNNER_ENABLED`), `staging_runner_repos` (CSV; **пусто → self-hosting only**),
`staging_runner_timeout_s=600`, `staging_runner_infra_max_retries=2`,
`staging_runner_infra_retry_delay_s=30`. Откат = `ORCH_STAGING_RUNNER_ENABLED=false` → на
`deploy-staging` снова LLM-`deployer` через `_spawn` **байт-в-байт**. Наблюдаемость — in-process
счётчики + read-only блок `staging_runner` в `GET /queue` + один структурный лог-вердикт на прогон
(различает код-фейл и tool-error). Норматив сопровождения ORCH-118: обновлены
`llm-call-sites.md`/`llm-determinization-roadmap.md`/`llm-usage-policy.md` (A6 — реализован, машинные
блоки/инвариант «единственный транспорт S0» целы) + `deployer.md` (LLM-ветвь — fallback). Покрытие —
`tests/test_orch115_staging_runner.py` (TC-01…TC-13) + зелёные LLM-анти-дрейф тесты (TC-14). Детали —
`docs/work-items/ORCH-115/06-adr/ADR-001-deterministic-staging-runner.md`, сквозной
`docs/architecture/adr/adr-0048-deterministic-staging-runner.md`.
## Машинный журнал уроков (ORCH-098)
Шаг 1 («Фундамент», F2) эпика саморазвития: формализует свободнотекстовые «уроки» из `memory/` в
**машинную структурированную таблицу отклонений конвейера** `lessons`, фундамент для будущих

View File

@@ -10,6 +10,7 @@
- **Review/Test Parsers** (`src/review_parse.py`, ORCH-046) — defensive-извлечение дословного must-fix текста из артефактов для встраивания в `task_desc` заворота: `extract_review_findings` (P0/P1 из `12-review.md`), `extract_test_failures` (фрагмент тела `13-test-report.md`). Контракт «never raise»: любая ошибка → `""`.
- **Quality Gates** (`src/qg/checks.py`) — проверки выхода со стадии, реестр `QG_CHECKS`.
- **Agent Launcher** (`src/agents/launcher.py`) — запуск Claude CLI агентов в изолированном git worktree, мониторинг, auto-advance. Модель/эффорт каждого агента резолвятся из config (`resolve_agent_model`/`resolve_agent_effort`, ORCH-41), а не из frontmatter промпта. **ORCH-74:** имя модели валидируется форматом `^claude-…$` (`is_valid_model`) перед `--model`; невалидное → лог + откат на следующий уровень/CLI-дефолт (never-break, как `VALID_EFFORTS` для эффорта). Тот же предикат гардит inline-чтение `--fallback-model`. **ORCH-109 ([adr-0040](adr/adr-0040-agent-timeout-budgets-and-launch-model-stamp.md)):** (1) резолвенная **модель стампится в `agent_runs.model` в момент launch** (`_spawn`, объединённый `UPDATE … SET model=?, effort=?` рядом со стампом эффорта ORCH-087; пустой резолв → `NULL`; never-raise) → модель видна не-`null` при любом исходе прогона, включая timeout-kill (`exit_code=-9`), и in-flight в `GET /metrics`/`GET /queue` (`get_running_agents` уже отдаёт `model`); постфактум `record_usage` (`model=COALESCE(?, model)`) остаётся **обогащением**, не единственным источником истины. (2) **Per-role wall-clock бюджеты** через выделенные ключи `agent_timeout_developer_s=3600`/`agent_timeout_reviewer_s=3000` (лестница `_resolve_timeout`: `agent_timeout_overrides_json` → выделенный ключ роли → `agent_timeout_seconds=1800`; прочие роли — байт-в-байт; малформный/вне-диапазонный конфиг → дефолт + WARNING). Инвариант reaper ORCH-065 сохранён синхронным поднятием `reaper_max_running_s` 3600→**5400** (`5400 > max(timeout)3600 + grace20`). FR-5 анти-salvage — структурно: продвижение гейтится `if exit_code==0`, timeout-kill → `_finalize_job` (retry/fail), не advance. `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД не тронуты.
- **Staging-runner** (`src/staging_runner.py`, ORCH-115 — [adr-0048](adr/adr-0048-deterministic-staging-runner.md)) — чистый **never-raise** leaf (паттерн `self_deploy`/`proc_group`/`staging_verdict`), заменяющий **LLM-агента `deployer` на стадии `deploy-staging`** детерминированным кодом (первый реализованный срез determinization-roadmap, A6/`replace-deterministic-now`). Перехват в `launch_job` **до `_spawn`** (рядом с D1/D2 `deploy-finalizer`/`post-deploy-monitor`): дискриминатор — **стадия задачи** `deploy-staging` **И** `applies(repo)` (kill-switch `staging_runner_enabled` + скоуп `staging_runner_repos`, пусто → self-hosting only), `should_intercept` never-raise → `False` → штатный `_spawn` (fail-safe к LLM). Раннер (зеркало `run_deploy_finalizer`) исполняет ту же staging-сюиту (`docker exec orchestrator-staging … staging_check.py`) через `proc_group` (tree-kill, таймаут `staging_runner_timeout_s=600`), маппит exit-код единым контрактом `self_deploy.map_exit_code_to_status` (`0→SUCCESS`/иначе→`FAILED`; ORCH-061 infra-tolerance остаётся внутри `staging_check.py`), пишет `15-staging-log.md` (тот же machine-key `staging_status:`, `author_agent: staging-runner`/`model_used: n/a`) + best-effort push в фичеветку, и вызывает **существующий** `advance_stage(current_stage="deploy-staging", finished_agent="deployer")` — без новых рёбер/исходов; transition-lease ORCH-114 берётся внутри `advance_stage` (раннер не трогает). **Двухуровневый исход (анти-ORCH-110):** сюита исполнилась → verdict→advance (FAILED → тот же откат `deploy-staging → development` + developer-retry, что у LLM); tool-error/таймаут → bounded defer (`staging_runner_infra_max_retries`) → fail-closed `FAILED` + alert на исчерпании (инфра ≠ код-фейл, никогда тихий advance/ложный green). `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_staging_status`/`_parse_staging_status`/machine-verdict/схема БД — **байт-в-байт не тронуты** (замена *продюсера*, не гейта). Наблюдаемость — in-process счётчики + блок `staging_runner` в `GET /queue`. Откат — `ORCH_STAGING_RUNNER_ENABLED=false` (прежний LLM-deployer-путь байт-в-байт). Детали — `docs/work-items/ORCH-115/06-adr/ADR-001-deterministic-staging-runner.md`.
- **Queue** (`src/queue_worker.py`, ORCH-1) — персистентная очередь задач (SQLite `jobs`), atomic claim, max_concurrency, ретраи, restart-safe. **ORCH-026:** `claim_next_job` гейтит задачи с незавершёнными зависимостями (`job_deps`, `NOT EXISTS`) без занятия слота; декларации/циклы — leaf `src/task_deps.py`.
- **Job-reaper** (`src/job_reaper.py`, ORCH-065 — [adr-0011](adr/adr-0011-job-reaper-lease-reclaim.md)) — фоновый daemon-поток (каркас `reconciler`), стартует/останавливается в `main.lifespan` (после `reconciler.start()` / перед `worker.stop()`). Детектирует «мёртвый» `running`-job **без рестарта** процесса (Tier-1 мёртвый `jobs.pid` после `reaper_dead_ticks` тиков; Tier-2 `agent_runs.exit_code` записан, а job ещё `running`; Tier-3 backstop `reaper_max_running_s`) и приводит строку к корректному статусу через те же контракты (`_try_advance_stage`/`_finalize_job`, gate-driven; exit≠0/неизвестно → `attempts<max``queued`, иначе `failed`+Telegram). Атомарный reap-claim (guard `status='running'`) совместим со стартовым `requeue_running_jobs`. Тот же поток периодически делает проактивный реклейм stale/dead merge-lease (см. ниже). never-raise; kill-switch `ORCH_REAPER_ENABLED`; снимок в `GET /queue` (блок `reaper`). **ORCH-113 ([adr-0043](adr/adr-0043-reaper-finalizer-liveness-ownership.md)):** на ребре `deploy-staging → deploy` тяжёлые edge-под-гейты (security/merge-gate re-test/coverage/image-freshness) исполняются в потоке монитора **после** штампа `finished_at` и **до** `_finalize_job` — минуты, а Tier-2 `finished_age_s` меряется от `finished_at`, поэтому живой долго финализирующий монитор ошибочно реапился (инцидент ORCH-111: повторный re-test → ложный откат `deploy-staging → development` параллельно успешному deploy). Фикс — процесс-локальный реестр владения финализацией (leaf `src/finalizer_liveness.py`, never-raise): монитор `mark()`/`clear()` (try/finally), reaper в Tier-2 при `stage=="deploy-staging"` И активном владении делает **defer** (не повторяет advance); Tier-3 backstop маркер игнорирует (мёртвый/застрявший finalizer добивается в ограниченное время). In-memory restart-safe через `requeue_running_jobs` (вызов до старта reaper); схема БД, `reaper_finalize_grace_s`/`reaper_max_running_s` и сквозной бюджет не тронуты. Kill-switch `ORCH_REAPER_FINALIZER_LIVENESS_ENABLED`.
- **Transition-ownership lease** (`src/transition_lease.py` + таблица `transition_lease`, ORCH-114 — реализовано, [adr-0045](adr/adr-0045-transition-ownership-lease-and-stage-cas.md)) — чистый **never-raise** leaf (паттерн `serial_gate`/`coverage_gate`/`finalizer_liveness`; импортирует только `db`+`config`, лениво `merge_gate.pid_alive`/`qg.checks`/`notifications`; НЕ импортирует `stage_engine`/`launcher`), закрывающий корневой класс инцидент-цепочки ORCH-110/111/112/113 — у side-effectful переходов стадий не было единого владения. Два слоя, оба под единым kill-switch: **(1) durable transition-lease** (владение на ВХОДЕ в side-effectful регион — аддитивная таблица `transition_lease`, `task_id PK`; `acquire` атомарным rowcount-guard `INSERT … ON CONFLICT DO NOTHING` после очистки stale-строки; liveness владельца = `owner_pid`+`owner_boot_id`, НЕ heartbeat → рестарт-recovery бесплатен) и **(2) expected-stage CAS** `db.update_task_stage_cas` (на ЗАПИСИ стадии; проигравший гонку — аборт без побочных эффектов; покрывает и 6 путей записи в обход `advance_stage`). `advance_stage` захватывает lease на side-effectful рёбрах (`deploy-staging`/`deploy`) и освобождает в `try/finally`; job-reaper `_finalizer_owns` обобщён до durable cross-path lease (defer при живом, реклейм мёртвого; Tier-3 backstop игнорирует маркер → bounded; реап force-освобождает lease); reconciler F-1 и Plane-webhook делают defer; `main.lifespan` зовёт `recover_on_startup()` после `requeue_running_jobs`. Lease без своего TTL (потолок = Tier-3 `reaper_max_running_s`) → сквозной бюджет ORCH-065/109/110/113 цел. Скоуп self-hosting (`transition_lease_repos=""` → только `orchestrator`); kill-switch `ORCH_TRANSITION_LEASE_ENABLED=false` → CAS вырождается в прежний `update_task_stage`, lease инертен, reaper → ORCH-113 fallback (байт-в-байт). Наблюдаемость — блок `transition_lease` в `GET /queue` + Telegram-алерт на форсированный/устаревший реклейм + опц. `POST /transition-lease/release?work_item=<id>`. `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схемы существующих таблиц — байт-в-байт. Подробнее ниже (§ «Единое владение side-effectful переходами»). Детали — `docs/work-items/ORCH-114/06-adr/ADR-001-transition-ownership-lease-and-stage-cas.md`.
@@ -416,6 +417,40 @@ ORCH-079 синхронизирует витрину с кодом и закры
- ADR: [adr-0023](adr/adr-0023-overview-docs-reviewer-axis-and-epic52-close.md); детально —
`docs/work-items/ORCH-079/06-adr/ADR-001-readme-sync-and-reviewer-overview-docs-axis.md`.
#### Карта LLM-консультаций + политика использования LLM (ORCH-118 — design)
Зонтичный follow-up RCA-трека ORCH-114/117: оркестратор не имел нормативного критерия «где LLM нужен,
а где это avoidable control path» и карты мест вызова LLM, прибитой к коду. ORCH-118 — **inventory +
карта + roadmap + политика + структурные тесты** (реализация детерминированных раннеров — follow-up'ы
**по роли**, без выдуманных Plane-ID). Это **docs + tests only**: `STAGE_TRANSITIONS` / реестр и имена
`QG_CHECKS`/`check_*` / machine-verdict-ключи / схема БД — **байт-в-байт не тронуты**; kill-switch не
нужен (нет рантайм-поведения), как ORCH-077/079/101/102/103/011.
- **Три ортогональных оси (ground-truth — код):** (1) consultation ≠ transport/slot (единственный
транспорт LLM-консультации в `src/**` — `launcher._spawn`, `launcher.py:472/610-614`; иного нет;
D1/D2 `deploy-finalizer`/`post-deploy-monitor` занимают слот, но перехватываются в `launch_job` до
`_spawn`, `launcher.py:389/394` — консультации нет); (2) **control-path (C) ≠ artifact-producer (P)**
по коду-потребителю в `src/qg/checks.py` (C: `check_*` ветвится на LLM-вердикте; P: детерминированный
гейт судит артефакт независимо — файлы/CI); (3) деривируемость вердикта из tool-сигналов.
- **Нормативное определение** «avoidable LLM control path» = двухбитный предикат: C-консультация **И**
вердикт деривируем из tool-сигналов. Целевой набор (поимённо, доказательно): **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` (нормативный принцип «LLM — только где
нужно настоящее суждение»). Анти-дрейф — `tests/test_llm_call_site_inventory.py` (offline; включая
control-path-инвариант сверки с `src/qg/checks.py` и фиксацию avoidable-набора).
- **Норматив сопровождения:** менял места вызова LLM **или** потребителя вердикта в `src/qg/checks.py`
→ обнови карту/разметку и политику в том же PR.
- **Первый срез реализован (ORCH-115 — [adr-0048](adr/adr-0048-deterministic-staging-runner.md)):**
rank-1 кандидат **deployer (staging-status)** заменён детерминированным `src/staging_runner.py`
(перехват в `launch_job` до `_spawn`, как D1/D2) — A6 переходит из «план» в «реализовано». Карта
`llm-call-sites.md` (A6), `llm-determinization-roadmap.md` (rank 1, инвариант «ровно один
`first_slice`») и анти-дрейф-тесты обновляются **атомарно с кодом** в development (норматив
сопровождения NFR-6). Второй кандидат — `tester`-гибрид (rank 2) — остаётся планом.
- ADR: [adr-0047](adr/adr-0047-llm-usage-policy-and-call-site-map.md); детально —
`docs/work-items/ORCH-118/06-adr/ADR-001-llm-call-site-map-and-determinization-roadmap.md`.
### Модель и эффорт по ролям (ORCH-41, валидация ORCH-74)
Модель и `--effort` каждого агента берутся из config (`src/config.py`), резолвятся `launcher.resolve_agent_model` / `resolve_agent_effort` по приоритету **project-override (`projects_json` `agent_models`/`agent_efforts`) > `ORCH_AGENT_MODEL_<AGENT>`/`ORCH_AGENT_EFFORT_<AGENT>` > `*_default` > CLI-дефолт (без флага)**. **Эффорт (ORCH-081):** ниже `*_default` добавлен непустой **per-role floor** — class-default поля `agent_effort_<role>` из `config.py` (его пустой env перебить не может). Floor — строго последний уровень (ниже default) и срабатывает ТОЛЬКО когда все уровни пусты, поэтому пустые прод-`ORCH_AGENT_EFFORT_*=` (которые pydantic трактует как явное `''` и обнуляют дефолт) больше не приводят к запуску без `--effort`: каждая роль получает свой канонический пол (developer=`xhigh`, tester/deployer=`medium`, прочие=`high`). Непустой явный конфиг по-прежнему побеждает floor; опечатка вне `VALID_EFFORTS` дропается валидацией ДО floor (never-break, не маскируется). См. `docs/work-items/ORCH-081/06-adr/ADR-001-effort-resolution-floor.md`. frontmatter `model:` в `.openclaw/agents/*.md` **удалён** (ORCH-74 G1) — он был мёртвой/лживой декларацией (launcher его не читает); config — единственный источник правды о модели. Model-routing (G3) НЕ включён — все 6 агентов на `claude-opus-4-8`.

View File

@@ -0,0 +1,114 @@
---
work_item: ORCH-118
stage: architecture
author_agent: architect
status: accepted
created_at: 2026-06-15
model_used: claude-opus-4-8
---
# adr-0047: Нормативная политика использования LLM + карта call-site'ов (control-path-ось «avoidable»)
> **Сквозной (cross-cutting) ADR.** Агрегирует решение ORCH-118, влияющее на **весь** оркестратор:
> нормативная политика использования LLM, три ортогональных оси, определение «avoidable LLM control
> path» и снимок-карта LLM-консультаций, прибитая к коду структурными тестами. Локальная детализация —
> `docs/work-items/ORCH-118/06-adr/ADR-001-llm-call-site-map-and-determinization-roadmap.md`.
## Статус
Accepted
## Контекст
RCA-цепочка ORCH-114/117 (и 110/111/112/113) показала корневой класс: у side-effectful и решающих
control-path'ов не было единого детерминированного владения; местами решение брал LLM-агент «потому
что удобно», хотя по сути это исполнение фиксированных команд + маппинг результата — лишний
недетерминизм, задержка и расход токенов в точке ветвления.
Оркестратор не имел **нормативного критерия** «где LLM нужен, а где это avoidable control path» и
**карты** мест вызова LLM, прибитой к коду. Без них любая будущая правка control-path'а могла снова
ввести LLM «на удобстве», а «вслепую» убирать LLM нельзя — часть путей несёт настоящее суждение
(анализ, архитектура, написание кода, ревью).
**Ground-truth кода (ORCH-118, сверено):** единственный транспорт LLM-консультации в `src/**`
`launcher._spawn` (`launcher.py:472`, CLI `610-614`); иного LLM-транспорта нет (нет SDK-импортов /
прямого HTTP Anthropic / второго сборщика). 6 ролей-агентов консультируют через него; D1/D2
(`deploy-finalizer`/`post-deploy-monitor`) перехватываются в `launch_job` **до** `_spawn`
(`launcher.py:389/394`) — слот есть, консультации нет. Потребитель вывода каждой роли — конкретный
`check_*`/`_parse_*` в `src/qg/checks.py`.
## Решение
### D1 — Три ортогональных оси (нормативно для всего оркестратора)
1. **consultation ≠ transport/slot** — «потребляет суждение LLM» ≠ «спавнит процесс / занимает слот
агента» (capability ≠ consultation).
2. **control-path (C) ≠ artifact-producer (P)** — определяется кодом-потребителем: C — `check_*`
ветвится на machine-verdict, написанном LLM; P — детерминированный гейт судит артефакт независимо
(файлы/CI).
3. **деривируемость вердикта** — вердикт C-консультации либо детерминированная функция tool-сигналов
(exit-code `pytest`/smoke/`staging_check.py`/деплоя), либо настоящее суждение.
### D2 — Нормативное определение «avoidable LLM control path»
> Call-site — **avoidable LLM control path** ⟺ **(i)** C-консультация (LLM-вердикт потребляется
> потоком управления) **И (ii)** вердикт деривируем из tool-сигналов, которые оркестратор уже
> вычисляет → LLM не добавляет информации.
Целевой набор (доказательно из `src/qg/checks.py`): **avoidable = {tester, deployer}**;
control-path-но-keep = `{reviewer}`; не-control-path (P, keep) = `{analyst, architect, developer}`;
уже детерминированы (вне консультаций) = `{deploy-finalizer, post-deploy-monitor}`.
### D3 — Нормативная политика использования LLM (`docs/architecture/llm-usage-policy.md`)
Принцип: **«LLM — только там, где требуется настоящее суждение».** Критерий keep vs replace —
через оси D1 (является ли путь control path; деривируем ли вердикт; обратимость; влияние на
автономность NFR-2). **Требование:** любая новая/изменённая control-path-консультация обязана
обосновать использование LLM против этой политики; reviewer контролирует это как обзорную ось
(в духе ORCH-079) — **как требование, не как новый машинный гейт**.
### D4 — Карта как снимок, прибитый к коду
`docs/architecture/llm-call-sites.md` — инвентарь + control-path-разметка + классификация со
схемой полей и машинным блоком (детали — work-item ADR-001 D2/D4). Структурные тесты
`tests/test_llm_call_site_inventory.py` (offline) держат инварианты: транспорт-агностичный
двусторонний инвариант единственной точки, отсутствие консультации в детерминированных путях,
control-path-разметка сверена с `src/qg/checks.py`, avoidable-набор = `{tester, deployer}`.
### D5 — Roadmap детерминизации (`docs/architecture/llm-determinization-roadmap.md`)
Рекомендованный первый срез — **deployer (staging-status)** (`replace-deterministic-now`: чистый
маппинг exit-кода `staging_check.py`; прод уже детерминирован Phase A/B/C ORCH-036; опора на
прецедент D1/D2). Затем — **tester-гибрид** (`needs-hybrid-fallback`). Кандидаты — **по роли**,
без конкретных Plane-ID (NFR-6).
### D6 — Скоуп и инварианты (нормативно)
ORCH-118 — **docs + tests only**: `STAGE_TRANSITIONS` / реестр и имена `QG_CHECKS`/`check_*` /
machine-verdict-ключи / схема БД — **байт-в-байт не тронуты**; раннеры замен не реализуются;
follow-up Plane-ID не фиксируются. Self-hosting-безопасно (только чтение кода + запись docs/tests).
**Норматив сопровождения (durable):** менял места вызова LLM **или** потребителя вердикта в
`src/qg/checks.py` → обнови карту/разметку и политику в **том же PR** (иначе тесты D4 красные).
## Альтернативы
- **Машинный гейт-enforcement политики (новый QG)** — отвергнуто: политика нормативно-описательная,
как ось трассировки ORCH-078; новый QG увеличил бы поверхность риска без необходимости (FR-6 §QG).
- **Реализация раннеров в этой же задаче** — отвергнуто: inventory-first по требованию заказчика;
«вслепую» убирать LLM рискованно без утверждённой карты.
- **Привязка к конкретным follow-up ID** — отвергнуто (NFR-6, корень отклонённой R2).
## Последствия
- **+** Единый нормативный критерий и код-привязанная карта закрывают класс «LLM на удобстве» и
делают замены предсказуемыми; автономность защищена политикой.
- **** Карта — снимок: эволюция `src/qg/checks.py` требует со-обновления карты (держится тестами).
*Митигейшн:* запланированный норматив сопровождения, тест указывает точку дрейфа.
- **Откат:** удаление/правка `docs/architecture/llm-*.md` + тест-файла + секции README; рантайм не
затронут.
## Ссылки
- Work-item ADR: `docs/work-items/ORCH-118/06-adr/ADR-001-llm-call-site-map-and-determinization-roadmap.md`
- BRD/TRZ/AC: `docs/work-items/ORCH-118/{01-brd,02-trz,03-acceptance-criteria}.md`
- Сверено по коду: `src/agents/launcher.py`, `src/qg/checks.py`, `.openclaw/agents/*.md`
- Связанные: ORCH-036 (детерминированный self-deploy), ORCH-061 (`staging_verdict`),
ORCH-077/079 (docs/prompts-only прецедент + reviewer-ось обзорных доков), ORCH-114/117 (RCA-трек)
</content>

View File

@@ -0,0 +1,92 @@
---
work_item: ORCH-115
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-16
model_used: claude-opus-4-8
---
# adr-0048: Детерминированный staging-раннер — первый реализованный срез determinization-roadmap
> **Сквозной (cross-cutting) ADR.** Агрегирует решение ORCH-115, влияющее на **весь**
> оркестратор: вводит новый компонент-leaf `src/staging_runner.py`, снимает первую
> avoidable LLM-консультацию (`deployer`/`staging-status`, A6) и переводит rank-1
> determinization-roadmap из «план» в «реализовано». Локальная детализация (все решения
> D1D11) — `docs/work-items/ORCH-115/06-adr/ADR-001-deterministic-staging-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)** первым срезом
(`first_slice = yes`, `replace-deterministic-now`, `hybrid_needed = no`). ORCH-118 раннеры
**не реализовывал** (docs+tests). ORCH-115 — первая фактическая реализация этого среза.
Вердикт `staging_status:` на стадии `deploy-staging` сейчас эмитит LLM-агент `deployer`, но
он есть **чистый маппинг exit-кода** `scripts/staging_check.py` (infra-tolerance ORCH-061
уже внутри скрипта), а гейт `check_staging_status` детерминирован. Это удовлетворяет обоим
условиям «avoidable»: C-консультация **и** деривируемый вердикт. Прецедент детерминированной
замены агента (`launch_job`-перехват до `_spawn`, D1/D2 `deploy-finalizer`/`post-deploy-monitor`)
и эталон «детерминированный джоб → `advance_stage`» (`run_deploy_finalizer`) уже работают в
проде — архитектурный риск снят.
## Решение
**Новый leaf `src/staging_runner.py` + перехват в `launch_job` до `_spawn`** (рядом с D1/D2).
На `deploy-staging` для in-scope репо джоб `deployer` обрабатывает раннер: исполняет
staging-сюиту через `proc_group` (tree-kill, ORCH-110), маппит exit-код единым контрактом
`self_deploy.map_exit_code_to_status`, пишет `15-staging-log.md` (тот же machine-key
`staging_status:`), вызывает **существующий** `advance_stage(finished_agent="deployer")`.
Кросс-каттинговые инварианты (сохранены **байт-в-байт**):
- `STAGE_TRANSITIONS` (`src/stages.py`), реестр и имена `QG_CHECKS`/`check_*`/`_parse_*`,
machine-verdict-ключи (`staging_status:`/`deploy_status:`/`verdict:`/`result:`/
`security_status:`/`coverage_status:`), **схема БД** — не тронуты. Это замена *продюсера*
артефакта, не гейта/стадии.
- Единственный транспорт LLM-консультации (`launcher._spawn`/S0,
[llm-usage-policy.md](../llm-usage-policy.md) §5) — соблюдён: раннер **не зовёт LLM**;
второй транспорт не вводится.
- Сквозной бюджет времени ORCH-065/109/110 (`reaper_max_running_s` > Σ(работ на ребре
`deploy-staging`) + grace) — соблюдён **без** правки `reaper_max_running_s` (раннер-таймаут
600s ≤ прежнего LLM-окна).
- Граница ORCH-112/ORCH-114: transition-lease берётся **внутри** `advance_stage`; раннер
lease/гигиену не модифицирует.
Скоуп — **self-hosting only** (`staging_runner_repos=""``is_self_hosting_repo`), под
kill-switch `staging_runner_enabled` (off → `_spawn` LLM-deployer'а байт-в-байт). never-raise
во всех публичных функциях; **двухуровневый исход** (verdict при исполнившейся сюите; bounded
defer → fail-closed на tool-error/таймауте) убирает с staging-ребра RCA-класс ORCH-110 (инфра
≠ код-фейл).
**Эволюция карты LLM (норматив сопровождения, в том же PR — D11 локального ADR):**
`llm-call-sites.md` (A6 → реализовано детерминированно), `llm-determinization-roadmap.md`
(rank 1 deployer → реализован; инвариант «ровно один `first_slice`» цел), `llm-usage-policy.md`
(§5 — транспорт не нарушен), плюс анти-дрейф-тесты (`test_llm_call_site_inventory.py`/
`test_llm_determinization_docs.py`). Эти правки коуплены к коду → применяются в development
атомарно с реализацией, не в architecture-стадии.
## Последствия
- **+** Минус один avoidable LLM control path; первый доказанный раннер-паттерн замены
C-консультации (опора для второго кандидата — `tester`-гибрид, rank 2).
- **+** Дешевле/быстрее/детерминированнее собственный `deploy-staging`; нет токенов/латентности
LLM в точке ветвления.
- **+** Паттерн переиспользуем: leaf + перехват до `_spawn` + `advance_stage` — шаблон для
будущих срезов и для Phase 2 (project deploy contract не-self репо).
- **** Новый компонент + врезка + defer-механика. Митигейшн: never-raise leaf, kill-switch
(fail-safe к LLM), без схемы БД, структурное покрытие.
- **Откат:** `ORCH_STAGING_RUNNER_ENABLED=false` → прежний LLM-путь на `deploy-staging`
байт-в-байт.
## Ссылки
- Локальный ADR: `docs/work-items/ORCH-115/06-adr/ADR-001-deterministic-staging-runner.md`
- Политика/карта/roadmap: [llm-usage-policy.md](../llm-usage-policy.md),
[llm-call-sites.md](../llm-call-sites.md), [llm-determinization-roadmap.md](../llm-determinization-roadmap.md),
[adr-0047](adr-0047-llm-usage-policy-and-call-site-map.md)
- Прецеденты: D1/D2 (`launcher.py:389/394`), `run_deploy_finalizer` (`stage_engine.py:2010`),
`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))

View File

@@ -96,6 +96,20 @@ claude.exe --print --system-prompt --allowedTools Read,Write,Edit,Bash
3. Стартует **watchdog thread** (per-role wall-clock бюджет → SIGTERM→grace→SIGKILL по pid; ORCH-109: developer 60 мин / reviewer 50 мин / прочие 30 мин дефолт, `_resolve_timeout`)
4. Стартует **monitor thread**: `proc.wait()` (гарантированный reap → реальный exit_code в БД) → закрывает log_fh → git commit/push → auto-advance
**Детерминированные перехваты `launch_job` ДО `_spawn` (no-LLM джобы).** Перед `_spawn` `launch_job`
перехватывает зарезервированные роли и исполняет их детерминированно, сам ведя `jobs`-строку через
`mark_job` (нет `agent_runs`, нет токенов): `deploy-finalizer` (D1, ORCH-036 Phase C) и
`post-deploy-monitor` (D2, ORCH-021). **ORCH-115 ([adr-0048](adr/adr-0048-deterministic-staging-runner.md)):**
тем же паттерном перехватывается джоб `deployer` на стадии `deploy-staging` для in-scope репо
(дискриминатор — **стадия задачи**, не имя роли: роль `deployer` общая для `deploy-staging`/`deploy`;
+`staging_runner.applies(repo)` под kill-switch `staging_runner_enabled`, скоуп `staging_runner_repos`,
пусто → self-hosting only; `should_intercept` never-raise → `False` → штатный `_spawn`, fail-safe к
LLM). Leaf `src/staging_runner.py` (зеркало `run_deploy_finalizer`) исполняет staging-сюиту через
`proc_group` (tree-kill, таймаут `staging_runner_timeout_s`), маппит exit-код
`self_deploy.map_exit_code_to_status`, пишет `15-staging-log.md` (тот же machine-key `staging_status:`)
и вызывает существующий `advance_stage(finished_agent="deployer")` (см. §5). Так LLM-агент `deployer`
на `deploy-staging` исчезает из happy-path; `STAGE_TRANSITIONS`/`QG_CHECKS`/схема БД не тронуты.
### 5. Auto-advance (`launcher._try_advance_stage`)
После успешного завершения агента:

View File

@@ -0,0 +1,162 @@
# 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` (наличие файлов) | ~80200k / 520 мин | да (через S0) | **P** | `keep-LLM` | анализ требований / BRD/ТЗ — настоящее суждение; гейт судит лишь наличие артефактов |
| **A2** | `.openclaw/agents/architect.md` | стадия `architecture` | architect | `06-adr/`, `07` | — | `check_architecture_done:62` (наличие 06-adr/07) | ~80200k / 520 мин | да (через S0) | **P** | `keep-LLM` | архитектурное решение / ADR — настоящее суждение |
| **A3** | `.openclaw/agents/developer.md` | стадия `development` | developer | код + PR | — | `check_ci_green:82` (+ `check_branch_mergeable:657`) — CI/merge | ~150400k / 1040 мин | да (через S0) | **P** | `keep-LLM` | написание кода — настоящее суждение; гейт судит CI/merge, не самоотчёт |
| **A4** | `.openclaw/agents/reviewer.md` | стадия `review` | reviewer | `12-review.md` | `verdict:` | `check_reviewer_verdict:336` (`verdict:`) | ~100300k / 525 мин | да (через 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:`) | ~60150k / 520 мин | да (через 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:`) | ~40120k / 315 мин | да (через S0; для in-scope репо на `deploy-staging`**нет**, перехват до `_spawn`) | **C** | `replace-deterministic-now` | **avoidable, СРЕЗ РЕАЛИЗОВАН (ORCH-115):** на `deploy-staging` для self-hosting `orchestrator` вердикт `staging_status:` производит детерминированный `src/staging_runner.py` (перехват в `launch_job` до `_spawn`, как D1/D2) — маппинг exit-кода `staging_check.py`; прод уже детерминирован Phase A/B/C (ORCH-036). LLM-ветвь остаётся fallback'ом под выключенным флагом / для не-self репо |
| **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). **Не менять заголовок и значения без
> синхронной правки кода/тестов.**
<!-- ORCH-118-INVENTORY-BLOCK:START -->
| 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 |
<!-- ORCH-118-INVENTORY-BLOCK:END -->
### 1.2 keep-LLM — названное суждение (обоснование)
> Для каждой `keep-LLM`-записи назван **конкретный** вид суждения, ради которого LLM сохраняется.
> Для C-keep (`reviewer`) обоснование явно фиксирует **НЕ-деривируемость** вердикта (почему не сводится
> к exit-коду). Парсится TC-05 (`- role: текст`).
<!-- ORCH-118-KEEP-JUSTIFICATION-BLOCK:START -->
- 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.
<!-- ORCH-118-KEEP-JUSTIFICATION-BLOCK:END -->
---
## 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'а**deployer (staging-status)** — реализован
**ORCH-115** (`src/staging_runner.py`, перехват в `launch_job` до `_spawn`): на `deploy-staging`
для in-scope репо вердикт `staging_status:` производит детерминированный код, не LLM. Это
`replace-deterministic-now` без ввода второго транспорта (раннер LLM не зовёт) — карта/инвариант
«единственный транспорт S0» соблюдены. Машинный `ORCH-118-INVENTORY-BLOCK` сохраняет deployer как
`avoidable=yes`/`axis=C` (LLM-ветвь жива как fallback под выключенным флагом / для не-self репо).
Второй кандидат (`tester`) остаётся follow-up'ом по роли.
- **Анти-дрейф тесты:** `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).

View File

@@ -0,0 +1,80 @@
# 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)** — ✅ РЕАЛИЗОВАН (ORCH-115)
> **Статус: реализовано.** Срез выполнен в **ORCH-115** — `src/staging_runner.py` (перехват в
> `launch_job` до `_spawn`, как `D1`/`D2`): на стадии `deploy-staging` для self-hosting `orchestrator`
> вердикт `staging_status:` производит детерминированный код (маппинг exit-кода `staging_check.py`
> через `self_deploy.map_exit_code_to_status`), а не LLM. Под kill-switch `staging_runner_enabled` +
> скоуп `staging_runner_repos` (пусто → self-hosting only); LLM-ветвь остаётся fallback'ом.
> Контракт артефакта/гейта `check_staging_status`/`STAGE_TRANSITIONS`/схема БД — не тронуты. Детали —
> `docs/work-items/ORCH-115/06-adr/ADR-001-deterministic-staging-runner.md`, сквозной
> `docs/architecture/adr/adr-0048-deterministic-staging-runner.md`. Запись `rank 1` в машинном блоке
> §4 сохраняется (`first_slice = yes`, инвариант карты) как историческая фиксация первого среза.
Обоснование (самый низкорисковый «чисто деривируемый» 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`.
<!-- ORCH-118-ROADMAP-BLOCK:START -->
| 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 |
<!-- ORCH-118-ROADMAP-BLOCK:END -->
---
## 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).

View File

@@ -0,0 +1,101 @@
# 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`**, а не «удобство на глаз».
<!-- ORCH-118-AVOIDABLE-DEFINITION-BLOCK:START -->
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-фолбэком только на не-деривируемую часть).
<!-- ORCH-118-AVOIDABLE-DEFINITION-BLOCK:END -->
**Поимённый целевой набор** (сверен с кодом, прибит тестами 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.
- **Реализованный срез (ORCH-115).** Снятие C-консультации с деривируемым вердиктом — это разрешённое
`replace-deterministic-now`, а не ввод новой LLM-консультации. ORCH-115 снял A6/staging-status:
детерминированный `src/staging_runner.py` производит `staging_status:` без `_spawn` (перехват до
него, как `D1`/`D2`) — раннер **LLM не зовёт** и **второй транспорт не вводит**, поэтому инвариант
«единственный транспорт S0» соблюдён (TC-12 зелёный). Это образец для последующих срезов roadmap'а.

View File

@@ -48,6 +48,12 @@ Machine-verdict ключи читаются гейтами **только из Y
Особенность: промпт `deployer` сознательно на английском (самый safety-critical — несёт
запреты self-hosting в видной рамке); остальные пять — на русском.
Особенность (ORCH-115): на стадии `deploy-staging` для self-hosting `orchestrator` LLM-`deployer`
заменён **детерминированным staging-раннером** (`src/staging_runner.py`) — его работа была чисто
механической (запуск staging-сюиты + маппинг exit-кода). LLM-промпт `deployer` остаётся fallback'ом
под выключенным флагом / для не-self репо и продолжает вести прод-стадию `deploy`. Подробнее —
[конвейер](tech-pipeline.md) и [карта LLM-консультаций](../architecture/llm-call-sites.md).
## Человек как седьмая роль
Человек не пишет артефакты конвейера, но принимает два решения, которые не делегированы

View File

@@ -34,6 +34,16 @@ created → analysis → architecture → development → review → testing →
| `done` | — | — | терминал |
| `cancelled` | — | — | терминал (сток отмены) |
> **Детерминированный staging-раннер (ORCH-115).** На стадии `deploy-staging` для self-hosting
> `orchestrator` работу ведёт **детерминированный код** (`src/staging_runner.py`), а не LLM-агент
> `deployer`: он перехватывается в `launch_job` до запуска агента (как Phase A/B/C прод-деплоя),
> исполняет ту же staging-сюиту, маппит exit-код в `staging_status:` и инициирует **тот же** гейт
> `check_staging_status`. Это замена *продюсера* артефакта, а не гейта: контракт `15-staging-log.md`,
> имя/семантика `check_staging_status`, `STAGE_TRANSITIONS` — не изменились. Под kill-switch
> `staging_runner_enabled` (скоуп `staging_runner_repos`, пусто → self-hosting only); при выключении
> на стадии снова работает LLM-`deployer` байт-в-байт. Это первый реализованный срез
> determinization-roadmap (см. `docs/architecture/llm-determinization-roadmap.md`).
## Под-гейты деплойного ребра — врезки, не стадии
На переходе `deploy-staging → deploy` исполняются четыре под-гейта в нормативном порядке

View File

@@ -40,6 +40,25 @@
- Анти-регресс машинный: структурные тесты сканируют исполняемый код на боевые хост-литералы,
а документацию — на секретоподобные значения; находка рвёт CI.
## Где уместен LLM: карта вызовов и нормативная политика
Платформа держит **доказательную карту** всех мест, где поток управления потребляет суждение
LLM, и **нормативную политику** «LLM — только там, где нужно настоящее суждение». Карта разводит
три факта: консультация ≠ транспорт/слот; **control-path** (вердикт LLM ветвит поток управления)
**artifact-producer** (детерминированный гейт судит артефакт независимо); и деривируемость
вердикта из tool-сигналов. Путь называется **avoidable LLM control path**, когда он одновременно
control-path и его вердикт деривируем из exit-кодов (тогда консультацию можно заменить
детерминированным раннером). Поимённо: avoidable = `{tester, deployer}`; настоящее суждение
сохраняется у `{analyst, architect, developer, reviewer}`. Карта — снимок, прибитый структурными
анти-дрейф тестами. **Первый срез реализован (ORCH-115):** на `deploy-staging` для self-hosting
`orchestrator` LLM-`deployer` заменён детерминированным `src/staging_runner.py` (вердикт
`staging_status:` = маппинг exit-кода staging-сюиты); LLM-ветвь остаётся fallback'ом, гейт
`check_staging_status` не тронут. Замена второго кандидата (`tester`) — 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-страховки
Платформа дорабатывает сама себя тем же конвейером — прод-инстанс при этом обслуживает и

View File

@@ -0,0 +1,7 @@
# Business Request: ORCH: replace LLM deployer with deterministic deploy runner
Work Item ID: ORCH-115
## Description
TBD

View File

@@ -0,0 +1,175 @@
---
work_item: ORCH-115
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-16
model_used: claude-opus-4-8
---
# 01 — BRD (бизнес-требования): ORCH-115 — заменить LLM-деплойера детерминированным staging-раннером
Work Item: **ORCH-115** · Repo: **orchestrator** · Стадия: analysis
## 1. Бизнес-контекст и проблема
Стадия `deploy-staging` сейчас исполняется **LLM-агентом `deployer`** (`src/stages.py:18`,
`get_agent_for_stage("testing") = "deployer"`). Фактическая работа агента на этой стадии —
**чисто детерминированная**: запустить staging-сюиту (`docker exec orchestrator-staging python3
scripts/staging_check.py --base-url http://localhost:8501 --mode stub`), смаппить **exit-код**
в вердикт (`0 → SUCCESS`, ≠0 → `FAILED`), записать `15-staging-log.md` с frontmatter
`staging_status:` и смержить лог в `main` (`.openclaw/agents/deployer.md`, шаги 14).
Это **avoidable LLM control path** по нормативной политике (`docs/architecture/llm-usage-policy.md`
§3): (i) это C-консультация — её вердикт `staging_status:` потребляется гейтом
`check_staging_status` (`src/qg/checks.py:599`), и (ii) вердикт **полностью деривируем** из
exit-кода `staging_check.py`. Карта вызовов (`docs/architecture/llm-call-sites.md`, строка **A6**)
классифицирует deployer как **`replace-deterministic-now`**, а roadmap
(`docs/architecture/llm-determinization-roadmap.md`, машинный блок) ставит его **rank 1** с
`first_slice = yes`, `hybrid_needed = no`. Эта задача — **первый срез** реализации того roadmap.
**Боль / риск, который закрываем:**
- **Недетерминизм в потоке управления.** Решение «advance или rollback» на `deploy-staging` зависит
от LLM-сессии (стоимость, латентность, риск галлюцинации команд), хотя сводится к одному exit-коду.
- **Стоимость и латентность.** Каждый прогон deployer'а на staging тратит токены/время opus-агента
(оценка по `agent_runs`: deployer-строки ~40120k токенов / 315 мин на прогон; точное число —
`GET /metrics`) ради действия, которое выполняется тремя shell-строками.
- **Класс инцидентов «LLM принял решение, которое есть исполнение фиксированных команд + маппинг
результата»** — тот же RCA-трек, что ORCH-110/111/112/113/114/117.
Установленные факты (не изобретать):
- Пьюр-логика вердикта уже существует и юнит-тестируема: `src/staging_verdict.py::compute_staging_verdict`
(ORCH-061) считает infra-tolerant вердикт **внутри** `staging_check.py`; раннеру остаётся доверять
exit-коду (как уже делает LLM-deployer — `deployer.md` step 2).
- Детерминированный прецедент замены агента уже работает: `launch_job` перехватывает зарезервированные
роли `deploy-finalizer` (D1, `src/agents/launcher.py:389`) и `post-deploy-monitor`
(D2, `:394`) **до `_spawn`** и исполняет их как no-LLM-джобы.
- Прод-ребро `deploy` для self-hosting уже детерминировано (`src/self_deploy.py` Phase A/B/C,
ORCH-036) — LLM в критическом self-restart-пути нет. Срез не трогает критический прод-путь.
## 2. Объём (scope)
### В объёме (Phase 1)
- **Детерминированный staging-раннер** для `deploy-staging` репо `orchestrator` (self-hosting):
исполняет staging-сюиту, маппит exit-код в `staging_status:`, пишет `15-staging-log.md`, мержит в
`main`**без** запуска LLM-агента `deployer`.
- Раннер активируется через **перехват в `launch_job` до `_spawn`** (прецедент D1/D2), **без правки
`src/stages.py`/`STAGE_TRANSITIONS`** (роль `deployer` в словаре остаётся; меняется лишь *кто*
обрабатывает джоб на стадии `deploy-staging` для in-scope репо).
- После выпуска вердикта раннер инициирует **существующую** оценку exit-гейта `check_staging_status`
ровно так, как это делал завершившийся LLM-deployer (`_try_advance_stage``advance_stage(
finished_agent="deployer")`) — все нижестоящие под-гейты (security → merge → coverage →
image-freshness, ORCH-022/043/027/058) и Phase A (ORCH-036) ведут себя идентично.
- Kill-switch + скоуп-CSV (паттерн ORCH-022/027/043/089/090): `*_enabled` (откат к LLM-пути) и
`*_repos` (пусто → self-hosting only).
- Наблюдаемость: read-only блок в `GET /queue` + структурный лог вердикта.
### Вне объёма (явно НЕ делаем в ORCH-115)
- **Phase 2 — «project deploy contract» для не-self репо** (например `enduro-trails`): конфигурируемый
контракт deploy/rollback/healthcheck для произвольных репо. Описан как **forward-looking
follow-up** (см. §6 и `02-trz.md` §8); **в приёмку ORCH-115 не входит**. Для не-self репо
`deploy-staging` сейчас — мгновенный pass (`check_staging_status` → N/A, `src/qg/checks.py:620`),
поэтому Phase 1 их не затрагивает.
- **Прод-ребро `deploy`** (Phase A/B/C self-deploy, ORCH-036) — уже детерминировано; не трогаем.
- **LLM debug/triage-аналитик после детерминированного FAILED** — `replace-deterministic-now` без
гибрида (roadmap `hybrid_needed = no`). В этом срезе LLM на `deploy-staging` отсутствует и в
happy-path, и в fail-path; опциональный off-control-path debug-аналитик оставлен как будущее
улучшение и **требованиями не запрещён** (см. NFR-7).
- **Любая правка `STAGE_TRANSITIONS` / реестра и имён `QG_CHECKS` / семантики `check_*` /
machine-verdict-ключей / схемы БД** (см. NFR-1).
- **ORCH-112 (checkout hygiene) и ORCH-114 (transition lease)** — по явной границе задачи не
смешиваем: раннер вызывает `advance_stage`, который уже владеет lease ORCH-114; сам lease/гигиену
не модифицируем.
## 3. Заинтересованные стороны
- **Заказчик / Owner** (`homenet542@gmail.com`) — инициатор детерминизации LLM-control-path'ов.
- **Платформа orchestrator (self-hosting)** — прямой потребитель: дешевле/быстрее/детерминированнее
собственный `deploy-staging`.
- **Другие проекты на общем инстансе** (enduro-trails) — НЕ затронуты в Phase 1 (скоуп self-hosting),
выигрывают позже от Phase 2.
- **Reviewer / Tester / Deployer-роли конвейера** — принимают результат через неизменные гейты.
## 4. Бизнес-требования (BR)
- **BR-1 — Детерминированный staging без LLM.** На `deploy-staging` для in-scope репо вердикт
`staging_status:` производится детерминированным кодом (исполнение `staging_check.py` + маппинг
exit-кода), **без** консультации LLM. Happy-path `deploy-staging` не вызывает `_spawn`.
- **BR-2 — Контракт артефакта неизменен.** Раннер пишет тот же `15-staging-log.md` с тем же
frontmatter-ключом `staging_status: SUCCESS|FAILED`, который читает `check_staging_status`/
`_parse_staging_status`. Гейт байт-в-байт не меняется.
- **BR-3 — Эквивалентность маршрутизации.** SUCCESS → продвижение на `deploy` через те же под-гейты
и Phase A; FAILED → существующий откат `deploy-staging → development` (тот же путь, что у
FAILED-вердикта LLM-deployer'а, `src/stage_engine.py:932`). Никаких новых рёбер/исходов.
- **BR-4 — Переиспользование существующей пьюр-логики.** Раннер использует уже существующий
exit-code→verdict маппинг (тривиальный `0→SUCCESS`/иначе`FAILED`, зеркало
`self_deploy.map_exit_code_to_status`); infra-tolerance (ORCH-061) остаётся **внутри**
`staging_check.py` — раннер ему доверяет, повторно не судит.
- **BR-5 — Обратимость одним флагом.** Глобальный kill-switch возвращает прежний LLM-deployer-путь
на `deploy-staging` байт-в-байт; скоуп-CSV ограничивает раннер in-scope репо (пусто → только
`orchestrator`).
- **BR-6 — Наблюдаемость.** Исход раннера (запущен / SUCCESS / FAILED / ошибка инструмента) виден в
`GET /queue` и в структурном логе; деградации (например staging-инстанс недоступен) различимы от
«код упал».
- **BR-7 — Self-hosting safety.** Раннер на `deploy-staging` **никогда** не рестартит прод-контейнер
8500, не трогает `main` force-push'ем, не правит `.env`/`docker-compose.yml`. Он лишь читает,
исполняет staging-сюиту (порт 8501), пишет лог и мержит лог штатным PR/artifact-merge-путём.
## 5. Нефункциональные требования (NFR)
- **NFR-1 — Скоуп-инвариант (анти-дрейф).** `STAGE_TRANSITIONS` (`src/stages.py`), реестр и имена
`QG_CHECKS`/`check_*`/`_parse_*` (`src/qg/checks.py`), machine-verdict-ключи
(`staging_status:`/`deploy_status:`/`verdict:`/`result:`/`security_status:`/`coverage_status:`),
схема БД — **байт-в-байт не тронуты**. Это замена *продюсера* артефакта, не гейта.
- **NFR-2 — never-raise / fail-safe.** Любая ошибка раннера (docker недоступен, таймаут, I/O) →
безопасный детерминированный исход без падения воркера: либо `FAILED` (fail-closed, никогда ложный
green), либо штатный requeue/defer — не «тихий advance». Сбой раннера не клинит очередь всех
проектов.
- **NFR-3 — Изоляция процесса / таймаут.** Спавненный subprocess (`docker exec …`) имеет
ограниченный таймаут и чистое завершение дерева процессов (согласовано с прецедентом ORCH-110
`proc_group`/tree-kill); сирот pytest/docker не оставляет.
- **NFR-4 — Сквозные бюджеты времени.** Таймаут раннера согласован со сквозным инвариантом
ORCH-065/109/110 (`reaper_max_running_s` > Σ(работ на ребре deploy-staging) + grace) — без правки
`reaper_max_running_s`.
- **NFR-5 — Совместимость с не-self репо.** Для репо вне скоупа `deploy-staging` ведёт себя 1:1 как
до ORCH-115 (LLM-deployer либо мгновенный N/A-pass). enduro-trails не затронут.
- **NFR-6 — Соответствие политике LLM.** Изменение снимает LLM-консультацию A6; карта
`docs/architecture/llm-call-sites.md` и политика/roadmap обновляются **в том же PR** (норматив
сопровождения ORCH-118): строка deployer переходит из «consults_llm: yes» в реализованное
детерминированное состояние.
- **NFR-7 — Не запрещать будущий debug-fallback.** Архитектура раннера не должна архитектурно
исключать опциональный off-control-path LLM debug-аналитик после FAILED (будущее улучшение); но
в ORCH-115 он не реализуется.
## 6. Допущения и ограничения
- **Допущение А1.** staging-инстанс `orchestrator-staging` (8501) поднят и доступен на хосте; его
недоступность раннер трактует детерминированно (fail-closed `FAILED` или defer — решает архитектор,
AC-7).
- **Допущение А2.** `scripts/staging_check.py` остаётся источником истины набора проверок и
exit-кода (включая infra-tolerance ORCH-061). ORCH-115 его логику не меняет.
- **Допущение А3.** Перехват «до `_spawn`» по имени джоб-роли + стадии задачи — достаточный механизм
диспетчеризации (как D1/D2); конкретный механизм финализирует архитектор (06-adr).
- **Ограничение О1.** Граница задачи: не смешивать с ORCH-112/ORCH-114 (их код не модифицируется).
- **Ограничение О2.** Phase 2 (project deploy contract) — отдельный follow-up; ORCH-115 закрывает
только Phase 1.
## 7. Критерии успеха
`deploy-staging` для `orchestrator` проходит без запуска LLM-агента `deployer`: детерминированный
раннер исполняет staging-сюиту, пишет корректный `15-staging-log.md` (`staging_status:`),
мержит его в `main`, и конвейер продвигается/откатывается ровно как раньше — при неизменных
`STAGE_TRANSITIONS`/`QG_CHECKS`/гейтах/схеме БД, под kill-switch с откатом к прежнему поведению.
Детальные PASS/FAIL — `03-acceptance-criteria.md`.
## 8. Риски
Краткий перечень (детали — `10-tech-risks.md`, заполняет архитектор):
- **R-1** — точка диспетчеризации «до `_spawn`» должна корректно отличать staging-deployer от
прод-deployer (по стадии задачи), иначе можно перехватить не тот джоб.
- **R-2** — после выпуска вердикта нужно надёжно инициировать `advance_stage`, иначе задача зависнет
на `deploy-staging` (нет «финиша агента», который раньше триггерил гейт).
- **R-3** — таймаут/изоляция docker-subprocess; утечка процессов (ср. инцидент ORCH-110).
- **R-4** — взаимодействие с transition-lease (ORCH-114) и serial-gate (ORCH-088) на side-effectful
ребре — не сломать владение.
- **R-5** — корректность отката FAILED (developer-retry cap) — должна совпасть с LLM-путём.

View File

@@ -0,0 +1,158 @@
---
work_item: ORCH-115
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-16
model_used: claude-opus-4-8
---
# 02 — ТЗ (TRZ): ORCH-115 — детерминированный staging-раннер вместо LLM-деплойера
Work Item: **ORCH-115** · Repo: **orchestrator** · Стадия: analysis
> ТЗ описывает **конкретные изменения к реализации**, выведенные из BRD и фактического кода.
> Архитектурное обоснование (точный механизм перехвата, размещение раннера, способ инициации
> `advance_stage`, лестница таймаутов) — задача архитектора (`06-adr/`). Здесь — требования и
> привязка к реальным модулям `src/`.
## 1. Сводка изменения
Заменить **LLM-агента `deployer`** на стадии `deploy-staging` (для self-hosting `orchestrator`)
**детерминированным staging-раннером**, перехватываемым в `launch_job` **до `_spawn`** (прецедент
`deploy-finalizer`/`post-deploy-monitor`, `src/agents/launcher.py:389/394`). Раннер исполняет ту же
staging-сюиту, что исполнял LLM (`docker exec orchestrator-staging python3
scripts/staging_check.py …`), маппит exit-код в `staging_status:` (`0→SUCCESS`, иначе `FAILED`),
пишет `15-staging-log.md`, best-effort мержит лог в `main`, затем инициирует **существующую** оценку
exit-гейта `check_staging_status` ровно как завершившийся LLM-deployer. Контракт артефакта, гейт,
`STAGE_TRANSITIONS`, схема БД — **неизменны**. Под kill-switch + скоуп-CSV; never-raise; fail-closed.
## 2. Задействованные модули / пути
| Путь | Действие | Назначение |
|------|----------|------------|
| `src/staging_runner.py` *(новый leaf)* | создать | Детерминированный раннер: `applies(repo)` (kill-switch + скоуп), исполнение staging-сюиты, маппинг exit-кода, запись `15-staging-log.md`, best-effort merge, снапшот для `/queue`. Leaf-чистота по образцу `self_deploy.py`/`staging_verdict.py`: импортирует только `config`/`git_worktree` (+ лениво `qg.checks.is_self_hosting_repo`), never-raise. |
| `src/agents/launcher.py` | изменить | В `launch_job` добавить перехват **до `_spawn`** (рядом с D1/D2): если джоб — `deployer` на стадии задачи `deploy-staging` и `staging_runner.applies(repo)` → исполнить раннер синхронно в воркер-треде, инициировать `advance_stage` и пометить джоб (как `_run_deploy_finalizer_job`); вернуть `None` (нет `agent_runs`-строки). |
| `src/config.py` | изменить | Добавить ключи `staging_runner_enabled: bool = True` (env `ORCH_STAGING_RUNNER_ENABLED`) и `staging_runner_repos: str = ""` (env `ORCH_STAGING_RUNNER_REPOS`; пусто → self-hosting only) + опц. `staging_runner_timeout_s` (см. FR-5). Дефолты = боевое; паттерн `coverage_gate_enabled`/`coverage_gate_repos`/`self_deploy_*`. |
| `src/stage_engine.py` | (потенциально) точечно | Если архитектор решит инициировать гейт из stage_engine, а не из launcher — добавить тонкий хелпер (вызов существующего `advance_stage(finished_agent="deployer")`). **Без** правки `STAGE_TRANSITIONS`/exit-гейтов. |
| `src/main.py` (`GET /queue`) | изменить | Read-only блок `staging_runner` (флаг/скоуп/счётчики исходов) — наблюдаемость BR-6. |
| `.openclaw/agents/deployer.md` | изменить (docs) | Отметить, что на `deploy-staging` для in-scope репо стадию ведёт детерминированный код (зеркало формулировки prod-Phase A/B/C); LLM-ветвь `deploy-staging` остаётся как fallback под выключенным флагом / для не-self репо. |
| `docs/architecture/llm-call-sites.md`, `llm-determinization-roadmap.md`, `llm-usage-policy.md` | изменить (docs) | Норматив сопровождения ORCH-118 (NFR-6): отразить реализацию A6 (deployer staging-status) — обновить инвентарь/политику/roadmap в том же PR; синхронно поправить `tests/test_llm_call_site_inventory.py` / `tests/test_llm_determinization_docs.py`. |
| `CLAUDE.md`, `CHANGELOG.md`, `docs/overview/` | изменить (docs) | Паспорт/чейнджлог/витрина — правило для агентов №2. |
| `tests/test_orch115_staging_runner.py` *(новый)* | создать | Покрытие (см. `04-test-plan.yaml`). |
> **Не трогать (NFR-1):** `src/stages.py::STAGE_TRANSITIONS`; имена/семантику `QG_CHECKS`/`check_*`/
> `_parse_*` в `src/qg/checks.py`; `src/staging_verdict.py` (переиспользуем как есть); `src/self_deploy.py`
> прод-путь; `src/transition_lease.py` (ORCH-114); `src/checkout_hygiene.py` (ORCH-112); схему БД.
## 3. Функциональные требования
### FR-1 — Детерминированный перехват на `deploy-staging` (без `_spawn`)
В `launch_job` (`src/agents/launcher.py`) **до** вызова `_spawn`, по образцу D1/D2: если
`job.agent == "deployer"` **и** стадия задачи (`tasks.stage` по `job.task_id`) == `deploy-staging`
**и** `staging_runner.applies(job.repo)` истинно → не вызывать `_spawn`, а исполнить раннер
синхронно. Контракт: возвращает `None` (нет `agent_runs`), сам ведёт `jobs`-строку
(`mark_job(done|failed|queued)`) как `_run_deploy_finalizer_job`.
- Дискриминатор «staging vs prod» — **стадия задачи**, не имя роли (роль `deployer` общая для
`deploy-staging` и `deploy`). Для self-hosting прод-ребро не запускает `deployer` (Phase A), поэтому
коллизии нет; гард по стадии — защита от перехвата не того джоба (R-1).
- `applies(repo)`: `staging_runner_enabled=False``False` (откат к LLM-пути); непустой
`staging_runner_repos` → membership; пустой CSV → `is_self_hosting_repo(repo)`. Никакой сети,
проверяется **первым** (нулевой оверхед при выключенном флаге). Never-raise → `False` при ошибке
(fail-safe к прежнему LLM-пути).
### FR-2 — Исполнение staging-сюиты
Раннер исполняет ту же канонную команду, что исполнял LLM-deployer
(`.openclaw/agents/deployer.md` step 1):
`docker exec orchestrator-staging python3 /repos/orchestrator/scripts/staging_check.py
--base-url http://localhost:8501 --mode stub` (точные аргументы/таргет — из config, не хардкодить
host-специфику; ORCH-101). Захватывает exit-код (и stdout для observability/тела лога). infra-tolerance
(ORCH-061) уже **внутри** `staging_check.py` → раннер вердикт повторно не судит (BR-4).
### FR-3 — Маппинг exit-кода → `staging_status:`
`0 → "SUCCESS"`, любой ненулевой / отсутствие кода / ошибка запуска → `"FAILED"` (fail-closed,
никогда ложный green). Зеркало уже существующего `self_deploy.map_exit_code_to_status` (pure,
unit-tested) — переиспользовать общий контракт, не плодить второй маппинг.
### FR-4 — Запись и merge `15-staging-log.md`
Раннер пишет `docs/work-items/<work_item_id>/15-staging-log.md` в worktree фичеветки с frontmatter:
`staging_status: SUCCESS|FAILED` + обязательная 52c-схема (`work_item`/`stage=deploy-staging`/
`author_agent`/`status`/`created_at`/`model_used`) — зеркало `self_deploy.build_deploy_log` для
`14-deploy-log.md`. `author_agent`/`model_used` отражают **детерминированный** продюсер (например
`author_agent: staging-runner`, `model_used: n/a` или платформенный литерал — финализирует архитектор;
ключи и имя `staging_status:` не меняются). При INFRA-WAIVED-строке от `staging_check.py` — скопировать
её в тело (observability, как требовал prompt). Best-effort `git add/commit/push` лога в `main`
(зеркало `self_deploy.write_deploy_log`, тот же git-identity-паттерн ORCH-101); гейт всё равно
читает worktree → origin/main fallback (`check_staging_status` lookup order, `src/qg/checks.py:627-638`).
### FR-5 — Инициация существующего гейта после вердикта
После записи (и best-effort merge) раннер инициирует ту же оценку exit-гейта, что триггерил
завершившийся LLM-deployer: `advance_stage(task_id, current_stage="deploy-staging", repo,
work_item_id, branch, finished_agent="deployer")` (через `_try_advance_stage`-эквивалент). Это
запускает `check_staging_status` и — на SUCCESS — под-гейты security→merge→coverage→image-freshness
(ORCH-022/043/027/058) и Phase A (ORCH-036); на FAILED — существующий rollback
(`src/stage_engine.py:932`). **Никакой новой ветви маршрутизации.** Lease ORCH-114 берётся внутри
`advance_stage` как сейчас — раннер его не трогает (граница задачи).
- Таймаут раннер-subprocess — выделенный ключ `staging_runner_timeout_s` с дефолтом, согласованным
со сквозным бюджетом ORCH-065/109/110 (NFR-4); малформ/непозитив → дефолт + WARNING (never-break).
### FR-6 — Kill-switch и скоуп (обратимость)
`staging_runner_enabled=False` → перехват не срабатывает → на `deploy-staging` запускается прежний
LLM-deployer (`_spawn`) **байт-в-байт** как до ORCH-115. `staging_runner_repos` ограничивает скоуп
(пусто → только `orchestrator`); не-self репо никогда не перехватываются (для них staging-гейт и так
N/A, `src/qg/checks.py:620`).
### FR-7 — Наблюдаемость
- Read-only блок `staging_runner` в `GET /queue`: `enabled`, `repos`, счётчики `success`/`failed`/
`tool_error`/`runs`.
- Один структурный лог-вердикт на прогон (`work_item`/`repo`/`exit_code`/`status`/`duration_s`),
различающий «код упал» (`FAILED` от staging-сюиты) и «инструмент недоступен» (tool-error).
## 4. Изменения API
- **`GET /queue`** — добавить read-only ключ `staging_runner` (наблюдаемость). Существующие поля
ответа не меняются.
- Опционально (на усмотрение архитектора, по образцу `POST /coverage/baseline`): нет обязательного
нового мутирующего эндпоинта. Откат — через env-флаг.
- Новых вебхуков нет.
## 5. Изменения схемы БД
**Нет.** Раннер использует существующие таблицы (`tasks` для стадии, `jobs` для статуса джоба) и
sentinel/worktree-механику. Никаких новых таблиц/колонок/миграций (NFR-1). Счётчики `/queue`
in-process (паттерн `_MERGE_GATE_COUNTERS`, ORCH-110), не БД.
## 6. Требования к новым/изменённым QG checks
**Нет новых QG и нет изменений существующих.** `check_staging_status` / `_parse_staging_status` /
ключ `staging_status:` (`src/qg/checks.py:538/599`) и состав `QG_CHECKS`**байт-в-байт неизменны**.
ORCH-115 меняет только *продюсера* `15-staging-log.md` (детерминированный код вместо LLM); гейт,
читающий артефакт, остаётся прежним. Это критический инвариант (NFR-1) — reviewer ловит любое
изменение имени/семантики гейта как finding ≥P1.
## 7. Совместимость / регресс
- **Обратная совместимость:** `staging_runner_enabled=False` → прежний LLM-deployer-путь
байт-в-байт; не-self репо → 1:1 (N/A-pass либо LLM, в зависимости от скоупа). enduro-trails не
затронут (NFR-5).
- **Kill-switch / область раската:** один флаг `staging_runner_enabled` + CSV `staging_runner_repos`
(пусто → self-hosting only). Откат = `ORCH_STAGING_RUNNER_ENABLED=false`.
- **Обратимость:** полностью обратимо флагом; артефакт и гейт неизменны, так что переключение туда-сюда
не оставляет несовместимого состояния.
- **never-raise / fail-safe (NFR-2):** ошибка раннера → `FAILED` (fail-closed) или штатный requeue,
не «тихий advance»; сбой не клинит очередь. Self-hosting safety (BR-7): никаких рестартов 8500 /
force-push в `main` / правок инфры.
- **Граница (О1):** код ORCH-112 (checkout hygiene) и ORCH-114 (transition lease) не модифицируется.
- **Норматив сопровождения (NFR-6):** в том же PR обновить `docs/architecture/llm-call-sites.md` /
`llm-determinization-roadmap.md` / `llm-usage-policy.md` + соответствующие анти-дрейф тесты;
`CLAUDE.md` / `CHANGELOG.md` / `docs/overview/`.
## 8. Phase 2 (forward-looking, вне приёмки ORCH-115)
Зафиксировано для преемственности — **не реализуется в этой задаче**, заводится отдельным follow-up:
- **Project deploy contract** для не-self репо (enduro-trails): декларативный per-repo контракт
`deploy` / `rollback` / `healthcheck` (команды + ожидаемые коды/эндпоинты), исполняемый тем же
детерминированным раннер-паттерном (run → map exit code → verdict → artifact → healthcheck).
- LLM остаётся допустим только как **off-control-path** debug/triage-аналитик после
детерминированного провала (NFR-7) — не как продюсер вердикта.
- Зависимость: устойчивый Phase 1 (этот work item) как доказанный паттерн перехвата + маппинга.

View File

@@ -0,0 +1,166 @@
---
work_item: ORCH-115
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-16
model_used: claude-opus-4-8
---
# 03 — Критерии приёмки (Acceptance Criteria): ORCH-115 — детерминированный staging-раннер
Work Item: **ORCH-115** · Repo: **orchestrator** · Стадия: analysis
Формат: каждый критерий имеет **PASS** (что должно быть истинно для приёмки) и **FAIL**
(что считается провалом). Любой машинный/ручной reviewer проверяет их буквально по файлам
репозитория.
---
## AC-1 — Детерминированный перехват на `deploy-staging` (нет `_spawn`/LLM)
**Условие:** При включённом флаге и in-scope репо джоб `deployer` на стадии `deploy-staging`
обрабатывается раннером, а не LLM-агентом.
- **PASS:** `launch_job` (`src/agents/launcher.py`) перехватывает джоб **до** `_spawn` (рядом с
D1/D2) при `agent=="deployer"` + стадия задачи `deploy-staging` + `staging_runner.applies(repo)`;
`_spawn` не вызывается; не создаётся строка `agent_runs`; джоб ведётся `mark_job(...)` самим
раннером. Тест воспроизводит это без живого Claude CLI.
- **FAIL:** На `deploy-staging` для in-scope репо при включённом флаге всё ещё вызывается `_spawn` /
создаётся `agent_runs`-строка LLM-deployer'а.
---
## AC-2 — Контракт артефакта `15-staging-log.md` неизменен
**Условие:** Раннер пишет тот же артефакт с тем же machine-key, что читает гейт.
- **PASS:** Создаётся `docs/work-items/<work_item_id>/15-staging-log.md` с frontmatter
`staging_status: SUCCESS|FAILED` (UPPERCASE) + обязательная 52c-схема
(`work_item`/`stage: deploy-staging`/`author_agent`/`status`/`created_at`/`model_used`).
`_parse_staging_status` читает его и возвращает корректный вердикт **без изменения** парсера.
- **FAIL:** Изменено имя/регистр ключа `staging_status:`, отсутствует frontmatter, либо вердикт
записан только прозой; либо парсер `_parse_staging_status` пришлось менять.
---
## AC-3 — Корректный exit-code → verdict маппинг
**Условие:** Exit-код staging-сюиты детерминированно маппится в вердикт.
- **PASS:** `0 → SUCCESS`; любой ненулевой / None / ошибка запуска → `FAILED` (fail-closed).
Маппинг — pure-функция, переиспользующая контракт `self_deploy.map_exit_code_to_status` (или
эквивалентный единый), покрыта unit-тестом на каждый класс входа. infra-tolerance (ORCH-061) не
пересуживается раннером.
- **FAIL:** Ненулевой код даёт `SUCCESS`; ошибка/None даёт `SUCCESS` (ложный green); раннер вводит
второй несогласованный маппинг.
---
## AC-4 — Эквивалентность маршрутизации (SUCCESS / FAILED)
**Условие:** После вердикта конвейер ведёт себя ровно как при завершившемся LLM-deployer'е.
- **PASS:** SUCCESS → раннер инициирует `advance_stage(finished_agent="deployer")`, далее
отрабатывают под-гейты security→merge→coverage→image-freshness (ORCH-022/043/027/058) и Phase A
(ORCH-036) — теми же путями. FAILED → существующий откат `deploy-staging → development` с
инкрементом developer-retry (`src/stage_engine.py:932`), тот же исход, что у FAILED-вердикта LLM.
- **FAIL:** Задача зависает на `deploy-staging` (гейт не инициирован); или FAILED не откатывает /
откатывает иначе; или появляется новое ребро/исход.
---
## AC-5 — Инвариант скоупа: гейты/стадии/схема БД не тронуты (анти-дрейф)
**Условие:** Изменена только сторона *продюсера*, не контракт конвейера.
- **PASS:** `git diff` не затрагивает `src/stages.py::STAGE_TRANSITIONS`; имена/семантику
`QG_CHECKS`/`check_*`/`_parse_*` в `src/qg/checks.py`; machine-verdict-ключи
(`staging_status:`/`deploy_status:`/`verdict:`/`result:`/`security_status:`/`coverage_status:`);
схему БД (нет новых таблиц/колонок/миграций). Анти-дрейф-тест это подтверждает.
- **FAIL:** Любой из перечисленных артефактов изменён по имени/семантике/структуре.
---
## AC-6 — Kill-switch и скоуп (обратимость)
**Условие:** Флаг возвращает прежнее поведение; скоуп ограничивает раннер.
- **PASS:** `staging_runner_enabled=False` → на `deploy-staging` запускается прежний LLM-deployer
через `_spawn` (байт-в-байт до ORCH-115). Пустой `staging_runner_repos` → раннер активен только для
`orchestrator`; не-self репо никогда не перехватываются. Покрыто тестом для обоих значений флага.
- **FAIL:** При выключенном флаге раннер всё равно перехватывает; либо не-self репо перехватывается.
---
## AC-7 — never-raise / fail-safe (инструмент недоступен)
**Условие:** Любая ошибка раннера приводит к безопасному детерминированному исходу.
- **PASS:** Недоступность docker/`orchestrator-staging`, таймаут, I/O-ошибка → раннер не роняет
воркер; исход — `FAILED` (fail-closed) **или** штатный requeue/defer, **никогда** тихий advance/
ложный green. Все публичные функции `staging_runner.py` — never-raise; `applies()` при ошибке → `False`.
- **FAIL:** Ошибка раннера роняет воркер/клинит очередь; либо ошибка/таймаут даёт `SUCCESS`.
---
## AC-8 — Self-hosting safety
**Условие:** Раннер на `deploy-staging` не выполняет опасных для прода действий.
- **PASS:** Раннер не рестартит контейнер 8500, не выполняет `docker compose up -d orchestrator`/
`--build`, не пушит force в `main`, не правит `.env`/`.env.staging`/`docker-compose.yml`. Merge
лога идёт штатным PR/artifact-merge-путём (как `self_deploy.write_deploy_log`). Подтверждается
ревью кода раннера + (где применимо) тестом, что в командах раннера нет запрещённых литералов.
- **FAIL:** Раннер содержит любой путь, рестартящий 8500 / force-push в `main` / правящий инфру.
---
## AC-9 — Изоляция процесса и таймаут
**Условие:** docker-subprocess ограничен по времени и не оставляет сирот.
- **PASS:** Раннер запускает staging-сюиту с ограниченным таймаутом
(`staging_runner_timeout_s`, согласован со сквозным бюджетом ORCH-065/109/110, не правя
`reaper_max_running_s`); малформ/непозитив таймаут → дефолт + WARNING; завершение чистое (без
осиротевших docker/pytest-процессов, согласовано с `proc_group`/tree-kill ORCH-110).
- **FAIL:** Нет таймаута / зависший subprocess клинит воркер; остаются сироты процессов.
---
## AC-10 — Наблюдаемость
**Условие:** Исход раннера виден и различим.
- **PASS:** `GET /queue` содержит read-only блок `staging_runner` (`enabled`/`repos`/счётчики
`success`/`failed`/`tool_error`/`runs`); на каждый прогон — один структурный лог-вердикт
(`work_item`/`repo`/`exit_code`/`status`/`duration_s`), различающий код-фейл и tool-error.
- **FAIL:** Нет блока в `/queue`; исход раннера не логируется/не различим.
---
## AC-11 — Норматив сопровождения LLM-карты/политики/витрины
**Условие:** Документация обновлена в том же PR (правило агентов №2 + норматив ORCH-118).
- **PASS:** `docs/architecture/llm-call-sites.md` (строка A6) / `llm-determinization-roadmap.md` /
`llm-usage-policy.md` отражают реализацию детерминированного deployer-staging; соответствующие
анти-дрейф-тесты (`tests/test_llm_call_site_inventory.py`, `tests/test_llm_determinization_docs.py`)
зелёные; `.openclaw/agents/deployer.md`, `CLAUDE.md`, `CHANGELOG.md`, `docs/overview/` обновлены.
- **FAIL:** Карта/политика/roadmap/витрина не обновлены; анти-дрейф-тесты красные (reviewer: ≥P1).
---
## AC-12 — Полный регресс зелёный
**Условие:** Существующий конвейер не сломан.
- **PASS:** `pytest tests/ -q` зелёный; новый `tests/test_orch115_staging_runner.py` зелёный;
staging-smoke (`scripts/staging_check.py`) на 8501 проходит штатно.
- **FAIL:** Любой ранее зелёный тест становится красным; новые тесты падают.
---
## Сводная матрица AC ↔ FR/BR
| AC | Покрывает |
|----|-----------|
| AC-1 | BR-1 / FR-1 |
| AC-2 | BR-2 / FR-4 |
| AC-3 | BR-4 / FR-2 / FR-3 |
| AC-4 | BR-3 / FR-5 |
| AC-5 | NFR-1 / FR-6 |
| AC-6 | BR-5 / FR-6 |
| AC-7 | NFR-2 / FR-1 |
| AC-8 | BR-7 |
| AC-9 | NFR-3 / NFR-4 / FR-5 |
| AC-10 | BR-6 / FR-7 |
| AC-11 | NFR-6 |
| AC-12 | NFR-5 / NFR-1 |

View File

@@ -0,0 +1,104 @@
work_item: ORCH-115
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-16
model_used: claude-opus-4-8
title: "Детерминированный staging-раннер вместо LLM-деплойера (deploy-staging)"
framework: pytest
scope: >
Покрывает Phase 1: перехват deployer-джоба на deploy-staging до _spawn, маппинг
exit-кода в staging_status:, запись/merge 15-staging-log.md, инициацию существующего
гейта check_staging_status, kill-switch/скоуп, never-raise/fail-safe, изоляцию
процесса/таймаут, наблюдаемость, и анти-дрейф инвариант (STAGE_TRANSITIONS/QG_CHECKS/
схема БД не тронуты). Вне покрытия: Phase 2 (project deploy contract для не-self репо),
прод-ребро deploy (ORCH-036), живой Claude CLI и живой staging-стенд (мокируются).
notes: >
Тесты не требуют живого Claude CLI, docker или сети: subprocess/docker-exec и
advance_stage мокируются; пьюр-маппинг тестируется напрямую. Полный регресс tests/
должен оставаться зелёным. Анти-дрейф (TC-09) защищает критический инвариант NFR-1.
tests:
- id: TC-01
type: unit
description: "applies(repo): enabled=False -> False (откат к LLM); пустой CSV -> True только для orchestrator; непустой CSV -> membership; not-self репо -> False; ошибка -> False (never-raise, fail-safe)."
module: tests/test_orch115_staging_runner.py
expected: PASS
- id: TC-02
type: unit
description: "Маппинг exit-кода: 0 -> SUCCESS; 1/2/любой ненулевой -> FAILED; None/нечисло/ошибка запуска -> FAILED (fail-closed). Согласован с self_deploy.map_exit_code_to_status."
module: tests/test_orch115_staging_runner.py
expected: PASS
- id: TC-03
type: unit
description: "Рендер 15-staging-log.md: frontmatter содержит staging_status: SUCCESS|FAILED (UPPERCASE) + 52c-схему (work_item/stage=deploy-staging/author_agent/status/created_at/model_used); INFRA-WAIVED строка из stdout копируется в тело."
module: tests/test_orch115_staging_runner.py
expected: PASS
- id: TC-04
type: integration
description: "Сгенерированный раннером 15-staging-log.md читается НЕИЗМЕНЁННЫМ _parse_staging_status -> корректный (bool, reason) для SUCCESS и FAILED (контракт артефакта/гейта неизменен)."
module: tests/test_orch115_staging_runner.py
expected: PASS
- id: TC-05
type: integration
description: "launch_job перехватывает deployer-джоб на стадии deploy-staging для in-scope репо ДО _spawn (как D1/D2): _spawn НЕ вызывается, agent_runs не создаётся, возвращается None, jobs-строка ведётся mark_job. _spawn мокирован."
module: tests/test_orch115_staging_runner.py
expected: PASS
- id: TC-06
type: integration
description: "Дискриминатор стадии: deployer-джоб на стадии deploy (не deploy-staging) НЕ перехватывается раннером (для self-hosting прод-ребро идёт через Phase A; для не-self остаётся прежний путь)."
module: tests/test_orch115_staging_runner.py
expected: PASS
- id: TC-07
type: integration
description: "После SUCCESS-вердикта раннер инициирует advance_stage(finished_agent='deployer') ровно как завершившийся LLM-deployer (advance_stage мокирован/наблюдается); после FAILED — тот же путь, что у FAILED LLM (откат deploy-staging -> development)."
module: tests/test_orch115_staging_runner.py
expected: PASS
- id: TC-08
type: integration
description: "Kill-switch: staging_runner_enabled=False -> на deploy-staging для orchestrator вызывается _spawn (прежний LLM-путь байт-в-байт), раннер не активируется."
module: tests/test_orch115_staging_runner.py
expected: PASS
- id: TC-09
type: unit
description: "Анти-дрейф NFR-1: STAGE_TRANSITIONS (src/stages.py) и реестр/имена QG_CHECKS + ключ staging_status: неизменны; в схеме БД нет новой таблицы/колонки от ORCH-115. Структурная проверка."
module: tests/test_orch115_staging_runner.py
expected: PASS
- id: TC-10
type: integration
description: "never-raise/fail-safe: docker exec бросает/таймаутит/возвращает ненулевой -> раннер не падает, исход FAILED (fail-closed) или штатный requeue, никогда тихий advance/ложный green; воркер/очередь не клинятся."
module: tests/test_orch115_staging_runner.py
expected: PASS
- id: TC-11
type: unit
description: "Таймаут: staging_runner_timeout_s применяется к subprocess; малформ/непозитив -> дефолт + WARNING (never-break); завершение чистое (tree-kill согласован с proc_group ORCH-110)."
module: tests/test_orch115_staging_runner.py
expected: PASS
- id: TC-12
type: unit
description: "Self-hosting safety: в командной строке раннера нет запрещённых литералов (рестарт 8500 / docker compose up orchestrator / --build / force-push main / правки .env)."
module: tests/test_orch115_staging_runner.py
expected: PASS
- id: TC-13
type: integration
description: "Наблюдаемость: GET /queue содержит блок staging_runner (enabled/repos/счётчики success/failed/tool_error/runs); на прогон пишется один структурный лог-вердикт, различающий код-фейл и tool-error."
module: tests/test_orch115_staging_runner.py
expected: PASS
- id: TC-14
type: integration
description: "Анти-дрейф LLM-карты: llm-call-sites.md (A6)/roadmap/policy обновлены под реализацию; tests/test_llm_call_site_inventory.py и tests/test_llm_determinization_docs.py остаются зелёными после правок."
module: tests/test_llm_call_site_inventory.py
expected: PASS

View File

@@ -0,0 +1,335 @@
---
work_item: ORCH-115
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-16
model_used: claude-opus-4-8
---
# ADR-001: Детерминированный staging-раннер вместо LLM-деплойера на `deploy-staging`
Work Item: **ORCH-115** — заменить LLM-агента `deployer` на стадии `deploy-staging`
(self-hosting `orchestrator`) детерминированным staging-раннером.
Стадия: **architecture**
Сквозная регистрация: **`docs/architecture/adr/adr-0048-deterministic-staging-runner.md`**
(решение кросс-каттинговое — вводит новый компонент-leaf и реализует первый срез
determinization-roadmap; влияет на карту LLM-консультаций всего оркестратора).
## Статус
Proposed
## Контекст
Стадию `deploy-staging` сейчас исполняет **LLM-агент `deployer`** (`src/stages.py`:
`get_agent_for_stage("testing") = "deployer"`; роль в `launcher.AGENTS`). Фактическая
работа агента на этой стадии — **чисто детерминированная** (`.openclaw/agents/deployer.md`
шаги 14):
1. запустить staging-сюиту — `docker exec orchestrator-staging python3
/repos/orchestrator/scripts/staging_check.py --base-url http://localhost:8501 --mode stub`;
2. смаппить **exit-код** в вердикт (`0 → SUCCESS`, ≠0 → `FAILED`) — infra-tolerance
ORCH-061 уже **внутри** `staging_check.py` (`src/staging_verdict.py::compute_staging_verdict`),
агент её не пересуживает;
3. записать `15-staging-log.md` с frontmatter `staging_status:`;
4. закоммитить лог.
Вердикт `staging_status:` потребляется детерминированным гейтом `check_staging_status`
(`src/qg/checks.py:599` → `_parse_staging_status:538`). По нормативной политике
(`docs/architecture/llm-usage-policy.md` §3) это **avoidable LLM control path**: (i) это
C-консультация (LLM-вердикт ветвит гейт) **и** (ii) вердикт **деривируем** из exit-кода
инструмента, который оркестратор уже умеет вычислять сам. Карта call-site'ов
(`docs/architecture/llm-call-sites.md`, строка **A6**) классифицирует deployer как
`replace-deterministic-now`; roadmap (`llm-determinization-roadmap.md`, rank 1,
`first_slice = yes`, `hybrid_needed = no`) ставит его первым срезом. ORCH-115 — реализация
этого среза.
**Установленные факты (сверено по коду, не изобретать):**
- Детерминированный прецедент замены агента уже работает: `launch_job` перехватывает
зарезервированные роли `deploy-finalizer` (D1, `src/agents/launcher.py:389`) и
`post-deploy-monitor` (D2, `:394`) **до `_spawn`** и исполняет их как no-LLM-джобы,
сами ведущие `jobs`-строку через `mark_job`.
- Эталонный поток «детерминированный джоб → вердикт → существующий контракт»:
`stage_engine.run_deploy_finalizer` (`:2010`) маппит exit-код, пишет `14-deploy-log.md`
и вызывает `advance_stage(..., finished_agent="deployer")`, после чего срабатывают
**существующие** контракты (`SUCCESS → done`/advance, `FAILED → откат на development`).
- Пьюр-маппинг exit-кода уже есть: `self_deploy.map_exit_code_to_status` (`:81`,
`0→SUCCESS`, иначе/None/нечисло→`FAILED`, fail-closed, unit-tested).
- Tree-kill subprocess'а под таймаутом уже есть: `proc_group.run_in_process_group`
(ORCH-110, stdlib-only leaf, never-raise, fallback к `subprocess.run`).
- Прод-ребро `deploy` для self-hosting уже детерминировано (`self_deploy` Phase A/B/C,
ORCH-036) — `deployer` там **не спавнится** (Phase A — `finished_agent is None`-путь).
Срез не трогает критический прод-путь.
«Как есть» не годится: каждый прогон тратит токены/время opus-агента (по `agent_runs`:
~40120k / 315 мин на прогон) ради действия в три shell-строки, встраивает недетерминизм
LLM в точку ветвления и принадлежит к RCA-классу «LLM принял решение, которое есть
исполнение фиксированных команд + маппинг результата» (ORCH-110/111/112/113/114/117).
## Решение
### Сводка
Ввести **новый leaf `src/staging_runner.py`** (never-raise, по образцу
`self_deploy.py`/`proc_group.py`/`staging_verdict.py`) и **перехват в `launch_job` до
`_spawn`** (рядом с D1/D2). Когда на стадии `deploy-staging` для in-scope репо к запуску
приходит джоб `deployer`, его обрабатывает раннер: исполняет ту же staging-сюиту через
`proc_group`, маппит exit-код в `staging_status:`, пишет `15-staging-log.md`, и вызывает
**существующий** `advance_stage(current_stage="deploy-staging", finished_agent="deployer")`
— ровно как завершившийся LLM-deployer. Контракт артефакта, гейт `check_staging_status`,
`STAGE_TRANSITIONS`, схема БД — **байт-в-байт неизменны** (это замена *продюсера*
артефакта, не гейта). Под kill-switch + скоуп-CSV; fail-safe к прежнему LLM-пути.
### D1 — Точка диспетчеризации: перехват в `launch_job` **до** `_spawn` (FR-1 / AC-1)
В `launcher.launch_job`, рядом с существующими врезками D1/D2, **до** `_spawn`:
```python
if job.get("agent") == "deployer" and staging_runner.should_intercept(job):
return self._run_staging_runner_job(job)
```
- **Дискриминатор «staging vs prod» — стадия задачи, НЕ имя роли** (роль `deployer` общая
для `deploy-staging` и `deploy`). `should_intercept(job)` истинно ⇔ `agent=="deployer"`
**И** `tasks.stage` (по `job["task_id"]`) `== "deploy-staging"` **И**
`staging_runner.applies(job["repo"])`. Гард по стадии — защита от перехвата не того джоба
(R-1): для self-hosting прод-ребро `deployer` не запускает; для не-self репо прод-`deploy`
запускает синхронный ssh-deployer — но там `applies==False`, поэтому не перехватывается
(NFR-5; TC-06).
- `should_intercept` — **never-raise**: любая ошибка (DB-lookup упал) → `False` → провал
в `_spawn` (fail-safe к прежнему LLM-пути).
- `_run_staging_runner_job(job)` — тонкая обёртка-зеркало `_run_deploy_finalizer_job`
(`:404`): синхронно зовёт `staging_runner.run_staging_gate(job)`, затем
`mark_job(job["id"], "done")`; любое исключение → `mark_job(..., "failed", error=…)`;
возвращает `None` (нет `agent_runs`-строки, `_spawn` не вызывается).
### D2 — Размещение логики: чистый leaf `src/staging_runner.py` (зеркало finalizer)
`run_staging_gate(job)` живёт в leaf'е и владеет полным детерминированным потоком
(зеркало `stage_engine.run_deploy_finalizer`):
1. поднять `work_item_id`/`branch`/`stage` по `task_id`;
2. исполнить staging-сюиту (D3) → `(returncode, timed_out, stdout)`;
3. определить исход (D5);
4. на verdict-исходе: записать `15-staging-log.md` (D6) и вызвать
`advance_stage(finished_agent="deployer")` (D7);
5. на defer-исходе: requeue (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` на импорте и не плодить цикл (паттерн
`transition_lease`/`merge_gate`). Все публичные функции — **never-raise** (AC-7).
### D3 — Исполнение сюиты: `proc_group` + config-арги (FR-2 / NFR-3 / AC-9 / AC-8)
Команда строится из конфига (ORCH-101, без host-хардкодов — анти-дрейф
`tests/test_no_host_hardcodes.py`):
```
docker exec <STAGING_SERVICE> python3 <repos_dir>/<SELF_HOSTING_REPO>/scripts/staging_check.py \
--base-url http://localhost:<staging_port> --mode stub
```
где `STAGING_SERVICE = "orchestrator-staging"` (платформенный сервис-литерал, уже допущен —
ср. `image_freshness._STAGING_SERVICE:68`), `repos_dir`/`staging_port` из `settings`,
`SELF_HOSTING_REPO` из `qg.checks`. Запуск — через
`proc_group.run_in_process_group(argv, cwd=None, timeout=<staging_runner_timeout_s>,
tree_kill=settings.subprocess_tree_kill_enabled)` → SIGTERM→grace→SIGKILL всего дерева на
таймауте, без сирот docker/pytest (согласовано с ORCH-110). **Self-hosting safety
(BR-7/AC-8):** в argv нет литералов рестарта 8500 / `docker compose up … orchestrator` /
`--build` / force-push / правок `.env` — раннер только читает и исполняет staging-сюиту
(8501) и пишет лог. Покрывается TC-12 (запрет литералов).
### D4 — Маппинг exit-кода: переиспользовать единый контракт (FR-3 / AC-3)
`staging_status` = `self_deploy.map_exit_code_to_status(returncode)` (`0→SUCCESS`, иначе/
None/нечисло→`FAILED`, fail-closed). **Второй маппинг не вводится** — общий пьюр-контракт,
уже покрытый unit-тестом. infra-tolerance ORCH-061 остаётся внутри `staging_check.py` —
раннер вердикт повторно не судит (BR-4).
### D5 — Двухуровневый исход: verdict vs tool-error (NFR-2 / AC-7 / R-3 / R-5) — **ключевое решение**
`AC-7`/`NFR-2`/A1 явно оставляют обработку «инструмент недоступен» архитектору, разрешая
**FAILED (fail-closed) ИЛИ штатный requeue/defer**. Выбран **двухуровневый исход**:
- **Сюита ИСПОЛНИЛАСЬ (получен реальный exit-код, 0 или ≠0):** доверяем коду.
`0 → SUCCESS → advance`; `≠0 → FAILED → откат deploy-staging → development` через
`advance_stage` (тот же путь и developer-retry-cap, что у FAILED-вердикта LLM —
`stage_engine.py:932`; R-5/AC-4). ORCH-061 уже внутри exit-кода.
- **Сюита НЕ исполнилась (tool-error: spawn-error / таймаут / `returncode is None`):**
это **инфра-сбой, а не код-фейл**. Раннер делает **ограниченный DEFER** — requeue
свежего `deployer`-джоба с задержкой и маркером в `task_content` (restart-safe счётчик
подсчётом маркера — зеркало `merge_gate._merge_infra_retry_count` ORCH-110 и
`_deploy_finalize_defer_count` ORCH-036; **без правки схемы БД**, NFR-1). На
**исчерпании** бюджета (`staging_runner_infra_max_retries`) — **fail-closed**: записать
`staging_status: FAILED` + `advance_stage` (откат) + alert (кликабельный номер). Так
раннер **никогда не делает тихий advance / ложный green** и **никогда не клинит очередь
навсегда**, но **не жжёт developer-retry на транзиентной инфре**.
**Почему не «tool-error → немедленный FAILED-откат»:** это в точности анти-паттерн ORCH-110
(инфра-таймаут, ошибочно маршрутизированный как код-фейл → откат на `development` + расход
developer-retry; на следующем retry падает так же → ручное вмешательство). RCA-осведомлённый
reviewer ловит это как регресс. Пьюр-маппинг D4 остаётся fail-closed (None→FAILED) — он
терминальный fallback на исчерпании defer, а не реакция на первый же tool-error.
### D6 — Артефакт `15-staging-log.md`: зеркало `write_deploy_log` (FR-4 / AC-2 / AC-8)
Раннер пишет `docs/work-items/<work_item_id>/15-staging-log.md` в worktree фичеветки
(`git_worktree.get_worktree_path`) с frontmatter:
```markdown
---
staging_status: SUCCESS # или FAILED — UPPERCASE, имя/регистр ключа не меняются
work_item: <work_item_id>
stage: deploy-staging
author_agent: staging-runner
status: success # или failed — выровнен по staging_status
created_at: <YYYY-MM-DD>
model_used: n/a
timestamp: <ISO>
base_url: http://localhost:<staging_port>
exit_code: <returncode>
---
# Staging Gate Log (deterministic runner, ORCH-115)
<exit-код, краткий хвост stdout; строку `INFRA-WAIVED:` из stdout скопировать в тело для observability>
```
- `author_agent: staging-runner` / `model_used: n/a` честно отражают **детерминированного**
продюсера; **machine-key `staging_status:` и его регистр/значения не меняются** (AC-2/AC-5),
читается неизменённым `_parse_staging_status` (TC-04).
- Запись через `frontmatter.render/write_frontmatter` либо литералом — обязательная 52c-схема
присутствует.
- **Best-effort commit+push в ФИЧЕВЕТКУ** (зеркало `write_deploy_log` — git-identity
ORCH-101, `_GIT_TIMEOUT`). Гейт читает worktree **первым** (`check_staging_status` lookup
order, `:627`), поэтому отдельный PR-merge лога в `main` **не выполняется** — это
сознательное упрощение относительно шага-4 LLM-deployer'а: исключает любую прямую работу с
`main` (усиливает AC-8/BR-7). Итоговый мерж фичеветки в `main` идёт штатным
merge-gate/merge-verify-путём позже.
### D7 — Инициация существующего гейта (FR-5 / AC-4 / R-2 / R-4, граница O1)
После записи лога раннер вызывает:
```python
advance_stage(task_id, current_stage="deploy-staging", repo, work_item_id, branch,
finished_agent="deployer")
```
Это запускает `check_staging_status` и — на SUCCESS — существующие под-гейты
security→merge→coverage→image-freshness (ORCH-022/043/027/058) и Phase A (ORCH-036); на
FAILED — существующий откат (`stage_engine.py:932`). **Никакой новой ветви маршрутизации,
никаких новых рёбер/исходов** (AC-4). **Граница O1/R-4:** transition-lease ORCH-114
берётся **внутри** `advance_stage` на side-effectful ребре — раннер его **не трогает**;
serial-gate ORCH-088 не взаимодействует (гейтит analyst-job claim, не deployer-job).
Код ORCH-112/ORCH-114 не модифицируется.
### D8 — Kill-switch и скоуп: обратимость (FR-6 / AC-6 / BR-5)
`config.py` (паттерн `coverage_gate_*`/`self_deploy_*`):
- `staging_runner_enabled: bool = True` (env `ORCH_STAGING_RUNNER_ENABLED`).
- `staging_runner_repos: str = ""` (env `ORCH_STAGING_RUNNER_REPOS`; CSV; **пусто →
self-hosting only** через `is_self_hosting_repo`).
- `staging_runner_timeout_s: int = 600` (env `ORCH_STAGING_RUNNER_TIMEOUT_S`; см. D9).
- `staging_runner_infra_max_retries: int = 2`, `staging_runner_infra_retry_delay_s: int = 30`
(defer-бюджет D5; зеркало `merge_retest_infra_*`).
`applies(repo)` (локально, без сети, **never-raise → False**): `staging_runner_enabled==False`
→ `False` (откат к LLM-пути); непустой CSV → membership; пустой → `is_self_hosting_repo(repo)`.
Проверяется **первым** в `should_intercept` → при выключенном флаге нулевой оверхед, на
`deploy-staging` штатно вызывается `_spawn` (прежний LLM-deployer **байт-в-байт**). Откат =
`ORCH_STAGING_RUNNER_ENABLED=false`.
### D9 — Бюджет времени (NFR-4 / AC-9, сквозной инвариант ORCH-065/109/110)
`staging_runner_timeout_s = 600` (дефолт; малформ/непозитив → дефолт + WARNING, never-break
— зеркало `_resolve_retest_timeout`). Обоснование инварианта **без правки
`reaper_max_running_s` (5400)**: окно «running» одного deployer-джоба = `staging_check`
(≤600s) + те же edge-под-гейты, что и раньше (security + merge re-test 900 + coverage 900 +
image-freshness rebuild). В LLM-пути это окно включало staging-прогон LLM (315 мин, до ~900s)
+ те же под-гейты. Раннер заменяет до-900s LLM-окна ограниченными ≤600s → **Σ(работ на ребре)
не растёт** → инвариант `reaper_max_running_s > Σ + grace` сохранён без изменения reaper'а.
### D10 — Наблюдаемость (FR-7 / AC-10 / BR-6)
In-process счётчики `_STAGING_RUNNER_COUNTERS` (зеркало `_MERGE_GATE_COUNTERS`,
`merge_gate.py:678`): `runs`/`success`/`failed`/`tool_error`/`deferred`. Read-only блок
`staging_runner` в `GET /queue` (`enabled`/`repos`/счётчики) — `src/main.py`. Один
структурный лог-вердикт на прогон: `work_item`/`repo`/`exit_code`/`status`/`duration_s`/
`outcome` — различает «код упал» (`FAILED` от сюиты) и «инструмент недоступен» (`tool_error`/
`deferred`). (Опционально, как ORCH-110: на исчерпании defer записать lesson `transient_retry`
через `lessons.record` — не обязательно для приёмки.)
### D11 — Норматив сопровождения LLM-карты (NFR-6 / AC-11) — спека для developer
Карта/политика/roadmap и их анти-дрейф-тесты **связаны с состоянием кода**, поэтому правятся
**в development-стадии атомарно с кодом** (иначе тесты покраснеют на полу-готовой ветке).
Архитектура фиксирует **точную спеку** правок (developer применяет в том же PR):
- `docs/architecture/llm-call-sites.md` — строка **A6** и машинный
`ORCH-118-INVENTORY-BLOCK`: deployer на `deploy-staging` для in-scope репо больше не
консультирует LLM; отразить реализованное детерминированное состояние (раннер-перехват до
`_spawn`, как D1/D2), сохранив парсимый заголовок таблицы; синхронно — `_parse_staging_status`
остаётся потребителем (имя гейта не меняется).
- `docs/architecture/llm-determinization-roadmap.md` — машинный `ORCH-118-ROADMAP-BLOCK`:
rank 1 (deployer) переходит из «план» в «реализовано»; **инвариант «ровно один
`first_slice = yes`»** держать корректным (см. `test_llm_determinization_docs.py`).
- `docs/architecture/llm-usage-policy.md` — §5: единственный транспорт LLM-консультации
(`_spawn`/S0) не нарушен; ввод второго транспорта запрещён (раннер LLM не зовёт).
- Анти-дрейф `tests/test_llm_call_site_inventory.py` / `tests/test_llm_determinization_docs.py`
— обновить ожидания синхронно, держать зелёными (TC-14).
- Прочие docs того же PR (правило агентов №2): `.openclaw/agents/deployer.md` (пометка, что
на `deploy-staging` для in-scope репо стадию ведёт детерминированный код; LLM-ветвь —
fallback под выключенным флагом / не-self репо), `CLAUDE.md`, `CHANGELOG.md`,
`docs/overview/`.
**Обоснование против `llm-usage-policy.md` §5:** ORCH-115 **снимает** C-консультацию с
деривируемым вердиктом (A6/staging-status) — это разрешённое `replace-deterministic-now`,
не ввод новой LLM-консультации; политика соблюдена.
## Альтернативы
- **Новая стадия / новый QG для детерминированного staging** — отвергнуто: нарушает NFR-1
(`STAGE_TRANSITIONS`/`QG_CHECKS` байт-в-байт). Меняется только *продюсер* артефакта; гейт
и ребро прежние.
- **tool-error → немедленный `FAILED`-откат на `development`** — отвергнуто: анти-паттерн
ORCH-110 (инфра-сбой как код-фейл → расход developer-retry → петля). Выбран двухуровневый
исход D5 (defer на tool-error, fail-closed на исчерпании).
- **Merge лога отдельным PR в `main`** (как шаг-4 LLM-deployer'а) — отвергнуто: гейт читает
worktree первым → достаточно push в фичеветку; исключение прямой работы с `main` усиливает
AC-8/BR-7.
- **Логика раннера прямо в `launcher.py`** — отвергнуто: нарушает разделение
транспорт/решение; leaf тестируем без живого CLI/docker (зеркало
`self_deploy`/`coverage_gate`).
- **Править `llm-call-sites.md`/roadmap/policy в architecture-стадии** — отвергнуто:
анти-дрейф-тесты коуплены к коду; правки идут атомарно с кодом (D11).
- **Свой второй exit-code→verdict маппинг** — отвергнуто: переиспользуем
`self_deploy.map_exit_code_to_status` (BR-4, единый контракт).
## Последствия
- **+** На `deploy-staging` для `orchestrator` исчезает LLM-консультация: дешевле/быстрее/
детерминированнее; минус один avoidable LLM control path (первый срез roadmap).
- **+** Happy-path не вызывает `_spawn` (нет `agent_runs`-строки, нет токенов).
- **+** Полная обратимость одним флагом; артефакт/гейт/ребро/схема БД неизменны → переключение
туда-сюда не оставляет несовместимого состояния.
- **+** Двухуровневый исход (D5) убирает класс ORCH-110 (инфра ≠ код-фейл) с staging-ребра.
- **** Новый leaf + врезка в `launch_job` + defer-механика — рост поверхности кода.
Митигейшн: leaf never-raise + kill-switch (fail-safe к LLM), тонкая врезка-зеркало D1/D2,
defer-счётчик без схемы БД (маркер в `task_content`), покрытие `tests/test_orch115_*`.
- **** Раннер программно зависит от доступности `docker exec` в `orchestrator-staging` и от
поднятого staging-контейнера (раньше это делал LLM). Митигейшн: D5 (defer + fail-closed),
предусловие фиксировано в `07-infra-requirements.md`.
- **Откат:** `ORCH_STAGING_RUNNER_ENABLED=false` → `applies()` → `False` → `should_intercept`
→ `False` → штатный `_spawn` LLM-deployer'а на `deploy-staging` **байт-в-байт** до ORCH-115.
## Ссылки
- BRD: `docs/work-items/ORCH-115/01-brd.md`
- TRZ: `docs/work-items/ORCH-115/02-trz.md`
- Acceptance: `docs/work-items/ORCH-115/03-acceptance-criteria.md`
- Инфра: `docs/work-items/ORCH-115/07-infra-requirements.md`;
Данные: `08-data-requirements.md`; Риски: `10-tech-risks.md`
- Сквозной ADR: `docs/architecture/adr/adr-0048-deterministic-staging-runner.md`
- Сверено по коду: `src/agents/launcher.py:389/404/1189`, `src/stage_engine.py:191/932/2010`,
`src/self_deploy.py:81/317`, `src/qg/checks.py:525/538/599`, `src/proc_group.py`,
`src/staging_verdict.py`, `src/merge_gate.py:678`, `src/config.py` (`coverage_gate_*`/
`reaper_max_running_s`)
- Политика/карта/roadmap: `docs/architecture/llm-usage-policy.md`,
`docs/architecture/llm-call-sites.md` (A6), `docs/architecture/llm-determinization-roadmap.md`

View File

@@ -0,0 +1,61 @@
---
work_item: ORCH-115
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-16
model_used: claude-opus-4-8
---
# 07 — Инфра-требования: ORCH-115 — детерминированный staging-раннер
Work Item: **ORCH-115** · Repo: **orchestrator** · Стадия: architecture
> When-applicable. Топология **не меняется** (новых контейнеров/портов/томов/compose-правок
> нет). Файл фиксирует **предусловия**, на которые раннер теперь опирается **программно**
> (раньше эти же шаги исполнял LLM-deployer), чтобы предусловие было аудитопригодно (BR-7/
> AC-8/AC-9).
## I-1. Топология / окружения
**Без изменений топологии.** Раннер исполняется в прод-контейнере `orchestrator` (8500), в
worker-треде, как `_run_deploy_finalizer_job`. Предусловия (существующие, не вводятся
ORCH-115):
- staging-контейнер `orchestrator-staging` (8501) поднят и доступен на хосте (Допущение А1);
- прод-контейнер имеет возможность `docker exec` в `orchestrator-staging` (та же возможность
использовал LLM-deployer — `.openclaw/agents/deployer.md` шаг 1; уже в проде);
- `scripts/staging_check.py` доступен внутри `orchestrator-staging` по bind-mount
(`/repos/orchestrator/scripts/…`, паттерн B6/ORCH-048) — источник истины набора проверок и
exit-кода (Допущение А2), ORCH-115 его не меняет.
Недоступность любого из предусловий обрабатывается **детерминированно** (раннер D5: bounded
defer → fail-closed `FAILED` + alert на исчерпании) — никогда тихий advance / ложный green.
## I-2. Переменные окружения / секреты
**Новые env-ключи (config, дефолты = боевое; добавить в `.env.example`):**
- `ORCH_STAGING_RUNNER_ENABLED` (`staging_runner_enabled`, дефолт `True`) — kill-switch;
`false` → прежний LLM-deployer-путь байт-в-байт.
- `ORCH_STAGING_RUNNER_REPOS` (`staging_runner_repos`, дефолт `""`) — CSV скоупа; пусто →
self-hosting only (`orchestrator`).
- `ORCH_STAGING_RUNNER_TIMEOUT_S` (`staging_runner_timeout_s`, дефолт `600`) — таймаут
subprocess'а; малформ/непозитив → дефолт + WARNING.
- `ORCH_STAGING_RUNNER_INFRA_MAX_RETRIES` (`staging_runner_infra_max_retries`, дефолт `2`) и
`ORCH_STAGING_RUNNER_INFRA_RETRY_DELAY_S` (`staging_runner_infra_retry_delay_s`, дефолт `30`)
— бюджет defer на tool-error (D5).
**Секретов не вводит.** Команда строится из существующих host-параметров (`repos_dir`/
`staging_port`/`SELF_HOSTING_REPO`/сервис-литерал `orchestrator-staging`) — без новых
host-хардкодов (анти-дрейф `tests/test_no_host_hardcodes.py`).
## I-3. Деплой / рестарт
**Self-hosting инвариант соблюдён.** Раннер на `deploy-staging` **никогда** не рестартит
прод-контейнер 8500, не выполняет `docker compose up -d orchestrator`/`--build`, не пушит
force в `main`, не правит `.env`/`.env.staging`/`docker-compose.yml` (BR-7/AC-8; TC-12 —
запрет литералов в argv). Прод-выкат самого ORCH-115 идёт штатно через staging-гейт (8501)
`Confirm Deploy` (ORCH-059). Изменение **docs/prompts/код+config**, активируется на
следующем worktree от `main`; включение в проде — флагом (по умолчанию `True`, обратимо).
## I-4. CI/CD
**Без изменений `.gitea/workflows/`.** Новый `tests/test_orch115_staging_runner.py` исполняется
существующим `pytest tests/ -q` (мокирует subprocess/docker-exec и `advance_stage`; живой
Claude CLI / docker / сеть не требуются). Staging-smoke (`scripts/staging_check.py` на 8501)
— штатный гейт, не меняется.

View File

@@ -0,0 +1,43 @@
---
work_item: ORCH-115
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-16
model_used: claude-opus-4-8
---
# 08 — Требования к данным: ORCH-115 — детерминированный staging-раннер
Work Item: **ORCH-115** · Repo: **orchestrator** · Стадия: architecture
> When-applicable. Создан **намеренно** при `N/A`-результате: «нет изменений схемы БД» — это
> критический инвариант NFR-1/AC-5, поэтому решение фиксируется явно и аудитопригодно.
## D-1. Схема БД
**Изменений нет.** Ни новых таблиц, ни колонок, ни миграций. Раннер использует существующие
таблицы read/write через существующие хелперы:
- `tasks` — чтение `stage`/`work_item_id`/`branch` по `task_id` (дискриминатор стадии D1,
поля артефакта/гейта); запись стадии — **только внутри `advance_stage`** (его существующий
CAS/lease-контракт ORCH-114, раннер схему не трогает).
- `jobs` — статус джоба ведётся `mark_job(done|failed)` самим раннером (зеркало
`_run_deploy_finalizer_job`); requeue defer — `enqueue_job` свежего `deployer`-джоба
(существующий механизм).
## D-2. Restart-safe состояние без схемы (defer-счётчик D5)
Счётчик infra-defer (ограничение петли tool-error, D5) — **без колонки БД**: подсчёт
маркера-подстроки в `task_content` нового `deployer`-джоба (зеркало
`merge_gate._merge_infra_retry_count` ORCH-110 и `_deploy_finalize_defer_count` ORCH-036).
Сохраняет restart-safe семантику без миграции (NFR-1).
## D-3. Артефакт `15-staging-log.md` (контракт неизменен)
Формат/расположение/machine-key прежние: `docs/work-items/<work_item_id>/15-staging-log.md`,
frontmatter `staging_status: SUCCESS|FAILED` (UPPERCASE) + обязательная 52c-схема. Меняется
лишь *продюсер* (детерминированный код вместо LLM): `author_agent: staging-runner`,
`model_used: n/a`**имя и регистр ключа `staging_status:` не меняются**; читается
неизменённым `_parse_staging_status` (AC-2/AC-5; TC-04).
## D-4. Наблюдаемость — in-process, не БД
Счётчики `staging_runner` (`runs`/`success`/`failed`/`tool_error`/`deferred`) — in-process
(`_STAGING_RUNNER_COUNTERS`, паттерн `_MERGE_GATE_COUNTERS` ORCH-110), отдаются read-only в
`GET /queue`. В БД не персистятся.

View File

@@ -0,0 +1,44 @@
---
work_item: ORCH-115
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-16
model_used: claude-opus-4-8
---
# 10 — Технические риски: ORCH-115 — детерминированный staging-раннер
Work Item: **ORCH-115** · Repo: **orchestrator** · Стадия: architecture
> Информационный (гейтом не парсится). Риски реализации + их митигейшн; нумерация
> наследует R-1…R-5 из BRD §8 и добавляет производные.
## Реестр рисков
| ID | Риск | Вер. | Влия. | Митигейшн |
|----|------|------|-------|-----------|
| TR-1 (R-1) | Перехват «до `_spawn`» перехватит **не тот** джоб (прод-deployer вместо staging) | Низ. | Выс. | Дискриминатор = **стадия задачи** (`tasks.stage=="deploy-staging"`) **И** `applies(repo)`, не только имя роли (ADR D1). Для self-hosting прод-ребро `deployer` не спавнит; для не-self `applies==False`. Покрытие TC-05/TC-06. `should_intercept` never-raise → `False` → штатный `_spawn`. |
| TR-2 (R-2) | После вердикта не инициирован `advance_stage` → задача зависает на `deploy-staging` (нет «финиша агента») | Низ. | Выс. | Раннер сам вызывает `advance_stage(current_stage="deploy-staging", finished_agent="deployer")` (зеркало `run_deploy_finalizer`, ADR D7). На исключении джоб → `mark_job(failed)` → reaper/worker по существующим контрактам; никогда тихий advance. Покрытие TC-07. |
| TR-3 (R-3) | Таймаут/изоляция docker-subprocess; утечка процессов (инцидент ORCH-110) | Сред. | Сред. | `proc_group.run_in_process_group` (tree-kill SIGTERM→grace→SIGKILL всего дерева), `staging_runner_timeout_s`; малформ/непозитив → дефолт + WARNING. Покрытие TC-11; запрет сирот согласован с ORCH-110. |
| TR-4 (R-4) | Конфликт с transition-lease (ORCH-114) / serial-gate (ORCH-088) на side-effectful ребре | Низ. | Выс. | Граница O1: lease берётся **внутри** `advance_stage` — раннер его не трогает (ADR D7). serial-gate гейтит analyst-job claim, не deployer-job. Код ORCH-112/114 не модифицируется. |
| TR-5 (R-5) | Откат FAILED (developer-retry cap) расходится с LLM-путём | Низ. | Сред. | Реальный `≠0` exit → тот же `advance_stage`-откат (`stage_engine.py:932`, тот же cap `MAX_DEVELOPER_RETRIES`), что у FAILED-вердикта LLM. Покрытие TC-07. |
| TR-6 | **Инфра-сбой как код-фейл** (docker down / staging недоступен / таймаут → откат + расход developer-retry → петля) — анти-паттерн ORCH-110 | Сред. | Выс. | **Двухуровневый исход D5:** сюита исполнилась → verdict; tool-error/таймаут → bounded defer (`staging_runner_infra_max_retries`) → fail-closed `FAILED` + alert на исчерпании. Не жжёт developer-retry на инфре. Покрытие TC-10. |
| TR-7 | Скоуп-дрейф: случайная правка `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-key/схемы БД | Низ. | Выс. | NFR-1 — замена только *продюсера*. Анти-дрейф структурный тест TC-09; reviewer ловит ≥P1. Раннер пишет тот же `staging_status:` key, читается неизменённым `_parse_staging_status`. |
| TR-8 | Сквозной бюджет времени (`reaper_max_running_s`) превышен суммой работ на ребре | Низ. | Сред. | Таймаут 600s ≤ прежнего LLM-окна (315 мин) → Σ(работ на ребре) не растёт → инвариант ORCH-065/109/110 сохранён **без** правки `reaper_max_running_s` (ADR D9). |
| TR-9 | Рассинхрон карты LLM (`llm-call-sites.md`/roadmap/policy) с реализацией → красные анти-дрейф-тесты | Сред. | Сред. | Норматив сопровождения NFR-6 (ADR D11): точная спека правок + `test_llm_call_site_inventory.py`/`test_llm_determinization_docs.py` обновляются **атомарно с кодом** в development. Инвариант «ровно один `first_slice`» держать корректным. Покрытие TC-14. |
| TR-10 | Self-hosting safety: раннер случайно рестартит 8500 / force-push в `main` / правит инфру | Низ. | Выс. | BR-7/AC-8: argv не содержит запрещённых литералов (TC-12); лог пишется в worktree + push в **фичеветку** (не main PR-merge, ADR D6); только чтение + staging-сюита (8501). |
## Сводный вывод
Доминирующий класс — **корректность диспетчеризации и обработки инфра-сбоев** (TR-1/TR-2/TR-6)
на side-effectful ребре общего прод-инстанса (self-hosting). Все сняты опорой на **доказанные
прецеденты** (D1/D2 перехват, `run_deploy_finalizer`, `proc_group`, transition-lease внутри
`advance_stage`) и **двухуровневым исходом D5**, явно адресующим RCA-класс ORCH-110.
Изменение **аддитивное, never-raise, под kill-switch с байт-в-байт откатом**; гейт/ребро/
схема БД неизменны. Остаточный риск для прод-конвейера — **низкий**.
Эскалация **не требуется**: это не новая стадия/QG/смена БД (лейбл `arch:major-change` не
ставится), ТЗ удовлетворяется без нарушения принципов (возврат в анализ не нужен). Единственное
**инфра-предусловие** для включения в проде — доступность `docker exec` в `orchestrator-staging`
(уже выполнено LLM-путём); до включения флага поведение байт-в-байт прежнее.

View File

@@ -0,0 +1,134 @@
---
verdict: APPROVED
work_item: ORCH-115
stage: review
author_agent: reviewer
status: approved
created_at: 2026-06-16
model_used: claude-opus-4-8
type: review
work_item_id: ORCH-115
version: 1
---
# Review ORCH-115 — детерминированный staging-раннер вместо LLM-деплойера
## Summary
Замена LLM-агента `deployer` на стадии `deploy-staging` (self-hosting `orchestrator`)
детерминированным leaf `src/staging_runner.py`, перехватываемым в `launch_job` **до** `_spawn`
(прецедент `deploy-finalizer`/`post-deploy-monitor`). Реализация строго соблюдает корневой
инвариант NFR-1 — это замена **продюсера** артефакта `15-staging-log.md`, а **не** гейта:
`STAGE_TRANSITIONS`, `QG_CHECKS`, `check_staging_status`/`_parse_staging_status`, machine-key
`staging_status:` и схема БД — байт-в-байт не тронуты. Все FR-1…FR-7 реализованы, все
AC-1…AC-12 выполнены, документация обновлена в том же PR на всех требуемых поверхностях,
полный регресс зелёный (**2105 passed**), новый сьют (24 теста) зелёный, анти-дрейф LLM-карты
зелёный.
**Вердикт: APPROVED** — P0/P1 findings отсутствуют.
## Проверка по осям
### 1. Соответствие ТЗ (`02-trz.md`) / критериям (`03-acceptance-criteria.md`)
- **FR-1 / AC-1** — перехват в `launch_job` до `_spawn` при `agent=="deployer"` +
`should_intercept` (стадия `deploy-staging` + `applies`); возвращает `None`, `_spawn` не
вызывается, `agent_runs` не создаётся, `jobs`-строка ведётся `mark_job` через
`_run_staging_runner_job` (зеркало `_run_deploy_finalizer_job`). ✅ (TC-05)
- **FR-2 / AC-9 / AC-8** — та же каноническая staging-сюита через `proc_group.run_in_process_group`
(tree-kill, таймаут). Команда из config (без host-хардкодов). ✅ (TC-11/TC-12)
- **FR-3 / AC-3** — exit-код → `staging_status:` через **единый** контракт
`self_deploy.map_exit_code_to_status` (`0→SUCCESS`, иначе/None/нечисло→`FAILED`), второй маппинг
не введён. ✅ (TC-02)
- **FR-4 / AC-2** — `15-staging-log.md` с `staging_status: SUCCESS|FAILED` (UPPERCASE) + полной
52c-схемой (`work_item`/`stage: deploy-staging`/`author_agent: staging-runner`/`status`/
`created_at`/`model_used: n/a`); INFRA-WAIVED копируется в тело; best-effort commit/push в
**фичеветку** (не в `main`), гейт читает worktree первым (`check_staging_status` lookup order
подтверждён в `src/qg/checks.py:627`). ✅ (TC-03/TC-04)
- **FR-5 / AC-4** — после вердикта вызывается существующий
`advance_stage(current_stage="deploy-staging", finished_agent="deployer")` — параметры в точности
соответствуют сигнатуре `stage_engine.advance_stage:191` и зеркалят `run_deploy_finalizer:2092`;
SUCCESS → под-гейты + Phase A; FAILED → существующий откат `deploy-staging → development`
(`stage_engine.py:932`, ветка `agent=="deployer" and qg=="check_staging_status"`). Новой ветви
маршрутизации нет. ✅ (TC-07/TC-10)
- **FR-6 / AC-6** — kill-switch `staging_runner_enabled` + скоуп `staging_runner_repos` (пусто →
self-hosting only через `is_self_hosting_repo`); `applies` проверяется первым, при выключенном
флаге `_spawn` LLM-deployer'а байт-в-байт. ✅ (TC-01/TC-08)
- **FR-7 / AC-10** — read-only блок `staging_runner` в `GET /queue` + один структурный лог-вердикт
на прогон, различающий `code-pass`/`code-fail`/`tool-error`. ✅ (TC-13)
- **AC-7 (never-raise)** — все публичные функции изолированы; tool-error (spawn-error/таймаут/
`returncode None`) даёт bounded DEFER, затем fail-closed FAILED; никогда тихий advance/ложный
green. ✅ (TC-10)
- **AC-12** — `pytest tests/ -q` зелёный (2105 passed); новый `tests/test_orch115_staging_runner.py`
зелёный (24 теста, покрытие leaf 83%; непокрытые строки — исключительно defensive `except`-ветви
never-raise). ✅
### 2. Соответствие ADR (`06-adr/ADR-001` + сквозной `adr-0048`)
- D1D11 реализованы как зафиксировано: точка диспетчеризации (перехват до `_spawn`, дискриминатор
по **стадии задачи**, не по имени роли — защита от перехвата прод-`deploy` deployer'а, TC-06),
чистота leaf'а (на импорте только `config`/`proc_group`; `db`/`git_worktree`/`stage_engine`/
`qg.checks`/`self_deploy`/`notifications` — лениво), переиспользование `proc_group` и единого
маппинга, **двухуровневый исход D5 (анти-ORCH-110)** — инфра-сбой ≠ код-фейл, restart-safe
маркер-счётчик в `task_content` без правки схемы БД, push только в фичеветку, инициация
существующего гейта, бюджет времени без правки `reaper_max_running_s`, наблюдаемость.
- **Инвариант NFR-1 / AC-5 соблюдён байт-в-байт** — анти-дрейф TC-09 подтверждает неизменность
`STAGE_TRANSITIONS["deploy-staging"]`, наличие `check_staging_status` в `QG_CHECKS`, отсутствие
новых таблиц/колонок, неизменность machine-key `staging_status:`.
- **Граница O1 (трассировка маркеров)** — код ORCH-114 (`transition_lease`) и ORCH-112
(`checkout_hygiene`) не модифицируется; lease берётся **внутри** `advance_stage`, раннер его не
трогает — зафиксированный инвариант цепочки ORCH-110/111/112/113/114 не сломан.
### 3. Качество кода
- Docstrings на всех публичных функциях; следование установленным паттернам (`self_deploy`/
`proc_group`/`merge_gate`); классификация `suite_ran = returncode is not None and not timed_out`
корректно трактует timeout-kill (returncode может быть `-9`, но `timed_out=True`) как tool-error,
а не код-фейл.
- Сверены все интеграционные сигнатуры: `ProcResult`, `run_in_process_group(grace_s/tree_kill)`,
`enqueue_job(available_at_delay_s)`, `mark_job`, `get_worktree_path`, `link_for`/`send_telegram`,
`is_self_hosting_repo`/`SELF_HOSTING_REPO` — все совпадают.
- **Self-hosting safety (AC-8/BR-7)** — в командной строке нет рестарта 8500 / `docker compose up`
/ `--build` / force-push / правок `.env`; git push строго в фичеветку, никогда в `main`. ✅ (TC-12)
- Багфикс-трек (BR-4) неприменим: ORCH-115 — полный цикл (метка не `Bug`, прошёл `architecture`),
регресс-тест-фиксатор не требуется; покрытие обеспечено новым сьютом.
### 4. Документация (обязательная проверка)
`src/` изменён → документация обновлена **в том же PR** на всех требуемых поверхностях:
- `CLAUDE.md` (раздел-паспорт ORCH-115), `CHANGELOG.md` (`[Unreleased]`).
- `docs/architecture/README.md` (компонент Staging-runner + adr-link), `internals.md`
(детерминированные перехваты `launch_job`).
- Норматив сопровождения ORCH-118: `llm-call-sites.md` (A6 — срез реализован, машинный
`ORCH-118-INVENTORY-BLOCK` сохраняет deployer `avoidable=yes`/`axis=C` как fallback),
`llm-determinization-roadmap.md` (rank 1 — ✅, инвариант «ровно один `first_slice`» цел),
`llm-usage-policy.md` (§5 — единственный транспорт S0 не нарушен). Анти-дрейф
`tests/test_llm_call_site_inventory.py` / `test_llm_determinization_docs.py` зелёные (TC-14).
- **Витрина системы `docs/overview/` (ORCH-011)** — обновлены `tech-pipeline.md`, `tech-agents.md`,
`tech-quality-security.md`; `tests/test_system_docs.py` зелёный.
- `.openclaw/agents/deployer.md` (LLM-ветвь `deploy-staging` — fallback), `.env.example` (5 ключей),
ADR work-item + сквозной `adr-0048`.
- **README «Известные ограничения» (ORCH-079)** — корректно не тронут: ни один из трёх открытых
пунктов (Telegram 48h / intra-repo deps / batch-autonomy) не закрывается ORCH-115; ложного
«решённое за открытое» нет.
## Findings
### P0 — Blocker
- Нет.
### P1 — Must fix
- Нет.
### P2 — Should fix
- Нет.
### P3 — Nice-to-have
- [ ] `run_staging_gate` при отсутствии `work_item_id`/`branch` (повреждённая строка `tasks`
задача на `deploy-staging` без branch) делает ранний `return`; обёртка `_run_staging_runner_job`
затем помечает джоб `done`, оставляя задачу «висеть» на `deploy-staging` до ре-драйва
reconciler/reaper. Это крайний кейс (любая задача на `deploy-staging` имеет branch), логируется
как ERROR и согласуется с never-raise-контрактом, но опционально можно сделать defensive-defer
вместо `done`-без-эффекта. Не блокирует приёмку.
## Документация
Обновлена полностью и в том же PR. Изменение `src/` сопровождено синхронным обновлением паспорта,
чейнджлога, архитектурных доков, LLM-карты/roadmap/политики (+ зелёные анти-дрейф тесты), витрины
`docs/overview/` (ORCH-011), промпта `deployer.md`, `.env.example` и обоих ADR. Обзорная витрина
README не требует правок (нет закрытого ORCH-115 пункта). Документационная ось — **PASS**.

View File

@@ -0,0 +1,95 @@
---
result: PASS
work_item: ORCH-115
stage: testing
author_agent: tester
status: pass
created_at: 2026-06-16
model_used: claude-opus-4-8
type: test-report
work_item_id: ORCH-115
---
# Test Report — ORCH-115 — детерминированный staging-раннер вместо LLM-деплойера
## Окружение
- Python: 3.12.13
- pytest: 8.3.3 (plugins: cov-5.0.0, anyio-4.13.0, asyncio-0.23.8)
- Worktree (изолированный, ветка задачи): `/repos/_wt/orchestrator/feature_ORCH-115-orch-replace-llm-deployer-with`
- Ветка: `feature/ORCH-115-orch-replace-llm-deployer-with`
- Дата: 2026-06-16
- Review verdict (`12-review.md`): **APPROVED** (P0/P1 — нет) ✅ предусловие стадии выполнено
## Команды
- Полный регресс: `pytest tests/ -q --tb=short`
- Целевой сьют + анти-дрейф LLM: `pytest tests/test_orch115_staging_runner.py tests/test_llm_call_site_inventory.py tests/test_llm_determinization_docs.py -v`
- Покрытие leaf: `pytest tests/test_orch115_staging_runner.py --cov=src.staging_runner --cov-report=term-missing`
- Smoke (read-only): `curl -s http://localhost:8500/{health,status,queue}`
## Результаты — покрытие плана тестов (`04-test-plan.yaml`)
Каждый TC сопоставлен с критериями `03-acceptance-criteria.md` и подтверждён конкретными
тест-функциями.
| TC ID | Тип | Описание (кратко) | AC | Тест-функции | Результат |
|-------|-----|-------------------|----|--------------|-----------|
| TC-01 | unit | `applies(repo)`: kill-switch / скоуп / not-self / never-raise→False | AC-6/AC-7 | `test_tc01_applies_killswitch_and_scope`, `test_tc01_applies_never_raises` | PASS |
| TC-02 | unit | Маппинг exit-кода `0→SUCCESS`, иначе/None→`FAILED` (контракт `self_deploy.map_exit_code_to_status`) | AC-3 | `test_tc02_map_exit_code` | PASS |
| TC-03 | unit | Рендер `15-staging-log.md`: `staging_status:` UPPERCASE + 52c-схема; INFRA-WAIVED в тело | AC-2 | `test_tc03_log_render_schema_and_infra_waived` | PASS |
| TC-04 | integration | Лог читается **неизменённым** `_parse_staging_status` для SUCCESS/FAILED | AC-2 | `test_tc04_gate_parser_unchanged` | PASS |
| TC-05 | integration | `launch_job` перехватывает до `_spawn`, нет `agent_runs`, `mark_job` ведёт строку | AC-1 | `test_tc05_launch_job_intercepts_before_spawn` | PASS |
| TC-06 | integration | Дискриминатор стадии: `deploy` (prod) и not-self репо НЕ перехватываются | AC-1 | `test_tc06_stage_discriminator_prod_not_intercepted`, `test_tc06_non_self_repo_not_intercepted` | PASS |
| TC-07 | integration | SUCCESS/FAILED → `advance_stage(finished_agent="deployer")` как у LLM | AC-4 | `test_tc07_advance_initiated_like_llm[0-SUCCESS]`, `[1-FAILED]` | PASS |
| TC-08 | integration | Kill-switch `=False` → прежний LLM-путь через `_spawn` (байт-в-байт) | AC-6 | `test_tc08_killswitch_falls_back_to_spawn` | PASS |
| TC-09 | unit | Анти-дрейф NFR-1: `STAGE_TRANSITIONS`/`QG_CHECKS`/`staging_status:`/схема БД неизменны | AC-5 | `test_tc09_pipeline_contract_unchanged` | PASS |
| TC-10 | integration | never-raise/fail-safe: ненулевой→FAILED+advance; таймаут→DEFER; бюджет исчерпан→FAILED; раннер не падает | AC-7 | `test_tc10_nonzero_exit_is_failed_and_advances`, `test_tc10_timeout_defers_without_advance`, `test_tc10_tool_error_budget_exhausted_fails_closed`, `test_tc10_run_gate_never_raises`, `test_tc10_launcher_contains_runner_fault` | PASS |
| TC-11 | unit | Таймаут `staging_runner_timeout_s`; малформ→дефолт+WARNING; передаётся в `proc_group` | AC-9 | `test_tc11_resolve_timeout_default_on_bad_value`, `test_tc11_timeout_passed_to_proc_group` | PASS |
| TC-12 | unit | Self-hosting safety: нет запрещённых литералов (рестарт 8500 / `up` / `--build` / force-push) | AC-8 | `test_tc12_command_has_no_dangerous_literals` | PASS |
| TC-13 | integration | Наблюдаемость: блок `staging_runner` в `/queue` + структурный лог-вердикт (различает код-фейл/tool-error) | AC-10 | `test_tc13_snapshot_shape`, `test_tc13_queue_endpoint_includes_block`, `test_tc13_structured_verdict_log_distinguishes_outcomes`, `test_tc13_snapshot_never_raises` | PASS |
| TC-14 | integration | Анти-дрейф LLM-карты: `llm-call-sites.md`(A6)/roadmap/policy обновлены, анти-дрейф зелёный | AC-11 | `test_llm_call_site_inventory.py` (9 тестов), `test_llm_determinization_docs.py` (3 теста) | PASS |
**Итог по плану: 14/14 TC выполнены и PASS.** Каждый AC (AC-1…AC-12) покрыт; AC-12 (полный регресс)
подтверждён прогоном всего `tests/`.
## Smoke API (read-only, prod 8500)
- `GET /health``{"status":"ok","service":"orchestrator"}`
- `GET /status` → отвечает, активные задачи перечислены (включая ORCH-115 на `testing`) ✅
- `GET /queue` → отвечает; блок **`serial_gate`** присутствует (ORCH-088) ✅; блок **`auto_labels`**
присутствует ✅. Полный набор ключей: `auto_labels, bug_fast_track, build_cache_prune,
checkout_hygiene, counts, coverage, disk_monitor, fs_ownership, lessons, max_concurrency,
merge_gate, merge_verify, poll_interval, post_deploy, reaper, recent, reconcile, resilience,
serial_gate, stop, task_deps, transition_lease`.
> Примечание: read-only блок `staging_runner` (FR-7) на боевом 8500 ещё отсутствует — это ожидаемо,
> т.к. ORCH-115 не задеплоен в прод. Наличие блока в коде ветки подтверждено тестами TC-13
> (`test_tc13_queue_endpoint_includes_block`). Это **не** регресс смока: обязательный для смока
> блок `serial_gate` присутствует.
## Покрытие нового кода
`src/staging_runner.py`**83%** (211 стэйтментов, 36 непокрытых). Непокрытые строки —
исключительно defensive `except`-ветви контракта never-raise (согласовано с `12-review.md`).
## Вывод pytest
Полный регресс:
```
........................................................................ [100%]
2105 passed, 1 warning in 93.61s (0:01:33)
```
Целевой сьют ORCH-115 + анти-дрейф LLM:
```
tests/test_orch115_staging_runner.py ......................... (24 passed)
tests/test_llm_call_site_inventory.py ......................... (9 passed)
tests/test_llm_determinization_docs.py ...................... (3 passed)
======================== 37 passed, 1 warning in 1.80s =========================
```
Единственное предупреждение — `PydanticDeprecatedSince20` (class-based `config`), не относится к
ORCH-115, присутствовало до изменения.
## Итог
**PASS** — полный регресс зелёный (2105 passed), целевой сьют (24 теста) и анти-дрейф LLM-карты
(12 тестов) зелёные, все 14 TC выполнены и сопоставлены с AC, smoke read-only OK (`serial_gate` и
`auto_labels` присутствуют в `/queue`). Машинный вердикт: `result: PASS` → задача переходит на
`deploy-staging`.

View File

@@ -0,0 +1,12 @@
---
deploy_status: SUCCESS
work_item: ORCH-115
hook_exit_code: 0
deployed_by: deploy-finalizer
---
# Deploy log — ORCH-036 executable self-deploy
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.

View File

@@ -0,0 +1,39 @@
---
staging_status: SUCCESS
work_item: ORCH-115
stage: deploy-staging
author_agent: deployer
status: success
created_at: 2026-06-16
model_used: claude-opus-4-8
timestamp: 2026-06-15T23:14:17Z
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed against the live `orchestrator-staging` container
(8501) via `docker exec` (Docker Engine API over the mounted socket). The suite
ran inside the container so the B6 registry-isolation check reads the registry
from the running instance's own process-env (`.env.staging`).
**Command:**
```
docker exec orchestrator-staging \
python3 /repos/orchestrator/scripts/staging_check.py \
--base-url http://localhost:8501 --mode stub
```
**Result: 8/10 checks PASS — exit code 0 → `staging_status: SUCCESS`.**
- REAL failed: none
- All Block A (SMOKE A1A3), Block B (ACCESS B4B6), and Block C creation/trigger
checks (C7, C8) passed.
INFRA-WAIVED: C9a Branch appears in orchestrator-sandbox, C9b Analyst job enqueued in staging queue (known sandbox-infra; real checks green)
VERDICT: SUCCESS (exit 0) — SUCCESS (infra-waived): ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue'] are known sandbox-infra checks; all real checks green
Per ORCH-061 waiver tolerance (`staging_infra_tolerance_enabled=True`), the two
infra-only checks C9a/C9b depend on SANDBOX bot accounts being project members —
not on the pipeline — and are tolerated when every REAL check is green. The
script exits 0. Exit-code → verdict mapping is unchanged: trust the exit code.

View File

@@ -1,14 +0,0 @@
---
post_deploy_status: HEALTHY
action_taken: NONE
work_item: ORCH-117
window_s: 900
checks_total: 30
checks_failed: 0
---
# Post-deploy log — ORCH-021 post-deploy monitor
Наблюдение прода завершено: `post_deploy_status: HEALTHY`, `action_taken: NONE`.
Окно наблюдения: 900s; опросов всего: 30, из них с провалом: 0.

View File

@@ -0,0 +1,7 @@
# Business Request: ORCH: replace avoidable LLM control paths with deterministic implementations
Work Item ID: ORCH-118
## Description
TBD

View File

@@ -0,0 +1,360 @@
---
work_item: ORCH-118
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-15
model_used: claude-opus-4-8
---
# 01 — BRD (бизнес-требования): ORCH-118 — replace avoidable LLM control paths with deterministic implementations
Work Item: **ORCH-118** · Repo: **orchestrator** · Стадия: analysis
> ⚠️ **Inventory-first.** Это **зонтичная inventory/architecture-задача**, а НЕ реализация
> детерминированных раннеров. Её результат — **карта** всех мест вызова LLM + классификация +
> упорядоченный roadmap + нормативная политика использования LLM, защищённая структурными тестами.
> Реализация конкретных замен — **последующие задачи**, запускаемые ПОСЛЕ утверждения карты. Их код
> в ORCH-118 **не вносится** (см. §2 «Вне объёма»).
> 📌 **Follow-up'ы именуются по РОЛИ, без выдуманных Plane-ID.** Roadmap рекомендует отдельные
> follow-up задачи по ролям-кандидатам (**deployer-замена**, **tester-гибрид** и т.д.). Конкретные
> Plane-ID этих задач в артефактах ORCH-118 **НЕ фиксируются** — они присваиваются при фактическом
> заведении задач в backlog. Аналитик не имеет доказательного источника на конкретные ID и не должен
> их выдумывать (см. NFR-6).
> 🔁 **Revision R3 (2026-06-15).** Из пакета **удалена** нормативная привязка follow-up'ов к
> конкретным ID `ORCH-115`/`ORCH-116` (этих work item нет ни в репозитории, ни в подтверждённом
> backlog — claim «источник истины: live Plane backlog» был **непроверяемым**). Вместе с ней удалены
> навязывавший её структурный тест (бывш. TC-11) и отдельный критерий приёмки (бывш. AC-9). Follow-up'ы
> теперь описаны **по роли**, ID — TBD. Содержательная классификация ролей не менялась
> (deployer = кандидат на детерминированную замену, tester = кандидат на hybrid-fallback).
> *(R1→R2 ранее «чинил» порядок этих ID; R3 убирает корень проблемы — фиксацию несуществующих ID.)*
> 🔁 **Revision R4 (2026-06-15).** Закрыт блокер R3-ревью: инвариант «места вызова LLM» **смешивал**
> факт *«существует процесс Claude CLI / спавнится subprocess»* (транспорт/механизм) с фактом
> *«потребляется суждение LLM»* (LLM-консультация). R4 развёл **транспорт** (`_spawn`) и **слот/
> capability** (D1/D2) от факта **консультации** во всех артефактах (см. §0). Содержательная
> классификация ролей и весь R3-материал (анти-фабрикация ID) — без изменений.
> 🔁 **Revision R5 (2026-06-15) — единственный оставшийся блокер R4-ревью.** Рецензент подтвердил R4
> (консультация ≠ транспорт/слот; no-alternative-LLM-transport; follow-up'ы по роли/TBD; нет
> runtime-дифа) — **менять их не нужно**. Закрывается **один** блокер: артефакты разводили
> «консультация ≠ транспорт/слот», но **не делали явной третью ось — самую важную для названия
> задачи** *«replace avoidable LLM **control paths**»*: среди фактических консультаций **не
> различались** (i) **control-path-консультации** — где LLM-вердикт **потребляется потоком управления**
> (на нём ветвится `check_*`-гейт), и (ii) **artifact-producer-консультации** — где LLM лишь
> **производит артефакт**, а ветвление делает **детерминированный гейт** (наличие файлов / CI), и
> суждение LLM в control flow **не входит**. И главное — нигде **явно не определена «avoidable LLM
> control path»**. R5 добавляет эту ось и определение во **все** артефакты (новый §0-bis, уточнённые
> BR-1/BR-2 + новые BR-8/BR-9, FR-1/FR-2 + новый FR-8, AC-1/AC-2 + новый AC-10, TC-13/TC-14).
> Содержательная классификация (analyst/architect/developer/reviewer → keep-LLM; deployer →
> replace-deterministic; tester → hybrid) **не меняется** — R5 даёт ей **доказательный control-path
> вывод** из кода (`check_*`/`_parse_*` в `src/qg/checks.py`).
---
## 0. Единица анализа: «LLM-консультация» ≠ «процесс Claude CLI» (R4)
Задача — про **LLM control paths**, поэтому единица инвентаря/классификации/инвариантов — это
**LLM-консультация (call-site)**, а НЕ «спавн процесса / существование Claude CLI». Три ортогональных
факта фиксируются **раздельно** (их смешение и было блокером R3-ревью):
1. **LLM-консультация (семантическая единица).** Точка потока управления, где решение/артефакт
конвейера **потребляет суждение LLM** (инференс модели). Именно её перечисляет и классифицирует
карта. «Заменить avoidable LLM control path» = убрать *консультацию* там, где суждение не нужно, —
независимо от того, каким транспортом она реализована.
2. **Транспорт (механизм) ≠ консультация.** Claude CLI-subprocess через `launcher._spawn`
(`src/agents/launcher.py`, сборка `CLAUDE_BIN --print … --system-prompt "$(cat …)"` + `Popen`) —
**текущий единственный транспорт**, которым реализуется LLM-консультация. Транспорт — это одна
*реализация* понятия, а НЕ его *определение*. «Существует процесс Claude CLI» само по себе **не**
равно «принято решение на основе суждения LLM».
3. **Capability ≠ consultation (способность ≠ факт консультации).** Спавн процесса делает site
*LLM-capable*; происходит ли консультация фактически — **гейтится потоком управления**:
- `D1/D2` (`deploy-finalizer`/`post-deploy-monitor`) — job-роли, **занимающие слот агента**, но
перехватываемые в `launch_job` **до** `_spawn` (`src/agents/launcher.py:389,394`, код прямо
помечает «Not an LLM spawn») → **консультации LLM нет**, хотя «агент» как job существует;
- timeout-kill (`exit_code=-9`) / salvage-guard (`if exit_code==0`) → спавненный процесс может **не
произвести** потреблённого суждения.
Поэтому «процесс спавнится» **переоценивает** «суждение потреблено».
Следствие для инварианта единственной точки (детализируется в BR-1/BR-6): он должен быть
**транспорт-агностичным и двусторонним** — (i) единственный транспорт LLM-консультации в `src/**`
`_spawn`, И (ii) **отсутствует любой иной транспорт** (импорт `anthropic`/`openai`/LLM-SDK, прямой
HTTP-эндпоинт Anthropic/Claude, второй model-invoking subprocess-сборщик `--system-prompt`/`--model`).
Дискриминатор — **«консультирует LLM», а не «спавнит subprocess»**: десятки subprocess-вызовов в
`src/**` (`git`/`pytest`/`docker`/`ssh`/сканеры/`staging_check.py`) **не** являются LLM-консультациями
и не должны попадать под инвариант (см. FR-6 матчинг).
---
## 0-bis. Третья ось: control-path-консультация ≠ artifact-producer-консультация; что такое «avoidable» (R5)
§0 (R4) отделил **факт консультации** от **транспорта** и **слота**. Но название задачи —
«replace **avoidable** LLM **control paths**» — требует ещё одного, **решающего** различия **внутри
множества фактических консультаций** (A1…A6). Без него «control path» и «avoidable» остаются
неопределёнными, и карта не отвечает на главный вопрос задачи: *какие именно консультации — это
avoidable LLM control paths*. Это и есть оставшийся блокер R4-ревью.
### Ось 3 — как именно LLM-вывод соотносится с потоком управления
Каждая фактическая консультация (роль-агент) относится **ровно к одному** из двух типов:
- **(C) Control-path-консультация.** LLM эмитит **machine-verdict**, который **потребляется потоком
управления конвейера**: соответствующий `check_*`-гейт **ветвится на этом вердикте**
(PASS→дальше / FAIL→откат). Суждение LLM **входит в control flow** — это и есть «**LLM control
path**» в точном смысле названия задачи.
- **(P) Artifact-producer-консультация.** LLM **производит артефакт** (документы/код/PR), а решение
о продвижении принимает **детерминированный гейт**, судящий артефакт **независимо** от
самоотчёта LLM (наличие файлов, статус CI). Суждение LLM **в control flow не входит** → это **не**
«LLM control path» (хотя консультация реальна и может требовать настоящего суждения).
Различие доказывается кодом — **кто потребляет вывод роли** (`src/qg/checks.py`, ground-truth на
момент задачи):
| Роль | Потребитель вывода (control-flow consumer) | Тип (C/P) | Control path? |
|------|--------------------------------------------|-----------|---------------|
| analyst | `check_analysis_complete` (checks.py:33) — **наличие файлов** 0104 | **P** | нет |
| architect | `check_architecture_done` (checks.py:62) — **наличие** 06-adr/07 | **P** | нет |
| developer | `check_ci_green` (checks.py:82) + `check_branch_mergeable` (657) — **CI/merge** | **P** | нет |
| reviewer | `check_reviewer_verdict` (checks.py:336) читает **`verdict:`** → REQUEST_CHANGES-откат | **C** | **да** |
| tester | `check_tests_passed` (checks.py:182) → `_parse_tests_verdict` (226) читает **`result:`** | **C** | **да** |
| deployer | `check_staging_status` (599)→`_parse_staging_status` (538) **`staging_status:`**; `check_deploy_status` (473)→`_parse_deploy_status` (413) **`deploy_status:`** | **C** | **да** |
> Для **P**-ролей гейт читает **не самоотчёт LLM**, а независимый детерминированный сигнал
> (файлы/CI) — поэтому подделать ветвление «самооценкой» нельзя; это структурно НЕ control path.
> Для **C**-ролей гейт читает **именно machine-verdict, который написал LLM** — суждение LLM и есть
> точка ветвления.
### Определение «avoidable LLM control path» (нормативное, R5)
Call-site — **avoidable LLM control path** ⟺ выполнены **оба** условия:
1. **(control path)** это **C**-консультация — её LLM-вердикт потребляется потоком управления
(`check_*` ветвится на нём); **и**
2. **(deterministically derivable)** этот вердикт по сути есть **детерминированная функция от
tool-сигналов** (exit-code `pytest`/smoke, `staging_check.py`, exit-code деплоя), которые
оркестратор **уже вычисляет сам** → суждение LLM **не добавляет информации** → консультацию можно
снять без потери смысла.
Отсюда — **точный целевой набор задачи** (доказательно, не «на глаз»):
- **avoidable LLM control paths = {tester, deployer}** — C **и** вердикт деривируем из exit-кодов
(`_parse_tests_verdict` судит то, что есть исход `pytest`; `_parse_staging_status`/
`_parse_deploy_status` судят то, что есть исход `staging_check.py`/деплоя; прод-деплой
self-hosting **уже** идёт детерминированным путём Phase A/B/C, ORCH-036).
- **control path, но НЕ avoidable = {reviewer}** — C, но вердикт **не** деривируем: «приемлем ли
код?» — настоящее суждение (keep-LLM). Это показывает, что «control path» **сам по себе** не равен
«avoidable» — отсекает условие 2.
- **НЕ control path (avoidable-вопрос неприменим) = {analyst, architect, developer}** — P:
детерминированный гейт судит артефакт, суждение LLM в control flow не входит; авторская работа
требует настоящего суждения (keep-LLM). Это отсекает условие 1.
- **уже детерминированы (вне консультаций) = {deploy-finalizer, post-deploy-monitor}** — §0.3.
Эта ось **выводит** R3/R4-классификацию из кода, а не постулирует её: класс call-site'а есть
**функция** его (C/P)-типа и деривируемости вердикта (см. BR-2). «Avoidable» больше не «удобство на
глаз», а проверяемый двухбитный предикат над `src/qg/checks.py`.
---
## 1. Бизнес-контекст и проблема
Зонтичный follow-up по итогам RCA-цепочки **ORCH-114/117** (и предшествующих ORCH-110/111/112/113):
корневым классом инцидентов было то, что **side-effectful и решающие control-path'ы не имели единого
детерминированного владения** — где-то решение принималось «потому что так удобно» через LLM-агента,
хотя по сути это исполнение фиксированных команд и маппинг результата.
Установленный факт по текущему коду (инвентаризация — §1 TRZ, артефакт-карта):
- В оркестраторе **единственный транспорт LLM-консультации**`src/agents/launcher.py::_spawn` (одна
`subprocess.Popen` сборка Claude CLI: `CLAUDE_BIN --print … --system-prompt "$(cat …)"`), которым
пользуются **6 ролей-агентов** (analyst, architect, developer, reviewer, tester, deployer).
Прямых вызовов Anthropic API / LLM-SDK / иных model-invoking транспортов в `src/**` **нет**
(подтверждено инвентаризацией; впредь — держится тестом FR-6). ⚠️ Это утверждение про **транспорт**,
а не про «единственный subprocess»: в `src/**` десятки прочих `subprocess`-вызовов (`git`/`pytest`/
`docker`/`ssh`/сканеры) — они **не** консультируют LLM (см. §0).
- **Из 6 консультаций только 3 — это LLM control paths** (вердикт потребляется потоком управления,
§0-bis): **reviewer / tester / deployer**. Остальные 3 (**analyst / architect / developer**) —
artifact-producer'ы: их выход судит **детерминированный** гейт (наличие файлов / CI), суждение LLM
в control flow не входит. Среди 3 control path'ов **avoidable** — те, чей вердикт деривируем из
exit-кодов: **tester** и **deployer**; **reviewer** — control path с **настоящим** суждением
(keep).
- **Все остальные control-path'ы уже детерминированы (без LLM):** маршрутизация стадий
(`STAGE_TRANSITIONS`/`advance_stage`), все Quality Gate'ы и под-гейты (`check_*`, security/merge/
coverage/image-freshness), парсеры вердиктов (`_parse_*` через `frontmatter.py`), классификатор
ретраев (`error_classifier.py`), serial-gate/transition-lease/reconciler/reaper, а также **две
зарезервированные job-роли** `deploy-finalizer` и `post-deploy-monitor` (перехватываются в
`launch_job` **до** `_spawn` — это рабочий прецедент детерминированной замены агента).
Боль/риск, который закрывает задача: LLM на **механических control path'ах** — это (а) лишний
источник недетерминизма и инцидентов (ложный вердикт/действие в точке ветвления), (б) задержка
(запуск opus-агента вместо прямого вызова), (в) расход токенов/денег. При этом «вслепую» убирать LLM
нельзя — часть путей несёт **настоящее суждение** (анализ, архитектура, написание кода, **ревью**), и
автономность/гибкость должны сохраниться. Точный дискриминатор «убирать/оставить» — определение
«avoidable LLM control path» (§0-bis).
ORCH-118 даёт **доказательную карту** «где LLM действительно нужен, а где это avoidable control path»
и **упорядоченный план** безопасных замен — фундамент, на котором последующие срезы (по
ролям-кандидатам) выполняются предсказуемо и без регресса.
## 2. Объём (scope)
### В объёме
- **BR-1** Полная инвентаризация всех мест вызова LLM и всех ролей-агентов (карта call-site'ов).
- **BR-2** Классификация каждого call-site в один из 4 классов (keep / replace-now / replace-later /
hybrid-fallback) с явным обоснованием, **выведенным** из control-path-оси (§0-bis): класс есть
функция (C/P)-типа и деривируемости вердикта.
- **BR-3** Доказательное подтверждение (с привязкой `file:line`), что не-агентские control-path'ы
(маршрутизация / ретраи / QG / парсеры / finalizer'ы) уже детерминированы.
- **BR-4** Упорядоченный roadmap замен: зависимости, оценка экономии токенов/времени, риски
безопасности, потребность в hybrid-fallback, рекомендованный **первый срез** — кандидаты названы
**по роли** (follow-up ID — TBD).
- **BR-5** Нормативная **политика использования LLM** («LLM — только там, где нужно настоящее
суждение») как durable-документ.
- **BR-6** Структурные regression-тесты, **прибивающие инварианты карты к коду** (транспорт-агностичный
двусторонний инвариант + control-path-ось) — анти-дрейф.
- **BR-7** Явно позиционировать **роль deployer** и **роль tester** как **кандидаты-follow-up** для
детерминированной замены — **по роли, без привязки к конкретным Plane-ID** (см. NFR-6).
- **BR-8 (R5)** Карта **явно** размечает каждую консультацию по оси (C) control-path /
(P) artifact-producer с доказательством — **кто потребляет вывод роли** (`check_*`/`_parse_*` с
`file:line`).
- **BR-9 (R5)** Карта/политика **явно** определяют термин **«avoidable LLM control path»** (двухбитный
предикат §0-bis) и **поимённо** называют целевой набор `{tester, deployer}`, явно отделяя его от
control-path-но-keep (`reviewer`) и от не-control-path (`analyst`/`architect`/`developer`).
### Вне объёма
-**Реализация** детерминированных раннеров deployer / tester и любых других замен — это отдельные
follow-up задачи ПОСЛЕ утверждения карты (явное требование заказчика в business request).
-**Выдумывание/фиксация конкретных follow-up Plane-ID** (напр. `ORCH-115`/`ORCH-116`) в любых
артефактах ORCH-118 — ID присваиваются при заведении задач (NFR-6).
- ❌ Изменение `STAGE_TRANSITIONS` / реестра `QG_CHECKS` / семантики и имён `check_*` /
machine-verdict-ключей (`verdict:`/`result:`/`staging_status:`/`deploy_status:`/`security_status:`/
`coverage_status:`) / схемы БД.
- ❌ Включение model-routing (G3 ORCH-41) или смена модели/эффорта ролей.
- ❌ Любая правка поведения `src/**` в рантайме (ORCH-118 — **docs + tests only**, по образцу
ORCH-077/079/101/102/103/011).
- ❌ Снижение автономности или гибкости конвейера.
## 3. Заинтересованные стороны
- **Заказчик / Owner** — инициатор RCA-трека ORCH-114/117; принимает карту и roadmap (gate утверждения).
- **Сопровождающие платформы (self-hosting)** — выигрывают в стабильности, скорости, экономии токенов.
- **Downstream-проекты (enduro-trails)** — делят общий прод/очередь; для них требуется **нулевая
регрессия** (NFR-1).
- **Будущие исполнители follow-up'ов** — потребители карты, roadmap и политики.
## 4. Бизнес-требования (BR)
- **BR-1 — Инвентарь LLM-консультаций (call-site'ов).** Выпустить durable-документ, перечисляющий
**каждое** место, где control-path **потребляет суждение LLM** или может его потребить (единица —
*LLM-консультация*, §0, а не «спавн процесса»): единственный транспорт `_spawn`, все 6 ролей-агентов
и обе зарезервированные job-роли `D1/D2` (включаются как доказательство «слот агента есть, но
консультации LLM нет» — перехват до `_spawn`). Для каждого — `file:line`, триггер, стадия/владелец,
выходной артефакт, machine-verdict-ключ (если есть), **потребитель вывода (`check_*`/`_parse_*` с
`file:line`)**, оценка токенов/времени, **признак capability-vs-consultation** (§0.3) и **признак
оси (C) control-path / (P) artifact-producer** (§0-bis, BR-8). Проверяемо: каждый `file:line`
резолвится в реальный код.
- **BR-2 — Классификация.** Каждому call-site присвоить **ровно один** класс из таксономии:
`keep-LLM` (нужно настоящее суждение), `replace-deterministic-now` (безопасная замена сейчас),
`replace-later/risky` (замена позже / рискованно), `needs-hybrid-fallback` (детерминированное ядро +
LLM-фолбэк на суждение). Класс **выводится** из control-path-оси (§0-bis): **P**`keep-LLM`
(артефактная авторская работа); **C + не-деривируемый вердикт**`keep-LLM` (reviewer); **C +
деривируемый вердикт** → `replace-*` / `needs-hybrid-fallback` (tester/deployer = avoidable). Для
`keep-LLM`**назвать конкретное суждение**, ради которого LLM сохраняется (для **C**-keep —
почему вердикт **не** деривируем).
- **BR-3 — Подтверждение детерминизма не-агентских путей.** Документально, с `file:line`-доказательством,
зафиксировать, что маршрутизация стадий, ретраи, QG-проверки, парсеры вердиктов и finalizer'ы **не
консультируют LLM** (не зависят от суждения LLM — ни через `_spawn`, ни через иной транспорт; их
subprocess-вызовы git/pytest/docker/ssh/сканеров — детерминированные инструменты, не LLM, §0). Если
инвентаризация найдёт неожиданную LLM-консультацию — она попадает в карту (BR-1) и классификацию
(BR-2).
- **BR-4 — Упорядоченный roadmap.** Ранжированный план замен: для каждого кандидата (названного **по
роли**) — зависимости, **оценка** экономии токенов/времени (из телеметрии `agent_runs`), риск
безопасности, нужен ли hybrid-fallback, ожидание kill-switch/обратимости. Явно указать
**рекомендованный первый срез** и обоснование выбора (опора на control-path-вывод: первым берётся
самый «чисто деривируемый» control path). Привязка к follow-up задаче — **по роли**; конкретный
Plane-ID НЕ фиксируется (заводится отдельно, NFR-6).
- **BR-5 — Политика использования LLM.** Нормативный durable-документ: «LLM — только там, где требуется
настоящее суждение»; критерии решения keep vs replace, **сформулированные через ось §0-bis**
(является ли путь control path; деривируем ли вердикт); требование к новым/изменённым control-path'ам
обосновывать любое использование LLM против этой политики.
- **BR-6 — Анти-дрейф структурными тестами.** Тесты, привязывающие инварианты карты к коду:
транспорт-агностичный двусторонний инвариант единственной точки (§0); перечисленные
детерминированные модули/job-роли не несут LLM-консультации; карта перечисляет ровно те 6 промптов,
что лежат в `.openclaw/agents/`; тотальность классификации; **плюс control-path-ось (R5):** карта
размечает каждую роль (C/P) согласованно с фактическим потребителем в `src/qg/checks.py`, а
avoidable-набор = `{tester, deployer}`. Тесты — offline. **Тест на привязку к конкретным follow-up
ID не вводится** (анти-паттерн R3).
- **BR-7 — Позиционирование follow-up'ов по роли.** Карта/roadmap явно отмечают **роль deployer** и
**роль tester** как кандидаты-замены, **не** реализуемые в ORCH-118; их старт гейтится утверждением
карты. Привязка — **по роли**, без конкретных Plane-ID (NFR-6).
- **BR-8 (R5) — Явная control-path-разметка.** Карта несёт для каждой консультации поле оси
**(C) control-path / (P) artifact-producer** + доказательство (потребитель вывода с `file:line`).
Разметка **проверяема** структурным тестом против `src/qg/checks.py` (BR-6, TC-13).
- **BR-9 (R5) — Явное определение «avoidable LLM control path» и поимённый целевой набор.** Карта/
политика дают нормативное определение термина (двухбитный предикат §0-bis) и **поимённо** называют
`{tester, deployer}` как avoidable, явно отделяя их от `reviewer` (control path, keep) и от
`analyst/architect/developer` (не control path). Набор **проверяем** тестом (BR-6, TC-14).
## 5. Нефункциональные требования (NFR)
- **NFR-1 — Сохранение поведения (нулевая регрессия).** ORCH-118 — docs+tests only:
`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict-ключи/схема БД — **байт-в-байт**; поведение
конвейера 1:1; enduro-trails не затронут.
- **NFR-2 — Сохранение автономности и гибкости.** Ни инвентаризация, ни политика не должны
предлагать решений, снижающих автономный/пакетный режим (ORCH-088/089) или гибкость; политика
**защищает** автономность как инвариант любой будущей замены.
- **NFR-3 — Self-hosting безопасность.** Задача только **читает** код и пишет docs+tests — не
деплоит, не рестартит прод-контейнер, не трогает `main`/force-push, не запускает процессов/сети.
- **NFR-4 — Трассируемость и сопровождаемость.** Карта привязана к коду маркерами/тестом и остаётся
честной при эволюции кода; формат — по `docs/_standards/PIPELINE_DOCS.md` и `TRACEABILITY.md`.
- **NFR-5 — Доказательность экономии.** Цифры экономии берутся из реальной телеметрии `agent_runs`
(модель/эффорт/токены/стоимость/время по ролям) и помечаются как **оценки** до фактического замера
после реализации.
- **NFR-6 — Только проверяемые ссылки (анти-фабрикация, R3).** В артефактах фиксируются только
ссылки, резолвящиеся в код/документы репозитория. Конкретные **follow-up Plane-ID не выдумываются**:
кандидаты-замены именуются по роли; ID присваивается при заведении задачи. (Это закрывает корень
отклонённой ревизии R2.)
- **NFR-7 (R5) — Контроль-ориентированность определений.** «LLM control path» и «avoidable»
определяются **через потребление вывода потоком управления** (кто из `check_*` ветвится на выводе),
а не через «есть ли вообще LLM-вызов на стадии». Любое утверждение «это avoidable LLM control path»
обязано резолвиться в конкретный `check_*`/`_parse_*` + tool-сигнал, из которого вердикт деривируем.
## 6. Допущения и ограничения
- Единственный транспорт LLM сейчас — Claude CLI через `launcher._spawn`; прямых вызовов Anthropic API
в `src/**` нет (подтверждается инвентаризацией).
- Model-routing не включён — все 6 ролей на `claude-opus-4-8` (ORCH-41), что упрощает оценку экономии.
- Карта — **снимок на момент задачи**, защищённый структурными тестами от тихого расхождения с кодом.
- Прецедент детерминированной замены агента уже существует и работает (`deploy-finalizer`/
`post-deploy-monitor` в `launch_job` до `_spawn`) — это снижает архитектурный риск follow-up'ов.
- На момент анализа конкретные follow-up work item для замены ролей в backlog **не подтверждены**
поэтому ID не фиксируются (NFR-6).
- **(R5)** Control-path-разметка опирается на ground-truth `src/qg/checks.py` на момент задачи; при
эволюции кода её честность держит структурный тест (TC-13/TC-14).
## 7. Критерии успеха
Карта LLM-консультаций полна и привязана к коду, и **разводит три ортогональных факта**: транспорт/слот
(«процесс Claude CLI существует», R4 §0), факт консультации, **и — control-path-ось (R5 §0-bis):
потребляется ли LLM-вывод потоком управления (`check_*` ветвится на нём) или его независимо судит
детерминированный гейт**. Термин **«avoidable LLM control path» явно определён** (двухбитный предикат)
и целевой набор **поимённо** назван `{tester, deployer}` с доказательным отделением от `reviewer`
(control path, keep) и от `analyst/architect/developer` (не control path). Каждый site классифицирован
с обоснованием, **выведенным** из этой оси; детерминизм не-агентских путей доказан (отсутствием
LLM-консультации, не отсутствием subprocess); есть упорядоченный roadmap с зависимостями/экономией/
рисками и рекомендованным первым срезом (кандидаты — по роли); есть нормативная политика; структурные
тесты зелёные и осмысленные (инвариант единственной точки транспорт-агностичен и двусторонен; control-path
-разметка сверена с `src/qg/checks.py`; avoidable-набор зафиксирован); ни один рантайм-инвариант не
тронут; раннеры замен НЕ реализованы; ни один артефакт не фиксирует непроверяемых follow-up ID.
Детальные PASS/FAIL — в `03-acceptance-criteria.md`.
## 8. Риски
- **Недо-/пере-классификация** (LLM убран там, где нужно суждение, или сохранён там, где не нужен) →
митигирует **control-path-вывод** (§0-bis: класс выводится из (C/P)-типа и деривируемости вердикта,
а не «на глаз») + требование «назвать конкретное суждение» для `keep-LLM` + ревью карты.
- **Конфляция «есть LLM на стадии» с «это control path»** (рецидив корня R4-блокера) → митигирует
явная разметка оси и тест TC-13 (сверка с фактическим потребителем `check_*`).
- **Дрейф карты** относительно кода со временем → митигируют структурные тесты (BR-6), включая
control-path-инварианты (TC-13/TC-14).
- **Преждевременная замена** в погоне за экономией ценой автономности/гибкости → инвентаризация
отделена от реализации; первый срез — самый низкорисковый «чисто деривируемый» control path.
- **Фабрикация ссылок/ID** (рецидив дефекта R2) → митигирует NFR-6 (только проверяемые ссылки;
follow-up'ы — по роли) и ревью. Детали техн.рисков — `10-tech-risks.md` (архитектор).

View File

@@ -0,0 +1,234 @@
---
work_item: ORCH-118
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-15
model_used: claude-opus-4-8
---
# 02 — ТЗ (TRZ): ORCH-118 — replace avoidable LLM control paths with deterministic implementations
Work Item: **ORCH-118** · Repo: **orchestrator** · Стадия: analysis
> ТЗ описывает **что** должно измениться и **где** (артефакты/контракты/тесты), выведено из BRD и
> фактического кода. **Как** (точная структура/размещение документов карты, формат классификации,
> схема структурных тестов) — решает архитектор в `06-adr/`. ТЗ фиксирует требования и границы.
>
> ⚠️ **Скоуп — inventory + классификация + roadmap + политика + структурные тесты.** Реализация
> детерминированных раннеров — **вне скоупа** (FR-7). Это **docs + tests only**: `src/**`-рантайм не
> меняется.
>
> 📌 **Follow-up'ы — по роли, без выдуманных Plane-ID** (R3, NFR-6). Конкретные ID (напр.
> `ORCH-115`/`ORCH-116`) в артефактах **не фиксируются** — этих work item нет в репо/подтверждённом
> backlog; ID присваивается при заведении задачи.
>
> 🔁 **R4 — блокер R3-ревью (закрыт).** Единица инвентаря/инварианта — **LLM-консультация** (control-path
> потребляет суждение LLM), **не** «спавн процесса / существование Claude CLI». Claude CLI-subprocess
> через `_spawn` — лишь **текущий транспорт**; «процесс существует» ≠ «LLM консультирован»
> (capability ≠ consultation). Развёрнуто — BRD §0; затрагивает FR-1/FR-3/FR-6.
>
> 🔁 **R5 — единственный оставшийся блокер R4-ревью.** Артефакты разводили «консультация ≠ транспорт/
> слот», но **не делали явной control-path-ось** — самую важную для названия задачи: среди реальных
> консультаций **не различались** (C) control-path (LLM-вердикт потребляется потоком управления —
> `check_*` ветвится на нём) и (P) artifact-producer (детерминированный гейт судит артефакт; суждение
> LLM в control flow не входит); и нигде **не был определён** термин **«avoidable LLM control path»**.
> R5 добавляет ось + определение (BRD §0-bis) и тянет их в FR-1/FR-2 + новый **FR-8**, в инвентарь
> §1 (новая колонка), в тесты FR-6 (**TC-13/TC-14**). Содержательная классификация не меняется —
> R5 **выводит** её из `src/qg/checks.py`.
---
## 1. Сводка изменения
Выпустить **доказательную карту** всех мест вызова LLM в оркестраторе с классификацией и
упорядоченным roadmap'ом детерминированных замен, а также нормативную **политику использования LLM**;
прибить инварианты карты к коду набором **структурных тестов**. Реализация замен не входит. Все
рантайм-контракты (`STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict-ключи / схема БД) —
**не меняются**.
Опорный факт инвентаризации (ground-truth кода на момент задачи; line-привязки уточняет карта):
> Единица — **LLM-консультация** (потребление суждения LLM), а не «спавн процесса» (R4, BRD §0).
> R5: дополнительно размечается **ось (C) control-path / (P) artifact-producer** и **потребитель
> вывода** (`check_*`/`_parse_*`), доказывающий ось. Колонка `Консультирует LLM?` помечает транспорт/
> слот vs факт консультации; колонка `Control path?` помечает, входит ли LLM-вывод в поток управления.
| # | Call-site | Где | Что делает | Консультирует LLM? | Потребитель вывода (control-flow consumer) | Control path? (C/P) | Avoidable LLM control path? |
|---|-----------|-----|------------|--------------------|--------------------------------------------|---------------------|-----------------------------|
| S0 | **Единственный транспорт LLM-консультации** | `src/agents/launcher.py::_spawn` (`CLAUDE_BIN --print … --system-prompt "$(cat …)"` + `Popen`) | реализует консультацию для любой из 6 ролей | транспорт (capability) | — | — | — (транспорт, не call-site решения) |
| A1 | analyst | `.openclaw/agents/analyst.md`, стадия `analysis` | анализ → 0104 | да (через S0) | `check_analysis_complete` (`src/qg/checks.py:33`) — **наличие файлов** | **P** (artifact-producer) | **нет** (не control path) → keep-LLM |
| A2 | architect | `.openclaw/agents/architect.md`, стадия `architecture` | архитектура → 06-adr | да (через S0) | `check_architecture_done` (`checks.py:62`) — **наличие** 06-adr/07 | **P** | **нет** → keep-LLM |
| A3 | developer | `.openclaw/agents/developer.md`, стадия `development` | реализация + PR | да (через S0) | `check_ci_green` (`checks.py:82`) + `check_branch_mergeable` (`checks.py:657`) — **CI/merge** | **P** | **нет** → keep-LLM |
| A4 | reviewer | `.openclaw/agents/reviewer.md`, стадия `review` | ревью → `12-review.md` (`verdict:`) | да (через S0) | `check_reviewer_verdict` (`checks.py:336`) читает **`verdict:`** → REQUEST_CHANGES-откат | **C** (control path) | **нет** (вердикт НЕ деривируем — настоящее суждение) → keep-LLM |
| A5 | tester | `.openclaw/agents/tester.md`, стадия `testing` | `pytest`+smoke → `13-test-report.md` (`result:`) | да (через S0) | `check_tests_passed` (`checks.py:182`) → `_parse_tests_verdict` (`checks.py:226`) читает **`result:`** | **C** | **ДА** (вердикт = exit-code `pytest`/smoke) → needs-hybrid-fallback |
| A6 | deployer | `.openclaw/agents/deployer.md`, стадии `deploy-staging`/`deploy` | `staging_check.py`/exit-code → `15`/`14` логи | да (через S0) | `check_staging_status` (`checks.py:599`)→`_parse_staging_status` (`checks.py:538`) **`staging_status:`**; `check_deploy_status` (`checks.py:473`)→`_parse_deploy_status` (`checks.py:413`) **`deploy_status:`** | **C** | **ДА** (вердикт = `staging_check.py`/exit-code; прод уже детерминирован Phase A/B/C) → replace-deterministic |
| D1 | deploy-finalizer | `launch_job` перехват **до** `_spawn` (`launcher.py:389`) | детерминированный (LLM не консультируется) | **нет** (слот агента, перехват до `_spawn`) | — | — (нет консультации) | — (уже детерминирован) |
| D2 | post-deploy-monitor | `launch_job` перехват **до** `_spawn` (`launcher.py:394`) | детерминированный (LLM не консультируется) | **нет** (слот агента, перехват до `_spawn`) | — | — (нет консультации) | — (уже детерминирован) |
> **Чтение таблицы (R5).** Три ортогональных факта: (1) `Консультирует LLM?` — транспорт/слот vs факт
> консультации (R4 §0); (2) `Control path?` — входит ли вывод роли в **поток управления** (C: `check_*`
> ветвится на LLM-вердикте; P: детерминированный гейт судит артефакт независимо); (3) `Avoidable …?` —
> двухбитный предикат BRD §0-bis (C **И** вердикт деривируем из tool-сигналов). Итог: **avoidable LLM
> control paths = {tester, deployer}**; control-path-но-keep = `{reviewer}`; не-control-path =
> `{analyst, architect, developer}`.
>
> Не-агентские control-path'ы (маршрутизация `advance_stage`, `QG_CHECKS`/`check_*`/`_parse_*`,
> `error_classifier`, `serial_gate`/`merge_gate`/`coverage_gate`/`security_gate`/`staging_verdict`/
> `review_parse`/`frontmatter`, `self_deploy` Phase A/B/C) — **уже детерминированы** (FR-3).
## 2. Задействованные модули / пути
| Путь | Действие |
|------|----------|
| `src/agents/launcher.py` | **читать** (инвентарь S0/D1/D2; `_spawn`, `launch_job:389/394`, `AGENT_CONFIGS`, `resolve_agent_model/effort`) — **не менять** |
| `.openclaw/agents/{analyst,architect,developer,reviewer,tester,deployer}.md` | **читать** (инвентарь 6 ролей) — **не менять** |
| `src/qg/checks.py` | **читать** (доказательство control-path-оси: кто потребляет вывод каждой роли — `check_analysis_complete:33`/`check_architecture_done:62`/`check_ci_green:82`/`check_reviewer_verdict:336`/`check_tests_passed:182`+`_parse_tests_verdict:226`/`check_staging_status:599`+`_parse_staging_status:538`/`check_deploy_status:473`+`_parse_deploy_status:413`; `QG_CHECKS`-реестр) — **не менять** |
| `src/stages.py`, `src/stage_engine.py` | **читать** (доказать детерминизм маршрутизации) — **не менять** |
| `src/{serial_gate,merge_gate,coverage_gate,security_gate,staging_verdict,review_parse,error_classifier,frontmatter,self_deploy,post_deploy,transition_lease,reconciler,job_reaper}.py` | **читать** (детерминированные leaf'ы — доказательная база) — **не менять** |
| `src/usage.py`, `src/db.py` (`agent_runs`) | **читать** (источник оценок экономии токенов/времени) — **не менять** |
| `docs/architecture/llm-call-sites.md` *(имя — пример; финально решает архитектор)* | **создать**: карта call-site'ов + классификация + **control-path-разметка** (FR-1/FR-2/FR-3/FR-8) |
| `docs/architecture/llm-determinization-roadmap.md` *(имя — пример)* | **создать**: упорядоченный roadmap (FR-4) |
| `docs/architecture/llm-usage-policy.md` *(имя — пример)* | **создать**: нормативная политика + определение «avoidable LLM control path» (FR-5/FR-8) |
| `docs/work-items/ORCH-118/06-adr/ADR-001-*.md` | **создать** (архитектор): фиксация карты/таксономии/control-path-оси/первого среза как ADR |
| `tests/test_llm_call_site_inventory.py` *(имя — пример)* | **создать**: структурные анти-дрейф тесты (FR-6), включая control-path-инварианты |
| `docs/architecture/README.md`, `docs/overview/*`, `CHANGELOG.md` | **обновить** (ссылка на карту/политику; норматив golden-source) |
> Документы карты/политики целесообразно разместить в `docs/architecture/` (durable, сквозное), а не
> только в `docs/work-items/ORCH-118/` — окончательное размещение/формат решает архитектор.
## 3. Функциональные требования
### FR-1 — Полнота и привязка инвентаря LLM-консультаций (BR-1)
Единица — **LLM-консультация** (control-path потребляет суждение LLM), а не «спавн процесса» (R4, BRD
§0). Карта перечисляет **каждый** call-site: `S0` (единственный транспорт `_spawn`), `A1…A6` (6 ролей,
консультируют через S0), `D1/D2` (job-роли, **занимают слот агента, но НЕ консультируют LLM**
перехват в `launch_job` до `_spawn`; включены как доказательство паттерна). Поля записи: `id`,
`location` (`file:line`), `trigger`, `stage/owner`, `output artifact`, `machine-verdict key` (если
есть), **`output consumer` (`check_*`/`_parse_*` с `file:line` — кто потребляет вывод роли)**,
`est. tokens/runtime`, **`consults-LLM`** (consultation vs LLM-capable transport/slot, §0.3),
**`axis` (C control-path / P artifact-producer, §0-bis)**, `classification`, `rationale`, `dependency`,
`risk`. Каждый `file:line` обязан резолвиться в реальный код. Инвариант (транспорт-агностичный,
двусторонний): единственный транспорт LLM-консультации в `src/**``S0`; **иного LLM-транспорта нет**
тесты FR-6(a)+(f).
### FR-2 — Таксономия классификации (BR-2)
Ровно 4 взаимоисключающих класса с определениями:
- `keep-LLM` — нужно настоящее суждение; **обязательно назвать** конкретное суждение.
- `replace-deterministic-now` — безопасная детерминированная замена сейчас.
- `replace-later/risky` — замена возможна, но позже / с риском (нужны предпосылки).
- `needs-hybrid-fallback` — детерминированное ядро + LLM-фолбэк только на суждение.
Каждому call-site присвоен **ровно один** класс, и класс **выводится из control-path-оси** (§0-bis,
FR-8), а не постулируется:
- **P (artifact-producer)** → `keep-LLM` — детерминированный гейт судит артефакт; авторская работа
требует суждения: `analyst`, `architect`, `developer`.
- **C + НЕ-деривируемый вердикт** → `keep-LLM` — control path, но суждение настоящее: `reviewer`
(назвать суждение: «приемлемость кода/решения», не сводится к exit-коду).
- **C + деривируемый вердикт** → avoidable → `replace-*` / `needs-hybrid-fallback`:
`deployer → replace-deterministic-now`/`replace-later/risky` (staging = exit-code-маппинг; прод
self-hosting уже детерминирован Phase A/B/C), `tester → needs-hybrid-fallback` (детерминированный
прогон `pytest`+smoke даёт PASS/FAIL; LLM-суждение только на триаж падений / маппинг TC↔критерии).
- `deploy-finalizer/post-deploy-monitor → already-deterministic` (вне таксономии замен, эталон).
> 📌 **Follow-up'ы — по роли, без Plane-ID (R3, NFR-6).** Кандидаты обозначаются ролью
> (deployer-замена, tester-гибрид), а не конкретными ID.
### FR-3 — Подтверждение детерминизма не-агентских путей (BR-3)
Карта отдельным разделом фиксирует, с `file:line`-доказательством, что НЕ консультируют LLM (не зависят
от суждения LLM ни через `_spawn`, ни иным транспортом): маршрутизация (`advance_stage`/
`STAGE_TRANSITIONS`), все `QG_CHECKS`/`check_*`, парсеры вердиктов (`_parse_*` через
`frontmatter.parse_frontmatter`), `error_classifier` (regex), под-гейты (security/merge/coverage/
image-freshness), `self_deploy` Phase A/B/C, reconciler/reaper/serial-gate/transition-lease. ⚠️ Эти
модули **спавнят subprocess'ы** (`git`/`pytest`/`docker`/`ssh`/сканеры) — это детерминированные
инструменты, **не** LLM-консультации (§0): доказательство детерминизма — отсутствие *LLM*-транспорта,
а не отсутствие *subprocess*. Любая найденная неожиданная LLM-консультация добавляется в инвентарь
(FR-1) и классифицируется (FR-2).
### FR-4 — Упорядоченный roadmap замен (BR-4)
Ранжированный список кандидатов (названных **по роли**); для каждого: зависимости (предпосылки/блокеры),
**оценка** экономии токенов/времени (из `agent_runs`), риск безопасности, нужен ли hybrid-fallback,
ожидание kill-switch/обратимости, и **тип будущей follow-up задачи по роли** (без конкретного
Plane-ID — заводится отдельно, NFR-6). Явно: **рекомендованный первый срез** + обоснование (самый
низкорисковый, «чисто деривируемый» control path, опирающийся на существующий прецедент D1/D2).
### FR-5 — Политика использования LLM (BR-5)
Нормативный durable-документ: принцип «LLM — только где нужно настоящее суждение»; критерии решения
keep vs replace, **сформулированные через ось §0-bis** (является ли путь control path — ветвится ли
`check_*` на LLM-выводе; деривируем ли вердикт из tool-сигналов; обратимость; влияние на автономность);
требование к новым/изменённым control-path'ам обосновывать любое использование LLM против политики.
Может включать рекомендацию reviewer-оси (как ORCH-079) — **как требование, не как реализацию гейта**
(новый QG не вводится, FR-6 §QG).
### FR-6 — Структурные анти-дрейф тесты (BR-6)
Новый offline-тест-файл (без сети/LLM/subprocess-к-модели), проверяющий инварианты карты. ⚠️ **R4 —
инвариант формулируется вокруг LLM-консультации/транспорта, а не «существования процесса Claude CLI».**
Дискриминатор тестов — **«консультирует LLM», а не «спавнит subprocess»**; десятки прочих subprocess
(`git`/`pytest`/`docker`/`ssh`/сканеры/`staging_check.py`) явно исключаются из матчинга.
- **(a) Единственный транспорт.** В `src/**` ровно **одна** точка сборки/запуска Claude CLI (матчинг
по совокупности признаков LLM-транспорта: `CLAUDE_BIN` + `--system-prompt` + `Popen`/`bash -c`), и
это `launcher._spawn`. *(Необходимое, но не достаточное — дополняется (f).)*
- **(f) Отсутствие иного LLM-транспорта (R4).** В `src/**` **нет** альтернативного транспорта
LLM-консультации: ни импорта `anthropic`/`openai`/иного LLM-SDK, ни прямого HTTP-эндпоинта
Anthropic/Claude (`api.anthropic.com`, `/v1/messages` и т.п.), ни второго model-invoking
subprocess-сборщика. Allowlist единственного разрешённого транспорта = `S0`.
- **(b) Нет консультации в детерминированных путях.** Перечисленные детерминированные модули и
обработчики `D1/D2` **не** консультируют LLM (ни `_spawn`-транспорта, ни альтернативного по (f)).
- **(c)** Карта перечисляет ровно те промпт-файлы, что физически лежат в `.openclaw/agents/`.
- **(d)** Классификация покрывает каждый перечисленный call-site **ровно один раз** (тотальность).
- **(e) Capability ≠ consultation.** `D1/D2` перехватываются в `launch_job` **до** `_spawn`
(`launcher.py:389/394`) → занимают слот, но консультации нет.
- **(g) Control-path-разметка верна (R5, TC-13).** Для каждой роли поле `axis` (C/P) карты **согласовано
с фактическим потребителем** в `src/qg/checks.py`: P-роли (`analyst`/`architect`/`developer`)
потребляются детерминированными гейтами (`check_analysis_complete`/`check_architecture_done`/
`check_ci_green`), C-роли (`reviewer`/`tester`/`deployer`) — verdict-парсерами
(`check_reviewer_verdict`/`_parse_tests_verdict`/`_parse_staging_status`/`_parse_deploy_status`).
Дрейф (роль переразмечена или потребитель в коде сменил природу) → красный.
- **(h) Avoidable-набор зафиксирован (R5, TC-14).** Множество «avoidable LLM control path» в карте =
ровно `{tester, deployer}`; `reviewer` помечен control-path-но-keep; `analyst`/`architect`/
`developer` — не control path. Любое добавление/удаление без обновления карты → красный.
> ❌ **Не вводить** тест, прибивающий карту к конкретным follow-up Plane-ID → ✅ тесты проверяют
> только инварианты, резолвящиеся в код/файлы репозитория (R3, NFR-6).
### FR-7 — Скоуп-гард (BR-7)
Раннеры замен **не реализуются** в ORCH-118. Карта/roadmap явно помечают кандидатов **по роли**
(deployer-замена, tester-гибрид) как follow-up, старт которых гейтится утверждением карты. Тест/диф не
должны содержать новых детерминированных раннеров tester/deployer. **Конкретные follow-up Plane-ID не
фиксируются** ни в одном артефакте (NFR-6).
### FR-8 (R5) — Явная control-path-ось и определение «avoidable» (BR-8/BR-9)
1. **Разметка оси.** Карта несёт для каждой консультации поле `axis` ∈ {C, P} (§0-bis) + доказательство
(поле `output consumer``check_*`/`_parse_*` с `file:line`). Разметка проверяема (FR-6g/TC-13).
2. **Определение термина.** Карта/политика дают **нормативное определение** «avoidable LLM control
path» — двухбитный предикат: (i) C-консультация (LLM-вердикт потребляется потоком управления) **И**
(ii) вердикт деривируем из tool-сигналов (exit-code `pytest`/smoke/`staging_check.py`/деплоя).
3. **Поимённый целевой набор.** Карта/roadmap **явно** называют `{tester, deployer}` как avoidable LLM
control paths, явно отделяя их от `reviewer` (C, но keep — суждение не деривируемо) и от
`analyst/architect/developer` (P — не control path). Набор проверяем (FR-6h/TC-14).
4. **Связь с классификацией.** Класс из FR-2 **выводится** из (C/P)-типа и деривируемости (а не наоборот).
## 4. Изменения API
Нет. (Опциональная read-only наблюдаемость в `GET /queue`/`GET /metrics`**вне скоупа** ORCH-118;
если архитектор сочтёт полезным — отдельная аддитивная врезка, но не требуется этой задачей.)
## 5. Изменения схемы БД
Нет. (Источник оценок экономии — существующие колонки `agent_runs`: `model`/`effort`/токены/стоимость/
время; только чтение.)
## 6. Требования к новым/изменённым QG checks
Нет. `QG_CHECKS` / `check_*` / `_parse_*` / machine-verdict-ключи — **байт-в-байт**. Структурные тесты
FR-6 (включая control-path TC-13/TC-14) — обычные `pytest`-тесты, **не** Quality Gate и **не** стадия.
Политика LLM (FR-5) — нормативный документ, а не машинный гейт. ⚠️ Control-path-ось — **аналитическая
разметка карты**, читающая `check_*` как ground-truth; она ничего в `src/qg/checks.py` не меняет.
## 7. Совместимость / регресс
- **Docs + tests only:** рантайм `src/**` не меняется → нулевая регрессия; enduro-trails не затронут;
kill-switch не нужен (нет рантайм-поведения), как в ORCH-077/079/101/102/103/011.
- **Обратимость:** артефакты — документы и тесты; откат = удаление/правка docs (рантайм-риска нет).
- **Анти-дрейф:** структурные тесты держат карту синхронной с кодом, включая control-path-ось
(TC-13/TC-14); норматив сопровождения — «менял места вызова LLM или потребителя вердикта в
`src/qg/checks.py` → обнови карту/разметку и политику в том же PR».
- **Анти-фабрикация (R3):** артефакты фиксируют только проверяемые ссылки; follow-up'ы — по роли,
без выдуманных Plane-ID (NFR-6).
- **Self-hosting:** не деплоит/не рестартит прод/не трогает `main` — безопасно для общего инстанса.

View File

@@ -0,0 +1,209 @@
---
work_item: ORCH-118
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-15
model_used: claude-opus-4-8
---
# 03 — Критерии приёмки (Acceptance Criteria): ORCH-118 — replace avoidable LLM control paths with deterministic implementations
Work Item: **ORCH-118** · Repo: **orchestrator** · Стадия: analysis
Формат: каждый критерий имеет **PASS** (что должно быть истинно для приёмки) и **FAIL** (что считается
провалом). Reviewer/тестер проверяет их буквально по файлам репозитория. Напоминание: ORCH-118 —
**inventory-first**, docs+tests only; реализация раннеров приёмкой **запрещена** в этой задаче (AC-7);
фиксация конкретных follow-up Plane-ID **запрещена** (AC-9, R3).
> 🔁 **R5.** Добавлены/уточнены критерии под **control-path-ось** (BRD §0-bis): среди реальных
> консультаций различаются (C) control-path (LLM-вердикт потребляется потоком управления) и
> (P) artifact-producer (детерминированный гейт судит артефакт); термин **«avoidable LLM control path»**
> явно определён, целевой набор `{tester, deployer}` поимённо назван. Затронуты **AC-1**, **AC-2** и
> новый **AC-10**; добавлены тесты **TC-13/TC-14**.
---
## AC-1 — Полнота и привязка инвентаря LLM-консультаций (+ control-path-разметка)
**Условие:** Документ-карта перечисляет каждый call-site, где control-path потребляет (или способен
потребить) суждение LLM — **единица = LLM-консультация, не «спавн процесса»** (R4) — с обязательными
полями, привязанными к коду, **включая ось (C/P) и потребителя вывода** (R5).
- **PASS:** Карта содержит `S0` (`launcher._spawn`**единственный транспорт LLM-консультации**), все
6 ролей (analyst/architect/developer/reviewer/tester/deployer, консультируют через S0) и обе job-роли
(deploy-finalizer/post-deploy-monitor, помеченные **«занимают слот агента, но LLM не консультируют»** —
перехват до `_spawn`); у каждой записи заполнены `location (file:line)` / `trigger` / `stage/owner` /
`output` / `machine-verdict key (если есть)` / **`output consumer` (`check_*`/`_parse_*` с `file:line`)** /
`est. tokens-runtime` / **`consults-LLM`** / **`axis` (C control-path / P artifact-producer)** /
`classification` / `rationale`; каждый `file:line` резолвится в реальный код. Карта явно разводит
«транспорт/слот существует» и «LLM фактически консультируется» (§0) **и** «consultation входит в поток
управления (C)» vs «детерминированный гейт судит артефакт (P)» (§0-bis).
- **FAIL:** Пропущен любой call-site; отсутствует любое обязательное поле (включая `output consumer`
или `axis`); `file:line` не резолвится; карта смешивает «процесс Claude CLI существует» с
«LLM-консультация происходит» (напр. помечает `D1/D2` консультирующими LLM); **или не размечает
ось C/P** (напр. называет analyst «control path», или не доказывает потребителем `check_*`); заявлен
второй транспорт LLM, не подтверждённый кодом.
---
## AC-2 — Классификация по таксономии (4 класса, тотально и однозначно, выведена из control-path-оси)
**Условие:** Каждый перечисленный call-site отнесён ровно к одному классу с обоснованием,
**выведенным** из оси §0-bis.
- **PASS:** Таксономия определена явно (`keep-LLM` / `replace-deterministic-now` /
`replace-later/risky` / `needs-hybrid-fallback`); каждому site присвоен **ровно один** класс; класс
согласован с осью: **P → keep-LLM** (analyst/architect/developer), **C + не-деривируемый вердикт →
keep-LLM** (reviewer, с названным конкретным суждением, не сводимым к exit-коду), **C + деривируемый
вердикт → replace-\*/hybrid** (tester/deployer); у `keep-LLM`-записей назван **конкретный** вид
суждения, ради которого LLM сохраняется.
- **FAIL:** Site не классифицирован / классифицирован дважды; класс вне таксономии; `keep-LLM` без
названного суждения; **класс противоречит оси** (напр. control-path-deployer помечен `keep-LLM` без
доказательства не-деривируемости вердикта, или artifact-producer-analyst помечен `replace-*`).
---
## AC-3 — Доказанный детерминизм не-агентских путей
**Условие:** Карта отдельно фиксирует, что control-path'ы вне 6 агентов не консультируют LLM, с доказательством.
- **PASS:** Перечислены маршрутизация (`advance_stage`/`STAGE_TRANSITIONS`), все `QG_CHECKS`/`check_*`,
парсеры `_parse_*`, `error_classifier`, под-гейты (security/merge/coverage/image-freshness),
`self_deploy` Phase A/B/C, reconciler/reaper/serial-gate/transition-lease — каждый с `file:line`,
подтверждающим отсутствие **LLM-консультации** (ни `_spawn`-транспорта, ни альтернативного). Их
subprocess-вызовы инструментов (`git`/`pytest`/`docker`/`ssh`/сканеры) явно квалифицированы как
**не-LLM** (детерминизм доказывается отсутствием LLM-транспорта, а не отсутствием subprocess).
- **FAIL:** Утверждение о детерминизме без `file:line`-доказательства; путь, заявленный
детерминированным, фактически консультирует LLM (и это не отражено в инвентаре/классификации); либо
детерминизм «доказан» простым отсутствием subprocess (подмена дискриминатора, §0).
---
## AC-4 — Упорядоченный roadmap с обязательными атрибутами и первым срезом
**Условие:** Есть ранжированный roadmap детерминированных замен.
- **PASS:** Roadmap упорядочен; для каждого кандидата (названного **по роли**) указаны зависимости,
**оценка** экономии токенов/времени (со ссылкой на источник — `agent_runs`/`usage`), риск
безопасности, потребность в hybrid-fallback, ожидание kill-switch/обратимости и **тип follow-up
задачи по роли** (без конкретного Plane-ID); явно назван **рекомендованный первый срез** с
обоснованием (самый низкорисковый «чисто деривируемый» control path).
- **FAIL:** Roadmap не упорядочен; у кандидата отсутствует любой обязательный атрибут; оценка экономии
не привязана к источнику; нет рекомендованного первого среза; кандидат привязан к выдуманному
Plane-ID (→ см. AC-9).
---
## AC-5 — Нормативная политика использования LLM
**Условие:** Существует durable-документ политики.
- **PASS:** Политика формулирует принцип «LLM — только где нужно настоящее суждение», даёт критерии
решения keep vs replace **через ось §0-bis** (control path ли это; деривируем ли вердикт) и требование
обосновывать любое новое использование LLM против политики; документ нормативный (durable, в `docs/`),
а не разовая заметка.
- **FAIL:** Политика отсутствует; не нормативна; не опирается на control-path-критерий; противоречит
сохранению автономности (NFR-2).
---
## AC-6 — Структурные анти-дрейф тесты: зелёные и осмысленные
**Условие:** Новый offline-тест-файл прибивает инварианты карты к коду; инвариант сформулирован вокруг
**LLM-консультации/транспорта** (R4) **и control-path-оси** (R5), а не «существования процесса Claude CLI».
- **PASS:** Тесты проверяют: (a) единственный транспорт LLM-консультации в `src/**` (= `launcher._spawn`);
**(f) отсутствие любого иного LLM-транспорта** (нет импорта `anthropic`/`openai`/LLM-SDK, нет прямого
HTTP-эндпоинта Anthropic/Claude, нет второго model-invoking subprocess-сборщика); (b) отсутствие
LLM-консультации в перечисленных детерминированных модулях и в обработчиках deploy-finalizer/
post-deploy-monitor; (c) двустороннюю сверку списка промптов карты с `.openclaw/agents/`;
(d) тотальность классификации; (e) перехват `D1/D2` в `launch_job` до `_spawn`; **(g) корректность
control-path-разметки (TC-13)** — `axis` каждой роли согласован с фактическим потребителем в
`src/qg/checks.py` (P-роли → `check_analysis_complete`/`check_architecture_done`/`check_ci_green`;
C-роли → `check_reviewer_verdict`/`_parse_tests_verdict`/`_parse_staging_status`/`_parse_deploy_status`);
**(h) фиксацию avoidable-набора (TC-14)** — множество avoidable LLM control paths = `{tester, deployer}`,
reviewer = control-path-keep, analyst/architect/developer = не control path. Дискриминатор тестов —
**«консультирует LLM», а не «спавнит subprocess»**. Тесты не используют сеть/LLM/subprocess-к-модели.
Полный `pytest tests/ -q` — зелёный.
- **FAIL:** Тестов нет; тест тривиально проходит (не привязан к коду); инвариант проверяет лишь «один
`Popen` Claude CLI» **без** (f); **отсутствует control-path-инвариант (g/h)** — карта могла бы помечать
analyst «control path» или забыть deployer в avoidable-наборе, и тест бы это пропустил (корень
R4-блокера); тест выродился в «подсчёт всех subprocess»; любой тест красный; полный прогон `tests/`
падает; введён тест, прибивающий карту к конкретным follow-up Plane-ID (анти-паттерн R3).
---
## AC-7 — Скоуп и сохранение поведения
**Условие:** ORCH-118 не меняет рантайм и не реализует раннеры.
- **PASS:** Диф не меняет `STAGE_TRANSITIONS` / реестр и имена `QG_CHECKS`/`check_*` /
machine-verdict-ключи / схему БД; в `src/**` нет нового детерминированного раннера tester/deployer
(раннеры замен не реализованы); изменения ограничены docs + новый(е) тест-файл(ы).
- **FAIL:** Изменён любой из перечисленных рантайм-контрактов; реализован детерминированный раннер
замены; правки `src/**`-поведения вне инвентаря/тестов.
---
## AC-8 — Синхронизация golden-source документации
**Условие:** Обзорные/архитектурные доки и CHANGELOG отражают новые артефакты.
- **PASS:** `docs/architecture/README.md` и витрина `docs/overview/*` ссылаются на карту call-site'ов
и политику использования LLM; `CHANGELOG.md` обновлён в том же PR.
- **FAIL:** Карта/политика введены, но golden-source доки/витрина/CHANGELOG не обновлены (ось ORCH-079/
ORCH-011 → finding ≥P1).
---
## AC-9 — Только проверяемые ссылки; follow-up'ы — по роли, без выдуманных ID (анти-фабрикация, R3)
**Условие:** Ни один артефакт не фиксирует непроверяемых follow-up Plane-ID; кандидаты-замены
именуются по роли.
- **PASS:** Везде, где карта/BRD/TRZ/roadmap/ADR упоминают кандидата-замену, он назван **по роли**
(deployer-замена, tester-гибрид); конкретные follow-up Plane-ID **не указаны**; все `file:line`/
ссылки на документы резолвятся в репозиторий. Тип follow-up'а описан по роли, ID — «TBD / при
заведении задачи».
- **FAIL:** Любой артефакт фиксирует конкретный follow-up Plane-ID (напр. `ORCH-115`/`ORCH-116`) как
нормативную привязку роли; присутствует ссылка/маркер, не резолвящийся в код/документ репозитория;
введён структурный тест, прибивающий карту к конкретному follow-up ID.
> Контекст: AC-9 заменяет ранее ошибочный «канонический-маппинг» критерий ревизии R2. Корень
> отклонения R2 — фиксация **несуществующих** ID (`ORCH-115`/`ORCH-116`) как нормативного инварианта
> с непроверяемым источником («live Plane backlog»). R3 запрещает фабрикацию ID и переводит follow-up'ы
> на именование по роли.
---
## AC-10 (R5) — Явная control-path-ось и определение «avoidable LLM control path»
**Условие:** Артефакты **явно** различают control-path-консультации и artifact-producer-консультации и
**явно** определяют целевой термин — это закрывает единственный оставшийся блокер R4-ревью (название
задачи — «replace avoidable LLM **control paths**»).
- **PASS:**
1. **Ось определена и применена.** Карта/политика явно вводят ось (C) control-path (LLM-вердикт
потребляется потоком управления — `check_*` ветвится на нём) vs (P) artifact-producer
(детерминированный гейт судит артефакт; суждение LLM в control flow не входит), и присваивают
**ровно один** тип каждой из 6 ролей с доказательством-потребителем (`check_*`/`_parse_*`,
`file:line`): P = `analyst`/`architect`/`developer`; C = `reviewer`/`tester`/`deployer`.
2. **Термин определён.** Дано нормативное определение «avoidable LLM control path» = двухбитный
предикат: (i) C-консультация **И** (ii) вердикт деривируем из tool-сигналов (exit-code `pytest`/
smoke/`staging_check.py`/деплоя).
3. **Целевой набор поимённо назван.** Артефакты явно называют **avoidable LLM control paths =
{tester, deployer}**, и явно отделяют: `reviewer` — control path, но **keep** (вердикт не
деривируем, настоящее суждение); `analyst`/`architect`/`developer`**не** control path
(artifact-producer). Это согласовано с классификацией (AC-2) и закреплено тестами TC-13/TC-14.
- **FAIL:** Артефакты упоминают «LLM control paths» без явного определения; не размечают ось C/P
по ролям или размечают её без доказательства-потребителя; не определяют «avoidable» как
проверяемый предикат; не называют поимённо целевой набор `{tester, deployer}` **или** не отделяют его
от reviewer (control-path-keep) и от analyst/architect/developer (не control path); разметка не
согласована с `src/qg/checks.py` / не закреплена TC-13/TC-14.
---
## Сводная матрица AC ↔ FR/BR
| AC | Покрывает |
|----|-----------|
| AC-1 | BR-1 / FR-1 / BR-8 / FR-8 |
| AC-2 | BR-2 / FR-2 (выведена из §0-bis) |
| AC-3 | BR-3 / FR-3 |
| AC-4 | BR-4 / FR-4 |
| AC-5 | BR-5 / FR-5 |
| AC-6 | BR-6 / FR-6 (вкл. TC-13/TC-14) |
| AC-7 | BR-7 / FR-7 / NFR-1 / NFR-3 |
| AC-8 | NFR-4 / правила агентов §2,§6 (golden-source) |
| AC-9 | NFR-6 / BR-7 / FR-7 (только проверяемые ссылки; follow-up'ы по роли, без выдуманных ID) |
| AC-10 | **BR-8 / BR-9 / FR-8 / NFR-7 (R5 — control-path-ось + определение «avoidable»)** |

View File

@@ -0,0 +1,132 @@
work_item: ORCH-118
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-15
model_used: claude-opus-4-8
title: "LLM call-site inventory + control-path axis + classification + roadmap + usage policy (inventory-first, docs+tests only)"
framework: pytest
scope: >
Покрываются СТРУКТУРНЫЕ инварианты карты LLM-консультаций и анти-дрейф (FR-6), плюс скоуп-гард
(рантайм-контракты не тронуты, раннеры не реализованы) и анти-фабрикация ссылок/ID (TC-11).
Единица — LLM-КОНСУЛЬТАЦИЯ (control-path потребляет суждение LLM), а не «спавн процесса / Claude
CLI существует» (R4, BRD §0). Инвариант единственной точки — транспорт-агностичный и двусторонний:
TC-01 (единственный транспорт = _spawn) + TC-12 (отсутствует иной LLM-транспорт).
R5: добавлена CONTROL-PATH-ОСЬ (BRD §0-bis) — среди реальных консультаций различаются
(C) control-path (LLM-вердикт потребляется потоком управления, check_* ветвится на нём) и
(P) artifact-producer (детерминированный гейт судит артефакт); термин «avoidable LLM control path»
определён как двухбитный предикат (C И вердикт деривируем из tool-сигналов), целевой набор поимённо
= {tester, deployer}. Эту ось проверяют TC-13 (разметка C/P согласована с потребителем в
src/qg/checks.py) и TC-14 (avoidable-набор зафиксирован). ВНЕ покрытия: реализация детерминированных
раннеров deployer / tester — отдельные follow-up задачи (именуются по роли; конкретные Plane-ID в
ORCH-118 не фиксируются, R3/NFR-6).
notes: >
Все тесты детерминированы и offline: без сети, без запуска LLM, без subprocess-к-модели.
Имена файла теста и документов карты — примерные (финально решает архитектор); тест-кейсы
привязываются к фактическим путям артефактов, выбранным в 06-adr. Полный регресс tests/
должен оставаться зелёным (TC-10). Дискриминатор всех structural-тестов — "консультирует LLM",
а НЕ "спавнит subprocess": прочие subprocess (git/pytest/docker/ssh/сканеры/staging_check.py) явно
исключаются из матчинга, иначе тест выродился бы в подсчёт всех Popen. Регрессом считается:
появление второго ТРАНСПОРТА LLM-консультации (новый _spawn ИЛИ импорт anthropic/openai/LLM-SDK ИЛИ
прямой HTTP Anthropic/Claude ИЛИ второй model-invoking subprocess), LLM-консультация в
детерминированном модуле, дрейф карты относительно .openclaw/agents/, изменение рантайм-контрактов
(STAGE_TRANSITIONS / QG_CHECKS / check_* / machine-verdict / схема БД), рассогласование
control-path-разметки с фактическим потребителем в src/qg/checks.py (TC-13), либо изменение
avoidable-набора без обновления карты (TC-14).
R5 (единственный блокер R4-ревью): артефакты разводили "консультация ≠ транспорт/слот", но не делали
явной CONTROL-PATH-ОСЬ — самую важную для названия задачи "replace avoidable LLM CONTROL PATHS".
Добавлены TC-13 (control-path-разметка C/P доказывается фактическим потребителем check_*/_parse_*) и
TC-14 (avoidable LLM control paths = {tester, deployer}; reviewer = control-path-keep;
analyst/architect/developer = не control path). TC-04 (тотальность) теперь сверяет согласованность
класса с осью.
R4 (предыдущий блокер): инвариант "места вызова LLM" разведён на ТРАНСПОРТ и КОНСУЛЬТАЦИЮ;
TC-01 уточнён (необходимое, но не достаточное), добавлен TC-12 (no-alternative-transport),
TC-02 уточнён по дискриминатору, TC-06 закрепляет capability ≠ consultation (D1/D2 — слот без
консультации).
R3: тест на привязку follow-up'ов к конкретным Plane-ID УДАЛЁН (бывш. TC-11) как анти-паттерн;
TC-11 теперь проверяет анти-фабрикацию (ID не выдуманы).
tests:
- id: TC-01
type: unit
description: "Единственный ТРАНСПОРТ LLM-консультации: ровно одно место в src/** собирает/запускает Claude CLI (матчинг по совокупности признаков LLM-транспорта CLAUDE_BIN + --system-prompt + Popen/bash -c), и это launcher._spawn. Необходимое, но НЕ достаточное условие — дополняется TC-12 (отсутствие иного транспорта) (R4 / FR-6a / AC-1)"
module: tests/test_llm_call_site_inventory.py
expected: PASS
- id: TC-02
type: unit
description: "Детерминированные модули без LLM-консультации: перечисленные leaf'ы (serial_gate, merge_gate, coverage_gate, security_gate, staging_verdict, review_parse, error_classifier, frontmatter, self_deploy, post_deploy, transition_lease, reconciler, job_reaper) не консультируют LLM (нет ни _spawn-транспорта, ни альтернативного по TC-12); их subprocess-вызовы git/pytest/docker/ssh/сканеров LLM-консультацией НЕ считаются — дискриминатор 'консультирует LLM', а не 'спавнит subprocess' (R4 / FR-6b / AC-3)"
module: tests/test_llm_call_site_inventory.py
expected: PASS
- id: TC-03
type: unit
description: "Анти-дрейф промптов: карта перечисляет ровно те 6 промпт-файлов, что физически лежат в .openclaw/agents/ (двусторонняя сверка, нет лишних/пропущенных) (FR-6c / AC-1)"
module: tests/test_llm_call_site_inventory.py
expected: PASS
- id: TC-04
type: unit
description: "Тотальность классификации: каждый перечисленный в карте call-site отнесён ровно к одному классу из таксономии {keep-LLM, replace-deterministic-now, replace-later/risky, needs-hybrid-fallback}; без дублей и пропусков; класс СОГЛАСОВАН с осью §0-bis (P → keep-LLM; C+не-деривируем → keep-LLM; C+деривируем → replace-*/hybrid) (FR-6d / FR-2 / AC-2)"
module: tests/test_llm_call_site_inventory.py
expected: PASS
- id: TC-05
type: unit
description: "keep-LLM требует обоснования: каждая запись класса keep-LLM несёт непустое поле названного конкретного суждения; для C-keep (reviewer) обоснование явно фиксирует НЕ-деривируемость вердикта (почему не сводится к exit-коду) (FR-2 / AC-2)"
module: tests/test_llm_call_site_inventory.py
expected: PASS
- id: TC-06
type: unit
description: "Capability ≠ consultation: launch_job перехватывает deploy-finalizer и post-deploy-monitor ДО _spawn (launcher.py:389/394) — job занимает слот агента, но LLM НЕ консультируется (процесс/слот существует, суждение не потребляется) — эталон паттерна замены и прямая иллюстрация R4-различия (FR-6e / AC-3)"
module: tests/test_llm_call_site_inventory.py
expected: PASS
- id: TC-07
type: unit
description: "Полнота roadmap: документ roadmap для каждого кандидата (названного ПО РОЛИ) содержит обязательные атрибуты (зависимости / оценка экономии со ссылкой на agent_runs / риск / hybrid-need / тип follow-up задачи по роли) и явно называет рекомендованный первый срез (FR-4 / AC-4)"
module: tests/test_llm_determinization_docs.py
expected: PASS
- id: TC-08
type: unit
description: "Политика LLM существует и нормативна: документ политики содержит принцип 'LLM только где нужно суждение', критерии keep vs replace СФОРМУЛИРОВАННЫЕ через ось §0-bis (control path ли это; деривируем ли вердикт), и нормативное определение термина 'avoidable LLM control path' (FR-5 / FR-8 / AC-5, AC-10)"
module: tests/test_llm_determinization_docs.py
expected: PASS
- id: TC-09
type: integration
description: "Скоуп-гард рантайм-контрактов: снимок set ролей-агентов из STAGE_TRANSITIONS и набора имён QG_CHECKS не изменился относительно эталона — ORCH-118 не тронул машину стадий/гейты (FR-7 / AC-7)"
module: tests/test_llm_call_site_inventory.py
expected: PASS
- id: TC-10
type: integration
description: "Полный регресс tests/ остаётся зелёным (pytest tests/ -q) — инвентаризация и тесты не ломают существующий конвейер (NFR-1 / AC-6, AC-7)"
module: tests/
expected: PASS
- id: TC-11
type: unit
description: "Анти-фабрикация follow-up ID (R3 / NFR-6 / AC-9): документы карты/roadmap НЕ содержат привязки кандидатов-замен к конкретным follow-up Plane-ID несуществующих work item (паттерн ORCH-1\\d\\d, не равный самому ORCH-118 и не присутствующий в docs/work-items/); кандидаты именуются по роли. Заменяет ошибочный mapping-тест R2, прибивавший карту к выдуманным ID."
module: tests/test_llm_determinization_docs.py
expected: PASS
- id: TC-12
type: unit
description: "Отсутствие иного LLM-транспорта (R4 / FR-6f / AC-1, AC-6): в src/** НЕТ альтернативного транспорта LLM-консультации помимо _spawn — ни импорта anthropic/openai/иного LLM-SDK, ни прямого HTTP-эндпоинта Anthropic/Claude (api.anthropic.com, /v1/messages), ни второго model-invoking subprocess-сборщика (другой бинарь с --system-prompt/--model). Закрывает дыру 'один _spawn зелёный, а рядом проросла новая консультация другим транспортом', которую TC-01 в одиночку не ловит. Allowlist единственного разрешённого транспорта = S0/launcher._spawn."
module: tests/test_llm_call_site_inventory.py
expected: PASS
- id: TC-13
type: unit
description: "Control-path-ось верна (R5 / FR-6g / FR-8 / AC-10): поле axis (C/P) каждой из 6 ролей в карте СОГЛАСОВАНО с фактическим потребителем вывода в src/qg/checks.py — P-роли (analyst/architect/developer) потребляются детерминированными гейтами (check_analysis_complete:33 / check_architecture_done:62 / check_ci_green:82, судящими наличие файлов / CI, НЕ самоотчёт LLM); C-роли (reviewer/tester/deployer) потребляются verdict-парсерами, читающими machine-verdict, который написал LLM (check_reviewer_verdict:336 'verdict:' / check_tests_passed:182→_parse_tests_verdict:226 'result:' / check_staging_status:599→_parse_staging_status:538 'staging_status:' + check_deploy_status:473→_parse_deploy_status:413 'deploy_status:'). Дискриминатор: 'LLM-вердикт ветвит поток управления', а не 'на стадии есть LLM'. Рассогласование (роль переразмечена ИЛИ потребитель в коде сменил природу) → красный."
module: tests/test_llm_call_site_inventory.py
expected: PASS
- id: TC-14
type: unit
description: "Avoidable-набор зафиксирован (R5 / FR-6h / FR-8 / AC-10): множество 'avoidable LLM control path' в карте = РОВНО {tester, deployer} (C И вердикт деривируем из exit-кодов); reviewer помечен control-path-но-keep (C, вердикт НЕ деривируем — настоящее суждение); analyst/architect/developer помечены НЕ control path (P, artifact-producer). Любое добавление/удаление роли в avoidable-набор без обновления карты, либо пометка analyst/architect/developer 'control path', либо пометка reviewer 'avoidable' → красный. Закрывает корень R4-блокера: 'есть LLM на стадии' ≠ 'это avoidable LLM control path'."
module: tests/test_llm_call_site_inventory.py
expected: PASS

View File

@@ -0,0 +1,295 @@
---
work_item: ORCH-118
stage: architecture
author_agent: architect
status: accepted
created_at: 2026-06-15
model_used: claude-opus-4-8
---
# ADR-001: Карта LLM-консультаций, control-path-ось «avoidable» и roadmap детерминизации
Work Item: **ORCH-118** — replace avoidable LLM control paths with deterministic implementations
Стадия: **architecture**
Сквозная регистрация: **`docs/architecture/adr/adr-0047-llm-usage-policy-and-call-site-map.md`**
(решение кросс-каттинговое — вводит нормативную политику использования LLM для всего оркестратора и
снимок-карту, прибитую к коду тестами).
## Статус
Accepted
## Контекст
ORCH-118 — **зонтичная inventory/architecture-задача** (RCA-трек ORCH-114/117 и предшественники
110/111/112/113): корневым классом инцидентов было отсутствие **единого детерминированного владения**
у side-effectful и решающих control-path'ов — местами решение принималось LLM-агентом «потому что
удобно», хотя по сути это исполнение фиксированных команд и маппинг результата. Задача **не**
реализует детерминированные раннеры (это follow-up'ы); её выход — **доказательная карта** всех мест
вызова LLM + классификация + roadmap + нормативная политика, защищённые структурными тестами.
ТЗ (02-trz) оставляет **архитектору** решить «как»: структуру/размещение/формат документов карты,
схему классификации, дизайн структурных тестов, рекомендованный первый срез. Этот ADR это фиксирует.
**Факты, сверенные с кодом на момент задачи (ground-truth):**
- **Единственный транспорт LLM-консультации в `src/**`** — `src/agents/launcher.py::_spawn` (`def`
`launcher.py:472`; сборка CLI `f'{self.CLAUDE_BIN} --print … --system-prompt "$(cat {system_prompt})"'`
`launcher.py:610-614`; парс токенов из CLI-JSON — `_monitor_agent`, `launcher.py:838`). Им
пользуются ровно **6 ролей** (`.openclaw/agents/{analyst,architect,developer,reviewer,tester,
deployer}.md` — подтверждено `ls .openclaw/agents/`). **Иного LLM-транспорта нет:**
`grep -rnE "import (anthropic|openai)|api\.anthropic\.com|/v1/messages" src/ watchdog/` → пусто;
`CLAUDE_BIN` вне `_spawn` встречается только в `src/preflight.py` (проверка `os.path.exists`, **не**
инференс) и `src/config.py:65` (литерал дефолта пути). Это критично для дизайна теста (D5).
- **Потребитель вывода каждой роли** (`src/qg/checks.py`, все `file:line` резолвятся):
`check_analysis_complete:33` (наличие файлов), `check_architecture_done:62` (наличие 06-adr/07),
`check_ci_green:82` + `check_branch_mergeable:657` (CI/merge), `check_reviewer_verdict:336`
(`verdict:`), `check_tests_passed:182``_parse_tests_verdict:226` (`result:`),
`check_staging_status:599``_parse_staging_status:538` (`staging_status:`),
`check_deploy_status:473``_parse_deploy_status:413` (`deploy_status:`).
- **Перехват D1/D2 до `_spawn`:** `launch_job:377` возвращает рано для `agent=="deploy-finalizer"`
(`launcher.py:389`) и `agent=="post-deploy-monitor"` (`launcher.py:394`) — код прямо помечает «Not an
LLM spawn» (`launcher.py:407,428`). Слот агента занят, но консультации LLM нет — **рабочий прецедент
детерминированной замены агента**.
- **Не-агентские control-path'ы уже детерминированы** (LLM-консультации не несут — подтверждено
наличием модулей): `src/{stages,stage_engine,staging_verdict,self_deploy,error_classifier,
frontmatter,serial_gate,merge_gate,coverage_gate,security_gate,post_deploy,transition_lease,
reconciler,job_reaper}.py`. Их subprocess-вызовы (`git`/`pytest`/`docker`/`ssh`/сканеры) —
детерминированные **инструменты**, а не LLM.
Без архитектурной фиксации «как» развести **три ортогональных факта** (транспорт/слот ≠ консультация
≠ control-path) и без нормативного определения «avoidable LLM control path» карта осталась бы
субъективной, а тесты — тривиальными (корень R4/R5-блокеров BRD).
## Решение
### Сводка
Фиксируем: (D1) набор и размещение durable-документов; (D2) схему записи инвентаря; (D3) три
ортогональных оси и **нормативное определение** «avoidable LLM control path»; (D4) таксономию и
правило **вывода** класса из осей с поимённой канонической таблицей ролей (= «фиксация карты»);
(D5) дизайн структурных анти-дрейф тестов; (D6) рекомендованный первый срез roadmap'а; (D7)
скоуп-гард. ORCH-118 — **docs + tests only**: `STAGE_TRANSITIONS` / реестр и имена `QG_CHECKS`/
`check_*` / machine-verdict-ключи / схема БД — **байт-в-байт не тронуты**; раннеры замен **не**
реализуются; конкретные follow-up Plane-ID **не** фиксируются (NFR-6).
### D1 — Набор и размещение документов (BR-1/BR-4/BR-5; FR-1/FR-4/FR-5; AC-1/AC-4/AC-5)
Три durable-документа размещаются в **`docs/architecture/`** (сквозные, переживают задачу, читаются
будущими follow-up'ами) — НЕ только в `docs/work-items/ORCH-118/`:
| Файл | Роль | Жизненный цикл |
|------|------|----------------|
| `docs/architecture/llm-call-sites.md` | **Карта** call-site'ов: инвентарь + control-path-разметка + классификация (D2/D3/D4) | **Снимок**, прибит тестами (D5); обновляется при дрейфе кода |
| `docs/architecture/llm-determinization-roadmap.md` | **Roadmap** замен: порядок, экономия, риски, первый срез (D6) | Транзиентный план; обновляется по мере закрытия follow-up'ов |
| `docs/architecture/llm-usage-policy.md` | **Политика**: принцип + критерии keep/replace через ось §0-bis + **определение «avoidable LLM control path»** (D3) | Нормативный durable-документ |
**Решение о разделении на 3 файла** (а не один): у них разные аудитория и жизненный цикл — карта
машинно-сверяется и есть снимок; roadmap транзиентен; политика нормативна и стабильна. Слияние
размыло бы тестируемость карты и стабильность политики.
> **Авторство.** Содержательное «как» (структура, поля, оси, классификация, дизайн тестов, первый
> срез) фиксирует **этот ADR** (он и есть «фиксация карты» по TRZ §2). Физическое создание трёх
> `docs/architecture/llm-*.md` + тест-файла + синхронизация golden-source (README/overview/CHANGELOG)
> — деливерабл **стадии development** строго по этому ADR. Канонические таблицы D3/D4 ниже —
> **source of truth**, которую развёрнутые документы и тесты **зеркалят** без расхождений.
### D2 — Схема записи инвентаря (BR-1/BR-8; FR-1/FR-8; AC-1)
Каждая строка карты несёт **обязательные поля** (порядок — нормативный, тест D5 проверяет наличие):
`id` · `location (file:line)` · `trigger` · `stage/owner` · `output artifact` · `machine-verdict key`
(если есть) · **`output consumer`** (`check_*`/`_parse_*` с `file:line` — кто потребляет вывод роли) ·
`est. tokens/runtime` (источник — `agent_runs`, помечено «оценка») · **`consults-LLM`** (consultation
vs LLM-capable transport/slot, §0.3 BRD) · **`axis`** (C control-path / P artifact-producer, §0-bis) ·
`classification` (D4) · `rationale` · `dependency` · `risk`.
Каждый `file:line` **обязан резолвиться** в реальный код (тест D5 точечно проверяет ключевые якоря).
**Машинно-читаемый якорь для тестов.** Карта несёт в `llm-call-sites.md` **канонический
markdown-блок** со стабильным заголовком таблицы (колонки `id | role | location | output_consumer |
consults_llm | axis | avoidable | classification`). Тест D5 парсит этот блок (split по `|`, без новых
зависимостей) и сверяет с кодом. Это держит документ человекочитаемым и одновременно
машинно-проверяемым (вместо хрупкого regex по прозе).
### D3 — Три ортогональных оси и нормативное определение «avoidable LLM control path» (BR-8/BR-9; FR-8; AC-10; NFR-7)
Карта/политика **явно** вводят три раздельных факта (их смешение — корень R3→R5-блокеров):
1. **Ось 1 — consultation ≠ transport/slot.** «LLM-консультация» = точка, где решение/артефакт
конвейера **потребляет суждение LLM**. Транспорт (`_spawn`) — реализация, не определение. Слот
агента (D1/D2 job-роли) делает site LLM-**capable**, но консультация гейтится потоком управления
(перехват до `_spawn`) → capability ≠ consultation.
2. **Ось 2 — control-path (C) ≠ artifact-producer (P).** Определяется **кодом-потребителем**:
- **(C) control-path** — LLM эмитит machine-verdict, на котором **ветвится `check_*`-гейт**
(PASS→дальше / FAIL→откат). Суждение LLM входит в control flow.
- **(P) artifact-producer** — LLM производит артефакт, а продвижение решает **детерминированный
гейт**, судящий артефакт **независимо** (наличие файлов / CI). Суждение LLM в control flow не
входит.
3. **Ось 3 — деривируемость вердикта.** Вердикт C-консультации либо есть **детерминированная функция
tool-сигналов** (exit-code `pytest`/smoke/`staging_check.py`/деплоя), которые оркестратор **уже
вычисляет сам**, либо требует **настоящего суждения**, не сводимого к exit-коду.
**Нормативное определение (фиксируется в `llm-usage-policy.md`):**
> Call-site — **avoidable LLM control path** ⟺ выполнены **оба** условия:
> **(i)** это **C**-консультация (её LLM-вердикт потребляется потоком управления — `check_*` ветвится
> на нём); **и** **(ii)** вердикт **деривируем** из tool-сигналов, которые оркестратор уже вычисляет
> → суждение LLM не добавляет информации → консультацию можно снять без потери смысла.
Это **двухбитный проверяемый предикат над `src/qg/checks.py`**, а не «удобство на глаз».
### D4 — Таксономия и каноническая классификация (= «фиксация карты») (BR-2; FR-2; AC-2)
Четыре взаимоисключающих класса; класс **выводится** из осей D3 (а не постулируется):
- `keep-LLM` — нужно настоящее суждение (обязательно **назвать** конкретное суждение).
- `replace-deterministic-now` — безопасная детерминированная замена сейчас.
- `replace-later/risky` — замена возможна позже / с предпосылками.
- `needs-hybrid-fallback` — детерминированное ядро + LLM-фолбэк только на суждение.
**Правило вывода:** `P → keep-LLM`; `C + не-деривируемый вердикт → keep-LLM`; `C + деривируемый
вердикт → replace-* / needs-hybrid-fallback (= avoidable)`.
**Каноническая таблица (source of truth; карта и тесты зеркалят её байт-в-смысл):**
| id | Роль | Транспорт/слот | Потребитель вывода (`src/qg/checks.py`) | Ось | Avoidable LLM control path? | Класс | Названное суждение (для keep) |
|----|------|----------------|------------------------------------------|-----|------------------------------|-------|-------------------------------|
| S0 | `_spawn` | транспорт | — | — | — (транспорт) | — | — |
| A1 | analyst | да (через S0) | `check_analysis_complete:33` (наличие файлов) | **P** | нет | `keep-LLM` | анализ требований, BRD/ТЗ — суждение |
| A2 | architect | да (через S0) | `check_architecture_done:62` (наличие 06-adr/07) | **P** | нет | `keep-LLM` | архитектурное решение/ADR — суждение |
| A3 | developer | да (через S0) | `check_ci_green:82` + `check_branch_mergeable:657` (CI/merge) | **P** | нет | `keep-LLM` | написание кода — суждение |
| A4 | reviewer | да (через S0) | `check_reviewer_verdict:336` (`verdict:`) | **C** | **нет** (вердикт НЕ деривируем) | `keep-LLM` | «приемлемость кода/решения» — не сводится к exit-коду |
| A5 | tester | да (через S0) | `check_tests_passed:182``_parse_tests_verdict:226` (`result:`) | **C** | **ДА** | `needs-hybrid-fallback` | (ядро детерминировано; LLM — триаж падений / маппинг TC↔критерии) |
| A6 | deployer | да (через S0) | `check_staging_status:599``_parse_staging_status:538` (`staging_status:`); `check_deploy_status:473``_parse_deploy_status:413` (`deploy_status:`) | **C** | **ДА** | `replace-deterministic-now` | (вердикт = `staging_check.py`/exit-code; прод уже детерминирован Phase A/B/C ORCH-036) |
| D1 | deploy-finalizer | слот, перехват до `_spawn` (`launcher.py:389`) | — | — | — (уже детерминирован) | `already-deterministic` (эталон) | — |
| D2 | post-deploy-monitor | слот, перехват до `_spawn` (`launcher.py:394`) | — | — | — (уже детерминирован) | `already-deterministic` (эталон) | — |
**Итог (поимённо, проверяется тестами D5):** `avoidable LLM control paths = {tester, deployer}`;
control-path-но-keep = `{reviewer}`; не-control-path (P) = `{analyst, architect, developer}`;
already-deterministic-эталон = `{deploy-finalizer, post-deploy-monitor}`.
> **Уточнение по deployer (точность карты).** Роль `deployer` охватывает два ребра. На `deploy-staging`
> (`staging_status:`) её вердикт — чистый маппинг exit-кода `staging_check.py` → `replace-deterministic
> -now`. На `deploy` (`deploy_status:`) для self-hosting `orchestrator` вердикт **уже** производит
> детерминированный finalizer (Phase C, ORCH-036), LLM в критическом self-restart-пути нет; для прочих
> репо deployer-агент делает синхронный ssh-деплой. Поэтому «чисто деривируемый» срез deployer'а
> прежде всего **staging-status** (см. D6).
### D5 — Дизайн структурных анти-дрейф тестов (BR-6; FR-6; AC-6)
Новый offline-файл `tests/test_llm_call_site_inventory.py` (без сети/LLM/subprocess-к-модели; маркер
`# ORCH-118` в шапке — TRACEABILITY). Дискриминатор всех проверок — **«консультирует LLM», а не
«спавнит subprocess»**.
- **(a) Единственный транспорт.** В `src/**` ровно одна точка сборки/запуска Claude CLI — матчинг по
**конъюнкции** признаков LLM-транспорта (`CLAUDE_BIN` **и** `--system-prompt` **и** `Popen`/`bash -c`
в одном месте), и это `launcher._spawn`. ⚠️ Конъюнкция обязательна: bare-`CLAUDE_BIN` дал бы
false-positive на `preflight.py` (existence-check) и `config.py` (литерал пути) — они **не**
консультируют (см. Контекст). Allowlist единственного транспорта = `_spawn`.
- **(f) Отсутствие иного LLM-транспорта.** В `src/**`+`watchdog/**` нет импорта `anthropic`/`openai`/
LLM-SDK, нет прямого HTTP-эндпоинта Anthropic/Claude (`api.anthropic.com`, `/v1/messages`), нет
второго model-invoking subprocess-сборщика. *(a)+(f) вместе = транспорт-агностичный двусторонний
инвариант.*
- **(b) Нет консультации в детерминированных путях.** Перечисленные модули D-списка и обработчики
D1/D2 не содержат LLM-транспорта (ни `_spawn`, ни (f)).
- **(c) Промпты ↔ файлы.** Карта перечисляет **ровно** те 6 промптов, что физически лежат в
`.openclaw/agents/` (двусторонняя сверка с `glob`).
- **(d) Тотальность.** Каждый перечисленный в карте call-site классифицирован **ровно один раз**.
- **(e) Capability ≠ consultation.** `launch_job` перехватывает `deploy-finalizer`/`post-deploy-monitor`
**до** `_spawn` (assert по `launcher.py` — наличие ранних return-веток до точки spawn).
- **(g) Control-path-разметка верна (TC-13).** Из машинного блока карты (D2) извлекается `role→axis`;
тест сверяет: P-роли потребляются `check_analysis_complete`/`check_architecture_done`/`check_ci_green`,
C-роли — `check_reviewer_verdict`/`_parse_tests_verdict`/`_parse_staging_status`/`_parse_deploy_status`
(наличие этих `def` в `src/qg/checks.py` как ground-truth). Дрейф разметки → красный.
- **(h) Avoidable-набор зафиксирован (TC-14).** Множество avoidable из карты = ровно `{tester,
deployer}`; `reviewer` = control-path-keep; `analyst`/`architect`/`developer` = не control path.
> ❌ **Не вводить** тест, прибивающий карту к конкретным follow-up Plane-ID → ✅ только инварианты,
> резолвящиеся в код/файлы репозитория (R3/NFR-6). Тесты — обычный `pytest`, **не** Quality Gate и
> **не** стадия (FR-6 §QG).
### D6 — Рекомендованный первый срез roadmap'а (BR-4; FR-4; AC-4)
**Первый срез = deployer (staging-status).** Обоснование (самый низкорисковый «чисто деривируемый»
control path):
1. Вердикт — **чистый маппинг** exit-кода `scripts/staging_check.py` → `staging_status:` (уже есть
leaf `src/staging_verdict.py` с `compute_staging_verdict`, ORCH-061) — деривируемость максимальна.
2. **Прод уже детерминирован** (Phase A/B/C, ORCH-036) → срез не трогает критический self-restart-путь
→ минимальная поверхность риска.
3. Опирается на **существующий прецедент** D1/D2 (`launch_job`-перехват до `_spawn`) — архитектурный
риск замены снижен (BRD §6).
4. `replace-deterministic-now`, без потребности в hybrid-fallback (в отличие от tester).
**Порядок roadmap'а:** (1) **deployer-замена** (staging-маппинг; prod уже детерминирован) →
(2) **tester-гибрид** (детерминированное ядро `pytest`+smoke + LLM-фолбэк на триаж падений / маппинг
TC↔критерии — `needs-hybrid-fallback`). Для каждого кандидата roadmap несёт: зависимости, **оценку**
экономии токенов/времени из `agent_runs` (помечено «оценка до фактического замера», NFR-5), риск
безопасности, потребность в hybrid-fallback, ожидание kill-switch/обратимости. Кандидаты названы
**по роли**; конкретный Plane-ID **не** фиксируется (NFR-6) — заводится при создании задачи.
### D7 — Скоуп-гард и инварианты (BR-7; FR-7; NFR-1/NFR-3; AC-7/AC-9)
- **Docs + tests only.** Диф не меняет `STAGE_TRANSITIONS` / реестр и имена `QG_CHECKS`/`check_*` /
machine-verdict-ключи (`verdict:`/`result:`/`staging_status:`/`deploy_status:`/`security_status:`/
`coverage_status:`) / схему БД. В `src/**` нет нового детерминированного раннера tester/deployer.
- **Анти-фабрикация.** Ни один артефакт не фиксирует конкретный follow-up Plane-ID; все ссылки
резолвятся в репозиторий. Тест не пинит карту к follow-up ID.
- **Self-hosting.** Только чтение кода + запись docs/tests — не деплоит, не рестартит прод, не трогает
`main`/force-push, без процессов/сети. kill-switch не нужен (нет рантайм-поведения), как
ORCH-077/079/101/102/103/011.
- **Наблюдаемость в `GET /queue`/`GET /metrics`** — **вне скоупа** (TRZ §4); карта/политика —
документы, не рантайм.
### Применимость 07/08
- **07-infra-requirements — N/A:** топология не меняется (нет нового сервиса/контейнера/порта/маунта).
- **08-data-requirements — N/A:** схема БД не меняется; `agent_runs` читается только для оценок (NFR-5).
## Альтернативы
- **Один объединённый документ (карта+roadmap+политика)** — отвергнуто: разные жизненные циклы и
тестируемость (D1); снижает стабильность нормативной политики и проверяемость снимка-карты.
- **Размещение карты только в `docs/work-items/ORCH-118/`** — отвергнуто: документ сквозной и durable,
читается будущими follow-up'ами; work-item-папка — неверная альтитуда (теряется при навигации по
архитектуре).
- **Тест по сырому regex прозы карты** — отвергнуто как хрупкое: дрейф формулировок ломал бы тест без
смыслового дрейфа. Выбран машинный markdown-блок (D2/D5g).
- **Тест-матчинг по bare-`CLAUDE_BIN`** — отвергнуто: false-positive на `preflight.py`/`config.py`
(capability/литерал, не консультация). Выбрана конъюнкция признаков транспорта (D5a).
- **Фиксация follow-up Plane-ID (`ORCH-115`/`ORCH-116`)** — отвергнуто нормативно (NFR-6, корень
отклонённой ревизии R2): этих work item нет; кандидаты — по роли.
- **Первый срез = tester** — отвергнуто: tester требует hybrid-fallback (триаж падений), поверхность
риска и объём больше; deployer-staging — чище деривируем и лучше обеспечен прецедентом (D6).
- **Включить наблюдаемость карты в `GET /queue`** — отвергнуто как scope-creep (TRZ §4): карта —
документ, а не рантайм-состояние.
## Последствия
- **+** Доказательная, код-привязанная карта разводит транспорт/слот ≠ консультация ≠ control-path и
даёт **проверяемый** предикат «avoidable» → закрывает блокеры R3→R5; follow-up'ы выполняются
предсказуемо.
- **+** Нормативная политика делает «LLM только там, где нужно суждение» инвариантом любой будущей
правки control-path'а; защищает автономность (NFR-2).
- **+** Структурные тесты держат карту синхронной с кодом (включая control-path-ось) — анти-дрейф.
- **** Карта — **снимок**: при эволюции `src/qg/checks.py` (смена потребителя / новая роль) тесты
D5g/h станут красными — требуется обновлять карту/политику в том же PR. *Митигейшн:* это
**запланированное** свойство (норматив сопровождения), а не дефект; тест указывает точку дрейфа.
- **** Машинный блок карты вводит лёгкую форматную дисциплину (стабильный заголовок таблицы).
*Митигейшн:* формат человекочитаемый, документирован в D2; парсер — stdlib split.
- **Откат:** удаление/правка трёх `docs/architecture/llm-*.md` + тест-файла + секции README. Рантайм
не затронут (риска нет).
## Ссылки
- BRD: `docs/work-items/ORCH-118/01-brd.md` (§0 / §0-bis / BR-1…BR-9 / NFR-1…NFR-7)
- TRZ: `docs/work-items/ORCH-118/02-trz.md` (FR-1…FR-8 / §2 таблица модулей)
- Acceptance: `docs/work-items/ORCH-118/03-acceptance-criteria.md` (AC-1…AC-10)
- Tech-risks: `docs/work-items/ORCH-118/10-tech-risks.md`
- Сквозной ADR: `docs/architecture/adr/adr-0047-llm-usage-policy-and-call-site-map.md`
- Сверено по коду: `src/agents/launcher.py` (`_spawn:472`, CLI `610-614`, `launch_job:377`,
перехват `389/394`, «Not an LLM spawn» `407/428`), `src/qg/checks.py`
(`33/62/82/336/182/226/599/538/473/413/657`), `.openclaw/agents/*.md` (6 промптов),
`src/{staging_verdict,self_deploy,frontmatter,...}.py` (детерминированные leaf'ы)
- Прецедент детерминированной замены агента: ORCH-036 (self-deploy Phase A/B/C), D1/D2 `launch_job`
- Прецедент docs+tests-only задач: ORCH-077/079/101/102/103/011
</content>
</invoke>

View File

@@ -0,0 +1,43 @@
---
work_item: ORCH-118
stage: architecture
author_agent: architect
status: accepted
created_at: 2026-06-15
model_used: claude-opus-4-8
---
# 10 — Технические риски: ORCH-118 — replace avoidable LLM control paths (inventory + map + policy)
Work Item: **ORCH-118** · Repo: **orchestrator** · Стадия: architecture
> Информационный документ (гейтом не парсится). Перечисляет риски реализации **этой** задачи
> (docs + tests only — inventory/карта/политика/тесты). Риски будущих раннеров замен — в roadmap'е и
> в ADR соответствующих follow-up'ов, **не здесь**.
## Реестр рисков
| ID | Риск | Вер. | Влия. | Митигейшн |
|----|------|------|-------|-----------|
| TR-1 | **Тривиальный тест** — структурные тесты «зелёные, но ничего не проверяют» (рецидив корня R4: проверяют «один `Popen`» без control-path-оси) | Сред. | Выс. | D5: обязательные инварианты (g) control-path-разметка сверена с `src/qg/checks.py` и (h) avoidable-набор `{tester, deployer}`; (a)+(f) двусторонний транспорт-инвариант; ревью AC-6 буквально требует (g)/(h) |
| TR-2 | **False-positive матчинга транспорта** — тест ловит `preflight.py`/`config.py` (bare `CLAUDE_BIN` — capability/литерал, не консультация) → ложный «второй транспорт» | Сред. | Сред. | D5a: матчинг по **конъюнкции** признаков (`CLAUDE_BIN``--system-prompt``Popen`/`bash -c`); allowlist = `_spawn`; явный негативный кейс на `preflight`/`config` |
| TR-3 | **Дрейф карты-снимка**`src/qg/checks.py` эволюционирует (смена потребителя / новая роль), карта не обновлена → ложно-зелёная витрина | Сред. | Сред. | Запланированное свойство: тесты D5g/h краснеют в точке дрейфа; норматив сопровождения «менял потребителя вердикта → обнови карту в том же PR» (ADR-001 D7 / adr-0047 D6) |
| TR-4 | **Хрупкий парс машинного блока** — regex по прозе карты ломается на переформулировке без смыслового дрейфа | Низ. | Сред. | D2/D5: стабильный markdown-блок с фиксированным заголовком таблицы, парс stdlib-split; формат документирован |
| TR-5 | **Непроверяемые ссылки / фабрикация follow-up ID** (рецидив дефекта R2) | Низ. | Выс. | NFR-6/AC-9: только резолвящиеся `file:line`/doc-ссылки; кандидаты — по роли; тест **не** пинит карту к follow-up ID; ревью AC-9 |
| TR-6 | **Scope-creep в рантайм** — соблазн «заодно» тронуть `QG_CHECKS`/`check_*`/раннер | Низ. | Выс. | AC-7/D7: docs+tests only; диф не меняет `STAGE_TRANSITIONS`/реестр-имена `QG_CHECKS`/machine-verdict/схему БД; нет нового раннера tester/deployer; ревью буквально |
| TR-7 | **Пере-/недо-классификация** (LLM убран где нужно суждение / сохранён где не нужен) | Низ. | Сред. | Класс **выводится** из осей D3 (двухбитный предикат), не «на глаз»; `keep-LLM` обязан назвать конкретное суждение; ревью карты против `src/qg/checks.py` |
| TR-8 | **Рассинхрон golden-source** — карта/политика введены, README/overview/CHANGELOG не обновлены | Сред. | Сред. | AC-8 (ось ORCH-079/011 → finding ≥P1); README-секция добавлена на стадии architecture; development досинхронизирует overview/CHANGELOG в том же PR |
| TR-9 | **Line-привязки `file:line` устаревают** между анализом и реализацией | Низ. | Низ. | Тест проверяет якоря по **имени** `def` (наличие в `src/qg/checks.py`), а не по номеру строки; номера в карте — справочные, обновляются разработчиком при материализации |
## Сводный вывод
Доминирующий класс — **риски качества тестов и анти-дрейфа** (TR-1/TR-2/TR-4), не рантайм-риски:
задача физически не меняет поведение конвейера (`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/
machine-verdict/схема БД — байт-в-байт), не деплоит и не трогает прод (self-hosting безопасно, NFR-3),
enduro-trails не затронут. Остаточный риск для прод-конвейера — **пренебрежимо мал**.
Эскалация `arch:major-change` **не требуется** (нет новой стадии/компонента/смены БД — это
docs+tests-only задача по прецеденту ORCH-077/079/101/102/103/011). Возврат в анализ **не требуется**:
ТЗ удовлетворяется без нарушения принципов архитектуры. Ключевой управляемый риск — не дать тестам
выродиться в тривиальные (TR-1) и не словить false-positive транспорта (TR-2); оба сняты дизайном D5.
</content>

View File

@@ -0,0 +1,123 @@
---
verdict: APPROVED
work_item: ORCH-118
stage: review
author_agent: reviewer
status: approved
created_at: 2026-06-16
model_used: claude-opus-4-8
type: review
work_item_id: ORCH-118
version: 1
---
# Review ORCH-118 — карта LLM-консультаций, control-path-ось «avoidable», roadmap и политика
> Машинный вердикт читается ТОЛЬКО из `verdict:` во frontmatter. `APPROVED` → дальше по конвейеру.
## Summary
ORCH-118 — зонтичная **inventory-first, docs + tests only** задача (RCA-трек ORCH-110…117): выпускает
доказательную карту LLM-консультаций, нормативную политику использования LLM, упорядоченный roadmap
детерминизации и набор структурных анти-дрейф тестов. Реализация раннеров — вне скоупа (FR-7).
Работа выполнена на эталонном уровне: скоуп выдержан **байт-в-байт** (`src/**` не тронут вообще),
каждый `file:line`-якорь карты резолвится в реальный код, все FR-1…FR-8 и AC-1…AC-10 закрыты, golden-source
синхронизирован, полный прогон `pytest tests/ -q`**зелёный (2081 passed)**. Один **P2** (косметика):
в трёх docs-артефактах (оба ADR + 10-tech-risks) на EOF протекли служебные теги `</content>`/`</invoke>`
из механизма записи файла. Это не ломает гейты/тесты/парсинг машинных блоков и не влияет на рантайм →
не блокирует. Рекомендуется зачистить.
Проверка проведена буквально по файлам: запущены оба новых тест-файла (13 passed), `test_system_docs.py`
(29 passed) и полный `tests/` (2081 passed); сверены ключевые якоря `launcher.py` и `src/qg/checks.py`;
подтверждено `git diff origin/main...HEAD -- src/` = **пусто**.
> ⚠️ **Замечание по базе ревью.** Локальный `main` в worktree **устаревший** (`64ba121`, до мержей
> ORCH-112/113/114/117). Истинная база ветки — `origin/main` (`13589fc`, мерж ORCH-117). Ревью
> проводилось против `origin/main...HEAD` (реальный changeset ORCH-118 — 16 файлов, +2365), а НЕ против
> stale `main...HEAD` (который ложно тянет чужие уже-смерженные `src/**`-правки ORCH-110…117).
## Оси проверки
### 1. Соответствие ТЗ (02-trz / 03-acceptance-criteria)
- **FR-1 / AC-1 (инвентарь полон и привязан):** карта `llm-call-sites.md` перечисляет `S0`, `A1…A6`,
`D1/D2` со всеми обязательными полями (`location`/`trigger`/`stage`/`output`/`machine-verdict key`/
`output consumer`/`est. tokens`/`consults-LLM`/`axis`/`classification`/`rationale`). **Все якоря
резолвятся** — сверено: `launcher.py:472`=`def _spawn(`, `610-614`=сборка `--system-prompt "$(cat …)"`,
`838`=`_monitor_agent`, `389/394`=перехваты `deploy-finalizer`/`post-deploy-monitor`, `407/428`=«Not an
LLM spawn»; `checks.py:33/62/82/182/226/336/413/473/538/599/657` — все 11 точно совпали с целевыми
`def`. ✔
- **FR-2 / AC-2 (таксономия 4 класса, выведена из оси):** классы определены, каждому site присвоен ровно
один (TC-04 тотальность), правило вывода `P→keep`, `C+!деривируем→keep`, `C+деривируем→replace/hybrid`
закреплено тестом (TC-04 axis-consistency). ✔
- **FR-3 / AC-3 (детерминизм не-агентских путей):** §3 карты + TC-02 с file:line; дискриминатор —
«консультирует LLM», а не «спавнит subprocess». ✔
- **FR-4 / AC-4 (roadmap + первый срез):** машинный блок roadmap с rank/deps/savings(источник `agent_runs`)/
risk/hybrid/followup_type/first_slice; ровно один `first_slice=yes`=deployer (TC-07). ✔
- **FR-5 / AC-5 (политика):** `llm-usage-policy.md` нормативна, критерии keep/replace через ось,
определение «avoidable» как двухбитный предикат (TC-08). ✔
- **FR-6 / AC-6 (структурные тесты):** TC-01…TC-14 покрывают единственный транспорт (a)+отсутствие иного
(f/TC-12), детерминированные пути (b), промпты↔файлы (c), тотальность (d), capability≠consultation (e),
control-path-разметку (g/TC-13), avoidable-набор (h/TC-14). Offline, stdlib-only, осмысленные. ✔
- **FR-7 / AC-7 (скоуп-гард):** `git diff origin/main...HEAD -- src/` пусто; `STAGE_TRANSITIONS`/
`QG_CHECKS`/`check_*`/machine-verdict/схема БД — нетронуты (TC-09 фиксирует снимок); новых раннеров нет. ✔
- **FR-8 / AC-10 (control-path-ось + «avoidable»):** ось C/P размечена по ролям с доказательством-потребителем,
термин определён, набор `{tester, deployer}` назван поимённо и отделён от `{reviewer}` (C-keep) и
`{analyst,architect,developer}` (P), сверено с `src/qg/checks.py` (TC-13/TC-14). ✔
- **AC-9 (анти-фабрикация ID):** follow-up'ы названы по роли; все `ORCH-1XX`-ссылки резолвятся в реальные
work-item-папки (TC-11 зелёный; подтверждено наличие ORCH-110/111/112/113/114/117). ✔
### 2. Соответствие ADR (06-adr / adr-0047 / глобальные ADR)
- Деливерабл-доки **зеркалят** канонические таблицы ADR-001 D2/D4 без расхождений (поля инвентаря,
классы, потребители, avoidable-набор, первый срез=deployer-staging). ✔
- D1 (3 durable-дока в `docs/architecture/`), D3 (3 оси + определение), D5 (offline-тесты, конъюнкция
признаков транспорта против false-positive на `preflight.py`/`config.py`), D6 (deployer первым), D7
(скоуп-гард) — реализованы верно. ✔
- **Глобальные ADR не нарушены:** machine-verdict-ключи и реестр `QG_CHECKS` байт-в-байт (TC-09);
трассировка ORCH-078 — маркированные инварианты `src/**` не правились (src не тронут). ✔
### 3. Качество кода (тестов)
- Тесты содержательные, не тривиальные: парсят машинные блоки карты/roadmap/политики и сверяют с
ground-truth кода (`src/qg/checks.py`, `launcher.py`, `.openclaw/agents/`). `_function_body` устойчив к
дрейфу строк; TC-01 корректно требует **конъюнкцию** `CLAUDE_BIN`+`--system-prompt`+launcher (исключая
ложные срабатывания); TC-12 закрывает «второй транспорт». Без сети/LLM/subprocess-к-модели. ✔
- **Регресс-тест-фиксатор (ORCH-019 BR-4) — N/A:** ORCH-118 — не багфикс-трек, а полный маршрут
design/inventory; обязательного теста-фиксатора дефекта не требуется. ✔
- Security/утечки — N/A (рантайм не меняется). ✔
### 4. Документация (golden-source — обязательная ось)
- **AC-8 синхронизация:** `docs/architecture/README.md` (секция + ссылка adr-0047), витрина
`docs/overview/tech-quality-security.md` (раздел «Где уместен LLM» + ссылки на все 3 дока, ORCH-011),
`CHANGELOG.md` — обновлены в этом же PR. `test_system_docs.py` зелёный (29 passed). ✔
- ADR заведён (work-item ADR-001 + сквозной adr-0047). ✔
- Поскольку `src/**` не менялся, ось «изменён src → обнови доку» неприменима как P0; обзорные доки
(ORCH-079/011) обновлены. ✔
## Findings
### P0 — Blocker
- (нет)
### P1 — Must fix
- (нет)
### P2 — Should fix
- [ ] **Протёкшие служебные теги на EOF трёх docs-артефактов.** На конце файлов остались артефакты
механизма записи, не относящиеся к содержимому (контент перед ними полный):
- `docs/work-items/ORCH-118/06-adr/ADR-001-llm-call-site-map-and-determinization-roadmap.md:294-295`
`</content>` + `</invoke>`
- `docs/architecture/adr/adr-0047-llm-usage-policy-and-call-site-map.md:114``</content>`
- `docs/work-items/ORCH-118/10-tech-risks.md:43``</content>`
Правило: «Документация = golden source» (CLAUDE.md §2) — durable-ADR не должен нести постороннюю
разметку. Не блокирует (тесты/гейты/машинные блоки карты не затронуты, рантайм-риска нет; три
новых dev-дока `llm-call-sites.md`/`llm-usage-policy.md`/`llm-determinization-roadmap.md`**чистые**).
Рекомендация: удалить хвостовые строки с тегами в этом же PR.
## Документация
**Обновлена полностью и корректно.** В PR синхронизированы все golden-source точки: `docs/architecture/README.md`
(+секция ORCH-118 и ссылка на adr-0047), витрина `docs/overview/tech-quality-security.md` (раздел про
карту LLM + ссылки на 3 дока), `CHANGELOG.md`, оба ADR (work-item + сквозной adr-0047). Три новых durable-дока
в `docs/architecture/` (`llm-call-sites.md`/`llm-determinization-roadmap.md`/`llm-usage-policy.md`) консистентны
между собой и с ADR; машинные блоки прибиты тестами. Единственное замечание по документации — косметический
P2 выше (хвостовые служебные теги в 2 ADR + tech-risks), не требующий отката.

View File

@@ -0,0 +1,80 @@
---
result: PASS
work_item: ORCH-118
stage: testing
author_agent: tester
status: pass
created_at: 2026-06-16
model_used: claude-opus-4-8
type: test-report
work_item_id: ORCH-118
---
# Test Report — ORCH-118
> Машинный вердикт читается ТОЛЬКО из `result:` во frontmatter. `PASS` → задача переходит на `deploy-staging`.
## Окружение
- Python: 3.12.13
- pytest: 8.3.3 (plugins: cov-5.0.0, anyio-4.13.0, asyncio-0.23.8)
- Worktree: `/repos/_wt/orchestrator/feature_ORCH-118-orch-replace-avoidable-llm-con` (ветка `feature/ORCH-118-orch-replace-avoidable-llm-con`)
- Дата: 2026-06-16
## Предусловия
- Review-вердикт (`12-review.md`): **APPROVED** (verdict читается из frontmatter). ✔
- Скоуп ORCH-118 — inventory-first, docs + tests only: `git diff origin/main...HEAD -- src/` пусто (рантайм не тронут).
## Smoke API (read-only)
- `GET /health``{"status":"ok","service":"orchestrator"}`
- `GET /status` → активная задача ORCH-118 на стадии `testing`
- `GET /queue` → присутствует блок `serial_gate` (ORCH-088: `orchestrator.active_task = ORCH-118/testing`, не frozen) ✔; присутствует блок `auto_labels` ✔; breaker `closed`, preflight OK. Смок-регресса нет.
## Результаты (покрытие 04-test-plan.yaml → 03-acceptance-criteria.md)
| TC ID | Описание | AC | Результат |
|-------|----------|----|-----------|
| TC-01 | Единственный ТРАНСПОРТ LLM-консультации = `launcher._spawn` (CLAUDE_BIN + `--system-prompt` + Popen) | AC-1/AC-6a | PASS |
| TC-02 | Детерминированные leaf-модули не консультируют LLM (дискриминатор «консультирует LLM», не «спавнит subprocess») | AC-3/AC-6b | PASS |
| TC-03 | Анти-дрейф промптов: карта ↔ `.openclaw/agents/` (6 файлов, двусторонняя сверка) | AC-1/AC-6c | PASS |
| TC-04 | Тотальность классификации (4 класса) + согласованность с осью C/P | AC-2/AC-6d | PASS |
| TC-05 | keep-LLM требует названного суждения; C-keep (reviewer) фиксирует не-деривируемость | AC-2 | PASS |
| TC-06 | Capability ≠ consultation: deploy-finalizer/post-deploy-monitor перехвачены до `_spawn` | AC-3/AC-6e | PASS |
| TC-07 | Полнота roadmap + ровно один `first_slice=yes` (deployer) | AC-4 | PASS |
| TC-08 | Политика LLM нормативна + определение «avoidable LLM control path» | AC-5/AC-10 | PASS |
| TC-09 | Скоуп-гард: снимок ролей `STAGE_TRANSITIONS` и имён `QG_CHECKS` не изменился | AC-7 | PASS |
| TC-10 | Полный регресс `pytest tests/ -q` зелёный | AC-6/AC-7 | PASS (2081 passed) |
| TC-11 | Анти-фабрикация follow-up Plane-ID (кандидаты по роли) | AC-9 | PASS |
| TC-12 | Отсутствие иного LLM-транспорта (нет anthropic/openai SDK, прямого HTTP, второго model-subprocess) | AC-1/AC-6f | PASS |
| TC-13 | Control-path-ось C/P согласована с фактическим потребителем в `src/qg/checks.py` | AC-10/AC-6g | PASS |
| TC-14 | Avoidable-набор зафиксирован = {tester, deployer}; reviewer=C-keep; analyst/architect/developer=P | AC-10/AC-6h | PASS |
Все 14 TC выполнены и сопоставлены с критериями приёмки. Пропусков нет.
## Вывод pytest
Целевые тесты ORCH-118:
```
tests/test_llm_call_site_inventory.py::test_tc01_single_llm_transport PASSED
tests/test_llm_call_site_inventory.py::test_tc12_no_alternative_llm_transport PASSED
tests/test_llm_call_site_inventory.py::test_tc02_deterministic_modules_no_llm_consultation PASSED
tests/test_llm_call_site_inventory.py::test_tc03_prompt_files_match_map PASSED
tests/test_llm_call_site_inventory.py::test_tc04_classification_total_and_axis_consistent PASSED
tests/test_llm_call_site_inventory.py::test_tc05_keep_llm_named_judgment PASSED
tests/test_llm_call_site_inventory.py::test_tc06_capability_not_consultation PASSED
tests/test_llm_call_site_inventory.py::test_tc09_runtime_contract_snapshot PASSED
tests/test_llm_call_site_inventory.py::test_tc13_control_path_axis_correct PASSED
tests/test_llm_call_site_inventory.py::test_tc14_avoidable_set_fixed PASSED
tests/test_llm_determinization_docs.py::test_tc07_roadmap_completeness_and_first_slice PASSED
tests/test_llm_determinization_docs.py::test_tc08_policy_normative_and_defines_avoidable PASSED
tests/test_llm_determinization_docs.py::test_tc11_no_fabricated_followup_ids PASSED
======================== 13 passed, 1 warning in 0.41s =========================
```
Полный регресс (TC-10):
```
2081 passed, 1 warning in 89.94s (0:01:29)
```
(Единственный warning — PydanticDeprecatedSince20 в `src/config.py:8`, предсуществующий, не связан с ORCH-118.)
## Итог
**PASS** — все 14 тест-кейсов зелёные, полный регресс `tests/` зелёный (2081 passed), smoke API (`/health`, `/status`, `/queue` с блоками `serial_gate` + `auto_labels`) — без регресса. Задача готова к переходу на `deploy-staging`.

View File

@@ -0,0 +1,12 @@
---
deploy_status: SUCCESS
work_item: ORCH-118
hook_exit_code: 0
deployed_by: deploy-finalizer
---
# Deploy log — ORCH-036 executable self-deploy
Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`.
Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM.

View File

@@ -0,0 +1,29 @@
---
staging_status: SUCCESS
work_item: ORCH-118
stage: deploy-staging
author_agent: deployer
status: success
created_at: 2026-06-16
model_used: claude-opus-4-8
timestamp: 2026-06-15T21:28:29Z
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed against the live `orchestrator-staging` instance (port 8501),
run canonically inside the container (`docker exec orchestrator-staging python3
/repos/orchestrator/scripts/staging_check.py --base-url http://localhost:8501 --mode stub`).
**Result: 8/10 checks PASS — exit code 0 → `staging_status: SUCCESS`.**
All REAL pipeline checks are green (Block A SMOKE: A1/A2/A3; Block B ACCESS: B4/B5/B6
registry isolation; Block C E2E: C7 create issue, C8 trigger pipeline). The only failures
are the two known sandbox-infra checks (C9a/C9b), which are tolerated under ORCH-061 because
SANDBOX bot accounts are not project members — they do not reflect a pipeline regression.
INFRA-WAIVED: C9a Branch appears in orchestrator-sandbox, C9b Analyst job enqueued in staging queue (known sandbox-infra; real checks green)
VERDICT: SUCCESS (exit 0) — SUCCESS (infra-waived): ['C9a Branch appears in orchestrator-sandbox', 'C9b Analyst job enqueued in staging queue'] are known sandbox-infra checks; all real checks green
Cleanup ran in `finally`: Plane SANDBOX test issue deleted (HTTP 204); no branch to delete.

View File

@@ -385,6 +385,14 @@ class AgentLauncher:
(no-LLM) job — intercept it BEFORE _spawn (which would raise
"Unknown agent", R-6) and run the deploy finalizer synchronously, driving
the jobs row status itself. Returns None (no agent_run row).
ORCH-115: the LLM ``deployer`` on the ``deploy-staging`` stage (self-hosting
scope) is replaced by a DETERMINISTIC staging-runner — intercepted here
BEFORE _spawn (same precedent as deploy-finalizer / post-deploy-monitor). The
discriminator is the TASK STAGE (deploy-staging), not the role name, so the
prod ``deploy`` deployer is never caught (staging_runner.should_intercept).
Kill-switch off / out of scope -> should_intercept False -> the prior LLM
deployer runs via _spawn byte-for-byte.
"""
if job.get("agent") == "deploy-finalizer":
return self._run_deploy_finalizer_job(job)
@@ -393,6 +401,11 @@ class AgentLauncher:
# observation tick synchronously. Returns None (no agent_run row).
if job.get("agent") == "post-deploy-monitor":
return self._run_post_deploy_monitor_job(job)
# ORCH-115: deterministic staging-runner intercept (BEFORE _spawn).
if job.get("agent") == "deployer":
from .. import staging_runner
if staging_runner.should_intercept(job):
return self._run_staging_runner_job(job)
return self._spawn(
job["agent"],
job["repo"],
@@ -422,6 +435,28 @@ class AgentLauncher:
pass
return None
def _run_staging_runner_job(self, job: dict):
"""ORCH-115: run the deterministic staging gate for a deployer job.
Not an LLM spawn — there is no subprocess/monitor of an agent, so we mark the
jobs row done/failed here (mirror of _run_deploy_finalizer_job). The runner
never-raises, but we guard anyway so a runner fault can't wedge the worker.
Returns None (no agent_run row, _spawn not called).
"""
from ..db import mark_job
from .. import staging_runner
try:
staging_runner.run_staging_gate(job)
mark_job(job["id"], "done")
logger.info(f"staging-runner job {job['id']} done")
except Exception as e:
logger.error(f"staging-runner job {job['id']} failed: {e}")
try:
mark_job(job["id"], "failed", error=f"staging-runner error: {e}")
except Exception:
pass
return None
def _run_post_deploy_monitor_job(self, job: dict):
"""ORCH-021: run one deterministic post-deploy monitor tick for a job.

View File

@@ -413,6 +413,51 @@ class Settings(BaseSettings):
coverage_tool_fail_closed: bool = False
coverage_run_timeout_s: int = 900
# ORCH-115: deterministic staging-runner replacing the LLM `deployer` agent on
# the `deploy-staging` stage for the self-hosting orchestrator. A new leaf
# src/staging_runner.py (never-raise) is intercepted in launch_job BEFORE _spawn
# (mirroring the deploy-finalizer / post-deploy-monitor reserved-agent
# precedent, launcher.py:389/394): it runs the SAME staging suite the LLM ran
# (`docker exec orchestrator-staging python3 .../staging_check.py`), maps the
# exit-code -> staging_status: via the existing self_deploy.map_exit_code_to_status
# contract, writes 15-staging-log.md, and initiates the EXISTING check_staging_status
# gate exactly as a finished LLM-deployer would. The artifact contract, the gate,
# STAGE_TRANSITIONS and the DB schema are byte-for-byte UNCHANGED — this only
# replaces the *producer* of the artifact. Pattern = coverage_gate_* / self_deploy_*.
# See docs/work-items/ORCH-115/06-adr/ADR-001-deterministic-staging-runner.md and
# docs/architecture/adr/adr-0048-deterministic-staging-runner.md.
# staging_runner_enabled -> SINGLE kill-switch (env
# ORCH_STAGING_RUNNER_ENABLED). False -> the
# intercept never fires -> the prior LLM
# deployer runs on deploy-staging via _spawn
# byte-for-byte as before ORCH-115 (D8/AC-6).
# staging_runner_repos -> CSV scope (env ORCH_STAGING_RUNNER_REPOS).
# Empty -> self-hosting only (orchestrator)
# via is_self_hosting_repo; non-empty ->
# membership. Mirrors coverage_gate_repos.
# staging_runner_timeout_s -> wall-clock budget for the docker-exec
# staging suite (env ORCH_STAGING_RUNNER_TIMEOUT_S).
# Malformed/non-positive -> default + WARNING
# (never-break). Aligned with the cross-cutting
# budget invariant ORCH-065/109/110 WITHOUT
# touching reaper_max_running_s (D9): it replaces
# the up-to-900s LLM staging window with a bounded
# <=600s deterministic one (Σ on the edge does not grow).
# staging_runner_infra_max_retries -> tool-error (suite did NOT execute: spawn-error /
# timeout / returncode None) bounded DEFER budget
# before a fail-closed FAILED (env
# ORCH_STAGING_RUNNER_INFRA_MAX_RETRIES). Mirrors
# merge_retest_infra_max_retries — infra hiccup is
# NOT a code-fault, so it never burns a developer-retry
# until the budget is exhausted (D5, anti ORCH-110).
# staging_runner_infra_retry_delay_s-> delay before the re-queued deployer job
# (env ORCH_STAGING_RUNNER_INFRA_RETRY_DELAY_S).
staging_runner_enabled: bool = True
staging_runner_repos: str = ""
staging_runner_timeout_s: int = 600
staging_runner_infra_max_retries: int = 2
staging_runner_infra_retry_delay_s: int = 30
# ORCH-098 (FND/F2): machine lessons-journal — additive `lessons` table + leaf
# src/lessons.py (never-raise observer, by образцу serial_gate/coverage_gate/
# metrics). The journal is an OBSERVER, never a Quality Gate: writing a lesson

View File

@@ -235,6 +235,7 @@ async def queue():
from . import lessons
from . import checkout_hygiene
from . import transition_lease
from . import staging_runner
from .disk_watchdog import disk_watchdog
from .build_cache_pruner import build_cache_pruner
return {
@@ -283,6 +284,11 @@ async def queue():
# (owner/stage/age/live) + defer/reclaim/CAS-lost counters. Additive block;
# never-raise.
"transition_lease": transition_lease.snapshot(),
# ORCH-115 (FR-7 / AC-10): deterministic staging-runner observability
# (read-only) — kill-switch, scope, timeout/infra budget + run/success/
# failed/tool_error/deferred counters, so a code-fail FAILED is distinguishable
# from an infra tool-error. Additive block; never-raise.
"staging_runner": staging_runner.snapshot(),
# ORCH-098 (FR-4 / AC-4): lessons-journal observability (read-only) —
# kill-switch + counts by type/status + last N lessons. Additive block;
# never-raise (snapshot() returns {"enabled": ...} minimum on error).

581
src/staging_runner.py Normal file
View File

@@ -0,0 +1,581 @@
"""Deterministic staging-runner (ORCH-115).
The `deploy-staging` stage for the self-hosting ``orchestrator`` repo was driven by
the LLM ``deployer`` agent, but the actual work is purely deterministic
(``.openclaw/agents/deployer.md`` steps 1-4): run the staging suite, map its
**exit-code** to a verdict (``0 -> SUCCESS``, else ``FAILED``), write
``15-staging-log.md`` and commit it. This leaf replaces that LLM consultation with
deterministic code, intercepted in ``launcher.launch_job`` BEFORE ``_spawn`` (the
``deploy-finalizer`` / ``post-deploy-monitor`` reserved-agent precedent,
``launcher.py:389/394``).
What is and is NOT changed (NFR-1, the critical invariant):
* UNCHANGED — the artifact contract (``15-staging-log.md`` with
``staging_status: SUCCESS|FAILED``), the gate ``check_staging_status`` /
``_parse_staging_status``, ``STAGE_TRANSITIONS``, the DB schema. This module
replaces only the *producer* of the artifact, never the gate that reads it.
* NEW — a deterministic producer + a launch_job intercept. Under a kill-switch +
repo-scope CSV; fail-safe back to the LLM path when off / out of scope.
This module is a **leaf** (mirror of ``self_deploy`` / ``proc_group`` /
``staging_verdict``): it imports only ``config`` / ``logging`` / ``proc_group`` at
module load; ``db`` / ``git_worktree`` / ``self_deploy.map_exit_code_to_status`` /
``qg.checks`` / ``stage_engine.advance_stage`` / ``notifications`` are imported
LAZILY inside functions so the heavy ``stage_engine`` is never pulled at import and
no import cycle forms (pattern: ``transition_lease`` / ``merge_gate``). Every public
function honours a **never-raise** contract (AC-7): a staging-infra hiccup can never
crash the worker / wedge the queue.
Two-level outcome (D5 — the key safety decision, anti ORCH-110):
* the suite EXECUTED (a real exit-code, 0 or non-zero) -> trust the code:
``0 -> SUCCESS -> advance``; ``!=0 -> FAILED -> the existing rollback
deploy-staging -> development`` (same developer-retry path as a FAILED LLM
verdict). ORCH-061 infra-tolerance is already INSIDE ``staging_check.py`` — the
runner never re-judges it (BR-4).
* the suite did NOT execute (tool-error: spawn-error / timeout / ``returncode is
None``) -> an infra fault, NOT a code fault -> a bounded DEFER (re-queue a fresh
``deployer`` job with a delay + a restart-safe marker). On budget exhaustion ->
fail-closed ``FAILED`` + advance + alert. So the runner NEVER does a silent
advance / false green, and NEVER wedges the queue, but does NOT burn a
developer-retry on transient infra.
"""
import logging
import time
from .config import settings
from . import proc_group
logger = logging.getLogger("orchestrator.staging_runner")
# Platform service literal — the staging compose service the suite runs inside.
# Already an accepted platform literal (mirror image_freshness._STAGING_SERVICE);
# NOT a host hardcode (test_no_host_hardcodes forbids host IP/home/hostname only).
STAGING_SERVICE = "orchestrator-staging"
# Default wall-clock budget for the docker-exec staging suite (D9). Kept <= the LLM
# staging window it replaces so Σ(work on the deploy-staging edge) does not grow and
# the cross-cutting reaper invariant (ORCH-065/109/110) holds WITHOUT touching
# reaper_max_running_s.
_DEFAULT_TIMEOUT_S = 600
_GIT_TIMEOUT = 60
# Restart-safe DEFER marker (counted from the persisted jobs queue, mirror of
# stage_engine._merge_infra_retry_count). Embedded verbatim in the re-queued job's
# task_content so a service restart never resets the infra-retry budget.
_INFRA_RETRY_MARKER = "staging-runner infra-retry"
# In-process observability counters (mirror merge_gate._MERGE_GATE_COUNTERS, ORCH-110).
_STAGING_RUNNER_COUNTERS: dict = {
"runs": 0, # run_staging_gate entered
"success": 0, # suite ran, exit 0 -> SUCCESS
"failed": 0, # suite ran non-zero, OR infra budget exhausted -> FAILED
"tool_error": 0, # suite did NOT execute (spawn-error / timeout / None)
"deferred": 0, # bounded infra DEFER (re-queued)
}
def _bump(key: str) -> None:
"""Increment an observability counter. Never raises."""
try:
_STAGING_RUNNER_COUNTERS[key] += 1
except Exception: # noqa: BLE001 - observability must never break a decision
pass
# ---------------------------------------------------------------------------
# Conditionality (D8 / FR-6 / AC-6)
# ---------------------------------------------------------------------------
def applies(repo: str) -> bool:
"""Whether the deterministic staging-runner is REAL for ``repo``.
Mirrors ``self_deploy_applies`` / ``coverage_gate``:
* ``staging_runner_enabled=False`` -> always False (global kill-switch); the
legacy LLM-deployer path runs on deploy-staging via ``_spawn``.
* ``staging_runner_repos`` (CSV) non-empty -> only the listed repos.
* empty CSV -> only the self-hosting repo (``orchestrator``), which is the only
one with a staging instance.
Checked FIRST in ``should_intercept`` (local, no network, no DB) so a disabled
flag costs nothing. never-raise -> False (fail-safe to the prior LLM path).
"""
try:
if not settings.staging_runner_enabled:
return False
raw = (settings.staging_runner_repos or "").strip()
if raw:
allowed = {r.strip().lower() for r in raw.split(",") if r.strip()}
return (repo or "").strip().lower() in allowed
# Lazy import keeps this module a leaf (no qg import at module load).
from .qg.checks import is_self_hosting_repo
return is_self_hosting_repo(repo)
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("staging_runner.applies error for %s: %s", repo, e)
return False
def should_intercept(job: dict) -> bool:
"""True iff this ``deployer`` job is the deterministic staging-suite job (D1).
The discriminator of "staging vs prod" is the TASK STAGE, not the role name
(``deployer`` owns both ``deploy-staging`` and ``deploy``): intercept iff
``agent == "deployer"`` AND the task's ``tasks.stage == "deploy-staging"`` AND
``applies(repo)``. For self-hosting the prod ``deploy`` edge runs Phase A (no
deployer spawn); for non-self repos ``applies`` is False, so a non-self prod
``deployer`` job is never intercepted (R-1 / TC-06).
never-raise -> False (a DB-lookup failure falls through to ``_spawn``, fail-safe
to the prior LLM path).
"""
try:
if (job.get("agent") or "") != "deployer":
return False
# applies() FIRST (local, no DB): disabled flag -> zero DB overhead.
if not applies(job.get("repo")):
return False
task_id = job.get("task_id")
if task_id is None:
return False
from .db import get_db
conn = get_db()
row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone()
conn.close()
if not row:
return False
return (row[0] or "") == "deploy-staging"
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("staging_runner.should_intercept error: %s", e)
return False
# ---------------------------------------------------------------------------
# Suite execution (D3 / FR-2 / NFR-3 / AC-8 / AC-9)
# ---------------------------------------------------------------------------
def build_staging_command() -> list[str]:
"""Build the canonical staging-suite argv (same command the LLM-deployer ran).
``docker exec <STAGING_SERVICE> python3 <repos_dir>/<self-repo>/scripts/staging_check.py
--base-url http://localhost:<staging_port> --mode stub``. Host-specifics come from
config (ORCH-101, no host hardcodes). Self-hosting safety (BR-7 / AC-8 / TC-12):
NO restart of 8500, NO ``docker compose up orchestrator`` / ``--build``, NO
force-push, NO ``.env`` edit — the runner only reads/executes the staging suite
(8501) and writes a log.
"""
from .qg.checks import SELF_HOSTING_REPO
repos_dir = (settings.repos_dir or "/repos").rstrip("/")
script = f"{repos_dir}/{SELF_HOSTING_REPO}/scripts/staging_check.py"
base_url = f"http://localhost:{int(settings.staging_port)}"
return [
"docker", "exec", STAGING_SERVICE,
"python3", script,
"--base-url", base_url,
"--mode", "stub",
]
def _resolve_timeout() -> int:
"""Resolve ``staging_runner_timeout_s`` (malformed/non-positive -> default +
WARNING, never-break — mirror of ``merge_gate._resolve_retest_timeout``)."""
raw = getattr(settings, "staging_runner_timeout_s", _DEFAULT_TIMEOUT_S)
try:
t = int(raw)
if t > 0:
return t
logger.warning(
"staging_runner_timeout_s non-positive (%r) -> default %ds",
raw, _DEFAULT_TIMEOUT_S,
)
except (TypeError, ValueError):
logger.warning(
"staging_runner_timeout_s malformed (%r) -> default %ds",
raw, _DEFAULT_TIMEOUT_S,
)
return _DEFAULT_TIMEOUT_S
def run_staging_suite() -> proc_group.ProcResult:
"""Execute the staging suite in its own process group, tree-killed on timeout.
Reuses ``proc_group.run_in_process_group`` (ORCH-110) so a hung docker-exec /
pytest subtree is killed whole (no orphans, AC-9). Never raises (proc_group
degrades any OS error to a safe ``ProcResult``).
"""
cmd = build_staging_command()
timeout = _resolve_timeout()
try:
grace = float(getattr(settings, "agent_kill_grace_seconds", 5) or 5)
except (TypeError, ValueError):
grace = 5.0
return proc_group.run_in_process_group(
cmd,
cwd=None,
timeout=timeout,
grace_s=grace,
tree_kill=bool(getattr(settings, "subprocess_tree_kill_enabled", True)),
)
# ---------------------------------------------------------------------------
# exit-code -> verdict (D4 / FR-3 / AC-3) — reuse the single contract, no 2nd mapping
# ---------------------------------------------------------------------------
def map_exit_code_to_status(exit_code) -> str:
"""``0 -> SUCCESS``; non-zero / None / non-int -> ``FAILED`` (fail-closed).
Delegates to ``self_deploy.map_exit_code_to_status`` — the SAME pure, unit-tested
contract the deploy-finalizer uses (BR-4: no second, drifting mapping).
"""
from .self_deploy import map_exit_code_to_status as _map
return _map(exit_code)
# ---------------------------------------------------------------------------
# Artifact 15-staging-log.md (D6 / FR-4 / AC-2 / AC-8) — mirror write_deploy_log
# ---------------------------------------------------------------------------
def _extract_infra_waived(stdout: str) -> list[str]:
"""Lines from the suite stdout carrying the ORCH-061 ``INFRA-WAIVED:`` marker
(copied into the log body for observability, as the prompt required)."""
if not stdout:
return []
return [ln.strip() for ln in stdout.splitlines() if "INFRA-WAIVED" in ln]
def build_staging_log(
work_item_id: str, exit_code, status: str, stdout: str = "", *, tool_error: bool = False
) -> str:
"""Render a ``15-staging-log.md`` body whose ``staging_status:`` frontmatter is the
verdict ``check_staging_status`` / ``_parse_staging_status`` reads (contract
UNCHANGED, AC-2). Carries the mandatory 52c schema (``work_item`` / ``stage`` /
``author_agent`` / ``status`` / ``created_at`` / ``model_used``); ``author_agent:
staging-runner`` / ``model_used: n/a`` honestly reflect the DETERMINISTIC producer.
The machine key ``staging_status:`` and its UPPERCASE value are NOT changed.
Written as a literal block (mirror of ``self_deploy.build_deploy_log``) so the
machine-read frontmatter is byte-exact; only the frontmatter is machine-read, the
body is informational.
"""
import datetime
created = datetime.date.today().isoformat()
sub_status = "success" if status == "SUCCESS" else "failed"
base_url = f"http://localhost:{int(settings.staging_port)}"
waived = _extract_infra_waived(stdout)
waived_body = ""
if waived:
waived_body = "\nINFRA-WAIVED lines (ORCH-061, copied for observability):\n" + "\n".join(
f"- {ln}" for ln in waived
) + "\n"
tail = ""
if stdout:
tail_text = stdout.strip()[-1500:]
if tail_text:
tail = f"\nStaging suite stdout (tail):\n```\n{tail_text}\n```\n"
note = (
"Staging suite did NOT execute (tool-error) and the infra-retry budget was "
"exhausted -> fail-closed FAILED."
if tool_error
else f"Staging suite exit-code `{exit_code}` -> `staging_status: {status}`."
)
return (
"---\n"
f"staging_status: {status}\n"
f"work_item: {work_item_id}\n"
"stage: deploy-staging\n"
"author_agent: staging-runner\n"
f"status: {sub_status}\n"
f"created_at: {created}\n"
"model_used: n/a\n"
f"exit_code: {exit_code}\n"
f"base_url: {base_url}\n"
"---\n\n"
"# Staging Gate Log (deterministic runner, ORCH-115)\n\n"
f"{note}\n\n"
"Вердикт зафиксирован детерминированным staging-раннером (ORCH-115), не LLM. "
"infra-tolerance (ORCH-061) уже учтена внутри `staging_check.py` — раннер её не "
"пересуживает.\n"
f"{waived_body}"
f"{tail}"
)
def write_staging_log(
repo: str, work_item_id: str, branch: str, exit_code, status: str,
stdout: str = "", *, tool_error: bool = False,
) -> bool:
"""Write ``15-staging-log.md`` into the task worktree (so ``check_staging_status``
reads it) and best-effort commit+push it to the FEATURE BRANCH. Returns True iff
the file was written. Never raises.
Mirror of ``self_deploy.write_deploy_log`` EXCEPT: the actor name is
``staging-runner`` and the log is pushed only to the feature branch — there is NO
separate PR-merge of the log into ``main`` (the gate reads the worktree first;
excluding any direct work on ``main`` strengthens AC-8 / BR-7). The feature branch
is merged into ``main`` later by the normal merge-gate path.
"""
import os
import subprocess
from .git_worktree import get_worktree_path
rel = f"docs/work-items/{work_item_id}/15-staging-log.md"
try:
wt = get_worktree_path(repo, branch)
except Exception as e: # noqa: BLE001 - never-raise
logger.error("write_staging_log: worktree error for %s/%s: %s", repo, branch, e)
return False
path = os.path.join(wt, rel)
content = build_staging_log(work_item_id, exit_code, status, stdout, tool_error=tool_error)
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
except OSError as e:
logger.error("write_staging_log: write error at %s: %s", path, e)
return False
# Best-effort commit + push to the feature branch (the gate also falls back to
# origin/main). ORCH-101: HOME + email domain from Settings; the actor NAME stays
# the platform literal `staging-runner` (deterministic system-actor commits).
_email = f"staging-runner@{settings.git_email_domain}"
git_env = {
**os.environ,
"HOME": settings.agent_home_dir,
"GIT_AUTHOR_NAME": "staging-runner",
"GIT_AUTHOR_EMAIL": _email,
"GIT_COMMITTER_NAME": "staging-runner",
"GIT_COMMITTER_EMAIL": _email,
}
try:
subprocess.run(["git", "-C", wt, "add", rel],
capture_output=True, timeout=_GIT_TIMEOUT, env=git_env)
commit = subprocess.run(
["git", "-C", wt, "commit", "-m",
f"staging(ORCH-115): staging gate {status} for {work_item_id}"],
capture_output=True, text=True, timeout=_GIT_TIMEOUT, env=git_env,
)
if commit.returncode == 0:
subprocess.run(["git", "-C", wt, "push", "origin", branch],
capture_output=True, timeout=_GIT_TIMEOUT, env=git_env)
except (subprocess.SubprocessError, OSError) as e:
logger.warning("write_staging_log: git commit/push best-effort failed: %s", e)
return True
# ---------------------------------------------------------------------------
# Existing-gate initiation (D7 / FR-5 / AC-4) — no new routing branch
# ---------------------------------------------------------------------------
def _advance(task_id, repo: str, work_item_id: str, branch: str) -> None:
"""Initiate the SAME ``advance_stage`` evaluation a finished LLM-deployer would
(``finished_agent="deployer"`` on ``deploy-staging``): SUCCESS -> sub-gates
security->merge->coverage->image-freshness (ORCH-022/043/027/058) + Phase A
(ORCH-036); FAILED -> the existing rollback deploy-staging -> development
(``stage_engine.py:932``). No new routing branch. The transition-lease (ORCH-114)
is taken INSIDE advance_stage on the side-effectful edge — the runner never
touches it (task boundary O1). never-raise."""
try:
from . import stage_engine
stage_engine.advance_stage(
task_id=task_id,
current_stage="deploy-staging",
repo=repo,
work_item_id=work_item_id,
branch=branch,
finished_agent="deployer",
)
except Exception as e: # noqa: BLE001 - never-raise into the worker
logger.error(
"staging_runner._advance: advance_stage failed for task %s (%s): %s",
task_id, work_item_id, e,
)
# ---------------------------------------------------------------------------
# Two-level outcome (D5) — tool-error DEFER bookkeeping
# ---------------------------------------------------------------------------
def _infra_retry_count(task_id) -> int:
"""How many times this task was re-queued by the tool-error DEFER path
(restart-safe; counted from the persisted jobs queue by the marker — mirror of
``stage_engine._merge_infra_retry_count``). Never raises -> 0 on error."""
try:
from .db import get_db
conn = get_db()
n = conn.execute(
"SELECT COUNT(*) FROM jobs WHERE task_id=? AND task_content LIKE ?",
(task_id, f"%{_INFRA_RETRY_MARKER}%"),
).fetchone()[0]
conn.close()
return int(n)
except Exception as e: # noqa: BLE001 - never-raise
logger.warning("staging_runner._infra_retry_count error for %s: %s", task_id, e)
return 0
def _handle_tool_error(
task_id, repo: str, work_item_id: str, branch: str, result: proc_group.ProcResult
) -> None:
"""Suite did NOT execute (tool-error) -> bounded DEFER, then fail-closed (D5).
Anti ORCH-110: an infra fault is NOT a code fault, so we re-queue a fresh
``deployer`` job (which re-enters this runner) with a delay instead of an
immediate FAILED-rollback that would burn a developer-retry. On budget exhaustion
-> write ``staging_status: FAILED`` + advance (the existing rollback) + an
INFRA-specific alert (explicitly "not a code defect"). Never a silent advance /
false green; never wedges the queue. never-raise."""
retries = _infra_retry_count(task_id)
try:
max_retries = int(settings.staging_runner_infra_max_retries)
except (TypeError, ValueError):
max_retries = 2
try:
delay = int(settings.staging_runner_infra_retry_delay_s)
except (TypeError, ValueError):
delay = 30
if retries < max_retries:
_bump("deferred")
reason = "timeout" if result.timed_out else "suite did not execute (tool-error)"
task_desc = (
f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n"
f"Stage: deploy-staging\nNote: {_INFRA_RETRY_MARKER} "
f"(attempt {retries + 1}/{max_retries}) — {reason}, retrying after {delay}s."
)
try:
from .db import enqueue_job
new_job = enqueue_job(
"deployer", repo, task_desc, task_id=task_id, available_at_delay_s=delay,
)
logger.warning(
"Task %s (%s): staging suite did not execute (%s) -> infra-DEFER "
"(job_id=%s, attempt %d/%d)",
task_id, work_item_id, reason, new_job, retries + 1, max_retries,
)
except Exception as e: # noqa: BLE001 - never-raise
logger.error("staging_runner: infra-DEFER enqueue failed for %s: %s", task_id, e)
return
# Budget exhausted -> fail-closed FAILED (terminal, never a false green).
_bump("failed")
logger.error(
"Task %s (%s): staging tool-error DEFER budget exhausted (%d) -> fail-closed FAILED",
task_id, work_item_id, max_retries,
)
write_staging_log(repo, work_item_id, branch, result.returncode, "FAILED",
result.stdout, tool_error=True)
_alert_infra_exhausted(work_item_id, max_retries)
_advance(task_id, repo, work_item_id, branch)
def _alert_infra_exhausted(work_item_id: str, max_retries: int) -> None:
"""Best-effort Telegram alert that the staging suite never executed (infra, NOT a
code defect) after the retry budget. never-raise."""
try:
from .notifications import send_telegram, link_for
send_telegram(
f"\U0001f6a8 {link_for(work_item_id)}: staging suite не запустилась "
f"(инфра, НЕ дефект кода) после {max_retries} попыток — fail-closed FAILED, "
f"откат на development. Нужно проверить staging-стенд."
)
except Exception as e: # noqa: BLE001 - never-raise
logger.warning("staging_runner: infra-exhausted alert failed for %s: %s", work_item_id, e)
# ---------------------------------------------------------------------------
# Entry point (D2) — owns the full deterministic flow, mirror run_deploy_finalizer
# ---------------------------------------------------------------------------
def run_staging_gate(job: dict) -> None:
"""Deterministic staging gate for a ``deployer`` job on ``deploy-staging``.
Flow (mirror of ``stage_engine.run_deploy_finalizer``):
1. resolve ``work_item_id`` / ``branch`` by ``task_id``;
2. execute the staging suite (D3) -> ProcResult;
3. suite EXECUTED -> map exit-code -> ``staging_status:``, write
``15-staging-log.md``, initiate the existing gate via ``advance_stage`` (D7);
4. suite did NOT execute (tool-error) -> bounded DEFER / fail-closed (D5);
5. observability counters + one structured verdict log (D10).
Never raises into the caller (the launcher marks the job done/failed)."""
started = time.time()
_bump("runs")
task_id = job.get("task_id")
repo = job.get("repo")
# 1. resolve task fields.
work_item_id, branch = None, None
try:
from .db import get_db
conn = get_db()
row = conn.execute(
"SELECT work_item_id, branch FROM tasks WHERE id=?", (task_id,)
).fetchone()
conn.close()
if row:
work_item_id, branch = row[0], row[1]
except Exception as e: # noqa: BLE001 - never-raise
logger.error("staging_runner: task lookup failed for task_id=%s: %s", task_id, e)
if not work_item_id or not branch:
logger.error(
"staging_runner: missing work_item_id/branch for task_id=%s — aborting", task_id
)
return
# 2-4. execute + classify + route — guarded so AC-7 (never-raise) holds even if
# an unexpected error escapes a sub-step (the worker must never crash on staging
# infra; the task is left on deploy-staging for the reconciler/reaper to re-drive).
try:
result = run_staging_suite()
duration_s = round(time.time() - started, 1)
suite_ran = (result.returncode is not None) and (not result.timed_out)
if suite_ran:
# 3. trust the exit-code (ORCH-061 already inside staging_check.py).
status = map_exit_code_to_status(result.returncode)
_bump("success" if status == "SUCCESS" else "failed")
logger.info(
"staging_runner verdict: work_item=%s repo=%s exit_code=%s status=%s "
"duration_s=%s outcome=%s",
work_item_id, repo, result.returncode, status, duration_s,
"code-pass" if status == "SUCCESS" else "code-fail",
)
write_staging_log(repo, work_item_id, branch, result.returncode, status, result.stdout)
_advance(task_id, repo, work_item_id, branch)
return
# 4. tool-error (suite did not execute) -> DEFER / fail-closed (D5).
_bump("tool_error")
logger.warning(
"staging_runner verdict: work_item=%s repo=%s exit_code=%s status=%s "
"duration_s=%s outcome=tool-error (timed_out=%s)",
work_item_id, repo, result.returncode, "TOOL-ERROR", duration_s, result.timed_out,
)
_handle_tool_error(task_id, repo, work_item_id, branch, result)
except Exception as e: # noqa: BLE001 - never-raise into the worker (AC-7)
logger.error(
"staging_runner.run_staging_gate: unexpected error for task %s (%s): %s",
task_id, work_item_id, e,
)
# ---------------------------------------------------------------------------
# Observability (D10 / FR-7 / AC-10)
# ---------------------------------------------------------------------------
def snapshot() -> dict:
"""Read-only staging-runner summary for ``GET /queue`` (FR-7 / AC-10).
Additive block; existing ``/queue`` keys are untouched. never-raise: any error ->
a minimal dict with the kill-switch state."""
try:
return {
"enabled": bool(settings.staging_runner_enabled),
"repos": getattr(settings, "staging_runner_repos", "") or "",
"timeout_s": getattr(settings, "staging_runner_timeout_s", _DEFAULT_TIMEOUT_S),
"infra_max_retries": getattr(settings, "staging_runner_infra_max_retries", 2),
"runs": _STAGING_RUNNER_COUNTERS["runs"],
"success": _STAGING_RUNNER_COUNTERS["success"],
"failed": _STAGING_RUNNER_COUNTERS["failed"],
"tool_error": _STAGING_RUNNER_COUNTERS["tool_error"],
"deferred": _STAGING_RUNNER_COUNTERS["deferred"],
}
except Exception as e: # noqa: BLE001 - never-raise -> minimal dict
logger.warning("staging_runner.snapshot error: %s", e)
return {"enabled": False}

View File

@@ -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 <name>`` 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"<!-- {name}:START -->"
end = f"<!-- {name}:END -->"
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"

View File

@@ -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"<!-- {name}:START -->"
end = f"<!-- {name}:END -->"
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)
)

View File

@@ -0,0 +1,438 @@
"""ORCH-115 — deterministic staging-runner replacing the LLM deployer on
`deploy-staging` (self-hosting orchestrator).
Covers Phase 1 (04-test-plan.yaml TC-01…TC-13): the launch_job intercept BEFORE
_spawn, the exit-code -> staging_status: mapping, 15-staging-log.md render + the
UNCHANGED gate contract, advance_stage initiation, kill-switch/scope, never-raise /
fail-safe, the bounded tool-error DEFER, process timeout, self-hosting safety, the
anti-drift invariant (STAGE_TRANSITIONS / QG_CHECKS / DB schema untouched), and the
/queue observability block.
No live Claude CLI, docker or network: the staging subprocess and advance_stage are
mocked; the pure mapping/render is tested directly. (TC-14 — the LLM call-site map
anti-drift — lives in tests/test_llm_call_site_inventory.py / test_llm_determinization_docs.py.)
"""
import os
import tempfile
import pytest
_test_db = os.path.join(tempfile.gettempdir(), "test_orch115_staging_runner.db")
os.environ["ORCH_DB_PATH"] = _test_db
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
from unittest.mock import MagicMock # noqa: E402
import src.db as _db # noqa: E402
from src.db import init_db, get_db, enqueue_job # noqa: E402
from src import staging_runner # noqa: E402
from src import stage_engine # noqa: E402
from src import config as cfg # noqa: E402
from src.proc_group import ProcResult # noqa: E402
from src.agents.launcher import AgentLauncher # noqa: E402
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def fresh_db(monkeypatch, tmp_path):
monkeypatch.setattr(_db.settings, "db_path", _test_db)
if os.path.exists(_test_db):
os.unlink(_test_db)
init_db()
# Worktree artefacts land in tmp; git commit/push degrade gracefully (no repo).
monkeypatch.setattr("src.git_worktree.settings.worktrees_dir", str(tmp_path), raising=False)
# Runner ON, self-hosting scope (default).
monkeypatch.setattr(cfg.settings, "staging_runner_enabled", True, raising=False)
monkeypatch.setattr(cfg.settings, "staging_runner_repos", "", raising=False)
monkeypatch.setattr(cfg.settings, "staging_runner_infra_max_retries", 2, raising=False)
# Reset in-process counters between tests.
for k in staging_runner._STAGING_RUNNER_COUNTERS:
staging_runner._STAGING_RUNNER_COUNTERS[k] = 0
yield
def _make_task(stage, repo="orchestrator", wi="ORCH-115", branch="feature/ORCH-115-x"):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) VALUES (?, ?, ?, ?, ?)",
(f"plane-{wi}", wi, repo, branch, stage),
)
tid = cur.lastrowid
conn.commit()
conn.close()
return tid
def _make_job(agent, repo, task_id, content="x"):
return enqueue_job(agent, repo, content, task_id=task_id)
def _job_dict(job_id, agent, repo, task_id):
return {"id": job_id, "agent": agent, "repo": repo, "task_id": task_id, "task_content": "x"}
def _read_log(tmp_path, repo, branch, wi):
from src.git_worktree import get_worktree_path
p = os.path.join(get_worktree_path(repo, branch), f"docs/work-items/{wi}/15-staging-log.md")
with open(p, "r", encoding="utf-8") as f:
return f.read()
# ---------------------------------------------------------------------------
# TC-01 — applies(): kill-switch / scope / fail-safe (FR-6 / AC-6)
# ---------------------------------------------------------------------------
def test_tc01_applies_killswitch_and_scope(monkeypatch):
# enabled=False -> False (fall back to the LLM path) regardless of repo.
monkeypatch.setattr(cfg.settings, "staging_runner_enabled", False)
assert staging_runner.applies("orchestrator") is False
# enabled, empty CSV -> self-hosting only.
monkeypatch.setattr(cfg.settings, "staging_runner_enabled", True)
monkeypatch.setattr(cfg.settings, "staging_runner_repos", "")
assert staging_runner.applies("orchestrator") is True
assert staging_runner.applies("enduro-trails") is False
assert staging_runner.applies("") is False
# non-empty CSV -> membership (case-insensitive).
monkeypatch.setattr(cfg.settings, "staging_runner_repos", "enduro-trails, orchestrator")
assert staging_runner.applies("ENDURO-TRAILS") is True
assert staging_runner.applies("other-repo") is False
def test_tc01_applies_never_raises(monkeypatch):
# A settings attribute that explodes on read -> applies() degrades to False.
class Boom:
@property
def staging_runner_enabled(self):
raise RuntimeError("boom")
monkeypatch.setattr(staging_runner, "settings", Boom())
assert staging_runner.applies("orchestrator") is False
# ---------------------------------------------------------------------------
# TC-02 — exit-code -> verdict, single shared contract (FR-3 / AC-3)
# ---------------------------------------------------------------------------
def test_tc02_map_exit_code():
from src import self_deploy
assert staging_runner.map_exit_code_to_status(0) == "SUCCESS"
for bad in (1, 2, 137, -9):
assert staging_runner.map_exit_code_to_status(bad) == "FAILED"
for noncode in (None, "x", object()):
assert staging_runner.map_exit_code_to_status(noncode) == "FAILED"
# Same contract as the deploy-finalizer (no second, drifting mapping).
for v in (0, 1, None, "garbage"):
assert staging_runner.map_exit_code_to_status(v) == self_deploy.map_exit_code_to_status(v)
# ---------------------------------------------------------------------------
# TC-03 — 15-staging-log.md render: machine key + 52c schema + INFRA-WAIVED (FR-4 / AC-2)
# ---------------------------------------------------------------------------
def test_tc03_log_render_schema_and_infra_waived():
stdout = "B6 OK\nINFRA-WAIVED: B9 image-build skipped (sandbox)\nDONE"
body = staging_runner.build_staging_log("ORCH-115", 0, "SUCCESS", stdout)
assert "staging_status: SUCCESS" in body # UPPERCASE machine key
for field in ("work_item: ORCH-115", "stage: deploy-staging",
"author_agent: staging-runner", "status: success",
"created_at:", "model_used: n/a"):
assert field in body, f"missing 52c field: {field}"
# INFRA-WAIVED line copied into the body for observability.
assert "INFRA-WAIVED: B9 image-build skipped (sandbox)" in body
failed = staging_runner.build_staging_log("ORCH-115", 1, "FAILED", "boom")
assert "staging_status: FAILED" in failed
assert "status: failed" in failed
# ---------------------------------------------------------------------------
# TC-04 — generated log read by the UNCHANGED _parse_staging_status (AC-2)
# ---------------------------------------------------------------------------
def test_tc04_gate_parser_unchanged():
from src.qg.checks import _parse_staging_status
ok, reason = _parse_staging_status(staging_runner.build_staging_log("ORCH-115", 0, "SUCCESS"))
assert ok is True and "SUCCESS" in reason
bad, reason2 = _parse_staging_status(staging_runner.build_staging_log("ORCH-115", 2, "FAILED"))
assert bad is False and "FAILED" in reason2
# ---------------------------------------------------------------------------
# TC-05 — launch_job intercepts deployer on deploy-staging BEFORE _spawn (AC-1)
# ---------------------------------------------------------------------------
def test_tc05_launch_job_intercepts_before_spawn(monkeypatch):
tid = _make_task("deploy-staging")
jid = _make_job("deployer", "orchestrator", tid)
lr = AgentLauncher()
spawn = MagicMock(return_value=999)
monkeypatch.setattr(lr, "_spawn", spawn)
run_gate = MagicMock()
monkeypatch.setattr(staging_runner, "run_staging_gate", run_gate)
ret = lr.launch_job(_job_dict(jid, "deployer", "orchestrator", tid))
assert ret is None # no agent_runs row
spawn.assert_not_called() # LLM path NOT taken
run_gate.assert_called_once() # deterministic runner ran
# jobs row driven to done by the launcher (no monitor/agent).
conn = get_db()
status = conn.execute("SELECT status FROM jobs WHERE id=?", (jid,)).fetchone()[0]
conn.close()
assert status == "done"
# ---------------------------------------------------------------------------
# TC-06 — stage discriminator: deployer on `deploy` is NOT intercepted (AC-1 / R-1)
# ---------------------------------------------------------------------------
def test_tc06_stage_discriminator_prod_not_intercepted(monkeypatch):
tid = _make_task("deploy") # prod edge, not deploy-staging
jid = _make_job("deployer", "orchestrator", tid)
lr = AgentLauncher()
spawn = MagicMock(return_value=42)
monkeypatch.setattr(lr, "_spawn", spawn)
run_gate = MagicMock()
monkeypatch.setattr(staging_runner, "run_staging_gate", run_gate)
ret = lr.launch_job(_job_dict(jid, "deployer", "orchestrator", tid))
assert ret == 42 # _spawn path taken
spawn.assert_called_once()
run_gate.assert_not_called()
assert staging_runner.should_intercept(_job_dict(jid, "deployer", "orchestrator", tid)) is False
def test_tc06_non_self_repo_not_intercepted(monkeypatch):
tid = _make_task("deploy-staging", repo="enduro-trails", wi="ET-9", branch="feature/ET-9-x")
jid = _make_job("deployer", "enduro-trails", tid)
assert staging_runner.should_intercept(_job_dict(jid, "deployer", "enduro-trails", tid)) is False
# ---------------------------------------------------------------------------
# TC-07 — routing equivalence: SUCCESS -> advance; FAILED -> same path (AC-4)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("rc,expected", [(0, "SUCCESS"), (1, "FAILED")])
def test_tc07_advance_initiated_like_llm(monkeypatch, tmp_path, rc, expected):
tid = _make_task("deploy-staging")
monkeypatch.setattr(staging_runner, "run_staging_suite",
lambda: ProcResult(returncode=rc, stdout="out", stderr="", timed_out=False))
advance = MagicMock()
monkeypatch.setattr(stage_engine, "advance_stage", advance)
staging_runner.run_staging_gate(_job_dict(1, "deployer", "orchestrator", tid))
advance.assert_called_once()
kwargs = advance.call_args.kwargs
assert kwargs["current_stage"] == "deploy-staging"
assert kwargs["finished_agent"] == "deployer"
assert kwargs["work_item_id"] == "ORCH-115"
# The log written for the gate carries the matching verdict.
assert f"staging_status: {expected}" in _read_log(tmp_path, "orchestrator", "feature/ORCH-115-x", "ORCH-115")
# ---------------------------------------------------------------------------
# TC-08 — kill-switch: enabled=False -> LLM path via _spawn (AC-6)
# ---------------------------------------------------------------------------
def test_tc08_killswitch_falls_back_to_spawn(monkeypatch):
monkeypatch.setattr(cfg.settings, "staging_runner_enabled", False)
tid = _make_task("deploy-staging")
jid = _make_job("deployer", "orchestrator", tid)
lr = AgentLauncher()
spawn = MagicMock(return_value=7)
monkeypatch.setattr(lr, "_spawn", spawn)
run_gate = MagicMock()
monkeypatch.setattr(staging_runner, "run_staging_gate", run_gate)
ret = lr.launch_job(_job_dict(jid, "deployer", "orchestrator", tid))
assert ret == 7
spawn.assert_called_once() # byte-for-byte the prior LLM-deployer path
run_gate.assert_not_called()
# ---------------------------------------------------------------------------
# TC-09 — anti-drift NFR-1: STAGE_TRANSITIONS / QG_CHECKS / schema untouched (AC-5)
# ---------------------------------------------------------------------------
def test_tc09_pipeline_contract_unchanged():
from src.stages import STAGE_TRANSITIONS
from src.qg.checks import QG_CHECKS
# The deploy-staging edge + its gate are byte-for-byte the prior contract.
assert STAGE_TRANSITIONS["deploy-staging"] == {
"next": "deploy", "agent": "deployer", "qg": "check_staging_status"
}
assert "check_staging_status" in QG_CHECKS
# No new ORCH-115 table/column: only the existing tables exist.
conn = get_db()
tables = {r[0] for r in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'").fetchall()}
conn.close()
assert not any("staging_runner" in t for t in tables)
# The machine key is not renamed: the runner emits `staging_status:`.
assert "staging_status:" in staging_runner.build_staging_log("ORCH-115", 0, "SUCCESS")
# ---------------------------------------------------------------------------
# TC-10 — never-raise / fail-safe: tool-error never a silent advance/false green (AC-7)
# ---------------------------------------------------------------------------
def test_tc10_nonzero_exit_is_failed_and_advances(monkeypatch, tmp_path):
tid = _make_task("deploy-staging")
monkeypatch.setattr(staging_runner, "run_staging_suite",
lambda: ProcResult(returncode=3, stdout="fail", stderr="", timed_out=False))
advance = MagicMock()
monkeypatch.setattr(stage_engine, "advance_stage", advance)
staging_runner.run_staging_gate(_job_dict(1, "deployer", "orchestrator", tid))
advance.assert_called_once()
assert "staging_status: FAILED" in _read_log(tmp_path, "orchestrator", "feature/ORCH-115-x", "ORCH-115")
def test_tc10_timeout_defers_without_advance(monkeypatch):
tid = _make_task("deploy-staging")
monkeypatch.setattr(cfg.settings, "staging_runner_infra_max_retries", 2)
monkeypatch.setattr(staging_runner, "run_staging_suite",
lambda: ProcResult(returncode=None, stdout="", stderr="", timed_out=True))
advance = MagicMock()
monkeypatch.setattr(stage_engine, "advance_stage", advance)
staging_runner.run_staging_gate(_job_dict(1, "deployer", "orchestrator", tid))
advance.assert_not_called() # NO silent advance / false green on infra hiccup
# A fresh deployer job was re-queued with the restart-safe marker (bounded DEFER).
conn = get_db()
n = conn.execute(
"SELECT COUNT(*) FROM jobs WHERE task_id=? AND task_content LIKE ?",
(tid, f"%{staging_runner._INFRA_RETRY_MARKER}%"),
).fetchone()[0]
conn.close()
assert n == 1
assert staging_runner._STAGING_RUNNER_COUNTERS["deferred"] == 1
assert staging_runner._STAGING_RUNNER_COUNTERS["tool_error"] == 1
def test_tc10_tool_error_budget_exhausted_fails_closed(monkeypatch, tmp_path):
tid = _make_task("deploy-staging")
monkeypatch.setattr(cfg.settings, "staging_runner_infra_max_retries", 0) # exhausted immediately
monkeypatch.setattr(staging_runner, "run_staging_suite",
lambda: ProcResult(returncode=None, stdout="", stderr="", timed_out=True))
advance = MagicMock()
monkeypatch.setattr(stage_engine, "advance_stage", advance)
staging_runner.run_staging_gate(_job_dict(1, "deployer", "orchestrator", tid))
advance.assert_called_once() # fail-closed: write FAILED + advance (existing rollback)
assert "staging_status: FAILED" in _read_log(tmp_path, "orchestrator", "feature/ORCH-115-x", "ORCH-115")
def test_tc10_run_gate_never_raises(monkeypatch):
tid = _make_task("deploy-staging")
def boom():
raise RuntimeError("docker exec exploded")
monkeypatch.setattr(staging_runner, "run_staging_suite", boom)
# Must NOT raise (AC-7): the worker is protected even on an unexpected error.
staging_runner.run_staging_gate(_job_dict(1, "deployer", "orchestrator", tid))
def test_tc10_launcher_contains_runner_fault(monkeypatch):
tid = _make_task("deploy-staging")
jid = _make_job("deployer", "orchestrator", tid)
lr = AgentLauncher()
monkeypatch.setattr(lr, "_spawn", MagicMock())
monkeypatch.setattr(staging_runner, "run_staging_gate",
MagicMock(side_effect=RuntimeError("kaboom")))
# The launcher wrapper contains the fault -> job marked failed, never raises.
ret = lr.launch_job(_job_dict(jid, "deployer", "orchestrator", tid))
assert ret is None
conn = get_db()
status = conn.execute("SELECT status FROM jobs WHERE id=?", (jid,)).fetchone()[0]
conn.close()
assert status == "failed"
# ---------------------------------------------------------------------------
# TC-11 — timeout resolution + propagation (NFR-4 / AC-9)
# ---------------------------------------------------------------------------
def test_tc11_resolve_timeout_default_on_bad_value(monkeypatch):
monkeypatch.setattr(cfg.settings, "staging_runner_timeout_s", 1234)
assert staging_runner._resolve_timeout() == 1234
for bad in (0, -5, "abc", None):
monkeypatch.setattr(cfg.settings, "staging_runner_timeout_s", bad)
assert staging_runner._resolve_timeout() == staging_runner._DEFAULT_TIMEOUT_S
def test_tc11_timeout_passed_to_proc_group(monkeypatch):
monkeypatch.setattr(cfg.settings, "staging_runner_timeout_s", 321)
monkeypatch.setattr(cfg.settings, "subprocess_tree_kill_enabled", True)
captured = {}
def fake_run(cmd, *, cwd, timeout, grace_s, tree_kill):
captured.update(cmd=cmd, timeout=timeout, tree_kill=tree_kill)
return ProcResult(returncode=0, stdout="", stderr="", timed_out=False)
monkeypatch.setattr(staging_runner.proc_group, "run_in_process_group", fake_run)
staging_runner.run_staging_suite()
assert captured["timeout"] == 321
assert captured["tree_kill"] is True
assert captured["cmd"][:2] == ["docker", "exec"]
# ---------------------------------------------------------------------------
# TC-12 — self-hosting safety: no forbidden literals in the runner command (AC-8)
# ---------------------------------------------------------------------------
def test_tc12_command_has_no_dangerous_literals():
cmd = " ".join(staging_runner.build_staging_command())
forbidden = ("compose", "up -d", "--build", "8500", "force", "push", ".env",
"rm ", "restart")
for token in forbidden:
assert token not in cmd, f"forbidden literal {token!r} in runner command: {cmd}"
# It IS the staging suite against 8501 via docker exec.
assert "docker exec" in cmd
assert "staging_check.py" in cmd
assert "8501" in cmd
assert "--mode stub" in cmd
# ---------------------------------------------------------------------------
# TC-13 — observability: /queue block + structured verdict log (AC-10)
# ---------------------------------------------------------------------------
def test_tc13_snapshot_shape():
snap = staging_runner.snapshot()
for k in ("enabled", "repos", "timeout_s", "infra_max_retries",
"runs", "success", "failed", "tool_error", "deferred"):
assert k in snap, f"snapshot missing key {k}"
def test_tc13_queue_endpoint_includes_block(monkeypatch):
import asyncio
from src import main
payload = asyncio.run(main.queue())
assert "staging_runner" in payload
assert "enabled" in payload["staging_runner"]
def test_tc13_structured_verdict_log_distinguishes_outcomes(monkeypatch, caplog, tmp_path):
tid = _make_task("deploy-staging")
monkeypatch.setattr(stage_engine, "advance_stage", MagicMock())
# code-pass
monkeypatch.setattr(staging_runner, "run_staging_suite",
lambda: ProcResult(returncode=0, stdout="", stderr="", timed_out=False))
with caplog.at_level("INFO", logger="orchestrator.staging_runner"):
staging_runner.run_staging_gate(_job_dict(1, "deployer", "orchestrator", tid))
assert any("outcome=code-pass" in r.message for r in caplog.records)
caplog.clear()
# tool-error
monkeypatch.setattr(cfg.settings, "staging_runner_infra_max_retries", 2)
monkeypatch.setattr(staging_runner, "run_staging_suite",
lambda: ProcResult(returncode=None, stdout="", stderr="", timed_out=True))
with caplog.at_level("WARNING", logger="orchestrator.staging_runner"):
staging_runner.run_staging_gate(_job_dict(1, "deployer", "orchestrator", tid))
assert any("outcome=tool-error" in r.message for r in caplog.records)
def test_tc13_snapshot_never_raises(monkeypatch):
class Boom:
def __getattr__(self, name):
raise RuntimeError("boom")
monkeypatch.setattr(staging_runner, "settings", Boom())
snap = staging_runner.snapshot()
assert snap["enabled"] is False