Compare commits

..

40 Commits

Author SHA1 Message Date
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
deploy-finalizer
285f5f05dc deploy(ORCH-036): finalize SUCCESS for ORCH-112
All checks were successful
CI / test (push) Successful in 3m9s
CI / test (pull_request) Successful in 3m11s
2026-06-15 15:33:15 +03:00
344ab72f37 tester(ET): auto-commit from tester run_id=706
All checks were successful
CI / test (push) Successful in 3m59s
CI / test (pull_request) Successful in 3m9s
2026-06-15 15:15:56 +03:00
7f673a45f7 reviewer(ET): auto-commit from reviewer run_id=705 2026-06-15 15:15:56 +03:00
a1f3b7588a fix(deploy): resilient-pull hygiene for dirty shared deploy-base (ORCH-112)
Self-deploy git pull blocked on a dirty shared main checkout (manual/abandoned
WIP from a failed/cancelled task) — incident ORCH-111: "Your local changes to
src/config.py would be overwritten by merge" wedged the prod deploy and required
manual intervention (a group risk on self-hosting).

The deploy hook (--deploy) now converges the deploy-base to a clean, current
origin/main BEFORE the pull (git fetch + reset --hard origin/main + a SCOPED
`git clean -fd`, NEVER -x), strictly preserving the rollback/log artefacts
(.deploy-prev-image-* / deploy-hook.log via -e), gitignored .env/data/*.db/build
(no -x), and sibling/.git state (out of clean scope). Gated by CHECKOUT_HYGIENE
env injected by self_deploy.build_deploy_command only when the new pure never-raise
leaf src/checkout_hygiene.py says applies(repo) (kill-switch + self-hosting scope).
Convergence after failed/cancelled is this same deploy-time self-heal — cancel_task
is NOT extended and no background janitor is introduced. Observability: the hook
writes a `hygiene` sentinel, the Phase-C finalizer reads it and sends a best-effort
Telegram alert.

Additive, under kill-switch (ORCH_CHECKOUT_HYGIENE_ENABLED, default true; off ->
bare `git pull origin main` 1:1 before ORCH-112), never-raise, self-hosting scope.
STAGE_TRANSITIONS / QG_CHECKS / check_* / machine-verdict keys / DB schema / the
hook exit-code contract (0/1/2, ORCH-036) are byte-for-byte untouched.

Coverage: tests/test_deploy_checkout_hygiene.py (TC-01..TC-10; real-hook shell
simulation in a temp git repo, no network/prod/ssh, + unit). TC-01 is the
mandatory ORCH-111 regression (RED before the fix, GREEN after). Docs golden
source updated in the same PR (CLAUDE.md, CHANGELOG.md, .env.example; INFRA.md /
architecture/README.md / adr-0044 written at the architecture stage).

Refs: ORCH-112

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:15:56 +03:00
31b4f3fd1d architect(ET): auto-commit from architect run_id=703 2026-06-15 15:15:56 +03:00
96b653d11c architect(ET): auto-commit from architect run_id=702 2026-06-15 15:15:56 +03:00
860de5b0a5 analyst(ET): auto-commit from analyst run_id=701 2026-06-15 15:15:56 +03:00
c086921aa1 docs: init ORCH-112 business request 2026-06-15 15:15:56 +03:00
0af5d7563c Merge pull request 'docs(ORCH-112): staging gate log artifact — SUCCESS' (#137) from deployer/ORCH-112-staging-log into main 2026-06-15 15:14:51 +03:00
eb1b7aa056 docs(ORCH-112): staging gate log artifact — SUCCESS
All checks were successful
CI / test (pull_request) Successful in 3m52s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 15:14:32 +03:00
a1544f4677 Merge pull request 'ORCH-113: reaper must not re-run deploy-staging finalization while the finalizer is alive' (#134) from feature/ORCH-113-bug-job-reaper-must-not-re-run into main
Some checks failed
CI / test (push) Has been cancelled
2026-06-15 13:51:54 +03:00
deploy-finalizer
c8faa1ec23 deploy(ORCH-036): finalize SUCCESS for ORCH-113
All checks were successful
CI / test (push) Successful in 3m9s
CI / test (pull_request) Successful in 3m5s
2026-06-15 13:51:44 +03:00
b62e196710 developer(ET): auto-commit from developer run_id=699
All checks were successful
CI / test (push) Successful in 3m22s
CI / test (pull_request) Successful in 3m43s
2026-06-15 13:43:22 +03:00
7523b843a5 tester(ET): auto-commit from tester run_id=696
All checks were successful
CI / test (push) Successful in 4m41s
CI / test (pull_request) Successful in 4m1s
2026-06-15 13:08:41 +03:00
adeffbb39a reviewer(ET): auto-commit from reviewer run_id=695 2026-06-15 13:08:41 +03:00
7cb1f83f6c fix(reaper): do not re-run deploy-staging finalization while finalizer is alive
On the deploy-staging -> deploy edge the live monitor stamps
agent_runs.finished_at FIRST, then runs the heavy edge sub-gates
(security/merge-gate re-test/coverage/image-freshness) in-thread for MINUTES
and only THEN _finalize_job. Reaper Tier-2 measures finished_age_s from
finished_at, so past reaper_finalize_grace_s it treated the live, long
finalizer as dead and independently re-ran the advance -> a second re-test
went red -> false rollback deploy-staging -> development while the original
finalizer concurrently merged the PR (incident ORCH-111, job 1914).

Add a process-local finalizer-ownership registry (src/finalizer_liveness.py,
never-raise): the monitor mark()s ownership right after the exit_code stamp and
clear()s it in a try/finally around the (verbatim-extracted) finalization tail,
so an exception in the monitor thread still releases ownership and a genuinely
dead finalizer is reaped. The reaper Tier-2 consults the marker only when the
kill-switch is on AND the task stage == deploy-staging AND ownership is active
-> DEFER (no second advance) and fall through to the Tier-3 backstop, which
ignores the marker (a stuck/dead finalizer is still reaped in bounded time).
In-memory is authoritative (monitor + reaper are daemon threads of one uvicorn
process); restart is covered by the startup requeue_running_jobs.

Additive, global kill-switch reaper_finalizer_liveness_enabled (default True;
false -> reaper byte-for-byte prior). STAGE_TRANSITIONS / QG_CHECKS / every
check_* / machine-verdict keys / DB schema unchanged; grace/ceiling and the
ORCH-065/109/110 budget invariant untouched; never restarts prod, never pushes
main. Observability: finalizer_defers_total + finalizer_owned in GET /queue.
Tests: tests/test_orch113_reaper_finalizer_liveness.py (TC-01..TC-08, incl. the
mandatory ORCH-111 regression: red before the fix, green after).

Refs: ORCH-113

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 13:08:41 +03:00
1e74b9d042 architect(ET): auto-commit from architect run_id=693 2026-06-15 13:08:41 +03:00
425ecb7585 analyst(ET): auto-commit from analyst run_id=692 2026-06-15 13:08:41 +03:00
55e9483fb8 docs: init ORCH-113 business request 2026-06-15 13:08:41 +03:00
ae75b1650b Merge pull request 'docs(ORCH-113): staging gate log — SUCCESS (8/10, C9a/C9b infra-waived)' (#135) from docs/ORCH-113-staging-log into main 2026-06-15 13:07:50 +03:00
89 changed files with 8877 additions and 97 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=
@@ -340,6 +353,15 @@ ORCH_DEPLOY_PROD_TARGET_IMAGE=orchestrator-orchestrator
ORCH_DEPLOY_PROD_COMPOSE_PROFILE=
ORCH_DEPLOY_PROD_PREV_IMAGE_FILE=.deploy-prev-image-prod
# ORCH-112: deploy-base checkout-hygiene (resilient-pull). The self-deploy hook
# converges a DIRTY shared deploy-base to a clean, current origin/main BEFORE the
# `git pull` (git fetch + reset --hard + a SCOPED `git clean -fd`, NEVER `-x`), so
# manual/abandoned WIP left by a failed/cancelled task never blocks the deploy
# (incident ORCH-111). False -> bare `git pull origin main` 1:1 as before ORCH-112.
# Empty REPOS -> only the self-hosting repo (orchestrator).
ORCH_CHECKOUT_HYGIENE_ENABLED=true
ORCH_CHECKOUT_HYGIENE_REPOS=
# ORCH-058: staging-image provenance before the BUILD-ONCE prod retag (INV-FRESH).
# Guarantees the staging image promoted to prod is the EXACT artefact rebuilt from the
# validated commit — two layers, self-hosting only:
@@ -425,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

@@ -1,4 +1,4 @@
Work item: ORCH-110
Work item: ORCH-112
Repo: orchestrator
Branch: feature/ORCH-110-bug-merge-gate-local-re-test-t
Branch: feature/ORCH-112-bug-failed-cancelled-task-arti
Stage: development

View File

@@ -3,6 +3,20 @@
Формат: [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) не затронут.
- **Сходимость после failed/cancelled (FR-2)** — этим же deploy-time self-heal (база сходится на следующем же self-deploy); `cancel_task` (ORCH-090) **не расширяется**, фоновый janitor **не вводится**. **Наблюдаемость (FR-4)** — хук пишет sentinel `hygiene` в deploy-state каталог; Phase-C finalizer (`stage_engine.run_deploy_finalizer`) читает (`read_report`) и шлёт Telegram-алерт (`alert_dirty`, кликабельный номер, best-effort, never-raise — сбой алерта не валит деплой).
- **Флаги** (`config.py`, дефолт = боевое): `checkout_hygiene_enabled` (env `ORCH_CHECKOUT_HYGIENE_ENABLED`), `checkout_hygiene_repos` (env `ORCH_CHECKOUT_HYGIENE_REPOS`). Откат = `ORCH_CHECKOUT_HYGIENE_ENABLED=false` → деплой байт-в-байт до ORCH-112. Покрытие — `tests/test_deploy_checkout_hygiene.py` (TC-01…TC-10: шелл-симуляция реального хука во временном git-репо без сети/прода/ssh + unit; TC-01 — обязательный регресс ORCH-111: КРАСНЫЙ до фикса, ЗЕЛЁНЫЙ после).
- **Job-reaper не реапит живой долго финализирующий монитор `deploy-staging`** (ORCH-113, `fix`, bug→escalate full-cycle): устранено расхождение состояния из инцидента ORCH-111 (deployer job 1914 / run_id 683). На ребре `deploy-staging → deploy` живой монитор (`launcher._monitor_agent`) штампит `agent_runs.finished_at`/`exit_code` **первым**, затем синхронно в своём потоке прогоняет тяжёлые edge-под-гейты (`security → merge-gate re-test → coverage → image-freshness`) — **минуты** — и лишь потом `_finalize_job`. Reaper Tier-2 меряет `finished_age_s` от `finished_at` (= начала финализации), поэтому по истечении `reaper_finalize_grace_s=300` трактовал живого долго финализирующего монитора как мёртвого и **независимо** повторял тот же тяжёлый advance: повторный re-test стал красным → ложный откат `deploy-staging → development` (+ ложный developer-retry) **параллельно** с тем, что исходный finalizer довёл deploy до SUCCESS и смержил PR — состояние раздвоилось. Аддитивно, под глобальным kill-switch, never-raise; `STAGE_TRANSITIONS`/`QG_CHECKS`/каждый `check_*`/machine-verdict ключи/схема БД — **байт-в-байт не тронуты**; `reaper_finalize_grace_s`/`reaper_max_running_s` и сквозной бюджет ORCH-065/109/110 (`5400 > Σ(gate-work)+grace`) сохранены; фикс не рестартит прод и не пушит `main`. ADR: `docs/work-items/ORCH-113/06-adr/ADR-001-reaper-finalizer-liveness-ownership.md`, сквозной `docs/architecture/adr/adr-0043-reaper-finalizer-liveness-ownership.md`.
- **Leaf `src/finalizer_liveness.py` (новый, процесс-локальный реестр владения):** чистый never-raise модуль (паттерн `serial_gate`/`coverage_gate`, без сети/БД) — `mark(job_id, run_id, stage)` / `clear(job_id)` / `is_active(job_id)` / `snapshot()`; состояние `{job_id: {...}}` под `threading.Lock`. Авторитетно in-memory, т.к. монитор и reaper — daemon-**потоки одного** uvicorn-процесса (CMD без `--workers`) с общей SQLite-БД. Собственного TTL нет — ограничение по времени даёт Tier-3 backstop. `is_active` при ошибке → `False` (консервативно: не блокировать добивание).
- **Эмиссия владения (`launcher._monitor_agent`):** `mark()` вызывается **сразу после** штампа `exit_code` (самый ранний момент Tier-2), хвост финализации вынесен в `_run_monitor_finalization` и обёрнут в `try/finally` с `clear()` в `finally` → исключение в потоке монитора гарантированно снимает владение, и реально мёртвый finalizer добивается. Маркер пишется **безусловно** (kill-switch гейтит только консультацию reaper, поэтому выключенный путь — байт-в-байт прежний). Хвост перенесён **дословно** (проверяется `git diff -w`: +49/0, нулевое изменение логики).
- **Консультация reaper (`job_reaper._reap_job` Tier-2):** при `reaper_finalizer_liveness_enabled` **И** стадии задачи `== "deploy-staging"` **И** активном владении → **defer** (счётчик + лог, не повторять advance), провал к Tier-3. **Tier-3 (`age >= reaper_max_running_s`) маркер игнорирует** — застрявший/мёртвый finalizer добивается в ограниченное время. Скоуп — только глобальный kill-switch `reaper_finalizer_liveness_enabled` (env `ORCH_REAPER_FINALIZER_LIVENESS_ENABLED`, дефолт `True`; `False` → reaper байт-в-байт прежний), **без** per-repo разреза (баг общий для всех репо со стадией `deploy-staging`).
- **Наблюдаемость:** аддитивные ключи `finalizer_liveness_enabled`/`finalizer_defers_total`/`finalizer_owned` в блоке `reaper` ответа `GET /queue` (существующие ключи не тронуты). Покрытие — `tests/test_orch113_reaper_finalizer_liveness.py` (TC-01…TC-08, включая обязательный регресс ORCH-111: КРАСНЫЙ до фикса, ЗЕЛЁНЫЙ после).
- **Merge-gate re-test: толерантность к инфра-таймауту + tree-kill спавненных pytest + контракт необходимости re-test** (ORCH-110, `fix`, bug→escalate full-cycle): устранён ложный откат `deploy-staging → development`, возникавший когда локальный re-test merge-gate падал по **таймауту** (инфра/ресурс) при зелёных CI + tester + staging (инцидент ORCH-109/PR #129: сюит 516.7s упёрся в бюджет 600s под CPU-голоданием от осиротевших pytest-процессов → `(False, "re-test timeout after 600s")``_handle_merge_gate_rollback` → каждый из 3 developer-retry падал так же → «Merge-gate still failing after 3 developer retries» → ручное вмешательство). Аддитивно, под 5 независимыми kill-switch, never-raise, скоуп self-hosting; `STAGE_TRANSITIONS`/реестр `QG_CHECKS`/семантика `check_*`/machine-verdict-ключи/схема БД — **байт-в-байт не тронуты**; INV-4 (никогда push/force-push `main`) и запрет рестарта прод-контейнера — соблюдены. ADR: `docs/work-items/ORCH-110/06-adr/ADR-001-merge-gate-retest-infra-tolerance-and-tree-kill.md`, сквозной `docs/architecture/adr/adr-0042-merge-gate-retest-infra-tolerance-and-tree-kill.md`.
- **D1 — process-group tree-kill (`src/proc_group.py`, новый stdlib-only leaf):** `merge_gate.retest_branch` и `coverage_gate.measure_coverage` теперь спавнят pytest в **отдельной группе процессов** (`start_new_session`) и при таймауте убивают **всё дерево** (`os.killpg`, каскад SIGTERM→grace→SIGKILL по образцу `launcher.stop_process`), а не только прямого потомка — осиротевшие внуки-pytest больше не переживают бюджет и не грузят CPU. Контракты возврата сохранены (меняется лишь побочный эффект — нет утечки). Грейс реюзит `agent_kill_grace_seconds`. Fallback never-break: `subprocess_tree_kill_enabled=False` или не-POSIX → прежний `subprocess.run(timeout=)`.
- **D2/D3 — классификация + маршрутизация инфра-таймаута:** чистый предикат `merge_gate.classify_retest_failure(reason)` различает `timeout`/`red`/`lock-busy`/`other` (scope-guard: `auto_rebase_onto_main`'s «rebase timeout» — НЕ инфра-таймаут re-test, остаётся на rollback-пути). Инфра-таймаут → новый `_handle_merge_gate_infra_retry` (ограниченный повтор по образцу `_handle_merge_gate_defer`: задача **остаётся на deploy-staging**, staging-deployer перезапускается с задержкой, **БЕЗ** отката на `development` и **БЕЗ** расхода developer-retry). Анти-над-толерантность (BR-6): детерминированно **красный** re-test / конфликт по-прежнему → `_handle_merge_gate_rollback`. Anti-loop: исчерпание бюджета → один **инфра-alert** (явно инфраструктурная формулировка «НЕ дефект кода» с кликабельным номером), задача НЕ уходит в `development`.

128
CLAUDE.md
View File

@@ -283,6 +283,134 @@ INV-4 (никогда push/force-push `main`) и запрет рестарта
`docs/work-items/ORCH-110/06-adr/ADR-001-merge-gate-retest-infra-tolerance-and-tree-kill.md`, сквозной
`docs/architecture/adr/adr-0042-merge-gate-retest-infra-tolerance-and-tree-kill.md`.
## Гигиена shared deploy-базы: устойчивый self-deploy `git pull` (ORCH-112)
Багфикс инцидента **ORCH-111** (bug → escalate full-cycle): прод-self-deploy падал на шаге
`git pull origin main` хост-хука (`scripts/orchestrator-deploy-hook.sh`) с `error: Your local changes
to the following files would be overwritten by merge: src/config.py` — грязь, оставленная
неуспешной/отменённой/брошенной задачей ORCH-104 в **общем** main checkout (`settings.deploy_host_repo_path`).
Деплой вставал → ручное вмешательство; на self-hosting (один прод-инстанс на все проекты) — групповой
риск. **Инвариант (нормативно):** shared main checkout `<host_repos_dir>/<repo>` — **deploy/worktree-management
база, НЕ редактируемый workspace** (агенты — worktree `git_worktree`, build — worktree-контекст, fallback'и
гейтов — read-only `git show origin/main`); локальных правок там быть не должно. Решение — **resilient-pull,
встроенный в хук** (`--deploy`): перед `git pull` хук при грязи приводит базу к чистому актуальному
`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 и **не** стадия).
- **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-команду `self_deploy.build_deploy_command` только при `applies==True`, иначе `""` →
хук видит `CHECKOUT_HYGIENE` неустановленным → голый `git pull` 1:1 до ORCH-112), `read_report`/`alert_dirty`
(наблюдаемость), `snapshot()` (read-only блок `GET /queue`).
- **Хук-блок «2a. Resilient pull»:** между шагом «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` и `.git/worktrees/*` —
вне области `git clean` в `$REPO`. Каждый git-шаг — `|| log "...continuing"` (never-break): сбой гигиены не
ухудшает исход относительно голого pull; чистая база → no-op (happy-path/exit-коды байт-в-байт);
`--build-staging` (build из worktree, без pull) не затронут.
- **Сходимость после failed/cancelled (FR-2)** — этим же deploy-time self-heal (база сходится на следующем же
self-deploy); `cancel_task` (ORCH-090) **не расширяется**, фоновый janitor **не вводится**.
**Наблюдаемость (FR-4)** — хук пишет sentinel `hygiene`; Phase-C finalizer (`stage_engine.run_deploy_finalizer`)
читает (`read_report`) и шлёт Telegram-алерт (`alert_dirty`, кликабельный номер, best-effort, never-raise).
- **Флаги** (`config.py`, дефолт = боевое): `checkout_hygiene_enabled` (env `ORCH_CHECKOUT_HYGIENE_ENABLED`),
`checkout_hygiene_repos` (env `ORCH_CHECKOUT_HYGIENE_REPOS`). Откат = `ORCH_CHECKOUT_HYGIENE_ENABLED=false` →
деплой байт-в-байт до ORCH-112. Покрытие — `tests/test_deploy_checkout_hygiene.py` (шелл-симуляция реального
хука во временном git-репо без сети/прода/ssh + unit; TC-01 — обязательный регресс ORCH-111: красный до
фикса, зелёный после). Детали — `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,95 @@
---
work_item: ORCH-113
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-15
model_used: claude-opus-4-8
---
# adr-0043: Reaper Tier-2 — in-memory ownership-маркер финализации `deploy-staging` (живой finalizer не реапится)
- **Статус:** proposed
- **Дата:** 2026-06-15
- **Задача:** ORCH-113 (bug → escalate full-cycle; кластер инцидента ORCH-111)
- **Детальный ADR:** `docs/work-items/ORCH-113/06-adr/ADR-001-reaper-finalizer-liveness-ownership.md`
- **Уточняет:** `adr-0011` (job-reaper/lease-reclaim ORCH-065), `adr-0040` (timeout-бюджеты ORCH-109),
`adr-0042` (merge-gate re-test infra-tolerance + tree-kill ORCH-110), `adr-0041`
(ORCH-111 `proc_blocking` — комплементарный наблюдатель того же инцидента)
## Контекст
На ребре `deploy-staging → deploy` живой монитор (`launcher._monitor_agent`) штампит
`agent_runs.finished_at`/`exit_code` **первым**, затем синхронно, в своём потоке, прогоняет тяжёлый
набор edge-под-гейтов через `_try_advance_stage → advance_stage` (`stage_engine.py:327368`):
`security``merge-gate` (полный локальный re-test, `merge_retest_timeout_s=900`) → `coverage`
(`pytest --cov`) → `image-freshness` (docker-rebuild + пересоздание staging) — **минуты**, — и лишь
потом `_finalize_job`. Reaper Tier-2 (`job_reaper.py:197209`) меряет `finished_age_s` от
`finished_at` = **начала** финализации и по `reaper_finalize_grace_s=300` считает живого, долго
финализирующего монитора мёртвым → независимо повторяет тот же тяжёлый advance. Атомарный
claim-before-act защищает лишь **флип строки** job, но не **side-effectful исполнение edge-гейтов**
(монитор не claim'ит строку перед `advance_stage`) → две `advance_stage` параллельно.
Инцидент ORCH-111 (job 1914): повторный re-test красный, ложный откат `deploy-staging → development`
(+ ложный developer-retry), **параллельно** исходный finalizer довёл deploy до SUCCESS и смержил
PR #130 — состояние раздвоилось. Реального сигнала «жив ли finalizer» нет (pid агента в Tier-2 мёртв в
обоих случаях). Per-stage grace, покрывающая Σ финализации (≈4160с), невозможна без нарушения сквозного
бюджета ORCH-065/109/110 `reaper_max_running_s (5400) > Σ(deploy-staging gate-work) + grace (≈4460)`.
**Решающий факт (проверен):** монитор и reaper — daemon-**потоки одного** uvicorn-процесса (CMD без
`--workers`), общая SQLite-БД → живость finalizer'а определяется **in-memory**. Рестарт покрыт
существующим `requeue_running_jobs()` (running→queued), вызываемым в `main.lifespan` **до** старта reaper.
## Решение
1. **Leaf `src/finalizer_liveness.py`** — чистый процесс-локальный реестр владения финализацией
(паттерн `serial_gate`/`coverage_gate`: never-raise, без сети/БД): `mark(job_id, run_id, stage)` /
`clear(job_id)` / `is_active(job_id) -> bool` / `snapshot()`; `{job_id: {...}}` + `threading.Lock`;
собственного TTL нет (ограничение по времени даёт Tier-3).
2. **Эмиссия владения**`launcher._monitor_agent`: `mark(...)` сразу после штампа `exit_code`
(самый ранний момент Tier-2), `clear(...)` в `try/finally` вокруг хвоста финализации → исключение
в потоке монитора гарантированно снимает владение (reaper добивает). Гибель процесса → рестарт →
`requeue_running_jobs` → реестр пуст (restart-safe без durable-хранения).
3. **Консультация reaper**`_reap_job` Tier-2 (`exit_code` записан, `finished_age >= grace`): если
`reaper_finalizer_liveness_enabled` **И** стадия `== "deploy-staging"` **И** `is_active(job_id)`
**defer** (лог + счётчик), не реапить через Tier-2, провалиться к Tier-3. Иначе — прежний путь.
**Tier-3 (`age >= reaper_max_running_s`) маркер игнорирует** — добивает всегда в ограниченное время.
4. **Скоуп/флаг** — только глобальный kill-switch `reaper_finalizer_liveness_enabled`
(env `ORCH_REAPER_FINALIZER_LIVENESS_ENABLED`, дефолт `True`); **без** per-repo разреза (баг общий
для всех репо со стадией `deploy-staging`; per-repo оставил бы баг активным для части репо).
`False` → reaper байт-в-байт прежний; стадии `!= deploy-staging` не консультируются.
5. **Наблюдаемость** — счётчик `finalizer_defers_total` + размер `snapshot()` в блоке `reaper`
`GET /queue`; существующие ключи ответа не меняются; новых эндпоинтов нет.
**Инварианты:** `STAGE_TRANSITIONS` / `QG_CHECKS` / каждый `check_*` / machine-verdict ключи / схема
существующих таблиц — **байт-в-байт**; **нулевое** изменение схемы БД; reaper остаётся never-raise
наблюдателем; `reaper_finalize_grace_s` и `reaper_max_running_s` **не меняются** (сквозной бюджет цел);
фикс не рестартит прод и не пушит `main`.
## Альтернативы
- Per-stage grace, покрывающая Σ — отвергнуто (нарушает бюджет `5400 > Σ+grace`; таймер = источник бага).
- Durable-колонка (heartbeat/owner-токен) — отвергнуто (один процесс → in-memory авторитетно; рестарт
покрыт requeue; блокирующий re-test не может бить heartbeat).
- Sub-state `finalizing` в `jobs.status` — отвергнуто (меняет семантику статуса для
claim/requeue/reconciler/reaper — нарушение NFR-2).
- Lease-файл на `(job, stage)` — отвергнуто (тяжелее, дублирует merge-lease, TTL = таймер-проблема).
- Флип job из `running` до тяжёлых гейтов — отвергнуто (ломает `get_running_jobs`/метрики и
restart-requeue).
## Последствия
- (+) Устранены повторный прогон edge-гейтов, ложный откат и расхождение состояния при живом долгом
finalizer'е `deploy-staging`; идемпотентность исполнения edge-гейтов через владение.
- (+) Реально мёртвый/застрявший finalizer добивается (finally-clear → Tier-2; иначе Tier-3); функция
reaper ORCH-065 сохранена.
- (+) Нулевое изменение схемы и контрактов; сквозной бюджет ORCH-065/109/110 не тронут; откат — один
env-флаг.
- () Гарантия владения валидна при **одном процессе/одной БД** (проверено: один uvicorn-воркер); ввод
`--workers>1` потребует durable-сигнала (риск в work-item 10-tech-risks).
- () Окно «штамп `finished_at``mark()`» (git push) маркером не покрыто — закрыто прежним grace=300.
## Связи
- Базируется/уточняет: `adr-0011`, `adr-0040`, `adr-0042`, `adr-0041`.
- Союзные задачи кластера инцидента ORCH-111: `ORCH-110` (инфра-толерантность merge-gate — отдельный
объём, не дублировать), `ORCH-109` (бюджеты).
- Детально: `docs/work-items/ORCH-113/06-adr/ADR-001-reaper-finalizer-liveness-ownership.md`.
</content>

View File

@@ -0,0 +1,66 @@
---
work_item: ORCH-112
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-15
model_used: claude-opus-4-8
---
# adr-0044: Гигиена shared deploy-базы — устойчивый self-deploy `git pull`
Сквозное (cross-cutting) решение. Детальный per-work-item ADR —
`docs/work-items/ORCH-112/06-adr/ADR-001-deploy-base-checkout-hygiene.md`.
## Статус
Proposed (ORCH-112)
## Контекст (сквозной)
Глобальный путь прод-деплоя self-hosting (`deploy`-стадия, ORCH-036) исполняет хост-хук
`scripts/orchestrator-deploy-hook.sh`, чей шаг «2. Pull latest code» — **голый** `git pull origin main`
в shared main clone (`settings.deploy_host_repo_path`). Любая грязь рабочего дерева (модифицированный
tracked-файл и/или untracked-остатки failed/cancelled/брошенной задачи) **блокирует** merge → деплой
встаёт → ручное вмешательство. На self-hosting (один прод-инстанс на все проекты с общей БД/очередью)
это **групповой риск**: залипший self-deploy орка останавливает обслуживание всех проектов
(инцидент ORCH-111, грязь от ORCH-104).
## Решение (сквозное)
Вводится **resilient-pull, встроенный в прод-deploy-хук** (`--deploy`), + новый чистый never-raise
leaf-компонент `src/checkout_hygiene.py`:
- **Хук** перед `git pull origin main` приводит грязную deploy-базу к чистому актуальному `origin/main`
(`git fetch` + `git reset --hard origin/main` + **скоупленный** `git clean -fd`), **строго сохраняя**
rollback/лог-артефакты. Гейт — env `CHECKOUT_HYGIENE`, инжектится `self_deploy.build_deploy_command`.
- **Leaf** `checkout_hygiene` решает условность (`applies(repo)`: kill-switch `checkout_hygiene_enabled`
+ скоуп `checkout_hygiene_repos`, пусто → self-hosting only), строит env-префикс, читает sentinel
отчёта, шлёт Telegram-алерт. Образец `serial_gate`/`cancel`/`self_deploy`.
- **Сходимость** базы после failed/cancelled (FR-2) — этим же deploy-time self-heal; `cancel_task`
(ORCH-090) **не расширяется**, фоновый janitor **не вводится**.
- **Наблюдаемость** — хук пишет sentinel `hygiene`, Phase-C finalizer читает и шлёт Telegram-алерт
(best-effort, never-raise).
- **Инвариант** «main checkout — deploy/worktree-management база, НЕ workspace» документируется
(INFRA.md + architecture/README.md); de-facto энфорс — сам resilient-pull.
## Кросс-каттинг-инварианты (обязательны к соблюдению будущими задачами)
- **INV-HYGIENE-1 (никогда `-x`):** hygiene-`git clean` — только `git clean -fd`. `-x` удалил бы
gitignored `.env` (прод-секреты) / `data/*.db` (БД прода) / `build/`. Анти-регресс — статический тест.
- **INV-HYGIENE-2 (явные excludes):** `.deploy-prev-image-*` (rollback, `deploy_prod_prev_image_file`)
и `deploy-hook.log` — untracked-но-НЕ-ignored → обязательны `-e`-исключения; их удаление сломало бы
rollback.
- **INV-HYGIENE-3 (скоуп = `$REPO`):** гигиена оперирует только рабочим деревом deploy-базы;
sibling `<repos_dir>/.deploy-state-*` / `.merge-lease-*.json` и `.git/worktrees/*` — вне области.
- **Self-hosting safety (NFR-1):** никогда не трогать `main` на remote, не force-push, не рестартить
прод вне штатного гейта, не сносить worktree/ветки других активных задач.
- **Нулевая регрессия (NFR-5):** `STAGE_TRANSITIONS` / реестр `QG_CHECKS` / семантика и имена `check_*` /
machine-verdict ключи / схема БД / exit-code-контракт хука (0/1/2, ORCH-036) — байт-в-байт. Это
устойчивость deploy-пути, **не** Quality Gate и **не** стадия.
## Связи
- Дополняет: adr-0007 (executable self-deploy, ORCH-036), adr-0008 (image-freshness, ORCH-058).
- Не нарушает: adr-0026 (STOP/cancel, ORCH-090) — каскад cancel не трогается.
## Откат
`ORCH_CHECKOUT_HYGIENE_ENABLED=false` → прод-деплой байт-в-байт до ORCH-112 (голый `git pull origin main`).

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, рестарт НЕ требуется)
@@ -385,7 +406,25 @@ daemon-поток `src/job_reaper.py` (каркас `reconciler`) периоди
git push/PR/Plane-комментарии (секунды-десятки секунд) и лишь потом
`_finalize_job`; pid агента к этому моменту мёртв в обоих случаях. Поэтому
Tier-2 реапит только после finalization-grace `reaper_finalize_grace_s`
(`finished_age_s >= grace`) — живой финализирующий monitor НЕ реапится;
(`finished_age_s >= grace`) — живой финализирующий monitor НЕ реапится.
**ORCH-113 (adr-0043):** на ребре `deploy-staging → deploy` финализация длится
**минуты** (тяжёлые edge-под-гейты после штампа `finished_at`, до `_finalize_job`),
grace=300 это не покрывал → живой долгий finalizer ошибочно реапился и повторял
advance (ложный откат, инцидент ORCH-111). Tier-2 консультирует процесс-локальный
реестр владения `src/finalizer_liveness.py` (`mark`/`clear` в потоке монитора через
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); схема БД и сквозной бюджет не тронуты.
**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

@@ -21,6 +21,14 @@
/repos/<project> ← общий каталог репозиториев (host: /home/slin/repos)
```
> **Инвариант deploy-базы (ORCH-112, нормативно).** Shared main checkout
> `<host_repos_dir>/<repo>` (= `/home/slin/repos/orchestrator` == `/repos/orchestrator` в контейнере
> через bind-mount == `settings.deploy_host_repo_path`) — это **deploy/worktree-management база, НЕ
> редактируемый workspace.** Рабочие изменения туда **не пишутся** конвейером/агентами: агенты —
> worktree `/repos/_wt/<repo>/<branch>` (`git_worktree`), `docker build` — worktree-контекст,
> fallback'и гейтов — read-only `git show origin/main`. Self-deploy `git pull` устойчив к грязной
> базе (resilient-pull, см. self-hosting-страховки ниже).
## Контейнеры
| Контейнер | Роль | Порт | env_file | БД (хост) | Старт |
@@ -133,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` | каталог репозиториев (в контейнере / на хосте) |
@@ -216,10 +226,23 @@ 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.
- **Свежесть staging-образа (ORCH-058):** на ребре `deploy-staging → deploy` (ПОСЛЕ merge-gate, ДО Phase A) QG-под-чек `check_staging_image_fresh` пересобирает staging-образ из валидированного коммита и пересоздаёт 8501 (Strategy A), а хук перед build-once retag fail-closed сверяет OCI-лейбл `revision` с `EXPECTED_REVISION` (Strategy B). Гарантирует: в прод промоутится РОВНО провалидированный артефакт (инцидент LESSONS_ORCH-036 п.4 — тихий промоут устаревшего образа). Сборки/recreate — ТОЛЬКО staging (8501); FAIL → откат на `development`. Условный: реален только для self-hosting.
- **Гигиена shared deploy-базы (ORCH-112):** self-deploy `git pull origin main` устойчив к грязному рабочему дереву deploy-базы (модифицированные tracked + untracked-остатки failed/cancelled/брошенных задач). Хук `--deploy` перед pull приводит базу к чистому `origin/main` (resilient-pull: `git fetch` + `git reset --hard origin/main` + `git clean -fd`), **строго сохраняя** rollback-снимки `.deploy-prev-image-*`, `deploy-hook.log`, gitignored `.env`/`data/`/`*.db` (НИКОГДА `-x`!), sibling `.deploy-state-*`/`.merge-lease-*.json`, `.git/worktrees/*`. Гейт — kill-switch `ORCH_CHECKOUT_HYGIENE_ENABLED` (дефолт `True`; off → голый pull 1:1); скоуп `ORCH_CHECKOUT_HYGIENE_REPOS` (пусто → self-hosting only). Грязь базы детектируется → лог + Telegram-алерт (Phase-C finalizer). Решает инцидент ORCH-111 (грязь ORCH-104 заблокировала `git pull`). Детально — `docs/work-items/ORCH-112/06-adr/ADR-001`, сквозной adr-0044.
**Правила для агентов при задачах ORCH:**
1. НЕ перезапускать / не ронять прод-контейнер `orchestrator` в рамках задачи.

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: failed/cancelled task artifacts must be cleaned from shared checkout
Work Item ID: ORCH-112
## Description
TBD

View File

@@ -0,0 +1,169 @@
---
work_item: ORCH-112
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-112 — failed/cancelled task artifacts must be cleaned from shared checkout
Work Item: **ORCH-112** · Repo: **orchestrator** · Стадия: analysis · Трек: **Bug → эскалация в full-cycle**
> ⚠️ **`escalate: full-cycle` (ADR-001 D5 ORCH-019).** Баг помечен `Bug`, но по сути это
> **архитектурный + safety-critical (self-hosting)** дефект: правка лежит в самом опасном пути
> прод-деплоя (хост-хук, прямо перед рестартом прод-контейнера) и требует **решения о политике
> жизненного цикла** shared checkout (ADR). Поэтому выпускается **полный** analysis-пакет, а не
> облегчённый bug-пакет. Оператор снимает багфикс-трек: `POST /bug-fast-track/escalate?work_item=ORCH-112`
> → задача пойдёт через стадию `architecture` (architect выпустит ADR для политики cleanup/изоляции).
---
## 1. Бизнес-контекст и проблема
### Симптом (наблюдаемое)
Self-deploy задачи **ORCH-111** упал на шаге `git pull origin main` хост-хука деплоя с ошибкой:
```
error: Your local changes to the following files would be overwritten by merge:
src/config.py
Please commit your changes or stash them before you merge.
```
Деплой прерван, конвейер потребовал **ручного вмешательства оператора** (на self-hosting это
групповой риск — встаёт деплой и всех других проектов).
### Причина симптома (установленный факт)
В **общем (shared) checkout** `/home/slin/repos/orchestrator` оставались грязные файлы от
ранее **неуспешной/отменённой/перезапущенной задачи ORCH-104** (тема Lite installer):
- модифицированный tracked-файл: `src/config.py`;
- модифицированный/untracked: `docs/deployment/LITE_SETUP.md`;
- untracked: `scripts/install_lite.py`, `tests/test_install_lite.py`,
`docs/deployment/lite-install.example.yaml`.
Через несколько дней эти остатки **заблокировали** `git pull` другой задачи (ORCH-111).
### Локализация (анализ — куда смотреть архитектору/разработчику)
**Установленный факт о топологии (CLAUDE.md / `docs/architecture/README.md`):**
`/home/slin/repos/orchestrator` (хост) == `/repos/orchestrator` (контейнер, bind-mount) ==
**main clone** (`settings.repos_dir/<repo>` = `settings.deploy_host_repo_path`). Это **deploy-база
и база управления worktree'ами**, а НЕ рабочая копия агента.
1. **Первичный дефект — нерезистентный `git pull`.**
`scripts/orchestrator-deploy-hook.sh:224-226` делает `cd "$REPO"` (= deploy-база) и
**голый** `git pull origin main` **без гигиены рабочего дерева**. Любая локальная правка
tracked-файла блокирует merge → деплой падает. Проверено: во всём `src/`+`scripts/` **нет ни
одного** `git reset --hard` / `git clean` / `git stash` для приведения базы к чистому состоянию.
Shared checkout трактуется как «всегда чистый», что не гарантировано.
2. **Невыполненный/неэнфорснутый инвариант + отсутствие «дворника».**
Нормальный конвейер **не пишет** в рабочее дерево main clone: агенты работают в изолированных
worktree'ах `/repos/_wt/<repo>/<branch>` (`git_worktree.ensure_worktree`); `docker build`
использует контекст **worktree** (`image_freshness._host_worktree_path`), не main clone;
fallback'и гейтов на main clone — **только чтение** (`git show origin/main:...`,
`qg/checks.py:451`, `coverage_gate.py:297`, `stage_engine.py:145`). Поэтому грязь ORCH-104
почти наверняка — **ручной/брошенный WIP** в shared checkout во время инцидента ORCH-104
(косвенное подтверждение: файлы `install_lite.py`/`test_install_lite.py`/`lite-install.example.yaml`
**никогда не существовали в git-истории** — закоммиченный артефакт ORCH-104 это
`scripts/setup_lite.py`, commit `e2cf883`). Вне зависимости от источника: **нет механизма**,
который детектирует/чистит грязную базу и **нет задокументированного/энфорснутого инварианта**
«main checkout — неизменяемая deploy-база, не workspace».
3. **`cancel_task` чистит worktree + remote-ветку, но НЕ shared checkout.**
`stage_engine.cancel_task` (шаг 3d, строки ~2330-2343): `remove_worktree(repo, branch)` +
`gitea.delete_remote_branch(repo, branch)`. Это корректно (конвейер в main clone не пишет), но
означает **нулевое покрытие** случая «грязная deploy-база» в каскадах failed/cancelled.
**Вывод:** даже если первопричина грязи — ручное действие, устойчивость должна быть на стороне
системы: deploy-база обязана **самовосстанавливаться** в чистый `origin/main` перед pull, а
политика жизненного цикла — гарантировать, что остатки failed/cancelled задач не клинят будущие
операции.
## 2. Объём (scope)
### В объёме
- Сделать self-deploy `git pull origin main` (shared deploy-база) **устойчивым к грязному рабочему
дереву** — приведение базы к чистому `origin/main` **автономно**, без ручного вмешательства.
- Гарантировать, что после **failed / cancelled / брошенной** задачи в shared checkout не остаётся
рабочих остатков, способных заблокировать будущий деплой/операцию (сходимость базы к чистому
`origin/main`).
- Задокументировать (и где осуществимо — мягко энфорснуть/гардить) инвариант
«shared main checkout — deploy/worktree-management база, НЕ редактируемый workspace».
- Наблюдаемость: лог + Telegram-алерт, когда deploy-база найдена грязной и автоочищена (или отказ).
### Вне объёма
- ❌ Запрет/контроль ручных операций оператора в shared checkout (вне технической власти системы;
закрываем устойчивостью, а не запретом).
- ❌ Изменение модели worktree per-task (`git_worktree`, ORCH-2) — она корректна и не трогается.
- ❌ Любое изменение `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` / machine-verdict ключей / схемы БД.
- ❌ Изменение поведения деплоя на чистой базе (happy-path должен остаться байт-в-байт).
- ❌ Выбор конкретного механизма (reset --hard vs janitor vs guard) — это **зона архитектора** (ADR).
## 3. Заинтересованные стороны
- **Заказчик/оператор (Слава)** — страдает от ручного разруливания залипших деплоев; принимает результат.
- **Self-hosting конвейер orchestrator** — прямой потребитель (надёжность прод-деплоя).
- **Все проекты на общем инстансе (enduro-trails)** — косвенно: залипший self-deploy орка
останавливает обслуживание их задач.
## 4. Бизнес-требования (BR)
- **BR-1** — Грязное рабочее дерево shared deploy-базы (модифицированные tracked-файлы и/или
untracked-файлы) **НЕ должно блокировать** self-deploy `git pull origin main`: деплой обязан
привести базу к чистому, актуальному `origin/main` **без ручного вмешательства**.
- **BR-2** — После failed / cancelled / брошенной задачи в shared checkout **не должно оставаться**
рабочих остатков этой задачи, способных заблокировать будущий деплой/git-операцию; база
**сходится** к чистому `origin/main`.
- **BR-3** — Инвариант «shared main checkout (`<host_repos_dir>/<repo>`) — deploy/worktree-management
база, НЕ workspace» должен быть **задокументирован** (`docs/operations/INFRA.md` +
`docs/architecture/README.md`) и, где осуществимо, **энфорснут/гардирован**; конвейер/агенты
**никогда** не пишут рабочие изменения в main clone (верифицировать, что это так).
- **BR-4** — **Наблюдаемость:** обнаружение грязной базы и факт автоочистки (или отказ) должны
логироваться и алертиться (Telegram, кликабельный номер) — оператор видит, что гигиена сработала.
- **BR-5** — На **чистой** базе поведение деплоя — **байт-в-байт прежнее** (обычный fast-forward
`git pull`); никакого регресса happy-path.
## 5. Нефункциональные требования (NFR)
- **NFR-1 (self-hosting safety)** — гигиена **никогда** не трогает ветку `main` на remote, не делает
force-push, не рестартит прод-контейнер вне штатного гейта, не удаляет worktree/ветки **других
активных** задач. Оперирует **только** настроенным путём deploy-базы.
- **NFR-2 (сохранность deploy-состояния)** — автоочистка **не должна** удалять артефакты, легитимно
живущие под `$REPO`/рядом: rollback-снимки `$REPO/.deploy-prev-image-*`
(`deploy_prod_prev_image_file`), `deploy-hook.log`, sibling-состояния
`<repos_dir>/.deploy-state-*` / `.merge-lease-*.json`, и админ-записи worktree в `.git/worktrees`.
(Наивный `git clean -xfd` в `$REPO` уничтожил бы `.deploy-prev-image-*` и сломал rollback — это
**жёсткое ограничение** для архитектора/разработчика.)
- **NFR-3 (обратимость / kill-switch)** — новое поведение под флагом; выключенный флаг → деплой
байт-в-байт как до ORCH-112 (голый `git pull origin main`).
- **NFR-4 (надёжность)** — never-raise / fail-safe (по образцу leaf'ов `serial_gate`/`cancel`);
идемпотентность; restart-safe; сбой гигиены не должен маскировать или ухудшать исход деплоя сверх
текущего.
- **NFR-5 (нулевая регрессия конвейера)** — `STAGE_TRANSITIONS` / `QG_CHECKS` / `check_*` /
machine-verdict ключи / схема БД / exit-code-контракт хука (0/1/2, ORCH-036) — **байт-в-байт**.
- **NFR-6 (область)** — изменение скоупится на self-hosting (`orchestrator`); поведение для прочих
репо/синхронного деплоя агентом — не ухудшается.
## 6. Допущения и ограничения
- Shared checkout и хост-хук физически разделяют один путь с контейнером через bind-mount
(`repos_dir``host_repos_dir`); хук исполняется на **хосте** по ssh (ORCH-036, detached).
- Build-once путь (`SOURCE_IMAGE` retag) **не** зависит от содержимого рабочего дерева main clone —
прод получает ровно staging-валидированный образ; значит дискард рабочего дерева base перед pull
**безопасен для деплоимого артефакта**. (`--build-staging` собирается из **worktree**, не из main —
отдельный контур.)
- Источник истины кода — `origin/main`; локальные правки в deploy-базе **по определению** не должны
существовать (это deploy-база, а не место работы).
- Конкретный механизм (resilient pull через reset+clean со скоуп-исключениями / активный janitor /
guard инварианта / комбинация) — **открытый вопрос для архитектуры**, решается в `06-adr/`.
## 7. Критерии успеха
Self-deploy успешно выполняет `git pull` на ранее грязной shared-базе **без ручного вмешательства**;
deploy-база сходится к чистому `origin/main`; rollback-состояние и sibling-артефакты сохранены;
happy-path и весь конвейер — без регресса; обязательный регресс-тест **красный до фикса, зелёный
после**. Детальные PASS/FAIL — `03-acceptance-criteria.md`.
## 8. Риски
- Деструктивная гигиена (`reset --hard`/`clean`) в **прод-deploy-базе** рядом с рестартом прода —
ошибка скоупа может удалить rollback-state/логи (см. NFR-2) → ADR обязателен.
- Маскировка реальной первопричины: если в будущем какой-то код **начнёт** писать в main clone,
«тихая автоочистка» это скроет → нужна наблюдаемость (BR-4).
- Кросс-каттинг с ORCH-036 (self-deploy), ORCH-058 (image-freshness/provenance), ORCH-090 (cancel),
ORCH-2 (worktree-модель). Детали/митигации — `10-tech-risks.md` (заполняет архитектор).

View File

@@ -0,0 +1,110 @@
---
work_item: ORCH-112
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-15
model_used: claude-opus-4-8
---
# 02 — ТЗ (TRZ): ORCH-112 — failed/cancelled task artifacts must be cleaned from shared checkout
Work Item: **ORCH-112** · Repo: **orchestrator** · Стадия: analysis
> ТЗ описывает **требования и ограничения к реализации**, выведенные из BRD и фактического кода.
> Архитектурное **решение** (какой механизм гигиены/изоляции выбрать) — задача архитектора (`06-adr/`),
> т.к. задача эскалирована в full-cycle (`01-brd.md` → `escalate: full-cycle`).
## 1. Сводка изменения
Сделать self-deploy устойчивым к **грязной shared deploy-базе** и гарантировать сходимость базы к
чистому `origin/main` после failed/cancelled/брошенных задач. Корень симптома — голый
`git pull origin main` в `scripts/orchestrator-deploy-hook.sh` (строка 226), исполняемый в
`$REPO` (= `settings.deploy_host_repo_path` = main clone), который падает при любой локальной правке
tracked-файла. Требуется: (а) приведение deploy-базы к чистому `origin/main` перед/в момент pull
**без ручного вмешательства**, со строгим сохранением deploy-rollback-состояния; (б) документирование
+ (по возможности) энфорс инварианта «main checkout — deploy-база, не workspace»; (в) наблюдаемость.
## 2. Задействованные модули / пути
| Путь | Действие | Примечание |
|------|----------|-----------|
| `scripts/orchestrator-deploy-hook.sh` | изменить | строки 224-226: голый `git pull origin main` в `$REPO` — точка отказа (FR-1) |
| `src/self_deploy.py` | возможно изменить | `build_deploy_command` / `initiate_deploy` / `rebuild_staging_image` строят инвокацию хука — возможная точка передачи гигиены/флага (решает архитектор) |
| `src/stage_engine.py` | возможно изменить | `cancel_task` (шаг 3d, ~2330-2343) — каскад cancel; расширение гигиены на shared-базу (FR-2, если выбран этот путь) |
| `src/git_worktree.py` | возможно изменить | модель main clone ↔ worktree; возможный helper приведения базы к чистоте / верификация инварианта (BR-3) |
| `src/config.py` | изменить | новый kill-switch + флаги области (FR-5) |
| `src/<new_leaf>.py` (напр. `checkout_hygiene.py`) | возможно создать | чистый never-raise leaf политики гигиены (по образцу `serial_gate`/`cancel`) — **создавать ли** решает архитектор |
| `docs/operations/INFRA.md` | изменить | инвариант «shared checkout — deploy-база, не workspace» (BR-3) |
| `docs/architecture/README.md` | изменить | описать политику гигиены/жизненного цикла deploy-базы |
| `CHANGELOG.md`, `CLAUDE.md` | изменить | правило «docs = golden source» (CLAUDE.md §2) |
| `tests/test_<...>.py` | создать | регресс + покрытие (см. `04-test-plan.yaml`) |
## 3. Функциональные требования
### FR-1 — Устойчивый self-deploy `git pull` (BR-1, BR-5)
- На пути self-deploy (`scripts/orchestrator-deploy-hook.sh`, шаг «2. Pull latest code»)
`git pull origin main` **не должен падать** из-за грязного рабочего дерева `$REPO`.
- Перед обновлением база приводится к чистому, актуальному `origin/main` (приведение tracked- и
untracked-изменений к состоянию `origin/main`), **с сохранением** артефактов из NFR-2.
- На **уже чистой** базе результат — обычный fast-forward; наблюдаемое поведение и exit-коды
(0/1/2, ORCH-036) — **байт-в-байт прежние** (BR-5).
- Контракт: never-break — сбой шага гигиены не должен ухудшать исход относительно текущего голого
pull (fail-safe).
### FR-2 — Сходимость shared-базы после failed/cancelled/брошенной задачи (BR-2)
- После терминации задачи (`failed` job-исход / `cancelled` через STOP / брошенный WIP) в shared
checkout **не остаётся** рабочих остатков, способных заблокировать будущий деплой/git-операцию.
- Допустимая трактовка «сходимости» (на выбор архитектора, **не** прескриптивно здесь): автоочистка
непосредственно в self-deploy перед pull (FR-1) **и/или** активный «дворник», приводящий
`<host_repos_dir>/<repo>` к чистому `origin/main`.
- Каскад `cancel_task` (ORCH-090) уже чистит **worktree + remote-ветку**; расширение на shared-базу
(если выбрано) делается тем же never-raise best-effort способом.
### FR-3 — Инвариант deploy-базы (BR-3)
- Задокументировать: `<host_repos_dir>/<repo>` — deploy/worktree-management база; рабочие изменения
туда **не пишутся** конвейером/агентами (агенты — worktree `git_worktree`; build — worktree-контекст;
fallback'и гейтов — read-only `git show origin/main`).
- Верифицировать, что текущий код этот инвариант соблюдает (анализ ORCH-112: соблюдает; единственные
обращения к main clone — read-only/fetch/worktree-управление). Где осуществимо — добавить
лёгкий guard/проверку (решает архитектор), **без** изменения горячих путей.
### FR-4 — Наблюдаемость (BR-4)
- Обнаружение грязной deploy-базы и факт автоочистки (число/имена сброшенных путей) или **отказ**
гигиены — лог (структурная запись) + Telegram-алерт (`send_telegram`, кликабельный номер задачи,
best-effort, never-raise). Опционально — read-only снапшот в `GET /queue` (решает архитектор).
### FR-5 — Условность / kill-switch (BR-5, NFR-3, NFR-6)
- Новое поведение под **kill-switch** (env `ORCH_*`, по образцу `serial_gate_enabled`/`stop_status_enabled`);
выключенный флаг → деплой байт-в-байт прежний (голый `git pull origin main`).
- Область — self-hosting (`orchestrator`); прочие репо/синхронный деплой агентом — не ухудшаются.
- `applies(repo)` (локальный, без сети) проверяется первым.
## 4. Изменения API
**Нет** обязательных. Опционально (на усмотрение архитектора) — read-only блок (напр. `checkout_hygiene`)
в существующем `GET /queue` для наблюдаемости. Новых управляющих эндпоинтов не требуется.
## 5. Изменения схемы БД
**Нет.** Состояние гигиены, если нужно, — in-memory / sentinel-файлы (паттерн `self_deploy`/`merge_gate`),
без миграции БД. Аддитивная таблица не требуется.
## 6. Требования к новым/изменённым QG checks
**Нет.** Это **не** Quality Gate и не стадия — это устойчивость deploy-пути и политика гигиены.
`STAGE_TRANSITIONS` / реестр `QG_CHECKS` / семантика и имена `check_*` / machine-verdict ключи
(`deploy_status:`/`staging_status:`/…) — **байт-в-байт не тронуты** (NFR-5).
## 7. Совместимость / регресс
- **Обратная совместимость:** kill-switch off → голый `git pull origin main` (1:1 до ORCH-112);
чистая база → fast-forward без изменений (BR-5).
- **Область раската:** self-hosting `orchestrator`; enduro/прочие — нулевая регрессия.
- **Обратимость:** выключение флага мгновенно возвращает прежнее поведение.
- **Сохранность (жёсткое ограничение, NFR-2):** гигиена **не удаляет** `$REPO/.deploy-prev-image-*`
(rollback), `deploy-hook.log`, sibling `<repos_dir>/.deploy-state-*` / `.merge-lease-*.json`,
админ-записи `.git/worktrees`. Любой `clean`-скоуп обязан их исключать.
- **Self-hosting инварианты (NFR-1):** никогда не трогать `main` на remote, не force-push, не
рестартить прод вне гейта, не сносить worktree/ветки других активных задач, оперировать только
настроенным путём deploy-базы. Exit-code-контракт хука (0/1/2) сохранён.
- **Артефакты pipeline:** создаются/обновляются обычные docs work item (`01..04` этой задачи,
`06-adr/` на стадии architecture после эскалации, `14-deploy-log.md` при деплое). Новых
pipeline-артефактов задача не вводит.
- **Трассировка (CLAUDE.md §9 / ORCH-078):** правки маркированных блоков (ORCH-036 self-deploy,
ORCH-058 image-freshness, ORCH-090 cancel) — сверять с их `06-adr/` перед изменением, инварианты
не ломать.

View File

@@ -0,0 +1,128 @@
---
work_item: ORCH-112
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-15
model_used: claude-opus-4-8
---
# 03 — Критерии приёмки (Acceptance Criteria): ORCH-112 — failed/cancelled task artifacts must be cleaned from shared checkout
Work Item: **ORCH-112** · Repo: **orchestrator** · Стадия: analysis
Формат: каждый критерий имеет **PASS** (что должно быть истинно для приёмки) и **FAIL**
(что считается провалом). Reviewer/CI проверяют их буквально по файлам репозитория и тестам.
---
## AC-1 — Грязная tracked-правка не блокирует self-deploy pull (регресс ORCH-104/ORCH-111)
**Условие:** shared deploy-база имеет локальную модификацию tracked-файла (напр. `src/config.py`),
self-deploy выполняет шаг pull.
- **PASS:** база приводится к чистому актуальному `origin/main` без ручного вмешательства; шаг pull
не возвращает «local changes would be overwritten by merge»; деплой продолжается; есть тест,
воспроизводящий точный сценарий ORCH-111 (**красный до фикса, зелёный после**).
- **FAIL:** pull/деплой падает на грязной tracked-правке; или сценарий не покрыт тестом.
---
## AC-2 — Untracked WIP-файлы не блокируют и не «протекают» в деплой
**Условие:** в shared-базе лежат untracked-файлы failed/брошенной задачи (напр.
`scripts/install_lite.py`, `tests/test_install_lite.py`, `docs/deployment/lite-install.example.yaml`).
- **PASS:** база сходится к чистому `origin/main`; untracked-остатки не блокируют операцию и не
попадают в деплоимый/собираемый артефакт.
- **FAIL:** untracked-остатки блокируют операцию либо остаются и клинят будущий деплой.
---
## AC-3 — Сохранность deploy-rollback-состояния и sibling-артефактов (жёсткое ограничение)
**Условие:** в `$REPO`/рядом присутствуют `.deploy-prev-image-*` (rollback), `deploy-hook.log`,
`<repos_dir>/.deploy-state-*`, `.merge-lease-*.json`, `.git/worktrees/*`.
- **PASS:** после гигиены **все** перечисленные артефакты на месте; rollback по
`.deploy-prev-image-*` остаётся работоспособным; есть тест, доказывающий их неудаление.
- **FAIL:** гигиена удаляет хотя бы один из этих артефактов (особенно `.deploy-prev-image-*`).
---
## AC-4 — Happy-path без регресса (чистая база)
**Условие:** shared-база чистая, self-deploy выполняет pull.
- **PASS:** поведение и exit-коды (0/1/2, ORCH-036) — байт-в-байт прежние (обычный fast-forward);
полный `pytest tests/ -q` зелёный.
- **FAIL:** изменилось наблюдаемое поведение/exit-код на чистой базе, либо красный регресс.
---
## AC-5 — Self-hosting safety
**Условие:** исполнение пути гигиены/деплоя.
- **PASS:** нет операций над веткой `main` на remote, нет force-push, нет рестарта прод-контейнера
вне штатного гейта, нет удаления worktree/веток других активных задач; операции строго в пределах
настроенного пути deploy-базы; тест/анализ это подтверждает.
- **FAIL:** любое из перечисленных нарушений присутствует или возможно.
---
## AC-6 — Kill-switch и обратимость
**Условие:** новый флаг выключен.
- **PASS:** деплой ведёт себя байт-в-байт как до ORCH-112 (голый `git pull origin main`); включение
флага активирует устойчивое поведение; область скоупится на self-hosting (прочие репо не затронуты).
- **FAIL:** выключенный флаг меняет поведение, либо нет kill-switch, либо затронуты прочие репо.
---
## AC-7 — Сходимость после cancel/failed
**Условие:** задача отменена (STOP/ORCH-090) или job завершился `failed`, оставив остатки в
рабочем дереве deploy-базы.
- **PASS:** shared-база сходится к чистому `origin/main` (через автоочистку в деплое и/или дворник),
и последующий self-deploy проходит без ручного вмешательства; покрыто интеграционным тестом.
- **FAIL:** остатки сохраняются и блокируют последующий деплой/git-операцию.
---
## AC-8 — Наблюдаемость
**Условие:** обнаружена грязная deploy-база.
- **PASS:** факт обнаружения и автоочистки (или отказ) — в логах (структурно) и в Telegram-алерте
(кликабельный номер); алерт best-effort, never-raise (его сбой не валит деплой).
- **FAIL:** тихая очистка без следа в логах/уведомлениях, либо сбой алерта роняет деплой.
---
## AC-9 — Инвариант конвейера и БД не тронуты
**Условие:** диф задачи.
- **PASS:** `STAGE_TRANSITIONS` / реестр `QG_CHECKS` / семантика и имена `check_*` / machine-verdict
ключи / схема БД / exit-code-контракт хука — байт-в-байт; структурные тесты конвейера зелёные.
- **FAIL:** любой из перечисленных контрактов изменён.
---
## AC-10 — Документация (golden source)
**Условие:** изменён функционал deploy-базы/гигиены.
- **PASS:** обновлены `docs/operations/INFRA.md` (инвариант deploy-база ≠ workspace) и
`docs/architecture/README.md`; `CHANGELOG.md`/`CLAUDE.md` отражают изменение; ADR заведён на
стадии `architecture` (после эскалации `escalate: full-cycle`).
- **FAIL:** функционал изменён, доки/ADR не обновлены (reviewer → finding ≥P1, CLAUDE.md §6).
---
## Сводная матрица AC ↔ FR/BR
| AC | Покрывает |
|----|-----------|
| AC-1 | BR-1 / FR-1 |
| AC-2 | BR-1, BR-2 / FR-1, FR-2 |
| AC-3 | NFR-2 / FR-1 (ограничение) |
| AC-4 | BR-5 / FR-1 |
| AC-5 | NFR-1 / FR-1, FR-2 |
| AC-6 | NFR-3, NFR-6 / FR-5 |
| AC-7 | BR-2 / FR-2 |
| AC-8 | BR-4 / FR-4 |
| AC-9 | NFR-5 / FR-1…FR-5 |
| AC-10 | BR-3 / FR-3 |

View File

@@ -0,0 +1,83 @@
work_item: ORCH-112
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-15
model_used: claude-opus-4-8
title: "Гигиена shared deploy-базы: устойчивость self-deploy git pull к грязному дереву"
framework: pytest
scope: >
Покрывается: устойчивость self-deploy `git pull origin main` к грязной shared deploy-базе
(модифицированные tracked + untracked файлы), сходимость базы к чистому origin/main после
failed/cancelled задач, сохранность deploy-rollback-состояния, kill-switch/область, наблюдаемость,
self-hosting safety. Вне покрытия: модель worktree per-task (ORCH-2, не трогается), запрет ручных
операций оператора, изменения STAGE_TRANSITIONS/QG_CHECKS/схемы БД (их нет).
notes: >
TC-01 — ОБЯЗАТЕЛЬНЫЙ регресс-тест воспроизведения инцидента ORCH-111: КРАСНЫЙ до фикса, ЗЕЛЁНЫЙ
после. Шелл-симуляции хука моделировать по образцу tests/test_deploy_hook_rollback_sim.py
(временный git-репо во временной директории, без сети/прода/ssh). Полный регресс `pytest tests/ -q`
обязан оставаться зелёным (NFR-5). Точные имена тест-модулей/функций уточнит разработчик; тип
гигиены (resilient-pull / janitor / guard) выберет архитектор — тесты сформулированы так, чтобы
проверять ТРЕБУЕМЫЙ ИНВАРИАНТ (база сходится к чистому origin/main, артефакты сохранены), а не
конкретный механизм.
tests:
- id: TC-01
type: integration
description: "РЕГРЕСС (обязательный, red→green): shared deploy-база с локальной модификацией tracked-файла src/config.py + untracked файлами — симуляция шага git pull хука приводит базу к чистому origin/main и НЕ падает с 'local changes would be overwritten by merge' (воспроизводит ORCH-111; красный до фикса)."
module: tests/test_deploy_checkout_hygiene.py
expected: PASS
- id: TC-02
type: integration
description: "Untracked WIP-файлы (install_lite.py / test_install_lite.py / lite-install.example.yaml) в shared-базе не блокируют операцию и база сходится к чистому origin/main."
module: tests/test_deploy_checkout_hygiene.py
expected: PASS
- id: TC-03
type: integration
description: "Сохранность (NFR-2): после гигиены файлы $REPO/.deploy-prev-image-* , deploy-hook.log, sibling .deploy-state-* / .merge-lease-*.json и .git/worktrees/* НЕ удалены; rollback по .deploy-prev-image-* остаётся работоспособным."
module: tests/test_deploy_checkout_hygiene.py
expected: PASS
- id: TC-04
type: integration
description: "Happy-path без регресса: на ЧИСТОЙ shared-базе шаг pull — обычный fast-forward; наблюдаемое поведение и exit-коды (0/1/2, ORCH-036) байт-в-байт прежние."
module: tests/test_deploy_checkout_hygiene.py
expected: PASS
- id: TC-05
type: unit
description: "Self-hosting safety: путь гигиены никогда не оперирует веткой main на remote, не делает force-push, не рестартит прод и не сносит worktree/ветки других задач; операции ограничены настроенным путём deploy-базы (статический/поведенческий ассерт)."
module: tests/test_deploy_checkout_hygiene.py
expected: PASS
- id: TC-06
type: unit
description: "Kill-switch off → деплой/pull байт-в-байт прежний (голый git pull origin main); on → активна устойчивая гигиена. Область applies(repo): self-hosting orchestrator real, прочие репо — no-op."
module: tests/test_deploy_checkout_hygiene.py
expected: PASS
- id: TC-07
type: integration
description: "Сходимость после cancel/failed: cancel_task (ORCH-090) / failed-исход не оставляет рабочих остатков в shared-базе, блокирующих будущий деплой; последующий self-deploy проходит без ручного вмешательства."
module: tests/test_deploy_checkout_hygiene.py
expected: PASS
- id: TC-08
type: unit
description: "Наблюдаемость: обнаружение грязной базы и факт автоочистки (или отказ) попадают в лог; Telegram-алерт best-effort/never-raise (его сбой не валит деплой), номер задачи кликабельный."
module: tests/test_deploy_checkout_hygiene.py
expected: PASS
- id: TC-09
type: unit
description: "Инвариант конвейера: STAGE_TRANSITIONS / реестр QG_CHECKS / имена и семантика check_* / machine-verdict ключи / exit-code-контракт хука — не изменены (структурный анти-регресс)."
module: tests/test_deploy_checkout_hygiene.py
expected: PASS
- id: TC-10
type: unit
description: "Документация-инвариант: docs/operations/INFRA.md и docs/architecture/README.md содержат правило «shared main checkout — deploy/worktree-management база, не workspace» (структурная сверка)."
module: tests/test_deploy_checkout_hygiene.py
expected: PASS

View File

@@ -0,0 +1,244 @@
---
work_item: ORCH-112
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-15
model_used: claude-opus-4-8
---
# ADR-001: Гигиена shared deploy-базы — устойчивый self-deploy `git pull` через resilient-pull в хуке
Work Item: **ORCH-112** — failed/cancelled task artifacts must be cleaned from shared checkout
Стадия: **architecture**
Сквозная регистрация: **`docs/architecture/adr/adr-0044-deploy-base-checkout-hygiene.md`** (решение
кросс-каттинговое: новый leaf-компонент на глобальном пути прод-деплоя + изменение прод-deploy-хука).
## Статус
Proposed
## Контекст
Self-deploy задачи **ORCH-111** упал на шаге `git pull origin main` хост-хука с
`error: Your local changes to the following files would be overwritten by merge: src/config.py`.
Деплой прерван → потребовал ручного вмешательства (на self-hosting это групповой риск — встаёт
деплой всех проектов). Грязь оставлена ранее **неуспешной/отменённой/перезапущенной** задачей
ORCH-104 в общем (shared) checkout.
Факты, сверенные с кодом:
1. **Точка отказа — голый pull.** `scripts/orchestrator-deploy-hook.sh:223-226` делает `cd "$REPO"`
(= `settings.deploy_host_repo_path` = main clone, строка 73) и **голый** `git pull origin main`
**без гигиены рабочего дерева**. Любая локальная правка tracked-файла блокирует merge. Во всём
`src/`+`scripts/` нет ни одного `git reset --hard` / `git clean` / `git stash` для приведения базы
к чистому состоянию.
2. **Инвариант «main clone ≠ workspace» соблюдён, но не задокументирован и не энфорснут.** Агенты
работают в worktree'ах `/repos/_wt/<repo>/<branch>` (`git_worktree.ensure_worktree`); `docker build`
берёт контекст **worktree** (`image_freshness._host_worktree_path`); fallback'и гейтов на main clone —
**только чтение** (`git show origin/main:...`). Значит грязь — почти наверняка ручной/брошенный WIP.
Но **нет механизма**, детектирующего/чистящего грязную базу, и **нет задокументированного
инварианта**.
3. **`cancel_task` чистит worktree + remote-ветку, но не shared checkout.** `stage_engine.cancel_task`
(строки 2333-2342): `remove_worktree(repo, branch)` + `gitea.delete_remote_branch(repo, branch)`
нулевое покрытие случая «грязная deploy-база».
4. **NFR-2 (сохранность) — жёсткое ограничение.** Сверено `.gitignore` + `src/config.py`:
- `.env`, `data/`, `*.db`, `.venv/`, `__pycache__/`, `build/`, `.env.staging`, `.env.watchdog`,
`deploy/bundled/repos/`**gitignored** → переживают `git clean -fd` (без `-x`) автоматически.
**Прод-секреты `.env` и БД `data/*.db` пропадут только при ошибочном `-x`.**
- `.deploy-prev-image-prod` (`deploy_prod_prev_image_file`) / `.deploy-prev-image-staging`,
а также `deploy-hook.log` (fallback-локация лога, хук строки 60-65) — **untracked, НЕ gitignored**
→ голый `git clean -fd` их **удалит** → сломает rollback (`do_rollback`, хук строки 107-139).
- Sibling-состояния `<repos_dir>/.deploy-state-<repo>/...` (`self_deploy._state_dir`) и
`<repos_dir>/.merge-lease-<repo>.json` (`merge_gate`, строка 315) лежат под **родителем** `$REPO`
(`settings.repos_dir`, не `$REPO`) → `git clean` в `$REPO` их физически не достаёт.
- `.git/worktrees/*` (админ-записи worktree) — внутри `.git/`, `git clean` его **никогда** не трогает.
«Как есть» не годится: deploy-база трактуется как «всегда чистая», что не гарантировано; устойчивость
должна быть на стороне системы — база обязана **самовосстанавливаться** в чистый `origin/main` перед pull.
## Решение
### Сводка
Сделать self-deploy `git pull` **устойчивым к грязной shared deploy-базе** через **resilient-pull,
встроенный в хук** (`--deploy`): перед `git pull origin main` добавляется детерминированный
hygiene-блок, который при обнаружении грязного дерева приводит базу к чистому актуальному `origin/main`
(`git fetch` + `git reset --hard origin/main` + **скоупленный** `git clean -fd`), **строго сохраняя**
rollback/лог-артефакты (NFR-2). Блок гейтится env-переменной `CHECKOUT_HYGIENE`, которую инжектит
`self_deploy.build_deploy_command` **только** когда новый leaf `src/checkout_hygiene.py`-`applies(repo)`
истинен (kill-switch + self-hosting-скоуп). Сходимость базы после failed/cancelled (FR-2) достигается
этим же deploy-time self-heal — **без** расширения `cancel_task` и **без** фонового «дворника».
Наблюдаемость — sentinel-файл от хука, который читает Phase-C finalizer и шлёт Telegram-алерт.
**Инвариант (NFR-5):** `STAGE_TRANSITIONS` / реестр `QG_CHECKS` / семантика и имена `check_*` /
machine-verdict ключи (`deploy_status:`/`staging_status:`/`security_status:`) / схема БД /
exit-code-контракт хука (0/1/2, ORCH-036) — **байт-в-байт не тронуты**. Это устойчивость deploy-пути
и политика гигиены, **не** Quality Gate и **не** стадия.
### D1 — Механизм: resilient-pull в хуке (а не janitor / не container-side clean) — FR-1, AC-1/AC-2
Падающий `git pull` исполняется **на хосте** внутри detached-хука (`self_deploy.build_deploy_command`
→ ssh + setsid). Поэтому гигиена обязана выполняться **в самом хуке, на хосте, непосредственно перед
pull** — это исключает TOCTOU-гонку, которая возникла бы при чистке shared-mount из контейнера
параллельно бегущему detached-хуку.
Новый блок «2a. Resilient pull» в `scripts/orchestrator-deploy-hook.sh` **между** шагом «1. Capture
PREV_IMG» и шагом «2. Pull latest code», под `if [[ "${CHECKOUT_HYGIENE:-0}" == "1" ]]`:
```bash
# 2a. ORCH-112: resilient pull — converge the shared deploy-base to a clean
# origin/main BEFORE the pull, so a dirty working tree (manual/abandoned WIP)
# never blocks the deploy. Gated by CHECKOUT_HYGIENE (Python kill-switch +
# self-hosting scope). NEVER `-x` (would delete .env/data/*.db); EXCLUDES the
# rollback/log artefacts (NFR-2). Best-effort: any failure is logged and the
# bare `git pull` below still runs (never-break).
if [[ "${CHECKOUT_HYGIENE:-0}" == "1" ]]; then
dirty="$(git status --porcelain 2>/dev/null || true)"
if [[ -n "$dirty" ]]; then
log "HYGIENE: dirty deploy-base detected, converging to origin/main:"
log "$dirty"
git fetch origin main >> "$LOG" 2>&1 || log "HYGIENE: fetch failed (continuing)"
git reset --hard origin/main >> "$LOG" 2>&1 || log "HYGIENE: reset failed (continuing)"
git clean -fd \
-e '.deploy-prev-image-*' \
-e 'deploy-hook.log' \
>> "$LOG" 2>&1 || log "HYGIENE: clean failed (continuing)"
if [[ -n "${HYGIENE_REPORT:-}" ]]; then
{ printf 'dirty=1\n'; printf '%s\n' "$dirty"; } > "$HYGIENE_REPORT" 2>/dev/null || true
fi
else
log "HYGIENE: deploy-base already clean (no-op)"
fi
fi
```
- `git status --porcelain` детектирует грязь; **чистая база → блок no-op** (порожний вывод) → дальше
обычный `git pull` (AC-4, happy-path байт-в-байт).
- `git fetch origin main` обновляет `origin/main`-ref, чтобы `reset --hard origin/main` сходился к
**актуальному** источнику истины; tracked-правки (`src/config.py`) дискардятся (AC-1).
- `git clean -fd` снимает untracked-остатки (AC-2). **`-x` запрещён** (см. D2). Последующий
`git pull origin main` (строка 226, **не изменяется**) становится no-op fast-forward.
- Шаг 1 хука пишет `.deploy-prev-image-prod` **до** hygiene → exclude `-e '.deploy-prev-image-*'`
сохраняет rollback-снимок **этого** деплоя (AC-3).
### D2 — Сохранность deploy-состояния: NFR-2 как жёсткий контракт — AC-3
**INV-HYGIENE-1 (никогда `-x`).** Hygiene-`git clean` исполняется **только** как `git clean -fd`
(без `-x`). `-x` удалил бы gitignored `.env` (прод-секреты), `data/*.db` (БД прода), `build/` — что
катастрофично. Анти-регресс: статический тест ассертит, что hygiene-блок хука **не содержит** токена
`-x` (TC-05/TC-09).
**INV-HYGIENE-2 (явные excludes).** `.deploy-prev-image-*` и `deploy-hook.log` untracked-но-НЕ-ignored
→ обязательны `-e '.deploy-prev-image-*'` и `-e 'deploy-hook.log'`. TC-03 доказывает их неудаление и
работоспособность rollback после гигиены.
**INV-HYGIENE-3 (скоуп = `$REPO`).** Hygiene оперирует **только** рабочим деревом `$REPO`. Sibling
`.deploy-state-*` / `.merge-lease-*.json` (под `<repos_dir>`, родитель `$REPO`) и `.git/worktrees/*`
(внутри `.git/`) **вне** области `git clean` в `$REPO` — подтверждено топологией; гигиена их не достаёт.
### D3 — Гейтинг: leaf `src/checkout_hygiene.py` + инжекция env в `build_deploy_command` — FR-5, AC-6
Новый чистый never-raise leaf `src/checkout_hygiene.py` (по образцу `serial_gate`/`cancel`/`self_deploy`;
импортирует только `config`, лениво — `qg.checks.is_self_hosting_repo`):
- `applies(repo) -> bool``checkout_hygiene_enabled=False``False` (kill-switch, голый pull 1:1 до
ORCH-112); `checkout_hygiene_repos` (CSV) непуст → только перечисленные репо; **пусто →
self-hosting only** (`is_self_hosting_repo`, как `self_deploy_repos`). Локальный, без сети, **первым**.
- `hook_env(repo, work_item_id) -> str` — возвращает префикс `CHECKOUT_HYGIENE=1 HYGIENE_REPORT=<path>`
(через `shlex.quote`) **только** при `applies==True`; иначе `""`. `HYGIENE_REPORT` =
`os.path.join(self_deploy.host_state_dir(repo, work_item_id), "hygiene")` — в том же deploy-state
каталоге, что и `result` (shared mount, читается контейнером).
- `read_report(repo, work_item_id) -> dict | None` — читает sentinel `hygiene` через
`self_deploy.container_state_dir`; never-raise.
- `alert_dirty(...)` — best-effort `send_telegram` (кликабельный номер), never-raise (D5).
- `snapshot() -> dict` — read-only блок для `GET /queue` (флаг/скоуп; опционально).
`self_deploy.build_deploy_command` (строки 253-261) добавляет `checkout_hygiene.hook_env(repo, wi)` в
`env_assignments` (одна строка-присваивание в префиксе detached-команды; точно как ORCH-058
`EXPECTED_REVISION`). Когда `applies==False` → префикс пуст → хук видит `CHECKOUT_HYGIENE` неустановленным
→ блок 2a — no-op → **голый `git pull` (1:1 до ORCH-112)**.
Флаги в `src/config.py` (дефолт = боевое):
- `checkout_hygiene_enabled: bool = True` (env `ORCH_CHECKOUT_HYGIENE_ENABLED`);
- `checkout_hygiene_repos: str = ""` (env `ORCH_CHECKOUT_HYGIENE_REPOS`; пусто → self-hosting only).
### D4 — Сходимость после failed/cancelled: deploy-time self-heal достаточен; `cancel_task` НЕ расширяется — FR-2, AC-7
Нормальный конвейер **не пишет** в main clone (факт 2 контекста) → грязь — ручной/брошенный WIP.
Resilient-pull (D1) приводит базу к чистому `origin/main` **на следующем же self-deploy** → база
**сходится** независимо от источника остатков → FR-2/AC-7 удовлетворены deploy-time self-heal.
`stage_engine.cancel_task` (ORCH-090) **намеренно не расширяется** на shared-базу:
- избегаем касания критично-оконного каскада ORCH-090 (`cancel.in_critical_window`, adr-0026);
- избегаем container-side мутации deploy-базы (гонка с возможным параллельным деплоем);
- расширение было бы избыточным — self-heal в D1 уже гарантирует сходимость.
**Отвергнут активный «дворник» (janitor)** (фоновый актор, приводящий базу к чистоте по таймеру/событию):
новый background-поток + дополнительная гонка с detached-деплоем и держателем merge-lease, без выигрыша
сверх deploy-time self-heal.
### D5 — Наблюдаемость: sentinel хука → Telegram-алерт finalizer'а — FR-4, AC-8
Detached-хук (bash) не шлёт Telegram сам. Он пишет sentinel `hygiene` (`HYGIENE_REPORT`) в deploy-state
каталог. Phase-C finalizer (`stage_engine`, уже читает `result` через `self_deploy.read_result`) после
маппинга exit-code вызывает `checkout_hygiene.read_report` + `alert_dirty` (best-effort):
структурный лог + Telegram (`send_telegram`, кликабельный `ORCH-112` через `plane_issue_link`,
`disable_web_page_preview`). Сбой алерта **не валит** деплой (never-raise, AC-8). Опционально — блок
`checkout_hygiene` в `GET /queue`.
### D6 — Инвариант deploy-базы: документирование + de-facto энфорс — FR-3, AC-10
Инвариант «`<host_repos_dir>/<repo>` (main checkout) — deploy/worktree-management база, **НЕ**
редактируемый workspace; рабочие изменения туда не пишутся конвейером/агентами» документируется в
`docs/operations/INFRA.md` (топология + self-hosting) и `docs/architecture/README.md` (раздел ORCH-36).
**De-facto энфорс** — сам resilient-pull (D1): любая локальная правка дискардится при следующем деплое.
**Отвергнут** тяжёлый рантайм-guard на горячих путях (FR-3 «где осуществимо, без изменения горячих
путей») — он не нужен: self-heal уже обеспечивает сходимость, а guard добавил бы риск/латентность.
### D7 — Скоуп пути: только `--deploy` pull, не `--build-staging`
Hygiene врезается **только** в режим `--deploy` (где есть падающий `git pull`). Режим `--build-staging`
(хук строки 166-204) собирает образ из `BUILD_CONTEXT` = **worktree** и **не делает** `git pull`
hygiene там не нужна и не добавляется.
## Альтернативы
- **`git stash` вместо `reset --hard`** — отвергнуто: накапливает stash-записи в deploy-базе, не
«сходится к чистому origin/main», прячет источник грязи; сложнее и менее детерминирован.
- **Container-side Python clean перед ssh-dispatch** — отвергнуто (D1): TOCTOU-гонка с detached-хуком,
дублирование пути deploy-базы.
- **Активный фоновый janitor** — отвергнуто (D4): новый актор + гонки, нулевой выигрыш над self-heal.
- **Расширить `cancel_task` на shared-базу** — отвергнуто (D4): касание критично-оконного каскада
ORCH-090 + container-side мутация; избыточно.
- **Тяжёлый рантайм-guard инварианта** — отвергнуто (D6): не нужен при self-heal, риск на горячем пути.
- **`git clean -xfd`** — **категорически** отвергнуто (D2/INV-HYGIENE-1): удалит `.env`/`data/*.db`.
## Последствия
- **+** Self-deploy `git pull` устойчив к грязной shared-базе → нет ручного разруливания (BR-1, AC-1/2).
- **+** База **сходится** к чистому `origin/main` после failed/cancelled/брошенных задач (BR-2, AC-7).
- **+** Happy-path и kill-switch-off — байт-в-байт (BR-5, AC-4/AC-6); конвейер/БД/exit-коды не тронуты.
- **+** Наблюдаемость: оператор видит факт автоочистки (BR-4, AC-8); инвариант задокументирован (AC-10).
- **** `reset --hard` **дискардит** локальные правки deploy-базы безвозвратно. Митигейшн: это **ровно**
инвариант (база = `origin/main`, источник истины — remote; deploy лишь fast-forward'ит); скоуп —
self-hosting; наблюдаемость показывает, что именно сброшено.
- **** Новый leaf + хук-блок + флаги = площадь сопровождения. Митигейшн: leaf минимальный, образец
существующих leaf'ов; never-raise; полный набор анти-регресс-тестов (`04-test-plan.yaml`).
- **Откат:** `ORCH_CHECKOUT_HYGIENE_ENABLED=false` → деплой байт-в-байт до ORCH-112 (голый
`git pull origin main`); полный откат — revert leaf + хук-блок + флагов.
## Ссылки
- BRD: `docs/work-items/ORCH-112/01-brd.md`
- TRZ: `docs/work-items/ORCH-112/02-trz.md`
- Acceptance: `docs/work-items/ORCH-112/03-acceptance-criteria.md`
- Test-plan: `docs/work-items/ORCH-112/04-test-plan.yaml`
- Infra-requirements: `docs/work-items/ORCH-112/07-infra-requirements.md`
- Tech-risks: `docs/work-items/ORCH-112/10-tech-risks.md`
- Сквозной ADR: `docs/architecture/adr/adr-0044-deploy-base-checkout-hygiene.md`
- Сверено по коду: `scripts/orchestrator-deploy-hook.sh:73,107-139,223-226`, `src/self_deploy.py:119-135,
220-278`, `src/merge_gate.py:315`, `src/config.py:285-291`, `.gitignore`, `src/stage_engine.py:2333-2342`
- Трассировка (CLAUDE.md §9 / ORCH-078): ORCH-036 (self-deploy, adr-0007), ORCH-058 (image-freshness,
adr-0008), ORCH-090 (cancel, adr-0026) — инварианты не нарушаются.

View File

@@ -0,0 +1,59 @@
---
work_item: ORCH-112
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-15
model_used: claude-opus-4-8
---
# 07 — Инфра-требования: ORCH-112 — гигиена shared deploy-базы
Work Item: **ORCH-112** · Repo: **orchestrator** · Стадия: architecture
> When-applicable. Затрагивается **жизненный цикл shared deploy-базы** (`<host_repos_dir>/<repo>`),
> а не топология контейнеров/портов/томов. Контейнеры/сеть/тома — `N/A`.
## I-1. Топология / окружения
**Без изменений в составе.** Контейнеры (`orchestrator` 8500 / `orchestrator-staging` 8501), сеть
(`network_mode: host`), порты, тома — прежние.
Затрагивается **deploy-база** `<host_repos_dir>/<repo>` (= `/home/slin/repos/orchestrator` ==
`/repos/orchestrator` в контейнере через bind-mount == `settings.deploy_host_repo_path`). **Нормативно
закрепляется инвариант:** deploy-база — **deploy/worktree-management база, НЕ редактируемый workspace**.
Рабочие изменения туда не пишутся конвейером/агентами (агенты — worktree `/repos/_wt/<repo>/<branch>`,
build — worktree-контекст, fallback'и гейтов — read-only `git show origin/main`). Документируется в
`docs/operations/INFRA.md` (топология + self-hosting) и `docs/architecture/README.md` (раздел ORCH-36).
**Контракт сохранности рабочего дерева deploy-базы (NFR-2, жёсткий):** автоочистка hygiene
(`git clean -fd`, **без** `-x`) **обязана сохранять**:
| Артефакт | Расположение | Почему сохраняется |
|----------|--------------|--------------------|
| `.deploy-prev-image-prod` / `.deploy-prev-image-staging` | `$REPO/` (untracked, НЕ ignored) | rollback-снимок → `-e '.deploy-prev-image-*'` |
| `deploy-hook.log` | `$REPO/` (fallback-лог) | аудит → `-e 'deploy-hook.log'` |
| `.env`, `data/`, `*.db`, `build/` | `$REPO/` (gitignored) | прод-секреты/БД → переживают `git clean` **без** `-x` |
| `.deploy-state-<repo>/*`, `.merge-lease-<repo>.json` | `<repos_dir>/` (sibling, родитель `$REPO`) | вне области `git clean` в `$REPO` |
| `.git/worktrees/*` | `$REPO/.git/` | `git clean` никогда не трогает `.git/` |
## I-2. Переменные окружения / секреты
Две новые env-переменные (`src/config.py`, дефолт = боевое; **обновить `.env.example`**):
| Ключ | Env | Дефолт | Назначение |
|------|-----|--------|-----------|
| `checkout_hygiene_enabled` | `ORCH_CHECKOUT_HYGIENE_ENABLED` | `True` | kill-switch resilient-pull; `False` → голый `git pull` (1:1 до ORCH-112) |
| `checkout_hygiene_repos` | `ORCH_CHECKOUT_HYGIENE_REPOS` | `""` | CSV-скоуп; пусто → self-hosting only (`orchestrator`) |
Внутренние env, инжектируемые в detached-хук `self_deploy.build_deploy_command` (не операторские):
`CHECKOUT_HYGIENE=1`, `HYGIENE_REPORT=<host_state_dir>/hygiene`. Новых секретов нет.
## I-3. Деплой / рестарт
**Рестарт прод-контейнера задачей ORCH-112 — НЕ требуется и ЗАПРЕЩЁН** (self-hosting инвариант,
CLAUDE.md). Изменение активируется штатно: новый промпт/хук/код `cat`-ается/деплоится в обычном
self-deploy-цикле через **staging-гейт (8501)** сначала, затем `Confirm Deploy` (ORCH-059). Хук-блок
hygiene исполняется **только** в `--deploy` режиме (где есть `git pull`); `--build-staging` собирает из
worktree и не пуллит → не затронут. Exit-code-контракт хука (0/1/2, ORCH-036) — байт-в-байт.
## I-4. CI/CD
**Без изменений** `.gitea/workflows/`. Новый тест-модуль `tests/test_deploy_checkout_hygiene.py`
(шелл-симуляция хука во временном git-репо, без сети/прода/ssh — образец
`tests/test_deploy_hook_rollback_sim.py`) исполняется обычным `pytest tests/ -q`. Полный регресс обязан
оставаться зелёным (NFR-5).

View File

@@ -0,0 +1,42 @@
---
work_item: ORCH-112
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-15
model_used: claude-opus-4-8
---
# 10 — Технические риски: ORCH-112 — гигиена shared deploy-базы
Work Item: **ORCH-112** · Repo: **orchestrator** · Стадия: architecture
> Информационный (гейтом не парсится). Риски реализации resilient-pull и их митигейшн.
## Реестр рисков
| ID | Риск | Вер. | Влия. | Митигейшн |
|----|------|------|-------|-----------|
| TR-1 | **`git clean -x`** ошибочно добавлен → удалит gitignored `.env` (прод-секреты) / `data/*.db` (БД прода) / `build/` — катастрофа на прод-deploy-базе | Низ. | **Выс.** | INV-HYGIENE-1 (ADR D2): только `git clean -fd`, **никогда** `-x`. Статический анти-регресс-тест ассертит отсутствие `-x` в hygiene-блоке хука (TC-05/TC-09); явная рамка в ADR + INFRA |
| TR-2 | Неверный/отсутствующий `-e` exclude → `git clean -fd` удалит `.deploy-prev-image-*` → сломан rollback (`do_rollback`) | Сред. | **Выс.** | INV-HYGIENE-2: обязательные `-e '.deploy-prev-image-*'` + `-e 'deploy-hook.log'`; TC-03 доказывает неудаление и работоспособность rollback; шаг 1 хука пишет prev-image **до** hygiene |
| TR-3 | Сбой шага hygiene (fetch/reset/clean) маскирует/ухудшает исход деплоя | Низ. | Сред. | never-break (ADR D1): каждая git-операция `\|\| log "...continuing"`; деплой продолжается к голому `git pull`; sentinel-отчёт фиксирует факт; fail-safe, исход не хуже текущего |
| TR-4 | `reset --hard origin/main` безвозвратно дискардит локальные правки deploy-базы, которые кто-то «нужными» считал | Низ. | Сред. | Это **ровно** инвариант: deploy-база = `origin/main` (источник истины — remote); deploy лишь fast-forward'ит. Наблюдаемость (D5) показывает сброшенное; скоуп — self-hosting; документировано (INFRA/README) |
| TR-5 | Гонка: hygiene на хосте чистит mount, пока контейнер читает deploy-базу | Низ. | Низ. | Деплой сериализован (serial-gate ORCH-088, один деплой за раз); hygiene в detached-хуке непосредственно перед pull; нормальный конвейер deploy-базу не читает в этот момент (worktree-изоляция). TOCTOU между `status` и `reset` пренебрежимо (один процесс) |
| TR-6 | Скоуп-leak: hygiene затронет прочие репо / синхронный деплой агентом | Низ. | Сред. | `applies(repo)` (локально, первым): пусто → self-hosting only; инжекция env только на self-deploy-пути (сам self-hosting-скоупленный, `self_deploy_applies`); TC-06 |
| TR-7 | Стейл `origin/main` (fetch не выполнился) → `reset --hard` сходится к устаревшему коммиту | Низ. | Низ. | `git fetch origin main` перед reset; при сбое fetch — `\|\| log` + продолжение; последующий `git pull origin main` (строка 226, не тронута) до-тянет недостающее; deploy промоутит build-once `SOURCE_IMAGE` (артефакт не зависит от дерева main clone) |
| TR-8 | Расширение объёма (касание `cancel_task`/`STAGE_TRANSITIONS`/QG) при реализации | Низ. | Сред. | ADR D4 явно запрещает расширение `cancel_task`; NFR-5 байт-в-байт; TC-09 структурно ассертит неизменность контрактов; трассировка ORCH-036/058/090 |
## Сводный вывод
Доминирующий класс — **деструктивная гигиена рядом с прод-rollback-состоянием** (TR-1/TR-2): низкая
вероятность, высокое влияние, **полностью** снимается контрактом сохранности (INV-HYGIENE-1/2) + явными
тестами (TC-03/TC-05/TC-09). Изменение аддитивно, под kill-switch (`checkout_hygiene_enabled`, дефолт
`True`; off → байт-в-байт до ORCH-112), never-raise, self-hosting-скоупленное, **не трогает**
`STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/схему БД/exit-code-контракт хука.
**Эскалация:** вводится новый leaf-компонент (`src/checkout_hygiene.py`) на глобальном пути прод-деплоя
+ правка прод-deploy-хука + сквозной ADR adr-0044 → рекомендуется лейбл **`arch:major-change`** для
reviewer-внимания (изменение safety-critical, в прод-deploy-пути). Возврат в анализ **не** требуется:
ТЗ удовлетворяется без нарушения принципов архитектуры (всё в Docker на одном сервере, без новых
зависимостей/очередей/k8s, без рестарта прода вне staging-гейта). Остаточный риск для прод-конвейера —
**низкий** при соблюдении INV-HYGIENE-1/2/3.

View File

@@ -0,0 +1,123 @@
---
verdict: APPROVED # APPROVED | REQUEST_CHANGES — строго одно из двух, UPPERCASE
work_item: ORCH-112
stage: review
author_agent: reviewer
status: approved
created_at: 2026-06-15
model_used: claude-opus-4-8
type: review
work_item_id: ORCH-112
version: 1
---
# Review ORCH-112 — deploy-base checkout-hygiene (resilient-pull)
## Summary
Багфикс инцидента **ORCH-111** (bug → escalate full-cycle): прод-self-deploy падал на голом
`git pull origin main` хост-хука из-за грязного shared main checkout (остатки ORCH-104 от ORCH-104).
Реализован **resilient-pull в хуке** (`--deploy`): перед pull при обнаружении грязи база сводится к
чистому `origin/main` (`git fetch` + `git reset --hard origin/main` + скоупленный `git clean -fd`),
под kill-switch, never-raise, скоуп self-hosting.
Проверены все 4 оси. Реализация **точно соответствует** ADR-001 (D1D7) и сквозному adr-0044, все
10 критериев приёмки (AC-1…AC-10) покрыты содержательными тестами, документация (golden source)
обновлена в том же PR, инварианты конвейера/БД/exit-code-контракт хука — байт-в-байт не тронуты.
**Вердикт: APPROVED.** P0/P1/P2 findings отсутствуют.
### Что сверено (доказательная база)
**Ось 1 — соответствие ТЗ (02-trz) / критериям (03-acceptance-criteria):**
- FR-1 (устойчивый pull) / AC-1 — ✅ хук-блок «2a. Resilient pull» + регресс **TC-01** (зелёный после
фикса) и **TC-01b** (тот же грязный base без гигиены → `would be overwritten by merge`, репро инцидента).
- FR-1 / AC-2 (untracked WIP) — ✅ **TC-02** (остатки сняты, не протекают в деплой).
- NFR-2 / AC-3 (сохранность) — ✅ **TC-03** (`.deploy-prev-image-*`, `deploy-hook.log`, gitignored
`.env`/`data/*.db`, sibling `.deploy-state-*`/`.merge-lease-*.json`, `.git/worktrees/*` — на месте) +
**TC-05** статический контракт (`git clean -fd`, **никогда `-x`**, явные excludes).
- BR-5 / AC-4 (happy-path) — ✅ **TC-04** (чистая база → no-op + fast-forward, exit-коды байт-в-байт).
- NFR-1 / AC-5 (self-hosting safety) — ✅ скоуп `$REPO`, `reset --hard origin/main` (не локальная
догадка), нет push/force-push (TC-05 ассерт).
- FR-5 / AC-6 (kill-switch + обратимость) — ✅ **TC-06** (off → инертно; пустой CSV → self-hosting only;
enduro не затронут).
- FR-2 / AC-7 (сходимость после cancel/failed) — ✅ **TC-07** (deploy-time self-heal; `cancel_task`
корректно НЕ расширён — D4).
- FR-4 / AC-8 (наблюдаемость) — ✅ **TC-08** (`read_report`/`alert_dirty` never-raise) + врезка в
`run_deploy_finalizer` (sentinel → Telegram, best-effort).
- NFR-5 / AC-9 (инвариант конвейера/БД) — ✅ **TC-09** + проверка дифа: `STAGE_TRANSITIONS`/`QG_CHECKS`/
`check_*`/machine-verdict/схема БД/exit-code-контракт хука (0/1/2) не тронуты.
- BR-3 / AC-10 (документация) — ✅ см. ось 4.
**Ось 2 — соответствие ADR:**
- ADR-001 D1D7 реализованы дословно: resilient-pull в хуке (не janitor/не container-side, D1),
NEVER `-x` + excludes (D2), leaf `checkout_hygiene.py` + инжекция env в `build_deploy_command` (D3),
`cancel_task` не расширяется / janitor не вводится (D4), sentinel → finalizer-alert (D5), docs (D6),
только `--deploy` не `--build-staging` (D7, подтверждено размещением блока между шагами 1 и 2 пути
`--deploy`).
- Трассировка (ORCH-078): правка `build_deploy_command` (маркеры ORCH-101/ORCH-058) — чисто аддитивна
(одно env-присваивание после `EXPECTED_REVISION`), инвариант image-freshness не сломан; ORCH-036
exit-code-контракт и ORCH-090 cancel-каскад не нарушены; INV-4 (никогда push/force-push `main`)
соблюдён.
**Ось 3 — качество кода:**
- Leaf чистый, never-raise, ленивые импорты (`self_deploy`/`qg.checks`/`notifications`) — leaf-инвариант
доказан **TC-05** (`leaf_is_a_pure_leaf`). Docstrings на всех публичных функциях. `shlex.quote` на
инжектируемом пути. Env-проводка консистентна с существующим паттерном `result`-sentinel
(`initiate_deploy` пред-создаёт `container_state_dir` → запись `hygiene` гарантированно проходит).
- **Багфикс-трек регресс-тест (ORCH-019 / BR-4):** присутствует — **TC-01** (фиксатор дефекта,
зелёный после фикса) в паре с **TC-01b** (репродукция инцидента: голый pull аборт без гигиены).
- Все ссылки на API существуют (`notifications.link_for`/`send_telegram`, `self_deploy.host_state_dir`/
`container_state_dir`, `qg.checks.is_self_hosting_repo`). `repo`/`work_item_id`/`task_id` в скоупе
финализатора.
- Тесты содержательные: 17 TC (шелл-симуляция реального хука в герметичном git-репо без сети/прода/ssh
+ unit). Прогон: **17/17 зелёные**; смежные deploy/config/stage_engine/frontmatter — **200/200 зелёные**;
docs/hardcode/canon — **101/101 зелёные**.
**Ось 4 — документация (golden source):**
- `src/` изменён → документация обновлена **в том же PR**: `docs/operations/INFRA.md` (инвариант
deploy-база ≠ workspace), `docs/architecture/README.md` (раздел ORCH-112 design), `CHANGELOG.md`,
`CLAUDE.md` (паспортный блок), `.env.example` (новые ключи), ADR-001 + сквозной
`docs/architecture/adr/adr-0044`. Консистентны между собой и с кодом.
- Обзорные доки (ORCH-079): открытые пункты `README.md` «Известные ограничения» (Telegram 48h /
intra-repo deps / batch-автоном) этим PR **не закрываются** → обновление не требуется. ✅
- Витрина системы (ORCH-011): фикс — внутренняя устойчивость deploy-пути, **не** новая
стадия/гейт/агент/интеграция/способность → витрина `docs/overview/` не затронута;
`tests/test_system_docs.py` зелёный. ✅
## Findings
### P0 — Blocker
- Нет.
### P1 — Must fix
- Нет.
### P2 — Should fix
- Нет.
### P3 — Nice-to-have (не блокирует, на усмотрение)
- `tests/test_deploy_checkout_hygiene.py::test_tc05_hook_clean_is_never_destructive` ассертит
`assert "-x" not in code` по **всем** исполняемым строкам хука. Текущий хук токена `-x` не содержит
(тест зелёный), но будущая легитимная конструкция (`set -x`, `[ -x file ]`, `chmod +x`) ложно уронит
ассерт. Можно сузить проверку до строки(ок) `git clean` — но это страховка критичного инварианта
INV-HYGIENE-1, поэтому строгость намеренна и допустима. Не блокирует.
## Документация
**Статус: обновлена полностью, в том же PR (golden source соблюдён).**
| Документ | Статус |
|----------|--------|
| `docs/work-items/ORCH-112/06-adr/ADR-001-deploy-base-checkout-hygiene.md` | ✅ заведён (architecture, после escalate full-cycle) |
| `docs/architecture/adr/adr-0044-deploy-base-checkout-hygiene.md` | ✅ сквозной ADR заведён |
| `docs/operations/INFRA.md` | ✅ инвариант deploy-база ≠ workspace + страховка resilient-pull |
| `docs/architecture/README.md` | ✅ раздел ORCH-112 (design) |
| `CHANGELOG.md` | ✅ запись [Unreleased] |
| `CLAUDE.md` | ✅ паспортный блок |
| `.env.example` | ✅ `ORCH_CHECKOUT_HYGIENE_ENABLED` / `_REPOS` |
| `docs/overview/` (витрина, ORCH-011) | не требуется (внутренний deploy-fix, не новая способность) |
| `README.md` «Известные ограничения» (ORCH-079) | не требуется (открытые пункты не закрываются) |
Необновлённой документации при изменённом `src/` **нет** → ось 4 пройдена, P0 по документации
отсутствует.

View File

@@ -0,0 +1,71 @@
---
result: PASS # PASS | FAIL — машинный вердикт, UPPERCASE
work_item: ORCH-112
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-112
---
# Test Report — ORCH-112
Гигиена shared deploy-базы: устойчивость self-deploy `git pull` к грязному дереву
(багфикс инцидента ORCH-111). Review-вердикт: **APPROVED** (`12-review.md`).
## Окружение
- 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-112-bug-failed-cancelled-task-arti/`
- Ветка: `feature/ORCH-112-bug-failed-cancelled-task-arti`
- Дата: 2026-06-15
## Smoke API (read-only)
| Endpoint | Результат |
|----------|-----------|
| `GET /health` | PASS — `{"status":"ok","service":"orchestrator"}` |
| `GET /status` | PASS — задача 102 ORCH-112 на стадии `testing`, ветка совпадает |
| `GET /queue` | PASS — блок `serial_gate` присутствует (ORCH-088); `auto_labels` присутствует |
Блок `checkout_hygiene`/`serial_gate`/`auto_labels` — все на месте в полезной нагрузке `/queue`,
регресса смока нет.
## Покрытие ТЗ (TC из 04-test-plan.yaml ↔ 03-acceptance-criteria)
| TC ID | Описание | AC | Тест | Результат |
|-------|----------|----|------|-----------|
| TC-01 | Регресс ORCH-111: грязный tracked `src/config.py` + untracked → база сходится к чистому `origin/main`, pull не падает (red→green) | AC-1 | `test_tc01_dirty_tracked_edit_converges_and_deploys` (+ `test_tc01b_bare_pull_aborts_without_hygiene_documents_incident`) | PASS |
| TC-02 | Untracked WIP-файлы не блокируют и не протекают в деплой | AC-2 | `test_tc02_untracked_wip_does_not_block` | PASS |
| TC-03 | Сохранность `.deploy-prev-image-*`/`deploy-hook.log`/sibling `.deploy-state-*`/`.merge-lease-*.json`/`.git/worktrees/*` (NFR-2) | AC-3 | `test_tc03_preserves_rollback_and_sibling_artifacts` | PASS |
| TC-04 | Happy-path: чистая база → fast-forward, exit-коды байт-в-байт | AC-4 | `test_tc04_clean_base_fast_forwards_no_op_hygiene` | PASS |
| TC-05 | Self-hosting safety: нет операций над `main`/force-push/рестарта прода; `git clean -fd` (никогда `-x`); leaf чист | AC-5 | `test_tc05_hook_clean_is_never_destructive`, `test_tc05_leaf_is_a_pure_leaf` | PASS |
| TC-06 | Kill-switch off → инертно; пустой CSV → self-hosting only; скоуп репо | AC-6 | `test_tc06_kill_switch_off_is_inert`, `test_tc06_empty_csv_is_self_hosting_only`, `test_tc06_csv_scope_limits_repos` | PASS |
| TC-07 | Сходимость после cancel/failed → следующий self-deploy чист | AC-7 | `test_tc07_convergence_then_next_deploy_is_clean` | PASS |
| TC-08 | Наблюдаемость: `read_report`/`alert_dirty`, Telegram best-effort/never-raise | AC-8 | `test_tc08_read_report_none_when_absent`, `test_tc08_read_report_parses_dirty_sentinel`, `test_tc08_alert_dirty_never_raises_on_send_failure` | PASS |
| TC-09 | Инвариант конвейера: `STAGE_TRANSITIONS`/`QG_CHECKS`/`check_*`/machine-verdict/exit-code-контракт хука не тронуты | AC-9 | `test_tc09_pipeline_contracts_untouched`, `test_tc09_hook_exit_code_contract_intact` | PASS |
| TC-10 | Документация-инвариант: INFRA.md и architecture/README.md содержат правило «main checkout — deploy-база, не workspace» | AC-10 | `test_tc10_docs_state_deploy_base_invariant` | PASS |
Каждый TC из `04-test-plan.yaml` выполнен и сопоставлен с критерием приёмки `03-acceptance-criteria.md`.
TC-01 (обязательный red→green регресс инцидента ORCH-111) — зелёный; парный TC-01b документирует
аборт голого pull без гигиены.
## Вывод pytest
### Целевой модуль `tests/test_deploy_checkout_hygiene.py`
```
collected 17 items
... 17 passed, 1 warning in 7.51s
```
### Полный регресс `pytest tests/ -q`
```
2018 passed, 1 warning in 342.01s (0:05:42)
```
(единственный warning — Pydantic V2 deprecation в `src/config.py:8`, существующий, не связан с задачей)
## Итог
PASS — все 10 TC (17 тест-функций) зелёные, полный регресс 2018/2018 зелёный, smoke API OK
(`/health`, `/status`, `/queue` с блоками `serial_gate` и `auto_labels`). Задача готова к переходу
на `deploy-staging`.

View File

@@ -0,0 +1,12 @@
---
deploy_status: SUCCESS
work_item: ORCH-112
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,38 @@
---
staging_status: SUCCESS
work_item: ORCH-112
stage: deploy-staging
author_agent: deployer
status: success
created_at: 2026-06-15
model_used: claude-opus-4-8
timestamp: 2026-06-15T12:11:26Z
base_url: http://localhost:8501
---
# Staging Gate Log
Staging test suite completed against the live staging environment (`orchestrator-staging`,
port 8501), executed inside the container via the canonical path
`/repos/orchestrator/scripts/staging_check.py --base-url http://localhost:8501 --mode stub`
(ORCH-048: B6 registry-isolation reads `.env.staging` from the running instance's own process-env).
**Result: 8/10 checks PASS — exit code 0 → SUCCESS.**
- REAL failed: none
- SANDBOX_INFRA failed (waived, ORCH-061): C9a, C9b
The two failing checks (C9a "Branch appears in orchestrator-sandbox", C9b "Analyst job enqueued in
staging queue") are the known sandbox-infra checks that depend on SANDBOX bot accounts being project
members — not on the pipeline. With every REAL check green, the suite waives them and exits 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
```
Block A (SMOKE): A1 /health 200, A2 /queue 200, A3 ORCH_STAGING=true — all PASS.
Block B (ACCESS): B4 Plane sandbox, B5 Gitea push, B6 registry isolation — all PASS.
Block C (E2E, stub): C7 create issue PASS, C8 trigger pipeline PASS, C9a/C9b waived sandbox-infra.
Staging gate passed. Task may advance to the `deploy` stage.

View File

@@ -0,0 +1,7 @@
# Business Request: BUG: job-reaper must not re-run deploy-staging finalization while original finalizer is alive
Work Item ID: ORCH-113
## Description
TBD

View File

@@ -0,0 +1,167 @@
---
work_item: ORCH-113
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-113 — BUG: job-reaper не должен повторно запускать финализацию `deploy-staging`, пока жив исходный finalizer
Work Item: **ORCH-113** · Repo: **orchestrator** · Стадия: analysis
> **Багфикс-трек → эскалация в полный цикл (`escalate: full-cycle`).** Задача помечена `Bug`, но
> сама баг-карточка явно требует «анализ контракта reaper, статуса `running/finalizing`, длительности
> grace и идемпотентности edge-гейтов» (см. «Ограничение» в бизнес-запросе) — это решение с
> несколькими проектными альтернативами (liveness-heartbeat finalizer'а / явный sub-state
> `finalizing` / per-stage grace / ownership-lease на edge-гейты) и нетривиальными инвариантами
> self-hosting, затрагивающее **задокументированный сквозной инвариант ORCH-065** (контракт
> живости reaper, `adr-0011`). По правилу ORCH-019 (ADR-001 D5) выпускается **полный** analysis-пакет,
> а трек эскалируется (`POST /bug-fast-track/escalate?work_item=ORCH-113`) → задача проходит стадию
> `architecture`. Прецедент — родственные задачи того же инцидент-кластера: ORCH-110 / ORCH-111
> («bug → escalate full-cycle»).
---
## 1. Бизнес-контекст и проблема
Оркестратор — self-hosting инструмент: его прод-контейнер обслуживает конвейер **всех** проектов из
одного инстанса с общей БД и общей очередью и дорабатывает сам себя. Фоновый демон **job-reaper**
(`src/job_reaper.py`, ORCH-065) — страховочный слой: он добивает «зомби»-job'ы, чей монитор умер,
не записав терминальный статус. Его Tier-2-ветка (процесс агента завершился — `agent_runs.exit_code`
записан, — но job всё ещё `running`) **неоднозначна**: это одновременно «монитор умер посреди
финализации» И «живой монитор ещё финализирует». Reaper разрешает неоднозначность таймером —
**finalization grace** `reaper_finalize_grace_s = 300` (db.py:1345-1348, job_reaper.py:36-44): если
`exit_code` записан дольше grace — трактует ситуацию как **мёртвый монитор** и сам до-водит стадию.
**Корневая ошибка контракта:** grace=300с построен на задокументированном допущении, что после записи
`finished_at` монитор делает лишь «git commit/push (+PR), БАГ-8-проверку и сетевые Plane-комментарии —
**секунды…десятки секунд**, и ТОЛЬКО ПОТОМ `_try_advance_stage`». Для ребра `deploy-staging → deploy`
это **неверно**: `_try_advance_stage` (`launcher._monitor_agent`, строка 998) синхронно, в потоке
монитора, прогоняет **весь набор тяжёлых детерминированных edge-под-гейтов**
`security``merge-gate` (полный локальный re-test, бюджет `merge_retest_timeout_s = 900`) →
`coverage` (ещё один полный `pytest --cov`) → `image-freshness` (docker-rebuild + пересоздание
staging). Это **минуты**, и весь этот объём идёт **ПОСЛЕ** штампа `finished_at` (launcher:861) и
**ДО** `_finalize_job` (launcher:1003). `finished_age_s` reaper меряет именно от `finished_at`
(db.py:1360) — то есть от **начала** финализации. Итог: пока живой монитор ещё внутри edge-гейтов,
reaper по истечении 300с считает его мёртвым и **независимо запускает тот же тяжёлый набор edge-гейтов
повторно**.
**Установленные факты инцидента (ORCH-111, deployer job `1914`, run_id `683`):**
- deployer завершился `exit_code=0` и записал `15-staging-log.md` со `staging_status: SUCCESS`;
- после выхода агента job `1914` оставалась `running`, т.к. монитор ещё финализировал стадию;
- финализация `deploy-staging` заняла **дольше 300с** (полный локальный re-test + coverage);
- reaper после `reaper_finalize_grace_s=300` трактовал это как умерший monitor и повторно вызвал
gate-driven advance (`_reap_exit0``_gate_driven_advance``_try_advance_stage``advance_stage`);
- edge-гейты `deploy-staging` исполнились **повторно**: `security-gate`, `merge-gate`, rebase, полный
локальный re-test;
- один из повторных re-test стал **красным**: `3 failed, 1916 passed, 1 warning, 14 errors in 444.79s`;
- задача была откатана `deploy-staging → development` (+ ложный developer-retry), хотя **параллельно**
исходный prod-deploy/finalizer дошёл до `deploy_status: SUCCESS`, **PR #130 был смержен**, задача
перешла `deploy → done`.
**Симптом:** две ветки системы расходятся по состоянию **одной** задачи — одна повторно откатывает
`deploy-staging`, другая успешно завершает deploy. Гонка + ложный rollback + ложный developer-retry +
шумные алерты + несогласованное состояние Plane/БД.
**Почему существующие гарды reaper не спасли:** атомарный claim-before-act
(`reap_running_job(... WHERE status='running')`, job_reaper.py:280) защищает **строку job** от
двойного терминального флипа, но **не защищает побочное исполнение edge-гейтов**: reaper вызывает
`_gate_driven_advance → advance_stage`, который и прогоняет тяжёлые под-гейты, **до/независимо** от
монитора. Гонка — в **side-effectful исполнении edge-гейтов**, а не в флипе строки. Дешёвая
read-only пред-проверка `_gate_is_green('deploy-staging')` читает лишь `check_staging_status`
(frontmatter `15-staging-log.md` = `SUCCESS`, зелёный) → reaper уверенно идёт в тяжёлый advance.
Tier-3 backstop (`reaper_max_running_s = 5400`) при этом не срабатывает — баг чисто в Tier-2 grace.
## 2. Объём (scope)
### В объёме
- Reaper **не должен** повторно исполнять тяжёлую финализацию `deploy-staging`/merge-gate (security /
merge-gate / локальный re-test / coverage / image-freshness), пока исходный monitor/finalizer ещё
**жив** или пока edge-гейты для этого job/stage **уже исполняются**.
- Повторная обработка завершившегося-но-ещё-`running` job на `deploy-staging` должна быть
**идемпотентной**: без второго локального re-test/merge-gate для того же job/stage без **строгого
владения состоянием**.
- Согласование Tier-2 grace (`reaper_finalize_grace_s`) с **фактической** wall-clock-длительностью
финализации `deploy-staging` ИЛИ замена таймерного критерия живости на сигнал, переживающий
«долгую, но живую» финализацию.
- Сохранение основной функции reaper (ORCH-065): реально **мёртвый** finalizer на `deploy-staging`
по-прежнему добивается за ограниченное время.
### Вне объёма
- Изменение `STAGE_TRANSITIONS` / `QG_CHECKS` / семантики любого `check_*` / machine-verdict ключей /
схемы существующих таблиц (правки — только аддитивные).
- Инфра-толерантность merge-gate к таймауту re-test и tree-kill осиротевших pytest-процессов — это
**ORCH-110** (союзная задача того же инцидента; не дублировать).
- Починка конкретных «мигающих» тестов, давших `3 failed … 14 errors`.
- Полный редизайн reaper или модели финализации монитора.
- **Выбор механизма** решения (heartbeat / sub-state `finalizing` / per-stage grace / ownership-lease)
— это **архитектурное решение** (06-adr), не зона аналитика.
## 3. Заинтересованные стороны
- **Owner / Слава** — заказчик исправления, держатель инвариантов self-hosting.
- **Конвейер всех проектов** (orchestrator self-hosting + enduro-trails) — общий инстанс/БД/очередь:
ложный rollback и гонка состояния касаются стабильности платформы в целом.
- **Операторы** — получатели алертов; именно их будят ложные «merge-gate FAILED / rolled back».
- **Архитектор** — принимает решение по механизму владения/живости (06-adr) после эскалации.
## 4. Бизнес-требования (BR)
- **BR-1** — Reaper **не должен** запускать второй прогон edge-гейтов ребра `deploy-staging → deploy`
(security / merge-gate / re-test / coverage / image-freshness) для job, чей исходный
monitor/finalizer **ещё жив**.
- **BR-2** — Повторная обработка завершившегося-но-`running` job на `deploy-staging` **идемпотентна**:
не более **одного** локального re-test/merge-gate на пару (job, stage) без строгого владения
состоянием; второй актор, не владеющий состоянием, **не исполняет** побочных шагов.
- **BR-3** — Критерий живости Tier-2 должен учитывать **реальную** wall-clock-длительность
финализации `deploy-staging` (включающую полный набор edge-гейтов), ИЛИ живость должна определяться
сигналом, который **переживает** долгую-но-живую финализацию (не одним `finished_age_s`).
- **BR-4** — Реально **мёртвый** монитор (краш посреди финализации `deploy-staging`) по-прежнему
должен добиваться reaper'ом за ограниченное время — основная функция ORCH-065 **сохраняется**;
фикс не превращает reaper в no-op для `deploy-staging`.
- **BR-5** — После согласования у задачи — **единственное** консистентное состояние: **никакого**
ложного отката `deploy-staging → development` и **никакого** ложного developer-retry после
фактически успешного deploy; ветки системы сходятся, не расходятся.
## 5. Нефункциональные требования (NFR)
- **NFR-1** — Контракт reaper сохранён: **never-raise** на единицу работы, **kill-switch**,
fail-safe; reaper остаётся наблюдателем-страховкой, не Quality Gate'ом.
- **NFR-2** — `STAGE_TRANSITIONS` / `QG_CHECKS` / каждый `check_*` / machine-verdict ключи / схема
существующих таблиц — **байт-в-байт**; любые БД-правки — только **аддитивные** (`_ensure_column` /
`CREATE TABLE IF NOT EXISTS`).
- **NFR-3** — Self-hosting-безопасно: фикс **никогда** не рестартит/не роняет прод-контейнер и
**никогда** не пушит/force-push'ит `main`.
- **NFR-4** — Обратная совместимость и обратимость: поведение reaper для **не-`deploy-staging`** стадий
и путь добивания **мёртвого** монитора сохранены; выключенный kill-switch → строго прежнее
поведение; раскат обратим.
- **NFR-5** — Restart-safe: in-memory состояние reaper сбрасывается при рестарте (это покрыто
стартовым `requeue_running_jobs`); любой **новый** маркер владения/живости должен быть либо durable,
либо безопасно восстановимым после рестарта.
- **NFR-6** — Сквозной инвариант ORCH-065/109/110 сохранён: `reaper_max_running_s (5400) >
Σ(deploy-staging gate-work) + grace` (Tier-3 backstop). Любая правка grace/таймаутов не должна его
нарушить.
## 6. Допущения и ограничения
- Задача помечена `Bug`; ввиду архитектурной природы — **эскалация в полный цикл** (нужен ADR +
анализ тех-рисков архитектором: 06-adr / 07 / 08 / 10).
- Инстанс общий для всех проектов (общая БД/очередь) — фикс не должен вносить регрессию для
enduro-trails и не-self репо.
- Выбор конкретного механизма владения/живости — за архитектором; настоящий BRD фиксирует **требования
и инварианты**, а не реализацию.
- Источник истины о «жив ли finalizer» сегодня отсутствует: pid агента в Tier-2 **уже мёртв** в обоих
случаях (`proc.wait()` вернулся), а живости **потока-монитора/финализатора** система не наблюдает —
это и есть пробел, который закрывает фикс.
## 7. Критерии успеха
Reaper при живом finalizer'е `deploy-staging` не запускает второй прогон edge-гейтов и не откатывает
задачу; повторная обработка идемпотентна; мёртвый finalizer по-прежнему добивается; после фикса нет
ложного rollback/developer-retry и расхождения состояния; инварианты ORCH-065/NFR-2 целы; полный
регресс `tests/` зелёный. Детальные PASS/FAIL — `03-acceptance-criteria.md`.
## 8. Риски
- **Гонка/расхождение состояния** (наблюдалось): повторный откат после успешного deploy. **Высокий.**
- **Над-толерантность**: слишком «доверять живости» → реально мёртвый finalizer не добивается (регресс
ORCH-065). Сдерживается BR-4 + Tier-3 backstop.
- **Нарушение сквозного бюджета** при правке grace/таймаутов (NFR-6).
Детальная проработка и контрмеры — `10-tech-risks.md` (заполняет архитектор).

View File

@@ -0,0 +1,106 @@
---
work_item: ORCH-113
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-113 — BUG: job-reaper не должен повторно запускать финализацию `deploy-staging`, пока жив исходный finalizer
Work Item: **ORCH-113** · Repo: **orchestrator** · Стадия: analysis
> ТЗ описывает **требования к изменению**, выведенные из BRD и фактического кода. Конкретный
> механизм (heartbeat живости / sub-state `finalizing` / per-stage grace / ownership-lease на
> edge-гейты), точные имена символов/колонок/флагов и порядок врезок — **архитектурное решение**
> (06-adr), не зона аналитика. Модули-плейсхолдеры ниже выровнены под манифест `PIPELINE_DOCS.md`.
## 1. Сводка изменения
Сделать повторную обработку reaper'ом завершившегося-но-ещё-`running` job на стадии `deploy-staging`
**идемпотентной и владеющей состоянием**: пока исходный monitor/finalizer **жив** (или edge-гейты для
пары `(job, stage)` уже исполняются), reaper **не должен** независимо запускать второй прогон edge-
под-гейтов ребра `deploy-staging → deploy` (security / merge-gate / локальный re-test / coverage /
image-freshness) и **не должен** на этом основании откатывать задачу. Корень — ошибочное допущение
Tier-2 finalization-grace (`reaper_finalize_grace_s=300`), что финализация после штампа `finished_at`
длится «секунды…десятки секунд»; для `deploy-staging` она длится **минуты** (полный re-test + coverage
+ rebuild), потому что `_try_advance_stage` (тяжёлые edge-гейты) выполняется **после** штампа
`finished_at` и **до** `_finalize_job`. Фикс должен дать reaper'у способ отличить «живой, долго
финализирующий монитор» от «мёртвого монитора» и обеспечить строгое владение исполнением edge-гейтов.
## 2. Задействованные модули / пути
| Путь | Действие |
|------|----------|
| `src/job_reaper.py` | изменить — Tier-2-ветка `_reap_job` (строки ~197-209), `_reap_known_outcome` / `_reap_exit0` / `_gate_driven_advance`: ввести проверку живости/владения перед side-effectful re-drive `advance_stage` для `deploy-staging`; при живом finalizer'е**defer**, не reap |
| `src/agents/launcher.py` | изменить (вероятно) — `_monitor_agent`: место/порядок штампа `finished_at` (строка ~861) относительно `_try_advance_stage` (строка ~998) и/или эмиссия durable-сигнала «finalizer жив/финализирует» перед запуском тяжёлых edge-гейтов |
| `src/db.py` | изменить (вероятно) — `get_running_jobs` (строки ~1337-1367, источник `finished_age_s`) и/или аддитивная колонка владения/живости (`_ensure_column`, паттерн `tasks.cancelled_at`); `reap_running_job` — без изменения контракта атомарного флипа |
| `src/config.py` | изменить (вероятно) — kill-switch фикса и/или per-stage/finalize-aware grace; сохранить сквозной инвариант `reaper_max_running_s > Σ gate-work + grace` (NFR-6) |
| `src/stage_engine.py` | **только чтение/ссылка**`advance_stage` ребро `deploy-staging` (строки ~321-383): эталон того, какие edge-гейты дублируются. Изменение нежелательно; идемпотентность предпочтительно решать на стороне reaper/launcher |
> Точный набор затронутых модулей и распределение логики (reaper-only vs launcher-сигнал vs db-колонка)
> архитектор фиксирует в 06-adr. Аналитик фиксирует, что центр тяжести правки — `src/job_reaper.py`.
## 3. Функциональные требования
### FR-1 — Распознавание живости финализирующего монитора (BR-1, BR-3)
Reaper в Tier-2 для стадии `deploy-staging` должен распознавать ситуацию «процесс агента завершён,
но monitor/finalizer ещё **жив** и исполняет edge-гейты» и **не** трактовать её как мёртвый монитор по
одному лишь `finished_age_s >= reaper_finalize_grace_s`. Сигнал живости должен переживать долгую
(минуты) финализацию `deploy-staging`. **Инвариант:** живой finalizer **никогда** не reap'ается.
### FR-2 — Идемпотентность и строгое владение edge-гейтами (BR-2)
Для пары `(job, stage)` на `deploy-staging` тяжёлый прогон edge-гейтов (security / merge-gate /
локальный re-test / coverage / image-freshness) исполняется **не более одного раза одновременно**:
актор, не владеющий состоянием, **не** запускает второй `advance_stage`/re-test/merge-gate. Текущий
атомарный claim-before-act (`reap_running_job ... WHERE status='running'`) защищает только флип строки
job — требование расширяет защиту на **side-effectful исполнение edge-гейтов**.
### FR-3 — Согласование grace/бюджета (BR-3, NFR-6)
Длительность finalization-grace (или заменяющий её сигнал живости) должна покрывать фактический
wall-clock финализации `deploy-staging` = Σ(security + merge re-test `merge_retest_timeout_s=900` +
coverage + image rebuild). Любая правка grace/таймаутов сохраняет сквозной инвариант ORCH-065/109/110:
`reaper_max_running_s (5400) > Σ(deploy-staging gate-work) + grace`.
### FR-4 — Сохранение добивания мёртвого finalizer'а (BR-4)
Реально **мёртвый** monitor/finalizer на `deploy-staging` (краш посреди финализации, сигнал живости
отсутствует/протух) по-прежнему добивается reaper'ом за ограниченное время по существующему контракту
(retry в пределах бюджета, иначе `failed` + Telegram; Tier-3 backstop как крайний предохранитель).
Reaper не становится no-op для `deploy-staging`.
### FR-5 — Отсутствие расхождения состояния (BR-5)
Исключить одновременный ложный откат `deploy-staging → development` и успешное завершение
`deploy → done` для одной задачи. После согласования у задачи — единственное консистентное
терминальное/стадийное состояние; ложный developer-retry не инкрементируется. Под-гейт merge-verify
(`deploy → done`, ORCH-071) остаётся единственным choke-point'ом в `done` — он **не** ослабляется.
## 4. Изменения API
Новых публичных эндпоинтов **не требуется**. Допустима **аддитивная read-only** наблюдаемость в
`GET /queue` (например, отметка «deploy-staging finalize in-progress / deferred-by-liveness» в блоке
`reaper`) — без изменения существующих ключей ответа.
## 5. Изменения схемы БД
Возможна **аддитивная** колонка владения/живости finalizer'а (например, durable-таймштамп
«finalizing heartbeat» или owner-токен на job/agent_run), вводимая идемпотентно через `_ensure_column`
(паттерн `tasks.cancelled_at` / `tasks.track`). Существующие таблицы/колонки/индексы и их семантика —
**без изменений**. Точная необходимость и форма — за архитектором (06-adr / 08-data-requirements).
## 6. Требования к новым/изменённым QG checks
**Нет.** `QG_CHECKS` и любой `check_*` (включая `check_staging_status`, `check_branch_mergeable`,
`check_coverage_gate`, `check_security_gate`, `check_staging_image_fresh`, `check_deploy_status`) — не
трогаются ни по составу, ни по семантике, ни по machine-verdict ключам. Багфикс — свойство страховочного
демона/финализатора, **не** Quality Gate.
## 7. Совместимость / регресс
- **Kill-switch:** фикс под выделенным флагом; `False` → строго прежнее поведение reaper (нулевая
регрессия).
- **Область:** поведение для **не-`deploy-staging`** стадий и путь добивания мёртвого монитора —
сохранены; self-hosting-only/скоуп репо согласовать с существующими leaf-паттернами (если вводится
per-repo разрез).
- **Обратимость:** раскат обратим выключением флага (+ откатом значений grace/таймаутов к дефолтам).
- **Never-raise / restart-safe / self-hosting (NFR-1/3/5):** любой сбой нового пути живости/владения
деградирует безопасно (reaper-тик продолжается); новый durable-маркер восстановим после рестарта;
фикс не рестартит прод и не пушит `main`.
- **Сквозной инвариант (NFR-6):** `reaper_max_running_s > Σ gate-work + grace` сохранён.
- **Анти-регресс:** структурный тест-гард (см. `04-test-plan.yaml`), полный `tests/` остаётся зелёным.

View File

@@ -0,0 +1,98 @@
---
work_item: ORCH-113
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-113 — BUG: job-reaper не должен повторно запускать финализацию `deploy-staging`, пока жив исходный finalizer
Work Item: **ORCH-113** · Repo: **orchestrator** · Стадия: analysis
Формат: каждый критерий имеет **PASS** (что должно быть истинно для приёмки) и **FAIL** (что считается
провалом). Любой машинный/ручной reviewer проверяет их буквально по файлам репозитория и тестам.
---
## AC-1 — Нет второго прогона edge-гейтов при живом finalizer'е
**Условие:** job на `deploy-staging` с `exit_code=0`, `finished_age_s >= reaper_finalize_grace_s`, но
исходный monitor/finalizer ещё **жив** (сигнал живости присутствует).
- **PASS:** reaper **не** вызывает `_gate_driven_advance`/`advance_stage` для этого job; второй прогон
security / merge-gate / локального re-test / coverage / image-freshness **не** запускается; reaper
логирует defer.
- **FAIL:** reaper запускает повторный `advance_stage` / любой edge-под-гейт, пока finalizer жив.
---
## AC-2 — Идемпотентность и строгое владение состоянием
**Условие:** монитор финализирует `deploy-staging` и параллельно срабатывает reaper-тик для того же
job/stage.
- **PASS:** тяжёлый прогон edge-гейтов (merge-gate / локальный re-test) исполняется **ровно один раз**
для пары `(job, stage)`; актор без владения состоянием не выполняет побочных шагов (мерж/re-test/
rollback).
- **FAIL:** второй локальный re-test/merge-gate запускается для того же job/stage без владения
состоянием (двойное исполнение edge-гейтов).
---
## AC-3 — Мёртвый finalizer по-прежнему добивается
**Условие:** monitor/finalizer на `deploy-staging` реально **умер** посреди финализации (сигнал
живости отсутствует/протух), job завис в `running`.
- **PASS:** reaper за ограниченное время добивает job по существующему контракту (retry в пределах
бюджета, иначе `failed` + Telegram; Tier-3 backstop как предохранитель); reaper для `deploy-staging`
**не** превращён в no-op.
- **FAIL:** мёртвый finalizer на `deploy-staging` не добивается reaper'ом (зомби-job блокирует очередь).
---
## AC-4 — Нет ложного отката и расхождения состояния после успешного deploy
**Условие:** сценарий инцидента ORCH-111 — долгая (> grace) финализация `deploy-staging` при зелёном
`staging_status: SUCCESS`, deploy/finalizer параллельно доходит до `deploy_status: SUCCESS` / merge PR.
- **PASS:** задача **не** откатывается `deploy-staging → development` параллельной reaper-веткой;
developer-retry **не** инкрементируется ложно; у задачи единственное консистентное
терминальное/стадийное состояние (сходимость, не расхождение).
- **FAIL:** задача откатана `deploy-staging → development` и/или начислен ложный developer-retry, в то
время как deploy фактически успешен; ветки состояния расходятся.
---
## AC-5 — Инварианты и контракт reaper сохранены
**Условие:** аудит диффа и поведения при выключенном kill-switch.
- **PASS:** `STAGE_TRANSITIONS` / `QG_CHECKS` / каждый `check_*` / machine-verdict ключи / схема
существующих таблиц — **байт-в-байт**; БД-правки только аддитивные (`_ensure_column` /
`CREATE TABLE IF NOT EXISTS`); reaper остаётся never-raise per unit; выключенный флаг → прежнее
поведение; правки не рестартят прод и не пушат `main`; сквозной инвариант
`reaper_max_running_s > Σ gate-work + grace` (ORCH-065/109/110) сохранён.
- **FAIL:** изменены `STAGE_TRANSITIONS`/`QG_CHECKS`/семантика `check_*`/machine-verdict ключи или
схема существующих таблиц; reaper может бросить исключение из тика; флаг `False` меняет поведение;
нарушен сквозной бюджетный инвариант.
---
## AC-6 — Обязательный регресс-тест и зелёный полный прогон
**Условие:** запуск тест-сюита репозитория.
- **PASS:** добавлен регресс-тест инцидента ORCH-111 (TC-05 в `04-test-plan.yaml`), **красный** на
коде до фикса и **зелёный** после; полный `pytest tests/ -q` зелёный.
- **FAIL:** регресс-теста нет, либо он зелёный и до фикса (не воспроизводит баг), либо полный регресс
`tests/` красный.
---
## Сводная матрица AC ↔ FR/BR
| AC | Покрывает |
|----|-----------|
| AC-1 | BR-1 / BR-3 / FR-1 |
| AC-2 | BR-2 / FR-2 |
| AC-3 | BR-4 / FR-4 |
| AC-4 | BR-5 / FR-5 |
| AC-5 | NFR-1 / NFR-2 / NFR-3 / NFR-6 / FR-3 |
| AC-6 | BR-1…BR-5 (регресс-доказательство) |

View File

@@ -0,0 +1,76 @@
work_item: ORCH-113
stage: analysis
author_agent: analyst
status: ready-for-review
created_at: 2026-06-15
model_used: claude-opus-4-8
escalate: full-cycle
title: "job-reaper не повторяет финализацию deploy-staging при живом finalizer'е: живость + идемпотентность + строгое владение"
framework: pytest
scope: >
Покрывает: распознавание живого финализирующего монитора в Tier-2 reaper на стадии deploy-staging
(не reap по одному finished_age_s); идемпотентность и строгое владение исполнением edge-гейтов
(не более одного локального re-test/merge-gate на (job, stage)); сохранение добивания РЕАЛЬНО
мёртвого finalizer'а; отсутствие ложного отката deploy-staging -> development и расхождения состояния
после успешного deploy; сохранение инвариантов (STAGE_TRANSITIONS/QG_CHECKS/check_*/machine-verdict/
схема существующих таблиц байт-в-байт; never-raise; kill-switch; сквозной бюджет ORCH-065/109/110).
Вне покрытия: инфра-толерантность merge-gate к таймауту re-test и tree-kill осиротевших процессов
(ORCH-110); починка конкретных мигающих тестов; поведение enduro/не-self репо (только проверяется
отсутствие регрессии / no-op).
notes: >
TC-05 — ОБЯЗАТЕЛЬНЫЙ регресс-тест инцидента ORCH-111 (deployer job 1914 / run_id 683): КРАСНЫЙ на
коде до фикса (reaper при живом долгом finalizer'е deploy-staging независимо запускает второй прогон
edge-гейтов и откатывает задачу), ЗЕЛЁНЫЙ после фикса. Подпроцессы (pytest re-test / coverage /
docker), сеть, Plane и Gitea — мокаются; «живой/мёртвый finalizer» и «долгая финализация > grace»
моделируются управляемо, без обращения наружу. Полный регресс tests/ должен оставаться зелёным.
Точные имена символов/колонок/флагов уточняет архитектор (06-adr); модули-плейсхолдеры выровнены под
манифест PIPELINE_DOCS.
tests:
- id: TC-01
type: unit
description: "Tier-2 reaper на deploy-staging: exit_code=0 и finished_age_s >= grace, но finalizer ЖИВ (сигнал живости присутствует) -> reaper НЕ вызывает _gate_driven_advance/advance_stage; второй прогон edge-гейтов не запускается; логируется defer (AC-1/FR-1)."
module: tests/test_orch113_reaper_finalizer_liveness.py
expected: PASS
- id: TC-02
type: unit
description: "Строгое владение: при попытке повторной обработки того же (job, stage) актор без владения состоянием НЕ исполняет merge-gate/локальный re-test/advance (claim/ownership проигран -> ноль побочных эффектов), AC-2/FR-2."
module: tests/test_orch113_reaper_finalizer_liveness.py
expected: PASS
- id: TC-03
type: unit
description: "Мёртвый finalizer на deploy-staging (сигнал живости отсутствует/протух) -> reaper по-прежнему добивает job за ограниченное время по существующему контракту (retry в пределах бюджета, иначе failed+Telegram; Tier-3 backstop срабатывает) — reaper не no-op для deploy-staging (AC-3/FR-4)."
module: tests/test_orch113_reaper_finalizer_liveness.py
expected: PASS
- id: TC-04
type: integration
description: "Идемпотентность под гонкой: монитор финализирует deploy-staging и параллельно срабатывает reaper-тик -> тяжёлый прогон edge-гейтов (merge-gate/re-test) исполняется РОВНО ОДИН раз для (job, stage); нет второго re-test и нет ложного rollback (AC-2/AC-4/FR-2/FR-5)."
module: tests/test_orch113_reaper_finalizer_liveness.py
expected: PASS
- id: TC-05
type: integration
description: "ОБЯЗАТЕЛЬНЫЙ регресс ORCH-111: долгая (> grace) финализация deploy-staging при staging_status=SUCCESS, deploy/finalizer параллельно доходит до успеха/merge PR -> reaper НЕ откатывает deploy-staging -> development и НЕ инкрементирует developer-retry; у задачи единственное консистентное состояние. КРАСНЫЙ до фикса, ЗЕЛЁНЫЙ после (AC-4/FR-5)."
module: tests/test_orch113_reaper_finalizer_liveness.py
expected: PASS
- id: TC-06
type: unit
description: "Регресс-гард совместимости: kill-switch выключен ИЛИ стадия не deploy-staging -> поведение reaper байт-в-байт прежнее (Tier-2 grace, claim-before-act, dead-pid/Tier-3 пути неизменны), NFR-4/AC-5."
module: tests/test_orch113_reaper_finalizer_liveness.py
expected: PASS
- id: TC-07
type: unit
description: "Сквозной инвариант бюджета (ORCH-065/109/110): при дефолтном конфиге reaper_max_running_s (5400) > Σ(deploy-staging gate-work) + grace; любая правка grace/таймаутов фикса инвариант не нарушает (NFR-6/AC-5)."
module: tests/test_orch113_reaper_finalizer_liveness.py
expected: PASS
- id: TC-08
type: unit
description: "never-raise: сбой/исключение в новом пути распознавания живости/владения деградирует безопасно — reaper-тик не падает, прочие job обрабатываются, прод не трогается, main не пушится (NFR-1/NFR-3/AC-5)."
module: tests/test_orch113_reaper_finalizer_liveness.py
expected: PASS

View File

@@ -0,0 +1,158 @@
---
work_item: ORCH-113
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-15
model_used: claude-opus-4-8
---
# ADR-001: Reaper Tier-2 — in-memory ownership-маркер финализации `deploy-staging` (живой finalizer не реапится)
Work Item: **ORCH-113** — BUG: job-reaper повторно запускает финализацию `deploy-staging`, пока жив исходный finalizer
Стадия: **architecture**
Сквозная регистрация: **`docs/architecture/adr/adr-0043-reaper-finalizer-liveness-ownership.md`** (решение кросс-каттинговое — уточняет контракт reaper ORCH-065/adr-0011).
## Статус
Proposed
## Контекст
Оркестратор self-hosting: один инстанс, общая БД/очередь, `max_concurrency=1`. Финальный статус job
(`done`/`queued`/`failed`/`cancelled`) пишется **только** в живом процессе
(`launcher._monitor_agent → _finalize_job`). Сверено по коду:
- `_monitor_agent` штампит `agent_runs.finished_at`/`exit_code` **ПЕРВЫМ** (`launcher.py:861`), затем
делает git commit/push (+PR), usage-комментарии Plane (секунды…десятки секунд), затем
`_try_advance_stage` (`launcher.py:998`) и лишь потом `_finalize_job` (`launcher.py:1003`).
- На ребре `deploy-staging → deploy` `_try_advance_stage → advance_stage` синхронно, **в потоке
монитора**, прогоняет тяжёлый набор edge-под-гейтов (`stage_engine.py:327368`):
`security``merge-gate` (полный локальный re-test, бюджет `merge_retest_timeout_s=900`) →
`coverage` (`pytest --cov`) → `image-freshness` (docker-rebuild + пересоздание staging) — это
**минуты**, и весь объём идёт **после** штампа `finished_at` и **до** `_finalize_job`.
- Reaper Tier-2 (`job_reaper._reap_job`, `job_reaper.py:197209`) меряет `finished_age_s` от
`agent_runs.finished_at` (`db.get_running_jobs`, `db.py:1360`) = **от начала** финализации. По
истечении `reaper_finalize_grace_s=300` он трактует живого, долго финализирующего монитора как
мёртвого и независимо запускает тот же тяжёлый advance (`_reap_exit0 → _gate_driven_advance →
_try_advance_stage → advance_stage`).
Дешёвая read-only пред-проверка `_gate_is_green('deploy-staging')` читает лишь `check_staging_status`
(frontmatter `15-staging-log.md` = `SUCCESS`) → reaper уверенно идёт в тяжёлый advance. Атомарный
claim-before-act (`reap_running_job ... WHERE status='running'`) защищает **флип строки** job, но **не
side-effectful исполнение edge-гейтов**: монитор не claim'ит строку перед `advance_stage`, поэтому
монитор и reaper выполняют `advance_stage` **параллельно**.
**Инцидент ORCH-111 (deployer job 1914, run_id 683):** финализация `deploy-staging` заняла >300с;
reaper повторил edge-гейты; один повторный re-test стал красным (`3 failed … 14 errors in 444.79s`);
задача ложно откатана `deploy-staging → development` (+ ложный developer-retry), **параллельно**
исходный finalizer довёл deploy до `SUCCESS` и смержил PR #130 (`deploy → done`). Состояние раздвоилось.
Источника истины «жив ли finalizer» сегодня нет: pid агента в Tier-2 уже мёртв в **обоих** случаях
(`proc.wait()` вернулся), а живость **потока-монитора** система не наблюдает. Per-stage grace,
покрывающая Σ финализации (`Σ ≈ 4160с`), невозможна без нарушения сквозного бюджета ORCH-065/109/110
`reaper_max_running_s (5400) > Σ(deploy-staging gate-work) + grace (≈4460)`.
**Решающий факт (проверен):** монитор и reaper — daemon-**потоки одного** uvicorn-процесса (CMD без
`--workers`; `_monitor_agent` стартует `threading.Thread`, `launcher.py:661`; reaper — daemon-поток,
`main.py:144`), разделяющие одну SQLite-БД. Значит, живость finalizer'а можно определить **in-memory**.
Рестарт покрыт существующим `requeue_running_jobs()` (`running → queued`), который в `main.lifespan`
вызывается (`main.py:59`) **до** старта reaper (`main.py:144`).
## Решение
### Сводка
Ввести **процесс-локальный реестр владения финализацией**: живой монитор регистрирует «я финализирую
job X», а reaper в Tier-2 на стадии `deploy-staging` **не реапит** job, чьё владение активно, и
переходит к Tier-3 backstop. Реестр in-memory — авторитетен в рамках одного процесса/БД; рестарт
покрыт `requeue_running_jobs`. Grace и `reaper_max_running_s` не меняются → сквозной бюджет цел. Под
глобальным kill-switch; **нулевое** изменение схемы БД и контрактов.
### D1 — Leaf `src/finalizer_liveness.py` (владение, FR-2)
Новый чистый процесс-локальный модуль (паттерн `serial_gate`/`coverage_gate`: never-raise, без сети/БД):
- `mark(job_id, run_id, stage)` — зарегистрировать активную финализацию;
- `clear(job_id)` — снять;
- `is_active(job_id) -> bool` — есть ли живое владение;
- `snapshot() -> dict` — read-only для наблюдаемости.
Состояние — `{job_id: {"run_id", "stage", "started_ts"}}` + `threading.Lock`. Собственного TTL нет —
ограничение по времени даёт Tier-3 (см. D3). Все функции изолированы `try/except` → дефолт
(`is_active` при ошибке → `False`, консервативно: не блокировать добивание).
### D2 — Эмиссия владения в `launcher._monitor_agent` (FR-1)
`mark(job_id, run_id, stage)` вызывается **сразу после** штампа `exit_code` (`launcher.py:864`, самый
ранний момент, когда reaper переходит в Tier-2; до этого pid агента жив → Tier-1 защищает). Хвост
финализации (git push … `_try_advance_stage``_finalize_job`) оборачивается в `try/finally`, в
`finally``clear(job_id)`. Так исключение **в потоке монитора** гарантированно снимает владение →
reaper добивает (FR-4). Только при `job_id is not None` (legacy `launch()` с `job_id=None` не в
`get_running_jobs`). Гибель **всего процесса** → рестарт → `requeue_running_jobs` → реестр пуст
(restart-safe без durable, NFR-5).
### D3 — Консультация reaper, scoped + Tier-3 backstop (FR-3, FR-4)
В `job_reaper._reap_job`, Tier-2-ветка (`exit_code` записан, `finished_age >= grace`): **перед**
`_reap_known_outcome` — если `settings.reaper_finalizer_liveness_enabled` **И** стадия задачи
(`_task_meta`) `== "deploy-staging"` **И** `finalizer_liveness.is_active(job_id)`**defer** (лог +
счётчик), **не** реапить через Tier-2, провалиться к Tier-3. Иначе — прежний путь
(`_reap_known_outcome; return`), байт-в-байт. **Tier-3** (`age >= reaper_max_running_s`) маркер
**игнорирует** — добивает всегда (ограниченное время; бюджет `5400 > Σ+grace ≈ 4460` гарантирует, что
легитимная финализация завершится до 5400 → ложного Tier-3-реапа живого finalizer'а нет).
### D4 — Скоуп и kill-switch (NFR-4)
Только глобальный `reaper_finalizer_liveness_enabled` (`config.py`, env
`ORCH_REAPER_FINALIZER_LIVENESS_ENABLED`, дефолт `True`). **Без** per-repo разреза: баг общий для
любого репо со стадией `deploy-staging` (enduro тоже); per-repo оставил бы баг активным для части
репо. Это сознательный отход от leaf-паттерна `*_repos` (он для **гейтов, действующих на репо**; здесь
— наблюдатель-безопасность глобального демона). `False` → reaper никогда не консультирует маркер →
поведение байт-в-байт прежнее; стадии `!= deploy-staging` не консультируются → не тронуты.
### D5 — Наблюдаемость (TZ §4)
Счётчик `finalizer_defers_total` + размер `finalizer_liveness.snapshot()` в блоке `reaper`
`GET /queue`. Существующие ключи ответа не меняются; новых эндпоинтов нет.
### Инварианты
`STAGE_TRANSITIONS` / `QG_CHECKS` / каждый `check_*` / machine-verdict ключи / схема существующих
таблиц — **байт-в-байт**; **нулевое** изменение схемы БД; reaper остаётся never-raise per-unit;
`reaper_finalize_grace_s` и `reaper_max_running_s` **не меняются** (NFR-6 цел); фикс не рестартит прод
и не пушит `main` (NFR-3). Merge-verify (`deploy → done`, ORCH-071) — единственный choke-point в
`done`, не ослабляется (FR-5).
## Альтернативы
- **Per-stage grace, покрывающая Σ** — отвергнуто: нарушает бюджет `5400 > Σ+grace` (Σ≈4160 ⇒ grace
пришлось бы <1240, не покрывает Σ); таймер — это и есть источник бага.
- **Durable-колонка (finalizing-heartbeat / owner-токен)** — отвергнуто: один процесс/одна БД →
in-memory авторитетно; рестарт покрыт requeue; блокирующий re-test (900с) не может бить периодический
heartbeat из того же потока; durable добавляет миграцию и запись ради нулевой выгоды.
- **Sub-state `finalizing` в `jobs.status`** — отвергнуто: меняет семантику статуса, который читают
`claim_next_job`/`requeue_running_jobs`/`reconciler`/`reaper` (`WHERE status='running'`) — нарушение
NFR-2 и высокий радиус поражения.
- **Lease-файл на `(job, stage)` (как merge-lease)** — отвергнуто: тяжелее (файловый I/O, TTL,
reclaim), дублирует merge-lease; in-memory достаточно при одном процессе; TTL возвращает таймер-проблему.
- **Флип job из `running` до тяжёлых гейтов** — отвергнуто: ломает `get_running_jobs`/метрики и
restart-requeue (краш мид-гейт оставит job non-running и нереквью'имым).
## Последствия
- **+** Устранены повторный прогон edge-гейтов, ложный откат `deploy-staging → development` и
расхождение состояния при живом долгом finalizer'е; идемпотентность edge-гейтов через владение
(AC-1/AC-2/AC-4).
- **+** Реально мёртвый/застрявший finalizer добивается (finally-clear → Tier-2; иначе Tier-3);
основная функция reaper ORCH-065 сохранена (AC-3).
- **+** Нулевое изменение схемы и контрактов; сквозной бюджет ORCH-065/109/110 не тронут (AC-5).
- **** Гарантия владения валидна при **одном процессе/одной БД** (проверено: один uvicorn-воркер);
ввод `--workers>1` потребует durable-сигнала — зафиксировано в `10-tech-risks.md` (TR-3).
- **** Окно «штамп `finished_at``mark()`» (git push) маркером не покрыто — закрыто прежним
grace=300 (окно ≪ grace), TR-2.
- **Откат:** `ORCH_REAPER_FINALIZER_LIVENESS_ENABLED=false` → reaper байт-в-байт прежний (маркер
по-прежнему пишется монитором, но не консультируется — инертен). Полный откат — удаление leaf +
двух врезок.
## Ссылки
- BRD: `docs/work-items/ORCH-113/01-brd.md`
- TRZ: `docs/work-items/ORCH-113/02-trz.md`
- Acceptance: `docs/work-items/ORCH-113/03-acceptance-criteria.md`
- Test-plan: `docs/work-items/ORCH-113/04-test-plan.yaml`
- Сквозной ADR: `docs/architecture/adr/adr-0043-reaper-finalizer-liveness-ownership.md`
- Базовые ADR: `adr-0011` (reaper/lease ORCH-065), `adr-0040` (timeout-бюджеты ORCH-109),
`adr-0042` (merge-gate re-test ORCH-110)
- Сверено по коду: `src/job_reaper.py`, `src/agents/launcher.py` (`_monitor_agent`/`_try_advance_stage`),
`src/db.py` (`get_running_jobs`/`requeue_running_jobs`), `src/stage_engine.py` (`advance_stage` ребро
`deploy-staging`), `src/config.py` (`reaper_*`), `src/main.py` (`lifespan`)
</content>

View File

@@ -0,0 +1,40 @@
---
work_item: ORCH-113
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-15
model_used: claude-opus-4-8
---
# 07 — Инфра-требования: ORCH-113 — reaper finalizer-liveness ownership
Work Item: **ORCH-113** · Repo: **orchestrator** · Стадия: architecture
> When-applicable / информационный. Топология **не меняется**; ниже — только конфиг и операционные
> инварианты, которые сопровождающий обязан удержать.
## Изменения топологии
**N/A.** Ни новых сервисов/контейнеров, ни портов, ни томов, ни сетевых правил. Решение целиком внутри
процесса `orchestrator` (новый leaf + две врезки в существующие потоки monitor/reaper).
## Новый конфиг (env)
| Ключ | Дефолт | Назначение |
|------|--------|-----------|
| `ORCH_REAPER_FINALIZER_LIVENESS_ENABLED` | `true` | Kill-switch. `false` → reaper байт-в-байт прежний (маркер пишется, но не консультируется). Откат фикса = установить `false`. |
Существующие `reaper_finalize_grace_s` (300) и `reaper_max_running_s` (5400) — **не меняются**.
`.env.example` пополнить новым ключом (дефолт = боевое значение, паттерн ORCH-101: пустой `.env`
прежнее поведение).
## Операционные инварианты (сопровождение)
- **Одно-процессная модель — несущий инвариант.** Авторитетность in-memory реестра владения держится
на том, что монитор и reaper — потоки **одного** uvicorn-процесса. CMD/команда compose **не должны**
получать `uvicorn --workers>1` без перевода сигнала в durable (см. `10-tech-risks.md` TR-3, ADR-001).
Сверено: `Dockerfile:65`, `docker-compose.yml:36` (prod), `docker-compose.yml:123` (staging) — без
`--workers`.
- **Сквозной бюджет ORCH-065/109/110** `reaper_max_running_s (5400) > Σ(deploy-staging gate-work)+grace
(≈4460)` остаётся в силе и фиксом не затрагивается (TR-4).
- **Self-hosting-страховка:** обкатка — на staging (8501, изолированная БД) до прод-деплоя; деплой
орка — только через статус «Confirm Deploy». Фикс не рестартит прод и не пушит `main`.
</content>

View File

@@ -0,0 +1,43 @@
---
work_item: ORCH-113
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-15
model_used: claude-opus-4-8
---
# 08 — Требования к данным: ORCH-113 — reaper finalizer-liveness ownership
Work Item: **ORCH-113** · Repo: **orchestrator** · Стадия: architecture
> When-applicable / информационный (гейтом не парсится).
## Изменения схемы БД
**N/A — нулевое изменение схемы.** Сознательное архитектурное решение (ADR-001 / adr-0043): сигнал
владения финализацией — **in-memory** (leaf `src/finalizer_liveness.py`), а не durable-колонка. Ни
новых таблиц, ни новых колонок, ни индексов; `init_db()` / `_ensure_column` не трогаются. Схема
существующих таблиц (`jobs`, `agent_runs`, `tasks`, …) и их семантика — **байт-в-байт** (NFR-2/AC-5).
## Новые/изменённые сущности
**Процесс-локальный реестр владения** (не БД): `finalizer_liveness` хранит
`{job_id: {"run_id", "stage", "started_ts"}}` под `threading.Lock`. Запись/снятие — живой
монитор-поток (`launcher._monitor_agent`); чтение — reaper-поток (`job_reaper`). Ключ — `jobs.id`
(существующая сущность). Никаких новых персистентных данных.
## Совместимость данных / миграции
- **Миграций нет** — нечего мигрировать (нет схемных изменений); общая прод-БД (self-hosting +
enduro-trails) не затрагивается.
- **Restart-safe без durable (NFR-5):** in-memory реестр сбрасывается при рестарте процесса, что
**безопасно** по существующему контракту: `main.lifespan` вызывает `requeue_running_jobs()`
(`running → queued`, `main.py:59`) **до** старта reaper (`main.py:144`). После рестарта нет ни одного
`running`-job, ссылающегося на потерянный маркер → отсутствие маркера корректно (нет живых
finalizer'ов). Гибель **потока** монитора (не процесса) покрыта `try/finally`-снятием маркера; гибель
**процесса** → рестарт → requeue.
- **Авторитетность in-memory** опирается на одно-процессную модель (один uvicorn-воркер, общая
SQLite-БД; проверено: CMD без `--workers`). Условие задокументировано как инвариант сопровождения —
при вводе `--workers>1` сигнал должен стать durable (см. `10-tech-risks.md` TR-3).
</content>

View File

@@ -0,0 +1,37 @@
---
work_item: ORCH-113
stage: architecture
author_agent: architect
status: proposed
created_at: 2026-06-15
model_used: claude-opus-4-8
---
# 10 — Технические риски: ORCH-113 — reaper finalizer-liveness ownership
Work Item: **ORCH-113** · Repo: **orchestrator** · Стадия: architecture
> Информационный (гейтом не парсится). Риски реализации и их митигейшн.
## Реестр рисков
| ID | Риск | Вер. | Влия. | Митигейшн |
|----|------|------|-------|-----------|
| TR-1 | **Над-толерантность:** маркер «жив» застрял (не снят) → реально мёртвый finalizer не добивается, зомби клинит очередь (регресс ORCH-065). | Низ. | Выс. | `try/finally`-снятие в `_monitor_agent` (исключение потока снимает владение); гибель процесса → рестарт → `requeue_running_jobs`. **Tier-3 backstop игнорирует маркер** и добивает при `age >= reaper_max_running_s=5400` → ограниченное время гарантировано (FR-4/AC-3). Покрытие — TC-03. |
| TR-2 | **Окно без владения** между штампом `finished_at` (launcher:861) и `mark()` (после exit_code, launcher:864): reaper мог бы реапнуть в этом окне. | Низ. | Сред. | Окно = git push/PR/Plane-комментарии (секунды…десятки секунд) ≪ `reaper_finalize_grace_s=300` → прежний grace покрывает его; маркер ставится самым ранним возможным моментом Tier-2 (до этого pid агента жив → Tier-1 защищает). |
| TR-3 | **Многопроцессность:** при `uvicorn --workers>1` монитор и reaper окажутся в разных процессах → in-memory реестр не разделяется → возможна двойная финализация. | Низ. | Выс. | Сейчас CMD без `--workers` (проверено: `Dockerfile:65`, `docker-compose.yml:36`). Инвариант сопровождения зафиксирован в ADR-001/adr-0043 и 08-data-requirements: ввод `--workers>1` ⇒ перевести сигнал в durable (heartbeat-колонка) — отдельная задача. Анти-дрейф можно усилить структурным тестом (нет `--workers` в CMD). |
| TR-4 | **Нарушение сквозного бюджета** ORCH-065/109/110 при правке grace/таймаутов. | Оч. низ. | Выс. | Решение **не меняет** `reaper_finalize_grace_s` (300) и `reaper_max_running_s` (5400) — инвариант `5400 > Σ(deploy-staging gate-work)+grace ≈ 4460` тривиально цел; покрытие — TC-07/AC-5. |
| TR-5 | **Гонка чтения/записи** реестра (монитор пишет, reaper читает). | Низ. | Сред. | `threading.Lock` вокруг операций реестра; `is_active`/`snapshot` атомарны под локом; never-raise → ошибка чтения = `False` (консервативно, не блокирует добивание). Покрытие — TC-02/TC-04/TC-08. |
| TR-6 | **Регресс не-deploy-staging / выключенного флага** (NFR-4): фикс случайно меняет прежние пути reaper. | Низ. | Сред. | Консультация маркера gated `enabled AND stage=="deploy-staging"`; Tier-1/Tier-3/exit≠0/claim-before-act не трогаются; `False` → reaper байт-в-байт прежний. Покрытие — TC-06. |
| TR-7 | **Ложный regression-тест** TC-05: зелёный и до фикса (не воспроизводит баг). | Сред. | Сред. | TC-05 моделирует «живой долгий finalizer > grace» управляемо (моки подпроцессов/сети); обязан быть **красным до** фикса и **зелёным после** (AC-6). Reviewer/tester проверяют красноту на базе. |
## Сводный вывод
Доминирующий класс — **над-толерантность** (TR-1) и **многопроцессная авторитетность** (TR-3); оба
имеют низкую вероятность и закрыты соответственно Tier-3 backstop'ом (без правки бюджета) и
зафиксированным инвариантом одно-процессной модели. Решение аддитивно, под kill-switch, без изменения
схемы/контрактов и без правки сквозного бюджета. Эскалация `arch:major-change` **не требуется**
(нет новой стадии/QG, нет изменения схемы БД, центр тяжести — один leaf + две точечные врезки).
Остаточный риск для прод-конвейера (self-hosting) — **низкий**; полностью обратим выключением
`ORCH_REAPER_FINALIZER_LIVENESS_ENABLED`.
</content>

View File

@@ -0,0 +1,86 @@
---
verdict: APPROVED
work_item: ORCH-113
stage: review
author_agent: reviewer
status: approved
created_at: 2026-06-15
model_used: claude-opus-4-8
type: review
work_item_id: ORCH-113
version: 1
---
# Review ORCH-113 — BUG: job-reaper не должен повторно запускать финализацию `deploy-staging`, пока жив исходный finalizer
## Summary
Фикс инцидента ORCH-111 реализован чисто и полно. Введён процесс-локальный реестр владения
финализацией (`src/finalizer_liveness.py`, never-raise leaf по паттерну `serial_gate`/`coverage_gate`):
монитор `mark()`-ит владение сразу после штампа `exit_code` и `clear()`-ит его в `finally` хвоста
финализации; reaper в Tier-2 при `stage=="deploy-staging"` И активном владении делает **defer** вместо
повторного `advance_stage`, проваливаясь к Tier-3 backstop (который маркер игнорирует → застрявший
finalizer всё равно добивается).
Проверено по всем 4 осям; блокирующих findings нет.
- **ТЗ:** FR-1…FR-5 реализованы; AC-1…AC-6 покрыты тестами `tests/test_orch113_reaper_finalizer_liveness.py`
(TC-01…TC-08). Схема БД — **нулевое** изменение (выбран in-memory реестр), что строже допущенной ТЗ §5
«аддитивная колонка». API §4 (read-only ключи в `GET /queue`) и QG §6 (не трогать) — соблюдены.
- **ADR:** реализация байт-в-байт соответствует ADR-001 / сквозному adr-0043 (D1D5). Трассировка
сохранена: авторитет Tier-3 (adr-0011/ORCH-065) и сквозной бюджет `reaper_max_running_s (5400) >
Σ(gate-work)+grace` (ORCH-109/110) не нарушены — зафиксировано регресс-тестом TC-07. Ни один
маркированный инвариант не сломан.
- **Качество кода:** хвост `_monitor_agent` вынесен в `_run_monitor_finalization` **дословно**
подтверждено `git diff -w` (+49/0, нулевое изменение логики); все переменные, на которые ссылается
извлечённое тело, — параметры/локальные/модульные (нет риска `NameError`, проверено вручную).
never-raise во всех публичных функциях и врезках. Обязательный регресс-тест багфикс-трека (ORCH-019
BR-4 / coverage ORCH-027) присутствует: TC-05 по построению КРАСНЫЙ до фикса (assert `calls == []`,
который pre-fix reaper нарушил бы вызовом `_try_advance_stage`) и ЗЕЛЁНЫЙ после.
- **Документация:** обновлены в том же PR — `docs/architecture/README.md` (описание Job-reaper +
раздел Tier-2 + список kill-switch + ссылки на ADR), `docs/architecture/internals.md` (детализация
Tier-2), `CHANGELOG.md`, ADR-001 (work-item) и сквозной adr-0043; все номерные доки задачи (0004,
06-adr, 07, 08, 10) на месте.
**Проверка прогона:** `pytest tests/ -q`**2001 passed**, 0 failures (AC-6); целевой файл — 13 passed.
## Findings
### P0 — Blocker
-ет_
### P1 — Must fix
-ет_
### P2 — Should fix
-ет_
### P3 — Nice-to-have (не блокирует приёмку)
- [ ] Frontmatter обоих ADR (`ADR-001` и `adr-0043`) держит `status: proposed`. По мере мержа фикса
статус естественно становится принятым решением — стоит при следующем касании обновить на `accepted`
(косметика трассировки, не влияет на гейт).
- [ ] В врезке `mark()` (`launcher._monitor_agent`, стр. ~884) делается отдельный
`get_task_by_repo_branch(repo, branch)` ради `stage`-контекста, хотя тот же lookup повторяется ниже в
хвосте финализации (стр. ~984). Дублирование на пути, и так делающем БД-работу, обёрнуто never-raise;
`stage` здесь — best-effort контекст для `snapshot()` (reaper резолвит стадию независимо через
`_task_meta`), так что корректность не зависит от него. Можно при желании переиспользовать один lookup.
## Документация
**Статус: полностью обновлена в том же PR (golden source соблюдён).**
| Артефакт | Изменение | Оценка |
|----------|-----------|--------|
| `docs/architecture/README.md` | Job-reaper компонент + раздел Tier-2 + список kill-switch (`ORCH_REAPER_FINALIZER_LIVENESS_ENABLED`) + ссылки на adr-0043 | ✅ |
| `docs/architecture/internals.md` | Детализация Tier-2 deploy-staging defer | ✅ |
| `CHANGELOG.md` | Развёрнутая запись `[Unreleased]` с подпунктами (leaf / эмиссия / консультация / наблюдаемость) | ✅ |
| `docs/work-items/ORCH-113/06-adr/ADR-001-…` | Детальный ADR (D1D5, альтернативы, последствия) | ✅ |
| `docs/architecture/adr/adr-0043-…` | Сквозной ADR (уточняет adr-0011/0040/0042/0041) | ✅ |
| `docs/work-items/ORCH-113/{00..04,07,08,10}` | Полный пакет номерных доков | ✅ |
**Обзорные доки / витрина:** правка внутренняя для job-reaper; высокоуровневые описания в
`docs/overview/tech-architecture.md` («job-reaper возвращает в очередь job'ы, чей исполнитель умер») и
`README.md` остаются корректными — обновления не требуют (ORCH-079/ORCH-011 не задеты). Раздел README
«Известные ограничения» не содержит пункта, закрываемого этим PR (баг был инцидентом, не значился
ограничением) — обновление не требуется. Известное ограничение `--workers>1` (TR-3) — системное
пред-допущение, документировано в `10-tech-risks.md` и обоих ADR; вынос в README не обязателен.

View File

@@ -0,0 +1,66 @@
---
result: PASS # PASS | FAIL — машинный вердикт, UPPERCASE
work_item: ORCH-113
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-113
---
# Test Report — ORCH-113
BUG: job-reaper не должен повторно запускать финализацию `deploy-staging`, пока жив исходный finalizer.
## Окружение
- Worktree: `/repos/_wt/orchestrator/feature_ORCH-113-bug-job-reaper-must-not-re-run` (ветка `feature/ORCH-113-bug-job-reaper-must-not-re-run`)
- Python: 3.12.13
- pytest: 8.3.3 (plugins: cov-5.0.0, anyio-4.13.0, asyncio-0.23.8)
- Дата: 2026-06-15
- Review verdict (12-review.md): `APPROVED`
## Smoke API (read-only)
| Endpoint | Результат |
|----------|-----------|
| `GET /health` | `{"status":"ok","service":"orchestrator"}` — PASS |
| `GET /status` | Отвечает; ORCH-113 (task 101) виден на стадии `testing` — PASS |
| `GET /queue` | Блоки `serial_gate` (ORCH-088) **и** `auto_labels` (ORCH-089) присутствуют — PASS |
## Результаты — покрытие тест-плана (04-test-plan.yaml)
Все TC реализованы в `tests/test_orch113_reaper_finalizer_liveness.py` (13 тест-функций на 8 TC).
| TC ID | Описание | AC / FR | Тест-функция | Результат |
|-------|----------|---------|--------------|-----------|
| TC-01 | Живой finalizer на `deploy-staging` (exit=0, age≥grace) → reaper НЕ вызывает `_gate_driven_advance`/`advance_stage`, логирует defer | AC-1/FR-1 | `test_tc01_live_finalizer_deploy_staging_not_reaped` | PASS |
| TC-02 | Строгое владение: актор без владения НЕ исполняет merge-gate/re-test/advance (ноль побочных эффектов) | AC-2/FR-2 | `test_tc02_non_owner_runs_no_edge_gates` | PASS |
| TC-03 | Мёртвый finalizer → reaper по-прежнему добивает job (Tier-2 retry + Tier-3 backstop игнорирует маркер); reaper не no-op | AC-3/FR-4 | `test_tc03_dead_finalizer_still_reaped_tier2`, `test_tc03_tier3_backstop_ignores_marker` | PASS |
| TC-04 | Идемпотентность под гонкой: тяжёлый прогон edge-гейтов исполняется ровно один раз для `(job, stage)`, нет второго re-test/ложного rollback | AC-2/AC-4/FR-2/FR-5 | `test_tc04_idempotent_no_second_advance_under_race` | PASS |
| TC-05 | **ОБЯЗАТЕЛЬНЫЙ регресс ORCH-111:** долгая (>grace) финализация при `staging_status=SUCCESS` → нет отката `deploy-staging → development`, нет ложного developer-retry; единственное консистентное состояние (красный до фикса, зелёный после) | AC-4/FR-5 | `test_tc05_orch111_no_false_rollback_no_retry_increment` | PASS |
| TC-06 | Регресс-гард совместимости: kill-switch off ИЛИ не-`deploy-staging` → поведение reaper байт-в-байт прежнее | NFR-4/AC-5 | `test_tc06_killswitch_off_byte_for_byte_prior`, `test_tc06_non_deploy_staging_stage_not_consulted`, `test_tc06_within_grace_unchanged` | PASS |
| TC-07 | Сквозной инвариант бюджета: `reaper_max_running_s (5400) > Σ(deploy-staging gate-work) + grace` (ORCH-065/109/110) | NFR-6/AC-5 | `test_tc07_budget_invariant_preserved` | PASS |
| TC-08 | never-raise: сбой пути живости/владения деградирует безопасно — reaper-тик не падает, прочие job обрабатываются | NFR-1/NFR-3/AC-5 | `test_tc08_liveness_error_never_breaks_tick`, `test_tc08_reap_once_isolates_and_never_raises`, `test_tc08_finalizer_liveness_leaf_never_raises` | PASS |
**Сопоставление с 03-acceptance-criteria.md:** AC-1…AC-6 покрыты (AC-1→TC-01, AC-2→TC-02/TC-04, AC-3→TC-03, AC-4→TC-04/TC-05, AC-5→TC-06/TC-07/TC-08, AC-6→полный зелёный прогон + TC-05 как регресс-доказательство). Каждый TC из тест-плана выполнен и сопоставлен.
## Вывод pytest
Целевой файл:
```
tests/test_orch113_reaper_finalizer_liveness.py ... 13 passed, 1 warning in 3.60s
```
Полный регресс:
```
$ python -m pytest tests/ -q
........................................................................ [ 39%]
... (snip) ...
......................................................... [100%]
2001 passed, 1 warning in 316.72s (0:05:16)
```
(единственный warning — `PydanticDeprecatedSince20` в `src/config.py:8`, не относится к задаче, присутствует исторически)
## Итог
**PASS** — целевой файл 13/13 PASS, полный регресс `tests/` 2001 passed / 0 failed, smoke API (`/health`, `/status`, `/queue` с блоками `serial_gate` + `auto_labels`) зелёный, каждый TC тест-плана выполнен и сопоставлен с критериями приёмки. Задача переходит на `deploy-staging`.

View File

@@ -0,0 +1,12 @@
---
deploy_status: SUCCESS
work_item: ORCH-113
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,25 @@
---
security_status: PASS
secrets_found: 0
deps_blocking: 0
deps_warning: 4
deps_audit_degraded: false
---
# Security Report — ORCH-113
Детерминированный security-гейт (ORCH-022): secret-scanning (gitleaks, offline) + dependency audit (pip-audit). Машинный вердикт читается ТОЛЬКО из frontmatter выше.
## Verdict
clean: 0 secrets, 0 blocking CVE(s)
## Secrets
- None
## Dependencies (blocking)
- None
## Dependencies (warning)
- `pytest==8.3.3` — GHSA-6w46-j5rx-g56g severity=UNKNOWN fix=9.0.3
- `starlette==0.38.6` — PYSEC-2026-161 severity=UNKNOWN fix=1.0.1
- `starlette==0.38.6` — GHSA-f96h-pmfr-66vw severity=UNKNOWN fix=0.40.0
- `starlette==0.38.6` — GHSA-2c2j-9gv5-cj73 severity=UNKNOWN fix=0.47.2

View File

@@ -0,0 +1,22 @@
---
coverage_status: PASS
work_item: ORCH-113
measured_coverage: 80.02
baseline: 79.95
floor: 0.00
policy: both
epsilon: 0.50
delta: 0.07
---
# Coverage Report — ORCH-113
Детерминированный гейт покрытия (ORCH-027) — под-гейт ребра `deploy-staging→deploy` (ПОСЛЕ merge-gate, ДО image-freshness). Машинный вердикт читается ТОЛЬКО из `coverage_status:` frontmatter выше.
## Verdict
measured=80.02% policy=both eps=0.50: absolute 80.02% >= floor 0.00%-eps0.50 -> PASS; baseline 80.02% >= base 79.95%-eps0.50 -> PASS
## Measurement
pytest --cov=src: line coverage src/ = 80.02%
## Policy
policy=both, floor=0.0%, baseline=79.95%, epsilon=0.5%

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

@@ -220,6 +220,35 @@ else
log "No previous image captured (first deploy or service not running?)"
fi
# 2a. ORCH-112: resilient pull — converge the shared deploy-base to a clean, current
# origin/main BEFORE the pull, so a dirty working tree (manual/abandoned WIP left
# by a failed/cancelled task) never blocks the deploy (incident ORCH-111, dirt from
# ORCH-104). Gated by CHECKOUT_HYGIENE (Python kill-switch + self-hosting scope,
# injected by self_deploy.build_deploy_command). NEVER `-x` (would delete gitignored
# .env / data/*.db / build/); EXCLUDES the untracked-but-not-ignored rollback/log
# artefacts .deploy-prev-image-* and deploy-hook.log (NFR-2). Best-effort: every git
# step is `|| log "...continuing"` and the bare `git pull` below still runs
# (never-break). On a CLEAN base the whole block is a no-op -> the happy-path
# behaviour and exit-codes (0/1/2, ORCH-036) are byte-for-byte unchanged.
if [[ "${CHECKOUT_HYGIENE:-0}" == "1" ]]; then
dirty="$(git status --porcelain 2>/dev/null || true)"
if [[ -n "$dirty" ]]; then
log "HYGIENE: dirty deploy-base detected, converging to origin/main:"
log "$dirty"
git fetch origin main >> "$LOG" 2>&1 || log "HYGIENE: fetch failed (continuing)"
git reset --hard origin/main >> "$LOG" 2>&1 || log "HYGIENE: reset failed (continuing)"
git clean -fd \
-e '.deploy-prev-image-*' \
-e 'deploy-hook.log' \
>> "$LOG" 2>&1 || log "HYGIENE: clean failed (continuing)"
if [[ -n "${HYGIENE_REPORT:-}" ]]; then
{ printf 'dirty=1\n'; printf '%s\n' "$dirty"; } > "$HYGIENE_REPORT" 2>/dev/null || true
fi
else
log "HYGIENE: deploy-base already clean (no-op)"
fi
fi
# 2. Pull latest code (keeps the host working tree current for future builds;
# the DEPLOYED artefact is the retagged SOURCE_IMAGE below when build-once).
log "git pull origin main"

View File

@@ -868,6 +868,55 @@ class AgentLauncher:
_task_id = _row[0] if _row else None
conn.close()
# ORCH-113 (adr-0043 / D2): register finalizer ownership at the EARLIEST
# moment the reaper can enter Tier-2 — exit_code is now stamped, so the agent
# pid is dead and Tier-1 no longer protects this row. On the
# deploy-staging -> deploy edge the rest of the finalization (git push, the
# heavy edge sub-gates via _try_advance_stage, _finalize_job) runs IN THIS
# THREAD for MINUTES; without ownership the reaper would treat the live
# finalizer as dead and re-run the advance (false rollback, incident
# ORCH-111). The marker is written unconditionally (kill-switch only gates the
# reaper's CONSULTATION, so the disabled path is byte-for-byte prior). Only
# queue-launched jobs (job_id is not None) are in get_running_jobs / reapable.
if job_id is not None:
try:
from .. import finalizer_liveness
_t_mark = get_task_by_repo_branch(repo, branch)
finalizer_liveness.mark(
job_id, run_id, _t_mark["stage"] if _t_mark else None
)
except Exception: # noqa: BLE001 - never-raise: marker is best-effort
pass
# The finalization tail runs under try/finally so ownership is ALWAYS
# released — including on ANY exception in this thread, which lets the reaper
# finish a genuinely dead finalizer (FR-4).
try:
self._run_monitor_finalization(
run_id, agent, repo, branch, exit_code, output_path,
job_id, _task_id, _duration_s,
)
finally:
if job_id is not None:
try:
from .. import finalizer_liveness
finalizer_liveness.clear(job_id)
except Exception: # noqa: BLE001 - never-raise
pass
def _run_monitor_finalization(
self, run_id, agent, repo, branch, exit_code, output_path,
job_id, _task_id, _duration_s,
):
"""ORCH-113 (adr-0043): finalization tail of ``_monitor_agent``.
Extracted VERBATIM from ``_monitor_agent`` (no logic change — verify with
``git diff -w``) so the caller can wrap it in a single ``try/finally`` that
releases the finalizer-ownership marker (``finalizer_liveness.clear``) on any
outcome. Runs in the monitor thread: notify, usage accounting, git
commit/push (+PR), failure handling, usage comments, the gate-driven
``_try_advance_stage`` and finally ``_finalize_job`` for queue-launched jobs.
"""
notify_agent_finished(run_id, agent, exit_code, task_id=_task_id, duration_s=_duration_s)
# Feature 4: parse token usage / cost from the (json) run log and record

214
src/checkout_hygiene.py Normal file
View File

@@ -0,0 +1,214 @@
"""ORCH-112 (ADR-001 / adr-0044): deploy-base checkout-hygiene leaf — pure policy.
Leaf module mirroring ``src/serial_gate.py`` / ``src/cancel.py`` / ``src/self_deploy.py``:
pure, unit-testable, never-raise functions over ``config`` + the deploy-state sentinels.
Module-level imports are limited to ``config`` (and stdlib); ``self_deploy``,
``qg.checks.is_self_hosting_repo`` and ``notifications`` are imported LAZILY so this
stays a leaf and an import cycle can never form.
What it answers / does (the MECHANISM — git fetch/reset/clean — lives in the host
deploy hook ``scripts/orchestrator-deploy-hook.sh`` block "2a. Resilient pull"; this
leaf only decides conditionality, builds the env gate, reads the report and alerts):
* ``applies(repo)`` — is resilient-pull hygiene REAL here?
* ``hook_env(repo, work_item_id)`` — the ``CHECKOUT_HYGIENE=1 HYGIENE_REPORT=…``
env prefix injected into the detached
deploy-hook command ("" when not applies).
* ``read_report(repo, work_item_id)`` — read the ``hygiene`` sentinel the hook wrote.
* ``alert_dirty(repo, work_item_id, report)``— best-effort Telegram + structured log.
* ``snapshot()`` — read-only block for ``GET /queue``.
never-raise contract (self-hosting safety): every public function degrades
conservatively. ``applies`` -> False on error (hygiene inert == kill-switch off, the
safe default that keeps the bare ``git pull`` 1:1 as before ORCH-112). ``hook_env`` ->
"" on error (no env -> the hook's ``${CHECKOUT_HYGIENE:-0}`` guard stays 0). The report
reader / alert swallow every error so a deploy is NEVER crashed by an observability
hiccup (D5 / AC-8).
"""
from __future__ import annotations
import logging
import os
import re
import shlex
from .config import settings
logger = logging.getLogger("orchestrator.checkout_hygiene")
# Sentinel filename the hook writes (HYGIENE_REPORT points at it) and read_report
# reads back. Lives in the SAME deploy-state dir as self_deploy's ``result`` (shared
# mount visible to both host and container).
REPORT_NAME = "hygiene"
# Repo tokens in the CSV scope must match this (mirrors serial_gate._REPO_TOKEN). The
# CSV is operator config, not user input, but the guard is mandatory; an invalid token
# is dropped.
_REPO_TOKEN = re.compile(r"^[A-Za-z0-9._-]+$")
# ---------------------------------------------------------------------------
# Conditionality (mirrors self_deploy_applies / serial_gate_applies)
# ---------------------------------------------------------------------------
def _scope_repos() -> set[str]:
"""Sanitised set of in-scope repo tokens from ``checkout_hygiene_repos`` (CSV).
Empty/blank CSV -> empty set, meaning "self-hosting only" (resolved in ``applies``).
Invalid tokens (regex miss) are dropped. Never raises.
"""
try:
raw = (settings.checkout_hygiene_repos or "").strip()
except Exception: # noqa: BLE001
return set()
if not raw:
return set()
out: set[str] = set()
for tok in raw.split(","):
t = tok.strip()
if t and _REPO_TOKEN.match(t):
out.add(t)
elif t:
logger.warning("checkout_hygiene: dropping invalid repo token %r from CSV", t)
return out
def applies(repo: str) -> bool:
"""Whether resilient-pull hygiene is REAL for this repo (D3 / AC-6).
* ``checkout_hygiene_enabled=False`` -> always False (kill-switch; the hook sees
no CHECKOUT_HYGIENE env -> bare ``git pull origin main`` 1:1 as before ORCH-112).
* ``checkout_hygiene_repos`` (CSV) non-empty -> real only for listed repos.
* empty CSV -> real ONLY for the self-hosting repo (``orchestrator``), mirroring
``self_deploy_repos`` — this is a self-hosting prod-deploy-path feature, so it
must NOT touch enduro / other repos' synchronous deploy.
Local-only (no network), meant to be checked FIRST. Never raises -> False on error.
"""
try:
if not getattr(settings, "checkout_hygiene_enabled", False):
return False
scope = _scope_repos()
if scope:
return (repo or "").strip() in scope
# Lazy import keeps this module a leaf (no qg import at module load).
from .qg.checks import is_self_hosting_repo
return is_self_hosting_repo(repo)
except Exception as e: # noqa: BLE001 - never-raise
logger.warning("checkout_hygiene.applies error for %s: %s", repo, e)
return False
# ---------------------------------------------------------------------------
# Env gate injected into the detached deploy-hook command (Phase B wiring)
# ---------------------------------------------------------------------------
def report_path_host(repo: str, work_item_id: str | None) -> str:
"""HOST view of the ``hygiene`` sentinel path (the wrapper writes it there)."""
from . import self_deploy
return os.path.join(self_deploy.host_state_dir(repo, work_item_id), REPORT_NAME)
def hook_env(repo: str, work_item_id: str | None) -> str:
"""Build the env-assignment prefix injected into the detached deploy-hook command.
Returns ``CHECKOUT_HYGIENE=1 HYGIENE_REPORT=<host-path>`` (shlex-quoted) ONLY when
``applies(repo)`` is True; otherwise ``""`` so the hook's ``${CHECKOUT_HYGIENE:-0}``
guard stays 0 and the bare ``git pull`` runs (1:1 before ORCH-112). The
``HYGIENE_REPORT`` path is the HOST view of the deploy-state dir (the host wrapper
writes the sentinel there; the container reads it back via ``read_report``). Never
raises -> "" (no hygiene env, the safe default).
"""
try:
if not applies(repo):
return ""
report = report_path_host(repo, work_item_id)
return f"CHECKOUT_HYGIENE=1 HYGIENE_REPORT={shlex.quote(report)}"
except Exception as e: # noqa: BLE001 - never-raise -> no hygiene env
logger.warning("checkout_hygiene.hook_env error for %s/%s: %s", repo, work_item_id, e)
return ""
# ---------------------------------------------------------------------------
# Report sentinel reader (Phase C observability)
# ---------------------------------------------------------------------------
def read_report(repo: str, work_item_id: str | None) -> dict | None:
"""Read the ``hygiene`` sentinel the hook wrote (container view of deploy-state).
The hook writes the sentinel ONLY when it detected a dirty base, body::
dirty=1
<git status --porcelain lines...>
Returns ``{"dirty": True, "paths": [...]}`` when the sentinel exists and reports a
dirty base; ``None`` when there is no sentinel (clean base / hygiene disabled / not
written yet). Never raises -> None on error.
"""
try:
from . import self_deploy
p = os.path.join(self_deploy.container_state_dir(repo, work_item_id), REPORT_NAME)
with open(p, "r", encoding="utf-8") as f:
raw = f.read()
except FileNotFoundError:
return None
except Exception as e: # noqa: BLE001 - never-raise
logger.warning("checkout_hygiene.read_report error for %s/%s: %s", repo, work_item_id, e)
return None
lines = raw.splitlines()
if not any(ln.strip() == "dirty=1" for ln in lines):
return None
paths = [
ln.strip() for ln in lines
if ln.strip() and not ln.strip().startswith("dirty=")
]
return {"dirty": True, "paths": paths}
# ---------------------------------------------------------------------------
# Best-effort Telegram alert (Phase C observability) — D5 / AC-8
# ---------------------------------------------------------------------------
def alert_dirty(repo: str, work_item_id: str | None, report: dict | None) -> bool:
"""Structured log + best-effort Telegram that the deploy-base was dirty and was
converged to ``origin/main`` before the pull (D5 / AC-8). Returns True iff an alert
was sent. Its failure NEVER crashes the finalizer (never-raise) — observability is
best-effort and must not block the conveyor (AC-8 FAIL is "alert crashes deploy").
"""
try:
if not report or not report.get("dirty"):
return False
paths = report.get("paths") or []
n = len(paths)
logger.warning(
"checkout_hygiene: dirty deploy-base converged to origin/main for %s/%s "
"(%d path(s)): %s", repo, work_item_id, n, paths[:20],
)
from .notifications import link_for, send_telegram
send_telegram(
f"\U0001f9f9 {link_for(work_item_id)}: грязная deploy-база сведена к "
f"origin/main перед прод-деплоем ({n} путь(ей) сброшено)."
)
return True
except Exception as e: # noqa: BLE001 - never-raise: alert is best-effort
logger.warning("checkout_hygiene.alert_dirty error for %s/%s: %s", repo, work_item_id, e)
return False
# ---------------------------------------------------------------------------
# Observability snapshot for GET /queue (D3, optional)
# ---------------------------------------------------------------------------
def snapshot() -> dict:
"""Read-only checkout-hygiene summary for GET /queue.
Additive block; existing /queue keys are untouched. never-raise -> a minimal dict
with the flags on error.
"""
try:
enabled = bool(getattr(settings, "checkout_hygiene_enabled", False))
except Exception: # noqa: BLE001
enabled = False
try:
repos_cfg = getattr(settings, "checkout_hygiene_repos", "") or ""
except Exception: # noqa: BLE001
repos_cfg = ""
return {
"enabled": enabled,
"repos": repos_cfg,
"scope": "csv" if (repos_cfg or "").strip() else "self-hosting-only",
}

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
@@ -290,6 +309,25 @@ class Settings(BaseSettings):
deploy_prod_compose_profile: str = ""
deploy_prod_prev_image_file: str = ".deploy-prev-image-prod"
# ORCH-112: deploy-base checkout-hygiene (resilient-pull). The self-deploy hook's
# bare `git pull origin main` in the shared main clone blocked on a dirty working
# tree (manual/abandoned WIP left by a failed/cancelled task — incident ORCH-111
# from ORCH-104). The fix converges the deploy-base to a clean, current origin/main
# (git fetch + reset --hard + a SCOPED `git clean -fd`, NEVER `-x`) BEFORE the pull,
# gated by the CHECKOUT_HYGIENE env injected by self_deploy.build_deploy_command.
# Pure leaf: src/checkout_hygiene.py (never-raise). Not a Quality Gate / not a stage
# — STAGE_TRANSITIONS / QG_CHECKS / check_* / machine-verdict / DB schema / the
# hook's exit-code contract (0/1/2, ORCH-036) are byte-for-byte untouched.
#
# checkout_hygiene_enabled -> kill-switch (env ORCH_CHECKOUT_HYGIENE_ENABLED).
# False -> the hook gets no CHECKOUT_HYGIENE env ->
# bare `git pull origin main` 1:1 as before ORCH-112.
# checkout_hygiene_repos -> CSV scope (env ORCH_CHECKOUT_HYGIENE_REPOS). Empty
# -> only the self-hosting repo (orchestrator). Mirrors
# self_deploy_repos (a self-hosting prod-deploy feature).
checkout_hygiene_enabled: bool = True
checkout_hygiene_repos: str = ""
# ORCH-058: staging-image provenance before the BUILD-ONCE retag to prod.
# Closes the INV-FRESH gap (ADR-001): the BUILD-ONCE retag (ORCH-36) promotes
# the staging image to prod WITHOUT a rebuild, assuming the staging image is
@@ -544,12 +582,69 @@ class Settings(BaseSettings):
# lease_reclaim_enabled -> kill-switch for the proactive stale/dead lease reclaim
# (false -> only the legacy lazy TTL reclaim in acquire).
# (reuse) merge_lock_timeout_s -> lease TTL; merge_gate_repos -> reclaim scope.
# reaper_finalizer_liveness_enabled -> ORCH-113 (adr-0043): consult the
# process-local finalizer-ownership registry
# (src/finalizer_liveness.py) in the Tier-2 branch. On the
# deploy-staging -> deploy edge the monitor runs the heavy
# edge sub-gates (security/merge-gate re-test/coverage/
# image-freshness) in-thread AFTER the finished_at stamp and
# BEFORE _finalize_job — MINUTES — which finished_age_s grace
# does NOT cover, so a live long finalizer was wrongly reaped
# and re-ran the advance (false rollback, incident ORCH-111).
# When true AND the task stage == 'deploy-staging' AND a live
# monitor owns the finalization, the reaper DEFERS (no second
# advance) and falls through to the Tier-3 backstop (which
# ignores the marker -> a stuck/dead finalizer is still reaped
# in bounded time). false -> reaper byte-for-byte as before
# (the marker is still written by the monitor but never
# consulted -> inert). Global only, NO per-repo split (the bug
# is common to every repo with a deploy-staging stage). Does
# NOT touch grace/ceiling -> the cross-cutting budget
# reaper_max_running_s > Σ(gate-work) + grace stays intact.
reaper_enabled: bool = True
reaper_interval_s: int = 60
reaper_dead_ticks: int = 2
reaper_max_running_s: int = 5400
reaper_finalize_grace_s: int = 300
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

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
# ---------------------------------------------------------------------------

120
src/finalizer_liveness.py Normal file
View File

@@ -0,0 +1,120 @@
"""ORCH-113 (adr-0043): process-local finalizer-ownership registry.
Leaf module — pure, process-local, never-raise (pattern of ``serial_gate`` /
``coverage_gate``: imports nothing from ``stage_engine`` / ``launcher`` / the DB,
talks to no network). It records "a LIVE monitor thread is currently finalizing
job X" so the job-reaper can tell a long-running-but-alive finalizer apart from a
genuinely dead one.
Why in-memory is authoritative (ADR-001 / adr-0043): the monitor
(``launcher._monitor_agent``) and the reaper (``job_reaper``) are daemon THREADS
of the SAME single uvicorn process (CMD has no ``--workers``), sharing one SQLite
DB. So liveness of the finalizing thread can be observed in-process. A whole-process
death is covered by the startup ``requeue_running_jobs()`` (``running -> queued``),
which ``main.lifespan`` runs BEFORE the reaper starts — so a restart leaves this
registry empty and the requeued jobs are re-driven cleanly (restart-safe, no durable
state needed).
The bug this closes (incident ORCH-111, deployer job 1914): on the
``deploy-staging -> deploy`` edge the monitor stamps ``agent_runs.finished_at``
FIRST, then runs the heavy edge sub-gates (security -> merge-gate re-test ->
coverage -> image-freshness) synchronously in its own thread — MINUTES — and only
THEN ``_finalize_job``. Reaper Tier-2 measures ``finished_age_s`` from
``finished_at`` (= the START of finalization), so once it exceeds
``reaper_finalize_grace_s`` (300s) it treated the live, long-finalizing monitor as
dead and independently re-ran the same heavy advance -> a second re-test went red ->
false rollback ``deploy-staging -> development`` while the original finalizer
concurrently merged the PR. State diverged.
No own TTL: time-bounding is the reaper's Tier-3 backstop (``reaper_max_running_s``),
which deliberately IGNORES this marker so a truly stuck finalizer is still reaped in
bounded time. Every public function is isolated (``try/except`` -> safe default);
``is_active`` defaults to ``False`` on error (conservative: never block the reaping
of a possibly-dead finalizer).
See docs/work-items/ORCH-113/06-adr/ADR-001-reaper-finalizer-liveness-ownership.md
and the cross-cutting docs/architecture/adr/adr-0043-reaper-finalizer-liveness-ownership.md.
"""
from __future__ import annotations
import logging
import threading
import time
logger = logging.getLogger("orchestrator.finalizer_liveness")
# Process-local ownership registry: {job_id: {"run_id", "stage", "started_ts"}}.
# Guarded by a Lock because the monitor thread writes (mark/clear) while the reaper
# thread reads (is_active/snapshot). All state resets on process restart, which is
# safe (the startup requeue_running_jobs covers the restart path).
_LOCK = threading.Lock()
_OWNED: dict[int, dict] = {}
def mark(job_id: int | None, run_id: int | None, stage: str | None) -> None:
"""Register that a live monitor thread is finalizing ``job_id``.
Called by ``launcher._monitor_agent`` right after the ``exit_code`` stamp (the
earliest moment the reaper can enter Tier-2). ``stage`` is best-effort context
for the snapshot only — the reaper decides the actual stage from ``tasks`` via
its own ``_task_meta`` lookup. No-op when ``job_id is None`` (legacy direct
``launch()`` jobs are not in ``get_running_jobs`` and are unreapable). Never
raises.
"""
if job_id is None:
return
try:
with _LOCK:
_OWNED[job_id] = {
"run_id": run_id,
"stage": stage,
"started_ts": time.time(),
}
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("finalizer_liveness.mark failed for job %s: %s", job_id, e)
def clear(job_id: int | None) -> None:
"""Release ownership of ``job_id`` (idempotent).
Called from the ``finally`` of the monitor's finalization tail, so ANY exception
in the monitor thread still releases ownership -> a genuinely dead finalizer is
reaped (FR-4). Never raises.
"""
if job_id is None:
return
try:
with _LOCK:
_OWNED.pop(job_id, None)
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("finalizer_liveness.clear failed for job %s: %s", job_id, e)
def is_active(job_id: int | None) -> bool:
"""True iff a live monitor currently owns the finalization of ``job_id``.
Consulted by the reaper Tier-2 branch. Defaults to ``False`` on any error or
when ``job_id is None`` (conservative: never block the reaping of a possibly
dead finalizer). Never raises.
"""
if job_id is None:
return False
try:
with _LOCK:
return job_id in _OWNED
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("finalizer_liveness.is_active failed for job %s: %s", job_id, e)
return False
def snapshot() -> dict:
"""Read-only view of current ownership for ``GET /queue`` observability.
Returns ``{"active": <count>, "jobs": [job_id, ...]}``. Never raises.
"""
try:
with _LOCK:
return {"active": len(_OWNED), "jobs": sorted(_OWNED.keys())}
except Exception as e: # noqa: BLE001 - never-raise contract
logger.warning("finalizer_liveness.snapshot failed: %s", e)
return {"active": 0, "jobs": []}

View File

@@ -38,7 +38,15 @@ Liveness (defense in depth, ADR-001 Р-1):
reaper therefore treats it as a dead monitor (KNOWN outcome) only after a
finalization grace: ``exit_code`` recorded for >= ``reaper_finalize_grace_s``
(a live finalizing monitor is NEVER reaped, FR-1.3/AC-3). Within the grace the
row is left untouched.
row is left untouched. **ORCH-113 (adr-0043):** on the ``deploy-staging ->
deploy`` edge the in-thread finalization runs the heavy edge sub-gates
(security/merge-gate re-test/coverage/image-freshness) for MINUTES AFTER the
``finished_at`` stamp, so even past the grace the monitor may be alive. Tier-2
now consults a process-local ownership marker (``finalizer_liveness``): a job
on ``deploy-staging`` still owned by a live finalizer is DEFERRED (not reaped via
Tier-2 — re-running the advance caused the false rollback in incident ORCH-111)
and falls through to the Tier-3 backstop, which IGNORES the marker. Kill-switch
``reaper_finalizer_liveness_enabled``.
* **Tier-3 (backstop): age ceiling.** A job ``running`` longer than
``reaper_max_running_s`` (deliberately > max ``agent_timeout`` + grace) is
reaped even when liveness cannot be determined (pid reused / unknown).
@@ -142,6 +150,10 @@ class JobReaper:
self.reaped_total: int = 0
self.last_reaped: dict | None = None
self.lease_reclaimed_total: int = 0
# ORCH-113 (adr-0043 / D5): count of Tier-2 reaps deferred because a live
# monitor still owns a deploy-staging finalization. Reset on restart (safe:
# startup requeue_running_jobs covers the restart path).
self.finalizer_defers_total: int = 0
# -- A: zombie-job reaping --------------------------------------------
def reap_once(self) -> None:
@@ -199,13 +211,34 @@ class JobReaper:
finished_age = job.get("finished_age_s")
grace = int(settings.reaper_finalize_grace_s)
if finished_age is not None and int(finished_age) >= grace:
self._reap_known_outcome(job, int(exit_code))
return
logger.info(
"reaper: job %s exit_code=%s recorded %ss ago (< grace %ss) — "
"deferring (monitor may still be finalizing)",
job_id, exit_code, finished_age, grace,
)
# ORCH-113 (adr-0043 / D3): even past the grace, a LIVE monitor may
# still be running the minutes-long deploy-staging edge sub-gates
# in-thread — finished_age is measured from the START of finalization
# (the finished_at stamp), and on deploy-staging the heavy advance
# (security/merge-gate re-test/coverage/image-freshness) runs AFTER
# that stamp and BEFORE _finalize_job. If a live finalizer still owns
# this job, DEFER the Tier-2 reap (re-running the advance caused the
# false rollback in incident ORCH-111) and fall through to the Tier-3
# backstop, which IGNORES the marker so a stuck/dead finalizer is
# still reaped in bounded time.
if self._finalizer_owns(job):
self.finalizer_defers_total += 1
logger.info(
"reaper: job %s (deploy-staging) still owned by a live "
"finalizer %ss past grace — deferring Tier-2 (Tier-3 backstop "
"at %ss still applies)",
job_id, finished_age, settings.reaper_max_running_s,
)
# fall through to the Tier-3 backstop guard below.
else:
self._reap_known_outcome(job, int(exit_code))
return
else:
logger.info(
"reaper: job %s exit_code=%s recorded %ss ago (< grace %ss) — "
"deferring (monitor may still be finalizing)",
job_id, exit_code, finished_age, grace,
)
# fall through to the Tier-3 backstop guard below.
else:
# Tier-1: dead pid, only after `reaper_dead_ticks` consecutive dead ticks.
@@ -400,6 +433,50 @@ class JobReaper:
job.get("id"), e)
return None, None, None
def _finalizer_owns(self, job: dict) -> bool:
"""True iff a LIVE actor still owns this job's side-effectful finalization, so
the Tier-2 reap must be deferred.
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)
if stage != "deploy-staging":
return False
from . import finalizer_liveness
return finalizer_liveness.is_active(job.get("id"))
except Exception as e: # noqa: BLE001 - never break the reap tick
logger.warning(
"reaper: finalizer-liveness check failed for job %s: %s",
job.get("id"), e,
)
return False
def _notify_failed(self, job: dict, reason: str) -> None:
try:
from .notifications import send_telegram
@@ -412,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"),
@@ -463,6 +552,14 @@ class JobReaper:
def status(self) -> dict:
"""Reaper snapshot for /queue observability (Р-6)."""
# ORCH-113 (adr-0043 / D5): expose the defer counter + the current finalizer
# ownership set (read-only, never-raise). Additive keys only — existing keys
# are unchanged.
try:
from . import finalizer_liveness
_owned = finalizer_liveness.snapshot()
except Exception: # noqa: BLE001 - observability must never break /queue
_owned = {"active": 0, "jobs": []}
return {
"enabled": settings.reaper_enabled,
"interval": self.interval_s,
@@ -470,6 +567,9 @@ class JobReaper:
"reaped_total": self.reaped_total,
"last_reaped": self.last_reaped,
"lease_reclaimed_total": self.lease_reclaimed_total,
"finalizer_liveness_enabled": settings.reaper_finalizer_liveness_enabled,
"finalizer_defers_total": self.finalizer_defers_total,
"finalizer_owned": _owned,
}

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
@@ -214,6 +233,8 @@ async def queue():
from . import cancel
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 {
@@ -254,6 +275,14 @@ async def queue():
# kill-switch, label, scope, bug-task counts + the structural savings metric
# (architecture stages skipped). Additive block; never-raise.
"bug_fast_track": bug_fast_track.snapshot(),
# 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).
@@ -320,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

@@ -239,7 +239,7 @@ def build_deploy_command(repo: str, work_item_id: str | None, branch: str) -> li
``expected_revision`` returns ``""`` and the env is omitted, keeping the hook's
backward-compatible "no provenance check" behaviour (AC-5 / AC-7).
"""
from . import image_freshness
from . import checkout_hygiene, image_freshness
host_dir = host_state_dir(repo, work_item_id)
result_sentinel = os.path.join(host_dir, RESULT)
@@ -262,6 +262,12 @@ def build_deploy_command(repo: str, work_item_id: str | None, branch: str) -> li
expected_rev = image_freshness.expected_revision(repo, branch)
if expected_rev:
env_assignments += f" EXPECTED_REVISION={shlex.quote(expected_rev)}"
# ORCH-112: inject CHECKOUT_HYGIENE=1 HYGIENE_REPORT=<path> only when the leaf says
# hygiene applies (kill-switch + self-hosting scope). Empty -> the hook's
# ${CHECKOUT_HYGIENE:-0} guard stays 0 -> bare `git pull` 1:1 as before ORCH-112.
hygiene_env = checkout_hygiene.hook_env(repo, work_item_id)
if hygiene_env:
env_assignments += f" {hygiene_env}"
inner = (
f"cd {shlex.quote(settings.deploy_host_repo_path)} && "
f"{env_assignments} "

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"
@@ -1957,6 +2069,17 @@ def run_deploy_finalizer(job: dict):
logger.info(
f"Task {task_id}: deploy finalized, hook exit={code} -> deploy_status={status}"
)
# ORCH-112 (D5 / AC-8): if the host hook converged a DIRTY deploy-base to
# origin/main before the pull, surface it (structured log + best-effort Telegram).
# never-raise — observability must never crash the finalizer.
try:
from . import checkout_hygiene
report = checkout_hygiene.read_report(repo, work_item_id)
if report:
checkout_hygiene.alert_dirty(repo, work_item_id, report)
except Exception as e: # noqa: BLE001 - never break the finalizer
logger.warning("Task %s: checkout-hygiene report read failed: %s", task_id, e)
if status == "SUCCESS" and work_item_id:
plane_add_comment(
work_item_id,

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,485 @@
"""ORCH-112: deploy-base checkout-hygiene (resilient-pull) — TC-01…TC-10.
Two test layers:
* SHELL simulation (TC-01..TC-04, TC-07) — drives the REAL
``scripts/orchestrator-deploy-hook.sh`` in a hermetic sandbox. ``git`` is REAL
(against a LOCAL bare "origin" — no network), while ``docker`` / ``curl`` /
``sleep`` are PATH-shimmed stubs so no real infra is touched and prod is never
restarted (INFRA safety). Models tests/test_deploy_hook_rollback_sim.py.
* UNIT (TC-05, TC-06, TC-08, TC-09, TC-10) — the ``checkout_hygiene`` leaf, the
static safety contract of the hook (never ``-x`` / explicit excludes), the
pipeline-invariant guard and the documentation invariant.
TC-01 is the MANDATORY incident-reproduction regression (ORCH-111): a dirty tracked
edit to ``src/config.py`` over an advanced ``origin/main`` makes the bare ``git pull``
abort with "local changes would be overwritten by merge" (RED before the fix); the
resilient-pull hygiene converges the base and the deploy proceeds (GREEN after).
"""
import os
import shutil
import stat
import subprocess
import pytest
from src import checkout_hygiene
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
HOOK = os.path.join(ROOT, "scripts", "orchestrator-deploy-hook.sh")
pytestmark = pytest.mark.skipif(
shutil.which("bash") is None or shutil.which("git") is None,
reason="bash + git required for the deploy-hook hygiene simulation",
)
# Distinctive file bodies so assertions prove WHICH version won.
_V1 = "ORIGIN-V1\n"
_V2 = "ORIGIN-V2-ADVANCED\n"
_DIRTY = "DIRTY-LOCAL-WIP\n"
# Isolate git from any host/global config (hermetic).
_GIT_ENV = {
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@example.com",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@example.com",
"GIT_CONFIG_GLOBAL": os.devnull,
"GIT_CONFIG_SYSTEM": os.devnull,
}
def _git(cwd, *args):
r = subprocess.run(
["git", "-C", str(cwd), *args],
capture_output=True, text=True, env={**os.environ, **_GIT_ENV},
)
assert r.returncode == 0, f"git {args} failed: {r.stdout}\n{r.stderr}"
return r
def _write(path, content):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
f.write(content)
def _write_exec(path, content):
_write(path, content)
os.chmod(path, os.stat(path).st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
def _make_stubs(binx, prev_running=True):
"""Healthy docker/curl/sleep stubs (no real infra; deploy always succeeds).
``prev_running`` controls whether ``docker compose ps -q`` returns a container id:
True -> step 1 captures a previous image and writes PREV_IMAGE_FILE (the normal
case); False -> no previous image is recorded (first-deploy / service-down), so the
deploy-base stays genuinely clean at the hygiene step (exercises the no-op branch).
"""
ps_id = "fakecid" if prev_running else ""
_write_exec(str(binx / "docker"), f"""#!/bin/bash
case "$1" in
compose)
for a in "$@"; do [ "$a" = "ps" ] && {{ echo "{ps_id}"; exit 0; }}; done
exit 0;;
inspect) echo "sha256:previmage"; exit 0;;
image) exit 0;;
tag) exit 0;;
*) exit 0;;
esac
""")
# curl: ALWAYS healthy -> deploy health-check passes immediately -> exit 0.
_write_exec(str(binx / "curl"), """#!/bin/bash
iscode=""
for a in "$@"; do [ "$a" = "-w" ] && iscode=1; done
[ -n "$iscode" ] && echo "200" || echo '{"status":"ok"}'
exit 0
""")
_write_exec(str(binx / "sleep"), "#!/bin/bash\nexit 0\n")
def _seed_origin_and_clone(tmp_path):
"""Build a local bare origin (at V2) + a deploy-base clone (at V1).
Returns ``repo`` (the deploy-base path). The clone is one commit BEHIND origin so
that, with a conflicting dirty edit to src/config.py, a bare ``git pull`` would
abort (the exact ORCH-111 incident), while hygiene's reset --hard converges it.
"""
work = tmp_path / "work"
work.mkdir()
_write(str(work / "src" / "config.py"), _V1)
_write(str(work / ".gitignore"), ".env\ndata/\n*.db\nbuild/\n")
_git(work, "init", "-q")
_git(work, "add", "-A")
_git(work, "commit", "-q", "-m", "v1")
_git(work, "branch", "-M", "main")
origin = tmp_path / "origin.git"
_git(tmp_path, "init", "-q", "--bare", str(origin))
_git(work, "remote", "add", "origin", str(origin))
_git(work, "push", "-q", "-u", "origin", "main")
repo = tmp_path / "repo"
_git(tmp_path, "clone", "-q", str(origin), str(repo))
# Advance origin/main to V2 (touches the SAME file we will dirty locally).
_write(str(work / "src" / "config.py"), _V2)
_git(work, "commit", "-q", "-am", "v2")
_git(work, "push", "-q", "origin", "main")
# Make repo's remote-tracking ref aware of V2's existence is the hook's job
# (it runs `git fetch`); leave repo at V1 deliberately.
return repo
def _run_hook(repo, tmp_path, hygiene="1", extra_env=None, prev_running=True):
"""Run the real hook in --deploy mode against ``repo`` with stubbed infra."""
binx = tmp_path / "bin"
if not binx.exists():
binx.mkdir()
_make_stubs(binx, prev_running=prev_running)
state = tmp_path / "state"
state.mkdir(exist_ok=True)
env = {
**os.environ,
**_GIT_ENV,
"PATH": f"{binx}:{os.environ['PATH']}",
"REPO": str(repo),
"LOG": str(state / "hook.log"),
"PREV_IMAGE_FILE": str(repo / ".deploy-prev-image-prod"),
"COMPOSE_PROFILE": "",
"TARGET_SERVICE": "orchestrator",
"TARGET_PORT": "8500",
}
if hygiene is not None:
env["CHECKOUT_HYGIENE"] = hygiene
env["HYGIENE_REPORT"] = str(state / "hygiene")
if extra_env:
env.update(extra_env)
return subprocess.run(
["bash", HOOK, "--deploy"], env=env, capture_output=True, text=True, timeout=60,
)
def _porcelain(repo):
r = subprocess.run(
["git", "-C", str(repo), "status", "--porcelain"],
capture_output=True, text=True, env={**os.environ, **_GIT_ENV},
)
return r.stdout.strip()
def _wip_dirt(repo):
"""Porcelain output MINUS the intentionally-preserved deploy artefacts.
After hygiene, the deploy-base is converged to origin/main but the rollback/log
artefacts (.deploy-prev-image-* / deploy-hook.log) are legitimately untracked-and-
preserved (NFR-2). This returns ONLY the *real* residual dirt (a non-empty result
means a tracked edit survived or WIP was not cleaned)."""
lines = []
for ln in _porcelain(repo).splitlines():
name = ln[3:] if len(ln) > 3 else ""
if name.startswith(".deploy-prev-image-") or name == "deploy-hook.log":
continue
lines.append(ln)
return "\n".join(lines).strip()
def _head_config(repo):
with open(repo / "src" / "config.py", encoding="utf-8") as f:
return f.read()
# ===========================================================================
# TC-01 — MANDATORY regression (red->green): dirty tracked edit + advanced origin
# ===========================================================================
def test_tc01_dirty_tracked_edit_converges_and_deploys(tmp_path):
repo = _seed_origin_and_clone(tmp_path)
# Dirty the SAME tracked file origin advanced -> a bare `git pull` would abort.
_write(str(repo / "src" / "config.py"), _DIRTY)
# Untracked WIP left behind too (failed/cancelled task residue).
_write(str(repo / "scripts" / "install_lite.py"), "# wip\n")
proc = _run_hook(repo, tmp_path, hygiene="1")
assert proc.returncode == 0, (
"resilient-pull must converge a dirty base and let the deploy proceed "
f"(stdout={proc.stdout}\nstderr={proc.stderr})"
)
# Base converged to the ADVANCED origin/main (dirty local edit discarded).
assert _head_config(repo) == _V2
# No real WIP remains (tracked edit reset, untracked WIP cleaned); the rollback
# snapshot .deploy-prev-image-prod is legitimately preserved (NFR-2), so we check
# the residual dirt MINUS the preserved artefacts.
assert _wip_dirt(repo) == ""
assert not (repo / "scripts" / "install_lite.py").exists()
out = proc.stdout + proc.stderr
assert "HYGIENE" in out
def test_tc01b_bare_pull_aborts_without_hygiene_documents_incident(tmp_path):
"""ORCH-111 reproduction: WITHOUT hygiene the same dirty base aborts the pull."""
repo = _seed_origin_and_clone(tmp_path)
_write(str(repo / "src" / "config.py"), _DIRTY)
proc = _run_hook(repo, tmp_path, hygiene=None) # CHECKOUT_HYGIENE unset
assert proc.returncode != 0, "bare `git pull` must abort on the conflicting dirty edit"
log = (tmp_path / "state" / "hook.log").read_text(encoding="utf-8")
assert "would be overwritten by merge" in (log + proc.stdout + proc.stderr)
# ===========================================================================
# TC-02 — untracked WIP files do not block and do not leak into the deploy
# ===========================================================================
def test_tc02_untracked_wip_does_not_block(tmp_path):
repo = _seed_origin_and_clone(tmp_path)
for rel in (
"scripts/install_lite.py",
"tests/test_install_lite.py",
"docs/deployment/lite-install.example.yaml",
):
_write(str(repo / rel), "# abandoned WIP\n")
proc = _run_hook(repo, tmp_path, hygiene="1")
assert proc.returncode == 0, f"{proc.stdout}\n{proc.stderr}"
assert _wip_dirt(repo) == ""
for rel in (
"scripts/install_lite.py",
"tests/test_install_lite.py",
"docs/deployment/lite-install.example.yaml",
):
assert not (repo / rel).exists(), f"{rel} must be cleaned, not leaked into deploy"
# ===========================================================================
# TC-03 — preservation of rollback/log/gitignored/sibling artefacts (NFR-2)
# ===========================================================================
def test_tc03_preserves_rollback_and_sibling_artifacts(tmp_path):
repo = _seed_origin_and_clone(tmp_path)
_write(str(repo / "src" / "config.py"), _DIRTY) # force the hygiene path
# In-$REPO artefacts that MUST survive (untracked, NOT gitignored).
_write(str(repo / ".deploy-prev-image-staging"), "sha256:stagingprev\n")
_write(str(repo / "deploy-hook.log"), "audit line\n")
# gitignored prod secrets / DB — must survive `git clean -fd` (NO -x).
_write(str(repo / ".env"), "ORCH_SECRET=keepme\n")
_write(str(repo / "data" / "orchestrator.db"), "sqlite-bytes\n")
# .git internal worktree admin record — git clean never touches .git/.
_write(str(repo / ".git" / "worktrees" / "wt1" / "HEAD"), "ref: refs/heads/x\n")
# Sibling state under the PARENT of $REPO — outside the clean scope.
_write(str(tmp_path / ".deploy-state-orchestrator" / "ORCH-112" / "result"), "0\n")
_write(str(tmp_path / ".merge-lease-orchestrator.json"), '{"branch":"x"}\n')
proc = _run_hook(repo, tmp_path, hygiene="1")
assert proc.returncode == 0, f"{proc.stdout}\n{proc.stderr}"
# Rollback snapshot freshly written by step 1 (PREV_IMAGE_FILE) survived hygiene.
assert (repo / ".deploy-prev-image-prod").is_file()
assert (repo / ".deploy-prev-image-prod").read_text().strip() != ""
# Wildcard-excluded sibling prev-image + log survived.
assert (repo / ".deploy-prev-image-staging").read_text() == "sha256:stagingprev\n"
assert (repo / "deploy-hook.log").read_text() == "audit line\n"
# gitignored secrets/DB survived (proves NO -x at runtime).
assert (repo / ".env").read_text() == "ORCH_SECRET=keepme\n"
assert (repo / "data" / "orchestrator.db").read_text() == "sqlite-bytes\n"
# .git internal + sibling state untouched.
assert (repo / ".git" / "worktrees" / "wt1" / "HEAD").is_file()
assert (tmp_path / ".deploy-state-orchestrator" / "ORCH-112" / "result").is_file()
assert (tmp_path / ".merge-lease-orchestrator.json").is_file()
# ===========================================================================
# TC-04 — happy-path: genuinely clean base -> hygiene no-op + plain fast-forward.
# Uses prev_running=False so step 1 records NO prev-image, leaving the base clean at
# the hygiene step (no untracked artefact) — the no-op `else` branch is exercised and
# the deploy reduces to the plain `git pull` fast-forward (exit-codes byte-for-byte).
# ===========================================================================
def test_tc04_clean_base_fast_forwards_no_op_hygiene(tmp_path):
repo = _seed_origin_and_clone(tmp_path) # repo is CLEAN, just behind origin
proc = _run_hook(repo, tmp_path, hygiene="1", prev_running=False)
assert proc.returncode == 0, f"{proc.stdout}\n{proc.stderr}"
log = (tmp_path / "state" / "hook.log").read_text(encoding="utf-8")
assert "deploy-base already clean (no-op)" in log
assert "dirty deploy-base detected" not in log
# Plain fast-forward brought origin/main's V2.
assert _head_config(repo) == _V2
# ===========================================================================
# TC-07 — convergence after cancel/failed: leftovers cleared, next deploy clean
# ===========================================================================
def test_tc07_convergence_then_next_deploy_is_clean(tmp_path):
repo = _seed_origin_and_clone(tmp_path)
# Leftovers from a failed/cancelled task: dirty tracked + untracked WIP.
_write(str(repo / "src" / "config.py"), _DIRTY)
_write(str(repo / "tests" / "test_install_lite.py"), "# wip\n")
first = _run_hook(repo, tmp_path, hygiene="1")
assert first.returncode == 0, f"{first.stdout}\n{first.stderr}"
assert _wip_dirt(repo) == "" # base converged, no WIP residue
assert _head_config(repo) == _V2
# A subsequent self-deploy proceeds without manual intervention (no WIP to block it).
second = _run_hook(repo, tmp_path, hygiene="1")
assert second.returncode == 0, f"{second.stdout}\n{second.stderr}"
assert _wip_dirt(repo) == ""
# ===========================================================================
# TC-05 — self-hosting safety + static hook safety contract (never -x / excludes)
# ===========================================================================
def _hook_code_lines():
"""Non-comment, non-blank lines of the hook (so a comment mentioning `-x` or
`exit` for documentation does not trip the static safety asserts)."""
out = []
for ln in open(HOOK, encoding="utf-8").read().splitlines():
s = ln.strip()
if not s or s.startswith("#"):
continue
out.append(ln)
return out
def test_tc05_hook_clean_is_never_destructive():
text = open(HOOK, encoding="utf-8").read()
code = "\n".join(_hook_code_lines())
assert "CHECKOUT_HYGIENE" in text, "hygiene block must exist in the hook"
# INV-HYGIENE-1: the hook's only `git clean` is `-fd`, NEVER `-x` (which would
# delete gitignored .env / data/*.db / build/). Checked against CODE only.
assert "git clean -fd" in code
assert "-x" not in code # no -x / -xfd / -fdx in any executable line
# INV-HYGIENE-2: explicit excludes for the untracked-but-not-ignored artefacts.
assert "-e '.deploy-prev-image-*'" in code
assert "-e 'deploy-hook.log'" in code
# Converge to the authoritative remote, never a local guess.
assert "git reset --hard origin/main" in code
# Self-hosting safety: the hygiene path never pushes/force-pushes the remote.
assert "push --force" not in code and "push -f " not in code
def test_tc05_leaf_is_a_pure_leaf():
"""checkout_hygiene must not import stage_engine / launcher at module load."""
src = open(os.path.join(ROOT, "src", "checkout_hygiene.py"), encoding="utf-8").read()
import_lines = [
ln for ln in src.splitlines()
if ln.startswith("import ") or ln.startswith("from ")
]
joined = "\n".join(import_lines)
assert "stage_engine" not in joined
assert "launcher" not in joined
# ===========================================================================
# TC-06 — kill-switch + repo scope (applies / hook_env)
# ===========================================================================
def test_tc06_kill_switch_off_is_inert(monkeypatch):
from src.config import settings
monkeypatch.setattr(settings, "checkout_hygiene_enabled", False)
assert checkout_hygiene.applies("orchestrator") is False
assert checkout_hygiene.hook_env("orchestrator", "ORCH-112") == ""
def test_tc06_empty_csv_is_self_hosting_only(monkeypatch):
from src.config import settings
monkeypatch.setattr(settings, "checkout_hygiene_enabled", True)
monkeypatch.setattr(settings, "checkout_hygiene_repos", "")
assert checkout_hygiene.applies("orchestrator") is True
assert checkout_hygiene.applies("enduro-trails") is False
env = checkout_hygiene.hook_env("orchestrator", "ORCH-112")
assert env.startswith("CHECKOUT_HYGIENE=1 ")
assert "HYGIENE_REPORT=" in env
# A non-self repo gets no hygiene env (other repos unaffected).
assert checkout_hygiene.hook_env("enduro-trails", "ET-1") == ""
def test_tc06_csv_scope_limits_repos(monkeypatch):
from src.config import settings
monkeypatch.setattr(settings, "checkout_hygiene_enabled", True)
monkeypatch.setattr(settings, "checkout_hygiene_repos", "alpha, beta")
assert checkout_hygiene.applies("alpha") is True
assert checkout_hygiene.applies("beta") is True
assert checkout_hygiene.applies("orchestrator") is False
# ===========================================================================
# TC-08 — observability: read_report / alert_dirty never-raise
# ===========================================================================
def test_tc08_read_report_none_when_absent(monkeypatch, tmp_path):
from src.config import settings
monkeypatch.setattr(settings, "repos_dir", str(tmp_path))
assert checkout_hygiene.read_report("orchestrator", "ORCH-112") is None
def test_tc08_read_report_parses_dirty_sentinel(monkeypatch, tmp_path):
from src import self_deploy
from src.config import settings
monkeypatch.setattr(settings, "repos_dir", str(tmp_path))
d = self_deploy.container_state_dir("orchestrator", "ORCH-112")
os.makedirs(d, exist_ok=True)
_write(os.path.join(d, "hygiene"), "dirty=1\n M src/config.py\n?? scripts/x.py\n")
rep = checkout_hygiene.read_report("orchestrator", "ORCH-112")
assert rep == {"dirty": True, "paths": ["M src/config.py", "?? scripts/x.py"]}
def test_tc08_alert_dirty_never_raises_on_send_failure(monkeypatch):
import src.notifications as notifications
def boom(*a, **k):
raise RuntimeError("telegram down")
monkeypatch.setattr(notifications, "send_telegram", boom)
# Must swallow the error (best-effort) and NOT crash the finalizer.
assert checkout_hygiene.alert_dirty(
"orchestrator", "ORCH-112", {"dirty": True, "paths": ["x"]}
) is False
# No report / not dirty -> no alert, no raise.
assert checkout_hygiene.alert_dirty("orchestrator", "ORCH-112", None) is False
# ===========================================================================
# TC-09 — pipeline invariant: STAGE_TRANSITIONS / QG_CHECKS / exit-codes untouched
# ===========================================================================
def test_tc09_pipeline_contracts_untouched():
from src import stages
from src.qg import checks
# The hygiene feature is NOT a stage and NOT a QG check.
assert "checkout_hygiene" not in {
k for tr in stages.STAGE_TRANSITIONS.values() for k in (tr if isinstance(tr, dict) else {})
}
assert not any("hygiene" in name for name in checks.QG_CHECKS)
def test_tc09_hook_exit_code_contract_intact():
text = open(HOOK, encoding="utf-8").read()
# The hook still maps to the 0/1/2 contract (ORCH-036).
assert "exit 0" in text and "exit 1" in text and "exit 2" in text
# The hygiene block itself never emits an `exit` statement (best-effort,
# never-break). Inspect only the CODE lines of the 2a block (a comment that
# mentions "exit-codes" must not trip this).
block = text.split("# 2a.", 1)[1].split("# 2.", 1)[0]
code_lines = [
ln for ln in block.splitlines()
if ln.strip() and not ln.strip().startswith("#")
]
for ln in code_lines:
assert not ln.strip().startswith("exit"), (
f"hygiene block must never change the hook exit-code: {ln!r}"
)
# ===========================================================================
# TC-10 — documentation invariant (golden source)
# ===========================================================================
def test_tc10_docs_state_deploy_base_invariant():
infra = open(os.path.join(ROOT, "docs", "operations", "INFRA.md"), encoding="utf-8").read()
readme = open(os.path.join(ROOT, "docs", "architecture", "README.md"), encoding="utf-8").read()
for doc in (infra, readme):
assert "ORCH-112" in doc
assert "deploy/worktree-management база" in doc
assert "workspace" in doc

View File

@@ -0,0 +1,386 @@
"""ORCH-113 (adr-0043): reaper must not re-run deploy-staging finalization while
the original finalizer is alive — finalizer-liveness ownership tests (TC-01..TC-08).
Covers the bug from incident ORCH-111 (deployer job 1914): on the
``deploy-staging -> deploy`` edge the live monitor runs the heavy edge sub-gates
(security/merge-gate re-test/coverage/image-freshness) in-thread for MINUTES AFTER
stamping ``agent_runs.finished_at`` and BEFORE ``_finalize_job``. Reaper Tier-2
measures ``finished_age_s`` from ``finished_at``, so past ``reaper_finalize_grace_s``
it treated the live, long-finalizing monitor as dead and independently re-ran the
advance -> a second re-test went red -> false rollback ``deploy-staging ->
development`` while the original finalizer concurrently merged the PR. State diverged.
The reaper never spawns claude / pytest / docker; we drive the DB directly (a
'running' jobs row + a linked agent_runs exit_code) and the process-local
``finalizer_liveness`` marker, then assert the reaper's terminal flip / deferral.
No network, no subprocess — every external is mocked.
"""
import os
import tempfile
import pytest
# Override env before importing app modules (same convention as test_job_reaper.py).
os.environ["ORCH_DB_PATH"] = os.path.join(
tempfile.gettempdir(), "test_orch113_reaper.db"
)
os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir()
os.environ["ORCH_GITEA_TOKEN"] = "test-token"
os.environ["ORCH_PLANE_API_TOKEN"] = "test-token"
import src.db as db
from src.db import init_db, get_db, get_job
import src.finalizer_liveness as fl
import src.job_reaper as jr
from src.job_reaper import JobReaper
@pytest.fixture(autouse=True)
def fresh_db(tmp_path, monkeypatch):
dbfile = tmp_path / "reaper113.db"
monkeypatch.setattr(db.settings, "db_path", str(dbfile))
init_db()
# Each test starts with a clean ownership registry (process-local module state).
with fl._LOCK:
fl._OWNED.clear()
# Default: kill-switch ON (the fix is active) unless a test flips it.
monkeypatch.setattr(db.settings, "reaper_finalizer_liveness_enabled", True)
yield
with fl._LOCK:
fl._OWNED.clear()
# --- helpers ----------------------------------------------------------------
def _make_task(repo="orchestrator", branch="feature/orch113",
stage="deploy-staging", work_item_id="ORCH-113"):
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 _make_running_job(agent="deployer", repo="orchestrator", task_id=None,
pid=None, age_s=0, attempts=0, max_attempts=2,
run_id=None, exit_code=0, finished_age_s=600):
"""Insert a job already in 'running' with a linked agent_runs row.
``finished_at`` is back-dated by ``finished_age_s`` so the Tier-2 grace
(default 300) is satisfied by default; pass a small value to stay within grace.
"""
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 _spy_advance(monkeypatch, side_effect=None):
"""Patch launcher._try_advance_stage with a call recorder.
Returns a mutable ``calls`` list. ``side_effect(run_id, agent, repo, branch)``
runs on each call (e.g. to simulate the false rollback to development).
"""
import src.agents.launcher as L
calls: list = []
def _fake(run_id, agent, repo, branch):
calls.append((run_id, agent, repo, branch))
if side_effect is not None:
side_effect(run_id, agent, repo, branch)
monkeypatch.setattr(L.launcher, "_try_advance_stage", _fake)
return calls
def _green_gate(monkeypatch):
"""Force the read-only canonical-QG pre-eval green (staging SUCCESS)."""
monkeypatch.setattr(JobReaper, "_gate_is_green",
lambda self, stage, job, branch, wid: True)
# --- TC-01: live finalizer on deploy-staging is NOT reaped (AC-1/FR-1) ------
def test_tc01_live_finalizer_deploy_staging_not_reaped(monkeypatch):
"""exit_code=0 and finished_age_s >= grace, but the finalizer is ALIVE (marked)
-> reaper does NOT call _try_advance_stage; the row stays running; defer logged."""
_green_gate(monkeypatch)
calls = _spy_advance(monkeypatch)
tid = _make_task(stage="deploy-staging")
jid = _make_running_job(task_id=tid, exit_code=0, finished_age_s=600)
# A live monitor owns this finalization.
fl.mark(jid, run_id=1, stage="deploy-staging")
r = JobReaper()
r.reap_once()
assert get_job(jid)["status"] == "running" # not reaped
assert calls == [] # no second advance
assert r.finalizer_defers_total == 1
assert r.reaped_total == 0
# --- TC-02: strict ownership — non-owner runs zero side effects (AC-2/FR-2) --
def test_tc02_non_owner_runs_no_edge_gates(monkeypatch):
"""While a live finalizer owns the (job, stage), a racing reaper tick performs
NO side-effectful advance/merge-gate/re-test (zero side effects)."""
_green_gate(monkeypatch)
calls = _spy_advance(monkeypatch)
tid = _make_task(stage="deploy-staging")
jid = _make_running_job(task_id=tid, exit_code=0, finished_age_s=900)
fl.mark(jid, run_id=7, stage="deploy-staging")
r = JobReaper()
# Several ticks while ownership is held — still zero advances, still running.
for _ in range(3):
r.reap_once()
assert calls == []
assert get_job(jid)["status"] == "running"
assert r.finalizer_defers_total == 3
# --- TC-03: a genuinely dead finalizer is still reaped (AC-3/FR-4) ----------
def test_tc03_dead_finalizer_still_reaped_tier2(monkeypatch):
"""No ownership marker (finalizer dead) -> reaper reaps via Tier-2 as before."""
_green_gate(monkeypatch)
calls = _spy_advance(monkeypatch)
tid = _make_task(stage="deploy-staging")
jid = _make_running_job(task_id=tid, exit_code=0, finished_age_s=600)
# No fl.mark() -> ownership absent (finalizer dead).
r = JobReaper()
r.reap_once()
assert get_job(jid)["status"] == "done" # reaped to done (gate green)
assert len(calls) == 1 # advance driven exactly once
assert r.finalizer_defers_total == 0
def test_tc03_tier3_backstop_ignores_marker(monkeypatch):
"""Even with an active ownership marker, a job past reaper_max_running_s is
reaped by the Tier-3 backstop (a stuck finalizer is never immortal)."""
monkeypatch.setattr(db.settings, "reaper_max_running_s", 1000)
tid = _make_task(stage="deploy-staging")
# age beyond the backstop ceiling; exit_code=0 within grace so Tier-2 defers,
# but Tier-3 must still fire.
jid = _make_running_job(task_id=tid, exit_code=0, finished_age_s=10,
age_s=2000, attempts=0, max_attempts=2)
fl.mark(jid, run_id=3, stage="deploy-staging")
r = JobReaper()
r.reap_once()
# Backstop reaps to a retry (attempts<max) -> queued, regardless of the marker.
assert get_job(jid)["status"] == "queued"
assert r.reaped_total == 1
# --- TC-04: idempotency under race — exactly-once advance (AC-2/AC-4/FR-2) ---
def test_tc04_idempotent_no_second_advance_under_race(monkeypatch):
"""Monitor finalizing (owns the job) + concurrent reaper ticks -> the heavy
edge-gate advance is NEVER duplicated by the reaper; no false rollback."""
rolled_back = {"hit": False}
def _rollback(run_id, agent, repo, branch):
# Simulate the incident: a second advance rolls back to development.
conn = get_db()
conn.execute("UPDATE tasks SET stage='development' WHERE branch=?", (branch,))
conn.commit()
conn.close()
rolled_back["hit"] = True
_green_gate(monkeypatch)
calls = _spy_advance(monkeypatch, side_effect=_rollback)
tid = _make_task(stage="deploy-staging")
jid = _make_running_job(task_id=tid, exit_code=0, finished_age_s=1200)
fl.mark(jid, run_id=9, stage="deploy-staging")
r = JobReaper()
for _ in range(5):
r.reap_once()
assert calls == [] # reaper never re-ran the advance
assert rolled_back["hit"] is False
# The task is NOT rolled back; the live finalizer remains the sole driver.
conn = get_db()
stage = conn.execute("SELECT stage FROM tasks WHERE id=?", (tid,)).fetchone()[0]
conn.close()
assert stage == "deploy-staging"
# --- TC-05: MANDATORY regression for incident ORCH-111 (AC-4/FR-5) ----------
def test_tc05_orch111_no_false_rollback_no_retry_increment(monkeypatch):
"""Long (> grace) deploy-staging finalization at staging_status=SUCCESS while
the deploy finalizer concurrently reaches success/merge -> reaper does NOT roll
back deploy-staging -> development and does NOT increment developer-retry; the
task keeps a single consistent state. RED before the fix, GREEN after."""
def _rollback(run_id, agent, repo, branch):
# Simulate the incident: a second advance rolls the task back to development
# and spawns a fresh developer run (the developer-retry count is derived from
# agent_runs — stage_engine.developer_retry_count).
conn = get_db()
conn.execute("UPDATE tasks SET stage='development' WHERE branch=?", (branch,))
_t = conn.execute("SELECT id FROM tasks WHERE branch=?", (branch,)).fetchone()
conn.execute(
"INSERT INTO agent_runs (task_id, agent) VALUES (?, 'developer')",
(_t[0],),
)
conn.commit()
conn.close()
from src.stage_engine import developer_retry_count
_green_gate(monkeypatch) # staging_status SUCCESS
calls = _spy_advance(monkeypatch, side_effect=_rollback)
tid = _make_task(stage="deploy-staging")
jid = _make_running_job(task_id=tid, exit_code=0, finished_age_s=1500)
# The original finalizer is still alive (running the heavy edge sub-gates).
fl.mark(jid, run_id=11, stage="deploy-staging")
r = JobReaper()
r.reap_once()
# No second advance => no false rollback, no developer-retry increment.
assert calls == []
conn = get_db()
stage = conn.execute("SELECT stage FROM tasks WHERE id=?", (tid,)).fetchone()[0]
conn.close()
assert stage == "deploy-staging" # NOT rolled back to development
assert developer_retry_count(tid) == 0 # developer-retry NOT incremented
assert get_job(jid)["status"] == "running"
assert r.finalizer_defers_total == 1
# --- TC-06: compatibility guard — kill-switch / non-deploy-staging (AC-5) ----
def test_tc06_killswitch_off_byte_for_byte_prior(monkeypatch):
"""Kill-switch OFF -> the marker is ignored; a deploy-staging exit0/past-grace
job is reaped exactly as before the fix (advance driven once)."""
monkeypatch.setattr(db.settings, "reaper_finalizer_liveness_enabled", False)
_green_gate(monkeypatch)
calls = _spy_advance(monkeypatch)
tid = _make_task(stage="deploy-staging")
jid = _make_running_job(task_id=tid, exit_code=0, finished_age_s=600)
fl.mark(jid, run_id=5, stage="deploy-staging") # marked, but ignored
r = JobReaper()
r.reap_once()
assert get_job(jid)["status"] == "done"
assert len(calls) == 1
assert r.finalizer_defers_total == 0
def test_tc06_non_deploy_staging_stage_not_consulted(monkeypatch):
"""A non-deploy-staging stage is never consulted -> reaped as before even when
an (irrelevant) marker happens to be present."""
_green_gate(monkeypatch)
calls = _spy_advance(monkeypatch)
tid = _make_task(stage="testing") # deployer also owns 'testing'
jid = _make_running_job(task_id=tid, agent="deployer", exit_code=0,
finished_age_s=600)
fl.mark(jid, run_id=6, stage="testing")
r = JobReaper()
r.reap_once()
assert get_job(jid)["status"] == "done"
assert len(calls) == 1
assert r.finalizer_defers_total == 0
def test_tc06_within_grace_unchanged(monkeypatch):
"""Within the finalization grace the Tier-2 path is unchanged (deferred, not
reaped) regardless of the marker — the fix only acts PAST the grace."""
monkeypatch.setattr(db.settings, "reaper_finalize_grace_s", 300)
_green_gate(monkeypatch)
calls = _spy_advance(monkeypatch)
tid = _make_task(stage="deploy-staging")
jid = _make_running_job(task_id=tid, exit_code=0, finished_age_s=5) # < grace
r = JobReaper()
r.reap_once()
assert get_job(jid)["status"] == "running"
assert calls == []
# Within-grace deferral is the legacy path, not a finalizer-liveness defer.
assert r.finalizer_defers_total == 0
# --- TC-07: cross-cutting budget invariant (NFR-6/AC-5) ---------------------
def test_tc07_budget_invariant_preserved():
"""reaper_max_running_s (5400) > Σ(deploy-staging gate-work) + grace; the fix
changed neither the grace nor the ceiling (ORCH-065/109/110 invariant)."""
s = jr.settings
# The fix did not touch these (zero schema/budget change).
assert s.reaper_finalize_grace_s == 300
assert s.reaper_max_running_s == 5400
# Conservative Σ of the heavy deploy-staging gate-work + grace must fit.
sigma = s.merge_retest_timeout_s + s.coverage_run_timeout_s
assert s.reaper_max_running_s > sigma + s.reaper_finalize_grace_s
# --- TC-08: never-raise — a fault in the liveness path degrades safely -------
def test_tc08_liveness_error_never_breaks_tick(monkeypatch):
"""An exception inside the ownership consultation must not crash the tick; the
job is still processed (conservatively reaped, never blocked)."""
def _boom(job_id):
raise RuntimeError("registry exploded")
monkeypatch.setattr(fl, "is_active", _boom)
_green_gate(monkeypatch)
calls = _spy_advance(monkeypatch)
tid = _make_task(stage="deploy-staging")
jid = _make_running_job(task_id=tid, exit_code=0, finished_age_s=600)
fl.mark(jid, run_id=2, stage="deploy-staging")
r = JobReaper()
r.reap_once() # must not raise
# is_active raised -> _finalizer_owns conservatively returns False -> reaped.
assert get_job(jid)["status"] == "done"
assert len(calls) == 1
def test_tc08_reap_once_isolates_and_never_raises(monkeypatch):
"""A fault while resolving one job's metadata is isolated; reap_once never
raises and other jobs are still processed."""
def _boom(self, job):
raise RuntimeError("meta exploded")
monkeypatch.setattr(JobReaper, "_task_meta", _boom)
tid = _make_task(stage="deploy-staging")
_make_running_job(task_id=tid, exit_code=0, finished_age_s=600)
r = JobReaper()
r.reap_once() # outer + per-job never-raise -> no exception propagates
def test_tc08_finalizer_liveness_leaf_never_raises():
"""The leaf degrades safely on bad input / None job_id."""
fl.mark(None, None, None) # no-op, no raise
fl.clear(None) # no-op, no raise
assert fl.is_active(None) is False
fl.mark(1234, 1, "deploy-staging")
assert fl.is_active(1234) is True
snap = fl.snapshot()
assert snap["active"] >= 1 and 1234 in snap["jobs"]
fl.clear(1234)
assert fl.is_active(1234) is False

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