Compare commits

...

27 Commits

Author SHA1 Message Date
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
deploy-finalizer
2fc359d8fa deploy(ORCH-036): finalize SUCCESS for ORCH-117
All checks were successful
CI / test (push) Successful in 1m6s
2026-06-15 22:32:05 +03:00
f26908ffc4 tester(ET): auto-commit from tester run_id=720
All checks were successful
CI / test (push) Successful in 1m13s
CI / test (pull_request) Successful in 1m13s
2026-06-15 21:32:20 +03:00
0cb121d105 reviewer(ET): auto-commit from reviewer run_id=719 2026-06-15 21:32:20 +03:00
861b5ee984 fix(plane): sandbox-only fail-closed guard for Plane writes from test process (ORCH-117)
Close the root class of incident ORCH-114: a pytest/worktree process performed a
REAL write (PATCH issues state=<Done> + comment) against the PRODUCTION Plane
project, because test/staging processes inherit the live Plane token
(PLANE_HEADERS/PROJECT_ID are captured at import — a post-hoc env/token swap is a
no-op) and nothing forced them to write only to the sandbox. Symmetric to the
existing _no_telegram autouse floor.

- New pure never-raise leaf src/plane_write_guard.py (decide/audit_block/
  audit_allow), wired into the 3 plane_sync write primitives (update_issue_state /
  add_comment / _set_issue_state_direct) via _guard_allows_write, AT CALL TIME,
  before any network step. Active ONLY in a test process (pytest in sys.modules /
  PYTEST_CURRENT_TEST); live + staging runtimes (uvicorn) are a strict no-op.
- In a test process: default-deny. A write is allowed iff opt-in
  (plane_test_write_enabled) AND target project in the sandbox allowlist
  (plane_test_sandbox_projects, default = the one SANDBOX id). Prod is blocked even
  with opt-in (allowlist sandbox-only); unresolved project -> block (fail-closed).
- Independent second layer: tests/conftest.py::_plane_sandbox_only autouse floor.
  Intentionally NO prod-block kill-switch (anti back-door, NFR-6).
- Audit: block -> loud ERROR; sandbox-allow -> INFO.
- Bypass fixtures for the 3 (+1) pre-existing tests that assert on the mocked
  write primitive's httpx call (header/URL/state logic), the guard is no Quality
  Gate: STAGE_TRANSITIONS / QG_CHECKS / check_* / machine-verdict / DB schema
  untouched.
- Tests: tests/test_orch117_plane_write_isolation.py (TC-01 mandatory ORCH-114
  regression + TC-02..TC-14). Docs: CLAUDE.md, architecture/README.md,
  operations/INFRA.md, .env.example, CHANGELOG.md.

Refs: ORCH-117
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 21:32:20 +03:00
77d3a66356 architect(ET): auto-commit from architect run_id=717 2026-06-15 21:32:20 +03:00
8ccbcb3f80 analyst(ET): auto-commit from analyst run_id=716 2026-06-15 21:32:20 +03:00
310bebb540 docs: init ORCH-117 business request 2026-06-15 21:32:20 +03:00
a9dabff539 deploy-staging(ORCH-117): staging gate SUCCESS (8/10 PASS, C9a/C9b infra-waived)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 21:31:57 +03:00
db2fbd23e0 Merge pull request 'ORCH-114: durable transition-ownership lease + expected-stage CAS (fix double-effect/rollback↔done class)' (#138) from feature/ORCH-114-bug-pipeline-stage-transitions into main
Some checks failed
CI / test (push) Has been cancelled
2026-06-15 19:35:58 +03:00
deploy-finalizer
eb34324852 deploy(ORCH-036): finalize SUCCESS for ORCH-114
All checks were successful
CI / test (push) Successful in 1m11s
2026-06-15 19:35:56 +03:00
7490f4fac4 tester(ET): auto-commit from tester run_id=714
All checks were successful
CI / test (push) Successful in 1m18s
CI / test (pull_request) Successful in 1m28s
2026-06-15 19:28:38 +03:00
d4eca78423 reviewer(ET): auto-commit from reviewer run_id=713 2026-06-15 19:28:38 +03:00
c4a97a7a28 fix(stage-engine): address ORCH-114 review — env/docs canon + in-region rollback CAS
Resolves the REQUEST_CHANGES findings on ORCH-114 (durable transition-ownership
lease + expected-stage CAS):

P1 — documentation = golden source:
- .env.example: add ORCH_TRANSITION_LEASE_ENABLED / ORCH_TRANSITION_LEASE_REPOS
  (canon of 100% start keys, ORCH-101), next to the other gate kill-switches.
- CLAUDE.md: add the ORCH-114 passport section (mechanism, invariant, flags,
  ADR links) so a future agent editing advance_stage/reaper/webhooks finds the
  ownership invariant in the first mandatory-read doc (ORCH-078 traceability index).

P2 — should-fix:
- docs/overview/ (system showcase, ORCH-011): add transition_lease to
  tech-data-model.md (helper tables), tech-observability.md (/queue blocks) and
  tech-architecture.md (components).
- ADR-001 D4 alignment: the four side-effectful-edge rollback handlers
  (_handle_merge_gate_rollback / _handle_security_gate / _handle_coverage_gate /
  _handle_image_freshness) now write `development` through the expected-stage CAS
  via a shared _rollback_stage_cas helper (defence against the rollback↔done
  contradiction, BR-6) instead of a bare unconditional update_task_stage. Under the
  held lease the sole owner always wins; a lost race aborts WITHOUT side effects.
  Kill-switch off / out-of-scope repo -> degenerates to the prior write -> 1:1.
- Test isolation: make tests/test_webhooks.py order-independent by pinning the
  proj-1 registry per-test (mirrors test_webhook_dedup.proj_registry); it had only
  passed by relying on import order. Drop the needless module-level ORCH_DB_PATH
  setdefault in test_orch114 (fresh_db already isolates db_path).

New regression tests (TC-11): in-region rollback writes route through CAS;
rollback CAS wins when at expected stage; rollback CAS-lost does NOT clobber `done`;
kill-switch-off rollback degenerates to the unconditional write.

ruff clean (src/stage_engine.py, src/transition_lease.py); full suite 2052 passed.

Refs: ORCH-114
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 19:28:38 +03:00
4a6b32e61d reviewer(ET): auto-commit from reviewer run_id=711 2026-06-15 19:28:38 +03:00
6ea4402942 fix(stage-engine): durable transition-ownership lease + expected-stage CAS (ORCH-114)
Close the root class of the ORCH-110/111/112/113 incident chain: side-effectful
stage transitions had no single ownership. `advance_stage` is re-enterable and wrote
the stage with a bare `UPDATE ... WHERE id=?` (no compare-and-swap), while >=5 actors
(monitor / Plane-webhook / reconciler F-1 / job-reaper / deploy-finalizer) enter the
same transition independently. A concurrent or post-restart re-entry therefore
re-applied irreversible effects (merge_pr / coverage-ratchet / image-rebuild /
prod-deploy initiation) and produced a contradictory rollback<->done (incident
ORCH-111, job 1914 / PR #130).

Two complementary layers, both additive, under one kill-switch, never-raise:
  1. Durable transition-lease (new table `transition_lease`) — owner-exclusion on
     ENTRY to the side-effectful region: a second actor that sees a LIVE owner does
     not start the heavy sub-gates at all (prevention, not post-hoc repair).
  2. Expected-stage CAS (`db.update_task_stage_cas`) — atomicity on the stage WRITE:
     a lost race aborts with NO side effect. Also closes the 6 paths that write the
     stage in bypass of advance_stage (gitea x5 + plane rollback).

Owner liveness = owner_pid + owner_boot_id (NOT a heartbeat — a blocking 900s merge
re-test cannot beat one; ADR-001 D3), making restart recovery free (a fresh boot_id
renders every prior lease stale -> reclaimed by recover_on_startup). The lease has no
own TTL: its hard age ceiling is the reaper Tier-3 backstop reaper_max_running_s, so
the cross-cutting budget invariant ORCH-065/109/110/113 is untouched.

Generalises ORCH-113 finalizer-liveness (process-local, Tier-2, deploy-staging) to a
durable cross-path lease: the reaper consults it on all relevant paths (defer live,
reclaim dead; Tier-3 ignores the marker -> bounded; a reap force-releases the lease);
reconciler F-1 and the Plane webhook defer on an active lease; main.lifespan calls
recover_on_startup() after requeue_running_jobs. finalizer_liveness.py is unchanged
(it remains the kill-switch-off fallback).

Scope self-hosting (transition_lease_repos="" -> orchestrator only; enduro untouched).
Kill-switch ORCH_TRANSITION_LEASE_ENABLED=false -> CAS degenerates to the prior
unconditional update_task_stage, lease inert, reaper -> ORCH-113 fallback (byte-for-
byte pre-ORCH-114). STAGE_TRANSITIONS / QG_CHECKS / check_* / machine-verdict keys /
existing table schemas — byte-for-byte (one additive table, no epoch column on tasks).

Observability: read-only `transition_lease` block in GET /queue + a Telegram alert on
forced/stale reclaim + optional POST /transition-lease/release?work_item=<id>.

Coverage: tests/test_orch114_transition_ownership.py (TC-01 mandatory regression of
the ORCH-111 class — red before fix, green after; TC-02..TC-14). Full suite green
(2048 passed); the 4 webhook tests that spied on the removed gitea.update_task_stage
were updated to spy on the new commit_stage_cas write path.

ADR: docs/work-items/ORCH-114/06-adr/ADR-001-transition-ownership-lease-and-stage-cas.md
Cross-cutting: docs/architecture/adr/adr-0045-transition-ownership-lease-and-stage-cas.md

Refs: ORCH-114
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 19:28:38 +03:00
cc03e68847 architect(ET): auto-commit from architect run_id=709 2026-06-15 19:28:38 +03:00
9fcca9efbc analyst(ET): auto-commit from analyst run_id=708 2026-06-15 19:28:38 +03:00
ab5e4c345b docs: init ORCH-114 business request 2026-06-15 19:28:38 +03:00
6565d50242 deploy-staging(ORCH-114): staging gate SUCCESS (8/10 PASS, C9a/C9b infra-waived)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 19:28:13 +03:00
6abb444839 Merge pull request 'ORCH-112: resilient-pull hygiene for dirty shared deploy-base (fix incident ORCH-111)' (#136) from feature/ORCH-112-bug-failed-cancelled-task-arti into main
Some checks failed
CI / test (push) Has been cancelled
2026-06-15 15:33:19 +03:00
58 changed files with 6084 additions and 89 deletions

View File

@@ -24,6 +24,19 @@ ORCH_PLANE_BOT_REVIEWER=
ORCH_PLANE_BOT_TESTER=
ORCH_PLANE_BOT_DEPLOYER=
ORCH_PLANE_BOT_STREAM=
# ORCH-117: sandbox-only fail-closed guard for Plane WRITES from a test/worktree
# process (regression of ORCH-114, where pytest mutated a live prod board issue).
# In the live runtime (uvicorn, no pytest) the guard is a no-op; in a test process
# it BLOCKS every Plane write unless BOTH the opt-in is true AND the target project
# is in the sandbox allowlist. Defaults are SAFE (default-deny): leave both as-is.
# ORCH_PLANE_TEST_WRITE_ENABLED -> opt-in for REAL Plane writes from a test process.
# false (default) = no test may write to Plane. NOT a kill-switch for the prod
# block: even true, only the sandbox allowlist below is writable (a prod write
# from pytest stays impossible).
# ORCH_PLANE_TEST_SANDBOX_PROJECTS -> CSV allowlist of sandbox project ids the
# opt-in may write to. Default = the single SANDBOX project; empty = none.
ORCH_PLANE_TEST_WRITE_ENABLED=false
ORCH_PLANE_TEST_SANDBOX_PROJECTS=8c5a3025-4f9d-4190-b79f-fa06276bb27e
# Telegram live-tracker / alerts (empty -> notifications are logged, not sent).
ORCH_TELEGRAM_BOT_TOKEN=
ORCH_TELEGRAM_CHAT_ID=
@@ -434,6 +447,34 @@ ORCH_REAPER_MAX_RUNNING_S=5400
ORCH_REAPER_FINALIZE_GRACE_S=300
ORCH_LEASE_RECLAIM_ENABLED=true
# ORCH-114 (adr-0045): durable transition-ownership lease + expected-stage CAS for
# side-effectful stage transitions. Generalises the process-local ORCH-113 finalizer-
# liveness into a DURABLE, cross-path owner-exclusion (additive table `transition_lease`)
# so a concurrent OR post-restart re-entry into a side-effectful transition (reaper /
# reconciler / webhook / startup-requeue) is deferred or a no-op instead of re-applying
# an irreversible effect (merge_pr / coverage-ratchet / image-rebuild / prod-deploy
# initiation / contradictory rollback<->done). Two layers, both gated by the SINGLE
# kill-switch below: (1) a durable lease on ENTRY to the side-effectful region (a second
# actor that sees a live owner does not start the heavy sub-gates at all); (2) an
# expected-stage CAS on the stage WRITE (a lost race -> abort with NO side effect), which
# also closes the paths that write the stage in bypass of advance_stage. Owner liveness =
# owner_pid + owner_boot_id (NOT a heartbeat), so restart recovery is free (new process ->
# new boot_id -> all prior leases instantly stale -> reclaimed). The lease has NO own TTL:
# its hard age ceiling IS the reaper Tier-3 backstop (ORCH_REAPER_MAX_RUNNING_S), so the
# cross-cutting budget invariant ORCH-065/109/110/113 is untouched. STAGE_TRANSITIONS /
# QG_CHECKS / check_* / machine-verdict keys / existing table schemas — byte-for-byte.
# TRANSITION_LEASE_ENABLED -> SINGLE kill-switch. false -> the lease is neither written
# nor read AND the CAS degenerates to the prior unconditional
# update_task_stage -> behaviour byte-for-byte as before
# ORCH-114 (reaper -> ORCH-113 in-memory fallback,
# reconciler/webhook skip-guard inert). Default true.
# TRANSITION_LEASE_REPOS -> CSV scope. Empty -> applies ONLY to the self-hosting repo
# (orchestrator), where the irreversible side-effectful edges
# live; non-empty -> only the listed repos. Mirrors
# ORCH_COVERAGE_GATE_REPOS -> enduro untouched at the default.
ORCH_TRANSITION_LEASE_ENABLED=true
ORCH_TRANSITION_LEASE_REPOS=
# ORCH-063: disk-watchdog — background heartbeat that measures HOST-FS fill via the
# mounted bind-paths (/repos, /app/data) with shutil.disk_usage (NOT the container
# overlay /) and Telegram-alerts the operator at >= threshold. On 07.06.2026 the

View File

@@ -3,6 +3,10 @@
Формат: [Keep a Changelog](https://keepachangelog.com/). Записи — на смысловой PR/задачу.
## [Unreleased]
- **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()`.
- **Интеграция:** `advance_stage` захватывает lease на входе в side-effectful ребро (`deploy-staging`/`deploy`), пишет стадию через CAS, освобождает lease в `try/finally` (на любом исходе, включая исключение/откат); **rollback-записи side-effectful под-гейтов** (`_handle_merge_gate_rollback`/`_handle_security_gate`/`_handle_coverage_gate`/`_handle_image_freshness`) пишут `development` через тот же CAS (общий хелпер `_rollback_stage_cas`, ADR-001 D4: защита rollback↔done — под держимым lease это единственный владелец, проигранный CAS → аборт без side-effects, не слепой перетир `done`); job-reaper `_finalizer_owns` обобщён с процесс-локального ORCH-113 (Tier-2/`deploy-staging`) на **durable cross-path** lease (defer при живом владельце; Tier-3 backstop игнорирует маркер → bounded reclaim; реап force-освобождает lease); reconciler F-1 и Plane-webhook (`_try_advance_stage`) делают **defer** при активном lease; `main.lifespan` зовёт `recover_on_startup()` после `requeue_running_jobs`. Наблюдаемость — read-only блок `transition_lease` в `GET /queue` + Telegram-алерт на форсированный/устаревший реклейм + опциональный `POST /transition-lease/release?work_item=<id>`. Покрытие — `tests/test_orch114_transition_ownership.py` (TC-01 обязательный регресс класса ORCH-111: красный до фикса, зелёный после; TC-02…TC-14 + регресс CAS на in-region rollback). Флаги (`config.py`, дефолт = боевое): `transition_lease_enabled` (env `ORCH_TRANSITION_LEASE_ENABLED`), `transition_lease_repos` (env `ORCH_TRANSITION_LEASE_REPOS`).
- **Гигиена shared deploy-базы: устойчивый self-deploy `git pull` к грязному дереву** (ORCH-112, `fix`, bug→escalate full-cycle): устранён инцидент ORCH-111 — self-deploy падал на шаге `git pull origin main` хост-хука с `error: Your local changes to the following files would be overwritten by merge: src/config.py` (грязь от неуспешной/отменённой/брошенной задачи ORCH-104 в общем main checkout) → деплой вставал → ручное вмешательство (на self-hosting — групповой риск). Решение — **resilient-pull, встроенный в прод-deploy-хук** (`--deploy`): перед `git pull` хук при обнаружении грязи приводит deploy-базу к чистому актуальному `origin/main` (`git fetch` + `git reset --hard origin/main` + **скоупленный** `git clean -fd`). Аддитивно, под kill-switch, never-raise, скоуп self-hosting; `STAGE_TRANSITIONS` / реестр `QG_CHECKS` / семантика и имена `check_*` / machine-verdict-ключи / схема БД / exit-code-контракт хука (0/1/2, ORCH-036) — **байт-в-байт не тронуты** (это устойчивость deploy-пути, **не** Quality Gate и **не** стадия). ADR: `docs/work-items/ORCH-112/06-adr/ADR-001-deploy-base-checkout-hygiene.md`, сквозной `docs/architecture/adr/adr-0044-deploy-base-checkout-hygiene.md`.
- **Leaf `src/checkout_hygiene.py` (новый, чистый never-raise):** по образцу `serial_gate`/`cancel`/`self_deploy` (импортирует только `config`, лениво `self_deploy`/`qg.checks`/`notifications`) — `applies(repo)` (kill-switch `checkout_hygiene_enabled` + скоуп `checkout_hygiene_repos`, пусто → self-hosting only, локально и ПЕРВЫМ), `hook_env(repo, work_item_id)` (env-префикс `CHECKOUT_HYGIENE=1 HYGIENE_REPORT=<host-path>`, инжектится в detached-команду хука только при `applies==True`, иначе `""` → голый pull 1:1), `read_report`/`alert_dirty` (наблюдаемость), `snapshot()` (read-only блок `GET /queue`).
- **Хук-блок «2a. Resilient pull» (`scripts/orchestrator-deploy-hook.sh`):** между шагом «1. Capture PREV_IMG» и «2. Pull», под `if [[ "${CHECKOUT_HYGIENE:-0}" == "1" ]]`. **Сохранность (NFR-2, жёсткий контракт):** `git clean`**только `-fd`, НИКОГДА `-x`** (иначе удалил бы gitignored `.env`/прод-секреты, `data/*.db`/БД, `build/`); явные `-e '.deploy-prev-image-*'` и `-e 'deploy-hook.log'` (untracked-но-НЕ-ignored — иначе сломался бы rollback `do_rollback`); sibling `<repos_dir>/.deploy-state-*`/`.merge-lease-*.json` (под родителем `$REPO`) и `.git/worktrees/*` (внутри `.git/`) — вне области `git clean` в `$REPO`. Каждый git-шаг — `|| log "...continuing"` (never-break): сбой гигиены не ухудшает исход относительно текущего голого pull; на чистой базе блок — no-op (happy-path и exit-коды байт-в-байт). `--build-staging` (build из worktree, без pull) не затронут.

View File

@@ -323,6 +323,94 @@ to the following files would be overwritten by merge: src/config.py` — гря
фикса, зелёный после). Детали — `docs/work-items/ORCH-112/06-adr/ADR-001-deploy-base-checkout-hygiene.md`,
сквозной `docs/architecture/adr/adr-0044-deploy-base-checkout-hygiene.md`.
## Единое владение side-effectful переходами: durable-lease + expected-stage CAS (ORCH-114)
Закрыт **корневой класс** инцидент-цепочки **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). Это **обобщение** процесс-локальной finalizer-liveness
ORCH-113 в **durable cross-path** владение. Аддитивно, под единым kill-switch, never-raise; новый
leaf `src/transition_lease.py`. **Инвариант:** `STAGE_TRANSITIONS` / реестр `QG_CHECKS` / семантика
и имена `check_*` / machine-verdict-ключи / **схемы существующих таблиц** — байт-в-байт (одна
аддитивная таблица `transition_lease`, без epoch-колонки на `tasks`); hot-path `claim_next_job`
lease **не консультирует** (fail-open, очередь репо никогда не клинится).
- **Два комплементарных слоя (оба под `transition_lease_enabled`):** (1) **durable transition-lease**
(таблица `transition_lease`) — владение на ВХОДЕ в side-effectful регион: второй актор, увидев
живого владельца (`is_held_by_live_owner`), не стартует тяжёлые под-гейты вовсе (предотвращение,
не починка постфактум); (2) **expected-stage CAS** (`db.update_task_stage_cas` ↔
`commit_stage_cas`) — на ЗАПИСИ стадии: проигравший гонку аборт без побочных эффектов. 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 мгновенно устаревшие → реклеймятся).
`main.lifespan` зовёт `recover_on_startup()` после `requeue_running_jobs`. Lease **без
собственного TTL**: его потолок возраста = Tier-3 backstop `reaper_max_running_s` (5400) →
сквозной бюджет ORCH-065/109/110/113 не тронут.
- **Интеграция:** `advance_stage` захватывает lease на входе в side-effectful ребро
(`deploy-staging`/`deploy`), пишет стадию через CAS, освобождает lease в `try/finally` (на
любом исходе, включая исключение/откат); job-reaper `_finalizer_owns` обобщён с процесс-локального
ORCH-113 на durable cross-path (defer при живом владельце; Tier-3 backstop игнорирует маркер →
bounded reclaim; реап force-освобождает lease); reconciler F-1 и Plane-webhook (`_try_advance_stage`)
делают **defer** при активном lease.
- **Флаги** (`config.py`, дефолт = боевое): `transition_lease_enabled` (env
`ORCH_TRANSITION_LEASE_ENABLED`; `False` → lease не пишется/не читается, CAS вырождается в прежний
безусловный `update_task_stage` → байт-в-байт до ORCH-114: reaper → ORCH-113 in-memory fallback,
reconciler/webhook skip-guard инертны), `transition_lease_repos` (env `ORCH_TRANSITION_LEASE_REPOS`;
CSV; **пусто → self-hosting only** — где живут необратимые рёбра; зеркало `coverage_gate_repos`,
enduro не затронут). Наблюдаемость — read-only блок `transition_lease` в `GET /queue` +
Telegram-алерт на форсированный/устаревший реклейм + опциональный
`POST /transition-lease/release?work_item=<id>`. Покрытие — `tests/test_orch114_transition_ownership.py`
(TC-01 обязательный регресс класса ORCH-111: красный до фикса, зелёный после; TC-02…TC-14). Детали —
`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`.
## Sandbox-only fail-closed изоляция записи в Plane (ORCH-117)
Закрыт корневой класс инцидента **ORCH-114**: тест/worktree-процесс выполнил РЕАЛЬНУЮ запись
(`PATCH …/issues/… state=<Done>` + комментарий «Stage: deploy → done») против **боевого**
Plane-проекта, т.к. тест/staging-процессы наследуют живой боевой Plane-токен
(`PLANE_HEADERS`/`PROJECT_ID` захвачены литералами **на импорте** `plane_sync` — подмена env/токена
постфактум бесполезна, NFR-4) и **ничто** не принуждало их писать только в sandbox. Прямой
прецедент — `tests/conftest.py::_no_telegram` (autouse-глушилка «pytest на проде слал реальные
Telegram-сообщения»); симметричной защиты для Plane-**записи** не было. **Инвариант:** запись в
боевой Plane-проект из любого pytest/worktree-процесса **физически невозможна** независимо от
токена. Аддитивно, never-raise в боевом пути; `STAGE_TRANSITIONS`/реестр `QG_CHECKS`/семантика и
имена `check_*`/machine-verdict-ключи/схема БД — **байт-в-байт не тронуты** (это изоляция клиента
Plane, **не** Quality Gate и **не** стадия).
- **Чокпоинт (D1):** новый чистый leaf `src/plane_write_guard.py` (`decide(project_id, op,
work_item) -> (ALLOW|BLOCK, reason)`, never-raise, по образцу `deploy_status_guard`/`serial_gate`/
`cancel`) врезан в **3 примитива записи** `plane_sync` (`update_issue_state` / `add_comment` /
`_set_issue_state_direct`) **на момент вызова** — сразу после локального `_resolve_project_id` и
**до** любого сетевого шага (ни GET, ни PATCH/POST). Все `set_issue_*`/`notify_*` сводятся к этим
трём примитивам → один гард ловит любой путь, включая будущие.
- **Детект тест-процесса (D2):** `"pytest" in sys.modules` `PYTEST_CURRENT_TEST` (на момент
вызова). Боевой и staging рантаймы — `uvicorn src.main:app`, pytest в процесс **не** импортируют →
гард там строгий **no-op** (NFR-2/NFR-3); worktree `python -m pytest` (инцидентный путь)
гарантированно имеет pytest в `sys.modules` → ловится.
- **Решение (D3):** default-deny. Запись из тест-процесса разрешена ⇔ одновременно (а) opt-in
`plane_test_write_enabled=True` **и** (б) целевой проект ∈ sandbox-allowlist
`plane_test_sandbox_projects` (дефолт = единственный SANDBOX `8c5a3025-4f9d-4190-b79f-fa06276bb27e`).
Нерезолвимый/пустой проект → блок (fail-closed, NFR-1). Боевой проект запрещён **даже при opt-in**
(allowlist sandbox-only). Внутренняя ошибка `decide` в тест-контексте → fail-CLOSED (`guard-error`).
- **Второй слой (D5):** независимый autouse-floor `tests/conftest.py::_plane_sandbox_only` форсит
opt-in OFF для **всего** сьюта (по образцу `_no_telegram`/`_disable_*`); sandbox-e2e ре-энейблит
opt-in в своей фикстуре поверх floor. Два sandbox-bound слоя → нет одиночной точки, чьё выключение
переоткрывает прод.
- **Умышленно БЕЗ kill-switch прод-блока (D4, NFR-6/FR-7, anti-drift):** выключателя,
переоткрывающего прод-запись из pytest, **нет** — единственный реверс — sandbox-bound opt-in. Не
добавлять «общий kill-switch гарда» (реинтродуцирует дефект ORCH-114; reviewer ловит как ≥P1).
- **Аудит (D7):** блок → громкий структурный ERROR (`project_id`/`work_item`/`op`/`reason`:
`prod-project-in-test`/`opt-in-disabled`/`ambiguous-target`/`guard-error`); разрешённая
sandbox-запись → INFO. **Флаги** (`config.py`, дефолты безопасные): `plane_test_write_enabled`
(env `ORCH_PLANE_TEST_WRITE_ENABLED`, дефолт `False`), `plane_test_sandbox_projects` (env
`ORCH_PLANE_TEST_SANDBOX_PROJECTS`, CSV). `scripts/staging_check.py` Block C (E2E в SANDBOX) —
отдельный процесс с собственными httpx-вызовами, гардом не затронут. Покрытие —
`tests/test_orch117_plane_write_isolation.py` (TC-01 — обязательный регресс ORCH-114: красный до
врезки, зелёный после; TC-02…TC-14). Детали —
`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`.
## Машинный журнал уроков (ORCH-098)
Шаг 1 («Фундамент», F2) эпика саморазвития: формализует свободнотекстовые «уроки» из `memory/` в
**машинную структурированную таблицу отклонений конвейера** `lessons`, фундамент для будущих

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,94 @@
---
work_item: ORCH-114
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-15
model_used: claude-opus-4-8
---
# adr-0045: Durable transition-ownership lease + expected-stage CAS — единое владение side-effectful переходами стадий
- **Статус:** proposed
- **Дата:** 2026-06-15
- **Задача:** ORCH-114 (bug → escalate full-cycle; системный наследник кластера ORCH-110/111/112/113)
- **Детальный ADR:** `docs/work-items/ORCH-114/06-adr/ADR-001-transition-ownership-lease-and-stage-cas.md`
- **Обобщает:** `adr-0043` (ORCH-113 in-memory finalizer-liveness — отправная точка)
- **Уточняет/опирается:** `adr-0011` (reaper/lease-reclaim ORCH-065), `adr-0040` (бюджеты ORCH-109),
`adr-0042` (merge-retest ORCH-110), `adr-0027` (merge-lease ORCH-043), `adr-0029` (coverage-ratchet ORCH-027),
ORCH-071/073/093 (SHA-in-main / already-in-main), ORCH-036 (`INITIATED` self-deploy)
## Контекст
Корневой класс инцидент-цепочки ORCH-110/111/112/113: **у side-effectful переходов стадий нет единого
владения**. `db.update_task_stage` — голый `UPDATE … WHERE id=?` без CAS (`db.py:671679`); `advance_stage`
ре-ентерабельна без защиты и исполняет минуты-длинные необратимые под-гейты (`deploy-staging → deploy`:
security→merge-retest→coverage→image-freshness; `deploy → done`: `merge_pr`/ratchet/proof-of-merge) **до**
единственной записи стадии. ≥5 акторов входят в переход независимо (монитор/webhook/reconciler F-1/reaper/
Phase-C finalizer) + 6 путей пишут стадию в обход `advance_stage` (5× `gitea.py`, 1× `plane.py:806`).
ORCH-113 (`finalizer_liveness`) закрыл это лишь in-memory, reaper-Tier-2, `deploy-staging`, теряя владение
на рестарте — остаточный кросс-путь дал двойной эффект и противоречие rollback↔done (ORCH-111, job 1914/PR #130).
## Решение
Два комплементарных аддитивных слоя под единым kill-switch, never-raise:
1. **Durable transition-lease** — новая аддитивная таблица `transition_lease`
(`task_id PK, owner, owner_pid, owner_boot_id, run_id, stage, acquired_at`; `CREATE TABLE IF NOT EXISTS`,
паттерн `repo_freeze`/`coverage_baseline`). Владение захватывается на **входе** в side-effectful регион
`advance_stage` (рёбра `deploy-staging→deploy`, `deploy→done`, Phase C `run_deploy_finalizer`); второй
актор, увидев **живого** владельца, не стартует под-гейты вовсе (предотвращение класса, а не починка).
Release — в `try/finally`. **Liveness = `owner_pid` + `owner_boot_id`**, НЕ heartbeat (heartbeat отвергнут
тем же доводом, что в adr-0043: блокирующий 900s re-test не может его бить). Реклейм мёртвого/устаревшего
(pid мёртв ИЛИ boot-id чужой) — немедленно; зависший живой добивается Tier-3.
2. **Expected-stage CAS**`update_task_stage_cas(task_id, expected_stage, new_stage)`
(`UPDATE tasks SET stage=? … WHERE id=? AND stage=?`, rowcount==1 ⇒ выиграл; 0 ⇒ проиграл → аборт без
побочных эффектов). Покрывает остаточное окно гонки И 6 обходных путей. Без epoch-колонки: для текущей
модели стадия *и есть* версия (epoch — задокументированное форвард-расширение под `--workers>1`).
**Осведомлённость акторов:** reaper консультирует durable-lease на **всех** путях (обобщение ORCH-113):
живой → defer, мёртвый → реклейм, Tier-3 маркер игнорирует; reconciler F-1 и webhook (Approved/Confirm
Deploy) — новый skip-guard по образцу escalated/Blocked/task-deps. `finalizer_liveness` сохранён без правок
как поведение при **выключенном** ORCH-114 (надстройка durable-слоя поверх).
**Умное восстановление (FR-4)**НЕ новый recovery-мозг, а композиция: `requeue_running_jobs` (есть) +
startup stale-clear (boot-id mismatch ⇒ старые lease мертвы) + идемпотентность re-drive через
**авторитетные durable-факты предшественников** (SHA-in-main ORCH-071/073, `INITIATED` ORCH-036,
coverage-ratchet CAS ORCH-027). Lease лишь гарантирует **последовательную**, не конкурентную, их проверку.
**Бюджет (NFR-6):** lease без собственного TTL; жёсткий потолок возраста = Tier-3 `reaper_max_running_s`
(5400), reaper при реапе force-освобождает lease. Сквозной инвариант `5400 > Σ(≈4460)+grace` и
`reaper_finalize_grace_s`/`reaper_max_running_s`**не тронуты**.
**Конфиг:** `transition_lease_enabled=True` (kill-switch) + `transition_lease_repos=""` (CSV; пусто →
self-hosting only, паттерн coverage/serial-gate). Leaf `src/transition_lease.py` never-raise.
**Инварианты:** `STAGE_TRANSITIONS` / `QG_CHECKS` / каждый `check_*` / machine-verdict-ключи / схемы
**существующих** таблиц — байт-в-байт; +1 аддитивная таблица; механизм не рестартит прод, не пушит/
force-push `main`, не трогает detached-деплой (NFR-5). Hot-path `claim_next_job` не тронут (fail-open).
## Альтернативы
- Только CAS (без lease) — не предотвращает двойной side-effect в полёте.
- Только lease (без CAS) — не покрывает 6 обходных путей + окно consult→acquire.
- Heartbeat-liveness — блокирующий re-test не бьёт heartbeat (довод adr-0043).
- Lease-файл per-task — CAS на стадию всё равно DB-операция; БД когерентнее, merge-lease-файл per-repo для
иной задачи (сериализация мержей), не дублируется.
- epoch-колонка / sub-state `finalizing` в `jobs.status` / per-stage grace на Σ — отвергнуто (как в adr-0043:
меняет семантику/нарушает бюджет/неиспользуемо).
## Последствия
- (+) Класс двойного эффекта закрыт в корне; конкурентный/после-рестартовый/reconciler/webhook пути покрыты.
- (+) Рестарт-safe без нового таймера; boot-id готовит multi-process; бюджет и инварианты конвейера целы; +1 таблица.
- (+) Дыра обходных путей gitea/plane закрыта CAS; откат — один env-флаг.
- () Полная multi-writer эксклюзия валидна при одном процессе/одной БД (как adr-0043); durable делает её
корректной для рестарта, но `--workers>1`-верификация — вне объёма (риск в `10-tech-risks.md`).
## Связи
- Обобщает `adr-0043`; опирается на `adr-0011`/`adr-0040`/`adr-0042`/`adr-0027`/`adr-0029` и ORCH-071/073/093/036.
- Маркеры (ORCH-078/TRACEABILITY): блоки reaper/finalizer-liveness/stage-engine несут ORCH-065/109/110/113 +
новый `ORCH-114`; правки сверяются с их ADR (анти-археология — этот сводный сквозной ADR).
- Детально: `docs/work-items/ORCH-114/06-adr/ADR-001-transition-ownership-lease-and-stage-cas.md`.
</content>

View File

@@ -0,0 +1,121 @@
---
work_item: ORCH-117
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-15
model_used: claude-opus-4-8
---
# adr-0046: Sandbox-only fail-closed гард записи в Plane из тест-процесса
Сквозной (cross-cutting) ADR. Вводит инвариант **«мутирующая запись в Plane из тест/worktree-процесса
физически невозможна в боевой проект; sandbox — только под явным opt-in»** поверх **общего**
Plane-клиента `src/plane_sync.py` (три примитива записи, используемые ВСЕМИ проектами общего
инстанса) и нового тест-харнесс-инварианта `tests/conftest.py`. Детальное решение задачи —
`docs/work-items/ORCH-117/06-adr/ADR-001-sandbox-only-plane-write-guard.md`.
> Регистрируется как сквозной, т.к. правит **системно используемые** примитивы записи
> `update_issue_state`/`add_comment`/`_set_issue_state_direct` и вводит новый рантайм-компонент
> (leaf `src/plane_write_guard.py`), затрагивающий индикацию (слой B, ORCH-066) всех проектов.
> Кросс-каттинг с adr-0028 (deploy-status guard, ORCH-094) и adr-0009 (staging-tolerance, ORCH-061):
> оба — потребители того же `plane_sync`; гард для них — no-op в боевом/staging рантайме.
## Статус
Proposed
## Контекст
Инцидент **ORCH-114**: тестовый/worktree-процесс (`python -m pytest` из worktree) выполнил
**реальную** запись в Plane против **боевого** проекта ORCH (`PATCH state=<Done>` + комментарий) —
«ложный Done» на боевой доске. Корень (сверено по коду `src/plane_sync.py`):
1. `PLANE_HEADERS`/`PROJECT_ID` (боевой токен + боевой дефолтный проект) **захвачены на импорте**
модуля (стр. 17/57) → подмена env/токена постфактум бесполезна.
2. Тестовые `os.environ.setdefault("ORCH_PLANE_API_TOKEN",…)`**no-op** в контейнере с уже
установленной боевой переменной.
3. Все мутации сходятся в **три** примитива (`update_issue_state`/`add_comment`/
`_set_issue_state_direct`), и ни один **не** проверяет тест-контекст и легитимность целевого
проекта.
Симметричная защита для Telegram (`tests/conftest.py::_no_telegram`) существует и работает по тому же
классу проблем («pytest на проде слал реальные сообщения»); для Plane-записи её **не было**.
## Решение
**Fail-closed гард на низком чокпоинте**, в момент вызова, двумя независимыми sandbox-bound слоями.
### D1 — Рантайм-leaf `src/plane_write_guard.py` (never-raise)
Чистый leaf (паттерн `serial_gate`/`cancel`/`deploy_status_guard`): импортирует только `config`,
лениво `db`. `decide(project_id, op, work_item_id) -> (ok: bool, reason: str)`:
1. `not _in_test_process()`**ALLOW** (боевой/staging рантайм — no-op, byte-for-byte).
2. `project_id` нерезолвим → **BLOCK** `ambiguous-target` (fail-closed, NFR-1).
3. `not plane_test_write_enabled`**BLOCK** `opt-in-disabled`.
4. `project_id ∉ sandbox-allowlist`**BLOCK** `prod-project-in-test` (sandbox-only даже при opt-in).
5. иначе → **ALLOW** `sandbox-opt-in` (audit INFO).
Врезается в 3 примитива `plane_sync` сразу после `_resolve_project_id` и **до** любого сетевого шага;
на BLOCK — структурный аудит + `return` (ни GET, ни PATCH/POST).
### D2 — Детект `_in_test_process()`
`"pytest" in sys.modules or PYTEST_CURRENT_TEST` (call-time). Боевой/staging рантайм
(`uvicorn src.main:app`) pytest в свой процесс не импортирует → детект там никогда не срабатывает
(нулевая регрессия). worktree-`python -m pytest` (инцидентный путь) детектируется гарантированно.
### D3 — Conftest-floor `tests/conftest.py::_plane_sandbox_only`
Autouse-фикстура (паттерн `_no_telegram`/`_reset_webhook_secrets`/`_disable_*`) форсит во ВСЕХ тестах
безопасные дефолты (`plane_test_write_enabled=False`, allowlist = канонический SANDBOX id),
перекрывая любую боевую переменную из окружения. Sandbox-e2e ре-энейблит opt-in **после** autouse
(scoping реальной записи на себя). Слой независим от рантайм-leaf → двойной default-deny.
### D4 — Реверс через opt-in, БЕЗ kill-switch (норматив)
Единственный реверсивный регулятор — sandbox-bound opt-in `plane_test_write_enabled` (+ allowlist
`plane_test_sandbox_projects`). **Намеренно нет** prod-блок kill-switch: выключатель, обнуляющий
prod-блок в тест-процессе, был бы «чёрным ходом» (NFR-6). Прецедент — `_no_telegram` (тоже без
«разрешить»-флага). **Анти-дрейф (норматив на будущее):** не вводить общий kill-switch гарда,
переоткрывающий прод-запись из pytest.
### D5 — Скоуп: НЕ `*_repos`
В отличие от гейт-leaf'ов (`serial_gate`/`coverage_gate`, scope по репо, т.к. *действуют* на репо),
гард защищает запись в **любой** боевой проект общего workspace (включая боевой enduro) → скоупа по
репо нет; гейты — `_in_test_process()` + opt-in (как у observer-leaf `lessons`).
## Инварианты (что НЕ меняется)
`STAGE_TRANSITIONS` / реестр `QG_CHECKS` / семантика и имена `check_*` / machine-verdict-ключи
(`verdict:`/`result:`/`staging_status:`/`deploy_status:`/`security_status:`/`coverage_status:`) /
схема БД — **байт-в-байт не тронуты**. Это bugfix-изоляция клиента Plane, **не** Quality Gate и
**не** стадия. Боевой и staging рантаймы — byte-for-byte (no-op гарда). adr-0028 (deploy-status
guard) / adr-0009 (staging-tolerance) / ORCH-066 (статусная модель) в проде/стейджинге не затронуты.
## Конфиг
| Ключ | Env | Дефолт |
|------|-----|--------|
| `plane_test_write_enabled` | `ORCH_PLANE_TEST_WRITE_ENABLED` | `False` |
| `plane_test_sandbox_projects` | `ORCH_PLANE_TEST_SANDBOX_PROJECTS` | `8c5a3025-4f9d-4190-b79f-fa06276bb27e` |
## Последствия
- **+** Прод-запись в Plane из pytest/worktree физически невозможна независимо от токена; ORCH-114
закрыт у источника и стал видимым (аудит).
- **+** Нулевая регрессия боевого/staging рантайма и гейтов/схемы БД.
- **** Детект завязан на «pytest-в-процессе» (теоретический ложноположительный риск — TR-1) и
умышленный отказ от kill-switch требует явной фиксации (TR-4). См. `10-tech-risks.md`.
- **Откат:** снять врезку гарда + autouse-фикстуру + 2 конфиг-ключа → поведение до ORCH-117 (дефект
возвращается).
## Ссылки
- Детально: `docs/work-items/ORCH-117/06-adr/ADR-001-sandbox-only-plane-write-guard.md`
- Риски: `docs/work-items/ORCH-117/10-tech-risks.md`
- Связанные: [adr-0028](adr-0028-terminal-window-aware-deploy-status-guard.md) (ORCH-094),
[adr-0009](adr-0009-staging-infra-tolerance.md) (ORCH-061),
[adr-0034](adr-0034-lessons-journal.md) (observer-leaf без `*_repos`)
- Сверено по коду: `src/plane_sync.py:17,57,846-889,1038-1051`, `tests/conftest.py`,
`scripts/staging_check.py:283`

View File

@@ -188,6 +188,21 @@ CREATE TABLE events (
payload TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- ORCH-114 (adr-0045): durable transition-ownership lease. ОДНА аддитивная таблица
-- (CREATE TABLE IF NOT EXISTS, паттерн repo_freeze/coverage_baseline/lessons) — одна
-- строка = ≤1 активный владелец side-effectful перехода задачи. Живость владельца =
-- owner_boot_id (нонс старта процесса; рестарт ⇒ смена ⇒ прежний lease мёртв) +
-- pid_alive(owner_pid). БЕЗ epoch/version-колонки на tasks (стадия = версия CAS).
CREATE TABLE transition_lease (
task_id INTEGER PRIMARY KEY,
owner TEXT NOT NULL, -- monitor|reaper|reconciler|webhook|finalizer|engine
owner_pid INTEGER,
owner_boot_id TEXT,
run_id INTEGER,
stage TEXT, -- from-стадия захвата (контекст/наблюдаемость)
acquired_at TEXT DEFAULT (datetime('now'))
);
```
## Deployment
@@ -369,7 +384,13 @@ status='queued'` и проверяет `rowcount`. При гонке двух т
В `main.py` lifespan **после** M-1 orphan-recovery вызывается `requeue_running_jobs()`:
jobs со статусом `running` (воркер умёр на рестарте) → возвращаются в `queued`.
Потом стартует воркер; на shutdown — `worker.stop()` (Event.set + join).
**ORCH-114 (adr-0045):** сразу следом вызывается `transition_lease.recover_on_startup()`
новый процесс имеет свежий `boot_id`, поэтому ВСЕ записанные ранее `transition_lease`
устарели (boot-id mismatch) → реклеймятся, и только что requeued-jobs переисполняют свои
side-effectful переходы **последовательно** (один владелец), без двойного необратимого
эффекта. Идемпотентность самого re-drive обеспечивают существующие авторитетные факты
(SHA-in-main ORCH-071/073, маркер `INITIATED` ORCH-036, coverage-ratchet CAS ORCH-027) —
НЕ новый recovery-мозг. Потом стартует воркер; на shutdown — `worker.stop()` (Event.set + join).
### Job-reaper (ORCH-065, рестарт НЕ требуется)
@@ -394,7 +415,16 @@ daemon-поток `src/job_reaper.py` (каркас `reconciler`) периоди
try/finally): при `stage=="deploy-staging"` И активном владении → **defer**;
Tier-3 backstop маркер игнорирует (мёртвый/застрявший finalizer добивается).
Kill-switch `ORCH_REAPER_FINALIZER_LIVENESS_ENABLED`; in-memory, restart-safe через
`requeue_running_jobs` (до старта reaper); схема БД и сквозной бюджет не тронуты;
`requeue_running_jobs` (до старта reaper); схема БД и сквозной бюджет не тронуты.
**ORCH-114 (adr-0045):** обобщает это in-memory-владение до **durable, кросс-путевого**
`transition_lease` (таблица `task_id PK, owner, owner_pid, owner_boot_id, …`): reaper
консультирует durable-lease на **всех** релевантных путях (не только Tier-2/`deploy-staging`),
живость владельца = `pid_alive(owner_pid)` + совпадение boot-id (рестарт ⇒ прежние lease мертвы);
парная CAS-запись стадии (`update_task_stage_cas`, `WHERE id=? AND stage=?`) — аборт проигравшего
без побочных эффектов; reconciler F-1 и webhook тоже defer при живом владельце. Kill-switch
`ORCH_TRANSITION_LEASE_ENABLED` (off → ровно поведение ORCH-113 выше); `finalizer_liveness.py`
не правится (надстройка durable-слоя поверх). Потолок возраста lease = `reaper_max_running_s`
(Tier-3 force-освобождает), сквозной бюджет цел;
- **Tier-3** — backstop: job висит `running` дольше `reaper_max_running_s`.
Реап атомарен (`UPDATE jobs SET ... WHERE id=? AND status='running'` + `rowcount`,

View File

@@ -141,6 +141,8 @@ watchdog'а: **watchdog сигналит, pruner убирает**.
| `ORCH_PLANE_API_URL` / `_TOKEN` / `_WORKSPACE_SLUG` | доступ к Plane API |
| `ORCH_PLANE_WEB_URL` | внешний (браузерный) web-URL Plane для кликабельных ссылок на issue в уведомлениях (ORCH-017); пусто → фолбэк на `ORCH_PLANE_API_URL`, loopback-фолбэк → ссылка опускается |
| `ORCH_PLANE_WEBHOOK_SECRET` | HMAC-проверка вебхуков Plane |
| `ORCH_PLANE_TEST_WRITE_ENABLED` | ORCH-117: opt-in реальной записи в Plane из **тест-процесса** (дефолт `false` = default-deny). НЕ kill-switch прод-блока: даже `true` пишет только в sandbox-allowlist (прод-запись из pytest невозможна). В боевом/staging рантайме гард — no-op |
| `ORCH_PLANE_TEST_SANDBOX_PROJECTS` | ORCH-117: CSV-allowlist sandbox-проектов, куда opt-in разрешает запись из тестов (дефолт = единственный SANDBOX `8c5a3025-…`; пусто → ни один проект из тестов не пишется) |
| `ORCH_GITEA_URL` / `_TOKEN` / `_WEBHOOK_SECRET` | доступ к Gitea + HMAC |
| `ORCH_CLAUDE_BIN` | путь к claude CLI |
| `ORCH_REPOS_DIR` / `ORCH_HOST_REPOS_DIR` | каталог репозиториев (в контейнере / на хосте) |
@@ -224,6 +226,18 @@ watchdog'а: **watchdog сигналит, pruner убирает**.
**Что изолировано (безопасно):**
- Staging (8501) — отдельная БД (`./data/staging`), отдельный реестр (`ORCH_PROJECTS_JSON` = только sandbox). Прод-проекты не видит.
- Репозитории разделены, изоляция веток через git worktree (ORCH-2).
- **Запись в Plane из тест-процесса — sandbox-only fail-closed (ORCH-117).** Тест/worktree-процесс
наследует живой боевой Plane-токен (`PLANE_HEADERS`/`PROJECT_ID` захвачены на импорте `plane_sync`);
раньше **ничто** не мешало pytest смутировать боевую доску (инцидент ORCH-114 — «ложный Done»).
Теперь leaf `src/plane_write_guard.py` врезан в 3 примитива записи `plane_sync`
(`update_issue_state`/`add_comment`/`_set_issue_state_direct`) и **в тест-процессе** (детект
`pytest`-в-процессе) блокирует запись по умолчанию; разрешена только при opt-in
`ORCH_PLANE_TEST_WRITE_ENABLED=true` **И** целевом проекте ∈ `ORCH_PLANE_TEST_SANDBOX_PROJECTS`
(sandbox-only — боевой проект запрещён даже при opt-in). Боевой и staging рантаймы
(`uvicorn src.main:app`, без pytest в процессе) — гард **no-op**, запись как прежде. Прод-блок
**без kill-switch** (выключателя-чёрного-хода нет); второй слой — autouse-floor
`tests/conftest.py::_plane_sandbox_only` (по образцу `_no_telegram`). Детали — `CLAUDE.md`
«Sandbox-only fail-closed изоляция записи в Plane (ORCH-117)», adr-0046.
**Страховки:**
- Стадия `deploy-staging` (порт 8501) — обязательный гейт перед прод-деплоем орка. Прод-деплой недостижим, пока staging-гейт не зелёный (см. `STAGING.md`, ORCH-35). Гейт условный: реален только для self-hosting (repo=orchestrator), для остальных проектов — no-op.

View File

@@ -32,6 +32,7 @@ worker запустил агента стадии → результат про
| **Очередь задач** (`jobs` + worker) | Собственная очередь на SQLite: атомарный захват job'а, ретраи с backoff, зависимости между job'ами, ограничение параллелизма. |
| **State machine** (`src/stages.py`) | Карта стадий `STAGE_TRANSITIONS`: для каждой стадии — следующая, агент и гейт выхода. Единственный источник истины о конвейере. |
| **Stage engine** (`src/stage_engine.py`) | Исполняет переходы: диспетчеризация гейтов, откаты, под-гейты деплойного ребра, синхронизация статусов с Plane. |
| **Transition-lease** (`src/transition_lease.py`) | Durable-владение side-effectful переходом стадии: один владелец на задачу (lease на входе + expected-stage CAS на записи), liveness по pid+boot-id. Не даёт конкурентному или после-рестартовому повторному входу дважды применить необратимый эффект (merge / деплой / ratchet). |
| **Agent launcher** (`src/agents/launcher.py`) | Запускает Claude CLI агента в изолированном git worktree ветки задачи, следит за процессом (watchdog), авто-продвигает стадию по завершении. |
| **Реестр гейтов** (`src/qg/checks.py`) | `QG_CHECKS` — машинные проверки выхода со стадий; вердикты читаются только из YAML-frontmatter артефактов. |
| **Plane-sync** (`src/plane_sync.py`) | Индикация статусов в Plane (слой «показать человеку», никогда не управление конвейером). |

View File

@@ -47,6 +47,7 @@ deploy-лога; манифест — [PIPELINE_DOCS](../_standards/PIPELINE_DOC
| `coverage_baseline` | базовая линия покрытия тестами; растёт только вверх (ratchet) |
| `tracker_messages` | леджер всех Telegram-карточек задачи (зачистка сирот) |
| `lessons` | машинный журнал уроков — структурированные отклонения конвейера |
| `transition_lease` | durable-владение side-effectful переходом стадии: один владелец на задачу, liveness по pid+boot-id (предотвращает двойное применение необратимых эффектов) |
Все изменения схемы — аддитивные и идемпотентные (`CREATE TABLE IF NOT EXISTS`, ensure-column
при старте): обновление платформы не требует ручных миграций.

View File

@@ -20,8 +20,9 @@
## Служебные страницы платформы
- **`GET /queue`** — человекочитаемый снимок всего конвейера: очередь и job'ы, состояние
serial gate и заморозок, авто-лейблы, багфикс-трек, coverage, журнал уроков, фоновые
демоны. Первая точка диагностики «что сейчас происходит».
serial gate и заморозок, авто-лейблы, багфикс-трек, coverage, журнал уроков, владение
переходами (`transition_lease`), фоновые демоны. Первая точка диагностики «что сейчас
происходит».
- **`GET /metrics`** — машинный контракт для внешнего наблюдателя (версионированная схема):
health, возраст последних событий, счётчики сбоев.
- **`GET /health`** — живость процесса.

View File

@@ -0,0 +1,7 @@
# Business Request: BUG: pipeline stage transitions need ownership lease and smart startup recovery
Work Item ID: ORCH-114
## Description
TBD

View File

@@ -0,0 +1,186 @@
---
work_item: ORCH-114
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-15
model_used: claude-opus-4-8
escalate: full-cycle
---
# 01 — BRD (бизнес-требования): ORCH-114 — Ownership-lease для side-effectful переходов стадий + умное восстановление при старте
Work Item: **ORCH-114** · Repo: **orchestrator** · Стадия: analysis
> **Багфикс-трек → ЭСКАЛАЦИЯ В ПОЛНЫЙ ЦИКЛ (`escalate: full-cycle`).** Задача пришла под меткой
> `Bug` (укороченный маршрут ORCH-019, пропуск `architecture`), но дефект **системный и
> архитектурный**: вводится глобальный инвариант владения переходом, durable-механизм, переживающий
> рестарт процесса, и compare-and-swap на запись стадии. Это требует **ADR** (выбор механизма:
> lease / heartbeat / transition-epoch / CAS) и затрагивает поведение всего конвейера и нескольких
> фоновых акторов. Поэтому выпускается **полный** analysis-пакет; оператор снимает багфикс-трек
> эндпоинтом `POST /bug-fast-track/escalate?work_item=ORCH-114` → задача уходит в `architecture`
> (ADR-001 D5 ORCH-019).
---
## 1. Бизнес-контекст и проблема
ORCH-114 — **системный наследник** инцидент-цепочки ORCH-110 / ORCH-111 / ORCH-112 / ORCH-113.
Каждый предшественник закрыл **точечный** симптом, но **корневой класс** остался открыт:
**у side-effectful переходов стадий нет единого владения (ownership)**.
### Корень (установленный факт, верифицировано кодом)
`stage_engine.advance_stage(...)` — единая точка перехода между стадиями и исполнения тяжёлых
под-гейтов ребра `deploy-staging → deploy` (security → merge-gate re-test → coverage →
image-freshness) и под-гейта `deploy → done` (`_handle_merge_verify`: `merge_pr`, ratchet
coverage-baseline, запись proof-of-merge). При этом:
- **Запись стадии не атомарна по предусловию.** `db.update_task_stage(task_id, stage)`
«голый» `UPDATE tasks SET stage=? WHERE id=?` **без** `WHERE stage=?` (нет compare-and-swap, нет
epoch/version-колонки). Любой второй вызов безусловно перезатирает результат первого.
- **`advance_stage` ре-ентерабельна без защиты.** Внутри неё нет ни in-memory-лока на `task_id`,
ни durable-маркера «переход в процессе». Два конкурентных вызова для одной задачи оба читают
`stage='deploy-staging'`, оба прогоняют ВСЕ под-гейты, оба пишут `deploy`, оба ставят
следующего агента.
- **Минимум 5 путей входят в переход независимо:** (1) монитор агента (`launcher._try_advance_stage`,
auto-advance по `exit_code==0`), (2) Plane-webhook (`webhooks/plane._try_advance_stage`,
Approved / Confirm Deploy), (3) reconciler F-1 (`advance_if_gate_passed → advance_stage`,
`finished_agent=None`), (4) job-reaper (`job_reaper._gate_driven_advance → launcher._try_advance_stage`),
(5) deploy-finalizer Phase C (`run_deploy_finalizer → advance_stage(finished_agent="deployer")`).
Ни один не проверяет, не находится ли **другой** актор уже внутри того же перехода.
### Почему предшественники не закрыли класс
| Задача | Что закрыла | Что осталось открытым |
|--------|-------------|------------------------|
| **ORCH-110** | merge-gate re-test: ложный rollback по инфра-таймауту + tree-kill осиротевших pytest | Только merge-gate re-test; не вводит владения переходом |
| **ORCH-112** | гигиена общего deploy-checkout (грязь блокировала `git pull`) | Только чистка артефактов; не про конкурентные переходы |
| **ORCH-113** | reaper не пере-исполняет **живую** финализацию `deploy-staging` (Tier-2) | **process-local in-memory** реестр (`finalizer_liveness`), **только reaper**, **только Tier-2**, **только `deploy-staging`**; **теряется при рестарте**; **НЕ** покрывает reconciler / webhook / restart-recovery |
Таким образом, **остаточный кросс-путь** (ORCH-113 §ограничения сам это фиксирует): живой монитор
внутри `advance_stage(deploy-staging)` — и параллельно reaper (при выключенном liveness-флаге или
**после рестарта**, когда in-memory реестр пуст), либо reconciler F-1, либо webhook-путь — повторно
входят в тот же переход. Результат — **двойные** эффекты (security/merge/coverage/image-freshness/
прод-деплой) и **противоречивые** исходы (один путь откатывает на `development`, другой доводит до
`done`). Именно это наблюдалось в инциденте ORCH-111 (job 1914 / PR #130): повторный re-test покраснел
и дал ложный откат `deploy-staging → development` с ложным developer-retry, **одновременно** с успешной
финализацией и мержем оригинального монитора.
### Особый разрез — рестарт процесса (self-hosting)
Прод-контейнер `orchestrator` рестартится при self-деплое. Если процесс умирает **в середине**
финализации, in-memory `finalizer_liveness._OWNED` исчезает, `requeue_running_jobs` переводит
`running → queued`, и переход может быть **пере-исполнен с нуля** без знания, что часть необратимых
шагов (мерж в `main`, ratchet baseline, прод-деплой) уже применена. **Durable**-сигнал владения,
переживающий рестарт, отсутствует — это ключевая дельта ORCH-114 над ORCH-113.
---
## 2. Объём (scope)
### В объёме
- Единый **инвариант владения** side-effectful переходом/финализацией: в любой момент времени
переход конкретной задачи исполняет **не более одного** актора.
- **Compare-and-swap (CAS)** / transition-epoch на запись стадии: писатель применяет переход
только если предусловие (текущая стадия / эпоха) не изменилось с момента чтения; проигравший —
аборт **без** побочных эффектов.
- **Durable** механизм владения (lease/heartbeat/epoch — выбор за архитектором), переживающий
рестарт процесса.
- Осведомлённость **job-reaper** и **startup-requeue** о живой / устаревшей финализации (обобщение
ORCH-113 за пределы Tier-2 / `deploy-staging` / in-memory).
- **Reconciler F-1** и **webhook**-пути: skip/defer при активном lease перехода.
- **Умное восстановление при старте**: после смерти процесса в середине финализации система сходится
к **единственному** согласованному исходу (без двойных необратимых эффектов и без противоречий
rollback↔done).
- **Наблюдаемость**: read-only блок в `GET /queue` + алерт на форсированный/устаревший реклейм lease.
- **Регресс-тесты**: `deploy-staging`-ребро, deploy-finalizer (Phase C), restart-recovery.
### Вне объёма
- Изменение состава/порядка стадий (`STAGE_TRANSITIONS`), реестра `QG_CHECKS`, семантики/имён
`check_*`, машинных вердикт-ключей (`verdict:`/`result:`/`deploy_status:`/`staging_status:`/
`security_status:`/`coverage_status:`) — **байт-в-байт не трогаются**.
- Повторная починка частных симптомов ORCH-110/112 (merge-retest tree-kill, checkout-hygiene) —
они уже закрыты; ORCH-114 их **переиспользует**, не переписывает.
- Переход на `uvicorn --workers>1` / мульти-процессную модель (остаётся одно-процессной; durable-lease
лишь делает инвариант корректным и на этот случай, но миграция модели — отдельная задача).
- Выбор конкретного механизма (lease vs heartbeat vs epoch), точная форма хранения (доп. таблица vs
доп. колонки) и порядок старта демонов — **решает архитектор** в `06-adr/` (это требования к
свойствам, не к реализации).
---
## 3. Заинтересованные стороны
- **Owner / оператор self-hosting** — заказчик; страдает от ложных откатов, двойных деплоев и ручного
разбора расхождений состояния.
- **Все проекты в общем инстансе** (orchestrator + enduro-trails) — групповой риск: расхождение
состояния и ложный freeze репо клинят общую очередь.
- **Принимает результат:** Owner; технически — финальная стадия конвейера (CI/гейты), не агент сам.
---
## 4. Бизнес-требования (BR)
| ID | Требование (проверяемое) | Покрытие |
|----|---------------------------|----------|
| **BR-1** | Side-effectful переход/финализацию задачи в любой момент исполняет **не более одного** актора (единое владение). | FR-1 / AC-1 |
| **BR-2** | Запись стадии для side-effectful переходов защищена **compare-and-swap / epoch**: проигравший гонку писатель не мутирует стадию и **не выполняет** побочных эффектов. | FR-2 / AC-1, AC-2 |
| **BR-3** | **job-reaper** осведомлён о живой vs устаревшей финализации на **всех** релевантных путях (не только Tier-2/`deploy-staging`): defer при живом владении, реклейм мёртвого/устаревшего владельца в **ограниченное** время. | FR-3 / AC-4, AC-5 |
| **BR-4** | **Startup-requeue / восстановление при старте** учитывает незавершённую финализацию через durable-состояние: не пере-исполняет уже применённый необратимый шаг. | FR-4 / AC-6 |
| **BR-5** | **Reconciler F-1** и **webhook**-пути продвижения **пропускают/откладывают** переход, пока активен lease владения для задачи. | FR-5 / AC-7, AC-8 |
| **BR-6** | После смерти процесса в середине финализации система сходится к **единственному** согласованному исходу: **нет** двойного `merge_pr` / ratchet baseline / image-rebuild / инициации прод-деплоя и **нет** противоречия rollback↔done. | FR-1…FR-4 / AC-1, AC-6 |
| **BR-7** | Состояние владения переходом **наблюдаемо**: read-only блок в `GET /queue` + алерт при форсированном/устаревшем реклейме. | FR-6 / AC-12 |
| **BR-8** | Поставляются **регресс-тесты** на конкурентный двойной эффект (`deploy-staging`), deploy-finalizer (Phase C) и restart-recovery; обязательный регресс воспроизводит исходный класс (красный до фикса, зелёный после). | FR-7 / AC-1, AC-6, тест-план |
| **BR-9** | Механизм **обратим**: kill-switch возвращает поведение **байт-в-байт** к состоянию до ORCH-114. | FR-7 / AC-9 |
---
## 5. Нефункциональные требования (NFR)
| ID | Требование |
|----|------------|
| **NFR-1** | **never-raise.** Любая ошибка механизма владения изолируется. Горячий путь claim/guard — **fail-open** (не заклинить общую очередь всех проектов, AC-8 ORCH-088); решения, критичные для безопасности прода/необратимости — **fail-closed**. |
| **NFR-2** | **Kill-switch + область раската** по образцу leaf-гейтов (`serial_gate`/`coverage_gate`/`finalizer_liveness`): глобальный флаг + при необходимости CSV-скоуп репо (пусто → self-hosting only). При выключенном флаге — нулевая регрессия (enduro не затронут). |
| **NFR-3** | **Инварианты конвейера не тронуты:** `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / машинные вердикт-ключи / схемы существующих таблиц — байт-в-байт. Любое новое хранилище — **аддитивно и идемпотентно** (`CREATE TABLE IF NOT EXISTS` / `_ensure_column`). |
| **NFR-4** | **Durable / restart-safe.** Сигнал владения переживает рестарт процесса (ключевая дельта над in-memory `finalizer_liveness` ORCH-113); после рестарта восстановление детерминированно решает «дорешать vs уже применено». |
| **NFR-5** | **Self-hosting безопасность.** Механизм владения сам по себе **никогда** не рестартит прод-контейнер, не пушит/force-push в `main`, не трогает detached deploy-процесс (NFR-3 ORCH-090/112). |
| **NFR-6** | **Сквозной бюджет reaper сохранён:** инвариант ORCH-065/109/110/113 `reaper_max_running_s (5400) > Σ(deploy-staging gate-work ≈4460) + grace`. Lease **не** удлиняет финализацию за backstop без согласованной правки бюджета; устаревший/мёртвый владелец добивается Tier-3 в ограниченное время. |
| **NFR-7** | **Идемпотентность.** Повторный заход в уже применённый переход — **no-op** (по epoch / SHA-in-main / lease), никогда не второй побочный эффект. |
| **NFR-8** | **Обратная совместимость.** При флаге off / репо вне области — путь старта, claim и переходы байт-в-байт прежние (enduro и текущий orchestrator). |
---
## 6. Допущения и ограничения
- **Одно-процессная модель сейчас** (один uvicorn-воркер без `--workers`), но требование NFR-4
(durable) делает инвариант корректным и при будущем рестарте/мульти-процессности — без переписывания.
- **Источник истины планировщика — локальная БД** (offline hot-path, NFR-2/ORCH-026/088): механизм
владения не должен вносить сетевых зависимостей в горячий claim.
- **Переиспользуются существующие durable-примитивы:** атомарный `reap_running_job` (rowcount-guard),
`claim_next_job` (rowcount-guard), `requeue_running_jobs`, merge-lease (ORCH-043). ORCH-114 **достраивает**
владение поверх них, а не дублирует.
- **`finalizer_liveness` (ORCH-113)** — отправная точка: ORCH-114 обобщает её до durable, кросс-путевого
владения; решение «расширить / заменить / надстроить» принимает архитектор.
- Точные **D-решения** (durable shape, эпоха vs lease-таблица, набор покрываемых рёбер сверх
`deploy-staging`/`deploy→done`, порядок старта демонов) — за архитектором (`06-adr/`, `10-tech-risks.md`).
## 7. Критерии успеха
Кратко (детальные PASS/FAIL — `03-acceptance-criteria.md`):
- Конкурентный/после-рестартовый повторный вход в side-effectful переход **не** даёт двойных эффектов
и противоречивых исходов; ровно один актор владеет и доводит переход.
- CAS/epoch на запись стадии: проигравший — чистый аборт.
- reaper / startup / reconciler / webhook осведомлены о живом lease (defer) и о мёртвом (реклейм в
ограниченное время).
- Полный `pytest tests/` зелёный; новые регресс-тесты (двойной эффект, restart-recovery) зелёные;
при выключенном kill-switch — поведение байт-в-байт прежнее.
## 8. Риски
Краткий перечень (детали — `10-tech-risks.md`, заполняет архитектор):
- **Дедлок / over-block:** слишком «жёсткое» владение может заклинить легитимный путь (reaper не
добьёт зависший финализатор) → требование NFR-6 (bounded reclaim) и fail-open на hot-path.
- **Бюджет vs lease:** lease, удерживаемый дольше `reaper_max_running_s`, конфликтует со сквозным
бюджетом → согласование с ORCH-065/109/110/113.
- **Durable-состояние и гонки на рестарте:** некорректный «умный recovery» может сам стать источником
двойного применения → обязательный restart-recovery регресс (BR-8).
- **Скрытые пути перехода** (gitea-webhook `handle_push`/`handle_ci_status`/`handle_pr` пишут стадию
**в обход** `advance_stage` через прямой `update_task_stage`) → охват CAS должен учитывать и их
(архитектор фиксирует границу).

View File

@@ -0,0 +1,143 @@
---
work_item: ORCH-114
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-15
model_used: claude-opus-4-8
escalate: full-cycle
---
# 02 — ТЗ (TRZ): ORCH-114 — Ownership-lease для side-effectful переходов стадий + умное восстановление при старте
Work Item: **ORCH-114** · Repo: **orchestrator** · Стадия: analysis
> ТЗ описывает **конкретные требования к реализации**, выведенные из BRD (`01-brd.md`) и фактического
> кода. **Выбор механизма** (durable lease / heartbeat / transition-epoch / форма хранения, набор
> покрываемых рёбер) и архитектурное обоснование — задача архитектора (`06-adr/`). Здесь — *что*
> должно быть истинно и *какие модули* затрагиваются, не *как* именно.
## 1. Сводка изменения
Вводится **единый инвариант владения** side-effectful переходом стадии: запись стадии и исполнение
тяжёлых под-гейтов/финализации защищаются **durable-механизмом владения** (lease/epoch) + **CAS** на
запись стадии, так что в любой момент переход конкретной задачи доводит **ровно один** актор, а
конкурентный/после-рестартовый повторный вход (reaper / reconciler / webhook / startup-requeue)
**откладывается или становится no-op** вместо повторного применения необратимых эффектов
(merge_pr / coverage-ratchet / image-rebuild / инициация прод-деплоя / противоречивый rollback↔done).
Аддитивно, под kill-switch, never-raise; `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / вердикт-ключи
/ схемы существующих таблиц — не трогаются (обобщает и делает durable процесс-локальный
`finalizer_liveness` ORCH-113).
## 2. Задействованные модули / пути
| Путь | Действие | Назначение в ORCH-114 |
|------|----------|------------------------|
| `src/stage_engine.py` | изменить | `advance_stage`: захват владения на границе side-effectful перехода/финализации; CAS на запись стадии; release в `try/finally`; проигравший — чистый аборт. Покрыть `_handle_merge_verify`, под-гейты `deploy-staging→deploy`, `run_deploy_finalizer` (Phase C), `advance_if_gate_passed` (F-1). |
| `src/db.py` | изменить | CAS-вариант записи стадии (запись только при совпадении ожидаемой текущей стадии/эпохи; rowcount-результат). Durable-хелперы владения (acquire / heartbeat-touch / release / reclaim / snapshot) — форму хранилища задаёт архитектор. |
| `src/finalizer_liveness.py` | изменить/обобщить | Отправная точка: обобщить process-local реестр до **durable, кросс-путевого** владения (или надстроить durable-слой поверх). Сохранить контракт never-raise + kill-switch. |
| `src/job_reaper.py` | изменить | Tier-2/Tier-3 осведомлены о durable-владении на **всех** релевантных путях (не только Tier-2/`deploy-staging`): defer при живом, реклейм мёртвого/устаревшего в ограниченное время (NFR-6). |
| `src/queue_worker.py` | изменить | `requeue_running_jobs` / стартовое восстановление сверяется с durable-владением: не пере-исполнять уже применённый необратимый шаг (умное восстановление). `claim_next_job` — не вносить сетевых зависимостей. |
| `src/reconciler.py` | изменить | F-1 (`advance_if_gate_passed`) — defer при активном lease перехода (по образцу skip-guard'ов escalated/Blocked/deps). |
| `src/webhooks/plane.py` | изменить | Пути продвижения (`_try_advance_stage`, Approved / Confirm Deploy) — defer при активном lease. |
| `src/webhooks/gitea.py` | изменить (учесть) | Прямые записи стадии в обход `advance_stage` (`handle_push`/`handle_ci_status`/`handle_pr`) должны попадать под тот же CAS-инвариант либо явно исключаться архитектором (граница в ADR). |
| `src/main.py` | изменить | Порядок старта демонов / точка восстановления; read-only блок в `GET /queue`; опц. operator-эндпоинт реклейма. |
| `src/config.py` | изменить | Kill-switch(и) + бюджеты/таймауты владения + (опц.) CSV-скоуп репо. |
| `tests/test_orch114_transition_ownership.py` | создать | Покрытие FR-1…FR-7 (см. `04-test-plan.yaml`). |
## 3. Функциональные требования
### FR-1 — Единое владение side-effectful переходом (BR-1, BR-6)
На границе, где начинается side-effectful финализация/переход (минимум: под-гейты
`deploy-staging→deploy`, `_handle_merge_verify` на `deploy→done`, Phase C `run_deploy_finalizer`),
актор **захватывает владение** задачей. Пока владение активно, другой актор не исполняет тот же
переход. Release — детерминированно в `try/finally` (в т.ч. на исключении/откате). Владение
**durable** (NFR-4): переживает рестарт и доступно для проверки другому актору/новому процессу.
### FR-2 — Compare-and-swap / epoch на запись стадии (BR-2)
Запись стадии для side-effectful переходов выполняется только если предусловие (ожидаемая текущая
стадия и/или эпоха перехода) не изменилось с момента чтения. Реализуется через CAS-вариант
`update_task_stage` (`UPDATE … SET stage=?[, epoch=epoch+1] WHERE id=? AND stage=?[ AND epoch=?]`,
решение по форме — архитектор). Проигравший гонку писатель получает «lost-race» результат, **не**
мутирует стадию и **не** выполняет ни одного побочного эффекта (merge_pr / ratchet / rebuild /
deploy-init / enqueue следующего агента). Инвариант распространяется и на пути, пишущие стадию в
обход `advance_stage` (gitea-webhook), — либо CAS, либо явное исключение (граница в ADR).
### FR-3 — Reaper, осведомлённый о владении на всех путях (BR-3, NFR-6)
Job-reaper перед реклеймом сверяется с durable-владением **не только** в Tier-2 для `deploy-staging`
(текущая область ORCH-113), а на всех релевантных тирах/рёбрах: **живой** владелец → **defer**
(лог + счётчик, без повторного advance); **мёртвый/устаревший** владелец → реклейм в ограниченное
время (Tier-3 backstop `reaper_max_running_s` добивает зависшего; маркер владения backstop не
обходит инвариант бюджета). Сохранить атомарный `reap_running_job` rowcount-guard.
### FR-4 — Умное восстановление при старте (BR-4, BR-6, NFR-7)
Стартовое восстановление (`requeue_running_jobs` + последующий цикл) использует durable-владение/эпоху,
чтобы **детерминированно** различить: (a) финализация не начиналась / безопасно перезапустить →
re-drive; (b) необратимый шаг уже применён (мерж в `main` / ratchet / прод-деплой инициирован) →
**сойтись к done/консистентному исходу без повторного применения**. Источник истины для «уже
применено» — авторитетные durable-факты (SHA-in-main ORCH-071/073, маркер `INITIATED` self-deploy,
durable-lease/эпоха), а не in-memory состояние.
### FR-5 — Skip/defer в reconciler и webhook (BR-5)
Reconciler F-1 (`advance_if_gate_passed`) и webhook-пути продвижения (`plane._try_advance_stage`,
Approved/Confirm Deploy) при **активном** lease перехода для задачи **откладывают** действие
(silent skip + наблюдаемость), по образцу существующих skip-guard'ов F-1 (escalated / Blocked /
task-deps). Fail-safe: неопределённость состояния lease → консервативный skip (не дублировать).
### FR-6 — Наблюдаемость (BR-7)
Аддитивный read-only блок в `GET /queue` (по образцу `serial_gate`/`merge_gate`/`reaper`):
держатели lease, возраст владения, defer-счётчики, форсированные/устаревшие реклеймы. Алерт
(`send_telegram`, кликабельный номер) на форсированный/устаревший реклейм. Опц. запись в
lessons-journal (ORCH-098, `source="auto"`). Опц. operator-эндпоинт ручного реклейма (по образцу
`POST /serial-gate/unfreeze`).
### FR-7 — Конфигурация, обратимость, never-raise (BR-9, NFR-1, NFR-2, NFR-8)
Все публичные функции владения — never-raise (ошибка → безопасный дефолт + WARNING). Kill-switch
возвращает поведение **байт-в-байт** к до-ORCH-114 (lease не пишется/не читается, CAS вырождается в
прежний безусловный `update_task_stage`). Hot-path — fail-open; prod-safety — fail-closed.
## 4. Изменения API
- **`GET /queue`** — аддитивный read-only блок владения переходом (имя ключа уточнит архитектор,
напр. `transition_ownership` / `transition_lease`). Существующие ключи `/queue` — байт-в-байт.
- **(Опционально, по решению архитектора)** `POST /transition-lease/release?work_item=<id>`
операторский ручной реклейм застрявшего владения (паттерн `POST /serial-gate/unfreeze`).
- `GET /metrics` (ORCH-099) — при необходимости аддитивное поле без бампа `schema_version` (sidecar
обязан толерировать незнакомые ключи). Прочие эндпоинты — не трогаются.
## 5. Изменения схемы БД
**Требование (форму выбирает архитектор, `06-adr/` + `08-data-requirements.md`):** для durable-владения
(NFR-4) и CAS/epoch (FR-2) требуется **аддитивное, идемпотентное** durable-состояние. Кандидаты
(не предписание):
- доп. аддитивная таблица владения (`CREATE TABLE IF NOT EXISTS`, паттерн `repo_freeze`/
`coverage_baseline`/`lessons`) с `(task_id/job_id, owner, run_id, stage, acquired_at, heartbeat_at,
expires_at)`; **либо**
- аддитивные колонки на `tasks`/`jobs` (`_ensure_column`, паттерн `tasks.track`/`tasks.cancelled_at`),
включая возможную `epoch/version`-колонку для CAS.
**Жёсткие ограничения (NFR-3):** только аддитивно/идемпотентно; **схемы существующих таблиц
(`tasks`/`jobs`/`agent_runs` и пр.) — байт-в-байт**; никаких изменений существующих столбцов/индексов,
ломающих обратную совместимость; restart-safe инициализация в `init_db()`.
## 6. Требования к новым/изменённым QG checks
**Нет.** `QG_CHECKS` / `check_*` / `_parse_*` / машинные вердикт-ключи — **не трогаются**. Владение
переходом — свойство **движка переходов и фоновых акторов**, а **не** Quality Gate и **не** стадия
(аналогично тому, как merge-lease/serial-gate/finalizer-liveness — врезки/leaf'ы, а не QG). Никаких
новых стадий/рёбер в `STAGE_TRANSITIONS`.
## 7. Совместимость / регресс
- **Kill-switch** (новый флаг в `config.py`, env `ORCH_*`): `False` → CAS вырождается в прежний
безусловный `update_task_stage`, lease не пишется/не читается, reaper/reconciler/webhook ведут себя
как до ORCH-114 — **байт-в-байт** (включая зелёный существующий `pytest tests/`).
- **Область раската:** по образцу leaf-гейтов; durable-lease минимально применяется к self-hosting
рёбрам (`deploy-staging`/`deploy→done`, где живут необратимые эффекты); generic-CAS инертен при
отсутствии гонки (нулевая стоимость на не-затронутых переходах). Точную область фиксирует архитектор.
- **Обратимость:** механизм аддитивен и изолирован; откат = выключить kill-switch (durable-таблица/
колонки остаются инертными).
- **never-raise / fail-open / fail-closed:** hot-path claim/guard — fail-open (не клинить общую
очередь, AC-8 ORCH-088); prod-safety/необратимость — fail-closed; любой сбой механизма — WARNING +
безопасный дефолт.
- **Сквозной бюджет:** lease согласован с `reaper_max_running_s`/`reaper_finalize_grace_s` (ORCH-065/
109/110/113) — не удлиняет финализацию за backstop; устаревший владелец добивается в ограниченное время.
- **Маркеры трассировки (ORCH-078):** правки в блоках с маркерами ORCH-065/109/110/111/113 (reaper,
finalizer-liveness, merge-gate) сверяются с их ADR перед изменением; новый код помечается `ORCH-114`.
- **enduro-trails:** при флаге off / репо вне области — нулевая регрессия.

View File

@@ -0,0 +1,177 @@
---
work_item: ORCH-114
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-15
model_used: claude-opus-4-8
escalate: full-cycle
---
# 03 — Критерии приёмки (Acceptance Criteria): ORCH-114 — Ownership-lease для side-effectful переходов + умное восстановление при старте
Work Item: **ORCH-114** · Repo: **orchestrator** · Стадия: analysis
Формат: каждый критерий имеет **PASS** (что должно быть истинно для приёмки) и **FAIL** (что
считается провалом). Reviewer/CI проверяет их буквально по файлам и тестам репозитория.
---
## AC-1 — Обязательный регресс: нет двойного эффекта при конкурентном входе в переход
**Условие:** два актора одновременно входят в `advance_stage(deploy-staging)` для одной задачи
(живой монитор-финализатор + второй путь: reaper / reconciler F-1 / webhook). Тест-двойники для
`merge_pr` / coverage-ratchet / image-rebuild / deploy-init считают число вызовов.
- **PASS:** side-effectful шаги (merge_pr, ratchet baseline, image-rebuild, инициация прод-деплоя,
enqueue следующего агента) выполняются **ровно один раз**; персистится **ровно один** согласованный
исход стадии; второй актор получает «lost-race»/defer и **не** выполняет побочных эффектов. Тест
**красный до фикса, зелёный после** (воспроизводит класс инцидента ORCH-111).
- **FAIL:** любой side-effectful шаг вызван дважды; либо два противоречивых исхода (один откатил на
`development`, другой довёл до `done`); либо тест не воспроизводит проблему до фикса.
---
## AC-2 — Compare-and-swap на запись стадии
**Условие:** CAS-вариант записи стадии вызывается двумя писателями с одинаковым ожидаемым предусловием;
первый применяется, второй приходит со «устаревшим» ожиданием.
- **PASS:** первый writer применяет переход (rowcount=1); второй получает «lost-race» (rowcount=0),
стадия **не** мутируется повторно; при выключенном kill-switch CAS вырождается в прежний безусловный
`update_task_stage` (байт-в-байт).
- **FAIL:** второй writer перезатирает стадию; либо CAS меняет семантику записи при выключенном флаге.
---
## AC-3 — Жизненный цикл владения: acquire / release / реклейм
**Условие:** актор начинает side-effectful финализацию.
- **PASS:** владение захватывается на границе финализации и освобождается в `try/finally` — в т.ч.
при исключении и при откате (rollback); durable-запись владения видна другому актору; после release
владение реклеймится/свободно.
- **FAIL:** владение не освобождается при исключении/откате (lease «течёт» и клинит задачу); либо не
захватывается на границе.
---
## AC-4 — Reaper откладывает реклейм при живом владении (все пути, не только Tier-2/deploy-staging)
**Условие:** durable-владение активно (живой финализатор), reaper делает тик.
- **PASS:** reaper **defer** (лог + счётчик, без повторного `advance_stage`) пока владение живо и в
пределах бюджета; область defer обобщена за пределы Tier-2/`deploy-staging` ORCH-113 на релевантные
пути; атомарный `reap_running_job` rowcount-guard сохранён.
- **FAIL:** reaper повторно исполняет финализацию при живом владельце; либо defer ограничен только
прежней узкой областью, оставляя кросс-путь открытым.
---
## AC-5 — Reaper добивает мёртвое/устаревшее владение в ограниченное время
**Условие:** владелец провально мёртв/завис (lease устарел), финализация не прогрессирует.
- **PASS:** reaper реклеймит задачу в пределах Tier-3 backstop `reaper_max_running_s` (маркер владения
backstop не обходит); задача не остаётся навсегда заклиненной; сквозной инвариант
`reaper_max_running_s > Σ(deploy-staging gate-work) + grace` сохранён.
- **FAIL:** мёртвое владение блокирует задачу бессрочно; либо нарушен бюджетный инвариант ORCH-065/
109/110/113.
---
## AC-6 — Умное восстановление при рестарте процесса
**Условие:** процесс убит **в середине** финализации `deploy-staging`/`deploy` (in-memory состояние
потеряно); процесс перезапущен (`requeue_running_jobs` + цикл).
- **PASS:** восстановление через durable-владение/эпоху + авторитетные факты (SHA-in-main ORCH-071/073,
маркер `INITIATED`) детерминированно сходится к **единственному** согласованному исходу: незавершённое
дорешается, **уже применённый** необратимый шаг (мерж/ratchet/прод-деплой) **не** применяется повторно.
- **FAIL:** после рестарта переход исполняется заново с двойным необратимым эффектом; либо возникает
противоречие rollback↔done; либо задача застревает нетерминальной с удержанным lease.
---
## AC-7 — Reconciler F-1 пропускает переход при активном lease
**Условие:** lease перехода активен; reconciler F-1 сканирует задачу.
- **PASS:** F-1 **defer/skip** (silent, наблюдаемо), не вызывает `advance_stage`, по образцу skip-guard'ов
escalated/Blocked/task-deps; fail-safe: неопределённость lease → консервативный skip.
- **FAIL:** F-1 продвигает стадию параллельно живому владельцу.
---
## AC-8 — Webhook-путь пропускает переход при активном lease
**Условие:** lease перехода активен; приходит Plane-webhook (Approved / Confirm Deploy) на ту же задачу.
- **PASS:** webhook-путь продвижения **defer**, не дублирует переход/финализацию при живом владельце;
поздний легитимный сигнал не теряется (повторно отработает после release или станет идемпотентным no-op).
- **FAIL:** webhook повторно входит в переход параллельно владельцу и даёт двойной эффект.
---
## AC-9 — Kill-switch off → поведение байт-в-байт прежнее
**Условие:** новый kill-switch выключен.
- **PASS:** lease не пишется/не читается; CAS вырождается в прежний безусловный `update_task_stage`;
reaper/reconciler/webhook/startup ведут себя как до ORCH-114; существующий `pytest tests/` зелёный
без правок ожиданий; enduro не затронут.
- **FAIL:** при выключенном флаге наблюдается любое отличие от до-ORCH-114 поведения.
---
## AC-10 — never-raise + fail-open (hot-path) / fail-closed (prod-safety)
**Условие:** механизм владения сталкивается с ошибкой (БД-сбой/повреждённая запись lease/исключение).
- **PASS:** ни одна публичная функция владения не роняет конвейер; горячий путь claim/guard —
**fail-open** (общая очередь всех проектов не клинится); решения, критичные для необратимости/прода —
**fail-closed**; на ошибке — WARNING + безопасный дефолт.
- **FAIL:** ошибка механизма роняет claim/конвейер; либо hot-path заклинивает очередь; либо
prod-критичное решение фейлит «открыто».
---
## AC-11 — Инварианты конвейера не тронуты
**Условие:** аудит диффа против `src/stages.py`, `src/qg/checks.py`, схемы БД.
- **PASS:** `STAGE_TRANSITIONS` / реестр `QG_CHECKS` / семантика и имена `check_*` / машинные
вердикт-ключи (`verdict:`/`result:`/`deploy_status:`/`staging_status:`/`security_status:`/
`coverage_status:`) — **байт-в-байт**; любое новое хранилище аддитивно/идемпотентно
(`CREATE TABLE IF NOT EXISTS` / `_ensure_column`), схемы существующих таблиц неизменны.
- **FAIL:** изменены состав/порядок стадий, реестр/семантика гейтов, вердикт-ключи или существующие
столбцы/индексы.
---
## AC-12 — Наблюдаемость
**Условие:** `GET /queue` при включённом флаге.
- **PASS:** присутствует аддитивный read-only блок владения (держатели/возраст/defer-счётчики/реклеймы);
существующие ключи `/queue` не сломаны; форсированный/устаревший реклейм даёт Telegram-алерт с
кликабельным номером.
- **FAIL:** блок отсутствует/ломает существующий вывод; реклейм происходит молча без наблюдаемости.
---
## AC-13 — Self-hosting безопасность
**Условие:** аудит механизма владения на побочные действия.
- **PASS:** механизм **никогда** не рестартит прод-контейнер `orchestrator`, не пушит/force-push в
`main`, не трогает detached deploy-процесс; деплой орка по-прежнему только через staging-гейт (8501)
и `Confirm Deploy`.
- **FAIL:** механизм владения инициирует рестарт прода/мутацию `main`/вмешательство в detached-деплой.
---
## Сводная матрица AC ↔ FR/BR/NFR
| AC | Покрывает | Тип проверки |
|----|-----------|--------------|
| AC-1 | BR-1, BR-6, BR-8 / FR-1, FR-2 | integration (regression, red→green) |
| AC-2 | BR-2 / FR-2 | unit |
| AC-3 | BR-1 / FR-1 | unit |
| AC-4 | BR-3 / FR-3 | integration |
| AC-5 | BR-3, NFR-6 / FR-3 | unit/integration |
| AC-6 | BR-4, BR-6, NFR-4, NFR-7 / FR-1…FR-4 | integration (restart-recovery) |
| AC-7 | BR-5 / FR-5 | unit/integration |
| AC-8 | BR-5 / FR-5 | unit/integration |
| AC-9 | BR-9, NFR-8 / FR-7 | regression (kill-switch off) |
| AC-10 | NFR-1 / FR-7 | unit |
| AC-11 | NFR-3 / FR-6 (negative) | structural audit |
| AC-12 | BR-7 / FR-6 | unit/integration |
| AC-13 | NFR-5 / FR-1 | structural audit |

View File

@@ -0,0 +1,107 @@
work_item: ORCH-114
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-15
model_used: claude-opus-4-8
escalate: full-cycle
title: "Ownership-lease для side-effectful переходов стадий + умное восстановление при старте"
framework: pytest
scope: >
Покрывается: единое владение side-effectful переходом (FR-1), CAS/epoch на запись стадии (FR-2),
осведомлённость reaper о живом/мёртвом владении на всех путях (FR-3), умное восстановление при
рестарте (FR-4), skip/defer в reconciler F-1 и webhook (FR-5), наблюдаемость (FR-6), kill-switch
и never-raise (FR-7). Вне покрытия: переход на uvicorn --workers>1, частные симптомы ORCH-110/112
(уже закрыты и переиспользуются), изменение STAGE_TRANSITIONS/QG_CHECKS/check_*.
notes: >
TC-01 — ОБЯЗАТЕЛЬНЫЙ регресс класса инцидента ORCH-111: красный до фикса, зелёный после.
Все side-effectful вызовы (merge_pr / coverage-ratchet / image-rebuild / deploy-init) проверяются
через тест-двойники со счётчиком вызовов — без сети/реального git/прода/ssh. Restart-recovery
моделируется сбросом in-memory состояния + повторным прогоном стартового восстановления над durable
состоянием БД. Полный регресс tests/ должен оставаться зелёным; при выключенном kill-switch
поведение байт-в-байт прежнее.
tests:
- id: TC-01
type: integration
description: "ОБЯЗАТЕЛЬНЫЙ РЕГРЕСС. Два конкурентных входа в advance_stage(deploy-staging) одной задачи (живой финализатор + reaper/reconciler/webhook): каждый side-effectful шаг (merge_pr/ratchet/rebuild/deploy-init/enqueue) вызывается ровно один раз; персистится один согласованный исход; второй актор — lost-race/defer без побочных эффектов. Красный до фикса, зелёный после."
module: tests/test_orch114_transition_ownership.py
expected: PASS
- id: TC-02
type: unit
description: "CAS-запись стадии: первый writer применяет (rowcount=1), второй с устаревшим предусловием получает lost-race (rowcount=0) и не мутирует стадию (FR-2/AC-2)."
module: tests/test_orch114_transition_ownership.py
expected: PASS
- id: TC-03
type: unit
description: "Жизненный цикл владения: acquire на границе финализации; release в try/finally при нормальном завершении, при исключении и при откате (lease не течёт); durable-запись видна другому актору (FR-1/AC-3)."
module: tests/test_orch114_transition_ownership.py
expected: PASS
- id: TC-04
type: integration
description: "Reaper defer при живом владении на путях за пределами Tier-2/deploy-staging ORCH-113: повторный advance не выполняется, атомарный reap_running_job rowcount-guard сохранён, ведётся счётчик defer (FR-3/AC-4)."
module: tests/test_orch114_transition_ownership.py
expected: PASS
- id: TC-05
type: unit
description: "Reaper добивает мёртвое/устаревшее владение в пределах Tier-3 backstop reaper_max_running_s; маркер владения backstop не обходит; задача не клинится бессрочно (FR-3/AC-5/NFR-6)."
module: tests/test_orch114_transition_ownership.py
expected: PASS
- id: TC-06
type: integration
description: "Умное восстановление при рестарте: процесс убит в середине финализации, in-memory сброшен; стартовое восстановление над durable-состоянием + авторитетными фактами (SHA-in-main, INITIATED) сходится к единственному исходу без повторного необратимого эффекта (FR-4/AC-6)."
module: tests/test_orch114_transition_ownership.py
expected: PASS
- id: TC-07
type: integration
description: "Reconciler F-1 (advance_if_gate_passed) делает defer/skip при активном lease перехода; fail-safe: неопределённость lease → консервативный skip (FR-5/AC-7)."
module: tests/test_orch114_transition_ownership.py
expected: PASS
- id: TC-08
type: integration
description: "Webhook-путь (plane._try_advance_stage, Approved/Confirm Deploy) делает defer при активном lease; поздний легитимный сигнал не теряется (повтор после release / идемпотентный no-op) (FR-5/AC-8)."
module: tests/test_orch114_transition_ownership.py
expected: PASS
- id: TC-09
type: integration
description: "Kill-switch off: lease не пишется/не читается, CAS вырождается в прежний безусловный update_task_stage, reaper/reconciler/webhook/startup — байт-в-байт до ORCH-114; существующий pytest tests/ зелёный (FR-7/AC-9/NFR-8)."
module: tests/test_orch114_transition_ownership.py
expected: PASS
- id: TC-10
type: unit
description: "never-raise + fail-open/fail-closed: ошибка/повреждённая запись lease/исключение БД не роняют конвейер; hot-path claim/guard fail-open; prod-safety решение fail-closed; WARNING + безопасный дефолт (FR-7/AC-10/NFR-1)."
module: tests/test_orch114_transition_ownership.py
expected: PASS
- id: TC-11
type: unit
description: "Структурный аудит инвариантов: STAGE_TRANSITIONS / QG_CHECKS / имена-семантика check_* / вердикт-ключи байт-в-байт; новое хранилище аддитивно/идемпотентно (CREATE TABLE IF NOT EXISTS / _ensure_column), схемы существующих таблиц неизменны (NFR-3/AC-11)."
module: tests/test_orch114_transition_ownership.py
expected: PASS
- id: TC-12
type: integration
description: "Наблюдаемость: GET /queue несёт аддитивный read-only блок владения (держатели/возраст/defer/реклеймы), существующие ключи не сломаны; форсированный/устаревший реклейм даёт Telegram-алерт с кликабельным номером (FR-6/AC-12)."
module: tests/test_orch114_transition_ownership.py
expected: PASS
- id: TC-13
type: unit
description: "Self-hosting безопасность: механизм владения не инициирует рестарт прод-контейнера, не пушит/force-push в main, не трогает detached deploy-процесс (NFR-5/AC-13)."
module: tests/test_orch114_transition_ownership.py
expected: PASS
- id: TC-14
type: integration
description: "Полный регресс конвейера: pytest tests/ остаётся зелёным; deploy-staging-ребро и deploy-finalizer (Phase C) проходят при включённом механизме без двойных эффектов в одно-акторном happy-path (BR-8)."
module: tests/
expected: PASS

View File

@@ -0,0 +1,300 @@
---
work_item: ORCH-114
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-15
model_used: claude-opus-4-8
---
# ADR-001: Durable transition-ownership lease + expected-stage CAS для side-effectful переходов стадий
Work Item: **ORCH-114** — Ownership-lease для side-effectful переходов стадий + умное восстановление при старте
Стадия: **architecture**
Сквозная регистрация: **`docs/architecture/adr/adr-0045-transition-ownership-lease-and-stage-cas.md`** (решение кросс-каттинговое: новый durable-механизм владения трогает движок переходов и ≥5 фоновых акторов).
## Статус
Proposed
## Контекст
ORCH-114 — **системный наследник** инцидент-цепочки ORCH-110/111/112/113. Каждый предшественник
закрыл точечный симптом, но **корневой класс остался открыт: у side-effectful переходов стадий нет
единого владения**. Факты сверены по коду:
- **Запись стадии не атомарна по предусловию.** `db.update_task_stage` (`src/db.py:671679`) —
голый `UPDATE tasks SET stage=?, updated_at=… WHERE id=?` **без** `WHERE stage=?` (нет CAS, нет
epoch/version-колонки). Второй вызов безусловно перезатирает первый.
- **`advance_stage` ре-ентерабельна без защиты** (`src/stage_engine.py:176507`). Внутри нет ни
in-memory-лока на `task_id`, ни durable-маркера «переход идёт». `current_stage` читается на входе
(параметр), тяжёлые под-гейты ребра `deploy-staging → deploy` (security → merge-gate re-test →
coverage → image-freshness, **минуты**) и `_handle_merge_verify` (`deploy → done`: `merge_pr`,
ratchet baseline, proof-of-merge) исполняются **до** единственной записи стадии на `:402`. Два
конкурентных вызова оба читают `deploy-staging`, оба гоняют ВСЕ под-гейты, оба пишут `deploy`.
- **Минимум 5 путей входят в переход независимо:** монитор (`launcher._try_advance_stage`),
Plane-webhook (`plane._try_advance_stage:865`), reconciler F-1 (`advance_if_gate_passed →
advance_stage`, `stage_engine.py:573`), job-reaper (`_gate_driven_advance → launcher._try_advance_stage`,
`job_reaper.py:406`), deploy-finalizer Phase C (`run_deploy_finalizer → advance_stage`,
`stage_engine.py:1980`). Ни один не проверяет, не в этом ли переходе уже другой актор.
- **6 путей пишут стадию в ОБХОД `advance_stage`** прямым `update_task_stage` (риск BRD §8): 5 в
`webhooks/gitea.py` (`:127` arch→dev по ADR-push, `:242` dev→review по CI-green, `:333` review→testing
по PR-approved, `:359` review→dev по REQUEST_CHANGES, `:398` *→done по PR-merge) и 1 в
`webhooks/plane.py:806` (rollback Rejected).
- **ORCH-113 (`finalizer_liveness`, adr-0043)** — отправная точка, но: **process-local in-memory**,
**только reaper Tier-2**, **только `deploy-staging`**, **теряется при рестарте**. Остаточный кросс-путь
(живой монитор внутри `advance_stage(deploy-staging)` + reaper после рестарта / reconciler F-1 /
webhook) повторно входит в тот же переход → двойные эффекты и противоречие rollback↔done (инцидент
ORCH-111, job 1914 / PR #130).
**Решающий факт, проверенный по коду:** механизм владения по pid уже существует и испытан — merge-lease
(`merge_gate.py`) штампит `os.getpid()` (`:360`) в lease-файл и реклеймит держателя по `pid_alive`
(`:452,526`); reaper Tier-1 тоже судит по `pid_alive` (`job_reaper.py:245`). ORCH-114 строит durable
владение **тем же испытанным pid-приёмом**, а не вводит новый таймер (таймер был источником бага
ORCH-111).
## Решение
### Сводка
Вводятся **два комплементарных слоя**, оба аддитивные, под единым kill-switch, never-raise:
1. **Durable transition-lease** (владение на **входе** в side-effectful регион) — новая аддитивная
таблица `transition_lease` (`src/db.py`, паттерн `repo_freeze`/`coverage_baseline`). Актор
**захватывает** владение задачей перед тяжёлой финализацией; второй актор, увидев живого владельца,
**не стартует** под-гейты вовсе. Это и убивает класс двойного эффекта (предотвращение, а не починка
постфактум). Release — в `try/finally`. **Liveness владельца = `owner_pid` + `owner_boot_id`** (НЕ
heartbeat), что делает рестарт-recovery бесплатным (boot-id новый ⇒ старые lease мертвы) и не
страдает от блокирующего re-test.
2. **Expected-stage CAS** (корректность на **коммите** записи стадии) — CAS-вариант
`update_task_stage_cas(task_id, expected_stage, new_stage)`
`UPDATE tasks SET stage=?, updated_at=… WHERE id=? AND stage=?`, rowcount==1 ⇒ выиграл, 0 ⇒
проиграл гонку → **аборт без побочных эффектов**. Покрывает узкое остаточное окно гонки И 6 путей
в обход `advance_stage`.
Слой 1 гарантирует «двое не начнут»; слой 2 гарантирует «даже если начали — запишет один». Defense in
depth. `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/вердикт-ключи/схемы существующих таблиц — байт-в-байт.
### D1 — Механизм: durable-lease (вход) + CAS (коммит), оба обязательны (FR-1, FR-2 / BR-1, BR-2, BR-6)
Почему **оба**, а не один:
- **CAS-only недостаточно.** CAS стоит на записи стадии — в *конце* `advance_stage` (`:402`). К этому
моменту проигравший актор **уже исполнил** `merge_pr` / docker-rebuild / re-test. CAS на коммите не
предотвращает двойной побочный эффект *в полёте*. → нужен lease на **входе** в регион.
- **Lease-only недостаточно** для 6 путей в обход `advance_stage` (gitea/plane прямой
`update_task_stage`) и для остаточного окна между «consult lease» и «acquire». → нужен CAS как
backstop записи.
Lease — это owner-эксклюзия; CAS — это атомарность-записи. Они ортогональны и складываются.
### D2 — Форма хранения: новая таблица `transition_lease`, без новых колонок на `tasks`/`jobs` (NFR-3, NFR-4)
Durable-владение хранится в **новой аддитивной таблице** (`CREATE TABLE IF NOT EXISTS`, паттерн
`repo_freeze`/`coverage_baseline`/`lessons`), а **не** в колонках `tasks`/`jobs`. Это **усиливает**
NFR-3: схемы существующих таблиц остаются буквально байт-в-байт; добавляется ровно один объект.
```sql
CREATE TABLE IF NOT EXISTS transition_lease (
task_id INTEGER PRIMARY KEY, -- одна задача = ≤1 активный владелец перехода
owner TEXT NOT NULL, -- актор: monitor|reaper|reconciler|webhook|finalizer
owner_pid INTEGER, -- pid процесса-держателя (как merge-lease)
owner_boot_id TEXT, -- нонс старта процесса (рестарт ⇒ смена ⇒ старый lease мёртв)
run_id INTEGER, -- agent_runs.id, если применимо
stage TEXT, -- from-стадия, на которой захвачено (контекст/наблюдаемость)
acquired_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
**CAS на запись стадии — через предикат ожидаемой стадии, без epoch-колонки.** В текущей одно-процессной
модели каждое side-effectful ребро ведёт в **отличную** стадию, поэтому `WHERE id=? AND stage=?`
полный и корректный compare-and-swap (стадия *и есть* версия). Отдельная `epoch/version`-колонка была бы
неиспользуемой машинерией → отвергнута; задокументирована как форвард-расширение под будущий
`--workers>1` (если появится same-stage ре-ентерабельность). Это решает FR-2 «ожидаемая текущая стадия
**и/или** эпоха» в пользу стадии.
### D3 — Liveness владельца = pid + boot-id, НЕ heartbeat (NFR-4, NFR-6)
Владелец считается **живым**`owner_boot_id == <текущий boot-id процесса>` **И**
`merge_gate.pid_alive(owner_pid)`. Иначе lease **устарел** → реклеймится.
Почему не heartbeat: ORCH-113 (adr-0043, раздел «Альтернативы») **сам отверг** durable-heartbeat
доводом «блокирующий re-test не может бить heartbeat» — `merge_retest_timeout_s=900` синхронно держит
поток монитора, heartbeat с коротким окном дал бы ложное «мёртв». pid-liveness свободна от этого: процесс
жив весь re-test → lease жив; **никакого heartbeat-кода в блокирующей финализации**.
- **Рестарт (self-deploy):** новый процесс имеет новый `boot_id` → все ранее записанные lease мгновенно
«мертвы» (boot-id mismatch) → реклеймятся → requeued job (после `requeue_running_jobs`) переисполняет
переход идемпотентно (D7). Это durable-аналог «in-memory реестр обнуляется на рестарте» (на чём держится
ORCH-113), но **переживает** рестарт как durable-запись для проверки другим актором/тиром.
- **Реальная смерть pid в том же процессе:** `pid_alive` False → реклейм немедленно (как reaper Tier-1).
- **Живой, но зависший владелец (pid жив, не прогрессирует):** добивается **Tier-3 backstop**
`reaper_max_running_s` (ниже, D8) — ограниченное время, маркер владения backstop **не обходит**.
### D4 — Область охвата: lease на side-effectful рёбрах, CAS — на всех записях стадии + 6 обходных путях
| Путь записи стадии | Lease | CAS | Обоснование |
|--------------------|:-----:|:---:|-------------|
| `deploy-staging → deploy` под-гейты (`stage_engine.py:321402`) | **да** | да | необратимо: merge re-test/rebuild/инициация |
| `deploy → done` `_handle_merge_verify` (`:397402,1726`) | **да** | да | необратимо: `merge_pr`, ratchet baseline |
| Phase C `run_deploy_finalizer` (`:18982009`) | **да** | да | необратимо: прод-деплой/мерж |
| прочие рёбра `advance_stage` (created→…→testing) | нет | да | обратимы; CAS инертен без гонки |
| rollback-записи `_handle_*_rollback` (`:740…1422`) | нет¹ | да | защита от rollback↔done (BR-6) |
| 5× `gitea.py` прямой `update_task_stage` | нет | **да** | закрыть обход (BRD §8) |
| 1× `plane.py:806` rollback | нет | **да** | закрыть обход (BRD §8) |
¹ rollback исполняет тот же единственный владелец lease (он держит lease на входе в регион), поэтому
отдельный lease на rollback-запись не нужен — достаточно CAS.
**Граница (фиксируется здесь):** обходные пути gitea/plane получают **CAS** (дёшево, закрывает дыру
BRD §8), но **не** полный lease — они не исполняют необратимых шагов (только enqueue агента/флип
индикативной стадии). CAS не даёт им перетереть авторитетную запись владельца.
### D5 — Интеграция в `advance_stage` (FR-1, FR-2, AC-1, AC-3)
```
advance_stage(...):
if transition_lease.applies(repo) and <ребро side-effectful>:
if not transition_lease.acquire(task_id, owner, run_id, current_stage):
return AdvanceResult(advanced=False, note="transition-lease-busy") # чистый аборт
try:
<под-гейты / _handle_merge_verify / финализация — как сейчас>
# запись стадии — через CAS:
if not update_task_stage_cas(task_id, current_stage, next_stage):
return AdvanceResult(advanced=False, note="stage-cas-lost") # без побочных эффектов
<enqueue next agent, notify, …>
finally:
transition_lease.release(task_id, owner) # в т.ч. на исключении/откате (AC-3)
```
Проигравший acquire или CAS — **не** мутирует стадию и **не** исполняет ни одного side-effect. Release
гарантирован `finally` (lease «не течёт» на исключении/rollback). Когда kill-switch off — `acquire`
no-op→True, CAS вырождается в прежний безусловный `update_task_stage` → байт-в-байт (D9, AC-9).
### D6 — Reaper / reconciler / webhook / startup осведомлены о владении (FR-3, FR-5, BR-3, BR-5)
- **Reaper** (`job_reaper.py`): перед реклеймом/re-drive консультирует durable-lease на **всех**
релевантных путях (обобщение ORCH-113 за пределы Tier-2/`deploy-staging`): **живой** владелец → defer
(лог + счётчик); **мёртвый/устаревший** → реклейм. Tier-3 (`reaper_max_running_s`) маркер **игнорирует**
(добивает зависшего). Атомарный `reap_running_job` rowcount-guard сохранён. **Реклейм/реап освобождает
lease задачи** (force) — lease не переживает реап.
- **Reconciler F-1** (`reconciler.py`, перед `advance_if_gate_passed` на `:249`): новый skip-guard по
образцу escalated/Blocked/task-deps — активный живой lease → silent defer.
- **Webhook** (`plane.py` Approved/`:413` + Confirm Deploy/`:219`): активный живой lease → defer; поздний
легитимный сигнал отработает после release или станет идемпотентным no-op.
- **`finalizer_liveness` (ORCH-113) сохраняется без правок** как поведение при **выключенном** ORCH-114
(надстройка durable-слоя поверх, TRZ §2): kill-switch off ⇒ reaper консультирует in-memory
`finalizer_liveness` (Tier-2/`deploy-staging`, ровно ORCH-113); kill-switch on ⇒ reaper консультирует
durable `transition_lease` (cross-path). Так ORCH-114 **обобщает** ORCH-113, не ломая его контракт/тест.
### D7 — Умное восстановление = stale-clear + авторитетные факты, БЕЗ нового «recovery-мозга» (FR-4, BR-4, BR-6, NFR-7)
Ключевое архитектурное решение, снимающее риск BRD §8 («некорректный smart-recovery сам станет источником
двойного применения»): ORCH-114 **не строит** новую машину восстановления. Восстановление = композиция:
1. **`requeue_running_jobs()`** (существует, `db.py:1320`; в `main.lifespan` до старта reaper) — running→queued.
2. **`transition_lease.recover_on_startup()`** — boot-id новый ⇒ все ранее записанные lease мертвы;
reaper/claim их реклеймят (наблюдаемо: лог + алерт на форсированный реклейм).
3. **Идемпотентность re-drive — уже обеспечена авторитетными durable-фактами предшественников**, lease их
не дублирует, а лишь гарантирует **последовательную** (не конкурентную) их проверку:
- **SHA-in-main** (ORCH-071/073/093): `merge_gate.verify_merged_to_main` / `ensure_open_pr →
"already-in-main"` → повторный `_handle_merge_verify` доводит до `done` **без** второго `merge_pr`.
- **Маркер `INITIATED`** (self-deploy ORCH-036): Phase B idempotency-guard (`stage_engine.py:1567`) →
повторный заход не инициирует второй прод-деплой.
- **Coverage-ratchet CAS** (ORCH-027): `ratchet_coverage_baseline` (`UPDATE … WHERE coverage<=?`) —
повторный ratchet идемпотентен по построению.
Итог: после смерти процесса в середине финализации система сходится к **единственному** исходу —
незавершённое дорешается, уже применённый необратимый шаг **не** повторяется (источник истины «уже
применено» = авторитетные факты, не in-memory).
### D8 — Сквозной бюджет reaper: без новых таймаутов (NFR-6)
Lease **не вводит** собственный долгий TTL. Его жёсткий потолок возраста **совпадает** с Tier-3
`reaper_max_running_s` (5400): reaper при реапе job на Tier-3 force-освобождает lease — lease и job
реклеймятся в один момент. Раннее обнаружение смерти — через pid+boot (D3), а не таймер. Поэтому
сквозной инвариант ORCH-065/109/110/113 `reaper_max_running_s (5400) > Σ(deploy-staging gate-work ≈4460)
+ grace` **остаётся нетронутым**, `reaper_finalize_grace_s`/`reaper_max_running_s` **не меняются**. Новых
бюджетных констант, требующих согласования, нет.
### D9 — never-raise / fail-open / fail-closed / kill-switch (FR-7, NFR-1, NFR-8, AC-9, AC-10)
- **Hot-path `claim_next_job` НЕ трогается** — lease консультируется на пути перехода/финализации и в
reaper/reconciler/webhook, **не** в claim. → общая очередь всех проектов не может заклиниться на баге
lease (fail-open по построению, AC-8 ORCH-088 цел).
- **acquire/guard-ошибка** (БД-сбой/повреждённая строка): на side-effectful пути → консервативный
**defer/abort текущей попытки без побочных эффектов** (fail-closed к недвоению; не вечный клин — следующий
тик/reaper переисполнит, в пределе Tier-3 добьёт). guard reconciler/webhook → консервативный skip.
- **CAS-ошибка** → аборт записи (не слепой write).
- **Kill-switch `transition_lease_enabled=False`** → lease не пишется/не читается; CAS вырождается в
прежний `update_task_stage`; reaper → ORCH-113 fallback; reconciler/webhook skip-guard инертен → **байт-в-байт**
до-ORCH-114 (зелёный существующий `pytest tests/` без правок ожиданий).
### D10 — Наблюдаемость и конфигурация (FR-6, BR-7, NFR-2, AC-12)
- `GET /queue` — аддитивный read-only блок `transition_lease` (держатели/owner/stage/возраст/defer-счётчики/
форсированные/устаревшие реклеймы); существующие ключи не тронуты.
- **Telegram-алерт** (`send_telegram`, кликабельный номер) на форсированный/устаревший реклейм.
- **Опц.** `POST /transition-lease/release?work_item=<id>` — операторский ручной реклейм (паттерн
`POST /serial-gate/unfreeze`).
- **Опц.** lessons-journal автозапись (ORCH-098, `source="auto"`) на форсированный реклейм.
- **Флаги** (`config.py`): `transition_lease_enabled: bool = True` (env `ORCH_TRANSITION_LEASE_ENABLED`,
kill-switch); `transition_lease_repos: str = ""` (CSV; **пусто → self-hosting only**, паттерн
`coverage_gate_repos`/`serial_gate_repos` → enduro не затронут). Новых таймаутов нет (D8).
- **Leaf `src/transition_lease.py`** (never-raise, паттерн `serial_gate`/`coverage_gate`/`finalizer_liveness`):
`applies(repo)` / `acquire(task_id, owner, run_id, stage) -> bool` / `is_held_by_live_owner(task_id) -> bool`
/ `release(task_id, owner=None)` / `reclaim_if_stale(task_id) -> bool` / `recover_on_startup()` / `snapshot()`.
## Альтернативы
- **Только CAS/epoch, без lease** — отвергнуто: CAS на коммите не предотвращает двойной side-effect
*в полёте* (re-test/rebuild/merge исполняются до записи стадии). Не закрывает класс ORCH-111.
- **Только durable-lease, без CAS** — отвергнуто: не покрывает 6 путей в обход `advance_stage` и узкое
окно «consult→acquire».
- **Heartbeat-liveness** — отвергнуто доводом самого ORCH-113: блокирующий 900s re-test не может бить
heartbeat → ложное «мёртв». pid+boot свободна от этого.
- **Lease-файл per-task** (клон merge-lease) — отвергнуто: CAS на запись стадии — DB-операция; держать
владение в той же транзакционной БД когерентнее и позволяет атомарный acquire тем же rowcount-guard
идиомом (`claim_next_job`/`reap_running_job`), что код уже доверяет. merge-lease-файл остаётся per-**repo**
для **другой** задачи (сериализация мержей между задачами репо) — не дублируется.
- **`epoch/version`-колонка на `tasks`** — отвергнуто (для текущей модели): стадия *и есть* версия для
side-effectful рёбер; колонка была бы неиспользуемой. Задокументирована как форвард-расширение.
- **Sub-state `finalizing` в `jobs.status`** — отвергнуто (как в ORCH-113): меняет семантику статуса для
claim/requeue/reconciler/reaper — нарушение NFR-3.
- **Per-stage grace, покрывающая Σ финализации** — отвергнуто (как в ORCH-113): нарушает бюджет
`5400 > Σ+grace`; таймер = источник бага.
- **Бесшовно для всех репо (без self-hosting-скоупа)** — отвергнуто: по образцу coverage/serial-gate
область по умолчанию self-hosting (необратимые эффекты живут на self-hosting-рёбрах); enduro инертен.
- **Новый бесшовный «recovery-мозг»** — отвергнуто (BRD §8 риск): композиция requeue + stale-clear +
авторитетные факты (D7) проще и не вносит нового источника двойного применения.
## Последствия
- **+** Класс двойного эффекта/противоречия rollback↔done закрыт **в корне** (предотвращение на входе),
а не починкой постфактум; покрыты конкурентный, reaper-после-рестарта, reconciler и webhook пути.
- **+** Рестарт-safe без нового таймера и без переписывания на multi-process (boot-id готовит почву под
будущий `--workers>1`, NFR-4).
- **+** Сквозной бюджет reaper и все инварианты конвейера (`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/
вердикт-ключи) не тронуты; схемы существующих таблиц байт-в-байт; +1 аддитивная таблица.
- **+** Дыра обходных путей gitea/plane (BRD §8) закрыта CAS.
- **** Гарантия эксклюзии валидна при одном процессе/одной БД (как ORCH-113); durable-lease лишь делает
её **корректной** и для рестарта/будущей multi-process — но полноценная multi-writer верификация — вне
объёма (риск TR-6 в `10-tech-risks.md`).
- **** Узкое окно «штамп `finished_at` → acquire» (как ORCH-113) маркером не покрыто — закрыто прежним
grace=300 + CAS-backstop.
- **Откат:** `ORCH_TRANSITION_LEASE_ENABLED=false` → байт-в-байт до-ORCH-114 (таблица остаётся инертной).
## Ссылки
- BRD: `docs/work-items/ORCH-114/01-brd.md`
- TRZ: `docs/work-items/ORCH-114/02-trz.md`
- Acceptance: `docs/work-items/ORCH-114/03-acceptance-criteria.md`
- Данные: `docs/work-items/ORCH-114/08-data-requirements.md`
- Риски: `docs/work-items/ORCH-114/10-tech-risks.md`
- Сквозной ADR: `docs/architecture/adr/adr-0045-transition-ownership-lease-and-stage-cas.md`
- Предшественники: `adr-0043` (ORCH-113 finalizer-liveness), `adr-0042` (ORCH-110 merge-retest),
`adr-0027`/`merge-lease` (ORCH-043), `adr-0040` (ORCH-109 бюджеты), `adr-0011` (ORCH-065 reaper),
`adr-0029` (ORCH-027 coverage-ratchet)
- Сверено по коду: `src/db.py:671679,13201335,14641505,9881055`, `src/stage_engine.py:176507,17262009`,
`src/finalizer_liveness.py`, `src/job_reaper.py:245,406,436461`, `src/reconciler.py:249,515575`,
`src/webhooks/plane.py:219,413,806`, `src/webhooks/gitea.py:127,242,333,359,398`,
`src/merge_gate.py:311411,452,526`
</content>
</invoke>

View File

@@ -0,0 +1,66 @@
---
work_item: ORCH-114
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-15
model_used: claude-opus-4-8
---
# 08 — Требования к данным: ORCH-114 — Durable transition-ownership lease + expected-stage CAS
Work Item: **ORCH-114** · Repo: **orchestrator** · Стадия: architecture
> When-applicable / информационный (гейтом не парсится). Сверено по `src/db.py`.
## Изменения схемы БД
**Ровно один новый объект — аддитивная таблица `transition_lease`** (`CREATE TABLE IF NOT EXISTS` в
`init_db()`, паттерн `repo_freeze`/`coverage_baseline`/`lessons`):
```sql
CREATE TABLE IF NOT EXISTS transition_lease (
task_id INTEGER PRIMARY KEY, -- одна задача = ≤1 активный владелец side-effectful перехода
owner TEXT NOT NULL, -- актор-держатель: monitor|reaper|reconciler|webhook|finalizer
owner_pid INTEGER, -- pid процесса-держателя (liveness, как merge-lease os.getpid())
owner_boot_id TEXT, -- нонс старта процесса; рестарт ⇒ смена ⇒ прежний lease мёртв
run_id INTEGER, -- agent_runs.id если применимо (контекст)
stage TEXT, -- from-стадия захвата (наблюдаемость/контекст)
acquired_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
Индекс не требуется (доступ по PK `task_id`); `snapshot()` для `GET /queue` — full-scan по малой таблице
(в любой момент строк ≈ числу активных side-effectful переходов, единицы).
**Изменений существующих таблиц НЕТ.** `tasks` / `jobs` / `agent_runs` / `events` / `job_deps` /
`repo_freeze` / `coverage_baseline` / `lessons` — схемы **байт-в-байт** (NFR-3, AC-11). **Колонка
`epoch/version` НЕ добавляется** (ADR-001 D2: для одно-процессной модели стадия *и есть* версия CAS;
epoch — форвард-расширение, не вводится сейчас).
## Новые/изменённые сущности
- **Таблица `transition_lease`** — durable-владение side-effectful переходом задачи. Инвариант:
активная строка для `task_id` ⇔ некий актор держит владение переходом этой задачи. **Живой** владелец ⇔
`owner_boot_id == <boot-id текущего процесса>` И `merge_gate.pid_alive(owner_pid)`; иначе **устарел**
реклеймится. Захват — атомарный rowcount-guard (паттерн `claim_next_job`/`reap_running_job`): `INSERT … ON
CONFLICT(task_id)` берётся только при отсутствии живого владельца (иначе rowcount==0 → busy).
- **Функция `update_task_stage_cas(task_id, expected_stage, new_stage) -> bool`** (новая, в `db.py`):
`UPDATE tasks SET stage=?, updated_at=datetime('now') WHERE id=? AND stage=?`; возвращает `cur.rowcount==1`
(выиграл CAS) / `False` (проиграл — стадия уже не та, что читали → аборт без побочных эффектов).
Прежний `update_task_stage` **сохраняется без изменений** (путь kill-switch-off и записи вне
side-effectful области).
## Совместимость данных / миграции
- **Аддитивно/идемпотентно/restart-safe:** `CREATE TABLE IF NOT EXISTS` в `init_db()` — повторный старт
no-op; на живой общей прод-БД данные enduro-trails не затрагиваются (новая таблица изолирована).
- **Никакого backfill** существующих строк не требуется (таблица заполняется рантаймом при захвате владения).
- **Рестарт-семантика:** durable-строки lease переживают рестарт физически; новый процесс получает новый
`owner_boot_id` → ранее записанные строки трактуются как устаревшие и реклеймятся (ADR-001 D3/D7);
`recover_on_startup()` зачищает их наблюдаемо (после `requeue_running_jobs`).
- **Откат (NFR-8):** при `transition_lease_enabled=False` таблица не читается/не пишется и остаётся
инертной; удалять её при откате не требуется. Поведение БД-слоя — байт-в-байт до-ORCH-114.
- **enduro-trails:** при `transition_lease_repos=""` (self-hosting only) механизм для enduro не активируется
— нулевая регрессия.
</content>

View File

@@ -0,0 +1,47 @@
---
work_item: ORCH-114
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-15
model_used: claude-opus-4-8
---
# 10 — Технические риски: ORCH-114 — Durable transition-ownership lease + expected-stage CAS
Work Item: **ORCH-114** · Repo: **orchestrator** · Стадия: architecture
> Информационный (гейтом не парсится). Риски реализации и митигейшн. Сверено по коду.
## Реестр рисков
| ID | Риск | Вер. | Влия. | Митигейшн |
|----|------|------|-------|-----------|
| **TR-1** | **Дедлок / over-block:** «жёсткое» владение заклинивает легитимный путь — reaper не добивает зависший finalizer, задача висит нетерминальной с удержанным lease (клинит serial-gate репо). | Сред. | Выс. | ADR D3/D8: liveness = pid+boot (мёртвый владелец реклеймится немедленно); Tier-3 `reaper_max_running_s` **игнорирует** lease и добивает зависшего живого; reaper при реапе force-освобождает lease. `release` в `try/finally`. Опц. `POST /transition-lease/release`. Обязательный тест AC-3 (release на исключении/откате) + AC-5 (bounded reclaim). |
| **TR-2** | **Lease «течёт»** при исключении/откате в `advance_stage` → задача навсегда заблокирована. | Сред. | Выс. | `release` строго в `finally` вокруг всего side-effectful региона (ADR D5); регресс-тест AC-3 (acquire→raise→release). Бэкстоп: stale-реклейм по pid/boot + Tier-3. |
| **TR-3** | **Buggy «smart recovery»** сам становится источником двойного применения необратимого шага после рестарта. | Сред. | Выс. | ADR D7: НЕ новый recovery-мозг, а композиция `requeue_running_jobs` + stale-clear + **существующие авторитетные факты** (SHA-in-main ORCH-071/073, `INITIATED` ORCH-036, coverage-ratchet CAS). Обязательный restart-recovery регресс (BR-8/AC-6): процесс убит в середине финализации → ровно один исход, без второго `merge_pr`/ratchet/deploy. |
| **TR-4** | **Скрытые обходные пути** (gitea `handle_push`/`handle_ci_status`/`handle_pr`, plane `_rollback_stage:806`) пишут стадию мимо `advance_stage` → CAS-инвариант обходится. | Выс. | Сред. | ADR D4: 6 обходных `update_task_stage` переведены на `update_task_stage_cas`; граница зафиксирована в ADR. Структурный аудит (AC-11): ни одного безусловного `update_task_stage` на side-effectful/конкурентных путях при флаге on. |
| **TR-5** | **Гонка consult→acquire:** актор A прошёл guard «lease свободен», но B захватил между проверкой и acquire A. | Сред. | Сред. | Двойной слой (ADR D1): даже если оба прошли consult, `acquire` атомарен (rowcount-guard, один INSERT выигрывает), а проигравший CAS на коммите не пишет стадию и не делает side-effect. Consult — лишь дешёвый front-defer, не источник истины. |
| **TR-6** | **Multi-process (`--workers>1`):** pid+boot-liveness и SQLite-CAS на одной БД корректны для одного процесса; ввод воркеров потребует верификации (SQLite write-lock contention, boot-id на процесс). | Низ. | Сред. | Вне объёма (BRD §scope: модель остаётся одно-процессной). Durable-форма (таблица + pid/boot + CAS) спроектирована совместимой; epoch-колонка — документированное форвард-расширение (ADR D2). Зафиксировано как ограничение в adr-0045 «Последствия ()». |
| **TR-7** | **Бюджетный конфликт:** lease, удерживаемый дольше `reaper_max_running_s`, нарушает сквозной инвариант ORCH-065/109/110/113. | Низ. | Выс. | ADR D8: lease без собственного TTL, потолок = Tier-3 `reaper_max_running_s` (5400); `reaper_finalize_grace_s`/`reaper_max_running_s` НЕ меняются; инвариант `5400 > Σ(≈4460)+grace` цел. Новых бюджетных констант нет. |
| **TR-8** | **Регрессия ORCH-113:** обобщение `finalizer_liveness` ломает его контракт/тест или меняет поведение при выключенном ORCH-114. | Сред. | Сред. | ADR D6: `finalizer_liveness.py` **не правится**, остаётся поведением kill-switch-off (reaper → in-memory, Tier-2/`deploy-staging`); on → durable cross-path. Зелёный существующий тест ORCH-113 + AC-9 (флаг off → байт-в-байт). Сверка маркеров ORCH-113 (TRACEABILITY/ORCH-078). |
| **TR-9** | **Сбой механизма заклинивает общую очередь** всех проектов (enduro + orchestrator), нарушая AC-8 ORCH-088. | Низ. | Выс. | ADR D9: hot-path `claim_next_job` **не трогается** (lease консультируется на пути перехода/reaper/reconciler/webhook, не в claim). never-raise; acquire/guard-ошибка → defer (не клин), CAS-ошибка → аборт записи. Регресс AC-10. |
| **TR-10** | **Ложная stale-реклейм** живого владельца (pid переиспользован ОС после рестарта, boot-id совпал случайно). | Низ. | Сред. | `owner_boot_id` — достаточно энтропийный нонс старта процесса (не предсказуемый), плюс pid-проверка; коллизия (тот же boot-id И тот же pid у нового процесса) практически невозможна. Бэкстоп: CAS на коммите не даст двойной записи даже при ложном реклейме. |
## Сводный вывод
Доминирующий класс рисков — **корректность владения и восстановления на необратимых рёбрах**
(TR-1/TR-2/TR-3) и **полнота охвата путей** (TR-4/TR-5). Все они снимаются архитектурой defense-in-depth
(lease на входе + CAS на коммите) и принципом «не строить новый recovery-мозг, опереться на существующие
авторитетные факты» (D7) — это сознательно минимизирует площадь нового кода, способного двоить
необратимый шаг.
**Эскалация `arch:major-change`: рекомендуется.** Изменение вводит новый durable-компонент (leaf
`transition_lease` + таблица), трогает движок переходов и ≥5 фоновых акторов и помечается сводным сквозным
ADR (adr-0045). Реализующему агенту (developer) обязательны: регресс двойного эффекта (AC-1, red→green),
restart-recovery (AC-6), kill-switch-off байт-в-байт (AC-9), сохранность бюджета (AC-5/NFR-6) и аудит
обходных путей (TR-4/AC-11). Возврата в анализ не требуется — требования полны и реализуемы без нарушения
архитектурных принципов (всё в Docker/одном процессе/SQLite, без новых внешних зависимостей и без рестарта
прода). Остаточный риск для прод-конвейера (self-hosting) при дисциплине тестов — **низкий**; единственное
осознанное ограничение — multi-process (TR-6), явно вне объёма.
</content>

View File

@@ -0,0 +1,119 @@
---
verdict: APPROVED # APPROVED | REQUEST_CHANGES — строго одно из двух, UPPERCASE
work_item: ORCH-114
stage: review
author_agent: reviewer
status: approved
created_at: 2026-06-15
model_used: claude-opus-4-8
type: review
work_item_id: ORCH-114
version: 2
---
# Review ORCH-114 — Durable transition-ownership lease + expected-stage CAS
## Summary
Повторное ревью (цикл 2). Прошлый вердикт был `REQUEST_CHANGES` из-за **частичной
незавершённости документации golden-source** (два P1 + три P2/P3). Кода-корректность и
инварианты конвейера уже тогда были без P0/P1. В этом цикле developer **закрыл все ранее
поднятые findings**:
- **P1 `.env.example`** → закрыт: добавлены `ORCH_TRANSITION_LEASE_ENABLED=true` /
`ORCH_TRANSITION_LEASE_REPOS=` с подробной нормативной шапкой (рядом с блоком reaper-флагов).
- **P1 `CLAUDE.md`** → закрыт: добавлена полноценная паспорт-секция «Единое владение
side-effectful переходами: durable-lease + expected-stage CAS (ORCH-114)» (механизм, два
слоя, инвариант, флаги, ADR-ссылки) — по образцу ORCH-112/113.
- **P2 витрина `docs/overview/`** → закрыта: `tech-data-model.md` (строка таблицы
`transition_lease`), `tech-observability.md` (упоминание блока `/queue`),
`tech-architecture.md` (компонент `Transition-lease`).
- **P2 расхождение код↔ADR D4 (CAS на rollback-записях)** → закрыто: введён общий хелпер
`_rollback_stage_cas`, и все четыре in-region rollback-записи (`_handle_merge_gate_rollback`/
`_handle_security_gate`/`_handle_coverage_gate`/`_handle_image_freshness`) теперь пишут
`development` через тот же expected-stage CAS. Реализация совпала с собственным ADR D4;
добавлены целевые тесты (TC-11 `test_tc11_inregion_rollback_writes_use_cas`,
`test_tc11_rollback_cas_lost_aborts_without_overwriting_done`).
- **P2 изоляция тестов** → закрыта: убран module-level `os.environ.setdefault("ORCH_DB_PATH")`,
`test_webhooks.py` пинит собственный реестр per-test, добавлен autouse-fixture
`_disable_transition_lease` (kill-switch off для всего suite по образцу `_disable_merge_verify`).
Архитектура реализована **в точном соответствии с ADR-001 D1D10**: durable-lease на входе
(`acquire`/`release` в `try/finally`) + expected-stage CAS на записи (включая 6 путей в обход
`advance_stage`), liveness по `pid`+`boot_id` без heartbeat, реклейм при рестарте
(`recover_on_startup` в `main.lifespan`), reaper/reconciler/webhook defer при живом владельце,
Tier-3 backstop добивает зависшего. `src/transition_lease.py` — чистый never-raise leaf
(импортирует только `db`+`config`, лениво `merge_gate.pid_alive`/`qg.checks`/`notifications`).
**Инварианты конвейера (AC-11):** `src/stages.py` и `src/qg/checks.py` **не тронуты** (нет в
диффе), `STAGE_TRANSITIONS`/`QG_CHECKS` встречаются в src-диффе только в комментарии;
machine-verdict ключи не тронуты; БД аддитивна (одна таблица `transition_lease`,
`CREATE TABLE IF NOT EXISTS`, без epoch-колонки). hot-path `claim_next_job` lease не
консультирует (AC-10/fail-open).
**Багфикс-трек (ORCH-019/BR-4):** задача `bug→escalate full-cycle`. Регресс-тест-фиксатор
**присутствует**: `test_tc01_concurrent_entry_no_double_effect` (зелёный с lease) +
`test_tc01_red_before_fix_demonstration` (красный при kill-switch off — второй актор
переисполняет все под-гейты, воспроизводя класс ORCH-111). Требование выполнено.
**Прогоны (verified):**
- `pytest tests/test_orch114_transition_ownership.py tests/test_webhooks.py`**50 passed**
(именно та комбинация двух модулей, что в прошлом цикле давала 4 падения — изоляция
подтверждённо починена).
- `pytest tests/` (полный) — **2052 passed** в детерминированном порядке → AC-9 (байт-в-байт
при kill-switch off) и CI-green выполняются операционно; ORCH-113-тесты в наборе зелёные
(контракт предшественника не сломан, ORCH-078).
## Findings
### P0 — Blocker
- _(нет)_
### P1 — Must fix
- _(нет)_
### P2 — Should fix
- _(нет)_
### P3 — Nice to have
- [ ] **Merge-lease не освобождается на (практически недостижимой) ветке CAS-lost в
rollback'е coverage/image-freshness.** В `_handle_coverage_gate`/`_handle_image_freshness`
`release_merge_lease` стоит **после** `_rollback_stage_cas`, поэтому при проигранном CAS
(`return True`) штатное освобождение merge-lease пропускается. Под держимым transition-lease
эта ветка практически недостижима (единственный владелец → CAS почти всегда выигрывает; чтобы
проиграть, нужен аномальный bypass-писатель, сдвинувший стадию с `deploy-staging`). Даже в
этом крайнем случае утечка **ограничена** собственным TTL+reclaim merge-lease (ORCH-043/065).
Корректность недвоения не нарушена. При желании — продублировать holder-aware
`release_merge_lease` до CAS-проверки (или задокументировать намеренный аборт). Не блокер.
_Ссылка: ADR-001 D1/D4, ORCH-027/058 rollback._
- [ ] **`reconciler`/`plane._try_advance_stage` зовут `is_held_by_live_owner(task_id)` без
предварительного `applies(repo)`** (в отличие от reaper). Для out-of-scope, но включённого
репо (enduro при дефолте) это безвредный лишний `SELECT` (строк нет → `False`). Функционально
корректно; ради нулевого оверхеда можно добавить дешёвый `applies`-гард. (Перенос P3 из цикла 1
— остаётся косметикой.)
## Документация
**Обновлено и проверено (полно):**
- `.env.example``ORCH_TRANSITION_LEASE_ENABLED` / `ORCH_TRANSITION_LEASE_REPOS` (канон ключей
старта, ORCH-101).
- `CLAUDE.md` — паспорт-секция ORCH-114.
- `docs/overview/``tech-data-model.md` / `tech-observability.md` / `tech-architecture.md`
(витрина системы, ORCH-011).
- `CHANGELOG.md` — подробная запись ORCH-114 в `[Unreleased]`.
- `docs/architecture/README.md` + `internals.md` — компонент, секция, таблица БД, API-эндпоинт
`POST /transition-lease/release`, блок `/queue`.
- ADR: `docs/work-items/ORCH-114/06-adr/ADR-001-…` + сквозной
`docs/architecture/adr/adr-0045-…` — полные, со сверкой по коду.
- Work-item доки `02-trz`/`03-acceptance-criteria`/`04-test-plan`/`08-data-requirements`/
`10-tech-risks` — на месте.
**Необновлённой документации при изменении `src/` не выявлено.** Пунктов README «Известные
ограничения», закрываемых этим PR, нет.
## Verdict rationale
Все P0/P1/P2 прошлого цикла закрыты; новых P0/P1 не выявлено по всем четырём осям
(ТЗ/AC, ADR, качество кода + обязательный регресс-тест багфикс-трека, документация
golden-source). Остаются только два P3-наблюдения (косметика/недостижимая граница), не
блокирующие приёмку. Полный `pytest tests/` зелёный (2052 passed), инварианты конвейера и
схемы существующих таблиц — байт-в-байт. → **APPROVED**.

View File

@@ -0,0 +1,85 @@
---
result: PASS # PASS | FAIL — машинный вердикт, UPPERCASE
work_item: ORCH-114
stage: testing
author_agent: tester
status: pass
created_at: 2026-06-15
model_used: claude-opus-4-8
type: test-report
work_item_id: ORCH-114
---
# Test Report — ORCH-114
Durable transition-ownership lease + expected-stage CAS для side-effectful переходов стадий
(закрытие корневого класса инцидент-цепочки ORCH-110/111/112/113).
## Окружение
- 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-114-bug-pipeline-stage-transitions/`
- Branch: `feature/ORCH-114-bug-pipeline-stage-transitions`
- Дата: 2026-06-15
## Предусловия
- Review-вердикт `12-review.md`: **APPROVED** (цикл 2, нет P0/P1/P2; только два P3-наблюдения).
- Тесты прогнаны в worktree ветки задачи (не в общем `/repos/orchestrator`) — анти-гонка checkout.
## Smoke API (read-only, prod 8500)
- `GET /health``{"status":"ok","service":"orchestrator"}`**OK**.
- `GET /status` → активная задача 103 (ORCH-114, stage=testing) видна — **OK**.
- `GET /queue` → блок `serial_gate` присутствует (ORCH-088), блок `auto_labels` присутствует
(ORCH-089) — **OK** (смок-инвариант соблюдён).
- Блок `transition_lease` в `/queue` прод-инстанса (8500) **отсутствует** — это **ожидаемо**, не
регресс: новый код ORCH-114 живёт в ветке/worktree и ещё не задеплоен в прод (стадия testing
предшествует deploy-staging/deploy). Наблюдаемость блока `transition_lease` покрыта unit-тестами
TC-12 (`test_tc12_queue_block_wired`).
## Результаты — покрытие каждого TC из 04-test-plan.yaml
| TC ID | Тип | Описание (кратко) | AC | Результат |
|-------|-----|-------------------|----|-----------|
| TC-01 | integration | ОБЯЗ. РЕГРЕСС: конкурентный вход в `advance_stage(deploy-staging)` — каждый side-effect ровно раз; красный до фикса, зелёный после | AC-1 | PASS |
| TC-02 | unit | CAS-запись стадии: первый writer rowcount=1, второй lost-race rowcount=0, без мутации | AC-2 | PASS |
| TC-03 | unit | Жизненный цикл владения: acquire/release в `try/finally` (норм + исключение), durable-видимость | AC-3 | PASS |
| TC-04 | integration | Reaper defer при живом владении за пределами Tier-2/deploy-staging; rowcount-guard сохранён | AC-4 | PASS |
| TC-05 | unit/integration | Reaper добивает мёртвое/устаревшее владение в Tier-3 backstop; бюджет-инвариант сохранён | AC-5 | PASS |
| TC-06 | integration | Умное восстановление при рестарте: сходимость к единственному исходу без повторного эффекта | AC-6 | PASS |
| TC-07 | integration | Reconciler F-1 defer/skip при активном lease; fail-safe консервативный skip | AC-7 | PASS |
| TC-08 | integration | Webhook-путь (Approved/Confirm Deploy) defer при активном lease; поздний сигнал не теряется | AC-8 | PASS |
| TC-09 | integration | Kill-switch off: lease инертен, CAS вырождается в безусловный write — байт-в-байт | AC-9 | PASS |
| TC-10 | unit | never-raise + fail-open (hot-path) / fail-closed (prod-safety) на ошибках БД/lease | AC-10 | PASS |
| TC-11 | unit | Структурный аудит: `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/вердикт-ключи байт-в-байт; хранилище аддитивно | AC-11 | PASS |
| TC-12 | integration | Наблюдаемость: блок `/queue`, Telegram-алерт на форсированный реклейм | AC-12 | PASS |
| TC-13 | unit | Self-hosting безопасность: нет рестарта прода / push в `main` / detached-вмешательства | AC-13 | PASS |
| TC-14 | integration | Полный регресс конвейера зелёный; happy-path deploy-staging/finalizer без двойных эффектов | BR-8 | PASS |
**Сопоставление с `03-acceptance-criteria.md`:** все 13 AC покрыты соответствующими TC (см. колонку
AC). Каждый TC из `04-test-plan.yaml` (TC-01…TC-14) выполнен и совпал с `expected: PASS`.
Детализация по dedicated-модулю `tests/test_orch114_transition_ownership.py` (34 теста, разбивка
TC-01…TC-13 на под-кейсы) — все PASSED. TC-14 — полный регресс `tests/`.
## Вывод pytest
Dedicated-модуль:
```
tests/test_orch114_transition_ownership.py — 34 passed, 1 warning in 3.84s
```
Полный регресс (TC-14 / AC-9 / CI-green):
```
2052 passed, 1 warning in 106.62s (0:01:46)
```
(единственный warning — `PydanticDeprecatedSince20` в `src/config.py:8`, преждесуществующий,
не связан с ORCH-114.)
Обязательный регресс класса ORCH-111 присутствует и зелёный:
`test_tc01_concurrent_entry_no_double_effect` (PASS с lease) +
`test_tc01_red_before_fix_demonstration` (демонстрация красного при kill-switch off).
## Итог
**PASS** — полный pytest зелёный (2052 passed), все 14 TC выполнены и сопоставлены с 13 AC, smoke
read-only (`/health`/`/status`/`/queue` c блоками `serial_gate` + `auto_labels`) OK. Задача готова
к переходу на `deploy-staging`.

View File

@@ -0,0 +1,12 @@
---
deploy_status: SUCCESS
work_item: ORCH-114
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,33 @@
---
staging_status: SUCCESS
work_item: ORCH-114
stage: deploy-staging
author_agent: deployer
status: success
created_at: 2026-06-15
model_used: claude-opus-4-8
timestamp: 2026-06-15T16:25:50Z
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`,
ORCH-048/ADR-001). Exit code **0** → advance.
All REAL pipeline checks passed. The only two failures are the known sandbox-infra checks
C9a/C9b, which are waived per ORCH-061 (they depend on SANDBOX bot accounts being project
members, not on the pipeline). The script printed the waiver and still exited 0.
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
## Results
- **Block A (SMOKE)**: PASS — A1 `/health` 200 ok, A2 `/queue` 200 with counts/max_concurrency/resilience, A3 `ORCH_STAGING=true`.
- **Block B (ACCESS)**: PASS — B4 Plane sandbox accessible (sandbox=YES), B5 Gitea orchestrator-sandbox accessible push=true, B6 registry isolation (sandbox present, prod ET/ORCH absent).
- **Block C (E2E, mode=stub)**: C7 create Plane issue PASS, C8 trigger `/webhook/plane` PASS; C9a/C9b FAIL → **INFRA-WAIVED** (sandbox-infra). Cleanup: Plane issue deleted (HTTP 204).
RESULT: 8/10 checks PASS. REAL failed: none. SANDBOX_INFRA failed (waived): C9a, C9b.
Tolerance: `staging_infra_tolerance_enabled=True`.

View File

@@ -0,0 +1,7 @@
# Business Request: BUG: test/staging Plane writes must be sandbox-only and never mutate prod
Work Item ID: ORCH-117
## Description
TBD

View File

@@ -0,0 +1,213 @@
---
work_item: ORCH-117
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-15
model_used: claude-opus-4-8
escalate: full-cycle
---
# 01 — BRD / Bug-report: ORCH-117 — test/staging Plane writes must be sandbox-only and never mutate prod
Work Item: **ORCH-117** · Repo: **orchestrator** · Стадия: analysis · Трек: **Bug → эскалация в full-cycle**
> ⚠️ **`escalate: full-cycle` (ADR-001 D5 ORCH-019).** Задача помечена `Bug`, но по сути это
> **архитектурный + safety-critical (self-hosting)** дефект изоляции окружений: нужно решение о
> том, **где** ставить fail-closed-чокпоинт записи в Plane, **как** детектировать тест-процесс
> (pytest/worktree) в отличие от staging-runtime, и **как** устроен явный аудируемый opt-in для
> sandbox-интеграции. Это требует ADR (политика изоляции + точка перехвата). Поэтому выпускается
> **полный** analysis-пакет, а не облегчённый bug-пакет. Оператор снимает багфикс-трек:
> `POST /bug-fast-track/escalate?work_item=ORCH-117` → задача пойдёт через стадию `architecture`
> (architect выпустит ADR для политики изоляции/чокпоинта).
---
## 1. Бизнес-контекст и проблема
### Симптом (наблюдаемое — установленный факт из инцидента ORCH-114)
Во время тестирования ORCH-114 **тестовый/worktree-путь выполнил РЕАЛЬНУЮ запись в Plane против
ПРОДАКШН-проекта ORCH**. В логах Plane зафиксировано:
```
PATCH /issues/dd57ad23... state=3738cd3c... # 3738cd3c == Done
+ comment: "Stage: deploy → done" (branch feature/orch114)
```
То есть `notify_stage_change("ORCH-114", "deploy", "done")`, запущенный из тестового процесса,
смутировал боевую задачу в Plane — поставил **Done** и оставил комментарий. Это «ложный Done»:
прод-доска показала задачу завершённой по действию тестов, а не конвейера.
### Причина симптома (установленный факт, верифицировано в коде)
Тест/staging-процессы **имеют доступ к живому Plane-токену и workspace** и **не принуждаются**
писать только в ORCH Sandbox:
1. **Токен берётся из боевого окружения контейнера.** В `src/plane_sync.py:17`
`PLANE_HEADERS = {"X-API-Key": settings.plane_api_token}` фиксируется **на импорте модуля**;
`settings.plane_api_token` читается из env контейнера, где боевой токен **уже установлен**.
2. **Защита в тестах не работает.** Тестовые модули делают
`os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")` (например
`tests/test_deploy_terminal_sync.py:19`, `tests/test_orch026_serialize_integration.py:16` и ~20
др.). `os.environ.setdefault` **НЕ перекрывает** уже установленную в контейнере переменную →
в проде это **no-op** → тесты наследуют **реальный** токен. Вдобавок, даже если бы env
переписывался ПОСЛЕ импорта `plane_sync`, `PLANE_HEADERS` уже захвачен литералом на импорте и
не пересчитывается (см. п.1) — подмена токена постфактум бесполезна.
3. **Целевой проект по умолчанию — боевой.** `src/plane_sync.py:57`
`PROJECT_ID = settings.plane_project_id or "7a79f0a9-5278-49cd-9007-9a338f238f9c"` — дефолт
указывает на **боевой ORCH-проект**; ничто не принуждает тест-процесс адресовать только
sandbox `8c5a3025-4f9d-4190-b79f-fa06276bb27e` (identifier `SANDBOX`).
4. **Нет fail-closed-гарда на путях записи.** Все мутирующие вызовы Plane проходят через **три**
примитива в `src/plane_sync.py``update_issue_state` (`httpx.patch`, стр. 861),
`add_comment` (`httpx.post`, стр. 885), `_set_issue_state_direct` (`httpx.patch`, стр. 1047) —
и **ни один** не проверяет, выполняется ли он в тест-процессе и легитимен ли целевой проект.
### Прецедент в кодовой базе (почему фикс уместен и как его форму подсказать)
`tests/conftest.py` уже содержит **ровно тот же класс защиты для Telegram**: autouse-фикстура
`_no_telegram` глушит `send_telegram`, потому что «pytest на проде слал РЕАЛЬНЫЕ Telegram-сообщения
Славе» (дословно из докстринга conftest). Аналогичная autouse-страховка для **Plane-записи**
**отсутствует** — это пробел того же рода, который и реализовался инцидентом ORCH-114. Sandbox как
понятие уже существует: `scripts/staging_check.py:283` фиксирует
`SANDBOX_PROJECT_ID = "8c5a3025-4f9d-4190-b79f-fa06276bb27e"`, а проверка B6 утверждает инвариант
«sandbox present ∧ prod-ET absent ∧ prod-ORCH absent» — но **только как read-only верификация
доступа**, а не как **гард записи**.
### Локализация (куда смотреть архитектору/разработчику)
- **Чокпоинт записи** — три примитива в `src/plane_sync.py` (`update_issue_state`, `add_comment`,
`_set_issue_state_direct`); все `set_issue_*`/`notify_*` сводятся к ним. Гард логично ставить
максимально близко к фактическому `httpx.patch/post` (низкий чокпоинт ловит любой путь, включая
будущие).
- **Захват токена на импорте** — `PLANE_HEADERS`/`PROJECT_ID` модульного уровня (`plane_sync.py:17,57`):
подмена env после импорта не лечит; гард обязан перехватывать **на момент вызова**.
- **Дефолт тестового окружения** — `tests/conftest.py` (autouse, fail-closed по образцу `_no_telegram`).
- **Конфиг opt-in** — `src/config.py` (новые ключи интеграционного включения + sandbox-allowlist).
- **Детект тест-процесса** — в `src/` сейчас **нет** механизма (`PYTEST_CURRENT_TEST`/`sys.modules`
не используются для этого); его предстоит ввести и/или опереть на явный конфиг-флаг.
**Вывод:** устойчивость должна быть на стороне системы — запись в Plane из тест/worktree-процесса в
**боевой** проект должна быть **физически невозможна** (fail-closed), независимо от того, какой
токен оказался в окружении; sandbox-запись разрешается только при **явном аудируемом opt-in** и
**только** в проект SANDBOX.
## 2. Объём (scope)
### В объёме
- **Жёсткая fail-closed изоляция записи в Plane:** прогон unit/test/full-regression (pytest, в т.ч.
из worktree) **не может** мутировать боевые Plane-проекты (state-PATCH и/или comment-POST) —
даже при наличии **живого боевого токена** в окружении.
- **Sandbox-only для реальных тестов:** staging / full-real e2e-тесты, которым нужна настоящая
запись в Plane, адресуют **только** проект ORCH Sandbox (`8c5a3025-…`); любой другой целевой
проект (особенно боевой `7a79f0a9-…`) — запрещён.
- **Явный аудируемый opt-in:** запись в Plane из тест-процесса возможна **исключительно** при
одновременном выполнении: (а) включён выделенный интеграционный флаг, (б) целевой проект ∈
sandbox-allowlist. Отсутствие любого условия → запись блокируется.
- **Дефолт тестов fail-closed:** autouse-страховка в `tests/conftest.py` (по образцу `_no_telegram`)
блокирует Plane-запись по умолчанию во **всех** тестах.
- **Наблюдаемость/аудит:** каждая заблокированная запись логируется структурно (WARNING/ERROR с
целевым project_id, work_item, операцией); каждая разрешённая sandbox-запись — audit-строкой.
- **Док/конфиг:** обновить `.env.example`, `CLAUDE.md`, `docs/architecture/README.md`,
`docs/operations/INFRA.md` (и, при необходимости, `docs/deployment/*` про тестовую изоляцию).
- **Обязательный регресс-тест:** воспроизводит инцидент ORCH-114 — красный до фикса, зелёный после.
### Вне объёма
- ❌ Изменение `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict ключей / схемы БД
(это bugfix-изоляция, а **не** Quality Gate и **не** стадия).
- ❌ Изменение поведения **боевого рантайма** оркестратора (не-pytest процесс): прод обязан писать
в Plane как прежде, гард для него — no-op.
- ❌ Изменение поведения **staging-рантайма** (8501): staging — реальный процесс оркестратора,
работающий **только** с sandbox-проектом по конфигу; он должен по-прежнему писать в SANDBOX.
- ❌ Запрет/контроль ручных операций оператора (вне технической власти системы).
- ❌ Выбор конкретного механизма детекта тест-процесса и точки перехвата (гард в `plane_sync` vs
обёртка `httpx` vs autouse-фикстура vs комбинация) — **зона архитектора** (ADR).
- ❌ Массовая чистка/нормализация существующих `os.environ.setdefault(...)` строк в тестах сверх
необходимого (можно оставить как есть — гард не должен от них зависеть; см. NFR-4).
## 3. Заинтересованные стороны
- **Заказчик/оператор (Слава)** — страдает от «ложных Done» и шумных комментариев в боевой Plane,
порождённых тестами; принимает результат.
- **Self-hosting конвейер orchestrator** — прямой потребитель: целостность боевой Plane-доски как
источника индикации (слой B, ORCH-066) не должна искажаться тест-прогонами.
- **Все проекты на общем инстансе (enduro-trails)** — косвенно: тестовая запись не должна задевать
чужие боевые проекты в общем workspace.
- **Разработчики/CI** — потребители sandbox-e2e: должны сохранить возможность реальной проверки
против SANDBOX.
## 4. Бизнес-требования (BR)
- **BR-1** — Прогон pytest (unit/integration/full-regression), в том числе из worktree,
**НЕ должен** выполнять мутирующую запись в Plane (state-PATCH и/или comment-POST) против
**боевых** проектов — **даже при наличии живого боевого токена** в окружении. Это **fail-closed**
свойство: запись в боевой проект из тест-процесса **невозможна**.
- **BR-2** — Реальная запись в Plane из тест/staging-контекста разрешена **только** в проект
**ORCH Sandbox** (`8c5a3025-4f9d-4190-b79f-fa06276bb27e`); любой иной целевой проект (в т.ч.
боевой ORCH `7a79f0a9-…` и боевой enduro-проект) — запрещён.
- **BR-3** — Реальная sandbox-запись из тест-процесса включается **только** явным аудируемым opt-in:
одновременно (а) выделенный интеграционный флаг включён **и** (б) целевой проект ∈ sandbox-allowlist.
Отсутствие любого условия → запись блокируется (default-deny).
- **BR-4** — Дефолтная тестовая поза — **fail-closed**: при обычном `pytest tests/` (без явного
opt-in) **ни один** тест не может писать в Plane (autouse-страховка в `conftest.py`, по образцу
существующего `_no_telegram`).
- **BR-5** — **Sandbox e2e сохраняется:** с включённым opt-in и целевым проектом SANDBOX реальная
запись в Plane успешно проходит (регрессии sandbox-сценария нет).
- **BR-6** — **Наблюдаемость/аудит:** каждая заблокированная попытка записи логируется структурно
(целевой project_id, work_item, операция, причина блокировки); каждая разрешённая sandbox-запись —
audit-строкой. Инцидент класса ORCH-114 должен быть **видимым**, а не молчаливым.
- **BR-7** — **Документация и конфиг обновлены** в том же PR (golden source наравне с кодом):
`.env.example`, `CLAUDE.md`, `docs/architecture/README.md`, `docs/operations/INFRA.md`.
## 5. Нефункциональные требования (NFR)
- **NFR-1 (fail-closed / default-deny)** — при любой неопределённости (не удаётся достоверно
определить целевой проект / окружение / тест-контекст) запись в тест-контексте **запрещается**.
«Не знаю» ⇒ «не пишу».
- **NFR-2 (нулевая регрессия боевого рантайма)** — реальный прод-процесс оркестратора (не pytest)
пишет в Plane **байт-в-байт** как до ORCH-117; гард для него — no-op. `STAGE_TRANSITIONS` /
`QG_CHECKS` / `check_*` / machine-verdict ключи / схема БД — **не тронуты**.
- **NFR-3 (staging-рантайм не сломан)** — staging-инстанс (8501) — **реальный** процесс
оркестратора (не pytest), сконфигурированный на sandbox-проект; он должен **по-прежнему** писать
в SANDBOX. Детект обязан отличать **тест-процесс (pytest/worktree)** от **staging-runtime**.
- **NFR-4 (устойчивость к захвату токена на импорте)** — фикс **не должен** полагаться на подмену
`settings.plane_api_token`/env постфактум (бесполезно из-за модульного захвата `PLANE_HEADERS`/
`PROJECT_ID`, `plane_sync.py:17,57`) и **не должен** зависеть от неработающего
`os.environ.setdefault(...)` в тестах. Перехват — **на момент вызова** примитива записи.
- **NFR-5 (надёжность / self-hosting safety)** — гард изолирован и **never-raise в боевом пути**
(по образцу leaf'ов `serial_gate`/`cancel`/`deploy_status_guard`): сбой/недоступность логики
гарда не роняет боевой конвейер и не блокирует легитимную боевую запись. В **тест-процессе**
срабатывание гарда должно быть **громким** (блок + аудит, при необходимости — жёсткий fail),
чтобы дефект всплыл, а не замаскировался.
- **NFR-6 (обратимость / kill-switch)** — поведение под флагом по конвенции проекта, **но**
дефолт = **безопасный** (fail-closed в тестах). Kill-switch **не должен** позволять случайно
переоткрыть запись в боевой проект из тестов без явного аудируемого opt-in (BR-3); т.е.
«выключить защиту полностью» не равно «разрешить запись в прод из pytest».
- **NFR-7 (область / композиция)** — изменение скоупится на изоляцию тест/staging-записи; не
ухудшает поведение для прочих репо/боевого рантайма; совместимо с ORCH-066 (статусная модель),
ORCH-094 (deploy-status guard), ORCH-061 (sandbox-infra tolerance staging_check).
## 6. Допущения и ограничения
- **Все** мутирующие записи в Plane проходят через 3 примитива `src/plane_sync.py`
(`update_issue_state`, `add_comment`, `_set_issue_state_direct`) — это единый узкий чокпоинт
(верифицировано: все `set_issue_*`/`notify_*` сводятся к ним).
- Боевой проект ORCH = `7a79f0a9-5278-49cd-9007-9a338f238f9c` (дефолт `PROJECT_ID`); sandbox =
`8c5a3025-4f9d-4190-b79f-fa06276bb27e` (`SANDBOX`, уже зафиксирован в `scripts/staging_check.py:283`).
- `PLANE_HEADERS`/`PROJECT_ID` захватываются на импорте модуля — гард обязан читать актуальное
состояние/контекст **в момент вызова**, не на импорте.
- Тест-процесс достоверно отличим (например по `PYTEST_CURRENT_TEST` в env, по наличию `pytest`
в `sys.modules`, и/или по явному конфиг-флагу тест-режима) — **выбор признака — вопрос ADR**;
признак должен быть надёжным и не давать ложноположительных срабатываний в боевом/staging
рантайме (NFR-2/NFR-3).
- Конкретный механизм (гард-leaf в `plane_sync` / обёртка над `httpx` / autouse-фикстура /
их комбинация) и протокол opt-in — **открытый вопрос архитектуры**, решается в `06-adr/`.
## 7. Критерии успеха
Прогон pytest с **живым боевым токеном** в окружении **физически не может** смутировать боевой
ORCH-проект (0 PATCH/POST в боевой проект); sandbox-e2e против SANDBOX по-прежнему работает при
явном opt-in; боевой и staging рантаймы — без регресса; каждая блокировка/разрешение записи —
наблюдаема (аудит-лог); док/конфиг обновлены; обязательный регресс-тест **красный до фикса,
зелёный после**. Детальные PASS/FAIL — `03-acceptance-criteria.md`.
## 8. Риски
- **Ложноположительный детект тест-процесса** в боевом/staging рантайме → блокировка легитимной
боевой/sandbox записи (молчаливая потеря индикации Plane). Митигирует NFR-2/NFR-3 + аудит (BR-6).
- **Ложноотрицательный детект** (тест-процесс не распознан) → дефект остаётся → нужен надёжный
признак + fail-closed по умолчанию (NFR-1) + дефолтная autouse-страховка (BR-4).
- **Захват на импорте** (`PLANE_HEADERS`/`PROJECT_ID`): неверная точка перехвата (на импорте, а не
на вызове) даст ложное чувство защиты — жёсткое ограничение для архитектора (NFR-4).
- **Kill-switch как чёрный ход:** грубо реализованный «выключатель» может переоткрыть запись в прод
из тестов — запрещено (NFR-6).
- Кросс-каттинг с ORCH-066 (Plane-индикация), ORCH-094 (deploy-status guard), ORCH-061
(staging sandbox-infra). Детали/митигации — `10-tech-risks.md` (заполняет архитектор).

View File

@@ -0,0 +1,132 @@
---
work_item: ORCH-117
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-15
model_used: claude-opus-4-8
escalate: full-cycle
---
# 02 — ТЗ (TRZ): ORCH-117 — sandbox-only fail-closed изоляция записи в Plane
Work Item: **ORCH-117** · Repo: **orchestrator** · Стадия: analysis
> ТЗ описывает **конкретные изменения к реализации**, выведенные из BRD и фактического кода.
> Архитектурное обоснование/решения (точка перехвата, признак тест-процесса, протокол opt-in) —
> задача архитектора (`06-adr/`). Ниже — требования и ограничения, привязанные к реальным модулям.
## 1. Сводка изменения
Ввести **fail-closed гард записи в Plane**: любая мутирующая запись (state-PATCH / comment-POST),
исходящая из **тест-процесса** (pytest/worktree), блокируется по умолчанию и допускается **только**
при явном аудируемом opt-in **и** целевом проекте из **sandbox-allowlist**. Боевой
рантайм-процесс оркестратора и staging-рантайм (8501) не затронуты (гард для них — no-op). Перехват
выполняется **на момент вызова** примитивов записи `src/plane_sync.py` (а не на импорте, где токен
уже захвачен). Дефолтная тестовая поза — блокировка, через autouse-страховку в `tests/conftest.py`
(по образцу `_no_telegram`). Изменение — bugfix-изоляция: **не** Quality Gate, **не** стадия;
`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схема БД — не трогаются.
## 2. Задействованные модули / пути
| Путь | Действие | Зачем |
|------|----------|-------|
| `src/plane_sync.py` | изменить | Врезать гард в 3 примитива записи: `update_issue_state` (`httpx.patch`, стр. 861), `add_comment` (`httpx.post`, стр. 885), `_set_issue_state_direct` (`httpx.patch`, стр. 1047). Учесть захват `PLANE_HEADERS`/`PROJECT_ID` на импорте (стр. 17/57). |
| `src/plane_write_guard.py` *(кандидат, имя — на усмотрение архитектора)* | создать *(вероятно)* | Чистый leaf-гард (never-raise в боевом пути, по образцу `serial_gate`/`cancel`/`deploy_status_guard`): `decide(project_id, op, work_item) -> ALLOW \| BLOCK` + детект тест-процесса + sandbox-allowlist. Альтернатива/комбинация — обёртка над `httpx`/autouse-фикстура (решает ADR). |
| `src/config.py` | изменить | Новые ключи: интеграционный opt-in флаг записи из тестов, sandbox-allowlist проектов, (опц.) kill-switch гарда. Дефолты = безопасные (fail-closed в тестах). |
| `tests/conftest.py` | изменить | Новая autouse-фикстура fail-closed (блок Plane-записи во всех тестах по умолчанию), по образцу `_no_telegram`. Тесты sandbox-e2e переопределяют её своим opt-in (как `test_*` переопределяют `_disable_*`). |
| `.env.example` | изменить | Канон новых `ORCH_*` ключей (opt-in/allowlist/kill-switch) с безопасными дефолтами. |
| `scripts/staging_check.py` | проверить/при необходимости адаптировать | Block C (E2E в SANDBOX) должен остаться рабочим: реальная запись в sandbox под opt-in. `SANDBOX_PROJECT_ID` (стр. 283) — источник идентификатора sandbox. |
| `CLAUDE.md`, `docs/architecture/README.md`, `docs/operations/INFRA.md` | изменить | Документировать инвариант изоляции тест/staging-записи (golden source наравне с кодом). |
| `tests/test_orch117_plane_write_isolation.py` *(имя — кандидат)* | создать | Покрытие FR-1…FR-6, включая обязательный регресс ORCH-114 (TC-01). |
> ⚠️ Список модулей **не** предписывает архитектуру. Точку перехвата (низкий чокпоинт в `plane_sync`
> у `httpx.patch/post` vs обёртка транспорта vs autouse-фикстура) и признак тест-процесса выбирает
> архитектор в ADR. ТЗ фиксирует **требования к поведению**, а не способ реализации.
## 3. Функциональные требования
### FR-1 — Fail-closed блок записи в боевой проект из тест-процесса (BR-1, BR-2)
В **тест-процессе** (pytest/worktree) любой вызов `update_issue_state` / `add_comment` /
`_set_issue_state_direct` с целевым `project_id` **вне** sandbox-allowlist (в частности боевой ORCH
`7a79f0a9-5278-49cd-9007-9a338f238f9c` и любой боевой enduro-проект) **НЕ должен** выполнять
`httpx.patch`/`httpx.post` — запись блокируется. Свойство **fail-closed**: при невозможности
достоверно определить целевой проект → блокировать (NFR-1). Гард читает контекст **в момент вызова**
(NFR-4), не полагается на токен/`os.environ.setdefault`.
### FR-2 — Разрешение только в sandbox при явном аудируемом opt-in (BR-2, BR-3, BR-5)
Запись из тест-процесса допускается ⇔ **одновременно**: (а) включён выделенный интеграционный
opt-in-флаг **и** (б) целевой `project_id` ∈ sandbox-allowlist (по умолчанию — единственный
`8c5a3025-4f9d-4190-b79f-fa06276bb27e`). При выполнении обоих условий примитив выполняет реальный
`httpx`-вызов в SANDBOX. Отсутствие любого условия → блок (default-deny). Запись в боевой проект
запрещена **даже при включённом opt-in** (allowlist sandbox-only).
### FR-3 — Дефолтная тестовая поза fail-closed (BR-4)
При обычном `pytest tests/` (без явного opt-in) autouse-страховка `conftest.py` гарантирует, что
**ни один** тест не пишет в Plane (все 3 примитива заблокированы/застаблены). Тесты sandbox-e2e,
которым нужна реальная запись, **явно** включают opt-in в собственной фикстуре/монкипатче (поверх
autouse), ограничивая реальную запись своим scope — паттерн уже применён для
`_disable_merge_verify`/`_disable_transition_lease`/`_no_telegram` в `conftest.py`.
### FR-4 — Детект тест-процесса vs боевой/staging рантайм (NFR-2, NFR-3)
Гард активен **только** в тест-процессе. Признак тест-процесса (например `PYTEST_CURRENT_TEST`
в env / `pytest` в `sys.modules` / явный конфиг-флаг тест-режима — выбор за ADR) обязан:
- **не** срабатывать в боевом рантайм-процессе оркестратора → боевая запись в Plane = байт-в-байт
как прежде (no-op гарда);
- **не** срабатывать в staging-рантайме (8501) → staging пишет в SANDBOX как прежде (staging —
реальный процесс, не pytest).
### FR-5 — Аудит/наблюдаемость (BR-6)
- Каждая **заблокированная** запись → структурный лог уровня WARNING/ERROR с полями: целевой
`project_id`, `work_item`, операция (`state`/`comment`), причина (`prod-project-in-test` /
`opt-in-disabled` / `ambiguous-target`). Сообщение должно делать инцидент класса ORCH-114
**очевидным**.
- Каждая **разрешённая** sandbox-запись из тест-процесса → audit-строка (INFO) с `project_id` и
операцией.
- (Опц., на усмотрение архитектора) read-only-видимость состояния гарда (флаг/allowlist) — без
обязательного нового эндпоинта.
### FR-6 — Поведение блокировки (NFR-5)
- В **боевом пути** гард **never-raise**: его внутренний сбой/недоступность не роняет конвейер и
не блокирует легитимную боевую запись (для боевого процесса гард в принципе no-op — FR-4).
- В **тест-процессе** срабатывание гарда — **громкое**: запись подавляется и аудируется; допустимо
жёсткое исключение/ассерт-фрэндли поведение, чтобы регресс-тест (TC-01) был детерминированно
красным до фикса и зелёным после. Конкретная семантика (no-op-стаб vs raise) — решение ADR, но
**наблюдаемый контракт**: «0 реальных PATCH/POST в боевой проект из pytest».
### FR-7 — Kill-switch без чёрного хода (NFR-6)
Если вводится kill-switch гарда — он **не должен** при выключении переоткрывать запись в **боевой**
проект из тест-процесса. Допустимое поведение «выключено» = деградация к прежнему (до-ORCH-117), но
без молчаливого разрешения прод-записи из pytest сверх того, что было; запись в SANDBOX из тестов
управляется **только** opt-in-флагом + allowlist (FR-2), а не общим kill-switch.
## 4. Изменения API
**Нет** обязательных. Никаких новых публичных эндпоинтов изоляция не требует. (Опционально архитектор
может добавить read-only-видимость состояния гарда, например блок в `GET /queue` — не обязательно.)
## 5. Изменения схемы БД
**Нет.** Изоляция — рантайм-гард по конфигу/окружению; персистентного состояния не требует. Схема БД
не трогается (NFR-2).
## 6. Требования к новым/изменённым QG checks
**Нет.** Это **не** Quality Gate и **не** под-гейт. `QG_CHECKS` / `check_*` / `STAGE_TRANSITIONS` /
machine-verdict ключи (`verdict:`/`result:`/`deploy_status:`/`staging_status:`/`security_status:`/
`coverage_status:`) — **байт-в-байт не тронуты** (инвариант ORCH-019 NFR-1: срезается/меняется только
не-гейтовое поведение; здесь — изоляция записи). Гард — свойство клиента Plane, не гейт конвейера.
## 7. Совместимость / регресс
- **Боевой рантайм:** гард — no-op (FR-4) → запись в Plane байт-в-байт как до ORCH-117 (NFR-2).
- **Staging-рантайм (8501):** реальный процесс, sandbox-проект по конфигу → пишет в SANDBOX как
прежде (NFR-3). `scripts/staging_check.py` Block C (E2E) должен остаться зелёным.
- **Существующий тест-сьют:** autouse fail-closed-фикстура не должна ломать существующие тесты —
большинство тестов **мокируют** `plane_*`/`add_comment` (например `tests/test_auto_labels_integration.py:58`
`monkeypatch.setattr(stage_engine, "plane_add_comment", MagicMock())`), поэтому реальная запись и
так не происходит; гард лишь делает это **гарантией по умолчанию**. Прежние неработающие
`os.environ.setdefault("ORCH_PLANE_API_TOKEN","test-token")` строки можно не трогать — гард не
зависит от них (NFR-4).
- **Sandbox-e2e:** под явным opt-in + allowlist реальная запись в SANDBOX сохраняется (BR-5).
- **Kill-switch:** при выключении гарда (если введён) — деградация к прежнему поведению, **без**
переоткрытия прод-записи из тестов (FR-7/NFR-6).
- **Обратимость:** дефолты безопасные (fail-closed в тестах); включение реальной записи —
только явным opt-in.
- **Артефакты pipeline:** создаёт/обновляет `docs/work-items/ORCH-117/06-adr/ADR-001-*.md`
(architect, после эскалации), `10-tech-risks.md`; в этом PR — обновление `.env.example`,
`CLAUDE.md`, `docs/architecture/README.md`, `docs/operations/INFRA.md`.

View File

@@ -0,0 +1,145 @@
---
work_item: ORCH-117
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-15
model_used: claude-opus-4-8
escalate: full-cycle
---
# 03 — Критерии приёмки (Acceptance Criteria): ORCH-117 — sandbox-only fail-closed изоляция записи в Plane
Work Item: **ORCH-117** · Repo: **orchestrator** · Стадия: analysis
Формат: каждый критерий имеет **PASS** (что должно быть истинно для приёмки) и **FAIL**
(что считается провалом). Любой машинный/ручной reviewer проверяет их буквально по файлам
репозитория.
---
## AC-1 — Регресс ORCH-114: живой прод-токен + pytest не мутируют боевой проект (ОБЯЗАТЕЛЬНЫЙ)
**Условие:** В тест-процессе с **живым боевым** `ORCH_PLANE_API_TOKEN` в окружении вызывается
`notify_stage_change("ORCH-114", "deploy", "done")` (и/или прямые `update_issue_state`/`add_comment`/
`_set_issue_state_direct`) с целевым боевым проектом `7a79f0a9-5278-49cd-9007-9a338f238f9c`.
- **PASS:** **ноль** реальных `httpx.patch`/`httpx.post` уходит в боевой проект (мок `httpx` не
вызван для prod-URL **или** гард блокирует до сетевого вызова). Существует тест, который **красный
до фикса** (воспроизводит запись инцидента) и **зелёный после**.
- **FAIL:** хоть один PATCH/POST достигает боевого проекта из тест-процесса; либо регресс-тест
отсутствует; либо он зелёный и до фикса (значит ничего не проверяет).
---
## AC-2 — Sandbox-e2e сохранён: запись в SANDBOX под opt-in проходит
**Условие:** В тест-процессе включён явный интеграционный opt-in **и** целевой проект — SANDBOX
`8c5a3025-4f9d-4190-b79f-fa06276bb27e`.
- **PASS:** примитивы записи выполняют реальный `httpx`-вызов в SANDBOX (в тесте — через мок,
подтверждающий, что вызов разрешён и адресован sandbox-URL); `scripts/staging_check.py` Block C
(E2E в SANDBOX) остаётся работоспособным.
- **FAIL:** запись в SANDBOX заблокирована при корректном opt-in; либо sandbox-e2e сломан.
---
## AC-3 — Sandbox-only даже с opt-in: боевой проект запрещён всегда
**Условие:** В тест-процессе включён opt-in, но целевой проект — боевой (`7a79f0a9-…` ORCH или
боевой enduro-проект).
- **PASS:** запись блокируется (allowlist sandbox-only) независимо от opt-in; аудит-лог фиксирует
причину `prod-project-in-test`.
- **FAIL:** включённый opt-in разрешает запись в любой проект, включая боевой.
---
## AC-4 — Default-deny: без opt-in запись из тестов заблокирована (fail-closed)
**Условие:** Обычный `pytest tests/` без явного opt-in; целевой проект — любой (sandbox или боевой).
- **PASS:** все 3 примитива (`update_issue_state`, `add_comment`, `_set_issue_state_direct`) не
делают реальной записи; autouse-фикстура `conftest.py` обеспечивает это по умолчанию во всех
тестах. Неопределённый/неразрешимый целевой проект → блок (NFR-1).
- **FAIL:** без opt-in возможна реальная запись в Plane; либо autouse-страховка отсутствует.
---
## AC-5 — Нулевая регрессия боевого рантайма
**Условие:** Процесс — **не** pytest (боевой рантайм оркестратора).
- **PASS:** гард — no-op; запись в Plane выполняется как до ORCH-117 (тот же URL/headers/payload).
`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict ключи/схема БД — **байт-в-байт не
изменены** (проверяемо diff'ом / структурными тестами).
- **FAIL:** боевая запись подавлена/изменена; либо изменены гейты/схема БД.
---
## AC-6 — Staging-рантайм пишет в SANDBOX
**Условие:** Процесс — staging-рантайм (8501), **не** pytest; сконфигурирован на sandbox-проект.
- **PASS:** детект тест-процесса **не** срабатывает для staging → запись в SANDBOX проходит как
прежде; staging не приравнивается к pytest.
- **FAIL:** staging-запись в SANDBOX заблокирована (ложноположительный детект тест-процесса).
---
## AC-7 — Устойчивость к захвату токена на импорте
**Условие:** `PLANE_HEADERS`/`PROJECT_ID` захвачены на импорте `plane_sync` (стр. 17/57); тест
выполняет запись, не подменяя токен/env постфактум.
- **PASS:** гард срабатывает **на момент вызова** примитива и блокирует прод-запись независимо от
того, какой токен в `PLANE_HEADERS`; защита **не** опирается на `os.environ.setdefault(...)`/
подмену `settings.plane_api_token`.
- **FAIL:** защита зависит от подмены токена/env и потому не срабатывает в проде (как было до фикса).
---
## AC-8 — Аудит/наблюдаемость блокировок и разрешений
**Условие:** Происходит блокировка записи (или разрешённая sandbox-запись).
- **PASS:** на блокировку эмитится структурный WARNING/ERROR с `project_id`, `work_item`, операцией и
причиной; на разрешённую sandbox-запись — audit-INFO. Сообщения делают инцидент видимым.
- **FAIL:** блокировка/разрешение происходят молча (нет логов с требуемыми полями).
---
## AC-9 — Kill-switch без чёрного хода
**Условие:** Если введён kill-switch гарда — он выключен.
- **PASS:** выключение деградирует к прежнему (до-ORCH-117) поведению, но **не** разрешает молча
запись в **боевой** проект из pytest сверх того, что было; реальная sandbox-запись из тестов
управляется только opt-in + allowlist (не общим kill-switch).
- **FAIL:** общий kill-switch служит чёрным ходом, переоткрывающим прод-запись из тест-процесса.
---
## AC-10 — Документация и конфиг обновлены (golden source)
**Условие:** PR закрывает ORCH-117.
- **PASS:** обновлены `.env.example` (новые `ORCH_*` ключи с безопасными дефолтами), `CLAUDE.md`,
`docs/architecture/README.md`, `docs/operations/INFRA.md` — описан инвариант изоляции тест/staging
записи в Plane. ADR выпущен (`06-adr/ADR-001-*.md`).
- **FAIL:** код есть, документация/конфиг не обновлены (по правилу reviewer'а ORCH — finding ≥P1).
---
## AC-11 — Полный регресс зелёный
**Условие:** `pytest tests/ -q` после фикса.
- **PASS:** весь сьют зелёный (автоматическая autouse-страховка не ломает существующие тесты).
- **FAIL:** появились падения/флапы из-за внедрённого гарда/фикстуры.
---
## Сводная матрица AC ↔ FR/BR
| AC | Покрывает |
|----|-----------|
| AC-1 | BR-1 / FR-1 / FR-6 (обязательный регресс ORCH-114) |
| AC-2 | BR-5 / FR-2 |
| AC-3 | BR-2 / FR-2 |
| AC-4 | BR-3 / BR-4 / FR-2 / FR-3 / NFR-1 |
| AC-5 | NFR-2 / FR-4 |
| AC-6 | NFR-3 / FR-4 |
| AC-7 | NFR-4 / FR-1 |
| AC-8 | BR-6 / FR-5 |
| AC-9 | NFR-6 / FR-7 |
| AC-10 | BR-7 |
| AC-11 | NFR-2 (регресс-нейтральность) |

View File

@@ -0,0 +1,113 @@
work_item: ORCH-117
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-15
model_used: claude-opus-4-8
title: "Sandbox-only fail-closed изоляция записи в Plane (регресс ORCH-114)"
framework: pytest
scope: >
Покрывает fail-closed гард записи в Plane: блок прод-записи из тест-процесса даже при живом
боевом токене (обязательный регресс ORCH-114), sandbox-only-разрешение под явным opt-in,
default-deny по умолчанию, отличие тест-процесса от боевого/staging рантайма, перехват на
момент вызова (устойчивость к захвату токена на импорте), аудит. ВНЕ покрытия: реальные сетевые
вызовы к боевому Plane (запрещены самим фиксом) — всё через мок httpx; выбор механизма детекта —
зона ADR (тесты проверяют поведение, не реализацию).
notes: >
TC-01 — ОБЯЗАТЕЛЬНЫЙ регресс инцидента ORCH-114: красный до фикса, зелёный после. Все три
примитива записи (update_issue_state / add_comment / _set_issue_state_direct) проверяются на
блок/разрешение через мок httpx.patch/httpx.post (никаких реальных сетевых вызовов). Полный
регресс tests/ должен оставаться зелёным (autouse fail-closed-фикстура не ломает существующие
тесты, большинство из которых уже мокируют plane_*/add_comment). Боевой ID проекта в тестах —
7a79f0a9-5278-49cd-9007-9a338f238f9c; sandbox — 8c5a3025-4f9d-4190-b79f-fa06276bb27e.
tests:
- id: TC-01
type: integration
description: "РЕГРЕСС ORCH-114: pytest-env + живой прод-токен → notify_stage_change('ORCH-114','deploy','done') на боевой проект НЕ делает ни одного httpx.patch/post (мок httpx не вызван для prod-URL / гард блокирует). Красный до фикса, зелёный после."
module: tests/test_orch117_plane_write_isolation.py
expected: PASS
- id: TC-02
type: unit
description: "update_issue_state в тест-процессе с целевым боевым проектом 7a79f0a9-… → блок (httpx.patch не вызван); аудит-причина prod-project-in-test."
module: tests/test_orch117_plane_write_isolation.py
expected: PASS
- id: TC-03
type: unit
description: "add_comment в тест-процессе с боевым проектом → блок (httpx.post не вызван)."
module: tests/test_orch117_plane_write_isolation.py
expected: PASS
- id: TC-04
type: unit
description: "_set_issue_state_direct в тест-процессе с боевым проектом → блок (httpx.patch не вызван). Покрывает все set_issue_* (Done/In Review/Blocked/…), сводящиеся к этому примитиву."
module: tests/test_orch117_plane_write_isolation.py
expected: PASS
- id: TC-05
type: unit
description: "Default-deny: без явного opt-in запись в тест-процессе блокируется для ЛЮБОГО целевого проекта (в т.ч. sandbox)."
module: tests/test_orch117_plane_write_isolation.py
expected: PASS
- id: TC-06
type: unit
description: "Sandbox-разрешение: opt-in включён + целевой проект SANDBOX 8c5a3025-… → реальный httpx-вызов разрешён и адресован sandbox-URL (мок подтверждает вызов)."
module: tests/test_orch117_plane_write_isolation.py
expected: PASS
- id: TC-07
type: unit
description: "Sandbox-only даже с opt-in: opt-in включён, но целевой проект боевой → блок (allowlist sandbox-only), независимо от opt-in."
module: tests/test_orch117_plane_write_isolation.py
expected: PASS
- id: TC-08
type: unit
description: "Fail-closed при неопределённости: целевой project_id неразрешим/пуст в тест-процессе → блок (NFR-1 'не знаю ⇒ не пишу')."
module: tests/test_orch117_plane_write_isolation.py
expected: PASS
- id: TC-09
type: unit
description: "Устойчивость к захвату на импорте: PLANE_HEADERS содержит реальный токен, env/settings не подменяются постфактум → гард всё равно блокирует прод-запись на момент вызова (не зависит от os.environ.setdefault / подмены plane_api_token)."
module: tests/test_orch117_plane_write_isolation.py
expected: PASS
- id: TC-10
type: unit
description: "Нулевая регрессия боевого рантайма: при имитации НЕ-pytest процесса гард = no-op, httpx.patch/post вызывается с прежним URL/headers/payload (запись в Plane как до ORCH-117)."
module: tests/test_orch117_plane_write_isolation.py
expected: PASS
- id: TC-11
type: unit
description: "Staging != pytest: имитация staging-рантайма (sandbox-проект, не тест-процесс) → запись в SANDBOX проходит (детект тест-процесса не срабатывает ложно)."
module: tests/test_orch117_plane_write_isolation.py
expected: PASS
- id: TC-12
type: unit
description: "Аудит: на блокировку эмитится структурный WARNING/ERROR с project_id/work_item/операцией/причиной (caplog); на разрешённую sandbox-запись — audit-INFO."
module: tests/test_orch117_plane_write_isolation.py
expected: PASS
- id: TC-13
type: integration
description: "Дефолтная autouse-страховка conftest: репрезентативный advance стадии в обычном тесте не делает реальной записи в боевой Plane (страховка активна по умолчанию для всего сьюта)."
module: tests/test_orch117_plane_write_isolation.py
expected: PASS
- id: TC-14
type: unit
description: "Kill-switch без чёрного хода: при выключенном kill-switch гарда запись в БОЕВОЙ проект из pytest всё равно не разрешается молча (реальная sandbox-запись управляется только opt-in+allowlist)."
module: tests/test_orch117_plane_write_isolation.py
expected: PASS
- id: TC-15
type: integration
description: "Полный регресс tests/ зелёный — внедрённая autouse fail-closed-фикстура не ломает существующие тесты (smoke: pytest tests/ -q)."
module: tests/
expected: PASS

View File

@@ -0,0 +1,251 @@
---
work_item: ORCH-117
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-15
model_used: claude-opus-4-8
---
# ADR-001: Sandbox-only fail-closed гард записи в Plane из тест-процесса
Work Item: **ORCH-117** — test/staging Plane writes must be sandbox-only and never mutate prod
Стадия: **architecture**
Сквозная регистрация: **`docs/architecture/adr/adr-0046-sandbox-only-plane-write-guard.md`**
(кросс-каттинговое: вводит новый рантайм-leaf поверх **общего** Plane-клиента `plane_sync`,
используемого ВСЕМИ проектами общего инстанса, + новый тест-харнесс-инвариант в `conftest.py`).
## Статус
Proposed
## Контекст
**Инцидент (установленный факт, ORCH-114).** Тестовый/worktree-процесс выполнил РЕАЛЬНУЮ запись в
Plane против **боевого** проекта ORCH: `PATCH …/issues/dd57ad23… state=<Done>` + комментарий
«Stage: deploy → done». То есть `notify_stage_change("ORCH-114","deploy","done")`, запущенный из
pytest, смутировал боевую задачу — «ложный Done» на боевой доске (источник индикации, слой B
ORCH-066).
**Корень (сверено по коду).** Тест/staging-процессы имеют доступ к живому боевому Plane-токену и
**ничто не принуждает** их писать только в sandbox:
- `src/plane_sync.py:17` `PLANE_HEADERS = {"X-API-Key": settings.plane_api_token}` и `:57`
`PROJECT_ID = settings.plane_project_id or "7a79f0a9-…"` (боевой ORCH) **захватываются на импорте
модуля** → подмена env/токена постфактум бесполезна (NFR-4).
- Тестовые `os.environ.setdefault("ORCH_PLANE_API_TOKEN","test-token")`**no-op** в контейнере с
уже установленной боевой переменной → тесты наследуют **реальный** токен.
- Все мутирующие записи сходятся в **три** примитива: `update_issue_state` (`httpx.patch`, стр. 861),
`add_comment` (`httpx.post`, стр. 885), `_set_issue_state_direct` (`httpx.patch`, стр. 1047) — и
**ни один** не проверяет тест-контекст и легитимность целевого проекта.
**Прецедент в репозитории.** `tests/conftest.py::_no_telegram` — autouse-фикстура, глушащая
`send_telegram` во ВСЕХ тестах, ровно потому что «pytest на проде слал РЕАЛЬНЫЕ Telegram-сообщения
Славе». Симметричной защиты для **Plane-записи** не было — это пробел того же класса, реализованный
ORCH-114. Идентификатор sandbox уже зафиксирован: `scripts/staging_check.py:283`
`SANDBOX_PROJECT_ID = "8c5a3025-4f9d-4190-b79f-fa06276bb27e"`.
**Почему «как есть» не годится.** Устойчивость стоит на стороне тестов (надеяться, что каждый тест
замокает Plane), а не на стороне системы. Любой новый/будущий путь записи, забывший мок, снова
смутирует боевую доску. Требуется **fail-closed**-инвариант: запись в боевой проект из
тест/worktree-процесса должна быть **физически невозможна**, независимо от токена в окружении.
## Решение
### Сводка
Вводим **fail-closed гард записи в Plane на низком чокпоинте** — на входе трёх примитивов записи
`plane_sync`, **в момент вызова** (не на импорте). Чистую логику держит **новый leaf
`src/plane_write_guard.py`** (never-raise, по образцу `serial_gate`/`cancel`/`deploy_status_guard`):
`decide(project_id, op, work_item_id) -> (ALLOW | BLOCK, reason)`. Гард активен **только в
тест-процессе** (детект `pytest`-в-процессе) — для боевого и staging рантайма он **no-op**
(byte-for-byte, NFR-2/NFR-3). В тест-процессе запись разрешена **исключительно** при
одновременном (а) включённом opt-in-флаге **и** (б) целевом проекте ∈ sandbox-allowlist; иначе —
блок (default-deny). Дополнительно — **независимый conftest-floor** (autouse-фикстура), который
форсит безопасные дефолты во ВСЕХ тестах (BR-4). Изменение — bugfix-изоляция: **не** Quality Gate и
**не** стадия; `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схема БД — байт-в-байт не
тронуты.
### D1 — Точка перехвата: низкий чокпоинт в 3 примитивах `plane_sync`, на момент вызова
Гард врезается в `update_issue_state` / `add_comment` / `_set_issue_state_direct` **сразу после**
`_resolve_project_id(...)` (локальное чтение БД) и **до** любого сетевого шага (`stage_to_state`,
`find_issue_id`, `httpx.patch/post`):
```python
project_id = _resolve_project_id(work_item_id, project_id)
ok, reason = plane_write_guard.decide(project_id, "state", work_item_id) # op ∈ {"state","comment"}
if not ok:
plane_write_guard.audit_block(project_id, "state", work_item_id, reason)
return # никакой сети — ни GET, ни PATCH/POST
# ... обычный путь (ALLOW)
```
**Почему этот чокпоинт (а не обёртка `httpx` и не только autouse-фикстура):**
- **Низкий и полный.** Все `set_issue_*`/`notify_*` сводятся к этим трём примитивам (верифицировано
BRD §6) → один гард ловит любой путь, включая будущие (тот же довод, что у `deploy_status_guard` на
входе сеттеров).
- **На момент вызова → иммунитет к захвату на импорте (NFR-4/AC-7).** `PLANE_HEADERS`/`PROJECT_ID`
захвачены литералами на импорте; гард читает контекст (тест-процесс + резолвенный `project_id`) при
каждом вызове, поэтому защита не зависит от того, какой токен в `PLANE_HEADERS`, и не опирается на
неработающий `os.environ.setdefault`.
- **До сети.** Размещение до `find_issue_id`/`stage_to_state` исключает даже «безобидный» GET в
боевой workspace из тестов.
- Обёртка над транспортом `httpx` отвергнута (см. «Альтернативы») — она ниже уровня, на котором
известны `project_id`/`work_item`/`op` для аудита, и хрупка к смене HTTP-клиента.
### D2 — Детект тест-процесса: `pytest`-в-процессе (call-time)
```python
def _in_test_process() -> bool:
import sys, os
return ("pytest" in sys.modules) or bool(os.environ.get("PYTEST_CURRENT_TEST"))
```
- **`"pytest" in sys.modules`** истинно на всём прогоне pytest (collection + выполнение) в **этом**
процессе. Боевой и staging рантаймы запускаются `uvicorn src.main:app` и **не импортируют** pytest
в свой процесс → детект там **никогда** не срабатывает (NFR-2/NFR-3, AC-5/AC-6). pytest установлен в
образе (для merge-gate/coverage re-test), но запускается **отдельным субпроцессом** `python -m
pytest` — он-то и есть worktree-тест-процесс из инцидента, и его гард **должен** ловить (✓).
- **`PYTEST_CURRENT_TEST`** — вторичный сигнал (выставляется pytest на время тела теста), добавлен как
дешёвый подтверждающий признак; основной — `sys.modules`.
- Оба читаются **в момент вызова** (NFR-4). Признак консервативный: ложноположительное срабатывание в
боевом рантайме требует, чтобы кто-то импортировал `pytest` в процесс uvicorn — чего штатный
entrypoint не делает (зафиксировано как допущение, см. `10-tech-risks.md` TR-1).
### D3 — Решение `decide`: default-deny, sandbox-allowlist, опт-ин
`decide(project_id, op, work_item_id) -> (bool ok, str reason)` — чистая, never-raise:
| Шаг | Условие | Исход | reason |
|-----|---------|-------|--------|
| 1 | `not _in_test_process()` | **ALLOW** | `live-runtime` (прод/staging — no-op, NFR-2/3) |
| 2 | `project_id` пуст/None/нерезолвим | **BLOCK** | `ambiguous-target` (NFR-1: «не знаю → не пишу») |
| 3 | `not settings.plane_test_write_enabled` | **BLOCK** | `opt-in-disabled` |
| 4 | `project_id ∉ sandbox_allowlist` | **BLOCK** | `prod-project-in-test` |
| 5 | иначе | **ALLOW** | `sandbox-opt-in` (audit INFO) |
- **Allowlist sandbox-only (BR-2, AC-3).** Шаг 4 запрещает боевой ORCH (`7a79f0a9-…`) и любой боевой
enduro-проект **даже при включённом opt-in** — opt-in лишь снимает шаг 3, allowlist остаётся
жёстким полом.
- **Fail-closed по неопределённости (NFR-1, AC-4).** Нерезолвимый целевой проект → блок (шаг 2).
- **never-raise (NFR-5).** Любое внутреннее исключение `decide` интерпретируется вызывающим примитивом
по контексту: в боевом пути это уже ALLOW (шаг 1 не достигнут — `_in_test_process` False); в
тест-пути исключение трактуется как **BLOCK** (громко, fail-closed) — дефект всплывает, а не
маскируется. (Реализация: `decide` ловит свои ошибки и в тест-контексте возвращает
`(False, "guard-error")`, в live-контексте — `(True, …)`.)
### D4 — Kill-switch: его нет (умышленно, NFR-6/FR-7) — реверс через opt-in
**Сознательное расхождение с конвенцией «у каждого leaf есть `*_enabled` kill-switch».** Гард,
делающий прод-запись из pytest *физически невозможной*, **не должен** поставляться с конфигом,
который её переоткрывает — это и есть «чёрный ход», запрещённый NFR-6. Прямой прецедент в репозитории:
`_no_telegram` тоже **не имеет** флага «разрешить реальный Telegram в тестах» — это безусловный
страховочный пол.
- **Единственный реверсивный регулятор — opt-in** `plane_test_write_enabled` (default `False` =
безопасно) + allowlist `plane_test_sandbox_projects` (default = единственный SANDBOX id). Он
управляет **только sandbox-записью**; его off-состояние — безопасный дефолт, on-состояние —
sandbox-bound. «Выключить защиту» ≠ «разрешить прод из pytest»: такого перехода в дизайне **нет**.
- Рантайм-leaf **инертен в боевом рантайме** по построению (`_in_test_process()` False) → отдельный
«выключатель» для безопасности прода не нужен; leaf never-raises → не может уронить боевой путь.
- **Норматив на будущее (анти-дрейф):** не добавлять «общий kill-switch гарда», обнуляющий
prod-блок в тест-процессе — это реинтродуцирует дефект ORCH-114. Зафиксировано в `10-tech-risks.md`
(TR-4) и в сквозном adr-0046.
### D5 — Conftest-floor: независимый default-deny во всех тестах (BR-4, FR-3)
Autouse-фикстура `tests/conftest.py::_plane_sandbox_only` (по образцу `_reset_webhook_secrets` /
`_disable_merge_verify`) форсит безопасные дефолты для **каждого** теста через `monkeypatch`,
**перекрывая** любую боевую переменную, унаследованную из окружения контейнера:
```python
@pytest.fixture(autouse=True)
def _plane_sandbox_only(monkeypatch):
from src import config as _cfg
monkeypatch.setattr(_cfg.settings, "plane_test_write_enabled", False, raising=False)
monkeypatch.setattr(_cfg.settings, "plane_test_sandbox_projects",
"8c5a3025-4f9d-4190-b79f-fa06276bb27e", raising=False)
yield
```
- С opt-in `False` гард блокирует **все** записи в тестах (и sandbox, и прод) → AC-4 default-deny.
- **Sandbox-e2e переопределяет** в собственной фикстуре *после* autouse (точно как
`test_merge_verify`/`test_orch114_*` ре-энейблят свои флаги): `plane_test_write_enabled=True`
(+ allowlist уже содержит sandbox) → запись в SANDBOX проходит (AC-2), в прод — по-прежнему блок
(allowlist, AC-3).
- Floor **независим от рантайм-логики**: даже если рантайм-leaf по ошибке вернёт ALLOW, инвариант
«opt-in off» делает прод-запись из обычного pytest невозможной. Два слоя, оба sandbox-bound →
ни один не способен разрешить прод-запись из pytest (двойной NFR-6).
### D6 — Конфиг-ключи
В `src/config.py` (дефолты = безопасные):
| Ключ | Env | Дефолт | Назначение |
|------|-----|--------|------------|
| `plane_test_write_enabled` | `ORCH_PLANE_TEST_WRITE_ENABLED` | `False` | opt-in реальной записи из тест-процесса |
| `plane_test_sandbox_projects` | `ORCH_PLANE_TEST_SANDBOX_PROJECTS` | `"8c5a3025-4f9d-4190-b79f-fa06276bb27e"` | CSV allowlist sandbox-проектов |
- **НЕ `*_repos`-scope.** В отличие от гейт-leaf'ов (`serial_gate`/`coverage_gate` *действуют* на
репо), этот гард защищает запись в **любой** боевой проект общего workspace (включая боевой enduro,
BR-2). Регуляторов scope по репо нет; единственные гейты — `_in_test_process()` (рантайм) + opt-in
(как у observer-leaf `lessons`, который тоже не скоупится по репо). Зафиксировано в adr-0046.
- `.env.example` дополняется обоими ключами с безопасными дефолтами (deliverable developer'а).
### D7 — Аудит/наблюдаемость (BR-6, FR-5, AC-8)
- **Блок** → структурный `logger.warning`/`error` с полями `project_id`, `work_item`, `op`
(`state`/`comment`), `reason` (`prod-project-in-test`/`opt-in-disabled`/`ambiguous-target`/
`guard-error`). Формулировка делает инцидент класса ORCH-114 **очевидным** (не молчаливым).
- **Разрешённая sandbox-запись** → audit `logger.info` с `project_id` и `op`.
- Новый эндпоинт **не вводится** (TRZ §4): состояние гарда (флаг/allowlist) при желании дорисовывается
read-only в `GET /queue`**необязательно**, оставлено на усмотрение developer'а.
## Альтернативы
- **Только autouse-фикстура в `conftest.py` (без рантайм-leaf)** — отвергнуто: не делает прод-запись
*физически невозможной* (AC-7) — любой путь, обошедший мок/фикстуру (прямой импорт `plane_sync` в
скрипте под pytest, sandbox-e2e с опечаткой проекта), снова смутирует прод. Нужен рантайм-floor на
момент вызова. Фикстура остаётся как **дополнительный** независимый слой (D5), не единственный.
- **Обёртка/монки над `httpx`-транспортом** — отвергнуто: уровень ниже, чем известны
`project_id`/`work_item`/`op` (хуже аудит); хрупко к смене HTTP-клиента; ловит и легитимные GET.
Низкий чокпоинт в примитивах точнее и стабильнее.
- **Подмена `settings.plane_api_token`/env на тестовый токен** — отвергнуто прямо BRD/NFR-4:
`PLANE_HEADERS`/`PROJECT_ID` захвачены на импорте → подмена постфактум бесполезна; `setdefault`
no-op в проде. Не лечит корень.
- **Гонять тесты в sandbox-проекте по дефолту (сменить дефолтный `PROJECT_ID`)** — отвергнуто: не
fail-closed (живой токен + забытый мок всё равно может адресовать прод явным `project_id`); не
отличает прод от sandbox по *намерению*.
- **Конвенциональный `plane_write_guard_enabled` kill-switch** — отвергнуто (D4): его off-состояние
было бы «чёрным ходом» к прод-записи из pytest (NFR-6). Реверс обеспечивает opt-in.
## Последствия
- **+** Прод-запись в Plane из любого pytest/worktree-процесса **физически невозможна** независимо от
токена (AC-1/AC-7); инцидент класса ORCH-114 закрыт у источника и стал **видимым** (аудит).
- **+** Боевой и staging рантаймы — **байт-в-байт** (no-op гарда, `_in_test_process()` False);
`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/схема БД не тронуты (NFR-2/NFR-3, AC-5/AC-6).
- **+** Два независимых sandbox-bound слоя (рантайм-leaf + conftest-floor) → нет одиночной точки, чьё
выключение переоткрывает прод (NFR-6).
- **+** Sandbox-e2e сохранён (opt-in + allowlist, AC-2); `scripts/staging_check.py` Block C
работоспособен.
- **** Детект тест-процесса завязан на «pytest-в-процессе» → теоретический ложноположительный риск,
если кто-то импортирует `pytest` в боевой uvicorn-процесс (не делает штатный entrypoint). Митигейшн:
TR-1, консервативный признак, аудит-видимость; в худшем случае под opt-in остаётся sandbox-запись.
- **** Намеренный отказ от kill-switch расходится с привычной конвенцией → требует явной фиксации,
чтобы будущий агент не «добавил выключатель» (D4-норматив, TR-4, adr-0046).
- **Откат:** удалить врезку гарда из 3 примитивов + autouse-фикстуру + 2 конфиг-ключа → поведение
байт-в-байт до ORCH-117 (дефект возвращается). Частичный «мягкий» откат (oct-in `True` глобально) —
**запрещён** как небезопасный (вернёт прод-риск только при условии allowlist; всё равно
sandbox-bound).
## Ссылки
- BRD: `docs/work-items/ORCH-117/01-brd.md`
- TRZ: `docs/work-items/ORCH-117/02-trz.md`
- Acceptance: `docs/work-items/ORCH-117/03-acceptance-criteria.md`
- Tech-risks: `docs/work-items/ORCH-117/10-tech-risks.md`
- Сквозной ADR: `docs/architecture/adr/adr-0046-sandbox-only-plane-write-guard.md`
- Сверено по коду: `src/plane_sync.py:17,57,846-889,1038-1051`, `tests/conftest.py` (`_no_telegram`),
`scripts/staging_check.py:283`, `src/deploy_status_guard.py` (образец leaf), `src/config.py`
- Прецедент-инвариант: `_no_telegram` (autouse safety-floor), `docs/_standards/TRACEABILITY.md`

View File

@@ -0,0 +1,41 @@
---
work_item: ORCH-117
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-15
model_used: claude-opus-4-8
---
# 10 — Технические риски: ORCH-117 — sandbox-only fail-closed изоляция записи в Plane
Work Item: **ORCH-117** · Repo: **orchestrator** · Стадия: architecture
> Информационный (гейтом не парсится). Риски реализации решения ADR-001 и их митигейшн.
## Реестр рисков
| ID | Риск | Вер. | Влия. | Митигейшн |
|----|------|------|-------|-----------|
| TR-1 | **Ложноположительный детект тест-процесса** в боевом/staging рантайме (`pytest` каким-то образом импортирован в процесс uvicorn) → блокировка легитимной боевой/sandbox записи (молчаливая потеря Plane-индикации, слой B ORCH-066). | Низ. | Сред. | Штатный entrypoint `uvicorn src.main:app` **не** импортирует `pytest`; merge-gate/coverage гоняют pytest **отдельным субпроцессом** (его блок легитимен). Признак консервативный (`sys.modules`+`PYTEST_CURRENT_TEST`), читается на момент вызова. Аудит-WARNING делает любой блок видимым (BR-6) → ложный блок в проде немедленно заметен, а не молчалив. Зафиксированное допущение: «прод-процесс не импортирует pytest». |
| TR-2 | **Ложноотрицательный детект** (worktree-тест-процесс не распознан как pytest) → дефект ORCH-114 остаётся. | Низ. | Выс. | Инцидентный путь (worktree `python -m pytest`) **гарантированно** имеет `pytest` в `sys.modules`. Двойной слой: даже при сбое рантайм-leaf conftest-floor (D5) держит default-deny. Обязательный регресс-тест AC-1/TC-01 (красный до фикса) доказывает покрытие именно инцидентного пути. |
| TR-3 | **Захват на импорте** (`PLANE_HEADERS`/`PROJECT_ID`, стр. 17/57): размещение гарда не там → ложное чувство защиты (NFR-4). | Низ. | Выс. | Архитектурно жёстко: гард — на момент **вызова** примитива, до сети, не зависит от токена/`os.environ.setdefault` (ADR-001 D1/D2). AC-7 проверяет это буквально. |
| TR-4 | **Kill-switch как чёрный ход:** будущий агент «добавляет общий выключатель гарда», который в off-состоянии переоткрывает прод-запись из pytest (NFR-6). | Сред. | Выс. | Дизайн **умышленно без** prod-блок kill-switch (ADR-001 D4): реверс — только sandbox-bound opt-in. Норматив зафиксирован в ADR-001, adr-0046 и `10-tech-risks` как анти-дрейф; прецедент `_no_telegram` (тоже без «разрешить» флага). Reviewer ловит реинтродукцию выключателя как finding ≥P1. |
| TR-5 | **Регрессия существующего сьюта:** autouse-фикстура `_plane_sandbox_only` ломает тесты, которые ждали реальную/иную запись или ассертили на мок-вызов. | Низ. | Сред. | Большинство тестов уже **мокируют** `plane_*`/`add_comment` (TRZ §7) → реальной записи и так нет; гард лишь делает это гарантией по умолчанию. Фикстура форсит лишь безопасные дефолты (opt-in off), не подменяет сами примитивы. AC-11 (полный регресс зелёный) — обязательный гейт. |
| TR-6 | **Sandbox-e2e ложно блокируется** (opt-in не доезжает / порядок фикстур). | Низ. | Сред. | Прецедент уже отработан в репозитории: `test_merge_verify`/`test_orch114_*` ре-энейблят свои флаги **после** autouse. AC-2 проверяет реальную sandbox-запись под opt-in; `staging_check.py` Block C — smoke. |
| TR-7 | **Утечка GET до гарда:** даже без PATCH/POST примитив мог бы сходить в боевой workspace `find_issue_id`/`stage_to_state`. | Низ. | Низ. | Гард размещён **до** любого сетевого шага (сразу после локального `_resolve_project_id`) — ни GET, ни мутации (ADR-001 D1). |
| TR-8 | **Кросс-каттинг с ORCH-066/094/061:** гард меняет поведение общего `plane_sync`, потенциально задевая deploy-status guard (ORCH-094), staging-tolerance (ORCH-061), статусную модель (ORCH-066). | Низ. | Сред. | Гард — no-op в боевом/staging рантайме (`_in_test_process()` False) → ORCH-094/061/066 в проде/стейджинге **не затронуты**. В тестах те фичи и так под своими дефолтами/моками. Маркер-инвариант не ломается (правка примитивов аддитивна, не трогает машинные ключи). |
## Сводный вывод
Доминирующий класс — **корректность детекта тест-процесса** (TR-1/TR-2) и **анти-дрейф kill-switch**
(TR-4). Все три закрываются архитектурно: консервативный признак «pytest-в-процессе» на момент
вызова + двойной независимый sandbox-bound слой (рантайм-leaf + conftest-floor) + обязательный
регресс-тест инцидентного пути (AC-1). Остаточный риск для прод-конвейера (self-hosting) — **низкий**:
гард инертен в боевом и staging рантайме по построению и never-raises, поэтому не способен ни уронить
конвейер, ни заблокировать легитимную боевую запись; худший реалистичный исход (ложный блок в проде)
требует несуществующего в штатном entrypoint импорта pytest и был бы немедленно виден через аудит.
**Эскалация не требуется:** решение аддитивно, в границах принципов (Docker/SQLite/leaf-pattern/
never-raise), не трогает `STAGE_TRANSITIONS`/`QG_CHECKS`/схему БД, не вводит новую стадию/QG/компонент
инфраструктуры. Лейбл `arch:major-change` не ставится; возврат в анализ не нужен.

View File

@@ -0,0 +1,107 @@
---
verdict: APPROVED
work_item: ORCH-117
stage: review
author_agent: reviewer
status: approved
created_at: 2026-06-15
model_used: claude-opus-4-8
type: review
work_item_id: ORCH-117
version: 1
---
# Review ORCH-117 — sandbox-only fail-closed изоляция записи в Plane
## Summary
Багфикс-трек (bug → escalate full-cycle) закрывает корневой класс инцидента **ORCH-114**: тест/
worktree-процесс выполнял РЕАЛЬНУЮ запись (`PATCH …/issues/… state=<Done>` + комментарий) против
**боевого** Plane-проекта, наследуя живой боевой токен. Реализован чистый never-raise leaf
`src/plane_write_guard.py` (`decide()` + `audit_*` + `snapshot`), врезанный через тонкий хелпер
`_guard_allows_write` в **3 (все) примитива записи** `plane_sync` (`update_issue_state`/`add_comment`/
`_set_issue_state_direct`) на момент вызова — сразу после `_resolve_project_id` и до любого сетевого
шага. Второй sandbox-bound слой — autouse-floor `tests/conftest.py::_plane_sandbox_only`.
Реализация **точно** соответствует ADR-001 (D1D7) и сквозному adr-0046. Все четыре оси проверки
пройдены, P0/P1-findings нет.
**Проведённая верификация (фактический прогон):**
- `pytest tests/test_orch117_plane_write_isolation.py`**16 passed** (TC-01…TC-14).
- **Обязательный регресс ORCH-019 BR-4 подтверждён вручную:** откатил врезку `plane_sync.py` на
версию `origin/main`**TC-01 КРАСНЫЙ** (`httpx.patch` уходит на боевой проект
`7a79f0a9-…` с `LIVE-PROD-TOKEN` — воспроизводит инцидент); с фиксом — **ЗЕЛЁНЫЙ**. Тест-фиксатор
дефекта содержателен.
- `pytest tests/ -q`**2068 passed** (AC-11 — нулевая регрессия, включая `test_system_docs.py`).
- `grep httpx.(patch|post|put|delete)` по `plane_sync.py` → ровно 3 write-call-site, **все**
под гардом; необёрнутых примитивов записи нет.
- `git diff --name-only src/` → изменены **только** `config.py`/`plane_sync.py`/`plane_write_guard.py`;
`stages.py`/`qg/`/`db.py`/`stage_engine.py`**не тронуты**.
## Findings
### P0 — Blocker
- Нет.
### P1 — Must fix
- Нет.
### P2 — Should fix
- Нет.
### P3 — Nice-to-have (не блокирует)
- [ ] Четыре существующих теста (`test_plane_author.py`, `test_plane_status_model.py`,
`test_plane_sync_labels.py`, `test_stage_visibility.py`) обходят гард монкипатчем
`decide → (True, "test-bypass")`. Это легитимно (per-test autouse, авто-revert; `httpx` в этих
тестах замокан → реальной записи быть не может; сам гард покрыт отдельным сьютом; паттерн —
аналог `_disable_merge_verify`). Чуть более хирургичной альтернативой был бы opt-in + добавление
целевого проекта в allowlist, но тесты проверяют маршрутизацию именно в НЕ-sandbox проекты, так
что полный bypass оправдан и явно задокументирован в каждом docstring. Менять не требуется.
## Ось 1 — Соответствие ТЗ (02-trz / 03-acceptance-criteria)
- **FR-1** (fail-closed блок прод-записи из теста) — ✅ TC-01/02/03/04/09.
- **FR-2** (разрешено только sandbox + opt-in) — ✅ TC-06/07; `decide` шаги 35 1:1 с таблицей ADR.
- **FR-3** (дефолтная поза fail-closed через conftest-floor) — ✅ TC-05/13.
- **FR-4** (детект тест-процесса; no-op в боевом/staging) — ✅ TC-10/11; `_in_test_process` =
`"pytest" in sys.modules` / `PYTEST_CURRENT_TEST`, call-time.
- **FR-5** (аудит блок ERROR / allow INFO с полями) — ✅ TC-12.
- **FR-6** (never-raise в боевом пути; громко в тесте) — ✅ live-path возвращает ALLOW ДО try-блока;
in-test исключение → fail-CLOSED `guard-error`.
- **FR-7** (kill-switch без чёрного хода) — ✅ TC-14 (ассерт отсутствия `plane_write_guard_enabled`).
- **AC-1…AC-11** — все выполнены; AC-1 (обязательный регресс) и AC-11 (полный регресс) подтверждены
фактическим прогоном (см. Summary).
## Ось 2 — Соответствие ADR (06-adr/ADR-001 + adr-0046)
- D1 чокпоинт (после `_resolve_project_id`, до сети, 3 примитива) — ✅ сверено по diff.
- D2 детект тест-процесса — ✅. D3 `decide` default-deny/sandbox-allowlist/opt-in — ✅ 1:1 с таблицей.
- D4 **умышленно без kill-switch прод-блока** — ✅ соблюдено (важный анти-дрейф: добавление общего
выключателя реинтродуцировало бы дефект ORCH-114; reviewer ловил бы как ≥P1 — здесь его нет).
- D5 conftest-floor — ✅. D6 конфиг-ключи (`plane_test_write_enabled=False`,
`plane_test_sandbox_projects=8c5a3025-…`) — ✅. D7 аудит — ✅.
- **Инварианты не сломаны:** `STAGE_TRANSITIONS`/реестр `QG_CHECKS`/семантика и имена `check_*`/
machine-verdict-ключи/схема БД — байт-в-байт (диф трогает только 3 src-файла; гейтовые модули
не изменены). Трассировка (ORCH-078): врезка в 3 примитива не ломает чужих маркированных
инвариантов.
## Ось 3 — Качество кода
- never-raise дисциплина корректна: внешний (боевой) путь физически не может упасть из-за гарда
(return до try); внутренний тест-путь fail-CLOSED.
- Полные docstrings на всех публичных функциях; стабильные reason-слаги вынесены константами и
проверяются тестами.
- **Багфикс-регресс (ORCH-019 BR-4):** TC-01 — фиксатор дефекта, красный до фикса / зелёный после
(проверено вручную). Требование выполнено.
- Покрытие всех 3 точек записи; необёрнутых write-примитивов не осталось.
## Документация
Обновлена полностью (golden source наравне с кодом), AC-10 выполнен:
- `CLAUDE.md` — новый раздел «Sandbox-only fail-closed изоляция записи в Plane (ORCH-117)». ✅
- `docs/architecture/README.md` — новый компонент «Plane write guard». ✅
- `docs/operations/INFRA.md` — таблица env (`ORCH_PLANE_TEST_WRITE_ENABLED`/
`ORCH_PLANE_TEST_SANDBOX_PROJECTS`) + раздел «что изолировано». ✅
- `.env.example`оба ключа с безопасными дефолтами. ✅
- `CHANGELOG.md` — запись `[Unreleased]`. ✅
- ADR: `06-adr/ADR-001-sandbox-only-plane-write-guard.md` + сквозной
`docs/architecture/adr/adr-0046-sandbox-only-plane-write-guard.md`. ✅
- **Обзорные доки (ORCH-079) / витрина `docs/overview/` (ORCH-011):** обновлять не требуется —
ORCH-117 это защитный тест-изоляционный гард, а не ранее задокументированное «Известное
ограничение» README и не изменение функциональности конвейера/стадий/гейтов/агентов/интеграций,
показываемой в витрине; `test_system_docs.py` зелёный в полном прогоне.

View File

@@ -0,0 +1,69 @@
---
result: PASS
work_item: ORCH-117
stage: testing
author_agent: tester
status: pass
created_at: 2026-06-15
model_used: claude-opus-4-8
type: test-report
work_item_id: ORCH-117
---
# Test Report — ORCH-117 — sandbox-only fail-closed изоляция записи в Plane
## Окружение
- 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-117-bug-test-staging-plane-writes-`
- Ветка: `feature/ORCH-117-bug-test-staging-plane-writes-`
- Дата: 2026-06-15
## Smoke API (read-only)
- `GET /health``{"status":"ok","service":"orchestrator"}`
- `GET /status` → 200, ORCH-117 (task 104) на стадии `testing`, `agent_running: null`
- `GET /queue` → 200; блок `serial_gate` присутствует ✅; блок `auto_labels` присутствует ✅
(регресс смока ORCH-088 не обнаружен).
## Результаты — покрытие test-plan (04-test-plan.yaml)
| TC ID | Описание | Тест | Результат |
|-------|----------|------|-----------|
| TC-01 | РЕГРЕСС ORCH-114: pytest-env + живой прод-токен → `notify_stage_change('ORCH-114','deploy','done')` на боевой проект делает 0 httpx.patch/post | `test_tc01_notify_stage_change_prod_makes_zero_writes` | PASS |
| TC-02 | `update_issue_state` в тест-процессе с боевым проектом → блок; аудит `prod-project-in-test` | `test_tc02_update_issue_state_prod_blocked` | PASS |
| TC-03 | `add_comment` в тест-процессе с боевым проектом → блок (httpx.post не вызван) | `test_tc03_add_comment_prod_blocked` | PASS |
| TC-04 | `_set_issue_state_direct` + `set_issue_done` в тест-процессе с боевым проектом → блок | `test_tc04_set_issue_state_direct_prod_blocked`, `test_tc04_set_issue_done_prod_blocked` | PASS |
| TC-05 | Default-deny: без opt-in запись блокируется для ЛЮБОГО проекта (вкл. sandbox) | `test_tc05_default_deny_blocks_sandbox_and_prod` | PASS |
| TC-06 | Sandbox-разрешение: opt-in + SANDBOX `8c5a3025-…` → реальный httpx-вызов разрешён | `test_tc06_sandbox_optin_allows_write` | PASS |
| TC-07 | Sandbox-only даже с opt-in: opt-in включён, боевой проект → блок | `test_tc07_optin_still_blocks_prod` | PASS |
| TC-08 | Fail-closed при неопределённости: пустой/неразрешимый project_id → блок | `test_tc08_ambiguous_target_blocked` | PASS |
| TC-09 | Устойчивость к захвату токена на импорте: гард блокирует на момент вызова | `test_tc09_blocks_regardless_of_captured_token` | PASS |
| TC-10 | Нулевая регрессия боевого рантайма: НЕ-pytest → гард no-op, реальный httpx-вызов | `test_tc10_live_runtime_is_noop` | PASS |
| TC-11 | Staging != pytest: staging-рантайм (sandbox) → запись проходит | `test_tc11_staging_writes_sandbox` | PASS |
| TC-12 | Аудит: блок → структурный WARNING/ERROR с полями; sandbox-allow → audit-INFO | `test_tc12_block_audited_loudly`, `test_tc12_sandbox_allow_audited_info` | PASS |
| TC-13 | Дефолтная autouse-страховка conftest: обычный тест не пишет в боевой Plane | `test_tc13_conftest_floor_default_deny` | PASS |
| TC-14 | Kill-switch без чёрного хода: прод-запись из pytest не разрешается молча | `test_tc14_no_killswitch_backdoor` | PASS |
| TC-15 | Полный регресс `tests/ -q` зелёный (autouse-фикстура не ломает существующие тесты) | `pytest tests/` | PASS |
Сопоставление с `03-acceptance-criteria.md`: AC-1↔TC-01, AC-2↔TC-06, AC-3↔TC-07,
AC-4↔TC-05/08, AC-5↔TC-10, AC-6↔TC-11, AC-7↔TC-09, AC-8↔TC-12, AC-9↔TC-14,
AC-10 (docs/config — проверено reviewer'ом, APPROVED), AC-11↔TC-15. Все AC покрыты.
## Вывод pytest
Целевой файл:
```
tests/test_orch117_plane_write_isolation.py ... 16 passed, 1 warning in 0.40s
(TC-01…TC-14; TC-04 и TC-12 — по два теста)
```
Полный регресс:
```
2068 passed, 1 warning in 88.02s (0:01:28)
```
(единственный warning — PydanticDeprecatedSince20 в `src/config.py:8`, не связан с ORCH-117.)
## Итог
**PASS** — все 15 TC выполнены и сопоставлены с критериями приёмки; целевой сьют (16 тестов) и
полный регресс (2068 passed) зелёные; smoke API (`/health`, `/status`, `/queue` с блоками
`serial_gate`/`auto_labels`) — OK. Задача готова к переходу на `deploy-staging`.

View File

@@ -0,0 +1,12 @@
---
deploy_status: SUCCESS
work_item: ORCH-117
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,34 @@
---
staging_status: SUCCESS
work_item: ORCH-117
stage: deploy-staging
author_agent: deployer
status: success
created_at: 2026-06-15
model_used: claude-opus-4-8
timestamp: 2026-06-15T18:30:57Z
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed against the live `orchestrator-staging` instance
(port 8501, executed inside the `orchestrator-staging` container per ORCH-048).
**Result: 8/10 checks PASS — exit code 0 → SUCCESS.**
- REAL failed: none
- SANDBOX_INFRA failed (waived, ORCH-061): C9a, C9b
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
## Block detail
- **Block A (SMOKE):** A1 `/health` 200 ✓, A2 `/queue` 200 ✓, A3 `ORCH_STAGING=true`
- **Block B (ACCESS):** B4 Plane sandbox project ✓, B5 Gitea `orchestrator-sandbox` push=true ✓,
B6 registry isolation (sandbox present, prod ET/ORCH absent) ✓
- **Block C (E2E, mode=stub):** C7 create issue ✓, C8 trigger pipeline ✓,
C9a/C9b — sandbox-infra (bot accounts not project members), waived; exit 0 preserved.
Staging gate passed. Cleanup completed (sandbox Plane issue deleted, HTTP 204).

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

@@ -29,6 +29,25 @@ class Settings(BaseSettings):
plane_bot_deployer: str = ""
plane_bot_stream: str = ""
# ORCH-117 (ADR-001 D6): sandbox-only fail-closed guard for Plane WRITE
# primitives from a test/worktree process (regression of incident ORCH-114,
# where a pytest run mutated a live prod board issue). The guard (leaf
# src/plane_write_guard.py) is a no-op in the live runtime (no pytest in the
# uvicorn process); in a test process it blocks every Plane write UNLESS both
# the opt-in flag is ON and the target project is in the sandbox allowlist.
# plane_test_write_enabled -> opt-in for REAL Plane writes from a test process
# (env ORCH_PLANE_TEST_WRITE_ENABLED). Default False
# = safe (default-deny). NOT a kill-switch for the
# prod-block: even ON, only sandbox projects are
# writable (allowlist below); a prod write from
# pytest stays physically impossible (NFR-6/FR-7).
# plane_test_sandbox_projects -> CSV allowlist of sandbox project ids the opt-in
# may write to (env ORCH_PLANE_TEST_SANDBOX_PROJECTS).
# Default = the single SANDBOX project. Empty -> no
# project is writable from a test process at all.
plane_test_write_enabled: bool = False
plane_test_sandbox_projects: str = "8c5a3025-4f9d-4190-b79f-fa06276bb27e"
# Gitea
gitea_url: str = "http://localhost:3000"
gitea_public_url: str = "" # external URL for clickable links in comments; falls back to gitea_url
@@ -590,6 +609,43 @@ class Settings(BaseSettings):
lease_reclaim_enabled: bool = True
reaper_finalizer_liveness_enabled: bool = True
# ORCH-114 (adr-0045): durable transition-ownership lease + expected-stage CAS for
# side-effectful stage transitions. Generalises the process-local ORCH-113
# finalizer-liveness to a DURABLE, cross-path owner-exclusion (additive table
# `transition_lease`) so a concurrent OR post-restart re-entry into a side-effectful
# transition (reaper / reconciler / webhook / startup-requeue) is deferred or a
# no-op instead of re-applying an irreversible effect (merge_pr / coverage-ratchet /
# image-rebuild / prod-deploy initiation / contradictory rollback↔done). Two
# complementary layers, both gated by the SINGLE kill-switch below:
# (1) durable lease on ENTRY to the side-effectful region (a second actor seeing a
# live owner does not start the heavy sub-gates at all — prevention, not repair);
# (2) expected-stage CAS on the stage WRITE (update_task_stage_cas: a lost race ->
# abort with NO side effect), which also closes the 6 paths that write the
# stage in bypass of advance_stage (gitea/plane direct update_task_stage).
# Liveness of the owner = owner_pid + owner_boot_id (NOT a heartbeat — a blocking
# 900s merge re-test cannot beat a heartbeat; ADR-001 D3), which makes restart
# recovery free (a new process -> new boot_id -> all prior leases are instantly
# stale -> reclaimed). The lease has NO own TTL: its hard age ceiling IS the reaper
# Tier-3 backstop reaper_max_running_s (5400), so the cross-cutting budget invariant
# ORCH-065/109/110/113 is untouched. STAGE_TRANSITIONS / QG_CHECKS / check_* /
# machine-verdict keys / existing table schemas — byte-for-byte. never-raise:
# hot-path guard fail-open (never wedge the shared queue), prod-safety fail-closed.
# See docs/work-items/ORCH-114/06-adr/ADR-001-transition-ownership-lease-and-stage-cas.md
# and the cross-cutting docs/architecture/adr/adr-0045-…md.
# transition_lease_enabled -> SINGLE kill-switch (env ORCH_TRANSITION_LEASE_ENABLED).
# False -> the lease is neither written nor read AND the
# CAS degenerates to the prior unconditional
# update_task_stage -> behaviour byte-for-byte as before
# ORCH-114 (reaper -> ORCH-113 in-memory fallback,
# reconciler/webhook skip-guard inert). Default True.
# transition_lease_repos -> CSV scope (env ORCH_TRANSITION_LEASE_REPOS). Empty ->
# applies ONLY to the self-hosting repo (orchestrator),
# where the irreversible side-effectful edges live;
# non-empty -> only the listed repos. Mirrors
# coverage_gate_repos -> enduro untouched at the default.
transition_lease_enabled: bool = True
transition_lease_repos: str = ""
# ORCH-063: disk-watchdog — background heartbeat that measures host-FS fill via
# the mounted bind-paths and Telegram-alerts the operator at >= threshold. On
# 07.06.2026 the mva154 host disk silently hit 100% and stalled the WHOLE

View File

@@ -263,6 +263,28 @@ def init_db():
_ensure_column(conn, "lessons", "attribution", "TEXT")
_ensure_column(conn, "lessons", "target_repo", "TEXT")
_ensure_column(conn, "lessons", "target_domain", "TEXT")
# ORCH-114 (adr-0045 / 08-data-requirements.md): durable transition-ownership
# lease. ONE additive object (CREATE TABLE IF NOT EXISTS, pattern repo_freeze/
# coverage_baseline/lessons) -> idempotent, restart-safe on the shared prod DB;
# existing tables (tasks/jobs/agent_runs/...) untouched byte-for-byte (NFR-3,
# AC-11). One row per task = at most one active owner of a side-effectful
# transition. Liveness of the holder = owner_boot_id (this process's start nonce)
# + owner_pid (os.getpid of the holding process); a row from a previous boot is
# instantly stale on restart -> reclaimed (ADR-001 D3). No index needed (access by
# PK task_id; snapshot() is a full-scan over a tiny table). The src/transition_lease.py
# leaf wraps all access in its never-raise contract. NO epoch/version column (D2:
# for the one-process model the stage IS the CAS version).
conn.executescript("""
CREATE TABLE IF NOT EXISTS transition_lease (
task_id INTEGER PRIMARY KEY,
owner TEXT NOT NULL,
owner_pid INTEGER,
owner_boot_id TEXT,
run_id INTEGER,
stage TEXT,
acquired_at TEXT NOT NULL DEFAULT (datetime('now'))
);
""")
conn.commit()
conn.close()
@@ -679,6 +701,39 @@ def update_task_stage(task_id: int, stage: str):
conn.close()
def update_task_stage_cas(task_id: int, expected_stage: str, new_stage: str) -> bool:
"""ORCH-114 (adr-0045 / FR-2): compare-and-swap variant of update_task_stage.
Writes the stage ONLY when the task is still at ``expected_stage`` (the value the
caller read before running the side-effectful region) — ``UPDATE … SET stage=?
WHERE id=? AND stage=?`` — and reports whether THIS writer won. Returns:
* ``True`` -> ``rowcount == 1``: the CAS succeeded, the stage moved exactly once.
* ``False`` -> ``rowcount == 0``: the task is no longer at ``expected_stage``
(another actor already advanced/rolled it back, or the row is gone) -> the
caller MUST abort WITHOUT applying any side effect (merge_pr / ratchet /
rebuild / deploy-init / enqueue) — it lost the race.
In the current one-process model each side-effectful edge leads to a DISTINCT
next stage, so the stage itself is a complete version for the compare-and-swap;
no separate epoch/version column is needed (ADR-001 D2). The plain
``update_task_stage`` above is kept unchanged for the kill-switch-off path and
for non-side-effectful writes. Mirrors the atomic rowcount-guard idiom of
``claim_next_job`` / ``reap_running_job``.
"""
conn = get_db()
try:
cur = conn.execute(
"UPDATE tasks SET stage = ?, updated_at = datetime('now') "
"WHERE id = ? AND stage = ?",
(new_stage, task_id, expected_stage),
)
conn.commit()
return cur.rowcount == 1
finally:
conn.close()
# ---------------------------------------------------------------------------
# ORCH-019: bug-fast-track task type (tasks.track) helpers
# ---------------------------------------------------------------------------

View File

@@ -434,18 +434,35 @@ class JobReaper:
return None, None, None
def _finalizer_owns(self, job: dict) -> bool:
"""ORCH-113 (adr-0043 / D3): True iff a LIVE monitor still owns this job's
``deploy-staging`` finalization, so the Tier-2 reap must be deferred.
"""True iff a LIVE actor still owns this job's side-effectful finalization, so
the Tier-2 reap must be deferred.
Order matters for the zero-regression contract: the kill-switch is checked
FIRST (disabled -> ``False`` with no DB lookup, so the path is byte-for-byte
prior); then the stage is scoped to ``deploy-staging`` only (the sole edge
whose in-thread finalization runs for minutes — every other stage is left
untouched); only then is the process-local ownership marker consulted. Never
raises -> ``False`` on any error (conservative: never block reaping when
ownership is unknowable, so the Tier-3 backstop is never neutered).
ORCH-114 (adr-0045 / D6) GENERALISES the ORCH-113 process-local, Tier-2,
``deploy-staging``-only marker to a DURABLE, cross-path lease: when the
transition-lease applies to this repo, consult ``transition_lease`` keyed on
the task (covers EVERY relevant edge — deploy-staging AND deploy->done — and
survives restart). Otherwise (kill-switch off) fall back to the unchanged
ORCH-113 in-memory ``finalizer_liveness`` (Tier-2 / ``deploy-staging`` only),
so the disabled path is byte-for-byte prior.
Either way the Tier-3 backstop (``reaper_max_running_s``) IGNORES this marker
(it does not call here), so a stuck/dead finalizer is still reaped in bounded
time. Never raises -> ``False`` on any error (conservative: never block reaping
when ownership is unknowable, so the backstop is never neutered).
"""
try:
repo = job.get("repo")
# ORCH-114: durable cross-path lease (when enabled for this repo).
try:
from . import transition_lease
if transition_lease.applies(repo):
return transition_lease.is_held_by_live_owner(job.get("task_id"))
except Exception as e: # noqa: BLE001 - fall back to ORCH-113 on any error
logger.warning(
"reaper: transition-lease check failed for job %s: %s",
job.get("id"), e,
)
# ORCH-113 fallback (kill-switch off): process-local, Tier-2/deploy-staging.
if not settings.reaper_finalizer_liveness_enabled:
return False
_branch, stage, _wid = self._task_meta(job)
@@ -472,6 +489,18 @@ class JobReaper:
def _note_reap(self, job: dict, outcome: str, reason: str) -> None:
"""Record + log one successful reap (Р-6 observability)."""
# ORCH-114 (adr-0045 / D6): a reap reclaims the job, so its durable
# transition-lease must NOT outlive it — force-release (any owner/boot) so a
# requeued job can re-acquire cleanly. never-raise; no-op when the lease is
# disabled / no row exists.
try:
from . import transition_lease
transition_lease.release(job.get("task_id"), force=True)
except Exception as e: # noqa: BLE001 - never break the reap
logger.warning(
"reaper: transition-lease force-release failed for job %s: %s",
job.get("id"), e,
)
self.reaped_total += 1
self.last_reaped = {
"job_id": job.get("id"),

View File

@@ -60,6 +60,25 @@ async def lifespan(app: FastAPI):
if requeued:
log.warning(f"Queue-recovery: requeued {requeued} running job(s) after restart")
# ORCH-114 (adr-0045 / D7 / FR-4): clear durable transition-leases left by the
# PREVIOUS process boot. This process has a fresh boot_id, so every prior lease is
# stale by construction -> reclaim it so the just-requeued jobs can re-drive their
# side-effectful transitions cleanly. Idempotency of the re-drive comes from the
# authoritative durable facts (SHA-in-main / the INITIATED self-deploy marker /
# the coverage-ratchet CAS), NOT from a new recovery brain — the lease only
# guarantees the re-drive runs SEQUENTIALLY (one owner), never concurrently. Runs
# AFTER requeue_running_jobs and BEFORE the reaper starts. never raises.
try:
from . import transition_lease
cleared_leases = transition_lease.recover_on_startup()
if cleared_leases:
log.warning(
f"Transition-lease recovery: cleared {cleared_leases} stale lease(s) "
f"from a previous boot"
)
except Exception as e:
log.warning(f"Transition-lease recovery skipped: {e}")
# ORCH-065: proactive startup reclaim of dead/stale merge-leases, next to the
# queue-recovery above. A lease held by the previous (now dead) process pid is
# released at once instead of waiting for the TTL / a foreign acquire so the
@@ -215,6 +234,7 @@ async def queue():
from . import bug_fast_track
from . import lessons
from . import checkout_hygiene
from . import transition_lease
from .disk_watchdog import disk_watchdog
from .build_cache_pruner import build_cache_pruner
return {
@@ -258,6 +278,11 @@ async def queue():
# ORCH-112 (D3): deploy-base checkout-hygiene observability (read-only) —
# kill-switch + scope. Additive block; never-raise.
"checkout_hygiene": checkout_hygiene.snapshot(),
# ORCH-114 (adr-0045 / D10 / FR-6): durable transition-ownership lease
# observability (read-only) — kill-switch, scope, boot_id, active holders
# (owner/stage/age/live) + defer/reclaim/CAS-lost counters. Additive block;
# never-raise.
"transition_lease": transition_lease.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).
@@ -324,6 +349,39 @@ async def serial_gate_unfreeze(repo: str = ""):
return {"ok": True, "repo": repo, "cleared": cleared, "frozen": frozen}
@app.post("/transition-lease/release")
async def transition_lease_release(work_item: str = ""):
"""ORCH-114 (adr-0045 / D10): operator manual reclaim of a stuck transition-lease.
By образцу ``POST /serial-gate/unfreeze``: if a lease somehow outlives its owner
(the normal try/finally release + the reaper force-release + the Tier-3 backstop
should make this unnecessary), an operator can force-release it by work-item id so
a re-approve / the reconciler can re-drive the transition. Idempotent: releasing a
free task reports ``released: false``. Read-only/never-raise otherwise.
"""
from . import transition_lease
from .db import get_task_by_work_item_id
if not work_item or not work_item.strip():
return {"ok": False, "error": "missing 'work_item'", "work_item": work_item}
work_item = work_item.strip()
task = get_task_by_work_item_id(work_item)
if not task:
return {"ok": False, "error": "task not found", "work_item": work_item}
task_id = task["id"]
held_before = transition_lease.is_held_by_live_owner(task_id)
transition_lease.release(task_id, force=True)
if held_before:
try:
from .notifications import send_telegram, link_for
send_telegram(
f"🔓 {link_for(work_item)}: transition-lease сброшен вручную "
f"(task {task_id}). Переход может быть пере-исполнен."
)
except Exception:
pass
return {"ok": True, "work_item": work_item, "task_id": task_id, "released": held_before}
@app.post("/fs-normalize/check")
async def fs_normalize_check(normalize: bool = False):
"""ORCH-057 (D6 / AC-4): force a fresh legacy-ownership detect (bypass the TTL

View File

@@ -4,6 +4,7 @@ import logging
import time
import httpx
from .config import settings
from . import plane_write_guard
logger = logging.getLogger("orchestrator.plane_sync")
@@ -843,9 +844,30 @@ def find_issue_id(work_item_id: str, project_id: str = None) -> str | None:
return None
def _guard_allows_write(work_item_id: str, project_id: str, op: str) -> bool:
"""ORCH-117: fail-closed gate in front of every Plane WRITE (state/comment).
Returns True if the write may proceed. In the live orchestrator/staging runtime
this is always True (the guard is a no-op — no pytest in the process). In a
test/worktree process a non-sandbox / non-opt-in write is BLOCKED here (audited
loudly) and this returns False, so the calling primitive returns BEFORE any
network step (no GET, no PATCH/POST). See src/plane_write_guard.py / ORCH-114.
"""
ok, reason = plane_write_guard.decide(project_id, op, work_item_id)
if not ok:
plane_write_guard.audit_block(project_id, op, work_item_id, reason)
return False
if reason == plane_write_guard.R_SANDBOX_OPT_IN:
plane_write_guard.audit_allow(project_id, op, work_item_id, reason)
return True
def update_issue_state(work_item_id: str, stage: str, project_id: str = None):
"""Update Plane issue state based on orchestrator stage."""
project_id = _resolve_project_id(work_item_id, project_id)
# ORCH-117: fail-closed guard — block prod Plane writes from a test process.
if not _guard_allows_write(work_item_id, project_id, plane_write_guard.OP_STATE):
return
# ORCH-10: resolve state UUID for this specific project (not global dict).
state_id = stage_to_state(stage, project_id)
if not state_id:
@@ -874,6 +896,9 @@ def add_comment(work_item_id: str, text: str, project_id: str = None, author: st
``_headers_for``). GET/PATCH calls elsewhere keep using PLANE_HEADERS.
"""
project_id = _resolve_project_id(work_item_id, project_id)
# ORCH-117: fail-closed guard — block prod Plane comment-writes from a test process.
if not _guard_allows_write(work_item_id, project_id, plane_write_guard.OP_COMMENT):
return
issue_id = find_issue_id(work_item_id, project_id)
if not issue_id:
logger.warning(f"Issue not found in Plane for {work_item_id}, skipping comment")
@@ -1038,6 +1063,9 @@ def set_issue_stage_state(work_item_id: str, stage: str, project_id: str = None)
def _set_issue_state_direct(work_item_id: str, state_id: str, project_id: str = None):
"""Set issue state directly by state_id."""
project_id = _resolve_project_id(work_item_id, project_id)
# ORCH-117: fail-closed guard — block prod Plane writes from a test process.
if not _guard_allows_write(work_item_id, project_id, plane_write_guard.OP_STATE):
return
issue_id = find_issue_id(work_item_id, project_id)
if not issue_id:
logger.warning(f"Issue not found in Plane for {work_item_id}")

193
src/plane_write_guard.py Normal file
View File

@@ -0,0 +1,193 @@
"""ORCH-117: fail-closed guard for Plane WRITE primitives from a test/worktree process.
Leaf module — pure, never-raise in the live path, config-gated. Mirrors the leaf
pattern of ``src/deploy_status_guard.py`` / ``src/serial_gate.py`` / ``src/cancel.py``:
it imports only ``config`` (and stdlib ``os``/``sys``), never ``plane_sync`` /
``stage_engine`` — the three write primitives that need a verdict call
:func:`decide`, the guard does not live there.
The incident (established fact, ORCH-114). A test/worktree process performed a REAL
write to Plane against the **production** project: ``PATCH …/issues/… state=<Done>``
plus a "Stage: deploy → done" comment, i.e. ``notify_stage_change("ORCH-114",
"deploy","done")`` run from pytest mutated a live board issue ("false Done"). The
root: test/staging processes inherit the live Plane token (``PLANE_HEADERS`` /
``PROJECT_ID`` are captured as literals at module import, so a post-hoc env/token
swap is a no-op, NFR-4), and *nothing* forced them to write only to the sandbox.
The precedent. ``tests/conftest.py::_no_telegram`` is an autouse fixture muting
``send_telegram`` in ALL tests, exactly because "pytest on prod sent REAL Telegram
messages to Slava". The symmetric protection for Plane WRITES did not exist — this
is that protection.
The fix (ADR-001 D1/D3): a low choke-point on the entry of the three write
primitives, evaluated **at call time** (not at import). The guard is active **only
in a test process** (``pytest``-in-process detection) — for the live orchestrator
runtime and the staging runtime (both ``uvicorn src.main:app``, no pytest in the
process) it is a strict no-op (byte-for-byte, NFR-2/NFR-3). In a test process a
write is allowed **iff** simultaneously (a) the dedicated opt-in flag is ON **and**
(b) the target project ∈ the sandbox-allowlist; otherwise BLOCK (default-deny). A
non-resolvable target → BLOCK (fail-closed, NFR-1). An allowed sandbox write is
audited at INFO; a block is audited LOUDLY at ERROR so an ORCH-114-class incident is
obvious, not silent (FR-5 / D7).
Deliberately NO kill-switch for the prod-block (ADR-001 D4 / FR-7 / NFR-6): a guard
that makes a prod write from pytest *physically impossible* must not ship with a
config that re-opens it (that would be the very back-door NFR-6 forbids). The only
reversible regulator is the sandbox-bound opt-in (``plane_test_write_enabled`` +
``plane_test_sandbox_projects``); "disable the guard""allow prod from pytest"
that transition does not exist by design. The independent conftest floor
(``_plane_sandbox_only``, ADR-001 D5) is the second sandbox-bound layer.
This is bugfix-isolation, NOT a Quality Gate and NOT a stage: ``STAGE_TRANSITIONS`` /
``QG_CHECKS`` / ``check_*`` / machine-verdict keys / the DB schema are byte-for-byte
untouched.
"""
from __future__ import annotations
import logging
import os
import sys
from .config import settings
logger = logging.getLogger("orchestrator.plane_write_guard")
# Verdicts returned by decide() (the calling primitive executes them).
ALLOW = True
BLOCK = False
# Reasons (stable slugs — asserted by tests / read in audit lines).
R_LIVE_RUNTIME = "live-runtime" # not a test process -> no-op (prod/staging).
R_AMBIGUOUS = "ambiguous-target" # project_id empty/unresolved -> "don't know => don't write".
R_OPT_IN_DISABLED = "opt-in-disabled" # test process, opt-in OFF -> default-deny.
R_PROD_IN_TEST = "prod-project-in-test" # test process, project NOT in sandbox allowlist.
R_SANDBOX_OPT_IN = "sandbox-opt-in" # test process, opt-in ON + sandbox project -> ALLOW.
R_GUARD_ERROR = "guard-error" # internal error inside the test-path -> fail-closed BLOCK.
# Operation tokens (one per call site) — used only for the audit line.
OP_STATE = "state" # update_issue_state / _set_issue_state_direct (httpx.patch)
OP_COMMENT = "comment" # add_comment (httpx.post)
def _in_test_process() -> bool:
"""True iff this Python process is a pytest/worktree test process (ADR-001 D2).
``"pytest" in sys.modules`` is true for the whole pytest run (collection +
execution) in THIS process, which is exactly the worktree ``python -m pytest``
process from the incident. The live orchestrator and the staging runtime start
via ``uvicorn src.main:app`` and never import pytest into their process, so the
detection never fires there (NFR-2/NFR-3, AC-5/AC-6). ``PYTEST_CURRENT_TEST`` is
a secondary confirming signal pytest sets for the duration of a test body. Both
are read at call time (NFR-4). Never raises: on any error we treat the process
as NOT-a-test (-> live ALLOW), so the guard can never accidentally wedge a
legitimate prod write.
"""
try:
if "pytest" in sys.modules:
return True
if os.environ.get("PYTEST_CURRENT_TEST"):
return True
except Exception: # noqa: BLE001 - never-raise; ambiguity -> "not a test" (live ALLOW).
return False
return False
def _sandbox_allowlist() -> set[str]:
"""Sanitised set of sandbox project ids from ``plane_test_sandbox_projects``.
Empty/blank CSV -> empty set (then EVERY project blocks in a test process,
fail-closed). Never raises.
"""
try:
raw = (settings.plane_test_sandbox_projects or "").strip()
except Exception: # noqa: BLE001 - never-raise.
return set()
if not raw:
return set()
return {tok.strip() for tok in raw.split(",") if tok.strip()}
def decide(project_id: str | None, op: str, work_item_id: str | None = None) -> tuple[bool, str]:
"""Decide whether a Plane WRITE primitive may hit the network (ADR-001 D3).
Returns ``(ok, reason)``. Steps:
1. ``not _in_test_process()`` -> ALLOW (``live-runtime``: prod/staging no-op).
2. ``project_id`` empty/None/unresolved -> BLOCK (``ambiguous-target``, NFR-1).
3. opt-in flag OFF -> BLOCK (``opt-in-disabled``, default-deny).
4. ``project_id`` ∉ sandbox allowlist -> BLOCK (``prod-project-in-test``, AC-3).
5. otherwise -> ALLOW (``sandbox-opt-in``, audit INFO).
never-raise: the live path returns at step 1 BEFORE the try-block, so a prod
write can never be wedged by a guard bug. Once we know we are in a test process,
any internal error fails CLOSED to BLOCK (``guard-error``) — the defect surfaces
loudly rather than re-opening prod (AC-9 / FR-7).
"""
# Step 1 — outside any test process the guard is a strict no-op. Evaluated FIRST
# and OUTSIDE the try-block so a live prod/staging write is never affected.
if not _in_test_process():
return ALLOW, R_LIVE_RUNTIME
# From here on we are in a test process: default-deny, fail-closed on any error.
try:
pid = (project_id or "").strip()
if not pid:
return BLOCK, R_AMBIGUOUS # step 2
if not bool(getattr(settings, "plane_test_write_enabled", False)):
return BLOCK, R_OPT_IN_DISABLED # step 3
if pid not in _sandbox_allowlist():
return BLOCK, R_PROD_IN_TEST # step 4 — sandbox-only, even with opt-in.
return ALLOW, R_SANDBOX_OPT_IN # step 5
except Exception as e: # noqa: BLE001 - never-raise; in-test -> fail CLOSED.
logger.error(
"plane_write_guard.decide error in test-process -> BLOCK (fail-closed): %s", e
)
return BLOCK, R_GUARD_ERROR
def audit_block(project_id: str | None, op: str, work_item_id: str | None, reason: str) -> None:
"""Loud structured audit of a BLOCKED write (FR-5 / D7).
Logged at ERROR so an ORCH-114-class incident (a pytest mutating a non-sandbox
project) is obvious in the run log, not silent. Never raises.
"""
try:
logger.error(
"plane_write_guard BLOCKED Plane %s write from a test process: "
"project_id=%s work_item=%s reason=%s "
"(ORCH-117 fail-closed; this would have mutated a non-sandbox Plane "
"project from pytest — cf. the ORCH-114 incident)",
op, project_id, work_item_id, reason,
)
except Exception: # noqa: BLE001 - logging must never raise.
pass
def audit_allow(project_id: str | None, op: str, work_item_id: str | None,
reason: str = R_SANDBOX_OPT_IN) -> None:
"""Audit (INFO) an ALLOWED real sandbox write from a test process (FR-5 / D7).
Only the ``sandbox-opt-in`` case is audited here — the ``live-runtime`` ALLOW
(prod/staging) is the normal hot path and is intentionally NOT logged to avoid
spamming the production log. Never raises.
"""
try:
logger.info(
"plane_write_guard ALLOWED sandbox Plane %s write from a test process: "
"project_id=%s work_item=%s reason=%s",
op, project_id, work_item_id, reason,
)
except Exception: # noqa: BLE001 - logging must never raise.
pass
def snapshot() -> dict:
"""Read-only view of the guard state (optional observability, D7). Never raises."""
try:
return {
"in_test_process": _in_test_process(),
"opt_in_enabled": bool(getattr(settings, "plane_test_write_enabled", False)),
"sandbox_allowlist": sorted(_sandbox_allowlist()),
}
except Exception as e: # noqa: BLE001 - never-raise.
return {"error": str(e)}

View File

@@ -70,6 +70,7 @@ from .webhooks.plane import handle_status_start, handle_verdict
from .notifications import send_telegram, link_for
from . import projects
from . import task_deps
from . import transition_lease
logger = logging.getLogger("orchestrator.reconciler")
@@ -153,6 +154,10 @@ class Reconciler:
# ORCH-068 observability: terminal-state skips and dedup suppressions.
self.skipped_terminal_total: int = 0
self.deduped_total: int = 0
# ORCH-114 (adr-0045 / FR-5): F-1 advances deferred because a live actor owns
# the task's side-effectful transition (transition-lease active). Reset on
# restart (safe: a live lease is itself recovered/reclaimed on restart).
self.transition_lease_defers_total: int = 0
# ORCH-068 (TR-3): in-memory dedup guard {issue_id -> last unblocked
# state uuid}. Best-effort (resets on restart, like unblocked_total);
# suppresses a repeat unblock notification for the same issue+state.
@@ -246,6 +251,19 @@ class Reconciler:
if cyc:
task_deps.handle_cycle(cyc)
return
# ORCH-114 (adr-0045 / FR-5, AC-7): a live actor already owns this task's
# side-effectful transition -> F-1 must NOT advance it in parallel. Silent
# defer (mirrors the escalated/Blocked/task-deps skip-guards above); the owner
# finishes the transition or, on death, the reaper reclaims it in bounded time.
# fail-safe: is_held_by_live_owner is conservative (True on doubt -> defer).
# never raises; no-op (False) when the lease is disabled / repo out of scope.
if transition_lease.is_held_by_live_owner(task_id):
self.transition_lease_defers_total += 1
logger.debug(
f"reconciler F-1: task {task_id} has an active transition-lease — "
f"deferring advance to its owner"
)
return
result = advance_if_gate_passed(
task_id,
stage,
@@ -596,6 +614,8 @@ class Reconciler:
# ORCH-068 observability.
"skipped_terminal_total": self.skipped_terminal_total,
"deduped_total": self.deduped_total,
# ORCH-114 observability: F-1 advances deferred to a live lease owner.
"transition_lease_defers_total": self.transition_lease_defers_total,
}

View File

@@ -41,6 +41,7 @@ from . import self_deploy
from . import post_deploy
from . import labels
from . import bug_fast_track
from . import transition_lease
from .notifications import (
notify_stage_change,
notify_qg_failure,
@@ -173,6 +174,20 @@ def developer_retry_count(task_id: int) -> int:
_developer_retry_count = developer_retry_count
def _is_side_effectful_edge(current_stage: str | None, next_stage: str | None) -> bool:
"""ORCH-114 (adr-0045 D4): does this ``advance_stage`` edge run IRREVERSIBLE work
that must be owned by exactly one actor (lease on entry)?
* ``deploy-staging`` (-> deploy): the heavy edge sub-gates (security / merge-gate
re-test / coverage / image-freshness rebuild) + Phase A.
* ``deploy`` (-> done OR Phase B): merge_pr / coverage-ratchet / proof-of-merge,
or the detached prod-deploy initiation (confirm_deploy).
Every other edge (created -> … -> testing) is reversible and is protected by the
CAS-on-write alone (no lease). Pure, never raises.
"""
return current_stage in ("deploy-staging", "deploy")
def advance_stage(
task_id: int,
current_stage: str,
@@ -210,6 +225,12 @@ def advance_stage(
"""
result = AdvanceResult(from_stage=current_stage)
agent = finished_agent
# ORCH-114 (adr-0045): set True once we acquire the durable transition-lease on a
# side-effectful edge, so the finally below ALWAYS releases it (on success, on a
# lost CAS, on a sub-gate rollback, and on ANY exception caught by the outer
# except). Released holder-aware (this process only) so a reaper reclaim + reacquire
# in between is never clobbered.
_lease_held = False
try:
qg_name = get_qg_for_stage(current_stage)
next_stage = get_next_stage(current_stage)
@@ -240,6 +261,28 @@ def advance_stage(
result.note = "terminal"
return result
# --- ORCH-114 transition-ownership lease: acquire on ENTRY (ADR-001 D5) ----
# On a side-effectful edge (deploy-staging / deploy) acquire the DURABLE
# owner-exclusion lease BEFORE the Phase B / sub-gate / merge-verify region. A
# second concurrent actor (reaper / reconciler / webhook / a re-driven startup
# job) that sees a live owner gets a clean "busy" defer here and does NOT start
# the heavy region at all — this is what kills the double-effect class
# (incident ORCH-111) at the root. Released in the `finally` below. Kill-switch
# off / repo out of scope -> applies() False -> no lease, byte-for-byte prior.
if _is_side_effectful_edge(current_stage, next_stage) and transition_lease.applies(repo):
if not transition_lease.acquire(
task_id, finished_agent or "engine", run_id=None, stage=current_stage
):
logger.info(
f"Task {task_id}: transition-lease busy on "
f"{current_stage}->{next_stage} — deferring (another actor owns "
f"this transition)"
)
result.note = "transition-lease-busy"
result.advanced = False
return result
_lease_held = True
# --- ORCH-036/059 Phase B: "Confirm Deploy" on `deploy` -> initiate ----
# ORCH-059: the prod-deploy trigger is now the DEDICATED "Confirm Deploy"
# status (confirm_deploy=True), NOT the overloaded "Approved". On the
@@ -399,7 +442,23 @@ def advance_stage(
return result
# --- Advance ---------------------------------------------------------
update_task_stage(task_id, next_stage)
# ORCH-114 (adr-0045 / FR-2): expected-stage compare-and-swap. Writes the
# stage only if the task is STILL at current_stage (the value we read on
# entry); a lost race (another writer advanced/rolled back first) returns
# False -> abort here WITHOUT any side effect (no notify / no arm / no
# terminal-sync / no enqueue). Kill-switch off / repo out of scope ->
# degenerates to the prior unconditional update_task_stage (returns True) ->
# byte-for-byte prior behaviour. Defense-in-depth: under the lease acquired
# above this CAS practically always wins; it also covers the narrow
# consult->acquire window and any bypass writer (TR-5).
if not transition_lease.commit_stage_cas(task_id, current_stage, next_stage, repo):
logger.info(
f"Task {task_id}: stage-CAS lost on {current_stage}->{next_stage}"
f"aborting without side effects (another writer advanced first)"
)
result.note = "stage-cas-lost"
result.advanced = False
return result
# Telegram live tracker: the analysis->architecture advance is the human
# Approved gate clearing -> stamp the END of "Ревью БРД" (the only
# human time). Idempotent: only the first stamp counts.
@@ -510,6 +569,16 @@ def advance_stage(
logger.error(f"advance_stage failed for task_id={task_id}: {e}")
result.note = f"error: {e}"
return result
finally:
# ORCH-114 (adr-0045 / AC-3): release the transition-lease on EVERY exit —
# normal advance, lost CAS, sub-gate rollback, Phase A/B early return, and any
# exception caught above — so the lease never "leaks" and wedges the task.
# holder-aware (force=False): only releases a row this process owns.
if _lease_held:
try:
transition_lease.release(task_id)
except Exception as e: # noqa: BLE001 - never-raise (Tier-3 backstop bounds it)
logger.warning(f"Task {task_id}: transition-lease release failed: {e}")
def advance_if_gate_passed(
@@ -1164,6 +1233,31 @@ def _merge_gate_infra_retry_impl(
)
def _rollback_stage_cas(task_id, current_stage, repo, result: AdvanceResult) -> bool:
"""ORCH-114 (ADR-001 D4): write a rollback stage (`development`) through the
expected-stage CAS — the same contract as the forward/bypass writes.
Returns True iff the write was applied (the caller proceeds with the rollback side
effects); False iff the CAS was lost (the caller MUST abort WITHOUT side effects).
These in-region rollback handlers run inside ``advance_stage`` under the held
transition-lease, so this is the sole owner and the CAS practically always wins. A
lost race means a concurrent winner already advanced this task (e.g. to ``done``) —
rolling back to ``development`` would be exactly the rollback↔done contradiction
BR-6 guards against, so we abort instead of a blind overwrite. Kill-switch off /
repo out of scope -> commit_stage_cas degenerates to the prior unconditional
``update_task_stage`` (always True) -> byte-for-byte (AC-9).
"""
if transition_lease.commit_stage_cas(task_id, current_stage, "development", repo):
return True
logger.info(
f"Task {task_id}: rollback stage-CAS lost on {current_stage}->development "
f"— aborting rollback without side effects (a concurrent winner advanced)"
)
result.note = "rollback-cas-lost"
return False
def _handle_merge_gate_rollback(
task_id, current_stage, repo, work_item_id, branch, reason, result: AdvanceResult
):
@@ -1174,7 +1268,8 @@ def _handle_merge_gate_rollback(
already released by check_branch_mergeable on failure; a defensive holder-aware
release here is a harmless no-op.
"""
update_task_stage(task_id, "development")
if not _rollback_stage_cas(task_id, current_stage, repo, result):
return
notify_stage_change(task_id, current_stage, "development")
plane_notify_stage(work_item_id, current_stage, "development")
result.rolled_back_to = "development"
@@ -1251,7 +1346,8 @@ def _handle_security_gate(
result.qg_passed = False
result.qg_reason = reason
update_task_stage(task_id, "development")
if not _rollback_stage_cas(task_id, current_stage, repo, result):
return True
notify_stage_change(task_id, current_stage, "development")
plane_notify_stage(work_item_id, current_stage, "development")
result.rolled_back_to = "development"
@@ -1339,7 +1435,8 @@ def _handle_coverage_gate(
result.qg_passed = False
result.qg_reason = reason
update_task_stage(task_id, "development")
if not _rollback_stage_cas(task_id, current_stage, repo, result):
return True
notify_stage_change(task_id, current_stage, "development")
plane_notify_stage(work_item_id, current_stage, "development")
result.rolled_back_to = "development"
@@ -1419,7 +1516,8 @@ def _handle_image_freshness(
result.qg_passed = False
result.qg_reason = reason
update_task_stage(task_id, "development")
if not _rollback_stage_cas(task_id, current_stage, repo, result):
return True
notify_stage_change(task_id, current_stage, "development")
plane_notify_stage(work_item_id, current_stage, "development")
result.rolled_back_to = "development"
@@ -1482,7 +1580,21 @@ def _handle_self_deploy_phase_a(
restart-safe `approve-requested` marker records that Phase A ran. The merge
lease stays HELD.
"""
update_task_stage(task_id, "deploy")
# ORCH-114 (adr-0045 / D4): this IS the deploy-staging -> deploy stage write on
# the self-hosting path (advance_stage's line-402 CAS is not reached — Phase A
# returns first). Use the same expected-stage CAS. It runs under the transition-
# lease acquired by advance_stage, so it practically always wins; a lost CAS
# (a concurrent writer despite the lease) -> abort Phase A WITHOUT initiating the
# prod-deploy ask / autoDeploy (no double effect). Kill-switch off / repo out of
# scope -> unconditional update (byte-for-byte).
if not transition_lease.commit_stage_cas(task_id, current_stage, "deploy", repo):
logger.info(
f"Task {task_id}: Phase A stage-CAS lost ({current_stage}->deploy) — "
f"aborting Phase A without side effects"
)
result.note = "phase-a-cas-lost"
result.advanced = False
return
notify_stage_change(task_id, current_stage, "deploy")
result.advanced = True
result.to_stage = "deploy"

471
src/transition_lease.py Normal file
View File

@@ -0,0 +1,471 @@
"""ORCH-114 (adr-0045): durable transition-ownership lease + expected-stage CAS.
Leaf module — pure, never-raise (pattern of ``serial_gate`` / ``coverage_gate`` /
``finalizer_liveness``: imports only ``db`` + ``config`` and lazily
``merge_gate.pid_alive`` / ``qg.checks.is_self_hosting_repo`` / ``notifications``;
it NEVER imports ``stage_engine`` / ``launcher`` and talks to no network).
The bug class it closes
-----------------------
``stage_engine.advance_stage`` is the single entry to side-effectful transitions
(the heavy ``deploy-staging -> deploy`` edge sub-gates — security / merge-gate
re-test / coverage / image-freshness — and the ``deploy -> done`` merge-verify:
``merge_pr`` / coverage-ratchet / proof-of-merge). It is RE-ENTERABLE: at least
five actors (monitor / Plane-webhook / reconciler F-1 / job-reaper / deploy
finalizer) can enter the SAME transition independently, and the stage write was a
bare ``UPDATE … WHERE id=?`` with no compare-and-swap. Two concurrent — or a
post-restart re-driven — entry therefore re-applied irreversible effects and
produced contradictory outcomes (one path rolled back to ``development`` while
another merged + finished — incident ORCH-111, job 1914 / PR #130). ORCH-113
closed only the in-memory, Tier-2, ``deploy-staging``-only slice of this; it is
lost on restart.
Two complementary layers (ADR-001 D1), both gated by one kill-switch:
1. **Durable lease (owner-exclusion on ENTRY).** A row in the additive
``transition_lease`` table (one per task) records "an actor owns this task's
side-effectful transition". A second actor that sees a LIVE owner does not
start the heavy sub-gates AT ALL (prevention, not post-hoc repair).
2. **Expected-stage CAS (atomicity on the WRITE).** ``update_task_stage_cas``
writes the stage only when the task is still at the expected stage; a lost
race aborts with NO side effect. It also closes the six paths that write the
stage in BYPASS of ``advance_stage`` (gitea / plane direct ``update_task_stage``).
Liveness without a heartbeat (ADR-001 D3)
-----------------------------------------
An owner is LIVE ⇔ ``owner_boot_id == <this process's boot id>`` AND
``merge_gate.pid_alive(owner_pid)``. There is NO heartbeat (a blocking 900 s merge
re-test cannot beat one — the very argument ORCH-113 used to reject heartbeats).
This makes restart recovery free: a new process has a new ``boot_id`` so every row
written by a previous process is instantly stale and reclaimed
(``recover_on_startup``). Within the one-process model every live owner shares the
SAME boot id and pid, so a same-boot row is by definition owned by the (alive)
current process; only a different-boot row can be stale — which is why the
acquire/recover logic keys staleness on the boot id.
No own TTL (ADR-001 D8): the lease's hard age ceiling IS the reaper Tier-3 backstop
``reaper_max_running_s`` (the reaper force-releases the lease when it reaps), so the
cross-cutting budget invariant ORCH-065/109/110/113 is untouched.
never-raise (ADR-001 D9 / NFR-1): every public function is isolated. The
directional defaults:
* ``acquire`` error -> ``False`` (busy): the caller DEFERS/aborts a side-effectful
transition rather than risk a double effect (fail-CLOSED to no-double-effect).
* ``is_held_by_live_owner`` error -> ``True`` (treat as held): the consulting
reconciler / webhook / reaper conservatively DEFERS (the safe action; the reaper
Tier-3 backstop still bounds a genuinely stuck task).
* ``commit_stage_cas`` error on the CAS path -> ``False``: abort the write, never a
blind overwrite.
The hot claim path (``db.claim_next_job``) is deliberately NOT touched, so a lease
bug can never wedge the shared queue of all projects (AC-8 ORCH-088 intact).
See docs/work-items/ORCH-114/06-adr/ADR-001-transition-ownership-lease-and-stage-cas.md
and the cross-cutting docs/architecture/adr/adr-0045-transition-ownership-lease-and-stage-cas.md.
"""
from __future__ import annotations
import logging
import os
import secrets
import threading
from . import db
from .config import settings
logger = logging.getLogger("orchestrator.transition_lease")
# Per-process boot nonce (ADR-001 D3). Generated ONCE at import: every lease row a
# previous process wrote carries a DIFFERENT boot id and is therefore instantly
# stale after a restart -> reclaimed by recover_on_startup / acquire. Not derived
# from the clock so it cannot collide across a fast restart.
_BOOT_ID = secrets.token_hex(16)
# Best-effort observability counters (reset on restart, like the reaper's). Guarded
# by a lock because the monitor / reaper / reconciler / webhook threads all touch
# them. Never a source of truth — purely for GET /queue.
_LOCK = threading.Lock()
_COUNTERS: dict[str, int] = {
"acquired_total": 0, # leases successfully acquired
"busy_total": 0, # acquire deferred — a live owner already held it
"released_total": 0, # normal try/finally releases
"cas_lost_total": 0, # stage-CAS lost the race (aborted without side effect)
"stale_reclaims_total": 0, # rows reclaimed because the owner was not live
"force_reclaims_total": 0, # rows force-released (reaper / operator)
}
def _bump(key: str, n: int = 1) -> None:
try:
with _LOCK:
_COUNTERS[key] = _COUNTERS.get(key, 0) + n
except Exception: # noqa: BLE001 - counters never break a caller
pass
def boot_id() -> str:
"""This process's boot nonce (exposed for tests / observability)."""
return _BOOT_ID
# ---------------------------------------------------------------------------
# Conditionality (mirrors coverage_gate_applies — self-hosting-only by default)
# ---------------------------------------------------------------------------
def _enabled() -> bool:
try:
return bool(getattr(settings, "transition_lease_enabled", False))
except Exception: # noqa: BLE001
return False
def applies(repo: str) -> bool:
"""Whether the transition-lease + CAS are REAL for this repo (ADR-001 D10).
* ``transition_lease_enabled=False`` -> always False (kill-switch; the lease is
neither written nor read AND ``commit_stage_cas`` degenerates to the prior
unconditional ``update_task_stage`` -> behaviour byte-for-byte as before
ORCH-114).
* ``transition_lease_repos`` (CSV) non-empty -> real only for the listed repos.
* empty CSV -> real ONLY for the self-hosting repo (``orchestrator``), where the
irreversible side-effectful edges live (mirrors coverage_gate_repos -> enduro
untouched at the default).
Never raises -> False on error (the safe "mechanism inert" default == kill-switch
off).
"""
try:
if not _enabled():
return False
raw = (getattr(settings, "transition_lease_repos", "") or "").strip()
if raw:
allowed = {r.strip().lower() for r in raw.split(",") if r.strip()}
return (repo or "").strip().lower() in allowed
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("transition_lease.applies error for %s: %s", repo, e)
return False
# ---------------------------------------------------------------------------
# Liveness
# ---------------------------------------------------------------------------
def _pid_alive(pid) -> bool:
"""Probe ``pid`` liveness via ``merge_gate.pid_alive`` (ADR-001 references it for
a single shared semantics). Lazy import keeps this module a leaf; on import error
fall back to a conservative ``True`` (a lease whose pid we cannot probe is treated
as live — the boot-id check below + the Tier-3 backstop still bound it).
"""
try:
from .merge_gate import pid_alive
return pid_alive(pid)
except Exception: # noqa: BLE001
return True
def _row_is_live(owner_boot_id, owner_pid) -> bool:
"""True iff the lease owner is LIVE (this process's boot AND a live pid).
A row from a DIFFERENT boot id (a previous process) is dead by construction
(ADR-001 D3); a same-boot row is owned by the current — alive — process, but we
still probe the pid for forward-compatibility with a future multi-process model.
"""
if owner_boot_id != _BOOT_ID:
return False
return _pid_alive(owner_pid)
def is_held_by_live_owner(task_id) -> bool:
"""True iff an active lease row for ``task_id`` is owned by a LIVE actor.
Consulted by the reconciler F-1 / Plane-webhook DEFER guards and the reaper.
Returns ``False`` when there is no row or the owner is stale. Fail-CLOSED on any
error -> ``True`` (treat as held): the consulting caller DEFERS, which is always
the safe-against-double-effect action (the reaper Tier-3 backstop still bounds a
truly stuck task). When the mechanism is disabled -> ``False`` (no defer).
"""
if task_id is None:
return False
if not _enabled():
return False
try:
conn = db.get_db()
try:
row = conn.execute(
"SELECT owner_boot_id, owner_pid FROM transition_lease WHERE task_id=?",
(task_id,),
).fetchone()
finally:
conn.close()
if row is None:
return False
return _row_is_live(row["owner_boot_id"], row["owner_pid"])
except Exception as e: # noqa: BLE001 - fail-CLOSED on doubt (conservative defer)
logger.warning(
"transition_lease.is_held_by_live_owner error for task %s -> "
"fail-CLOSED (defer): %s", task_id, e,
)
return True
# ---------------------------------------------------------------------------
# Acquire / release / reclaim
# ---------------------------------------------------------------------------
def _clear_stale_row(conn, task_id) -> int:
"""Delete the lease row for ``task_id`` IFF its owner is not live. Returns the
rowcount. Uses the caller's connection (same transaction as the INSERT in
``acquire``). Raises on a real DB fault (the caller swallows)."""
row = conn.execute(
"SELECT owner_boot_id, owner_pid FROM transition_lease WHERE task_id=?",
(task_id,),
).fetchone()
if row is None:
return 0
if _row_is_live(row["owner_boot_id"], row["owner_pid"]):
return 0
cur = conn.execute("DELETE FROM transition_lease WHERE task_id=?", (task_id,))
return cur.rowcount or 0
def acquire(task_id, owner: str, run_id=None, stage: str | None = None) -> bool:
"""Acquire the side-effectful-transition lease for ``task_id`` (ADR-001 D5).
Atomic rowcount-guard (pattern ``claim_next_job`` / ``reap_running_job``): a stale
row (owner from a previous boot / dead pid) is cleared first, then an
``INSERT … ON CONFLICT(task_id) DO NOTHING`` competes only with LIVE same-process
owners. ``rowcount == 1`` -> WE won. ``rowcount == 0`` -> a live owner already
holds it -> ``False`` (the caller DEFERS without starting the heavy region).
Kill-switch off -> ``True`` (no-op acquire; the caller proceeds exactly as before
ORCH-114; ``release`` is then an idempotent no-op). ``task_id is None`` -> ``True``
(a job with no task cannot be leased — legacy direct ``launch()``; proceed).
never-raise: any error -> ``False`` (busy) so the caller DEFERS a side-effectful
transition rather than risk a double effect (fail-CLOSED to no-double-effect,
ADR-001 D9).
"""
if not _enabled():
return True
if task_id is None:
return True
try:
conn = db.get_db()
try:
_clear_stale_row(conn, task_id)
cur = conn.execute(
"INSERT INTO transition_lease "
"(task_id, owner, owner_pid, owner_boot_id, run_id, stage) "
"VALUES (?, ?, ?, ?, ?, ?) "
"ON CONFLICT(task_id) DO NOTHING",
(task_id, owner or "engine", os.getpid(), _BOOT_ID, run_id, stage),
)
conn.commit()
won = (cur.rowcount == 1)
finally:
conn.close()
if won:
_bump("acquired_total")
return True
_bump("busy_total")
logger.info(
"transition_lease: task %s busy (a live owner holds the transition); "
"%s defers", task_id, owner,
)
return False
except Exception as e: # noqa: BLE001 - fail-CLOSED (busy) to avoid double effects
logger.warning("transition_lease.acquire error for task %s: %s", task_id, e)
return False
def release(task_id, force: bool = False) -> None:
"""Release the lease for ``task_id`` (ADR-001 D5). Idempotent, never raises.
* ``force=False`` (normal try/finally release in ``advance_stage``): delete only
a row owned by THIS process (``owner_boot_id == boot``), so a release delayed
past a reaper-reclaim-then-reacquire can never delete a lease a DIFFERENT
process/owner acquired afterwards (holder-aware, mirrors ``release_merge_lease``).
* ``force=True`` (reaper reap / operator endpoint): delete unconditionally.
"""
if task_id is None:
return
if not _enabled():
return
try:
conn = db.get_db()
try:
if force:
cur = conn.execute(
"DELETE FROM transition_lease WHERE task_id=?", (task_id,)
)
else:
cur = conn.execute(
"DELETE FROM transition_lease WHERE task_id=? AND owner_boot_id=?",
(task_id, _BOOT_ID),
)
conn.commit()
n = cur.rowcount or 0
finally:
conn.close()
if n:
_bump("force_reclaims_total" if force else "released_total", n)
except Exception as e: # noqa: BLE001 - never-raise (a leaked lease is bounded by Tier-3)
logger.warning("transition_lease.release error for task %s: %s", task_id, e)
def reclaim_if_stale(task_id) -> bool:
"""Reclaim (delete) the lease row for ``task_id`` IFF its owner is not live.
Returns True iff a stale row was reclaimed. Used by the operator endpoint and as
a backstop. never-raise -> False on error.
"""
if task_id is None or not _enabled():
return False
try:
conn = db.get_db()
try:
n = _clear_stale_row(conn, task_id)
conn.commit()
finally:
conn.close()
if n:
_bump("stale_reclaims_total", n)
logger.warning("transition_lease: reclaimed stale lease for task %s", task_id)
return n > 0
except Exception as e: # noqa: BLE001 - never-raise
logger.warning("transition_lease.reclaim_if_stale error for task %s: %s", task_id, e)
return False
def recover_on_startup() -> int:
"""Clear every lease left by a PREVIOUS process boot (ADR-001 D7).
Called from ``main.lifespan`` right after ``requeue_running_jobs`` and BEFORE the
reaper starts. A fresh process boot id means every existing row predates this
process -> stale -> deleted, so the requeued jobs re-drive their transitions
cleanly (idempotency comes from the authoritative durable facts — SHA-in-main,
the INITIATED self-deploy marker, the coverage-ratchet CAS — NOT from a new
recovery brain). Returns the number of rows cleared. never-raise -> 0 on error.
"""
if not _enabled():
return 0
try:
conn = db.get_db()
try:
cur = conn.execute(
"DELETE FROM transition_lease "
"WHERE owner_boot_id IS NULL OR owner_boot_id != ?",
(_BOOT_ID,),
)
conn.commit()
n = cur.rowcount or 0
finally:
conn.close()
if n:
_bump("stale_reclaims_total", n)
logger.warning(
"transition_lease.recover_on_startup: cleared %d stale lease(s) from a "
"previous boot", n,
)
# FR-6 / AC-12: a forced/stale reclaim is observable (Telegram alert). A
# restart-time bulk reclaim is summarised (per-task clickable alerts come
# from the operator endpoint). best-effort, never-raise.
try:
from .notifications import send_telegram
send_telegram(
f"♻️ Transition-lease recovery: сброшено {n} устаревш"
f"(ий/их) lease после рестарта (переходы будут пере-исполнены "
f"последовательно)."
)
except Exception: # noqa: BLE001 - alert is best-effort
pass
return n
except Exception as e: # noqa: BLE001 - never-raise
logger.warning("transition_lease.recover_on_startup error: %s", e)
return 0
# ---------------------------------------------------------------------------
# Stage write — compare-and-swap wrapper (ADR-001 D5 / FR-2)
# ---------------------------------------------------------------------------
def commit_stage_cas(task_id, expected_stage: str, new_stage: str, repo: str) -> bool:
"""Write the task stage under the ORCH-114 contract. Returns True iff the write
was applied (and the caller may proceed with side effects), False iff the writer
lost the CAS race (the caller MUST abort WITHOUT side effects).
* ``applies(repo)`` False (kill-switch off / repo out of scope) -> the prior
unconditional ``update_task_stage`` (byte-for-byte) -> always True. Not wrapped
in a swallowing try, so a DB error propagates EXACTLY as before ORCH-114.
* ``applies(repo)`` True -> ``update_task_stage_cas`` (expected-stage compare-and-
swap). A lost race -> False (no side effect). never-raise on the CAS path: a DB
error -> False (abort the write; never a blind overwrite).
"""
try:
scoped = applies(repo)
except Exception: # noqa: BLE001 - applies already never-raises; belt-and-suspenders
scoped = False
if not scoped:
db.update_task_stage(task_id, new_stage)
return True
try:
won = db.update_task_stage_cas(task_id, expected_stage, new_stage)
if not won:
_bump("cas_lost_total")
return won
except Exception as e: # noqa: BLE001 - abort the write (no blind overwrite)
logger.warning(
"transition_lease.commit_stage_cas error for task %s (%s->%s): %s",
task_id, expected_stage, new_stage, e,
)
return False
# ---------------------------------------------------------------------------
# Observability snapshot for GET /queue (ADR-001 D10 / FR-6)
# ---------------------------------------------------------------------------
def snapshot() -> dict:
"""Read-only transition-lease summary for GET /queue. Additive block; existing
/queue keys untouched. never-raise -> a minimal dict on error.
"""
try:
enabled = _enabled()
except Exception: # noqa: BLE001
enabled = False
try:
repos_cfg = getattr(settings, "transition_lease_repos", "") or ""
except Exception: # noqa: BLE001
repos_cfg = ""
holders: list[dict] = []
try:
conn = db.get_db()
try:
rows = conn.execute(
"SELECT task_id, owner, owner_pid, owner_boot_id, run_id, stage, "
"acquired_at, "
"CAST(strftime('%s','now') - strftime('%s', acquired_at) AS INTEGER) "
" AS age_s "
"FROM transition_lease ORDER BY task_id"
).fetchall()
finally:
conn.close()
for r in rows:
holders.append({
"task_id": r["task_id"],
"owner": r["owner"],
"stage": r["stage"],
"run_id": r["run_id"],
"age_s": r["age_s"],
"live": _row_is_live(r["owner_boot_id"], r["owner_pid"]),
})
except Exception as e: # noqa: BLE001 - never break /queue
logger.warning("transition_lease.snapshot error: %s", e)
try:
with _LOCK:
counters = dict(_COUNTERS)
except Exception: # noqa: BLE001
counters = {}
return {
"enabled": enabled,
"repos": repos_cfg,
"boot_id": _BOOT_ID,
"active": len(holders),
"holders": holders,
"counters": counters,
}

View File

@@ -13,7 +13,6 @@ from ..config import settings
from ..db import (
get_db,
get_task_by_repo_branch,
update_task_stage,
enqueue_job,
insert_event_dedup,
)
@@ -24,6 +23,7 @@ from ..notifications import notify_stage_change, notify_qg_failure, notify_error
from ..agents.launcher import launcher
from ..plane_sync import notify_stage_change as plane_notify_stage
from ..projects import get_project_by_repo
from .. import transition_lease
logger = logging.getLogger("orchestrator.webhooks.gitea")
@@ -124,18 +124,25 @@ async def handle_push(payload: dict):
if has_adr:
# Advance to development
next_stage = "development"
update_task_stage(task_id, next_stage)
notify_stage_change(task_id, current_stage, next_stage)
plane_notify_stage(work_item_id, current_stage, next_stage)
# ORCH-114 (adr-0045 / D4, TR-4): this push-driven advance writes the stage
# in BYPASS of advance_stage -> route through the expected-stage CAS so it
# cannot clobber a concurrent authoritative write; a lost race skips the
# notify + enqueue (no duplicate agent). Kill-switch off -> unconditional
# (byte-for-byte).
if transition_lease.commit_stage_cas(task_id, current_stage, next_stage, repo_name):
notify_stage_change(task_id, current_stage, next_stage)
plane_notify_stage(work_item_id, current_stage, next_stage)
agent = get_agent_for_stage(current_stage)
if agent:
try:
task_desc = f"Work item: {work_item_id}\nRepo: {repo_name}\nBranch: {branch}\nStage: {next_stage}"
job_id = enqueue_job(agent, repo_name, task_desc, task_id=task_id)
logger.info(f"Task {task_id}: push triggered {current_stage}{next_stage}, enqueued '{agent}' (job_id={job_id})")
except Exception as e:
notify_error(task_id, f"Failed to launch agent '{agent}': {e}")
agent = get_agent_for_stage(current_stage)
if agent:
try:
task_desc = f"Work item: {work_item_id}\nRepo: {repo_name}\nBranch: {branch}\nStage: {next_stage}"
job_id = enqueue_job(agent, repo_name, task_desc, task_id=task_id)
logger.info(f"Task {task_id}: push triggered {current_stage}{next_stage}, enqueued '{agent}' (job_id={job_id})")
except Exception as e:
notify_error(task_id, f"Failed to launch agent '{agent}': {e}")
else:
logger.info(f"Task {task_id}: push-advance stage-CAS lost ({current_stage}->{next_stage}); another writer moved it")
elif current_stage == "development":
# Source files pushed — just log, wait for CI
@@ -239,18 +246,22 @@ async def handle_ci_status(payload: dict):
passed, reason = check_ci_green(repo_name, branch)
if passed:
next_stage = "review"
update_task_stage(task_id, next_stage)
notify_stage_change(task_id, current_stage, next_stage)
plane_notify_stage(work_item_id, current_stage, next_stage)
# ORCH-114 (adr-0045 / D4, TR-4): CI-green advance in BYPASS of
# advance_stage -> expected-stage CAS; a lost race skips notify + enqueue.
if transition_lease.commit_stage_cas(task_id, current_stage, next_stage, repo_name):
notify_stage_change(task_id, current_stage, next_stage)
plane_notify_stage(work_item_id, current_stage, next_stage)
agent = get_agent_for_stage(current_stage)
if agent:
try:
task_desc = f"Work item: {work_item_id}\nRepo: {repo_name}\nBranch: {branch}\nStage: {next_stage}"
job_id = enqueue_job(agent, repo_name, task_desc, task_id=task_id)
logger.info(f"Task {task_id}: CI green → {next_stage}, enqueued '{agent}' (job_id={job_id})")
except Exception as e:
notify_error(task_id, f"Failed to launch agent '{agent}': {e}")
agent = get_agent_for_stage(current_stage)
if agent:
try:
task_desc = f"Work item: {work_item_id}\nRepo: {repo_name}\nBranch: {branch}\nStage: {next_stage}"
job_id = enqueue_job(agent, repo_name, task_desc, task_id=task_id)
logger.info(f"Task {task_id}: CI green → {next_stage}, enqueued '{agent}' (job_id={job_id})")
except Exception as e:
notify_error(task_id, f"Failed to launch agent '{agent}': {e}")
else:
logger.info(f"Task {task_id}: CI-green stage-CAS lost ({current_stage}->{next_stage}); another writer moved it")
else:
notify_qg_failure(task_id, current_stage, "check_ci_green", reason)
@@ -330,18 +341,22 @@ async def handle_pr(payload: dict):
passed, reason = check_review_approved(repo_name, pr_number)
if passed:
next_stage = "testing"
update_task_stage(task_id, next_stage)
notify_stage_change(task_id, current_stage, next_stage)
plane_notify_stage(work_item_id, current_stage, next_stage)
# ORCH-114 (adr-0045 / D4, TR-4): PR-approved advance in BYPASS of
# advance_stage -> expected-stage CAS; a lost race skips notify + enqueue.
if transition_lease.commit_stage_cas(task_id, current_stage, next_stage, repo_name):
notify_stage_change(task_id, current_stage, next_stage)
plane_notify_stage(work_item_id, current_stage, next_stage)
agent = get_agent_for_stage(current_stage)
if agent:
try:
task_desc = f"Work item: {work_item_id}\nRepo: {repo_name}\nBranch: {head_branch}\nStage: {next_stage}"
job_id = enqueue_job(agent, repo_name, task_desc, task_id=task_id)
logger.info(f"Task {task_id}: PR approved → {next_stage}, enqueued '{agent}' (job_id={job_id})")
except Exception as e:
notify_error(task_id, f"Failed to launch agent '{agent}': {e}")
agent = get_agent_for_stage(current_stage)
if agent:
try:
task_desc = f"Work item: {work_item_id}\nRepo: {repo_name}\nBranch: {head_branch}\nStage: {next_stage}"
job_id = enqueue_job(agent, repo_name, task_desc, task_id=task_id)
logger.info(f"Task {task_id}: PR approved → {next_stage}, enqueued '{agent}' (job_id={job_id})")
except Exception as e:
notify_error(task_id, f"Failed to launch agent '{agent}': {e}")
else:
logger.info(f"Task {task_id}: PR-approved stage-CAS lost ({current_stage}->{next_stage}); another writer moved it")
else:
notify_qg_failure(task_id, current_stage, "check_review_approved", reason)
@@ -355,18 +370,24 @@ async def handle_pr(payload: dict):
conn.close()
if retry_count < MAX_DEV_RETRIES:
# Back to development, relaunch developer
update_task_stage(task_id, "development")
notify_stage_change(task_id, current_stage, "development")
try:
task_desc = (
f"Work item: {work_item_id}\nRepo: {repo_name}\nBranch: {head_branch}\n"
f"Stage: development\nNote: Changes requested in review (attempt {retry_count + 1}/{MAX_DEV_RETRIES})"
)
job_id = enqueue_job("developer", repo_name, task_desc, task_id=task_id)
logger.info(f"Task {task_id}: changes requested, enqueued developer (attempt {retry_count + 1}, job_id={job_id})")
except Exception as e:
notify_error(task_id, f"Failed to relaunch developer: {e}")
# Back to development, relaunch developer.
# ORCH-114 (adr-0045 / D4, TR-4): REQUEST_CHANGES rollback writes the
# stage in BYPASS of advance_stage -> expected-stage CAS so it cannot
# clobber a concurrent authoritative write (e.g. a task that already
# advanced); a lost race skips the rollback + developer relaunch.
if transition_lease.commit_stage_cas(task_id, current_stage, "development", repo_name):
notify_stage_change(task_id, current_stage, "development")
try:
task_desc = (
f"Work item: {work_item_id}\nRepo: {repo_name}\nBranch: {head_branch}\n"
f"Stage: development\nNote: Changes requested in review (attempt {retry_count + 1}/{MAX_DEV_RETRIES})"
)
job_id = enqueue_job("developer", repo_name, task_desc, task_id=task_id)
logger.info(f"Task {task_id}: changes requested, enqueued developer (attempt {retry_count + 1}, job_id={job_id})")
except Exception as e:
notify_error(task_id, f"Failed to relaunch developer: {e}")
else:
logger.info(f"Task {task_id}: REQUEST_CHANGES rollback stage-CAS lost ({current_stage}->development); another writer moved it")
else:
notify_error(task_id, f"Max developer retries ({MAX_DEV_RETRIES}) reached, escalating")
logger.error(f"Task {task_id}: max retries reached, needs manual intervention")
@@ -395,6 +416,11 @@ async def handle_pr(payload: dict):
f"deployer verdict (check_deploy_status), ignoring merge-driven done."
)
return
update_task_stage(task_id, "done")
notify_stage_change(task_id, current_stage, "done")
logger.info(f"Task {task_id}: PR merged, stage → done")
# ORCH-114 (adr-0045 / D4, TR-4): merge-driven done writes the stage in BYPASS
# of advance_stage -> expected-stage CAS so a concurrent authoritative writer
# is not clobbered; a lost race skips the (idempotent) notify.
if transition_lease.commit_stage_cas(task_id, current_stage, "done", repo_name):
notify_stage_change(task_id, current_stage, "done")
logger.info(f"Task {task_id}: PR merged, stage → done")
else:
logger.info(f"Task {task_id}: merge-driven done stage-CAS lost ({current_stage}->done); another writer moved it")

View File

@@ -14,7 +14,6 @@ from ..db import (
get_task_by_plane_id,
get_next_work_item_id,
ensure_unique_work_item_id,
update_task_stage,
enqueue_job,
insert_event_dedup,
create_task_atomic,
@@ -35,6 +34,7 @@ from ..projects import (
get_project_by_repo,
known_plane_project_ids,
)
from .. import transition_lease
logger = logging.getLogger("orchestrator.webhooks.plane")
@@ -803,7 +803,17 @@ async def _rollback_stage(
if not prev_stage:
logger.info(f"Task {task_id}: rejected at {current_stage} but no previous stage")
return
update_task_stage(task_id, prev_stage)
# ORCH-114 (adr-0045 / D4, TR-4): this Rejected-rollback writes the stage in
# BYPASS of advance_stage. Route it through the expected-stage CAS so it can never
# clobber an authoritative write made by a concurrent owner (e.g. a deploy->done
# finalizer) — a lost race aborts the rollback WITHOUT its side effects. Kill-switch
# off / repo out of scope -> unconditional update (byte-for-byte).
if not transition_lease.commit_stage_cas(task_id, current_stage, prev_stage, repo):
logger.info(
f"Task {task_id}: rollback stage-CAS lost ({current_stage}->{prev_stage}) "
f"— task already moved by another writer; skipping rollback"
)
return
notify_stage_change(task_id, current_stage, prev_stage)
# Feature 3: plane_notify_stage moves the board to the prev stage's status.
plane_notify_stage(work_item_id, current_stage, prev_stage)
@@ -857,10 +867,25 @@ async def _try_advance_stage(
advance_stage). It is True ONLY on the "Confirm Deploy" path
(handle_confirm_deploy) and gates Phase B of the self-hosting prod deploy; the
plain Approved path (handle_verdict) leaves it at the default False.
ORCH-114 (adr-0045 / FR-5, AC-8): if a live actor already owns this task's
side-effectful transition (transition-lease active), DEFER — do not re-enter the
transition in parallel. The late legitimate signal is not lost: once the owner
releases (or dies and the reaper reclaims), a re-approve / the reconciler re-drives
it, or advance_stage becomes an idempotent no-op against the authoritative facts
(SHA-in-main / INITIATED). never raises; no-op when the lease is disabled / repo
out of scope.
"""
import asyncio
from ..stage_engine import advance_stage
if transition_lease.is_held_by_live_owner(task_id):
logger.info(
f"Task {task_id}: transition-lease active — deferring webhook advance "
f"from {current_stage} (confirm_deploy={confirm_deploy})"
)
return
await asyncio.to_thread(
advance_stage,
task_id,

View File

@@ -133,3 +133,56 @@ def _disable_merge_verify(monkeypatch):
_cfg.settings, "merge_verify_autocreate_pr_enabled", False, raising=False
)
yield
@pytest.fixture(autouse=True)
def _plane_sandbox_only(monkeypatch):
"""ORCH-117: fail-closed FLOOR — no test may write to a non-sandbox Plane project.
The independent second layer of the sandbox-only Plane-write guard (ADR-001 D5),
by the same model as ``_no_telegram``: it forces the safe defaults for EVERY
test, OVERRIDING any live variable inherited from the container environment.
With the opt-in OFF, ``src/plane_write_guard.decide`` blocks ALL Plane writes
from the test process (both sandbox and prod) -> default-deny (AC-4). Even if the
runtime leaf ever erroneously returned ALLOW, this floor keeps a prod write from
a plain ``pytest tests/`` impossible. Sandbox-e2e tests that need a REAL write to
SANDBOX re-enable the opt-in in their OWN fixture AFTER this autouse (exactly as
``test_orch114_*`` / ``test_merge_verify`` re-enable their flags); the allowlist
already contains the SANDBOX id, so the write to SANDBOX passes while a prod write
still blocks (allowlist sandbox-only, AC-3).
"""
from src import config as _cfg
monkeypatch.setattr(_cfg.settings, "plane_test_write_enabled", False, raising=False)
monkeypatch.setattr(
_cfg.settings,
"plane_test_sandbox_projects",
"8c5a3025-4f9d-4190-b79f-fa06276bb27e",
raising=False,
)
yield
@pytest.fixture(autouse=True)
def _disable_transition_lease(monkeypatch):
"""ORCH-114: disable the transition-ownership lease + expected-stage CAS by
default in ALL tests.
The prod default is ON for the self-hosting repo (``transition_lease_enabled=True``,
``transition_lease_repos=""`` -> orchestrator only). Left ON, the expected-stage
CAS (``update_task_stage_cas``) would change the stage-write semantics for every
existing test that calls ``advance_stage`` / the gitea-plane webhook handlers with
repo ``orchestrator`` (a CAS write needs the task row to actually BE at the
expected stage; the bare ``update_task_stage`` did not). We therefore default the
kill-switch OFF for the whole suite (mirrors ``_disable_merge_verify`` /
``_disable_*`` precedent), which makes ``commit_stage_cas`` degenerate to the prior
unconditional ``update_task_stage`` and the lease inert -> the existing 2000+ tests
stay byte-for-byte (AC-9). The dedicated ORCH-114 test module
(``test_orch114_transition_ownership.py``) re-enables it via its own monkeypatch,
scoping the feature ON to just those tests.
"""
from src import config as _cfg
monkeypatch.setattr(
_cfg.settings, "transition_lease_enabled", False, raising=False
)
yield

View File

@@ -0,0 +1,714 @@
"""ORCH-114 (adr-0045): durable transition-ownership lease + expected-stage CAS.
Covers FR-1…FR-7 / AC-1…AC-13 (TC-01..TC-14, see 04-test-plan.yaml). The mechanism
prevents a concurrent OR post-restart re-entry into a side-effectful stage transition
(``deploy-staging -> deploy`` sub-gates, ``deploy -> done`` merge-verify, Phase C
finalize) from re-applying an irreversible effect or producing a contradictory
rollback↔done — incident ORCH-111.
No network / no real git / no docker / no prod: the heavy edge sub-gates and the
finalization handlers are stubbed with call-counters and the DB is driven directly
(the same convention as test_orch113_reaper_finalizer_liveness.py).
The autouse conftest fixture defaults the kill-switch OFF for the whole suite; this
module re-enables it per test (``_enable``) so the feature is scoped ON here.
"""
import inspect
import os
import tempfile
import pytest
# NB: deliberately NO module-level os.environ["ORCH_DB_PATH"] setdefault — pinning the
# process-wide settings.db_path on first import is needless here (the autouse `fresh_db`
# fixture below isolates db_path per-test via monkeypatch). The cross-module settings
# singleton (e.g. ORCH_PROJECTS_JSON) is whoever imports `src` first; test_webhooks now
# pins its own registry per-test rather than relying on import order (ORCH-114 review P2).
os.environ.setdefault("ORCH_REPOS_DIR", tempfile.gettempdir())
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
import src.db as db
from src.db import init_db, get_db, get_job, update_task_stage_cas
import src.transition_lease as tl
import src.stage_engine as se
from src.job_reaper import JobReaper
_REPO = "orchestrator" # self-hosting -> transition_lease.applies(repo) is True
@pytest.fixture(autouse=True)
def fresh_db(tmp_path, monkeypatch):
dbfile = tmp_path / "orch114.db"
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
init_db()
# Reset the leaf's in-memory counters between tests (process-local module state).
with tl._LOCK:
for k in tl._COUNTERS:
tl._COUNTERS[k] = 0
yield
def _enable(monkeypatch, repos: str = ""):
"""Turn the ORCH-114 mechanism ON (it is OFF by default via conftest)."""
monkeypatch.setattr(db.settings, "transition_lease_enabled", True, raising=False)
monkeypatch.setattr(db.settings, "transition_lease_repos", repos, raising=False)
def _disable(monkeypatch):
monkeypatch.setattr(db.settings, "transition_lease_enabled", False, raising=False)
# --- helpers ----------------------------------------------------------------
def _make_task(stage="deploy-staging", repo=_REPO, branch="feature/orch114",
work_item_id="ORCH-114"):
conn = get_db()
cur = conn.execute(
"INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) "
"VALUES (?, ?, ?, ?, ?)",
(work_item_id, work_item_id, repo, branch, stage),
)
tid = cur.lastrowid
conn.commit()
conn.close()
return tid
def _task_stage(tid):
conn = get_db()
row = conn.execute("SELECT stage FROM tasks WHERE id=?", (tid,)).fetchone()
conn.close()
return row[0] if row else None
def _make_running_job(agent="deployer", repo=_REPO, task_id=None, pid=None,
age_s=0, attempts=0, max_attempts=2, run_id=None,
exit_code=0, finished_age_s=600):
conn = get_db()
if run_id is None and exit_code is not None:
cur = conn.execute(
"INSERT INTO agent_runs (task_id, agent, finished_at, exit_code) "
"VALUES (?, ?, datetime('now', ?), ?)",
(task_id, agent, f"-{int(finished_age_s)} seconds", exit_code),
)
run_id = cur.lastrowid
cur = conn.execute(
"INSERT INTO jobs (agent, repo, task_id, status, attempts, max_attempts, "
"run_id, pid, started_at) "
"VALUES (?, ?, ?, 'running', ?, ?, ?, ?, datetime('now', ?))",
(agent, repo, task_id, attempts, max_attempts, run_id, pid,
f"-{int(age_s)} seconds"),
)
job_id = cur.lastrowid
conn.commit()
conn.close()
return job_id
def _stub_side_effects(monkeypatch):
"""Patch the deploy-staging edge sub-gates + Phase A with call-counters.
Each sub-gate returns False (no intervention) so advance_stage proceeds to Phase
A; Phase A is stubbed to a counter that does NOT touch the network/prod. Returns
the shared ``counts`` dict.
"""
counts = {"security": 0, "merge": 0, "coverage": 0, "image": 0, "phase_a": 0}
def _mk(key):
def _fake(task_id, current_stage, repo, work_item_id, branch, agent, result):
counts[key] += 1
return False # no intervention -> advance continues
return _fake
monkeypatch.setattr(se, "_handle_security_gate", _mk("security"))
monkeypatch.setattr(se, "_handle_merge_gate", _mk("merge"))
monkeypatch.setattr(se, "_handle_coverage_gate", _mk("coverage"))
monkeypatch.setattr(se, "_handle_image_freshness", _mk("image"))
def _fake_phase_a(task_id, current_stage, repo, work_item_id, branch, result):
counts["phase_a"] += 1
result.advanced = True
result.to_stage = "deploy"
monkeypatch.setattr(se, "_handle_self_deploy_phase_a", _fake_phase_a)
# The QG (check_staging_status) is the entry gate; force it green so we reach the
# side-effectful sub-gates instead of rolling back.
monkeypatch.setattr(se, "_run_qg", lambda *a, **k: (True, "ok"))
return counts
# ===========================================================================
# TC-01 — MANDATORY regression: no double effect on concurrent entry (AC-1)
# ===========================================================================
def test_tc01_concurrent_entry_no_double_effect(monkeypatch):
_enable(monkeypatch)
counts = _stub_side_effects(monkeypatch)
tid = _make_task(stage="deploy-staging")
# Actor A — a LIVE finalizer — owns the transition (acquired on entry).
assert tl.acquire(tid, "monitor", stage="deploy-staging") is True
# Actor B (reaper/reconciler/webhook re-drive) enters the SAME transition.
res_b = se.advance_stage(tid, "deploy-staging", _REPO, "ORCH-114", "feature/orch114",
finished_agent=None)
# Busy -> deferred WITHOUT any side effect, stage unchanged.
assert res_b.note == "transition-lease-busy"
assert res_b.advanced is False
assert counts == {"security": 0, "merge": 0, "coverage": 0, "image": 0, "phase_a": 0}
assert _task_stage(tid) == "deploy-staging"
# The owner finishes (release), then drives the transition exactly once.
tl.release(tid, force=True)
res_a = se.advance_stage(tid, "deploy-staging", _REPO, "ORCH-114", "feature/orch114",
finished_agent="deployer")
# Each side-effectful step ran EXACTLY once (one consistent outcome).
assert counts == {"security": 1, "merge": 1, "coverage": 1, "image": 1, "phase_a": 1}
assert res_a.advanced is True
def test_tc01_red_before_fix_demonstration(monkeypatch):
"""With the kill-switch OFF (== before ORCH-114) the second actor is NOT blocked
and re-runs every sub-gate -> the double-effect bug. This is the RED that the
lease turns GREEN."""
_disable(monkeypatch)
counts = _stub_side_effects(monkeypatch)
tid = _make_task(stage="deploy-staging")
# acquire is a no-op when disabled -> no owner-exclusion.
assert tl.acquire(tid, "monitor", stage="deploy-staging") is True
se.advance_stage(tid, "deploy-staging", _REPO, "ORCH-114", "feature/orch114",
finished_agent=None)
# Without the lease the "second" actor ran the side effects (the bug).
assert counts["merge"] == 1 and counts["security"] == 1
# ===========================================================================
# TC-02 — expected-stage CAS on the stage write (AC-2)
# ===========================================================================
def test_tc02_cas_first_wins_second_lost(monkeypatch):
tid = _make_task(stage="review")
# First writer with the correct expectation wins.
assert update_task_stage_cas(tid, "review", "testing") is True
assert _task_stage(tid) == "testing"
# Second writer with the now-stale expectation loses; stage is NOT re-mutated.
assert update_task_stage_cas(tid, "review", "development") is False
assert _task_stage(tid) == "testing"
def test_tc02_commit_cas_killswitch_off_unconditional(monkeypatch):
"""Kill-switch off / repo out of scope -> commit_stage_cas degenerates to the
prior unconditional update_task_stage (byte-for-byte: the expected_stage is
ignored, the write always lands)."""
_disable(monkeypatch)
tid = _make_task(stage="review")
# Even a WRONG expected stage writes unconditionally when the mechanism is off.
assert tl.commit_stage_cas(tid, "totally-wrong", "testing", _REPO) is True
assert _task_stage(tid) == "testing"
def test_tc02_commit_cas_enabled_does_real_cas(monkeypatch):
_enable(monkeypatch)
tid = _make_task(stage="review")
# Wrong expectation -> CAS lost, no write.
assert tl.commit_stage_cas(tid, "wrong", "testing", _REPO) is False
assert _task_stage(tid) == "review"
# Correct expectation -> CAS won.
assert tl.commit_stage_cas(tid, "review", "testing", _REPO) is True
assert _task_stage(tid) == "testing"
# ===========================================================================
# TC-03 — ownership lifecycle: acquire / release / reclaim (AC-3)
# ===========================================================================
def test_tc03_acquire_release_visible_durably(monkeypatch):
_enable(monkeypatch)
tid = _make_task()
assert tl.is_held_by_live_owner(tid) is False
assert tl.acquire(tid, "monitor", run_id=7, stage="deploy-staging") is True
assert tl.is_held_by_live_owner(tid) is True
# Durable: a fresh DB read (snapshot) sees the holder.
snap = tl.snapshot()
assert snap["active"] == 1
assert snap["holders"][0]["task_id"] == tid
assert snap["holders"][0]["owner"] == "monitor"
assert snap["holders"][0]["live"] is True
# A second acquire by another actor is busy while the live owner holds it.
assert tl.acquire(tid, "reaper", stage="deploy-staging") is False
tl.release(tid, force=True)
assert tl.is_held_by_live_owner(tid) is False
def test_tc03_release_in_finally_on_exception(monkeypatch):
"""advance_stage must release the lease even when a sub-gate raises (try/finally)."""
_enable(monkeypatch)
monkeypatch.setattr(se, "_run_qg", lambda *a, **k: (True, "ok"))
def _boom(*a, **k):
raise RuntimeError("sub-gate exploded")
monkeypatch.setattr(se, "_handle_security_gate", _boom)
tid = _make_task(stage="deploy-staging")
res = se.advance_stage(tid, "deploy-staging", _REPO, "ORCH-114", "feature/orch114",
finished_agent="deployer")
# The outer except swallowed the error; the finally released the lease.
assert res.advanced is False
assert tl.is_held_by_live_owner(tid) is False
# ===========================================================================
# TC-04 — reaper defers on a live lease, cross-path (beyond deploy-staging) (AC-4)
# ===========================================================================
def test_tc04_reaper_defers_on_deploy_edge(monkeypatch):
"""ORCH-114 generalises ORCH-113 beyond Tier-2/deploy-staging: a live lease on the
deploy->done edge also defers the reaper."""
_enable(monkeypatch)
monkeypatch.setattr(JobReaper, "_gate_is_green",
lambda self, stage, job, branch, wid: True)
calls = []
import src.agents.launcher as L
monkeypatch.setattr(L.launcher, "_try_advance_stage",
lambda *a, **k: calls.append(a))
tid = _make_task(stage="deploy") # NOT deploy-staging -> proves generalisation
jid = _make_running_job(task_id=tid, exit_code=0, finished_age_s=600)
assert tl.acquire(tid, "finalizer", stage="deploy") is True
r = JobReaper()
r.reap_once()
assert get_job(jid)["status"] == "running" # not reaped
assert calls == [] # no second advance
assert r.finalizer_defers_total == 1
# ===========================================================================
# TC-05 — reaper reaps a dead/stale lease in bounded time (Tier-3) (AC-5)
# ===========================================================================
def test_tc05_tier3_backstop_reaps_and_releases_lease(monkeypatch):
_enable(monkeypatch)
monkeypatch.setattr(db.settings, "reaper_max_running_s", 1000)
tid = _make_task(stage="deploy")
jid = _make_running_job(task_id=tid, exit_code=0, finished_age_s=10,
age_s=2000, attempts=0, max_attempts=2)
assert tl.acquire(tid, "finalizer", stage="deploy") is True
r = JobReaper()
r.reap_once()
# Backstop reaps regardless of the marker; the lease is force-released with the job.
assert get_job(jid)["status"] == "queued"
assert tl.is_held_by_live_owner(tid) is False
def test_tc05_reclaim_if_stale_removes_dead_boot_row(monkeypatch):
_enable(monkeypatch)
tid = _make_task()
# A row from a PREVIOUS process boot (a dead owner) is stale.
conn = get_db()
conn.execute(
"INSERT INTO transition_lease (task_id, owner, owner_pid, owner_boot_id) "
"VALUES (?, 'monitor', 1, 'OLD-DEAD-BOOT')",
(tid,),
)
conn.commit()
conn.close()
assert tl.is_held_by_live_owner(tid) is False # stale -> not live
assert tl.reclaim_if_stale(tid) is True
assert tl.snapshot()["active"] == 0
def test_tc05_budget_invariant_preserved():
"""The lease introduced no new TTL; the cross-cutting reaper budget is untouched."""
s = db.settings
assert s.reaper_max_running_s == 5400
assert s.reaper_finalize_grace_s == 300
sigma = s.merge_retest_timeout_s + s.coverage_run_timeout_s
assert s.reaper_max_running_s > sigma + s.reaper_finalize_grace_s
# ===========================================================================
# TC-06 — smart restart recovery (AC-6)
# ===========================================================================
def test_tc06_recover_on_startup_clears_previous_boot_lease(monkeypatch):
_enable(monkeypatch)
tid = _make_task(stage="deploy")
# Simulate a process that died MID-finalization: a lease row with a DIFFERENT boot.
conn = get_db()
conn.execute(
"INSERT INTO transition_lease (task_id, owner, owner_pid, owner_boot_id) "
"VALUES (?, 'finalizer', 999999, 'PREVIOUS-BOOT')",
(tid,),
)
conn.commit()
conn.close()
# Before recovery the row is stale (boot mismatch) -> not a live owner.
assert tl.is_held_by_live_owner(tid) is False
# Startup recovery (after requeue_running_jobs) clears it deterministically.
assert tl.recover_on_startup() == 1
assert tl.snapshot()["active"] == 0
# The requeued job can now re-drive the transition cleanly (no stale owner blocks).
assert tl.acquire(tid, "monitor", stage="deploy") is True
def test_tc06_recovery_does_not_touch_current_boot_lease(monkeypatch):
"""A lease this very process holds must NOT be cleared by recovery (only previous
boots are stale)."""
_enable(monkeypatch)
tid = _make_task()
assert tl.acquire(tid, "monitor", stage="deploy-staging") is True
assert tl.recover_on_startup() == 0 # current-boot lease is live, kept
assert tl.is_held_by_live_owner(tid) is True
# ===========================================================================
# TC-07 — reconciler F-1 defers on an active lease (AC-7)
# ===========================================================================
def test_tc07_reconciler_f1_defers(monkeypatch):
_enable(monkeypatch)
from src.reconciler import Reconciler
import src.reconciler as rec
# Spy on the advance path; it must NOT be called while the lease is held.
advanced = []
monkeypatch.setattr(rec, "advance_if_gate_passed",
lambda *a, **k: advanced.append(a))
# Pass the cheap local guards so we reach the lease check.
monkeypatch.setattr(rec, "has_active_job_for_task", lambda *a, **k: False)
monkeypatch.setattr(rec, "developer_retry_count", lambda *a, **k: 0)
monkeypatch.setattr(rec, "MAX_DEVELOPER_RETRIES", 3, raising=False)
monkeypatch.setattr(rec, "grace_for_stage", lambda *a, **k: 0)
r = Reconciler()
monkeypatch.setattr(r, "_resolve_issue_status", lambda task: ({}, {}, None))
monkeypatch.setattr(r, "_is_terminal_state", lambda *a, **k: False)
monkeypatch.setattr(r, "_is_blocked_or_needs_input", lambda *a, **k: False)
tid = _make_task(stage="review")
assert tl.acquire(tid, "monitor", stage="review") is True
r._reconcile_gate_task({
"id": tid, "stage": "review", "repo": _REPO,
"work_item_id": "ORCH-114", "branch": "feature/orch114", "age_s": 10_000,
})
assert advanced == [] # F-1 deferred
assert r.transition_lease_defers_total == 1
# ===========================================================================
# TC-08 — webhook path defers on an active lease (AC-8)
# ===========================================================================
def test_tc08_plane_webhook_defers(monkeypatch):
_enable(monkeypatch)
import asyncio
from src.webhooks.plane import _try_advance_stage
called = []
monkeypatch.setattr(se, "advance_stage", lambda *a, **k: called.append(a))
tid = _make_task(stage="deploy")
assert tl.acquire(tid, "finalizer", stage="deploy") is True
# Lease held -> the webhook advance is deferred (advance_stage NOT invoked).
asyncio.run(_try_advance_stage(tid, "deploy", _REPO, "ORCH-114", "feature/orch114"))
assert called == []
# The late legitimate signal is not lost: after release it advances.
tl.release(tid, force=True)
asyncio.run(_try_advance_stage(tid, "deploy", _REPO, "ORCH-114", "feature/orch114"))
assert len(called) == 1
# ===========================================================================
# TC-09 — kill-switch off -> byte-for-byte prior (AC-9)
# ===========================================================================
def test_tc09_killswitch_off_inert(monkeypatch):
_disable(monkeypatch)
tid = _make_task(stage="review")
# Lease neither written nor read.
assert tl.acquire(tid, "monitor", stage="review") is True # no-op True
assert tl.is_held_by_live_owner(tid) is False
assert tl.snapshot()["enabled"] is False
assert tl.snapshot()["active"] == 0
# CAS degenerates to the unconditional update (expected ignored).
assert tl.commit_stage_cas(tid, "anything", "testing", _REPO) is True
assert _task_stage(tid) == "testing"
def test_tc09_applies_scope(monkeypatch):
_enable(monkeypatch) # empty repos CSV -> self-hosting only
assert tl.applies("orchestrator") is True
assert tl.applies("enduro-trails") is False
# Explicit CSV scope.
_enable(monkeypatch, repos="enduro-trails")
assert tl.applies("enduro-trails") is True
assert tl.applies("orchestrator") is False
# ===========================================================================
# TC-10 — never-raise + fail-open (hot path) / fail-closed (prod safety) (AC-10)
# ===========================================================================
def test_tc10_never_raise_on_db_error(monkeypatch):
_enable(monkeypatch)
def _boom(*a, **k):
raise RuntimeError("DB exploded")
monkeypatch.setattr(tl.db, "get_db", _boom)
# acquire -> fail-CLOSED (busy) so a side-effectful caller DEFERS (no double effect).
assert tl.acquire(123, "monitor", stage="deploy") is False
# is_held_by_live_owner -> fail-CLOSED (treat as held -> conservative defer).
assert tl.is_held_by_live_owner(123) is True
# release / reclaim / recover / snapshot never raise.
tl.release(123, force=True)
assert tl.reclaim_if_stale(123) is False
assert tl.recover_on_startup() == 0
assert isinstance(tl.snapshot(), dict)
def test_tc10_commit_cas_error_aborts_write(monkeypatch):
_enable(monkeypatch)
monkeypatch.setattr(tl.db, "update_task_stage_cas",
lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")))
# CAS error -> abort the write (never a blind overwrite) -> False, no raise.
assert tl.commit_stage_cas(1, "review", "testing", _REPO) is False
def test_tc10_hot_claim_path_not_touched():
"""AC-8 ORCH-088 intact: the hot claim path does NOT consult the transition-lease,
so a lease bug can never wedge the shared queue (fail-open by construction)."""
src_claim = inspect.getsource(db.claim_next_job)
assert "transition_lease" not in src_claim
# ===========================================================================
# TC-11 — structural audit: pipeline invariants untouched, storage additive (AC-11)
# ===========================================================================
def test_tc11_stage_transitions_and_qg_untouched():
from src.stages import STAGE_TRANSITIONS
from src.qg.checks import QG_CHECKS
# The canonical edge order is intact (no new stages/edges).
assert STAGE_TRANSITIONS["deploy-staging"]["next"] == "deploy"
assert STAGE_TRANSITIONS["deploy-staging"]["qg"] == "check_staging_status"
assert STAGE_TRANSITIONS["deploy"]["next"] == "done"
# The QG registry still carries the machine-verdict gates byte-for-byte.
for name in ("check_staging_status", "check_deploy_status", "check_coverage_gate"):
assert name in QG_CHECKS
def test_tc11_storage_additive_existing_tables_unchanged():
conn = get_db()
# The additive table exists (CREATE TABLE IF NOT EXISTS).
row = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='transition_lease'"
).fetchone()
assert row is not None
# `tasks` schema is byte-for-byte: NO epoch/version column was added (ADR D2).
cols = {r[1] for r in conn.execute("PRAGMA table_info(tasks)").fetchall()}
conn.close()
assert "epoch" not in cols and "version" not in cols
assert {"id", "stage", "repo", "branch", "work_item_id"} <= cols
def test_tc11_bypass_paths_use_cas_not_unconditional_write():
"""The 6 bypass writers (gitea x5 + plane rollback) + the main advance write route
through commit_stage_cas; none does an unconditional update_task_stage on the
concurrent path (TR-4)."""
import src.webhooks.gitea as g
import src.webhooks.plane as p
gsrc = inspect.getsource(g)
assert "commit_stage_cas" in gsrc
# The gitea handlers no longer import / call the bare update_task_stage.
assert "update_task_stage(" not in gsrc
psrc = inspect.getsource(p._rollback_stage)
assert "commit_stage_cas" in psrc
assert "update_task_stage(" not in psrc
# The main advance write uses CAS.
asrc = inspect.getsource(se.advance_stage)
assert "commit_stage_cas(task_id, current_stage, next_stage" in asrc
def test_tc11_inregion_rollback_writes_use_cas(monkeypatch):
"""ADR-001 D4: the four side-effectful-edge rollback handlers
(_handle_merge_gate_rollback / _handle_security_gate / _handle_coverage_gate /
_handle_image_freshness) write `development` through the expected-stage CAS
(via _rollback_stage_cas), NOT a bare unconditional update_task_stage. (The
non-side-effectful launcher rollbacks in _handle_qg_failure_rollbacks are out of
scope — no lease is held there.)"""
for fn in (
se._handle_merge_gate_rollback,
se._handle_security_gate,
se._handle_coverage_gate,
se._handle_image_freshness,
):
src = inspect.getsource(fn)
assert "_rollback_stage_cas(task_id, current_stage, repo, result)" in src, (
f"{fn.__name__} must route the rollback write through the CAS helper"
)
assert 'update_task_stage(task_id, "development")' not in src, (
f"{fn.__name__} must not do a bare unconditional rollback write"
)
# The helper itself goes through commit_stage_cas.
assert "commit_stage_cas(task_id, current_stage" in inspect.getsource(
se._rollback_stage_cas
)
def test_tc11_rollback_cas_wins_when_at_expected_stage(monkeypatch):
"""With the mechanism ON, a rollback whose task is STILL at current_stage wins the
CAS -> the stage is written to `development` and the caller proceeds (returns True)."""
_enable(monkeypatch)
tid = _make_task(stage="deploy-staging")
result = se.AdvanceResult()
assert se._rollback_stage_cas(tid, "deploy-staging", _REPO, result) is True
assert _task_stage(tid) == "development"
assert result.note != "rollback-cas-lost"
def test_tc11_rollback_cas_lost_aborts_without_overwriting_done(monkeypatch):
"""BR-6 / ADR-001 D4: if a concurrent winner already advanced the task to `done`,
the stale rollback LOSES the expected-stage CAS -> it must NOT overwrite `done`
with `development`, and the caller aborts the rollback side effects."""
_enable(monkeypatch)
tid = _make_task(stage="deploy-staging")
# Simulate a concurrent winner having advanced the task to terminal `done`.
conn = get_db()
conn.execute("UPDATE tasks SET stage='done' WHERE id=?", (tid,))
conn.commit()
conn.close()
result = se.AdvanceResult()
# The rollback still believes current_stage is deploy-staging (its read-on-entry).
assert se._rollback_stage_cas(tid, "deploy-staging", _REPO, result) is False
assert _task_stage(tid) == "done" # NOT clobbered back to development
assert result.note == "rollback-cas-lost"
def test_tc11_rollback_cas_killswitch_off_unconditional(monkeypatch):
"""Kill-switch off -> _rollback_stage_cas degenerates to the prior unconditional
write (always True, no CAS), so behaviour is byte-for-byte pre-ORCH-114 (AC-9)."""
_disable(monkeypatch)
tid = _make_task(stage="done") # even a mismatched stage writes unconditionally
result = se.AdvanceResult()
assert se._rollback_stage_cas(tid, "deploy-staging", _REPO, result) is True
assert _task_stage(tid) == "development"
# ===========================================================================
# TC-12 — observability (AC-12)
# ===========================================================================
def test_tc12_snapshot_shape_and_counters(monkeypatch):
_enable(monkeypatch)
tid = _make_task(stage="deploy-staging")
tl.acquire(tid, "monitor", run_id=3, stage="deploy-staging")
snap = tl.snapshot()
assert snap["enabled"] is True
assert snap["active"] == 1
assert set(snap.keys()) >= {"enabled", "repos", "boot_id", "active", "holders", "counters"}
h = snap["holders"][0]
assert {"task_id", "owner", "stage", "age_s", "live"} <= set(h.keys())
assert snap["counters"]["acquired_total"] >= 1
def test_tc12_forced_reclaim_emits_telegram(monkeypatch):
_enable(monkeypatch)
sent = []
monkeypatch.setattr("src.notifications.send_telegram",
lambda *a, **k: sent.append(a), raising=False)
tid = _make_task()
# A previous-boot (stale) lease that recovery force-reclaims at startup.
conn = get_db()
conn.execute(
"INSERT INTO transition_lease (task_id, owner, owner_pid, owner_boot_id) "
"VALUES (?, 'finalizer', 1, 'PREV-BOOT')",
(tid,),
)
conn.commit()
conn.close()
assert tl.recover_on_startup() == 1
assert len(sent) == 1 # forced/stale reclaim is observable via Telegram
def test_tc12_queue_block_wired():
"""GET /queue carries the additive transition_lease block (read-only)."""
import src.main as main_mod
qsrc = inspect.getsource(main_mod.queue)
assert '"transition_lease": transition_lease.snapshot()' in qsrc
# ===========================================================================
# TC-13 — self-hosting safety (AC-13)
# ===========================================================================
def _code_only(module) -> str:
"""Return the module source with comments AND string literals stripped, so a
structural audit scans EXECUTABLE code only (not docstring prose). Mirrors the
tokenize approach of tests/test_no_host_hardcodes.py."""
import io
import tokenize
src = inspect.getsource(module)
out = []
for tok in tokenize.generate_tokens(io.StringIO(src).readline):
if tok.type in (tokenize.COMMENT, tokenize.STRING):
continue
out.append(tok.string)
return " ".join(out)
def test_tc13_leaf_has_no_dangerous_side_effects():
"""The ownership mechanism never restarts the prod container, never pushes /
force-pushes main, never spawns a subprocess and never touches the detached
deploy process. Scans EXECUTABLE code only (docstring prose is excluded)."""
code = _code_only(tl)
forbidden = ["subprocess", "system", "docker", "force_push", "Popen",
"os.kill", "restart", "rmtree", "remove"]
for token in forbidden:
assert token not in code, f"transition_lease must not reference {token!r} in code"
def test_tc13_leaf_imports_only_safe_modules():
"""The leaf imports only db + config at module load (lazily merge_gate / qg /
notifications) — it never imports stage_engine / launcher / self_deploy."""
src_tl = inspect.getsource(tl)
assert "import stage_engine" not in src_tl
assert "from .stage_engine" not in src_tl
assert "import launcher" not in src_tl
assert "self_deploy" not in src_tl
# ===========================================================================
# TC-14 — full pipeline happy-path with the mechanism ON (BR-8)
# ===========================================================================
def test_tc14_single_actor_happy_path_one_set_of_effects(monkeypatch):
"""A single advance on deploy-staging with the mechanism ON runs each sub-gate
exactly once and leaves NO lease behind (clean acquire+release)."""
_enable(monkeypatch)
counts = _stub_side_effects(monkeypatch)
tid = _make_task(stage="deploy-staging")
res = se.advance_stage(tid, "deploy-staging", _REPO, "ORCH-114", "feature/orch114",
finished_agent="deployer")
assert counts == {"security": 1, "merge": 1, "coverage": 1, "image": 1, "phase_a": 1}
assert res.advanced is True
# The lease was released in the finally (no leak).
assert tl.is_held_by_live_owner(tid) is False
def test_tc14_deploy_to_done_finalize_advances_via_cas(monkeypatch):
"""The deploy->done finalize path (Phase C) reaches the terminal write via the CAS
and releases the lease (single consistent done)."""
_enable(monkeypatch)
monkeypatch.setattr(se, "_run_qg", lambda *a, **k: (True, "ok"))
# merge-verify CONFIRMED (no HOLD) so advance proceeds to done.
monkeypatch.setattr(se, "_handle_merge_verify", lambda *a, **k: False)
# Avoid post-deploy / plane side effects on the done write.
monkeypatch.setattr(se.post_deploy, "post_deploy_applies", lambda *a, **k: False)
monkeypatch.setattr(se, "set_issue_done", lambda *a, **k: None, raising=False)
monkeypatch.setattr(se.merge_gate, "release_merge_lease", lambda *a, **k: None)
monkeypatch.setattr(se, "enqueue_job", lambda *a, **k: 1, raising=False)
tid = _make_task(stage="deploy")
res = se.advance_stage(tid, "deploy", _REPO, "ORCH-114", "feature/orch114",
finished_agent="deployer")
assert res.advanced is True
assert _task_stage(tid) == "done"
assert tl.is_held_by_live_owner(tid) is False

View File

@@ -0,0 +1,287 @@
"""ORCH-117 (adr-0046): sandbox-only fail-closed isolation of Plane WRITES.
Regression of the ORCH-114 incident: a pytest/worktree process performed a REAL
``PATCH …/issues/… state=<Done>`` + comment against the PRODUCTION Plane project,
because test/staging processes inherit the live Plane token and nothing forced them
to write only to the sandbox. This suite pins the fix (``src/plane_write_guard.py``
врезка in the three ``plane_sync`` write primitives + the conftest floor).
Covers TC-01…TC-14 (see docs/work-items/ORCH-117/04-test-plan.yaml). httpx is mocked
throughout — there are NO real network calls (a prod write is the very thing the fix
forbids). The autouse conftest fixture ``_plane_sandbox_only`` sets the safe floor
(opt-in OFF, sandbox allowlist = the one SANDBOX id) for the whole suite; ALLOW-path
tests re-enable the opt-in in their own monkeypatch AFTER it (the documented pattern).
TC-01 is the MANDATORY incident regression: it is RED before the fix (без the
guard врезка the call reaches ``httpx.patch``/``httpx.post``) and GREEN after.
"""
import logging
import os
# Match the env-default convention of the other plane suites so config loads cleanly.
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
os.environ.setdefault("ORCH_PLANE_WORKSPACE_SLUG", "test-ws")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
from unittest.mock import MagicMock, patch # noqa: E402
import pytest # noqa: E402
from src import config as _cfg # noqa: E402
from src import plane_sync as PS # noqa: E402
from src import plane_write_guard as PWG # noqa: E402
# Project ids (verified literals — TRZ §3 / ADR-001 / test-plan notes).
PROD = "7a79f0a9-5278-49cd-9007-9a338f238f9c" # a live (non-sandbox) project.
SANDBOX = "8c5a3025-4f9d-4190-b79f-fa06276bb27e" # the one allowed sandbox project.
# --------------------------------------------------------------------------- #
# Helpers / fixtures
# --------------------------------------------------------------------------- #
def _opt_in(monkeypatch, projects: str = SANDBOX):
"""Turn the sandbox-write opt-in ON (it is OFF by default via the conftest floor)."""
monkeypatch.setattr(_cfg.settings, "plane_test_write_enabled", True, raising=False)
monkeypatch.setattr(_cfg.settings, "plane_test_sandbox_projects", projects, raising=False)
def _mock_httpx():
"""Patch ``plane_sync.httpx`` so any patch/post/get is RECORDED, never sent."""
return patch.object(PS, "httpx", MagicMock())
def _resp_ok():
r = MagicMock()
r.status_code = 200
r.raise_for_status.return_value = None
return r
@pytest.fixture
def _network_stubs():
"""Stub the network helpers so an ALLOWED write would reach httpx (not the DB/API)."""
with patch.object(PS, "find_issue_id", return_value="issue-uuid"), \
patch.object(PS, "stage_to_state", return_value="state-uuid"):
yield
# --------------------------------------------------------------------------- #
# TC-01 — MANDATORY regression of the ORCH-114 incident.
# --------------------------------------------------------------------------- #
def test_tc01_notify_stage_change_prod_makes_zero_writes(monkeypatch):
"""A live prod token in PLANE_HEADERS + pytest + the incident call
``notify_stage_change('ORCH-114','deploy','done')`` against the prod project ->
ZERO real httpx.patch/post. RED before the guard врезка, GREEN after."""
# Mirror the incident: a REAL prod token is captured in the module headers.
monkeypatch.setattr(PS, "PLANE_HEADERS", {"X-API-Key": "LIVE-PROD-TOKEN"}, raising=False)
# No opt-in (default floor) — exactly a normal `pytest tests/` run.
with _mock_httpx() as mock_httpx, \
patch.object(PS, "find_issue_id", return_value="issue-uuid"), \
patch.object(PS, "stage_to_state", return_value="state-uuid"):
PS.notify_stage_change("ORCH-114", "deploy", "done", project_id=PROD)
mock_httpx.patch.assert_not_called()
mock_httpx.post.assert_not_called()
# --------------------------------------------------------------------------- #
# TC-02 / TC-03 / TC-04 — each write primitive blocks a prod target in-test.
# --------------------------------------------------------------------------- #
def test_tc02_update_issue_state_prod_blocked(monkeypatch, caplog, _network_stubs):
"""update_issue_state -> prod project -> httpx.patch NOT called; reason prod-project-in-test."""
_opt_in(monkeypatch) # opt-in ON so the BLOCK reason is the allowlist, not opt-in-off.
with _mock_httpx() as mock_httpx, caplog.at_level(logging.INFO, logger="orchestrator.plane_write_guard"):
PS.update_issue_state("ORCH-1", "done", project_id=PROD)
mock_httpx.patch.assert_not_called()
assert PWG.R_PROD_IN_TEST in caplog.text
def test_tc03_add_comment_prod_blocked(monkeypatch, _network_stubs):
"""add_comment -> prod project -> httpx.post NOT called."""
_opt_in(monkeypatch)
with _mock_httpx() as mock_httpx:
PS.add_comment("ORCH-1", "hello", project_id=PROD)
mock_httpx.post.assert_not_called()
def test_tc04_set_issue_state_direct_prod_blocked(monkeypatch, _network_stubs):
"""_set_issue_state_direct (the primitive every set_issue_* funnels into) ->
prod project -> httpx.patch NOT called."""
_opt_in(monkeypatch)
with _mock_httpx() as mock_httpx:
PS._set_issue_state_direct("ORCH-1", "state-uuid", project_id=PROD)
mock_httpx.patch.assert_not_called()
def test_tc04_set_issue_done_prod_blocked(monkeypatch):
"""set_issue_done -> _set_issue_state_direct -> prod -> blocked (covers the
public set_issue_* surface, which all reduce to the guarded primitive)."""
_opt_in(monkeypatch)
with _mock_httpx() as mock_httpx, \
patch.object(PS, "get_project_states", return_value={"done": "done-uuid"}), \
patch.object(PS, "find_issue_id", return_value="issue-uuid"):
PS.set_issue_done("ORCH-1", project_id=PROD)
mock_httpx.patch.assert_not_called()
# --------------------------------------------------------------------------- #
# TC-05 — default-deny: without opt-in, EVERY target (incl. sandbox) is blocked.
# --------------------------------------------------------------------------- #
def test_tc05_default_deny_blocks_sandbox_and_prod(_network_stubs):
"""No opt-in (conftest floor) -> sandbox AND prod both blocked."""
with _mock_httpx() as mock_httpx:
PS.update_issue_state("ORCH-1", "done", project_id=SANDBOX)
PS.update_issue_state("ORCH-1", "done", project_id=PROD)
mock_httpx.patch.assert_not_called()
# Verdict-level: the reason is opt-in-disabled for both.
assert PS.plane_write_guard.decide(SANDBOX, "state")[1] == PWG.R_OPT_IN_DISABLED
assert PS.plane_write_guard.decide(PROD, "state")[1] == PWG.R_OPT_IN_DISABLED
# --------------------------------------------------------------------------- #
# TC-06 — sandbox allow: opt-in ON + sandbox project -> real (mocked) write fires.
# --------------------------------------------------------------------------- #
def test_tc06_sandbox_optin_allows_write(monkeypatch, _network_stubs):
"""opt-in ON + SANDBOX -> httpx.patch IS called, addressed to the sandbox URL."""
_opt_in(monkeypatch)
with _mock_httpx() as mock_httpx:
mock_httpx.patch.return_value = _resp_ok()
PS.update_issue_state("ORCH-1", "done", project_id=SANDBOX)
mock_httpx.patch.assert_called_once()
url = mock_httpx.patch.call_args.args[0]
assert SANDBOX in url
assert PROD not in url
# --------------------------------------------------------------------------- #
# TC-07 — sandbox-only even with opt-in: a prod target is ALWAYS blocked.
# --------------------------------------------------------------------------- #
def test_tc07_optin_still_blocks_prod(monkeypatch):
"""opt-in ON does NOT unlock prod — the allowlist is sandbox-only (AC-3)."""
_opt_in(monkeypatch)
ok, reason = PS.plane_write_guard.decide(PROD, "state", "ORCH-1")
assert ok is False
assert reason == PWG.R_PROD_IN_TEST
# --------------------------------------------------------------------------- #
# TC-08 — fail-closed on ambiguity: empty/None target -> block.
# --------------------------------------------------------------------------- #
def test_tc08_ambiguous_target_blocked(monkeypatch):
"""opt-in ON but project_id empty/None -> block (NFR-1 'don't know => don't write')."""
_opt_in(monkeypatch)
assert PS.plane_write_guard.decide("", "state")[1] == PWG.R_AMBIGUOUS
assert PS.plane_write_guard.decide(None, "comment")[1] == PWG.R_AMBIGUOUS
assert PS.plane_write_guard.decide(" ", "state")[1] == PWG.R_AMBIGUOUS
# --------------------------------------------------------------------------- #
# TC-09 — immune to the import-time token capture (AC-7 / NFR-4).
# --------------------------------------------------------------------------- #
def test_tc09_blocks_regardless_of_captured_token(monkeypatch, _network_stubs):
"""A REAL token in PLANE_HEADERS (captured at import) does not help: the guard
decides at CALL time on (test-process + target project), not on the token, and
does not rely on os.environ.setdefault / a settings token swap."""
monkeypatch.setattr(PS, "PLANE_HEADERS", {"X-API-Key": "LIVE-PROD-TOKEN"}, raising=False)
# No opt-in: a plain pytest run with a live token still cannot mutate prod.
with _mock_httpx() as mock_httpx:
PS.update_issue_state("ORCH-1", "done", project_id=PROD)
PS._set_issue_state_direct("ORCH-1", "state-uuid", project_id=PROD)
mock_httpx.patch.assert_not_called()
# The verdict is token-independent.
assert PS.plane_write_guard.decide(PROD, "state")[0] is False
# --------------------------------------------------------------------------- #
# TC-10 — zero regression of the LIVE runtime: not-a-test -> guard is a no-op.
# --------------------------------------------------------------------------- #
def test_tc10_live_runtime_is_noop(monkeypatch, _network_stubs):
"""Simulate a non-pytest process -> guard ALLOWs (live-runtime) and the prod
write goes out byte-for-byte (same URL/headers/payload as before ORCH-117)."""
monkeypatch.setattr(PWG, "_in_test_process", lambda: False)
monkeypatch.setattr(PS, "PLANE_HEADERS", {"X-API-Key": "LIVE-PROD-TOKEN"}, raising=False)
with _mock_httpx() as mock_httpx:
mock_httpx.patch.return_value = _resp_ok()
PS.update_issue_state("ORCH-1", "done", project_id=PROD)
mock_httpx.patch.assert_called_once()
args, kwargs = mock_httpx.patch.call_args
assert PROD in args[0]
assert kwargs["headers"] == {"X-API-Key": "LIVE-PROD-TOKEN"}
assert kwargs["json"] == {"state": "state-uuid"}
# The verdict itself is ALLOW/live-runtime.
assert PWG.decide(PROD, "state") == (True, PWG.R_LIVE_RUNTIME)
# --------------------------------------------------------------------------- #
# TC-11 — staging runtime (not pytest) writes to SANDBOX normally.
# --------------------------------------------------------------------------- #
def test_tc11_staging_writes_sandbox(monkeypatch, _network_stubs):
"""Staging is a real uvicorn process (not pytest) on the sandbox project ->
the test-process detection does NOT fire, the write to SANDBOX passes."""
monkeypatch.setattr(PWG, "_in_test_process", lambda: False)
with _mock_httpx() as mock_httpx:
mock_httpx.patch.return_value = _resp_ok()
PS.update_issue_state("ORCH-1", "done", project_id=SANDBOX)
mock_httpx.patch.assert_called_once()
assert SANDBOX in mock_httpx.patch.call_args.args[0]
# --------------------------------------------------------------------------- #
# TC-12 — audit/observability of block (loud) and allow (info).
# --------------------------------------------------------------------------- #
def test_tc12_block_audited_loudly(monkeypatch, caplog, _network_stubs):
"""A blocked write emits a structured WARNING/ERROR carrying project_id /
work_item / op / reason."""
_opt_in(monkeypatch)
with caplog.at_level(logging.INFO, logger="orchestrator.plane_write_guard"), _mock_httpx():
PS.update_issue_state("ORCH-114", "done", project_id=PROD)
blocks = [r for r in caplog.records if r.levelno >= logging.WARNING]
assert blocks, "a block must emit at least one WARNING/ERROR record"
text = caplog.text
assert PROD in text and "ORCH-114" in text
assert PWG.OP_STATE in text and PWG.R_PROD_IN_TEST in text
def test_tc12_sandbox_allow_audited_info(monkeypatch, caplog, _network_stubs):
"""An allowed sandbox write emits an INFO audit line."""
_opt_in(monkeypatch)
with caplog.at_level(logging.INFO, logger="orchestrator.plane_write_guard"), \
_mock_httpx() as mock_httpx:
mock_httpx.patch.return_value = _resp_ok()
PS.update_issue_state("ORCH-1", "done", project_id=SANDBOX)
infos = [r for r in caplog.records if r.levelno == logging.INFO and "ALLOWED" in r.message]
assert infos, "an allowed sandbox write must emit an INFO audit line"
assert SANDBOX in caplog.text
# --------------------------------------------------------------------------- #
# TC-13 — the autouse conftest floor protects the whole suite by default.
# --------------------------------------------------------------------------- #
def test_tc13_conftest_floor_default_deny():
"""Without any per-test opt-in, the floor leaves the opt-in OFF and the sandbox
allowlist pinned to the one SANDBOX id -> a representative write to prod is a
no-op (default-deny is active for every test, not just this file)."""
assert _cfg.settings.plane_test_write_enabled is False
assert _cfg.settings.plane_test_sandbox_projects == SANDBOX
with _mock_httpx() as mock_httpx, \
patch.object(PS, "find_issue_id", return_value="issue-uuid"), \
patch.object(PS, "stage_to_state", return_value="state-uuid"):
PS.update_issue_state("ORCH-2", "done", project_id=PROD)
mock_httpx.patch.assert_not_called()
# --------------------------------------------------------------------------- #
# TC-14 — kill-switch без чёрного хода (NFR-6 / FR-7 / D4 anti-drift).
# --------------------------------------------------------------------------- #
def test_tc14_no_killswitch_backdoor(monkeypatch):
"""There is intentionally NO ``plane_write_guard_enabled`` kill-switch that
re-opens a prod write from pytest. The only reversible regulator is the
sandbox-bound opt-in; even with it ON, prod stays blocked."""
# Anti-drift: the back-door config key must not exist (a future agent adding it
# would reintroduce the ORCH-114 defect — see ADR-001 D4 / TR-4).
assert not hasattr(_cfg.settings, "plane_write_guard_enabled")
# Opt-in ON is sandbox-bound, never a prod back-door.
_opt_in(monkeypatch)
assert PWG.decide(PROD, "state")[0] is False
assert PWG.decide(SANDBOX, "state")[0] is True

View File

@@ -16,11 +16,27 @@ import os
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "shared-token")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
import pytest # noqa: E402
from unittest.mock import patch, MagicMock # noqa: E402
from src import plane_sync # noqa: E402
@pytest.fixture(autouse=True)
def _allow_plane_writes(monkeypatch):
"""ORCH-117: these tests exercise the write primitives' header/URL routing and
assert on the (mocked) httpx call. The fail-closed sandbox guard (conftest
``_plane_sandbox_only``) would otherwise block the write in-process (proj is not
a sandbox id + opt-in off). Bypass the guard verdict here so the network-shape
assertions still run; the guard ITSELF is covered by
tests/test_orch117_plane_write_isolation.py."""
monkeypatch.setattr(
plane_sync.plane_write_guard, "decide",
lambda *a, **k: (True, "test-bypass"), raising=False,
)
yield
# --------------------------------------------------------------------------- #
# _headers_for
# --------------------------------------------------------------------------- #

View File

@@ -15,11 +15,24 @@ import os
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
import pytest # noqa: E402
from unittest.mock import patch, MagicMock # noqa: E402
from src import plane_sync as PS # noqa: E402
@pytest.fixture(autouse=True)
def _allow_plane_writes(monkeypatch):
"""ORCH-117: bypass the fail-closed sandbox write-guard so these layer-B
URL/state-resolution assertions still reach the (mocked) httpx.patch. The guard
itself is covered by tests/test_orch117_plane_write_isolation.py."""
monkeypatch.setattr(
PS.plane_write_guard, "decide",
lambda *a, **k: (True, "test-bypass"), raising=False,
)
yield
# A per-project state map that DEFINES the new ORCH-066 statuses with distinct
# UUIDs, so we can prove the dedicated status (not the base alias) is used.
_STATES_WITH_NEW = {

View File

@@ -33,6 +33,14 @@ def fresh_cache(monkeypatch):
ps.reload_project_labels()
monkeypatch.setattr(ps, "_resolve_project_id", lambda w=None, p=None: "proj-1")
monkeypatch.setattr(ps.settings, "auto_label_states_ttl_s", 300, raising=False)
# ORCH-117: the TC-09 set_issue_approved test reaches the guarded write primitive
# with a non-sandbox project ("proj-1"); bypass the fail-closed sandbox guard so
# its (mocked) httpx.patch assertion runs. The guard is covered by
# tests/test_orch117_plane_write_isolation.py.
monkeypatch.setattr(
ps.plane_write_guard, "decide",
lambda *a, **k: (True, "test-bypass"), raising=False,
)
yield
ps.reload_project_labels()

View File

@@ -16,11 +16,24 @@ import os
os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token")
os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token")
import pytest # noqa: E402
from unittest.mock import patch, MagicMock # noqa: E402
from src import plane_sync as PS # noqa: E402
@pytest.fixture(autouse=True)
def _allow_plane_writes(monkeypatch):
"""ORCH-117: bypass the fail-closed sandbox write-guard so these stage-visibility
PATCH assertions still reach the (mocked) httpx.patch. The guard itself is covered
by tests/test_orch117_plane_write_isolation.py."""
monkeypatch.setattr(
PS.plane_write_guard, "decide",
lambda *a, **k: (True, "test-bypass"), raising=False,
)
yield
EXPECTED_UUIDS = {
"architecture": "3020bbb7-6122-4663-930c-0315ba8dfa3d",
"development": "9920609b-f140-4e46-ab95-89acda8412c8",

View File

@@ -25,6 +25,28 @@ os.environ["ORCH_PROJECTS_JSON"] = (
from fastapi.testclient import TestClient
from src.main import app
from src.db import init_db, get_db
from src import projects as projects_mod
@pytest.fixture(autouse=True)
def proj_registry():
"""Pin the shared project registry to proj-1/enduro-trails for each test.
The registry (projects.PROJECTS / _BY_PLANE_ID) is a process-wide singleton built
at FIRST `src` import: this module's import-time ORCH_PROJECTS_JSON only wins if
test_webhooks happens to import `src` before any other module (true when it runs
right after test_webhook_dedup, false for an arbitrary subset like
`pytest test_orch114… test_webhooks`). Forcing the registry per-test makes these
fixtures order-independent (mirrors test_webhook_dedup.proj_registry; ORCH-114
review P2)."""
os.environ["ORCH_PROJECTS_JSON"] = (
'[{"plane_project_id": "proj-1", "repo": "enduro-trails", '
'"work_item_prefix": "ET", "name": "enduro-trails"}]'
)
projects_mod.settings.projects_json = os.environ["ORCH_PROJECTS_JSON"]
projects_mod.reload_projects()
yield
projects_mod.reload_projects()
@pytest.fixture(autouse=True)
@@ -396,15 +418,19 @@ def _mock_db_with_retry_count(count):
@patch("src.webhooks.gitea.notify_error")
@patch("src.webhooks.gitea.notify_qg_failure")
@patch("src.webhooks.gitea.enqueue_job")
@patch("src.webhooks.gitea.update_task_stage")
@patch("src.webhooks.gitea.transition_lease.commit_stage_cas")
@patch("src.webhooks.gitea.get_db")
@patch("src.webhooks.gitea.get_task_by_repo_branch")
@patch("src.webhooks.gitea.get_project_by_repo")
def test_ci_failure_development_retries_developer_under_limit(
mock_proj, mock_task, mock_get_db, mock_update_stage,
mock_proj, mock_task, mock_get_db, mock_commit_cas,
mock_enqueue, mock_qg, mock_err,
):
"""retry_count < MAX_DEV_RETRIES → relaunch developer, stage untouched."""
"""retry_count < MAX_DEV_RETRIES → relaunch developer, stage untouched.
ORCH-114: the CI-failure path never writes the stage (no advance) -> the
expected-stage CAS write helper is never invoked.
"""
from src.webhooks.gitea import handle_ci_status
mock_proj.return_value = {"repo": "enduro-trails"}
@@ -423,19 +449,19 @@ def test_ci_failure_development_retries_developer_under_limit(
assert mock_enqueue.call_args[0][0] == "developer"
# No escalation.
assert not mock_err.called
# Stage stays on development — no update_task_stage in the CI-failure path.
assert not mock_update_stage.called
# Stage stays on development — no stage write in the CI-failure path.
assert not mock_commit_cas.called
@patch("src.webhooks.gitea.notify_error")
@patch("src.webhooks.gitea.notify_qg_failure")
@patch("src.webhooks.gitea.enqueue_job")
@patch("src.webhooks.gitea.update_task_stage")
@patch("src.webhooks.gitea.transition_lease.commit_stage_cas")
@patch("src.webhooks.gitea.get_db")
@patch("src.webhooks.gitea.get_task_by_repo_branch")
@patch("src.webhooks.gitea.get_project_by_repo")
def test_ci_failure_development_escalates_at_limit(
mock_proj, mock_task, mock_get_db, mock_update_stage,
mock_proj, mock_task, mock_get_db, mock_commit_cas,
mock_enqueue, mock_qg, mock_err,
):
"""retry_count >= MAX_DEV_RETRIES → escalate via notify_error, no relaunch."""
@@ -458,8 +484,8 @@ def test_ci_failure_development_escalates_at_limit(
err_msg = " ".join(str(a) for a in mock_err.call_args[0])
assert "Max developer retries" in err_msg
assert "after CI failure" in err_msg
# Stage untouched.
assert not mock_update_stage.called
# Stage untouched (no stage write).
assert not mock_commit_cas.called
# ---------------------------------------------------------------------------
@@ -483,11 +509,11 @@ def _merged_pr_payload(branch="feature/ET-012-x"):
@patch("src.webhooks.gitea.notify_stage_change")
@patch("src.webhooks.gitea.update_task_stage")
@patch("src.webhooks.gitea.transition_lease.commit_stage_cas")
@patch("src.webhooks.gitea.get_task_by_repo_branch")
@patch("src.webhooks.gitea.get_project_by_repo")
def test_merge_on_deploy_stage_does_not_set_done(
mock_proj, mock_task, mock_update_stage, mock_notify,
mock_proj, mock_task, mock_commit_cas, mock_notify,
):
"""FIX 1: merge at deploy stage is ignored — done is gated by deployer verdict."""
from src.webhooks.gitea import handle_pr
@@ -499,28 +525,34 @@ def test_merge_on_deploy_stage_does_not_set_done(
asyncio.run(handle_pr(_merged_pr_payload()))
# The merge-driven done path must NOT run on deploy.
assert not mock_update_stage.called
# The merge-driven done path must NOT run on deploy (no stage write).
assert not mock_commit_cas.called
assert not mock_notify.called
@patch("src.webhooks.gitea.notify_stage_change")
@patch("src.webhooks.gitea.update_task_stage")
@patch("src.webhooks.gitea.transition_lease.commit_stage_cas")
@patch("src.webhooks.gitea.get_task_by_repo_branch")
@patch("src.webhooks.gitea.get_project_by_repo")
def test_merge_on_non_deploy_stage_sets_done(
mock_proj, mock_task, mock_update_stage, mock_notify,
mock_proj, mock_task, mock_commit_cas, mock_notify,
):
"""FIX 1: merge behaviour is preserved for non-deploy stages (e.g. review)."""
"""FIX 1: merge behaviour is preserved for non-deploy stages (e.g. review).
ORCH-114: the merge-driven done write now goes through the expected-stage CAS
helper (commit_stage_cas(task_id, current_stage, "done", repo)); on a won CAS the
notify still fires.
"""
from src.webhooks.gitea import handle_pr
mock_proj.return_value = {"repo": "enduro-trails"}
mock_task.return_value = {
"id": 2, "stage": "review", "work_item_id": "ET-013",
}
mock_commit_cas.return_value = True
asyncio.run(handle_pr(_merged_pr_payload(branch="feature/ET-013-x")))
# Non-deploy stages still get the merge-driven done.
mock_update_stage.assert_called_once_with(2, "done")
# Non-deploy stages still get the merge-driven done (review -> done via CAS).
mock_commit_cas.assert_called_once_with(2, "review", "done", "enduro-trails")
assert mock_notify.called